Source code for mappyng.map

"""
Main Map class for mappyng.

Orchestrates SVG rendering of geographic data with support for:
- Main map with configurable bounding box
- Cartouches (inset maps) with independent CRS and bbox
- Zoom windows
- Title, scale bar, source text
- Layer system per viewport (basemap, choropleth, etc.)

Layout matches mappyng's positioning conventions:
- Cartouches stacked vertically on the left, top-aligned with main map
- Main map fills the remaining space
- Zoom window positioned inside the main map area
- Facecolor renders as "ocean" background inside each viewport


Example
-------
>>> from mappyng import Map, BasemapLayer
>>> m = Map(gdf, bbox=[90000, 6040000, 1280000, 7150000],
...         width=800, height=900, facecolor="#B9D9EB")
>>> m.add(BasemapLayer())
>>> m.title("France métropolitaine")
>>> m.render("map.svg")
"""

import math
import os
import textwrap
import warnings
from typing import Optional, Dict, Any, List, Tuple, Union, TYPE_CHECKING

import shapely

import geopandas as gpd
from shapely.geometry import box

from . import defaults as D
from .config import ConfigManager, Style
from .overflow import OverflowRegistry
from .renderer import ViewBox, SvgViewport, SvgDocument, make_bbox_square
from ._state import MapState

if TYPE_CHECKING:  # resolve forward-ref annotations for docs/type-checkers
    from .layers import Layer
    from .interactive import InteractiveMap


class _Unset:
    """Sentinel for an unset keyword argument.

    Lets a public method expose every option as a named parameter while
    forwarding only the ones the caller actually set. Omitted options stay
    out of the params dict, so each renderer keeps resolving its own
    default value.
    """

    __slots__ = ()

    def __repr__(self) -> str:
        return "<unset>"


_UNSET = _Unset()


def _collect_set(**kwargs: Any) -> Dict[str, Any]:
    """Return only the keyword arguments whose value is not ``_UNSET``."""
    return {k: v for k, v in kwargs.items() if v is not _UNSET}


[docs] class Map: """SVG map builder with viewport-based architecture. Each distinct area (main map, cartouche, zoom) gets its own SvgViewport with independent geographic bounds and clip region. Parameters ---------- gdf : geopandas.GeoDataFrame Base GeoDataFrame defining the map extent and basemap geometry. bbox : list, optional Bounding box [minx, miny, maxx, maxy]. If None, computed from gdf. width : float or "auto" SVG document width in pixels. ``"auto"`` derives the width from the data aspect ratio (the height is then the fixed reference). height : float or "auto" SVG document height in pixels. ``"auto"`` derives the height from the data aspect ratio so the map is not letterboxed (width stays fixed). See also :meth:`autosize`. padding : float Document-level padding in pixels. style : str or dict Style name (``"classic"``, ``"modern"``) or a dict with ``"base"`` key selecting the starting preset and other keys overriding specific style values (e.g. ``"title_color"``). facecolor : str, optional Ocean/background color drawn inside each viewport. background : str, optional Document background color (default white). basemap_color : str, optional Override fill color for basemap polygons. border_radius : float Corner radius in pixels for the main map viewport (0 = sharp). box_shadow : bool or dict, optional Drop-shadow effect on the main map viewport. ``True`` for defaults, dict for custom (dx, dy, blur, color, opacity). glow : bool or dict, optional Outer glow effect on the main map viewport. cartouche_params : dict, optional Cartouche definitions. Keys are integer indices, values are dicts with required keys 'bbox' and 'crs', plus optional 'cartouche_title', 'cartouche_title_size', 'size', 'border_radius', 'box_shadow', 'glow'. cartouche_spacing : float, optional Vertical spacing between cartouches in pixels. If None, uses the default figure-fraction layout (mappyng convention). font_scale : float or ``"auto"``, default ``"auto"`` Global font size multiplier applied to titles, scale bar, and legends. ``"auto"`` computes the scale from the document area so that fonts look consistent across different map sizes. coordinate_precision : int, default 2 Decimal places kept for geometry path coordinates in the SVG output. Lower values shrink the file and speed up parsing at the cost of sub-pixel accuracy. Points that round to the same position are merged, which also reduces dense geometries. seed : int or None, optional Random seed for reproducible stochastic components (currently automatic label placement). Defaults to 42, which keeps maps reproducible across runs. Pass ``None`` for non-deterministic behaviour. A ``seed=`` passed directly to ``LabelLayer`` takes precedence over this map-level seed. """
[docs] def __init__(self, gdf: gpd.GeoDataFrame, bbox: Optional[List[float]] = None, width: Union[float, str] = D.DOCUMENT_WIDTH, height: Union[float, str] = D.DOCUMENT_HEIGHT, padding: float = D.DOCUMENT_PADDING, style: Union[str, Dict[str, Any]] = "classic", facecolor: Optional[str] = None, background: Optional[str] = None, basemap_color: Optional[str] = None, border_radius: float = D.BORDER_RADIUS, box_shadow=None, glow=None, cartouche_params: Optional[Dict[int, Any]] = None, cartouche_spacing: Optional[float] = None, font_scale: Union[float, str] = "auto", coordinate_precision: int = D.DEFAULT_COORDINATE_PRECISION, seed: Optional[int] = 42): if gdf is None or gdf.empty: raise ValueError( "Map requires a non-empty GeoDataFrame: the map bounds and " "geometries are derived from it. Received " f"{'None' if gdf is None else 'an empty GeoDataFrame'}. " "Load your data (e.g. geopandas.read_file(...)) before " "creating the Map." ) self.gdf = gdf self._style_arg = style # original style argument, for serialisation self.style = ConfigManager.get_style(style) self.padding = padding self.facecolor = facecolor self.basemap_color = basemap_color self.cartouche_params = cartouche_params or {} self._cartouche_spacing_override = cartouche_spacing self._coordinate_precision: int = coordinate_precision self._seed: Optional[int] = seed # Main viewport effects self._main_border_radius = border_radius self._main_box_shadow = box_shadow self._main_glow = glow # Geographic bounds self._bbox_arg = list(bbox) if bbox is not None else None # for serialisation if bbox is not None: self.bbox = list(bbox) else: self.bbox = list(gdf.total_bounds) # Document dimensions: "auto" derives the missing dimension from # the data aspect ratio so the content area is not letterboxed. # Resolution needs the bbox, hence it happens here. self._auto_width = (width == "auto") self._auto_height = (height == "auto") width, height = self._resolve_auto_dimensions(width, height) self.width = width self.height = height if font_scale == "auto": import math as _math self.font_scale = D.FONT_SCALE_AUTO_COEFF * _math.sqrt( (width * height) / (D.FONT_SCALE_REFERENCE_WIDTH * D.FONT_SCALE_REFERENCE_HEIGHT) ) else: self.font_scale = float(font_scale) self._font_scale_auto = (font_scale == "auto") # Accumulated state (grouped, see _state.MapState) self._state = MapState() # Build state self._built: bool = False # Structural state (initialised at construction, frozen after build) self._cartouche_viewports: Dict[int, SvgViewport] = {} self._zoom_viewport: Optional[SvgViewport] = None self._zoom_bbox: Optional[List[float]] = None # Create SVG document self.svg = SvgDocument(width=width, height=height) # Document background self._background = background or D.DOCUMENT_BACKGROUND bg = self._background self.svg.add_overlay_rect( "background", 0, 0, width, height, z_order=D.Z_BACKGROUND, fill=bg, ) # Build main first (cartouches align to its content_y) self._build_main_viewport() if self.cartouche_params: self._build_cartouches()
# ------------------------------------------------------------------ # Context manager # ------------------------------------------------------------------ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): pass # ------------------------------------------------------------------ # Internal: build viewports # ------------------------------------------------------------------ def _add_ocean(self, viewport: SvgViewport) -> None: """Add an ocean/facecolor background rect inside a viewport. Uses the content area (after aspect-ratio fitting) so the ocean matches the geographic bbox exactly, without spilling into padding. """ if not self.facecolor: return vb = viewport.viewbox viewport.add_rect("ocean", vb.content_x, vb.content_y, vb.content_width, vb.content_height, z_order=D.Z_OCEAN, fill=self.facecolor) # ------------------------------------------------------------------ # Figure-fraction to SVG pixel helpers # ------------------------------------------------------------------ def _fig_x(self, fx: float) -> float: """Convert figure-fraction x to SVG pixel x.""" return fx * self.width def _fig_y(self, fy: float) -> float: """Convert figure-fraction y (bottom=0) to SVG pixel y (top=0).""" return (1 - fy) * self.height def _fig_w(self, fw: float) -> float: """Convert figure-fraction width to SVG pixel width.""" return fw * self.width def _fig_h(self, fh: float) -> float: """Convert figure-fraction height to SVG pixel height.""" return fh * self.height # ------------------------------------------------------------------ # Dynamic canvas # ------------------------------------------------------------------ def _resolve_auto_dimensions(self, width, height): """Resolve ``"auto"`` document dimensions from the data aspect ratio. The missing dimension is computed so the main content area matches the geographic bounds exactly, leaving only the intentional layout margins (chrome) instead of aspect-ratio letterboxing. Strategy: width is the fixed reference; height follows the data. If only ``height="auto"`` the width is kept; if only ``width="auto"`` the height is kept and the width is derived; if both are ``"auto"`` the default document width is used as the fixed reference. """ w_auto = (width == "auto") h_auto = (height == "auto") if not w_auto and not h_auto: # Numeric dimensions are passed through unchanged (preserving int # vs float) so explicitly-sized maps render byte-identically. return width, height minx, miny, maxx, maxy = self.bbox geo_w = maxx - minx geo_h = maxy - miny if geo_w <= 0 or geo_h <= 0: # Degenerate bbox: fall back to defaults for any auto dimension. w = D.DOCUMENT_WIDTH if w_auto else width h = D.DOCUMENT_HEIGHT if h_auto else height return w, h geo_aspect = geo_h / geo_w # data height / width pad = D.VIEWPORT_PADDING_MAIN if h_auto: # Width fixed, derive height so content area matches data aspect. w = D.DOCUMENT_WIDTH if w_auto else width draw_w = D.LAYOUT_BASEMAP_WIDTH * w - 2 * pad draw_h = draw_w * geo_aspect mh = draw_h + 2 * pad h = mh / D.LAYOUT_BASEMAP_HEIGHT return w, h # Only width is auto: keep height, derive width. h = height draw_h = D.LAYOUT_BASEMAP_HEIGHT * h - 2 * pad draw_w = draw_h / geo_aspect mw = draw_w + 2 * pad w = mw / D.LAYOUT_BASEMAP_WIDTH return w, h
[docs] def autosize(self): """Recompute the document so the content area fits the data exactly. Snaps the height to the current data aspect ratio (width fixed), removing aspect-ratio letterboxing. Must be called before :meth:`add`-ing layers (it rebuilds the canvas and would discard already-rendered content). Returns ``self`` for chaining. """ from .layers import LayerStateError if self._built: raise LayerStateError( "autosize() must be called before the map is built / rendered" ) # The width stays as-is; only the height snaps to the data. _, height = self._resolve_auto_dimensions(self.width, "auto") self.height = height self._auto_height = True if self._font_scale_auto: import math as _math self.font_scale = D.FONT_SCALE_AUTO_COEFF * _math.sqrt( (self.width * self.height) / (D.FONT_SCALE_REFERENCE_WIDTH * D.FONT_SCALE_REFERENCE_HEIGHT) ) self._rebuild_canvas() return self
def _rebuild_canvas(self): """Recreate the SVG document and viewports for the current size.""" self.svg = SvgDocument(width=self.width, height=self.height) self.svg.add_overlay_rect( "background", 0, 0, self.width, self.height, z_order=D.Z_BACKGROUND, fill=self._background, ) self._cartouche_viewports = {} self._build_main_viewport() if self.cartouche_params: self._build_cartouches() # ------------------------------------------------------------------ # Public accessors for accumulated state # ------------------------------------------------------------------ @property def overflow(self) -> OverflowRegistry: """Overflow registry (delegates to internal MapState).""" return self._state.overflow # ------------------------------------------------------------------ # Layer id generation # ------------------------------------------------------------------ def _next_layer_id(self, kind: str) -> int: """Return a unique, per-instance layer id for the given kind. Used internally by the ``Layer`` render paths (choropleth, proportional, situation...) and :meth:`add_shadow`. The counter starts at 1 per kind and per ``Map`` instance, so two distinct maps never share ids and test order has no effect on generated SVG ids. Conventional kinds are ``"choropleth"``, ``"proportional"``, ``"situation"`` and ``"shadow"``. An unknown kind is accepted without warning: the helper creates the entry at 0 then increments. Parameters ---------- kind : str Layer kind (e.g. ``"choropleth"``, ``"proportional"``, ``"situation"``). Returns ------- int Next available id for this kind. """ self._state.layer_counters[kind] = self._state.layer_counters.get(kind, 0) + 1 return self._state.layer_counters[kind] # ------------------------------------------------------------------ # First-class Layer API # ------------------------------------------------------------------
[docs] def add(self, layer: "Layer") -> "Layer": """Register a first-class :class:`~mappyng.layers.Layer`. The layer is validated and assigned a unique id, then queued for rendering when the map is built (deferred, in ``z_index`` order). Parameters ---------- layer : Layer The layer to add. Returns ------- Layer The same layer, now carrying its assigned ``id``. Raises ------ RuntimeError If the map has already been built (its SVG is final, so a new layer would not appear). TypeError If ``layer`` is not a :class:`~mappyng.layers.Layer`. """ from .layers import Layer as _Layer if not isinstance(layer, _Layer): raise TypeError( f"Map.add expects a Layer instance, got " f"{type(layer).__name__}. Build one first, e.g. " f"m.add(ChoroplethLayer(gdf, column='density'))." ) if self._built: raise RuntimeError( "This map has already been built; its SVG is final, so a " "newly added layer would not appear. Add all layers before " "rendering (m.render(...) / m.to_string())." ) # The layer was already validated at construction (and on update); # re-validating here would re-emit semiology warnings twice. # Public Layer.id uses a separate counter namespace so it never # consumes the per-kind counter the render path relies on for # internal SVG element ids (which would shift them). n = self._next_layer_id(f"_public:{layer.kind}") layer.id = f"{layer.kind}-{n}" self._state.layers.append(layer) return layer
[docs] def remove(self, layer_or_id: "Union[Layer, str]") -> None: """Remove a registered layer, by object or by id. Parameters ---------- layer_or_id : Layer or str The layer instance or its ``id``. Raises ------ KeyError If no matching layer is registered. RuntimeError If the map has already been built. """ if self._built: raise RuntimeError( "This map has already been built; layers can no longer be " "removed because the SVG is final." ) layers = self._state.layers for i, lyr in enumerate(layers): if lyr is layer_or_id or lyr.id == layer_or_id: del layers[i] return raise KeyError( f"No layer matching {layer_or_id!r} is registered. Current " f"layer ids: {[lyr.id for lyr in layers]}." )
@property def layers(self) -> "List[Layer]": """The registered first-class layers, in insertion order (copy).""" return list(self._state.layers)
[docs] def get_layer(self, layer_id: str) -> "Layer": """Return a registered layer by its ``id``. Raises ------ KeyError If no layer has that id. """ for lyr in self._state.layers: if lyr.id == layer_id: return lyr raise KeyError( f"No layer with id {layer_id!r}. Current layer ids: " f"{[lyr.id for lyr in self._state.layers]}." )
def _render_layers(self) -> None: """Render registered layers into the SVG. Called once by :meth:`_build`. Layers are drawn in ``z_index`` order; ties keep insertion order (stable sort). Invisible layers and already-rendered layers are skipped. """ from .layers import RenderContext ctx = RenderContext(self) ordered = sorted(self._state.layers, key=lambda lyr: lyr.z_index) for lyr in ordered: if lyr.visible and not lyr._rendered: lyr.render(ctx) # ------------------------------------------------------------------ # Chrome convenience methods # ------------------------------------------------------------------
[docs] def title( self, text: str, *, subtitle: Any = _UNSET, loc: Any = _UNSET, max_chars: Any = _UNSET, font_size: Any = _UNSET, fill: Any = _UNSET, font_weight: Any = _UNSET, font_family: Any = _UNSET, subtitle_font_size: Any = _UNSET, subtitle_color: Any = _UNSET, subtitle_font_weight: Any = _UNSET, top_gap: Any = _UNSET, offset_x: Any = _UNSET, offset_y: Any = _UNSET, ) -> None: """Add a map title above the map content. Parameters ---------- text : str Title text. Wrapped onto several lines past ``max_chars``. subtitle : str, optional Subtitle drawn below the title in a lighter style. loc : str, optional Horizontal alignment: ``"left"`` (default), ``"center"`` or ``"right"``. max_chars : int, optional Maximum characters per line before wrapping. font_size : float, optional Title font size in points (default from style). fill : str, optional Title color as ``#RRGGBB`` (default from style). font_weight : str, optional Title font weight, e.g. ``"bold"`` (default from style). font_family : str, optional Title font family (default from style). subtitle_font_size : float, optional Subtitle font size (default derived from ``font_size``). subtitle_color : str, optional Subtitle color as ``#RRGGBB`` (default: same as ``fill``). subtitle_font_weight : str, optional Subtitle font weight (default ``"normal"``). top_gap : float, optional Vertical gap in pixels between the title baseline and the top of the map content. Positive moves the title up, negative moves it down. Default 8. offset_x : float, optional Extra horizontal shift in pixels (positive = right). Default 0. offset_y : float, optional Extra vertical shift in pixels (positive = down). Default 0. Examples -------- >>> m.title("Population par departement") >>> m.title("Population", subtitle="2024", loc="center", font_size=14) """ params = { "text": text, **_collect_set( subtitle=subtitle, loc=loc, max_chars=max_chars, font_size=font_size, fill=fill, font_weight=font_weight, font_family=font_family, subtitle_font_size=subtitle_font_size, subtitle_color=subtitle_color, subtitle_font_weight=subtitle_font_weight, top_gap=top_gap, offset_x=offset_x, offset_y=offset_y, ), } self._state.chrome["title"] = params self._render_title(params)
[docs] def source( self, text: str, *, layout: Any = _UNSET, position: Any = _UNSET, rotation: Any = _UNSET, font_size: Any = _UNSET, color: Any = _UNSET, opacity: Any = _UNSET, font_style: Any = _UNSET, font_weight: Any = _UNSET, font_family: Any = _UNSET, text_anchor: Any = _UNSET, ) -> None: """Add a source or credit line. Parameters ---------- text : str Source text, rendered in italic by default. Use ``"\\n"`` to split into several lines. layout : str, optional ``"vertical"`` (default), rotated 90 degrees at the right edge and read bottom to top, or ``"horizontal"`` at the bottom-right of the map. position : dict, optional Manual placement as ``{"x": frac, "y": frac}`` relative to the map content area. Overrides the automatic positioning. rotation : float, optional Explicit rotation in degrees. Used only when ``position`` is unset (default 90 for vertical, 0 for horizontal). font_size : float, optional Font size in points (default from style). color : str, optional Text color as ``#RRGGBB`` (default from style). opacity : float, optional Text opacity in ``[0, 1]`` (default from style). font_style : str, optional CSS font style (default ``"italic"``). font_weight : str, optional CSS font weight (default ``"normal"``). font_family : str, optional Font family (default from style). text_anchor : str, optional SVG text anchor: ``"start"``, ``"middle"`` or ``"end"``. Default depends on layout and position. Examples -------- >>> m.source("Source : INSEE") >>> m.source("Source : INSEE", layout="horizontal") >>> m.source("Source : INSEE", position={"x": 0.5, "y": 1.05}) """ params = { "text": text, **_collect_set( layout=layout, position=position, rotation=rotation, font_size=font_size, color=color, opacity=opacity, font_style=font_style, font_weight=font_weight, font_family=font_family, text_anchor=text_anchor, ), } self._state.chrome["source"] = params self._render_source(params)
[docs] def scale_bar( self, *, length: Any = _UNSET, label: Any = _UNSET, unit: Any = _UNSET, unit_factor: Any = _UNSET, location: Any = _UNSET, position: Any = _UNSET, color: Any = _UNSET, bar_color: Any = _UNSET, tick_color: Any = _UNSET, label_color: Any = _UNSET, font_size: Any = _UNSET, font_weight: Any = _UNSET, font_family: Any = _UNSET, bar_height: Any = _UNSET, tick_height: Any = _UNSET, tick_width: Any = _UNSET, margin: Any = _UNSET, opacity: Any = _UNSET, ) -> None: """Add a scale bar. Parameters ---------- length : float, optional Bar length in map CRS units, e.g. metres. If omitted, a round value near 20 percent of the map width is chosen. label : str, optional Text above the bar. Auto-generated from ``length`` if omitted. Pass ``""`` to suppress the label. unit : str, optional Display unit for auto-labels (default ``"km"``). unit_factor : float, optional Divisor from CRS units to display units (default 1000 for metres to km). location : str, optional Preset corner: ``"lower right"``, ``"lower left"``, ``"upper right"`` or ``"upper left"``. position : dict, optional Manual placement ``{"x": frac, "y": frac}`` relative to the content area (overrides ``location``). color : str, optional Color for bar, ticks and label (default from style). bar_color : str, optional Bar line color (overrides ``color``). tick_color : str, optional Tick line color (overrides ``color``). label_color : str, optional Label text color (overrides ``color``). font_size : float, optional Label font size (default 8). font_weight : str, optional Label font weight (default ``"normal"``). font_family : str, optional Label font family (default sans-serif). bar_height : float, optional Bar thickness in SVG pixels (default 3). tick_height : float, optional Tick height in SVG pixels (default ``bar_height + 3``). tick_width : float, optional Tick stroke width (default 1). margin : float, optional Distance from the content edge in pixels (default 15). opacity : float, optional Opacity of bar, ticks and label in ``[0, 1]`` (default 1). Examples -------- >>> m.scale_bar() >>> m.scale_bar(length=200000, label="200 km") >>> m.scale_bar(position={"x": 0.05, "y": 0.95}) """ params = _collect_set( length=length, label=label, unit=unit, unit_factor=unit_factor, location=location, position=position, color=color, bar_color=bar_color, tick_color=tick_color, label_color=label_color, font_size=font_size, font_weight=font_weight, font_family=font_family, bar_height=bar_height, tick_height=tick_height, tick_width=tick_width, margin=margin, opacity=opacity, ) self._state.chrome["scale_bar"] = params self._render_scale_bar(params)
[docs] def north_arrow( self, *, location: Any = _UNSET, position: Any = _UNSET, size: Any = _UNSET, color: Any = _UNSET, margin: Any = _UNSET, label: Any = _UNSET, font_size: Any = _UNSET, stroke_width: Any = _UNSET, rotation: Any = _UNSET, ) -> None: """Add a discreet north arrow. By convention the top of the map is north, so a north arrow is only needed to lift an ambiguity. The arrow points up; pass ``rotation`` only when the map itself is rotated. Parameters ---------- location : str, optional Preset corner: ``"upper right"`` (default), ``"upper left"``, ``"lower right"`` or ``"lower left"``. position : dict, optional Manual placement ``{"x": frac, "y": frac}`` relative to the content area (overrides ``location``). size : float, optional Total arrow height in SVG pixels (default 24). color : str, optional Color for shaft, arrowhead and label. margin : float, optional Distance from the content edge in pixels (default 15). label : str, optional Text above the arrow (default ``"N"``; pass ``""`` to hide it). font_size : float, optional Label font size (default 9). stroke_width : float, optional Shaft stroke width (default 1). rotation : float, optional Manual rotation in degrees (default 0, points up). Examples -------- >>> m.north_arrow() >>> m.north_arrow(location="lower left") >>> m.north_arrow(position={"x": 0.92, "y": 0.08}) """ params = _collect_set( location=location, position=position, size=size, color=color, margin=margin, label=label, font_size=font_size, stroke_width=stroke_width, rotation=rotation, ) self._state.chrome["north_arrow"] = params self._render_north_arrow(params or None)
# ------------------------------------------------------------------ # Build viewports # ------------------------------------------------------------------ def _build_main_viewport(self) -> None: """Create the main map viewport using figure-fraction layout.""" mx = self._fig_x(D.LAYOUT_BASEMAP_X0) my = self._fig_y(D.LAYOUT_BASEMAP_Y0 + D.LAYOUT_BASEMAP_HEIGHT) mw = self._fig_w(D.LAYOUT_BASEMAP_WIDTH) mh = self._fig_h(D.LAYOUT_BASEMAP_HEIGHT) vb = ViewBox( geo_bounds=tuple(self.bbox), svg_x=mx, svg_y=my, svg_width=max(mw, 1), svg_height=max(mh, 1), padding=D.VIEWPORT_PADDING_MAIN, precision=self._coordinate_precision, ) self.main = SvgViewport( "main", vb, border_radius=self._main_border_radius, box_shadow=self._main_box_shadow, glow=self._main_glow, ) self._add_ocean(self.main) self.svg.add_viewport(self.main) def _build_cartouches(self) -> None: """Create cartouche viewports to the left of the basemap. Aligned to main.viewbox.content_y (the actual top of the geographic content after aspect-ratio fitting), not the viewport top. This ensures cartouches start at the same visual level as the map content. Per-cartouche keys in ``cartouche_params[i]``: - ``bbox``, ``crs`` (required) - ``cartouche_title``, ``cartouche_title_size`` - ``size`` (float): scale factor relative to the default cartouche size (default 1.0). E.g. 1.5 = 50 % larger. - ``spacing`` (float): vertical spacing **above** this cartouche, in pixels. Overrides the global default for this cartouche only. - ``border_radius`` (float): corner radius in pixels (0 = sharp). - ``border_color`` (str): override border stroke color. - ``border_width`` (float): override border stroke width. - ``box_shadow`` (bool or dict): drop shadow behind the cartouche. ``True`` for defaults; dict to override keys (dx, dy, blur, color, opacity). - ``glow`` (bool or dict): outer glow effect. """ if not self.cartouche_params: return # Default cartouche size in figure fraction (square) cart_frac = D.LAYOUT_CARTOUCHE_SIZE * D.LAYOUT_BASEMAP_HEIGHT default_w_px = self._fig_w(cart_frac) default_h_px = self._fig_h(cart_frac) # Default vertical spacing between cartouches (in pixels) if self._cartouche_spacing_override is not None: default_v_spacing = self._cartouche_spacing_override default_title_gap = 0 else: default_v_spacing = D.LAYOUT_CARTOUCHE_VSPACING * self.height default_title_gap = D.LAYOUT_CARTOUCHE_TITLE_GAP * self.height # Vertical: align to main content top + small title offset title_offset_px = D.LAYOUT_CARTOUCHE_TITLE_OFFSET * self.height y_cursor = self.main.viewbox.content_y + title_offset_px sorted_keys = sorted(self.cartouche_params.keys()) for i, index in enumerate(sorted_keys): params = self.cartouche_params[index] if 'bbox' not in params or 'crs' not in params: warnings.warn(f"Cartouche {index}: missing 'bbox' or 'crs', skipping") continue title = params.get('cartouche_title', '') title_size = params.get('cartouche_title_size', D.CARTOUCHE_TITLE_SIZE) * self.font_scale # Per-cartouche size (scale factor, default 1.0) scale = params.get('size', 1.0) cart_w_px = default_w_px * scale cart_h_px = default_h_px * scale # Horizontal position: left of basemap content cart_x_px = (self.main.viewbox.content_x - cart_w_px - D.LAYOUT_CARTOUCHE_HSPACING * self.width) # Per-cartouche spacing above (pixels) if i > 0: spacing = params.get('spacing', default_v_spacing) y_cursor += spacing # Extra gap if this cartouche has a title if title and 'spacing' not in params: y_cursor += default_title_gap # Title space above cartouche title_h = (title_size + D.CARTOUCHE_TITLE_PADDING) if title else 0 cart_y_px = y_cursor + title_h cart_bbox = make_bbox_square(params['bbox']) vb = ViewBox( geo_bounds=tuple(cart_bbox), svg_x=cart_x_px, svg_y=cart_y_px, svg_width=cart_w_px, svg_height=cart_h_px, padding=D.VIEWPORT_PADDING_CARTOUCHE, precision=self._coordinate_precision, ) vp = SvgViewport( f"cartouche_{index}", vb, border=True, border_color=params.get('border_color', self.style.config.layout_edge_color), border_width=params.get('border_width', D.CARTOUCHE_BORDER_WIDTH), border_radius=params.get('border_radius', D.BORDER_RADIUS), box_shadow=params.get('box_shadow', None), glow=params.get('glow', None), ) self._add_ocean(vp) self._cartouche_viewports[index] = vp self.svg.add_viewport(vp) # Title text above cartouche if title: tx = cart_x_px + cart_w_px / 2 ty = cart_y_px - D.CARTOUCHE_TITLE_MARGIN_ABOVE self.svg.add_overlay_text( "cartouche_titles", tx, ty, title, z_order=D.Z_CARTOUCHE_TITLE, text_anchor="middle", font_size=title_size, fill=self.style["title_color"], font_family=D.FONT_FAMILY, ) # Advance cursor past this cartouche y_cursor = cart_y_px + cart_h_px self._fix_cartouche_overflow() def _fix_cartouche_overflow(self) -> None: """Register cartouche viewport bounds into the overflow registry.""" for vp in self._cartouche_viewports.values(): self.overflow.register( vp.viewbox.svg_x, vp.viewbox.svg_y, vp.viewbox.svg_x + vp.viewbox.svg_width, vp.viewbox.svg_y + vp.viewbox.svg_height, ) # ------------------------------------------------------------------ # Basemap # ------------------------------------------------------------------ def _render_basemap(self, fill: Optional[str] = None, stroke: Optional[str] = None, stroke_width: Optional[float] = None) -> None: """Draw the basemap polygons on the main viewport and all cartouches. Parameters ---------- fill : str, optional Fill color (default from style or basemap_color override). stroke : str, optional Outline color (default from style). stroke_width : float, optional Outline width (default from style). """ fill = fill or self.basemap_color or self.style["basemap_fill_color"] stroke = stroke or self.style["basemap_edge_color"] sw = stroke_width or self.style["basemap_edge_width"] z = self.style.config.z_order.BASEMAP attrs = dict(fill=fill, stroke=stroke, stroke_width=sw) # Main map, clip to bbox gdf_clip = self._clip_gdf(self.gdf, self.bbox) if not gdf_clip.empty: self.main.draw_geodataframe("basemap", gdf_clip, z_order=z, **attrs) # Cartouches for index, vp in self._cartouche_viewports.items(): params = self.cartouche_params[index] cart_gdf = self._prepare_cartouche_data(self.gdf, params) if not cart_gdf.empty: vp.draw_geodataframe("basemap", cart_gdf, z_order=z, **attrs) # Zoom if self._zoom_viewport and self._zoom_bbox: zoom_gdf = self._clip_gdf(self.gdf, self._zoom_bbox) if not zoom_gdf.empty: self._zoom_viewport.draw_geodataframe( "basemap", zoom_gdf, z_order=z, **attrs) # ------------------------------------------------------------------ # Graticules # ------------------------------------------------------------------ def _render_graticule(self, params: Optional[Dict[str, Any]] = None) -> None: """Draw graticule lines (meridians and parallels) on all viewports. Parameters ---------- params : dict, optional - ``step`` (float): spacing in degrees (default 10.0). - ``stroke`` (str): line color (default ``'#aaaaaa'``). - ``opacity`` (float): stroke opacity (default 0.5). - ``stroke_width`` (float): line width (default 0.5). - ``dash`` (str): SVG stroke-dasharray (default ``'4 2'``). - ``position`` (str): ``'background'`` (default) draws the graticule above the ocean but below the basemap and data; ``'top'`` draws it over everything, which suits globe-style maps where the grid should cross the continents. - ``z`` (int): explicit stacking order, overriding ``position``. Lets the grid be slotted between custom layers, e.g. above an ocean fill but below the land. Example:: m.add(GraticuleLayer(step=5.0, stroke="#888888", opacity=0.4)) """ cfg = params or {} step = float(cfg.get("step", 10.0)) stroke = cfg.get("stroke", "#aaaaaa") opacity = float(cfg.get("opacity", 0.5)) stroke_width = float(cfg.get("stroke_width", 0.5)) dash = cfg.get("dash", "4 2") position = cfg.get("position", "background") z_override = cfg.get("z", None) if z_override is not None: z = int(z_override) elif position == "top": z = self.style.config.z_order.LAYER_TOP else: z = self.style.config.z_order.GRATICULES attrs = dict(fill="none", stroke=stroke, stroke_width=stroke_width, stroke_opacity=opacity, stroke_dasharray=dash) # Main viewport grat = self._build_graticule_gdf(self.bbox, self.gdf.crs, step) if grat is not None and not grat.empty: clipped = self._clip_gdf(grat, self.bbox) if not clipped.empty: self.main.draw_geodataframe("graticule", clipped, z_order=z, **attrs) # Zoom viewport if self._zoom_viewport and self._zoom_bbox: grat_z = self._build_graticule_gdf(self._zoom_bbox, self.gdf.crs, step) if grat_z is not None and not grat_z.empty: clipped_z = self._clip_gdf(grat_z, self._zoom_bbox) if not clipped_z.empty: self._zoom_viewport.draw_geodataframe( "graticule", clipped_z, z_order=z, **attrs) # Cartouche viewports for index, vp in self._cartouche_viewports.items(): cart_params = self.cartouche_params[index] cart_crs = cart_params.get("crs") or self.gdf.crs cart_bbox = cart_params.get("bbox") or self.bbox grat_c = self._build_graticule_gdf(cart_bbox, cart_crs, step) if grat_c is not None and not grat_c.empty: clip_box = box(*cart_bbox) try: clipped_c = grat_c.clip(mask=clip_box) clipped_c = clipped_c[~clipped_c.is_empty] except Exception: clipped_c = grat_c if not clipped_c.empty: vp.draw_geodataframe("graticule", clipped_c, z_order=z, **attrs) def _build_graticule_gdf(self, bbox: List[float], crs: Any, step: float) -> Optional["gpd.GeoDataFrame"]: """Generate meridian + parallel lines in the map CRS, clipped to bbox. Each line is generated in EPSG:4326 with intermediate points and then reprojected, so meridians and parallels bend correctly in curved projections (for example orthographic). Projections whose frame cannot be expressed as a lon/lat box (the orthographic disc reprojects to an infinite extent) fall back to the whole globe; vertices that land on invalid coordinates (the hidden hemisphere) are dropped, and each line is split into its visible segments. Returns None if too many lines would be produced (>1000). """ import numpy as np from shapely.geometry import LineString # Reproject bbox corners to EPSG:4326 to find the lat/lon extent. try: bbox_gdf = gpd.GeoDataFrame( geometry=[box(*bbox)], crs=crs ).to_crs("EPSG:4326") b = bbox_gdf.total_bounds # [minx, miny, maxx, maxy] in lon/lat except Exception: warnings.warn( "GraticuleLayer: could not reproject the map bbox to " "EPSG:4326, so no graticule was drawn. Check that the map " "data has a CRS set." ) return None if not np.all(np.isfinite(b)): # The frame has no finite lon/lat box (e.g. an orthographic disc # whose corners fall beyond the limb): cover the whole globe and # let the invalid-vertex pruning below keep the visible part. lon_min, lat_min, lon_max, lat_max = -180.0, -90.0, 180.0, 90.0 else: lon_min, lat_min, lon_max, lat_max = b # Extend slightly beyond bbox for clean line coverage. pad = step * 0.1 lon_ticks = np.arange( np.ceil((lon_min - pad) / step) * step, lon_max + pad + step, step, ) lat_ticks = np.arange( np.ceil((lat_min - pad) / step) * step, lat_max + pad + step, step, ) # Clamp to valid lat/lon range. Parallels at the poles are degenerate. lon_ticks = lon_ticks[(lon_ticks >= -180) & (lon_ticks <= 180)] lat_ticks = lat_ticks[(lat_ticks > -90) & (lat_ticks < 90)] # Densify each line so it follows the projection's curvature. The # along-line sampling stays clamped just inside the poles. lat_lo, lat_hi = max(lat_min - pad, -89.5), min(lat_max + pad, 89.5) lon_lo, lon_hi = max(lon_min - pad, -180.0), min(lon_max + pad, 180.0) lat_samples = np.linspace(lat_lo, lat_hi, max(2, int((lat_hi - lat_lo) / 2) + 1)) lon_samples = np.linspace(lon_lo, lon_hi, max(2, int((lon_hi - lon_lo) / 2) + 1)) lines = [] for lon in lon_ticks: lines.append(LineString([(lon, la) for la in lat_samples])) for lat in lat_ticks: lines.append(LineString([(lo, lat) for lo in lon_samples])) if len(lines) > 1000: warnings.warn( "GraticuleLayer: the requested 'step' would draw over 1000 " "lines, so the graticule was skipped. Increase 'step' (the " "spacing in degrees between graticule lines)." ) return None if not lines: return None gdf = gpd.GeoDataFrame(geometry=lines, crs="EPSG:4326") try: gdf = gdf.to_crs(crs) except Exception as e: warnings.warn( f"GraticuleLayer: reprojecting the graticule to the map CRS " f"failed ({e}); no graticule was drawn." ) return None return self._prune_nonfinite_lines(gdf) @staticmethod def _prune_nonfinite_lines( gdf: "gpd.GeoDataFrame") -> Optional["gpd.GeoDataFrame"]: """Drop non-finite vertices and split lines into their finite runs. Curved projections (orthographic) send hidden-hemisphere points to infinite coordinates. Keeping only the contiguous runs of finite vertices leaves the visible arcs of each meridian and parallel. """ import numpy as np from shapely.geometry import LineString out = [] for geom in gdf.geometry: if geom is None or geom.is_empty: continue parts = (geom.geoms if geom.geom_type == "MultiLineString" else [geom]) for part in parts: coords = np.asarray(part.coords) finite = np.isfinite(coords).all(axis=1) run = [] for keep, xy in zip(finite, coords): if keep: run.append((xy[0], xy[1])) elif len(run) >= 2: out.append(LineString(run)) run = [] else: run = [] if len(run) >= 2: out.append(LineString(run)) if not out: return None return gpd.GeoDataFrame(geometry=out, crs=gdf.crs) # ------------------------------------------------------------------ # Tiles # ------------------------------------------------------------------ def _render_tile(self, params: Dict[str, Any] = None) -> None: """Add a web tile basemap (OpenStreetMap, CartoDB, ESRI, Stadia...). Tiles are downloaded in XYZ format, assembled with Pillow, and embedded as base64 PNG ``<image>`` elements. The tile mosaic is placed below the basemap by default (z_order < 0). Requires: ``pip install mercantile Pillow`` Parameters (dict keys) ---------------------- source : str, default ``"cartodb_positron"`` Provider alias or custom URL template with ``{x}``, ``{y}``, ``{z}`` placeholders. Built-in aliases: - OpenStreetMap: ``"openstreetmap"`` / ``"osm"`` - CartoDB: ``"cartodb_positron"`` / ``"positron"``, ``"cartodb_darkmatter"`` / ``"darkmatter"``, ``"cartodb_voyager"`` / ``"voyager"`` - Stadia/Stamen: ``"terrain"``, ``"toner"``, ``"watercolor"`` - ESRI: ``"satellite"`` / ``"esri_worldimagery"``, ``"esri_worldstreet"``, ``"esri_natgeo"``, ``"esri_ocean"`` - OpenTopoMap: ``"opentopomap"`` / ``"topo"`` zoom : int or ``"auto"`` Zoom level (``"auto"`` picks the smallest zoom where the map area spans at least the viewport width in tile pixels). zoom_offset : int, default 0 Added to the auto-calculated zoom level (e.g. ``-1`` to fetch lighter, ``+1`` for more detail). opacity : float, default 1.0 Tile opacity (0 = transparent, 1 = opaque). on_cartouches : bool, default True Render tiles on cartouche viewports. on_zoom : bool, default True Render tiles on the zoom viewport. cache : bool, default True Cache tiles on disk in ``~/.cache/mappyng/tiles``. max_tiles : int, default 64 Safety cap on tiles downloaded per viewport. extra_zoom_levels : int, default 0 Number of coarser zoom levels to also fetch (0-3 recommended). Levels are composited coarse to fine: finer tiles overwrite coarser ones where they exist; coarser tiles fill gaps where finer downloads failed. Example: if auto-zoom is 8 and ``extra_zoom_levels=2``, levels 6, 7, 8 are fetched and blended. Examples:: m.add(TileLayer()) # CartoDB Positron, auto-zoom m.add(TileLayer(source="satellite")) # ESRI imagery m.add(TileLayer(source="terrain", opacity=0.7, zoom_offset=-1)) m.add(TileLayer(source="https://tile.openstreetmap.org/{z}/{x}/{y}.png")) """ from .tile import TileConfig, add_tile as _add_tile cfg_dict = dict(params or {}) cfg = TileConfig(**cfg_dict) _add_tile(self, cfg) # ------------------------------------------------------------------ # Raster # ------------------------------------------------------------------ def _render_raster(self, params: Dict[str, Any]) -> None: """Add raster backgrounds (GeoTIFF) to the map. Each viewport picks the raster file whose geographic footprint overlaps its bbox, so ``antilles.tif`` is used for Guadeloupe and Martinique cartouches, ``reunion.tif`` for Réunion, etc. Parameters (dict keys) ---------------------- paths : dict Region name to GeoTIFF path. Example:: { "metropole": "data/MNT/BDTOPO2018_gen3_NC.tif", "antilles": "data/MNT/antilles.tif", "guyane": "data/MNT/guyane.tif", "reunion": "data/MNT/reunion.tif", } opacity : float Raster opacity 0-1 (default 1.0). on_zoom : bool Render on zoom viewport (default True). on_cartouches : bool Render on cartouche viewports (default True). Example:: m.add(RasterLayer( paths={ "metropole": "data/MNT/BDTOPO2018_gen3_NC.tif", "antilles": "data/MNT/antilles.tif", }, opacity=0.6, )) """ from .raster import add_raster as _add_raster cfg = dict(params) _add_raster( self, paths=cfg.pop("paths"), opacity=cfg.pop("opacity", 1.0), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), ) def _get_main_crs(self): """Return the CRS EPSG code of the base GeoDataFrame.""" if self.gdf.crs is None: return 4326 try: return self.gdf.crs.to_epsg() except Exception: return self.gdf.crs # ------------------------------------------------------------------ # Layers # ------------------------------------------------------------------ def _render_vector(self, gdf: gpd.GeoDataFrame, params: Optional[Dict[str, Any]] = None) -> None: """Add an extra vector layer. Parameters ---------- gdf : GeoDataFrame Geometries to draw. params : dict, optional Layer configuration: - ``position`` (str): ``'top'`` or ``'background'`` (default ``'background'``). - ``z`` (int): explicit stacking order, overriding ``position``. Useful to interleave custom layers, e.g. a graticule between an ocean fill and the land. - ``fill`` (str): fill color (default ``'none'``). - ``stroke`` (str): stroke color. - ``stroke_width`` (float): stroke width. - ``fill_opacity`` (float): fill opacity. - ``stroke_opacity`` (float): stroke opacity. Drawing a wide, faint stroke under a thin bright one fakes a glowing line. - ``stroke_linecap`` (str): ``'butt'``, ``'round'`` or ``'square'``. - ``stroke_dasharray`` (str): SVG dash pattern, e.g. ``'4 2'``. - ``on_zoom`` (bool): render on zoom (default False). - ``on_cartouches`` (bool): render on cartouches (default False). Example:: m.add(VectorLayer(gdf_reg, stroke="#333", stroke_width=0.4, position="top", on_cartouches=True)) """ cfg = dict(params or {}) position = cfg.pop("position", "background") fill = cfg.pop("fill", "none") stroke = cfg.pop("stroke", None) or self.style["edge_color"] stroke_width = cfg.pop("stroke_width", None) or self.style["edge_width"] fill_opacity = cfg.pop("fill_opacity", None) stroke_opacity = cfg.pop("stroke_opacity", None) stroke_linecap = cfg.pop("stroke_linecap", None) stroke_dasharray = cfg.pop("stroke_dasharray", None) on_zoom = cfg.pop("on_zoom", False) on_cartouches = cfg.pop("on_cartouches", False) z = self.style.config.z_order z_override = cfg.pop("z", None) if z_override is not None: z_order = int(z_override) else: z_order = z.LAYER_TOP if position == "top" else z.LAYER_BACKGROUND attrs = dict(fill=fill, stroke=stroke, stroke_width=stroke_width) if fill_opacity is not None: attrs["fill_opacity"] = fill_opacity if stroke_opacity is not None: attrs["stroke_opacity"] = stroke_opacity if stroke_linecap is not None: attrs["stroke_linecap"] = stroke_linecap if stroke_dasharray is not None: attrs["stroke_dasharray"] = stroke_dasharray layer_name = f"layer_{position}" # Main gdf_clip = self._clip_gdf(gdf, self.bbox) if not gdf_clip.empty: self.main.draw_geodataframe(layer_name, gdf_clip, z_order=z_order, **attrs) # Zoom if on_zoom and self._zoom_viewport and self._zoom_bbox: zoom_gdf = self._clip_gdf(gdf, self._zoom_bbox) if not zoom_gdf.empty: self._zoom_viewport.draw_geodataframe( layer_name, zoom_gdf, z_order=z_order, **attrs) # Cartouches if on_cartouches: for index, vp in self._cartouche_viewports.items(): params = self.cartouche_params[index] cart_gdf = self._prepare_cartouche_data(gdf, params) if not cart_gdf.empty: vp.draw_geodataframe(layer_name, cart_gdf, z_order=z_order, **attrs) # ------------------------------------------------------------------ # Shadow # ------------------------------------------------------------------
[docs] def add_shadow(self, gdf: gpd.GeoDataFrame, params: Optional[Dict[str, Any]] = None) -> None: """Add a drop-shadow layer for the given geometries. Parameters ---------- gdf : GeoDataFrame Geometries to shadow. params : dict, optional Shadow configuration: - ``query`` (str): pandas query to filter gdf (e.g. ``"code_pays_iso3=='FRA'"``). - ``dx`` (float): horizontal offset px. - ``dy`` (float): vertical offset px. - ``stdDeviation`` (float): blur radius. - ``color`` (str): shadow color. - ``opacity`` (float): shadow opacity 0-1. - ``fill`` (str): fill of the shadow shape. - ``on_cartouches`` (bool): propagate to cartouches (default True). - ``on_zoom`` (bool): propagate to zoom (default False). Example:: m.add_shadow(gdf_ue, {"query": "code_pays_iso3=='FRA'", "on_cartouches": True}) """ import xml.etree.ElementTree as ET cfg = dict(params or {}) query = cfg.pop("query", None) on_cartouches = cfg.pop("on_cartouches", True) on_zoom = cfg.pop("on_zoom", False) sid = self._next_layer_id("shadow") filter_id = f"shadow-{sid}" dx = cfg.pop("dx", D.SHADOW_DX) dy = cfg.pop("dy", D.SHADOW_DY) std = cfg.pop("stdDeviation", D.SHADOW_STD_DEVIATION) color = cfg.pop("color", D.SHADOW_COLOR) opacity = cfg.pop("opacity", D.SHADOW_OPACITY) fill = cfg.pop("fill", D.SHADOW_FILL) # Create SVG filter in <defs> # Use primitive chain instead of feDropShadow for broad compatibility margin = D.SHADOW_FILTER_MARGIN filt = ET.SubElement(self.svg._defs, "filter", id=filter_id) filt.set("x", f"-{margin}%") filt.set("y", f"-{margin}%") filt.set("width", f"{100 + 2 * margin}%") filt.set("height", f"{100 + 2 * margin}%") # 1. Blur the source alpha blur = ET.SubElement(filt, "feGaussianBlur") blur.set("in", "SourceAlpha") blur.set("stdDeviation", str(std)) blur.set("result", "blur") # 2. Offset the blurred shape offset = ET.SubElement(filt, "feOffset") offset.set("in", "blur") offset.set("dx", str(dx)) offset.set("dy", str(dy)) offset.set("result", "offsetBlur") # 3. Flood with shadow color + opacity flood = ET.SubElement(filt, "feFlood") flood.set("flood-color", color) flood.set("flood-opacity", str(opacity)) flood.set("result", "shadowColor") # 4. Clip flood to the blurred shape comp = ET.SubElement(filt, "feComposite") comp.set("in", "shadowColor") comp.set("in2", "offsetBlur") comp.set("operator", "in") comp.set("result", "shadow") # 5. Merge: shadow underneath, then source graphic on top merge = ET.SubElement(filt, "feMerge") m1 = ET.SubElement(merge, "feMergeNode") m1.set("in", "shadow") m2 = ET.SubElement(merge, "feMergeNode") m2.set("in", "SourceGraphic") # Filter gdf data = gdf.query(query) if query else gdf if data.empty: return # Dissolve to get a single outline dissolved = data.dissolve() z = self.style.config.z_order.SHADOW attrs = dict(fill=fill, stroke="none", filter=f"url(#{filter_id})") # Main viewport clipped = self._clip_gdf(dissolved, self.bbox) if not clipped.empty: self.main.draw_geodataframe( f"shadow_{sid}", clipped, z_order=z, **attrs) # Cartouches if on_cartouches: for index, vp in self._cartouche_viewports.items(): params = self.cartouche_params[index] cart_gdf = self._prepare_cartouche_data(dissolved, params) if not cart_gdf.empty: vp.draw_geodataframe( f"shadow_{sid}", cart_gdf, z_order=z, **attrs) # Zoom if on_zoom and self._zoom_viewport and self._zoom_bbox: zoom_gdf = self._clip_gdf(dissolved, self._zoom_bbox) if not zoom_gdf.empty: self._zoom_viewport.draw_geodataframe( f"shadow_{sid}", zoom_gdf, z_order=z, **attrs)
# ------------------------------------------------------------------ # Gradients # ------------------------------------------------------------------
[docs] def add_radial_gradient( self, stops: List[Tuple[float, str]], *, gradient_id: Optional[str] = None, cx: float = 0.5, cy: float = 0.5, r: float = 0.5, fx: Optional[float] = None, fy: Optional[float] = None, units: str = "objectBoundingBox", spread: str = "pad", ) -> str: """Define a radial gradient and return a fill reference. The returned string is a ``"url(#id)"`` usable as the ``fill`` of any layer. Offsetting the focal point (``fx``, ``fy``) from the centre fakes a highlight, turning a flat disc into a lit sphere. Parameters ---------- stops : list of tuple Colour stops from centre to edge: ``(offset, color)`` or ``(offset, color, opacity)``, ``offset`` in ``[0, 1]``. gradient_id : str, optional Explicit element id. A unique id is generated if omitted. cx, cy : float, default 0.5 Centre of the gradient. In the default ``"objectBoundingBox"`` units these are fractions of the filled shape's bounding box. r : float, default 0.5 Radius of the gradient. fx, fy : float, optional Focal point. Offsetting it from the centre fakes a highlight. Defaults to ``(cx, cy)``. units : str, default ``"objectBoundingBox"`` ``"objectBoundingBox"`` (fractions of the shape) or ``"userSpaceOnUse"`` (absolute SVG coordinates). spread : str, default ``"pad"`` ``spreadMethod``: ``"pad"``, ``"reflect"`` or ``"repeat"``. Returns ------- str A ``"url(#id)"`` string. Examples -------- .. code-block:: python ocean = m.add_radial_gradient( [(0.0, "#bfe6ff"), (1.0, "#0b3a57")], fx=0.35, fy=0.35) m.add(VectorLayer(disc, fill=ocean)) """ return self.svg.add_radial_gradient( stops, gradient_id=gradient_id, cx=cx, cy=cy, r=r, fx=fx, fy=fy, units=units, spread=spread, )
# ------------------------------------------------------------------ # Zoom # ------------------------------------------------------------------
[docs] def add_zoom(self, params: Dict[str, Any]) -> SvgViewport: """Add a zoom window showing a magnified area. Parameters (dict keys) ---------------------- bbox : list Geographic bounding box [minx, miny, maxx, maxy] (required). x, y : float, optional Position in SVG pixels (defaults to mappyng figure-fraction). width, height : float, optional Zoom window size in pixels. border_color : str, optional Border color. border_width : float Border stroke width. border_radius : float Corner radius in pixels (0 = sharp corners). box_shadow : bool or dict Drop shadow behind the zoom window. ``True`` for defaults; dict to override keys (dx, dy, blur, color, opacity). glow : bool or dict Outer glow effect. Returns ------- SvgViewport Example:: m.add_zoom({"bbox": [586377, 6780533, 739396, 6904563], "border_radius": 8, "box_shadow": True}) """ cfg = dict(params) bbox = cfg.pop("bbox") x = cfg.pop("x", None) y = cfg.pop("y", None) width = cfg.pop("width", None) height = cfg.pop("height", None) border_color = cfg.pop("border_color", None) border_width = cfg.pop("border_width", D.ZOOM_BORDER_WIDTH) border_radius = cfg.pop("border_radius", D.BORDER_RADIUS) box_shadow = cfg.pop("box_shadow", None) glow = cfg.pop("glow", None) self._zoom_bbox = list(bbox) zw = width if width is not None else self._fig_w(D.LAYOUT_ZOOM_WIDTH) zh = height if height is not None else self._fig_h(D.LAYOUT_ZOOM_HEIGHT) zx = x if x is not None else self._fig_x(D.LAYOUT_ZOOM_LEFT) zy = y if y is not None else self._fig_y( D.LAYOUT_ZOOM_BOTTOM + D.LAYOUT_ZOOM_HEIGHT) bc = border_color or self.style.config.layout_edge_color vb = ViewBox( geo_bounds=tuple(bbox), svg_x=zx, svg_y=zy, svg_width=zw, svg_height=zh, padding=D.VIEWPORT_PADDING_ZOOM, precision=self._coordinate_precision, ) self._zoom_viewport = SvgViewport( "zoom", vb, border=True, border_color=bc, border_width=border_width, border_radius=border_radius, box_shadow=box_shadow, glow=glow, ) self._add_ocean(self._zoom_viewport) self.svg.add_viewport(self._zoom_viewport) return self._zoom_viewport
# ------------------------------------------------------------------ # Title # ------------------------------------------------------------------ def _render_title(self, params: Dict[str, Any]) -> None: """Add a title (and optional subtitle) above the map. Parameters (dict keys) ---------------------- text : str Title text (required). subtitle : str, optional Subtitle rendered below the title in a lighter style. max_chars : int Max characters per line before wrapping. font_size, fill, font_weight, font_family Title font properties (defaults from style). subtitle_font_size, subtitle_color, subtitle_font_weight Subtitle font properties. top_gap : float Vertical gap in pixels between the title baseline and the top edge of the map content area. Positive values move the title up (away from the map); negative values move it down (toward or into the map). Default: ``TITLE_TOP_GAP`` (8 px). offset_x : float Additional horizontal shift in pixels (positive = right). Default 0. offset_y : float Additional vertical shift in pixels (positive = down). Default 0. Example:: m.title( "Population par département", subtitle="Données fictives, 2024", font_size=14, ) """ cfg = dict(params) text = cfg.pop("text", "") if not text: return subtitle = cfg.pop("subtitle", None) max_chars = cfg.pop("max_chars", D.TITLE_MAX_CHARS) font_size = cfg.pop("font_size", self.style["title_size"]) * self.font_scale fill = cfg.pop("fill", self.style["title_color"]) font_weight = cfg.pop("font_weight", self.style["title_fontweight"]) font_family = cfg.pop("font_family", self.style["title_font_family"]) sub_font_size = cfg.pop("subtitle_font_size", round(font_size * D.TITLE_SUBTITLE_SIZE_RATIO)) sub_color = cfg.pop("subtitle_color", fill) sub_font_weight = cfg.pop("subtitle_font_weight", D.TITLE_SUBTITLE_WEIGHT) offset_x = cfg.pop("offset_x", 0.0) offset_y = cfg.pop("offset_y", 0.0) loc = cfg.pop("loc", "left") lines = textwrap.wrap(text, max_chars) if len(text) > max_chars else [text] line_height = font_size * D.TITLE_LINE_HEIGHT_RATIO vb = self.main.viewbox top_gap = cfg.pop("top_gap", D.TITLE_TOP_GAP) # Horizontal alignment (loc), determines x anchor and text-anchor if loc == "center": x = vb.content_x + vb.content_width / 2 + offset_x text_anchor = "middle" elif loc == "right": x = vb.content_x + vb.content_width + offset_x text_anchor = "end" else: # "left" (default) x = vb.content_x + offset_x text_anchor = "start" # Pre-compute subtitle lines so we can anchor the whole block from the bottom. # The last text element's baseline is fixed at content_y - top_gap; lines above # grow upward, so multi-line titles never overflow into the map. sub_lines = [] sub_lh = sub_font_size * D.TITLE_LINE_HEIGHT_RATIO if subtitle: sub_lines = (textwrap.wrap(subtitle, max_chars) if len(subtitle) > max_chars else [subtitle]) # Bottom-anchor: last baseline sits at content_y - top_gap y_bottom = vb.content_y - top_gap + offset_y if sub_lines: # subtitle block height above y_bottom sub_block = (len(sub_lines) - 1) * sub_lh sub_y = y_bottom - sub_block y_start = sub_y - D.TITLE_SUBTITLE_GAP - (len(lines) - 1) * line_height - line_height else: y_start = y_bottom - (len(lines) - 1) * line_height attrs = { "text_anchor": text_anchor, "font_size": font_size, "fill": fill, "font_weight": font_weight, "font_family": font_family, } attrs.update(cfg) for i, line in enumerate(lines): self.svg.add_overlay_text( "title", x, y_start + i * line_height, line, z_order=D.Z_TITLE, **attrs, ) if sub_lines: for j, sline in enumerate(sub_lines): self.svg.add_overlay_text( "title", x, sub_y + j * sub_lh, sline, z_order=D.Z_TITLE, text_anchor=text_anchor, font_size=sub_font_size, fill=sub_color, font_weight=sub_font_weight, font_family=font_family, ) # Register chrome bbox for label avoidance vb = self.main.viewbox cx0 = vb.content_x cx1 = vb.content_x + vb.content_width cy0 = y_start - font_size # top of first line cap-height (approx) cy1 = y_bottom + sub_lh * 0.3 # bottom of last baseline + descenders self._register_chrome_bbox(cx0, cy0, cx1, cy1) # ------------------------------------------------------------------ # Scale bar # ------------------------------------------------------------------ @staticmethod def _nice_scale_length(max_geo_length: float) -> float: """Pick a round geographic length that fits comfortably on the map. Returns a "nice" value (1, 2, 5 × 10^n) that is <= *max_geo_length*. """ import math if max_geo_length <= 0: return 1.0 exp = math.floor(math.log10(max_geo_length)) base = 10 ** exp for nice in (5, 2, 1): candidate = nice * base if candidate <= max_geo_length: return candidate return base def _render_scale_bar(self, params: Dict[str, Any]) -> None: """Add a scale bar to the map. Parameters (dict keys) ---------------------- length : float, optional Bar length in map CRS units (e.g. meters). If omitted, a round value is chosen automatically (~20 % of map width). label : str, optional Text above the bar. If omitted and *length* is set, an auto-label is generated using *unit* / *unit_factor*. Pass ``""`` to suppress the label entirely. unit : str Display unit appended to auto-labels (default ``"km"``). unit_factor : float Divisor for CRS to display conversion (default ``1000`` for metres to km). location : str Preset position: ``"lower right"``, ``"lower left"``, ``"upper right"``, ``"upper left"``. position : dict, optional Manual placement ``{"x": frac, "y": frac}`` relative to the content area (overrides *location*). color : str, optional Color for bar, ticks and label (default from style). bar_color : str, optional Bar line color (overrides *color*). tick_color : str, optional Tick line color (overrides *color*). label_color : str, optional Label text color (overrides *color*). font_size : int Label font size (default 8). font_weight : str Label font weight (default ``"normal"``). font_family : str Label font family (default sans-serif). bar_height : float Bar thickness in SVG px (default 3). tick_height : float Tick height in SVG px (default ``bar_height + 3``). tick_width : float Tick stroke width (default 1). margin : float Distance from content edge in px (default 15). Examples:: m.scale_bar() # fully automatic m.scale_bar(length=200000, label="200 km") m.scale_bar(length=100000, color="red", bar_height=4, font_size=10) m.scale_bar(position={"x": 0.05, "y": 0.95}) """ cfg = dict(params) vb = self.main.viewbox # --- Length / label --------------------------------------------------- unit = cfg.pop("unit", D.SCALE_BAR_UNIT) unit_factor = cfg.pop("unit_factor", D.SCALE_BAR_UNIT_FACTOR) length = cfg.pop("length", None) if length is None: # Auto: pick a nice round value ~ 20% of map geographic width geo_w = vb.content_width / vb.scale if vb.scale else 1 length = self._nice_scale_length(geo_w * 0.20) label = cfg.pop("label", None) if label is None: # Auto-generate label from length display_val = length / unit_factor if display_val == int(display_val): label = f"{int(display_val)} {unit}" else: label = f"{display_val:.1f} {unit}" if length <= 0: return # --- Styling ---------------------------------------------------------- base_color = cfg.pop("color", None) or self.style.config.scale.color bar_color = cfg.pop("bar_color", None) or base_color tick_color = cfg.pop("tick_color", None) or base_color label_color = cfg.pop("label_color", None) or base_color font_size = cfg.pop("font_size", D.SCALE_BAR_FONT_SIZE) * self.font_scale font_weight = cfg.pop("font_weight", D.SCALE_BAR_FONT_WEIGHT) font_family = cfg.pop("font_family", D.FONT_FAMILY) bar_height = cfg.pop("bar_height", D.SCALE_BAR_HEIGHT) tick_height = cfg.pop("tick_height", None) if tick_height is None: tick_height = bar_height + D.SCALE_BAR_TICK_EXTRA tick_width = cfg.pop("tick_width", D.SCALE_BAR_TICK_WIDTH) margin = cfg.pop("margin", D.SCALE_BAR_MARGIN) opacity = float(cfg.pop("opacity", 1.0)) bar_px = vb.geo_length_to_svg(length) # --- Position --------------------------------------------------------- position = cfg.pop("position", D.SCALE_BAR_POSITION) location = cfg.pop("location", D.SCALE_BAR_LOCATION) if position is not None: # Manual placement if isinstance(position, (list, tuple)) and len(position) == 2: xf, yf = position elif isinstance(position, dict): xf = position.get("x", 0.0) yf = position.get("y", 0.0) else: xf, yf = 0.0, 0.0 x1 = vb.content_x + xf * vb.content_width y = vb.content_y + yf * vb.content_height x2 = x1 + bar_px else: # Preset location if "right" in location: x2 = vb.content_x + vb.content_width - margin x1 = x2 - bar_px else: x1 = vb.content_x + margin x2 = x1 + bar_px if "upper" in location: y = vb.content_y + margin + font_size + D.SCALE_BAR_UPPER_OFFSET else: y = vb.content_y + vb.content_height - margin # --- Draw ------------------------------------------------------------- opacity_kwarg = {"opacity": opacity} if opacity < 1.0 else {} # Bar line self.svg.add_overlay_line( "scale_bar", x1, y, x2, y, z_order=D.Z_SCALE_BAR, stroke=bar_color, stroke_width=bar_height, **opacity_kwarg, ) # End ticks for tx in (x1, x2): self.svg.add_overlay_line( "scale_bar", tx, y - tick_height / 2, tx, y + tick_height / 2, z_order=D.Z_SCALE_BAR, stroke=tick_color, stroke_width=tick_width, **opacity_kwarg, ) # Label if label: label_x = (x1 + x2) / 2 label_y = y - bar_height - D.SCALE_BAR_LABEL_GAP self.svg.add_overlay_text( "scale_bar", label_x, label_y, label, z_order=D.Z_SCALE_BAR, text_anchor="middle", font_size=font_size, fill=label_color, font_family=font_family, font_weight=font_weight, **opacity_kwarg, ) # Register chrome bbox for label avoidance pad = 4 self._register_chrome_bbox( x1 - pad, y - bar_height - font_size - D.SCALE_BAR_LABEL_GAP - pad, x2 + pad, y + bar_height + pad, ) # ------------------------------------------------------------------ # North arrow # ------------------------------------------------------------------ def _render_north_arrow(self, params: Optional[Dict[str, Any]] = None) -> None: """Add a discreet north arrow to the map. By convention the top of the map is north, so a north arrow is only needed to lift an ambiguity. It is therefore optional and drawn discreetly. The arrow points up; pass ``rotation`` only if the map itself is rotated (mappyng does not rotate maps automatically). Parameters (dict keys) ---------------------- location : str Preset corner: ``"upper right"`` (default), ``"upper left"``, ``"lower right"``, ``"lower left"``. position : dict, optional Manual placement ``{"x": frac, "y": frac}`` relative to the content area (overrides *location*). size : float Total arrow height in SVG px (default 24). color : str Color for shaft, arrowhead and label. margin : float Distance from content edge in px (default 15). label : str Text above the arrow (default ``"N"``; pass ``""`` to hide). font_size : int Label font size (default 9). stroke_width : float Shaft stroke width (default 1). rotation : float Manual rotation in degrees (default 0 = points up). Examples -------- >>> m.north_arrow() # upper-right, default >>> m.north_arrow(location="lower left") >>> m.north_arrow(position={"x": 0.92, "y": 0.08}) """ import xml.etree.ElementTree as ET cfg = dict(params or {}) vb = self.main.viewbox # --- Parameters --- size = cfg.pop("size", D.NORTH_ARROW_SIZE) color = cfg.pop("color", D.NORTH_ARROW_COLOR) margin = cfg.pop("margin", D.NORTH_ARROW_MARGIN) label = cfg.pop("label", D.NORTH_ARROW_LABEL) font_size = cfg.pop("font_size", D.NORTH_ARROW_FONT_SIZE) stroke_width = cfg.pop("stroke_width", D.NORTH_ARROW_STROKE_WIDTH) rotation = cfg.pop("rotation", D.NORTH_ARROW_ROTATION) position = cfg.pop("position", D.NORTH_ARROW_POSITION) location = cfg.pop("location", D.NORTH_ARROW_LOCATION) # head = top quarter of size; shaft = remaining three quarters head_h = size * 0.35 shaft_h = size - head_h head_w = size * 0.40 # --- Anchor point (tip of arrowhead) --- if position is not None: if isinstance(position, dict): xf = position.get("x", 0.0) yf = position.get("y", 0.0) else: xf, yf = float(position[0]), float(position[1]) tip_x = vb.content_x + xf * vb.content_width tip_y = vb.content_y + yf * vb.content_height else: cx = vb.content_x + vb.content_width / 2 if "right" in location: tip_x = vb.content_x + vb.content_width - margin else: tip_x = vb.content_x + margin if "upper" in location: tip_y = vb.content_y + margin + (font_size + 2 if label else 0) + head_h else: tip_y = vb.content_y + vb.content_height - margin - shaft_h # Centre x of the arrow cx = tip_x shaft_top_y = tip_y shaft_bot_y = tip_y + shaft_h # --- SVG group --- group = self.svg.add_overlay_group( "north_arrow", z_order=D.Z_NORTH_ARROW, id="north-arrow", ) # Apply rotation around the centroid of the arrow if rotation: centre_y = tip_y + size / 2 group.set("transform", f"rotate({rotation:.2f},{cx:.2f},{centre_y:.2f})") # Arrowhead, filled triangle (tip up) pts = ( f"{cx:.2f},{shaft_top_y:.2f} " f"{cx - head_w / 2:.2f},{shaft_top_y + head_h:.2f} " f"{cx + head_w / 2:.2f},{shaft_top_y + head_h:.2f}" ) tri = ET.SubElement(group, "polygon", points=pts) tri.set("fill", color) tri.set("stroke", color) tri.set("stroke-width", "0.5") tri.set("class", "north-arrow") # Shaft shaft = ET.SubElement( group, "line", x1=f"{cx:.2f}", y1=f"{shaft_top_y + head_h:.2f}", x2=f"{cx:.2f}", y2=f"{shaft_bot_y:.2f}", ) shaft.set("stroke", color) shaft.set("stroke-width", str(stroke_width)) shaft.set("class", "north-arrow") # Label if label: lbl_y = shaft_top_y - 2 txt = ET.SubElement( group, "text", x=f"{cx:.2f}", y=f"{lbl_y:.2f}", ) txt.text = label txt.set("font-size", str(font_size)) txt.set("font-family", D.FONT_FAMILY) txt.set("fill", color) txt.set("text-anchor", "middle") txt.set("class", "north-arrow") # Chrome bbox for label avoidance label_top = (shaft_top_y - font_size - 2) if label else shaft_top_y self._register_chrome_bbox( cx - head_w / 2 - 2, label_top, cx + head_w / 2 + 2, shaft_bot_y + 2, ) # ------------------------------------------------------------------ # Source text # ------------------------------------------------------------------ def _render_source(self, params: Dict[str, Any]) -> None: """Add source attribution text. Parameters (dict keys) ---------------------- text : str Source text (required). Use ``"\\n"`` to split into multiple lines (useful in horizontal layout). layout : str, optional ``"vertical"`` (default), rotated 90° at the right edge of the basemap, reading bottom-to-top, aligned with the basemap bottom. ``"horizontal"``, normal horizontal text at the bottom-right of the basemap. position : dict, optional Manual placement as ``{"x": frac, "y": frac}`` relative to the basemap content area (0,0 = top-left, 1,1 = bottom-right). Overrides the automatic positioning of both layouts. rotation : float, optional Explicit rotation in degrees (default 90 for vertical, 0 for horizontal). Only used if ``position`` is not set. font_size : int, optional Font size (default from style). color : str, optional Text color (default from style). opacity : float, optional Text opacity (default from style). font_style : str, optional CSS font-style (default ``"italic"``). font_weight : str, optional CSS font-weight (default ``"normal"``). font_family : str, optional Font family (default from style). text_anchor : str, optional SVG text-anchor: ``"start"``, ``"middle"``, or ``"end"``. Default depends on layout/position. Examples:: m.source("Source : INSEE") m.source("Source : INSEE\\nTraitement : auteur", layout="horizontal") m.source("Source : INSEE", position={"x": 0.5, "y": 1.05}) """ cfg = dict(params) text = cfg.pop("text", "") if not text: return layout = cfg.pop("layout", D.SOURCE_LAYOUT) position = cfg.pop("position", D.SOURCE_POSITION) fs = (cfg.pop("font_size", None) or self.style.config.source.font.size) * self.font_scale c = cfg.pop("color", None) or self.style.config.source.font.color a = cfg.pop("opacity", None) or self.style.config.source.font.opacity font_style = cfg.pop("font_style", D.SOURCE_FONT_STYLE) font_weight = cfg.pop("font_weight", D.SOURCE_FONT_WEIGHT) font_family = cfg.pop("font_family", None) or self.style.config.source.font.family text_anchor = cfg.pop("text_anchor", None) vb = self.main.viewbox lines = text.split("\\n") if "\\n" in text else [text] # --- Manual position ------------------------------------------------- if position is not None: if isinstance(position, (list, tuple)) and len(position) == 2: xf, yf = position elif isinstance(position, dict): xf = position.get("x", 0.0) yf = position.get("y", 0.0) else: xf, yf = 0.0, 0.0 x = vb.content_x + xf * vb.content_width y = vb.content_y + yf * vb.content_height rotation = cfg.pop("rotation", 0) anchor = text_anchor or "start" self._render_source_lines( lines, x, y, fs, c, a, font_style, font_weight, font_family, anchor, rotation, ) return # --- Vertical (default), rotated 90° at right edge ------------------ if layout == "vertical": rotation = cfg.pop("rotation", D.SOURCE_ROTATION) x = vb.svg_x + vb.svg_width + D.SOURCE_OFFSET_X # Align text start with basemap bottom y = vb.content_y + vb.content_height anchor = text_anchor or "start" self._render_source_lines( lines, x, y, fs, c, a, font_style, font_weight, font_family, anchor, rotation, ) # Rotated 90°: horizontal footprint ~ font_size, vertical = text length upward text_length = sum(len(ln) for ln in lines) * fs * 0.6 self.overflow.register(x, y - text_length, x + fs, y) return # --- Horizontal, bottom-right of basemap ---------------------------- rotation = cfg.pop("rotation", 0) margin = D.SOURCE_HORIZONTAL_MARGIN x = vb.content_x + vb.content_width - margin y = vb.content_y + vb.content_height - margin anchor = text_anchor or "end" self._render_source_lines( lines, x, y, fs, c, a, font_style, font_weight, font_family, anchor, rotation, bottom_up=True, ) def _render_source_lines( self, lines: list, x: float, y: float, font_size: int, fill: str, opacity: float, font_style: str, font_weight: str, font_family: str, text_anchor: str, rotation: float, bottom_up: bool = False, ) -> None: """Render one or more source text lines as SVG ``<text>`` elements.""" line_h = font_size * D.SOURCE_HORIZONTAL_LINE_SPACING # For bottom_up, lines are stacked upward from the anchor y if bottom_up: y_positions = [y - i * line_h for i in range(len(lines))] y_positions.reverse() # first line at top else: y_positions = [y + i * line_h for i in range(len(lines))] for i, (line_text, ly) in enumerate(zip(lines, y_positions)): if not line_text.strip(): continue attrs = { "font_size": font_size, "fill": fill, "opacity": opacity, "font_family": font_family, "font_style": font_style, "font_weight": font_weight, "text_anchor": text_anchor, } if rotation: attrs["transform"] = f"rotate(-{rotation}, {x:.2f}, {ly:.2f})" self.svg.add_overlay_text( "source", x, ly, line_text, z_order=D.Z_SOURCE, **attrs, ) # ------------------------------------------------------------------ # Label obstacle / chrome tracking # ------------------------------------------------------------------ def _register_obstacle_geom(self, geom) -> None: """Store a shapely geometry (SVG px) as a label obstacle.""" self._state.obstacle_geoms.append(geom) def _register_chrome_bbox(self, x0: float, y0: float, x1: float, y1: float) -> None: """Store a chrome element bounding box (SVG px) for label avoidance.""" self._state.chrome_bboxes.append((x0, y0, x1, y1)) def _geo_obstacles_to_svg(self, viewport) -> List[Any]: """Convert stored geo-CRS obstacle GeoDataFrames to SVG-px shapely boxes.""" from shapely.geometry import box as shapely_box result = [] vb = viewport.viewbox for item in self._state.obstacle_geoms: # item can be a GeoDataFrame or a single geometry if hasattr(item, "geometry"): for geom in item.geometry: if geom is None or geom.is_empty: continue b = geom.bounds # minx, miny, maxx, maxy in geo coords px0, py0 = vb.geo_to_svg(b[0], b[3]) # top-left in SVG px1, py1 = vb.geo_to_svg(b[2], b[1]) # bottom-right in SVG result.append(shapely_box( min(px0, px1), min(py0, py1), max(px0, px1), max(py0, py1), )) else: b = item.bounds px0, py0 = vb.geo_to_svg(b[0], b[3]) px1, py1 = vb.geo_to_svg(b[2], b[1]) result.append(shapely_box( min(px0, px1), min(py0, py1), max(px0, px1), max(py0, py1), )) return result # ------------------------------------------------------------------ # Labels (toponyms) # ------------------------------------------------------------------ def _render_labels(self, gdf: gpd.GeoDataFrame, column: str, **kwargs) -> None: """Place automatic labels on map features using simulated annealing. Parameters ---------- gdf : GeoDataFrame Features to label. Any CRS is accepted (auto-reprojected). column : str Column containing the label text. **kwargs Any field of :class:`~mappyng.toponyme.LabelConfig`: ``font_size``, ``font_weight``, ``color``, ``halo_color``, ``halo_width``, ``priority_col``, ``max_labels``, ``initial_position``, ``label_offset``, ``max_displacement``, ``avoid_overlap``, ``allow_hide``, ``leader_lines``, ``leader_threshold``, ``n_sweeps``, ``seed``, ``target``, etc. Example:: m.add(LabelLayer(gdf_villes, column="nom", priority_col="population", max_labels=20, font_size=9, n_sweeps=50)) """ from .toponyme import LabelConfig, place_labels, numbered_legend_size, _numbered_ranked from . import defaults as D # Build config target = kwargs.pop("target", "main") # Propagate the Map-level seed unless this call sets one explicitly. if "seed" not in kwargs: kwargs["seed"] = self._seed cfg = LabelConfig(target=target, **kwargs) # Resolve viewport if target == "main": viewport = self.main crs = self.gdf.crs elif target == "zoom": if self._zoom_viewport is None: raise ValueError( "LabelLayer(target='zoom'): no zoom viewport exists yet. " "Call m.add_zoom(...) before adding labels on the zoom." ) viewport = self._zoom_viewport crs = self.gdf.crs elif target.startswith("cartouche:"): idx = int(target.split(":")[1]) if idx not in self._cartouche_viewports: raise ValueError( f"LabelLayer(target='cartouche:{idx}'): cartouche {idx} " f"is not defined. Pass cartouche_params for index {idx} " f"when creating the Map." ) viewport = self._cartouche_viewports[idx] crs = self.cartouche_params[idx].get("crs", self.gdf.crs) else: raise ValueError( f"LabelLayer: unknown target '{target}'. Use 'main', " f"'zoom', or 'cartouche:<index>'." ) # Reproject to viewport CRS if gdf.crs is not None and crs is not None: try: gdf_proj = gdf.to_crs(crs) except Exception: gdf_proj = gdf else: gdf_proj = gdf # Build SVG-px obstacle geometries from stored geo-CRS obstacles obs_svg = self._geo_obstacles_to_svg(viewport) # For non-main viewports in numbered mode: emit legend on main layer. legend_layer = None legend_xy = None if cfg.numbered_above is not None and target != "main": legend_layer = self.main.get_layer("labels_legend", z_order=D.Z_TOPONYME + 1) if cfg.numbered_legend_position is not None: # Manual placement via figure fractions {"x": ..., "y": ...} # Convention: (0,0) = bottom-left, (1,1) = top-right (same as # other mappyng legend positions). _fig_y flips to SVG coords. fx = cfg.numbered_legend_position.get("x", 0.75) fy = cfg.numbered_legend_position.get("y", 0.10) # Estimate legend height to anchor bottom edge at fy ranked_est = _numbered_ranked( [type("L", (), {"priority": 0, "text": ""})()] * min(len(gdf_proj), 10) ) _, leg_h = numbered_legend_size(ranked_est, cfg) lx = self._fig_x(fx) # fy is the bottom edge to SVG y of top = _fig_y(fy) - leg_h ly = self._fig_y(fy) - leg_h else: # Auto default: bottom-right, aligned with the legend column. # Estimate legend dimensions with max 10 items. n_est = min(len(gdf_proj), 10) dummy = type("L", (), {"priority": 0, "text": "ABCDEFGHIJ"}) ranked_est = [dummy() for _ in range(n_est)] leg_w, leg_h = numbered_legend_size(ranked_est, cfg) pad = self.padding lx = self.width - pad - leg_w ly = self.height - pad - leg_h legend_xy = (lx, ly) place_labels( gdf=gdf_proj, column=column, viewbox=viewport.viewbox, viewport=viewport, obstacle_geoms=obs_svg, chrome_bboxes_svg=self._state.chrome_bboxes, config=cfg, z_order=D.Z_TOPONYME, legend_layer=legend_layer, legend_xy=legend_xy, ) # ------------------------------------------------------------------ # Accessors # ------------------------------------------------------------------ @property def zoom_viewport(self) -> Optional[SvgViewport]: """The zoom viewport, if created.""" return self._zoom_viewport
[docs] def get_cartouche_viewport(self, index: int) -> Optional[SvgViewport]: """Get a cartouche viewport by index.""" return self._cartouche_viewports.get(index)
@property def cartouche_viewports(self) -> Dict[int, SvgViewport]: """All cartouche viewports.""" return self._cartouche_viewports # ------------------------------------------------------------------ # Data helpers # ------------------------------------------------------------------ def _clip_gdf(self, gdf: gpd.GeoDataFrame, bbox: List[float]) -> gpd.GeoDataFrame: """Clip a GeoDataFrame to a bounding box.""" try: clip_geom = box(*bbox) clipped = gdf.clip(mask=clip_geom) return clipped[~clipped.is_empty] except Exception as e: warnings.warn(f"Clip failed ({e}), returning unclipped data") return gdf def _prepare_cartouche_data(self, gdf: gpd.GeoDataFrame, params: Dict[str, Any]) -> gpd.GeoDataFrame: """Reproject and clip data for a cartouche.""" if gdf is None or gdf.empty: return gdf try: local = gdf if 'crs' in params and gdf.crs: local = gdf.to_crs(params['crs']) if 'bbox' in params: clip_geom = box(*params['bbox']) local = local.clip(mask=clip_geom) local = local[~local.is_empty] return local except Exception as e: warnings.warn(f"Error preparing cartouche data: {e}") return gdf.iloc[0:0] # ------------------------------------------------------------------ # Interactive export # ------------------------------------------------------------------
[docs] def to_interactive( self, columns: Optional[List[str]] = None, aliases: Optional[Dict[str, str]] = None, tooltip: Optional[Dict[str, Any]] = None, hover_opacity: Optional[float] = None, ) -> "InteractiveMap": """Export the map as an interactive SVG with hover tooltips. Returns an :class:`~mappyng.interactive.InteractiveMap` that auto-renders in Jupyter and can be saved as standalone HTML. Parameters ---------- columns : list of str, optional Restrict tooltip to these columns. If *None*, all non-internal columns are shown. aliases : dict, optional Column name to display label mapping, e.g. ``{"LIB_GEO": "Département"}``. tooltip : dict, optional Tooltip appearance, any field of :class:`~mappyng.interactive.TooltipStyle`: ``background``, ``text_color``, ``font_size``, ``font_family``, ``border_radius``, ``key_weight``, ``highlight_opacity``. Returns ------- InteractiveMap Object with ``_repr_html_()`` and ``.save(path)`` methods. Example:: m.to_interactive( columns=["LIB_GEO", "population"], aliases={"LIB_GEO": "Département"}, tooltip={"background": "#1a1a2e", "font_size": 13}, ) """ from .interactive import ( InteractiveMap, TooltipStyle, _ensure_tooltip_style, auto_detect_columns, _build_tooltip_map, inject_tooltips, ) # Render layers first: interactive metadata is recorded when each # layer renders (deferred). self._build() # hover_opacity is a v1-compat convenience alias for # tooltip={"hover": {"opacity": <value>}} if hover_opacity is not None: tooltip = dict(tooltip or {}) hover_raw = tooltip.get("hover", {}) if isinstance(hover_raw, dict): hover_raw = dict(hover_raw) else: hover_raw = {} hover_raw["opacity"] = float(hover_opacity) tooltip["hover"] = hover_raw style = _ensure_tooltip_style(tooltip) tooltip_maps = [] for layer in self._state.interactive_layers: gdf = layer["gdf"] cols = columns or auto_detect_columns(gdf) tm = _build_tooltip_map( gdf, cols, aliases=aliases, id_prefix=layer["id_prefix"], ) tooltip_maps.append(tm) svg_str = self.to_string() interactive_svg = inject_tooltips(svg_str, tooltip_maps, style) return InteractiveMap(interactive_svg)
[docs] def save_interactive( self, path: str, columns: Optional[List[str]] = None, aliases: Optional[Dict[str, str]] = None, tooltip: Optional[Dict[str, Any]] = None, hover_opacity: Optional[float] = None, title: str = "Interactive Map", ) -> None: """Save the map as an interactive HTML file. Convenience wrapper around :meth:`to_interactive` + :meth:`InteractiveMap.save`. Parameters ---------- path : str Output file path. columns, aliases, tooltip, hover_opacity See :meth:`to_interactive`. title : str HTML page title. """ self.to_interactive( columns=columns, aliases=aliases, tooltip=tooltip, hover_opacity=hover_opacity, ).save(path, title=title)
# ------------------------------------------------------------------ # Output # ------------------------------------------------------------------ def _resolve_overflow(self) -> None: """Expand SVG viewBox to contain all registered bounding boxes (once).""" if getattr(self, "_overflow_resolved", False): return self._overflow_resolved = True self.overflow.resolve(self) def _build(self) -> None: """Finalise the map in memory (idempotent). Resolves overflow (mutating viewports) and builds viewport XML elements. Does NOT serialise to string or write any file, those remain the responsibility of to_string() / save(). Safe to call multiple times: subsequent calls are no-ops. Notes ----- This is the single finalisation entry point used by all output methods (save, to_string, _repr_svg_, to_interactive). """ if self._built: return self._render_layers() self._resolve_overflow() self.svg._build_viewports() self._built = True
[docs] def render(self, path: str, dpi: int = D.EXPORT_DEFAULT_DPI) -> "Map": """Render the map to a file. Format inferred from the extension. ``.svg`` (or no extension) is written natively. ``.png`` and ``.pdf`` are rendered from the SVG via the optional ``cairosvg`` dependency (``pip install mappyng[export]``); the SVG remains the canonical source. Parameters ---------- path : str Destination file path. The extension selects the format: ``.svg`` (default), ``.png`` or ``.pdf``. dpi : int Resolution for PNG export (ignored for SVG and PDF). Defaults to :data:`~mappyng.defaults.EXPORT_DEFAULT_DPI`. Returns ------- Map ``self``, to allow chaining. Raises ------ ValueError If the extension is none of ``.svg``, ``.png``, ``.pdf``. ImportError If a PNG/PDF export is requested but ``cairosvg`` is not installed. """ self._build() ext = os.path.splitext(path)[1].lower() if ext in ("", ".svg"): self.svg.save(path) return self if ext not in (".png", ".pdf"): raise ValueError( f"Unsupported export format '{ext}'. mappyng writes .svg " f"natively and .png/.pdf via cairosvg. Use one of these." ) try: import cairosvg except ImportError as e: raise ImportError( f"Exporting to {ext} requires the optional 'cairosvg' " f"dependency, which is not installed. Install it with:\n" f" pip install mappyng[export]\n" f"Native SVG export (.svg) works without it." ) from e svg_bytes = self.to_string().encode("utf-8") if ext == ".png": cairosvg.svg2png(bytestring=svg_bytes, write_to=path, dpi=dpi) else: # .pdf cairosvg.svg2pdf(bytestring=svg_bytes, write_to=path) return self
[docs] def save(self, path: str, dpi: int = D.EXPORT_DEFAULT_DPI) -> "Map": """Deprecated alias of :meth:`render`. Kept for backward compatibility; emits a :class:`DeprecationWarning` and forwards to :meth:`render`. Use ``m.render(path)`` instead. """ warnings.warn( "Map.save() is deprecated; use Map.render() instead " "(same signature and behaviour).", DeprecationWarning, stacklevel=2, ) return self.render(path, dpi=dpi)
[docs] def to_string(self) -> str: """Render the map and return the SVG markup as a string. Returns ------- str Self-contained SVG string, ready for embedding or saving. """ self._build() return self.svg.to_string()
# ------------------------------------------------------------------ # YAML serialisation # ------------------------------------------------------------------
[docs] def to_yaml(self, path: Optional[str] = None) -> str: """Serialise the map's intent to YAML. The output captures the configuration, the ordered layers with their parameters, and the chrome. It does not store the SVG nor the geometry. Geometry is reattached at load time (see :meth:`from_yaml`). Parameters ---------- path : str, optional If given, the YAML is also written to this file. Returns ------- str The YAML document. """ from .serialize import to_yaml as _to_yaml return _to_yaml(self, path)
[docs] @classmethod def from_yaml(cls, source: str, gdf: "gpd.GeoDataFrame" = None, data: Optional[Dict[str, Any]] = None, base_dir: Optional[str] = None) -> "Map": """Rebuild a map from a YAML file path or string. The serialised form stores layers by reference, not geometry, so the geometry has to come from somewhere. Either supply it with *gdf* (and optionally per-layer *data*), or let the document carry its own sources: a ``data:`` block with a ``dataset:`` (a built-in dataset) or a ``path:`` (a vector file) — optionally with ``crs``, ``filter`` and a CSV ``join`` — on the map or on each layer. A fully source-bearing document builds with no Python at all. Parameters ---------- source : str A YAML file path or a YAML string. gdf : GeoDataFrame, optional Base geometry. When given, declared sources are ignored. data : dict, optional Per-layer geometry override, keyed by layer ``id``. base_dir : str, optional Directory that relative ``path:`` entries resolve against. Defaults to the YAML file's directory (or the cwd for a string). Returns ------- Map """ from .serialize import from_yaml as _from_yaml return _from_yaml(source, gdf=gdf, data=data, base_dir=base_dir)
def _repr_svg_(self) -> str: """Jupyter notebook SVG display.""" self._build() return self.svg.to_string()