Source code for mappyng.legend

"""
Shared legend primitives for mappyng.

Provides reusable building blocks (title, no-data indicator, hatch patterns,
legend frame) that any legend type (choropleth, proportional, categorical...)
can compose.

All default values are imported from :mod:`mappyng.defaults`.
"""

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import Any, Dict, Optional

from . import defaults as D
from .config import Style


# ============================================================================
# Legend frame configuration
# ============================================================================

[docs] @dataclass class LegendFrameConfig: """Configuration for the optional legend background frame. Pass as ``frame=True`` (defaults) or ``frame={...}`` (overrides) in the legend params dict. Attributes ---------- enabled : bool Show the frame (default False). fill : str Frame background color. fill_opacity : float Frame background opacity 0-1. stroke : str Frame border color. stroke_width : float Frame border width in SVG px. border_radius : float Frame corner radius in SVG px. padding : float Uniform inner padding on all four sides (SVG px). Used as fallback when individual side overrides are not set. padding_top : float or None Top padding override (SVG px). Falls back to ``padding`` when None. padding_right : float or None Right padding override (SVG px). Falls back to ``padding`` when None. padding_bottom : float or None Bottom padding override (SVG px). Falls back to ``padding`` when None. padding_left : float or None Left padding override (SVG px). Falls back to ``padding`` when None. shadow : bool Add a drop shadow behind the frame. shadow_dx : float Shadow horizontal offset (SVG px). shadow_dy : float Shadow vertical offset (SVG px). shadow_blur : float Shadow blur radius (SVG px). shadow_opacity : float Shadow opacity 0-1. """ enabled: bool = D.LEGEND_FRAME fill: str = D.LEGEND_FRAME_FILL fill_opacity: float = D.LEGEND_FRAME_FILL_OPACITY stroke: str = D.LEGEND_FRAME_STROKE stroke_width: float = D.LEGEND_FRAME_STROKE_WIDTH border_radius: float = D.LEGEND_FRAME_BORDER_RADIUS padding: float = D.LEGEND_FRAME_PADDING padding_top: Optional[float] = None padding_right: Optional[float] = None padding_bottom: Optional[float] = None padding_left: Optional[float] = None shadow: bool = D.LEGEND_FRAME_SHADOW shadow_dx: float = D.LEGEND_FRAME_SHADOW_DX shadow_dy: float = D.LEGEND_FRAME_SHADOW_DY shadow_blur: float = D.LEGEND_FRAME_SHADOW_BLUR shadow_opacity: float = D.LEGEND_FRAME_SHADOW_OPACITY
[docs] def effective_padding(self) -> tuple: """Return (top, right, bottom, left) padding values.""" p = self.padding return ( self.padding_top if self.padding_top is not None else p, self.padding_right if self.padding_right is not None else p, self.padding_bottom if self.padding_bottom is not None else p, self.padding_left if self.padding_left is not None else p, )
def _parse_frame_config(raw) -> LegendFrameConfig: """Parse a ``frame`` parameter into a :class:`LegendFrameConfig`. Accepts: - ``False`` / ``None``: disabled - ``True``: enabled with all defaults - ``dict``: enabled with overrides; the ``padding`` key may be either a float (uniform) or a dict with keys ``top``, ``right``, ``bottom``, ``left`` (any omitted side falls back to ``D.LEGEND_FRAME_PADDING``). """ if not raw: return LegendFrameConfig(enabled=False) if raw is True: return LegendFrameConfig(enabled=True) if isinstance(raw, dict): kwargs = {k: v for k, v in raw.items() if k != "padding"} padding_raw = raw.get("padding", D.LEGEND_FRAME_PADDING) if isinstance(padding_raw, dict): # Per-side dict: {"top": x, "right": y, "bottom": z, "left": w} default = D.LEGEND_FRAME_PADDING kwargs["padding"] = default kwargs["padding_top"] = padding_raw.get("top", default) kwargs["padding_right"] = padding_raw.get("right", default) kwargs["padding_bottom"] = padding_raw.get("bottom", default) kwargs["padding_left"] = padding_raw.get("left", default) else: kwargs["padding"] = padding_raw return LegendFrameConfig(enabled=True, **kwargs) return LegendFrameConfig(enabled=False) # ============================================================================ # Base legend configuration # ============================================================================
[docs] @dataclass class BaseLegendConfig: """Shared configuration fields for all legend types. All fields can be passed as keys in the ``legend`` dict of ``ChoroplethLayer``, ``ProportionalLayer``, and ``SituationLayer``. ``None`` values are filled from the active style at render time. Attributes ---------- title : str or None Legend title text. title_fontsize : int or None Title font size. Defaults to style value. title_fontcolor : str or None Title font color. title_fontweight : str or None Title font weight (e.g. ``"bold"``). title_fontfamily : str or None Title font family. title_max_chars : int Maximum characters per line before title wraps (default 35). title_offset_x : float Additional horizontal offset for the title in SVG px (default 0). title_offset_y : float Additional vertical offset for the title in SVG px (default 0). subtitle : str or None Optional subtitle rendered below the title. subtitle_fontsize : int or None Subtitle font size. subtitle_fontcolor : str or None Subtitle font color. subtitle_fontweight : str or None Subtitle font weight. subtitle_fontfamily : str or None Subtitle font family. fontsize : int or None Legend item label font size. fontcolor : str or None Legend item label color. fontweight : str or None Legend item label weight. fontfamily : str or None Legend item label font family. hatch_color : str or None Hatch pattern color (used for choropleth swatch hatching). hatch_style : str or None Hatch pattern style. Available: ``"///"`` , ``"\\\\"``, ``"xxx"``, ``"..."`` , ``"|||"``, ``"---"``. spacing : float Extra vertical spacing added to the auto-computed legend position as a fraction of the content height (default 0.01). position : dict or None Manual legend position as ``{"x": frac, "y": frac}`` where (0, 0) = top-left and (1, 1) = bottom-right of the content area. When ``None``, position is computed automatically. frame : bool or dict or None Background frame behind the legend. ``False``/``None`` = no frame, ``True`` = frame with defaults, dict = frame with overrides (see :class:`~mappyng.legend.LegendFrameConfig`). """ title: Optional[str] = None title_fontsize: Optional[int] = None title_fontcolor: Optional[str] = None title_fontweight: Optional[str] = None title_fontfamily: Optional[str] = None title_max_chars: int = D.LEGEND_TITLE_MAX_CHARS title_offset_x: float = 0.0 """Horizontal offset applied to the legend title (pixels). Positive = right.""" title_offset_y: float = 0.0 """Vertical offset applied to the legend title (pixels). Positive = down.""" subtitle: Optional[str] = None subtitle_fontsize: Optional[int] = None subtitle_fontcolor: Optional[str] = None subtitle_fontweight: Optional[str] = None subtitle_fontfamily: Optional[str] = None fontsize: Optional[int] = None fontcolor: Optional[str] = None fontweight: Optional[str] = None fontfamily: Optional[str] = None hatch_color: Optional[str] = None hatch_style: Optional[str] = None spacing: float = D.LEGEND_SPACING position: Any = D.LEGEND_POSITION # None or dict {"x": frac, "y": frac} frame: Any = None # True, False, or dict of LegendFrameConfig overrides
[docs] def resolve_defaults(self, style: Style, font_scale: float = 1.0) -> None: """Fill ``None`` fields from the active style. Parameters ---------- font_scale : float Multiplier applied to all resolved font sizes (default 1.0 = no change). """ self.title_fontsize = round((self.title_fontsize or style["legend_title_fontsize"]) * font_scale) self.title_fontcolor = self.title_fontcolor or style["legend_title_fontcolor"] self.title_fontweight = self.title_fontweight or style["legend_title_fontweight"] self.title_fontfamily = self.title_fontfamily or style["legend_title_fontfamily"] self.subtitle_fontsize = round((self.subtitle_fontsize or D.LEGEND_SUBTITLE_FONT_SIZE) * font_scale) self.subtitle_fontcolor = self.subtitle_fontcolor or style["legend_fontcolor"] self.subtitle_fontweight = self.subtitle_fontweight or D.LEGEND_SUBTITLE_FONT_WEIGHT self.subtitle_fontfamily = self.subtitle_fontfamily or style["legend_fontfamily"] self.fontsize = round((self.fontsize or style["legend_fontsize"]) * font_scale) self.fontcolor = self.fontcolor or style["legend_fontcolor"] self.fontweight = self.fontweight or style["legend_fontweight"] self.fontfamily = self.fontfamily or style["legend_fontfamily"] self.hatch_color = self.hatch_color or style["legend_hatch_color"] self.hatch_style = self.hatch_style or style["legend_hatch_style"]
def compute_legend_position(config: BaseLegendConfig, vb) -> tuple: """Compute (legend_x, legend_y) from config.position and a ViewBox. Parameters ---------- config : BaseLegendConfig Legend configuration (reads ``position``). vb : ViewBox The main viewport's viewbox. Returns ------- (legend_x, legend_y) : tuple of float ``config.position`` accepts three forms: * ``None``, default placement: left-aligned, below the map. * ``{"x": frac, "y": frac}``, fractions of the viewport **content** area (0,0 = top-left of content, 1,1 = bottom-right). * ``{"px": x, "py": y}``, absolute SVG pixel coordinates. """ pos = config.position if pos is None: spacing_px = getattr(config, "spacing", 0.0) * vb.content_height return ( vb.content_x, vb.content_y + vb.content_height + D.LEGEND_BELOW_MAP_GAP + spacing_px, ) if isinstance(pos, (list, tuple)) and len(pos) == 2: xf, yf = pos return ( vb.content_x + xf * vb.content_width, vb.content_y + yf * vb.content_height, ) if isinstance(pos, dict): if "px" in pos or "py" in pos: return (pos.get("px", vb.content_x), pos.get("py", vb.content_y)) xf = pos.get("x", 0.0) yf = pos.get("y", 0.0) return ( vb.content_x + xf * vb.content_width, vb.content_y + yf * vb.content_height, ) return ( vb.content_x, vb.content_y + vb.content_height + D.LEGEND_BELOW_MAP_GAP, ) # ============================================================================ # Shared SVG helpers # ============================================================================ def ensure_hatch_pattern( defs: ET.Element, pattern_id: str, color: str, size: int = D.LEGEND_HATCH_PATTERN_SIZE, style: str = D.LEGEND_HATCH_STYLE, ) -> None: """Add a hatch ``<pattern>`` to *defs* if not already present. Parameters ---------- style : str Hatch style. Supported values (matplotlib-compatible names): - ``"///"``, diagonal lines at 45° (default) - ``"\\\\\\\\"``, ``"back"``, diagonal lines at 135° - ``"xxx"``, cross-hatching (45° + 135°) - ``"..."``, dot grid - ``"|||"``, vertical lines - ``"---"``, horizontal lines Unrecognised values fall back to ``"///"``. """ for child in defs: if child.get("id") == pattern_id: return lw = str(D.LEGEND_HATCH_LINE_WIDTH) s = str(size) # Normalise style aliases _style = style.strip() if style else "///" if _style in ("\\\\", "\\\\\\\\", "back", "backslash"): _style = "back" elif _style in ("xxx", "x", "cross_hatch"): _style = "xxx" elif _style in ("...", "dots", "dot"): _style = "..." elif _style in ("|||", "vertical", "vert"): _style = "|||" elif _style in ("---", "horizontal", "horiz"): _style = "---" else: _style = "///" if _style == "///": pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse", patternTransform="rotate(45)") line = ET.SubElement(pat, "line", x1="0", y1="0", x2="0", y2=s, stroke=color) line.set("stroke-width", lw) elif _style == "back": pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse", patternTransform="rotate(-45)") line = ET.SubElement(pat, "line", x1="0", y1="0", x2="0", y2=s, stroke=color) line.set("stroke-width", lw) elif _style == "xxx": pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse") l1 = ET.SubElement(pat, "line", x1="0", y1="0", x2=s, y2=s, stroke=color) l1.set("stroke-width", lw) l2 = ET.SubElement(pat, "line", x1=s, y1="0", x2="0", y2=s, stroke=color) l2.set("stroke-width", lw) elif _style == "...": half = str(size / 2) r = str(max(1, size // 5)) pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse") c1 = ET.SubElement(pat, "circle", cx="0", cy="0", r=r, fill=color) c2 = ET.SubElement(pat, "circle", cx=s, cy="0", r=r, fill=color) c3 = ET.SubElement(pat, "circle", cx="0", cy=s, r=r, fill=color) c4 = ET.SubElement(pat, "circle", cx=s, cy=s, r=r, fill=color) c5 = ET.SubElement(pat, "circle", cx=half, cy=half, r=r, fill=color) elif _style == "|||": pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse") line = ET.SubElement(pat, "line", x1="0", y1="0", x2="0", y2=s, stroke=color) line.set("stroke-width", lw) elif _style == "---": pat = ET.SubElement(defs, "pattern", id=pattern_id, width=s, height=s, patternUnits="userSpaceOnUse") line = ET.SubElement(pat, "line", x1="0", y1="0", x2=s, y2="0", stroke=color) line.set("stroke-width", lw) def draw_legend_title( parent: ET.Element, x: float, y: float, config: BaseLegendConfig, align: str = None, ) -> float: """Draw the legend title text and return the updated *y* cursor. Parameters ---------- parent : ET.Element ``<g>`` element to append the ``<text>`` to. x : float Horizontal position of the legend title (SVG px). y : float Baseline *y* for the title text. config : BaseLegendConfig Legend configuration (title text + font settings). align : str, optional ``"left"`` or ``"center"``. Defaults to ``D.LEGEND_ALIGN``. Returns ------- float Updated *y* position below the title, ready for the next element. """ if not config.title: return y import textwrap align = align or D.LEGEND_ALIGN anchor = "start" if align == "left" else "middle" title_size = config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE tx = x + (config.title_offset_x or 0.0) ty = y + (config.title_offset_y or 0.0) max_chars = getattr(config, "title_max_chars", D.LEGEND_TITLE_MAX_CHARS) line_h = title_size * 1.2 attrs = { "text-anchor": anchor, "font-size": str(title_size), "font-weight": config.title_fontweight or D.LEGEND_TITLE_FONT_WEIGHT, "font-family": config.title_fontfamily or D.FONT_FAMILY, "fill": config.title_fontcolor or config.fontcolor or D.FONT_COLOR, } lines = textwrap.wrap(config.title, max_chars) if len(config.title) > max_chars else [config.title] for i, line in enumerate(lines): ET.SubElement( parent, "text", x=f"{tx:.2f}", y=f"{ty + i * line_h:.2f}", **attrs, ).text = line return ty + len(lines) * line_h + D.LEGEND_TITLE_SPACING def draw_legend_subtitle( parent: ET.Element, x: float, y: float, config: BaseLegendConfig, align: str = None, ) -> float: """Draw the legend subtitle text and return the updated *y* cursor. Parameters ---------- parent : ET.Element ``<g>`` element to append the ``<text>`` to. x : float Horizontal position of the subtitle (SVG px). y : float Baseline *y* for the subtitle text. config : BaseLegendConfig Legend configuration (subtitle text + font settings). align : str, optional ``"left"`` or ``"center"``. Defaults to ``D.LEGEND_ALIGN``. Returns ------- float Updated *y* position below the subtitle, ready for the next element. """ if not config.subtitle: return y align = align or D.LEGEND_ALIGN anchor = "start" if align == "left" else "middle" subtitle_size = config.subtitle_fontsize or D.LEGEND_SUBTITLE_FONT_SIZE # Subtitle is part of the title block, follow its offset. sx = x + (config.title_offset_x or 0.0) sy = y + (config.title_offset_y or 0.0) ET.SubElement( parent, "text", x=f"{sx:.2f}", y=f"{sy:.2f}", **{ "text-anchor": anchor, "font-size": str(subtitle_size), "font-weight": config.subtitle_fontweight or D.LEGEND_SUBTITLE_FONT_WEIGHT, "font-family": config.subtitle_fontfamily or D.FONT_FAMILY, "fill": config.subtitle_fontcolor or config.fontcolor or D.FONT_COLOR, }, ).text = config.subtitle return y + subtitle_size + D.LEGEND_SUBTITLE_SPACING def draw_nodata_indicator( parent: ET.Element, defs: ET.Element, x: float, y: float, swatch_width: float, swatch_height: float, label_y: float, config: BaseLegendConfig, legend_prefix: str = "choropleth", ) -> None: """Draw a hatched swatch + 'N/A' label for missing data. Parameters ---------- parent : ET.Element Legend ``<g>`` group. defs : ET.Element Document ``<defs>`` for the hatch pattern. x, y : float Top-left corner of the N/A swatch. swatch_width, swatch_height : float Swatch dimensions. label_y : float Baseline *y* for the label text. config : BaseLegendConfig Legend configuration. legend_prefix : str CSS class prefix (e.g. ``"choropleth"`` or ``"proportional"``). """ hc = config.hatch_color or D.LEGEND_HATCH_COLOR hs = config.hatch_style or D.LEGEND_HATCH_STYLE fc = config.fontcolor or D.FONT_COLOR ff = config.fontfamily or D.FONT_FAMILY font_size = config.fontsize or D.LEGEND_FONT_SIZE pattern_id = f"hatch-legend-{legend_prefix}" ensure_hatch_pattern(defs, pattern_id, hc, style=hs) rect = ET.SubElement( parent, "rect", x=f"{x:.2f}", y=f"{y:.2f}", width=f"{swatch_width:.2f}", height=f"{swatch_height:.2f}", fill=f"url(#{pattern_id})", stroke=hc, id=f"{legend_prefix}-legend-nodata", ) rect.set("stroke-width", str(D.LEGEND_SWATCH_STROKE_WIDTH)) rect.set("class", f"{legend_prefix}-legend {legend_prefix}-nodata") ET.SubElement( parent, "text", x=f"{x + swatch_width / 2:.2f}", y=f"{label_y:.2f}", **{ "font-size": str(font_size), "font-family": ff, "fill": fc, "text-anchor": "middle", }, ).text = D.LEGEND_NODATA_LABEL # ============================================================================ # Legend frame # ============================================================================ _legend_frame_counter = 0 def draw_legend_frame( legend_g: ET.Element, defs: ET.Element, x: float, y: float, width: float, height: float, frame_cfg: LegendFrameConfig, ) -> None: """Draw a background frame (rect) behind the legend content. The frame ``<rect>`` is inserted as the **first** child of *legend_g* so it sits behind all other legend elements. Parameters ---------- legend_g : ET.Element The ``<g>`` that contains all legend content. defs : ET.Element Document ``<defs>`` for optional shadow filter. x, y : float Top-left corner of the content area (before padding). width, height : float Size of the content area (before padding). frame_cfg : LegendFrameConfig Frame styling options. """ if not frame_cfg.enabled: return global _legend_frame_counter _legend_frame_counter += 1 pt, pr, pb, pl = frame_cfg.effective_padding() fx = x - pl fy = y - pt fw = width + pl + pr fh = height + pt + pb attrs = { "x": f"{fx:.2f}", "y": f"{fy:.2f}", "width": f"{fw:.2f}", "height": f"{fh:.2f}", "fill": frame_cfg.fill, "fill-opacity": f"{frame_cfg.fill_opacity:.2f}", "stroke": frame_cfg.stroke, "stroke-width": str(frame_cfg.stroke_width), } if frame_cfg.border_radius > 0: attrs["rx"] = f"{frame_cfg.border_radius:.1f}" attrs["ry"] = f"{frame_cfg.border_radius:.1f}" filter_ref = None if frame_cfg.shadow: fid = f"legend-frame-shadow-{_legend_frame_counter}" _ensure_shadow_filter( defs, fid, frame_cfg.shadow_dx, frame_cfg.shadow_dy, frame_cfg.shadow_blur, frame_cfg.shadow_opacity, ) filter_ref = f"url(#{fid})" if filter_ref: attrs["filter"] = filter_ref frame_rect = ET.Element("rect", **attrs) frame_rect.set("class", "legend-frame") # Insert as first child so it's behind everything legend_g.insert(0, frame_rect) def _ensure_shadow_filter( defs: ET.Element, filter_id: str, dx: float, dy: float, blur: float, opacity: float, ) -> None: """Add a drop-shadow ``<filter>`` to *defs* if not present.""" for child in defs: if child.get("id") == filter_id: return margin = 30 f = ET.SubElement( defs, "filter", id=filter_id, x=f"-{margin}%", y=f"-{margin}%", width=f"{100 + 2 * margin}%", height=f"{100 + 2 * margin}%", ) ET.SubElement(f, "feDropShadow", **{ "dx": str(dx), "dy": str(dy), "stdDeviation": str(blur), "flood-color": "#000000", "flood-opacity": str(opacity), })