mirror of
https://github.com/wassname/anz-2040-draft.git
synced 2026-08-20 12:10:40 +08:00
105 lines
5.5 KiB
Python
105 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Build tree.svg from data/tree.json. No dependencies beyond stdlib, no JS in the
|
|
output. Layout is automatic from each item's `tier` (row) and `order` (left-to-right),
|
|
so there are no hand-tuned coordinates to drift. Box width fits its title and subtitle;
|
|
probability mass is shown by edge width and explicit percentage labels. Every item is an <a> into its prose section and carries a
|
|
<title> tooltip. The viewBox makes it scale to any screen width (phones included).
|
|
-- claude (opus) + wassname, 2026-07"""
|
|
import json, math, html, pathlib
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
d = json.loads((ROOT / "data" / "tree.json").read_text())
|
|
KINDS = d["kinds"]
|
|
CV = d["canvas"]
|
|
W = CV["w"]
|
|
FLOOR, PSCALE, NH, GAP = CV["floor_w"], CV["p_scale"], CV["node_h"], CV["gap"]
|
|
CHOICE_W, ROW_H, TOP = CV["choice_w"], CV["row_h"], CV["top"]
|
|
TITLE = d.get("title", "")
|
|
TITLE_H = 82 if TITLE else 0 # hero heading band at the top (ai-2040 "Choose a Path" style)
|
|
|
|
def width(n):
|
|
# box fits its text (probability is carried honestly by the flow-weighted edges and
|
|
# the explicit % labels, not by node size, which can't be honest at variable text length)
|
|
fit = max(len(n["title"]) * 7.3, len(n.get("sub", "")) * 5.7) + 26
|
|
return max(fit, CHOICE_W)
|
|
|
|
# ---- layout: place each tier's nodes left-to-right, centred on the canvas ----
|
|
tiers = {}
|
|
for n in d["nodes"]:
|
|
tiers.setdefault(n["tier"], []).append(n)
|
|
pos = {} # id -> (cx, cy, w)
|
|
for t, row in tiers.items():
|
|
row.sort(key=lambda n: n["order"])
|
|
widths = [width(n) for n in row]
|
|
total = sum(widths) + GAP * (len(row) - 1)
|
|
x = (W - total) / 2
|
|
cy = TOP + TITLE_H + t * ROW_H
|
|
for n, w in zip(row, widths):
|
|
pos[n["id"]] = (x + w / 2, cy, w)
|
|
x += w + GAP
|
|
|
|
H = TOP + TITLE_H + (max(tiers) + 1) * ROW_H
|
|
out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H:.0f}" '
|
|
f'font-family="Georgia, serif" font-size="14">',
|
|
f'<rect width="{W}" height="{H:.0f}" fill="#fffff8"/>']
|
|
if TITLE:
|
|
out.append(f'<text x="{W/2:.0f}" y="{TOP+42:.0f}" text-anchor="middle" font-size="28" '
|
|
f'font-weight="bold" fill="#111">{html.escape(TITLE)}</text>')
|
|
|
|
def edge_pts(a, b):
|
|
ax, ay, aw = pos[a]; bx, by, bw = pos[b]
|
|
if by > ay: # child below: leave bottom, enter top
|
|
return ax, ay + NH / 2, bx, by - NH / 2
|
|
if by < ay: # child above (back-edge): leave top, enter bottom
|
|
return ax, ay - NH / 2, bx, by + NH / 2
|
|
# same row: side to side
|
|
return (ax + aw / 2, ay, bx - bw / 2, by) if bx > ax else (ax - aw / 2, ay, bx + bw / 2, by)
|
|
|
|
FLOWSCALE = 26 # edge thickness = flow (probability mass through the edge) * this
|
|
edge_labels = []
|
|
for e in d["edges"]:
|
|
x1, y1, x2, y2 = edge_pts(e["from"], e["to"])
|
|
my = (y1 + y2) / 2
|
|
sw = max(1.0, e.get("flow", 0.02) * FLOWSCALE) # thick where the mass pours, thin in the trickle
|
|
dash = ' stroke-dasharray="5 5"' if e.get("dashed") else ""
|
|
out.append(f'<path d="M{x1:.0f},{y1:.0f} C{x1:.0f},{my:.0f} {x2:.0f},{my:.0f} {x2:.0f},{y2:.0f}" '
|
|
f'fill="none" stroke="#a9a99f" stroke-width="{sw:.1f}"{dash}/>')
|
|
if e.get("label") and not e.get("dashed"): # dashed (secondary) edges stay unlabelled
|
|
# place the label near the source (in the gap just below the parent) so labels on
|
|
# tier-skipping edges don't land on top of the boxes in the row they cross
|
|
# adjacent edges: label at the midpoint (siblings have spread apart there, so they
|
|
# don't overlap). tier-skipping edges: push the label near the source, into the gap
|
|
# below the parent, so it doesn't land on the boxes in the row it crosses.
|
|
lf = 0.30 if abs(y2 - y1) > ROW_H else 0.5
|
|
edge_labels.append((x1 + lf * (x2 - x1), y1 + lf * (y2 - y1) - 2, e["label"]))
|
|
# draw labels last, each on a background halo so no stroke cuts through the text
|
|
for lx, ly, lab in edge_labels:
|
|
lw = len(lab) * 6.6 + 10
|
|
out.append(f'<rect x="{lx-lw/2:.0f}" y="{ly-12:.0f}" width="{lw:.0f}" height="17" fill="#fffff8"/>')
|
|
out.append(f'<text x="{lx:.0f}" y="{ly:.0f}" text-anchor="middle" fill="#555" font-size="12.5" '
|
|
f'font-style="italic">{html.escape(lab)}</text>')
|
|
|
|
for n in d["nodes"]:
|
|
cx, cy, w = pos[n["id"]]
|
|
k = KINDS[n["kind"]]
|
|
x, y = cx - w / 2, cy - NH / 2
|
|
rx = 22 if n["kind"] == "outcome" else 4 # pill endings, square choices/risks
|
|
sw = 3.6 if n["kind"] == "hazard" else 2.2 # thicker borders so the category colour reads; heaviest on risks
|
|
tip = html.escape(f'{n["title"]} — {n["sub"]}' if n.get("sub") else n["title"])
|
|
out.append(f'<a href="{n["href"]}"><title>{tip}</title>')
|
|
# ai-2040 style: box fill matches the page background so only the category-coloured
|
|
# border shows (pure white would read as a patch on the cream page)
|
|
out.append(f'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{NH}" rx="{rx}" '
|
|
f'fill="#fffff8" stroke="{k["stroke"]}" stroke-width="{sw}"/>')
|
|
ty = cy + (4 if not n.get("sub") else -3)
|
|
out.append(f'<text x="{cx:.0f}" y="{ty:.0f}" text-anchor="middle" font-weight="bold" '
|
|
f'font-size="13">{html.escape(n["title"])}</text>')
|
|
if n.get("sub"):
|
|
out.append(f'<text x="{cx:.0f}" y="{cy+13:.0f}" text-anchor="middle" fill="#333" '
|
|
f'font-size="11">{html.escape(n["sub"])}</text>')
|
|
out.append('</a>')
|
|
|
|
out.append('</svg>')
|
|
(ROOT / "tree.svg").write_text("\n".join(out))
|
|
print(f"wrote tree.svg: {len(d['nodes'])} nodes, {len(d['edges'])} edges, canvas {W}x{H:.0f}")
|