summaryrefslogtreecommitdiff
path: root/graph/web/layout.js
diff options
context:
space:
mode:
authorCliff Click <cliffc@acm.org>2026-09-23 16:35:50 -0700
committerCliff Click <cliffc@acm.org>2026-09-23 16:35:50 -0700
commit0f32001b466def0cee753557ad6e296cf15e2504 (patch)
tree9e16038c574407bbd3f5c21f469c2df71e6835f8 /graph/web/layout.js
parent695429df954b084d2226fe2a6c1a47e1861bcc01 (diff)
downloadSimple-0f32001b466def0cee753557ad6e296cf15e2504.tar.gz
Simple-0f32001b466def0cee753557ad6e296cf15e2504.zip
Keyboard shortcuts
Fast forward/reverse, pick-a-frame. Shorter type names.
Diffstat (limited to 'graph/web/layout.js')
-rw-r--r--graph/web/layout.js161
1 files changed, 124 insertions, 37 deletions
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))};
}