docs(design): graph-render redesign prototype

Interactive, throwaway prototype of the aura graph render redesign — the
visual language ratified for cycle 0026, replacing the ascii-dag text view.
Homogeneous pin-nodes (body + n input pins + m output pins) rendered via
Graphviz-WASM in the browser; pin-to-pin edges, type-as-colour, drill-down +
inline-expand of composites, per-element styled tooltips, source/sink colouring,
top-rank input ordering. index.html is the artifact; the ~1.4 MB WASM dep is
git-ignored and restored by fetch-deps.sh. Visual companion to docs/specs/0026.
This commit is contained in:
2026-06-10 10:58:47 +02:00
parent 63af2d1095
commit 7f485bbe72
4 changed files with 323 additions and 0 deletions
@@ -0,0 +1,2 @@
viz-standalone.js
svg-pan-zoom.min.js
@@ -0,0 +1,54 @@
# graph-render prototype
An interactive, throwaway prototype of the **`aura graph` render redesign** — the
visual language ratified during the cycle that replaces the `ascii-dag` text view.
It is a *reference for the implementation*, not shippable code: the Rust serializer
in `aura-cli` should emit the DOT this prototype generates, and the shipped viewer
should mirror this harness.
## Run
```sh
./fetch-deps.sh # restore the two git-ignored vendored deps
python3 -m http.server 8000 --bind 0.0.0.0
# open http://<host>:8000/ in a browser
```
`index.html` is the artifact (all the design decisions live there). The vendored
deps are git-ignored because one is a ~1.4 MB WASM blob; `fetch-deps.sh` re-pins
them (`@viz-js/viz@3.7.0`, `svg-pan-zoom@3.6.1`).
## What it embodies (the ratified visual language)
- **Substrate.** aura emits **DOT**; layout + draw is **Graphviz compiled to
WebAssembly**, run in the browser (`@viz-js/viz`). No native binary, no layout
engine in aura — aura is a *serializer*, Graphviz does the rest. Runs on a thin
client (validated on a Chromebook over LAN).
- **Homogeneous node template.** Every node = body (type + param signature) + n
input pins + m output pins, generated from one template off the declared
interface (`NodeSchema`). Each pin/body is an individually addressable cell.
- **Pin-to-pin edges.** Edges dock output-pin → input-pin (DOT `node:port`), not
node-to-node.
- **Type as colour.** One colour per scalar base type (i64/f64/bool/timestamp) on
pins, params, and wires. The `: kind` text is off by default.
- **Navigation.** A composite is opaque with a left `[+]` button (inline-expand to
a framed cluster, recursively / nested) and a body click (drill-down to its
interior, with a breadcrumb).
- **Styled per-element tooltips.** A cursor-following div with mini-markdown: a pin
shows `name · kind` (+ firing), a body shows type + params, an edge shows the
kind, an expanded cluster shows the composite name.
- **Roles by colour + position.** Sources green, sinks maroon; composite-boundary
`in:`/`out:` markers render as sources/sinks. Sources/inputs are pinned to the
top rank in signature order; outputs to the bottom rank (free order); sinks float.
The canonical, prose record of these decisions is the cycle **spec** under
`docs/specs/` — this prototype is the visual companion to it.
## Graphviz gotchas baked in (carry into the implementation)
- Use a Graphviz **core font** (`Courier`) so HTML-label box sizing matches the
rendered text — a web font overflows the box (Graphviz lays out with its own
font metrics).
- Graphviz prefixes anchor (`href`) element ids with `a_` in the SVG.
- Strip Graphviz's native `<title>`/`xlink:title` (they pop up as browser tooltips)
and supply our own.
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Restore the vendored, re-downloadable render deps for the graph-render prototype.
# These are deliberately git-ignored (a ~1.4 MB WASM blob); index.html is the artifact.
set -euo pipefail
cd "$(dirname "$0")"
curl -sL https://unpkg.com/@viz-js/viz@3.7.0/lib/viz-standalone.js -o viz-standalone.js
curl -sL https://unpkg.com/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js -o svg-pan-zoom.min.js
echo "deps restored. serve with: python3 -m http.server 8000 --bind 0.0.0.0"
@@ -0,0 +1,259 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>aura graph — PoC (nested composites, multi-input)</title>
<style>
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
font-family: ui-monospace, monospace; }
header { padding: 8px 14px; border-bottom: 1px solid #313244; font-size: 13px;
display: flex; gap: 16px; align-items: baseline; flex-wrap: nowrap;
white-space: nowrap; overflow: hidden; }
header b { color: #f5e0dc; } .sub { color: #6c7086; }
#crumb a { color: #89b4fa; cursor: pointer; text-decoration: none; }
#crumb a:hover { text-decoration: underline; }
#crumb .sep { color: #6c7086; }
#status { color: #6c7086; margin-left: auto; }
#stage { width: 100%; height: calc(100% - 44px); }
#stage svg { width: 100%; height: 100%; cursor: default; }
#stage svg text { cursor: inherit; user-select: none; -webkit-user-select: none; }
#stage svg a { cursor: inherit; }
#err { padding: 14px; color: #f38ba8; white-space: pre-wrap; }
#tip { position: fixed; display: none; pointer-events: none; z-index: 10;
background: #1e1e2e; border: 1px solid #585b70; border-radius: 6px; padding: 6px 9px;
font-family: ui-monospace, monospace; font-size: 12px; color: #cdd6f4;
max-width: 420px; box-shadow: 0 6px 18px rgba(0,0,0,.55); line-height: 1.5; }
#tip b { color: #f5e0dc; } #tip code { color: #89b4fa; } #tip .dim { color: #7f849c; }
</style>
</head>
<body>
<header>
<b>aura graph</b>
<span id="crumb"></span>
<span class="sub">hover · [+] expand · body drill</span>
</header>
<div id="stage"></div>
<div id="tip"></div>
<pre id="err"></pre>
<script src="./viz-standalone.js"></script>
<script src="./svg-pan-zoom.min.js"></script>
<script>
const TYPE_COLOR = { i64: "#f9e2af", f64: "#89b4fa", bool: "#a6e3a1", timestamp: "#cba6f7" };
const COLOR_TYPE = Object.fromEntries(Object.entries(TYPE_COLOR).map(([k, v]) => [v.toLowerCase(), k]));
const PURPLE = "#5b4b8a", TEAL = "#2c5a5a", PLUS = "#7c6ba8";
const LEAF = "#45475a", SOURCE = "#2e5a3a", SINK = "#5a2e3e";
const bodyBgFor = (role) => role === "source" ? SOURCE : role === "sink" ? SINK : LEAF;
const HEAD = `bgcolor="#16161a"; rankdir=TB; nodesep=0.5; ranksep=0.65;
node [shape=plaintext, fontname="Courier", fontsize=12];
edge [penwidth=1.7, arrowsize=0.8];`;
const col = (k) => TYPE_COLOR[k] || "#cdd6f4";
const F = "f64", I = "i64";
// === hierarchical model: leaves, composites, the root harness ================
const P = (type, params, ins, outs, role) => ({ prim: { type, params, ins, outs, role: role || "node" } });
const C = (comp) => ({ comp });
// edge endpoints: "@role" (composite input) · "key.oN"/"key.iN" · "#N" (composite output)
const COMP = {
macd: {
inputs: [["price", F]], outputs: [["macd", F], ["signal", F], ["histogram", F]],
nodes: {
ema_f: P("EMA", [["fast", I]], [["price", F]], [["ema", F]]),
ema_s: P("EMA", [["slow", I]], [["price", F]], [["ema", F]]),
sub_m: P("Sub", [], [["a", F], ["b", F]], [["diff", F]]),
ema_sig: P("EMA", [["signal", I]], [["macd", F]], [["ema", F]]),
sub_h: P("Sub", [], [["a", F], ["b", F]], [["diff", F]]),
},
edges: [["@price", "ema_f.i0"], ["@price", "ema_s.i0"], ["ema_f.o0", "sub_m.i0"], ["ema_s.o0", "sub_m.i1"],
["sub_m.o0", "ema_sig.i0"], ["sub_m.o0", "sub_h.i0"], ["ema_sig.o0", "sub_h.i1"],
["sub_m.o0", "#0"], ["ema_sig.o0", "#1"], ["sub_h.o0", "#2"]],
},
vol_regime: { // multi-input composite: volume + price
inputs: [["volume", F], ["price", F]], outputs: [["regime", F]],
nodes: {
sma_v: P("SMA", [["window", I]], [["x", F]], [["avg", F]]),
mul: P("Mul", [], [["a", F], ["b", F]], [["p", F]]),
clamp: P("Clamp", [["lo", F], ["hi", F]], [["x", F]], [["y", F]]),
},
edges: [["@volume", "sma_v.i0"], ["@price", "mul.i0"], ["sma_v.o0", "mul.i1"], ["mul.o0", "clamp.i0"], ["clamp.o0", "#0"]],
},
strategy: { // multi-input + contains two composites (one of them multi-input)
inputs: [["price", F], ["volume", F]], outputs: [["exposure", F]],
nodes: { macd: C("macd"), vol: C("vol_regime"), gate: P("Mul", [], [["a", F], ["b", F]], [["exposure", F]]) },
edges: [["@price", "macd.i0"], ["@volume", "vol.i0"], ["@price", "vol.i1"],
["macd.o2", "gate.i0"], ["vol.o0", "gate.i1"], ["gate.o0", "#0"]],
},
};
const ROOT = {
inputs: [], outputs: [],
nodes: {
price: P("price", [], [], [["price", F]], "source"),
volume: P("volume", [], [], [["volume", F]], "source"),
strategy: C("strategy"),
broker: P("SimBroker", [["spread", F]], [["exposure", F], ["price", F, { firing: "barrier 0" }]], [["equity", F]]),
rec_e: P("Recorder", [], [["exposure", F]], [], "sink"),
rec_q: P("Recorder", [], [["equity", F]], [], "sink"),
},
edges: [["price.o0", "strategy.i0"], ["volume.o0", "strategy.i1"], ["price.o0", "broker.i1"],
["strategy.o0", "broker.i0"], ["strategy.o0", "rec_e.i0"], ["broker.o0", "rec_q.i0"]],
};
// === ONE homogeneous, per-cell-addressable node template =====================
function cellLabel(id, o, showTypes) {
const cols = Math.max(o.ins.length, o.outs.length, 1);
const span = (n) => Math.max(1, Math.floor(cols / n));
const txt = (n, k) => (showTypes && k) ? `${n} : ${k}` : n;
const cell = (io, arr) => ([n, k], i) =>
`<td port="${io}${i}" id="P_${id}_${io}${i}" href="#" colspan="${span(arr.length)}" ` +
`bgcolor="${io === "i" ? "#222b33" : "#332b22"}"><font color="${col(k)}">${txt(n, k)}</font></td>`;
const sp = o.params.map(([n, k]) => `<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
const sig = o.params.length ? `<font color="#9399b2">(</font>${sp.join('<font color="#9399b2">, </font>')}<font color="#9399b2">)</font>` : "";
const name = `<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
let bodyRow;
if (o.composite) {
bodyRow = `<tr><td colspan="${cols}" bgcolor="${o.bodyBg}"><table border="0" cellborder="0" cellspacing="3"><tr>` +
`<td id="plus_${id}" href="#" align="center" valign="middle" fixedsize="true" width="24" height="24" border="1" color="#a594c8" bgcolor="${PLUS}"><font color="#f5f5f5"><b>+</b></font></td>` +
`<td id="B_${id}" href="#">${name}</td></tr></table></td></tr>`;
} else {
bodyRow = `<tr><td id="B_${id}" href="#" colspan="${cols}" bgcolor="${o.bodyBg}">${name}</td></tr>`;
}
let rows = "";
if (o.ins.length) rows += `<tr>${o.ins.map(cell("i", o.ins)).join("")}</tr>`;
rows += bodyRow;
if (o.outs.length) rows += `<tr>${o.outs.map(cell("o", o.outs)).join("")}</tr>`;
return `<<table border="0" cellborder="1" cellspacing="0" cellpadding="6">${rows}</table>>`;
}
function addInfo(INFO, id, o) {
const ps = o.params && o.params.length ? o.params.map(([n, k]) => `\`${n}:${k}\``).join(", ") : "<span class='dim'>none</span>";
INFO.B[id] = `**${o.type}** <span class='dim'>· ${o.role || "node"}</span>\nparams: ${ps}`;
(o.ins || []).forEach(([n, k, m], i) => INFO.P[`P_${id}_i${i}`] = `**${n}** · \`${k}\`` + (m && m.firing ? `\nfiring: \`${m.firing}\`` : ""));
(o.outs || []).forEach(([n, k], i) => INFO.P[`P_${id}_o${i}`] = `**${n}** · \`${k}\``);
}
// === recursive DOT generator (the mini-inliner) ==============================
function genDot(def, role, expandedSet, showTypes) {
const INFO = { B: {}, P: {}, C: {} }, drill = {}, expandable = [], clusters = [], edges = [], topRank = [], bottomRank = [];
function build(path, def, brole) {
const childRes = {}; let block = "";
for (const key in def.nodes) {
const inst = def.nodes[key], cpath = [...path, key], cid = cpath.join("__");
if (inst.prim) {
const s = inst.prim;
block += `${cid} [label=${cellLabel(cid, { type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
addInfo(INFO, cid, s);
if (s.role === "source") topRank.push(cid); // sinks are placed freely (not rank-pinned)
childRes[key] = { type: "leaf", id: cid, spec: s };
} else {
const cname = inst.comp, cdef = COMP[cname];
if (expandedSet.has(cid)) {
const sub = build(cpath, cdef, "cluster"); clusters.push(cid);
INFO.C["clust_" + cid] = `**${cname}** <span class='dim'>· composite · click frame to collapse</span>`;
block += `subgraph cluster_${cid} { id="clust_${cid}"; style=filled; color="#5b4b8a"; fillcolor="#211c30"; penwidth=2;\n${sub.block}}\n`;
childRes[key] = { type: "exp", resolveIn: sub.resolveIn, resolveOut: sub.resolveOut };
} else {
block += `${cid} [label=${cellLabel(cid, { type: cname, params: [], ins: cdef.inputs, outs: cdef.outputs, bodyBg: PURPLE, composite: true }, showTypes)}];\n`;
addInfo(INFO, cid, { type: cname, role: "composite", params: [], ins: cdef.inputs, outs: cdef.outputs });
drill[cid] = cname; expandable.push(cid);
childRes[key] = { type: "opaque", id: cid, cdef };
}
}
}
const pk = (r, io, idx) => r.type === "leaf" ? (io === "i" ? r.spec.ins : r.spec.outs)[idx][1] : (io === "i" ? r.cdef.inputs : r.cdef.outputs)[idx][1];
const resTarget = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveIn(idx) : [{ id: r.id, port: "i" + idx, kind: pk(r, "i", idx) }]; };
const resSource = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveOut(idx) : { id: r.id, port: "o" + idx, kind: pk(r, "o", idx) }; };
const resolveIn = (i) => { const rn = "@" + def.inputs[i][0]; return def.edges.filter(e => e[0] === rn).flatMap(e => resTarget(e[1])); };
const resolveOut = (i) => { const e = def.edges.find(e => e[1] === "#" + i); return resSource(e[0]); };
for (const [f, t] of def.edges) { if (f[0] === "@" || t[0] === "#") continue; const s = resSource(f); for (const d of resTarget(t)) edges.push([s, d]); }
if (brole === "drill") {
def.inputs.forEach(([rn, rk], i) => { const bid = `bin_${i}`;
block += `${bid} [label=${cellLabel(bid, { type: rn, params: [], ins: [], outs: [[rn, rk]], bodyBg: SOURCE, composite: false }, showTypes)}];\n`;
addInfo(INFO, bid, { type: rn, role: "input", params: [], ins: [], outs: [[rn, rk]] });
topRank.push(bid);
resolveIn(i).forEach(d => edges.push([{ id: bid, port: "o0", kind: rk }, d])); });
def.outputs.forEach(([on, ok], i) => { const bid = `bout_${i}`;
block += `${bid} [label=${cellLabel(bid, { type: on, params: [], ins: [[on, ok]], outs: [], bodyBg: SINK, composite: false }, showTypes)}];\n`;
addInfo(INFO, bid, { type: on, role: "output", params: [], ins: [[on, ok]], outs: [] });
bottomRank.push(bid);
edges.push([resolveOut(i), { id: bid, port: "i0", kind: ok }]); });
}
return { block, resolveIn, resolveOut };
}
const top = build([], def, role);
let dot = `digraph g { ${HEAD}\n${top.block}`;
// annotation by position: sources/inputs share the top rank, outputs the bottom rank.
// Only the top row gets an invisible chain pinning its left-to-right order to
// signature order; the bottom row (outputs) is pinned to rank=max but ordered freely,
// and sinks are not rank-pinned at all (placed wherever the layout prefers).
const rankRow = (ids, r, chain) => ids.length
? `{rank=${r};${ids.map(id => " " + id + ";").join("")}${chain && ids.length > 1 ? " " + ids.join(" -> ") + " [style=invis];" : ""}}\n`
: "";
dot += rankRow(topRank, "min", true);
dot += rankRow(bottomRank, "max", false);
for (const [s, d] of edges) dot += `${s.id}:${s.port}:s -> ${d.id}:${d.port}:n [color="${TYPE_COLOR[s.kind] || "#7f849c"}"];\n`;
return { dot: dot + "}", info: INFO, drill, expandable, clusters };
}
// === styled tooltip + interaction ============================================
const tip = document.getElementById("tip");
const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`(.+?)`/g, "<code>$1</code>").replace(/\n/g, "<br>");
function showTip(text, x, y) {
tip.innerHTML = md(text); tip.style.display = "block";
const w = tip.offsetWidth, h = tip.offsetHeight;
let l = x + 14, t = y + 16;
if (l + w > innerWidth - 8) l = x - w - 14;
if (t + h > innerHeight - 8) t = y - h - 16;
tip.style.left = l + "px"; tip.style.top = t + "px";
}
const hideTip = () => { tip.style.display = "none"; };
const stage = document.getElementById("stage"), crumbEl = document.getElementById("crumb");
let viz, viewStack = [{ name: "root", def: ROOT }], expanded = new Set(), SHOW_TYPES = false, INFO = { B: {}, P: {}, C: {} };
function chrome() {
crumbEl.innerHTML = "";
viewStack.forEach((v, i) => {
if (i) { const s = document.createElement("span"); s.className = "sep"; s.textContent = " "; crumbEl.appendChild(s); }
const a = document.createElement("a"); a.textContent = v.name;
a.onclick = () => { viewStack = viewStack.slice(0, i + 1); expanded = new Set(); show(); };
crumbEl.appendChild(a);
});
}
function show() {
const v = viewStack[viewStack.length - 1];
const G = genDot(v.def, v.name === "root" ? "root" : "drill", expanded, SHOW_TYPES);
INFO = G.info;
stage.innerHTML = ""; hideTip();
const svg = viz.renderSVGElement(G.dot);
stage.appendChild(svg);
const pz = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, minZoom: 0.1, maxZoom: 12, dblClickZoomEnabled: false });
addEventListener("resize", () => { pz.resize(); pz.fit(); pz.center(); });
svg.querySelectorAll("title").forEach(t => t.remove());
svg.querySelectorAll("a").forEach(a => a.removeAttributeNS("http://www.w3.org/1999/xlink", "title"));
const idOf = (el) => { const a = el.closest && el.closest("a[id], g[id]"); return a ? a.id.replace(/^a_/, "") : ""; };
svg.addEventListener("mousemove", (e) => {
const id = idOf(e.target);
if (id.startsWith("P_") && INFO.P[id]) return showTip(INFO.P[id], e.clientX, e.clientY);
if (id.startsWith("B_")) { const k = id.slice(2); if (INFO.B[k]) return showTip(INFO.B[k], e.clientX, e.clientY); }
if (id.startsWith("plus_")) return showTip("**expand** inline", e.clientX, e.clientY);
if (id.startsWith("clust_") && INFO.C[id]) return showTip(INFO.C[id], e.clientX, e.clientY);
const eg = e.target.closest && e.target.closest("g.edge");
if (eg) { const p = eg.querySelector("path"); const c = p && p.getAttribute("stroke") ? p.getAttribute("stroke").toLowerCase() : ""; return showTip("`" + (COLOR_TYPE[c] || "?") + "`", e.clientX, e.clientY); }
hideTip();
});
svg.addEventListener("mouseleave", hideTip);
svg.addEventListener("click", (e) => { const a = e.target.closest && e.target.closest("a"); if (a) e.preventDefault(); });
const click = (id, fn) => { const el = svg.getElementById(id) || svg.getElementById("a_" + id); if (el) { el.style.cursor = "pointer"; el.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(); }); } };
for (const id in G.drill) click("B_" + id, () => { viewStack.push({ name: G.drill[id], def: COMP[G.drill[id]] }); expanded = new Set(); show(); });
for (const id of G.expandable) click("plus_" + id, () => { expanded.add(id); show(); });
for (const id of G.clusters) click("clust_" + id, () => { expanded.delete(id); show(); });
chrome();
}
Viz.instance().then(v => { viz = v; show(); }).catch(e => { document.getElementById("err").textContent = String(e && e.stack || e); });
</script>
</body>
</html>