62f6592ef6
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
273 lines
16 KiB
JavaScript
273 lines
16 KiB
JavaScript
// 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 };
|
||
}
|