From 6b5a44f0f3cb9eda9c1730e37b19a06e41d3f2a8 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:40:05 +0800 Subject: [PATCH] maps: general anchor-set label placer (labelplace.py), region + marker in one pass Each label owns a set of 1..N candidate anchor points: a marker label passes its single point, a zone label passes its whole densified hull perimeter (densify_polygon promotes the polygon PATH to points, since a hull stores only ~6 corners). Two obstacle classes: hard (markers + placed labels, never covered) and soft (polygon edges, only region labels avoid; marker labels wear a thin white outline and may cross). Region labels maximise clearance over their perimeter -> emptiest open air, no white box, no leader; marker labels take the nearest clear slot with a ~half-char gap and a leader only when far. Runs in pixel space AFTER invert_xaxis so the try-every-side geometry isn't mirrored (fixes the all-labels-drift-left bug). Adapted from textalloc + wassname's plotly placer. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- src/tinymfv/labelplace.py | 155 ++++++++++++++++++++++++++++++++++++++ src/tinymfv/maps.py | 84 ++++++++------------- 2 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 src/tinymfv/labelplace.py diff --git a/src/tinymfv/labelplace.py b/src/tinymfv/labelplace.py new file mode 100644 index 0000000..5fa635e --- /dev/null +++ b/src/tinymfv/labelplace.py @@ -0,0 +1,155 @@ +"""General candidate-slot label placement for matplotlib: polygon-aware, short-leader, gist-ready. + +The problem: matplotlib has no label placer that (a) tries every side of a marker and keeps the +nearest clear slot, (b) draws a leader line ONLY when the label had to move far, and (c) treats a +filled polygon (a convex-hull region) as something to avoid. adjustText (Phlya/adjustText) relaxes +by force and parks labels in local minima; textalloc (ckjellson/textalloc) does candidate placement +but only against points/lines/other-text, and always/never draws lines. This is a small placer that +does all three, generalised so ONE call handles both marker labels and region labels. + +The generalisation (wassname's idea): every label owns a SET of 1..N candidate anchor points, and we +search placements around all of them. + - a MARKER label (country / model dot) passes its single point -> the box sits adjacent to it. + - a REGION label (a zone name over a convex hull) passes its whole densified perimeter -> the box + can attach ANYWHERE along the hull edge, so it has tons of options and never needs to overlap. + +Two obstacle classes: + - HARD points (markers, and every already-placed label box): no label may cover these. + - SOFT points (polygon edges, via densify_polygon): only REGION labels avoid these. A marker label + wears a thin white outline, so it may cross a hull line and stay readable (cheaper than contorting + every country label around the zone boundaries). + +Selection differs by label kind, which is the whole point of the 1..N anchor-set framing: + - region labels MAXIMISE clearance over all (perimeter-anchor x slot) candidates -> the emptiest arc + of their own hull, in the open, no white box and no leader. + - marker labels take the NEAREST clear slot (adjacent reads as attached), with a ~half-character gap + from every obstacle and a leader line only when the slot is far or contested. + +Runs in PIXEL space (measures real rendered text extents), so call it AFTER the axes are at their +final limits and orientation -- e.g. after ax.invert_xaxis() -- otherwise the 'try every side' +geometry is mirrored and every label drifts one way. + +Adapted from textalloc (ckjellson/textalloc, MIT) and wassname's plotly placer +(gist b0b34492cd1679f1daeb5892ef714dce). -- authored by Claude +""" +from __future__ import annotations + +import numpy as np +import matplotlib.patheffects as pe + + +def densify_polygon(coords: np.ndarray, step: float) -> np.ndarray: + """A convex hull is stored as ~6 CORNER vertices; the long straight edges between them carry no + points, so a label can sit ON an edge and 'see' nothing to dodge. Sample points every `step` data + units ALONG each closed edge, promoting the polygon PATH (not just its corners) to a point cloud + the box-collision test can feel.""" + coords = np.asarray(coords, float) + pts = [] + for i in range(len(coords)): + a, b = coords[i], coords[(i + 1) % len(coords)] + n = max(2, int(np.hypot(*(b - a)) / step) + 1) + pts.extend(a + t * (b - a) for t in np.linspace(0, 1, n, endpoint=False)) + return np.array(pts) if pts else np.empty((0, 2)) + + +def _box_metrics(box, pts): + """(count of pts inside box, distance from box to nearest pt) for a padded AABB and a point cloud.""" + if not len(pts): + return 0, np.inf + x0, y0, x1, y1 = box + dx = np.maximum(0.0, np.maximum(x0 - pts[:, 0], pts[:, 0] - x1)) + dy = np.maximum(0.0, np.maximum(y0 - pts[:, 1], pts[:, 1] - y1)) + d = np.hypot(dx, dy) + return int(np.count_nonzero(d == 0.0)), float(d.min()) + + +# candidate directions in priority order: right, left, under, up (horizontal reads best, 'under' +# before 'over'), then the four diagonals. y is UP in matplotlib display space. +_ANGLES = np.deg2rad([0, 180, 270, 90, 315, 225, 45, 135]) +_DIRS = np.column_stack([np.cos(_ANGLES), np.sin(_ANGLES)]) + + +def allocate_labels(ax, anchor_sets: list[np.ndarray], texts: list[str], colors: list[str], + weights: list[str], hard_pts: np.ndarray, *, soft_pts: np.ndarray | None = None, + region: list[bool] | None = None, fontsize: float = 9.0, + fontsizes: list[float] | None = None, styles: list[str] | None = None, + anchor_pad: list[float] | None = None, gap_frac: float = 0.28, + stroke: float = 2.0, linecolor: str = "#9a958a", linewidth: float = 0.6): + """Place N labels. See the module docstring for the model. Draws directly onto `ax`. + + anchor_sets : per label, an (Ki, 2) array of candidate attachment points (data coords). + hard_pts : (M, 2) markers no label may cover; placed label boxes are added to this as we go. + soft_pts : (P, 2) polygon-edge points; only `region` labels avoid them. + region[i] : True -> multi-anchor, maximise clearance, avoid soft points, no white box, no leader + False -> nearest clear slot, hard points only, thin white outline, leader if far. + anchor_pad[i]: px radius of label i's own marker, so the box clears a big star as well as the gap. + gap_frac : gap kept from every obstacle, as a fraction of text height (~half a character). + """ + n = len(texts) + region = region or [False] * n + fs = fontsizes or [fontsize] * n + st = styles or ["normal"] * n + pad0 = anchor_pad or [4.0] * n + fig = ax.figure + fig.canvas.draw() # freeze limits + get a live renderer + rend = fig.canvas.get_renderer() + to_px = ax.transData.transform + to_data = ax.transData.inverted().transform + A_px = [to_px(np.asarray(a, float).reshape(-1, 2)) for a in anchor_sets] + hard = to_px(np.asarray(hard_pts, float)) if len(hard_pts) else np.empty((0, 2)) + soft = to_px(np.asarray(soft_pts, float)) if (soft_pts is not None and len(soft_pts)) else np.empty((0, 2)) + abox = ax.get_window_extent() + wh = [] # measured (w, h) px per label + for t, w, s, z in zip(texts, weights, st, fs): + h = ax.text(0, 0, t, fontsize=z, fontweight=w, fontstyle=s, ha="left", va="bottom") + e = h.get_window_extent(rend); wh.append((e.width, e.height)); h.remove() + placed = [] # settled label boxes -> hard obstacles + order = sorted(range(n), key=lambda i: not region[i]) # region labels first, so markers dodge them + for i in order: + w_i, h_i = wh[i] + gap = gap_frac * h_i # ~half a character clear of every obstacle + r0 = pad0[i] + gap # clear the marker itself + the gap + radii = [r0, r0 + 0.9 * h_i] if region[i] else [r0 + k * h_i for k in (0.0, 0.9, 1.8, 2.8, 4.0)] + obstacles = np.vstack([hard, soft]) if region[i] and len(soft) else hard + best = None # (penalty, -clearance, box, anchor, radius) + for anc in A_px[i]: + ax0, ay0 = anc + for r in radii: + for ux, uy in _DIRS: + cx, cy = ax0 + ux * (r + w_i / 2), ay0 + uy * (r + h_i / 2) + box = (cx - w_i / 2 - gap, cy - h_i / 2 - gap, cx + w_i / 2 + gap, cy + h_i / 2 + gap) + pen = 0.0 + if box[0] < abox.x0 or box[2] > abox.x1 or box[1] < abox.y0 or box[3] > abox.y1: + pen += 1000.0 # off-canvas: last resort + inside, clear = _box_metrics(box, obstacles) + pen += 50.0 * inside + for pb in placed: # overlap area with settled labels + ox = max(0.0, min(box[2], pb[2]) - max(box[0], pb[0])) + oy = max(0.0, min(box[3], pb[3]) - max(box[1], pb[1])) + pen += 0.02 * ox * oy + key = (pen, -clear) + if best is None or key < best[0]: + best = (key, (cx, cy), box, (ax0, ay0), r) + if pen == 0.0 and not region[i]: + break # marker: first clear slot (nearest) wins + else: + continue + break + else: + continue + if not region[i]: + break + (pen, _), (cx, cy), box, (ax0, ay0), r = best + placed.append(box) + # leader line: marker labels only, when the slot is far or contested. Same-colour (model/steer) + # labels get a looser threshold since their colour already ties them to the marker. + if not region[i]: + thr = h_i * (2.6 if colors[i] != "#111" else 1.15) + if r > r0 + thr or pen > 0: + nx, ny = min(max(ax0, box[0]), box[2]), min(max(ay0, box[1]), box[3]) + (lx0, ly0), (lx1, ly1) = to_data((ax0, ay0)), to_data((nx, ny)) + ax.plot([lx0, lx1], [ly0, ly1], "-", color=linecolor, lw=linewidth, zorder=2.5) + dx, dy = to_data((cx, cy)) + ax.text(dx, dy, texts[i], color=colors[i], fontsize=fs[i], fontweight=weights[i], fontstyle=st[i], + ha="center", va="center", zorder=10, + path_effects=[pe.withStroke(linewidth=stroke, foreground="white")]) diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index 6e9a21f..6873bb1 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -28,6 +28,7 @@ import matplotlib.pyplot as plt from matplotlib.patches import Ellipse from .instrument import Instrument +from .labelplace import allocate_labels, densify_polygon DATA = Path(__file__).resolve().parent / "data" @@ -257,27 +258,6 @@ def model_family_color(name: str) -> str: return MODEL_RED -def _hull_label_pos(coords: np.ndarray, center: np.ndarray, obstacles: list[tuple[float, float]], - span: np.ndarray, out: float = 0.03) -> tuple[float, float, str, str]: - """Place a zone label JUST OUTSIDE the emptiest arc of its hull -- NO leader line and NOT on the - coloured edge line itself. A convex hull has plenty of perimeter, so walk its boundary vertices, - push each OUTWARD (away from the plot centre), and keep the one whose NEAREST dot/label is farthest - (distances normalised by the data span). Returns (x, y, ha, va) where the alignment makes the text - box extend further outward, so it clears its own outline instead of straddling it.""" - best, best_score, best_u = (float(coords[0][0]), float(coords[0][1])), -np.inf, np.array([0.0, 1.0]) - for vx, vy in coords: - dn = np.array([(vx - center[0]) / span[0], (vy - center[1]) / span[1]]) - u = dn / (np.hypot(*dn) or 1.0) # outward unit vector (normalised space) - cx, cy = vx + out * u[0] * span[0], vy + out * u[1] * span[1] - dmin = min(np.hypot((cx - ox) / span[0], (cy - oy) / span[1]) for ox, oy in obstacles) - if dmin > best_score: - best_score, best, best_u = dmin, (cx, cy), u - ux, uy = best_u - ha = "left" if ux > 0.3 else "right" if ux < -0.3 else "center" # text extends outward from the edge - va = "bottom" if uy > 0.3 else "top" if uy < -0.3 else "center" - return best[0], best[1], ha, va - - def plot_value_map(display: str, countries: list[str], P: np.ndarray, poles: tuple[str, str, str, str], *, models: dict[str, tuple[float, float]] | None = None, model_labels: dict[str, str] | None = None, @@ -299,8 +279,6 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray, same visual language as plot_ipsative_pca's trajectory, so the two map families read alike. Returns the Figure.""" from .zones import zones_for - import textalloc as ta - import matplotlib.patheffects as pe zones_all, emph = zones_for(countries) emph = (emphasize or set()) | emph zones, dot_cols, label_set = _map_annotations(P, countries, zones_all, emph, "#888888") @@ -314,12 +292,12 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray, zone_specs = draw_zone_hulls(ax, P, countries, zones, label=False) # labels go through the allocator ax.scatter(P[:, 0], P[:, 1], s=26, c=dot_cols, alpha=0.85, edgecolors="white", linewidths=0.5, zorder=3) - # Two-stage placement. Point labels (country / model / steer) go through ONE adjustText pass (force - # repulsion off the dots and each other, leader lines). The few big ZONE labels get a dedicated - # emptiest-slot search first (adjustText's local relaxation parks them in crowded local minima), and - # the point labels then avoid those. obs_x/obs_y are the dots every label must dodge. + # Two-stage placement. The few big ZONE labels get a dedicated emptiest-hull-edge search first, then + # the point labels (country / model / steer) go through allocate_labels, which dodges dots + hull + # edges + those zone labels. obs_x/obs_y are the dots+stars every label must dodge. obs_x, obs_y = list(P[:, 0]), list(P[:, 1]) - lab_specs = [(P[i, 0], P[i, 1], countries[i], "#111", "normal", "normal", 9) + # each marker label spec: (x, y, text, colour, weight, marker_pad_px). pad clears the marker glyph. + lab_specs = [(P[i, 0], P[i, 1], countries[i], "#111", "normal", 5.0) for i, c in enumerate(countries) if c in label_set] if models: # each model is a STAR coloured by lab family. Every model is plotted, but only `model_labels` @@ -332,7 +310,7 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray, for k, x, y, col in zip(mnames, mx, my, mcols): disp = k if model_labels is None else model_labels.get(k) if disp: - lab_specs.append((x, y, disp, col, "bold", "normal", 9)) + lab_specs.append((x, y, disp, col, "bold", 13.0)) # star is big -> larger marker pad obs_x += list(mx); obs_y += list(my) if steer: bx, by, blab = steer["base"] @@ -342,36 +320,36 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray, ex, ey, elab = steer[key] ax.plot([bx, ex], [by, ey], "-", color=col, lw=1.6, alpha=0.85, zorder=7) # connected arm ax.scatter(ex, ey, s=90, c=col, edgecolors="white", linewidths=1.0, zorder=8) - lab_specs.append((ex, ey, elab, col, "bold", "normal", 9)) + lab_specs.append((ex, ey, elab, col, "bold", 8.0)) obs_x.append(ex); obs_y.append(ey) ax.scatter(bx, by, s=90, c=C_BASE, edgecolors="white", linewidths=1.0, zorder=8) - lab_specs.append((bx, by, blab, C_BASE, "bold", "normal", 9)) + lab_specs.append((bx, by, blab, C_BASE, "bold", 8.0)) obs_x.append(bx); obs_y.append(by) ax.margins(0.13) - span = P.max(0) - P.min(0) - center = P.mean(0) - obs_pts = list(zip(obs_x, obs_y)) - # Zone labels: seat each JUST OUTSIDE the emptiest arc of its OWN hull edge (polygon-aware, no leader, - # not on the coloured line). Their spots then join the obstacle set so point labels dodge them too. - zx_obs, zy_obs = [], [] - for zn, coords, zc in zone_specs: - lx, ly, lha, lva = _hull_label_pos(coords, center, obs_pts + list(zip(zx_obs, zy_obs)), span) - ax.text(lx, ly, zn, color=zc, fontsize=10, fontweight="bold", fontstyle="italic", ha=lha, va=lva, - zorder=9, path_effects=[pe.withStroke(linewidth=3.0, foreground="white")]) - zx_obs.append(lx); zy_obs.append(ly) - # Point labels via textalloc: a grid + candidate-box placer that tries slots on EVERY side of each - # marker and keeps the first that clears the obstacle grid -- so a label auto-takes the roomier side - # and never sits on its own marker (leader line only when it must reach). Obstacles = dots + sampled - # hull EDGES + the zone-label spots, so it dodges polygons and area names too. - sx = obs_x + [x for _, coords, _ in zone_specs for x, _ in coords[::2]] + zx_obs - sy = obs_y + [y for _, coords, _ in zone_specs for _, y in coords[::2]] + zy_obs - ta.allocate_text(fig, ax, [s[0] for s in lab_specs], [s[1] for s in lab_specs], - [s[2] for s in lab_specs], x_scatter=sx, y_scatter=sy, textsize=9, - textcolor=[s[3] for s in lab_specs], linecolor="#aaa", linewidth=0.6, draw_lines=True) + if invert_x: # e.g. Self-expression on the LEFT. Flip BEFORE + ax.invert_xaxis() # placement so the pixel-space allocator sees the + ax.autoscale(False) # final orientation (else every label mirrors left). + # ONE placement pass for everything (see labelplace.allocate_labels). Each zone name is a REGION + # label whose candidate anchors are its whole densified hull perimeter -- so it seats itself in the + # emptiest open air outside the hull, no white box, no leader. Country/model/steer names are MARKER + # labels sitting adjacent to their point. hard_pts = dots + stars every label dodges; soft_pts = all + # hull edges, which only the region labels avoid (marker labels wear a white outline and may cross). + step = 0.02 * float(np.mean(P.max(0) - P.min(0))) + zone_perims = [densify_polygon(coords, step) for _, coords, _ in zone_specs] + soft_pts = np.vstack(zone_perims) if zone_perims else np.empty((0, 2)) + anchor_sets = zone_perims + [np.array([[s[0], s[1]]]) for s in lab_specs] + texts = [zn for zn, _, _ in zone_specs] + [s[2] for s in lab_specs] + colors = [zc for _, _, zc in zone_specs] + [s[3] for s in lab_specs] + weights = ["bold"] * len(zone_specs) + [s[4] for s in lab_specs] + fontsizes = [10.0] * len(zone_specs) + [9.0] * len(lab_specs) + styles = ["italic"] * len(zone_specs) + ["normal"] * len(lab_specs) + region = [True] * len(zone_specs) + [False] * len(lab_specs) + anchor_pad = [3.0] * len(zone_specs) + [s[5] for s in lab_specs] + allocate_labels(ax, anchor_sets, texts, colors, weights, np.array(list(zip(obs_x, obs_y))), + soft_pts=soft_pts, region=region, fontsizes=fontsizes, styles=styles, + anchor_pad=anchor_pad) _pole_signposts(ax, med_x, med_y, poles) - if invert_x: # e.g. put Self-expression on the LEFT - ax.invert_xaxis() ax.set_xticks([]); ax.set_yticks([]); ax.set_xlabel(""); ax.set_ylabel("") if models: # legend: one star swatch per lab family present from matplotlib.lines import Line2D