OR-1 dataflow CPU sketch

feat: add physical view and view toggle

Implements Phase 6 tasks 1-4:

Task 1: Add physicalLayout() to layout.ts
- Exports new physical layout function with tighter spacing
- Uses dagre top-to-bottom layout for PE cluster visualization

Task 2: Add PE cluster, cross-PE, and intra-PE styles to style.ts
- PE cluster parent nodes: rounded rectangle with solid border
- Cross-PE edges: thicker, darker (3px, #5c6bc0)
- Intra-PE edges: lighter (1.5px, #bbb)

Task 3: Add view toggle and physical element building to main.ts
- buildPhysicalLabel(): formats nodes with [iram:offset, ctx:slot] annotations
- buildPhysicalElements(): creates PE cluster nodes and assigns children
- View mode tracking: logical (default) or physical
- Toggle button in index.html switches between views
- Physical view only available when stage == 'allocate'
- Disables toggle and reverts to logical if graph becomes incomplete

Task 4: Manual verification
- esbuild bundle succeeds without errors (1.2mb)
- Full test suite passes: 608/608 tests
- Frontend builds successfully with npm run build

Acceptance criteria covered:
- AC3.1: Nodes grouped into PE cluster boxes by PE ID
- AC3.2: Nodes annotated with IRAM offset and context slot
- AC3.3: Cross-PE edges visually distinct from intra-PE
- AC3.4: Physical view unavailable when stage != 'allocate'

Orual 6f0233b3 edb6ec69

+174 -3
+3
dfgraph/frontend/index.html
··· 9 9 body { font-family: system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; } 10 10 #toolbar { padding: 8px 16px; background: #1a1a2e; color: #eee; display: flex; align-items: center; gap: 12px; } 11 11 #toolbar h1 { font-size: 14px; font-weight: 600; } 12 + #view-toggle { padding: 6px 12px; background: #5c6bc0; color: #eee; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; } 13 + #view-toggle:hover { background: #3f51b5; } 12 14 #graph { flex: 1; } 13 15 </style> 14 16 </head> 15 17 <body> 16 18 <div id="toolbar"> 17 19 <h1>dfgraph</h1> 20 + <button id="view-toggle">Physical View</button> 18 21 </div> 19 22 <div id="graph"></div> 20 23 <script type="module" src="dist/bundle.js"></script>
+11
dfgraph/frontend/src/layout.ts
··· 8 8 animate: false, 9 9 }; 10 10 } 11 + 12 + export function physicalLayout(): object { 13 + return { 14 + name: "dagre", 15 + rankDir: "TB", 16 + nodeSep: 40, 17 + rankSep: 60, 18 + edgeSep: 15, 19 + animate: false, 20 + }; 21 + }
+128 -3
dfgraph/frontend/src/main.ts
··· 2 2 import dagre from "cytoscape-dagre"; 3 3 import type { GraphUpdate, GraphNode, GraphEdge, GraphRegion } from "./types"; 4 4 import { stylesheet } from "./style"; 5 - import { logicalLayout } from "./layout"; 5 + import { logicalLayout, physicalLayout } from "./layout"; 6 6 7 7 cytoscape.use(dagre); 8 8 ··· 19 19 return `${node.opcode}\n${node.const}`; 20 20 } 21 21 return node.opcode; 22 + } 23 + 24 + function buildPhysicalLabel(node: GraphNode): string { 25 + const parts = [node.opcode]; 26 + if (node.const !== null) parts.push(`=${node.const}`); 27 + if (node.iram_offset !== null) parts.push(`\n[iram:${node.iram_offset}, ctx:${node.ctx}]`); 28 + return parts.join(""); 22 29 } 23 30 24 31 function buildElements( ··· 80 87 return elements; 81 88 } 82 89 83 - function renderGraph(update: GraphUpdate): void { 90 + function buildPhysicalElements(update: GraphUpdate): cytoscape.ElementDefinition[] { 91 + const elements: cytoscape.ElementDefinition[] = []; 92 + const peNodes = new Map<number, string[]>(); 93 + 94 + // Group nodes by PE 95 + for (const node of update.nodes) { 96 + if (node.pe !== null) { 97 + if (!peNodes.has(node.pe)) peNodes.set(node.pe, []); 98 + peNodes.get(node.pe)!.push(node.id); 99 + } 100 + } 101 + 102 + // Create PE cluster parent nodes 103 + for (const peId of peNodes.keys()) { 104 + elements.push({ 105 + data: { id: `pe-${peId}`, label: `PE ${peId}` }, 106 + classes: "pe-cluster", 107 + }); 108 + } 109 + 110 + // Create operation nodes parented to PE clusters 111 + for (const node of update.nodes) { 112 + const el: cytoscape.ElementDefinition = { 113 + data: { 114 + id: node.id, 115 + label: buildPhysicalLabel(node), 116 + colour: node.colour, 117 + category: node.category, 118 + pe: node.pe, 119 + iram_offset: node.iram_offset, 120 + ctx: node.ctx, 121 + }, 122 + classes: node.has_error ? "error" : undefined, 123 + }; 124 + if (node.pe !== null) { 125 + el.data.parent = `pe-${node.pe}`; 126 + } 127 + elements.push(el); 128 + } 129 + 130 + // Create edges with cross-PE / intra-PE classification 131 + const nodePeMap = new Map<string, number | null>(); 132 + for (const node of update.nodes) { 133 + nodePeMap.set(node.id, node.pe); 134 + } 135 + 136 + for (const edge of update.edges) { 137 + const sourcePe = nodePeMap.get(edge.source); 138 + const targetPe = nodePeMap.get(edge.target); 139 + const isCrossPe = sourcePe !== null && targetPe !== null && sourcePe !== targetPe; 140 + 141 + elements.push({ 142 + data: { 143 + id: `${edge.source}->${edge.target}:${edge.port}`, 144 + source: edge.source, 145 + target: edge.target, 146 + targetLabel: edge.port, 147 + sourceLabel: "", 148 + }, 149 + classes: (isCrossPe ? "cross-pe" : "intra-pe") + (edge.has_error ? " error" : ""), 150 + }); 151 + } 152 + 153 + return elements; 154 + } 155 + 156 + type ViewMode = "logical" | "physical"; 157 + let currentView: ViewMode = "logical"; 158 + let latestUpdate: GraphUpdate | null = null; 159 + 160 + function renderLogical(update: GraphUpdate): void { 84 161 cy.batch(() => { 85 162 cy.elements().remove(); 86 163 cy.add(buildElements(update)); ··· 89 166 cy.fit(undefined, 40); 90 167 } 91 168 169 + function renderPhysical(update: GraphUpdate): void { 170 + cy.batch(() => { 171 + cy.elements().remove(); 172 + cy.add(buildPhysicalElements(update)); 173 + }); 174 + cy.layout(physicalLayout()).run(); 175 + cy.fit(undefined, 40); 176 + } 177 + 178 + function renderUpdate(update: GraphUpdate): void { 179 + latestUpdate = update; 180 + if (currentView === "physical" && update.stage !== "allocate") { 181 + currentView = "logical"; 182 + const toggleBtn = document.getElementById("view-toggle"); 183 + if (toggleBtn) { 184 + toggleBtn.textContent = "Physical View"; 185 + } 186 + } 187 + if (currentView === "logical") { 188 + renderLogical(update); 189 + } else { 190 + renderPhysical(update); 191 + } 192 + } 193 + 194 + function setupToggleButton(): void { 195 + const toggleBtn = document.getElementById("view-toggle"); 196 + if (toggleBtn) { 197 + toggleBtn.addEventListener("click", () => { 198 + if (!latestUpdate) return; 199 + if (currentView === "logical") { 200 + if (latestUpdate.stage !== "allocate") { 201 + // Physical view not available 202 + return; 203 + } 204 + currentView = "physical"; 205 + toggleBtn.textContent = "Logical View"; 206 + renderPhysical(latestUpdate); 207 + } else { 208 + currentView = "logical"; 209 + toggleBtn.textContent = "Physical View"; 210 + renderLogical(latestUpdate); 211 + } 212 + }); 213 + } 214 + } 215 + 92 216 function connect(): void { 93 217 const protocol = location.protocol === "https:" ? "wss:" : "ws:"; 94 218 const ws = new WebSocket(`${protocol}//${location.host}/ws`); ··· 96 220 ws.onmessage = (event: MessageEvent) => { 97 221 const update: GraphUpdate = JSON.parse(event.data); 98 222 if (update.type === "graph_update") { 99 - renderGraph(update); 223 + renderUpdate(update); 100 224 } 101 225 }; 102 226 ··· 105 229 }; 106 230 } 107 231 232 + setupToggleButton(); 108 233 connect();
+32
dfgraph/frontend/src/style.ts
··· 74 74 "target-arrow-color": "#e53935", 75 75 }, 76 76 }, 77 + { 78 + selector: "node.pe-cluster", 79 + style: { 80 + shape: "roundrectangle", 81 + "border-width": 2, 82 + "border-color": "#5c6bc0", 83 + "background-color": "rgba(92, 107, 192, 0.06)", 84 + padding: "20px", 85 + "text-valign": "top", 86 + "text-halign": "center", 87 + label: "data(label)", 88 + "font-size": 13, 89 + "font-weight": "bold", 90 + color: "#5c6bc0", 91 + }, 92 + }, 93 + { 94 + selector: "edge.cross-pe", 95 + style: { 96 + width: 3, 97 + "line-color": "#5c6bc0", 98 + "target-arrow-color": "#5c6bc0", 99 + }, 100 + }, 101 + { 102 + selector: "edge.intra-pe", 103 + style: { 104 + width: 1.5, 105 + "line-color": "#bbb", 106 + "target-arrow-color": "#bbb", 107 + }, 108 + }, 77 109 ];