import cytoscape from "cytoscape"; import elk from "cytoscape-elk"; import svg from "cytoscape-svg"; import type { MonitorNode, MonitorEdge, MonitorUpdate, GraphLoadedMessage, ResetMessage, ServerMessage, SystemState, } from "./types"; import { monitorStylesheet } from "./style"; import { logicalLayout, physicalLayout } from "@common/layout"; import { exportSvg, exportPng, copyPng } from "@common/export"; import { createConnection } from "./connection"; import type { MonitorConnection } from "./connection"; import { clearEventLog, updateEventLog, setEventFilter, updateStateInspector, displayNodeMatchingStore, displaySMNodeState } from "./panels"; import { rp } from "./palette"; // Register plugins cytoscape.use(elk); cytoscape.use(svg); const ROUTING_CATEGORY = "routing"; // Global state let cy: cytoscape.Core; let isPhysicalLayout = true; let currentState: SystemState | null = null; let currentNodes: ReadonlyArray = []; let currentEdges: ReadonlyArray = []; /** * Route edges with intelligent bezier curves to avoid node collisions. * Ported from dfgraph/frontend/src/main.ts. */ function routeEdges(selector?: string): void { const nodes = cy.nodes().not(":parent").not(".port-node"); const nodePositions: Array<{ x: number; y: number }> = []; nodes.forEach((n) => { nodePositions.push({ x: n.position("x"), y: n.position("y") }); }); let leftCount = 0; let rightCount = 0; const edges = selector ? cy.edges(selector) : cy.edges(); edges.forEach((edge) => { const sy = edge.source().position("y"); const ty = edge.target().position("y"); const sx = edge.source().position("x"); const tx = edge.target().position("x"); const span = Math.abs(ty - sy); if (span < 80) { edge.style({ "curve-style": "bezier" }); return; } const blocked = nodePositions.some((p) => { return p.y > Math.min(sy, ty) + 25 && p.y < Math.max(sy, ty) - 25; }); if (!blocked) { edge.style({ "curve-style": "bezier" }); return; } const avgX = (sx + tx) / 2; const centerX = (Math.min(...nodePositions.map((p) => p.x)) + Math.max(...nodePositions.map((p) => p.x))) / 2; const goLeft = avgX >= centerX; const baseOffset = 30; const stagger = goLeft ? 12 * leftCount : 12 * rightCount; const offset = (goLeft ? -1 : 1) * (baseOffset + stagger); if (goLeft) leftCount++; else rightCount++; edge.style({ "curve-style": "unbundled-bezier", "control-point-distances": [offset * 0.6, offset, offset * 0.6], "control-point-weights": [0.2, 0.5, 0.8], }); }); } /** * Build Cytoscape elements for logical view (flat, no PE clustering). */ function buildLogicalElements( nodes: ReadonlyArray, edges: ReadonlyArray, ): cytoscape.ElementDefinition[] { const elements: cytoscape.ElementDefinition[] = []; for (const node of nodes) { const label = node.label ? node.label : node.const !== null ? `${node.opcode}\n${node.const}` : node.opcode; elements.push({ data: { id: node.id, label, colour: node.colour, category: node.category, pe: node.pe, iram_offset: node.iram_offset, ctx: node.ctx, synthetic: node.synthetic ?? false, sm_id: (node as any).sm_id ?? null, }, }); } for (const edge of edges) { const sourceNode = nodes.find((n) => n.id === edge.source); let sourceLabel: string | undefined; if (sourceNode && sourceNode.category === ROUTING_CATEGORY && edge.source_port) { sourceLabel = edge.source_port === "L" ? "T" : "F"; } elements.push({ data: { id: `${edge.source}->${edge.target}:${edge.port}`, source: edge.source, target: edge.target, targetLabel: edge.port, sourceLabel: sourceLabel ?? "", }, classes: edge.synthetic ? "synthetic" : undefined, }); } return elements; } /** * Build Cytoscape elements for physical view with bus topology. * Ported from dfgraph/frontend/src/main.ts buildPhysicalElements(). */ function buildPhysicalElements( nodes: ReadonlyArray, edges: ReadonlyArray, ): cytoscape.ElementDefinition[] { const elements: cytoscape.ElementDefinition[] = []; // Build edge target set for seed const detection const edgeTargets = new Set(); for (const edge of edges) { edgeTargets.add(edge.target); } const seedConstIds = new Set(); const nodePeMap = new Map(); const peIds = new Set(); for (const node of nodes) { nodePeMap.set(node.id, node.pe); if (node.pe !== null) peIds.add(node.pe); if (node.category === "config" && node.opcode === "const" && !edgeTargets.has(node.id)) { seedConstIds.add(node.id); } } // Create PE cluster parent nodes for (const peId of peIds) { elements.push({ data: { id: `pe-${peId}`, label: `PE ${peId}` }, classes: "pe-cluster", }); } // Create operation nodes for (const node of nodes) { const isSeedConst = seedConstIds.has(node.id); const label = node.label ? node.label : node.const !== null ? `${node.opcode}\n${node.const}` : node.opcode; const el: cytoscape.ElementDefinition = { data: { id: node.id, label, colour: node.colour, category: node.category, pe: node.pe, iram_offset: node.iram_offset, ctx: node.ctx, synthetic: node.synthetic ?? false, sm_id: (node as any).sm_id ?? null, }, classes: [isSeedConst ? "seed-const" : undefined].filter(Boolean).join(" ") || undefined, }; // Synthetic SM nodes and seed consts float freely if (!node.synthetic && !isSeedConst && node.pe !== null) { el.data.parent = `pe-${node.pe}`; } elements.push(el); } // Scan cross-PE edges to find PE pairs for bus topology type PePair = { srcPe: number; tgtPe: number }; const pePairKey = (src: number, tgt: number) => `${src}->${tgt}`; const crossPePairs = new Map(); const busEdgeCounts = new Map(); for (const edge of edges) { if (seedConstIds.has(edge.source) || edge.synthetic) continue; const srcPe = nodePeMap.get(edge.source) ?? null; const tgtPe = nodePeMap.get(edge.target) ?? null; if (srcPe !== null && tgtPe !== null && srcPe !== tgtPe) { const key = pePairKey(srcPe, tgtPe); if (!crossPePairs.has(key)) { crossPePairs.set(key, { srcPe, tgtPe }); } busEdgeCounts.set(key, (busEdgeCounts.get(key) ?? 0) + 1); } } // Create port nodes for each directed PE pair for (const [, pair] of crossPePairs) { elements.push({ data: { id: `port-${pair.srcPe}-to-${pair.tgtPe}-exit`, parent: `pe-${pair.srcPe}`, }, classes: "port-node", }); elements.push({ data: { id: `port-${pair.srcPe}-to-${pair.tgtPe}-entry`, parent: `pe-${pair.tgtPe}`, }, classes: "port-node", }); } // Create bus edges (one per PE pair) for (const [key, pair] of crossPePairs) { const count = busEdgeCounts.get(key) ?? 1; elements.push({ data: { id: `bus-${pair.srcPe}-to-${pair.tgtPe}`, source: `port-${pair.srcPe}-to-${pair.tgtPe}-exit`, target: `port-${pair.srcPe}-to-${pair.tgtPe}-entry`, label: count > 1 ? `×${count}` : "", }, classes: "physical bus-segment", }); } // Create edges with proper splitting for (const edge of edges) { const srcPe = nodePeMap.get(edge.source) ?? null; const tgtPe = nodePeMap.get(edge.target) ?? null; if (edge.synthetic) { // Synthetic edges (SM request/return) — never bundled elements.push({ data: { id: `${edge.source}->${edge.target}:${edge.port}`, source: edge.source, target: edge.target, targetLabel: edge.port, sourceLabel: "", }, classes: "physical synthetic", }); } else if (seedConstIds.has(edge.source)) { // Seed const: direct edge, never bundled elements.push({ data: { id: `${edge.source}->${edge.target}:${edge.port}`, source: edge.source, target: edge.target, targetLabel: edge.port, sourceLabel: "", }, classes: "seed-edge physical", }); } else if (srcPe !== null && tgtPe !== null && srcPe !== tgtPe) { // Cross-PE: split into exit segment + entry segment (bus already created) elements.push({ data: { id: `${edge.source}->exit-${srcPe}-to-${tgtPe}:${edge.port}`, source: edge.source, target: `port-${srcPe}-to-${tgtPe}-exit`, sourceLabel: "", targetLabel: "", }, classes: "physical exit-segment", }); elements.push({ data: { id: `entry-${srcPe}-to-${tgtPe}->${edge.target}:${edge.port}`, source: `port-${srcPe}-to-${tgtPe}-entry`, target: edge.target, targetLabel: edge.port, }, classes: "physical entry-segment", }); } else { // Intra-PE or unplaced const sourceNode = nodes.find((n) => n.id === edge.source); let sourceLabel: string | undefined; if (sourceNode && sourceNode.category === ROUTING_CATEGORY && edge.source_port) { sourceLabel = edge.source_port === "L" ? "T" : "F"; } elements.push({ data: { id: `${edge.source}->${edge.target}:${edge.port}`, source: edge.source, target: edge.target, targetLabel: edge.port, sourceLabel: sourceLabel ?? "", }, classes: "physical intra-pe", }); } } return elements; } /** * Initialize Cytoscape on the graph container. */ function initializeCytoscape(): void { const container = document.getElementById("graph"); if (!container) { console.error("Graph container not found"); return; } cy = cytoscape({ container, style: [...monitorStylesheet], elements: [], maxZoom: 2.0, minZoom: 0.1, }); } /** * Render graph from loaded data. */ function renderGraph( nodes: ReadonlyArray, edges: ReadonlyArray, ): void { currentNodes = nodes; currentEdges = edges; const elements = isPhysicalLayout ? buildPhysicalElements(nodes, edges) : buildLogicalElements(nodes, edges); cy.batch(() => { cy.elements().remove(); cy.add(elements); }); const layoutOptions = isPhysicalLayout ? physicalLayout() : logicalLayout(); const layout = cy.layout(layoutOptions); layout.on("layoutstop", () => { if (isPhysicalLayout) { routeEdges(".intra-pe, .seed-edge"); } else { routeEdges(); } cy.fit(undefined, 40); }); layout.run(); } /** * Update graph execution state from monitor update message. */ function updateGraphExecution( nodes: ReadonlyArray, edges: ReadonlyArray, state: SystemState, ): void { // Clear previous execution classes cy.nodes().removeClass(["active", "executed", "matched", "half-matched", "cell-written"]); cy.edges().removeClass("token-flow"); // Apply execution state to nodes for (const node of nodes) { const cyNode = cy.getElementById(node.id); if (!cyNode || cyNode.empty()) { if (node.synthetic) { console.warn("[Monitor] SM node not found in cy:", node.id, "label:", node.label); } continue; } if (node.active) cyNode.addClass("active"); if (node.matched) cyNode.addClass("matched"); if (node.executed) cyNode.addClass("executed"); if (node.cell_written) cyNode.addClass("cell-written"); // Update SM node labels with live state if (node.synthetic) { if (node.label) { cyNode.data("label", node.label); } } // Check for half-matched: look in matching store if (node.pe !== null && node.iram_offset !== null) { const peId = node.pe.toString(); const peState = state.pes?.[peId]; if (peState && node.ctx !== null) { const ctx = node.ctx; const ms = peState.matching_store?.[ctx]; if (ms && node.iram_offset < ms.length && ms[node.iram_offset]?.occupied) { cyNode.addClass("half-matched"); } } } } // Apply token flow to edges — need to handle both edge ID formats for (const monitorEdge of edges) { if (monitorEdge.token_flow) { // Try both ID formats (logical uses source->target, physical uses source->target:port) const logicalId = `${monitorEdge.source}->${monitorEdge.target}`; const physicalId = `${monitorEdge.source}->${monitorEdge.target}:${monitorEdge.port}`; const cyEdge = cy.getElementById(physicalId); if (cyEdge && !cyEdge.empty()) { cyEdge.addClass("token-flow"); } else { const fallback = cy.getElementById(logicalId); if (fallback && !fallback.empty()) { fallback.addClass("token-flow"); } } } } } /** * Handle graph_loaded message from server. */ function handleGraphLoaded(msg: GraphLoadedMessage): void { console.log("[Monitor] Graph loaded"); currentState = msg.state; renderGraph(msg.graph.nodes, msg.graph.edges); // Initialize panels — clear event log for fresh program clearEventLog(); updateEventLog([], { container: document.getElementById("event-log") || document.createElement("div"), maxEvents: 1000, }); updateStateInspector(msg.state, { container: document.getElementById("state-inspector") || document.createElement("div"), }); // Update sim time updateSimTime(msg.sim_time, false); } /** * Handle monitor_update message from server. */ function handleMonitorUpdate(msg: MonitorUpdate): void { currentState = msg.state; // Update graph execution state updateGraphExecution(msg.graph.nodes, msg.graph.edges, msg.state); // Update panels const eventLogContainer = document.getElementById("event-log"); if (eventLogContainer) { updateEventLog(msg.events, { container: eventLogContainer, maxEvents: 1000, }); } const stateInspectorContainer = document.getElementById("state-inspector"); if (stateInspectorContainer) { updateStateInspector(msg.state, { container: stateInspectorContainer, }); } updateSimTime(msg.sim_time, msg.finished); } /** * Handle reset message from server. */ function handleReset(msg: ResetMessage): void { console.log("[Monitor] Simulation reset"); // Clear the graph cy.elements().remove(); // Reset event log clearEventLog(); const eventLogContainer = document.getElementById("event-log"); if (eventLogContainer) { updateEventLog([], { container: eventLogContainer, maxEvents: 1000, }); } // Clear state inspector const stateInspectorContainer = document.getElementById("state-inspector"); if (stateInspectorContainer) { stateInspectorContainer.innerHTML = ""; } // Update simulation time updateSimTime(msg.sim_time, false); } /** * Update simulation time display. */ function updateSimTime(time: number, finished: boolean): void { const timeDisplay = document.getElementById("sim-time"); if (!timeDisplay) return; timeDisplay.textContent = `t=${time.toFixed(3)}`; const finishedBadge = document.getElementById("finished-badge"); if (finishedBadge) { finishedBadge.style.display = finished ? "inline" : "none"; } } /** * Handle layout toggle. */ function toggleLayout(): void { isPhysicalLayout = !isPhysicalLayout; const button = document.getElementById("layout-toggle"); if (button) { button.textContent = isPhysicalLayout ? "Logical Layout" : "Physical Layout"; } // Re-render graph with stored data if (currentNodes.length > 0) { renderGraph(currentNodes, currentEdges); } } /** * Handle node click to show matching store (AC3.11). */ function setupNodeClickHandler(): void { cy.on("tap", "node", (event: any) => { const node = event.target; const nodeData = node.data(); // Highlight this node cy.elements().removeClass("highlighted"); node.addClass("highlighted"); const stateInspectorContainer = document.getElementById("state-inspector"); if (!currentState || !stateInspectorContainer) return; // SM node clicked: show SM state detail if (nodeData.synthetic && nodeData.sm_id !== null) { const smIdStr = nodeData.sm_id.toString(); const smState = currentState.sms?.[smIdStr]; if (smState) { displaySMNodeState(nodeData.sm_id, smState, stateInspectorContainer); } return; } // PE node clicked: show matching store info (AC3.11) if (nodeData.pe !== null && nodeData.ctx !== null && nodeData.iram_offset !== null) { const peId = nodeData.pe; const peIdStr = peId.toString(); const peState = currentState.pes?.[peIdStr]; if (peState) { const ms = peState.matching_store?.[nodeData.ctx]; if (ms && nodeData.iram_offset < ms.length) { const entry = ms[nodeData.iram_offset]; displayNodeMatchingStore(peId, nodeData.ctx, nodeData.iram_offset, entry, stateInspectorContainer); } } } }); } /** * Setup control handlers. */ function setupControlHandlers(connection: MonitorConnection): void { // Load file button const fileInput = document.getElementById("file-input") as HTMLInputElement; const loadButton = document.getElementById("load-button"); if (loadButton) { loadButton.addEventListener("click", () => { fileInput?.click(); }); } if (fileInput) { fileInput.addEventListener("change", (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { const source = event.target?.result as string; connection.send({ cmd: "load", source }); }; reader.readAsText(file); } }); } // Step tick button const stepTickButton = document.getElementById("step-tick-button"); if (stepTickButton) { stepTickButton.addEventListener("click", () => { connection.send({ cmd: "step_tick" }); }); } // Step event button const stepEventButton = document.getElementById("step-event-button"); if (stepEventButton) { stepEventButton.addEventListener("click", () => { connection.send({ cmd: "step_event" }); }); } // Run until button const runUntilButton = document.getElementById("run-until-button"); const runUntilInput = document.getElementById("run-until-input") as HTMLInputElement; if (runUntilButton) { runUntilButton.addEventListener("click", () => { const until = parseFloat(runUntilInput?.value || "0"); if (!isNaN(until)) { connection.send({ cmd: "run_until", until }); } }); } // Send token button const sendButton = document.getElementById("send-button"); if (sendButton) { sendButton.addEventListener("click", () => { const target = parseInt( (document.getElementById("send-target") as HTMLInputElement)?.value || "0" ); const offset = parseInt( (document.getElementById("send-offset") as HTMLInputElement)?.value || "0" ); const ctx = parseInt( (document.getElementById("send-ctx") as HTMLInputElement)?.value || "0" ); const data = parseInt( (document.getElementById("send-data") as HTMLInputElement)?.value || "0" ); if (!isNaN(target) && !isNaN(offset) && !isNaN(ctx) && !isNaN(data)) { connection.send({ cmd: "send", target, offset, ctx, data }); } }); } // Inject token button const injectButton = document.getElementById("inject-button"); if (injectButton) { injectButton.addEventListener("click", () => { const target = parseInt( (document.getElementById("inject-target") as HTMLInputElement)?.value || "0" ); const offset = parseInt( (document.getElementById("inject-offset") as HTMLInputElement)?.value || "0" ); const ctx = parseInt( (document.getElementById("inject-ctx") as HTMLInputElement)?.value || "0" ); const data = parseInt( (document.getElementById("inject-data") as HTMLInputElement)?.value || "0" ); if (!isNaN(target) && !isNaN(offset) && !isNaN(ctx) && !isNaN(data)) { connection.send({ cmd: "inject", target, offset, ctx, data }); } }); } // Reset button const resetButton = document.getElementById("reset-button"); if (resetButton) { resetButton.addEventListener("click", () => { connection.send({ cmd: "reset", reload: true }); }); } // Layout toggle const layoutToggle = document.getElementById("layout-toggle"); if (layoutToggle) { layoutToggle.addEventListener("click", toggleLayout); } // Export buttons const exportSvgButton = document.getElementById("export-svg-button"); if (exportSvgButton) { exportSvgButton.addEventListener("click", () => { exportSvg(cy); }); } const exportPngButton = document.getElementById("export-png-button"); if (exportPngButton) { exportPngButton.addEventListener("click", async () => { try { await exportPng(cy); } catch (err) { console.error("Export PNG failed:", err); } }); } const copyPngButton = document.getElementById("copy-png-button"); if (copyPngButton) { copyPngButton.addEventListener("click", async () => { try { await copyPng(cy); } catch (err) { console.error("Copy PNG failed:", err); } }); } // Event filter const filterComponentSelect = document.getElementById("filter-component") as HTMLSelectElement; const filterTypeSelect = document.getElementById("filter-type") as HTMLSelectElement; function updateFilter(): void { const logContainer = document.getElementById("event-log"); setEventFilter( { component: filterComponentSelect?.value || null, eventType: filterTypeSelect?.value || null, }, logContainer ); } if (filterComponentSelect) { filterComponentSelect.addEventListener("change", updateFilter); } if (filterTypeSelect) { filterTypeSelect.addEventListener("change", updateFilter); } } /** * Main initialization. */ function main(): void { initializeCytoscape(); setupNodeClickHandler(); // Determine WebSocket URL const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const host = window.location.host; const wsUrl = `${protocol}//${host}/ws`; console.log("[Monitor] Connecting to", wsUrl); const connection = createConnection({ url: wsUrl, onMessage: (message: ServerMessage) => { if (message.type === "graph_loaded") { handleGraphLoaded(message); } else if (message.type === "monitor_update") { handleMonitorUpdate(message); } else if (message.type === "reset") { handleReset(message); } else if (message.type === "error") { console.error("[Monitor] Server error:", message.message); } }, onConnect: () => { console.log("[Monitor] Connected"); const statusEl = document.getElementById("connection-status"); if (statusEl) { statusEl.textContent = "Connected"; statusEl.style.color = rp.foam; } }, onDisconnect: () => { console.log("[Monitor] Disconnected"); const statusEl = document.getElementById("connection-status"); if (statusEl) { statusEl.textContent = "Disconnected"; statusEl.style.color = rp.love; } }, }); setupControlHandlers(connection); } // Start on DOM ready if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", main); } else { main(); }