summaryrefslogtreecommitdiff
path: root/graph/web
diff options
context:
space:
mode:
Diffstat (limited to 'graph/web')
-rw-r--r--graph/web/index.html12
-rw-r--r--graph/web/layout.js5
-rw-r--r--graph/web/peep.js197
-rw-r--r--graph/web/render.js140
-rw-r--r--graph/web/viewer.js234
5 files changed, 539 insertions, 49 deletions
diff --git a/graph/web/index.html b/graph/web/index.html
index 4a32b461..7e66fe5a 100644
--- a/graph/web/index.html
+++ b/graph/web/index.html
@@ -33,6 +33,13 @@
.node.focus .box { stroke:#9c4900; stroke-width:3; }
.node.selected .box { stroke:#1569b9; stroke-width:3; }
.node.folded .box { stroke-width:4; }
+ .node.folding .box { fill:#f4ccd7; }
+ .fold-tag { font-size:9px; font-weight:700; fill:#963751; }
+ .peep-area { fill:#fff3d9; fill-opacity:.8; stroke:#c18221; stroke-width:2; pointer-events:none; }
+ .node.peep-edit .box { stroke:#9c4900; stroke-width:3; }
+ .node.error .box { stroke:#b42318; stroke-width:4; }
+ #event.error { color:#b42318; font-weight:600; white-space:pre-wrap; overflow-wrap:anywhere; }
+ .peep-ghosts { pointer-events:none; }
.region-box { stroke:#9da9b8; stroke-width:1.5; }
.fold-btn { cursor:pointer; }
.fold-btn rect { fill:white; stroke:#596674; }
@@ -64,6 +71,7 @@
<script src="vendor/elk-api.js"></script>
<script src="groups.js"></script>
<script src="layout.js"></script>
+ <script src="peep.js"></script>
<script src="render.js"></script>
</head>
<body>
@@ -87,12 +95,12 @@
<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>
+ <button id="doPrev" type="button" onclick="doPrev()" class="button" aria-label="Previous frame" aria-keyshortcuts="ArrowLeft" title="Previous step (Left arrow)">&#11164;</button>
<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="doNext" type="button" onclick="doNext()" class="button" aria-label="Next frame" aria-keyshortcuts="ArrowRight" title="Next step (Right arrow)">&#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>
diff --git a/graph/web/layout.js b/graph/web/layout.js
index 8260fbd2..880e7662 100644
--- a/graph/web/layout.js
+++ b/graph/web/layout.js
@@ -25,7 +25,7 @@ class GraphLayout {
const nodes = snap.nodes.map(n => {
const label = `${this.clip(n.label)} #${n.id}${n.proj ? "/" + n.proj.idx : ""}`, 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,
+ let w = Math.ceil(Math.max(120, this.ctx.measureText(label).width + 24 + (n.folding ? 60 : 0),
this.ctx.measureText(type).width + 24, (n.edges.length + 1) * 18));
const scope = n.kind === "SCOPE";
const cols = scope ? n.edges.map(e => Math.ceil(Math.max(48,
@@ -49,8 +49,9 @@ class GraphLayout {
// roots; unfinished expression values are inferred from absent uses.
const used = new Set();
for (const n of snap.nodes) for (const e of n.edges) if (e.def) used.add(e.def);
+ const temps = new Set(evt?.temps);
const held = parsing ? nodes.filter(n =>
- (n.n.proj || ["DATA", "MEM", "PHI"].includes(n.n.kind)) && !used.has(n.n.id)) : [];
+ (n.n.proj || ["DATA", "MEM", "PHI"].includes(n.n.kind)) && !used.has(n.n.id) && !temps.has(n.n.id)) : [];
for (const n of held) n.held = true;
const owned = [...held, ...scopes.filter(n => parsing && roots.has(n.n.id))];
const projs = new Map();
diff --git a/graph/web/peep.js b/graph/web/peep.js
new file mode 100644
index 00000000..2cf1ccf2
--- /dev/null
+++ b/graph/web/peep.js
@@ -0,0 +1,197 @@
+// Temporary geometry for one edit. Stable snapshots and their layouts are untouched.
+class GraphPeep {
+ // A recursive peephole can return a replacement before its caller attaches
+ // it. Accept the next snapshot only when every intervening graph change is
+ // that substitution or deletion of its now-unused inputs.
+ static attached(a, b, evt) {
+ const pre = new Map(a.nodes.map(n => [n.id,n]));
+ const post = new Map(b.nodes.map(n => [n.id,n]));
+ if (!pre.has(evt.node) || !pre.has(evt.repl) || !post.has(evt.repl)) return false;
+ if (b.nodes.some(n => !pre.has(n.id))) return false;
+ const gone = new Set();
+ let rewires = 0;
+ for (const n of a.nodes) {
+ const m = post.get(n.id);
+ if (!m) { gone.add(n.id); continue; }
+ if (n.kind !== m.kind || n.label !== m.label || n.type !== m.type || !!n.folding !== !!m.folding ||
+ JSON.stringify(n.proj) !== JSON.stringify(m.proj) || n.edges.length !== m.edges.length) return false;
+ for (let i=0; i<n.edges.length; i++) {
+ const e = n.edges[i], f = m.edges[i];
+ if (JSON.stringify(e) === JSON.stringify(f)) continue;
+ if (e.def !== evt.node || f.def !== evt.repl ||
+ JSON.stringify({...e, def: f.def}) !== JSON.stringify(f)) return false;
+ rewires++;
+ }
+ }
+ const todo = [evt.node];
+ while (todo.length) {
+ const id = todo.pop();
+ if (!gone.delete(id)) continue;
+ for (const e of pre.get(id).edges) if (gone.has(e.def)) todo.push(e.def);
+ }
+ return rewires > 0 && !gone.size;
+ }
+
+ static diff(a, b, evt) {
+ const pre = new Map(a.nodes.map(n => [n.id, n]));
+ const post = new Map(b.nodes.map(n => [n.id, n]));
+ const changed = new Set();
+ let structural = false;
+ for (const id of new Set([...pre.keys(), ...post.keys()])) {
+ const n = pre.get(id), m = post.get(id);
+ const shape = n => n && JSON.stringify([n.kind, n.proj, !!n.folding, n.edges.map(e => [e.idx,e.def])]);
+ const edit = shape(n) !== shape(m);
+ if (!edit && n.type === m.type && n.label === m.label && JSON.stringify(n.edges) === JSON.stringify(m.edges)) continue;
+ changed.add(id);
+ if (edit) structural = true;
+ }
+ const core = new Set([...changed, evt.node]);
+ // GVN can return an unchanged node that was already in the graph. Show it
+ // alongside the original, even when it isn't a def of any changed node.
+ if (evt.repl) core.add(evt.repl);
+ const near = new Set(core);
+ // One hop from changed nodes and both peep roots, in both snapshots. Do
+ // not expand from the added defs or scheduling dependencies.
+ for (const ns of [pre,post]) for (const id of core) {
+ const n = ns.get(id);
+ if (!n) continue;
+ const constant = n.kind !== "SCOPE" && !n.proj && n.edges.every(e => e.role === "ASSOC");
+ for (const e of n.edges) {
+ const def = ns.get(e.def);
+ if (!def) continue;
+ // Early chapters still describe Start as CTRL rather than START.
+ if (constant && (def.kind === "START" || def.label === "Start")) continue;
+ near.add(e.def);
+ }
+ }
+ near.delete(0);
+ return {structural, changed, near};
+ }
+
+ static async make(pre, post, diff, layout, folded, sceneFor, evt = post.evt) {
+ const folds = new Set(folded);
+ // Reveal the edit even when its function/loop is folded. Release restores
+ // the user's folds; gathering never changes the persistent fold set.
+ for (const frame of [pre, post]) {
+ const view = GraphGroups.view(frame.snap, new Set());
+ for (const id of folds)
+ if ([...(view.groups.get(id)?.members || [])].some(n => diff.near.has(n))) folds.delete(id);
+ }
+ const a = await sceneFor(pre, folds), b = await sceneFor(post, folds);
+ const ids = new Set(diff.near);
+ const nodes = new Map();
+ for (const scene of [a, b]) for (const n of scene.nodes) nodes.set(n.n.id, n);
+ // Multi/projection cells travel as one box. A gathered Phi brings its
+ // Region for the slot-0 marker, but a Region does not bring sibling Phis.
+ let more = true;
+ while (more) {
+ more = false;
+ for (const scene of [a, b]) for (const n of scene.nodes) {
+ const par = n.par || n.reg;
+ if (!par || !(ids.has(n.n.id) || n.par && ids.has(par.n.id))) continue;
+ for (const id of [n.n.id, par.n.id]) if (!ids.has(id)) { ids.add(id); more = true; }
+ }
+ }
+ for (const id of ids) if (!nodes.has(id)) ids.delete(id);
+ const rim = new Set();
+ for (const scene of [a,b]) {
+ // Use original slots too: some associations have no routed display edge.
+ for (const n of scene.nodes) for (const e of n.n.edges) if (e.def) {
+ if (ids.has(n.n.id) && !ids.has(e.def)) rim.add(e.def);
+ if (ids.has(e.def) && !ids.has(n.n.id)) rim.add(n.n.id);
+ }
+ if (scene.edges.some(e => e.role === "PARSER" && ids.has(e.def))) rim.add(0);
+ }
+ const parent = new Map([...ids].map(id => [id, id]));
+ const root = id => {
+ while (parent.get(id) !== id) id = parent.get(id);
+ return id;
+ };
+ for (const scene of [a, b]) for (const n of scene.nodes) {
+ const par = n.par || n.reg;
+ if (ids.has(n.n.id) && par && ids.has(par.n.id)) parent.set(root(n.n.id), root(par.n.id));
+ }
+ const parts = new Map(), sizes = new Map();
+ for (const scene of [a, b]) for (const n of scene.nodes) if (ids.has(n.n.id)) {
+ const old = sizes.get(n.n.id);
+ sizes.set(n.n.id, {width: Math.max(n.width, old?.width || 0), height: Math.max(n.height, old?.height || 0)});
+ }
+ for (const id of ids) {
+ const par = root(id);
+ if (!parts.has(par)) parts.set(par, []);
+ parts.get(par).push(nodes.get(id));
+ }
+ const pos = new Map(), children = [];
+ for (const [id, ns] of parts) {
+ const n = nodes.get(id), size = sizes.get(id);
+ const projs = ns.filter(p => p.n.id !== id && p.par).sort((a,b) => a.n.proj.idx-b.n.proj.idx);
+ const row = projs.reduce((sum,p) => sum + sizes.get(p.n.id).width, 0);
+ const width = Math.max(size.width, row);
+ pos.set(id, {x: 0, y: 0, width, height: size.height});
+ let x = 0, height = size.height;
+ for (const p of projs) {
+ const s = sizes.get(p.n.id), w = s.width + (width-row)/projs.length;
+ pos.set(p.n.id, {x, y: size.height, width: w, height: s.height});
+ x += w; height = Math.max(height, size.height+s.height);
+ }
+ x = width;
+ for (const p of ns.filter(p => p !== n && !projs.includes(p))) {
+ const s = sizes.get(p.n.id);
+ pos.set(p.n.id, {x: x+32, y: 0, ...s});
+ x += 32+s.width; height = Math.max(height, s.height);
+ }
+ children.push({id: String(id), width: x, height});
+ }
+ const edges = new Map();
+ for (const scene of [a, b]) for (const e of scene.edges) {
+ if (!ids.has(e.def) || !ids.has(e.use) || ["ASSOC", "PARSER"].includes(e.role)) continue;
+ const def = root(e.def), use = root(e.use), id = `${def}:${use}`;
+ if (def !== use) edges.set(id, {id, sources: [String(def)], targets: [String(use)]});
+ }
+ const graph = await layout.elk.layout({id: "peep", children, edges: [...edges.values()], layoutOptions: {
+ "elk.algorithm": "layered", "elk.direction": "DOWN", "elk.spacing.nodeNode": 32,
+ "elk.layered.spacing.nodeNodeBetweenLayers": 48, "elk.padding": "[top=32,left=32,bottom=32,right=32]",
+ "elk.randomSeed": 1
+ }});
+ // Gather near the peep's original location, then pan/zoom to the enclosure.
+ const anchor = a.nodes.find(n => n.n.id === evt.node) || a.nodes.find(n => ids.has(n.n.id));
+ const x = (anchor?.x || 0) - graph.width/2, y = (anchor?.y || 0) - graph.height/2;
+ for (const box of graph.children) for (const n of parts.get(Number(box.id))) {
+ const p = pos.get(n.n.id);
+ p.x += box.x+x; p.y += box.y+y;
+ }
+ const area = {x, y, width: graph.width, height: graph.height};
+ const oldNodes = new Map(a.nodes.map(n => [n.n.id,n]));
+ const focus = scene => {
+ const ns = scene.nodes.map(n => {
+ const p = {...(pos.get(n.n.id) || oldNodes.get(n.n.id) || n)};
+ // Clear the enclosure of unrelated boxes. Their exact placement is
+ // deliberately unimportant here, but they must not obscure the edit.
+ if (!ids.has(n.n.id) && p.x+p.width > x-24 && p.x < x+area.width+24 &&
+ p.y+p.height > y-24 && p.y < y+area.height+24) {
+ const dx = (p.x+p.width/2-x-area.width/2)/area.width;
+ const dy = (p.y+p.height/2-y-area.height/2)/area.height;
+ if (Math.abs(dx) > Math.abs(dy)) p.x = dx < 0 ? x-p.width-48 : x+area.width+48;
+ else p.y = dy < 0 ? y-p.height-48 : y+area.height+48;
+ }
+ return {...n, x: p.x, y: p.y, width: p.width, height: p.height,
+ ports: n.ports.map(q => ({...q, x: (q.x+3)*p.width/n.width-3, y: (q.y+3)*p.height/n.height-3}))};
+ });
+ const byId = new Map(ns.map(n => [n.n.id,n]));
+ const parser = scene.parser && {...scene.parser,
+ x: a.parser?.x ?? scene.parser.x, y: a.parser?.y ?? scene.parser.y};
+ const es = scene.edges.map(e => {
+ const def = byId.get(e.def), use = e.role === "PARSER" ? parser : byId.get(e.use);
+ const p = def?.ports.find(p => p.id === e.sources[0]), q = use?.ports.find(p => p.id === e.targets[0]);
+ if (!p || !q) return e;
+ const x = def.x+p.x+3, y = def.y+p.y+3, u = use.x+q.x+3, v = use.y+q.y+3;
+ const bend = Math.max(24, Math.abs(v-y)/2);
+ return {...e, path: `M${x},${y}C${x},${y+bend} ${u},${v-bend} ${u},${v}`};
+ });
+ // Container furniture fades away while the local enclosure explains scope.
+ return {...scene, nodes: ns, edges: es, parser, groups: a.groups, jumps: [],
+ peep: {area, ids, rim, changed: diff.changed}};
+ };
+ return {scenes: [focus(a), focus(b)], area};
+ }
+}
diff --git a/graph/web/render.js b/graph/web/render.js
index 7fe058b3..e8b664d2 100644
--- a/graph/web/render.js
+++ b/graph/web/render.js
@@ -17,6 +17,7 @@ class GraphView {
}
this.world = this.svg.append("g");
this.regions = this.world.append("g").attr("class", "regions");
+ this.halo = this.world.append("rect").attr("class", "peep-area").attr("rx", 36).style("opacity", 0);
this.links = this.world.append("g").attr("class", "edges");
this.hits = this.world.append("g").attr("class", "edge-hits");
this.parser = this.world.append("g").attr("class", "parser");
@@ -24,13 +25,14 @@ class GraphView {
// Titles/buttons stay above crossing edges, even with narrow group margins.
this.heads = this.world.append("g").attr("class", "region-heads");
this.shortcuts = this.world.append("g").attr("class", "shortcuts");
+ this.ghosts = this.world.append("g").attr("class", "peep-ghosts").attr("pointer-events", "none");
// Keep the same edge clickable under a stationary mouse during the pan,
// even while other nodes/edges pass underneath it.
this.hop = null;
this.spot = this.svg.append("circle").attr("class", "edge-jump").attr("r", 7).style("display", "none")
.on("click", event => { if (this.hop) this.edgeJump(event, this.hop.e, this.hop.path); });
this.zoom = d3.zoom().scaleExtent([.02, 4]).on("zoom", event => {
- if (event.sourceEvent) { this.auto = false; this.clearJump(); }
+ if (event.sourceEvent) { this.auto = false; this.clearJump(); this.svg.interrupt("peep-view"); }
this.world.attr("transform", event.transform);
});
this.svg.call(this.zoom).on("dblclick.zoom", null)
@@ -48,6 +50,7 @@ class GraphView {
}
show(snap, scene, evt) {
+ this.cancelMove();
this.clearJump();
if (this.comp !== snap.comp) {
this.comp = snap.comp;
@@ -64,7 +67,7 @@ class GraphView {
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" : g.n.kind === "FUN" ? "#fff8ec" : "#f2f7ef");
+ .attr("fill", g => g.n.folding ? "#fff0f3" : g.n.kind === "LOOP" ? "#f1f6fc" : g.n.kind === "FUN" ? "#fff8ec" : "#f2f7ef");
const heads = this.heads.selectAll("g.region-head").data(scene.groups || [], g => g.gid).join(enter => {
const g = enter.append("g").attr("class", "region-head");
g.append("text").attr("class", "label").attr("x", 26).attr("y", 17);
@@ -72,7 +75,7 @@ class GraphView {
return g;
}).attr("transform", g => `translate(${g.x},${g.y})`);
heads.select("text.label").each((g, i, items) => this.label(d3.select(items[i]),
- g.n.label.slice(0, Math.max(12, Math.floor((g.width-80)/7))) + ` #${g.gid}`));
+ g.n.label.slice(0, Math.max(12, Math.floor((g.width-80-(g.n.folding ? 60 : 0))/7))) + ` #${g.gid}`, g.n.folding));
heads.select("title").text(g => `${g.n.label} · ${g.members.size} nodes`);
heads.each((g, i, items) => this.foldButton(d3.select(items[i]), g.gid, false, 4, 4));
this.links.selectAll("path.edge").data(scene.edges, e => e.id).join("path")
@@ -116,6 +119,7 @@ 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)
+ .classed("folding", n => !!n.n.folding)
.attr("transform", n => `translate(${n.x},${n.y})`)
.on("click", (event, n) => { event.stopPropagation(); this.clearJump(); this.select(n.n.id); });
boxes.select("path.box").attr("d", n => this.shape(n))
@@ -123,7 +127,7 @@ class GraphView {
UNIT: "#ffe0ab", START: "#fff1c4", STOP: "#fff1c4", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]);
boxes.select("text.label").attr("x", n => (n.width - (n.n.fold ? 24 : 0)) / 2)
.attr("y", n => n.compact ? 17 : n.scope ? 48 : 18)
- .each((n, i, items) => this.label(d3.select(items[i]), n.label));
+ .each((n, i, items) => this.label(d3.select(items[i]), n.label, n.n.folding));
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) => {
@@ -174,15 +178,122 @@ class GraphView {
jumps.select("title").text(j => `Center function #${j.target}\n` +
j.refs.map(r => `#${r.use}[${r.idx}] → #${r.def} · ${r.role}`).join("\n"));
this.assocs(document.getElementById("assocs").checked);
+ const peep = scene.peep;
+ if (peep) this.halo.attr("x", peep.area.x).attr("y", peep.area.y)
+ .attr("width", peep.area.width).attr("height", peep.area.height);
+ this.halo.style("opacity", peep ? 1 : 0);
+ this.nodes.selectAll("g.node").style("opacity", n => !peep || peep.ids.has(n.n.id) ? 1 : peep.rim.has(n.n.id) ? .4 : .08)
+ .classed("peep-edit", n => !!peep?.changed.has(n.n.id));
+ this.links.selectAll("path.edge").style("opacity", e => !peep ? 1 :
+ peep.ids.has(e.def) && peep.ids.has(e.use) ? 1 : peep.ids.has(e.def) || peep.ids.has(e.use) ? .4 : .04);
+ this.hits.style("pointer-events", peep ? "none" : null).style("display", peep ? "none" : null);
+ for (const layer of [this.regions, this.heads, this.parser, this.shortcuts])
+ layer.style("opacity", peep ? 0 : 1).style("pointer-events", peep ? "none" : null);
+ if (peep?.rim.has(0)) this.parser.style("opacity", .4);
+ this.nodes.selectAll(".fold-btn").style("display", peep ? "none" : null);
this.mark();
this.select(this.pick);
if (this.auto) this.fit();
}
- label(host, text) {
+ cancelMove() {
+ this.svg.interrupt("peep-view");
+ this.world.selectAll("*").interrupt("peep-move");
+ this.ghosts.selectAll("*").remove();
+ }
+
+ // Sample paths so an orthogonal route can smoothly become a local curve.
+ // Rewired edges cross-fade instead of appearing to sweep through other defs.
+ pathTween(path, from, to) {
+ const points = d => {
+ path.setAttribute("d", d);
+ const len = path.getTotalLength();
+ return Array.from({length: 33}, (_,i) => {
+ const p = path.getPointAtLength(len*i/32);
+ return [p.x,p.y];
+ });
+ };
+ const a = points(from), b = points(to), mix = d3.interpolateArray(a,b);
+ path.setAttribute("d", from);
+ return t => t === 1 ? to : mix(t).map((p,i) => `${i ? "L" : "M"}${p[0]},${p[1]}`).join(" ");
+ }
+
+ async move(snap, scene, evt, view) {
+ this.cancelMove();
+ this.clearJump();
+ const ms = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 480;
+ const now = d3.zoomTransform(this.svg.node());
+ const oldNodes = new Map(), oldEdges = new Map();
+ this.nodes.selectAll("g.node").each(function(n) {
+ oldNodes.set(n.id, {el: this.cloneNode(true), at: this.getAttribute("transform"), opacity: this.style.opacity});
+ });
+ this.links.selectAll("path.edge").each(function(e) {
+ oldEdges.set(e.id, {e, el: this.cloneNode(true), path: this.getAttribute("d"), opacity: this.style.opacity});
+ });
+ const opacity = new Map([this.regions, this.heads, this.parser, this.shortcuts, this.halo].map(s => [s, s.style("opacity")]));
+ this.auto = false;
+ this.show(snap, scene, evt);
+ const jobs = [];
+ const tween = s => {
+ const t = s.transition("peep-move").duration(ms).ease(d3.easeCubicInOut);
+ jobs.push(t.end().catch(() => {}));
+ return t;
+ };
+ const ghost = (el, opacity) => {
+ el.removeAttribute("id");
+ el.querySelectorAll("[id]").forEach(n => n.removeAttribute("id"));
+ this.ghosts.node().append(el);
+ tween(d3.select(el).style("opacity", opacity || 1)).style("opacity", 0).remove();
+ };
+ this.nodes.selectAll("g.node").each((n,i,items) => {
+ const s = d3.select(items[i]), at = s.attr("transform"), opacity = s.style("opacity");
+ const old = oldNodes.get(n.id);
+ oldNodes.delete(n.id);
+ s.attr("transform", old?.at || at).style("opacity", old?.opacity || 0);
+ tween(s).attr("transform", at).style("opacity", opacity);
+ });
+ for (const n of oldNodes.values()) ghost(n.el, n.opacity);
+ this.links.selectAll("path.edge").each((e,i,items) => {
+ const s = d3.select(items[i]), opacity = s.style("opacity"), old = oldEdges.get(e.id);
+ oldEdges.delete(e.id);
+ if (old && old.e.def === e.def && old.e.use === e.use) {
+ const path = this.pathTween(items[i], old.path, e.path);
+ tween(s.style("opacity", old.opacity)).attrTween("d", () => path).style("opacity", opacity);
+ } else {
+ if (old) ghost(old.el, old.opacity);
+ tween(s.style("opacity", 0)).style("opacity", opacity);
+ }
+ });
+ for (const e of oldEdges.values()) ghost(e.el, e.opacity);
+ for (const [s, old] of opacity) {
+ const next = s.style("opacity");
+ tween(s.style("opacity", old)).style("opacity", next);
+ }
+ // Hide hit targets until the drawn routes reach their destination.
+ this.hits.style("display", "none");
+ const mix = d3.interpolateArray([now.x,now.y,now.k], [view.x,view.y,view.k]);
+ jobs.push(this.svg.transition("peep-view").duration(ms).ease(d3.easeCubicInOut)
+ .tween("view", () => t => {
+ const [x,y,k] = mix(t);
+ this.svg.call(this.zoom.transform, d3.zoomIdentity.translate(x,y).scale(k));
+ }).end().catch(() => {}));
+ await Promise.all(jobs);
+ if (this.scene === scene) this.hits.style("display", scene.peep ? "none" : null);
+ }
+
+ view(area = this.scene) {
+ const w = this.host.clientWidth, h = this.host.clientHeight;
+ const k = Math.max(.02, Math.min(1.5, (w-32)/area.width, (h-32)/area.height));
+ return d3.zoomIdentity.translate((w-area.width*k)/2-(area.x || 0)*k,
+ (h-area.height*k)/2-(area.y || 0)*k).scale(k);
+ }
+
+ label(host, text, folding = false) {
const split = text.lastIndexOf(" #");
- host.selectAll("tspan").data([text.slice(0, split), "\u00a0\u00a0" + text.slice(split + 1)]).join("tspan")
- .attr("class", (_, i) => i ? "node-id" : null).text(s => s);
+ const parts = [text.slice(0, split), "\u00a0\u00a0" + text.slice(split + 1)];
+ if (folding) parts.push("\u00a0\u00a0FOLDING");
+ host.selectAll("tspan").data(parts).join("tspan")
+ .attr("class", (_, i) => i === 2 ? "fold-tag" : i ? "node-id" : null).text(s => s);
}
foldButton(host, id, folded, x, y) {
@@ -221,6 +332,7 @@ class GraphView {
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 : ""}` +
+ (n.folding ? "\nFOLDING: being inlined; pending removal" : "") +
(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") : "") +
@@ -240,6 +352,7 @@ class GraphView {
detail.hidden = !id;
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.`;
+ if (this.evt?.kind === "ERROR" && this.evt.node === id) detail.textContent += "\n\n" + this.evt.msg;
}
assocs(show) {
@@ -281,21 +394,23 @@ class GraphView {
d3.zoomIdentity.translate(ix(t),iy(t)).scale(now.k)));
}
- center(id) {
+ center(id, readable = false) {
this.clearJump();
const dst = this.scene.cover?.get(id) || id;
const n = this.scene.nodes.find(n => n.n.id === dst);
if (!n) return;
this.auto = false;
this.select(id);
+ if (readable) this.svg.call(this.zoom.scaleTo, Math.min(1.2, (this.host.clientWidth - 40) / n.width));
this.svg.call(this.zoom.translateTo, n.x + n.width / 2, n.y + n.height / 2);
}
mark() {
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) ||
+ const near = new Set(show ? this.scene?.peep?.ids || evt?.near : []);
+ this.nodes.selectAll("g.node").classed("error", n => evt?.kind === "ERROR" && n.n.id === evt.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))));
@@ -332,9 +447,6 @@ class GraphView {
fit() {
this.clearJump();
if (!this.scene || !this.host.clientWidth || !this.host.clientHeight) return;
- const w = this.host.clientWidth, h = this.host.clientHeight;
- const scale = Math.max(.02, Math.min(1.5, (w - 32) / this.scene.width, (h - 32) / this.scene.height));
- this.svg.call(this.zoom.transform, d3.zoomIdentity
- .translate((w - this.scene.width * scale) / 2, (h - this.scene.height * scale) / 2).scale(scale));
+ this.svg.call(this.zoom.transform, this.view());
}
}
diff --git a/graph/web/viewer.js b/graph/web/viewer.js
index 7e41c082..6309deae 100644
--- a/graph/web/viewer.js
+++ b/graph/web/viewer.js
@@ -19,6 +19,53 @@ let compiling = false;
let connection = "Connecting to compiler...";
let failure = "";
let busy = "";
+let peep = null;
+let stepping = false;
+let motion = 0;
+
+// Nested captures describe work inside an enclosing edit, not stable stops.
+// Keep capture numbers, but make navigation land on the outer before/result.
+function stable(index) {
+ while (index > 0 && frames[index]?.evt?.up) index--;
+ const end = resultEnd(index);
+ if (end < 0 || end > index) {
+ const id = frames[index].evt.peep;
+ while (index > 0 && !(frames[index].evt.kind === "BEFORE" && frames[index].evt.peep === id)) index--;
+ }
+ return index;
+}
+
+function resultEnd(index) {
+ const frame = frames[index], evt = frame?.evt;
+ if (evt?.kind !== "RETURN" || evt.up || !evt.repl || evt.repl === evt.node) return index;
+ if (frame.attach !== undefined) return frame.attach;
+ if (!frame.snap.nodes.some(n => n.id === evt.node)) return frame.attach = index;
+ const next = frames[index+1];
+ if (!next) return done || failure ? index : -1;
+ return frame.attach = !next.evt.up && ["BEFORE", "PHASE"].includes(next.evt.kind) &&
+ next.evt.phase === evt.phase && GraphPeep.attached(frame.snap, next.snap, evt) ? index+1 : index;
+}
+
+function editEvent(from, to) {
+ const evt = frames[to-1]?.evt;
+ return evt?.peep === frames[from].evt?.peep && resultEnd(to-1) === to ? evt : frames[to].evt;
+}
+
+function nextFrame(index, dir) {
+ const evt = frames[index]?.evt;
+ const pair = dir > 0 ? evt?.kind === "BEFORE" : ["RETURN", "APPLY"].includes(evt?.kind);
+ if (pair) {
+ for (let i = index+dir; i >= 0 && i < frames.length; i += dir) {
+ const e = frames[i].evt;
+ if (e?.peep === evt.peep && (dir > 0 ? ["RETURN", "APPLY"].includes(e.kind) : e.kind === "BEFORE"))
+ return dir > 0 ? resultEnd(i) : i;
+ }
+ // A streaming capture hasn't delivered this peep's result yet.
+ return -1;
+ }
+ const dest = index+dir;
+ return dest >= 0 && dest < frames.length ? stable(dest) : -1;
+}
function updateUI() {
status.textContent = failure || (connection !== "Connected" ? connection :
@@ -32,31 +79,36 @@ function updateUI() {
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("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("doFirst").disabled = !rendererReady || !frames.length || (!peep && !stepping && at <= 0);
+ document.getElementById("doLast").disabled = !rendererReady || !frames.length || (!peep && !stepping && at >= stable(frames.length-1));
+ document.getElementById("doPrev").disabled = !rendererReady || rendering || stepping || (!peep && at <= 0);
+ document.getElementById("doNext").disabled = !rendererReady || rendering || stepping || (!peep && nextFrame(at,1) < 0);
document.getElementById("compile").disabled =
- !socket || socket.readyState !== WebSocket.OPEN || compiling || rendering;
+ !socket || socket.readyState !== WebSocket.OPEN || compiling || rendering || stepping;
document.getElementById("fit").disabled = rendering || current < 0;
- document.getElementById("save").disabled = rendering || !frames[current]?.snap;
- document.getElementById("unfold").disabled = !folded.size;
+ document.getElementById("save").disabled = rendering || stepping || !!peep || !frames[current]?.snap;
+ document.getElementById("unfold").disabled = !folded.size || !!peep || stepping;
document.getElementById("assocs").disabled = !frames[current]?.snap;
document.getElementById("near").disabled = !frames[current]?.evt;
- const evt = frames[current]?.evt;
+ const result = peep?.evt;
+ const evt = peep ? (peep.stage === 1 ? {...result, kind: "BEFORE"} : result) : frames[current]?.evt;
let label = "";
if (evt) {
- const text = evt.kind === "PHASE" ? "Phase complete" :
+ const text = evt.kind === "ERROR" ? `Error${evt.node ? " at node #" + evt.node : ""}: ${evt.msg}` :
+ evt.kind === "PHASE" ? "Phase complete" :
evt.kind === "BEFORE" ? `Before peephole #${evt.peep} at node #${evt.node}` :
evt.kind === "RETURN" ? `Return #${evt.repl} for #${evt.node}` :
!evt.repl ? `Removed #${evt.node}` : evt.repl === evt.node ? `Updated #${evt.node}` :
`Replaced #${evt.node} with #${evt.repl}`;
label = `${evt.phase} · ${text}${evt.up ? " (inside peephole #" + evt.up + ")" : ""}`;
}
+ if (peep) label += ` · ${peep.stage === 1 ? "Gathered — next: rewrite" : "Rewritten — next: release"} (between frames ${peep.from+1} and ${peep.to+1})`;
document.getElementById("event").textContent = label;
+ document.getElementById("event").classList.toggle("error", evt?.kind === "ERROR");
}
function reportError(error) {
+ cancelPeep();
failure = "Viewer error: " + (error && error.message || error || "Graph rendering failed");
rendering = false;
wanted = -1;
@@ -76,6 +128,7 @@ function get_program() {
return;
}
if (compiling || rendering) return;
+ cancelPeep();
generation++;
frames = [];
current = -1;
@@ -91,6 +144,8 @@ function get_program() {
async function render(index) {
if (!rendererReady || index < 0 || index >= frames.length) return;
+ index = stable(index);
+ cancelPeep();
wanted = index;
if (rendering) { updateUI(); return; }
const frameGeneration = generation;
@@ -101,30 +156,16 @@ async function render(index) {
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...";
+ busy = "Laying out frame " + (index + 1) + "...";
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;
- }
+ const scene = await sceneFor(frame);
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.
- 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.
- if (document.activeElement !== frameNo && document.activeElement !== scrub)
- program.focus({preventScroll: true});
- }
+ if (frame.evt.kind === "ERROR" && frame.evt.node) renderer.center(frame.evt.node, true);
+ cursor(frame);
current = index;
wanted = -1;
}
@@ -136,17 +177,125 @@ async function render(index) {
}
}
+async function sceneFor(frame, folds = folded) {
+ if (!layout) layout = new GraphLayout();
+ // Reveal the diagnostic even inside a folded function/loop. Keep the user's
+ // fold choices for earlier frames.
+ if (frame.evt.kind === "ERROR" && frame.evt.node && folds.size) {
+ folds = new Set(folds);
+ const view = GraphGroups.view(frame.snap, new Set());
+ for (const id of folds)
+ if (view.groups.get(id)?.members.has(frame.evt.node)) folds.delete(id);
+ }
+ const key = [...folds].sort((a,b) => a-b).join(",");
+ let scene = key ? (frame.foldKey === key ? frame.foldLayout : null) : frame.layout;
+ if (!scene) {
+ scene = await layout.run(frame.snap, frame.evt, new Set(folds));
+ // Ordinary layout plus only the most recent folded variant.
+ if (key) { frame.foldKey = key; frame.foldLayout = scene; }
+ else frame.layout = scene;
+ }
+ return scene;
+}
+
+function cursor(frame) {
+ if (frame.pos < 0) return;
+ // Keep the last visible parsed character selected at whitespace/EOF.
+ 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);
+ // Keep navigation focus: moving the source cursor must not turn the next
+ // playback arrow into a text-editing key.
+}
+
+function cancelPeep() {
+ motion++;
+ stepping = false;
+ renderer?.cancelMove();
+ if (peep) {
+ renderer.auto = peep.auto;
+ renderer.svg.call(renderer.zoom.transform, peep.view);
+ peep = null;
+ }
+}
+
+function releaseView(p, scene) {
+ if (p.auto) return renderer.view(scene);
+ // Keep a nearby surviving node at the same screen position after relayout.
+ const [x,y] = p.view.invert([renderer.host.clientWidth/2, renderer.host.clientHeight/2]);
+ const nodes = new Map(scene.nodes.map(n => [n.id,n]));
+ let anchor, dist = Infinity;
+ for (const n of p.entry.nodes) if (nodes.has(n.id)) {
+ const d = Math.hypot(n.x+n.width/2-x, n.y+n.height/2-y);
+ if (d < dist) { anchor = n; dist = d; }
+ }
+ if (!anchor) return p.view;
+ const n = nodes.get(anchor.id);
+ return p.view.translate(anchor.x+anchor.width/2-n.x-n.width/2,
+ anchor.y+anchor.height/2-n.y-n.height/2);
+}
+
+async function step(dir) {
+ if (!rendererReady || rendering || stepping) return;
+ const ticket = ++motion;
+ try {
+ if (!peep) {
+ const dest = nextFrame(current,dir);
+ if (current < 0 || dest < 0 || dest >= frames.length) return;
+ const from = Math.min(current,dest), to = Math.max(current,dest);
+ const evt = editEvent(from,to);
+ // Pair the enclosing attempt's before/result, including any recursive
+ // construction of replacement nodes. Never expose those nodes early.
+ const diff = ["RETURN", "APPLY"].includes(evt?.kind) && GraphPeep.diff(frames[from].snap, frames[to].snap, evt);
+ if (!diff?.structural) return render(dest);
+ stepping = true;
+ busy = "Gathering peephole neighborhood...";
+ const view = d3.zoomTransform(renderer.svg.node()), auto = renderer.auto, entry = renderer.scene;
+ updateUI();
+ const local = await GraphPeep.make(frames[from], frames[to], diff, layout, folded, sceneFor, evt);
+ if (ticket !== motion) return;
+ peep = {...local, from, to, evt, stage: dir > 0 ? 0 : 3, view, auto, entry};
+ }
+ stepping = true;
+ const p = peep, stage = p.stage+dir;
+ const index = stage <= 1 ? p.from : p.to, frame = frames[index];
+ busy = stage === 1 ? "Gathering..." : stage === 2 ? "Rewriting..." : "Returning to graph...";
+ updateUI();
+ const scene = stage === 0 || stage === 3 ? await sceneFor(frame) : p.scenes[stage-1];
+ if (ticket !== motion) return;
+ const view = scene.peep ? renderer.view(p.area) : releaseView(p, scene);
+ p.stage = stage;
+ current = index;
+ cursor(frame);
+ updateUI();
+ await renderer.move(frame.snap, scene, p.evt, view);
+ if (ticket !== motion) return;
+ if (!scene.peep) {
+ renderer.auto = p.auto;
+ renderer.evt = frame.evt;
+ renderer.mark();
+ peep = null;
+ }
+ stepping = false;
+ busy = "";
+ updateUI();
+ } catch (error) {
+ if (ticket === motion) reportError(error);
+ }
+}
+
function doFirst() { render(0); }
function toggleFold(id) {
+ if (peep || stepping) return;
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 doNext() { return step(1); }
+function doPrev() { return step(-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;
+ const index = stable(Number.isFinite(n) ? Math.max(0, Math.min(frames.length - 1, Math.trunc(n) - 1)) : current);
frameNo.value = String(index + 1);
render(index);
}
@@ -170,7 +319,21 @@ frameNo.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); jump(); }
});
scrub.addEventListener("input", () => render(scrub.valueAsNumber - 1));
+scrub.addEventListener("keydown", event => {
+ const dir = {ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1}[event.key];
+ if (!dir) return;
+ event.preventDefault();
+ // A native one-frame increment could land inside the same enclosing peep
+ // and snap back forever. Slider keys skip directly to the next stable stop.
+ const index = nextFrame(wanted >= 0 ? wanted : current, dir);
+ if (index >= 0) render(index);
+});
document.getElementById("fit").addEventListener("click", () => {
+ if (peep) {
+ renderer.svg.interrupt("peep-view");
+ renderer.svg.call(renderer.zoom.transform, renderer.view(peep.area));
+ return;
+ }
renderer.auto = true;
renderer.fit();
});
@@ -178,7 +341,16 @@ document.getElementById("assocs").addEventListener("change", event => renderer.a
document.getElementById("near").addEventListener("change", () => renderer.mark());
document.getElementById("save").addEventListener("click", () => renderer.save(frames[current].snap.step));
document.addEventListener("keydown", event => {
- if (event.key === "Escape") { renderer.clearJump(); renderer.select(0); }
+ if (!event.defaultPrevented && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey &&
+ !event.target.closest("input, textarea, select") && !event.target.isContentEditable &&
+ (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
+ event.preventDefault();
+ document.getElementById(event.key === "ArrowLeft" ? "doPrev" : "doNext").click();
+ }
+ if (event.key === "Escape") {
+ if (peep || stepping) render(current);
+ renderer.clearJump(); renderer.select(0);
+ }
});
try {