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>
This commit is contained in:
wassname
2026-07-05 13:40:05 +08:00
co-authored by Claudypoo
parent 8ef59aacc2
commit 6b5a44f0f3
2 changed files with 186 additions and 53 deletions
+155
View File
@@ -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")])
+31 -53
View File
@@ -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