"""
Choropleth map visualization for mappyng.
Handles data classification (via mapclassify) and SVG rendering of
colored polygons on all viewports (main, cartouches, zoom).
Each polygon gets SVG attributes:
- id: based on GeoDataFrame index for later filtering/interactivity
- class: CSS class "choropleth choropleth-{class_index}"
- data-value: original data value for tooltips
All default values are imported from :mod:`mappyng.defaults`.
Example
-------
>>> from mappyng import Map, BasemapLayer, ChoroplethLayer
>>> m = Map(gdf)
>>> m.add(BasemapLayer())
>>> m.add(ChoroplethLayer(gdf, column="population", method="Quantiles", cmap="YlOrRd"))
>>> m.render("choropleth.svg")
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple, Union
import geopandas as gpd
import numpy as np
import mapclassify as mc
from shapely.geometry import box
from . import defaults as D
from .config import COLORMAP_DEFS, Style
from .legend import (
BaseLegendConfig, LegendFrameConfig, _parse_frame_config,
compute_legend_position, ensure_hatch_pattern,
draw_legend_title, draw_legend_subtitle, draw_nodata_indicator, draw_legend_frame,
)
from .renderer import SvgViewport, geometry_to_paths
# ============================================================================
# Classification
# ============================================================================
METHODS = {
"EqualInterval": lambda col, k: mc.EqualInterval(col, k=k),
"Quantiles": lambda col, k: mc.Quantiles(col, k=k),
"FisherJenks": lambda col, k: mc.FisherJenks(col, k=k),
"Q6": lambda col, _: mc.Percentiles(col, pct=[5, 25, 50, 75, 95, 100]),
"StdMean": lambda col, k: mc.StdMean(col, multiples=D.CHOROPLETH_STDMEAN_MULTIPLES[k]),
"PrettyBreaks": lambda col, k: mc.PrettyBreaks(col, k=k),
}
def _resolve_stdmean_k(num_classes: int) -> int:
"""Validate and snap the StdMean class count.
StdMean splits the data in standard-deviation steps either side of the
mean, with a central class straddling it, so the count must be odd. Only
the counts in :data:`~mappyng.defaults.CHOROPLETH_STDMEAN_MULTIPLES` (5 and
7) are supported. They build a *double gamme* (diverging) palette: one ramp
below the mean and one above, meeting at a neutral central class.
An unsupported count is not an error: it snaps to the nearest valid count
(5 or 7) and emits a :class:`UserWarning`, so the map still renders.
"""
if num_classes in D.CHOROPLETH_STDMEAN_MULTIPLES:
return num_classes
allowed = sorted(D.CHOROPLETH_STDMEAN_MULTIPLES)
snapped = min(allowed, key=lambda k: (abs(k - num_classes), k))
warnings.warn(
f"StdMean supports {', '.join(str(k) for k in allowed)} classes only "
f"(odd counts: standard-deviation steps either side of the mean, with a "
f"central class straddling it). Requested num_classes={num_classes}; "
f"using num_classes={snapped} instead.",
UserWarning,
stacklevel=2,
)
return snapped
def _resolve_num_classes(method: Union[str, list], num_classes: int) -> int:
"""Return the effective number of classes."""
if method == "Q6":
return 6
if method == "StdMean":
return _resolve_stdmean_k(num_classes)
if isinstance(method, list):
# method is a list of break points (edges): N+1 values to N classes
return len(method) - 1
if not D.CHOROPLETH_MIN_CLASSES <= num_classes <= D.CHOROPLETH_MAX_CLASSES:
raise ValueError(
f"num_classes must be {D.CHOROPLETH_MIN_CLASSES}-"
f"{D.CHOROPLETH_MAX_CLASSES}, got {num_classes}"
)
return num_classes
def _resolve_colors(cmap: Union[str, List[str]], num_classes: int) -> List[str]:
"""Return a list of hex color strings."""
if isinstance(cmap, list):
if len(cmap) != num_classes:
raise ValueError(
f"Custom color list length ({len(cmap)}) != num_classes ({num_classes})"
)
return cmap
if cmap not in COLORMAP_DEFS:
raise ValueError(f"Unknown colormap: {cmap}. Available: {list(COLORMAP_DEFS)}")
if num_classes not in COLORMAP_DEFS[cmap]:
raise ValueError(f"No {num_classes}-class palette for '{cmap}'")
return COLORMAP_DEFS[cmap][num_classes]
def _resolve_colors_with_fallback(cmap: Union[str, List[str]], k_real: int) -> List[str]:
"""Resolve colors for a real class count, with fallback for missing palette sizes.
Used by PrettyBreaks, whose actual class count may differ from the requested k.
If the colormap has no palette for k_real, the nearest available palette is used
(preferring the smallest k_avail >= k_real, then truncating to k_real colors).
A warning is emitted when a fallback is applied.
Parameters
----------
cmap : str or list
Colormap name or explicit list of hex colors.
k_real : int
Actual number of classes produced by the classifier.
Returns
-------
list of str
Exactly k_real hex color strings.
"""
if isinstance(cmap, list):
if len(cmap) != k_real:
raise ValueError(
f"Custom color list length ({len(cmap)}) must equal the real number "
f"of PrettyBreaks classes ({k_real})."
)
return cmap
if cmap not in COLORMAP_DEFS:
raise ValueError(f"Unknown colormap: {cmap}. Available: {list(COLORMAP_DEFS)}")
palette_map = COLORMAP_DEFS[cmap]
if k_real in palette_map:
return palette_map[k_real]
available = sorted(palette_map.keys())
# Prefer smallest k_avail >= k_real (then truncate); fallback to largest available.
candidates_above = [k for k in available if k >= k_real]
if candidates_above:
k_avail = min(candidates_above)
colors = palette_map[k_avail][:k_real]
warnings.warn(
f"PrettyBreaks: no {k_real}-class palette for '{cmap}'. "
f"Using first {k_real} colors from the {k_avail}-class palette.",
UserWarning,
stacklevel=4,
)
else:
k_avail = max(available)
colors = palette_map[k_avail]
warnings.warn(
f"PrettyBreaks: no {k_real}-class palette for '{cmap}' and no larger palette "
f"available. Using the {k_avail}-class palette ({k_avail} colors for {k_real} classes).",
UserWarning,
stacklevel=4,
)
return colors
def _looks_like_stock(values: np.ndarray) -> Optional[float]:
"""Return the column maximum if *values* look like stock data, else None.
A choropleth maps colour value, read as a relative quantity. Absolute
counts (stock) are better served by proportional symbols. This heuristic
flags the common signature of stock data so a (non-blocking) warning can
be emitted. All of the following must hold:
* at least 3 non-NaN values,
* all strictly positive,
* all integer-valued (rates, densities and averages are typically
fractional, so this filters most relative variables out),
* the maximum reaches
:data:`~mappyng.defaults.CHOROPLETH_STOCK_MAGNITUDE_WARN` (rules out
identifier-like columns with a small range, e.g. region ids),
* the max/min ratio is at least
:data:`~mappyng.defaults.CHOROPLETH_STOCK_RATIO_WARN` (rules out
near-constant columns such as a year of reference).
Parameters
----------
values : np.ndarray
Raw column values (may contain NaN).
Returns
-------
float or None
The column maximum when it looks like stock data, else None.
"""
v = np.asarray(values, dtype=float)
v = v[~np.isnan(v)]
if v.size < 3 or np.any(v <= 0):
return None
if not np.allclose(v, np.round(v)):
return None
vmin, vmax = float(v.min()), float(v.max())
if vmax < D.CHOROPLETH_STOCK_MAGNITUDE_WARN:
return None
if vmax / vmin < D.CHOROPLETH_STOCK_RATIO_WARN:
return None
return vmax
def classify(
gdf: gpd.GeoDataFrame,
column: str,
method: Union[str, list] = D.CHOROPLETH_DEFAULT_METHOD,
num_classes: int = D.CHOROPLETH_DEFAULT_NUM_CLASSES,
decimals: int = D.CHOROPLETH_DECIMALS,
) -> Tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, mc.classifiers.MapClassifier, np.ndarray]:
"""Classify a GeoDataFrame column and assign class indices.
Returns
-------
classified : GeoDataFrame
Rows with data, gains ``_class`` (int) column.
nodata : GeoDataFrame
Rows where *column* is NaN.
scheme : mapclassify classifier
The fitted classification object.
bins : ndarray
Bin edges including the minimum value.
"""
gdf = gdf.copy()
nodata = gdf[gdf[column].isna()]
gdf = gdf.dropna(subset=[column])
if isinstance(method, list):
bins_list = [float(v) for v in method]
scheme = mc.UserDefined(gdf[column], bins=bins_list[1:])
else:
if method not in METHODS:
raise ValueError(f"Unknown method '{method}'. Available: {list(METHODS)}")
if method == "StdMean":
# StdMean has its own valid class counts (5, 7); snap with a warning
# rather than enforcing the generic 4-7 range.
num_classes = _resolve_stdmean_k(num_classes)
elif not D.CHOROPLETH_MIN_CLASSES <= num_classes <= D.CHOROPLETH_MAX_CLASSES:
raise ValueError(
f"num_classes must be {D.CHOROPLETH_MIN_CLASSES}-"
f"{D.CHOROPLETH_MAX_CLASSES}, got {num_classes}"
)
gdf[column] = gdf[column].round(decimals=decimals)
scheme = METHODS[method](gdf[column], num_classes)
bins = np.round([gdf[column].min()] + list(scheme.bins), decimals)
# Use len(scheme.bins) as the effective class count: for PrettyBreaks,
# scheme.k reports the requested k, not the actual number of bins produced.
k_eff = len(scheme.bins)
gdf["_class"] = np.clip(scheme.yb, 0, k_eff - 1)
return gdf, nodata, scheme, bins
# ============================================================================
# SVG rendering helpers
# ============================================================================
def _resolve_tolerance(simplify, viewport: SvgViewport) -> Optional[float]:
"""Resolve a simplification tolerance in CRS units for one viewport.
Parameters
----------
simplify : None, "auto" or float
``None`` disables simplification. ``"auto"`` targets half a pixel
at the viewport scale. A number is used directly as a tolerance in
the data CRS units.
viewport : SvgViewport
The viewport whose scale sets the pixel size for ``"auto"``.
Returns
-------
float or None
The tolerance, or ``None`` when simplification is off or the scale
is unusable.
"""
if simplify is None:
return None
if isinstance(simplify, str):
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 the "
f"render scale, or pass a tolerance in the data CRS units."
)
scale = getattr(viewport.viewbox, "scale", 0.0)
if not scale or scale <= 0:
return None
return D.SIMPLIFY_AUTO_PIXELS / scale
return float(simplify)
def _simplify_gdf(gdf: gpd.GeoDataFrame, simplify,
viewport: SvgViewport) -> gpd.GeoDataFrame:
"""Return a copy of *gdf* with geometries simplified for *viewport*.
Uses Douglas-Peucker with topology preserved within each geometry.
Borders shared between separate features are simplified independently,
so gaps can appear between neighbours when the data is not dissolved.
If the tolerance would empty a geometry, the original is kept and a
non-blocking warning is emitted.
"""
tol = _resolve_tolerance(simplify, viewport)
if not tol or gdf.empty:
return gdf
simplified = gdf.geometry.simplify(tol, preserve_topology=True)
emptied = simplified.is_empty | simplified.isna()
if emptied.any():
warnings.warn(
f"simplify: tolerance {tol:.4g} emptied "
f"{int(emptied.sum())} geometry(ies); their originals are kept. "
f"Lower the tolerance or pass simplify='auto' to scale it to "
f"the render resolution.",
UserWarning, stacklevel=2,
)
simplified = simplified.where(~emptied, gdf.geometry)
out = gdf.copy()
out.geometry = simplified
return out
def _draw_classified_gdf(
viewport: SvgViewport,
gdf: gpd.GeoDataFrame,
colors: List[str],
column: str,
stroke: str,
stroke_width: float,
z_order: int,
layer_id: int,
simplify=None,
) -> None:
"""Render classified polygons as SVG paths with semantic attributes."""
layer_name = "choropleth"
gdf = _simplify_gdf(gdf, simplify, viewport)
for idx, row in gdf.iterrows():
geom = row.geometry
if geom is None or geom.is_empty:
continue
cls = int(row["_class"])
fill = colors[cls]
value = row.get(column, "")
attrs = {
"fill": fill,
"stroke": stroke,
"stroke_width": stroke_width,
"id": f"choro-{layer_id}-{idx}",
"class": f"choropleth choropleth-{cls}",
"data_value": str(value),
}
for d in geometry_to_paths(geom, viewport.viewbox):
viewport.add_path(layer_name, d, z_order=z_order, **attrs)
def _draw_nodata_gdf(
viewport: SvgViewport,
gdf: gpd.GeoDataFrame,
hatch_color: str,
stroke_width: float,
z_order: int,
layer_id: int,
defs_element,
hatch_style: str = None,
simplify=None,
) -> None:
"""Render no-data polygons with a hatch pattern fill."""
if gdf.empty:
return
from . import defaults as _D
pattern_id = f"hatch-{layer_id}-{viewport.name}"
ensure_hatch_pattern(defs_element, pattern_id, hatch_color,
style=hatch_style or _D.LEGEND_HATCH_STYLE)
layer_name = "choropleth"
gdf = _simplify_gdf(gdf, simplify, viewport)
for idx, row in gdf.iterrows():
geom = row.geometry
if geom is None or geom.is_empty:
continue
attrs = {
"fill": f"url(#{pattern_id})",
"stroke": hatch_color,
"stroke_width": stroke_width,
"id": f"choro-nodata-{layer_id}-{idx}",
"class": "choropleth choropleth-nodata",
}
for d in geometry_to_paths(geom, viewport.viewbox):
viewport.add_path(layer_name, d, z_order=z_order, **attrs)
# ============================================================================
# Legend
# ============================================================================
[docs]
@dataclass
class ChoroplethLegendConfig(BaseLegendConfig):
"""Choropleth-specific legend configuration.
Inherits all shared fields from :class:`~mappyng.legend.BaseLegendConfig`.
Pass as the ``legend`` dict in :meth:`~mappyng.Map.add_choropleth`.
Attributes
----------
decimals : int
Number of decimal places for class boundary labels (default 1).
orientation : str
Legend layout: ``"horizontal"`` (default) or ``"vertical"``.
swatch_width : float
Width of each color swatch in horizontal mode (SVG px).
swatch_size : float or None
Sets the swatch width for both horizontal and vertical modes at once.
Height is always derived as ``swatch_size / phi``. When ``None``,
``swatch_width`` and ``vertical_swatch_width`` are used independently.
swatch_height : float or None
Height of each color swatch in horizontal mode.
Defaults to ``swatch_width / phi`` (golden ratio).
swatch_spacing : float
Gap between swatches (SVG px).
label_rotation : float
Rotation of bin labels in degrees (0 = horizontal, default 0).
label_position : str
Horizontal legend label placement: ``"border"`` (default, n+1
labels at swatch edges) or ``"center"`` (n labels centered inside
each swatch).
label_format : str
Label content when ``label_position="center"``: ``"value"`` (lower
bound only) or ``"range"`` (``[min, max]`` interval).
label_anchor : str
SVG ``text-anchor`` for rotated labels: ``"middle"`` (default) or
``"end"``.
label_margin : float
Gap in SVG px between the bottom of swatches and label baseline.
nodata_gap : float
Horizontal gap between the last class swatch and the N/A swatch.
nodata_label_gap : float
Gap between the N/A swatch right edge and its label.
nodata_label : str or None
Custom label for missing-value swatch. Defaults to ``"N/A"``.
vertical_swatch_width : float
Swatch width in vertical mode (SVG px).
vertical_swatch_height : float or None
Swatch height in vertical mode.
Defaults to ``vertical_swatch_width / phi`` (golden ratio).
vertical_label_gap : float
Gap between swatch right edge and label in vertical mode.
labels : list of str or None
Custom label list that replaces auto-generated range labels.
Must have length == num_classes.
"""
decimals: int = D.CHOROPLETH_DECIMALS
orientation: str = D.CHOROPLETH_ORIENTATION
swatch_size: Optional[float] = None # global width override for both modes
swatch_width: float = D.CHOROPLETH_SWATCH_WIDTH
swatch_height: Optional[float] = None # default: swatch_width / phi
swatch_spacing: float = D.CHOROPLETH_SWATCH_SPACING
label_rotation: float = D.CHOROPLETH_LABEL_ROTATION
label_position: str = "border"
"""Horizontal legend label placement: 'border' (default, n+1 at swatch edges)
or 'center' (n labels centered inside each swatch)."""
label_format: str = "value"
"""Label content in center mode: 'value' (min bound) or 'range' ([min, max])."""
label_anchor: str = "middle"
"""SVG text-anchor for rotated labels: 'end' or 'middle' (default).
With 'middle', vertical offset is auto-adjusted to prevent overlap."""
label_margin: float = D.CHOROPLETH_LABEL_MARGIN
"""Gap in pixels between the bottom of the swatches and the label baseline."""
nodata_gap: float = D.LEGEND_NODATA_GAP
"""Horizontal gap in pixels between the last class swatch and the N/A swatch."""
nodata_label_gap: float = D.LEGEND_NODATA_LABEL_GAP
"""Horizontal gap in pixels between the N/A swatch right edge and its label."""
nodata_label: Optional[str] = None
"""Custom label for the N/A swatch. Defaults to D.LEGEND_NODATA_LABEL ('N/A')."""
# Vertical-specific overrides (used when orientation == "vertical")
vertical_swatch_width: float = D.CHOROPLETH_VERTICAL_SWATCH_WIDTH
vertical_swatch_height: Optional[float] = None # default: vertical_swatch_width / phi
vertical_label_gap: float = D.CHOROPLETH_VERTICAL_LABEL_GAP
# Custom label list (overrides auto-generated range labels)
labels: Optional[List[str]] = None
decimal_sep: str = "."
"""Decimal separator. Use ',' for French convention (e.g. '12,5')."""
thousands_sep: str = " "
"""Thousands separator. Default narrow space ('1 234'); use '' for none."""
# Distribution histogram
histogram: bool = False
"""Draw a mini distribution histogram to the right of the legend block."""
histogram_bins: int = D.CHOROPLETH_HIST_DEFAULT_BINS
"""Number of bars in the histogram."""
histogram_width: float = D.CHOROPLETH_HIST_WIDTH
"""Width of the histogram area (SVG px)."""
histogram_height: float = D.CHOROPLETH_HIST_HEIGHT
"""Height of the histogram area (SVG px)."""
histogram_gap: float = D.CHOROPLETH_HIST_GAP
"""Horizontal gap between legend block and histogram (SVG px)."""
[docs]
def resolve_defaults(self, style, font_scale: float = 1.0) -> None:
super().resolve_defaults(style, font_scale)
s = D.LEGEND_SWATCH_SCALE
# swatch_size overrides both widths; height is always derived via PHI
if self.swatch_size is not None:
self.swatch_width = self.swatch_size
self.vertical_swatch_width = self.swatch_size
self.swatch_height = None
self.vertical_swatch_height = None
self.swatch_width = self.swatch_width * s
if self.swatch_height is not None:
self.swatch_height = self.swatch_height * s
self.swatch_spacing = self.swatch_spacing * s
self.vertical_swatch_width = self.vertical_swatch_width * s
if self.vertical_swatch_height is not None:
self.vertical_swatch_height = self.vertical_swatch_height * s
self.label_margin = self.label_margin * font_scale
self.nodata_gap = self.nodata_gap * s
@dataclass
class FactorLegend(ChoroplethLegendConfig):
"""Legend for a principal-component (factor) choropleth.
A factor map shows the score of one component of a principal component
analysis. The axis is diverging and centred on zero. This legend adds,
around the usual class block, the elements such a map needs: the factor
and its share of variance in the title, an interpretation text at each
pole of the axis, and a methodology note. Pass it as the ``legend``
argument of a :class:`~mappyng.ChoroplethLayer`; the layer's breaks and
colours are reused as is.
Attributes
----------
variance : float or None
Share of total variance carried by the factor, a fraction in
``[0, 1]``. Appended to the title as a percentage. ``None`` omits it.
pole_high : str or None
Meaning of the positive end of the axis (the highest class).
pole_low : str or None
Meaning of the negative end of the axis (the lowest class).
note : str or None
Methodology note, rendered small at the foot of the legend.
pole_fontsize : int or None
Font size of the pole texts. Defaults to the item label size.
note_fontsize : int or None
Font size of the note. Defaults to the subtitle size.
pole_max_chars : int
Characters per line before a pole text wraps.
note_max_chars : int
Characters per line before the note wraps.
"""
variance: Optional[float] = None
pole_high: Optional[str] = None
pole_low: Optional[str] = None
note: Optional[str] = None
pole_fontsize: Optional[int] = None
note_fontsize: Optional[int] = None
value_fontsize: Optional[int] = None
pole_max_chars: int = D.FACTOR_POLE_MAX_CHARS
note_max_chars: int = D.FACTOR_NOTE_MAX_CHARS
def resolve_defaults(self, style, font_scale: float = 1.0) -> None:
super().resolve_defaults(style, font_scale)
self.pole_fontsize = round(
(self.pole_fontsize or style["legend_fontsize"]) * font_scale)
self.note_fontsize = round(
(self.note_fontsize or D.LEGEND_SUBTITLE_FONT_SIZE) * font_scale)
# Break values and the no-data label are smaller than the pole texts.
self.value_fontsize = round(
(self.value_fontsize or D.FACTOR_VALUE_FONT_SIZE) * font_scale)
def title_with_variance(self) -> Optional[str]:
"""Return the title with the variance share appended, if any."""
if self.variance is None:
return self.title
pct = _fmt_value(self.variance * 100, 1, self.thousands_sep,
self.decimal_sep)
suffix = f"{pct}% of total variance"
return f"{self.title} ({suffix})" if self.title else f"({suffix})"
def _fmt_value(v, decimals: int, thousands_sep: str = " ", decimal_sep: str = ".") -> str:
"""Format a single numeric value respecting decimals (0 to integer, no trailing .0)."""
if decimals <= 0:
s = f"{int(round(v)):,}"
else:
s = f"{v:,.{decimals}f}"
return s.replace(",", "\x00").replace(".", decimal_sep).replace("\x00", thousands_sep)
def _format_range_label(low, high, decimals: int,
thousands_sep: str = " ", decimal_sep: str = ".") -> str:
"""Format a range label like ``from 123 to 456``."""
def _fmt(v):
if decimals <= 0:
s = f"{int(round(v)):,}"
else:
s = f"{v:,.{decimals}f}"
return s.replace(",", "\x00").replace(".", decimal_sep).replace("\x00", thousands_sep)
return f"from {_fmt(low)} to {_fmt(high)}"
def _draw_legend_histogram(
legend_g,
x: float,
y: float,
values: np.ndarray,
bins: np.ndarray,
colors: List[str],
config: "ChoroplethLegendConfig",
) -> Optional[Tuple[float, float, float, float]]:
"""Draw a per-class-coloured distribution histogram to the right of the legend.
Parameters
----------
legend_g : xml.etree.ElementTree.Element
Parent SVG group element.
x, y : float
Top-left corner of the histogram area (SVG px).
values : np.ndarray
Raw data values (pre-dissolve, pre-NaN-drop).
bins : np.ndarray
Class boundaries including true minimum: [min, b1, b2, ..., bk].
colors : list of str
Class colors (len == number of classes).
config : ChoroplethLegendConfig
Legend configuration.
Returns
-------
tuple (x0, y0, x1, y1) or None
Histogram bounding box, or None when skipped (degenerate distribution).
"""
import xml.etree.ElementTree as ET
hist_min = float(values.min())
hist_max = float(values.max())
if len(values) < 2 or hist_min == hist_max:
warnings.warn(
"Histogram skipped: column has no spread.",
UserWarning,
stacklevel=6,
)
return None
n_bars = config.histogram_bins
w = config.histogram_width
h = config.histogram_height
bar_w = w / n_bars
counts, edges = np.histogram(values, bins=n_bars, range=(hist_min, hist_max))
max_count = int(counts.max())
if max_count == 0:
return None
# bins[1:] are the upper class boundaries (== scheme.bins).
# np.searchsorted with side='left' matches mapclassify's own assignment.
upper_bounds = np.asarray(bins[1:], dtype=float)
for i, count in enumerate(counts):
if count == 0:
continue
center = float((edges[i] + edges[i + 1]) / 2)
class_idx = int(np.clip(
np.searchsorted(upper_bounds, center, side="left"),
0, len(colors) - 1,
))
bar_h = h * count / max_count
bar_x = x + i * bar_w
bar_y = y + h - bar_h
rect = ET.SubElement(
legend_g, "rect",
x=f"{bar_x:.2f}", y=f"{bar_y:.2f}",
width=f"{bar_w:.2f}", height=f"{bar_h:.2f}",
fill=colors[class_idx],
)
rect.set("class", "choropleth-legend-hist-bar")
rect.set("stroke", "#ffffff")
rect.set("stroke-width", str(D.CHOROPLETH_HIST_BAR_STROKE_WIDTH))
rect.set("data-count", str(count))
# Baseline
baseline = ET.SubElement(
legend_g, "line",
x1=f"{x:.2f}", y1=f"{y + h:.2f}",
x2=f"{x + w:.2f}", y2=f"{y + h:.2f}",
stroke=D.CHOROPLETH_HIST_BASELINE_COLOR,
)
baseline.set("stroke-width", "0.5")
return (x, y, x + w, y + h)
def _draw_legend(
svg_doc,
colors: List[str],
bins: np.ndarray,
config: ChoroplethLegendConfig,
has_nodata: bool,
main_viewport: SvgViewport,
map_obj=None,
values: Optional[np.ndarray] = None,
) -> None:
"""Draw a choropleth legend below the main map.
Supports horizontal (classic boundary labels) and vertical
(swatch + range text) orientations.
"""
if config.orientation == "vertical":
bbox = _draw_legend_vertical(svg_doc, colors, bins, config, has_nodata, main_viewport, values=values)
else:
bbox = _draw_legend_horizontal(svg_doc, colors, bins, config, has_nodata, main_viewport, values=values)
if map_obj is not None and bbox is not None:
map_obj.overflow.register(*bbox)
def _draw_legend_horizontal(
svg_doc,
colors: List[str],
bins: np.ndarray,
config: ChoroplethLegendConfig,
has_nodata: bool,
main_viewport: SvgViewport,
values: Optional[np.ndarray] = None,
):
"""Draw a horizontal choropleth legend (classic boundary labels). Returns (x0,y0,x1,y1)."""
import xml.etree.ElementTree as ET
vb = main_viewport.viewbox
num = len(colors)
sw_w = config.swatch_width
sw_h = (config.swatch_height if config.swatch_height is not None
else round(sw_w / D.PHI, 1))
sw_gap = config.swatch_spacing
font_size = config.fontsize or D.LEGEND_FONT_SIZE
label_rot = config.label_rotation
fc = config.fontcolor or D.FONT_COLOR
ff = config.fontfamily or D.FONT_FAMILY
# Total width of swatches + gaps
total_w = num * sw_w + (num - 1) * sw_gap
# Position
legend_x, legend_y = compute_legend_position(config, vb)
group = svg_doc.get_overlay_layer("legend", z_order=D.Z_LEGEND)
legend_g = ET.SubElement(group, "g", id="choropleth-legend")
# ---- Title (shared) ----
y_cursor = draw_legend_title(
legend_g, legend_x, legend_y, config, align="left",
)
y_cursor = draw_legend_subtitle(
legend_g, legend_x, y_cursor, config, align="left",
)
# ---- Color swatches ----
for i, color in enumerate(colors):
x = legend_x + i * (sw_w + sw_gap)
rect = ET.SubElement(
legend_g, "rect",
x=f"{x:.2f}", y=f"{y_cursor:.2f}",
width=f"{sw_w:.2f}", height=f"{sw_h:.2f}",
fill=color, stroke=fc,
id=f"choropleth-legend-swatch-{i}",
)
rect.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
rect.set("class", f"choropleth-legend choropleth-{i}")
# ---- Bin labels ----
import math
label_y = y_cursor + sw_h + config.label_margin + font_size
# When middle anchor + rotation: the right half of the text rises toward the swatches.
# Add extra vertical margin = (sw_w/2) * sin(|angle|) to prevent overlap.
if label_rot and config.label_anchor == "middle":
label_y += (sw_w / 2) * math.sin(math.radians(abs(label_rot)))
if config.label_position == "center":
# n labels, one per swatch, centered inside each swatch
for i in range(num):
lx = legend_x + i * (sw_w + sw_gap) + sw_w / 2
if config.labels is not None and i < len(config.labels):
# Custom categorical labels (parity with the vertical legend).
label_text = str(config.labels[i])
elif config.label_format == "range":
label_text = _format_range_label(
bins[i], bins[i + 1], config.decimals,
config.thousands_sep, config.decimal_sep)
else:
label_text = _fmt_value(
bins[i], config.decimals,
config.thousands_sep, config.decimal_sep)
attrs = {
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
"text-anchor": "middle",
}
if label_rot:
attrs["transform"] = f"rotate({label_rot}, {lx:.2f}, {label_y:.2f})"
attrs["text-anchor"] = config.label_anchor
ET.SubElement(
legend_g, "text",
x=f"{lx:.2f}", y=f"{label_y:.2f}",
**attrs,
).text = label_text
else:
# Default "border" mode: n+1 labels.
# Labels at i=0 (far left) and i=n (far right) sit at the outer edges of
# the first/last swatch. Interior labels (i=1..n-1) are centered in the
# gap between swatch[i-1] and swatch[i].
n_bins = len(bins)
for i, value in enumerate(bins):
if i == 0:
lx = legend_x
elif i == n_bins - 1:
lx = legend_x + (i - 1) * (sw_w + sw_gap) + sw_w
else:
# Center of the gap between swatch[i-1] and swatch[i]
lx = legend_x + (i - 1) * (sw_w + sw_gap) + sw_w + sw_gap / 2
attrs = {
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
}
if label_rot:
attrs["transform"] = f"rotate({label_rot}, {lx:.2f}, {label_y:.2f})"
attrs["text-anchor"] = config.label_anchor
elif i == 0:
attrs["text-anchor"] = "start"
elif i == n_bins - 1:
attrs["text-anchor"] = "end"
else:
attrs["text-anchor"] = "middle"
ET.SubElement(
legend_g, "text",
x=f"{lx:.2f}", y=f"{label_y:.2f}",
**attrs,
).text = _fmt_value(value, config.decimals, config.thousands_sep, config.decimal_sep)
# ---- No-data indicator, swatch à droite, label à droite du swatch ----
if has_nodata:
nd_x = legend_x + total_w + config.nodata_gap
hc = config.hatch_color or D.LEGEND_HATCH_COLOR
hs = config.hatch_style or D.LEGEND_HATCH_STYLE
nd_label = config.nodata_label or D.LEGEND_NODATA_LABEL
pattern_id = "hatch-legend-choropleth-horiz"
ensure_hatch_pattern(svg_doc._defs, pattern_id, hc, style=hs)
nd_rect = ET.SubElement(
legend_g, "rect",
x=f"{nd_x:.2f}", y=f"{y_cursor:.2f}",
width=f"{sw_w:.2f}", height=f"{sw_h:.2f}",
fill=f"url(#{pattern_id})",
stroke=hc,
id="choropleth-legend-nodata",
)
nd_rect.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
nd_rect.set("class", "choropleth-legend choropleth-nodata")
# Label to the right of the N/A swatch, vertically centered
nd_label_x = nd_x + sw_w + config.nodata_label_gap
nd_label_y = y_cursor + sw_h / 2 + font_size * 0.35
ET.SubElement(
legend_g, "text",
x=f"{nd_label_x:.2f}", y=f"{nd_label_y:.2f}",
**{
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
"text-anchor": "start",
},
).text = nd_label
# ---- Frame ----
frame_cfg = _parse_frame_config(config.frame)
if frame_cfg.enabled:
content_h = label_y - legend_y + font_size
draw_legend_frame(
legend_g, svg_doc._defs,
legend_x, legend_y - font_size,
total_w, content_h + font_size,
frame_cfg,
)
x1 = legend_x + total_w
if has_nodata:
x1 = legend_x + total_w + config.nodata_gap + sw_w + config.nodata_label_gap + font_size * 4
y1 = label_y + font_size
if config.histogram and values is not None:
hist_bbox = _draw_legend_histogram(
legend_g, x1 + config.histogram_gap, legend_y,
values, bins, colors, config,
)
if hist_bbox is not None:
x1 = hist_bbox[2]
return (legend_x, legend_y, x1, y1)
def _draw_legend_vertical(
svg_doc,
colors: List[str],
bins: np.ndarray,
config: ChoroplethLegendConfig,
has_nodata: bool,
main_viewport: SvgViewport,
values: Optional[np.ndarray] = None,
):
"""Draw a vertical choropleth legend. Returns (x0,y0,x1,y1).
Label modes (controlled by ``config.label_position`` and ``config.label_format``):
* ``"border"`` (default), n+1 bin values at class junctions, like the
horizontal legend: true min at top, true max at bottom.
* ``"center"`` + ``"range"``, one ``from xx to xx`` label per swatch,
centered vertically on the swatch.
* ``"center"`` + ``"value"``, lower-bound value per swatch, centered.
* ``config.labels`` supplied, custom text per class (implies center).
"""
import xml.etree.ElementTree as ET
vb = main_viewport.viewbox
num = len(colors)
sw_w = config.vertical_swatch_width
sw_h = (config.vertical_swatch_height if config.vertical_swatch_height is not None
else round(sw_w / D.PHI, 1))
sw_gap = config.swatch_spacing
label_gap = config.vertical_label_gap
font_size = config.fontsize or D.LEGEND_FONT_SIZE
fc = config.fontcolor or D.FONT_COLOR
ff = config.fontfamily or D.FONT_FAMILY
legend_x, legend_y = compute_legend_position(config, vb)
group = svg_doc.get_overlay_layer("legend", z_order=D.Z_LEGEND)
legend_g = ET.SubElement(group, "g", id="choropleth-legend")
y_cursor = draw_legend_title(legend_g, legend_x, legend_y, config, align="left")
y_cursor = draw_legend_subtitle(legend_g, legend_x, y_cursor, config, align="left")
# Determine effective mode
use_border = (config.label_position == "border") and (config.labels is None)
# ---- Color swatches (always the same) ----
for i, color in enumerate(colors):
row_y = y_cursor + i * (sw_h + sw_gap)
rect = ET.SubElement(
legend_g, "rect",
x=f"{legend_x:.2f}", y=f"{row_y:.2f}",
width=f"{sw_w:.2f}", height=f"{sw_h:.2f}",
fill=color, stroke=fc,
id=f"choropleth-legend-swatch-{i}",
)
rect.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
rect.set("class", f"choropleth-legend choropleth-{i}")
label_x = legend_x + sw_w + label_gap
max_label_w = 0.0
# ---- Labels ----
if use_border:
# n+1 bin values with uniform spacing.
# Treat min and max as if they sit in a virtual gap above/below the block:
# edge_y[i] = y_cursor - sw_gap/2 + i * (sw_h + sw_gap)
# to spacing between every consecutive pair = sw_h + sw_gap (constant).
for i, value in enumerate(bins):
edge_y = y_cursor - sw_gap / 2 + i * (sw_h + sw_gap)
label_text = _fmt_value(value, config.decimals, config.thousands_sep, config.decimal_sep)
ET.SubElement(
legend_g, "text",
x=f"{label_x:.2f}", y=f"{edge_y + font_size * 0.35:.2f}",
**{
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
"text-anchor": "start",
},
).text = label_text
est_w = len(label_text) * font_size * 0.55
if est_w > max_label_w:
max_label_w = est_w
else:
# center mode: one label per swatch
if config.labels is not None:
center_labels = list(config.labels)
elif config.label_format == "range":
center_labels = [
_format_range_label(bins[i], bins[i + 1], config.decimals, config.thousands_sep, config.decimal_sep)
for i in range(num)
]
else:
center_labels = [_fmt_value(bins[i], config.decimals, config.thousands_sep, config.decimal_sep) for i in range(num)]
for i, label_text in enumerate(center_labels):
row_y = y_cursor + i * (sw_h + sw_gap)
label_y = row_y + sw_h / 2 + font_size * 0.35
ET.SubElement(
legend_g, "text",
x=f"{label_x:.2f}", y=f"{label_y:.2f}",
**{
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
"text-anchor": "start",
},
).text = label_text
est_w = len(label_text) * font_size * 0.55
if est_w > max_label_w:
max_label_w = est_w
# ---- No-data column (to the right of the main block) ----
nd_col_w = 0.0
if has_nodata:
hc = config.hatch_color or D.LEGEND_HATCH_COLOR
hs = config.hatch_style or D.LEGEND_HATCH_STYLE
pattern_id = "hatch-legend-choropleth"
ensure_hatch_pattern(svg_doc._defs, pattern_id, hc, style=hs)
nd_label = config.nodata_label or D.LEGEND_NODATA_LABEL
nd_col_x = legend_x + sw_w + label_gap + max_label_w + config.nodata_gap
rect = ET.SubElement(
legend_g, "rect",
x=f"{nd_col_x:.2f}", y=f"{y_cursor:.2f}",
width=f"{sw_w:.2f}", height=f"{sw_h:.2f}",
fill=f"url(#{pattern_id})",
stroke=hc,
id="choropleth-legend-nodata",
)
rect.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
rect.set("class", "choropleth-legend choropleth-nodata")
nd_lbl_x = nd_col_x + sw_w + config.nodata_label_gap
nd_lbl_y = y_cursor + sw_h / 2 + font_size * 0.35
ET.SubElement(
legend_g, "text",
x=f"{nd_lbl_x:.2f}", y=f"{nd_lbl_y:.2f}",
**{
"font-size": str(font_size),
"font-family": ff,
"fill": fc,
"text-anchor": "start",
},
).text = nd_label
nd_label_w = len(nd_label) * font_size * 0.55
nd_col_w = config.nodata_gap + sw_w + config.nodata_label_gap + nd_label_w
# ---- Compute precise bounding box ----
# Bottom of content depends on label mode:
# - border: last label baseline = y_cursor - sw_gap/2 + num*(sw_h+sw_gap), add font_size margin
# - center/custom: bottom of last swatch
if use_border:
y_content_bottom = y_cursor - sw_gap / 2 + num * (sw_h + sw_gap) + font_size
else:
y_content_bottom = y_cursor + (num - 1) * (sw_h + sw_gap) + sw_h
content_w = sw_w + label_gap + max_label_w + nd_col_w
title_size = config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE
frame_top = legend_y - title_size * 0.3 # small margin above title text
# ---- Frame ----
frame_cfg = _parse_frame_config(config.frame)
if frame_cfg.enabled:
draw_legend_frame(
legend_g, svg_doc._defs,
legend_x, frame_top,
content_w, y_content_bottom - frame_top,
frame_cfg,
)
x1 = legend_x + content_w
y1 = y_content_bottom
if config.histogram and values is not None:
hist_bbox = _draw_legend_histogram(
legend_g, x1 + config.histogram_gap, y_cursor,
values, bins, colors, config,
)
if hist_bbox is not None:
x1 = hist_bbox[2]
y1 = max(y1, hist_bbox[3])
return (legend_x, legend_y, x1, y1)
def _draw_wrapped_text(parent, x, y, text, max_chars, font_size, ff, fc,
weight=None, anchor="start"):
"""Draw wrapped text from baseline y. Return (y_below, max_line_width)."""
import textwrap
import xml.etree.ElementTree as ET
lines = textwrap.wrap(text, max_chars) if len(text) > max_chars else [text]
line_h = font_size * D.FACTOR_LINE_HEIGHT
max_w = 0.0
for i, line in enumerate(lines):
attrs = {"font-size": str(font_size), "font-family": ff, "fill": fc,
"text-anchor": anchor}
if weight:
attrs["font-weight"] = weight
ET.SubElement(parent, "text", x=f"{x:.2f}",
y=f"{y + font_size + i * line_h:.2f}", **attrs).text = line
max_w = max(max_w, len(line) * font_size * 0.55)
return y + font_size + (len(lines) - 1) * line_h, max_w
def _arrow_v(parent, x, tail_y, tip_y, color):
"""Vertical single arrow from tail_y to tip_y, head at the tip."""
import xml.etree.ElementTree as ET
h = D.FACTOR_ARROW_HEAD
ln = ET.SubElement(parent, "line", x1=f"{x:.2f}", y1=f"{tail_y:.2f}",
x2=f"{x:.2f}", y2=f"{tip_y:.2f}", stroke=color)
ln.set("stroke-width", str(D.FACTOR_ARROW_WIDTH))
d = h if tip_y > tail_y else -h # head points toward the tip
ET.SubElement(parent, "polygon", fill=color, points=(
f"{x:.2f},{tip_y:.2f} {x - h / 2:.2f},{tip_y - d:.2f} "
f"{x + h / 2:.2f},{tip_y - d:.2f}"))
def _arrow_h(parent, y, tail_x, tip_x, color):
"""Horizontal single arrow from tail_x to tip_x, head at the tip."""
import xml.etree.ElementTree as ET
h = D.FACTOR_ARROW_HEAD
ln = ET.SubElement(parent, "line", x1=f"{tail_x:.2f}", y1=f"{y:.2f}",
x2=f"{tip_x:.2f}", y2=f"{y:.2f}", stroke=color)
ln.set("stroke-width", str(D.FACTOR_ARROW_WIDTH))
d = h if tip_x > tail_x else -h
ET.SubElement(parent, "polygon", fill=color, points=(
f"{tip_x:.2f},{y:.2f} {tip_x - d:.2f},{y - h / 2:.2f} "
f"{tip_x - d:.2f},{y + h / 2:.2f}"))
def _draw_factor_legend(svg_doc, colors, bins, config, has_nodata,
main_viewport, map_obj=None):
"""Draw a factor (PCA) legend: title, poles, class block, note.
Vertical: poles sit above and below the stacked swatches. Horizontal:
poles sit at the two ends above the swatch row. Breaks and colours come
from the choropleth layer unchanged.
"""
import xml.etree.ElementTree as ET
vb = main_viewport.viewbox
num = len(colors)
fs = config.fontsize or D.LEGEND_FONT_SIZE
pfs = config.pole_fontsize or fs
nfs = config.note_fontsize or D.LEGEND_SUBTITLE_FONT_SIZE
fc = config.fontcolor or D.FONT_COLOR
ff = config.fontfamily or D.FONT_FAMILY
vertical = config.orientation == "vertical"
# By default the legend sits outside the map, below the content. The
# overflow registry then expands the canvas downward to show it. A manual
# position keeps the old behaviour.
if config.position is None:
legend_x = vb.content_x
legend_y = vb.content_y + vb.content_height + D.FACTOR_BELOW_GAP
else:
legend_x, legend_y = compute_legend_position(config, vb)
group = svg_doc.get_overlay_layer("legend", z_order=D.Z_LEGEND)
legend_g = ET.SubElement(group, "g", id="factor-legend")
import math
y = draw_legend_title(legend_g, legend_x, legend_y, config, align="left")
x1 = legend_x
vfs = config.value_fontsize or D.FACTOR_VALUE_FONT_SIZE
# Variance share, smaller, on its own line below the title.
if config.variance is not None:
pct = _fmt_value(config.variance * 100, 1, config.thousands_sep,
config.decimal_sep)
y, w = _draw_wrapped_text(legend_g, legend_x, y,
f"{pct}% of total variance",
config.note_max_chars, nfs, ff, fc)
x1 = max(x1, legend_x + w)
y += D.FACTOR_POLE_GAP
def _fmt(v):
return _fmt_value(v, config.decimals, config.thousands_sep,
config.decimal_sep)
if vertical:
sw_w = config.vertical_swatch_width
sw_h = (config.vertical_swatch_height
if config.vertical_swatch_height is not None
else round(sw_w / D.PHI, 1))
sw_gap = config.swatch_spacing
label_gap = config.vertical_label_gap
arrow_x = legend_x + D.FACTOR_ARROW_GAP / 2
swatch_x = legend_x + D.FACTOR_ARROW_GAP
# Positive pole on top; its baseline ends just above the up arrow tip.
if config.pole_high:
y, w = _draw_wrapped_text(legend_g, legend_x, y, config.pole_high,
config.pole_max_chars, pfs, ff, fc)
x1 = max(x1, legend_x + w)
up_tip = y + D.FACTOR_POLE_GAP
block_top = up_tip + D.FACTOR_ARROW_LEN + vfs
# Factor axis reads high to low, top to bottom.
vcolors = list(reversed(colors))
vbins = list(reversed(bins))
for i, color in enumerate(vcolors):
row_y = block_top + i * (sw_h + sw_gap)
r = ET.SubElement(legend_g, "rect", x=f"{swatch_x:.2f}",
y=f"{row_y:.2f}", width=f"{sw_w:.2f}",
height=f"{sw_h:.2f}", fill=color, stroke=fc,
id=f"factor-legend-swatch-{i}")
r.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
r.set("class", f"factor-legend factor-{i}")
label_x = swatch_x + sw_w + label_gap
max_lw = 0.0
for i, value in enumerate(vbins):
edge_y = block_top - sw_gap / 2 + i * (sw_h + sw_gap)
txt = _fmt(value)
ET.SubElement(legend_g, "text", x=f"{label_x:.2f}",
y=f"{edge_y + vfs * 0.35:.2f}",
**{"font-size": str(vfs), "font-family": ff,
"fill": fc, "text-anchor": "start"}).text = txt
max_lw = max(max_lw, len(txt) * vfs * 0.55)
block_bottom = block_top - sw_gap / 2 + num * (sw_h + sw_gap)
x1 = max(x1, label_x + max_lw)
# Two separate arrows: one up to the positive pole, one down to the
# negative pole, each ending at its pole text.
_arrow_v(legend_g, arrow_x, block_top, up_tip, fc)
down_tip = block_bottom + D.FACTOR_ARROW_LEN
_arrow_v(legend_g, arrow_x, block_bottom, down_tip, fc)
y = down_tip + vfs
# Negative pole below, starting at the down arrow tip.
if config.pole_low:
y, w = _draw_wrapped_text(legend_g, legend_x, y, config.pole_low,
config.pole_max_chars, pfs, ff, fc)
x1 = max(x1, legend_x + w)
# No-data row, set further down for separation.
if has_nodata:
y += D.FACTOR_NODATA_GAP
hc = config.hatch_color or D.LEGEND_HATCH_COLOR
hs = config.hatch_style or D.LEGEND_HATCH_STYLE
pid = "hatch-legend-factor"
ensure_hatch_pattern(svg_doc._defs, pid, hc, style=hs)
r = ET.SubElement(legend_g, "rect", x=f"{swatch_x:.2f}",
y=f"{y:.2f}", width=f"{sw_w:.2f}",
height=f"{sw_h:.2f}", fill=f"url(#{pid})",
stroke=hc, id="factor-legend-nodata")
r.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
nd = config.nodata_label or D.LEGEND_NODATA_LABEL
ET.SubElement(legend_g, "text", x=f"{label_x:.2f}",
y=f"{y + sw_h / 2 + vfs * 0.35:.2f}",
**{"font-size": str(vfs), "font-family": ff,
"fill": fc, "text-anchor": "start"}).text = nd
x1 = max(x1, label_x + len(nd) * vfs * 0.55)
y += sw_h
else:
sw_w = config.swatch_width
sw_h = (config.swatch_height if config.swatch_height is not None
else round(sw_w / D.PHI, 1))
sw_gap = config.swatch_spacing
bar_w = num * sw_w + (num - 1) * sw_gap
def _est_width(text):
import textwrap
lines = (textwrap.wrap(text, config.pole_max_chars)
if len(text) > config.pole_max_chars else [text])
return max(len(ln) for ln in lines) * pfs * 0.55
# Poles centred over each half of the bar, symmetric about the bar
# centre. Each text gets a half at least as wide as itself, so the bar
# is centred in a wider row and the two texts mirror each other.
low_text = ("- " + config.pole_low) if config.pole_low else None
high_text = (config.pole_high + " +") if config.pole_high else None
content_w = bar_w
bar_x = legend_x
if low_text or high_text:
wl = _est_width(low_text) if low_text else 0.0
wh = _est_width(high_text) if high_text else 0.0
content_w = max(bar_w, 2 * max(wl, wh))
bar_x = legend_x + (content_w - bar_w) / 2
mid = legend_x + content_w / 2
yl = yr = y
if low_text:
yl, _ = _draw_wrapped_text(
legend_g, legend_x + content_w / 4, y, low_text,
config.pole_max_chars, pfs, ff, fc, anchor="middle")
if high_text:
yr, _ = _draw_wrapped_text(
legend_g, legend_x + 3 * content_w / 4, y, high_text,
config.pole_max_chars, pfs, ff, fc, anchor="middle")
y = max(yl, yr) + D.FACTOR_POLE_GAP
# Two separate arrows, symmetric about the centre, pointing to the
# bar ends.
_arrow_h(legend_g, y, mid - D.FACTOR_ARROW_HEAD, bar_x, fc)
_arrow_h(legend_g, y, mid + D.FACTOR_ARROW_HEAD, bar_x + bar_w, fc)
y += D.FACTOR_POLE_GAP
bar_top = y
for i, color in enumerate(colors):
cx = bar_x + i * (sw_w + sw_gap)
r = ET.SubElement(legend_g, "rect", x=f"{cx:.2f}",
y=f"{bar_top:.2f}", width=f"{sw_w:.2f}",
height=f"{sw_h:.2f}", fill=color, stroke=fc,
id=f"factor-legend-swatch-{i}")
r.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
r.set("class", f"factor-legend factor-{i}")
# Break values at the class boundaries, rotated like the choropleth.
label_rot = config.label_rotation
lbl_y = bar_top + sw_h + config.label_margin + vfs
if label_rot and config.label_anchor == "middle":
lbl_y += (sw_w / 2) * math.sin(math.radians(abs(label_rot)))
last = len(bins) - 1
for i, value in enumerate(bins):
if i == 0:
ex = bar_x
elif i == last:
ex = bar_x + bar_w
else:
ex = bar_x + i * (sw_w + sw_gap) - sw_gap / 2
attrs = {"font-size": str(vfs), "font-family": ff, "fill": fc,
"text-anchor": "middle"}
if label_rot:
attrs["transform"] = f"rotate({label_rot}, {ex:.2f}, {lbl_y:.2f})"
attrs["text-anchor"] = config.label_anchor
ET.SubElement(legend_g, "text", x=f"{ex:.2f}", y=f"{lbl_y:.2f}",
**attrs).text = _fmt(value)
x1 = max(x1, legend_x + content_w)
y = lbl_y + vfs
if has_nodata:
hc = config.hatch_color or D.LEGEND_HATCH_COLOR
hs = config.hatch_style or D.LEGEND_HATCH_STYLE
pid = "hatch-legend-factor"
ensure_hatch_pattern(svg_doc._defs, pid, hc, style=hs)
nd_x = bar_x + bar_w + config.nodata_gap
r = ET.SubElement(legend_g, "rect", x=f"{nd_x:.2f}",
y=f"{bar_top:.2f}", width=f"{sw_w:.2f}",
height=f"{sw_h:.2f}", fill=f"url(#{pid})",
stroke=hc, id="factor-legend-nodata")
r.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH))
nd = config.nodata_label or D.LEGEND_NODATA_LABEL
ET.SubElement(legend_g, "text",
x=f"{nd_x + sw_w + config.nodata_label_gap:.2f}",
y=f"{bar_top + sw_h / 2 + vfs * 0.35:.2f}",
**{"font-size": str(vfs), "font-family": ff,
"fill": fc, "text-anchor": "start"}).text = nd
x1 = max(x1, nd_x + sw_w + config.nodata_label_gap
+ len(nd) * vfs * 0.55)
# Methodology note at the foot.
if config.note:
y += D.FACTOR_NOTE_GAP
y, w = _draw_wrapped_text(legend_g, legend_x, y, config.note,
config.note_max_chars, nfs, ff, fc)
x1 = max(x1, legend_x + w)
y1 = y
frame_cfg = _parse_frame_config(config.frame)
if frame_cfg.enabled:
title_size = config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE
frame_top = legend_y - title_size * 0.3
draw_legend_frame(legend_g, svg_doc._defs, legend_x, frame_top,
x1 - legend_x, y1 - frame_top, frame_cfg)
if map_obj is not None:
map_obj.overflow.register(legend_x, legend_y, x1, y1)
# ============================================================================
# Public API, integrated into Map
# ============================================================================
def add_choropleth(
map_obj,
gdf: gpd.GeoDataFrame,
column: str,
cmap: Union[str, List[str]] = D.CHOROPLETH_DEFAULT_CMAP,
reverse: bool = False,
method: Union[str, list] = D.CHOROPLETH_DEFAULT_METHOD,
num_classes: int = D.CHOROPLETH_DEFAULT_NUM_CLASSES,
stroke: Optional[str] = None,
stroke_width: Optional[float] = None,
legend_params: Optional[Dict[str, Any]] = None,
dissolve: bool = False,
simplify=None,
on_zoom: bool = True,
on_cartouches: bool = True,
) -> None:
"""Add a choropleth layer to a Map.
Parameters
----------
map_obj : mappyng.Map
The map instance.
gdf : GeoDataFrame
Data with geometries and a numeric column.
column : str
Column name to classify.
cmap : str or list
Colormap name or list of hex colors. With ``"StdMean"`` a diverging
ramp (``"RdBu"``, ``"BrBG"``, ``"RdYlGn"``) suits the classification.
reverse : bool
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 or list
Classification method or custom break values.
num_classes : int
Number of classes (4-7). ``"StdMean"`` supports 5 or 7 (a diverging
*double gamme*); any other count snaps to the nearest valid one with a
warning. ``"Q6"`` always yields 6.
stroke : str, optional
Polygon outline color.
stroke_width : float, optional
Polygon outline width.
legend_params : dict, optional
Legend configuration (see ChoroplethLegendConfig fields).
dissolve : bool
Whether to dissolve geometries by class.
simplify : None, "auto" or float
Geometry simplification before rendering. ``None`` (default) keeps
every vertex. ``"auto"`` drops vertices that move less than half a
pixel at each viewport scale, which removes detail invisible at the
render size. A number is a tolerance in the data CRS units.
Simplification runs per viewport, after the optional dissolve.
Without dissolve it is applied per feature, so gaps can appear
between neighbours.
on_zoom : bool
Render on zoom viewport.
on_cartouches : bool
Render on cartouche viewports.
Notes
-----
A choropleth is intended for *relative* data (rates, ratios,
densities). If *column* looks like absolute count (stock) data,
strictly positive integers spanning a wide range, a non-blocking
``UserWarning`` suggests :meth:`~mappyng.Map.add_proportional`
instead. The rendering always proceeds.
"""
layer_id = map_obj._next_layer_id("choropleth")
# The stock-data semiology check lives in
# ChoroplethLayer.validate(), emitted once at layer construction.
style = map_obj.style
stroke = stroke or style["edge_color"]
sw = stroke_width or style["edge_width"]
z = style.config.z_order.CHOROPLETH
# Classification
k = _resolve_num_classes(method, num_classes)
# legend_params may be a dict (built into ChoroplethLegendConfig) or an
# already-built config instance (e.g. FactorLegend), used as is.
if isinstance(legend_params, ChoroplethLegendConfig):
legend_cfg = legend_params
else:
legend_cfg = ChoroplethLegendConfig(**(legend_params or {}))
legend_cfg.resolve_defaults(style, font_scale=getattr(map_obj, "font_scale", 1.0))
if method == "PrettyBreaks":
# PrettyBreaks does not guarantee the requested k: classify first, then
# derive the real class count from the bins actually produced.
classified, nodata, scheme, bins = classify(
gdf, column, method=method, num_classes=k, decimals=legend_cfg.decimals,
)
k_real = len(scheme.bins)
if k_real != num_classes:
warnings.warn(
f"PrettyBreaks produced {k_real} classes (rounded breaks) despite "
f"num_classes={num_classes}. This is expected: PrettyBreaks prioritises "
f"round interval bounds over the exact class count.",
UserWarning,
stacklevel=2,
)
colors = _resolve_colors_with_fallback(cmap, k_real)
else:
colors = _resolve_colors(cmap, k)
classified, nodata, scheme, bins = classify(
gdf, column, method=method, num_classes=k, decimals=legend_cfg.decimals,
)
if reverse:
colors = list(reversed(colors))
# Capture raw values before dissolve for the legend histogram
hist_values = classified[column].to_numpy(dtype=float, copy=True)
if dissolve:
classified = classified.dissolve(by="_class").reset_index()
# Assign colors
classified["_color"] = classified["_class"].map(lambda c: colors[c])
# ---- Render on each viewport ----
def _render_on(viewport: SvgViewport, data: gpd.GeoDataFrame,
nd: gpd.GeoDataFrame, clip_bbox: list) -> None:
clip_geom = box(*clip_bbox)
clipped = data.clip(mask=clip_geom)
clipped = clipped[~clipped.is_empty] if not clipped.empty else clipped
if not clipped.empty:
_draw_classified_gdf(viewport, clipped, colors, column, stroke, sw, z, layer_id,
simplify=simplify)
nd_clipped = nd.clip(mask=clip_geom) if not nd.empty else nd
nd_clipped = nd_clipped[~nd_clipped.is_empty] if not nd_clipped.empty else nd_clipped
if not nd_clipped.empty:
_draw_nodata_gdf(viewport, nd_clipped, legend_cfg.hatch_color, sw, z, layer_id,
map_obj.svg._defs, hatch_style=legend_cfg.hatch_style,
simplify=simplify)
# Main viewport
_render_on(map_obj.main, classified, nodata, map_obj.bbox)
# Zoom
if on_zoom and map_obj._zoom_viewport and map_obj._zoom_bbox:
_render_on(map_obj._zoom_viewport, classified, nodata, map_obj._zoom_bbox)
# Cartouches
if on_cartouches:
for index, vp in map_obj._cartouche_viewports.items():
params = map_obj.cartouche_params[index]
cart_data = map_obj._prepare_cartouche_data(classified, params)
cart_nd = map_obj._prepare_cartouche_data(nodata, params)
if not cart_data.empty:
_draw_classified_gdf(vp, cart_data, colors, column, stroke, sw, z, layer_id,
simplify=simplify)
if not cart_nd.empty:
_draw_nodata_gdf(vp, cart_nd, legend_cfg.hatch_color, sw, z, layer_id,
map_obj.svg._defs, hatch_style=legend_cfg.hatch_style,
simplify=simplify)
# Legend
if isinstance(legend_cfg, FactorLegend):
_draw_factor_legend(map_obj.svg, colors, bins, legend_cfg,
not nodata.empty, map_obj.main, map_obj)
else:
_draw_legend(map_obj.svg, colors, bins, legend_cfg, not nodata.empty,
map_obj.main, map_obj, values=hist_values)
return layer_id