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, Optional

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


[docs] @register_layer class ChoroplethLayer(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, optional Legend configuration (see :class:`ChoroplethLegendConfig`), or a legend config instance. 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. 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(ChoroplethLayer(gdf, column="density", method="FisherJenks")) """ kind = "choropleth" requires_gdf = True
[docs] def __init__( self, gdf: Optional[Any] = 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, 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, on_zoom=on_zoom, on_cartouches=on_cartouches, ), )
[docs] def validate(self) -> None: if "column" not in self.params: raise ValueError( "ChoroplethLayer needs a 'column' parameter naming the " "numeric column to classify, e.g. " "ChoroplethLayer(gdf, column='density'). A choropleth " "colours each area by a value, so it has nothing to draw " "without one." ) 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." ) # 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"ChoroplethLayer: 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, ProportionalLayer preserves the " f"magnitude. Consider normalising by area or population, " f"or use ProportionalLayer.", 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 (method == "StdMean" and isinstance(cmap, str) and cmap not in DIVERGING_COLORMAPS): choices = ", ".join(repr(c) for c in DIVERGING_COLORMAPS) warnings.warn( f"ChoroplethLayer: 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, 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), on_zoom=cfg.pop("on_zoom", True), on_cartouches=cfg.pop("on_cartouches", True), ) ctx.map._state.interactive_layers.append({ "type": "choropleth", "gdf": self.gdf, "column": column, "layer_id": layer_id, "id_prefix": f"choro-{layer_id}-", }) self._rendered = True
[docs] @register_layer class ProportionalLayer(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, optional Legend configuration (see :class:`ProportionalLegendConfig`). 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(ProportionalLayer(gdf, column="population", max_radius=30)) """ kind = "proportional" requires_gdf = True
[docs] def __init__( self, gdf: Optional[Any] = 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, 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, ), )
[docs] def validate(self) -> None: if "column" not in self.params: raise ValueError( "ProportionalLayer needs a 'column' parameter naming the " "numeric column that drives symbol size, e.g. " "ProportionalLayer(gdf, column='population')." )
[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, 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), ) 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 SituationLayer(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, optional Legend configuration (see :class:`SituationLegendConfig`). 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(SituationLayer(gdf, marker="square", fill="#1f78b4")) """ kind = "situation" requires_gdf = True
[docs] def __init__( self, gdf: Optional[Any] = 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, 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, ), )
[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"SituationLayer: {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) layer_id = _add_situation( ctx.map, self.gdf, column=cfg.pop("column", None), 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), ) ctx.map._state.interactive_layers.append({ "type": "situation", "gdf": self.gdf, "column": cfg.get("column"), "layer_id": layer_id, "id_prefix": f"situ-{layer_id}-", }) self._rendered = True
[docs] @register_layer class LabelLayer(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). 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. z_index : int, optional Render order (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(LabelLayer(gdf, column="name", priority_col="population", ... max_labels=20, font_size=9)) """ kind = "labels" requires_gdf = True
[docs] def __init__( self, gdf: Optional[Any] = 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, 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, 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, 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, ), )
[docs] def validate(self) -> None: if "column" not in self.params: raise ValueError( "LabelLayer needs a 'column' parameter naming the text " "column to label with, e.g. " "LabelLayer(gdf, column='name')." )
[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 VectorLayer(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. 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"``. on_zoom : bool, optional Draw on the zoom viewport (default False). on_cartouches : bool, optional Draw on cartouche viewports (default False). z_index : int, optional Render order among layers (default 0). visible : bool, optional Whether the layer is drawn (default True). Examples -------- >>> m.add(VectorLayer(gdf, stroke="#333", stroke_width=0.4, ... position="top")) """ kind = "vector" requires_gdf = True
[docs] def __init__( self, gdf: Optional[Any] = 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, 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, stroke_dasharray=stroke_dasharray, on_zoom=on_zoom, on_cartouches=on_cartouches, ), )
[docs] def render(self, ctx: RenderContext) -> None: ctx.map._render_vector(self.gdf, dict(self.params)) self._rendered = True