mirror of
https://github.com/wassname/moral-maps.git
synced 2026-08-20 12:30:40 +08:00
maps: route every map through labelplace; drop textalloc + adjustText deps
plot_ipsative_pca (society codes + zone names + base/steer) and plot_range_zoom now use labelplace.allocate_labels, the same placer as the WVS/value maps, so all maps read alike: zone names hug their hull, points sit adjacent, short leader only when crowded. Labels are collected then placed AFTER the crop so the pixel-space allocator sees the final window. The compass rose is deterministic radial-outward (a rose is not a map -- the nearest-clear-slot search fought the radial layout). No map imports textalloc now. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
+1
-3
@@ -18,10 +18,8 @@ dependencies = [
|
||||
# numeric-only consumers (steering-lite) skip this. Install with `pip install tiny-mfv[maps]`.
|
||||
maps = [
|
||||
"matplotlib>=3.8",
|
||||
"textalloc>=1.2.3", # non-overlapping label placement on the maps
|
||||
"shapely>=2.0", # union of per-country discs into one merged zone region
|
||||
"adjusttext>=1.3",
|
||||
]
|
||||
] # label placement is in-repo (tinymfv.labelplace); no textalloc/adjustText dep
|
||||
# tinymfv.read_api (sampling readout for logprob-less API models) only. Install with
|
||||
# `pip install tiny-mfv[api]`. The local logprob reader (read.py) needs none of this.
|
||||
api = [
|
||||
|
||||
+58
-62
@@ -163,12 +163,15 @@ def _map_annotations(P: np.ndarray, countries: list[str], zones_all: dict[str, l
|
||||
zone_of_c = {c: z for z, ms in zones.items() for c in ms}
|
||||
dot_cols = [ZONE_COLORS.get(zone_of_c.get(c), grey) for c in countries]
|
||||
cidx = {c: i for i, c in enumerate(countries)}
|
||||
emph = emphasize or set()
|
||||
reps = set()
|
||||
for ms in zones.values():
|
||||
if emph & set(ms): # zone already has a landmark labelled -> no rep
|
||||
continue # (else e.g. Macau SAR reps East Asia next to Japan/China)
|
||||
mem = [c for c in ms if c in cidx]
|
||||
mp = P[[cidx[c] for c in mem]]
|
||||
reps.add(mem[int(np.argmin(np.hypot(*(mp - mp.mean(0)).T)))])
|
||||
label_set = (emphasize or set()) | outlying_countries(P, countries, 4) | reps
|
||||
label_set = emph | outlying_countries(P, countries, 4) | reps
|
||||
return zones, dot_cols, label_set
|
||||
|
||||
|
||||
@@ -351,21 +354,9 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray,
|
||||
anchor_pad=anchor_pad)
|
||||
_pole_signposts(ax, med_x, med_y, poles)
|
||||
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
|
||||
fams, seen_f = [], set()
|
||||
for k in mnames: # keep map order, dedupe to one entry per family
|
||||
low = k.lower()
|
||||
fam = next((f for f in MODEL_FAMILY_COLORS if f in low), None)
|
||||
if fam and fam not in seen_f:
|
||||
seen_f.add(fam)
|
||||
fams.append((fam, MODEL_FAMILY_COLORS[fam]))
|
||||
handles = [Line2D([], [], marker="*", linestyle="none", markerfacecolor=col,
|
||||
markeredgecolor="white", markersize=12, label=fam) for fam, col in fams]
|
||||
handles.append(Line2D([], [], marker="o", linestyle="none", markerfacecolor="#8f8a80",
|
||||
markeredgecolor="white", markersize=8, label=f"{len(countries)} societies"))
|
||||
ax.legend(handles=handles, loc="upper left", fontsize=8, frameon=False,
|
||||
borderaxespad=0.6, handletextpad=0.3, labelspacing=0.3, ncol=2).set_zorder(11)
|
||||
# NO legend: each model family already has a directly-placed, family-coloured label on the map, so a
|
||||
# swatch legend would just duplicate that ink (Tufte eraser test). Grey dots read as societies from
|
||||
# the labelled examples (Sweden, Japan...); the count lives in the README caption.
|
||||
# Title + caption are OFF by default -- the README carries the headline + sources (nicer voice
|
||||
# there than baked jargon). Pass title/note only for a standalone figure.
|
||||
if title:
|
||||
@@ -465,18 +456,21 @@ def compass(ax_main, L: np.ndarray, labels: list[str], title: str = "compass",
|
||||
cax = ax_main.inset_axes(list(box))
|
||||
cax.patch.set_facecolor("#faf8f2"); cax.patch.set_alpha(0.92) # opaque: sits in the padded legend strip
|
||||
cax.add_patch(plt.Circle((0, 0), circle_r, fill=False, color="#bbbbbb", lw=0.7))
|
||||
tx, ty, tips_x, tips_y = [], [], [], []
|
||||
for j, lab in enumerate(labels):
|
||||
x, y = L[j]
|
||||
for x, y in L:
|
||||
cax.annotate("", xy=(x, y), xytext=(0, 0), arrowprops=dict(arrowstyle="->", color=color, lw=1.1))
|
||||
r = np.hypot(x, y)
|
||||
tx.append(x / r * (r + 0.07)); ty.append(y / r * (r + 0.07)); tips_x.append(x); tips_y.append(y)
|
||||
cax.set_xlim(-1.5, 1.5); cax.set_ylim(-1.5, 1.5)
|
||||
import textalloc as ta # textalloc spreads colliding tip labels
|
||||
ta.allocate_text(ax_main.figure, cax, tx, ty, [l.capitalize() for l in labels],
|
||||
x_scatter=tips_x + [0], y_scatter=tips_y + [0], textsize=7.5,
|
||||
linecolor=color, linewidth=0.5, textcolor=color, draw_lines=True)
|
||||
cax.set_aspect("equal"); cax.axis("off")
|
||||
cax.set_aspect("equal")
|
||||
# A compass is a RADIAL layout, not a map -- the general placer's 'nearest clear slot' fights the
|
||||
# rose. Each factor name sits just beyond its own arrow tip, pushed straight OUT from the origin,
|
||||
# with outward ha/va so long names lean away from the centre. -- authored by Claude
|
||||
for (x, y), lab in zip(L, labels):
|
||||
r = np.hypot(x, y) or 1.0
|
||||
ux, uy = x / r, y / r
|
||||
ha = "left" if ux > 0.25 else "right" if ux < -0.25 else "center"
|
||||
va = "bottom" if uy > 0.25 else "top" if uy < -0.25 else "center"
|
||||
cax.text(x + ux * 0.16, y + uy * 0.16, lab.capitalize(), fontsize=7, color=color,
|
||||
ha=ha, va=va, zorder=10)
|
||||
cax.axis("off")
|
||||
cax.set_title(title, fontsize=10, fontweight="bold", color=color, pad=3)
|
||||
|
||||
|
||||
@@ -561,32 +555,19 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
# Same clean treatment as the WVS map (shared policy via _map_annotations): draw only the 4 zones
|
||||
# covering the most separate space, as edge-only hulls, and colour each dot by its drawn zone.
|
||||
sel_zones, dot_cols, label_set = _map_annotations(P, countries, zones, emphasize, C_HUM)
|
||||
if sel_zones:
|
||||
draw_zone_hulls(ax, P, countries, sel_zones)
|
||||
zone_specs = draw_zone_hulls(ax, P, countries, sel_zones, label=False) if sel_zones else []
|
||||
ax.scatter(P[:, 0], P[:, 1], s=26, c=dot_cols, alpha=0.75, edgecolors="white", linewidths=0.5, zorder=3)
|
||||
# Society labels: each name/ISO code is pinned RIGHT NEXT to its dot (small fixed offset, no
|
||||
# leader line). A label is dropped if its box would collide with an already-placed one -- better an
|
||||
# omitted code than one flung far from its point. `emphasize` countries are placed FIRST (so they
|
||||
# win contested space) and drawn bold+dark, so the named outliers always survive the drop.
|
||||
# Label only the landmarks (emphasize) + the 4 most-outlying + one representative (most-central
|
||||
# member) per drawn zone -- the same de-clutter rule as the WVS map, so no map letters all N dots.
|
||||
fig.canvas.draw()
|
||||
renderer = fig.canvas.get_renderer()
|
||||
placed_boxes = []
|
||||
# Labels go through labelplace.allocate_labels (the same placer as the WVS/value maps), so the
|
||||
# ipsative map reads alike: zone names hug their hull, society codes sit adjacent to their dot with
|
||||
# a short leader only when crowded, emphasize (landmark) countries are bold+dark. Collect the specs
|
||||
# here; PLACE them after the crop below, so the pixel-space allocator sees the final axis window.
|
||||
# Label only landmarks (emphasize) + 4 most-outlying + one central rep per drawn zone (de-clutter).
|
||||
emph = emphasize or set()
|
||||
order_lr = [i for i in np.argsort(P[:, 0]) if countries[i] in label_set] # leftmost wins contested space
|
||||
order = [i for i in order_lr if countries[i] in emph] + [i for i in order_lr if countries[i] not in emph]
|
||||
for i in order:
|
||||
is_e = countries[i] in emph
|
||||
t = ax.annotate(countries[i], (P[i, 0], P[i, 1]),
|
||||
fontsize=8.5 if is_e else 7, color="#111111" if is_e else "#555555",
|
||||
fontweight="bold" if is_e else "normal",
|
||||
xytext=(3, 2), textcoords="offset points", zorder=7 if is_e else 6)
|
||||
bb = t.get_window_extent(renderer)
|
||||
if any(bb.overlaps(b) for b in placed_boxes):
|
||||
t.remove()
|
||||
else:
|
||||
placed_boxes.append(bb)
|
||||
mk_specs = [(P[i, 0], P[i, 1], countries[i],
|
||||
"#111111" if countries[i] in emph else "#555555",
|
||||
"bold" if countries[i] in emph else "normal",
|
||||
8.5 if countries[i] in emph else 7.0, 5.0) # (x,y,text,colour,weight,fontsize,pad)
|
||||
for i in range(len(countries)) if countries[i] in label_set]
|
||||
for key, col, pt in [("base", C_BASE, pb), ("honest", C_HON, ph), ("dis", C_DIS, pf)]:
|
||||
if boots and key in boots and pt is not None:
|
||||
bp = (np.asarray(boots[key]) @ Pc - mu) @ Vt[:2].T
|
||||
@@ -594,10 +575,10 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
ax.errorbar(pt[0], pt[1], xerr=e1, yerr=e2, fmt="none", ecolor=col,
|
||||
elinewidth=0.7, alpha=0.55, capsize=2.5, capthick=0.8, zorder=4)
|
||||
base_lab, pos_lab, neg_lab = labels
|
||||
pt_specs = list(mk_specs) # (x,y,text,colour,weight,fontsize,pad); placed post-crop
|
||||
if pb is not None:
|
||||
ax.scatter(*pb, s=72, c=C_BASE, marker="o", edgecolors="white", linewidths=1.0, zorder=7)
|
||||
ax.annotate(base_lab, pb, xytext=(9, -13), textcoords="offset points", fontsize=9,
|
||||
color=C_BASE, fontweight="bold", ha="left", va="center", zorder=8)
|
||||
pt_specs.append((pb[0], pb[1], base_lab, C_BASE, "bold", 9.0, 8.0))
|
||||
traj_pts = None
|
||||
if traj:
|
||||
inco = traj_incoherent or set()
|
||||
@@ -621,17 +602,13 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
linewidths=1.25, zorder=6)
|
||||
if c == c_end:
|
||||
lab = pos_lab if c > 0 else neg_lab
|
||||
dxy, ha = ((9, 9), "left") if c > 0 else ((-9, -1), "right")
|
||||
ax.annotate(lab, p, xytext=dxy, textcoords="offset points", fontsize=9,
|
||||
color=col, fontweight="bold", ha=ha, va="center", zorder=8)
|
||||
pt_specs.append((p[0], p[1], lab, col, "bold", 9.0, 8.0))
|
||||
else:
|
||||
for pt, col, lab, dxy, ha in [(ph, C_HON, pos_lab, (9, 9), "left"),
|
||||
(pf, C_DIS, neg_lab, (-9, -1), "right")]:
|
||||
for pt, col, lab in [(ph, C_HON, pos_lab), (pf, C_DIS, neg_lab)]:
|
||||
if pt is None:
|
||||
continue
|
||||
ax.scatter(*pt, s=42, c=col, marker="o", edgecolors="white", linewidths=1.0, zorder=7)
|
||||
ax.annotate(lab, pt, xytext=dxy, textcoords="offset points", fontsize=9, color=col,
|
||||
fontweight="bold", ha=ha, va="center", zorder=8)
|
||||
pt_specs.append((pt[0], pt[1], lab, col, "bold", 9.0, 8.0))
|
||||
# Crop to the SOCIETIES + steer anchors for EVERY instrument (the human cloud is far wider and would
|
||||
# bury them in a central blob; it stays a clipped backdrop). Then PAD THE BOTTOM to reserve a clean
|
||||
# strip for the legend insets -- deterministic placement, identical on every plot, no overlap with
|
||||
@@ -650,6 +627,24 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
dview = (x0, x1, y0, y1); sy = y1 - y0
|
||||
ax.set_xlim(dview[0], dview[1])
|
||||
ax.set_ylim(dview[2] - PAD_B * sy, dview[3])
|
||||
# ONE placement pass, now that the axis window is final (see labelplace.allocate_labels). Zone names
|
||||
# are REGION labels hugging their hull; society codes + base/steer are MARKER labels. hard_pts = all
|
||||
# society dots (+ steer anchors); soft_pts = hull edges (only zone names dodge them).
|
||||
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))
|
||||
steer_pts = [p for p in (pb, ph, pf) if p is not None] + ([traj_pts] if traj_pts is not None else [])
|
||||
hard_pts = np.vstack([P] + [np.atleast_2d(p) for p in steer_pts]) if steer_pts else P
|
||||
anchor_sets = zone_perims + [np.array([[s[0], s[1]]]) for s in pt_specs]
|
||||
allocate_labels(
|
||||
ax, anchor_sets,
|
||||
[zn for zn, _, _ in zone_specs] + [s[2] for s in pt_specs],
|
||||
[zc for _, _, zc in zone_specs] + [s[3] for s in pt_specs],
|
||||
["bold"] * len(zone_specs) + [s[4] for s in pt_specs], hard_pts, soft_pts=soft_pts,
|
||||
region=[True] * len(zone_specs) + [False] * len(pt_specs),
|
||||
fontsizes=[10.0] * len(zone_specs) + [s[5] for s in pt_specs],
|
||||
styles=["italic"] * len(zone_specs) + ["normal"] * len(pt_specs),
|
||||
anchor_pad=[3.0] * len(zone_specs) + [s[6] for s in pt_specs])
|
||||
# legend strip along the padded bottom: minimap bottom-right (it reads like a small map), compass
|
||||
# bottom-left. Both insets are opaque (set in compass()/_minimap) so the faint haze stays behind them.
|
||||
if cloud is not None:
|
||||
@@ -883,7 +878,6 @@ def plot_range_zoom(instr: Instrument, dims: list[str], cs: list[float], prof: d
|
||||
"""Zoomed companion: one subplot per factor with its OWN y-axis, so the steer (small vs the
|
||||
human spread) is legible. Societies near the steer named; off-range extremes in the corners.
|
||||
Returns the Figure."""
|
||||
import textalloc as ta
|
||||
n = len(dims)
|
||||
ncol = min(3, n)
|
||||
nrow = (n + ncol - 1) // ncol
|
||||
@@ -922,8 +916,10 @@ def plot_range_zoom(instr: Instrument, dims: list[str], cs: list[float], prof: d
|
||||
txt = [("c=0" if c == 0 else f"c={c:+g}") for c in cs] + list(named)
|
||||
dot_x = [xs] * len(cs) + (list(soc_x) if near else [])
|
||||
dot_y = list(map(float, yv)) + ([v for _, v in near] if near else [])
|
||||
ta.allocate_text(fig, ax, tx, ty, txt, x_scatter=dot_x, y_scatter=dot_y,
|
||||
textsize=6.5, linecolor="#bbbbbb", linewidth=0.4, textcolor="#333333")
|
||||
allocate_labels(ax, [np.array([[x, y]]) for x, y in zip(tx, ty)], txt,
|
||||
["#333333"] * len(txt), ["normal"] * len(txt),
|
||||
np.column_stack([dot_x, dot_y]) if dot_x else np.empty((0, 2)),
|
||||
fontsize=6.5, anchor_pad=[4.0] * len(txt), linecolor="#bbbbbb", linewidth=0.4)
|
||||
mx_name, mx_val = max(soc, key=lambda t: t[1])
|
||||
mn_name, mn_val = min(soc, key=lambda t: t[1])
|
||||
if mx_val > yhi:
|
||||
|
||||
Reference in New Issue
Block a user