summaryrefslogtreecommitdiff
path: root/graph/web
diff options
context:
space:
mode:
Diffstat (limited to 'graph/web')
-rw-r--r--graph/web/groups.js46
-rw-r--r--graph/web/index.html18
-rw-r--r--graph/web/layout.js161
-rw-r--r--graph/web/render.js71
-rw-r--r--graph/web/viewer.js82
5 files changed, 309 insertions, 69 deletions
diff --git a/graph/web/groups.js b/graph/web/groups.js
new file mode 100644
index 00000000..6ee4e091
--- /dev/null
+++ b/graph/web/groups.js
@@ -0,0 +1,46 @@
+// A display projection of a snapshot. Original nodes and edge slots stay intact.
+class GraphGroups {
+ static view(snap, folded) {
+ const raw = new Map(snap.nodes.map(n => [n.id, n]));
+ const groups = new Map((snap.groups || []).map(g => [g.id, {...g, members: new Set(g.nodes)}]));
+ const home = new Map(), cover = new Map();
+ for (const g of groups.values()) for (const id of g.nodes) home.set(id, g.id);
+ for (const g of groups.values()) {
+ for (let p = groups.get(g.par); p; p = groups.get(p.par))
+ for (const id of g.nodes) p.members.add(id);
+ }
+ for (const n of snap.nodes) {
+ let id = n.id;
+ for (let g = groups.get(home.get(id)); g; g = groups.get(g.par))
+ if (folded.has(g.id)) id = g.id;
+ cover.set(n.id, id);
+ }
+ const nodes = [], byId = new Map();
+ for (const n of snap.nodes) {
+ if (cover.get(n.id) !== n.id) continue;
+ const fold = groups.has(n.id) && folded.has(n.id);
+ const v = {...n, edges: [], fold: fold ? groups.get(n.id) : null};
+ if (fold) {
+ v.proj = null;
+ v.type = `${v.fold.members.size} nodes folded`;
+ }
+ nodes.push(v); byId.set(n.id, v);
+ }
+ for (const n of snap.nodes) for (const e of n.edges) {
+ const use = byId.get(cover.get(n.id)), def = cover.get(e.def) || 0;
+ if (use.fold && (!def || def === use.id)) continue;
+ use.edges.push({...e, idx: use.fold ? use.edges.length : e.idx, def,
+ orig: {use: n.id, def: e.def, idx: e.idx}});
+ }
+ // Expanded groups only; a folded group is represented by its header box.
+ const open = [...groups.values()].filter(g => !folded.has(g.id) && cover.get(g.id) === g.id);
+ const depth = g => {
+ let n = 0;
+ for (; g.par; g = groups.get(g.par)) n++;
+ return n;
+ };
+ // Paint outer containers before inner ones, even after headers are cloned.
+ open.sort((a,b) => depth(a) - depth(b) || a.id - b.id);
+ return {snap: {...snap, nodes}, raw, home, cover, open, groups};
+ }
+}
diff --git a/graph/web/index.html b/graph/web/index.html
index a568cbc6..909f7b1d 100644
--- a/graph/web/index.html
+++ b/graph/web/index.html
@@ -28,6 +28,12 @@
.node.near .box { stroke:#c18221; stroke-width:2; }
.node.focus .box { stroke:#9c4900; stroke-width:3; }
.node.selected .box { stroke:#1569b9; stroke-width:3; }
+ .node.folded .box { stroke-width:4; }
+ .region-box { stroke:#9da9b8; stroke-width:1.5; }
+ .fold-btn { cursor:pointer; }
+ .fold-btn rect { fill:white; stroke:#596674; }
+ .fold-btn text { font-size:14px; fill:#152330; }
+ .fold-btn:focus rect { stroke:#1569b9; stroke-width:2; }
.head { font-size:10px; fill:#596674; }
.label { font-size:12px; fill:#152330; font-weight:600; }
.type { font-size:11px; fill:#455768; }
@@ -38,13 +44,16 @@
#detail { position:absolute; top:8px; right:8px; margin:0; padding:12px; max-width:320px;
max-height:45%; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere;
background:#fffffff2; border:1px solid #bac5d0; border-radius:4px; font-size:12px; }
- .controls { display:flex; justify-content:center; align-items:center; gap:4px; padding:4px; }
+ .controls { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:6px; padding:6px; }
.button { color:white; background:black; font-size:20px; min-width:30px; }
.button:disabled { background:grey; }
.count { font-size:20px; min-width:35px; text-align:center; }
+ #N { width:6ch; }
+ #scrub { width:clamp(120px,35vw,600px); }
</style>
<script src="vendor/d3.v7.min.js"></script>
<script src="vendor/elk-api.js"></script>
+ <script src="groups.js"></script>
<script src="layout.js"></script>
<script src="render.js"></script>
</head>
@@ -58,6 +67,7 @@
<div class="tools">
<button id="fit" type="button" disabled>Fit</button>
<button id="save" type="button" disabled>Save SVG</button>
+ <button id="unfold" type="button" disabled>Unfold all</button>
<label><input id="assocs" type="checkbox"> Associations</label>
<label><input id="near" type="checkbox" checked> Peep neighborhood</label>
<span class="ctrl">Control</span><span class="data">Data</span><span class="mem">Memory</span>
@@ -67,11 +77,15 @@
</div>
<div id="graph"><div id="elk"></div><pre id="detail" hidden></pre></div>
<div class="controls">
+ <button id="doFirst" type="button" onclick="doFirst()" class="button" aria-label="First frame" title="First frame">|&#11164;</button>
<button id="doPrev" type="button" onclick="doPrev()" class="button" aria-label="Previous frame">&#11164;</button>
- <span id="N" class="count">0</span>
+ <label for="N">Frame</label>
+ <input id="N" class="count" type="number" min="1" max="1" step="1" value="0" disabled title="Enter a frame number">
<span>/</span>
<span id="len" class="count">0</span>
<button id="doNext" type="button" onclick="doNext()" class="button" aria-label="Next frame">&#11166;</button>
+ <button id="doLast" type="button" onclick="doLast()" class="button" aria-label="Last frame" title="Last available frame">&#11166;|</button>
+ <input id="scrub" type="range" min="1" max="1" step="1" value="1" disabled aria-label="Frame slider">
<button id="doExit" type="button" onclick="doExit()" class="button">Exit</button>
</div>
</div>
diff --git a/graph/web/layout.js b/graph/web/layout.js
index b192de87..53e26f53 100644
--- a/graph/web/layout.js
+++ b/graph/web/layout.js
@@ -11,9 +11,20 @@ class GraphLayout {
return chars.length > 44 ? chars.slice(0, 43).join("") + "…" : chars.join("");
}
- async run(snap, evt) {
+ async run(snap, evt, folded = new Set()) {
+ const view = GraphGroups.view(snap, folded);
+ snap = view.snap;
+ const raw = new Map(snap.nodes.map(n => [n.id, n]));
+ const outs = new Map();
+ for (const n of snap.nodes) for (const e of n.edges) if (raw.get(e.def)?.fold) {
+ if (!outs.has(e.def)) outs.set(e.def, new Set());
+ outs.get(e.def).add(e.orig.def);
+ }
+ // The whole-program SCCP cycle is not source control flow.
+ const hidden = (n, e) => ["START", "UNIT"].includes(n.kind) && raw.get(e.def)?.kind === "STOP";
const nodes = snap.nodes.map(n => {
const label = this.clip(n.label), type = this.clip(n.type);
+ const compact = ["START", "UNIT", "STOP"].includes(n.kind);
let w = Math.ceil(Math.max(120, this.ctx.measureText(label).width + 24,
this.ctx.measureText(type).width + 24, (n.edges.length + 1) * 18));
const scope = n.kind === "SCOPE";
@@ -21,8 +32,14 @@ class GraphLayout {
this.ctx.measureText(e.label || "[" + e.idx + "]").width + 24))) : [];
if (scope) w = Math.ceil(Math.max(120, this.ctx.measureText(label).width + 24,
cols.reduce((sum, w) => sum + w, 0)));
- return {id: "n" + n.id, width: w, height: 62,
- ports: [], n, label, type, scope, cols, stop: n.kind === "STOP", ox: 0, oy: 0};
+ if (compact) w = Math.ceil(Math.max(72, this.ctx.measureText(`#${n.id} ${label}`).width + 24,
+ (n.edges.filter(e => !hidden(n, e)).length + 1) * 18));
+ if (n.fold) {
+ const slot = Math.max(18, ...n.edges.map(e => this.ctx.measureText(`${e.orig.use}:${e.orig.idx}`).width + 12));
+ w = Math.ceil(Math.max(200, w + 32, (n.edges.length + 1) * slot, ((outs.get(n.id)?.size || 0) + 1) * 18));
+ }
+ return {id: "n" + n.id, width: w, height: compact ? 26 : 62,
+ ports: [], n, label, type, scope, cols, compact, stop: n.kind === "STOP", ox: 0, oy: 0};
});
const byId = new Map(nodes.map(n => [n.n.id, n]));
const parsing = evt?.phase === "Parse" && evt.kind !== "PHASE";
@@ -41,16 +58,16 @@ class GraphLayout {
for (const n of nodes) {
const par = byId.get(n.n.proj?.par);
// A projection without its parent still gets a standalone box.
- if (!par || par.n.proj) continue;
+ if (!par || par.n.proj || par.n.fold || n.n.fold) continue;
if (!projs.has(par)) projs.set(par, []);
projs.get(par).push(n);
n.par = par;
}
const phis = new Map();
for (const n of nodes) {
- if (n.n.kind !== "PHI") continue;
+ if (n.n.kind !== "PHI" || n.n.fold) continue;
const reg = byId.get(n.n.edges.find(e => e.idx === 0)?.def);
- if (!reg || !["REGION", "LOOP", "FUN"].includes(reg.n.kind)) continue;
+ if (!reg || reg.n.fold || !["REGION", "LOOP", "FUN"].includes(reg.n.kind)) continue;
if (!phis.has(reg)) phis.set(reg, []);
phis.get(reg).push(n);
n.reg = reg;
@@ -84,10 +101,11 @@ class GraphLayout {
(p.width - p.cols.reduce((sum, w) => sum + w, 0)) / p.cols.length : 0;
let x = 0;
for (const [idx, e] of p.n.edges.entries()) {
+ if (hidden(p.n, e)) continue;
// The parent/projection link is represented by the shared box.
if (p.par && e.idx === 0 && e.def === p.par.n.id) continue;
// Phi's Region input is a local left-facing marker, not a routed edge.
- if (p.n.kind === "PHI" && e.idx === 0) continue;
+ if (p.reg && e.idx === 0) continue;
if (p.scope) {
const span = p.cols[idx] + extra;
ports.push({id: p.id + "i" + e.idx, x: x + span / 2 - 3, y: -3, span,
@@ -104,52 +122,93 @@ class GraphLayout {
ports.push({id: p.id + "o", x: p.ox + (side === "EAST" ? p.width : p.width / 2) - 3,
y: p.oy + (side === "EAST" ? p.height / 2 : p.height) - 3,
width: 6, height: 6, layoutOptions: {"elk.port.side": side}});
+ const defs = [...(outs.get(p.n.id) || [])];
+ for (const [i, id] of defs.entries()) ports.push({id: p.id + "o" + id,
+ x: p.width * (i + 1) / (defs.length + 1) - 3, y: p.height - 3,
+ width: 6, height: 6, layoutOptions: {"elk.port.side": "SOUTH"}});
}
- return {id: n.id, width, height: n.height * (slots.length ? 2 : 1), ports, parts};
+ return {id: n.id, width, height: n.height + (slots.length ? Math.max(...slots.map(p => p.height)) : 0), ports, parts};
});
const edges = [];
for (const use of snap.nodes) {
for (const e of use.edges) {
- if (!e.def) continue;
- if (use.kind === "PHI" && e.idx === 0) continue;
+ if (!e.def || hidden(use, e)) continue;
+ if (byId.get(use.id).reg && e.idx === 0) continue;
if (byId.get(use.id).par && e.idx === 0 && e.def === use.proj.par) continue;
- const id = "e" + use.id + "i" + e.idx;
+ const id = "e" + e.orig.use + "i" + e.orig.idx;
const reg = byId.get(use.edges[0]?.def)?.n;
- const back = e.idx === 2 && (use.kind === "LOOP" ||
+ const back = !use.fold && e.idx === 2 && (use.kind === "LOOP" ||
(use.kind === "PHI" && reg?.kind === "LOOP"));
- edges.push({id, use: use.id, def: e.def, idx: e.idx, role: e.role, label: e.label,
+ edges.push({id, use: use.id, def: e.def, idx: e.orig.idx, orig: e.orig, role: e.role, label: e.label,
bind: use.kind === "SCOPE",
// Layout follows value/control flow downward. SVG arrows point back
// from use to def, matching Simple's actual edge direction.
- sources: ["n" + e.def + "o"], targets: ["n" + use.id + "i" + e.idx],
+ sources: ["n" + e.def + "o" + (byId.get(e.def).n.fold ? e.orig.def : "")], targets: ["n" + use.id + "i" + e.idx],
layoutOptions: {"elk.layered.priority.direction": back ? 0 : e.role === "CTRL" ? 10 : 1}});
}
}
- const graph = await this.elk.layout({
- id: "root", children: groups.filter(g => !g.parts[0].scope && !g.parts[0].stop).map(({parts, ...box}) => ({...box,
- layoutOptions: {"elk.portConstraints": "FIXED_POS",
- // Keep a projection with its parent even when that parent has real uses.
- "elk.layered.layering.layerConstraint": parts.length === 1 && parts[0].held ? "LAST_SEPARATE" : "NONE"}
- })),
+ const opts = {
+ "elk.algorithm": "layered", "elk.direction": "DOWN", "elk.edgeRouting": "ORTHOGONAL",
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
+ "elk.spacing.nodeNode": 32, "elk.layered.spacing.nodeNodeBetweenLayers": 48,
+ "elk.padding": "[top=24,left=24,bottom=24,right=24]",
+ "elk.separateConnectedComponents": held.length === 0,
+ // ELK 0.12's model-order presort fails on nested hierarchy edge dummies.
+ "elk.layered.considerModelOrder.strategy": view.open.length ? "NONE" : "NODES_AND_EDGES",
+ "elk.randomSeed": 1
+ };
+ const containers = new Map(view.open.map(g => [g.id, {id: "g" + g.id, children: [],
+ layoutOptions: {...opts, "elk.padding": "[top=58,left=28,bottom=28,right=28]"}}]));
+ const children = [];
+ const add = (par, box) => (containers.get(par)?.children || children).push(box);
+ for (const g of view.open) add(g.par, containers.get(g.id));
+ for (const {parts, ...box} of groups) {
+ const n = parts[0];
+ if (n.scope || n.stop) continue;
+ // The folded header replaces its own container inside the parent group.
+ const par = n.n.fold ? n.n.fold.par : view.home.get(n.n.id);
+ add(par, {...box, layoutOptions: {"elk.portConstraints": "FIXED_POS",
+ // LAST_SEPARATE conflicts with ELK's hierarchical boundary ports.
+ "elk.layered.layering.layerConstraint": parts.length === 1 && n.held ?
+ (view.open.length ? "LAST" : "LAST_SEPARATE") : "NONE"}});
+ }
+ const input = {
+ id: "root", children,
// Scope bindings and lifetime associations must not impose CFG ranks.
edges: edges.filter(e => e.role !== "ASSOC" && !e.bind && !byId.get(e.def).scope &&
!byId.get(e.use).stop && !byId.get(e.def).stop).map(e => ({
id: e.id, sources: e.sources, targets: e.targets, layoutOptions: e.layoutOptions
})),
- layoutOptions: {
- "elk.algorithm": "layered", "elk.direction": "DOWN", "elk.edgeRouting": "ORTHOGONAL",
- "elk.spacing.nodeNode": 32, "elk.layered.spacing.nodeNodeBetweenLayers": 48,
- "elk.padding": "[top=24,left=24,bottom=24,right=24]",
- "elk.separateConnectedComponents": held.length === 0,
- "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
- "elk.randomSeed": 1
- }
- });
+ layoutOptions: opts
+ };
+ // ELK expects an edge on the lowest container containing both endpoints.
+ const portBox = new Map(), parents = new Map();
+ const index = box => {
+ for (const p of box.ports || []) portBox.set(p.id, box);
+ for (const child of box.children || []) { parents.set(child, box); index(child); }
+ };
+ index(input);
+ const flow = input.edges;
+ input.edges = [];
+ for (const e of flow) {
+ const ancestors = new Set();
+ for (let p = parents.get(portBox.get(e.sources[0])); p; p = parents.get(p)) ancestors.add(p);
+ let p = parents.get(portBox.get(e.targets[0]));
+ while (!ancestors.has(p)) p = parents.get(p);
+ (p.edges ||= []).push(e);
+ }
+ const graph = await this.elk.layout(input);
// Place scopes and Stops after the flow graph; their ports stay local.
for (const g of groups.filter(g => g.parts[0].scope || g.parts[0].stop))
graph.children.push({...g, x: 0, y: 0});
- const boxes = new Map(graph.children.map(n => [n.id, n]));
- const routes = new Map((graph.edges || []).map(e => [e.id, e]));
+ const boxes = new Map(), routes = new Map();
+ const collect = (box, x, y) => {
+ box.x = (box.x || 0) + x; box.y = (box.y || 0) + y;
+ boxes.set(box.id, box);
+ for (const e of box.edges || []) routes.set(e.id, {e, box});
+ for (const child of box.children || []) collect(child, box.x, box.y);
+ };
+ collect(graph, 0, 0);
for (const group of groups) {
const box = boxes.get(group.id);
const ports = new Map(box.ports.map(p => [p.id, p]));
@@ -157,13 +216,14 @@ class GraphLayout {
n.x = box.x + n.ox; n.y = box.y + n.oy;
// Keep semantic nodes/slots intact for selection and neighborhood marks.
n.ports = n.n.edges.map(e => {
- if (n.n.kind === "PHI" && e.idx === 0)
+ if (n.reg && e.idx === 0)
return {id: n.id + "i0", x: -6, y: n.height / 2 - 3, width: 6, height: 6, reg: true, edge: e};
const p = ports.get(n.id + "i" + e.idx);
return p && {...p, x: p.x - n.ox, y: p.y - n.oy, edge: e};
}).filter(Boolean);
const out = ports.get(n.id + "o");
n.ports.push({...out, x: out.x - n.ox, y: out.y - n.oy});
+ for (const id of outs.get(n.n.id) || []) n.ports.push(ports.get(n.id + "o" + id));
}
}
// Saved scopes describe suspended parser states at their control points.
@@ -193,8 +253,21 @@ class GraphLayout {
}
// Parser ownership is an overlay, never an IR node or an ELK flow edge.
const parser = owned.length ? {width: Math.max(180, (owned.length + 1) * 18), height: 36} : null;
- // Stop belongs at the bottom even before any Return has attached to it.
- const foot = [...nodes.filter(n => n.stop), ...(parser ? [parser] : [])];
+ // Stack Stop inputs before their collector: StopCUs above the outer Stop.
+ // Peers share a row, so adding CUs costs width rather than another level.
+ const pending = new Set(nodes.filter(n => n.stop)), rows = [];
+ while (pending.size) {
+ const row = [...pending].filter(n => !n.n.edges.some(e => pending.has(byId.get(e.def))));
+ // A malformed cycle should still be drawable.
+ if (!row.length) row.push(...pending);
+ for (const n of row) pending.delete(n);
+ rows.push({nodes: row, width: row.reduce((sum, n) => sum + n.width, 0) + (row.length - 1) * 32,
+ height: Math.max(...row.map(n => n.height))});
+ }
+ const stops = rows.length ? {width: Math.max(...rows.map(r => r.width)),
+ height: rows.reduce((sum, r) => sum + r.height, 0) + (rows.length - 1) * 32} : null;
+ // The whole Stop stack stays below the flow graph, beside the Parser.
+ const foot = [...(stops ? [stops] : []), ...(parser ? [parser] : [])];
if (foot.length) {
const width = foot.reduce((sum, n) => sum + n.width, 0) + (foot.length - 1) * 32;
const height = Math.max(...foot.map(n => n.height));
@@ -207,6 +280,18 @@ class GraphLayout {
}
graph.height += height + 48;
}
+ if (stops) {
+ let y = stops.y;
+ for (const row of rows) {
+ let x = stops.x + (stops.width - row.width) / 2;
+ for (const n of row.nodes) {
+ n.x = x;
+ n.y = y;
+ x += n.width + 32;
+ }
+ y += row.height + 32;
+ }
+ }
if (parser) {
owned.sort((a, b) => a.x - b.x || a.n.id - b.n.id);
parser.ports = owned.map((n, i) => ({id: "parser-i" + n.n.id,
@@ -224,12 +309,14 @@ class GraphLayout {
const u = use.x + b.x + 3, v = use.y + b.y + 3;
e.path = `M${x},${y} C${x},${y + 24} ${u},${v - 24} ${u},${v}`;
} else {
- e.path = routes.get(e.id).sections.map(s =>
+ const route = routes.get(e.id), origin = boxes.get(route.e.container) || route.box;
+ e.path = route.e.sections.map(s =>
[s.startPoint, ...(s.bendPoints || []), s.endPoint]
- .map((p, i) => `${i ? "L" : "M"}${p.x},${p.y}`).join(" ")).join(" ");
+ .map((p, i) => `${i ? "L" : "M"}${p.x + origin.x},${p.y + origin.y}`).join(" ")).join(" ");
}
}
- return {width: graph.width, height: graph.height, nodes, edges,
+ return {width: graph.width, height: graph.height, nodes, edges, raw: view.raw, cover: view.cover,
+ groups: view.open.map(g => ({...g, ...boxes.get("g" + g.id), gid: g.id, n: view.raw.get(g.id)})),
parser, scope: active?.n.id || 0, scopes: new Set(owned.filter(n => n.scope).map(n => n.n.id)),
held: new Set(held.map(n => n.n.id))};
}
diff --git a/graph/web/render.js b/graph/web/render.js
index 883475f5..f0b622db 100644
--- a/graph/web/render.js
+++ b/graph/web/render.js
@@ -16,6 +16,7 @@ class GraphView {
.append("path").attr("d", "M0,-4L8,0L0,4Z").attr("fill", color);
}
this.world = this.svg.append("g");
+ this.regions = this.world.append("g").attr("class", "regions");
this.links = this.world.append("g").attr("class", "edges");
this.parser = this.world.append("g").attr("class", "parser");
this.nodes = this.world.append("g").attr("class", "nodes");
@@ -41,6 +42,18 @@ class GraphView {
}
this.scene = scene;
this.evt = evt;
+ const regions = this.regions.selectAll("g.region").data(scene.groups || [], g => g.gid).join(enter => {
+ const g = enter.append("g").attr("class", "region");
+ g.append("rect").attr("class", "region-box").attr("rx", 8);
+ g.append("text").attr("class", "label").attr("x", 34).attr("y", 22);
+ g.append("title");
+ return g;
+ }).attr("transform", g => `translate(${g.x},${g.y})`);
+ regions.select("rect.region-box").attr("width", g => g.width).attr("height", g => g.height)
+ .attr("fill", g => g.n.kind === "LOOP" ? "#f1f6fc" : "#fff8ec");
+ regions.select("text.label").text(g => (`#${g.gid} ${g.n.label}`).slice(0, Math.max(12, Math.floor((g.width-48)/7))));
+ regions.select("title").text(g => `${g.n.label} · ${g.members.size} nodes`);
+ regions.each((g, i, items) => this.foldButton(d3.select(items[i]), g.gid, false, 8, 8));
this.links.selectAll("path.edge").data(scene.edges, e => e.id).join("path")
.attr("class", e => "edge " + e.role).attr("id", e => e.id)
.attr("d", e => e.path).attr("stroke", e => this.colors[e.role])
@@ -49,7 +62,7 @@ class GraphView {
d3.select(this).selectAll("title").data([e]).join("title")
.text(e.role === "PARSER" ? (e.scope ? `Parser holds ${e.active ? "active" : "saved"} scope #${e.def}` :
`Parser holds #${e.def}; no graph users yet`) :
- `#${e.use}[${e.idx}] → #${e.def} · ${e.role}${e.label ? " · " + e.label : ""}`);
+ `#${e.orig?.use ?? e.use}[${e.idx}] → #${e.orig?.def ?? e.def} · ${e.role}${e.label ? " · " + e.label : ""}`);
});
this.parser.selectAll("g").data(scene.parser ? [scene.parser] : []).join(enter => {
const g = enter.append("g");
@@ -73,20 +86,22 @@ class GraphView {
});
boxes.attr("id", n => n.id).attr("data-id", n => n.n.id)
.classed("held", n => !!n.held)
+ .classed("folded", n => !!n.n.fold)
.attr("transform", n => `translate(${n.x},${n.y})`)
.on("click", (event, n) => { event.stopPropagation(); this.select(n.n.id); });
boxes.select("path.box").attr("d", n => this.shape(n))
.attr("fill", n => ({CTRL: "#fff1c4", REGION: "#fff1c4", LOOP: "#ffe0ab", FUN: "#ffe0ab",
- UNIT: "#ffe0ab", STOP: "#fff1c4", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]);
+ UNIT: "#ffe0ab", START: "#fff1c4", STOP: "#fff1c4", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]);
boxes.select("text.head").attr("y", n => n.scope ? 47 : 15)
- .text(n => `#${n.n.id}${n.n.proj ? " · p" + n.n.proj.idx : ""}`);
- boxes.select("text.label").attr("x", n => n.width / 2).attr("y", n => n.scope ? 51 : 33)
- .text(n => n.label);
- boxes.select("text.type").attr("x", n => n.width / 2).text(n => n.scope ? "" : n.type);
+ .text(n => n.compact ? "" : `#${n.n.id}${n.n.proj ? " · p" + n.n.proj.idx : ""}`);
+ boxes.select("text.label").attr("x", n => n.width / 2).attr("y", n => n.compact ? 17 : n.scope ? 51 : 33)
+ .text(n => n.compact ? `#${n.n.id} ${n.label}` : n.label);
+ boxes.select("text.type").attr("x", n => n.width / 2).text(n => n.scope || n.compact ? "" : n.type);
boxes.select("title").text(n => this.info(n.n));
boxes.each((n, i, groups) => {
- const ports = n.ports.filter(p => p.id !== n.id + "o");
+ const ports = n.ports.filter(p => p.edge);
const group = d3.select(groups[i]);
+ this.foldButton(group, n.n.fold?.id, true, n.width - 24, 6);
group.selectAll("line.row").data(n.scope ? ports : [], p => p.id).join("line")
.attr("class", "row").attr("x1", (p, i) => i ? p.x + 3 - p.span / 2 : 0)
.attr("x2", (p, i) => i ? p.x + 3 - p.span / 2 : n.width)
@@ -106,7 +121,7 @@ class GraphView {
});
group.selectAll("text.slot").data(ports, p => p.id).join("text")
.attr("class", "slot").attr("x", p => p.reg ? -10 : p.x + 3).attr("y", p => p.y - 4)
- .attr("text-anchor", "middle").text(p => p.edge.idx);
+ .attr("text-anchor", "middle").text(p => n.n.fold ? `${p.edge.orig.use}:${p.edge.orig.idx}` : p.edge.idx);
});
this.assocs(document.getElementById("assocs").checked);
this.mark();
@@ -114,10 +129,29 @@ class GraphView {
if (this.auto) this.fit();
}
+ foldButton(host, id, folded, x, y) {
+ const btn = host.selectAll("g.fold-btn").data(id ? [id] : []).join(enter => {
+ const g = enter.append("g").attr("class", "fold-btn").attr("role", "button").attr("tabindex", 0);
+ g.append("rect").attr("width", 16).attr("height", 16).attr("rx", 2);
+ g.append("text").attr("x", 8).attr("y", 12).attr("text-anchor", "middle");
+ g.append("title");
+ return g;
+ }).attr("transform", `translate(${x},${y})`)
+ .attr("aria-label", `${folded ? "Unfold" : "Fold"} region #${id}`)
+ .on("click", (event, id) => { event.stopPropagation(); this.onFold?.(id); })
+ .on("keydown", (event, id) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault(); event.stopPropagation(); this.onFold?.(id);
+ }
+ });
+ btn.select("text").text(folded ? "+" : "−");
+ btn.select("title").text(folded ? "Unfold region" : "Fold region");
+ }
+
shape(n) {
const w = n.width, h = n.height;
// A MultiNode and its projection cells tile one rectangular box.
- if (n.cell || ["CTRL", "UNIT", "STOP"].includes(n.n.kind)) return `M0,0H${w}V${h}H0Z`;
+ if (n.cell || ["CTRL", "UNIT", "START", "STOP"].includes(n.n.kind)) return `M0,0H${w}V${h}H0Z`;
if (n.n.kind === "REGION") return `M12,0H${w - 12}L${w},${h}H0Z`;
if (n.n.kind === "PHI") return `M12,0H${w - 12}L${w},${h / 2}L${w - 12},${h}H12L0,${h / 2}Z`;
if (n.n.kind === "MEM" || n.n.kind === "FUN")
@@ -128,7 +162,10 @@ class GraphView {
}
info(n) {
+ const fold = this.scene.nodes.find(v => v.n.id === n.id)?.n.fold;
+ n = this.scene.raw?.get(n.id) || n;
return `#${n.id} ${n.label}${n.proj ? "\nProjection " + n.proj.idx + " of #" + n.proj.par : ""}` +
+ (fold ? `\n${fold.members.size} nodes folded` : "") +
(this.scene.held.has(n.id) ? "\nHeld by parser; no graph users yet" : "") +
(this.scene.scopes.has(n.id) ? (this.scene.scope === n.id ? "\nActive parser scope" : "\nSaved parser scope") : "") +
`\n${n.type || "Type not computed"}\n\n` + n.edges.map(e =>
@@ -137,12 +174,14 @@ class GraphView {
select(id) {
this.pick = id;
- const node = this.scene?.nodes.find(n => n.n.id === id);
- this.nodes.selectAll("g.node").classed("selected", n => n.n.id === id);
- this.links.selectAll("path.edge").classed("selected", e => id !== 0 && (e.use === id || e.def === id));
+ const node = this.scene?.raw?.get(id) || this.scene?.nodes.find(n => n.n.id === id)?.n;
+ this.nodes.selectAll("g.node").classed("selected", n => n.n.id === (this.scene.cover?.get(id) || id));
+ this.links.selectAll("path.edge").classed("selected", e => id !== 0 &&
+ ((e.orig?.use ?? e.use) === id || (e.orig?.def ?? e.def) === id || e.use === id || e.def === id));
const detail = document.getElementById("detail");
detail.hidden = !id;
- detail.textContent = node ? this.info(node.n) : `#${id} is absent in this frame.`;
+ const box = this.scene?.cover?.get(id);
+ detail.textContent = node ? this.info(node) + (box && box !== id ? `\nInside folded region #${box}` : "") : `#${id} is absent in this frame.`;
}
assocs(show) { this.links.selectAll(".ASSOC").style("display", e => show || e.bind ? null : "none"); }
@@ -151,8 +190,10 @@ class GraphView {
const evt = this.evt;
const show = document.getElementById("near").checked;
const near = new Set(show ? evt?.near : []);
- this.nodes.selectAll("g.node").classed("near", n => near.has(n.n.id))
- .classed("focus", n => show && (n.n.id === evt?.node || n.n.id === evt?.repl));
+ this.nodes.selectAll("g.node").classed("near", n => near.has(n.n.id) ||
+ n.n.fold && [...n.n.fold.members].some(id => near.has(id)))
+ .classed("focus", n => show && (n.n.id === evt?.node || n.n.id === evt?.repl ||
+ n.n.fold && (n.n.fold.members.has(evt?.node) || n.n.fold.members.has(evt?.repl))));
}
// Save the whole graph at its layout size, independent of pan/zoom.
diff --git a/graph/web/viewer.js b/graph/web/viewer.js
index 4a88245b..77703b71 100644
--- a/graph/web/viewer.js
+++ b/graph/web/viewer.js
@@ -1,6 +1,8 @@
// Connection and playback state are independent of renderer initialization.
const program = document.getElementById("program");
const status = document.getElementById("status");
+const frameNo = document.getElementById("N");
+const scrub = document.getElementById("scrub");
let socket;
let renderer;
let layout;
@@ -8,6 +10,9 @@ let rendererReady = false;
let rendering = false;
let frames = [];
let current = -1;
+let wanted = -1;
+const folded = new Set();
+const foldKey = () => [...folded].sort((a,b) => a-b).join(",");
let generation = 0;
let done = false;
let compiling = false;
@@ -20,15 +25,22 @@ function updateUI() {
!rendererReady ? "Connected; initializing graph renderer..." :
busy ? busy : compiling ? "Compiling... " + frames.length + " frames received" :
done ? frames.length + " frames ready" : "Connected");
- document.getElementById("N").textContent = current < 0 ? "0" : String(current + 1);
+ const at = wanted >= 0 ? wanted : current;
+ if (document.activeElement !== frameNo) frameNo.value = String(at + 1);
+ frameNo.max = scrub.max = String(Math.max(1, frames.length));
+ frameNo.disabled = scrub.disabled = !rendererReady || !frames.length;
+ scrub.value = String(Math.max(1, at + 1));
+ scrub.setAttribute("aria-valuetext", `Frame ${at + 1} of ${frames.length}`);
document.getElementById("len").textContent = String(frames.length);
- document.getElementById("doPrev").disabled = rendering || current <= 0;
- document.getElementById("doNext").disabled =
- !rendererReady || rendering || current + 1 >= frames.length;
+ document.getElementById("doFirst").disabled = document.getElementById("doPrev").disabled =
+ !rendererReady || at <= 0;
+ document.getElementById("doLast").disabled = document.getElementById("doNext").disabled =
+ !rendererReady || !frames.length || at + 1 >= frames.length;
document.getElementById("compile").disabled =
!socket || socket.readyState !== WebSocket.OPEN || compiling || rendering;
document.getElementById("fit").disabled = rendering || current < 0;
document.getElementById("save").disabled = rendering || !frames[current]?.snap;
+ document.getElementById("unfold").disabled = !folded.size;
document.getElementById("assocs").disabled = !frames[current]?.snap;
document.getElementById("near").disabled = !frames[current]?.evt;
const evt = frames[current]?.evt;
@@ -47,6 +59,7 @@ function updateUI() {
function reportError(error) {
failure = "Viewer error: " + (error && error.message || error || "Graph rendering failed");
rendering = false;
+ wanted = -1;
busy = "";
updateUI();
console.error(error);
@@ -66,7 +79,9 @@ function get_program() {
generation++;
frames = [];
current = -1;
+ wanted = -1;
done = false;
+ folded.clear();
compiling = true;
failure = "";
document.getElementById("detail").hidden = true;
@@ -75,17 +90,31 @@ function get_program() {
}
async function render(index) {
- if (!rendererReady || rendering || index < 0 || index >= frames.length) return;
+ if (!rendererReady || index < 0 || index >= frames.length) return;
+ wanted = index;
+ if (rendering) { updateUI(); return; }
const frameGeneration = generation;
rendering = true;
- const frame = frames[index];
- busy = !frame.layout ? "Laying out frame " + (index + 1) + "..." : "Drawing...";
- updateUI();
try {
if (!layout) layout = new GraphLayout();
- if (!frame.layout) frame.layout = await layout.run(frame.snap, frame.evt);
- if (generation === frameGeneration) {
- renderer.show(frame.snap, frame.layout, frame.evt);
+ while (wanted >= 0 && generation === frameGeneration) {
+ index = wanted;
+ const frame = frames[index];
+ const key = foldKey();
+ let scene = key ? (frame.foldKey === key ? frame.foldLayout : null) : frame.layout;
+ busy = !scene ? "Laying out frame " + (index + 1) + "..." : "Drawing...";
+ updateUI();
+ if (!scene) {
+ scene = await layout.run(frame.snap, frame.evt, new Set(folded));
+ // Keep the ordinary layout plus only the most recent folded variant.
+ if (key) { frame.foldKey = key; frame.foldLayout = scene; }
+ else frame.layout = scene;
+ }
+ if (generation !== frameGeneration) break;
+ // Scrubbing can change the destination while ELK works. Draw only the
+ // latest request, without computing all the intervening frames.
+ if (wanted !== index || foldKey() !== key) continue;
+ renderer.show(frame.snap, scene, frame.evt);
if (frame.pos >= 0) {
// A newline or EOF has no visible character to highlight. Show the last
// parsed character there, rather than losing the position indicator.
@@ -93,20 +122,34 @@ async function render(index) {
while (pos > 0 && (!program.value[pos] || /\s/.test(program.value[pos]))) pos--;
program.setSelectionRange(pos, pos + 1);
// Focus after selecting, so the browser brings the new position into view.
- program.focus({preventScroll: true});
+ if (document.activeElement !== frameNo && document.activeElement !== scrub)
+ program.focus({preventScroll: true});
}
+ current = index;
+ wanted = -1;
}
rendering = false;
busy = "";
- if (generation === frameGeneration) current = index;
updateUI();
} catch (error) {
reportError(error);
}
}
-function doNext() { render(current + 1); }
-function doPrev() { render(current - 1); }
+function doFirst() { render(0); }
+function toggleFold(id) {
+ if (folded.has(id)) folded.delete(id); else folded.add(id);
+ render(wanted >= 0 ? wanted : current);
+}
+function doLast() { render(frames.length - 1); }
+function doNext() { render((wanted >= 0 ? wanted : current) + 1); }
+function doPrev() { render((wanted >= 0 ? wanted : current) - 1); }
+function jump() {
+ const n = frameNo.valueAsNumber;
+ const index = Number.isFinite(n) ? Math.max(0, Math.min(frames.length - 1, Math.trunc(n) - 1)) : current;
+ frameNo.value = String(index + 1);
+ render(index);
+}
function doExit() {
if (socket && socket.readyState === WebSocket.OPEN) socket.send("null");
}
@@ -119,6 +162,14 @@ program.addEventListener("keydown", event => {
}
});
document.getElementById("compile").addEventListener("click", get_program);
+document.getElementById("unfold").addEventListener("click", () => {
+ folded.clear(); render(wanted >= 0 ? wanted : current);
+});
+frameNo.addEventListener("change", jump);
+frameNo.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); jump(); }
+});
+scrub.addEventListener("input", () => render(scrub.valueAsNumber - 1));
document.getElementById("fit").addEventListener("click", () => {
renderer.auto = true;
renderer.fit();
@@ -132,6 +183,7 @@ document.addEventListener("keydown", event => {
try {
renderer = new GraphView(document.getElementById("elk"));
+ renderer.onFold = toggleFold;
rendererReady = true;
socket = new WebSocket("ws://" + (location.hostname || "127.0.0.1") + ":12345");
socket.onopen = () => {