feat: ledlab

This commit is contained in:
2025-10-11 17:46:32 +02:00
commit 30814807aa
30 changed files with 5690 additions and 0 deletions

100
public/index.html Normal file
View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE LEDLab</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<div class="container">
<header class="ledlab-header">
<h1 class="ledlab-title">SPORE LEDLab</h1>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
</header>
<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>
</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="loading">Discovering nodes...</div>
</div>
</div>
<!-- Preset Selection -->
<div class="control-group">
<h3 class="control-group-title">Animation Presets</h3>
<select class="preset-select" id="preset-select">
<option value="">Select a preset...</option>
</select>
<div class="preset-controls" id="preset-controls">
<!-- Dynamic controls will be inserted here -->
</div>
<div class="btn-container">
<button class="btn" id="start-btn">Start Streaming</button>
<button class="btn btn-secondary" id="stop-btn" disabled>Stop Streaming</button>
</div>
</div>
<!-- Matrix Configuration -->
<div class="control-group">
<h3 class="control-group-title">Matrix Configuration</h3>
<div class="matrix-config">
<div class="matrix-input">
<label for="matrix-width">Width</label>
<input type="number" id="matrix-width" min="1" max="32" value="16">
</div>
<div class="matrix-input">
<label for="matrix-height">Height</label>
<input type="number" id="matrix-height" min="1" max="32" value="16">
</div>
<button class="btn btn-small" id="apply-matrix-btn">Apply</button>
</div>
</div>
<!-- Manual Control -->
<div class="control-group">
<h3 class="control-group-title">Manual Control</h3>
<div class="btn-container">
<button class="btn btn-secondary" id="send-test-btn">Send Test Frame</button>
<button class="btn btn-secondary" id="clear-matrix-btn">Clear Matrix</button>
</div>
</div>
</section>
</main>
</div>
<!-- Load JavaScript files -->
<script src="scripts/constants.js"></script>
<script src="scripts/theme-manager.js"></script>
<script src="scripts/framework.js"></script>
<script src="scripts/matrix-display.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

@@ -0,0 +1,51 @@
(function(){
const TIMING = {
NAV_COOLDOWN_MS: 300,
VIEW_FADE_OUT_MS: 150,
VIEW_FADE_IN_MS: 200,
VIEW_FADE_DELAY_MS: 50,
AUTO_REFRESH_MS: 30000,
PRIMARY_NODE_REFRESH_MS: 10000,
LOAD_GUARD_MS: 10000,
UDP_DISCOVERY_INTERVAL_MS: 5000,
WEBSOCKET_PING_INTERVAL_MS: 30000,
FRAME_UPDATE_INTERVAL_MS: 50
};
const SELECTORS = {
NAV_TAB: '.nav-tab',
VIEW_CONTENT: '.view-content',
MATRIX_CANVAS: '.matrix-canvas',
PRESET_SELECT: '.preset-select',
NODE_LIST: '.node-list',
CONTROL_PANEL: '.control-panel'
};
const CLASSES = {
NODE_CONNECTED: 'node-connected',
NODE_DISCONNECTED: 'node-disconnected',
NODE_SELECTED: 'node-selected',
PRESET_ACTIVE: 'preset-active',
STREAMING_ACTIVE: 'streaming-active'
};
const DEFAULTS = {
MATRIX_WIDTH: 16,
MATRIX_HEIGHT: 16,
UDP_PORT: 4210,
WEBSOCKET_PORT: 8080,
BROADCAST_ADDRESS: '255.255.255.255'
};
const MESSAGES = {
NODE_DISCOVERED: 'node:discovered',
NODE_LOST: 'node:lost',
STREAM_START: 'stream:start',
STREAM_STOP: 'stream:stop',
STREAM_UPDATE: 'stream:update',
PRESET_CHANGED: 'preset:changed',
MATRIX_UPDATED: 'matrix:updated'
};
window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES, DEFAULTS, MESSAGES };
})();

759
public/scripts/framework.js Normal file
View File

@@ -0,0 +1,759 @@
// SPORE LEDLab Framework - Component-based architecture with pub/sub system
// Lightweight logger with level gating
const logger = {
debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } },
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
error: (...args) => console.error(...args),
};
if (typeof window !== 'undefined') {
window.logger = window.logger || logger;
}
// Event Bus for pub/sub communication
class EventBus {
constructor() {
this.events = new Map();
}
// Subscribe to an event
subscribe(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
// Return unsubscribe function
return () => {
const callbacks = this.events.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
// Publish an event
publish(event, data) {
if (this.events.has(event)) {
this.events.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event callback for ${event}:`, error);
}
});
}
}
// Unsubscribe from an event
unsubscribe(event, callback) {
if (this.events.has(event)) {
const callbacks = this.events.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
// Clear all events
clear() {
this.events.clear();
}
}
// Base ViewModel class with enhanced state management
class ViewModel {
constructor() {
this._data = {};
this._listeners = new Map();
this._eventBus = null;
this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc.
this._previousData = {}; // Store previous data for change detection
}
// Set the event bus for this view model
setEventBus(eventBus) {
this._eventBus = eventBus;
}
// Get data property
get(property) {
return this._data[property];
}
// Set data property and notify listeners
set(property, value) {
logger.debug(`ViewModel: Setting property '${property}' to:`, value);
// Check if the value has actually changed
const hasChanged = this._data[property] !== value;
if (hasChanged) {
// Store previous value for change detection
this._previousData[property] = this._data[property];
// Update the data
this._data[property] = value;
logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
this._notifyListeners(property, value, this._previousData[property]);
} else {
logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
}
}
// Set multiple properties at once with change detection
setMultiple(properties) {
const changedProperties = {};
// Determine changes and update previousData snapshot per key
Object.keys(properties).forEach(key => {
const newValue = properties[key];
const oldValue = this._data[key];
if (oldValue !== newValue) {
this._previousData[key] = oldValue;
changedProperties[key] = newValue;
}
});
// Apply all properties
Object.keys(properties).forEach(key => {
this._data[key] = properties[key];
});
// Notify listeners only for changed properties with accurate previous values
Object.keys(changedProperties).forEach(key => {
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
if (Object.keys(changedProperties).length > 0) {
logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
}
}
// Subscribe to property changes
subscribe(property, callback) {
if (!this._listeners.has(property)) {
this._listeners.set(property, []);
}
this._listeners.get(property).push(callback);
// Return unsubscribe function
return () => {
const callbacks = this._listeners.get(property);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
// Notify listeners of property changes
_notifyListeners(property, value, previousValue) {
logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
if (this._listeners.has(property)) {
const callbacks = this._listeners.get(property);
logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
callbacks.forEach((callback, index) => {
try {
logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
callback(value, previousValue);
} catch (error) {
console.error(`Error in property listener for ${property}:`, error);
}
});
} else {
logger.debug(`ViewModel: No listeners found for property '${property}'`);
}
}
// Publish event to event bus
publish(event, data) {
if (this._eventBus) {
this._eventBus.publish(event, data);
}
}
// Get all data
getAll() {
return { ...this._data };
}
// Clear all data
clear() {
this._data = {};
this._listeners.clear();
}
// UI State Management Methods
setUIState(key, value) {
this._uiState.set(key, value);
}
getUIState(key) {
return this._uiState.get(key);
}
getAllUIState() {
return new Map(this._uiState);
}
clearUIState(key) {
if (key) {
this._uiState.delete(key);
} else {
this._uiState.clear();
}
}
// Check if a property has changed
hasChanged(property) {
return this._data[property] !== this._previousData[property];
}
// Get previous value of a property
getPrevious(property) {
return this._previousData[property];
}
// Batch update with change detection
batchUpdate(updates, options = {}) {
const { notifyChanges = true } = options;
// Track which keys actually change and what the previous values were
const changedKeys = [];
Object.keys(updates).forEach(key => {
const newValue = updates[key];
const oldValue = this._data[key];
if (oldValue !== newValue) {
this._previousData[key] = oldValue;
this._data[key] = newValue;
changedKeys.push(key);
} else {
// Still apply to ensure consistency if needed
this._data[key] = newValue;
}
});
// Notify listeners for changed keys
if (notifyChanges) {
changedKeys.forEach(key => {
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
}
}
}
// Base Component class
class Component {
constructor(container, viewModel, eventBus) {
this.container = container;
this.viewModel = viewModel;
this.eventBus = eventBus;
this.isMounted = false;
this.unsubscribers = [];
this.uiState = new Map(); // Local UI state for this component
// Set event bus on view model
if (this.viewModel) {
this.viewModel.setEventBus(eventBus);
}
// Bind methods
this.render = this.render.bind(this);
this.mount = this.mount.bind(this);
this.unmount = this.unmount.bind(this);
this.updatePartial = this.updatePartial.bind(this);
}
// Mount the component
mount() {
if (this.isMounted) return;
logger.debug(`${this.constructor.name}: Starting mount...`);
this.isMounted = true;
this.setupEventListeners();
this.setupViewModelListeners();
this.render();
logger.debug(`${this.constructor.name}: Mounted successfully`);
}
// Unmount the component
unmount() {
if (!this.isMounted) return;
this.isMounted = false;
this.cleanupEventListeners();
this.cleanupViewModelListeners();
logger.debug(`${this.constructor.name} unmounted`);
}
// Setup event listeners (override in subclasses)
setupEventListeners() {
// Override in subclasses
}
// Setup view model listeners (override in subclasses)
setupViewModelListeners() {
// Override in subclasses
}
// Cleanup event listeners (override in subclasses)
cleanupEventListeners() {
// Override in subclasses
}
// Cleanup view model listeners (override in subclasses)
cleanupViewModelListeners() {
// Override in subclasses
}
// Render the component (override in subclasses)
render() {
// Override in subclasses
}
// Partial update method for efficient data updates
updatePartial(property, newValue, previousValue) {
// Override in subclasses to implement partial updates
logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
}
// UI State Management Methods
setUIState(key, value) {
this.uiState.set(key, value);
// Also store in view model for persistence across refreshes
if (this.viewModel) {
this.viewModel.setUIState(key, value);
}
}
getUIState(key) {
// First try local state, then view model state
return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null);
}
getAllUIState() {
const localState = new Map(this.uiState);
const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map();
// Merge states, with local state taking precedence
const mergedState = new Map(viewModelState);
localState.forEach((value, key) => mergedState.set(key, value));
return mergedState;
}
clearUIState(key) {
if (key) {
this.uiState.delete(key);
if (this.viewModel) {
this.viewModel.clearUIState(key);
}
} else {
this.uiState.clear();
if (this.viewModel) {
this.viewModel.clearUIState();
}
}
}
// Helper method to add event listener and track for cleanup
addEventListener(element, event, handler) {
element.addEventListener(event, handler);
this.unsubscribers.push(() => {
element.removeEventListener(event, handler);
});
}
// Helper method to subscribe to event bus and track for cleanup
subscribeToEvent(event, handler) {
const unsubscribe = this.eventBus.subscribe(event, handler);
this.unsubscribers.push(unsubscribe);
}
// Helper method to subscribe to view model property and track for cleanup
subscribeToProperty(property, handler) {
if (this.viewModel) {
const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => {
// Call handler with both new and previous values for change detection
handler(newValue, previousValue);
});
this.unsubscribers.push(unsubscribe);
}
}
// Helper method to find element within component container
findElement(selector) {
return this.container.querySelector(selector);
}
// Helper method to find all elements within component container
findAllElements(selector) {
return this.container.querySelectorAll(selector);
}
// Helper method to set innerHTML safely
setHTML(selector, html) {
logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
let element;
if (selector === '') {
// Empty selector means set HTML on the component's container itself
element = this.container;
logger.debug(`${this.constructor.name}: Using component container for empty selector`);
} else {
// Find element within the component's container
element = this.findElement(selector);
}
if (element) {
logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
element.innerHTML = html;
logger.debug(`${this.constructor.name}: innerHTML set successfully`);
} else {
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
}
}
// Helper method to set text content safely
setText(selector, text) {
const element = this.findElement(selector);
if (element) {
element.textContent = text;
}
}
// Helper method to add/remove CSS classes
setClass(selector, className, add = true) {
const element = this.findElement(selector);
if (element) {
if (add) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
}
}
// Helper method to set CSS styles
setStyle(selector, property, value) {
const element = this.findElement(selector);
if (element) {
element.style[property] = value;
}
}
// Helper method to show/hide elements
setVisible(selector, visible) {
const element = this.findElement(selector);
if (element) {
element.style.display = visible ? '' : 'none';
}
}
// Helper method to enable/disable elements
setEnabled(selector, enabled) {
const element = this.findElement(selector);
if (element) {
element.disabled = !enabled;
}
}
// Reusable render helpers
renderLoading(customHtml) {
const html = customHtml || `
<div class="loading">
<div>Loading...</div>
</div>
`;
this.setHTML('', html);
}
renderError(message) {
const safe = this.escapeHtml(String(message || 'An error occurred'));
const html = `
<div class="error">
<strong>Error:</strong><br>
${safe}
</div>
`;
this.setHTML('', html);
}
renderEmpty(customHtml) {
const html = customHtml || `
<div class="empty-state">
<div>No data</div>
</div>
`;
this.setHTML('', html);
}
// Basic HTML escaping for dynamic values
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}
// Application class to manage components and routing
class App {
constructor() {
this.eventBus = new EventBus();
this.components = new Map();
this.currentView = null;
this.routes = new Map();
this.navigationInProgress = false;
this.navigationQueue = [];
this.lastNavigationTime = 0;
this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300;
// Component cache to keep components alive
this.componentCache = new Map();
this.cachedViews = new Set();
}
// Register a route
registerRoute(name, componentClass, containerId, viewModel = null) {
this.routes.set(name, { componentClass, containerId, viewModel });
}
// Navigate to a route
navigateTo(routeName) {
// Check cooldown period
const now = Date.now();
if (now - this.lastNavigationTime < this.navigationCooldown) {
logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
return;
}
// If navigation is already in progress, queue this request
if (this.navigationInProgress) {
logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
if (!this.navigationQueue.includes(routeName)) {
this.navigationQueue.push(routeName);
}
return;
}
// If trying to navigate to the same route, do nothing
if (this.currentView && this.currentView.routeName === routeName) {
logger.debug(`App: Already on route '${routeName}', skipping navigation`);
return;
}
this.lastNavigationTime = now;
this.performNavigation(routeName);
}
// Perform the actual navigation
async performNavigation(routeName) {
this.navigationInProgress = true;
try {
logger.debug(`App: Navigating to route '${routeName}'`);
const route = this.routes.get(routeName);
if (!route) {
console.error(`Route '${routeName}' not found`);
return;
}
logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
// Get or create component from cache
let component = this.componentCache.get(routeName);
if (!component) {
logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`);
const container = document.getElementById(route.containerId);
if (!container) {
console.error(`Container '${route.containerId}' not found`);
return;
}
component = new route.componentClass(container, route.viewModel, this.eventBus);
component.routeName = routeName;
component.isCached = true;
this.componentCache.set(routeName, component);
}
// Hide current view smoothly
if (this.currentView) {
logger.debug('App: Hiding current view');
await this.hideCurrentView();
}
// Show new view
logger.debug(`App: Showing new view '${routeName}'`);
await this.showView(routeName, component);
// Update navigation state
this.updateNavigation(routeName);
// Set as current view
this.currentView = component;
// Mark view as cached for future use
this.cachedViews.add(routeName);
logger.debug(`App: Navigation to '${routeName}' completed`);
} catch (error) {
console.error('App: Navigation failed:', error);
} finally {
this.navigationInProgress = false;
// Process any queued navigation requests
if (this.navigationQueue.length > 0) {
const nextRoute = this.navigationQueue.shift();
logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
setTimeout(() => this.navigateTo(nextRoute), 100);
}
}
}
// Hide current view smoothly
async hideCurrentView() {
if (!this.currentView) return;
// If component is mounted, pause it instead of unmounting
if (this.currentView.isMounted) {
logger.debug('App: Pausing current view instead of unmounting');
this.currentView.pause();
}
// Fade out the container
if (this.currentView.container) {
this.currentView.container.style.opacity = '0';
this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`;
}
// Wait for fade out to complete
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150));
}
// Show view smoothly
async showView(routeName, component) {
const container = component.container;
// Ensure component is mounted (but not necessarily active); lazy-create now if needed
if (!component) {
const route = this.routes.get(routeName);
const container = document.getElementById(route.containerId);
component = new route.componentClass(container, route.viewModel, this.eventBus);
component.routeName = routeName;
component.isCached = true;
this.componentCache.set(routeName, component);
}
if (!component.isMounted) {
logger.debug(`App: Mounting component for '${routeName}'`);
component.mount();
} else {
logger.debug(`App: Resuming component for '${routeName}'`);
component.resume();
}
// Fade in the container
container.style.opacity = '0';
container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`;
// Small delay to ensure smooth transition
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
// Fade in
container.style.opacity = '1';
}
// Update navigation state
updateNavigation(activeRoute) {
// Remove active class from all nav tabs
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
// Add active class to current route tab
const activeTab = document.querySelector(`[data-view="${activeRoute}"]`);
if (activeTab) {
activeTab.classList.add('active');
}
// Hide all view contents with smooth transition
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => {
view.classList.remove('active');
view.style.opacity = '0';
view.style.transition = 'opacity 0.15s ease-out';
});
// Show current view content with smooth transition
const activeView = document.getElementById(`${activeRoute}-view`);
if (activeView) {
activeView.classList.add('active');
// Small delay to ensure smooth transition
setTimeout(() => {
activeView.style.opacity = '1';
activeView.style.transition = 'opacity 0.2s ease-in';
}, 50);
}
}
// Register a component
registerComponent(name, component) {
this.components.set(name, component);
}
// Get a component by name
getComponent(name) {
return this.components.get(name);
}
// Get the event bus
getEventBus() {
return this.eventBus;
}
// Initialize the application
init() {
logger.debug('SPORE LEDLab Framework initialized');
}
// Setup navigation
setupNavigation() {
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
const routeName = tab.dataset.view;
this.navigateTo(routeName);
});
});
}
// Clean up cached components (call when app is shutting down)
cleanup() {
logger.debug('App: Cleaning up cached components...');
this.componentCache.forEach((component, routeName) => {
if (component.isMounted) {
logger.debug(`App: Unmounting cached component '${routeName}'`);
component.unmount();
}
});
this.componentCache.clear();
this.cachedViews.clear();
}
}
// Global app instance (disabled for LEDLab single-page app)
// window.app = new App();

View File

@@ -0,0 +1,245 @@
// LEDLab Main Application
class LEDLabApp {
constructor() {
this.viewModel = new ViewModel();
this.eventBus = new EventBus();
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.matrixDisplay = null;
this.presetControls = null;
this.nodeDiscovery = null;
this.init();
}
init() {
// Set up event bus on view model
this.viewModel.setEventBus(this.eventBus);
// Initialize components
this.initComponents();
// Set up WebSocket connection
this.connectWebSocket();
// Set up global event listeners
this.setupGlobalEventListeners();
console.log('LEDLab app initialized');
}
initComponents() {
// Initialize Matrix Display component
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() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
try {
this.ws = new WebSocket(wsUrl);
this.setupWebSocketEventHandlers();
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
setupWebSocketEventHandlers() {
if (!this.ws) return;
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
// Send any queued messages
this.flushMessageQueue();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.ws.onclose = (event) => {
console.log('WebSocket disconnected:', event.code, event.reason);
if (event.code !== 1000) { // Not a normal closure
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
handleWebSocketMessage(data) {
// Publish event to the event bus for components to handle
this.eventBus.publish(data.type, data);
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.connectWebSocket();
}, delay);
}
sendWebSocketMessage(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('WebSocket not connected, queuing message');
// Queue message for when connection is restored
if (!this.messageQueue) {
this.messageQueue = [];
}
this.messageQueue.push(data);
}
}
flushMessageQueue() {
if (this.messageQueue && this.messageQueue.length > 0) {
console.log(`Flushing ${this.messageQueue.length} queued messages`);
this.messageQueue.forEach(data => this.sendWebSocketMessage(data));
this.messageQueue = [];
}
}
setupGlobalEventListeners() {
// Listen for messages from components that need to be sent to server
this.eventBus.subscribe('startPreset', (data) => {
this.sendWebSocketMessage({
type: 'startPreset',
...data
});
});
this.eventBus.subscribe('stopStreaming', (data) => {
this.sendWebSocketMessage({
type: 'stopStreaming',
...data
});
});
this.eventBus.subscribe('updatePresetParameter', (data) => {
this.sendWebSocketMessage({
type: 'updatePresetParameter',
...data
});
});
this.eventBus.subscribe('setMatrixSize', (data) => {
this.sendWebSocketMessage({
type: 'setMatrixSize',
...data
});
});
this.eventBus.subscribe('sendToNode', (data) => {
this.sendWebSocketMessage({
type: 'sendToNode',
...data
});
});
this.eventBus.subscribe('broadcastToAll', (data) => {
this.sendWebSocketMessage({
type: 'broadcastToAll',
...data
});
});
this.eventBus.subscribe('selectNode', (data) => {
this.sendWebSocketMessage({
type: 'selectNode',
...data
});
});
this.eventBus.subscribe('selectBroadcast', (data) => {
this.sendWebSocketMessage({
type: 'selectBroadcast',
...data
});
});
// Handle theme changes
window.addEventListener('themeChanged', (event) => {
console.log('Theme changed to:', event.detail.theme);
// Update any theme-specific UI elements if needed
});
}
// Public API methods for external use
startPreset(presetName, width, height) {
this.viewModel.publish('startPreset', { presetName, width, height });
}
stopStreaming() {
this.viewModel.publish('stopStreaming', {});
}
updatePresetParameter(parameter, value) {
this.viewModel.publish('updatePresetParameter', { parameter, value });
}
setMatrixSize(width, height) {
this.viewModel.publish('setMatrixSize', { width, height });
}
sendToNode(nodeIp, message) {
this.viewModel.publish('sendToNode', { nodeIp, message });
}
broadcastToAll(message) {
this.viewModel.publish('broadcastToAll', { message });
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
window.ledlabApp = new LEDLabApp();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = LEDLabApp;
}

View File

@@ -0,0 +1,203 @@
// Matrix Display Component
class MatrixDisplay extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.canvas = null;
this.ctx = null;
this.pixelSize = 20;
this.matrixWidth = 16;
this.matrixHeight = 16;
this.frameData = null;
this.animationId = null;
}
mount() {
super.mount();
this.setupCanvas();
this.setupEventListeners();
this.setupViewModelListeners();
}
setupCanvas() {
this.canvas = this.findElement('#matrix-canvas');
if (!this.canvas) {
console.error('Matrix canvas element not found');
return;
}
this.ctx = this.canvas.getContext('2d');
this.updateCanvasSize();
}
updateCanvasSize() {
if (!this.canvas || !this.ctx) return;
const container = this.canvas.parentElement;
const containerWidth = container.clientWidth - 32; // Account for padding
const containerHeight = container.clientHeight - 32;
// Calculate pixel size to fit the matrix in the container
const maxPixelWidth = Math.floor(containerWidth / this.matrixWidth);
const maxPixelHeight = Math.floor(containerHeight / this.matrixHeight);
this.pixelSize = Math.min(maxPixelWidth, maxPixelHeight, 40); // Cap at 40px
this.canvas.width = this.matrixWidth * this.pixelSize;
this.canvas.height = this.matrixHeight * this.pixelSize;
// Center the canvas
const canvasContainer = this.canvas.parentElement;
canvasContainer.style.display = 'flex';
canvasContainer.style.alignItems = 'center';
canvasContainer.style.justifyContent = 'center';
}
setupEventListeners() {
// Handle window resize
this.addEventListener(window, 'resize', () => {
this.updateCanvasSize();
this.renderFrame();
});
}
setupViewModelListeners() {
this.subscribeToEvent('frame', (data) => {
this.frameData = data.data;
this.renderFrame();
});
this.subscribeToEvent('matrixSizeChanged', (data) => {
this.matrixWidth = data.size.width;
this.matrixHeight = data.size.height;
this.updateCanvasSize();
this.updateMatrixSize(data.size.width, data.size.height);
this.renderFrame();
});
this.subscribeToEvent('streamingStarted', (data) => {
this.updateStreamingStatus('streaming');
});
this.subscribeToEvent('streamingStopped', () => {
this.updateStreamingStatus('stopped');
});
this.subscribeToEvent('status', (data) => {
// Update UI to reflect current server state
this.updateStreamingStatus(data.data.streaming ? 'streaming' : 'stopped');
if (data.data.matrixSize) {
this.matrixWidth = data.data.matrixSize.width;
this.matrixHeight = data.data.matrixSize.height;
this.updateCanvasSize();
this.updateMatrixSize(data.data.matrixSize.width, data.data.matrixSize.height);
}
});
}
updateStreamingStatus(status) {
const statusElement = this.findElement('#streaming-status');
if (statusElement) {
statusElement.className = `status-indicator status-${status}`;
statusElement.textContent = status === 'streaming' ? 'Streaming' : 'Stopped';
}
}
renderFrame() {
if (!this.ctx || !this.canvas) return;
// Clear canvas
this.ctx.fillStyle = 'var(--matrix-bg)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Render pixels if we have frame data
if (this.frameData && this.frameData.startsWith('RAW:')) {
const pixelData = this.frameData.substring(4); // Remove 'RAW:' prefix
// Render pixels in serpentine order to match hardware layout
for (let row = 0; row < this.matrixHeight; row++) {
for (let col = 0; col < this.matrixWidth; col++) {
// Calculate serpentine index manually
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);
this.renderPixel(col, row, hexColor);
}
}
}
}
}
renderPixel(col, row, hexColor) {
const x = col * this.pixelSize;
const y = row * this.pixelSize;
// Convert hex to 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 rendering if pixel is black (optimization)
if (r === 0 && g === 0 && b === 0) {
return;
}
// Draw pixel with a subtle border for better visibility
this.ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2);
// Add a subtle glow effect for brighter pixels
if (r > 128 || g > 128 || b > 128) {
this.ctx.shadowColor = `rgb(${r}, ${g}, ${b})`;
this.ctx.shadowBlur = 2;
this.ctx.fillRect(x + 1, y + 1, this.pixelSize - 2, this.pixelSize - 2);
this.ctx.shadowBlur = 0;
}
}
// Method to render a test pattern
renderTestPattern() {
this.frameData = 'RAW:';
for (let row = 0; row < this.matrixHeight; row++) {
for (let col = 0; col < this.matrixWidth; col++) {
// Calculate serpentine index manually
const hardwareIndex = (row % 2 === 0) ?
(row * this.matrixWidth + col) :
(row * this.matrixWidth + (this.matrixWidth - 1 - col));
// Create a checkerboard pattern
if ((row + col) % 2 === 0) {
this.frameData += '00ff00'; // Green
} else {
this.frameData += '000000'; // Black
}
}
}
this.renderFrame();
}
// Method to clear the matrix
clearMatrix() {
if (this.ctx && this.canvas) {
this.ctx.fillStyle = 'var(--matrix-bg)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
this.frameData = null;
}
// Update matrix size display
updateMatrixSize(width, height) {
const sizeElement = this.findElement('#matrix-size');
if (sizeElement) {
sizeElement.textContent = `${width}x${height}`;
}
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = MatrixDisplay;
}

View File

@@ -0,0 +1,178 @@
// 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

@@ -0,0 +1,372 @@
// Preset Controls Component
class PresetControls extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.presets = {};
this.currentPreset = null;
this.presetControls = new Map();
}
mount() {
super.mount();
this.setupEventListeners();
this.setupViewModelListeners();
this.loadPresets();
}
setupEventListeners() {
// Preset selection
const presetSelect = this.findElement('#preset-select');
if (presetSelect) {
this.addEventListener(presetSelect, 'change', (e) => {
this.selectPreset(e.target.value);
});
}
// Apply matrix config button
const applyMatrixBtn = this.findElement('#apply-matrix-btn');
if (applyMatrixBtn) {
this.addEventListener(applyMatrixBtn, 'click', () => {
this.applyMatrixConfig();
});
}
// Start/Stop buttons
const startBtn = this.findElement('#start-btn');
if (startBtn) {
this.addEventListener(startBtn, 'click', () => {
this.startStreaming();
});
}
const stopBtn = this.findElement('#stop-btn');
if (stopBtn) {
this.addEventListener(stopBtn, 'click', () => {
this.stopStreaming();
});
}
// Test and clear buttons
const sendTestBtn = this.findElement('#send-test-btn');
if (sendTestBtn) {
this.addEventListener(sendTestBtn, 'click', () => {
this.sendTestFrame();
});
}
const clearMatrixBtn = this.findElement('#clear-matrix-btn');
if (clearMatrixBtn) {
this.addEventListener(clearMatrixBtn, 'click', () => {
this.clearMatrix();
});
}
}
setupViewModelListeners() {
this.subscribeToEvent('streamingStarted', (data) => {
this.updateStreamingState(true, data.preset);
});
this.subscribeToEvent('streamingStopped', () => {
this.updateStreamingState(false);
});
this.subscribeToEvent('presetParameterUpdated', (data) => {
this.updatePresetParameter(data.parameter, data.value);
});
this.subscribeToEvent('status', (data) => {
// Update UI to reflect current server state
const isStreaming = data.data.streaming;
const currentPreset = data.data.currentPreset;
const presetParameters = data.data.presetParameters;
this.updateStreamingState(isStreaming, currentPreset ? { name: currentPreset } : null);
if (currentPreset) {
this.selectPreset(currentPreset.toLowerCase().replace('-preset', ''));
}
if (presetParameters && this.currentPreset) {
// Update parameter controls with current values
Object.entries(presetParameters).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;
}
}
});
}
});
}
async loadPresets() {
try {
const response = await fetch('/api/presets');
const data = await response.json();
this.presets = data.presets;
this.populatePresetSelect();
} catch (error) {
console.error('Error loading presets:', error);
}
}
populatePresetSelect() {
const presetSelect = this.findElement('#preset-select');
if (!presetSelect) return;
// Clear existing options (except the first one)
while (presetSelect.children.length > 1) {
presetSelect.removeChild(presetSelect.lastChild);
}
// Add preset options
Object.entries(this.presets).forEach(([name, metadata]) => {
const option = document.createElement('option');
option.value = name;
option.textContent = metadata.name;
presetSelect.appendChild(option);
});
}
selectPreset(presetName) {
if (!presetName || !this.presets[presetName]) {
this.currentPreset = null;
this.clearPresetControls();
return;
}
this.currentPreset = this.presets[presetName];
this.createPresetControls();
}
createPresetControls() {
const controlsContainer = this.findElement('#preset-controls');
if (!controlsContainer) return;
// Clear existing controls
controlsContainer.innerHTML = '';
if (!this.currentPreset || !this.currentPreset.parameters) {
return;
}
// Create controls for each parameter
Object.entries(this.currentPreset.parameters).forEach(([paramName, paramConfig]) => {
const controlDiv = document.createElement('div');
controlDiv.className = 'preset-control';
const label = document.createElement('label');
label.className = 'preset-label';
label.textContent = this.formatParameterName(paramName);
controlDiv.appendChild(label);
const input = this.createParameterInput(paramName, paramConfig);
controlDiv.appendChild(input);
controlsContainer.appendChild(controlDiv);
this.presetControls.set(paramName, input);
});
}
createParameterInput(paramName, paramConfig) {
const { type, min, max, step, default: defaultValue } = paramConfig;
switch (type) {
case 'range':
const sliderInput = document.createElement('input');
sliderInput.type = 'range';
sliderInput.className = 'preset-slider';
sliderInput.min = min;
sliderInput.max = max;
sliderInput.step = step || 0.1;
sliderInput.value = defaultValue;
// Add value display
const valueDisplay = document.createElement('span');
valueDisplay.className = 'preset-value';
valueDisplay.textContent = defaultValue;
sliderInput.addEventListener('input', (e) => {
valueDisplay.textContent = parseFloat(e.target.value).toFixed(2);
this.updatePresetParameter(paramName, parseFloat(e.target.value));
});
const container = document.createElement('div');
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.gap = '0.5rem';
container.appendChild(sliderInput);
container.appendChild(valueDisplay);
return container;
case 'color':
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.className = 'preset-input';
colorInput.value = this.hexToColorValue(defaultValue);
colorInput.addEventListener('input', (e) => {
const hexValue = this.colorValueToHex(e.target.value);
this.updatePresetParameter(paramName, hexValue);
});
return colorInput;
default:
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.className = 'preset-input';
textInput.value = defaultValue;
textInput.addEventListener('input', (e) => {
this.updatePresetParameter(paramName, e.target.value);
});
return textInput;
}
}
updatePresetParameter(parameter, value) {
// Send parameter update to server
this.viewModel.publish('updatePresetParameter', {
parameter,
value
});
}
clearPresetControls() {
const controlsContainer = this.findElement('#preset-controls');
if (controlsContainer) {
controlsContainer.innerHTML = '';
}
this.presetControls.clear();
}
formatParameterName(name) {
return name
.split(/(?=[A-Z])/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
hexToColorValue(hex) {
// Convert hex (rrggbb) to color value (#rrggbb)
if (hex.startsWith('#')) return hex;
return `#${hex}`;
}
colorValueToHex(colorValue) {
// Convert color value (#rrggbb) to hex (rrggbb)
return colorValue.replace('#', '');
}
startStreaming() {
const presetSelect = this.findElement('#preset-select');
if (!presetSelect || !presetSelect.value) {
alert('Please select a preset first');
return;
}
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
this.viewModel.publish('startPreset', {
presetName: presetSelect.value,
width,
height
});
}
stopStreaming() {
this.viewModel.publish('stopStreaming', {});
}
sendTestFrame() {
// 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;
let frameData = 'RAW:';
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
// Calculate serpentine index manually
const hardwareIndex = (row % 2 === 0) ?
(row * width + col) :
(row * width + (width - 1 - col));
// Create a checkerboard pattern
if ((row + col) % 2 === 0) {
frameData += '00ff00'; // Green
} else {
frameData += '000000'; // Black
}
}
}
this.viewModel.publish('broadcastToAll', {
message: frameData
});
}
clearMatrix() {
// 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;
let frameData = 'RAW:';
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
// Calculate serpentine index manually
const hardwareIndex = (row % 2 === 0) ?
(row * width + col) :
(row * width + (width - 1 - col));
frameData += '000000';
}
}
this.viewModel.publish('broadcastToAll', {
message: frameData
});
}
applyMatrixConfig() {
const width = parseInt(this.findElement('#matrix-width')?.value);
const height = parseInt(this.findElement('#matrix-height')?.value);
if (width && height) {
this.viewModel.publish('setMatrixSize', { width, height });
}
}
updateStreamingState(isStreaming, preset) {
const startBtn = this.findElement('#start-btn');
const stopBtn = this.findElement('#stop-btn');
if (isStreaming) {
startBtn.disabled = true;
stopBtn.disabled = false;
} else {
startBtn.disabled = false;
stopBtn.disabled = true;
}
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = PresetControls;
}

View File

@@ -0,0 +1,120 @@
// Theme Manager - Handles theme switching and persistence
class ThemeManager {
constructor() {
this.currentTheme = this.getStoredTheme() || 'dark';
this.themeToggle = document.getElementById('theme-toggle');
this.init();
}
init() {
// Apply stored theme on page load
this.applyTheme(this.currentTheme);
// Set up event listener for theme toggle
if (this.themeToggle) {
this.themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Listen for system theme changes
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener((e) => {
if (this.getStoredTheme() === 'system') {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
}
getStoredTheme() {
try {
return localStorage.getItem('spore-ledlab-theme');
} catch (e) {
console.warn('Could not access localStorage for theme preference');
return 'dark';
}
}
setStoredTheme(theme) {
try {
localStorage.setItem('spore-ledlab-theme', theme);
} catch (e) {
console.warn('Could not save theme preference to localStorage');
}
}
applyTheme(theme) {
// Update data attribute on html element
document.documentElement.setAttribute('data-theme', theme);
// Update theme toggle icon
this.updateThemeIcon(theme);
// Store the theme preference
this.setStoredTheme(theme);
this.currentTheme = theme;
// Dispatch custom event for other components
window.dispatchEvent(new CustomEvent('themeChanged', {
detail: { theme: theme }
}));
}
updateThemeIcon(theme) {
if (!this.themeToggle) return;
const svg = this.themeToggle.querySelector('svg');
if (!svg) return;
// Update the SVG content based on theme
if (theme === 'light') {
// Sun icon for light theme
svg.innerHTML = `
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
`;
} else {
// Moon icon for dark theme
svg.innerHTML = `
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
`;
}
}
toggleTheme() {
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
// Add a subtle animation to the toggle button
if (this.themeToggle) {
this.themeToggle.style.transform = 'scale(0.9)';
setTimeout(() => {
this.themeToggle.style.transform = 'scale(1)';
}, 150);
}
}
// Method to get current theme (useful for other components)
getCurrentTheme() {
return this.currentTheme;
}
// Method to set theme programmatically
setTheme(theme) {
if (['dark', 'light'].includes(theme)) {
this.applyTheme(theme);
}
}
}
// Initialize theme manager when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
window.themeManager = new ThemeManager();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ThemeManager;
}

562
public/styles/main.css Normal file
View File

@@ -0,0 +1,562 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Dark theme colors */
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--text-tertiary: #888888;
--accent-primary: #4ade80;
--accent-secondary: #22d3ee;
--accent-warning: #fbbf24;
--accent-error: #f87171;
--border-primary: #333333;
--border-secondary: #444444;
--shadow-primary: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--backdrop-blur: blur(12px);
/* LEDLab specific colors */
--matrix-bg: #000000;
--pixel-on: #4ade80;
--pixel-off: #1a1a1a;
--pixel-dim: #22c55e;
--control-bg: #1e1e1e;
--preset-active: #4ade80;
--node-connected: #22c55e;
--node-disconnected: #ef4444;
}
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #e2e8f0;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--accent-primary: #16a34a;
--accent-secondary: #0891b2;
--accent-warning: #d97706;
--accent-error: #dc2626;
--border-primary: #e2e8f0;
--border-secondary: #cbd5e1;
--shadow-primary: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--backdrop-blur: blur(12px);
/* Light theme LEDLab colors */
--matrix-bg: #f8fafc;
--pixel-on: #16a34a;
--pixel-off: #f1f5f9;
--pixel-dim: #22c55e;
--control-bg: #f8fafc;
--preset-active: #16a34a;
--node-connected: #16a34a;
--node-disconnected: #dc2626;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg-primary);
height: 100vh;
padding: 1rem;
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.container {
max-width: none;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
flex: 1;
padding: 0 2rem;
max-height: calc(100vh - 2rem);
overflow: hidden;
}
/* LEDLab specific styles */
.ledlab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.ledlab-title {
font-size: 2rem;
font-weight: 700;
color: var(--accent-primary);
}
.theme-toggle {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background: var(--bg-tertiary);
transform: scale(1.05);
}
.theme-toggle svg {
width: 20px;
height: 20px;
fill: currentColor;
}
/* Main content layout */
.ledlab-main {
display: flex;
flex: 1;
gap: 2rem;
overflow: hidden;
}
/* Matrix display section */
.matrix-section {
flex: 1;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-primary);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.matrix-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.matrix-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.matrix-info {
font-size: 0.875rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.matrix-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--matrix-bg);
border-radius: 12px;
border: 1px solid var(--border-secondary);
position: relative;
overflow: hidden;
}
.matrix-canvas {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Control panel section */
.control-section {
width: 320px;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-primary);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
overflow-y: auto;
}
.control-group {
background: var(--control-bg);
border-radius: 12px;
padding: 1rem;
border: 1px solid var(--border-secondary);
}
.control-group-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group-title::before {
content: '';
width: 4px;
height: 16px;
background: var(--accent-primary);
border-radius: 2px;
}
/* Node list */
.node-controls {
margin-bottom: 1rem;
}
.node-list {
max-height: 200px;
overflow-y: auto;
}
.node-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.node-item:hover {
background: var(--bg-tertiary);
}
.node-item.connected {
border-left: 3px solid var(--node-connected);
}
.node-item.disconnected {
border-left: 3px solid var(--node-disconnected);
}
.node-item.selected {
background: rgba(74, 222, 128, 0.1);
border-left: 3px solid var(--accent-primary);
}
.node-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-disconnected);
}
.node-indicator.connected {
background: var(--node-connected);
}
.node-info {
flex: 1;
font-size: 0.875rem;
}
.node-ip {
font-weight: 600;
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.node-status {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Preset controls */
.preset-select {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
}
.preset-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.preset-control {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.preset-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.preset-input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
}
.preset-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2);
}
.preset-slider {
width: 100%;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
outline: none;
-webkit-appearance: none;
}
.preset-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
}
.preset-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--accent-primary);
border-radius: 50%;
cursor: pointer;
border: none;
}
/* Buttons */
.btn {
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn:hover {
background: var(--accent-secondary);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover {
background: var(--bg-primary);
}
.btn-small {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
}
/* Matrix configuration */
.matrix-config {
display: flex;
gap: 1rem;
align-items: end;
}
.matrix-input {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.matrix-input label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.matrix-input input {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
padding: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
width: 80px;
}
.matrix-input input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2);
}
/* Status indicators */
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-weight: 500;
}
.status-connected {
background: rgba(34, 197, 94, 0.1);
color: var(--node-connected);
border: 1px solid rgba(34, 197, 94, 0.2);
}
.status-disconnected {
background: rgba(239, 68, 68, 0.1);
color: var(--node-disconnected);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.status-streaming {
background: rgba(74, 222, 128, 0.1);
color: var(--accent-primary);
border: 1px solid rgba(74, 222, 128, 0.2);
}
/* Loading and error states */
.loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-secondary);
font-size: 1.125rem;
}
.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 1rem;
color: var(--accent-error);
text-align: center;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-secondary);
font-style: italic;
}
/* Scrollbar styling */
.control-section::-webkit-scrollbar {
width: 6px;
}
.control-section::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
.control-section::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 3px;
}
.control-section::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.ledlab-main {
flex-direction: column;
gap: 1rem;
}
.control-section {
width: 100%;
max-height: 300px;
}
.matrix-section {
min-height: 400px;
}
}
/* Animation keyframes */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}