// 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 += `
> ${msg}
`; consoleBody.scrollTop = consoleBody.scrollHeight; } }