Source code for mappyng.layers.thematic

"""Thematic (data-driven) layers.

Choropleth, proportional and situation layers. Each is a first-class
object whose ``render`` delegates to the matching drawing helper on
:class:`~mappyng.Map`.
"""

from __future__ import annotations

import warnings
from typing import Any

from .. import defaults as D
from .base import _UNSET, Layer, RenderContext, collect_set, register_layer


def _class_tooltip_frame(column: str, labels: list[str]) -> Any:
    """Tooltip data of a dissolved layer: one row per class.

    A dissolved polygon merges every feature of a class, so it has no row
    of its own in the layer data. Its tooltip gives the class instead,
    under the name of the mapped column. The index matches the element
    ids written by the renderer (``class0``, ``class1``...).
    """
    import pandas as pd
    return pd.DataFrame(
        {column: labels}, index=[f"class{i}" for i in range(len(labels))])


def _require_column(layer: Layer, column: Any, role: str = "column") -> None:
    """Raise in plain language when *column* is not a column of the data."""
    columns = getattr(layer.gdf, "columns", None)
    if column is None or columns is None or column in columns:
        return
    available = [c for c in columns if c != "geometry"]
    raise ValueError(
        f"{type(layer).__name__}: {role} {column!r} is not a column of gdf. "
        f"Available columns: {available}."
    )


[docs] @register_layer class Choropleth(Layer): """Choropleth (graduated colour) layer. Maps a *relative* quantity (rate, ratio, density) to colour value. Parameters ---------- gdf : GeoDataFrame Polygons with a numeric column. column : str Column to classify (required). cmap : str or list, optional Colour ramp name or explicit list of hex colours (default from defaults). With ``"StdMean"`` a diverging ramp (``"RdBu"``, ``"BrBG"``, ``"RdYlGn"``) reads the two sides of the mean apart; a sequential ramp raises a warning. reverse : bool, optional Reverse the colour order, so the lowest class takes the ramp's last colour (default False). Applies to named ramps and explicit lists. method : str, optional Classification scheme: ``"Quantiles"``, ``"EqualInterval"``, ``"FisherJenks"``, ``"Q6"``, ``"StdMean"`` or ``"PrettyBreaks"``. num_classes : int, optional Number of classes, usually 4 to 7 (default 5). ``"StdMean"`` supports 5 or 7 (the central class straddles the mean, so the count is odd); any other count snaps to the nearest valid one with a warning. ``"Q6"`` always yields 6. stroke : str, optional Outline colour of each area as ``#RRGGBB`` (default from style). stroke_width : float, optional Outline width (default from style). legend : dict or False, optional Legend configuration (see :class:`ChoroplethLegendConfig`), or a legend config instance. ``False`` draws no legend. dissolve : bool, optional Merge adjacent areas that share a class before drawing (default False). simplify : None, str or float, optional Geometry simplification: ``None`` (default, no simplification), ``"auto"`` (half a pixel at the render scale) or a tolerance in the data CRS units. opacity_column : str, optional Column holding a probability/confidence in ``[0, 1]``. When set, colour still encodes *column*'s class while opacity encodes certainty (linearly mapped onto ``[min_opacity, max_opacity]``). min_opacity, max_opacity : float, optional Opacity bounds for *opacity_column* (defaults 0.15 and 1.0). Ignored without *opacity_column*. blend_background : str, optional With *opacity_column*, pre-composite the class colour over this ``#RRGGBB`` background and draw an opaque solid fill instead of relying on SVG ``fill-opacity``. Gives a colour that looks the same regardless of what renders underneath; omit it to use native SVG transparency, which blends live with the basemap or other layers. opacity_bins : int or list of float, optional Discretize *opacity_column* into this many equal-width bins over ``[0, 1]`` (or explicit bin edges). With *dissolve*, features sharing both class and opacity bin merge into one path instead of having their opacity averaged. ``None`` (default) keeps opacity continuous, one value per feature. detail : GeoDataFrame or dict, optional Finer geometry for the zoom window and the cartouches, drawn instead of the generalised one. The symbology computed from *gdf* is carried to it through *detail_join*. A GeoDataFrame applies to the zoom and every cartouche; a dict maps ``"zoom"``, ``"cartouches"`` or a cartouche index to a GeoDataFrame. See :mod:`mappyng.detail`. detail_join : str, tuple or None, optional Key shared by *gdf* and *detail*: a column name present in both, a ``(gdf_column, detail_column)`` pair, or None (default) to join on the index. border_contrast : bool, str, dict or BorderContrast, optional Make the borders between touching areas of similar colour visible: ``True`` redraws only those shared borders in a contrasting colour, ``"check"`` reveals them thick in magenta and warns, a dict or a :class:`~mappyng.BorderContrast` sets thresholds and colours. Default off. See :mod:`mappyng.border_contrast`. on_zoom : bool, optional Draw on the zoom viewport (default True). on_cartouches : bool, optional Draw on cartouche viewports (default True). z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Choropleth(gdf, column="density", method="FisherJenks")) """ #: :class:`~mappyng.choropleth_classify.ClassificationReport` of the last #: render (method applied, bounds, class sizes, excluded values, #: adjustments); ``None`` until the layer has been drawn. report = None kind = "choropleth" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, column: Any = _UNSET, cmap: Any = _UNSET, reverse: Any = _UNSET, method: Any = _UNSET, num_classes: Any = _UNSET, stroke: Any = _UNSET, stroke_width: Any = _UNSET, legend: Any = _UNSET, dissolve: Any = _UNSET, simplify: Any = _UNSET, opacity_column: Any = _UNSET, min_opacity: Any = _UNSET, max_opacity: Any = _UNSET, blend_background: Any = _UNSET, opacity_bins: Any = _UNSET, detail: Any = _UNSET, detail_join: Any = _UNSET, border_contrast: Any = _UNSET, on_zoom: Any = _UNSET, on_cartouches: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( column=column, cmap=cmap, reverse=reverse, method=method, num_classes=num_classes, stroke=stroke, stroke_width=stroke_width, legend=legend, dissolve=dissolve, simplify=simplify, opacity_column=opacity_column, min_opacity=min_opacity, max_opacity=max_opacity, blend_background=blend_background, opacity_bins=opacity_bins, detail=detail, detail_join=detail_join, border_contrast=border_contrast, on_zoom=on_zoom, on_cartouches=on_cartouches, ), )
[docs] def validate(self) -> None: from ..border_contrast import parse_border_contrast parse_border_contrast(self.params.get("border_contrast")) if "column" not in self.params: raise ValueError( "Choropleth needs a 'column' parameter naming the " "numeric column to classify, e.g. " "Choropleth(gdf, column='density'). A choropleth " "colours each area by a value, so it has nothing to draw " "without one." ) _require_column(self, self.params["column"]) simplify = self.params.get("simplify") if simplify is not None and not isinstance(simplify, (int, float)): if simplify != "auto": raise ValueError( f"simplify must be None, 'auto' or a number, got " f"{simplify!r}. Use 'auto' to target half a pixel at " f"the render scale, or pass a tolerance in the data " f"CRS units." ) opacity_column = self.params.get("opacity_column") if (opacity_column is not None and self.gdf is not None and opacity_column not in getattr(self.gdf, "columns", ())): raise ValueError( f"Choropleth: opacity_column {opacity_column!r} is not " f"a column of gdf. Available columns: " f"{list(self.gdf.columns)}." ) blend_background = self.params.get("blend_background") if blend_background is not None and opacity_column is None: raise ValueError( "Choropleth: blend_background has no effect without " "opacity_column; pass opacity_column to drive per-feature " "opacity from a probability column." ) # Semiology check: a choropleth maps colour value, read # as a relative quantity. Warn (never block) if the column looks # like stock data. Emitted at construction, once. from ..choropleth import _looks_like_stock column = self.params["column"] if self.gdf is not None and column in getattr(self.gdf, "columns", ()): try: stock_max = _looks_like_stock( self.gdf[column].to_numpy(dtype=float)) except (TypeError, ValueError): stock_max = None if stock_max is not None: warnings.warn( f"Choropleth: column '{column}' looks like absolute " f"count (stock) data: strictly positive integers " f"reaching large values (max about {stock_max:.0f}) with " f"a wide spread. A choropleth maps colour value, read as " f"a relative quantity (rate, ratio, density). For " f"absolute counts, Proportional preserves the " f"magnitude. Consider normalising by area or population, " f"or use Proportional.", UserWarning, stacklevel=2, ) # Semiology check: StdMean is a diverging classification (classes are # standard-deviation steps either side of the mean). A sequential # palette flattens that symmetry. Warn (never block) toward a # diverging palette. from ..config import DIVERGING_COLORMAPS method = self.params.get("method", D.CHOROPLETH_DEFAULT_METHOD) cmap = self.params.get("cmap", D.CHOROPLETH_DEFAULT_CMAP) if (isinstance(method, str) and method == "StdMean" and isinstance(cmap, str) and cmap not in DIVERGING_COLORMAPS): choices = ", ".join(repr(c) for c in DIVERGING_COLORMAPS) warnings.warn( f"Choropleth: method 'StdMean' is a diverging " f"classification (classes are standard-deviation steps either " f"side of the mean), but cmap {cmap!r} is sequential. A " f"diverging palette reads the two sides apart. Try cmap " f"{choices}.", UserWarning, stacklevel=2, )
[docs] def render(self, ctx: RenderContext) -> None: from ..choropleth import add_choropleth as _add_choropleth cfg = dict(self.params) column = cfg.pop("column") layer_id = _add_choropleth( ctx.map, ctx.map._in_map_crs(self.gdf), column=column, cmap=cfg.pop("cmap", D.CHOROPLETH_DEFAULT_CMAP), reverse=cfg.pop("reverse", False), method=cfg.pop("method", D.CHOROPLETH_DEFAULT_METHOD), num_classes=cfg.pop("num_classes", D.CHOROPLETH_DEFAULT_NUM_CLASSES), stroke=cfg.pop("stroke", None), stroke_width=cfg.pop("stroke_width", None), legend_params=cfg.pop("legend", None), dissolve=cfg.pop("dissolve", False), simplify=cfg.pop("simplify", None), opacity_column=cfg.pop("opacity_column", None), min_opacity=cfg.pop("min_opacity", D.CHOROPLETH_DEFAULT_MIN_OPACITY), max_opacity=cfg.pop("max_opacity", D.CHOROPLETH_DEFAULT_MAX_OPACITY), blend_background=cfg.pop("blend_background", None), opacity_bins=cfg.pop("opacity_bins", None), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), detail=cfg.pop("detail", None), detail_join=cfg.pop("detail_join", None), border_contrast=cfg.pop("border_contrast", None), ) self.report = ctx.map._state.classification_reports.get(layer_id) tip_gdf = self.gdf if self.params.get("dissolve") and self.report is not None: bins = self.report.bins tip_gdf = _class_tooltip_frame(column, [ f"{bins[i]:g} \u2013 {bins[i + 1]:g}" for i in range(len(bins) - 1)]) ctx.map._state.interactive_layers.append({ "type": "choropleth", "gdf": tip_gdf, "column": column, "layer_id": layer_id, "id_prefix": f"choro-{layer_id}-", }) self._rendered = True
[docs] @register_layer class Category(Layer): """Categorical (qualitative) choropleth layer. Colours each polygon by a nominal category (an id, a type, a cluster label...) via an explicit mapping, rather than classifying a continuous value like :class:`Choropleth`. Opacity can separately encode a probability/confidence column. Parameters ---------- gdf : GeoDataFrame Polygons with a category column. column : str Column holding the category per feature (required). fill_map : dict Mapping ``category -> "#RRGGBB"`` (required). default_fill : str, optional Colour for categories present in *column* but absent from *fill_map*. Without it, an unmapped category raises ``ValueError``. default_label : str, optional Legend label for the *default_fill* bucket (default ``"Autre"``). stroke : str, optional Outline colour of each area as ``#RRGGBB`` (default from style). stroke_width : float, optional Outline width (default from style). legend : dict or False, optional Legend configuration (ChoroplethLegendConfig fields). Defaults to a vertical legend with one swatch per category. ``False`` draws no legend. dissolve : bool, optional Merge adjacent areas that share a category before drawing (default False). simplify : None, str or float, optional Geometry simplification, see :class:`Choropleth`. opacity_column : str, optional Column holding a probability/confidence in ``[0, 1]``. When set, colour encodes the category while opacity encodes certainty (linearly mapped onto ``[min_opacity, max_opacity]``). min_opacity, max_opacity : float, optional Opacity bounds for *opacity_column* (defaults 0.15 and 1.0). blend_background : str, optional With *opacity_column*, pre-composite the category colour over this ``#RRGGBB`` background and draw an opaque solid fill instead of relying on SVG ``fill-opacity``. opacity_bins : int or list of float, optional Discretize *opacity_column* into this many equal-width bins over ``[0, 1]`` (or explicit bin edges). With *dissolve*, features sharing both category and opacity bin merge into one path instead of having their opacity averaged. detail : GeoDataFrame or dict, optional Finer geometry for the zoom window and the cartouches, drawn instead of the generalised one. The symbology computed from *gdf* is carried to it through *detail_join*. A GeoDataFrame applies to the zoom and every cartouche; a dict maps ``"zoom"``, ``"cartouches"`` or a cartouche index to a GeoDataFrame. See :mod:`mappyng.detail`. detail_join : str, tuple or None, optional Key shared by *gdf* and *detail*: a column name present in both, a ``(gdf_column, detail_column)`` pair, or None (default) to join on the index. border_contrast : bool, str, dict or BorderContrast, optional Make the borders between touching areas of similar colour visible: ``True`` redraws only those shared borders in a contrasting colour, ``"check"`` reveals them thick in magenta and warns, a dict or a :class:`~mappyng.BorderContrast` sets thresholds and colours. Default off. See :mod:`mappyng.border_contrast`. on_zoom : bool, optional Draw on the zoom viewport (default True). on_cartouches : bool, optional Draw on cartouche viewports (default True). z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Category( ... gdf, column="cluster", ... fill_map={"A": "#1f78b4", "B": "#33a02c", "C": "#e31a1c"}, ... opacity_column="confidence", ... )) """ #: :class:`~mappyng.choropleth_classify.CategoryReport` of the last render #: (features per category, excluded values); ``None`` until drawn. report = None kind = "category" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, column: Any = _UNSET, fill_map: Any = _UNSET, default_fill: Any = _UNSET, default_label: Any = _UNSET, stroke: Any = _UNSET, stroke_width: Any = _UNSET, legend: Any = _UNSET, dissolve: Any = _UNSET, simplify: Any = _UNSET, opacity_column: Any = _UNSET, min_opacity: Any = _UNSET, max_opacity: Any = _UNSET, blend_background: Any = _UNSET, opacity_bins: Any = _UNSET, detail: Any = _UNSET, detail_join: Any = _UNSET, border_contrast: Any = _UNSET, on_zoom: Any = _UNSET, on_cartouches: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( column=column, fill_map=fill_map, default_fill=default_fill, default_label=default_label, stroke=stroke, stroke_width=stroke_width, legend=legend, dissolve=dissolve, simplify=simplify, opacity_column=opacity_column, min_opacity=min_opacity, max_opacity=max_opacity, blend_background=blend_background, opacity_bins=opacity_bins, detail=detail, detail_join=detail_join, border_contrast=border_contrast, on_zoom=on_zoom, on_cartouches=on_cartouches, ), )
[docs] def validate(self) -> None: from ..border_contrast import parse_border_contrast parse_border_contrast(self.params.get("border_contrast")) if "column" not in self.params: raise ValueError( "Category needs a 'column' parameter naming the " "category column, e.g. " "Category(gdf, column='cluster', fill_map={...})." ) if "fill_map" not in self.params: raise ValueError( "Category needs a 'fill_map' parameter mapping each " "category to a colour, e.g. " "Category(gdf, column='cluster', " "fill_map={'A': '#1f78b4', 'B': '#33a02c'})." ) _require_column(self, self.params["column"]) opacity_column = self.params.get("opacity_column") if (opacity_column is not None and self.gdf is not None and opacity_column not in getattr(self.gdf, "columns", ())): raise ValueError( f"Category: opacity_column {opacity_column!r} is not " f"a column of gdf. Available columns: " f"{list(self.gdf.columns)}." ) blend_background = self.params.get("blend_background") if blend_background is not None and opacity_column is None: raise ValueError( "Category: blend_background has no effect without " "opacity_column; pass opacity_column to drive per-feature " "opacity from a probability column." )
[docs] def render(self, ctx: RenderContext) -> None: from ..category import add_category as _add_category cfg = dict(self.params) column = cfg.pop("column") layer_id = _add_category( ctx.map, ctx.map._in_map_crs(self.gdf), column=column, fill_map=cfg.pop("fill_map"), default_fill=cfg.pop("default_fill", None), default_label=cfg.pop("default_label", "Autre"), stroke=cfg.pop("stroke", None), stroke_width=cfg.pop("stroke_width", None), legend_params=cfg.pop("legend", None), dissolve=cfg.pop("dissolve", False), simplify=cfg.pop("simplify", None), opacity_column=cfg.pop("opacity_column", None), min_opacity=cfg.pop("min_opacity", D.CHOROPLETH_DEFAULT_MIN_OPACITY), max_opacity=cfg.pop("max_opacity", D.CHOROPLETH_DEFAULT_MAX_OPACITY), blend_background=cfg.pop("blend_background", None), opacity_bins=cfg.pop("opacity_bins", None), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), detail=cfg.pop("detail", None), detail_join=cfg.pop("detail_join", None), border_contrast=cfg.pop("border_contrast", None), ) self.report = ctx.map._state.classification_reports.get(layer_id) tip_gdf = self.gdf if self.params.get("dissolve") and self.report is not None: default_label = self.params.get("default_label", "Autre") tip_gdf = _class_tooltip_frame(column, [ default_label if c == "__default__" else str(c) for c in self.report.categories]) ctx.map._state.interactive_layers.append({ "type": "category", "gdf": tip_gdf, "column": column, "layer_id": layer_id, "id_prefix": f"choro-{layer_id}-", }) self._rendered = True
[docs] @register_layer class Proportional(Layer): """Proportional symbols layer. Maps an *absolute* quantity (stock) to symbol size, preserving magnitude where colour value would flatten it. Parameters ---------- gdf : GeoDataFrame Geometries with a numeric column. column : str Column driving symbol size (required). fill : str, optional Symbol fill colour as ``#RRGGBB`` (default from defaults). fill_opacity : float, optional Symbol fill opacity in ``[0, 1]`` (default from defaults). stroke : str, optional Symbol outline colour (default from defaults). stroke_width : float, optional Symbol outline width (default from defaults). max_radius : float, optional Radius in pixels of the largest symbol (default from defaults). reference_value : float, optional Value mapped to ``reference_radius`` for the legend. Defaults to the data maximum. reference_radius : float, optional Radius in pixels for ``reference_value``. Defaults to ``max_radius``. legend : dict or False, optional Legend configuration (see :class:`ProportionalLegendConfig`). ``False`` draws no legend. on_zoom : bool, optional Draw on the zoom viewport (default True). on_cartouches : bool, optional Draw on cartouche viewports (default True). fill_column : str, optional Column giving a category per feature. With *fill_map*, colours each circle individually so a single layer can mix colours while keeping the global "smallest on top" ordering (one layer per colour breaks it). fill_map : dict, optional Mapping ``category -> colour`` used with *fill_column*; unmapped categories fall back to *fill*. detail : GeoDataFrame or dict, optional Finer geometry for the zoom window and the cartouches, used to place the circles instead of the generalised one. Values and colours stay those of *gdf*, carried through *detail_join*. A GeoDataFrame applies to the zoom and every cartouche; a dict maps ``"zoom"``, ``"cartouches"`` or a cartouche index to a GeoDataFrame. See :mod:`mappyng.detail`. detail_join : str, tuple or None, optional Key shared by *gdf* and *detail*: a column name present in both, a ``(gdf_column, detail_column)`` pair, or None (default) to join on the index. z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Proportional(gdf, column="population", max_radius=30)) >>> m.add(Proportional(gdf, column="n", fill_column="type", ... fill_map={"a": "#6A1B9A", "b": "#78909C"})) """ kind = "proportional" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, column: Any = _UNSET, fill: Any = _UNSET, fill_opacity: Any = _UNSET, stroke: Any = _UNSET, stroke_width: Any = _UNSET, max_radius: Any = _UNSET, reference_value: Any = _UNSET, reference_radius: Any = _UNSET, legend: Any = _UNSET, on_zoom: Any = _UNSET, on_cartouches: Any = _UNSET, detail: Any = _UNSET, detail_join: Any = _UNSET, fill_column: Any = _UNSET, fill_map: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( column=column, fill=fill, fill_opacity=fill_opacity, stroke=stroke, stroke_width=stroke_width, max_radius=max_radius, reference_value=reference_value, reference_radius=reference_radius, legend=legend, on_zoom=on_zoom, on_cartouches=on_cartouches, fill_column=fill_column, fill_map=fill_map, detail=detail, detail_join=detail_join, ), )
[docs] def validate(self) -> None: if "column" not in self.params: raise ValueError( "Proportional needs a 'column' parameter naming the " "numeric column that drives symbol size, e.g. " "Proportional(gdf, column='population')." ) _require_column(self, self.params["column"]) _require_column(self, self.params.get("fill_column"), "fill_column")
[docs] def render(self, ctx: RenderContext) -> None: from ..proportional import add_proportional as _add_proportional cfg = dict(self.params) column = cfg.pop("column") layer_id = _add_proportional( ctx.map, ctx.map._in_map_crs(self.gdf), column=column, fill=cfg.pop("fill", D.PROPORTIONAL_FILL), fill_opacity=cfg.pop("fill_opacity", D.PROPORTIONAL_FILL_OPACITY), stroke=cfg.pop("stroke", D.PROPORTIONAL_STROKE), stroke_width=cfg.pop("stroke_width", D.PROPORTIONAL_STROKE_WIDTH), max_radius=cfg.pop("max_radius", D.PROPORTIONAL_MAX_RADIUS), reference_value=cfg.pop("reference_value", None), reference_radius=cfg.pop("reference_radius", None), legend_params=cfg.pop("legend", None), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), detail=cfg.pop("detail", None), detail_join=cfg.pop("detail_join", None), fill_column=cfg.pop("fill_column", None), fill_map=cfg.pop("fill_map", None), ) ctx.map._state.interactive_layers.append({ "type": "proportional", "gdf": self.gdf, "column": column, "layer_id": layer_id, "id_prefix": f"prop-{layer_id}-", }) self._rendered = True
[docs] @register_layer class Situation(Layer): """Situation (qualitative point/area) layer. Parameters ---------- gdf : GeoDataFrame Geometries to draw. column : str, optional Category column. When set together with ``symbol``, each category gets its own marker. symbol : dict, optional Mapping of category value to a per-category marker style. marker : str, optional Marker shape used in uniform mode (default ``"circle"``). fill : str, optional Marker fill colour as ``#RRGGBB`` (default ``"#e31a1c"``). size : float, optional Marker size in pixels (default 8). stroke : str, optional Marker outline colour (default ``"#ffffff"``). stroke_width : float, optional Marker outline width (default 0.5). fill_opacity : float, optional Marker fill opacity in ``[0, 1]`` (default 1). inner_fill : str, optional Fill colour of an inner marker, for layered symbols. inner_stroke : str, optional Outline colour of the inner marker. label : str, optional Column holding a short text drawn next to each marker. legend : dict or False, optional Legend configuration (see :class:`SituationLegendConfig`). ``None`` or ``False`` draws no legend. on_zoom : bool, optional Draw on the zoom viewport (default True). on_cartouches : bool, optional Draw on cartouche viewports (default True). detail : GeoDataFrame or dict, optional Finer geometry for the zoom window and the cartouches, used to place the markers instead of the generalised one. Values and colours stay those of *gdf*, carried through *detail_join*. A GeoDataFrame applies to the zoom and every cartouche; a dict maps ``"zoom"``, ``"cartouches"`` or a cartouche index to a GeoDataFrame. See :mod:`mappyng.detail`. detail_join : str, tuple or None, optional Key shared by *gdf* and *detail*: a column name present in both, a ``(gdf_column, detail_column)`` pair, or None (default) to join on the index. z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Situation(gdf, marker="square", fill="#1f78b4")) """ kind = "situation" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, column: Any = _UNSET, symbol: Any = _UNSET, marker: Any = _UNSET, fill: Any = _UNSET, size: Any = _UNSET, stroke: Any = _UNSET, stroke_width: Any = _UNSET, fill_opacity: Any = _UNSET, inner_fill: Any = _UNSET, inner_stroke: Any = _UNSET, label: Any = _UNSET, legend: Any = _UNSET, on_zoom: Any = _UNSET, on_cartouches: Any = _UNSET, detail: Any = _UNSET, detail_join: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( column=column, symbol=symbol, marker=marker, fill=fill, size=size, stroke=stroke, stroke_width=stroke_width, fill_opacity=fill_opacity, inner_fill=inner_fill, inner_stroke=inner_stroke, label=label, legend=legend, on_zoom=on_zoom, on_cartouches=on_cartouches, detail=detail, detail_join=detail_join, ), )
[docs] def validate(self) -> None: # Semiology check: too many categorical symbols hurt # readability. Warn (never block) at construction. symbol = self.params.get("symbol") column = self.params.get("column") if column and symbol and len(symbol) > D.SITUATION_MAX_CATEGORIES_WARN: warnings.warn( f"Situation: {len(symbol)} categories defined. The human " f"eye reliably distinguishes about " f"{D.SITUATION_MAX_CATEGORIES_WARN} categorical symbols on a " f"single map; beyond that, readability drops. Consider " f"grouping categories or splitting the map.", UserWarning, stacklevel=2, )
[docs] def render(self, ctx: RenderContext) -> None: from ..situation import add_situation as _add_situation cfg = dict(self.params) column = cfg.pop("column", None) layer_id = _add_situation( ctx.map, ctx.map._in_map_crs(self.gdf), column=column, symbol=cfg.pop("symbol", None), marker=cfg.pop("marker", "circle"), fill=cfg.pop("fill", "#e31a1c"), size=cfg.pop("size", 8.0), stroke=cfg.pop("stroke", "#ffffff"), stroke_width=cfg.pop("stroke_width", 0.5), fill_opacity=cfg.pop("fill_opacity", 1.0), inner_fill=cfg.pop("inner_fill", None), inner_stroke=cfg.pop("inner_stroke", None), label=cfg.pop("label", None), legend_params=cfg.pop("legend", None), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), detail=cfg.pop("detail", None), detail_join=cfg.pop("detail_join", None), ) ctx.map._state.interactive_layers.append({ "type": "situation", "gdf": self.gdf, "column": column, "layer_id": layer_id, "id_prefix": f"situ-{layer_id}-", }) self._rendered = True
[docs] @register_layer class Label(Layer): """Text label layer placed by simulated annealing. Parameters ---------- gdf : GeoDataFrame Geometries to label. column : str Column holding the label text (required). target : str, optional Viewport to place labels on: ``"main"`` (default), ``"zoom"`` or ``"cartouche:<index>"``. font_size : float, optional Label font size in pixels (default 10). font_family : str, optional Font family (default ``"sans-serif"``). font_weight : str, optional Font weight (default ``"normal"``). color : str, optional Text colour as ``#RRGGBB`` (default ``"#000000"``). halo_color : str or None, optional Halo (outline) colour; ``None`` disables the halo. halo_width : float, optional Halo width in pixels (default 2). priority_col : str, optional Column ranking which labels are placed first (higher first). min_priority : float, optional Skip rows whose priority is below this threshold. max_labels : int, optional Hard cap on the number of labels placed. initial_position : str, optional Starting candidate position (default ``"NE"``). candidate_positions : list of str, optional Candidate positions tried around each anchor. label_offset : float, optional Distance from anchor to label edge in pixels (default 4). max_displacement : float, optional Maximum displacement before a label is hidden (default 40). avoid_overlap : bool, optional Enable label-label overlap avoidance (default True). allow_hide : bool, optional Allow hiding labels that cannot be placed (default True). A label that still covers a map symbol after placement is hidden too. allow_symbol_overlap : bool, optional Keep labels that cover a map symbol instead of hiding them (default False). Useful on dense maps where most labels would be hidden. leader_lines : bool, optional Draw leader lines for displaced labels (default True). leader_threshold : float, optional Displacement (in label heights) that triggers a leader line. leader_color : str, optional Leader line colour (default ``"#666666"``). leader_width : float, optional Leader line width (default 0.5). wrap : bool, optional Wrap long labels at dashes (default True). wrap_max_chars : int, optional Maximum characters per line before wrapping (default 12). numbered_above : int, optional Switch to numbered-circle mode past this label count. numbered_legend_position : dict, optional Legend placement ``{"x": frac, "y": frac}`` in figure fractions. numbered_circle_r : float, optional Numbered circle radius in pixels (default 5). numbered_circle_fill : str, optional Numbered circle fill colour (default ``"#ffffff"``). numbered_circle_stroke : str, optional Numbered circle outline colour (default ``"#333333"``). numbered_legend_bg : str, optional Numbered legend background colour (default ``"white"``). numbered_legend_bg_opacity : float, optional Numbered legend background opacity (default 0.88). numbered_legend_border : str, optional Numbered legend border colour (default ``"#cccccc"``). numbered_legend_title : str, optional Numbered legend title text. n_sweeps : int, optional Number of annealing sweeps (default 50). initial_temperature : float, optional Annealing start temperature (default 1.0). final_temperature : float, optional Annealing end temperature (default 0.01). seed : int, optional Random seed for reproducible placement. Defaults to the map seed. w_LL, w_LO, w_LC, w_LA, w_D, w_OR, w_OOB : float, optional Energy weights tuning the placement: label-label, label-obstacle, label-chrome, label-anchor, distance, orientation bias and out-of-bounds penalties. detail : GeoDataFrame or dict, optional Finer geometry used to place the labels of a zoom or cartouche *target* instead of the generalised one. Text and priority stay those of *gdf*, carried through *detail_join*; a unit made of several finer parts is labelled once. A GeoDataFrame applies to a zoom or cartouche target; a dict maps ``"zoom"``, ``"cartouches"`` or a cartouche index to a GeoDataFrame. Ignored for the main map. See :mod:`mappyng.detail`. detail_join : str, tuple or None, optional Key shared by *gdf* and *detail*: a column name present in both, a ``(gdf_column, detail_column)`` pair, or None (default) to join on the index. z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Label(gdf, column="name", priority_col="population", ... max_labels=20, font_size=9)) """ kind = "labels" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, column: Any = _UNSET, target: Any = _UNSET, font_size: Any = _UNSET, font_family: Any = _UNSET, font_weight: Any = _UNSET, color: Any = _UNSET, halo_color: Any = _UNSET, halo_width: Any = _UNSET, priority_col: Any = _UNSET, min_priority: Any = _UNSET, max_labels: Any = _UNSET, initial_position: Any = _UNSET, candidate_positions: Any = _UNSET, label_offset: Any = _UNSET, max_displacement: Any = _UNSET, avoid_overlap: Any = _UNSET, allow_hide: Any = _UNSET, allow_symbol_overlap: Any = _UNSET, leader_lines: Any = _UNSET, leader_threshold: Any = _UNSET, leader_color: Any = _UNSET, leader_width: Any = _UNSET, wrap: Any = _UNSET, wrap_max_chars: Any = _UNSET, numbered_above: Any = _UNSET, numbered_legend_position: Any = _UNSET, numbered_circle_r: Any = _UNSET, numbered_circle_fill: Any = _UNSET, numbered_circle_stroke: Any = _UNSET, numbered_legend_bg: Any = _UNSET, numbered_legend_bg_opacity: Any = _UNSET, numbered_legend_border: Any = _UNSET, numbered_legend_title: Any = _UNSET, n_sweeps: Any = _UNSET, initial_temperature: Any = _UNSET, final_temperature: Any = _UNSET, seed: Any = _UNSET, w_LL: Any = _UNSET, w_LO: Any = _UNSET, w_LC: Any = _UNSET, w_LA: Any = _UNSET, w_D: Any = _UNSET, w_OR: Any = _UNSET, w_OOB: Any = _UNSET, detail: Any = _UNSET, detail_join: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( column=column, target=target, font_size=font_size, font_family=font_family, font_weight=font_weight, color=color, halo_color=halo_color, halo_width=halo_width, priority_col=priority_col, min_priority=min_priority, max_labels=max_labels, initial_position=initial_position, candidate_positions=candidate_positions, label_offset=label_offset, max_displacement=max_displacement, avoid_overlap=avoid_overlap, allow_hide=allow_hide, allow_symbol_overlap=allow_symbol_overlap, leader_lines=leader_lines, leader_threshold=leader_threshold, leader_color=leader_color, leader_width=leader_width, wrap=wrap, wrap_max_chars=wrap_max_chars, numbered_above=numbered_above, numbered_legend_position=numbered_legend_position, numbered_circle_r=numbered_circle_r, numbered_circle_fill=numbered_circle_fill, numbered_circle_stroke=numbered_circle_stroke, numbered_legend_bg=numbered_legend_bg, numbered_legend_bg_opacity=numbered_legend_bg_opacity, numbered_legend_border=numbered_legend_border, numbered_legend_title=numbered_legend_title, n_sweeps=n_sweeps, initial_temperature=initial_temperature, final_temperature=final_temperature, seed=seed, w_LL=w_LL, w_LO=w_LO, w_LC=w_LC, w_LA=w_LA, w_D=w_D, w_OR=w_OR, w_OOB=w_OOB, detail=detail, detail_join=detail_join, ), )
[docs] def validate(self) -> None: if "column" not in self.params: raise ValueError( "Label needs a 'column' parameter naming the text " "column to label with, e.g. " "Label(gdf, column='name')." ) _require_column(self, self.params["column"]) _require_column(self, self.params.get("priority_col"), "priority_col")
[docs] def render(self, ctx: RenderContext) -> None: params = dict(self.params) column = params.pop("column") ctx.map._render_labels(self.gdf, column, **params) self._rendered = True
[docs] @register_layer class Vector(Layer): """Raw vector layer drawn with explicit styling. Parameters ---------- gdf : GeoDataFrame Geometries to draw. position : str, optional ``"background"`` (default) or ``"top"`` relative to the data. Without ``position`` or ``z``, a positive ``z_index`` implies ``"top"``. z : int, optional Explicit stacking order, overriding ``position``. fill : str, optional Fill colour as ``#RRGGBB`` (default ``"none"``). stroke : str, optional Stroke colour (default from style). stroke_width : float, optional Stroke width (default from style). fill_opacity : float, optional Fill opacity in ``[0, 1]``. stroke_opacity : float, optional Stroke opacity in ``[0, 1]``. A wide faint stroke under a thin bright one fakes a glowing line. stroke_linecap : str, optional ``"butt"``, ``"round"`` or ``"square"``. stroke_dasharray : str, optional SVG dash pattern, e.g. ``"4 2"``. detail : GeoDataFrame or dict, optional Finer geometry drawn instead of *gdf* on the zoom window and the cartouches (see :class:`Choropleth`). No join is needed: the finer geometries replace *gdf*'s. on_zoom : bool, optional Draw on the zoom viewport (default False). on_cartouches : bool, optional Draw on cartouche viewports (default True). z_index : int, optional Render order among layers (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(Vector(gdf, stroke="#333", stroke_width=0.4, position="top")) """ kind = "vector" requires_gdf = True
[docs] def __init__( self, gdf: Any | None = None, *, position: Any = _UNSET, z: Any = _UNSET, fill: Any = _UNSET, stroke: Any = _UNSET, stroke_width: Any = _UNSET, fill_opacity: Any = _UNSET, stroke_opacity: Any = _UNSET, stroke_linecap: Any = _UNSET, stroke_dasharray: Any = _UNSET, detail: Any = _UNSET, on_zoom: Any = _UNSET, on_cartouches: Any = _UNSET, z_index: int = 0, visible: bool = True, ) -> None: super().__init__( gdf, z_index=z_index, visible=visible, **collect_set( position=position, z=z, fill=fill, stroke=stroke, stroke_width=stroke_width, fill_opacity=fill_opacity, stroke_opacity=stroke_opacity, stroke_linecap=stroke_linecap, detail=detail, stroke_dasharray=stroke_dasharray, on_zoom=on_zoom, on_cartouches=on_cartouches, ), )
[docs] def render(self, ctx: RenderContext) -> None: params = dict(self.params) # A raised z_index lifts the layer above the data, like position="top". if self.z_index > 0 and "z" not in params and "position" not in params: params["position"] = "top" ctx.map._render_vector(ctx.map._in_map_crs(self.gdf), params) self._rendered = True