Spaces:
Paused
Paused
| // static/js/canvas.js | |
| // --- Configuration --- | |
| const CONFIG = { | |
| // Only render nodes within this padding of the viewport | |
| viewportPadding: 200, | |
| // Throttle scroll events to run only every 16ms (60fps) | |
| throttleMs: 16, | |
| // Visual settings | |
| nodeWidth: 200, | |
| nodeHeight: 50, | |
| indentWidth: 60, | |
| rowHeight: 80 | |
| }; | |
| // --- Setup Stage --- | |
| const width = window.innerWidth - 400; // Adjust for sidebar | |
| const height = window.innerHeight; | |
| const stage = new Konva.Stage({ | |
| container: 'container', | |
| width: width, | |
| height: height, | |
| draggable: true | |
| }); | |
| const layer = new Konva.Layer(); | |
| stage.add(layer); | |
| // Global State | |
| let allNodes = []; // Array of { group, x, y, visible } | |
| let sourceLines = []; // Cached source code lines | |
| let isTicking = false; // For throttling | |
| // --- 1. Optimization Core: Viewport Culling --- | |
| function updateVisibleNodes() { | |
| isTicking = false; | |
| // Get the visible viewport in "World Coordinates" (accounting for zoom/pan) | |
| const scale = stage.scaleX(); | |
| const stageX = stage.x(); | |
| const stageY = stage.y(); | |
| // The logic: Invert the transform to find what part of the world is visible | |
| const viewX = -(stageX / scale) - CONFIG.viewportPadding; | |
| const viewY = -(stageY / scale) - CONFIG.viewportPadding; | |
| const viewW = (stage.width() / scale) + (CONFIG.viewportPadding * 2); | |
| const viewH = (stage.height() / scale) + (CONFIG.viewportPadding * 2); | |
| const viewRight = viewX + viewW; | |
| const viewBottom = viewY + viewH; | |
| // Batch updates to prevent multiple redraws | |
| let nodesChanged = false; | |
| // Fast loop (standard for-loop is faster than .forEach for massive arrays) | |
| for (let i = 0; i < allNodes.length; i++) { | |
| const node = allNodes[i]; | |
| // Simple Bounding Box Collision Check | |
| const isVisible = ( | |
| node.x < viewRight && | |
| node.x + CONFIG.nodeWidth > viewX && | |
| node.y < viewBottom && | |
| node.y + CONFIG.nodeHeight > viewY | |
| ); | |
| // Only touch the DOM/Konva object if state changes (Save CPU) | |
| if (node.visible !== isVisible) { | |
| node.group.visible(isVisible); | |
| node.visible = isVisible; | |
| nodesChanged = true; | |
| } | |
| } | |
| if (nodesChanged) { | |
| // layer.batchDraw() is more efficient than layer.draw() | |
| layer.batchDraw(); | |
| } | |
| } | |
| // Request Animation Frame Wrapper (Throttling) | |
| function requestUpdate() { | |
| if (!isTicking) { | |
| requestAnimationFrame(updateVisibleNodes); | |
| isTicking = true; | |
| } | |
| } | |
| // Bind optimization to interactions | |
| stage.on('dragmove', requestUpdate); | |
| stage.on('wheel', (e) => { | |
| // ... (Zoom logic below) ... | |
| // After zoom, we must update visibility | |
| requestUpdate(); | |
| }); | |
| // --- 2. Standard Logic (Zoom & Draw) --- | |
| // Zoom Logic | |
| stage.on('wheel', (e) => { | |
| e.evt.preventDefault(); | |
| const oldScale = stage.scaleX(); | |
| const pointer = stage.getPointerPosition(); | |
| const scaleBy = 1.05; | |
| const newScale = e.evt.deltaY > 0 ? oldScale / scaleBy : oldScale * scaleBy; | |
| stage.scale({ x: newScale, y: newScale }); | |
| const newPos = { | |
| x: pointer.x - (pointer.x - stage.x()) / oldScale * newScale, | |
| y: pointer.y - (pointer.y - stage.y()) / oldScale * newScale | |
| }; | |
| stage.position(newPos); | |
| }); | |
| // Resize Handler | |
| window.addEventListener('resize', () => { | |
| stage.width(window.innerWidth - 400); | |
| stage.height(window.innerHeight); | |
| requestUpdate(); | |
| }); | |
| // API Listener | |
| document.getElementById('btnVisualize').addEventListener('click', () => { | |
| const code = document.getElementById('codeInput').value; | |
| log('Parsing...'); | |
| fetch('/parse', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ code: code }) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| if(data.error) log(data.error, 'error'); | |
| else { | |
| drawGraph(data, code); | |
| log(`Graph: ${data.nodes.length} nodes`); | |
| } | |
| }); | |
| }); | |
| function drawGraph(data, fullSourceCode) { | |
| // Cleanup old memory | |
| layer.destroyChildren(); | |
| allNodes = []; | |
| // Cache source lines | |
| sourceLines = fullSourceCode.split('\n'); | |
| const nodeMap = {}; | |
| // --- Batch Create Nodes --- | |
| data.nodes.forEach((node, index) => { | |
| const x = 100 + (node.lvl * CONFIG.indentWidth); | |
| const y = 50 + (index * CONFIG.rowHeight); | |
| // Styling | |
| const colors = { | |
| 'function': '#a29bfe', 'class': '#e84393', 'if': '#fab1a0', | |
| 'for': '#fdcb6e', 'while': '#fdcb6e', 'return': '#55efc4', | |
| 'assigned_variable': '#74b9ff', 'import': '#b2bec3' | |
| }; | |
| const group = new Konva.Group({ | |
| x: x, | |
| y: y, | |
| // Optimization: Stop this group from catching mouse events if not needed | |
| listening: true | |
| }); | |
| const rect = new Konva.Rect({ | |
| width: CONFIG.nodeWidth, | |
| height: CONFIG.nodeHeight, | |
| fill: '#2d3436', | |
| stroke: colors[node.type] || '#636e72', | |
| strokeWidth: 2, | |
| cornerRadius: 8, | |
| shadowColor: 'black', | |
| shadowBlur: 10, | |
| shadowOpacity: 0.3, | |
| // Optimization: Perfect bounding box for hit detection | |
| hitStrokeWidth: 0 | |
| }); | |
| const text = new Konva.Text({ | |
| x: 10, y: 10, | |
| text: node.lbl, | |
| fontSize: 14, fontFamily: 'JetBrains Mono', fill: '#fff', | |
| width: 180, ellipsis: true, | |
| // Optimization: Text doesn't need to listen to clicks, the Group/Rect handles it | |
| listening: false | |
| }); | |
| const vecText = new Konva.Text({ | |
| x: 10, y: 32, | |
| text: `V:[${node.vec[0]}, ${node.vec[2]}...]`, | |
| fontSize: 10, fontFamily: 'JetBrains Mono', fill: '#636e72', | |
| listening: false | |
| }); | |
| group.add(rect); | |
| group.add(text); | |
| group.add(vecText); | |
| // Interaction | |
| group.on('click', () => { | |
| const start = node.loc[0] - 1; | |
| const end = node.loc[1]; | |
| const snippet = sourceLines.slice(start, end).join('\n'); | |
| log(`Source:\n${snippet}`); | |
| }); | |
| // Hover | |
| group.on('mouseover', () => { document.body.style.cursor = 'pointer'; rect.fill('#353b48'); layer.batchDraw(); }); | |
| group.on('mouseout', () => { document.body.style.cursor = 'default'; rect.fill('#2d3436'); layer.batchDraw(); }); | |
| layer.add(group); | |
| // Save Reference for Culling Logic | |
| nodeMap[node.id] = { x, y }; | |
| allNodes.push({ | |
| group: group, | |
| x: x, | |
| y: y, | |
| visible: true | |
| }); | |
| }); | |
| // Draw Connections | |
| // Optimization: Draw connections on a generic "listening: false" shape to avoid hit-detection overhead | |
| data.connections.forEach(conn => { | |
| const f = nodeMap[conn.f]; | |
| const t = nodeMap[conn.t]; | |
| if (f && t) { | |
| const line = new Konva.Line({ | |
| points: [f.x+20, f.y+50, f.x+20, t.y-10, t.x+20, t.y-10, t.x+20, t.y], | |
| stroke: '#636e72', strokeWidth: 2, tension: 0.2, opacity: 0.5, | |
| listening: false // Critical: Don't calculate mouse hits for lines | |
| }); | |
| layer.add(line); | |
| line.moveToBottom(); | |
| } | |
| }); | |
| layer.batchDraw(); | |
| // Initial cull calculation | |
| updateVisibleNodes(); | |
| } | |
| function log(msg, type='info') { | |
| const consoleBody = document.getElementById('logOutput'); | |
| const color = type === 'error' ? '#ff7675' : '#55efc4'; | |
| if(consoleBody) { | |
| consoleBody.innerHTML += `<div style="color:${color}">> ${msg}</div>`; | |
| consoleBody.scrollTop = consoleBody.scrollHeight; | |
| } | |
| } |