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