Source code for mappyng.toponyme

"""
Automatic label placement for mappyng, simulated annealing, ported from d3-labeler.

Supports:
- Anti-overlap between labels (label-label energy)
- Obstacle avoidance (proportional symbols, markers, etc.)
- Chrome avoidance (title, scale bar, source)
- Leader lines when label is far from anchor
- Priority-based hiding when labels can't all fit
"""

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

import geopandas as gpd
from shapely.geometry import box as shapely_box, Point, MultiPoint
from shapely.strtree import STRtree


# ============================================================================
# Position helpers
# ============================================================================

POSITIONS = ["NE", "E", "SE", "S", "SW", "W", "NW", "N"]

_POS_DX = {"NE": 1, "E": 1, "SE": 1, "S": 0, "SW": -1, "W": -1, "NW": -1, "N": 0}
_POS_DY = {"NE": -1, "E": 0, "SE": 1, "S": 1, "SW": 1, "W": 0, "NW": -1, "N": -1}


def estimate_text_bbox(text: str, font_size: float,
                       font_family: str = "sans-serif",
                       font_weight: str = "normal") -> Tuple[float, float]:
    """Estimate text bounding box (width, height) without rendering."""
    coeff = 1.15 if font_weight in ("bold", "700", "800", "900") else 1.0
    width = font_size * 0.55 * coeff * len(text)
    height = font_size * 1.2
    return width, height


def _split_at_dash(text: str, max_chars: int) -> List[str]:
    """Split a label into max 2 lines at the dash nearest the middle.

    Returns a 1-element list if splitting is not needed or not possible.
    The dash is kept at the end of the first line (French typographic convention).

    Examples
    --------
    >>> _split_at_dash("HAUTS-DE-SEINE", 12)
    ['HAUTS-DE-', 'SEINE']
    >>> _split_at_dash("SEINE-ET-MARNE", 12)
    ['SEINE-ET-', 'MARNE']
    >>> _split_at_dash("PARIS", 12)
    ['PARIS']
    """
    if len(text) <= max_chars:
        return [text]
    dashes = [i for i, c in enumerate(text) if c == "-"]
    if not dashes:
        return [text]
    mid = len(text) / 2.0
    # Pick the dash closest to the middle
    best = min(dashes, key=lambda i: abs((i + 1) - mid))
    return [text[:best + 1], text[best + 1:]]


def _bbox_for_lines(lines: List[str], font_size: float,
                    font_family: str = "sans-serif",
                    font_weight: str = "normal") -> Tuple[float, float]:
    """Bounding box for a multi-line label (max-width × stacked height)."""
    widths = [estimate_text_bbox(ln, font_size, font_family, font_weight)[0]
              for ln in lines]
    _, line_h = estimate_text_bbox("A", font_size, font_family, font_weight)
    return max(widths), line_h * len(lines)


LINE_HEIGHT_RATIO = 1.2   # SVG dy between baselines as multiple of font_size


def _place_label(ax: float, ay: float,
                 w: float, h: float,
                 pos: str, offset: float) -> Tuple[float, float]:
    """Compute top-left (x,y) for a label at `pos` direction from anchor."""
    dx = _POS_DX[pos]
    dy = _POS_DY[pos]

    if pos in ("N", "S"):
        x = ax - w / 2
    elif dx > 0:
        x = ax + offset
    else:
        x = ax - offset - w

    if pos in ("E", "W"):
        y = ay - h / 2
    elif dy < 0:          # upward in SVG (N, NE, NW)
        y = ay - offset - h
    else:                 # downward in SVG (S, SE, SW)
        y = ay + offset

    return x, y


def _label_box(x: float, y: float, w: float, h: float):
    return shapely_box(x, y, x + w, y + h)


# ============================================================================
# Configuration
# ============================================================================

[docs] @dataclass class LabelConfig: """Label placement configuration for ``LabelLayer``. Attributes ---------- font_size : float Label font size in SVG px (default 10.0). font_family : str Font family (default ``"sans-serif"``). font_weight : str Font weight (default ``"normal"``). color : str Label text color (default ``"#000000"``). halo_color : str or None Halo (text outline) color. None disables the halo. halo_width : float Halo stroke width in SVG px (default 2.0). priority_col : str or None Column name used for priority ranking (higher value = placed first). min_priority : float or None Minimum priority threshold; rows below it are skipped. max_labels : int or None Hard cap on the number of labels placed. initial_position : str Starting candidate position (default ``"NE"``). One of: ``"NE"``, ``"E"``, ``"SE"``, ``"S"``, ``"SW"``, ``"W"``, ``"NW"``, ``"N"``. label_offset : float Distance from anchor point to label edge in SVG px (default 4.0). max_displacement : float Maximum displacement from the initial position before hiding (px). avoid_overlap : bool Enable label-label overlap avoidance (default True). allow_hide : bool Allow labels to be hidden when they cannot be placed without overlap (default True). leader_lines : bool Draw leader lines when label is displaced beyond *leader_threshold* (default True). leader_threshold : float Minimum displacement (× label height) that triggers a leader line. wrap : bool Wrap long labels at dashes (default True). wrap_max_chars : int Max characters per line before wrapping (default 12). numbered_above : int or None Switch to numbered-circle mode when label count exceeds this value. None = always use text labels. n_sweeps : int Number of simulated-annealing sweeps (default 50). seed : int or None Random seed for reproducible placement (default 42). target : str Viewport to place labels on: ``"main"`` (default), ``"zoom"``, or ``"cartouche:<index>"``. """ # Appearance font_size: float = 10.0 font_family: str = "sans-serif" font_weight: str = "normal" color: str = "#000000" halo_color: Optional[str] = "#ffffff" halo_width: float = 2.0 # Priority / filtering priority_col: Optional[str] = None min_priority: Optional[float] = None max_labels: Optional[int] = None # Placement initial_position: str = "NE" candidate_positions: List[str] = field( default_factory=lambda: ["NE", "E", "SE", "S", "SW", "W", "NW", "N"] ) label_offset: float = 4.0 max_displacement: float = 40.0 # Anti-overlap avoid_overlap: bool = True allow_hide: bool = True # Leader lines leader_lines: bool = True leader_threshold: float = 1.5 leader_color: str = "#666666" leader_width: float = 0.5 # Wrapping wrap: bool = True wrap_max_chars: int = 12 # Numbered mode (auto-triggered when label count > numbered_above) numbered_above: Optional[int] = None # Legend position: {"x": float, "y": float} in figure fractions (0-1), # where (0,0) = bottom-left and (1,1) = top-right, same convention as # other mappyng legend positions. None = automatic (bottom-right, # aligned with the legend column). numbered_legend_position: Optional[dict] = None # Circle appearance numbered_circle_r: float = 5.0 numbered_circle_fill: str = "#ffffff" numbered_circle_stroke: str = "#333333" # Legend box style numbered_legend_bg: str = "white" numbered_legend_bg_opacity: float = 0.88 numbered_legend_border: str = "#cccccc" numbered_legend_title: Optional[str] = None # Simulated annealing n_sweeps: int = 50 initial_temperature: float = 1.0 final_temperature: float = 0.01 seed: Optional[int] = 42 # Target viewport target: str = "main" # Energy weights w_LL: float = 30.0 # label-label w_LO: float = 25.0 # label-obstacle w_LC: float = 100.0 # label-chrome w_LA: float = 30.0 # label-anchor circle w_D: float = 1.0 # distance from anchor w_OR: float = 3.0 # orientation bias w_OOB: float = 1000.0 # out-of-bounds
# ============================================================================ # Label candidate # ============================================================================ @dataclass class LabelCandidate: text: str anchor_x: float anchor_y: float anchor_r: float # radius of the anchor marker (px) width: float height: float x: float # current top-left x (SVG px) y: float # current top-left y (SVG px) pos: str # current position name priority: float = 0.0 visible: bool = True lines: List[str] = field(default_factory=list) # 1 or 2 display lines # ============================================================================ # Obstacle index # ============================================================================ class ObstacleIndex: """Spatial index for obstacles using shapely STRtree (SVG pixel coords).""" def __init__(self, geoms: List[Any]): self._geoms = list(geoms) self._tree = STRtree(self._geoms) if self._geoms else None def overlap_area(self, label_box) -> float: if not self._tree: return 0.0 area = 0.0 for i in self._tree.query(label_box): inter = label_box.intersection(self._geoms[i]) area += inter.area return area # ============================================================================ # Label placer, simulated annealing # ============================================================================ class LabelPlacer: """ Simulated annealing label placer, adapted from d3-labeler (BSD). Energy function: E = w_LL * Σ label-label overlap + w_LO * Σ label-obstacle overlap + w_LC * Σ label-chrome overlap + w_LA * Σ label-anchor overlap + w_D * distance(label_center, anchor) + w_OR * orientation_penalty + w_OOB * out_of_bounds_area """ def __init__(self, labels: List[LabelCandidate], obstacle_index: ObstacleIndex, chrome_boxes: List, viewport_bbox: Tuple[float, float, float, float], config: LabelConfig, rng: random.Random): self.labels = labels self.obs = obstacle_index self.chrome = chrome_boxes self.vbbox = viewport_bbox # (x0, y0, x1, y1) SVG px self.cfg = config self.rng = rng self._vbox = shapely_box(*viewport_bbox) # ------------------------------------------------------------------ # Fast float-only bbox overlap (avoids shapely for hot inner loop) # ------------------------------------------------------------------ @staticmethod def _bb_overlap(ax1: float, ay1: float, ax2: float, ay2: float, bx1: float, by1: float, bx2: float, by2: float) -> float: """Axis-aligned bbox intersection area, pure Python.""" dx = min(ax2, bx2) - max(ax1, bx1) dy = min(ay2, by2) - max(ay1, by1) return dx * dy if dx > 0 and dy > 0 else 0.0 def energy(self, i: int) -> float: cfg = self.cfg lab = self.labels[i] # Label bbox corners lx1, ly1 = lab.x, lab.y lx2, ly2 = lab.x + lab.width, lab.y + lab.height e = 0.0 # Distance label-center to anchor cx = (lx1 + lx2) * 0.5 cy = (ly1 + ly2) * 0.5 e += cfg.w_D * math.sqrt((cx - lab.anchor_x) ** 2 + (cy - lab.anchor_y) ** 2) # Orientation bias if lab.pos != cfg.initial_position: e += cfg.w_OR bb = self._bb_overlap w_LL, w_LA = cfg.w_LL, cfg.w_LA # Label-label and label-anchor overlap (pure float, O(n)) for other in self.labels: if other is lab or not other.visible: # Still check anchor even when same label pass else: # label-label e += w_LL * bb(lx1, ly1, lx2, ly2, other.x, other.y, other.x + other.width, other.y + other.height) # label-anchor for every anchor (including self) r = other.anchor_r e += w_LA * bb(lx1, ly1, lx2, ly2, other.anchor_x - r, other.anchor_y - r, other.anchor_x + r, other.anchor_y + r) # Label-obstacle overlap (shapely STRtree, sparse) if cfg.avoid_overlap: lbox = _label_box(lx1, ly1, lab.width, lab.height) e += cfg.w_LO * self.obs.overlap_area(lbox) # Label-chrome overlap (float arithmetic, typically few boxes) vx0, vy0, vx1, vy1 = self.vbbox for cbox in self.chrome: cb = cbox.bounds # (minx, miny, maxx, maxy) e += cfg.w_LC * bb(lx1, ly1, lx2, ly2, cb[0], cb[1], cb[2], cb[3]) # Out-of-bounds penalty (float arithmetic) oob_x = max(0.0, vx0 - lx1) + max(0.0, lx2 - vx1) oob_y = max(0.0, vy0 - ly1) + max(0.0, ly2 - vy1) if oob_x > 0 or oob_y > 0: clamp_w = max(0.0, lab.width - oob_x) clamp_h = max(0.0, lab.height - oob_y) oob_area = lab.width * lab.height - clamp_w * clamp_h e += cfg.w_OOB * oob_area return e def mcmove(self, T: float) -> None: rng = self.rng cfg = self.cfg n = len(self.labels) if n == 0: return i = rng.randint(0, n - 1) lab = self.labels[i] old_x, old_y = lab.x, lab.y old_E = self.energy(i) sigma = cfg.max_displacement * T lab.x += rng.gauss(0, sigma) lab.y += rng.gauss(0, sigma) # Reject if too far from anchor cx = lab.x + lab.width / 2 cy = lab.y + lab.height / 2 if math.sqrt((cx - lab.anchor_x) ** 2 + (cy - lab.anchor_y) ** 2) > cfg.max_displacement: lab.x, lab.y = old_x, old_y return new_E = self.energy(i) dE = new_E - old_E if dE >= 0 and rng.random() >= math.exp(-dE / max(T, 1e-10)): lab.x, lab.y = old_x, old_y def mcrotate(self, T: float) -> None: rng = self.rng cfg = self.cfg n = len(self.labels) if n == 0: return i = rng.randint(0, n - 1) lab = self.labels[i] old_x, old_y, old_pos = lab.x, lab.y, lab.pos old_E = self.energy(i) candidates = [p for p in cfg.candidate_positions if p != lab.pos] if not candidates: return new_pos = rng.choice(candidates) lab.x, lab.y = _place_label( lab.anchor_x, lab.anchor_y, lab.width, lab.height, new_pos, cfg.label_offset, ) lab.pos = new_pos new_E = self.energy(i) dE = new_E - old_E if dE >= 0 and rng.random() >= math.exp(-dE / max(T, 1e-10)): lab.x, lab.y, lab.pos = old_x, old_y, old_pos def run(self) -> List[LabelCandidate]: cfg = self.cfg if not self.labels: return self.labels n_iter = cfg.n_sweeps * len(self.labels) T0, Tf = cfg.initial_temperature, cfg.final_temperature for k in range(n_iter): T = T0 * (Tf / T0) ** (k / n_iter) if self.rng.random() < 0.5: self.mcmove(T) else: self.mcrotate(T) if cfg.allow_hide: self._hide_colliding() return self.labels def _hide_colliding(self) -> None: """Greedy hide: keep highest-priority labels, hide conflicting lower ones.""" order = sorted(range(len(self.labels)), key=lambda i: -self.labels[i].priority) accepted: List[int] = [] for i in order: lab = self.labels[i] lbox = _label_box(lab.x, lab.y, lab.width, lab.height) collides = any( lbox.intersects(_label_box( self.labels[j].x, self.labels[j].y, self.labels[j].width, self.labels[j].height, )) for j in accepted ) if collides: lab.visible = False else: accepted.append(i) # ============================================================================ # SVG emission # ============================================================================ def _xml_escape(text: str) -> str: """Escape XML special characters.""" return (text.replace("&", "&amp;") .replace("<", "&lt;") .replace(">", "&gt;") .replace('"', "&quot;") .replace("'", "&apos;")) def _add_text_content(elem: ET.Element, lines: List[str], x: float, baseline_y: float, font_size: float) -> None: """Fill a <text> element with 1 or 2 lines using <tspan> for line 2.""" if len(lines) == 1: elem.text = lines[0] else: # Line 1 as a tspan so line 2 can use dy ts1 = ET.SubElement(elem, "tspan") ts1.set("x", f"{x:.2f}") ts1.set("y", f"{baseline_y:.2f}") ts1.text = lines[0] ts2 = ET.SubElement(elem, "tspan") ts2.set("x", f"{x:.2f}") ts2.set("dy", f"{font_size * LINE_HEIGHT_RATIO:.2f}") ts2.text = lines[1] def emit_labels_svg(labels: List[LabelCandidate], config: LabelConfig, layer_elem: ET.Element) -> None: """Append label SVG elements to an existing layer <g> element.""" fs = config.font_size for lab in labels: if not lab.visible: continue lines = lab.lines if lab.lines else [lab.text] # Baseline of first line = top + font_size baseline_y = lab.y + fs cx = lab.x + lab.width / 2 g = ET.SubElement(layer_elem, "g") g.set("class", "mappyng-label") g.set("data-priority", f"{lab.priority:.2f}") # Leader line (drawn first so it's under text) dist = math.sqrt( (cx - lab.anchor_x) ** 2 + (baseline_y - lab.anchor_y) ** 2 ) if config.leader_lines and dist > config.leader_threshold * lab.height: line = ET.SubElement(g, "line") line.set("x1", f"{lab.anchor_x:.2f}") line.set("y1", f"{lab.anchor_y:.2f}") line.set("x2", f"{cx:.2f}") line.set("y2", f"{baseline_y:.2f}") line.set("stroke", config.leader_color) line.set("stroke-width", str(config.leader_width)) text_attrs = { "font-family": config.font_family, "font-size": str(fs), "font-weight": config.font_weight, } # Halo if config.halo_color: th = ET.SubElement(g, "text") th.set("x", f"{lab.x:.2f}") th.set("y", f"{baseline_y:.2f}") for k, v in text_attrs.items(): th.set(k, v) th.set("fill", "none") th.set("stroke", config.halo_color) th.set("stroke-width", str(config.halo_width)) th.set("stroke-linejoin", "round") _add_text_content(th, lines, lab.x, baseline_y, fs) # Fill text t = ET.SubElement(g, "text") t.set("x", f"{lab.x:.2f}") t.set("y", f"{baseline_y:.2f}") for k, v in text_attrs.items(): t.set(k, v) t.set("fill", config.color) _add_text_content(t, lines, lab.x, baseline_y, fs) # ============================================================================ # Numbered mode emission # ============================================================================ def _numbered_ranked(labels: List[LabelCandidate]) -> List[LabelCandidate]: """Return up to 10 labels sorted by priority descending.""" return sorted(labels, key=lambda l: -l.priority)[:10] def emit_numbered_circles( labels: List[LabelCandidate], config: LabelConfig, layer_elem: ET.Element, ) -> List[LabelCandidate]: """Emit numbered circles at anchor positions in *layer_elem*. Returns the ranked list (<= 10) so the caller can emit the matching legend. """ ranked = _numbered_ranked(labels) r = config.numbered_circle_r fs_num = max(r * 1.1, config.font_size * 0.65) for n, lab in enumerate(ranked, start=1): g = ET.SubElement(layer_elem, "g") g.set("class", "mappyng-label-numbered") g.set("data-n", str(n)) g.set("data-label", _xml_escape(lab.text)) circ = ET.SubElement(g, "circle") circ.set("cx", f"{lab.anchor_x:.2f}") circ.set("cy", f"{lab.anchor_y:.2f}") circ.set("r", str(r)) circ.set("fill", config.numbered_circle_fill) circ.set("stroke", config.numbered_circle_stroke) circ.set("stroke-width", "0.8") t = ET.SubElement(g, "text") t.set("x", f"{lab.anchor_x:.2f}") t.set("y", f"{lab.anchor_y + fs_num * 0.38:.2f}") t.set("text-anchor", "middle") t.set("font-size", f"{fs_num:.2f}") t.set("font-family", config.font_family) t.set("font-weight", "bold") t.set("fill", config.numbered_circle_stroke) t.text = str(n) return ranked def numbered_legend_size(ranked: List[LabelCandidate], config: LabelConfig) -> Tuple[float, float]: """Return (width, height) in SVG px for the legend box.""" r = config.numbered_circle_r pad = 4.0 fs_leg = config.font_size line_h = fs_leg * 1.5 title_h = fs_leg * 1.6 if config.numbered_legend_title else 0.0 max_len = max((len(l.text) for l in ranked), default=4) w = r * 2 + pad * 3 + fs_leg * 0.55 * max_len h = len(ranked) * line_h + pad * 2 + title_h return w, h def emit_numbered_legend( ranked: List[LabelCandidate], config: LabelConfig, lx: float, ly: float, layer_elem: ET.Element, ) -> None: """Emit the numbered key legend box at absolute SVG position (lx, ly). ``ranked`` must be the list returned by :func:`emit_numbered_circles` so numbers match 1-for-1. """ r = config.numbered_circle_r fs_num = max(r * 1.1, config.font_size * 0.65) pad = 4.0 fs_leg = config.font_size line_h = fs_leg * 1.5 title_h = fs_leg * 1.6 if config.numbered_legend_title else 0.0 legend_w, legend_h = numbered_legend_size(ranked, config) g_leg = ET.SubElement(layer_elem, "g") g_leg.set("class", "mappyng-label-legend-box") bg = ET.SubElement(g_leg, "rect") bg.set("x", f"{lx:.2f}") bg.set("y", f"{ly:.2f}") bg.set("width", f"{legend_w:.2f}") bg.set("height", f"{legend_h:.2f}") bg.set("fill", config.numbered_legend_bg) bg.set("fill-opacity", str(config.numbered_legend_bg_opacity)) bg.set("rx", "2") bg.set("stroke", config.numbered_legend_border) bg.set("stroke-width", "0.5") # Optional title if config.numbered_legend_title: tt = ET.SubElement(g_leg, "text") tt.set("x", f"{lx + pad:.2f}") tt.set("y", f"{ly + pad + fs_leg * 0.85:.2f}") tt.set("font-size", f"{fs_leg:.2f}") tt.set("font-family", config.font_family) tt.set("font-weight", "bold") tt.set("fill", config.color) tt.text = config.numbered_legend_title for n, lab in enumerate(ranked, start=1): cy_row = ly + pad + title_h + (n - 0.5) * line_h baseline = cy_row + fs_leg * 0.38 circ = ET.SubElement(g_leg, "circle") circ.set("cx", f"{lx + pad + r:.2f}") circ.set("cy", f"{cy_row:.2f}") circ.set("r", str(r)) circ.set("fill", config.numbered_circle_fill) circ.set("stroke", config.numbered_circle_stroke) circ.set("stroke-width", "0.8") tn = ET.SubElement(g_leg, "text") tn.set("x", f"{lx + pad + r:.2f}") tn.set("y", f"{cy_row + fs_num * 0.38:.2f}") tn.set("text-anchor", "middle") tn.set("font-size", f"{fs_num:.2f}") tn.set("font-family", config.font_family) tn.set("font-weight", "bold") tn.set("fill", config.numbered_circle_stroke) tn.text = str(n) tname = ET.SubElement(g_leg, "text") tname.set("x", f"{lx + pad + r * 2 + pad:.2f}") tname.set("y", f"{baseline:.2f}") tname.set("font-size", f"{fs_leg:.2f}") tname.set("font-family", config.font_family) tname.set("font-weight", config.font_weight) tname.set("fill", config.color) tname.text = lab.text def emit_numbered_svg( labels: List[LabelCandidate], config: LabelConfig, viewport_bbox: Tuple[float, float, float, float], layer_elem: ET.Element, legend_layer: Optional[ET.Element] = None, legend_xy: Optional[Tuple[float, float]] = None, ) -> None: """Emit numbered circles + legend box. If *legend_layer* and *legend_xy* are provided, the legend box is placed in that external layer (e.g. the main map layer, adjacent to the zoom window). Otherwise it falls back to the same *layer_elem* with corner positioning inside *viewport_bbox*. """ ranked = emit_numbered_circles(labels, config, layer_elem) if legend_layer is not None and legend_xy is not None: emit_numbered_legend(ranked, config, legend_xy[0], legend_xy[1], legend_layer) else: # Fallback: place inside the viewport (SE corner) when no external # layer is available (e.g. unit tests or main-viewport use). vx0, vy0, vx1, vy1 = viewport_bbox pad = 4.0 legend_w, legend_h = numbered_legend_size(ranked, config) lx = max(vx0 + pad, vx1 - legend_w - pad * 2) ly = max(vy0 + pad, vy1 - legend_h - pad * 2) emit_numbered_legend(ranked, config, lx, ly, layer_elem) # ============================================================================ # Public entry point (used by Map.add_labels) # ============================================================================ def place_labels( gdf: gpd.GeoDataFrame, column: str, viewbox, # mappyng ViewBox viewport, # SvgViewport obstacle_geoms: List[Any], chrome_bboxes_svg: List[Tuple[float, float, float, float]], config: LabelConfig, z_order: int = 40, legend_layer: Optional[ET.Element] = None, legend_xy: Optional[Tuple[float, float]] = None, ) -> None: """ Place labels for a GeoDataFrame and emit SVG into `viewport`. Parameters ---------- gdf : GeoDataFrame Features to label. Geometries should already be in the viewport CRS. column : str Column containing the label text. viewbox : ViewBox Coordinate transform for geo to SVG pixel conversion. viewport : SvgViewport Target viewport to emit SVG into. obstacle_geoms : list Shapely geometries in SVG pixel coordinates used as obstacles. chrome_bboxes_svg : list of (x0,y0,x1,y1) Chrome element bounding boxes (title, scale bar...) in SVG px. config : LabelConfig All placement parameters. z_order : int Z-order for the label layer. """ rng = random.Random(config.seed) # --- Build LabelCandidates --- labels: List[LabelCandidate] = [] anchor_r = max(config.font_size * 0.3, 2.0) for _, row in gdf.iterrows(): text = str(row[column]) if row[column] is not None else "" if not text: continue geom = row.geometry if geom is None or geom.is_empty: continue # Anchor point: representative_point for robustness pt = geom.representative_point() if not isinstance(geom, (Point, MultiPoint)) else ( geom if isinstance(geom, Point) else list(geom.geoms)[0] ) if isinstance(pt, MultiPoint): pt = list(pt.geoms)[0] ax, ay = viewbox.geo_to_svg(pt.x, pt.y) # Priority priority = float(row[config.priority_col]) if ( config.priority_col and config.priority_col in gdf.columns ) else 0.0 # Wrapping: split at dash if text too long if config.wrap: lines = _split_at_dash(text, config.wrap_max_chars) else: lines = [text] w, h = _bbox_for_lines(lines, config.font_size, config.font_family, config.font_weight) ix, iy = _place_label(ax, ay, w, h, config.initial_position, config.label_offset) labels.append(LabelCandidate( text=text, anchor_x=ax, anchor_y=ay, anchor_r=anchor_r, width=w, height=h, x=ix, y=iy, pos=config.initial_position, priority=priority, visible=True, lines=lines, )) if not labels: return # --- Filter by priority --- if config.min_priority is not None: labels = [l for l in labels if l.priority >= config.min_priority] if config.max_labels is not None: labels = sorted(labels, key=lambda l: -l.priority)[:config.max_labels] # --- Viewport bounds --- vb = viewbox viewport_bbox = ( vb.content_x, vb.content_y, vb.content_x + vb.content_width, vb.content_y + vb.content_height, ) layer = viewport.get_layer("labels", z_order=z_order) # --- Numbered mode: skip annealing when too many labels --- if config.numbered_above is not None and len(labels) > config.numbered_above: emit_numbered_svg(labels, config, viewport_bbox, layer, legend_layer=legend_layer, legend_xy=legend_xy) return # --- Obstacle index (SVG px) --- chrome_shapes = [shapely_box(*bb) for bb in chrome_bboxes_svg] obs_idx = ObstacleIndex(obstacle_geoms) # --- Run annealing --- placer = LabelPlacer( labels=labels, obstacle_index=obs_idx, chrome_boxes=chrome_shapes, viewport_bbox=viewport_bbox, config=config, rng=rng, ) placed = placer.run() # --- Emit SVG --- emit_labels_svg(placed, config, layer)