feat: node canvas grid

This commit is contained in:
2025-10-11 21:18:21 +02:00
parent 294d86f24b
commit 32f35c70cf
11 changed files with 1444 additions and 421 deletions

View File

@@ -27,80 +27,70 @@
<!-- Stream View -->
<div id="stream-view" class="view-content active">
<main class="ledlab-main">
<!-- Matrix Display Section -->
<section class="matrix-section">
<div class="matrix-header">
<h2 class="matrix-title">Matrix Display</h2>
<div class="matrix-info">
<span id="matrix-size">16x16</span> |
<span id="streaming-status" class="status-indicator status-disconnected">Stopped</span>
<!-- Full Width Matrix Grid Section -->
<section class="matrix-grid-section">
<div class="matrix-grid-header">
<h2 class="matrix-title">SPORE Nodes</h2>
<div class="matrix-grid-controls">
<button class="btn btn-secondary btn-small" id="settings-toggle-btn">⚙️ Settings</button>
</div>
</div>
</div>
<div class="matrix-container">
<canvas class="matrix-canvas" id="matrix-canvas"></canvas>
</div>
</section>
<!-- Control Panel Section -->
<section class="control-section">
<!-- Node Discovery -->
<div class="control-group">
<h3 class="control-group-title">SPORE Nodes</h3>
<div class="node-controls">
<button class="btn btn-secondary" id="broadcast-btn">Broadcast to All</button>
</div>
<div class="node-list" id="node-list">
<div class="node-canvas-grid" id="node-canvas-grid">
<div class="loading">Discovering nodes...</div>
</div>
</div>
</section>
<!-- Global Controls -->
<div class="control-group">
<h3 class="control-group-title">Global Settings</h3>
<div class="global-controls">
<div class="preset-control">
<label class="preset-label">Frame Rate (FPS)</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="range" class="preset-slider" id="fps-slider" min="1" max="60" step="1" value="20">
<span class="preset-value" id="fps-value">20</span>
<!-- Floating Control Panel (appears when node is selected) -->
<aside class="floating-controls" id="floating-controls" style="display: none;">
<div class="floating-controls-header">
<h3 class="control-title">
<span class="node-indicator"></span>
<span id="selected-node-name">No node selected</span>
</h3>
<button class="btn-close" id="close-controls-btn"></button>
</div>
<div class="floating-controls-content">
<!-- Global Controls -->
<div class="control-group">
<h4 class="control-group-subtitle">Global Settings</h4>
<div class="preset-control">
<label class="preset-label">Frame Rate (FPS)</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="range" class="preset-slider" id="fps-slider" min="1" max="60" step="1" value="20">
<span class="preset-value" id="fps-value">20</span>
</div>
</div>
</div>
<!-- Preset Selection -->
<div class="control-group">
<h4 class="control-group-subtitle">Animation Preset</h4>
<div class="preset-selector-wrapper">
<select class="preset-select" id="preset-select">
<option value="">Select a preset...</option>
</select>
</div>
<div class="preset-controls" id="preset-controls">
<!-- Dynamic controls will be inserted here -->
</div>
<div class="btn-container">
<button class="btn btn-primary" id="toggle-stream-btn" data-streaming="false">
<span class="btn-icon"></span>
<span class="btn-text">Start Streaming</span>
</button>
</div>
</div>
</div>
</div>
<!-- Preset Selection -->
<div class="control-group">
<h3 class="control-group-title">Animation Presets</h3>
<div class="preset-selector-wrapper">
<select class="preset-select" id="preset-select">
<option value="">Select a preset...</option>
</select>
</div>
<div class="preset-controls" id="preset-controls">
<!-- Dynamic controls will be inserted here -->
</div>
<div class="btn-container">
<button class="btn btn-primary" id="toggle-stream-btn" data-streaming="false">
<span class="btn-icon"></span>
<span class="btn-text">Start Streaming</span>
</button>
</div>
</div>
<!-- Hidden Matrix Configuration (used by navigation.js) -->
<div class="control-group" style="display: none;">
<div class="matrix-config">
<div class="matrix-input">
<input type="number" id="matrix-width" min="1" max="32" value="16">
</div>
<div class="matrix-input">
<input type="number" id="matrix-height" min="1" max="32" value="16">
</div>
<!-- Hidden Matrix Configuration -->
<div style="display: none;">
<input type="number" id="matrix-width" min="1" max="32" value="16">
<input type="number" id="matrix-height" min="1" max="32" value="16">
<button id="apply-matrix-btn">Apply</button>
</div>
</div>
</section>
</main>
</aside>
</main>
</div>
<!-- Settings View -->
@@ -148,8 +138,8 @@
<script src="scripts/framework.js"></script>
<script src="scripts/navigation.js"></script>
<script src="scripts/matrix-display.js"></script>
<script src="scripts/node-canvas-grid.js"></script>
<script src="scripts/preset-controls.js"></script>
<script src="scripts/node-discovery.js"></script>
<script src="scripts/ledlab-app.js"></script>
</body>
</html>

View File

@@ -33,26 +33,26 @@ class LEDLabApp {
}
initComponents() {
// Initialize Matrix Display component
// Initialize Node Canvas Grid component (new multi-canvas view)
const gridSection = document.querySelector('.matrix-grid-section');
if (gridSection) {
this.nodeCanvasGrid = new NodeCanvasGrid(gridSection, this.viewModel, this.eventBus);
this.nodeCanvasGrid.mount();
}
// Initialize Preset Controls component (now works with floating controls)
const floatingControls = document.querySelector('#floating-controls');
if (floatingControls) {
this.presetControls = new PresetControls(floatingControls, this.viewModel, this.eventBus);
this.presetControls.mount();
}
// Keep old Matrix Display component for backwards compatibility with settings view
const matrixContainer = document.querySelector('.matrix-section');
if (matrixContainer) {
this.matrixDisplay = new MatrixDisplay(matrixContainer, this.viewModel, this.eventBus);
this.matrixDisplay.mount();
}
// Initialize Preset Controls component
const controlsContainer = document.querySelector('.control-section');
if (controlsContainer) {
this.presetControls = new PresetControls(controlsContainer, this.viewModel, this.eventBus);
this.presetControls.mount();
}
// Initialize Node Discovery component
const nodeContainer = document.querySelector('#node-list').parentElement;
if (nodeContainer) {
this.nodeDiscovery = new NodeDiscovery(nodeContainer, this.viewModel, this.eventBus);
this.nodeDiscovery.mount();
}
}
connectWebSocket() {
@@ -180,13 +180,6 @@ class LEDLabApp {
});
});
this.eventBus.subscribe('broadcastToAll', (data) => {
this.sendWebSocketMessage({
type: 'broadcastToAll',
...data
});
});
this.eventBus.subscribe('selectNode', (data) => {
this.sendWebSocketMessage({
type: 'selectNode',
@@ -194,13 +187,6 @@ class LEDLabApp {
});
});
this.eventBus.subscribe('selectBroadcast', (data) => {
this.sendWebSocketMessage({
type: 'selectBroadcast',
...data
});
});
this.eventBus.subscribe('updateFrameRate', (data) => {
this.sendWebSocketMessage({
type: 'updateFrameRate',
@@ -235,10 +221,6 @@ class LEDLabApp {
sendToNode(nodeIp, message) {
this.viewModel.publish('sendToNode', { nodeIp, message });
}
broadcastToAll(message) {
this.viewModel.publish('broadcastToAll', { message });
}
}
// Initialize the app when DOM is loaded

View File

@@ -0,0 +1,486 @@
// 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;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = NodeCanvasGrid;
}

View File

@@ -1,178 +0,0 @@
// Node Discovery Component
class NodeDiscovery extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.nodes = [];
this.currentTarget = null;
}
mount() {
super.mount();
this.setupEventListeners();
this.setupViewModelListeners();
this.loadNodes();
this.startPeriodicRefresh();
}
setupEventListeners() {
// Broadcast button
const broadcastBtn = this.findElement('#broadcast-btn');
if (broadcastBtn) {
this.addEventListener(broadcastBtn, 'click', () => {
this.selectBroadcastTarget();
});
}
}
setupViewModelListeners() {
this.subscribeToEvent('nodeDiscovered', (data) => {
this.addOrUpdateNode(data.node);
});
this.subscribeToEvent('nodeLost', (data) => {
this.removeNode(data.node.ip);
});
this.subscribeToEvent('status', (data) => {
// Update UI to reflect current server state
if (data.data.nodes) {
this.nodes = data.data.nodes;
this.currentTarget = data.data.currentTarget;
this.renderNodeList();
}
});
}
async loadNodes() {
try {
const response = await fetch('/api/nodes');
const data = await response.json();
this.nodes = data.nodes || [];
this.renderNodeList();
} 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) {
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.renderNodeList();
}
removeNode(nodeIp) {
this.nodes = this.nodes.filter(node => node.ip !== nodeIp);
this.renderNodeList();
}
renderNodeList() {
const nodeListContainer = this.findElement('#node-list');
if (!nodeListContainer) return;
if (this.nodes.length === 0) {
nodeListContainer.innerHTML = '<div class="empty-state">No nodes discovered</div>';
return;
}
const html = this.nodes.map(node => `
<div class="node-item ${node.status} ${node.ip === this.currentTarget ? 'selected' : ''}" data-ip="${this.escapeHtml(node.ip)}" style="cursor: pointer;">
<div class="node-indicator ${node.status}"></div>
<div class="node-info">
<div class="node-ip">${this.escapeHtml(node.ip === 'broadcast' ? 'Broadcast' : node.ip)}</div>
<div class="node-status">${node.status} • Port ${node.port}</div>
</div>
</div>
`).join('');
nodeListContainer.innerHTML = html;
// Add click handlers for node selection
this.nodes.forEach(node => {
const nodeElement = nodeListContainer.querySelector(`[data-ip="${node.ip}"]`);
if (nodeElement) {
this.addEventListener(nodeElement, 'click', () => {
this.selectNode(node.ip);
});
}
});
}
selectNode(nodeIp) {
this.currentTarget = nodeIp;
this.viewModel.publish('selectNode', { nodeIp });
// Update visual selection
const nodeListContainer = this.findElement('#node-list');
if (nodeListContainer) {
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
item.classList.remove('selected');
});
const selectedNode = nodeListContainer.querySelector(`[data-ip="${nodeIp}"]`);
if (selectedNode) {
selectedNode.classList.add('selected');
}
}
}
selectBroadcast() {
this.currentTarget = 'broadcast';
this.viewModel.publish('selectBroadcast', {});
// Update visual selection
const nodeListContainer = this.findElement('#node-list');
if (nodeListContainer) {
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
item.classList.remove('selected');
});
const broadcastNode = nodeListContainer.querySelector(`[data-ip="broadcast"]`);
if (broadcastNode) {
broadcastNode.classList.add('selected');
}
}
}
showError(message) {
const nodeListContainer = this.findElement('#node-list');
if (nodeListContainer) {
nodeListContainer.innerHTML = `<div class="error">${this.escapeHtml(message)}</div>`;
}
}
// Public method to select broadcast (called from outside)
selectBroadcastTarget() {
this.selectBroadcast();
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = NodeDiscovery;
}

View File

@@ -6,6 +6,8 @@ class PresetControls extends Component {
this.presets = {};
this.currentPreset = null;
this.presetControls = new Map();
this.selectedNode = null; // Track which node is currently selected
this.nodeParameters = new Map(); // Store parameters per node: nodeIp -> { presetName, parameters }
}
mount() {
@@ -80,28 +82,36 @@ class PresetControls extends Component {
}
setupViewModelListeners() {
this.subscribeToEvent('streamingStarted', (data) => {
this.updateStreamingState(true, data.preset);
// Listen for node selection
this.subscribeToEvent('selectNode', (data) => {
this.selectedNode = data.nodeIp;
this.loadNodeSettings();
});
this.subscribeToEvent('streamingStopped', () => {
this.updateStreamingState(false);
this.subscribeToEvent('streamingStarted', (data) => {
this.updateStreamingState(true, data.preset, data.nodeIp);
});
this.subscribeToEvent('streamingStopped', (data) => {
this.updateStreamingState(false, null, data.nodeIp);
});
this.subscribeToEvent('presetParameterUpdated', (data) => {
// Update control display without triggering another update
const control = this.presetControls.get(data.parameter);
if (control) {
if (control.type === 'range') {
control.value = data.value;
const valueDisplay = control.parentElement.querySelector('.preset-value');
if (valueDisplay) {
valueDisplay.textContent = parseFloat(data.value).toFixed(2);
// Only update if this is for the currently selected node
if (data.nodeIp === this.selectedNode) {
const control = this.presetControls.get(data.parameter);
if (control) {
if (control.type === 'range') {
control.value = data.value;
const valueDisplay = control.parentElement.querySelector('.preset-value');
if (valueDisplay) {
valueDisplay.textContent = parseFloat(data.value).toFixed(2);
}
} else if (control.type === 'color') {
control.value = this.hexToColorValue(data.value);
} else {
control.value = data.value;
}
} else if (control.type === 'color') {
control.value = this.hexToColorValue(data.value);
} else {
control.value = data.value;
}
}
});
@@ -202,9 +212,60 @@ class PresetControls extends Component {
}
this.currentPreset = this.presets[presetName];
// Store preset selection for current node
if (this.selectedNode) {
const nodeParams = this.nodeParameters.get(this.selectedNode) || {};
nodeParams.presetName = presetName;
this.nodeParameters.set(this.selectedNode, nodeParams);
}
this.createPresetControls();
}
loadNodeSettings() {
if (!this.selectedNode) return;
// Get stored parameters for this node
const nodeParams = this.nodeParameters.get(this.selectedNode);
if (nodeParams && nodeParams.presetName) {
// Load the preset and restore parameters
const presetSelect = this.findElement('#preset-select');
if (presetSelect && presetSelect.value !== nodeParams.presetName) {
presetSelect.value = nodeParams.presetName;
this.selectPreset(nodeParams.presetName);
}
// Restore parameter values
if (nodeParams.parameters) {
Object.entries(nodeParams.parameters).forEach(([param, value]) => {
const control = this.presetControls.get(param);
if (control) {
if (control.type === 'range') {
control.value = value;
const valueDisplay = control.parentElement.querySelector('.preset-value');
if (valueDisplay) {
valueDisplay.textContent = parseFloat(value).toFixed(2);
}
} else if (control.type === 'color') {
control.value = this.hexToColorValue(value);
} else {
control.value = value;
}
}
});
}
} else {
// Reset to default
const presetSelect = this.findElement('#preset-select');
if (presetSelect) {
presetSelect.value = '';
this.clearPresetControls();
}
}
}
createPresetControls() {
const controlsContainer = this.findElement('#preset-controls');
if (!controlsContainer) return;
@@ -313,13 +374,24 @@ class PresetControls extends Component {
}
updatePresetParameter(parameter, value) {
// Store parameter for current node
if (this.selectedNode) {
const nodeParams = this.nodeParameters.get(this.selectedNode) || {};
if (!nodeParams.parameters) {
nodeParams.parameters = {};
}
nodeParams.parameters[parameter] = value;
this.nodeParameters.set(this.selectedNode, nodeParams);
}
// Send parameter update to server immediately (real-time)
this.viewModel.publish('updatePresetParameter', {
parameter,
value
value,
nodeIp: this.selectedNode
});
console.log(`Parameter updated: ${parameter} = ${value}`);
console.log(`Parameter updated for ${this.selectedNode}: ${parameter} = ${value}`);
}
clearPresetControls() {
@@ -355,21 +427,39 @@ class PresetControls extends Component {
return;
}
if (!this.selectedNode) {
alert('Please select a node first');
return;
}
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
// Get current parameters for this node
const nodeParams = this.nodeParameters.get(this.selectedNode);
const parameters = nodeParams?.parameters || {};
this.viewModel.publish('startPreset', {
presetName: presetSelect.value,
width,
height
height,
nodeIp: this.selectedNode,
parameters
});
}
stopStreaming() {
this.viewModel.publish('stopStreaming', {});
this.viewModel.publish('stopStreaming', {
nodeIp: this.selectedNode
});
}
sendTestFrame() {
if (!this.selectedNode) {
alert('Please select a node first');
return;
}
// Create a test frame with a simple pattern in serpentine order
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
@@ -390,12 +480,18 @@ class PresetControls extends Component {
}
}
this.viewModel.publish('broadcastToAll', {
this.viewModel.publish('sendToNode', {
nodeIp: this.selectedNode,
message: frameData
});
}
clearMatrix() {
if (!this.selectedNode) {
alert('Please select a node first');
return;
}
// Send a frame with all black pixels in serpentine order
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
@@ -411,7 +507,8 @@ class PresetControls extends Component {
}
}
this.viewModel.publish('broadcastToAll', {
this.viewModel.publish('sendToNode', {
nodeIp: this.selectedNode,
message: frameData
});
}
@@ -425,7 +522,12 @@ class PresetControls extends Component {
}
}
updateStreamingState(isStreaming, preset) {
updateStreamingState(isStreaming, preset, nodeIp) {
// Only update UI if this is for the currently selected node
if (nodeIp !== this.selectedNode && nodeIp !== null) {
return;
}
const toggleBtn = this.findElement('#toggle-stream-btn');
const btnIcon = toggleBtn?.querySelector('.btn-icon');
const btnText = toggleBtn?.querySelector('.btn-text');

View File

@@ -146,10 +146,10 @@ body {
}
.nav-tab.active {
background: var(--bg-tertiary);
border: 1px solid var(--accent-primary);
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
box-shadow: 0 4px 20px rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.nav-right {
@@ -207,12 +207,152 @@ body {
.ledlab-main {
display: flex;
flex: 1;
gap: 1.5rem;
position: relative;
overflow: hidden;
min-height: 0;
}
/* Matrix display section */
/* Matrix Grid Section - Full Width */
.matrix-grid-section {
flex: 1;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-primary);
padding: 1.5rem;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow: var(--shadow-primary);
backdrop-filter: var(--backdrop-blur);
}
.matrix-grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.matrix-grid-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Node Canvas Grid */
.node-canvas-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-auto-rows: minmax(min(200px, calc((100vh - 10rem) / 3)), max(200px, calc((100vh - 10rem) / 2)));
gap: 0.75rem;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem;
align-items: stretch;
}
.node-canvas-item {
background: var(--matrix-bg);
border-radius: 12px;
border: 2px solid var(--border-secondary);
padding: 0.75rem;
display: flex;
flex-direction: column;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 100%;
position: relative;
overflow: hidden;
}
.node-canvas-item:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
.node-canvas-item.selected {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.2), 0 8px 24px rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
}
.node-canvas-item.streaming {
border-color: var(--accent-primary);
}
.node-canvas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
}
.node-canvas-title {
display: flex;
align-items: center;
gap: 0.375rem;
}
.node-canvas-ip {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.node-canvas-status {
font-size: 0.6rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.node-canvas-status.connected {
background: rgba(34, 197, 94, 0.15);
color: var(--node-connected);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.node-canvas-status.streaming {
background: rgba(74, 222, 128, 0.15);
color: var(--accent-primary);
border: 1px solid rgba(74, 222, 128, 0.3);
animation: pulse-glow 2s ease-in-out infinite;
}
.node-canvas-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--matrix-bg);
border-radius: 8px;
position: relative;
overflow: hidden;
min-height: 0;
width: 100%;
height: 100%;
}
.node-canvas {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Old Matrix display section - kept for backwards compatibility */
.matrix-section {
flex: 1;
background: var(--bg-secondary);
@@ -310,7 +450,92 @@ body {
object-fit: contain;
}
/* Control panel section */
/* Floating Control Panel */
.floating-controls {
position: absolute;
top: 1rem;
right: 1rem;
width: 380px;
max-width: calc(100vw - 4rem);
max-height: calc(100vh - 12rem);
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-primary);
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
backdrop-filter: var(--backdrop-blur);
z-index: 100;
animation: slideInFromRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.floating-controls-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-tertiary);
}
.control-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
}
.control-title .node-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--node-connected);
display: inline-block;
}
.btn-close {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
}
.btn-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.floating-controls-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Old Control panel section - kept for backwards compatibility */
.control-section {
width: 380px;
min-width: 320px;
@@ -358,6 +583,24 @@ body {
border-radius: 2px;
}
.control-group-subtitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group-subtitle::before {
content: '';
width: 3px;
height: 14px;
background: var(--accent-primary);
border-radius: 2px;
}
/* Node list */
.node-controls {
margin-bottom: 1rem;
@@ -819,34 +1062,121 @@ body {
}
/* Scrollbar styling */
.control-section::-webkit-scrollbar {
.control-section::-webkit-scrollbar,
.floating-controls-content::-webkit-scrollbar,
.node-canvas-grid::-webkit-scrollbar {
width: 6px;
}
.control-section::-webkit-scrollbar-track {
.control-section::-webkit-scrollbar-track,
.floating-controls-content::-webkit-scrollbar-track,
.node-canvas-grid::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.control-section::-webkit-scrollbar-thumb {
.control-section::-webkit-scrollbar-thumb,
.floating-controls-content::-webkit-scrollbar-thumb,
.node-canvas-grid::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 3px;
}
.control-section::-webkit-scrollbar-thumb:hover {
.control-section::-webkit-scrollbar-thumb:hover,
.floating-controls-content::-webkit-scrollbar-thumb:hover,
.node-canvas-grid::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Responsive adjustments */
@media (min-width: 1600px) {
.node-canvas-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-auto-rows: minmax(220px, 1fr);
}
}
@media (max-width: 1400px) {
.node-canvas-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-auto-rows: minmax(180px, 1fr);
}
}
@media (max-width: 1200px) {
.node-canvas-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
grid-auto-rows: minmax(min(160px, calc((100vh - 8rem) / 3)), max(160px, calc((100vh - 8rem) / 2)));
gap: 0.5rem;
}
.node-canvas-item {
padding: 0.625rem;
}
.node-canvas-ip {
font-size: 0.75rem;
}
}
@media (max-width: 768px) {
.container {
padding: 0 1rem;
gap: 1rem;
padding: 0 0.5rem;
gap: 0.5rem;
}
.ledlab-main {
flex-direction: column;
gap: 1rem;
gap: 0.5rem;
}
.node-canvas-grid {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
grid-auto-rows: minmax(min(140px, calc((100vh - 7rem) / 3)), max(140px, calc((100vh - 7rem) / 2)));
gap: 0.5rem;
padding: 0.25rem;
}
.node-canvas-item {
padding: 0.5rem;
}
.node-canvas-header {
margin-bottom: 0.375rem;
padding-bottom: 0.25rem;
}
.node-canvas-ip {
font-size: 0.7rem;
}
.node-canvas-status {
font-size: 0.55rem;
padding: 0.15rem 0.35rem;
}
.floating-controls {
position: fixed;
top: auto;
bottom: 0;
right: 0;
left: 0;
width: 100%;
max-width: 100%;
max-height: 70vh;
border-radius: 16px 16px 0 0;
animation: slideInFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideInFromBottom {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.control-section {