| |
|
|
| import { patternColor } from "./ir.js"; |
|
|
| const SVG_NS = "http://www.w3.org/2000/svg"; |
|
|
| |
| |
| const KERNELS_URL = "https://huggingface.co/kernels-community"; |
|
|
| |
| 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" }, |
| }; |
|
|
| |
| |
| 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 }; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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 }; |
| } |
| } |
|
|
| |
| |
| |
| |
| function edgePath(s, t) { |
| |
| |
| |
| 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"); |
| } else if (s.y >= t.y + t.h - gap) { |
| axis = "v"; |
| p1 = port(s, "top"); |
| p2 = port(t, "bottom"); |
| } 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(); |
| this.disp = new Map(); |
| this._raf = null; |
|
|
| this._defs(); |
| this.viewport = el("g", { class: "viewport" }); |
| this.svg.appendChild(this.viewport); |
|
|
| this._bindPointer(); |
| } |
|
|
| |
| 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)); |
| |
| const clip = el( |
| "clipPath", |
| { id: "avatar-clip", clipPathUnits: "objectBoundingBox" }, |
| el("circle", { cx: "0.5", cy: "0.5", r: "0.5" }) |
| ); |
| this.defs.appendChild(clip); |
| } |
|
|
| |
| _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; |
| } |
|
|
| |
|
|
| _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 }); |
| } |
| } |
|
|
| |
| 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() { |
| |
| let mode = null; |
| 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; |
| 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) { |
| |
| 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)); |
| |
| 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(); |
| |
| |
| 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(); |
| } |
|
|
| |
|
|
| |
| |
| _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 }); |
| } |
| } |
|
|
| |
| _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))); |
| } |
|
|
| |
| |
| |
| 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" : ""; |
| } |
|
|
| |
| _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); |
| 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, |
| }) |
| ); |
| |
| 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); |
| |
| 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); |
| } |
| |
| 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") { |
| |
| 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); |
| 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 })); |
| |
| 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)); |
| |
| 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); |
| } |
| |
| 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)); |
| } |
| |
| if (this.opts.showKernels !== false) { |
| const kb = this._kernelBadge(r.node, r.x + r.w, r.y + 8); |
| if (kb) g.appendChild(kb); |
| } |
| |
| 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; |
| } |
|
|
| |
| |
| |
| _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; |
| } |
|
|
| |
| |
| _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]))]; |
| |
| 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; |
| } |
| |
| 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; |
| } |
|
|
| |
| |
| _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; |
| } |
|
|
| |
| _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; |
| } |
|
|
| |
| |
| |
| |
| _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" }); |
| |
| for (let i = geo.cards.length - 1; i >= 0; i--) { |
| const c = geo.cards[i]; |
| g.appendChild( |
| el("rect", { |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| _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) { |
| |
| 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 { |
| |
| 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; |
| |
| 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, |
| }); |
| |
| 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; |
| } |
|
|
| |
|
|
| 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 }; |
|
|