summaryrefslogtreecommitdiff
path: root/graph/web
diff options
context:
space:
mode:
authorCliff Click <cliffc@acm.org>2026-09-22 15:50:22 -0700
committerCliff Click <cliffc@acm.org>2026-09-22 15:50:22 -0700
commitfb80ada35ce2806c2f617a0e336d2ca4ab1d5d12 (patch)
treef27250a0efcc74354831da98d2cf764dfa7d9216 /graph/web
parented13d1ca215ea094b4d6beab89b90ab4c05ed396 (diff)
downloadSimple-fb80ada35ce2806c2f617a0e336d2ca4ab1d5d12.tar.gz
Simple-fb80ada35ce2806c2f617a0e336d2ca4ab1d5d12.zip
Graph cleanup
Phis stay with regions, stop sinks to bottom,
Diffstat (limited to 'graph/web')
-rw-r--r--graph/web/layout.js63
-rw-r--r--graph/web/render.js13
-rw-r--r--graph/web/viewer.js8
3 files changed, 65 insertions, 19 deletions
diff --git a/graph/web/layout.js b/graph/web/layout.js
index a277cb53..b192de87 100644
--- a/graph/web/layout.js
+++ b/graph/web/layout.js
@@ -22,7 +22,7 @@ class GraphLayout {
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, ox: 0, oy: 0};
+ ports: [], n, label, type, scope, cols, 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";
@@ -46,7 +46,16 @@ class GraphLayout {
projs.get(par).push(n);
n.par = par;
}
- const groups = nodes.filter(n => !n.par).map(n => {
+ const phis = new Map();
+ for (const n of nodes) {
+ if (n.n.kind !== "PHI") 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 (!phis.has(reg)) phis.set(reg, []);
+ phis.get(reg).push(n);
+ n.reg = reg;
+ }
+ const groups = nodes.filter(n => !n.par && !n.reg).map(n => {
const slots = (projs.get(n) || []).sort((a, b) => a.n.proj.idx - b.n.proj.idx || a.n.id - b.n.id);
const parts = [n, ...slots];
if (slots.length) {
@@ -61,15 +70,24 @@ class GraphLayout {
x += p.width;
}
}
+ // ELK sees one row; the renderer keeps each Region/Phi as a separate box.
+ let width = n.width;
+ for (const p of phis.get(n) || []) {
+ p.ox = width + 32;
+ width = p.ox + p.width;
+ parts.push(p);
+ }
const ports = [];
for (const p of parts) {
- p.cell = slots.length > 0;
+ p.cell = slots.length > 0 && !p.reg;
const extra = p.scope && p.cols.length ?
(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()) {
// 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.scope) {
const span = p.cols[idx] + extra;
ports.push({id: p.id + "i" + e.idx, x: x + span / 2 - 3, y: -3, span,
@@ -77,7 +95,8 @@ class GraphLayout {
x += span;
continue;
}
- ports.push({id: p.id + "i" + e.idx, x: p.ox + p.width * (e.idx + 1) / (p.n.edges.length + 1) - 3,
+ const off = p.n.kind === "PHI" ? 0 : 1;
+ ports.push({id: p.id + "i" + e.idx, x: p.ox + p.width * (e.idx + off) / (p.n.edges.length + off) - 3,
y: p.oy - 3, width: 6, height: 6, layoutOptions: {"elk.port.side": "NORTH"}});
}
// Direct uses of the MultiNode attach to its header, projections below.
@@ -86,12 +105,13 @@ class GraphLayout {
y: p.oy + (side === "EAST" ? p.height / 2 : p.height) - 3,
width: 6, height: 6, layoutOptions: {"elk.port.side": side}});
}
- return {id: n.id, width: n.width, height: n.height * (slots.length ? 2 : 1), ports, parts};
+ return {id: n.id, width, height: n.height * (slots.length ? 2 : 1), 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 (byId.get(use.id).par && e.idx === 0 && e.def === use.proj.par) continue;
const id = "e" + use.id + "i" + e.idx;
const reg = byId.get(use.edges[0]?.def)?.n;
@@ -106,13 +126,14 @@ class GraphLayout {
}
}
const graph = await this.elk.layout({
- id: "root", children: groups.filter(g => !g.parts[0].scope).map(({parts, ...box}) => ({...box,
+ 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"}
})),
// Scope bindings and lifetime associations must not impose CFG ranks.
- edges: edges.filter(e => e.role !== "ASSOC" && !e.bind && !byId.get(e.def).scope).map(e => ({
+ 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: {
@@ -124,8 +145,9 @@ class GraphLayout {
"elk.randomSeed": 1
}
});
- // Scope ports use the same local geometry; place their boxes after CFG nodes.
- for (const g of groups.filter(g => g.parts[0].scope)) graph.children.push({...g, x: 0, y: 0});
+ // 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]));
for (const group of groups) {
@@ -135,6 +157,8 @@ 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)
+ 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);
@@ -169,11 +193,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] : [])];
+ 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));
+ graph.width = Math.max(graph.width, width + 48);
+ let x = (graph.width - width) / 2;
+ for (const n of foot) {
+ n.x = x;
+ n.y = graph.height + 24 + height - n.height;
+ x += n.width + 32;
+ }
+ graph.height += height + 48;
+ }
if (parser) {
- graph.width = Math.max(graph.width, parser.width + 48);
- parser.x = (graph.width - parser.width) / 2;
- parser.y = graph.height + 24;
- graph.height = parser.y + parser.height + 24;
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,
x: parser.width * (i + 1) / (owned.length + 1) - 3, y: -3}));
@@ -181,7 +215,8 @@ class GraphLayout {
use: 0, def: n.n.id, role: "PARSER", sources: [n.id + "o"], targets: ["parser-i" + n.n.id]});
}
for (const e of edges) {
- if (e.role === "ASSOC" || e.role === "PARSER" || e.bind || byId.get(e.def).scope) {
+ if (e.role === "ASSOC" || e.role === "PARSER" || e.bind || byId.get(e.def).scope ||
+ byId.get(e.use)?.stop || byId.get(e.def).stop) {
const def = byId.get(e.def), use = e.role === "PARSER" ? parser : byId.get(e.use);
const a = def.ports.find(p => p.id === e.sources[0]);
const b = use.ports.find(p => p.id === e.targets[0]);
diff --git a/graph/web/render.js b/graph/web/render.js
index 34c66a02..883475f5 100644
--- a/graph/web/render.js
+++ b/graph/web/render.js
@@ -77,7 +77,7 @@ class GraphView {
.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", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]);
+ UNIT: "#ffe0ab", 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)
@@ -97,8 +97,15 @@ class GraphView {
group.selectAll("circle.port").data(ports, p => p.id).join("circle")
.attr("class", "port").attr("cx", p => p.x + 3).attr("cy", p => p.y + 3)
.attr("r", 3).attr("fill", p => p.edge.def ? "#536477" : "white");
+ group.selectAll("path.reg").data(ports.filter(p => p.reg && p.edge.def), p => p.id).join("path")
+ .attr("class", "reg").attr("fill", "none").attr("stroke", this.colors.CTRL)
+ .attr("d", p => `M-3,${p.y + 3}H-18m4,-3l-4,3l4,3`)
+ .each(function(p) {
+ d3.select(this).selectAll("title").data([p]).join("title")
+ .text(`[0] → #${p.edge.def}`);
+ });
group.selectAll("text.slot").data(ports, p => p.id).join("text")
- .attr("class", "slot").attr("x", p => p.x + 3).attr("y", p => p.y - 4)
+ .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);
});
this.assocs(document.getElementById("assocs").checked);
@@ -110,7 +117,7 @@ class GraphView {
shape(n) {
const w = n.width, h = n.height;
// A MultiNode and its projection cells tile one rectangular box.
- if (n.cell || n.n.kind === "CTRL" || n.n.kind === "UNIT") return `M0,0H${w}V${h}H0Z`;
+ if (n.cell || ["CTRL", "UNIT", "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")
diff --git a/graph/web/viewer.js b/graph/web/viewer.js
index 63ee195e..4a88245b 100644
--- a/graph/web/viewer.js
+++ b/graph/web/viewer.js
@@ -87,9 +87,13 @@ async function render(index) {
if (generation === frameGeneration) {
renderer.show(frame.snap, frame.layout, frame.evt);
if (frame.pos >= 0) {
- // Stepping focuses a button; focus the source so its selection is visible.
+ // A newline or EOF has no visible character to highlight. Show the last
+ // parsed character there, rather than losing the position indicator.
+ let pos = Math.min(frame.pos, program.value.length);
+ 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});
- program.setSelectionRange(frame.pos, frame.pos + 1);
}
}
rendering = false;