diff options
Diffstat (limited to 'graph/web')
| -rw-r--r-- | graph/web/index.html | 12 | ||||
| -rw-r--r-- | graph/web/layout.js | 5 | ||||
| -rw-r--r-- | graph/web/peep.js | 197 | ||||
| -rw-r--r-- | graph/web/render.js | 140 | ||||
| -rw-r--r-- | graph/web/viewer.js | 234 |
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">|⮜</button> - <button id="doPrev" type="button" onclick="doPrev()" class="button" aria-label="Previous frame">⮜</button> + <button id="doPrev" type="button" onclick="doPrev()" class="button" aria-label="Previous frame" aria-keyshortcuts="ArrowLeft" title="Previous step (Left arrow)">⮜</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">⮞</button> + <button id="doNext" type="button" onclick="doNext()" class="button" aria-label="Next frame" aria-keyshortcuts="ArrowRight" title="Next step (Right arrow)">⮞</button> <button id="doLast" type="button" onclick="doLast()" class="button" aria-label="Last frame" title="Last available frame">⮞|</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 { |
