Files
Brummel 62f6592ef6 feat(cli): surface the composite doc in the graph viewer (#125)
The viewer threads the model's doc through normalizeModel into both
composite view states — appended to INFO.B (collapsed box) and INFO.C
(expanded cluster frame) — and shows the root composite's doc as a muted
header line (#rootdoc span in GRAPH_HEAD, empty when absent; the DOM
population sits in the browser-only block, the same boundary as the
breadcrumb). viewer_tooltip.mjs pins the doc'd cases beside the
byte-exact un-doc'd pins; sample-model.json gains the additive doc key;
e2e drives aura graph over a nested-doc blueprint into the emitted page.

closes #125
2026-07-11 20:18:32 +02:00

273 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// aura graph viewer — ported from docs/design/prototypes/graph-render/index.html.
// A render asset (C10): no node/strategy/experiment logic, no DSL. Reads aura's
// emitted model (window.AURA_MODEL, injected by render_html) and draws a
// homogeneous pin-graph via Graphviz-WASM. genDot/cellLabel/chrome are the
// prototype verbatim; normalizeModel adapts aura's real tuple shape (post-0027
// named inputs) to the viewer's native shape.
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";
// === adapt aura's emitted model to the viewer's native shape =================
// aura input tuples are [kind, firing, name]; the viewer wants [name, kind, meta?]
// (non-"any" firing surfaces in the tooltip). outs/params are already [name, kind].
// aura composite inputs are [kind, firing, name]; the viewer wants [name, kind].
// node keys (indices / "src_<role>") and edge endpoints pass through unchanged.
function adaptIns(ins) {
return ins.map(([kind, firing, name]) =>
firing && firing !== "any" ? [name, kind, { firing }] : [name, kind]);
}
function adaptNodes(nodes) {
const out = {};
for (const key in nodes) {
const n = nodes[key];
if (n.prim) {
const p = n.prim;
out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, bound: p.bound || [], ins: adaptIns(p.ins), outs: p.outs } };
} else {
out[key] = n; // composite reference { comp }
}
}
return out;
}
function normalizeModel(model) {
const composites = {};
for (const cname in (model.composites || {})) {
const c = model.composites[cname];
composites[cname] = {
inputs: c.inputs.map(([kind, firing, name]) => [name, kind]),
outputs: c.outputs,
nodes: adaptNodes(c.nodes),
edges: c.edges,
gangs: c.gangs || [], // #61: the scope's gang table, empty for a gang-free scope
doc: c.doc, // #125: authored rationale (undefined when absent)
};
}
return {
root: {
inputs: [], outputs: [],
nodes: adaptNodes(model.root.nodes), edges: model.root.edges,
gangs: model.root.gangs || [],
doc: model.root.doc, // #125: the blueprint-level rationale
},
composites,
};
}
// In the browser `window.AURA_MODEL` is injected by render_html; under a node
// test harness the caller sets `global.window = { AURA_MODEL: <model> }` before
// requiring this file. The guard keeps a bare require (no window) from throwing.
const MODEL = (typeof window !== "undefined" && window.AURA_MODEL)
? normalizeModel(window.AURA_MODEL)
: { root: { inputs: [], outputs: [], nodes: {}, edges: [] }, composites: {} };
const COMP = MODEL.composites;
const ROOT = MODEL.root;
// === 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>`;
// merge free params and bound params back into slot order by original position;
// a bound slot renders as a dimmed name=value (reuses the 0035 separator grey).
const bnd = o.bound || [];
const total = o.params.length + bnd.length;
const byPos = new Map(bnd.map(([pos, n, k, v]) => [pos, [n, k, v]]));
// a ganged open param renders with its public knob name so the fusion is
// visible on the node card: "length (gang: channel_length)"
const gangFor = (pos) =>
(o.gangs || []).find((g) => g.members.some(([n, p]) => n === o.nodeIndex && p === pos));
const slots = [];
let fi = 0;
for (let pos = 0; pos < total; pos++) {
if (byPos.has(pos)) {
const [n, , v] = byPos.get(pos);
slots.push(`<font color="#9399b2">${n}=${v}</font>`);
} else {
const [n, k] = o.params[fi++];
const gang = gangFor(pos);
const label = txt(n, k) + (gang ? `<font color="#9399b2"> (gang: ${gang.name})</font>` : "");
slots.push(`<font color="${col(k)}">${label}</font>`);
}
}
const sig = total ? `<font color="#9399b2">[</font>${slots.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
const prefix = o.name
? `<font color="#cdd6f4">${o.name}</font><font color="#9399b2">: </font>`
: "";
const name = `${prefix}<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.doc ? `\n${o.doc}` : "");
(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) {
// `n`-prefix keeps cid a valid DOT identifier: model node keys are numeric
// indices (C23), and a bare `0__0` (digit-led, non-numeral) is misparsed by
// Graphviz — it collapses inline-expanded composites onto a phantom node.
const inst = def.nodes[key], cpath = [...path, key], cid = "n" + cpath.join("__");
if (inst.prim) {
const s = inst.prim;
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false, nodeIndex: Number(key), gangs: def.gangs || [] }, 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>`
+ (cdef.doc ? `\n${cdef.doc}` : "");
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, doc: cdef.doc });
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.
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 ============================================
// `md` is a pure string transform (no DOM access) hoisted above the document
// guard so it can be exported for headless testing (see module.exports below);
// this is a location-only move, not a behaviour change — it is still called
// exactly the same way from `showTip` inside the browser-only block.
const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`(.+?)`/g, "<code>$1</code>").replace(/\n/g, "<br>");
// The DOM bootstrap runs only in the browser. Under a node test harness there is
// no `document`/`Viz`, so the whole interaction block is skipped (the pure parts
// — normalizeModel, genDot, md — are exported below for headless testing).
if (typeof document !== "undefined") {
const tip = document.getElementById("tip");
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");
// #125: the root composite's authored rationale as a muted header line;
// stays empty when the blueprint carries no doc.
const rootdocEl = document.getElementById("rootdoc");
if (rootdocEl && ROOT.doc) rootdocEl.textContent = ROOT.doc;
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); });
}
// Headless export: the node DOT-id guard requires this file (after setting
// global.window) and exercises the pure generator. No effect in the browser.
// `md` is included solely so the tooltip guard (viewer_tooltip.mjs) can pin its
// markdown-to-HTML conversion; it is unchanged, just now reachable headlessly.
if (typeof module !== "undefined" && module.exports) {
module.exports = { normalizeModel, genDot, md };
}