diff options
Diffstat (limited to 'graph')
| -rw-r--r-- | graph/README.md | 30 | ||||
| -rw-r--r-- | graph/src/main/java/com/seaofnodes/graph/GraphAdapter.java | 7 | ||||
| -rw-r--r-- | graph/src/main/java/com/seaofnodes/graph/GraphCapture.java | 18 | ||||
| -rw-r--r-- | graph/src/main/java/com/seaofnodes/graph/GraphJson.java | 2 | ||||
| -rw-r--r-- | graph/src/main/java/com/seaofnodes/graph/GraphSnapshot.java | 4 | ||||
| -rw-r--r-- | graph/web/index.html | 5 | ||||
| -rw-r--r-- | graph/web/layout.js | 161 | ||||
| -rw-r--r-- | graph/web/render.js | 64 | ||||
| -rw-r--r-- | graph/web/viewer.js | 12 |
9 files changed, 251 insertions, 52 deletions
diff --git a/graph/README.md b/graph/README.md index 21ee0d07..a2145220 100644 --- a/graph/README.md +++ b/graph/README.md @@ -105,7 +105,7 @@ The JSON frame has this shape: ```json { "snap": { - "ver": 1, "comp": "compilation UUID", "step": 0, "roots": [1], + "ver": 1, "comp": "compilation UUID", "step": 0, "roots": [1], "scope": 0, "nodes": [ {"id": 1, "label": "Start", "type": null, "kind": "CTRL", "edges": [], "proj": null} ] @@ -124,6 +124,9 @@ program, and `step` starts at zero. `nodes` carries the complete graph, includin each node's `id`, `label`, `type`, `kind`, `edges`, and `proj`. Enum values use their names; absent types, labels and projections use JSON null. Missing node references use ID zero. `pos` is the parser position, or -1 outside parsing. +`scope` identifies the parser's active scope (zero outside parsing); scope nodes +in `roots` include the parser's saved scopes. These are context references, +not IR edges. The browser caches the parsed objects in `frames`. It computes geometry on the first visit to a frame and keeps it in `frames[i].layout`; revisiting a frame @@ -137,8 +140,27 @@ 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. Associations are drawn -as a separate overlay and do not constrain layout. Projections remain real -nodes for now. +as a separate overlay and do not constrain layout. Each MultiNode and its +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. + +Scopes have named slots across the top, including control, memory and variable +bindings. Edges leave these slots upward toward their definitions. They stay visible +even when **Associations** is unchecked, and do not constrain CFG layout. +While parsing, saved scopes line up beside the graph, slightly below their +`$ctrl` definitions, with space to avoid overlapping other scopes. The active +scope stays at the bottom. The **Parser** box points to both active and saved +scopes using display-only arrows. + +During parsing, values with no graph users yet appear in a separate bottom +row, with dashed display-only arrows from a **Parser** +box. Scope bindings count as users. Pending projections keep their parent box +and get an arrow to their own cell. These marks disappear when real uses attach +or parsing finishes; they do not add nodes or edges to the compiler graph. +This identifies unconsumed values from the snapshot, not Java stack references. This first layout is flat. It does not discover compiler loops, compute RPO, or build a SESE hierarchy. ELK breaks cycles for drawing, which can still put @@ -162,7 +184,7 @@ The shared Java code is in `src/main/java/com/seaofnodes/graph/`: - `GraphAdapter<N>` is an abstract base with hooks for `id`, `desc`, and indexed edge access (`nIns`/`in`, `nOuts`/`out`). Its final `snap` method walks definitions and uses iteratively, handles cycles, and sorts the copied records by ID. -- `GraphSnapshot` is detached data: compilation key, step, roots, and nodes. +- `GraphSnapshot` is detached data: compilation key, step, roots, active scope, and nodes. Each node has an ID, plain-text label/type, kind, edges to its defs, and optional projection metadata. It contains no chapter classes or layout coordinates. Node and edge lists are concrete `ArrayList`s, treated as read-only after capture. diff --git a/graph/src/main/java/com/seaofnodes/graph/GraphAdapter.java b/graph/src/main/java/com/seaofnodes/graph/GraphAdapter.java index 3e840b54..47bf3444 100644 --- a/graph/src/main/java/com/seaofnodes/graph/GraphAdapter.java +++ b/graph/src/main/java/com/seaofnodes/graph/GraphAdapter.java @@ -37,6 +37,11 @@ public abstract class GraphAdapter<N> { } public final GraphSnapshot snap(String comp, long step, ArrayList<N> roots) { + return snap(comp, step, roots, 0); + } + + /** scope names the active parser scope, which the caller includes in roots. */ + public final GraphSnapshot snap(String comp, long step, ArrayList<N> roots, int scope) { // Node IDs are dense; index directly without boxed keys or map entries. var seen = new ArrayList<N>(); var todo = new ArrayDeque<N>(); @@ -55,7 +60,7 @@ public abstract class GraphAdapter<N> { // Determinism without reordering the compiler's use lists. nodes.sort(Comparator.comparingInt(GraphSnapshot.Node::id)); return new GraphSnapshot(GraphSnapshot.VER, comp, step, - Arrays.copyOf(rids, len), nodes); + Arrays.copyOf(rids, len), scope, nodes); } private boolean enq(N node, ArrayList<N> seen, ArrayDeque<N> todo) { diff --git a/graph/src/main/java/com/seaofnodes/graph/GraphCapture.java b/graph/src/main/java/com/seaofnodes/graph/GraphCapture.java index 6fc40b5f..4be65392 100644 --- a/graph/src/main/java/com/seaofnodes/graph/GraphCapture.java +++ b/graph/src/main/java/com/seaofnodes/graph/GraphCapture.java @@ -23,6 +23,8 @@ public abstract class GraphCapture<N> extends GraphObserver<N> { protected abstract String phase(); protected abstract int pos(); protected abstract void roots(ArrayList<N> roots); + // Called only during parsing; the shared capture adds this root too. + protected N scope() { return null; } protected abstract void detach(); final void run(String src, GraphSocket server) { @@ -65,7 +67,7 @@ public abstract class GraphCapture<N> extends GraphObserver<N> { near(p, n); // Buffer until we know that this attempt (or a nested one) made progress. // Include n explicitly: parse-time nodes need not have any uses yet. - p.pre = snap(n); + p.pre = snap(n, true); } @Override public void after(N n, N repl, boolean applied) { @@ -85,7 +87,7 @@ public abstract class GraphCapture<N> extends GraphObserver<N> { up.sent = true; } N rez = repl == null ? n : repl; - emit(snap(_graph.dead(rez) ? null : rez), pos(), + emit(snap(_graph.dead(rez) ? null : rez, true), pos(), evt(p, applied ? GraphEvent.Kind.APPLY : GraphEvent.Kind.RETURN, _graph.dead(rez) ? 0 : _graph.id(rez))); } @@ -101,7 +103,7 @@ public abstract class GraphCapture<N> extends GraphObserver<N> { @Override public void phase(String phase) { if( _off ) return; - emit(snap(null), pos(), new GraphEvent(GraphEvent.Kind.PHASE, 0, 0, phase, 0, 0, new int[0])); + emit(snap(null, false), pos(), new GraphEvent(GraphEvent.Kind.PHASE, 0, 0, phase, 0, 0, new int[0])); } private GraphEvent evt(Peep p, GraphEvent.Kind kind, int repl) { @@ -121,18 +123,22 @@ public abstract class GraphCapture<N> extends GraphObserver<N> { } - private GraphSnapshot snap(N extra) { + private GraphSnapshot snap(N extra, boolean parsing) { var roots = new ArrayList<N>(); roots(roots); + // Early parsers retain their emptied scope after returning. A completed + // phase no longer owns that scope, even while phase() still says Parse. + N scope = parsing && "Parse".equals(phase()) ? scope() : null; + roots.add(scope); for( Peep p : _peeps ) if( !_graph.dead(p.node) ) roots.add(p.node); roots.add(extra); - return _graph.snap(_comp, 0, roots); + return _graph.snap(_comp, 0, roots, _graph.ref(scope)); } private void emit(GraphSnapshot snap, int pos, GraphEvent evt) { if( _off ) return; // Assign steps on emission; attempts without progress do not consume frames. - snap = new GraphSnapshot(snap.ver(), _comp, _step++, snap.roots(), snap.nodes()); + snap = new GraphSnapshot(snap.ver(), _comp, _step++, snap.roots(), snap.scope(), snap.nodes()); try { _server.put(GraphJson.frame(snap, pos, evt)); } catch( IOException e ) { diff --git a/graph/src/main/java/com/seaofnodes/graph/GraphJson.java b/graph/src/main/java/com/seaofnodes/graph/GraphJson.java index 8726d81b..c920763a 100644 --- a/graph/src/main/java/com/seaofnodes/graph/GraphJson.java +++ b/graph/src/main/java/com/seaofnodes/graph/GraphJson.java @@ -42,7 +42,7 @@ public class GraphJson { if( i > 0 ) sb.append(','); sb.append(roots[i]); } - sb.append("],\"nodes\":["); + sb.append("],\"scope\":").append(snap.scope()).append(",\"nodes\":["); var nodes = snap.nodes(); for( int i = 0; i < nodes.size(); i++ ) { if( i > 0 ) sb.append(','); diff --git a/graph/src/main/java/com/seaofnodes/graph/GraphSnapshot.java b/graph/src/main/java/com/seaofnodes/graph/GraphSnapshot.java index 18926726..6836693e 100644 --- a/graph/src/main/java/com/seaofnodes/graph/GraphSnapshot.java +++ b/graph/src/main/java/com/seaofnodes/graph/GraphSnapshot.java @@ -2,9 +2,9 @@ package com.seaofnodes.graph; import java.util.ArrayList; -/** A detached graph. Treat its lists as read-only after capture. */ +/** A detached graph. Lists are read-only; scope is the active parser scope ID, or zero. */ public record GraphSnapshot(int ver, String comp, long step, - int[] roots, ArrayList<Node> nodes) { + int[] roots, int scope, ArrayList<Node> nodes) { public static final int VER = 1; public GraphSnapshot { diff --git a/graph/web/index.html b/graph/web/index.html index 771d3d4a..a568cbc6 100644 --- a/graph/web/index.html +++ b/graph/web/index.html @@ -19,9 +19,12 @@ .edge { fill:none; stroke-width:1.5; } .edge.CTRL { stroke-width:2; } .edge.ASSOC { stroke-dasharray:4 4; } + .edge.PARSER { stroke-dasharray:5 4; } + .parser rect { fill:#f6f0ff; stroke:#7956a5; stroke-width:1.5; stroke-dasharray:5 4; } .edge.selected { stroke-width:3.5; } .node { cursor:pointer; } .box { stroke:#758191; stroke-width:1; } + .node.held .box { stroke-dasharray:5 3; } .node.near .box { stroke:#c18221; stroke-width:2; } .node.focus .box { stroke:#9c4900; stroke-width:3; } .node.selected .box { stroke:#1569b9; stroke-width:3; } @@ -29,6 +32,8 @@ .label { font-size:12px; fill:#152330; font-weight:600; } .type { font-size:11px; fill:#455768; } .port { stroke:#536477; stroke-width:1; } + .row { stroke:#b6afcf; stroke-width:1; } + .bind { font-size:12px; fill:#152330; } .slot { font-size:10px; fill:#364352; paint-order:stroke; stroke:#fafbfc; stroke-width:3; } #detail { position:absolute; top:8px; right:8px; margin:0; padding:12px; max-width:320px; max-height:45%; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; diff --git a/graph/web/layout.js b/graph/web/layout.js index 1cf9fb14..a277cb53 100644 --- a/graph/web/layout.js +++ b/graph/web/layout.js @@ -11,31 +11,94 @@ class GraphLayout { return chars.length > 44 ? chars.slice(0, 43).join("") + "…" : chars.join(""); } - async run(snap) { + async run(snap, evt) { const nodes = snap.nodes.map(n => { const label = this.clip(n.label), type = this.clip(n.type); - const 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, this.ctx.measureText(type).width + 24, (n.edges.length + 1) * 18)); - const h = 62; - const ports = n.edges.map(e => ({ - id: "n" + n.id + "i" + e.idx, x: w * (e.idx + 1) / (n.edges.length + 1) - 3, - y: -3, width: 6, height: 6, layoutOptions: {"elk.port.side": "NORTH"} - })); - ports.push({id: "n" + n.id + "o", x: w / 2 - 3, y: h - 3, - width: 6, height: 6, layoutOptions: {"elk.port.side": "SOUTH"}}); - return {id: "n" + n.id, width: w, height: h, ports, - layoutOptions: {"elk.portConstraints": "FIXED_POS"}, n, label, type}; + const scope = n.kind === "SCOPE"; + const cols = scope ? n.edges.map(e => Math.ceil(Math.max(48, + this.ctx.measureText(e.label || "[" + e.idx + "]").width + 24))) : []; + if (scope) w = Math.ceil(Math.max(120, this.ctx.measureText(label).width + 24, + cols.reduce((sum, w) => sum + w, 0))); + return {id: "n" + n.id, width: w, height: 62, + ports: [], n, label, type, scope, cols, ox: 0, oy: 0}; }); const byId = new Map(nodes.map(n => [n.n.id, n])); + const parsing = evt?.phase === "Parse" && evt.kind !== "PHASE"; + const roots = new Set(snap.roots); + const scopes = nodes.filter(n => n.scope); + const active = parsing ? byId.get(snap.scope) : null; + // Scope bindings count as users too. Scope ownership comes from parser + // 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 held = parsing ? nodes.filter(n => + (n.n.proj || ["DATA", "MEM", "PHI"].includes(n.n.kind)) && !used.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(); + for (const n of nodes) { + const par = byId.get(n.n.proj?.par); + // A projection without its parent still gets a standalone box. + if (!par || par.n.proj) continue; + if (!projs.has(par)) projs.set(par, []); + projs.get(par).push(n); + n.par = par; + } + const groups = nodes.filter(n => !n.par).map(n => { + const slots = (projs.get(n) || []).sort((a, b) => a.n.proj.idx - b.n.proj.idx || a.n.id - b.n.id); + const parts = [n, ...slots]; + if (slots.length) { + const w = slots.reduce((sum, p) => sum + p.width, 0); + n.width = Math.max(n.width, w); + const extra = (n.width - w) / slots.length; + let x = 0; + for (const p of slots) { + p.width += extra; + p.ox = x; + p.oy = n.height; + x += p.width; + } + } + const ports = []; + for (const p of parts) { + p.cell = slots.length > 0; + const extra = p.scope && p.cols.length ? + (p.width - p.cols.reduce((sum, w) => sum + w, 0)) / p.cols.length : 0; + let x = 0; + for (const [idx, e] of p.n.edges.entries()) { + // The parent/projection link is represented by the shared box. + if (p.par && e.idx === 0 && e.def === p.par.n.id) continue; + if (p.scope) { + const span = p.cols[idx] + extra; + ports.push({id: p.id + "i" + e.idx, x: x + span / 2 - 3, y: -3, span, + width: 6, height: 6, layoutOptions: {"elk.port.side": "NORTH"}}); + x += span; + continue; + } + ports.push({id: p.id + "i" + e.idx, x: p.ox + p.width * (e.idx + 1) / (p.n.edges.length + 1) - 3, + y: p.oy - 3, width: 6, height: 6, layoutOptions: {"elk.port.side": "NORTH"}}); + } + // Direct uses of the MultiNode attach to its header, projections below. + const side = p === n && slots.length ? "EAST" : "SOUTH"; + ports.push({id: p.id + "o", x: p.ox + (side === "EAST" ? p.width : p.width / 2) - 3, + y: p.oy + (side === "EAST" ? p.height / 2 : p.height) - 3, + width: 6, height: 6, layoutOptions: {"elk.port.side": side}}); + } + return {id: n.id, width: n.width, height: n.height * (slots.length ? 2 : 1), ports, parts}; + }); const edges = []; for (const use of snap.nodes) { for (const e of use.edges) { if (!e.def) continue; + if (byId.get(use.id).par && e.idx === 0 && e.def === use.proj.par) continue; const id = "e" + use.id + "i" + e.idx; const reg = byId.get(use.edges[0]?.def)?.n; const back = e.idx === 2 && (use.kind === "LOOP" || (use.kind === "PHI" && reg?.kind === "LOOP")); edges.push({id, use: use.id, def: e.def, idx: e.idx, role: e.role, label: e.label, + bind: use.kind === "SCOPE", // 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"], targets: ["n" + use.id + "i" + e.idx], @@ -43,31 +106,83 @@ class GraphLayout { } } const graph = await this.elk.layout({ - id: "root", children: nodes.map(({n, label, type, ...box}) => box), + id: "root", children: groups.filter(g => !g.parts[0].scope).map(({parts, ...box}) => ({...box, + layoutOptions: {"elk.portConstraints": "FIXED_POS", + // Keep a projection with its parent even when that parent has real uses. + "elk.layered.layering.layerConstraint": parts.length === 1 && parts[0].held ? "LAST_SEPARATE" : "NONE"} + })), // Scope bindings and lifetime associations must not impose CFG ranks. - edges: edges.filter(e => e.role !== "ASSOC").map(e => ({ + edges: edges.filter(e => e.role !== "ASSOC" && !e.bind && !byId.get(e.def).scope).map(e => ({ id: e.id, sources: e.sources, targets: e.targets, layoutOptions: e.layoutOptions })), layoutOptions: { "elk.algorithm": "layered", "elk.direction": "DOWN", "elk.edgeRouting": "ORTHOGONAL", "elk.spacing.nodeNode": 32, "elk.layered.spacing.nodeNodeBetweenLayers": 48, "elk.padding": "[top=24,left=24,bottom=24,right=24]", + "elk.separateConnectedComponents": held.length === 0, "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", "elk.randomSeed": 1 } }); + // Scope ports use the same local geometry; place their boxes after CFG nodes. + for (const g of groups.filter(g => g.parts[0].scope)) graph.children.push({...g, x: 0, y: 0}); const boxes = new Map(graph.children.map(n => [n.id, n])); - const routes = new Map(graph.edges.map(e => [e.id, e])); - for (const n of nodes) { - const box = boxes.get(n.id); - n.x = box.x; n.y = box.y; - // Keep ports in input-slot order even if ELK reorders its result. + const routes = new Map((graph.edges || []).map(e => [e.id, e])); + for (const group of groups) { + const box = boxes.get(group.id); const ports = new Map(box.ports.map(p => [p.id, p])); - n.ports = n.ports.map(p => ports.get(p.id)); + for (const n of group.parts) { + n.x = box.x + n.ox; n.y = box.y + n.oy; + // Keep semantic nodes/slots intact for selection and neighborhood marks. + n.ports = n.n.edges.map(e => { + const p = ports.get(n.id + "i" + e.idx); + return p && {...p, x: p.x - n.ox, y: p.y - n.oy, edge: e}; + }).filter(Boolean); + const out = ports.get(n.id + "o"); + n.ports.push({...out, x: out.x - n.ox, y: out.y - n.oy}); + } + } + // Saved scopes describe suspended parser states at their control points. + // Keep a common column, nudging down only when two name tables overlap. + const ctrlY = n => { + const ctrl = byId.get(n.n.edges.find(e => e.label === "$ctrl" || e.idx === 0)?.def); + return ctrl ? ctrl.y + ctrl.height + 24 : 24; + }; + const saved = scopes.filter(n => n !== active).sort((a, b) => ctrlY(a) - ctrlY(b) || a.n.id - b.n.id); + if (saved.length) { + const x = graph.width + 48; + let y = 24; + for (const n of saved) { + n.x = x; + n.y = Math.max(y, ctrlY(n)); + y = n.y + n.height + 24; + } + graph.width = x + Math.max(...saved.map(n => n.width)) + 24; + graph.height = Math.max(graph.height, y); + } + // The active scope moves with the parser, below the current graph. + if (active) { + graph.width = Math.max(graph.width, active.width + 48); + active.x = (graph.width - active.width) / 2; + active.y = graph.height + 24; + graph.height = active.y + active.height + 24; + } + // Parser ownership is an overlay, never an IR node or an ELK flow edge. + const parser = owned.length ? {width: Math.max(180, (owned.length + 1) * 18), height: 36} : null; + if (parser) { + graph.width = Math.max(graph.width, parser.width + 48); + parser.x = (graph.width - parser.width) / 2; + parser.y = graph.height + 24; + graph.height = parser.y + parser.height + 24; + owned.sort((a, b) => a.x - b.x || a.n.id - b.n.id); + parser.ports = owned.map((n, i) => ({id: "parser-i" + n.n.id, + x: parser.width * (i + 1) / (owned.length + 1) - 3, y: -3})); + 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]}); } for (const e of edges) { - if (e.role === "ASSOC") { - const def = byId.get(e.def), use = byId.get(e.use); + if (e.role === "ASSOC" || e.role === "PARSER" || e.bind || byId.get(e.def).scope) { + const def = byId.get(e.def), use = e.role === "PARSER" ? parser : byId.get(e.use); const a = def.ports.find(p => p.id === e.sources[0]); const b = use.ports.find(p => p.id === e.targets[0]); const x = def.x + a.x + 3, y = def.y + a.y + 3; @@ -79,6 +194,8 @@ class GraphLayout { .map((p, i) => `${i ? "L" : "M"}${p.x},${p.y}`).join(" ")).join(" "); } } - return {width: graph.width, height: graph.height, nodes, edges}; + return {width: graph.width, height: graph.height, nodes, edges, + 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))}; } } diff --git a/graph/web/render.js b/graph/web/render.js index 76cf8c06..34c66a02 100644 --- a/graph/web/render.js +++ b/graph/web/render.js @@ -6,7 +6,7 @@ class GraphView { this.pick = 0; this.auto = true; this.scene = null; - this.colors = {CTRL: "#b63838", DATA: "#536477", MEM: "#2867b2", ASSOC: "#9396a3"}; + this.colors = {CTRL: "#b63838", DATA: "#536477", MEM: "#2867b2", ASSOC: "#9396a3", PARSER: "#7956a5"}; this.svg = d3.select(host).append("svg").attr("aria-label", "Compiler graph"); const defs = this.svg.append("defs"); for (const [role, color] of Object.entries(this.colors)) { @@ -17,6 +17,7 @@ class GraphView { } this.world = this.svg.append("g"); this.links = this.world.append("g").attr("class", "edges"); + this.parser = this.world.append("g").attr("class", "parser"); this.nodes = this.world.append("g").attr("class", "nodes"); this.zoom = d3.zoom().scaleExtent([.02, 4]).on("zoom", event => { if (event.sourceEvent) this.auto = false; @@ -46,11 +47,24 @@ class GraphView { .attr("marker-start", e => "url(#arrow-" + e.role + ")") .each(function(e) { d3.select(this).selectAll("title").data([e]).join("title") - .text(`#${e.use}[${e.idx}] → #${e.def} · ${e.role}${e.label ? " · " + e.label : ""}`); + .text(e.role === "PARSER" ? (e.scope ? `Parser holds ${e.active ? "active" : "saved"} scope #${e.def}` : + `Parser holds #${e.def}; no graph users yet`) : + `#${e.use}[${e.idx}] → #${e.def} · ${e.role}${e.label ? " · " + e.label : ""}`); + }); + this.parser.selectAll("g").data(scene.parser ? [scene.parser] : []).join(enter => { + const g = enter.append("g"); + g.append("rect").attr("rx", 6); + g.append("text").attr("class", "label").attr("text-anchor", "middle").attr("y", 22).text("Parser"); + g.append("title").text("Display-only references to parser scopes and unfinished values; these are not compiler edges."); + return g; + }).attr("transform", p => `translate(${p.x},${p.y})`) + .each(function(p) { + d3.select(this).select("rect").attr("width", p.width).attr("height", p.height); + d3.select(this).selectAll("text").attr("x", p.width / 2); }); const boxes = this.nodes.selectAll("g.node").data(scene.nodes, n => n.id).join(enter => { const g = enter.append("g").attr("class", "node"); - g.append("rect").attr("class", "box"); + g.append("path").attr("class", "box"); g.append("text").attr("class", "head").attr("x", 8).attr("y", 15); g.append("text").attr("class", "label").attr("text-anchor", "middle").attr("y", 33); g.append("text").attr("class", "type").attr("text-anchor", "middle").attr("y", 51); @@ -58,25 +72,34 @@ class GraphView { return g; }); boxes.attr("id", n => n.id).attr("data-id", n => n.n.id) + .classed("held", n => !!n.held) .attr("transform", n => `translate(${n.x},${n.y})`) .on("click", (event, n) => { event.stopPropagation(); this.select(n.n.id); }); - boxes.select("rect.box").attr("width", n => n.width).attr("height", n => n.height) - .attr("rx", n => ["CTRL", "REGION", "LOOP", "FUN", "UNIT"].includes(n.n.kind) ? 3 : 14) + boxes.select("path.box").attr("d", n => this.shape(n)) .attr("fill", n => ({CTRL: "#fff1c4", REGION: "#fff1c4", LOOP: "#ffe0ab", FUN: "#ffe0ab", UNIT: "#ffe0ab", MEM: "#dcecff", PHI: "#f5e4fa", SCOPE: "#e5e0ff", DATA: "#edf3f7"})[n.n.kind]); - boxes.select("text.head").text(n => `#${n.n.id} · ${n.n.kind}${n.n.proj ? " · p" + n.n.proj.idx : ""}`); - boxes.select("text.label").attr("x", n => n.width / 2).text(n => n.label); - boxes.select("text.type").attr("x", n => n.width / 2).text(n => n.type); + boxes.select("text.head").attr("y", n => n.scope ? 47 : 15) + .text(n => `#${n.n.id}${n.n.proj ? " · p" + n.n.proj.idx : ""}`); + boxes.select("text.label").attr("x", n => n.width / 2).attr("y", n => n.scope ? 51 : 33) + .text(n => n.label); + boxes.select("text.type").attr("x", n => n.width / 2).text(n => n.scope ? "" : n.type); boxes.select("title").text(n => this.info(n.n)); boxes.each((n, i, groups) => { const ports = n.ports.filter(p => p.id !== n.id + "o"); const group = d3.select(groups[i]); + group.selectAll("line.row").data(n.scope ? ports : [], p => p.id).join("line") + .attr("class", "row").attr("x1", (p, i) => i ? p.x + 3 - p.span / 2 : 0) + .attr("x2", (p, i) => i ? p.x + 3 - p.span / 2 : n.width) + .attr("y1", (p, i) => i ? 0 : 30).attr("y2", 30); + group.selectAll("text.bind").data(n.scope ? ports : [], p => p.id).join("text") + .attr("class", "bind").attr("x", p => p.x + 3).attr("y", 20).attr("text-anchor", "middle") + .text(p => p.edge.label || "[" + p.edge.idx + "]"); group.selectAll("circle.port").data(ports, p => p.id).join("circle") .attr("class", "port").attr("cx", p => p.x + 3).attr("cy", p => p.y + 3) - .attr("r", 3).attr("fill", (p, j) => n.n.edges[j].def ? "#536477" : "white"); + .attr("r", 3).attr("fill", p => p.edge.def ? "#536477" : "white"); group.selectAll("text.slot").data(ports, p => p.id).join("text") .attr("class", "slot").attr("x", p => p.x + 3).attr("y", p => p.y - 4) - .attr("text-anchor", "middle").text((p, j) => n.n.edges[j].idx); + .attr("text-anchor", "middle").text(p => p.edge.idx); }); this.assocs(document.getElementById("assocs").checked); this.mark(); @@ -84,8 +107,23 @@ class GraphView { if (this.auto) this.fit(); } + shape(n) { + const w = n.width, h = n.height; + // A MultiNode and its projection cells tile one rectangular box. + if (n.cell || n.n.kind === "CTRL" || n.n.kind === "UNIT") return `M0,0H${w}V${h}H0Z`; + if (n.n.kind === "REGION") return `M12,0H${w - 12}L${w},${h}H0Z`; + if (n.n.kind === "PHI") return `M12,0H${w - 12}L${w},${h / 2}L${w - 12},${h}H12L0,${h / 2}Z`; + if (n.n.kind === "MEM" || n.n.kind === "FUN") + return `M10,0H${w - 10}L${w},10V${h - 10}L${w - 10},${h}H10L0,${h - 10}V10Z`; + const r = n.n.kind === "LOOP" ? 24 : n.n.kind === "SCOPE" ? 4 : 14; + return `M${r},0H${w - r}Q${w},0 ${w},${r}V${h - r}Q${w},${h} ${w - r},${h}` + + `H${r}Q0,${h} 0,${h - r}V${r}Q0,0 ${r},0Z`; + } + info(n) { - return `#${n.id} ${n.label}\n${n.kind}${n.proj ? " · projection " + n.proj.idx + " of #" + n.proj.par : ""}` + + return `#${n.id} ${n.label}${n.proj ? "\nProjection " + n.proj.idx + " of #" + n.proj.par : ""}` + + (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") : "") + `\n${n.type || "Type not computed"}\n\n` + n.edges.map(e => `[${e.idx}] → ${e.def ? "#" + e.def : "—"} ${e.role}${e.label ? " " + e.label : ""}`).join("\n"); } @@ -94,13 +132,13 @@ class GraphView { this.pick = id; const node = this.scene?.nodes.find(n => n.n.id === id); this.nodes.selectAll("g.node").classed("selected", n => n.n.id === id); - this.links.selectAll("path.edge").classed("selected", e => e.use === id || e.def === id); + this.links.selectAll("path.edge").classed("selected", e => id !== 0 && (e.use === id || e.def === id)); const detail = document.getElementById("detail"); detail.hidden = !id; detail.textContent = node ? this.info(node.n) : `#${id} is absent in this frame.`; } - assocs(show) { this.links.selectAll(".ASSOC").style("display", show ? null : "none"); } + assocs(show) { this.links.selectAll(".ASSOC").style("display", e => show || e.bind ? null : "none"); } mark() { const evt = this.evt; diff --git a/graph/web/viewer.js b/graph/web/viewer.js index c36c369c..63ee195e 100644 --- a/graph/web/viewer.js +++ b/graph/web/viewer.js @@ -81,11 +81,17 @@ async function render(index) { const frame = frames[index]; busy = !frame.layout ? "Laying out frame " + (index + 1) + "..." : "Drawing..."; updateUI(); - if (frame.pos >= 0) program.setSelectionRange(frame.pos, frame.pos + 1); try { if (!layout) layout = new GraphLayout(); - if (!frame.layout) frame.layout = await layout.run(frame.snap); - if (generation === frameGeneration) renderer.show(frame.snap, frame.layout, frame.evt); + if (!frame.layout) frame.layout = await layout.run(frame.snap, frame.evt); + if (generation === frameGeneration) { + renderer.show(frame.snap, frame.layout, frame.evt); + if (frame.pos >= 0) { + // Stepping focuses a button; focus the source so its selection is visible. + program.focus({preventScroll: true}); + program.setSelectionRange(frame.pos, frame.pos + 1); + } + } rendering = false; busy = ""; if (generation === frameGeneration) current = index; |
