Files
Aura/crates/aura-cli/assets/graph-viewer.js
T
Brummel 0ae8320d14 feat(aura): surface explicit node instance name in the graph viewer
A leaf primitive built with `.named("fast")` now renders its `aura graph` viewer
box head as `fast: SMA[length]` — the instance name as a `:` declaration prefix.
An unnamed leaf keeps the bare `SMA[length]`.

Engine half: a new raw `PrimitiveBuilder::instance_name() -> Option<&str>` (the
explicit name, default not resolved) feeds a conditional leading `"name"` field
in prim_record, present only for an explicitly-named node; both golden twins
(inline model_golden + sample-model.json) re-captured. Viewer half: adaptNodes
carries `name`, the genDot leaf-emit forwards it, and cellLabel prepends the
`name: ` prefix when present (composites stay bare). `named()`'s non-empty
debug_assert is unchanged, its doc re-grounded to the now load-bearing Some/None
invariant (knob-address segment + prefix switch).

The name is a render/model-only debug symbol — dropped at lowering (C23), and the
model stays deterministic (C14). Parameter ganging (one knob, several nodes) was
considered and spun off as an explicit composite-shared-param idea (#61), not via
name collision (param_space is injective, C12/C19).

closes #58
2026-06-13 19:02:16 +02:00

233 lines
14 KiB
JavaScript
Raw 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, 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,
};
}
return {
root: { inputs: [], outputs: [], nodes: adaptNodes(model.root.nodes), edges: model.root.edges },
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>`;
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 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.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, 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.
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 ============================================
// 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 — are exported below for headless testing).
if (typeof document !== "undefined") {
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); });
}
// Headless export: the node DOT-id guard requires this file (after setting
// global.window) and exercises the pure generator. No effect in the browser.
if (typeof module !== "undefined" && module.exports) {
module.exports = { normalizeModel, genDot };
}