feat: ledlab
This commit is contained in:
100
public/index.html
Normal file
100
public/index.html
Normal 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>
|
||||
51
public/scripts/constants.js
Normal file
51
public/scripts/constants.js
Normal 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
759
public/scripts/framework.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
245
public/scripts/ledlab-app.js
Normal file
245
public/scripts/ledlab-app.js
Normal 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;
|
||||
}
|
||||
203
public/scripts/matrix-display.js
Normal file
203
public/scripts/matrix-display.js
Normal 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;
|
||||
}
|
||||
178
public/scripts/node-discovery.js
Normal file
178
public/scripts/node-discovery.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = NodeDiscovery;
|
||||
}
|
||||
372
public/scripts/preset-controls.js
Normal file
372
public/scripts/preset-controls.js
Normal 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;
|
||||
}
|
||||
120
public/scripts/theme-manager.js
Normal file
120
public/scripts/theme-manager.js
Normal 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
562
public/styles/main.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user