mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-09 11:27:22 +08:00
maps: extract shared _map_annotations, fail loud on textalloc
The zone/dot-colour/label-set policy was duplicated inline in plot_value_map and plot_ipsative_pca -- subtle plotting policy that would silently diverge between the two figures. One helper now, so they can't drift. Also drop the broad except-Exception textalloc fallbacks (textalloc is a hard maps dep; the fallback hid broken layouts) and a dead textalloc import in plot_ipsative_pca. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
+30
-57
@@ -152,6 +152,25 @@ def outlying_countries(P: np.ndarray, countries: list[str], n: int = 4) -> set[s
|
||||
return {countries[int(np.argmax(Pc[:, 0] * dx + Pc[:, 1] * dy))] for dx, dy in dirs}
|
||||
|
||||
|
||||
def _map_annotations(P: np.ndarray, countries: list[str], zones_all: dict[str, list[str]] | None,
|
||||
emphasize: set[str] | None, grey: str):
|
||||
"""Shared map policy for plot_value_map + plot_ipsative_pca -- one copy so the two renderers can't
|
||||
silently diverge: pick the 4 most-separate zones, colour each dot by its drawn zone (`grey` if
|
||||
ungrouped), and build label_set = landmarks (emphasize) + 4 corner outliers + one central
|
||||
representative per drawn zone. Returns (selected_zones, dot_cols, label_set)."""
|
||||
zones = select_spread_zones(P, countries, zones_all, 4) if zones_all else {}
|
||||
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)}
|
||||
reps = set()
|
||||
for ms in zones.values():
|
||||
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
|
||||
return zones, dot_cols, label_set
|
||||
|
||||
|
||||
def draw_zone_hulls(ax, P: np.ndarray, countries: list[str], zones: dict[str, list[str]],
|
||||
pad: float = 0.022) -> None:
|
||||
"""Economist-style zone outline: the tight CONVEX HULL of a zone's country-mean points, rounded
|
||||
@@ -218,16 +237,7 @@ def plot_value_map(display: str, countries: list[str], P: np.ndarray,
|
||||
import textalloc as ta
|
||||
zones_all, emph = zones_for(countries)
|
||||
emph = (emphasize or set()) | emph
|
||||
zones = select_spread_zones(P, countries, zones_all, 4)
|
||||
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), "#888888") for c in countries]
|
||||
cidx = {c: i for i, c in enumerate(countries)}
|
||||
reps = set()
|
||||
for ms in zones.values():
|
||||
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 = emph | outlying_countries(P, countries, 4) | reps
|
||||
zones, dot_cols, label_set = _map_annotations(P, countries, zones_all, emph, "#888888")
|
||||
|
||||
med_x, med_y = float(np.median(P[:, 0])), float(np.median(P[:, 1]))
|
||||
fig, ax = plt.subplots(figsize=(10.5, 8.5))
|
||||
@@ -372,21 +382,10 @@ def compass(ax_main, L: np.ndarray, labels: list[str], title: str = "compass",
|
||||
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)
|
||||
placed = False
|
||||
try: # textalloc spreads colliding tip labels
|
||||
import textalloc as ta
|
||||
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)
|
||||
placed = True
|
||||
except Exception:
|
||||
placed = False
|
||||
if not placed:
|
||||
for j, lab in enumerate(labels):
|
||||
x, y = L[j]; r = np.hypot(x, y)
|
||||
cax.text(x / r * (r + 0.07), y / r * (r + 0.07), lab.capitalize(), fontsize=7.5,
|
||||
fontweight="bold", color=color, ha="left" if x >= 0 else "right",
|
||||
va="bottom" if y >= 0 else "top", clip_on=False)
|
||||
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_title(title, fontsize=10, fontweight="bold", color=color, pad=3)
|
||||
|
||||
@@ -453,10 +452,6 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
variance dominates). An eigenvalue floor gives a 1- or 2-country zone a visible blob. The PCA is
|
||||
fit on the country means M; `respondents`/`haze` only scatter + set the crop. Returns the
|
||||
Figure."""
|
||||
try:
|
||||
import textalloc as ta
|
||||
except ImportError:
|
||||
ta = None
|
||||
_, Vt, var, mu, Pc = ipsative_pca(M) # fit on country means: between-country axes
|
||||
P = (M @ Pc - mu) @ Vt[:2].T
|
||||
cloud = haze if haze is not None else respondents # what we scatter + crop to (fit is separate)
|
||||
@@ -473,13 +468,11 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
Pi = (cloud @ Pc - mu) @ Vt[:2].T
|
||||
ax.scatter(Pi[:, 0], Pi[:, 1], s=4, c="#8f8a7e", alpha=0.14, edgecolors="none",
|
||||
zorder=1, rasterized=True)
|
||||
# Same clean treatment as the WVS map: draw only the 4 zones covering the most separate space, as
|
||||
# edge-only hulls, and colour each dot by its drawn zone (grey if ungrouped).
|
||||
sel_zones = select_spread_zones(P, countries, zones, 4) if zones else {}
|
||||
# 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_of_c = {c: z for z, ms in sel_zones.items() for c in ms}
|
||||
dot_cols = [ZONE_COLORS.get(zone_of_c.get(c), C_HUM) for c in countries]
|
||||
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
|
||||
@@ -491,13 +484,6 @@ def plot_ipsative_pca(instr: Instrument, dims: list[str], countries: list[str],
|
||||
renderer = fig.canvas.get_renderer()
|
||||
placed_boxes = []
|
||||
emph = emphasize or set()
|
||||
cidx = {c: i for i, c in enumerate(countries)}
|
||||
reps = set()
|
||||
for ms in sel_zones.values():
|
||||
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 = emph | outlying_countries(P, countries, 4) | reps
|
||||
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:
|
||||
@@ -807,10 +793,7 @@ 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."""
|
||||
try:
|
||||
import textalloc as ta
|
||||
except ImportError:
|
||||
ta = None
|
||||
import textalloc as ta
|
||||
n = len(dims)
|
||||
ncol = min(3, n)
|
||||
nrow = (n + ncol - 1) // ncol
|
||||
@@ -849,18 +832,8 @@ 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 [])
|
||||
placed = False
|
||||
if ta is not None:
|
||||
try:
|
||||
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")
|
||||
placed = True
|
||||
except Exception:
|
||||
placed = False
|
||||
if not placed:
|
||||
for x, y, t in zip(tx, ty, txt):
|
||||
ax.annotate(t, (x + (0.12 if x >= 0 else -0.12), y), fontsize=6.5,
|
||||
ha="left" if x >= 0 else "right", va="center", color="#333333")
|
||||
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")
|
||||
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