Source code for mappyng.proportional

"""
Proportional symbols for mappyng.

Renders area-proportional circles on all viewports (main, cartouches, zoom)
with a nested-circle legend.

Sizing is area-proportional: ``area proportional to value``.  A fixed reference can be
passed via ``reference_value`` / ``reference_size`` so that two maps sharing
the same reference produce comparable circles.

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

Example
-------
>>> from mappyng import ProportionalLayer
>>> m.add(ProportionalLayer(
...     gdf_pts, column="population", fill="#e31a1c",
...     legend={"title": "Population"},
... ))
"""

from __future__ import annotations

import math
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union

import geopandas as gpd
import numpy as np
from shapely.geometry import box, Point, MultiPoint

from . import defaults as D
from .config import Style
from .legend import (
    BaseLegendConfig, LegendFrameConfig, _parse_frame_config,
    compute_legend_position, draw_legend_title, draw_legend_subtitle, draw_legend_frame,
)
from .renderer import SvgViewport


# ============================================================================
# Size calculation
# ============================================================================

def _calculate_radii(
    values: np.ndarray,
    max_radius: float = D.PROPORTIONAL_MAX_RADIUS,
    reference_value: Optional[float] = None,
    reference_radius: Optional[float] = None,
) -> np.ndarray:
    """Return SVG pixel radii for *values* (area-proportional).

    Area is proportional to value, so radius is proportional to sqrt(value).

    Parameters
    ----------
    values : array
        Positive numeric values.
    max_radius : float
        Maximum circle radius in SVG pixels (used when no reference is given).
    reference_value : float, optional
        A data value that should map to *reference_radius* pixels.
    reference_radius : float, optional
        The circle radius in SVG pixels for *reference_value*.

    Returns
    -------
    ndarray of radii (same length as *values*).
    """
    abs_vals = np.abs(values).astype(float)
    sqrt_vals = np.sqrt(abs_vals)

    if reference_value is not None and reference_radius is not None:
        # Fixed scale: radius = reference_radius * sqrt(value / reference_value)
        sqrt_ref = math.sqrt(abs(reference_value))
        if sqrt_ref == 0:
            return np.zeros_like(sqrt_vals)
        scale = reference_radius / sqrt_ref
    else:
        # Auto-scale to max_radius
        max_sqrt = sqrt_vals.max()
        if max_sqrt == 0:
            return np.zeros_like(sqrt_vals)
        scale = max_radius / max_sqrt

    return sqrt_vals * scale


def _format_value(val: float, decimals: int = 0, number_format: str = "space") -> str:
    """Format a numeric value.

    Parameters
    ----------
    number_format : ``"space"`` (default) or ``"none"``
        ``"space"``: narrow-space thousands separator; ``"none"``: no separator.
    """
    if decimals == 0:
        s = f"{int(round(val)):,}"
    else:
        s = f"{val:,.{decimals}f}"
    if number_format == "space":
        s = s.replace(",", " ")  # narrow no-break space
    else:
        s = s.replace(",", "")
    return s


# ============================================================================
# SVG rendering
# ============================================================================

def _draw_circles(
    viewport: SvgViewport,
    gdf: gpd.GeoDataFrame,
    radii: np.ndarray,
    fill: str,
    fill_opacity: float,
    stroke: str,
    stroke_width: float,
    z_order: int,
    layer_id: int,
    column: str,
) -> None:
    """Render proportional circles as SVG <circle> elements.

    Circles are drawn largest-first so smaller circles sit on top.
    """
    layer_name = "proportional"
    vb = viewport.viewbox

    # Sort by radius descending (largest drawn first to smallest on top)
    order = np.argsort(-radii)

    for idx in order:
        row = gdf.iloc[idx]
        r = radii[idx]
        if r < D.PROPORTIONAL_MIN_RADIUS:
            continue

        geom = row.geometry
        if geom is None or geom.is_empty:
            continue

        # Get centroid
        if isinstance(geom, (Point, MultiPoint)):
            pt = geom if isinstance(geom, Point) else geom.centroid
        else:
            pt = geom.centroid

        cx, cy = vb.geo_to_svg(pt.x, pt.y)

        viewport.add_circle(
            layer_name, cx, cy, r,
            z_order=z_order,
            fill=fill,
            fill_opacity=fill_opacity,
            stroke=stroke,
            stroke_width=stroke_width,
            id=f"prop-{layer_id}-{row.name}",
            **{"class": "proportional"},
            data_value=str(row.get(column, "")),
        )


# ============================================================================
# Legend
# ============================================================================

[docs] @dataclass class ProportionalLegendConfig(BaseLegendConfig): """Proportional-symbol legend configuration. Inherits all shared fields from :class:`~mappyng.legend.BaseLegendConfig`. Pass as the ``legend`` dict in :meth:`~mappyng.Map.add_proportional`. Attributes ---------- orientation : str Legend layout: ``"nested"`` (default, concentric circles), ``"vertical"`` (stacked), or ``"horizontal"`` (side by side). values : list of float or None Explicit data values to show in the legend. Auto-generated if None. num_values : int Number of auto-generated legend values (default 4). decimals : int Decimal places for value labels (default 0). number_format : str Thousands separator: ``"space"`` (default, narrow no-break space) or ``"none"`` (no separator). circle_fill : str or None Legend circle fill color override. Uses the layer fill if None. circle_fill_opacity : float Legend circle fill opacity 0-1. circle_stroke : str Legend circle stroke color. circle_stroke_width : float Legend circle stroke width (SVG px). circle_spacing : float or None Fixed horizontal gap between circles in horizontal mode (SVG px). Auto-computed from circle sizes if None. label_gap : float Gap between circle edge and label text (SVG px). leader_color : str Leader line color in nested mode. leader_width : float Leader line stroke width (SVG px). leader_dash : str Leader line SVG ``stroke-dasharray`` (e.g. ``"4 2"``). leader_opacity : float Leader line opacity 0-1. """ orientation: str = D.PROPORTIONAL_LEGEND_ORIENTATION values: Optional[List[float]] = None num_values: int = D.LEGEND_CIRCLE_NUM_VALUES decimals: int = 0 number_format: str = "space" """Thousands separator: ``"space"`` (default) or ``"none"``.""" circle_fill: Optional[str] = None circle_fill_opacity: float = D.LEGEND_CIRCLE_FILL_OPACITY circle_stroke: str = D.LEGEND_CIRCLE_EDGECOLOR circle_stroke_width: float = D.LEGEND_CIRCLE_LINEWIDTH circle_spacing: Optional[float] = None """Fixed horizontal gap between circles in horizontal mode (SVG px). Overrides default.""" label_gap: float = D.LEGEND_CIRCLE_LABEL_GAP leader_color: str = D.LEGEND_CIRCLE_LEADER_COLOR leader_width: float = D.LEGEND_CIRCLE_LEADER_WIDTH leader_dash: str = D.LEGEND_CIRCLE_LEADER_DASH leader_opacity: float = D.LEGEND_CIRCLE_LEADER_OPACITY """Opacity of leader lines in nested legend (0-1)."""
def _auto_legend_values( values: np.ndarray, num: int = D.LEGEND_CIRCLE_NUM_VALUES, ) -> List[float]: """Generate visually balanced legend values from data range. Spacing is done in sqrt-space so that legend circles are visually distinguishable (not bunched at the top). """ vmin, vmax = float(np.nanmin(values)), float(np.nanmax(values)) if vmin == vmax: return [vmax] sqrt_space = np.linspace(math.sqrt(vmin), math.sqrt(vmax), num) return [round(v ** 2) for v in sqrt_space] def _draw_proportional_legend( svg_doc, config: ProportionalLegendConfig, legend_values: List[float], legend_radii: np.ndarray, fill: str, main_viewport: SvgViewport, ): """Draw a proportional legend below the main map. Dispatches to the appropriate layout based on ``config.orientation``: ``"nested"`` (default), ``"vertical"``, or ``"horizontal"``. Returns (x0, y0, x1, y1) bounding box or None if nothing was drawn. """ orientation = config.orientation if orientation == "vertical": return _draw_prop_legend_vertical(svg_doc, config, legend_values, legend_radii, fill, main_viewport) elif orientation == "horizontal": return _draw_prop_legend_horizontal(svg_doc, config, legend_values, legend_radii, fill, main_viewport) else: return _draw_prop_legend_nested(svg_doc, config, legend_values, legend_radii, fill, main_viewport) def _draw_prop_legend_nested( svg_doc, config: ProportionalLegendConfig, legend_values: List[float], legend_radii: np.ndarray, fill: str, main_viewport: SvgViewport, ) -> None: """Nested-circle legend: stacked circles with leader lines and labels.""" vb = main_viewport.viewbox fc = config.fontcolor or D.FONT_COLOR ff = config.fontfamily or D.FONT_FAMILY font_size = config.fontsize or D.LEGEND_FONT_SIZE circle_fill = config.circle_fill or fill max_r = float(max(legend_radii)) if max_r <= 0: return 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="proportional-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", ) baseline_y = y_cursor + 2 * max_r cx = legend_x + max_r label_x = cx + max_r + config.label_gap sorted_pairs = sorted( zip(legend_values, legend_radii), key=lambda p: -p[1], ) for i, (val, r) in enumerate(sorted_pairs): if r <= 0: continue cy = baseline_y - r ET.SubElement( legend_g, "circle", cx=f"{cx:.2f}", cy=f"{cy:.2f}", r=f"{r:.2f}", fill=circle_fill, stroke=config.circle_stroke, id=f"proportional-legend-circle-{i}", **{ "fill-opacity": str(config.circle_fill_opacity), "stroke-width": str(config.circle_stroke_width), "class": "proportional-legend", }, ) line_y = baseline_y - 2 * r ET.SubElement( legend_g, "line", x1=f"{cx:.2f}", y1=f"{line_y:.2f}", x2=f"{label_x:.2f}", y2=f"{line_y:.2f}", stroke=config.leader_color, **{ "stroke-width": str(config.leader_width), "stroke-dasharray": config.leader_dash, "stroke-opacity": str(config.leader_opacity), }, ) ET.SubElement( legend_g, "text", x=f"{label_x + 3:.2f}", y=f"{line_y + font_size * 0.35:.2f}", **{ "font-size": str(font_size), "font-family": ff, "fill": fc, "text-anchor": "start", }, ).text = _format_value(val, config.decimals, config.number_format) # ---- Bbox and frame ---- max_label_chars = max( len(_format_value(v, config.decimals)) for v in legend_values ) if legend_values else 0 content_w = label_x + 3 + max_label_chars * font_size * 0.55 - legend_x content_h = baseline_y - legend_y + max_r frame_cfg = _parse_frame_config(config.frame) if frame_cfg.enabled: title_offset = (config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE) * 0.3 draw_legend_frame( legend_g, svg_doc._defs, legend_x, legend_y - title_offset, content_w, content_h + title_offset, frame_cfg, ) return (legend_x, legend_y, legend_x + content_w, legend_y + content_h) def _draw_prop_legend_vertical( svg_doc, config: ProportionalLegendConfig, legend_values: List[float], legend_radii: np.ndarray, fill: str, main_viewport: SvgViewport, ) -> None: """Vertical legend: one circle + label per row, top to bottom.""" vb = main_viewport.viewbox fc = config.fontcolor or D.FONT_COLOR ff = config.fontfamily or D.FONT_FAMILY font_size = config.fontsize or D.LEGEND_FONT_SIZE circle_fill = config.circle_fill or fill circle_gap = D.PROPORTIONAL_LEGEND_CIRCLE_GAP max_r = float(max(legend_radii)) if max_r <= 0: return 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="proportional-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", ) # Draw rows: largest first, each circle + label sorted_pairs = sorted( zip(legend_values, legend_radii), key=lambda p: -p[1], ) max_label_w = 0.0 label_x = legend_x + max_r * 2 + config.label_gap for i, (val, r) in enumerate(sorted_pairs): if r <= 0: continue cy = y_cursor + r cx = legend_x + max_r ET.SubElement( legend_g, "circle", cx=f"{cx:.2f}", cy=f"{cy:.2f}", r=f"{r:.2f}", fill=circle_fill, stroke=config.circle_stroke, id=f"proportional-legend-circle-{i}", **{ "fill-opacity": str(config.circle_fill_opacity), "stroke-width": str(config.circle_stroke_width), "class": "proportional-legend", }, ) label_text = _format_value(val, config.decimals, config.number_format) ET.SubElement( legend_g, "text", x=f"{label_x:.2f}", y=f"{cy + 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 y_cursor += 2 * r + circle_gap # ---- Bbox and frame ---- content_w = label_x + max_label_w - legend_x content_h = y_cursor - circle_gap - legend_y frame_cfg = _parse_frame_config(config.frame) if frame_cfg.enabled: title_offset = (config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE) * 0.3 draw_legend_frame( legend_g, svg_doc._defs, legend_x, legend_y - title_offset, content_w, content_h + title_offset, frame_cfg, ) return (legend_x, legend_y, legend_x + content_w, legend_y + content_h) def _draw_prop_legend_horizontal( svg_doc, config: ProportionalLegendConfig, legend_values: List[float], legend_radii: np.ndarray, fill: str, main_viewport: SvgViewport, ) -> None: """Horizontal legend: circles in a row with labels below.""" vb = main_viewport.viewbox fc = config.fontcolor or D.FONT_COLOR ff = config.fontfamily or D.FONT_FAMILY font_size = config.fontsize or D.LEGEND_FONT_SIZE circle_fill = config.circle_fill or fill h_gap = config.circle_spacing if config.circle_spacing is not None else D.PROPORTIONAL_LEGEND_HORIZONTAL_GAP max_r = float(max(legend_radii)) if max_r <= 0: return 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="proportional-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", ) # Baseline: all circles sit on the same baseline (bottom-aligned) baseline_y = y_cursor + 2 * max_r # Draw circles left-to-right, largest first sorted_pairs = sorted( zip(legend_values, legend_radii), key=lambda p: -p[1], ) x_cursor = legend_x label_y = baseline_y + font_size + 4 for i, (val, r) in enumerate(sorted_pairs): if r <= 0: continue cx = x_cursor + r cy = baseline_y - r ET.SubElement( legend_g, "circle", cx=f"{cx:.2f}", cy=f"{cy:.2f}", r=f"{r:.2f}", fill=circle_fill, stroke=config.circle_stroke, id=f"proportional-legend-circle-{i}", **{ "fill-opacity": str(config.circle_fill_opacity), "stroke-width": str(config.circle_stroke_width), "class": "proportional-legend", }, ) # Label centered below circle ET.SubElement( legend_g, "text", x=f"{cx:.2f}", y=f"{label_y:.2f}", **{ "font-size": str(font_size), "font-family": ff, "fill": fc, "text-anchor": "middle", }, ).text = _format_value(val, config.decimals, config.number_format) x_cursor += 2 * r + h_gap # ---- Bbox and frame ---- content_w = x_cursor - h_gap - legend_x content_h = label_y + font_size * 0.3 - legend_y frame_cfg = _parse_frame_config(config.frame) if frame_cfg.enabled: title_offset = (config.title_fontsize or D.LEGEND_TITLE_FONT_SIZE) * 0.3 draw_legend_frame( legend_g, svg_doc._defs, legend_x, legend_y - title_offset, content_w, content_h + title_offset, frame_cfg, ) return (legend_x, legend_y, legend_x + content_w, legend_y + content_h) # ============================================================================ # Public API, integrated into Map # ============================================================================ def add_proportional( map_obj, gdf: gpd.GeoDataFrame, column: str, fill: str = D.PROPORTIONAL_FILL, fill_opacity: float = D.PROPORTIONAL_FILL_OPACITY, stroke: str = D.PROPORTIONAL_STROKE, stroke_width: float = D.PROPORTIONAL_STROKE_WIDTH, max_radius: float = D.PROPORTIONAL_MAX_RADIUS, reference_value: Optional[float] = None, reference_radius: Optional[float] = None, legend_params: Optional[Dict[str, Any]] = None, on_zoom: bool = True, on_cartouches: bool = True, ) -> None: """Add proportional circles to a Map. Parameters ---------- map_obj : mappyng.Map The map instance. gdf : GeoDataFrame Data with point/polygon geometries and a numeric column. column : str Column name for sizing. fill : str Circle fill color. fill_opacity : float Circle fill opacity (0-1). stroke : str Circle stroke color. stroke_width : float Circle stroke width. max_radius : float Maximum circle radius in SVG pixels (auto-scale mode). reference_value : float, optional Data value that should map to *reference_radius* pixels. Allows consistent sizing across multiple maps. reference_radius : float, optional Circle radius in SVG pixels for *reference_value*. legend_params : dict, optional Legend configuration (see ProportionalLegendConfig fields). on_zoom : bool Render on zoom viewport. on_cartouches : bool Render on cartouche viewports. """ layer_id = map_obj._next_layer_id("proportional") z = map_obj.style.config.z_order.PROPORTIONAL # Drop NaN / zero data = gdf.dropna(subset=[column]).copy() data = data[data[column] != 0] if data.empty: return layer_id values = data[column].values # Compute radii for data radii = _calculate_radii(values, max_radius, reference_value, reference_radius) # ---- Legend setup ---- legend_cfg = ProportionalLegendConfig(**(legend_params or {})) legend_cfg.resolve_defaults(map_obj.style, font_scale=getattr(map_obj, "font_scale", 1.0)) legend_values = legend_cfg.values if legend_values is None: legend_values = _auto_legend_values(values, legend_cfg.num_values) legend_radii = _calculate_radii( np.array(legend_values, dtype=float), max_radius, reference_value, reference_radius, ) # ---- Render on viewports ---- main_scale = map_obj.main.viewbox.scale def _render_on(viewport: SvgViewport, data_gdf: gpd.GeoDataFrame, clip_bbox: list) -> None: clip_geom = box(*clip_bbox) # Use centroids for clipping (a polygon whose centroid is in bbox should show) centroids = data_gdf.geometry.centroid mask = centroids.within(clip_geom) clipped = data_gdf[mask] if clipped.empty: return clip_vals = clipped[column].values clip_radii = _calculate_radii( clip_vals, max_radius, reference_value, reference_radius, ) # Scale radii so circles represent the same geographic size as on the # main viewport. Without this, a 25 px circle on a zoom (which has a # higher pixel/geo scale) would cover a much larger geographic area. scale_ratio = main_scale / viewport.viewbox.scale clip_radii = clip_radii * scale_ratio _draw_circles(viewport, clipped, clip_radii, fill, fill_opacity, stroke, stroke_width, z, layer_id, column) # Main viewport _render_on(map_obj.main, data, map_obj.bbox) # Zoom if on_zoom and map_obj._zoom_viewport and map_obj._zoom_bbox: _render_on(map_obj._zoom_viewport, data, 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(data, params) if not cart_data.empty: cart_vals = cart_data[column].values cart_radii = _calculate_radii( cart_vals, max_radius, reference_value, reference_radius, ) scale_ratio = main_scale / vp.viewbox.scale cart_radii = cart_radii * scale_ratio _draw_circles(vp, cart_data, cart_radii, fill, fill_opacity, stroke, stroke_width, z, layer_id, column) # Legend legend_bbox = _draw_proportional_legend( map_obj.svg, legend_cfg, legend_values, legend_radii, fill, map_obj.main, ) if legend_bbox is not None: map_obj.overflow.register(*legend_bbox) return layer_id