Source code for mappyng.choropleth

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