diff options
| author | Cliff Click <cliffc@acm.org> | 2026-09-24 14:07:50 -0700 |
|---|---|---|
| committer | Cliff Click <cliffc@acm.org> | 2026-09-24 14:07:50 -0700 |
| commit | e9e02b18ee3223f2cfe1990be00674f9868e1a26 (patch) | |
| tree | 208421237588f436457d80766f2887a383017a54 /graph | |
| parent | 7279862d57c97063eb46997804b5efb6c0b07189 (diff) | |
| download | Simple-e9e02b18ee3223f2cfe1990be00674f9868e1a26.tar.gz Simple-e9e02b18ee3223f2cfe1990be00674f9868e1a26.zip | |
edge zooming
Diffstat (limited to 'graph')
| -rw-r--r-- | graph/README.md | 13 | ||||
| -rw-r--r-- | graph/web/index.html | 4 | ||||
| -rw-r--r-- | graph/web/layout.js | 73 | ||||
| -rw-r--r-- | graph/web/render.js | 83 | ||||
| -rw-r--r-- | graph/web/viewer.js | 2 |
5 files changed, 155 insertions, 20 deletions
diff --git a/graph/README.md b/graph/README.md index 32f5e094..27845b46 100644 --- a/graph/README.md +++ b/graph/README.md @@ -52,6 +52,12 @@ graph again. Clicking a node shows its full label, type and input slots, and highlights its edges. Selection follows that node's ID across frames; Escape clears it. **Associations** shows scope bindings and lifetime edges, which are hidden by default. Input slots retain their indices, including null slots. +Click the middle of an edge to pan to its use; click near the use to jump to +the def. Repeated clicks without moving the mouse alternate between the two. +The edge lands under the pointer just outside the node, preserving zoom, with +a short eased pan. Folded edges jump to the visible folded boxes. Moving the +mouse away, panning, zooming, Escape, Fit, or changing frames cancels the jump. +The pan is immediate when the browser requests reduced motion. **Peep neighborhood** outlines nearby nodes in amber, with a darker outline on the current node/replacement. The event label identifies the phase, rewrite, and enclosing rewrite when calls nest. This marks context; it does not zoom or @@ -159,7 +165,10 @@ The browser owns frame history and backward/forward playback. It uses ELK's layered layout with fixed input ports and orthogonal routing. Layout follows def-to-use flow downward, while the displayed arrows point from use to def, matching Simple's edges. Control edges get a higher layout -priority; known Loop/Phi backedges get a lower priority. Network-simplex node +priority; known Loop/Phi backedges get a lower priority. Expanded loops reserve +a right-side gutter for their control and Phi backedges, with separate tracks +and leads routed around node boxes. Nested loops use their own gutters; folding +a loop hides its internal backedges. Network-simplex node placement gives forward control edges a straightness priority of 100; other edges have zero straightness priority. Associations are drawn as a separate overlay and do not constrain layout. Each MultiNode and its @@ -167,7 +176,7 @@ projections occupy one box: the parent above, projection cells below in index order. Each cell keeps its node ID, selection, highlights and edge connections; the internal parent/projection edge is represented by the shared box. A projection whose parent is absent is drawn on its own. Node kinds use color -and shape rather than a KIND label. IDs share the name line; projections use +and shape rather than a KIND label. Small IDs follow the name on the same line; projections use `#id/idx`. Ordinary nodes have two lines (name and type) in a 44-pixel box. Phi slot 0 uses a short left-facing arrow at the middle of the box's left side, diff --git a/graph/web/index.html b/graph/web/index.html index 198db3ad..4a32b461 100644 --- a/graph/web/index.html +++ b/graph/web/index.html @@ -24,6 +24,8 @@ .edge.selected { stroke-width:3.5; } .edge.bundle { stroke-width:4; } .edge.bundle.selected { stroke-width:6; } + .edge-hit { fill:none; stroke:transparent; stroke-width:12; vector-effect:non-scaling-stroke; pointer-events:stroke; cursor:pointer; } + .edge-jump { fill:transparent; pointer-events:all; cursor:pointer; } .node { cursor:pointer; } .box { stroke:#758191; stroke-width:1; } .node.held .box { stroke-dasharray:5 3; } @@ -78,7 +80,7 @@ <label><input id="assocs" type="checkbox"> Associations</label> <label><input id="near" type="checkbox" checked> Peep neighborhood</label> <span class="ctrl">Control</span><span class="data">Data</span><span class="mem">Memory</span> - <span class="hint">Scroll to zoom · drag to pan · click a node for details</span> + <span class="hint">Scroll to zoom · drag to pan · click a node for details · click an edge to jump between ends</span> </div> <div id="event" class="hint" role="status"></div> </div> diff --git a/graph/web/layout.js b/graph/web/layout.js index 68445a3b..8260fbd2 100644 --- a/graph/web/layout.js +++ b/graph/web/layout.js @@ -23,7 +23,7 @@ class GraphLayout { // The whole-program SCCP cycle is not source control flow. const hidden = (n, e) => ["START", "UNIT"].includes(n.kind) && raw.get(e.def)?.kind === "STOP"; const nodes = snap.nodes.map(n => { - const label = `#${n.id}${n.proj ? "/" + n.proj.idx : ""} ${this.clip(n.label)}`, type = this.clip(n.type); + 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, this.ctx.measureText(type).width + 24, (n.edges.length + 1) * 18)); @@ -98,7 +98,7 @@ class GraphLayout { // ELK to the remote function or Return and thus cannot order functions. const links = []; for (const p of parts) for (const e of p.n.edges) if (e.jump) { - const label = `↗ #${e.jump} ${this.clip(view.raw.get(e.jump).label)}`; + const label = `↗ ${this.clip(view.raw.get(e.jump).label)} #${e.jump}`; links.push({id: `j${p.n.id}f${e.jump}`, box: n.id, use: p.n.id, target: e.jump, slot: e.idx, refs: e.refs, role: e.role, label, x: width + 24, y: links.length * 28, height: 22, @@ -143,7 +143,7 @@ class GraphLayout { return {id: n.id, width: links.length ? width + 24 + Math.max(...links.map(j => j.width)) : width, height: Math.max(height, links.length * 28), ports, parts}; }); - const edges = []; + const edges = [], backs = new Map(); for (const use of snap.nodes) { for (const e of use.edges) { if (!e.def || e.jump || hidden(use, e)) continue; @@ -154,8 +154,11 @@ class GraphLayout { const reg = byId.get(use.edges[0]?.def)?.n; const back = !use.fold && e.idx === 2 && (use.kind === "LOOP" || (use.kind === "PHI" && reg?.kind === "LOOP")); + const loop = back ? (use.kind === "LOOP" ? use.id : reg.id) : 0; + const wrap = loop && view.groups.has(loop) && !raw.get(loop).fold ? loop : 0; + if (wrap) backs.set(wrap, (backs.get(wrap) || 0) + 1); edges.push({id, use: use.id, def: e.def, idx: e.orig.idx, orig: e.orig, refs: e.refs, bundle, role: e.role, label: e.label, - bind: use.kind === "SCOPE", + bind: use.kind === "SCOPE", wrap, // Layout follows value/control flow downward. SVG arrows point back // from use to def, matching Simple's actual edge direction. sources: ["n" + e.def + "o" + (byId.get(e.def).n.fold ? use.id : "")], targets: ["n" + use.id + "i" + e.idx], @@ -175,7 +178,7 @@ class GraphLayout { "elk.randomSeed": 1 }; const containers = new Map(view.open.map(g => [g.id, {id: "g" + g.id, children: [], - layoutOptions: {...opts, "elk.padding": "[top=34,left=12,bottom=12,right=12]"}}])); + layoutOptions: {...opts, "elk.padding": `[top=${34 + (backs.get(g.id) || 0) * 8},left=12,bottom=12,right=${backs.has(g.id) ? 24 + backs.get(g.id) * 10 : 12}]`}}])); const children = []; const add = (par, box) => (containers.get(par)?.children || children).push(box); for (const g of view.open) add(g.par, containers.get(g.id)); @@ -316,6 +319,10 @@ class GraphLayout { for (const n of owned) edges.push({id: "parser-hold-" + n.n.id, scope: n.scope, active: n === active, use: 0, def: n.n.id, role: "PARSER", sources: [n.id + "o"], targets: ["parser-i" + n.n.id]}); } + // Each expanded loop reserves a clear gutter on its right. Route the + // endpoint leads around node boxes, then join them along that gutter. + const obstacles = groups.filter(g => !g.parts[0].scope && !g.parts[0].stop).map(g => boxes.get(g.id)); + const lanes = new Map(); for (const e of edges) { if (e.role === "ASSOC" || e.role === "PARSER" || e.bind || byId.get(e.def).scope || byId.get(e.use)?.stop || byId.get(e.def).stop) { @@ -331,6 +338,22 @@ class GraphLayout { [s.startPoint, ...(s.bendPoints || []), s.endPoint] .map((p, i) => `${i ? "L" : "M"}${p.x + origin.x},${p.y + origin.y}`).join(" ")).join(" "); } + if (e.wrap) { + const box = boxes.get("g" + e.wrap), i = lanes.get(e.wrap) || 0; + lanes.set(e.wrap, i + 1); + const def = byId.get(e.def), use = byId.get(e.use); + const a = def.ports.find(p => p.id === e.sources[0]), b = use.ports.find(p => p.id === e.targets[0]); + const start = {x: def.x + a.x + 3, y: def.y + a.y + 3}; + const end = {x: use.x + b.x + 3, y: use.y + b.y + 3}; + const east = a.layoutOptions["elk.port.side"] === "EAST"; + const from = {x: start.x + (east ? 10 : 0), y: start.y + (east ? 0 : 10)}; + const to = {x: end.x, y: end.y - 12 - (backs.get(e.wrap) - i - 1) * 8}; + const right = box.x + box.width - 12 - i * 10; + const head = this.toRight(from, right, obstacles), tail = this.toRight(to, right, obstacles); + const pts = [start, ...head, ...tail.reverse(), end]; + e.path = pts.map((p, i) => `${i ? "L" : "M"}${p.x},${p.y}`).join(" "); + e.lane = right; + } } for (const j of jumps) { const box = boxes.get(j.box), use = byId.get(j.use); @@ -344,4 +367,44 @@ class GraphLayout { parser, scope: active?.n.id || 0, scopes: new Set(owned.filter(n => n.scope).map(n => n.n.id)), held: new Set(held.map(n => n.n.id))}; } + + // An orthogonal lead to a right-side gutter. Search only the channels + // between boxes; this is routing geometry, not a second node layout. + toRight(start, right, boxes) { + const pad = 8; + const left = Math.min(start.x, right), end = Math.max(start.x, right); + const rects = boxes.map(b => ({l: b.x - pad, r: b.x + b.width + pad, + t: b.y - pad, b: b.y + b.height + pad})).filter(b => b.r > left && b.l < end); + const xs = [...new Set([start.x, right, ...rects.flatMap(b => [b.l, b.r])])] + .filter(x => x >= left && x <= end).sort((a,b) => a-b); + const ys = [...new Set([start.y, ...rects.flatMap(b => [b.t, b.b])])].sort((a,b) => a-b); + const w = xs.length, size = w * ys.length, first = ys.indexOf(start.y) * w + xs.indexOf(start.x); + const goal = xs.indexOf(right), dir = right >= start.x ? 1 : -1; + const prev = new Int32Array(size).fill(-1), todo = new Int32Array(size); + let read = 0, len = 1, last = -1; + todo[0] = first; prev[first] = first; + const clear = (x, y, u, v) => !rects.some(b => x === u ? + x > b.l && x < b.r && Math.max(y,v) > b.t && Math.min(y,v) < b.b : + y > b.t && y < b.b && Math.max(x,u) > b.l && Math.min(x,u) < b.r); + while (read < len) { + const at = todo[read++], x = at % w, y = Math.floor(at / w); + if (x === goal) { last = at; break; } + for (const [u,v] of [[x+dir,y], [x,y-1], [x,y+1]]) { + if (u < 0 || u >= w || v < 0 || v >= ys.length) continue; + const next = v*w+u; + if (prev[next] !== -1 || !clear(xs[x],ys[y],xs[u],ys[v])) continue; + prev[next] = at; todo[len++] = next; + } + } + // A transient overlapping box during parsing can close all channels. + if (last === -1) return [start, {x: right, y: start.y}]; + const pts = []; + for (let at = last;; at = prev[at]) { + pts.push({x: xs[at % w], y: ys[Math.floor(at / w)]}); + if (at === first) break; + } + pts.reverse(); + return pts.filter((p,i) => !i || i === pts.length-1 || + !((pts[i-1].x === p.x && p.x === pts[i+1].x) || (pts[i-1].y === p.y && p.y === pts[i+1].y))); + } } diff --git a/graph/web/render.js b/graph/web/render.js index b1a294ec..7fe058b3 100644 --- a/graph/web/render.js +++ b/graph/web/render.js @@ -18,16 +18,28 @@ class GraphView { this.world = this.svg.append("g"); this.regions = this.world.append("g").attr("class", "regions"); 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"); this.nodes = this.world.append("g").attr("class", "nodes"); // 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"); + // 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; + if (event.sourceEvent) { this.auto = false; this.clearJump(); } this.world.attr("transform", event.transform); }); - this.svg.call(this.zoom).on("dblclick.zoom", null).on("click", () => this.select(0)); + this.svg.call(this.zoom).on("dblclick.zoom", null) + .on("click", () => { this.clearJump(); this.select(0); }) + .on("mousemove", event => { + if (!this.hop) return; + const [x,y] = d3.pointer(event, this.svg.node()); + if (Math.hypot(x-this.hop.x, y-this.hop.y) > 10) this.clearJump(); + }).on("mouseleave", () => this.clearJump()); this.resize = new ResizeObserver(() => { this.svg.attr("width", host.clientWidth).attr("height", host.clientHeight); if (this.auto) this.fit(); @@ -36,6 +48,7 @@ class GraphView { } show(snap, scene, evt) { + this.clearJump(); if (this.comp !== snap.comp) { this.comp = snap.comp; this.pick = 0; @@ -59,7 +72,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.gid} ${g.n.label}`).slice(0, Math.max(12, Math.floor((g.width-48)/7))))); + g.n.label.slice(0, Math.max(12, Math.floor((g.width-80)/7))) + ` #${g.gid}`)); 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") @@ -74,6 +87,13 @@ class GraphView { (e.bundle ? `${e.refs.length} edges bundled\n` : "") + e.refs.map(r => `#${r.use}[${r.idx}] → #${r.def} · ${r.role}${r.label ? " · " + r.label : ""}`).join("\n")); }); + this.hits.selectAll("path").data(scene.edges, e => e.id).join("path") + .attr("class", e => "edge-hit " + e.role).attr("d", e => e.path) + .on("click", (event, e) => this.edgeJump(event, e, event.currentTarget)) + .each((e, i, items) => { + d3.select(items[i]).selectAll("title").data([e]).join("title").text( + "Click to jump between use and def\n" + document.getElementById(e.id).querySelector("title").textContent); + }); this.parser.selectAll("g").data(scene.parser ? [scene.parser] : []).join(enter => { const g = enter.append("g"); g.append("rect").attr("rx", 6); @@ -97,7 +117,7 @@ class GraphView { .classed("held", n => !!n.held) .classed("folded", n => !!n.n.fold) .attr("transform", n => `translate(${n.x},${n.y})`) - .on("click", (event, n) => { event.stopPropagation(); this.select(n.n.id); }); + .on("click", (event, n) => { event.stopPropagation(); this.clearJump(); this.select(n.n.id); }); boxes.select("path.box").attr("d", n => this.shape(n)) .attr("fill", n => ({CTRL: "#fff1c4", REGION: "#fff1c4", LOOP: "#ffe0ab", FUN: "#ffe0ab", UNIT: "#ffe0ab", START: "#fff1c4", STOP: "#fff1c4", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]); @@ -149,7 +169,8 @@ class GraphView { .attr("marker-start", j => `url(#arrow-${j.role})`); jumps.select("rect").attr("x", j => j.x).attr("y", j => j.y) .attr("width", j => j.width).attr("height", j => j.height); - jumps.select("text").attr("x", j => j.x + 10).attr("y", j => j.y + 15).text(j => j.label); + jumps.select("text").attr("x", j => j.x + 10).attr("y", j => j.y + 15) + .each((j, i, items) => this.label(d3.select(items[i]), j.label)); 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); @@ -159,9 +180,9 @@ class GraphView { } label(host, text) { - const split = text.indexOf(" "); - host.selectAll("tspan").data([text.slice(0, split), text.slice(split)]).join("tspan") - .attr("class", (_, i) => i ? null : "node-id").text(s => s); + 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); } foldButton(host, id, folded, x, y) { @@ -173,10 +194,10 @@ class GraphView { return g; }).attr("transform", `translate(${x},${y})`) .attr("aria-label", `${folded ? "Unfold" : "Fold"} region #${id}`) - .on("click", (event, id) => { event.stopPropagation(); this.onFold?.(id); }) + .on("click", (event, id) => { event.stopPropagation(); this.clearJump(); this.onFold?.(id); }) .on("keydown", (event, id) => { if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); event.stopPropagation(); this.onFold?.(id); + event.preventDefault(); event.stopPropagation(); this.clearJump(); this.onFold?.(id); } }); btn.select("text").text(folded ? "+" : "−"); @@ -221,9 +242,47 @@ class GraphView { detail.textContent = node ? this.info(node) + (box && box !== id ? `\nInside folded region #${box}` : "") : `#${id} is absent in this frame.`; } - assocs(show) { this.links.selectAll(".ASSOC").style("display", e => show || e.bind ? null : "none"); } + assocs(show) { + this.clearJump(); + for (const layer of [this.links, this.hits]) + layer.selectAll(".ASSOC").style("display", e => show || e.bind ? null : "none"); + } + + clearJump() { + this.svg.interrupt("edge-pan"); + this.hop = null; + this.spot.style("display", "none"); + } + + edgeJump(event, e, path) { + event.preventDefault(); event.stopPropagation(); + this.svg.interrupt("edge-pan"); + const [x,y] = d3.pointer(event, this.svg.node()); + const now = d3.zoomTransform(this.svg.node()), len = path.getTotalLength(); + if (!len) return; + const def = path.getPointAtLength(0), use = path.getPointAtLength(len); + const dist = p => Math.hypot(now.applyX(p.x)-x, now.applyY(p.y)-y); + const again = this.hop?.e.id === e.id && Math.hypot(x-this.hop.x, y-this.hop.y) <= 10; + const at = again ? (this.hop.at === "use" ? "def" : "use") : + dist(use) < 32 && dist(use) < dist(def) ? "def" : "use"; + // Land on the edge just outside the node, keeping the mouse off its box. + const inset = Math.min(20 / now.k, len / 3); + const p = path.getPointAtLength(at === "def" ? inset : len-inset); + const next = d3.zoomIdentity.translate(x-p.x*now.k, y-p.y*now.k).scale(now.k); + this.auto = false; + this.hop = {e, path, at, x, y}; + this.select(at === "use" ? e.use : e.def); + this.spot.attr("cx", x).attr("cy", y).style("display", null); + const ms = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : + Math.min(450, 160 + 6*Math.sqrt(Math.hypot(next.x-now.x, next.y-now.y))); + const ix = d3.interpolateNumber(now.x,next.x), iy = d3.interpolateNumber(now.y,next.y); + this.svg.transition("edge-pan").duration(ms).ease(d3.easeCubicInOut) + .tween("pan", () => t => this.svg.call(this.zoom.transform, + d3.zoomIdentity.translate(ix(t),iy(t)).scale(now.k))); + } center(id) { + this.clearJump(); const dst = this.scene.cover?.get(id) || id; const n = this.scene.nodes.find(n => n.n.id === dst); if (!n) return; @@ -245,6 +304,7 @@ class GraphView { // Save the whole graph at its layout size, independent of pan/zoom. save(step) { const svg = this.svg.node().cloneNode(true); + svg.querySelectorAll(".edge-hit,.edge-jump").forEach(n => n.remove()); svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); svg.setAttribute("width", this.scene.width); svg.setAttribute("height", this.scene.height); @@ -270,6 +330,7 @@ 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)); diff --git a/graph/web/viewer.js b/graph/web/viewer.js index 77703b71..7e41c082 100644 --- a/graph/web/viewer.js +++ b/graph/web/viewer.js @@ -178,7 +178,7 @@ 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.select(0); + if (event.key === "Escape") { renderer.clearJump(); renderer.select(0); } }); try { |
