From 8ef59aacc2312796aac0d26cdd353b6f642fb5e5 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:17:46 +0800 Subject: [PATCH] wvs map: textalloc (candidate placement) for point labels, hull-edge zones Switch point labels back from adjustText (force-relaxation, local minima, sometimes parks a label on its own marker) to textalloc, whose grid+candidate-box algorithm tries slots on every side of a marker and takes the roomier one, off the marker, with a leader only when it must reach. Keep _hull_label_pos (with outward ha/va) for zone labels so they sit just outside the emptiest arc of their hull. Feed textalloc the dots + sampled hull edges + zone-label spots so it dodges polygons too. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- src/tinymfv/maps.py | 58 +++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/tinymfv/maps.py b/src/tinymfv/maps.py index 96f02e9..6e9a21f 100644 --- a/src/tinymfv/maps.py +++ b/src/tinymfv/maps.py @@ -258,22 +258,24 @@ def model_family_color(name: str) -> str: def _hull_label_pos(coords: np.ndarray, center: np.ndarray, obstacles: list[tuple[float, float]], - span: np.ndarray, out: float = 0.018) -> tuple[float, float]: - """Place a zone label directly ON its hull's boundary, hugging the emptiest arc -- NO leader line. A - convex hull has plenty of perimeter, so rather than fling the label into open space with an arrow, - walk its boundary vertices, nudge each slightly OUTWARD (away from the plot centre so the text sits - just outside the edge), and keep the one whose NEAREST dot/label is farthest (distances normalised - by the data span so x/y crowding weigh equally). The label lands against an uncrowded stretch of - its own outline.""" - best, best_score = tuple(coords[0]), -np.inf + 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 = dmin, (cx, cy) - return best + 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, @@ -297,7 +299,7 @@ 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 - from adjustText import adjust_text + import textalloc as ta import matplotlib.patheffects as pe zones_all, emph = zones_for(countries) emph = (emphasize or set()) | emph @@ -350,23 +352,23 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray, span = P.max(0) - P.min(0) center = P.mean(0) obs_pts = list(zip(obs_x, obs_y)) - zone_texts, zplaced = [], [] - for zn, coords, zc in zone_specs: # label hugs the emptiest arc of its OWN hull edge - lx, ly = _hull_label_pos(coords, center, obs_pts + zplaced, span) - zplaced.append((lx, ly)) - zone_texts.append(ax.text(lx, ly, zn, color=zc, fontsize=10, fontweight="bold", fontstyle="italic", - ha="center", va="center", zorder=9, - path_effects=[pe.withStroke(linewidth=3.0, foreground="white")])) - # adjustText only understands points, so make it polygon-aware by adding sampled points along every - # hull EDGE to the obstacle cloud -- a country/model label then avoids sitting on a coloured hull line - # too, not just on a dot. - edge_x = obs_x + [x for _, coords, _ in zone_specs for x, _ in coords[::2]] - edge_y = obs_y + [y for _, coords, _ in zone_specs for _, y in coords[::2]] - texts = [ax.text(x, y, t, color=c, fontsize=fs, fontweight=fw, fontstyle=st, ha="center", - va="center", zorder=9, path_effects=[pe.withStroke(linewidth=2.5, foreground="white")]) - for x, y, t, c, fw, st, fs in lab_specs] - adjust_text(texts, x=edge_x, y=edge_y, ax=ax, objects=zone_texts, expand=(1.15, 1.4), - arrowprops=dict(arrowstyle="-", color="#aaa", lw=0.6)) + # 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) _pole_signposts(ax, med_x, med_y, poles) if invert_x: # e.g. put Self-expression on the LEFT ax.invert_xaxis()