"""
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()