// Node Canvas Grid Component - Displays multiple SPORE nodes with individual canvases class NodeCanvasGrid extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.nodes = []; this.selectedNode = null; this.nodeCanvases = new Map(); // Store canvas contexts for each node this.nodeFrameData = new Map(); // Store frame data for each node this.matrixWidth = 16; this.matrixHeight = 16; this.pendingRenders = new Set(); // Track which nodes need rendering this.animationFrameId = null; // Track animation frame } mount() { super.mount(); this.setupEventListeners(); this.setupViewModelListeners(); this.loadNodes(); this.startPeriodicRefresh(); } setupEventListeners() { // Settings toggle button const settingsToggleBtn = document.getElementById('settings-toggle-btn'); if (settingsToggleBtn) { this.addEventListener(settingsToggleBtn, 'click', () => { if (window.navigationManager) { window.navigationManager.switchView('settings'); } }); } // Close controls button const closeControlsBtn = document.getElementById('close-controls-btn'); if (closeControlsBtn) { this.addEventListener(closeControlsBtn, 'click', () => { this.deselectNode(); }); } } setupViewModelListeners() { this.subscribeToEvent('nodeDiscovered', (data) => { this.addOrUpdateNode(data.node); }); this.subscribeToEvent('nodeLost', (data) => { this.removeNode(data.node.ip); }); this.subscribeToEvent('frame', (data) => { // Update frame data for specific node if (data.nodeIp) { this.nodeFrameData.set(data.nodeIp, data.data); this.scheduleRender(data.nodeIp); } }); this.subscribeToEvent('matrixSizeChanged', (data) => { this.matrixWidth = data.size.width; this.matrixHeight = data.size.height; this.renderAllCanvases(); }); this.subscribeToEvent('streamingStarted', (data) => { if (data.nodeIp) { this.updateNodeStreamingStatus(data.nodeIp, true); } }); this.subscribeToEvent('streamingStopped', (data) => { if (data.nodeIp) { this.updateNodeStreamingStatus(data.nodeIp, false); } }); this.subscribeToEvent('status', (data) => { // Update UI to reflect current server state if (data.data.nodes) { this.nodes = data.data.nodes; this.renderNodeGrid(); } if (data.data.matrixSize) { this.matrixWidth = data.data.matrixSize.width; this.matrixHeight = data.data.matrixSize.height; } }); } async loadNodes() { try { const response = await fetch('/api/nodes'); const data = await response.json(); // Filter out any broadcast nodes this.nodes = (data.nodes || []).filter(node => node.ip !== 'broadcast'); this.renderNodeGrid(); } catch (error) { console.error('Error loading nodes:', error); this.showError('Failed to load nodes'); } } startPeriodicRefresh() { // Refresh node list every 5 seconds setInterval(() => { this.loadNodes(); }, 5000); } addOrUpdateNode(node) { // Never add broadcast nodes if (node.ip === 'broadcast') { return; } const existingIndex = this.nodes.findIndex(n => n.ip === node.ip); if (existingIndex >= 0) { // Update existing node this.nodes[existingIndex] = { ...node, lastSeen: Date.now() }; } else { // Add new node this.nodes.push({ ...node, lastSeen: Date.now() }); } this.renderNodeGrid(); } removeNode(nodeIp) { // Don't process broadcast nodes if (nodeIp === 'broadcast') { return; } this.nodes = this.nodes.filter(node => node.ip !== nodeIp); this.nodeCanvases.delete(nodeIp); this.nodeFrameData.delete(nodeIp); // If the removed node was selected, deselect it if (this.selectedNode === nodeIp) { this.deselectNode(); } this.renderNodeGrid(); } renderNodeGrid() { const gridContainer = document.getElementById('node-canvas-grid'); if (!gridContainer) return; // Clear existing content gridContainer.innerHTML = ''; // Filter out broadcast nodes one more time for safety const validNodes = this.nodes.filter(node => node.ip !== 'broadcast'); if (validNodes.length === 0) { gridContainer.innerHTML = '
No nodes discovered. Waiting for SPORE nodes...
'; return; } validNodes.forEach(node => { const nodeItem = this.createNodeCanvasItem(node); gridContainer.appendChild(nodeItem); }); // Update visual selection this.updateNodeSelection(); } createNodeCanvasItem(node) { const nodeItem = document.createElement('div'); nodeItem.className = `node-canvas-item ${node.status || 'connected'}`; nodeItem.dataset.nodeIp = node.ip; // Create header const header = document.createElement('div'); header.className = 'node-canvas-header'; const title = document.createElement('div'); title.className = 'node-canvas-title'; const ip = document.createElement('div'); ip.className = 'node-canvas-ip'; ip.textContent = node.ip; const status = document.createElement('div'); status.className = `node-canvas-status ${node.status || 'connected'}`; status.textContent = node.status || 'connected'; title.appendChild(ip); header.appendChild(title); header.appendChild(status); // Create canvas container const canvasContainer = document.createElement('div'); canvasContainer.className = 'node-canvas-container'; const canvas = document.createElement('canvas'); canvas.className = 'node-canvas'; canvas.dataset.nodeIp = node.ip; canvasContainer.appendChild(canvas); // Assemble node item nodeItem.appendChild(header); nodeItem.appendChild(canvasContainer); // Setup canvas this.setupNodeCanvas(node.ip, canvas); // Add click handler for node selection nodeItem.addEventListener('click', (e) => { e.stopPropagation(); this.selectNode(node.ip); }); return nodeItem; } setupNodeCanvas(nodeIp, canvas) { const ctx = canvas.getContext('2d', { alpha: false }); if (!ctx) { console.error('Failed to get canvas context for node:', nodeIp); return; } // Create off-screen canvas for double buffering const offscreenCanvas = document.createElement('canvas'); const offscreenCtx = offscreenCanvas.getContext('2d', { alpha: false }); // Set initial size for offscreen canvas offscreenCanvas.width = canvas.width || 320; offscreenCanvas.height = canvas.height || 320; // Configure both contexts [ctx, offscreenCtx].forEach(context => { context.imageSmoothingEnabled = false; context.webkitImageSmoothingEnabled = false; context.mozImageSmoothingEnabled = false; context.msImageSmoothingEnabled = false; }); // Store both canvas contexts this.nodeCanvases.set(nodeIp, { canvas, ctx, offscreenCanvas, offscreenCtx, resizeTimer: null }); // Function to update canvas size const updateCanvasSize = () => { const container = canvas.parentElement; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Calculate pixel size to maximize canvas space const maxPixelWidth = Math.floor(containerWidth / this.matrixWidth); const maxPixelHeight = Math.floor(containerHeight / this.matrixHeight); const pixelSize = Math.min(maxPixelWidth, maxPixelHeight); const newWidth = this.matrixWidth * pixelSize; const newHeight = this.matrixHeight * pixelSize; // Only update if size actually changed if (canvas.width !== newWidth || canvas.height !== newHeight) { canvas.width = newWidth; canvas.height = newHeight; offscreenCanvas.width = newWidth; offscreenCanvas.height = newHeight; this.renderNodeCanvas(nodeIp); } }; // Initial size - call immediately and wait for next frame requestAnimationFrame(() => { updateCanvasSize(); // Render initial frame if we have data if (this.nodeFrameData.has(nodeIp)) { this.renderNodeCanvas(nodeIp); } }); // Handle resize with debouncing let resizeTimer = null; const resizeObserver = new ResizeObserver(() => { // Clear existing timer if (resizeTimer) { clearTimeout(resizeTimer); } // Set new timer with debounce resizeTimer = setTimeout(() => { updateCanvasSize(); }, 100); }); resizeObserver.observe(canvas.parentElement); } scheduleRender(nodeIp) { // Add node to pending renders this.pendingRenders.add(nodeIp); // Schedule animation frame if not already scheduled if (!this.animationFrameId) { this.animationFrameId = requestAnimationFrame(() => { this.renderPendingFrames(); }); } } renderPendingFrames() { // Render all pending nodes this.pendingRenders.forEach(nodeIp => { this.renderNodeCanvas(nodeIp); }); // Clear pending renders and animation frame ID this.pendingRenders.clear(); this.animationFrameId = null; } renderNodeCanvas(nodeIp) { const canvasData = this.nodeCanvases.get(nodeIp); if (!canvasData) return; const { canvas, ctx, offscreenCanvas, offscreenCtx } = canvasData; const frameData = this.nodeFrameData.get(nodeIp); // Check if canvases are properly sized if (!canvas.width || !canvas.height || !offscreenCanvas.width || !offscreenCanvas.height) { console.warn(`Canvas not sized for ${nodeIp}`); return; } // Skip if no frame data to prevent unnecessary clears if (!frameData || !frameData.startsWith('RAW:')) { return; } const pixelData = frameData.substring(4); const pixelSize = offscreenCanvas.width / this.matrixWidth; // Render to off-screen canvas (not visible, no flicker) offscreenCtx.fillStyle = '#000000'; offscreenCtx.fillRect(0, 0, offscreenCanvas.width, offscreenCanvas.height); // Batch render all pixels to off-screen canvas for (let row = 0; row < this.matrixHeight; row++) { for (let col = 0; col < this.matrixWidth; col++) { // Calculate serpentine index const hardwareIndex = (row % 2 === 0) ? (row * this.matrixWidth + col) : (row * this.matrixWidth + (this.matrixWidth - 1 - col)); const pixelStart = hardwareIndex * 6; if (pixelStart + 5 < pixelData.length) { const hexColor = pixelData.substring(pixelStart, pixelStart + 6); // Quick parse RGB const r = parseInt(hexColor.substring(0, 2), 16); const g = parseInt(hexColor.substring(2, 4), 16); const b = parseInt(hexColor.substring(4, 6), 16); // Skip black pixels for performance if (r === 0 && g === 0 && b === 0) continue; // Draw pixel to off-screen canvas const x = col * pixelSize; const y = row * pixelSize; offscreenCtx.fillStyle = `rgb(${r},${g},${b})`; offscreenCtx.fillRect(x, y, pixelSize, pixelSize); } } } // Copy the complete off-screen canvas to visible canvas in one operation (smooth!) ctx.drawImage(offscreenCanvas, 0, 0); } renderAllCanvases() { this.nodes.forEach(node => { this.renderNodeCanvas(node.ip); }); } selectNode(nodeIp) { this.selectedNode = nodeIp; this.updateNodeSelection(); this.showFloatingControls(nodeIp); // Notify other components this.viewModel.publish('selectNode', { nodeIp }); } deselectNode() { this.selectedNode = null; this.updateNodeSelection(); this.hideFloatingControls(); } updateNodeSelection() { const gridContainer = document.getElementById('node-canvas-grid'); if (!gridContainer) return; // Remove selected class from all items gridContainer.querySelectorAll('.node-canvas-item').forEach(item => { item.classList.remove('selected'); }); // Add selected class to the selected node if (this.selectedNode) { const selectedItem = gridContainer.querySelector(`[data-node-ip="${this.selectedNode}"]`); if (selectedItem) { selectedItem.classList.add('selected'); } } } showFloatingControls(nodeIp) { const floatingControls = document.getElementById('floating-controls'); const selectedNodeName = document.getElementById('selected-node-name'); if (floatingControls && selectedNodeName) { selectedNodeName.textContent = nodeIp; floatingControls.style.display = 'flex'; } } hideFloatingControls() { const floatingControls = document.getElementById('floating-controls'); if (floatingControls) { floatingControls.style.display = 'none'; } } updateNodeStreamingStatus(nodeIp, isStreaming) { const gridContainer = document.getElementById('node-canvas-grid'); if (!gridContainer) return; const nodeItem = gridContainer.querySelector(`[data-node-ip="${nodeIp}"]`); if (nodeItem) { if (isStreaming) { nodeItem.classList.add('streaming'); } else { nodeItem.classList.remove('streaming'); } const statusElement = nodeItem.querySelector('.node-canvas-status'); if (statusElement) { statusElement.textContent = isStreaming ? 'streaming' : 'connected'; statusElement.className = `node-canvas-status ${isStreaming ? 'streaming' : 'connected'}`; } } } showError(message) { const gridContainer = document.getElementById('node-canvas-grid'); if (gridContainer) { gridContainer.innerHTML = `
${this.escapeHtml(message)}
`; } } escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = NodeCanvasGrid; }