mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-10 12:14:54 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user