490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
// 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 = '<div class="empty-state">No nodes discovered. Waiting for SPORE nodes...</div>';
|
|
return;
|
|
}
|
|
|
|
// Set the data-item-count attribute for proper grid layout
|
|
gridContainer.setAttribute('data-item-count', validNodes.length);
|
|
|
|
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 = `<div class="error">${this.escapeHtml(message)}</div>`;
|
|
}
|
|
}
|
|
|
|
escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
}
|
|
|
|
// Export for use in other modules
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = NodeCanvasGrid;
|
|
}
|
|
|