summaryrefslogtreecommitdiff
path: root/graph/web/render.js
diff options
context:
space:
mode:
Diffstat (limited to 'graph/web/render.js')
-rw-r--r--graph/web/render.js140
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());
}
}