Skip to content

qgis_map module

Main module for qgis-map.

This module provides a high-level Map class for working with QGIS, similar to the leafmap Map class but designed for PyQGIS.

Map

A high-level Map class for QGIS, inspired by the leafmap API.

This class provides a simple, user-friendly interface for common QGIS operations like adding basemaps, vector layers, raster layers, and creating interactive dockable panels.

Attributes:

Name Type Description
project

The current QGIS project instance.

canvas

The QGIS map canvas (if available).

layers

A dictionary of layers added to the map.

Examples:

>>> from qgis_map import Map
>>> m = Map()
>>> m.add_basemap("OpenStreetMap")
>>> m.add_vector("path/to/data.shp", layer_name="My Layer")
>>> m.add_raster("path/to/image.tif", layer_name="My Raster")
Source code in qgis_map/qgis_map.py
class Map:
    """A high-level Map class for QGIS, inspired by the leafmap API.

    This class provides a simple, user-friendly interface for common QGIS
    operations like adding basemaps, vector layers, raster layers, and
    creating interactive dockable panels.

    Attributes:
        project: The current QGIS project instance.
        canvas: The QGIS map canvas (if available).
        layers: A dictionary of layers added to the map.

    Example:
        >>> from qgis_map import Map
        >>> m = Map()
        >>> m.add_basemap("OpenStreetMap")
        >>> m.add_vector("path/to/data.shp", layer_name="My Layer")
        >>> m.add_raster("path/to/image.tif", layer_name="My Raster")
    """

    def __init__(
        self,
        center: Optional[Tuple[float, float]] = None,
        zoom: Optional[int] = None,
        crs: str = "EPSG:3857",
        basemap: Optional[str] = "OpenStreetMap",
        **kwargs,
    ):
        """Initialize a Map instance.

        Args:
            center: The initial center of the map as (latitude, longitude).
                Defaults to (0, 0).
            zoom: The initial zoom level. Defaults to 2.
            crs: The coordinate reference system. Defaults to "EPSG:3857".
            basemap: The initial basemap to add. Defaults to "OpenStreetMap".
                Set to None to skip adding a basemap.
            **kwargs: Additional keyword arguments.
        """
        if not HAS_QGIS:
            raise ImportError(
                "QGIS libraries are not available. "
                "Please run this code within QGIS or install PyQGIS."
            )

        self.project = QgsProject.instance()
        self._iface = iface
        self._layers: Dict[str, Any] = {}
        self._basemap_layers: List[str] = []
        self._crs = QgsCoordinateReferenceSystem(crs)
        self._center = center or (0, 0)
        self._zoom = zoom or 2
        self._time_slider_dock = None
        self._temporal_controller = None

        # Get the map canvas if running in QGIS
        if self._iface is not None:
            self.canvas = self._iface.mapCanvas()
        else:
            self.canvas = None

        # Set the project CRS
        self.project.setCrs(self._crs)

        # # Add initial basemap if specified
        # if basemap is not None:
        #     self.add_basemap(basemap)

        # Set initial center and zoom if canvas is available
        if self.canvas is not None and center is not None:
            self.set_center(center[0], center[1], zoom)

    @property
    def layers(self) -> Dict[str, Any]:
        """Get all layers added to the map.

        Returns:
            A dictionary mapping layer names to layer objects.
        """
        return self._layers

    def get_layer_names(self) -> List[str]:
        """Get the names of all layers in the project.

        Returns:
            A list of layer names.
        """
        return [layer.name() for layer in self.project.mapLayers().values()]

    def get_layer(self, name: str) -> Optional[Any]:
        """Get a layer by name.

        Args:
            name: The name of the layer.

        Returns:
            The layer object, or None if not found.
        """
        layers = self.project.mapLayersByName(name)
        return layers[0] if layers else None

    def set_center(self, lat: float, lon: float, zoom: Optional[int] = None) -> None:
        """Set the center of the map.

        Args:
            lat: Latitude of the center point.
            lon: Longitude of the center point.
            zoom: Optional zoom level to set.
        """
        if self.canvas is None:
            return

        # Transform coordinates if needed
        point = QgsPointXY(lon, lat)
        if self._crs.authid() != "EPSG:4326":
            transform = QgsCoordinateTransform(
                QgsCoordinateReferenceSystem("EPSG:4326"),
                self.canvas.mapSettings().destinationCrs(),
                self.project,
            )
            point = transform.transform(point)

        self.canvas.setCenter(point)

        if zoom is not None:
            # Approximate scale from zoom level
            scale = 591657550.500000 / (2**zoom)
            self.canvas.zoomScale(scale)

        self.canvas.refresh()

    def zoom_to_layer(self, layer_name: str) -> None:
        """Zoom the map to the extent of a layer.

        Args:
            layer_name: The name of the layer to zoom to.
        """
        layer = self.get_layer(layer_name)
        if layer is None:
            print(f"Layer '{layer_name}' not found.")
            return

        if self.canvas is not None:
            extent = layer.extent()
            # Transform extent if needed
            if layer.crs() != self.canvas.mapSettings().destinationCrs():
                transform = QgsCoordinateTransform(
                    layer.crs(),
                    self.canvas.mapSettings().destinationCrs(),
                    self.project,
                )
                extent = transform.transformBoundingBox(extent)
            self.canvas.setExtent(extent)
            self.canvas.refresh()
        elif self._iface is not None:
            self._iface.zoomToActiveLayer()

    def zoom_to_bounds(
        self, bounds: Tuple[float, float, float, float], crs: str = "EPSG:4326"
    ) -> None:
        """Zoom the map to the specified bounds.

        Args:
            bounds: The bounds as (xmin, ymin, xmax, ymax).
            crs: The CRS of the bounds. Defaults to "EPSG:4326".
        """
        if self.canvas is None:
            return

        xmin, ymin, xmax, ymax = bounds
        extent = QgsRectangle(xmin, ymin, xmax, ymax)

        # Transform if needed
        source_crs = QgsCoordinateReferenceSystem(crs)
        if source_crs != self.canvas.mapSettings().destinationCrs():
            transform = QgsCoordinateTransform(
                source_crs,
                self.canvas.mapSettings().destinationCrs(),
                self.project,
            )
            extent = transform.transformBoundingBox(extent)

        self.canvas.setExtent(extent)
        self.canvas.refresh()

    def add_basemap(
        self,
        basemap: str = "OpenStreetMap",
        layer_name: Optional[str] = None,
        visible: bool = True,
        opacity: float = 1.0,
        **kwargs,
    ) -> Optional["QgsRasterLayer"]:
        """Add a basemap to the map.

        Args:
            basemap: The name of the basemap or an XYZ tile URL.
                Available basemaps include: OpenStreetMap, CartoDB.Positron,
                CartoDB.DarkMatter, Esri.WorldImagery, SATELLITE, HYBRID, etc.
                Use get_basemap_names() to see all available basemaps.
            layer_name: The name for the layer. Defaults to the basemap name.
            visible: Whether the layer should be visible. Defaults to True.
            opacity: The layer opacity (0-1). Defaults to 1.0.
            **kwargs: Additional keyword arguments.

        Returns:
            The created raster layer, or None if failed.

        Example:
            >>> m = Map()
            >>> m.add_basemap("OpenStreetMap")
            >>> m.add_basemap("Esri.WorldImagery", layer_name="Satellite")
            >>> m.add_basemap(
            ...     "https://custom.tiles.com/{z}/{x}/{y}.png",
            ...     layer_name="Custom Tiles"
            ... )
        """
        # Determine the URL
        if basemap in BASEMAPS:
            url = BASEMAPS[basemap]["url"]
            name = layer_name or BASEMAPS[basemap]["name"]
        elif basemap.startswith("http"):
            url = basemap
            name = layer_name or "Custom Basemap"
        else:
            # Try case-insensitive match
            try:
                url = get_basemap_url(basemap)
                name = layer_name or basemap
            except ValueError as e:
                print(str(e))
                return None

        # Create the XYZ layer URI
        uri = get_xyz_uri(url)

        # Create and add the layer
        layer = QgsRasterLayer(uri, name, "wms")

        if not layer.isValid():
            print(f"Failed to load basemap: {name}")
            return None

        # Set opacity
        layer.renderer().setOpacity(opacity)

        # Add to project
        self.project.addMapLayer(layer, False)

        # Add to layer tree at the bottom (below other layers)
        root = self.project.layerTreeRoot()
        root.insertLayer(-1, layer)

        # Set visibility
        layer_node = root.findLayer(layer.id())
        if layer_node:
            layer_node.setItemVisibilityChecked(visible)

        # Track the layer
        self._layers[name] = layer
        self._basemap_layers.append(name)

        # Refresh canvas
        if self.canvas is not None:
            self.canvas.refresh()

        return layer

    def add_vector(
        self,
        source: str,
        layer_name: Optional[str] = None,
        style: Optional[Dict] = None,
        zoom_to_layer: bool = False,
        visible: bool = True,
        opacity: float = 1.0,
        encoding: str = "UTF-8",
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Add a vector layer to the map.

        Args:
            source: Path to the vector file (shapefile, GeoJSON, GeoPackage, etc.)
                or a URL to a web service.
            layer_name: The name for the layer. Defaults to the file name.
            style: A dictionary specifying the style. Supported keys:
                - color: Fill color as hex string (e.g., "#ff0000") or RGB tuple.
                - stroke_color: Stroke/outline color.
                - stroke_width: Stroke width in pixels.
                - opacity: Fill opacity (0-1).
                - symbol: Symbol type for points ("circle", "square", "triangle").
            zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
            visible: Whether the layer should be visible. Defaults to True.
            opacity: Layer opacity (0-1). Defaults to 1.0.
            encoding: File encoding. Defaults to "UTF-8".
            **kwargs: Additional keyword arguments passed to QgsVectorLayer.

        Returns:
            The created vector layer, or None if failed.

        Example:
            >>> m = Map()
            >>> m.add_vector("data/cities.geojson", layer_name="Cities")
            >>> m.add_vector(
            ...     "data/polygons.shp",
            ...     style={"color": "#3388ff", "stroke_color": "#000000"},
            ...     zoom_to_layer=True
            ... )
        """
        # Determine the layer name
        if layer_name is None:
            layer_name = os.path.splitext(os.path.basename(source))[0]

        # Handle different source types
        if source.startswith("http"):
            # Web service (WFS, etc.)
            layer = QgsVectorLayer(source, layer_name, "WFS")
        else:
            # Local file
            layer = QgsVectorLayer(source, layer_name, "ogr")
            if encoding:
                layer.setProviderEncoding(encoding)

        if not layer.isValid():
            print(f"Failed to load vector layer: {source}")
            return None

        # Apply style if provided
        if style:
            self._apply_vector_style(layer, style)

        # Set opacity
        layer.setOpacity(opacity)

        # Add to project
        self.project.addMapLayer(layer)

        # Set visibility
        root = self.project.layerTreeRoot()
        layer_node = root.findLayer(layer.id())
        if layer_node:
            layer_node.setItemVisibilityChecked(visible)

        # Track the layer
        self._layers[layer_name] = layer

        # Zoom to layer if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer_name)
        elif self.canvas is not None:
            self.canvas.refresh()

        return layer

    def add_gdf(
        self,
        gdf: Any,
        layer_name: str = "GeoDataFrame",
        style: Optional[Dict] = None,
        zoom_to_layer: bool = False,
        visible: bool = True,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Add a GeoDataFrame to the map.

        Args:
            gdf: A GeoPandas GeoDataFrame.
            layer_name: The name for the layer. Defaults to "GeoDataFrame".
            style: A dictionary specifying the style (see add_vector).
            zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
            visible: Whether the layer should be visible. Defaults to True.
            **kwargs: Additional keyword arguments.

        Returns:
            The created vector layer, or None if failed.
        """
        try:
            import geopandas as gpd
        except ImportError:
            print(
                "GeoPandas is required to add GeoDataFrames. Install with: pip install geopandas"
            )
            return None

        # Create a temporary file to store the GeoDataFrame
        import tempfile

        with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as tmp:
            gdf.to_file(tmp.name, driver="GeoJSON")
            return self.add_vector(
                tmp.name,
                layer_name=layer_name,
                style=style,
                zoom_to_layer=zoom_to_layer,
                visible=visible,
                **kwargs,
            )

    def _apply_vector_style(self, layer: "QgsVectorLayer", style: Dict) -> None:
        """Apply a style dictionary to a vector layer.

        Args:
            layer: The vector layer to style.
            style: The style dictionary.
        """
        symbol = layer.renderer().symbol()

        if symbol is None:
            return

        # Get geometry type
        geom_type = layer.geometryType()

        # Apply fill color
        if "color" in style:
            color = self._parse_color(style["color"])
            if color:
                symbol.setColor(color)

        # Apply stroke/outline
        if "stroke_color" in style:
            color = self._parse_color(style["stroke_color"])
            if color:
                if geom_type == QgsWkbTypes.PolygonGeometry:
                    symbol.symbolLayer(0).setStrokeColor(color)
                elif geom_type == QgsWkbTypes.LineGeometry:
                    symbol.setColor(color)
                elif geom_type == QgsWkbTypes.PointGeometry:
                    symbol.symbolLayer(0).setStrokeColor(color)

        if "stroke_width" in style:
            width = style["stroke_width"]
            if geom_type == QgsWkbTypes.PolygonGeometry:
                symbol.symbolLayer(0).setStrokeWidth(width)
            elif geom_type == QgsWkbTypes.LineGeometry:
                symbol.setWidth(width)
            elif geom_type == QgsWkbTypes.PointGeometry:
                symbol.symbolLayer(0).setStrokeWidth(width)

        # Apply opacity
        if "opacity" in style:
            opacity = style["opacity"]
            color = symbol.color()
            color.setAlphaF(opacity)
            symbol.setColor(color)

        # Trigger a repaint
        layer.triggerRepaint()

    def _parse_color(self, color: Union[str, Tuple]) -> Optional["QColor"]:
        """Parse a color specification into a QColor.

        Args:
            color: A color as hex string, RGB tuple, or RGBA tuple.

        Returns:
            A QColor object, or None if parsing failed.
        """
        if isinstance(color, str):
            return QColor(color)
        elif isinstance(color, (list, tuple)):
            if len(color) == 3:
                return QColor(*color)
            elif len(color) == 4:
                return QColor(*color[:3], int(color[3] * 255))
        return None

    def add_raster(
        self,
        source: str,
        layer_name: Optional[str] = None,
        band: Optional[int] = None,
        colormap: Optional[str] = None,
        vmin: Optional[float] = None,
        vmax: Optional[float] = None,
        nodata: Optional[float] = None,
        zoom_to_layer: bool = False,
        visible: bool = True,
        opacity: float = 1.0,
        **kwargs,
    ) -> Optional["QgsRasterLayer"]:
        """Add a raster layer to the map.

        Args:
            source: Path to the raster file (GeoTIFF, etc.) or a URL.
            layer_name: The name for the layer. Defaults to the file name.
            band: The band number to display (1-indexed). Defaults to None (auto).
            colormap: The name of a color ramp to apply (e.g., "viridis", "Spectral").
            vmin: Minimum value for color scaling. Defaults to band minimum.
            vmax: Maximum value for color scaling. Defaults to band maximum.
            nodata: The nodata value. Defaults to the layer's nodata value.
            zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
            visible: Whether the layer should be visible. Defaults to True.
            opacity: Layer opacity (0-1). Defaults to 1.0.
            **kwargs: Additional keyword arguments.

        Returns:
            The created raster layer, or None if failed.

        Example:
            >>> m = Map()
            >>> m.add_raster("elevation.tif", colormap="terrain", zoom_to_layer=True)
            >>> m.add_raster("landsat.tif", band=4, vmin=0, vmax=3000)
        """
        # Determine the layer name
        if layer_name is None:
            layer_name = os.path.splitext(os.path.basename(source))[0]

        # Create the raster layer
        layer = QgsRasterLayer(source, layer_name)

        if not layer.isValid():
            print(f"Failed to load raster layer: {source}")
            return None

        # Get the data provider
        provider = layer.dataProvider()

        # Set nodata if specified
        if nodata is not None:
            provider.setNoDataValue(1, nodata)

        # Apply colormap/renderer
        if colormap or band or vmin is not None or vmax is not None:
            self._apply_raster_style(layer, band, colormap, vmin, vmax)

        # Set opacity
        layer.renderer().setOpacity(opacity)

        # Add to project
        self.project.addMapLayer(layer)

        # Set visibility
        root = self.project.layerTreeRoot()
        layer_node = root.findLayer(layer.id())
        if layer_node:
            layer_node.setItemVisibilityChecked(visible)

        # Track the layer
        self._layers[layer_name] = layer

        # Zoom to layer if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer_name)
        elif self.canvas is not None:
            self.canvas.refresh()

        return layer

    def _apply_raster_style(
        self,
        layer: "QgsRasterLayer",
        band: Optional[int],
        colormap: Optional[str],
        vmin: Optional[float],
        vmax: Optional[float],
    ) -> None:
        """Apply styling to a raster layer.

        Args:
            layer: The raster layer to style.
            band: The band number (1-indexed).
            colormap: The name of the color ramp.
            vmin: Minimum value for color scaling.
            vmax: Maximum value for color scaling.
        """
        provider = layer.dataProvider()
        band_count = provider.bandCount()

        if band is None:
            band = 1

        # Get statistics for the band
        stats = provider.bandStatistics(band, QgsRasterBandStats.All, layer.extent(), 0)

        if vmin is None:
            vmin = stats.minimumValue
        if vmax is None:
            vmax = stats.maximumValue

        if band_count == 1 or band is not None:
            # Single band rendering
            if colormap:
                # Use a color ramp shader
                shader = QgsRasterShader()
                color_ramp_shader = QgsColorRampShader()
                color_ramp_shader.setColorRampType(QgsColorRampShader.Interpolated)

                # Try to get the color ramp from QGIS styles
                style = QgsStyle.defaultStyle()
                ramp = style.colorRamp(colormap)

                if ramp is None:
                    # Create a default gradient
                    color_ramp_shader.setColorRampItemList(
                        [
                            QgsColorRampShader.ColorRampItem(vmin, QColor(68, 1, 84)),
                            QgsColorRampShader.ColorRampItem(
                                (vmin + vmax) / 2, QColor(59, 82, 139)
                            ),
                            QgsColorRampShader.ColorRampItem(
                                vmax, QColor(253, 231, 37)
                            ),
                        ]
                    )
                else:
                    # Use the color ramp
                    items = []
                    num_classes = 10
                    for i in range(num_classes + 1):
                        value = vmin + (vmax - vmin) * i / num_classes
                        color = ramp.color(i / num_classes)
                        items.append(QgsColorRampShader.ColorRampItem(value, color))
                    color_ramp_shader.setColorRampItemList(items)

                shader.setRasterShaderFunction(color_ramp_shader)
                renderer = QgsSingleBandPseudoColorRenderer(provider, band, shader)
            else:
                # Grayscale rendering
                renderer = QgsSingleBandGrayRenderer(provider, band)
                renderer.setContrastEnhancement(layer.renderer().contrastEnhancement())

            layer.setRenderer(renderer)

        elif band_count >= 3:
            # Multi-band RGB rendering
            renderer = QgsMultiBandColorRenderer(provider, 1, 2, 3)
            layer.setRenderer(renderer)

        layer.triggerRepaint()

    def add_cog(
        self,
        url: str,
        layer_name: Optional[str] = None,
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsRasterLayer"]:
        """Add a Cloud Optimized GeoTIFF (COG) to the map.

        Args:
            url: The URL to the COG file.
            layer_name: The name for the layer. Defaults to "COG".
            zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
            **kwargs: Additional keyword arguments passed to add_raster.

        Returns:
            The created raster layer, or None if failed.
        """
        if layer_name is None:
            layer_name = "COG"

        # Use GDAL's vsicurl for remote access
        vsicurl_url = f"/vsicurl/{url}"

        return self.add_raster(
            vsicurl_url,
            layer_name=layer_name,
            zoom_to_layer=zoom_to_layer,
            **kwargs,
        )

    def add_wms(
        self,
        url: str,
        layers: str,
        layer_name: Optional[str] = None,
        format: str = "image/png",
        crs: str = "EPSG:4326",
        visible: bool = True,
        opacity: float = 1.0,
        **kwargs,
    ) -> Optional["QgsRasterLayer"]:
        """Add a WMS layer to the map.

        Args:
            url: The WMS service URL.
            layers: The layer name(s) to request from the WMS.
            layer_name: The display name for the layer.
            format: The image format. Defaults to "image/png".
            crs: The CRS to request. Defaults to "EPSG:4326".
            visible: Whether the layer should be visible. Defaults to True.
            opacity: Layer opacity (0-1). Defaults to 1.0.
            **kwargs: Additional keyword arguments.

        Returns:
            The created raster layer, or None if failed.
        """
        if layer_name is None:
            layer_name = layers

        # Build WMS URI
        uri = (
            f"url={url}&"
            f"layers={layers}&"
            f"format={format}&"
            f"crs={crs}&"
            f"styles="
        )

        layer = QgsRasterLayer(uri, layer_name, "wms")

        if not layer.isValid():
            print(f"Failed to load WMS layer: {url}")
            return None

        layer.renderer().setOpacity(opacity)

        self.project.addMapLayer(layer)

        # Set visibility
        root = self.project.layerTreeRoot()
        layer_node = root.findLayer(layer.id())
        if layer_node:
            layer_node.setItemVisibilityChecked(visible)

        self._layers[layer_name] = layer

        if self.canvas is not None:
            self.canvas.refresh()

        return layer

    def add_time_slider(
        self,
        layers: Optional[Dict[str, str]] = None,
        labels: Optional[List[str]] = None,
        time_interval: int = 1,
        position: str = "bottomright",
        time_format: str = "%Y-%m-%d",
        **kwargs,
    ) -> Optional["QDockWidget"]:
        """Add a time slider to control temporal layers or switch between layers.

        This method creates a dockable panel with a slider that can either:
        1. Control the temporal properties of a single layer with time-enabled data.
        2. Switch between multiple layers representing different time periods.

        Args:
            layers: A dictionary mapping labels to layer sources (file paths or URLs).
                If provided, clicking the slider will switch between these layers.
            labels: A list of labels for the time steps (used with `layers`).
            time_interval: Time interval between steps in seconds. Defaults to 1.
            position: Position of the dock widget ("left", "right", "top", "bottom").
                Defaults to "bottomright".
            time_format: The format string for displaying time labels.
            **kwargs: Additional keyword arguments.

        Returns:
            The created QDockWidget, or None if failed.

        Example:
            >>> m = Map()
            >>> # Switch between multiple raster layers
            >>> layers = {
            ...     "2020": "data/ndvi_2020.tif",
            ...     "2021": "data/ndvi_2021.tif",
            ...     "2022": "data/ndvi_2022.tif",
            ... }
            >>> m.add_time_slider(layers=layers)
        """
        if not HAS_PYQT:
            print("PyQt5 is required for time slider functionality.")
            return None

        if self._iface is None:
            print("Time slider requires running within QGIS.")
            return None

        # Create the dock widget
        dock = QDockWidget("Time Slider", self._iface.mainWindow())
        dock.setObjectName("TimeSliderDock")

        # Create the main widget
        main_widget = QWidget()
        layout = QVBoxLayout()

        if layers is not None:
            # Mode 1: Switch between multiple layers
            layer_list = list(layers.items())
            if labels is None:
                labels = list(layers.keys())

            # Load all layers but hide them except the first
            loaded_layers = []
            failed_layers = []
            for i, (label, source) in enumerate(layer_list):
                # Determine if raster or vector
                if source.lower().endswith(
                    (".tif", ".tiff", ".img", ".jp2", ".png", ".jpg")
                ):
                    layer = self.add_raster(
                        source,
                        layer_name=label,
                        visible=(i == 0),
                        zoom_to_layer=(i == 0),
                    )
                else:
                    layer = self.add_vector(
                        source,
                        layer_name=label,
                        visible=(i == 0),
                        zoom_to_layer=(i == 0),
                    )

                if layer is None:
                    failed_layers.append((label, source))
                    print(f"Warning: Failed to load layer '{label}' from {source}")
                loaded_layers.append(layer)

            # Check if any layers were loaded successfully
            if all(layer is None for layer in loaded_layers):
                print(
                    "Error: No layers were loaded successfully. Please check your file paths."
                )
                if failed_layers:
                    print("Failed layers:")
                    for label, source in failed_layers:
                        print(f"  - {label}: {source}")
                return None

            # Create the slider
            slider = QSlider(Qt.Horizontal)
            slider.setMinimum(0)
            slider.setMaximum(len(layer_list) - 1)
            slider.setValue(0)
            slider.setTickPosition(QSlider.TicksBelow)
            slider.setTickInterval(1)

            # Create label display
            label_display = QLabel(labels[0])
            label_display.setAlignment(Qt.AlignCenter)

            # Play/Pause button
            play_btn = QPushButton("▶ Play")
            play_btn.setCheckable(True)

            # Timer for auto-play
            from PyQt5.QtCore import QTimer

            timer = QTimer()
            timer.setInterval(time_interval * 1000)

            def on_slider_change(value):
                label_display.setText(labels[value])
                # Hide all layers except the current one
                for i, layer in enumerate(loaded_layers):
                    if layer is not None:
                        root = self.project.layerTreeRoot()
                        layer_node = root.findLayer(layer.id())
                        if layer_node:
                            layer_node.setItemVisibilityChecked(i == value)
                if self.canvas:
                    self.canvas.refresh()

            def on_timeout():
                current = slider.value()
                next_val = (current + 1) % len(layer_list)
                slider.setValue(next_val)

            def on_play_clicked(checked):
                if checked:
                    play_btn.setText("⏸ Pause")
                    timer.start()
                else:
                    play_btn.setText("▶ Play")
                    timer.stop()

            slider.valueChanged.connect(on_slider_change)
            timer.timeout.connect(on_timeout)
            play_btn.clicked.connect(on_play_clicked)

            # Add widgets to layout
            layout.addWidget(label_display)
            layout.addWidget(slider)

            # Add control buttons
            btn_layout = QHBoxLayout()
            prev_btn = QPushButton("◀ Prev")
            next_btn = QPushButton("Next ▶")
            prev_btn.clicked.connect(
                lambda: slider.setValue(max(0, slider.value() - 1))
            )
            next_btn.clicked.connect(
                lambda: slider.setValue(min(len(layer_list) - 1, slider.value() + 1))
            )
            btn_layout.addWidget(prev_btn)
            btn_layout.addWidget(play_btn)
            btn_layout.addWidget(next_btn)
            layout.addLayout(btn_layout)

        else:
            # Mode 2: Control QGIS temporal navigation
            info_label = QLabel(
                "Use the QGIS Temporal Controller for time-enabled layers.\n"
                "Enable temporal properties on your layer first."
            )
            info_label.setWordWrap(True)
            layout.addWidget(info_label)

            # Add a button to open the temporal controller
            open_temporal_btn = QPushButton("Open Temporal Controller")
            open_temporal_btn.clicked.connect(
                lambda: (
                    self._iface.mainWindow()
                    .findChild(QDockWidget, "TemporalControllerDock")
                    .show()
                    if self._iface.mainWindow().findChild(
                        QDockWidget, "TemporalControllerDock"
                    )
                    else None
                )
            )
            layout.addWidget(open_temporal_btn)

        main_widget.setLayout(layout)
        dock.setWidget(main_widget)

        # Determine dock area
        dock_areas = {
            "left": Qt.LeftDockWidgetArea,
            "right": Qt.RightDockWidgetArea,
            "top": Qt.TopDockWidgetArea,
            "bottom": Qt.BottomDockWidgetArea,
            "bottomright": Qt.BottomDockWidgetArea,
            "bottomleft": Qt.BottomDockWidgetArea,
            "topright": Qt.TopDockWidgetArea,
            "topleft": Qt.TopDockWidgetArea,
        }
        area = dock_areas.get(position.lower(), Qt.BottomDockWidgetArea)

        self._iface.addDockWidget(area, dock)
        self._time_slider_dock = dock

        return dock

    def remove_layer(self, layer_name: str) -> bool:
        """Remove a layer from the map.

        Args:
            layer_name: The name of the layer to remove.

        Returns:
            True if the layer was removed, False otherwise.
        """
        layer = self.get_layer(layer_name)
        if layer is None:
            print(f"Layer '{layer_name}' not found.")
            return False

        self.project.removeMapLayer(layer.id())

        if layer_name in self._layers:
            del self._layers[layer_name]

        if layer_name in self._basemap_layers:
            self._basemap_layers.remove(layer_name)

        if self.canvas is not None:
            self.canvas.refresh()

        return True

    def clear_layers(self, keep_basemap: bool = True) -> None:
        """Remove all layers from the map.

        Args:
            keep_basemap: Whether to keep basemap layers. Defaults to True.
        """
        layers_to_remove = []

        for layer in self.project.mapLayers().values():
            if keep_basemap and layer.name() in self._basemap_layers:
                continue
            layers_to_remove.append(layer.id())

        for layer_id in layers_to_remove:
            self.project.removeMapLayer(layer_id)

        # Update internal tracking
        if keep_basemap:
            self._layers = {
                name: layer
                for name, layer in self._layers.items()
                if name in self._basemap_layers
            }
        else:
            self._layers = {}
            self._basemap_layers = []

        if self.canvas is not None:
            self.canvas.refresh()

    def add_layer_control(self) -> None:
        """Show the layer panel in QGIS.

        This opens or focuses the Layers panel in QGIS.
        """
        if self._iface is not None:
            # The layer panel is usually already visible, but ensure it is
            layers_dock = self._iface.mainWindow().findChild(QDockWidget, "Layers")
            if layers_dock:
                layers_dock.show()
                layers_dock.raise_()

    def to_image(
        self,
        output_path: str,
        width: int = 1920,
        height: int = 1080,
        extent: Optional[Tuple[float, float, float, float]] = None,
        **kwargs,
    ) -> str:
        """Export the current map view to an image.

        Args:
            output_path: The output file path.
            width: Image width in pixels. Defaults to 1920.
            height: Image height in pixels. Defaults to 1080.
            extent: Optional extent as (xmin, ymin, xmax, ymax).
            **kwargs: Additional keyword arguments.

        Returns:
            The output file path.
        """
        from qgis.core import QgsMapSettings, QgsMapRendererCustomPainterJob
        from PyQt5.QtGui import QImage, QPainter
        from PyQt5.QtCore import QSize

        # Set up map settings
        settings = QgsMapSettings()
        settings.setOutputSize(QSize(width, height))
        settings.setLayers(list(self.project.mapLayers().values()))

        if extent:
            xmin, ymin, xmax, ymax = extent
            settings.setExtent(QgsRectangle(xmin, ymin, xmax, ymax))
        elif self.canvas:
            settings.setExtent(self.canvas.extent())

        settings.setBackgroundColor(QColor(255, 255, 255))
        settings.setDestinationCrs(self.project.crs())

        # Create image
        image = QImage(QSize(width, height), QImage.Format_ARGB32)
        image.fill(Qt.white)

        # Render
        painter = QPainter(image)
        job = QgsMapRendererCustomPainterJob(settings, painter)
        job.start()
        job.waitForFinished()
        painter.end()

        # Save
        image.save(output_path)

        return output_path

    def add_legend(
        self,
        title: str = "Legend",
        legend_dict: Optional[Dict[str, str]] = None,
        labels: Optional[List[str]] = None,
        colors: Optional[List[Union[str, Tuple]]] = None,
        position: str = "bottomright",
        layer_name: Optional[str] = None,
        **kwargs,
    ) -> Optional["QDockWidget"]:
        """Add a legend to the map.

        Args:
            title: Title of the legend. Defaults to "Legend".
            legend_dict: A dictionary containing legend items as keys and colors as values.
                If provided, labels and colors will be ignored.
            labels: A list of legend labels.
            colors: A list of legend colors (hex strings, RGB tuples, or color names).
            position: Position of the legend ("topleft", "topright", "bottomleft", "bottomright").
                Defaults to "bottomright".
            layer_name: Layer name to associate the legend with. Defaults to None.
            **kwargs: Additional keyword arguments (e.g., width, height).

        Returns:
            The created QDockWidget, or None if failed.

        Example:
            >>> m = Map()
            >>> m.add_legend(
            ...     title="Land Cover",
            ...     labels=["Forest", "Water", "Urban"],
            ...     colors=["#228B22", "#4169E1", "#DC143C"]
            ... )
            >>> # Or using a dictionary
            >>> m.add_legend(
            ...     legend_dict={"Forest": "#228B22", "Water": "#4169E1", "Urban": "#DC143C"}
            ... )
        """
        if not HAS_PYQT:
            print("PyQt5 is required for legend functionality.")
            return None

        if self._iface is None:
            print("Legend requires running within QGIS.")
            return None

        # Process legend_dict
        if legend_dict is not None:
            if not isinstance(legend_dict, dict):
                print("The legend_dict must be a dictionary.")
                return None
            labels = list(legend_dict.keys())
            colors = list(legend_dict.values())

        # Validate labels and colors
        if labels is None:
            labels = ["One", "Two", "Three", "Four"]
        if colors is None:
            colors = ["#8DD3C7", "#FFFFB3", "#BEBADA", "#FB8072"]

        if not isinstance(labels, list):
            print("The labels must be a list.")
            return None
        if not isinstance(colors, list):
            print("The colors must be a list.")
            return None

        # Convert RGB tuples to hex if needed
        converted_colors = []
        for color in colors:
            if isinstance(color, tuple):
                if len(color) == 3:
                    converted_colors.append(
                        "#{:02x}{:02x}{:02x}".format(
                            int(color[0]), int(color[1]), int(color[2])
                        )
                    )
                elif len(color) == 4:
                    converted_colors.append(
                        "#{:02x}{:02x}{:02x}".format(
                            int(color[0]), int(color[1]), int(color[2])
                        )
                    )
                else:
                    print(f"Invalid color tuple: {color}")
                    return None
            elif isinstance(color, str):
                # Handle colors without # prefix
                if len(color) == 6 and not color.startswith("#"):
                    converted_colors.append("#" + color)
                else:
                    converted_colors.append(color)
            else:
                print(f"Invalid color type: {type(color)}")
                return None
        colors = converted_colors

        if len(labels) != len(colors):
            print("The labels and colors must be the same length.")
            return None

        # Validate position
        allowed_positions = ["topleft", "topright", "bottomleft", "bottomright"]
        if position not in allowed_positions:
            print(f"The position must be one of: {', '.join(allowed_positions)}")
            return None

        # Create the dock widget
        dock = QDockWidget(title, self._iface.mainWindow())
        dock.setObjectName(f"{title}LegendDock")

        # Create the main widget with scroll area
        from PyQt5.QtWidgets import QScrollArea

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)

        main_widget = QWidget()
        layout = QVBoxLayout()
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(5)

        # Add legend items
        for label, color in zip(labels, colors):
            item_layout = QHBoxLayout()

            # Color box
            color_label = QLabel()
            color_label.setFixedSize(20, 20)
            color_label.setStyleSheet(
                f"background-color: {color}; border: 1px solid #000000;"
            )

            # Label text
            text_label = QLabel(label)

            item_layout.addWidget(color_label)
            item_layout.addWidget(text_label)
            item_layout.addStretch()

            layout.addLayout(item_layout)

        layout.addStretch()
        main_widget.setLayout(layout)
        scroll.setWidget(main_widget)
        dock.setWidget(scroll)

        # Set size constraints if provided
        if "width" in kwargs:
            dock.setMinimumWidth(kwargs["width"])
        else:
            dock.setMinimumWidth(150)

        if "height" in kwargs:
            dock.setMinimumHeight(kwargs["height"])

        # Determine dock area
        dock_areas = {
            "topleft": Qt.LeftDockWidgetArea,
            "topright": Qt.RightDockWidgetArea,
            "bottomleft": Qt.LeftDockWidgetArea,
            "bottomright": Qt.RightDockWidgetArea,
        }
        area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

        self._iface.addDockWidget(area, dock)

        return dock

    def add_colorbar(
        self,
        colors: List[Union[str, Tuple]],
        vmin: float = 0,
        vmax: float = 1.0,
        index: Optional[List[float]] = None,
        caption: str = "",
        categorical: bool = False,
        step: Optional[int] = None,
        width: int = 300,
        height: int = 60,
        position: str = "bottomright",
        **kwargs,
    ) -> Optional["QDockWidget"]:
        """Add a colorbar to the map.

        Args:
            colors: The set of colors for interpolation. Can be:
                - List of hex strings (e.g., ["#ff0000", "#00ff00", "#0000ff"])
                - List of RGB tuples (e.g., [(255, 0, 0), (0, 255, 0), (0, 0, 255)])
                - List of color names (e.g., ["red", "green", "blue"])
            vmin: The minimum value for the colormap. Defaults to 0.
            vmax: The maximum value for the colormap. Defaults to 1.0.
            index: The values corresponding to each color. Must be sorted and same length as colors.
                If None, a regular grid between vmin and vmax is created.
            caption: The caption/title for the colorbar. Defaults to "".
            categorical: Whether to create a categorical (discrete) colorbar. Defaults to False.
            step: The number of steps for categorical colorbar. Defaults to None.
            width: The width of the colorbar widget in pixels. Defaults to 300.
            height: The height of the colorbar widget in pixels. Defaults to 60.
            position: Position of the colorbar ("topleft", "topright", "bottomleft", "bottomright").
                Defaults to "bottomright".
            **kwargs: Additional keyword arguments.

        Returns:
            The created QDockWidget, or None if failed.

        Example:
            >>> m = Map()
            >>> m.add_colorbar(
            ...     colors=["blue", "cyan", "yellow", "red"],
            ...     vmin=0,
            ...     vmax=100,
            ...     caption="Temperature (°C)"
            ... )
            >>> # Categorical colorbar
            >>> m.add_colorbar(
            ...     colors=["#00ff00", "#ffff00", "#ff0000"],
            ...     vmin=0,
            ...     vmax=1,
            ...     categorical=True,
            ...     caption="Risk Level"
            ... )
        """
        if not HAS_PYQT:
            print("PyQt5 is required for colorbar functionality.")
            return None

        if self._iface is None:
            print("Colorbar requires running within QGIS.")
            return None

        from PyQt5.QtWidgets import QScrollArea
        from PyQt5.QtGui import QPainter, QLinearGradient
        from PyQt5.QtCore import QRectF

        # Convert colors to QColor
        qcolors = []
        for color in colors:
            if isinstance(color, tuple):
                if len(color) == 3:
                    qcolors.append(QColor(int(color[0]), int(color[1]), int(color[2])))
                elif len(color) == 4:
                    qcolors.append(
                        QColor(
                            int(color[0]),
                            int(color[1]),
                            int(color[2]),
                            int(color[3] * 255) if color[3] <= 1 else int(color[3]),
                        )
                    )
            elif isinstance(color, str):
                if len(color) == 6 and not color.startswith("#"):
                    qcolors.append(QColor("#" + color))
                else:
                    qcolors.append(QColor(color))
            else:
                print(f"Invalid color type: {type(color)}")
                return None

        # Create index if not provided
        if index is None:
            index = [
                vmin + (vmax - vmin) * i / (len(qcolors) - 1)
                for i in range(len(qcolors))
            ]
        elif len(index) != len(qcolors):
            print("The index and colors must be the same length.")
            return None

        # Create the dock widget
        title = caption if caption else "Colorbar"
        dock = QDockWidget(title, self._iface.mainWindow())
        dock.setObjectName(f"{title}ColorbarDock")

        # Create the main widget
        main_widget = QWidget()
        layout = QVBoxLayout()
        layout.setContentsMargins(10, 10, 10, 10)
        layout.setSpacing(5)

        # Create colorbar as a pixmap and display in QLabel
        from PyQt5.QtGui import QPixmap, QImage
        from PyQt5.QtCore import Qt as QtCore

        # Create the colorbar image
        bar_height = 30
        pixmap = QPixmap(width, bar_height)
        pixmap.fill(QtCore.transparent)

        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.Antialiasing)

        if categorical and step and step > 1:
            # Draw discrete color segments
            segment_width = width / step
            for i in range(step):
                if step > 1:
                    color_idx = int(i * (len(qcolors) - 1) / (step - 1))
                else:
                    color_idx = 0
                color_idx = min(color_idx, len(qcolors) - 1)
                painter.fillRect(
                    int(i * segment_width),
                    0,
                    int(segment_width + 1),
                    bar_height,
                    qcolors[color_idx],
                )
        else:
            # Draw continuous gradient
            gradient = QLinearGradient(0, 0, width, 0)
            if len(qcolors) > 1:
                for i, color in enumerate(qcolors):
                    pos = i / (len(qcolors) - 1)
                    gradient.setColorAt(pos, color)
            else:
                gradient.setColorAt(0, qcolors[0])
                gradient.setColorAt(1, qcolors[0])
            painter.fillRect(0, 0, width, bar_height, gradient)

        # Draw border
        painter.setPen(QColor("#000000"))
        painter.drawRect(0, 0, width - 1, bar_height - 1)
        painter.end()

        # Create label with the colorbar pixmap
        colorbar_label = QLabel()
        colorbar_label.setPixmap(pixmap)
        colorbar_label.setMinimumSize(width, bar_height)
        colorbar_label.setMaximumSize(width, bar_height)
        layout.addWidget(colorbar_label)

        # Add value labels
        labels_layout = QHBoxLayout()
        vmin_label = QLabel(f"{vmin:.2f}")
        vmax_label = QLabel(f"{vmax:.2f}")
        labels_layout.addWidget(vmin_label)
        labels_layout.addStretch()
        labels_layout.addWidget(vmax_label)
        layout.addLayout(labels_layout)

        main_widget.setLayout(layout)
        dock.setWidget(main_widget)

        # Set size constraints instead of fixed size
        dock.setMinimumWidth(width + 40)
        dock.setMaximumWidth(width + 60)
        dock.setMinimumHeight(height + 80)
        dock.setMaximumHeight(height + 100)

        # Determine dock area
        dock_areas = {
            "topleft": Qt.LeftDockWidgetArea,
            "topright": Qt.RightDockWidgetArea,
            "bottomleft": Qt.LeftDockWidgetArea,
            "bottomright": Qt.RightDockWidgetArea,
        }
        area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

        self._iface.addDockWidget(area, dock)

        return dock

    def create_dock_widget(
        self,
        title: str,
        widget: Optional["QWidget"] = None,
        position: str = "right",
    ) -> "QDockWidget":
        """Create a custom dockable panel in QGIS.

        Args:
            title: The title of the dock widget.
            widget: The widget to place in the dock. If None, creates an empty container.
            position: Position of the dock ("left", "right", "top", "bottom").

        Returns:
            The created QDockWidget.
        """
        if self._iface is None:
            raise RuntimeError("Dock widgets require running within QGIS.")

        dock = QDockWidget(title, self._iface.mainWindow())
        dock.setObjectName(f"{title}Dock")

        if widget is None:
            widget = QWidget()
            widget.setLayout(QVBoxLayout())

        dock.setWidget(widget)

        dock_areas = {
            "left": Qt.LeftDockWidgetArea,
            "right": Qt.RightDockWidgetArea,
            "top": Qt.TopDockWidgetArea,
            "bottom": Qt.BottomDockWidgetArea,
        }
        area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

        self._iface.addDockWidget(area, dock)

        return dock

    def layer_opacity(
        self,
        layer_name: str,
        opacity: float,
    ) -> bool:
        """Set opacity for an existing layer.

        Args:
            layer_name: Name of the layer to modify.
            opacity: Opacity value between 0.0 (transparent) and 1.0 (opaque).

        Returns:
            True if opacity was set successfully, False if layer not found.

        Example:
            >>> m = Map()
            >>> m.add_vector("data.shp", layer_name="test")
            >>> m.layer_opacity("test", 0.5)
            True
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return False

        # Get the layer
        layer = self.get_layer(layer_name)
        if layer is None:
            print(f"Layer '{layer_name}' not found")
            return False

        # Clamp opacity to valid range
        opacity = max(0.0, min(1.0, opacity))

        try:
            # Set opacity based on layer type
            if layer.type() == QgsMapLayerType.VectorLayer:
                layer.setOpacity(opacity)
            elif layer.type() == QgsMapLayerType.RasterLayer:
                layer.renderer().setOpacity(opacity)

            # Trigger repaint
            layer.triggerRepaint()

            if self.canvas:
                self.canvas.refresh()

            return True

        except Exception as e:
            print(f"Error setting layer opacity: {e}")
            return False

    def zoom_to_gdf(
        self,
        gdf: Any,
        crs: Optional[str] = None,
    ) -> None:
        """Zoom map to GeoDataFrame extent.

        Args:
            gdf: GeoDataFrame to zoom to.
            crs: Optional CRS string. If None, uses gdf.crs.

        Example:
            >>> import geopandas as gpd
            >>> m = Map()
            >>> gdf = gpd.read_file("data.geojson")
            >>> m.zoom_to_gdf(gdf)
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return

        try:
            # Get bounds from GeoDataFrame
            bounds = gdf.total_bounds  # Returns (minx, miny, maxx, maxy)

            # Get CRS from gdf if not specified
            if crs is None:
                crs = str(gdf.crs)

            # Use existing zoom_to_bounds method
            self.zoom_to_bounds(bounds, crs=crs)

        except Exception as e:
            print(f"Error zooming to GeoDataFrame: {e}")

    def find_layer(
        self,
        name: str,
    ) -> Optional[List["QgsMapLayer"]]:
        """Find all layers matching a name pattern.

        Supports exact matching and wildcard patterns using * and ?.

        Args:
            name: Layer name or pattern to search for.

        Returns:
            List of matching layers, or None if no matches found.

        Example:
            >>> m = Map()
            >>> m.add_vector("data.shp", layer_name="test1")
            >>> m.add_vector("data2.shp", layer_name="test2")
            >>> layers = m.find_layer("test*")
            >>> len(layers)
            2
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        # First try exact match
        exact_matches = self.project.mapLayersByName(name)
        if exact_matches:
            return exact_matches

        # If no exact match, try pattern matching
        all_layers = list(self.project.mapLayers().values())
        matches = []

        for layer in all_layers:
            if fnmatch.fnmatch(layer.name(), name):
                matches.append(layer)

        return matches if matches else None

    def add_geojson(
        self,
        source: Union[str, Dict],
        layer_name: Optional[str] = None,
        style: Optional[Dict] = None,
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Add a GeoJSON layer from file, URL, or Python dict.

        Args:
            source: GeoJSON file path, URL, or Python dictionary.
            layer_name: Name for the layer. If None, auto-generated.
            style: Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.
            zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
            **kwargs: Additional keyword arguments passed to add_vector.

        Returns:
            The created vector layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> # From file
            >>> m.add_geojson("data.geojson")
            >>> # From URL
            >>> m.add_geojson("https://example.com/data.geojson")
            >>> # From dict
            >>> geojson_dict = {"type": "FeatureCollection", "features": [...]}
            >>> m.add_geojson(geojson_dict, layer_name="custom")
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        temp_file = None

        try:
            # Handle different source types
            if isinstance(source, dict):
                # Python dictionary - save to temporary file
                temp_file = tempfile.NamedTemporaryFile(
                    mode="w", suffix=".geojson", delete=False
                )
                json.dump(source, temp_file)
                temp_file.close()
                file_path = temp_file.name

            elif source.startswith(("http://", "https://")):
                # URL - download the file
                from . import common

                file_path = common.download_file(source)

            else:
                # Assume it's a file path
                file_path = source

            # Use existing add_vector method
            layer = self.add_vector(
                file_path,
                layer_name=layer_name,
                style=style,
                zoom_to_layer=zoom_to_layer,
                **kwargs,
            )

            return layer

        except Exception as e:
            print(f"Error adding GeoJSON: {e}")
            return None

        finally:
            # Clean up temporary file if created
            if temp_file is not None:
                try:
                    os.unlink(temp_file.name)
                except:
                    pass

    def add_circle_markers_from_xy(
        self,
        data: Union[str, Any],
        x: str = "longitude",
        y: str = "latitude",
        radius: Union[float, str] = 10,
        color: Union[str, List] = "#3388ff",
        stroke_color: str = "#000000",
        stroke_width: float = 1,
        layer_name: str = "Circle Markers",
        crs: str = "EPSG:4326",
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Add circle markers from CSV or DataFrame point data.

        Args:
            data: Path to CSV file or pandas DataFrame.
            x: Column name for x-coordinate (longitude). Defaults to "longitude".
            y: Column name for y-coordinate (latitude). Defaults to "latitude".
            radius: Circle radius in pixels (fixed) or column name for data-defined sizing.
            color: Fill color as hex string, RGB tuple, or list of colors.
            stroke_color: Stroke color as hex string or RGB tuple.
            stroke_width: Stroke width in pixels.
            layer_name: Name for the layer. Defaults to "Circle Markers".
            crs: Coordinate reference system. Defaults to "EPSG:4326".
            zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
            **kwargs: Additional keyword arguments.

        Returns:
            The created vector layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> m.add_circle_markers_from_xy(
            ...     "cities.csv",
            ...     x="lon",
            ...     y="lat",
            ...     radius=15,
            ...     color="#ff0000"
            ... )
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        try:
            from . import common

            # Parse point data
            df, attr_columns = common.parse_point_data(data, x, y)

            # Create memory layer
            layer = QgsVectorLayer(f"Point?crs={crs}", layer_name, "memory")
            provider = layer.dataProvider()

            # Add attribute fields
            fields = QgsFields()
            for col in attr_columns:
                fields.append(QgsField(col, 10))  # QVariant::String = 10

            provider.addAttributes(fields.toList())
            layer.updateFields()

            # Add features
            features = []
            for idx, row in df.iterrows():
                feat = QgsFeature(layer.fields())
                point = QgsPointXY(float(row[x]), float(row[y]))
                feat.setGeometry(QgsGeometry.fromPointXY(point))

                # Set attributes
                for col in attr_columns:
                    feat.setAttribute(col, row[col])

                features.append(feat)

            provider.addFeatures(features)
            layer.updateExtents()

            # Apply circle marker styling
            symbol = QgsMarkerSymbol.createSimple(
                {
                    "name": "circle",
                    "color": (
                        color if isinstance(color, str) else ",".join(map(str, color))
                    ),
                    "outline_color": stroke_color,
                    "outline_width": str(stroke_width),
                    "size": str(radius) if isinstance(radius, (int, float)) else "10",
                }
            )

            # Handle data-defined sizing if radius is a column name
            if isinstance(radius, str) and radius in attr_columns:
                symbol.setDataDefinedProperty(
                    QgsSymbol.PropertySize, QgsProperty.fromField(radius)
                )

            renderer = QgsSingleSymbolRenderer(symbol)
            layer.setRenderer(renderer)

            # Add to project
            self.project.addMapLayer(layer, False)
            root = self.project.layerTreeRoot()
            root.addLayer(layer)

            # Track layer
            self._layers[layer_name] = layer

            # Zoom if requested
            if zoom_to_layer:
                self.zoom_to_layer(layer_name)

            # Refresh canvas
            if self.canvas:
                self.canvas.refresh()

            return layer

        except ImportError as e:
            print(f"Missing dependency: {e}")
            return None
        except Exception as e:
            print(f"Error adding circle markers: {e}")
            return None

    def add_points_from_xy(
        self,
        data: Union[str, Any],
        x: str = "longitude",
        y: str = "latitude",
        layer_name: str = "Points",
        crs: str = "EPSG:4326",
        style: Optional[Dict] = None,
        color_column: Optional[str] = None,
        size_column: Optional[str] = None,
        popup_fields: Optional[List[str]] = None,
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Enhanced CSV point loading with data-driven styling and popups.

        Args:
            data: Path to CSV file or pandas DataFrame.
            x: Column name for x-coordinate (longitude). Defaults to "longitude".
            y: Column name for y-coordinate (latitude). Defaults to "latitude".
            layer_name: Name for the layer. Defaults to "Points".
            crs: Coordinate reference system. Defaults to "EPSG:4326".
            style: Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.
            color_column: Column name for color-based categorization.
            size_column: Column name for size-based scaling.
            popup_fields: List of field names to include in popup. If None, includes all fields.
            zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
            **kwargs: Additional keyword arguments.

        Returns:
            The created vector layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> m.add_points_from_xy(
            ...     "cities.csv",
            ...     x="lon",
            ...     y="lat",
            ...     color_column="category",
            ...     size_column="population",
            ...     popup_fields=["name", "population"]
            ... )
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        try:
            from . import common

            # Parse point data
            df, attr_columns = common.parse_point_data(data, x, y)

            # Create memory layer
            layer = QgsVectorLayer(f"Point?crs={crs}", layer_name, "memory")
            provider = layer.dataProvider()

            # Add attribute fields with proper types
            fields = QgsFields()
            for col in attr_columns:
                # Infer field type from data
                dtype = df[col].dtype
                if dtype in ["int64", "int32"]:
                    fields.append(QgsField(col, 2))  # QVariant::Int
                elif dtype in ["float64", "float32"]:
                    fields.append(QgsField(col, 6))  # QVariant::Double
                else:
                    fields.append(QgsField(col, 10))  # QVariant::String

            provider.addAttributes(fields.toList())
            layer.updateFields()

            # Add features
            features = []
            for idx, row in df.iterrows():
                feat = QgsFeature(layer.fields())
                point = QgsPointXY(float(row[x]), float(row[y]))
                feat.setGeometry(QgsGeometry.fromPointXY(point))

                # Set attributes
                for col in attr_columns:
                    feat.setAttribute(col, row[col])

                features.append(feat)

            provider.addFeatures(features)
            layer.updateExtents()

            # Apply styling
            if color_column and color_column in attr_columns:
                # Use categorized renderer for color column
                unique_values = df[color_column].unique()
                categories = []
                colors = [
                    "#e41a1c",
                    "#377eb8",
                    "#4daf4a",
                    "#984ea3",
                    "#ff7f00",
                    "#ffff33",
                    "#a65628",
                    "#f781bf",
                ]

                for i, value in enumerate(unique_values):
                    color = colors[i % len(colors)]
                    symbol = QgsSymbol.defaultSymbol(0)  # Point = 0
                    symbol.setColor(self._parse_color(color))
                    category = QgsRendererCategory(value, symbol, str(value))
                    categories.append(category)

                renderer = QgsCategorizedSymbolRenderer(color_column, categories)
                layer.setRenderer(renderer)
            elif style:
                self._apply_vector_style(layer, style)

            # Handle data-defined sizing
            if size_column and size_column in attr_columns:
                symbol = layer.renderer().symbol()
                if symbol:
                    symbol.setDataDefinedProperty(
                        QgsSymbol.PropertySize, QgsProperty.fromField(size_column)
                    )

            # Set popup template
            if popup_fields:
                fields_to_show = [f for f in popup_fields if f in attr_columns]
            else:
                fields_to_show = attr_columns

            popup_html = "<html><body>"
            for field in fields_to_show:
                popup_html += f'<p><b>{field}:</b> [% "{field}" %]</p>'
            popup_html += "</body></html>"
            layer.setMapTipTemplate(popup_html)

            # Add to project
            self.project.addMapLayer(layer, False)
            root = self.project.layerTreeRoot()
            root.addLayer(layer)

            # Track layer
            self._layers[layer_name] = layer

            # Zoom if requested
            if zoom_to_layer:
                self.zoom_to_layer(layer_name)

            # Refresh canvas
            if self.canvas:
                self.canvas.refresh()

            return layer

        except ImportError as e:
            print(f"Missing dependency: {e}")
            return None
        except Exception as e:
            print(f"Error adding points from XY: {e}")
            return None

    def add_heatmap(
        self,
        data: Union[str, Any],
        latitude: str = "latitude",
        longitude: str = "longitude",
        value: Optional[str] = None,
        layer_name: str = "Heatmap",
        radius: int = 20,
        weight_field: Optional[str] = None,
        max_value: Optional[float] = None,
        color_ramp: str = "YlOrRd",
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Create a heatmap visualization from point data.

        Args:
            data: Path to CSV file or pandas DataFrame containing point data.
            latitude: Column name for latitude values. Defaults to "latitude".
            longitude: Column name for longitude values. Defaults to "longitude".
            value: Optional column name for weighting values.
            layer_name: Name for the layer. Defaults to "Heatmap".
            radius: Heatmap radius in pixels. Defaults to 20.
            weight_field: Field to use for weighting (overrides value). Defaults to None.
            max_value: Maximum value for scaling. If None, uses data maximum.
            color_ramp: Color ramp name. Defaults to "YlOrRd".
            zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
            **kwargs: Additional keyword arguments.

        Returns:
            The created heatmap layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> layer = m.add_heatmap(
            ...     "cities.csv",
            ...     latitude="lat",
            ...     longitude="lon",
            ...     value="population",
            ...     radius=30,
            ...     color_ramp="Reds"
            ... )
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        try:
            from . import common

            # Parse point data
            df, attr_columns = common.parse_point_data(data, longitude, latitude)

            # Create memory layer
            layer = QgsVectorLayer(f"Point?crs=EPSG:4326", layer_name, "memory")
            provider = layer.dataProvider()

            # Add attribute fields
            fields = QgsFields()
            for col in attr_columns:
                # Infer field type
                dtype = df[col].dtype
                if dtype in ["int64", "int32", "float64", "float32"]:
                    fields.append(QgsField(col, 6))  # QVariant::Double
                else:
                    fields.append(QgsField(col, 10))  # QVariant::String

            provider.addAttributes(fields.toList())
            layer.updateFields()

            # Add features
            features = []
            for idx, row in df.iterrows():
                feat = QgsFeature(layer.fields())
                point = QgsPointXY(float(row[longitude]), float(row[latitude]))
                feat.setGeometry(QgsGeometry.fromPointXY(point))

                # Set attributes
                for col in attr_columns:
                    feat.setAttribute(col, row[col])

                features.append(feat)

            provider.addFeatures(features)
            layer.updateExtents()

            # Apply heatmap renderer
            heatmap_renderer = QgsHeatmapRenderer()
            heatmap_renderer.setRadius(radius)

            # Set weight field if specified
            if weight_field and weight_field in attr_columns:
                heatmap_renderer.setWeightExpression(f'"{weight_field}"')
            elif value and value in attr_columns:
                heatmap_renderer.setWeightExpression(f'"{value}"')

            # Set maximum value
            if max_value:
                heatmap_renderer.setMaximumValue(max_value)

            # Set color ramp
            try:
                ramp = common.get_color_ramp(color_ramp)
                heatmap_renderer.setColorRamp(ramp)
            except Exception as e:
                print(f"Could not set color ramp: {e}")

            layer.setRenderer(heatmap_renderer)

            # Add to project
            self.project.addMapLayer(layer, False)
            root = self.project.layerTreeRoot()
            root.addLayer(layer)

            # Track layer
            self._layers[layer_name] = layer

            # Zoom if requested
            if zoom_to_layer:
                self.zoom_to_layer(layer_name)

            # Refresh canvas
            if self.canvas:
                self.canvas.refresh()

            return layer

        except ImportError as e:
            print(f"Missing dependency: {e}")
            return None
        except Exception as e:
            print(f"Error adding heatmap: {e}")
            return None

    def add_styled_vector(
        self,
        source: Union[str, Any],
        layer_name: Optional[str] = None,
        column: Optional[str] = None,
        scheme: str = "Quantiles",
        k: int = 5,
        color_ramp: str = "Spectral",
        legend: bool = True,
        legend_title: Optional[str] = None,
        zoom_to_layer: bool = False,
        **kwargs,
    ) -> Optional["QgsVectorLayer"]:
        """Add vector layer with automatic data-driven classification and legend.

        Args:
            source: File path or GeoDataFrame.
            layer_name: Name for the layer. If None, auto-generated.
            column: Column name for classification. If None, uses simple styling.
            scheme: Classification scheme: "Quantiles", "EqualInterval", "NaturalBreaks", "StandardDeviation".
            k: Number of classes. Defaults to 5.
            color_ramp: Color ramp name. Defaults to "Spectral".
            legend: Whether to auto-generate legend. Defaults to True.
            legend_title: Title for legend. If None, uses column name.
            zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
            **kwargs: Additional keyword arguments.

        Returns:
            The created vector layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> m.add_styled_vector(
            ...     "states.shp",
            ...     column="population",
            ...     scheme="Quantiles",
            ...     k=5,
            ...     color_ramp="YlOrRd",
            ...     legend=True
            ... )
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        try:
            # Load vector layer
            if hasattr(source, "to_file"):  # GeoDataFrame
                layer = self.add_gdf(
                    source, layer_name=layer_name, zoom_to_layer=False, **kwargs
                )
            else:
                layer = self.add_vector(
                    source, layer_name=layer_name, zoom_to_layer=False, **kwargs
                )

            if layer is None or column is None:
                return layer

            # Get classification method
            classification_methods = {
                "Quantiles": QgsClassificationQuantile,
                "EqualInterval": QgsClassificationEqualInterval,
                "NaturalBreaks": QgsClassificationJenks,
                "Jenks": QgsClassificationJenks,
                "StandardDeviation": QgsClassificationStandardDeviation,
            }

            classification_class = classification_methods.get(
                scheme, QgsClassificationQuantile
            )
            classification_method = classification_class()

            # Get color ramp
            from . import common

            ramp = common.get_color_ramp(color_ramp, n_colors=k)

            # Create graduated renderer
            renderer = QgsGraduatedSymbolRenderer()
            renderer.setClassAttribute(column)
            renderer.setSourceColorRamp(ramp)

            # Calculate classes
            renderer.updateClasses(layer, classification_method, k)

            # Apply renderer
            layer.setRenderer(renderer)
            layer.triggerRepaint()

            # Generate legend if requested
            if legend:
                legend_dict = {}
                for i, range_obj in enumerate(renderer.ranges()):
                    label = range_obj.label()
                    color = range_obj.symbol().color()
                    # Convert QColor to hex
                    hex_color = (
                        f"#{color.red():02x}{color.green():02x}{color.blue():02x}"
                    )
                    legend_dict[label] = hex_color

                title = legend_title if legend_title else column
                self.add_legend(title=title, legend_dict=legend_dict)

            # Zoom if requested
            if zoom_to_layer:
                self.zoom_to_layer(layer.name() if layer_name is None else layer_name)

            # Refresh canvas
            if self.canvas:
                self.canvas.refresh()

            return layer

        except Exception as e:
            print(f"Error adding styled vector: {e}")
            return None

    def add_stac_layer(
        self,
        url: Optional[str] = None,
        item: Optional[Dict] = None,
        assets: Optional[Union[str, List[str]]] = None,
        layer_name: Optional[str] = None,
        zoom_to_layer: bool = True,
        **kwargs,
    ) -> Optional["QgsRasterLayer"]:
        """Load STAC item as COG layer.

        Args:
            url: URL to STAC item JSON.
            item: STAC item dictionary (if not providing URL).
            assets: Asset name(s) to load. If None, uses first visual/data asset.
            layer_name: Name for the layer. If None, uses item ID.
            zoom_to_layer: Whether to zoom to layer extent. Defaults to True.
            **kwargs: Additional keyword arguments passed to add_cog.

        Returns:
            The created raster layer, or None if creation failed.

        Example:
            >>> m = Map()
            >>> m.add_stac_layer(
            ...     url="https://example.com/item.json",
            ...     assets="visual"
            ... )
        """
        if not HAS_QGIS:
            print("QGIS libraries not available")
            return None

        try:
            # Fetch STAC item if URL provided
            if url and not item:
                if not HAS_REQUESTS:
                    print("requests library required for fetching STAC items from URL")
                    return None

                response = requests.get(url)
                response.raise_for_status()
                item = response.json()

            if not item:
                print("No STAC item provided (url or item required)")
                return None

            # Extract assets
            item_assets = item.get("assets", {})
            if not item_assets:
                print("No assets found in STAC item")
                return None

            # Select asset(s) to load
            if assets:
                if isinstance(assets, str):
                    asset_names = [assets]
                else:
                    asset_names = assets
            else:
                # Auto-select first visual or data asset
                visual_assets = ["visual", "rendered_preview", "thumbnail", "overview"]
                data_assets = ["data", "cog", "image"]

                asset_names = []
                for name in visual_assets + data_assets:
                    if name in item_assets:
                        asset_names = [name]
                        break

                if not asset_names:
                    # Just use first asset
                    asset_names = [list(item_assets.keys())[0]]

            # Load first asset as COG
            asset_name = asset_names[0]
            if asset_name not in item_assets:
                print(
                    f"Asset '{asset_name}' not found. Available: {list(item_assets.keys())}"
                )
                return None

            asset = item_assets[asset_name]
            cog_url = asset.get("href")

            if not cog_url:
                print(f"No href found for asset '{asset_name}'")
                return None

            # Generate layer name
            if layer_name is None:
                layer_name = f"{item.get('id', 'STAC')}_{asset_name}"

            # Load as COG
            layer = self.add_cog(
                cog_url, layer_name=layer_name, zoom_to_layer=zoom_to_layer, **kwargs
            )

            return layer

        except ImportError as e:
            print(f"Missing dependency: {e}")
            return None
        except Exception as e:
            print(f"Error adding STAC layer: {e}")
            return None

    def __repr__(self) -> str:
        """Return a string representation of the Map.

        Returns:
            A string representation.
        """
        n_layers = len(self.project.mapLayers())
        return f"Map(layers={n_layers}, crs={self.project.crs().authid()})"

layers: Dict[str, Any] property readonly

Get all layers added to the map.

Returns:

Type Description
Dict[str, Any]

A dictionary mapping layer names to layer objects.

__init__(self, center=None, zoom=None, crs='EPSG:3857', basemap='OpenStreetMap', **kwargs) special

Initialize a Map instance.

Parameters:

Name Type Description Default
center Optional[Tuple[float, float]]

The initial center of the map as (latitude, longitude). Defaults to (0, 0).

None
zoom Optional[int]

The initial zoom level. Defaults to 2.

None
crs str

The coordinate reference system. Defaults to "EPSG:3857".

'EPSG:3857'
basemap Optional[str]

The initial basemap to add. Defaults to "OpenStreetMap". Set to None to skip adding a basemap.

'OpenStreetMap'
**kwargs

Additional keyword arguments.

{}
Source code in qgis_map/qgis_map.py
def __init__(
    self,
    center: Optional[Tuple[float, float]] = None,
    zoom: Optional[int] = None,
    crs: str = "EPSG:3857",
    basemap: Optional[str] = "OpenStreetMap",
    **kwargs,
):
    """Initialize a Map instance.

    Args:
        center: The initial center of the map as (latitude, longitude).
            Defaults to (0, 0).
        zoom: The initial zoom level. Defaults to 2.
        crs: The coordinate reference system. Defaults to "EPSG:3857".
        basemap: The initial basemap to add. Defaults to "OpenStreetMap".
            Set to None to skip adding a basemap.
        **kwargs: Additional keyword arguments.
    """
    if not HAS_QGIS:
        raise ImportError(
            "QGIS libraries are not available. "
            "Please run this code within QGIS or install PyQGIS."
        )

    self.project = QgsProject.instance()
    self._iface = iface
    self._layers: Dict[str, Any] = {}
    self._basemap_layers: List[str] = []
    self._crs = QgsCoordinateReferenceSystem(crs)
    self._center = center or (0, 0)
    self._zoom = zoom or 2
    self._time_slider_dock = None
    self._temporal_controller = None

    # Get the map canvas if running in QGIS
    if self._iface is not None:
        self.canvas = self._iface.mapCanvas()
    else:
        self.canvas = None

    # Set the project CRS
    self.project.setCrs(self._crs)

    # # Add initial basemap if specified
    # if basemap is not None:
    #     self.add_basemap(basemap)

    # Set initial center and zoom if canvas is available
    if self.canvas is not None and center is not None:
        self.set_center(center[0], center[1], zoom)

__repr__(self) special

Return a string representation of the Map.

Returns:

Type Description
str

A string representation.

Source code in qgis_map/qgis_map.py
def __repr__(self) -> str:
    """Return a string representation of the Map.

    Returns:
        A string representation.
    """
    n_layers = len(self.project.mapLayers())
    return f"Map(layers={n_layers}, crs={self.project.crs().authid()})"

add_basemap(self, basemap='OpenStreetMap', layer_name=None, visible=True, opacity=1.0, **kwargs)

Add a basemap to the map.

Parameters:

Name Type Description Default
basemap str

The name of the basemap or an XYZ tile URL. Available basemaps include: OpenStreetMap, CartoDB.Positron, CartoDB.DarkMatter, Esri.WorldImagery, SATELLITE, HYBRID, etc. Use get_basemap_names() to see all available basemaps.

'OpenStreetMap'
layer_name Optional[str]

The name for the layer. Defaults to the basemap name.

None
visible bool

Whether the layer should be visible. Defaults to True.

True
opacity float

The layer opacity (0-1). Defaults to 1.0.

1.0
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsRasterLayer']

The created raster layer, or None if failed.

Examples:

>>> m = Map()
>>> m.add_basemap("OpenStreetMap")
>>> m.add_basemap("Esri.WorldImagery", layer_name="Satellite")
>>> m.add_basemap(
...     "https://custom.tiles.com/{z}/{x}/{y}.png",
...     layer_name="Custom Tiles"
... )
Source code in qgis_map/qgis_map.py
def add_basemap(
    self,
    basemap: str = "OpenStreetMap",
    layer_name: Optional[str] = None,
    visible: bool = True,
    opacity: float = 1.0,
    **kwargs,
) -> Optional["QgsRasterLayer"]:
    """Add a basemap to the map.

    Args:
        basemap: The name of the basemap or an XYZ tile URL.
            Available basemaps include: OpenStreetMap, CartoDB.Positron,
            CartoDB.DarkMatter, Esri.WorldImagery, SATELLITE, HYBRID, etc.
            Use get_basemap_names() to see all available basemaps.
        layer_name: The name for the layer. Defaults to the basemap name.
        visible: Whether the layer should be visible. Defaults to True.
        opacity: The layer opacity (0-1). Defaults to 1.0.
        **kwargs: Additional keyword arguments.

    Returns:
        The created raster layer, or None if failed.

    Example:
        >>> m = Map()
        >>> m.add_basemap("OpenStreetMap")
        >>> m.add_basemap("Esri.WorldImagery", layer_name="Satellite")
        >>> m.add_basemap(
        ...     "https://custom.tiles.com/{z}/{x}/{y}.png",
        ...     layer_name="Custom Tiles"
        ... )
    """
    # Determine the URL
    if basemap in BASEMAPS:
        url = BASEMAPS[basemap]["url"]
        name = layer_name or BASEMAPS[basemap]["name"]
    elif basemap.startswith("http"):
        url = basemap
        name = layer_name or "Custom Basemap"
    else:
        # Try case-insensitive match
        try:
            url = get_basemap_url(basemap)
            name = layer_name or basemap
        except ValueError as e:
            print(str(e))
            return None

    # Create the XYZ layer URI
    uri = get_xyz_uri(url)

    # Create and add the layer
    layer = QgsRasterLayer(uri, name, "wms")

    if not layer.isValid():
        print(f"Failed to load basemap: {name}")
        return None

    # Set opacity
    layer.renderer().setOpacity(opacity)

    # Add to project
    self.project.addMapLayer(layer, False)

    # Add to layer tree at the bottom (below other layers)
    root = self.project.layerTreeRoot()
    root.insertLayer(-1, layer)

    # Set visibility
    layer_node = root.findLayer(layer.id())
    if layer_node:
        layer_node.setItemVisibilityChecked(visible)

    # Track the layer
    self._layers[name] = layer
    self._basemap_layers.append(name)

    # Refresh canvas
    if self.canvas is not None:
        self.canvas.refresh()

    return layer

add_circle_markers_from_xy(self, data, x='longitude', y='latitude', radius=10, color='#3388ff', stroke_color='#000000', stroke_width=1, layer_name='Circle Markers', crs='EPSG:4326', zoom_to_layer=False, **kwargs)

Add circle markers from CSV or DataFrame point data.

Parameters:

Name Type Description Default
data Union[str, Any]

Path to CSV file or pandas DataFrame.

required
x str

Column name for x-coordinate (longitude). Defaults to "longitude".

'longitude'
y str

Column name for y-coordinate (latitude). Defaults to "latitude".

'latitude'
radius Union[float, str]

Circle radius in pixels (fixed) or column name for data-defined sizing.

10
color Union[str, List]

Fill color as hex string, RGB tuple, or list of colors.

'#3388ff'
stroke_color str

Stroke color as hex string or RGB tuple.

'#000000'
stroke_width float

Stroke width in pixels.

1
layer_name str

Name for the layer. Defaults to "Circle Markers".

'Circle Markers'
crs str

Coordinate reference system. Defaults to "EPSG:4326".

'EPSG:4326'
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if creation failed.

Examples:

>>> m = Map()
>>> m.add_circle_markers_from_xy(
...     "cities.csv",
...     x="lon",
...     y="lat",
...     radius=15,
...     color="#ff0000"
... )
Source code in qgis_map/qgis_map.py
def add_circle_markers_from_xy(
    self,
    data: Union[str, Any],
    x: str = "longitude",
    y: str = "latitude",
    radius: Union[float, str] = 10,
    color: Union[str, List] = "#3388ff",
    stroke_color: str = "#000000",
    stroke_width: float = 1,
    layer_name: str = "Circle Markers",
    crs: str = "EPSG:4326",
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Add circle markers from CSV or DataFrame point data.

    Args:
        data: Path to CSV file or pandas DataFrame.
        x: Column name for x-coordinate (longitude). Defaults to "longitude".
        y: Column name for y-coordinate (latitude). Defaults to "latitude".
        radius: Circle radius in pixels (fixed) or column name for data-defined sizing.
        color: Fill color as hex string, RGB tuple, or list of colors.
        stroke_color: Stroke color as hex string or RGB tuple.
        stroke_width: Stroke width in pixels.
        layer_name: Name for the layer. Defaults to "Circle Markers".
        crs: Coordinate reference system. Defaults to "EPSG:4326".
        zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
        **kwargs: Additional keyword arguments.

    Returns:
        The created vector layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> m.add_circle_markers_from_xy(
        ...     "cities.csv",
        ...     x="lon",
        ...     y="lat",
        ...     radius=15,
        ...     color="#ff0000"
        ... )
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    try:
        from . import common

        # Parse point data
        df, attr_columns = common.parse_point_data(data, x, y)

        # Create memory layer
        layer = QgsVectorLayer(f"Point?crs={crs}", layer_name, "memory")
        provider = layer.dataProvider()

        # Add attribute fields
        fields = QgsFields()
        for col in attr_columns:
            fields.append(QgsField(col, 10))  # QVariant::String = 10

        provider.addAttributes(fields.toList())
        layer.updateFields()

        # Add features
        features = []
        for idx, row in df.iterrows():
            feat = QgsFeature(layer.fields())
            point = QgsPointXY(float(row[x]), float(row[y]))
            feat.setGeometry(QgsGeometry.fromPointXY(point))

            # Set attributes
            for col in attr_columns:
                feat.setAttribute(col, row[col])

            features.append(feat)

        provider.addFeatures(features)
        layer.updateExtents()

        # Apply circle marker styling
        symbol = QgsMarkerSymbol.createSimple(
            {
                "name": "circle",
                "color": (
                    color if isinstance(color, str) else ",".join(map(str, color))
                ),
                "outline_color": stroke_color,
                "outline_width": str(stroke_width),
                "size": str(radius) if isinstance(radius, (int, float)) else "10",
            }
        )

        # Handle data-defined sizing if radius is a column name
        if isinstance(radius, str) and radius in attr_columns:
            symbol.setDataDefinedProperty(
                QgsSymbol.PropertySize, QgsProperty.fromField(radius)
            )

        renderer = QgsSingleSymbolRenderer(symbol)
        layer.setRenderer(renderer)

        # Add to project
        self.project.addMapLayer(layer, False)
        root = self.project.layerTreeRoot()
        root.addLayer(layer)

        # Track layer
        self._layers[layer_name] = layer

        # Zoom if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer_name)

        # Refresh canvas
        if self.canvas:
            self.canvas.refresh()

        return layer

    except ImportError as e:
        print(f"Missing dependency: {e}")
        return None
    except Exception as e:
        print(f"Error adding circle markers: {e}")
        return None

add_cog(self, url, layer_name=None, zoom_to_layer=False, **kwargs)

Add a Cloud Optimized GeoTIFF (COG) to the map.

Parameters:

Name Type Description Default
url str

The URL to the COG file.

required
layer_name Optional[str]

The name for the layer. Defaults to "COG".

None
zoom_to_layer bool

Whether to zoom to the layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments passed to add_raster.

{}

Returns:

Type Description
Optional['QgsRasterLayer']

The created raster layer, or None if failed.

Source code in qgis_map/qgis_map.py
def add_cog(
    self,
    url: str,
    layer_name: Optional[str] = None,
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsRasterLayer"]:
    """Add a Cloud Optimized GeoTIFF (COG) to the map.

    Args:
        url: The URL to the COG file.
        layer_name: The name for the layer. Defaults to "COG".
        zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
        **kwargs: Additional keyword arguments passed to add_raster.

    Returns:
        The created raster layer, or None if failed.
    """
    if layer_name is None:
        layer_name = "COG"

    # Use GDAL's vsicurl for remote access
    vsicurl_url = f"/vsicurl/{url}"

    return self.add_raster(
        vsicurl_url,
        layer_name=layer_name,
        zoom_to_layer=zoom_to_layer,
        **kwargs,
    )

add_colorbar(self, colors, vmin=0, vmax=1.0, index=None, caption='', categorical=False, step=None, width=300, height=60, position='bottomright', **kwargs)

Add a colorbar to the map.

Parameters:

Name Type Description Default
colors List[Union[str, Tuple]]

The set of colors for interpolation. Can be: - List of hex strings (e.g., ["#ff0000", "#00ff00", "#0000ff"]) - List of RGB tuples (e.g., [(255, 0, 0), (0, 255, 0), (0, 0, 255)]) - List of color names (e.g., ["red", "green", "blue"])

required
vmin float

The minimum value for the colormap. Defaults to 0.

0
vmax float

The maximum value for the colormap. Defaults to 1.0.

1.0
index Optional[List[float]]

The values corresponding to each color. Must be sorted and same length as colors. If None, a regular grid between vmin and vmax is created.

None
caption str

The caption/title for the colorbar. Defaults to "".

''
categorical bool

Whether to create a categorical (discrete) colorbar. Defaults to False.

False
step Optional[int]

The number of steps for categorical colorbar. Defaults to None.

None
width int

The width of the colorbar widget in pixels. Defaults to 300.

300
height int

The height of the colorbar widget in pixels. Defaults to 60.

60
position str

Position of the colorbar ("topleft", "topright", "bottomleft", "bottomright"). Defaults to "bottomright".

'bottomright'
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QDockWidget']

The created QDockWidget, or None if failed.

Examples:

>>> m = Map()
>>> m.add_colorbar(
...     colors=["blue", "cyan", "yellow", "red"],
...     vmin=0,
...     vmax=100,
...     caption="Temperature (°C)"
... )
>>> # Categorical colorbar
>>> m.add_colorbar(
...     colors=["#00ff00", "#ffff00", "#ff0000"],
...     vmin=0,
...     vmax=1,
...     categorical=True,
...     caption="Risk Level"
... )
Source code in qgis_map/qgis_map.py
def add_colorbar(
    self,
    colors: List[Union[str, Tuple]],
    vmin: float = 0,
    vmax: float = 1.0,
    index: Optional[List[float]] = None,
    caption: str = "",
    categorical: bool = False,
    step: Optional[int] = None,
    width: int = 300,
    height: int = 60,
    position: str = "bottomright",
    **kwargs,
) -> Optional["QDockWidget"]:
    """Add a colorbar to the map.

    Args:
        colors: The set of colors for interpolation. Can be:
            - List of hex strings (e.g., ["#ff0000", "#00ff00", "#0000ff"])
            - List of RGB tuples (e.g., [(255, 0, 0), (0, 255, 0), (0, 0, 255)])
            - List of color names (e.g., ["red", "green", "blue"])
        vmin: The minimum value for the colormap. Defaults to 0.
        vmax: The maximum value for the colormap. Defaults to 1.0.
        index: The values corresponding to each color. Must be sorted and same length as colors.
            If None, a regular grid between vmin and vmax is created.
        caption: The caption/title for the colorbar. Defaults to "".
        categorical: Whether to create a categorical (discrete) colorbar. Defaults to False.
        step: The number of steps for categorical colorbar. Defaults to None.
        width: The width of the colorbar widget in pixels. Defaults to 300.
        height: The height of the colorbar widget in pixels. Defaults to 60.
        position: Position of the colorbar ("topleft", "topright", "bottomleft", "bottomright").
            Defaults to "bottomright".
        **kwargs: Additional keyword arguments.

    Returns:
        The created QDockWidget, or None if failed.

    Example:
        >>> m = Map()
        >>> m.add_colorbar(
        ...     colors=["blue", "cyan", "yellow", "red"],
        ...     vmin=0,
        ...     vmax=100,
        ...     caption="Temperature (°C)"
        ... )
        >>> # Categorical colorbar
        >>> m.add_colorbar(
        ...     colors=["#00ff00", "#ffff00", "#ff0000"],
        ...     vmin=0,
        ...     vmax=1,
        ...     categorical=True,
        ...     caption="Risk Level"
        ... )
    """
    if not HAS_PYQT:
        print("PyQt5 is required for colorbar functionality.")
        return None

    if self._iface is None:
        print("Colorbar requires running within QGIS.")
        return None

    from PyQt5.QtWidgets import QScrollArea
    from PyQt5.QtGui import QPainter, QLinearGradient
    from PyQt5.QtCore import QRectF

    # Convert colors to QColor
    qcolors = []
    for color in colors:
        if isinstance(color, tuple):
            if len(color) == 3:
                qcolors.append(QColor(int(color[0]), int(color[1]), int(color[2])))
            elif len(color) == 4:
                qcolors.append(
                    QColor(
                        int(color[0]),
                        int(color[1]),
                        int(color[2]),
                        int(color[3] * 255) if color[3] <= 1 else int(color[3]),
                    )
                )
        elif isinstance(color, str):
            if len(color) == 6 and not color.startswith("#"):
                qcolors.append(QColor("#" + color))
            else:
                qcolors.append(QColor(color))
        else:
            print(f"Invalid color type: {type(color)}")
            return None

    # Create index if not provided
    if index is None:
        index = [
            vmin + (vmax - vmin) * i / (len(qcolors) - 1)
            for i in range(len(qcolors))
        ]
    elif len(index) != len(qcolors):
        print("The index and colors must be the same length.")
        return None

    # Create the dock widget
    title = caption if caption else "Colorbar"
    dock = QDockWidget(title, self._iface.mainWindow())
    dock.setObjectName(f"{title}ColorbarDock")

    # Create the main widget
    main_widget = QWidget()
    layout = QVBoxLayout()
    layout.setContentsMargins(10, 10, 10, 10)
    layout.setSpacing(5)

    # Create colorbar as a pixmap and display in QLabel
    from PyQt5.QtGui import QPixmap, QImage
    from PyQt5.QtCore import Qt as QtCore

    # Create the colorbar image
    bar_height = 30
    pixmap = QPixmap(width, bar_height)
    pixmap.fill(QtCore.transparent)

    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.Antialiasing)

    if categorical and step and step > 1:
        # Draw discrete color segments
        segment_width = width / step
        for i in range(step):
            if step > 1:
                color_idx = int(i * (len(qcolors) - 1) / (step - 1))
            else:
                color_idx = 0
            color_idx = min(color_idx, len(qcolors) - 1)
            painter.fillRect(
                int(i * segment_width),
                0,
                int(segment_width + 1),
                bar_height,
                qcolors[color_idx],
            )
    else:
        # Draw continuous gradient
        gradient = QLinearGradient(0, 0, width, 0)
        if len(qcolors) > 1:
            for i, color in enumerate(qcolors):
                pos = i / (len(qcolors) - 1)
                gradient.setColorAt(pos, color)
        else:
            gradient.setColorAt(0, qcolors[0])
            gradient.setColorAt(1, qcolors[0])
        painter.fillRect(0, 0, width, bar_height, gradient)

    # Draw border
    painter.setPen(QColor("#000000"))
    painter.drawRect(0, 0, width - 1, bar_height - 1)
    painter.end()

    # Create label with the colorbar pixmap
    colorbar_label = QLabel()
    colorbar_label.setPixmap(pixmap)
    colorbar_label.setMinimumSize(width, bar_height)
    colorbar_label.setMaximumSize(width, bar_height)
    layout.addWidget(colorbar_label)

    # Add value labels
    labels_layout = QHBoxLayout()
    vmin_label = QLabel(f"{vmin:.2f}")
    vmax_label = QLabel(f"{vmax:.2f}")
    labels_layout.addWidget(vmin_label)
    labels_layout.addStretch()
    labels_layout.addWidget(vmax_label)
    layout.addLayout(labels_layout)

    main_widget.setLayout(layout)
    dock.setWidget(main_widget)

    # Set size constraints instead of fixed size
    dock.setMinimumWidth(width + 40)
    dock.setMaximumWidth(width + 60)
    dock.setMinimumHeight(height + 80)
    dock.setMaximumHeight(height + 100)

    # Determine dock area
    dock_areas = {
        "topleft": Qt.LeftDockWidgetArea,
        "topright": Qt.RightDockWidgetArea,
        "bottomleft": Qt.LeftDockWidgetArea,
        "bottomright": Qt.RightDockWidgetArea,
    }
    area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

    self._iface.addDockWidget(area, dock)

    return dock

add_gdf(self, gdf, layer_name='GeoDataFrame', style=None, zoom_to_layer=False, visible=True, **kwargs)

Add a GeoDataFrame to the map.

Parameters:

Name Type Description Default
gdf Any

A GeoPandas GeoDataFrame.

required
layer_name str

The name for the layer. Defaults to "GeoDataFrame".

'GeoDataFrame'
style Optional[Dict]

A dictionary specifying the style (see add_vector).

None
zoom_to_layer bool

Whether to zoom to the layer extent. Defaults to False.

False
visible bool

Whether the layer should be visible. Defaults to True.

True
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if failed.

Source code in qgis_map/qgis_map.py
def add_gdf(
    self,
    gdf: Any,
    layer_name: str = "GeoDataFrame",
    style: Optional[Dict] = None,
    zoom_to_layer: bool = False,
    visible: bool = True,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Add a GeoDataFrame to the map.

    Args:
        gdf: A GeoPandas GeoDataFrame.
        layer_name: The name for the layer. Defaults to "GeoDataFrame".
        style: A dictionary specifying the style (see add_vector).
        zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
        visible: Whether the layer should be visible. Defaults to True.
        **kwargs: Additional keyword arguments.

    Returns:
        The created vector layer, or None if failed.
    """
    try:
        import geopandas as gpd
    except ImportError:
        print(
            "GeoPandas is required to add GeoDataFrames. Install with: pip install geopandas"
        )
        return None

    # Create a temporary file to store the GeoDataFrame
    import tempfile

    with tempfile.NamedTemporaryFile(suffix=".geojson", delete=False) as tmp:
        gdf.to_file(tmp.name, driver="GeoJSON")
        return self.add_vector(
            tmp.name,
            layer_name=layer_name,
            style=style,
            zoom_to_layer=zoom_to_layer,
            visible=visible,
            **kwargs,
        )

add_geojson(self, source, layer_name=None, style=None, zoom_to_layer=False, **kwargs)

Add a GeoJSON layer from file, URL, or Python dict.

Parameters:

Name Type Description Default
source Union[str, Dict]

GeoJSON file path, URL, or Python dictionary.

required
layer_name Optional[str]

Name for the layer. If None, auto-generated.

None
style Optional[Dict]

Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.

None
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments passed to add_vector.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if creation failed.

Examples:

>>> m = Map()
>>> # From file
>>> m.add_geojson("data.geojson")
>>> # From URL
>>> m.add_geojson("https://example.com/data.geojson")
>>> # From dict
>>> geojson_dict = {"type": "FeatureCollection", "features": [...]}
>>> m.add_geojson(geojson_dict, layer_name="custom")
Source code in qgis_map/qgis_map.py
def add_geojson(
    self,
    source: Union[str, Dict],
    layer_name: Optional[str] = None,
    style: Optional[Dict] = None,
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Add a GeoJSON layer from file, URL, or Python dict.

    Args:
        source: GeoJSON file path, URL, or Python dictionary.
        layer_name: Name for the layer. If None, auto-generated.
        style: Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.
        zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
        **kwargs: Additional keyword arguments passed to add_vector.

    Returns:
        The created vector layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> # From file
        >>> m.add_geojson("data.geojson")
        >>> # From URL
        >>> m.add_geojson("https://example.com/data.geojson")
        >>> # From dict
        >>> geojson_dict = {"type": "FeatureCollection", "features": [...]}
        >>> m.add_geojson(geojson_dict, layer_name="custom")
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    temp_file = None

    try:
        # Handle different source types
        if isinstance(source, dict):
            # Python dictionary - save to temporary file
            temp_file = tempfile.NamedTemporaryFile(
                mode="w", suffix=".geojson", delete=False
            )
            json.dump(source, temp_file)
            temp_file.close()
            file_path = temp_file.name

        elif source.startswith(("http://", "https://")):
            # URL - download the file
            from . import common

            file_path = common.download_file(source)

        else:
            # Assume it's a file path
            file_path = source

        # Use existing add_vector method
        layer = self.add_vector(
            file_path,
            layer_name=layer_name,
            style=style,
            zoom_to_layer=zoom_to_layer,
            **kwargs,
        )

        return layer

    except Exception as e:
        print(f"Error adding GeoJSON: {e}")
        return None

    finally:
        # Clean up temporary file if created
        if temp_file is not None:
            try:
                os.unlink(temp_file.name)
            except:
                pass

add_heatmap(self, data, latitude='latitude', longitude='longitude', value=None, layer_name='Heatmap', radius=20, weight_field=None, max_value=None, color_ramp='YlOrRd', zoom_to_layer=False, **kwargs)

Create a heatmap visualization from point data.

Parameters:

Name Type Description Default
data Union[str, Any]

Path to CSV file or pandas DataFrame containing point data.

required
latitude str

Column name for latitude values. Defaults to "latitude".

'latitude'
longitude str

Column name for longitude values. Defaults to "longitude".

'longitude'
value Optional[str]

Optional column name for weighting values.

None
layer_name str

Name for the layer. Defaults to "Heatmap".

'Heatmap'
radius int

Heatmap radius in pixels. Defaults to 20.

20
weight_field Optional[str]

Field to use for weighting (overrides value). Defaults to None.

None
max_value Optional[float]

Maximum value for scaling. If None, uses data maximum.

None
color_ramp str

Color ramp name. Defaults to "YlOrRd".

'YlOrRd'
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created heatmap layer, or None if creation failed.

Examples:

>>> m = Map()
>>> layer = m.add_heatmap(
...     "cities.csv",
...     latitude="lat",
...     longitude="lon",
...     value="population",
...     radius=30,
...     color_ramp="Reds"
... )
Source code in qgis_map/qgis_map.py
def add_heatmap(
    self,
    data: Union[str, Any],
    latitude: str = "latitude",
    longitude: str = "longitude",
    value: Optional[str] = None,
    layer_name: str = "Heatmap",
    radius: int = 20,
    weight_field: Optional[str] = None,
    max_value: Optional[float] = None,
    color_ramp: str = "YlOrRd",
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Create a heatmap visualization from point data.

    Args:
        data: Path to CSV file or pandas DataFrame containing point data.
        latitude: Column name for latitude values. Defaults to "latitude".
        longitude: Column name for longitude values. Defaults to "longitude".
        value: Optional column name for weighting values.
        layer_name: Name for the layer. Defaults to "Heatmap".
        radius: Heatmap radius in pixels. Defaults to 20.
        weight_field: Field to use for weighting (overrides value). Defaults to None.
        max_value: Maximum value for scaling. If None, uses data maximum.
        color_ramp: Color ramp name. Defaults to "YlOrRd".
        zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
        **kwargs: Additional keyword arguments.

    Returns:
        The created heatmap layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> layer = m.add_heatmap(
        ...     "cities.csv",
        ...     latitude="lat",
        ...     longitude="lon",
        ...     value="population",
        ...     radius=30,
        ...     color_ramp="Reds"
        ... )
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    try:
        from . import common

        # Parse point data
        df, attr_columns = common.parse_point_data(data, longitude, latitude)

        # Create memory layer
        layer = QgsVectorLayer(f"Point?crs=EPSG:4326", layer_name, "memory")
        provider = layer.dataProvider()

        # Add attribute fields
        fields = QgsFields()
        for col in attr_columns:
            # Infer field type
            dtype = df[col].dtype
            if dtype in ["int64", "int32", "float64", "float32"]:
                fields.append(QgsField(col, 6))  # QVariant::Double
            else:
                fields.append(QgsField(col, 10))  # QVariant::String

        provider.addAttributes(fields.toList())
        layer.updateFields()

        # Add features
        features = []
        for idx, row in df.iterrows():
            feat = QgsFeature(layer.fields())
            point = QgsPointXY(float(row[longitude]), float(row[latitude]))
            feat.setGeometry(QgsGeometry.fromPointXY(point))

            # Set attributes
            for col in attr_columns:
                feat.setAttribute(col, row[col])

            features.append(feat)

        provider.addFeatures(features)
        layer.updateExtents()

        # Apply heatmap renderer
        heatmap_renderer = QgsHeatmapRenderer()
        heatmap_renderer.setRadius(radius)

        # Set weight field if specified
        if weight_field and weight_field in attr_columns:
            heatmap_renderer.setWeightExpression(f'"{weight_field}"')
        elif value and value in attr_columns:
            heatmap_renderer.setWeightExpression(f'"{value}"')

        # Set maximum value
        if max_value:
            heatmap_renderer.setMaximumValue(max_value)

        # Set color ramp
        try:
            ramp = common.get_color_ramp(color_ramp)
            heatmap_renderer.setColorRamp(ramp)
        except Exception as e:
            print(f"Could not set color ramp: {e}")

        layer.setRenderer(heatmap_renderer)

        # Add to project
        self.project.addMapLayer(layer, False)
        root = self.project.layerTreeRoot()
        root.addLayer(layer)

        # Track layer
        self._layers[layer_name] = layer

        # Zoom if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer_name)

        # Refresh canvas
        if self.canvas:
            self.canvas.refresh()

        return layer

    except ImportError as e:
        print(f"Missing dependency: {e}")
        return None
    except Exception as e:
        print(f"Error adding heatmap: {e}")
        return None

add_layer_control(self)

Show the layer panel in QGIS.

This opens or focuses the Layers panel in QGIS.

Source code in qgis_map/qgis_map.py
def add_layer_control(self) -> None:
    """Show the layer panel in QGIS.

    This opens or focuses the Layers panel in QGIS.
    """
    if self._iface is not None:
        # The layer panel is usually already visible, but ensure it is
        layers_dock = self._iface.mainWindow().findChild(QDockWidget, "Layers")
        if layers_dock:
            layers_dock.show()
            layers_dock.raise_()

add_legend(self, title='Legend', legend_dict=None, labels=None, colors=None, position='bottomright', layer_name=None, **kwargs)

Add a legend to the map.

Parameters:

Name Type Description Default
title str

Title of the legend. Defaults to "Legend".

'Legend'
legend_dict Optional[Dict[str, str]]

A dictionary containing legend items as keys and colors as values. If provided, labels and colors will be ignored.

None
labels Optional[List[str]]

A list of legend labels.

None
colors Optional[List[Union[str, Tuple]]]

A list of legend colors (hex strings, RGB tuples, or color names).

None
position str

Position of the legend ("topleft", "topright", "bottomleft", "bottomright"). Defaults to "bottomright".

'bottomright'
layer_name Optional[str]

Layer name to associate the legend with. Defaults to None.

None
**kwargs

Additional keyword arguments (e.g., width, height).

{}

Returns:

Type Description
Optional['QDockWidget']

The created QDockWidget, or None if failed.

Examples:

>>> m = Map()
>>> m.add_legend(
...     title="Land Cover",
...     labels=["Forest", "Water", "Urban"],
...     colors=["#228B22", "#4169E1", "#DC143C"]
... )
>>> # Or using a dictionary
>>> m.add_legend(
...     legend_dict={"Forest": "#228B22", "Water": "#4169E1", "Urban": "#DC143C"}
... )
Source code in qgis_map/qgis_map.py
def add_legend(
    self,
    title: str = "Legend",
    legend_dict: Optional[Dict[str, str]] = None,
    labels: Optional[List[str]] = None,
    colors: Optional[List[Union[str, Tuple]]] = None,
    position: str = "bottomright",
    layer_name: Optional[str] = None,
    **kwargs,
) -> Optional["QDockWidget"]:
    """Add a legend to the map.

    Args:
        title: Title of the legend. Defaults to "Legend".
        legend_dict: A dictionary containing legend items as keys and colors as values.
            If provided, labels and colors will be ignored.
        labels: A list of legend labels.
        colors: A list of legend colors (hex strings, RGB tuples, or color names).
        position: Position of the legend ("topleft", "topright", "bottomleft", "bottomright").
            Defaults to "bottomright".
        layer_name: Layer name to associate the legend with. Defaults to None.
        **kwargs: Additional keyword arguments (e.g., width, height).

    Returns:
        The created QDockWidget, or None if failed.

    Example:
        >>> m = Map()
        >>> m.add_legend(
        ...     title="Land Cover",
        ...     labels=["Forest", "Water", "Urban"],
        ...     colors=["#228B22", "#4169E1", "#DC143C"]
        ... )
        >>> # Or using a dictionary
        >>> m.add_legend(
        ...     legend_dict={"Forest": "#228B22", "Water": "#4169E1", "Urban": "#DC143C"}
        ... )
    """
    if not HAS_PYQT:
        print("PyQt5 is required for legend functionality.")
        return None

    if self._iface is None:
        print("Legend requires running within QGIS.")
        return None

    # Process legend_dict
    if legend_dict is not None:
        if not isinstance(legend_dict, dict):
            print("The legend_dict must be a dictionary.")
            return None
        labels = list(legend_dict.keys())
        colors = list(legend_dict.values())

    # Validate labels and colors
    if labels is None:
        labels = ["One", "Two", "Three", "Four"]
    if colors is None:
        colors = ["#8DD3C7", "#FFFFB3", "#BEBADA", "#FB8072"]

    if not isinstance(labels, list):
        print("The labels must be a list.")
        return None
    if not isinstance(colors, list):
        print("The colors must be a list.")
        return None

    # Convert RGB tuples to hex if needed
    converted_colors = []
    for color in colors:
        if isinstance(color, tuple):
            if len(color) == 3:
                converted_colors.append(
                    "#{:02x}{:02x}{:02x}".format(
                        int(color[0]), int(color[1]), int(color[2])
                    )
                )
            elif len(color) == 4:
                converted_colors.append(
                    "#{:02x}{:02x}{:02x}".format(
                        int(color[0]), int(color[1]), int(color[2])
                    )
                )
            else:
                print(f"Invalid color tuple: {color}")
                return None
        elif isinstance(color, str):
            # Handle colors without # prefix
            if len(color) == 6 and not color.startswith("#"):
                converted_colors.append("#" + color)
            else:
                converted_colors.append(color)
        else:
            print(f"Invalid color type: {type(color)}")
            return None
    colors = converted_colors

    if len(labels) != len(colors):
        print("The labels and colors must be the same length.")
        return None

    # Validate position
    allowed_positions = ["topleft", "topright", "bottomleft", "bottomright"]
    if position not in allowed_positions:
        print(f"The position must be one of: {', '.join(allowed_positions)}")
        return None

    # Create the dock widget
    dock = QDockWidget(title, self._iface.mainWindow())
    dock.setObjectName(f"{title}LegendDock")

    # Create the main widget with scroll area
    from PyQt5.QtWidgets import QScrollArea

    scroll = QScrollArea()
    scroll.setWidgetResizable(True)

    main_widget = QWidget()
    layout = QVBoxLayout()
    layout.setContentsMargins(10, 10, 10, 10)
    layout.setSpacing(5)

    # Add legend items
    for label, color in zip(labels, colors):
        item_layout = QHBoxLayout()

        # Color box
        color_label = QLabel()
        color_label.setFixedSize(20, 20)
        color_label.setStyleSheet(
            f"background-color: {color}; border: 1px solid #000000;"
        )

        # Label text
        text_label = QLabel(label)

        item_layout.addWidget(color_label)
        item_layout.addWidget(text_label)
        item_layout.addStretch()

        layout.addLayout(item_layout)

    layout.addStretch()
    main_widget.setLayout(layout)
    scroll.setWidget(main_widget)
    dock.setWidget(scroll)

    # Set size constraints if provided
    if "width" in kwargs:
        dock.setMinimumWidth(kwargs["width"])
    else:
        dock.setMinimumWidth(150)

    if "height" in kwargs:
        dock.setMinimumHeight(kwargs["height"])

    # Determine dock area
    dock_areas = {
        "topleft": Qt.LeftDockWidgetArea,
        "topright": Qt.RightDockWidgetArea,
        "bottomleft": Qt.LeftDockWidgetArea,
        "bottomright": Qt.RightDockWidgetArea,
    }
    area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

    self._iface.addDockWidget(area, dock)

    return dock

add_points_from_xy(self, data, x='longitude', y='latitude', layer_name='Points', crs='EPSG:4326', style=None, color_column=None, size_column=None, popup_fields=None, zoom_to_layer=False, **kwargs)

Enhanced CSV point loading with data-driven styling and popups.

Parameters:

Name Type Description Default
data Union[str, Any]

Path to CSV file or pandas DataFrame.

required
x str

Column name for x-coordinate (longitude). Defaults to "longitude".

'longitude'
y str

Column name for y-coordinate (latitude). Defaults to "latitude".

'latitude'
layer_name str

Name for the layer. Defaults to "Points".

'Points'
crs str

Coordinate reference system. Defaults to "EPSG:4326".

'EPSG:4326'
style Optional[Dict]

Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.

None
color_column Optional[str]

Column name for color-based categorization.

None
size_column Optional[str]

Column name for size-based scaling.

None
popup_fields Optional[List[str]]

List of field names to include in popup. If None, includes all fields.

None
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if creation failed.

Examples:

>>> m = Map()
>>> m.add_points_from_xy(
...     "cities.csv",
...     x="lon",
...     y="lat",
...     color_column="category",
...     size_column="population",
...     popup_fields=["name", "population"]
... )
Source code in qgis_map/qgis_map.py
def add_points_from_xy(
    self,
    data: Union[str, Any],
    x: str = "longitude",
    y: str = "latitude",
    layer_name: str = "Points",
    crs: str = "EPSG:4326",
    style: Optional[Dict] = None,
    color_column: Optional[str] = None,
    size_column: Optional[str] = None,
    popup_fields: Optional[List[str]] = None,
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Enhanced CSV point loading with data-driven styling and popups.

    Args:
        data: Path to CSV file or pandas DataFrame.
        x: Column name for x-coordinate (longitude). Defaults to "longitude".
        y: Column name for y-coordinate (latitude). Defaults to "latitude".
        layer_name: Name for the layer. Defaults to "Points".
        crs: Coordinate reference system. Defaults to "EPSG:4326".
        style: Optional style dictionary with keys: color, stroke_color, stroke_width, opacity, symbol.
        color_column: Column name for color-based categorization.
        size_column: Column name for size-based scaling.
        popup_fields: List of field names to include in popup. If None, includes all fields.
        zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
        **kwargs: Additional keyword arguments.

    Returns:
        The created vector layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> m.add_points_from_xy(
        ...     "cities.csv",
        ...     x="lon",
        ...     y="lat",
        ...     color_column="category",
        ...     size_column="population",
        ...     popup_fields=["name", "population"]
        ... )
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    try:
        from . import common

        # Parse point data
        df, attr_columns = common.parse_point_data(data, x, y)

        # Create memory layer
        layer = QgsVectorLayer(f"Point?crs={crs}", layer_name, "memory")
        provider = layer.dataProvider()

        # Add attribute fields with proper types
        fields = QgsFields()
        for col in attr_columns:
            # Infer field type from data
            dtype = df[col].dtype
            if dtype in ["int64", "int32"]:
                fields.append(QgsField(col, 2))  # QVariant::Int
            elif dtype in ["float64", "float32"]:
                fields.append(QgsField(col, 6))  # QVariant::Double
            else:
                fields.append(QgsField(col, 10))  # QVariant::String

        provider.addAttributes(fields.toList())
        layer.updateFields()

        # Add features
        features = []
        for idx, row in df.iterrows():
            feat = QgsFeature(layer.fields())
            point = QgsPointXY(float(row[x]), float(row[y]))
            feat.setGeometry(QgsGeometry.fromPointXY(point))

            # Set attributes
            for col in attr_columns:
                feat.setAttribute(col, row[col])

            features.append(feat)

        provider.addFeatures(features)
        layer.updateExtents()

        # Apply styling
        if color_column and color_column in attr_columns:
            # Use categorized renderer for color column
            unique_values = df[color_column].unique()
            categories = []
            colors = [
                "#e41a1c",
                "#377eb8",
                "#4daf4a",
                "#984ea3",
                "#ff7f00",
                "#ffff33",
                "#a65628",
                "#f781bf",
            ]

            for i, value in enumerate(unique_values):
                color = colors[i % len(colors)]
                symbol = QgsSymbol.defaultSymbol(0)  # Point = 0
                symbol.setColor(self._parse_color(color))
                category = QgsRendererCategory(value, symbol, str(value))
                categories.append(category)

            renderer = QgsCategorizedSymbolRenderer(color_column, categories)
            layer.setRenderer(renderer)
        elif style:
            self._apply_vector_style(layer, style)

        # Handle data-defined sizing
        if size_column and size_column in attr_columns:
            symbol = layer.renderer().symbol()
            if symbol:
                symbol.setDataDefinedProperty(
                    QgsSymbol.PropertySize, QgsProperty.fromField(size_column)
                )

        # Set popup template
        if popup_fields:
            fields_to_show = [f for f in popup_fields if f in attr_columns]
        else:
            fields_to_show = attr_columns

        popup_html = "<html><body>"
        for field in fields_to_show:
            popup_html += f'<p><b>{field}:</b> [% "{field}" %]</p>'
        popup_html += "</body></html>"
        layer.setMapTipTemplate(popup_html)

        # Add to project
        self.project.addMapLayer(layer, False)
        root = self.project.layerTreeRoot()
        root.addLayer(layer)

        # Track layer
        self._layers[layer_name] = layer

        # Zoom if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer_name)

        # Refresh canvas
        if self.canvas:
            self.canvas.refresh()

        return layer

    except ImportError as e:
        print(f"Missing dependency: {e}")
        return None
    except Exception as e:
        print(f"Error adding points from XY: {e}")
        return None

add_raster(self, source, layer_name=None, band=None, colormap=None, vmin=None, vmax=None, nodata=None, zoom_to_layer=False, visible=True, opacity=1.0, **kwargs)

Add a raster layer to the map.

Parameters:

Name Type Description Default
source str

Path to the raster file (GeoTIFF, etc.) or a URL.

required
layer_name Optional[str]

The name for the layer. Defaults to the file name.

None
band Optional[int]

The band number to display (1-indexed). Defaults to None (auto).

None
colormap Optional[str]

The name of a color ramp to apply (e.g., "viridis", "Spectral").

None
vmin Optional[float]

Minimum value for color scaling. Defaults to band minimum.

None
vmax Optional[float]

Maximum value for color scaling. Defaults to band maximum.

None
nodata Optional[float]

The nodata value. Defaults to the layer's nodata value.

None
zoom_to_layer bool

Whether to zoom to the layer extent. Defaults to False.

False
visible bool

Whether the layer should be visible. Defaults to True.

True
opacity float

Layer opacity (0-1). Defaults to 1.0.

1.0
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsRasterLayer']

The created raster layer, or None if failed.

Examples:

>>> m = Map()
>>> m.add_raster("elevation.tif", colormap="terrain", zoom_to_layer=True)
>>> m.add_raster("landsat.tif", band=4, vmin=0, vmax=3000)
Source code in qgis_map/qgis_map.py
def add_raster(
    self,
    source: str,
    layer_name: Optional[str] = None,
    band: Optional[int] = None,
    colormap: Optional[str] = None,
    vmin: Optional[float] = None,
    vmax: Optional[float] = None,
    nodata: Optional[float] = None,
    zoom_to_layer: bool = False,
    visible: bool = True,
    opacity: float = 1.0,
    **kwargs,
) -> Optional["QgsRasterLayer"]:
    """Add a raster layer to the map.

    Args:
        source: Path to the raster file (GeoTIFF, etc.) or a URL.
        layer_name: The name for the layer. Defaults to the file name.
        band: The band number to display (1-indexed). Defaults to None (auto).
        colormap: The name of a color ramp to apply (e.g., "viridis", "Spectral").
        vmin: Minimum value for color scaling. Defaults to band minimum.
        vmax: Maximum value for color scaling. Defaults to band maximum.
        nodata: The nodata value. Defaults to the layer's nodata value.
        zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
        visible: Whether the layer should be visible. Defaults to True.
        opacity: Layer opacity (0-1). Defaults to 1.0.
        **kwargs: Additional keyword arguments.

    Returns:
        The created raster layer, or None if failed.

    Example:
        >>> m = Map()
        >>> m.add_raster("elevation.tif", colormap="terrain", zoom_to_layer=True)
        >>> m.add_raster("landsat.tif", band=4, vmin=0, vmax=3000)
    """
    # Determine the layer name
    if layer_name is None:
        layer_name = os.path.splitext(os.path.basename(source))[0]

    # Create the raster layer
    layer = QgsRasterLayer(source, layer_name)

    if not layer.isValid():
        print(f"Failed to load raster layer: {source}")
        return None

    # Get the data provider
    provider = layer.dataProvider()

    # Set nodata if specified
    if nodata is not None:
        provider.setNoDataValue(1, nodata)

    # Apply colormap/renderer
    if colormap or band or vmin is not None or vmax is not None:
        self._apply_raster_style(layer, band, colormap, vmin, vmax)

    # Set opacity
    layer.renderer().setOpacity(opacity)

    # Add to project
    self.project.addMapLayer(layer)

    # Set visibility
    root = self.project.layerTreeRoot()
    layer_node = root.findLayer(layer.id())
    if layer_node:
        layer_node.setItemVisibilityChecked(visible)

    # Track the layer
    self._layers[layer_name] = layer

    # Zoom to layer if requested
    if zoom_to_layer:
        self.zoom_to_layer(layer_name)
    elif self.canvas is not None:
        self.canvas.refresh()

    return layer

add_stac_layer(self, url=None, item=None, assets=None, layer_name=None, zoom_to_layer=True, **kwargs)

Load STAC item as COG layer.

Parameters:

Name Type Description Default
url Optional[str]

URL to STAC item JSON.

None
item Optional[Dict]

STAC item dictionary (if not providing URL).

None
assets Optional[Union[str, List[str]]]

Asset name(s) to load. If None, uses first visual/data asset.

None
layer_name Optional[str]

Name for the layer. If None, uses item ID.

None
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to True.

True
**kwargs

Additional keyword arguments passed to add_cog.

{}

Returns:

Type Description
Optional['QgsRasterLayer']

The created raster layer, or None if creation failed.

Examples:

>>> m = Map()
>>> m.add_stac_layer(
...     url="https://example.com/item.json",
...     assets="visual"
... )
Source code in qgis_map/qgis_map.py
def add_stac_layer(
    self,
    url: Optional[str] = None,
    item: Optional[Dict] = None,
    assets: Optional[Union[str, List[str]]] = None,
    layer_name: Optional[str] = None,
    zoom_to_layer: bool = True,
    **kwargs,
) -> Optional["QgsRasterLayer"]:
    """Load STAC item as COG layer.

    Args:
        url: URL to STAC item JSON.
        item: STAC item dictionary (if not providing URL).
        assets: Asset name(s) to load. If None, uses first visual/data asset.
        layer_name: Name for the layer. If None, uses item ID.
        zoom_to_layer: Whether to zoom to layer extent. Defaults to True.
        **kwargs: Additional keyword arguments passed to add_cog.

    Returns:
        The created raster layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> m.add_stac_layer(
        ...     url="https://example.com/item.json",
        ...     assets="visual"
        ... )
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    try:
        # Fetch STAC item if URL provided
        if url and not item:
            if not HAS_REQUESTS:
                print("requests library required for fetching STAC items from URL")
                return None

            response = requests.get(url)
            response.raise_for_status()
            item = response.json()

        if not item:
            print("No STAC item provided (url or item required)")
            return None

        # Extract assets
        item_assets = item.get("assets", {})
        if not item_assets:
            print("No assets found in STAC item")
            return None

        # Select asset(s) to load
        if assets:
            if isinstance(assets, str):
                asset_names = [assets]
            else:
                asset_names = assets
        else:
            # Auto-select first visual or data asset
            visual_assets = ["visual", "rendered_preview", "thumbnail", "overview"]
            data_assets = ["data", "cog", "image"]

            asset_names = []
            for name in visual_assets + data_assets:
                if name in item_assets:
                    asset_names = [name]
                    break

            if not asset_names:
                # Just use first asset
                asset_names = [list(item_assets.keys())[0]]

        # Load first asset as COG
        asset_name = asset_names[0]
        if asset_name not in item_assets:
            print(
                f"Asset '{asset_name}' not found. Available: {list(item_assets.keys())}"
            )
            return None

        asset = item_assets[asset_name]
        cog_url = asset.get("href")

        if not cog_url:
            print(f"No href found for asset '{asset_name}'")
            return None

        # Generate layer name
        if layer_name is None:
            layer_name = f"{item.get('id', 'STAC')}_{asset_name}"

        # Load as COG
        layer = self.add_cog(
            cog_url, layer_name=layer_name, zoom_to_layer=zoom_to_layer, **kwargs
        )

        return layer

    except ImportError as e:
        print(f"Missing dependency: {e}")
        return None
    except Exception as e:
        print(f"Error adding STAC layer: {e}")
        return None

add_styled_vector(self, source, layer_name=None, column=None, scheme='Quantiles', k=5, color_ramp='Spectral', legend=True, legend_title=None, zoom_to_layer=False, **kwargs)

Add vector layer with automatic data-driven classification and legend.

Parameters:

Name Type Description Default
source Union[str, Any]

File path or GeoDataFrame.

required
layer_name Optional[str]

Name for the layer. If None, auto-generated.

None
column Optional[str]

Column name for classification. If None, uses simple styling.

None
scheme str

Classification scheme: "Quantiles", "EqualInterval", "NaturalBreaks", "StandardDeviation".

'Quantiles'
k int

Number of classes. Defaults to 5.

5
color_ramp str

Color ramp name. Defaults to "Spectral".

'Spectral'
legend bool

Whether to auto-generate legend. Defaults to True.

True
legend_title Optional[str]

Title for legend. If None, uses column name.

None
zoom_to_layer bool

Whether to zoom to layer extent. Defaults to False.

False
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if creation failed.

Examples:

>>> m = Map()
>>> m.add_styled_vector(
...     "states.shp",
...     column="population",
...     scheme="Quantiles",
...     k=5,
...     color_ramp="YlOrRd",
...     legend=True
... )
Source code in qgis_map/qgis_map.py
def add_styled_vector(
    self,
    source: Union[str, Any],
    layer_name: Optional[str] = None,
    column: Optional[str] = None,
    scheme: str = "Quantiles",
    k: int = 5,
    color_ramp: str = "Spectral",
    legend: bool = True,
    legend_title: Optional[str] = None,
    zoom_to_layer: bool = False,
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Add vector layer with automatic data-driven classification and legend.

    Args:
        source: File path or GeoDataFrame.
        layer_name: Name for the layer. If None, auto-generated.
        column: Column name for classification. If None, uses simple styling.
        scheme: Classification scheme: "Quantiles", "EqualInterval", "NaturalBreaks", "StandardDeviation".
        k: Number of classes. Defaults to 5.
        color_ramp: Color ramp name. Defaults to "Spectral".
        legend: Whether to auto-generate legend. Defaults to True.
        legend_title: Title for legend. If None, uses column name.
        zoom_to_layer: Whether to zoom to layer extent. Defaults to False.
        **kwargs: Additional keyword arguments.

    Returns:
        The created vector layer, or None if creation failed.

    Example:
        >>> m = Map()
        >>> m.add_styled_vector(
        ...     "states.shp",
        ...     column="population",
        ...     scheme="Quantiles",
        ...     k=5,
        ...     color_ramp="YlOrRd",
        ...     legend=True
        ... )
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    try:
        # Load vector layer
        if hasattr(source, "to_file"):  # GeoDataFrame
            layer = self.add_gdf(
                source, layer_name=layer_name, zoom_to_layer=False, **kwargs
            )
        else:
            layer = self.add_vector(
                source, layer_name=layer_name, zoom_to_layer=False, **kwargs
            )

        if layer is None or column is None:
            return layer

        # Get classification method
        classification_methods = {
            "Quantiles": QgsClassificationQuantile,
            "EqualInterval": QgsClassificationEqualInterval,
            "NaturalBreaks": QgsClassificationJenks,
            "Jenks": QgsClassificationJenks,
            "StandardDeviation": QgsClassificationStandardDeviation,
        }

        classification_class = classification_methods.get(
            scheme, QgsClassificationQuantile
        )
        classification_method = classification_class()

        # Get color ramp
        from . import common

        ramp = common.get_color_ramp(color_ramp, n_colors=k)

        # Create graduated renderer
        renderer = QgsGraduatedSymbolRenderer()
        renderer.setClassAttribute(column)
        renderer.setSourceColorRamp(ramp)

        # Calculate classes
        renderer.updateClasses(layer, classification_method, k)

        # Apply renderer
        layer.setRenderer(renderer)
        layer.triggerRepaint()

        # Generate legend if requested
        if legend:
            legend_dict = {}
            for i, range_obj in enumerate(renderer.ranges()):
                label = range_obj.label()
                color = range_obj.symbol().color()
                # Convert QColor to hex
                hex_color = (
                    f"#{color.red():02x}{color.green():02x}{color.blue():02x}"
                )
                legend_dict[label] = hex_color

            title = legend_title if legend_title else column
            self.add_legend(title=title, legend_dict=legend_dict)

        # Zoom if requested
        if zoom_to_layer:
            self.zoom_to_layer(layer.name() if layer_name is None else layer_name)

        # Refresh canvas
        if self.canvas:
            self.canvas.refresh()

        return layer

    except Exception as e:
        print(f"Error adding styled vector: {e}")
        return None

add_time_slider(self, layers=None, labels=None, time_interval=1, position='bottomright', time_format='%Y-%m-%d', **kwargs)

Add a time slider to control temporal layers or switch between layers.

This method creates a dockable panel with a slider that can either: 1. Control the temporal properties of a single layer with time-enabled data. 2. Switch between multiple layers representing different time periods.

Parameters:

Name Type Description Default
layers Optional[Dict[str, str]]

A dictionary mapping labels to layer sources (file paths or URLs). If provided, clicking the slider will switch between these layers.

None
labels Optional[List[str]]

A list of labels for the time steps (used with layers).

None
time_interval int

Time interval between steps in seconds. Defaults to 1.

1
position str

Position of the dock widget ("left", "right", "top", "bottom"). Defaults to "bottomright".

'bottomright'
time_format str

The format string for displaying time labels.

'%Y-%m-%d'
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QDockWidget']

The created QDockWidget, or None if failed.

Examples:

>>> m = Map()
>>> # Switch between multiple raster layers
>>> layers = {
...     "2020": "data/ndvi_2020.tif",
...     "2021": "data/ndvi_2021.tif",
...     "2022": "data/ndvi_2022.tif",
... }
>>> m.add_time_slider(layers=layers)
Source code in qgis_map/qgis_map.py
def add_time_slider(
    self,
    layers: Optional[Dict[str, str]] = None,
    labels: Optional[List[str]] = None,
    time_interval: int = 1,
    position: str = "bottomright",
    time_format: str = "%Y-%m-%d",
    **kwargs,
) -> Optional["QDockWidget"]:
    """Add a time slider to control temporal layers or switch between layers.

    This method creates a dockable panel with a slider that can either:
    1. Control the temporal properties of a single layer with time-enabled data.
    2. Switch between multiple layers representing different time periods.

    Args:
        layers: A dictionary mapping labels to layer sources (file paths or URLs).
            If provided, clicking the slider will switch between these layers.
        labels: A list of labels for the time steps (used with `layers`).
        time_interval: Time interval between steps in seconds. Defaults to 1.
        position: Position of the dock widget ("left", "right", "top", "bottom").
            Defaults to "bottomright".
        time_format: The format string for displaying time labels.
        **kwargs: Additional keyword arguments.

    Returns:
        The created QDockWidget, or None if failed.

    Example:
        >>> m = Map()
        >>> # Switch between multiple raster layers
        >>> layers = {
        ...     "2020": "data/ndvi_2020.tif",
        ...     "2021": "data/ndvi_2021.tif",
        ...     "2022": "data/ndvi_2022.tif",
        ... }
        >>> m.add_time_slider(layers=layers)
    """
    if not HAS_PYQT:
        print("PyQt5 is required for time slider functionality.")
        return None

    if self._iface is None:
        print("Time slider requires running within QGIS.")
        return None

    # Create the dock widget
    dock = QDockWidget("Time Slider", self._iface.mainWindow())
    dock.setObjectName("TimeSliderDock")

    # Create the main widget
    main_widget = QWidget()
    layout = QVBoxLayout()

    if layers is not None:
        # Mode 1: Switch between multiple layers
        layer_list = list(layers.items())
        if labels is None:
            labels = list(layers.keys())

        # Load all layers but hide them except the first
        loaded_layers = []
        failed_layers = []
        for i, (label, source) in enumerate(layer_list):
            # Determine if raster or vector
            if source.lower().endswith(
                (".tif", ".tiff", ".img", ".jp2", ".png", ".jpg")
            ):
                layer = self.add_raster(
                    source,
                    layer_name=label,
                    visible=(i == 0),
                    zoom_to_layer=(i == 0),
                )
            else:
                layer = self.add_vector(
                    source,
                    layer_name=label,
                    visible=(i == 0),
                    zoom_to_layer=(i == 0),
                )

            if layer is None:
                failed_layers.append((label, source))
                print(f"Warning: Failed to load layer '{label}' from {source}")
            loaded_layers.append(layer)

        # Check if any layers were loaded successfully
        if all(layer is None for layer in loaded_layers):
            print(
                "Error: No layers were loaded successfully. Please check your file paths."
            )
            if failed_layers:
                print("Failed layers:")
                for label, source in failed_layers:
                    print(f"  - {label}: {source}")
            return None

        # Create the slider
        slider = QSlider(Qt.Horizontal)
        slider.setMinimum(0)
        slider.setMaximum(len(layer_list) - 1)
        slider.setValue(0)
        slider.setTickPosition(QSlider.TicksBelow)
        slider.setTickInterval(1)

        # Create label display
        label_display = QLabel(labels[0])
        label_display.setAlignment(Qt.AlignCenter)

        # Play/Pause button
        play_btn = QPushButton("▶ Play")
        play_btn.setCheckable(True)

        # Timer for auto-play
        from PyQt5.QtCore import QTimer

        timer = QTimer()
        timer.setInterval(time_interval * 1000)

        def on_slider_change(value):
            label_display.setText(labels[value])
            # Hide all layers except the current one
            for i, layer in enumerate(loaded_layers):
                if layer is not None:
                    root = self.project.layerTreeRoot()
                    layer_node = root.findLayer(layer.id())
                    if layer_node:
                        layer_node.setItemVisibilityChecked(i == value)
            if self.canvas:
                self.canvas.refresh()

        def on_timeout():
            current = slider.value()
            next_val = (current + 1) % len(layer_list)
            slider.setValue(next_val)

        def on_play_clicked(checked):
            if checked:
                play_btn.setText("⏸ Pause")
                timer.start()
            else:
                play_btn.setText("▶ Play")
                timer.stop()

        slider.valueChanged.connect(on_slider_change)
        timer.timeout.connect(on_timeout)
        play_btn.clicked.connect(on_play_clicked)

        # Add widgets to layout
        layout.addWidget(label_display)
        layout.addWidget(slider)

        # Add control buttons
        btn_layout = QHBoxLayout()
        prev_btn = QPushButton("◀ Prev")
        next_btn = QPushButton("Next ▶")
        prev_btn.clicked.connect(
            lambda: slider.setValue(max(0, slider.value() - 1))
        )
        next_btn.clicked.connect(
            lambda: slider.setValue(min(len(layer_list) - 1, slider.value() + 1))
        )
        btn_layout.addWidget(prev_btn)
        btn_layout.addWidget(play_btn)
        btn_layout.addWidget(next_btn)
        layout.addLayout(btn_layout)

    else:
        # Mode 2: Control QGIS temporal navigation
        info_label = QLabel(
            "Use the QGIS Temporal Controller for time-enabled layers.\n"
            "Enable temporal properties on your layer first."
        )
        info_label.setWordWrap(True)
        layout.addWidget(info_label)

        # Add a button to open the temporal controller
        open_temporal_btn = QPushButton("Open Temporal Controller")
        open_temporal_btn.clicked.connect(
            lambda: (
                self._iface.mainWindow()
                .findChild(QDockWidget, "TemporalControllerDock")
                .show()
                if self._iface.mainWindow().findChild(
                    QDockWidget, "TemporalControllerDock"
                )
                else None
            )
        )
        layout.addWidget(open_temporal_btn)

    main_widget.setLayout(layout)
    dock.setWidget(main_widget)

    # Determine dock area
    dock_areas = {
        "left": Qt.LeftDockWidgetArea,
        "right": Qt.RightDockWidgetArea,
        "top": Qt.TopDockWidgetArea,
        "bottom": Qt.BottomDockWidgetArea,
        "bottomright": Qt.BottomDockWidgetArea,
        "bottomleft": Qt.BottomDockWidgetArea,
        "topright": Qt.TopDockWidgetArea,
        "topleft": Qt.TopDockWidgetArea,
    }
    area = dock_areas.get(position.lower(), Qt.BottomDockWidgetArea)

    self._iface.addDockWidget(area, dock)
    self._time_slider_dock = dock

    return dock

add_vector(self, source, layer_name=None, style=None, zoom_to_layer=False, visible=True, opacity=1.0, encoding='UTF-8', **kwargs)

Add a vector layer to the map.

Parameters:

Name Type Description Default
source str

Path to the vector file (shapefile, GeoJSON, GeoPackage, etc.) or a URL to a web service.

required
layer_name Optional[str]

The name for the layer. Defaults to the file name.

None
style Optional[Dict]

A dictionary specifying the style. Supported keys: - color: Fill color as hex string (e.g., "#ff0000") or RGB tuple. - stroke_color: Stroke/outline color. - stroke_width: Stroke width in pixels. - opacity: Fill opacity (0-1). - symbol: Symbol type for points ("circle", "square", "triangle").

None
zoom_to_layer bool

Whether to zoom to the layer extent. Defaults to False.

False
visible bool

Whether the layer should be visible. Defaults to True.

True
opacity float

Layer opacity (0-1). Defaults to 1.0.

1.0
encoding str

File encoding. Defaults to "UTF-8".

'UTF-8'
**kwargs

Additional keyword arguments passed to QgsVectorLayer.

{}

Returns:

Type Description
Optional['QgsVectorLayer']

The created vector layer, or None if failed.

Examples:

>>> m = Map()
>>> m.add_vector("data/cities.geojson", layer_name="Cities")
>>> m.add_vector(
...     "data/polygons.shp",
...     style={"color": "#3388ff", "stroke_color": "#000000"},
...     zoom_to_layer=True
... )
Source code in qgis_map/qgis_map.py
def add_vector(
    self,
    source: str,
    layer_name: Optional[str] = None,
    style: Optional[Dict] = None,
    zoom_to_layer: bool = False,
    visible: bool = True,
    opacity: float = 1.0,
    encoding: str = "UTF-8",
    **kwargs,
) -> Optional["QgsVectorLayer"]:
    """Add a vector layer to the map.

    Args:
        source: Path to the vector file (shapefile, GeoJSON, GeoPackage, etc.)
            or a URL to a web service.
        layer_name: The name for the layer. Defaults to the file name.
        style: A dictionary specifying the style. Supported keys:
            - color: Fill color as hex string (e.g., "#ff0000") or RGB tuple.
            - stroke_color: Stroke/outline color.
            - stroke_width: Stroke width in pixels.
            - opacity: Fill opacity (0-1).
            - symbol: Symbol type for points ("circle", "square", "triangle").
        zoom_to_layer: Whether to zoom to the layer extent. Defaults to False.
        visible: Whether the layer should be visible. Defaults to True.
        opacity: Layer opacity (0-1). Defaults to 1.0.
        encoding: File encoding. Defaults to "UTF-8".
        **kwargs: Additional keyword arguments passed to QgsVectorLayer.

    Returns:
        The created vector layer, or None if failed.

    Example:
        >>> m = Map()
        >>> m.add_vector("data/cities.geojson", layer_name="Cities")
        >>> m.add_vector(
        ...     "data/polygons.shp",
        ...     style={"color": "#3388ff", "stroke_color": "#000000"},
        ...     zoom_to_layer=True
        ... )
    """
    # Determine the layer name
    if layer_name is None:
        layer_name = os.path.splitext(os.path.basename(source))[0]

    # Handle different source types
    if source.startswith("http"):
        # Web service (WFS, etc.)
        layer = QgsVectorLayer(source, layer_name, "WFS")
    else:
        # Local file
        layer = QgsVectorLayer(source, layer_name, "ogr")
        if encoding:
            layer.setProviderEncoding(encoding)

    if not layer.isValid():
        print(f"Failed to load vector layer: {source}")
        return None

    # Apply style if provided
    if style:
        self._apply_vector_style(layer, style)

    # Set opacity
    layer.setOpacity(opacity)

    # Add to project
    self.project.addMapLayer(layer)

    # Set visibility
    root = self.project.layerTreeRoot()
    layer_node = root.findLayer(layer.id())
    if layer_node:
        layer_node.setItemVisibilityChecked(visible)

    # Track the layer
    self._layers[layer_name] = layer

    # Zoom to layer if requested
    if zoom_to_layer:
        self.zoom_to_layer(layer_name)
    elif self.canvas is not None:
        self.canvas.refresh()

    return layer

add_wms(self, url, layers, layer_name=None, format='image/png', crs='EPSG:4326', visible=True, opacity=1.0, **kwargs)

Add a WMS layer to the map.

Parameters:

Name Type Description Default
url str

The WMS service URL.

required
layers str

The layer name(s) to request from the WMS.

required
layer_name Optional[str]

The display name for the layer.

None
format str

The image format. Defaults to "image/png".

'image/png'
crs str

The CRS to request. Defaults to "EPSG:4326".

'EPSG:4326'
visible bool

Whether the layer should be visible. Defaults to True.

True
opacity float

Layer opacity (0-1). Defaults to 1.0.

1.0
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
Optional['QgsRasterLayer']

The created raster layer, or None if failed.

Source code in qgis_map/qgis_map.py
def add_wms(
    self,
    url: str,
    layers: str,
    layer_name: Optional[str] = None,
    format: str = "image/png",
    crs: str = "EPSG:4326",
    visible: bool = True,
    opacity: float = 1.0,
    **kwargs,
) -> Optional["QgsRasterLayer"]:
    """Add a WMS layer to the map.

    Args:
        url: The WMS service URL.
        layers: The layer name(s) to request from the WMS.
        layer_name: The display name for the layer.
        format: The image format. Defaults to "image/png".
        crs: The CRS to request. Defaults to "EPSG:4326".
        visible: Whether the layer should be visible. Defaults to True.
        opacity: Layer opacity (0-1). Defaults to 1.0.
        **kwargs: Additional keyword arguments.

    Returns:
        The created raster layer, or None if failed.
    """
    if layer_name is None:
        layer_name = layers

    # Build WMS URI
    uri = (
        f"url={url}&"
        f"layers={layers}&"
        f"format={format}&"
        f"crs={crs}&"
        f"styles="
    )

    layer = QgsRasterLayer(uri, layer_name, "wms")

    if not layer.isValid():
        print(f"Failed to load WMS layer: {url}")
        return None

    layer.renderer().setOpacity(opacity)

    self.project.addMapLayer(layer)

    # Set visibility
    root = self.project.layerTreeRoot()
    layer_node = root.findLayer(layer.id())
    if layer_node:
        layer_node.setItemVisibilityChecked(visible)

    self._layers[layer_name] = layer

    if self.canvas is not None:
        self.canvas.refresh()

    return layer

clear_layers(self, keep_basemap=True)

Remove all layers from the map.

Parameters:

Name Type Description Default
keep_basemap bool

Whether to keep basemap layers. Defaults to True.

True
Source code in qgis_map/qgis_map.py
def clear_layers(self, keep_basemap: bool = True) -> None:
    """Remove all layers from the map.

    Args:
        keep_basemap: Whether to keep basemap layers. Defaults to True.
    """
    layers_to_remove = []

    for layer in self.project.mapLayers().values():
        if keep_basemap and layer.name() in self._basemap_layers:
            continue
        layers_to_remove.append(layer.id())

    for layer_id in layers_to_remove:
        self.project.removeMapLayer(layer_id)

    # Update internal tracking
    if keep_basemap:
        self._layers = {
            name: layer
            for name, layer in self._layers.items()
            if name in self._basemap_layers
        }
    else:
        self._layers = {}
        self._basemap_layers = []

    if self.canvas is not None:
        self.canvas.refresh()

create_dock_widget(self, title, widget=None, position='right')

Create a custom dockable panel in QGIS.

Parameters:

Name Type Description Default
title str

The title of the dock widget.

required
widget Optional['QWidget']

The widget to place in the dock. If None, creates an empty container.

None
position str

Position of the dock ("left", "right", "top", "bottom").

'right'

Returns:

Type Description
'QDockWidget'

The created QDockWidget.

Source code in qgis_map/qgis_map.py
def create_dock_widget(
    self,
    title: str,
    widget: Optional["QWidget"] = None,
    position: str = "right",
) -> "QDockWidget":
    """Create a custom dockable panel in QGIS.

    Args:
        title: The title of the dock widget.
        widget: The widget to place in the dock. If None, creates an empty container.
        position: Position of the dock ("left", "right", "top", "bottom").

    Returns:
        The created QDockWidget.
    """
    if self._iface is None:
        raise RuntimeError("Dock widgets require running within QGIS.")

    dock = QDockWidget(title, self._iface.mainWindow())
    dock.setObjectName(f"{title}Dock")

    if widget is None:
        widget = QWidget()
        widget.setLayout(QVBoxLayout())

    dock.setWidget(widget)

    dock_areas = {
        "left": Qt.LeftDockWidgetArea,
        "right": Qt.RightDockWidgetArea,
        "top": Qt.TopDockWidgetArea,
        "bottom": Qt.BottomDockWidgetArea,
    }
    area = dock_areas.get(position.lower(), Qt.RightDockWidgetArea)

    self._iface.addDockWidget(area, dock)

    return dock

find_layer(self, name)

Find all layers matching a name pattern.

Supports exact matching and wildcard patterns using * and ?.

Parameters:

Name Type Description Default
name str

Layer name or pattern to search for.

required

Returns:

Type Description
Optional[List['QgsMapLayer']]

List of matching layers, or None if no matches found.

Examples:

>>> m = Map()
>>> m.add_vector("data.shp", layer_name="test1")
>>> m.add_vector("data2.shp", layer_name="test2")
>>> layers = m.find_layer("test*")
>>> len(layers)
2
Source code in qgis_map/qgis_map.py
def find_layer(
    self,
    name: str,
) -> Optional[List["QgsMapLayer"]]:
    """Find all layers matching a name pattern.

    Supports exact matching and wildcard patterns using * and ?.

    Args:
        name: Layer name or pattern to search for.

    Returns:
        List of matching layers, or None if no matches found.

    Example:
        >>> m = Map()
        >>> m.add_vector("data.shp", layer_name="test1")
        >>> m.add_vector("data2.shp", layer_name="test2")
        >>> layers = m.find_layer("test*")
        >>> len(layers)
        2
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return None

    # First try exact match
    exact_matches = self.project.mapLayersByName(name)
    if exact_matches:
        return exact_matches

    # If no exact match, try pattern matching
    all_layers = list(self.project.mapLayers().values())
    matches = []

    for layer in all_layers:
        if fnmatch.fnmatch(layer.name(), name):
            matches.append(layer)

    return matches if matches else None

get_layer(self, name)

Get a layer by name.

Parameters:

Name Type Description Default
name str

The name of the layer.

required

Returns:

Type Description
Optional[Any]

The layer object, or None if not found.

Source code in qgis_map/qgis_map.py
def get_layer(self, name: str) -> Optional[Any]:
    """Get a layer by name.

    Args:
        name: The name of the layer.

    Returns:
        The layer object, or None if not found.
    """
    layers = self.project.mapLayersByName(name)
    return layers[0] if layers else None

get_layer_names(self)

Get the names of all layers in the project.

Returns:

Type Description
List[str]

A list of layer names.

Source code in qgis_map/qgis_map.py
def get_layer_names(self) -> List[str]:
    """Get the names of all layers in the project.

    Returns:
        A list of layer names.
    """
    return [layer.name() for layer in self.project.mapLayers().values()]

layer_opacity(self, layer_name, opacity)

Set opacity for an existing layer.

Parameters:

Name Type Description Default
layer_name str

Name of the layer to modify.

required
opacity float

Opacity value between 0.0 (transparent) and 1.0 (opaque).

required

Returns:

Type Description
bool

True if opacity was set successfully, False if layer not found.

Examples:

>>> m = Map()
>>> m.add_vector("data.shp", layer_name="test")
>>> m.layer_opacity("test", 0.5)
True
Source code in qgis_map/qgis_map.py
def layer_opacity(
    self,
    layer_name: str,
    opacity: float,
) -> bool:
    """Set opacity for an existing layer.

    Args:
        layer_name: Name of the layer to modify.
        opacity: Opacity value between 0.0 (transparent) and 1.0 (opaque).

    Returns:
        True if opacity was set successfully, False if layer not found.

    Example:
        >>> m = Map()
        >>> m.add_vector("data.shp", layer_name="test")
        >>> m.layer_opacity("test", 0.5)
        True
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return False

    # Get the layer
    layer = self.get_layer(layer_name)
    if layer is None:
        print(f"Layer '{layer_name}' not found")
        return False

    # Clamp opacity to valid range
    opacity = max(0.0, min(1.0, opacity))

    try:
        # Set opacity based on layer type
        if layer.type() == QgsMapLayerType.VectorLayer:
            layer.setOpacity(opacity)
        elif layer.type() == QgsMapLayerType.RasterLayer:
            layer.renderer().setOpacity(opacity)

        # Trigger repaint
        layer.triggerRepaint()

        if self.canvas:
            self.canvas.refresh()

        return True

    except Exception as e:
        print(f"Error setting layer opacity: {e}")
        return False

remove_layer(self, layer_name)

Remove a layer from the map.

Parameters:

Name Type Description Default
layer_name str

The name of the layer to remove.

required

Returns:

Type Description
bool

True if the layer was removed, False otherwise.

Source code in qgis_map/qgis_map.py
def remove_layer(self, layer_name: str) -> bool:
    """Remove a layer from the map.

    Args:
        layer_name: The name of the layer to remove.

    Returns:
        True if the layer was removed, False otherwise.
    """
    layer = self.get_layer(layer_name)
    if layer is None:
        print(f"Layer '{layer_name}' not found.")
        return False

    self.project.removeMapLayer(layer.id())

    if layer_name in self._layers:
        del self._layers[layer_name]

    if layer_name in self._basemap_layers:
        self._basemap_layers.remove(layer_name)

    if self.canvas is not None:
        self.canvas.refresh()

    return True

set_center(self, lat, lon, zoom=None)

Set the center of the map.

Parameters:

Name Type Description Default
lat float

Latitude of the center point.

required
lon float

Longitude of the center point.

required
zoom Optional[int]

Optional zoom level to set.

None
Source code in qgis_map/qgis_map.py
def set_center(self, lat: float, lon: float, zoom: Optional[int] = None) -> None:
    """Set the center of the map.

    Args:
        lat: Latitude of the center point.
        lon: Longitude of the center point.
        zoom: Optional zoom level to set.
    """
    if self.canvas is None:
        return

    # Transform coordinates if needed
    point = QgsPointXY(lon, lat)
    if self._crs.authid() != "EPSG:4326":
        transform = QgsCoordinateTransform(
            QgsCoordinateReferenceSystem("EPSG:4326"),
            self.canvas.mapSettings().destinationCrs(),
            self.project,
        )
        point = transform.transform(point)

    self.canvas.setCenter(point)

    if zoom is not None:
        # Approximate scale from zoom level
        scale = 591657550.500000 / (2**zoom)
        self.canvas.zoomScale(scale)

    self.canvas.refresh()

to_image(self, output_path, width=1920, height=1080, extent=None, **kwargs)

Export the current map view to an image.

Parameters:

Name Type Description Default
output_path str

The output file path.

required
width int

Image width in pixels. Defaults to 1920.

1920
height int

Image height in pixels. Defaults to 1080.

1080
extent Optional[Tuple[float, float, float, float]]

Optional extent as (xmin, ymin, xmax, ymax).

None
**kwargs

Additional keyword arguments.

{}

Returns:

Type Description
str

The output file path.

Source code in qgis_map/qgis_map.py
def to_image(
    self,
    output_path: str,
    width: int = 1920,
    height: int = 1080,
    extent: Optional[Tuple[float, float, float, float]] = None,
    **kwargs,
) -> str:
    """Export the current map view to an image.

    Args:
        output_path: The output file path.
        width: Image width in pixels. Defaults to 1920.
        height: Image height in pixels. Defaults to 1080.
        extent: Optional extent as (xmin, ymin, xmax, ymax).
        **kwargs: Additional keyword arguments.

    Returns:
        The output file path.
    """
    from qgis.core import QgsMapSettings, QgsMapRendererCustomPainterJob
    from PyQt5.QtGui import QImage, QPainter
    from PyQt5.QtCore import QSize

    # Set up map settings
    settings = QgsMapSettings()
    settings.setOutputSize(QSize(width, height))
    settings.setLayers(list(self.project.mapLayers().values()))

    if extent:
        xmin, ymin, xmax, ymax = extent
        settings.setExtent(QgsRectangle(xmin, ymin, xmax, ymax))
    elif self.canvas:
        settings.setExtent(self.canvas.extent())

    settings.setBackgroundColor(QColor(255, 255, 255))
    settings.setDestinationCrs(self.project.crs())

    # Create image
    image = QImage(QSize(width, height), QImage.Format_ARGB32)
    image.fill(Qt.white)

    # Render
    painter = QPainter(image)
    job = QgsMapRendererCustomPainterJob(settings, painter)
    job.start()
    job.waitForFinished()
    painter.end()

    # Save
    image.save(output_path)

    return output_path

zoom_to_bounds(self, bounds, crs='EPSG:4326')

Zoom the map to the specified bounds.

Parameters:

Name Type Description Default
bounds Tuple[float, float, float, float]

The bounds as (xmin, ymin, xmax, ymax).

required
crs str

The CRS of the bounds. Defaults to "EPSG:4326".

'EPSG:4326'
Source code in qgis_map/qgis_map.py
def zoom_to_bounds(
    self, bounds: Tuple[float, float, float, float], crs: str = "EPSG:4326"
) -> None:
    """Zoom the map to the specified bounds.

    Args:
        bounds: The bounds as (xmin, ymin, xmax, ymax).
        crs: The CRS of the bounds. Defaults to "EPSG:4326".
    """
    if self.canvas is None:
        return

    xmin, ymin, xmax, ymax = bounds
    extent = QgsRectangle(xmin, ymin, xmax, ymax)

    # Transform if needed
    source_crs = QgsCoordinateReferenceSystem(crs)
    if source_crs != self.canvas.mapSettings().destinationCrs():
        transform = QgsCoordinateTransform(
            source_crs,
            self.canvas.mapSettings().destinationCrs(),
            self.project,
        )
        extent = transform.transformBoundingBox(extent)

    self.canvas.setExtent(extent)
    self.canvas.refresh()

zoom_to_gdf(self, gdf, crs=None)

Zoom map to GeoDataFrame extent.

Parameters:

Name Type Description Default
gdf Any

GeoDataFrame to zoom to.

required
crs Optional[str]

Optional CRS string. If None, uses gdf.crs.

None

Examples:

>>> import geopandas as gpd
>>> m = Map()
>>> gdf = gpd.read_file("data.geojson")
>>> m.zoom_to_gdf(gdf)
Source code in qgis_map/qgis_map.py
def zoom_to_gdf(
    self,
    gdf: Any,
    crs: Optional[str] = None,
) -> None:
    """Zoom map to GeoDataFrame extent.

    Args:
        gdf: GeoDataFrame to zoom to.
        crs: Optional CRS string. If None, uses gdf.crs.

    Example:
        >>> import geopandas as gpd
        >>> m = Map()
        >>> gdf = gpd.read_file("data.geojson")
        >>> m.zoom_to_gdf(gdf)
    """
    if not HAS_QGIS:
        print("QGIS libraries not available")
        return

    try:
        # Get bounds from GeoDataFrame
        bounds = gdf.total_bounds  # Returns (minx, miny, maxx, maxy)

        # Get CRS from gdf if not specified
        if crs is None:
            crs = str(gdf.crs)

        # Use existing zoom_to_bounds method
        self.zoom_to_bounds(bounds, crs=crs)

    except Exception as e:
        print(f"Error zooming to GeoDataFrame: {e}")

zoom_to_layer(self, layer_name)

Zoom the map to the extent of a layer.

Parameters:

Name Type Description Default
layer_name str

The name of the layer to zoom to.

required
Source code in qgis_map/qgis_map.py
def zoom_to_layer(self, layer_name: str) -> None:
    """Zoom the map to the extent of a layer.

    Args:
        layer_name: The name of the layer to zoom to.
    """
    layer = self.get_layer(layer_name)
    if layer is None:
        print(f"Layer '{layer_name}' not found.")
        return

    if self.canvas is not None:
        extent = layer.extent()
        # Transform extent if needed
        if layer.crs() != self.canvas.mapSettings().destinationCrs():
            transform = QgsCoordinateTransform(
                layer.crs(),
                self.canvas.mapSettings().destinationCrs(),
                self.project,
            )
            extent = transform.transformBoundingBox(extent)
        self.canvas.setExtent(extent)
        self.canvas.refresh()
    elif self._iface is not None:
        self._iface.zoomToActiveLayer()