ANZ 2040 draft: decision-tree site (prose + inline probability-flow SVG)

This commit is contained in:
wassname
2026-07-11 17:29:53 +08:00
commit 93d095e856
49 changed files with 24521 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Build index.html from anz-2040.md. One static file, no build step on GitHub's side
and no runtime JavaScript. Prose goes through pandoc (gfm, so it matches GitHub's own
rendering and keeps quotes verbatim); the linked tree.svg image is replaced by the same
SVG inlined into the page, so its <a> links to
each section and <title> tooltips work. CSS is hand-rolled tufte, borrowing ai-2040's
palette (global.css is a Tailwind *source* file and can't be served as-is).
-- claude (opus) + wassname, 2026-07"""
import re, subprocess, pathlib
ROOT = pathlib.Path(__file__).resolve().parent.parent
md = (ROOT / "anz-2040.md").read_text()
# Claude: Strip internal credit and local-evidence notes from the public page.
md = re.sub(r"<!--.*?-->", "", md, flags=re.DOTALL)
# Claude: Inline the linked SVG so its links work in the standalone page.
tree_link = "[![ANZ 2040: choices and possible futures](tree.svg)](tree.svg)"
assert md.count(tree_link) == 1, f"expected exactly one tree link, found {md.count(tree_link)}"
md = md.replace(tree_link, "@@TREE@@")
body = subprocess.run(
["pandoc", "-f", "gfm", "-t", "html", "--wrap=none"],
input=md, capture_output=True, text=True, check=True,
).stdout
svg = (ROOT / "tree.svg").read_text()
# Claude: Crash when an SVG link no longer matches a prose heading.
heading_ids = set(re.findall(r'<h[23] id="([^"]+)"', body))
svg_targets = set(re.findall(r'href="#([^"]+)"', svg))
missing_targets = svg_targets - heading_ids
assert not missing_targets, f"tree.svg links to missing headings: {sorted(missing_targets)}"
body = body.replace("<p>@@TREE@@</p>", f'<figure class="tree">{svg}</figure>')
CSS = """
:root { --bg:#fffff8; --fg:#111; --muted:#666; --rule:#ccc; --accent:#2A623D; }
html { font-size: 17px; }
body { max-width: 42em; margin: 4rem auto; padding: 0 1.25rem;
background: var(--bg); color: var(--fg);
font-family: Georgia, 'Times New Roman', serif; line-height: 1.55; }
h1,h2,h3 { font-weight: normal; line-height: 1.2; margin: 2.2rem 0 0.6rem; }
h1 { font-size: 2rem; } h2 { font-size: 1.5rem; border-bottom: 1px solid var(--rule); padding-bottom: .2rem; }
h3 { font-size: 1.2rem; color: #333; }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
blockquote { margin: 1rem 0 1rem 1.2rem; padding-left: 1rem; border-left: 3px solid var(--rule);
color: #333; font-size: .95rem; }
table { border-collapse: collapse; margin: 1.2rem 0; font-size: .92rem; }
th,td { text-align: left; padding: .35rem .8rem; border-bottom: 1px solid var(--rule); }
thead th { border-bottom: 2px solid #999; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: .88em; }
/* the tree is a tall, narrow two-per-row flow; cap its width and centre it so the text
stays a legible size (a wide canvas stretched to the column would shrink the text) */
.tree { margin: 1.5rem auto; max-width: 560px; overflow-x: auto; }
.tree svg { width: 100%; height: auto; display: block; }
hr { border: none; border-top: 1px solid var(--rule); margin: 2.5rem 0; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--rule);
color: var(--muted); font-size: .85rem; }
"""
HTML = f"""<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ANZ 2040: the decisions we still get to make</title>
<style>{CSS}</style>
</head>
<body>
{body}
<footer>Built by claude (fable 5) and wassname from the
<a href="https://ai-2040.com">AI 2040 / Plan A</a> supplements. Source and mirrors in the
<a href="sources/">repository</a>.</footer>
</body>
</html>
"""
(ROOT / "index.html").write_text(HTML)
print(f"wrote index.html ({len(HTML)} bytes), inlined tree.svg ({len(svg)} chars)")
+104
View File
@@ -0,0 +1,104 @@
#!/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}")