mirror of
https://github.com/wassname/anz-2040-draft.git
synced 2026-09-09 11:17:24 +08:00
ANZ 2040 draft: decision-tree site (prose + inline probability-flow SVG)
This commit is contained in:
@@ -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 = "[](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)")
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user