diff options
| author | Cliff Click <cliffc@acm.org> | 2026-09-25 15:00:00 -0700 |
|---|---|---|
| committer | Cliff Click <cliffc@acm.org> | 2026-09-25 15:00:00 -0700 |
| commit | a32e83c9d81cfdb3759a2961d8b192ca44d75935 (patch) | |
| tree | 9898306adf1a2d2432f333a50db5576999fb82c8 /graph/web/render.js | |
| parent | e9e02b18ee3223f2cfe1990be00674f9868e1a26 (diff) | |
| download | Simple-a32e83c9d81cfdb3759a2961d8b192ca44d75935.tar.gz Simple-a32e83c9d81cfdb3759a2961d8b192ca44d75935.zip | |
Bunch more animation work
Diffstat (limited to 'graph/web/render.js')
| -rw-r--r-- | graph/web/render.js | 140 |
1 files changed, 126 insertions, 14 deletions
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()); } } |
