inspector / js /graph.js
lysandre's picture
lysandre HF Staff
Deploy architecture inspector
ed5700a verified
Raw
History Blame Contribute Delete
28.3 kB
// graph.js — SVG renderer + pan/zoom/select for the computed Layout.
import { patternColor } from "./ir.js";
const SVG_NS = "http://www.w3.org/2000/svg";
// The HF kernels hub — a kernel (e.g. flash-attn) can be loaded to accelerate
// attention. Shown as a badge on attention nodes when flash_attention is supported.
const KERNELS_URL = "https://huggingface.co/kernels-community";
// Visual style per known edge kind (kept in sync with the sidebar legend).
const EDGE_STYLE = {
data: { color: "#f97316", dash: null, label: "Data" },
residual: { color: "#0ea5e9", dash: "2 5", label: "Residual" },
mask: { color: "#8b5cf6", dash: "6 4", label: "Mask" },
position: { color: "#10b981", dash: "6 4", label: "Position" },
cross_attention: { color: "#ec4899", dash: "8 4", label: "Cross-Attention" },
cache_read: { color: "#06b6d4", dash: "1 4", label: "Cache read" },
cache_write: { color: "#f59e0b", dash: "1 4", label: "Cache write" },
};
// Colours for kinds not in the known set (e.g. cache, route, dataflow), picked
// deterministically so a given kind always renders the same colour.
const FALLBACK_PALETTE = ["#eab308", "#22d3ee", "#f472b6", "#a78bfa", "#84cc16", "#fb7185", "#60a5fa"];
function styleForKind(kind) {
if (EDGE_STYLE[kind]) return EDGE_STYLE[kind];
let hash = 0;
for (const c of String(kind)) hash = (hash * 31 + c.charCodeAt(0)) >>> 0;
const label = String(kind).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return { color: FALLBACK_PALETTE[hash % FALLBACK_PALETTE.length], dash: "3 4", label };
}
// Sanitize an edge kind for use in DOM ids/classes.
function cssId(s) {
return String(s).replace(/[^a-zA-Z0-9_-]/g, "_");
}
function el(tag, attrs = {}, ...children) {
const node = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
node.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
node.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return node;
}
// Centre of one side of a rect ("port"). Anchoring edges at fixed ports (rather
// than at the point facing the other box's centre) keeps fan-in/out tidy: every
// child exits its bottom-centre and enters the target's top-centre.
function port(rect, side) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
switch (side) {
case "top": return { x: cx, y: rect.y };
case "bottom": return { x: cx, y: rect.y + rect.h };
case "left": return { x: rect.x, y: cy };
default: return { x: rect.x + rect.w, y: cy }; // right
}
}
// A smooth cubic path between two rects. In a top-down layout, edges that cross
// layers must route vertically (bottom→top) regardless of horizontal offset —
// so we key off vertical separation, not the dominant axis. Horizontal ports
// are used only for genuine same-row siblings (overlapping vertical extents).
function edgePath(s, t) {
// Containment: a block feeding its own children (self_attn → q/k/v). Drop
// straight down from the container's top edge (above the child) into the
// child's top — reads as "the block's input fans out to these".
const inside = (a, b) => b.x >= a.x - 1 && b.y >= a.y - 1 && b.x + b.w <= a.x + a.w + 1 && b.y + b.h <= a.y + a.h + 1;
if (inside(s, t)) {
const cx = t.x + t.w / 2;
const k = Math.max(12, (t.y - s.y) * 0.4);
return `M ${cx} ${s.y} C ${cx} ${s.y + k}, ${cx} ${t.y - k}, ${cx} ${t.y}`;
}
if (inside(t, s)) {
const cx = s.x + s.w / 2;
const k = Math.max(12, (t.y + t.h - (s.y + s.h)) * 0.4);
return `M ${cx} ${s.y + s.h} C ${cx} ${s.y + s.h + k}, ${cx} ${t.y + t.h - k}, ${cx} ${t.y + t.h}`;
}
const gap = 4;
let p1, p2, axis;
if (t.y >= s.y + s.h - gap) {
axis = "v";
p1 = port(s, "bottom");
p2 = port(t, "top"); // target is below → downward flow
} else if (s.y >= t.y + t.h - gap) {
axis = "v";
p1 = port(s, "top");
p2 = port(t, "bottom"); // target is above → upward flow
} else {
axis = "h";
const toRight = t.x + t.w / 2 >= s.x + s.w / 2;
p1 = port(s, toRight ? "right" : "left");
p2 = port(t, toRight ? "left" : "right");
}
if (axis === "v") {
const k = Math.max(18, Math.abs(p2.y - p1.y) * 0.4);
const sgn = Math.sign(p2.y - p1.y) || 1;
return `M ${p1.x} ${p1.y} C ${p1.x} ${p1.y + sgn * k}, ${p2.x} ${p2.y - sgn * k}, ${p2.x} ${p2.y}`;
}
const k = Math.max(18, Math.abs(p2.x - p1.x) * 0.4);
const sgn = Math.sign(p2.x - p1.x) || 1;
return `M ${p1.x} ${p1.y} C ${p1.x + sgn * k} ${p1.y}, ${p2.x - sgn * k} ${p2.y}, ${p2.x} ${p2.y}`;
}
class Graph {
constructor(svg, callbacks = {}) {
this.svg = svg;
this.cb = callbacks;
this.tx = 0;
this.ty = 0;
this.scale = 1;
this.selectedId = null;
this.layout = null;
this.overrides = new Map(); // id -> {dx,dy} manual position nudges
this.disp = new Map(); // id -> displayed {x,y,w,h} (base + overrides)
this._raf = null;
this._defs();
this.viewport = el("g", { class: "viewport" });
this.svg.appendChild(this.viewport);
this._bindPointer();
}
// Clear all manual node positions.
clearOverrides() {
this.overrides.clear();
}
resetPositions() {
this.clearOverrides();
if (this.layout) this.render(this.layout, this.opts);
}
_defs() {
this.defs = el("defs");
this._markers = new Set();
this.svg.appendChild(this.defs);
Object.keys(EDGE_STYLE).forEach((k) => this._ensureMarker(k));
// Shared circular clip for org avatars (object-bounding-box → any size).
const clip = el(
"clipPath",
{ id: "avatar-clip", clipPathUnits: "objectBoundingBox" },
el("circle", { cx: "0.5", cy: "0.5", r: "0.5" })
);
this.defs.appendChild(clip);
}
// Lazily create an arrowhead marker for any edge kind (incl. unknown ones).
_ensureMarker(kind) {
const id = `arrow-${cssId(kind)}`;
if (this._markers.has(id)) return id;
this._markers.add(id);
const marker = el(
"marker",
{
id,
viewBox: "0 0 10 10",
refX: "9",
refY: "5",
markerWidth: "7",
markerHeight: "7",
orient: "auto-start-reverse",
},
el("path", { d: "M0,0 L10,5 L0,10 z", fill: styleForKind(kind).color })
);
this.defs.appendChild(marker);
return id;
}
// --- Pan / zoom -----------------------------------------------------------
_apply() {
this.viewport.setAttribute(
"transform",
`translate(${this.tx} ${this.ty}) scale(${this.scale})`
);
if (!this._suppressView && this.cb.onView) {
this.cb.onView({ tx: this.tx, ty: this.ty, scale: this.scale });
}
}
// Mirror another graph's view (for synced side-by-side panels).
setView(v) {
this._suppressView = true;
this.tx = v.tx;
this.ty = v.ty;
this.scale = v.scale;
this._apply();
this._suppressView = false;
}
_scheduleRender() {
if (this._raf) return;
const raf =
typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : (f) => f();
this._raf = raf(() => {
this._raf = null;
if (this.layout) this.render(this.layout, this.opts);
});
}
_bindPointer() {
// Either panning the canvas or dragging a single node/subtree.
let mode = null; // 'pan' | 'node'
let dragId = null;
let sx = 0;
let sy = 0;
let stx = 0;
let sty = 0;
let baseOverride = { dx: 0, dy: 0 };
let moved = false;
this.svg.addEventListener("pointerdown", (e) => {
if (e.target.closest("[data-toggle]")) return; // toggle handles itself
moved = false;
sx = e.clientX;
sy = e.clientY;
const nodeEl = e.target.closest("[data-node]");
if (nodeEl) {
mode = "node";
dragId = nodeEl.getAttribute("data-node");
const o = this.overrides.get(dragId) || { dx: 0, dy: 0 };
baseOverride = { dx: o.dx, dy: o.dy };
} else {
mode = "pan";
stx = this.tx;
sty = this.ty;
}
this.svg.classList.add("grabbing");
this.svg.setPointerCapture(e.pointerId);
});
this.svg.addEventListener("pointermove", (e) => {
if (!mode) return;
const ddx = e.clientX - sx;
const ddy = e.clientY - sy;
if (Math.abs(ddx) + Math.abs(ddy) > 3) moved = true;
if (mode === "pan") {
this.tx = stx + ddx;
this.ty = sty + ddy;
this._apply();
} else if (mode === "node" && moved) {
// Drag distance is in screen px; convert to graph units.
this.overrides.set(dragId, {
dx: baseOverride.dx + ddx / this.scale,
dy: baseOverride.dy + ddy / this.scale,
});
this._scheduleRender();
}
});
const end = (e) => {
if (mode === "node" && !moved && dragId != null) {
this.cb.onSelect && this.cb.onSelect(dragId);
}
mode = null;
dragId = null;
this.svg.classList.remove("grabbing");
try {
this.svg.releasePointerCapture(e.pointerId);
} catch (_) {}
};
this.svg.addEventListener("pointerup", end);
this.svg.addEventListener("pointercancel", end);
this.svg.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const rect = this.svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = Math.exp(-e.deltaY * 0.0015);
const next = Math.max(0.2, Math.min(2.5, this.scale * factor));
// Zoom toward cursor.
this.tx = mx - (mx - this.tx) * (next / this.scale);
this.ty = my - (my - this.ty) * (next / this.scale);
this.scale = next;
this._apply();
},
{ passive: false }
);
}
fit() {
if (!this.layout) return;
const rect = this.svg.getBoundingClientRect();
// Don't compute a transform while the SVG is hidden (0×0) — it would shrink
// the content to nothing and leave a blank view once shown again.
if (rect.width < 1 || rect.height < 1) return;
const margin = 48;
const sw = Math.max(1, rect.width - margin * 2);
const sh = Math.max(1, rect.height - margin * 2);
const s = Math.min(sw / this.layout.width, sh / this.layout.height, 1.4);
this.scale = Number.isFinite(s) && s > 0 ? s : 1;
this.tx = (rect.width - this.layout.width * this.scale) / 2;
this.ty = margin;
this._apply();
}
// --- Rendering ------------------------------------------------------------
// Accumulate manual overrides down the containment chain so dragging a
// container moves its whole subtree with it.
_computeDisp() {
this.disp = new Map();
const off = (id) => {
const r = this.layout.rectById.get(id);
const o = this.overrides.get(id) || { dx: 0, dy: 0 };
const p = r && r.parentId ? off(r.parentId) : { dx: 0, dy: 0 };
return { dx: o.dx + p.dx, dy: o.dy + p.dy };
};
for (const r of this.layout.placed) {
const o = off(r.id);
this.disp.set(r.id, { x: r.x + o.dx, y: r.y + o.dy, w: r.w, h: r.h });
}
}
// A copy of a placed rect with displayed (override-adjusted) coordinates.
_dr(r) {
const d = this.disp.get(r.id);
return { ...r, x: d.x, y: d.y };
}
render(layout, opts) {
this.layout = layout;
this.opts = opts;
this._computeDisp();
this.viewport.replaceChildren();
const frames = el("g", { class: "layer-frames" });
const edgesG = el("g", { class: "layer-edges" });
const nodesG = el("g", { class: "layer-nodes" });
for (const r of layout.placed) {
if (r.isRoot) continue;
if (r.kind === "container") frames.appendChild(this._container(this._dr(r)));
}
for (const e of layout.visibleEdges(opts.edgeKinds)) {
edgesG.appendChild(this._edge(e));
}
for (const r of layout.placed) {
if (r.isRoot || r.kind === "container") continue;
nodesG.appendChild(this._leaf(this._dr(r)));
}
// Edges last → on top of nodes, so a wire is never hidden behind an
// intervening block (you can trace it to its real target). The layer is
// non-interactive so nodes underneath stay clickable/draggable.
this.viewport.appendChild(frames);
this.viewport.appendChild(nodesG);
this.viewport.appendChild(edgesG);
this._applySelection();
}
_shared(id) {
return this.opts.sharedIds && this.opts.sharedIds.has(id) ? " shared" : "";
}
// Merged-diff origin class ("origin-a" / "origin-b"; "both" gets no class).
_origin(id) {
if (!this.opts.origin) return "";
const o = this.opts.origin.get(id);
return o === "a" || o === "b" ? ` origin-${o}` : "";
}
_container(r) {
const cls = (r.isRepeat ? "group group-repeat" : "group group-plain") + this._shared(r.id) + this._origin(r.id);
const g = el("g", { class: cls, "data-node": r.id });
if (r.isRepeat) {
const deck = this._deck(r, 12); // keep the depth impression when expanded
if (deck) g.appendChild(deck);
}
g.appendChild(
el("rect", {
class: "group-box",
x: r.x,
y: r.y,
width: r.w,
height: r.h,
rx: 12,
})
);
// Header bar.
g.appendChild(
el("rect", {
class: "group-header",
x: r.x,
y: r.y,
width: r.w,
height: r.headerH,
rx: 12,
})
);
const label = this.ir().label(r.id, this.opts.fields);
const sub = r.isRepeat ? null : this.ir().kindLabel(r.node && r.node.kind);
// With a schedule strip in the header, pin the title near the top; else centre.
const titleY = r.hasSchedule ? r.y + 17 : r.y + r.headerH / 2 + 4;
g.appendChild(
el(
"text",
{ class: "group-title", x: r.x + 12, y: titleY },
r.isRepeat ? label : label + (sub ? ` · ${sub}` : "")
)
);
if (r.hasSchedule) {
const sched = this.ir().scheduleForRepeat(r.node, this.opts.fields);
if (sched) g.appendChild(this._scheduleStrip(sched, r.x, r.y + r.headerH - 12, r.w));
}
const ck = r.node && r.node.kind;
if (ck === "attention" || ck === "cross_attention") {
const tags = this._backendTags(r.x + r.w, r.y + r.headerH / 2 - 8);
if (tags) g.appendChild(tags);
}
// Kernel badge for a kernelizable container (e.g. a MoE MLP).
if (this.opts.showKernels !== false) {
const kb = this._kernelBadge(r.node, r.x + r.w, r.y + r.headerH / 2 - 8);
if (kb) g.appendChild(kb);
}
if (r.isRepeat) g.appendChild(this._toggle(r, true));
return g;
}
_leaf(r) {
const kind = r.node ? r.node.kind : "input";
if (r.kind === "input") {
// Pseudo endpoint; class by prefix so state:* looks distinct from input:*.
const prefix = String(r.id).includes(":") ? String(r.id).split(":")[0] : "input";
const g = el("g", { class: `node node-pseudo node-pseudo-${cssId(prefix)}`, "data-node": r.id });
g.appendChild(
el("rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: r.h / 2 })
);
g.appendChild(
el(
"text",
{ class: "node-title", x: r.x + r.w / 2, y: r.y + r.h / 2 + 4, "text-anchor": "middle" },
this.ir().label(r.id, this.opts.fields)
)
);
return g;
}
const g = el("g", {
class: `node ${r.isRepeat ? "node-repeat" : "node-leaf"}${this._shared(r.id)}${this._origin(r.id)}`,
"data-node": r.id,
"data-kind": kind,
});
if (r.isRepeat) {
const deck = this._deck(r, 10); // stacked "deck" — depth = layers, coloured by schedule
if (deck) g.appendChild(deck);
}
g.appendChild(el("rect", { class: "node-box", x: r.x, y: r.y, width: r.w, height: r.h, rx: 10 }));
// Left accent stripe, inset past the box's corner radius so it stays inside.
g.appendChild(el("rect", { class: "kind-bar", x: r.x, y: r.y + 10, width: 4, height: Math.max(6, r.h - 20), rx: 2 }));
g.appendChild(el("circle", { class: "kind-dot", cx: r.x + 16, cy: r.y + 19, r: 4 }));
const title = this.ir().label(r.id, this.opts.fields);
g.appendChild(el("text", { class: "node-title", x: r.x + 28, y: r.y + 23 }, title));
const sub = this.ir().kindLabel(kind);
g.appendChild(el("text", { class: "node-sub", x: r.x + 28, y: r.y + 41 }, sub));
// Shape / attribute caption (from the IR) — the extra density.
if (r.info) {
g.appendChild(el("text", { class: "node-info", x: r.x + 28, y: r.y + 58 }, r.info));
}
if (r.hasSchedule) {
const sched = this.ir().scheduleForRepeat(r.node, this.opts.fields);
if (sched) g.appendChild(this._scheduleStrip(sched, r.x, r.y + r.h - 13, r.w));
}
if (kind === "attention" || kind === "cross_attention") {
const tags = this._backendTags(r.x + r.w, r.y + 8);
if (tags) g.appendChild(tags);
}
// Projection shard-direction glyph (top-right).
if (this.opts.showTP !== false && kind === "projection" && r.node.attributes && r.node.attributes.tp) {
g.appendChild(this._tpGlyph(r.node.attributes.tp, r.x + r.w - 20, r.y + 9));
}
// Kernel badge for any kernelizable leaf (RMSNorm, …).
if (this.opts.showKernels !== false) {
const kb = this._kernelBadge(r.node, r.x + r.w, r.y + 8);
if (kb) g.appendChild(kb);
}
// "tied" badge on the word embedding when weights are shared with the LM head.
if (this.opts.tied && this.opts.ir && this.opts.ir.isWordEmbedding(r.node)) {
g.appendChild(this._tiedBadge(r.x + r.w, r.y + 8));
}
if (r.isRepeat) g.appendChild(this._toggle(r, false));
return g;
}
// Right-aligned attention-backend tags for an attention node: the faster
// implementations that could replace this layer, plus a Kernels badge when
// Flash Attention is available (a kernel can be loaded to integrate it).
_backendTags(rightEdge, y) {
const caps = this.opts.ir && this.opts.ir.capabilities;
const backends = caps && caps.attention_backends;
if (!Array.isArray(backends) || !backends.length) return null;
const g = el("g", { class: "attn-tags" });
let cx = rightEdge - 10;
const tag = (label, cls, href) => {
const w = Math.round(12 + label.length * 6.0);
cx -= w;
const t = el("g", { class: `attn-tag ${cls}` });
t.appendChild(el("rect", { x: cx, y, width: w, height: 16, rx: 8 }));
t.appendChild(el("text", { class: "attn-tag-txt", x: cx + w / 2, y: y + 11.5, "text-anchor": "middle" }, label));
if (href) {
t.addEventListener("click", (ev) => {
ev.stopPropagation();
if (typeof window !== "undefined" && window.open) window.open(href, "_blank", "noopener");
});
}
g.appendChild(t);
cx -= 5;
};
["flex_attention", "flash_attention", "sdpa", "eager"]
.filter((b) => backends.includes(b))
.forEach((b) =>
tag(
b.startsWith("flash") ? "⚡ flash" : b.replace("_attention", ""),
b.startsWith("flash") ? "flash" : b === "eager" ? "muted" : "std"
)
);
return g;
}
// Unified ⚡ Kernels badge for any node the generator marked as kernelizable
// (attributes.kernel). Links to the kernel's Hub repo. Right-aligned at rightEdge.
_kernelBadge(node, rightEdge, y) {
const k = this.opts.ir && this.opts.ir.nodeKernel(node);
if (!k) return null;
const open = (href) => (ev) => {
ev.stopPropagation();
if (typeof window !== "undefined" && window.open) window.open(href, "_blank", "noopener");
};
const g = el("g", {});
const avatars = this.opts.avatars;
const orgs = [...new Set(k.repos.map((r) => String(r).split("/")[0]))];
// Org avatars (deduped), right-to-left.
let ax = rightEdge - 6;
for (const org of orgs) {
const url = avatars && avatars.get(org);
if (!url) continue;
ax -= 18;
const a = el("g", { class: "org-avatar" });
a.appendChild(el("title", {}, `${k.name} · ${org}`));
a.appendChild(el("image", { href: url, x: ax, y, width: 18, height: 18, "clip-path": "url(#avatar-clip)", preserveAspectRatio: "xMidYMid slice" }));
a.appendChild(el("circle", { class: "org-avatar-ring", cx: ax + 9, cy: y + 9, r: 9 }));
a.addEventListener("click", open(`https://huggingface.co/${org}`));
g.appendChild(a);
ax -= 4;
}
// Lightning bolt, always shown (left of the avatars).
ax -= 12;
const b = el("g", { class: "kernel-bolt-badge" });
b.appendChild(el("title", {}, `${k.name}${k.repos.length ? " · " + k.repos.join(", ") : ""}`));
b.appendChild(el("text", { x: ax + 6, y: y + 13, "text-anchor": "middle" }, "⚡"));
b.addEventListener("click", open(k.repos.length ? `https://huggingface.co/${k.repos[0]}` : KERNELS_URL));
g.appendChild(b);
return g;
}
// Tensor-parallel shard-direction glyph: vertical bars = column-parallel
// (output split), horizontal bars = row-parallel (input split).
_tpGlyph(tp, x, y) {
const g = el("g", { class: `tp-glyph tp-${tp}` });
g.appendChild(
el("title", {}, tp === "colwise" ? "column-parallel · splits output" : "row-parallel · splits input")
);
const size = 12;
const bar = 2.5;
const step = 4.5;
for (let i = 0; i < 3; i++) {
g.appendChild(
tp === "colwise"
? el("rect", { x: x + i * step, y, width: bar, height: size, rx: 1 })
: el("rect", { x, y: y + i * step, width: size, height: bar, rx: 1 })
);
}
return g;
}
// "🔗 tied" badge — the input embedding shares weights with the output head.
_tiedBadge(rightEdge, y) {
const label = "🔗 tied";
const w = Math.round(14 + label.length * 6.0);
const x = rightEdge - w - 8;
const g = el("g", { class: "tied-badge" });
g.appendChild(el("title", {}, "tied word embeddings — input embedding weights shared with the output (LM head)"));
g.appendChild(el("rect", { x, y, width: w, height: 16, rx: 8 }));
g.appendChild(el("text", { class: "attn-tag-txt", x: x + w / 2, y: y + 11.5, "text-anchor": "middle" }, label));
return g;
}
// A stacked "deck" behind a repeat block conveying its depth: one card per
// layer, offset down-right, coloured by the attention schedule when present
// (so sliding×5+full reads as 5 amber slivers then 1 blue) or neutral. Drawn
// back-to-front so nearer layers sit on top; the block box covers the rest.
_deck(r, rx) {
const geo = this.opts.ir && this.opts.ir.deckGeometry(r.node, this.opts.fields);
if (!geo || !geo.cards.length) return null;
const g = el("g", { class: "deck" });
// Back-to-front: deepest band first, so nearer (earlier) layers sit on top.
for (let i = geo.cards.length - 1; i >= 0; i--) {
const c = geo.cards[i];
g.appendChild(
el("rect", {
// Scheduled cards colour via inline fill; neutral ones via the class
// (a CSS `fill` rule would otherwise override the inline attribute).
class: c.pattern ? "deck-card" : "deck-card neutral",
x: r.x + c.off * 0.6,
y: r.y + c.off,
width: r.w,
height: r.h,
rx,
fill: c.pattern ? patternColor(c.pattern) : undefined,
})
);
}
return g;
}
// A row of per-layer attention-schedule cells. When there's room it prefixes
// an "attn/layer" label and appends a legend (swatch + pattern name) so the
// colours are self-explanatory; otherwise it just centres the bare cells.
_scheduleStrip(sched, x, y, w) {
const g = el("g", { class: "sched-strip" });
const n = sched.length;
const short = (p) => String(p).replace(/_attention$/, "");
const distinct = [...new Set(sched)];
const cw = 8;
const gap = 1;
const cellsW = n * cw + (n - 1) * gap;
const label = "attn/layer";
const labelW = label.length * 5.6 + 8;
const legendItemW = (p) => 11 + 4 + short(p).length * 6 + 12;
const legendW = distinct.reduce((s, p) => s + legendItemW(p), 0);
const full = labelW + cellsW + 10 + legendW;
const drawCells = (cx0) => {
let cx = cx0;
for (let i = 0; i < n; i++) {
const c = el("rect", { x: cx, y, width: cw - gap, height: 8, rx: 1.5, fill: patternColor(sched[i]) });
c.appendChild(el("title", {}, `layer ${i}: ${short(sched[i])}`));
g.appendChild(c);
cx += cw + gap;
}
return cx;
};
if (full + 16 <= w) {
// label · cells · legend, centred as a unit
let cur = x + (w - full) / 2;
g.appendChild(el("text", { class: "sched-strip-label", x: cur, y: y + 7.5 }, label));
cur += labelW;
cur = drawCells(cur) + 8;
for (const p of distinct) {
g.appendChild(el("rect", { x: cur, y: y - 1, width: 11, height: 10, rx: 2, fill: patternColor(p) }));
g.appendChild(el("text", { class: "sched-strip-label", x: cur + 15, y: y + 7.5 }, short(p)));
cur += legendItemW(p);
}
} else {
// narrow (collapsed block): bare centred cells with per-cell tooltips
const fitCw = Math.max(2, Math.min(9, (w - 20) / n));
const fg = fitCw > 4 ? 1 : 0;
const tw = n * fitCw + (n - 1) * fg;
let cx = x + (w - tw) / 2;
for (let i = 0; i < n; i++) {
const c = el("rect", { x: cx, y, width: Math.max(1, fitCw - fg), height: 8, rx: 1.5, fill: patternColor(sched[i]) });
c.appendChild(el("title", {}, `layer ${i}: ${short(sched[i])}`));
g.appendChild(c);
cx += fitCw + fg;
}
}
return g;
}
_toggle(r, expanded) {
const size = 18;
const cx = r.x + r.w - size / 2 - 8;
const cy = r.y + (expanded ? r.headerH / 2 : 16);
const g = el("g", { class: "toggle", "data-toggle": r.id });
g.appendChild(el("circle", { cx, cy, r: size / 2 }));
g.appendChild(
el("path", {
class: "toggle-icon",
d: expanded
? `M ${cx - 4} ${cy} H ${cx + 4}`
: `M ${cx - 4} ${cy} H ${cx + 4} M ${cx} ${cy - 4} V ${cy + 4}`,
})
);
g.addEventListener("click", (ev) => {
ev.stopPropagation();
this.cb.onToggle && this.cb.onToggle(r.id);
});
return g;
}
_edge(e) {
const s = this.disp.get(e.source);
const t = this.disp.get(e.target);
const d = edgePath(s, t);
const style = styleForKind(e.kind);
const markerId = this._ensureMarker(e.kind);
const observed = e.raw && e.raw.observed_forward;
// Merged-diff: colour edges that exist in only one model.
let originCls = "";
let stroke = style.color;
if (this.opts.edgeOrigin && e.raw) {
const o = this.opts.edgeOrigin.get(`${e.raw.source}|${e.raw.target}|${e.raw.kind}`);
if (o === "a" || o === "b") {
originCls = ` origin-${o}`;
stroke = o === "a" ? "#38bdf8" : "#f87171";
}
}
const g = el("g", {
class: `edge edge-${cssId(e.kind)}${observed ? " edge-observed" : ""}${originCls}`,
"data-src": e.source,
"data-dst": e.target,
});
// Background halo (reads as "passing over" a block) + the coloured line.
g.appendChild(el("path", { class: "edge-halo", d, fill: "none" }));
g.appendChild(
el("path", {
class: "edge-line",
d,
fill: "none",
stroke,
"stroke-dasharray": style.dash,
"marker-end": `url(#${markerId})`,
})
);
return g;
}
// --- Selection ------------------------------------------------------------
setSelected(id) {
this.selectedId = id;
this._applySelection();
}
_applySelection() {
this.viewport.querySelectorAll("[data-node]").forEach((n) => {
n.classList.toggle("selected", n.getAttribute("data-node") === this.selectedId);
});
this.viewport.querySelectorAll(".edge").forEach((ed) => {
const on =
this.selectedId &&
(ed.getAttribute("data-src") === this.selectedId ||
ed.getAttribute("data-dst") === this.selectedId);
ed.classList.toggle("edge-hi", !!on);
ed.classList.toggle("edge-dim", !!this.selectedId && !on);
});
}
ir() {
return this.opts.ir;
}
}
export { Graph, EDGE_STYLE, styleForKind };