/** * StateManager - Centralized state management with Proxy-based reactivity * * Usage: * state.time.speed = 2.0 // automatically emits events * state.on('time.speed', (value) => console.log('Speed changed:', value)) * state.on('time.*', (change) => console.log('Time domain changed:', change)) * * State Domains: * - userPrefs: showGrid, showMetrics, theme, etc. * - uiConfig: active panels, layout, dimensions * - time: current time, speed, paused state, real elapsed time * - rendering: graphs, renderer info * - health: framerate, service connections, db access * - dataInput: sources, structure, metadata * - inputActions: keyboard/mouse/gamepad action mappings */ // Simple EventEmitter implementation class EventEmitter { constructor() { this.events = new Map(); } on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(callback); // Return unsubscribe function return () => this.off(event, callback); } off(event, callback) { if (!this.events.has(event)) return; const callbacks = this.events.get(event); const index = callbacks.indexOf(callback); if (index > -1) { callbacks.splice(index, 1); } } emit(event, data) { if (!this.events.has(event)) return; this.events.get(event).forEach(callback => { try { callback(data); } catch (e) { console.error(`[State] Error in event handler for '${event}':`, e); } }); } once(event, callback) { const wrapper = (data) => { callback(data); this.off(event, wrapper); }; this.on(event, wrapper); } clear() { this.events.clear(); } } export class StateManager extends EventEmitter { constructor() { super(); // Internal state storage (not proxied) this._state = { userPrefs: { showGrid: true, showMetrics: true, theme: 'dark', rollingWindow: 60, historyCapacity: 10000, metricsUpdateInterval: 10, }, uiConfig: { activePanels: ['graph1', 'graph2'], layout: 'horizontal-split', canvasWidth: 0, canvasHeight: 0, }, time: { current: 0, // Current plot time realElapsed: 0, // Real time elapsed since start speed: 1.0, // Time speed multiplier (0.1 to 5.0) isPaused: false, // Pause state startTimestamp: Date.now(), // Real timestamp when started verticalScale: 1.0, // Vertical zoom for time history }, rendering: { rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas' frameCounter: 0, // Note: graph instances are NOT stored here to avoid proxy wrapping }, health: { fps: 0, updateMs: 0, renderMs: 0, vertexCount: 0, lineCount: 0, serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' } }, dataInput: { sources: [], // Array of data source configs activeSource: null, // Currently active source dataStructure: null, // Schema of incoming data metadata: {}, // Additional metadata }, inputActions: { keyboardMap: new Map(), // Map of KeyboardEvent.code => action name mouseMap: new Map(), // Map of mouse button => action name actionHandlers: new Map(), // Map of action name => handler function }, }; // Track which domains should be persisted this._persistedDomains = new Set(['userPrefs']); // Load persisted state this._loadPersistedState(); // Create proxied state - this is what users interact with this.state = this._createProxy(this._state, []); } /** * Create a reactive Proxy that emits events on property changes * @param {Object} target - The object to proxy * @param {Array} path - Current property path (e.g., ['time', 'speed']) * @private */ _createProxy(target, path) { // Don't proxy non-objects or special objects like Map/Set if (typeof target !== 'object' || target === null) { return target; } // Don't proxy Maps and Sets - they need special handling if (target instanceof Map || target instanceof Set) { return target; } const self = this; return new Proxy(target, { get(obj, prop) { const value = obj[prop]; // Return primitives and functions as-is if (typeof value !== 'object' || value === null) { return value; } // Return nested objects as proxies return self._createProxy(value, [...path, prop]); }, set(obj, prop, value) { const oldValue = obj[prop]; // Only emit if value actually changed if (oldValue === value) { return true; } obj[prop] = value; // Build event path const fullPath = [...path, prop]; const pathString = fullPath.join('.'); const domain = fullPath[0]; // Emit specific property change: "time.speed" self.emit(pathString, { path: fullPath, value: value, oldValue: oldValue, }); // Emit domain wildcard: "time.*" if (domain) { self.emit(`${domain}.*`, { path: fullPath, property: prop, value: value, oldValue: oldValue, }); } // Emit global wildcard: "*" self.emit('*', { path: fullPath, value: value, oldValue: oldValue, }); // Auto-persist certain domains if (self._persistedDomains.has(domain)) { self._persistDomain(domain); } return true; } }); } // ========================================================================= // Persistence // ========================================================================= _persistDomain(domain) { try { const data = this._state[domain]; // Convert Maps to objects for JSON serialization const serializable = this._makeSerializable(data); localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable)); } catch (e) { console.warn(`[State] Failed to persist ${domain}:`, e); } } _loadPersistedState() { this._persistedDomains.forEach(domain => { try { const saved = localStorage.getItem(`timeplot-${domain}`); if (saved) { const data = JSON.parse(saved); // Deep merge to preserve defaults for new properties this._state[domain] = this._deepMerge(this._state[domain], data); } } catch (e) { console.warn(`[State] Failed to load ${domain}:`, e); } }); } _makeSerializable(obj) { if (obj instanceof Map) { return Object.fromEntries(obj); } if (obj instanceof Set) { return Array.from(obj); } if (typeof obj === 'object' && obj !== null) { const result = {}; for (const [key, value] of Object.entries(obj)) { result[key] = this._makeSerializable(value); } return result; } return obj; } _deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { result[key] = this._deepMerge(target[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } // ========================================================================= // Convenience Methods // ========================================================================= /** * Toggle a boolean preference */ togglePref(key) { const current = this.state.userPrefs[key]; if (typeof current === 'boolean') { this.state.userPrefs[key] = !current; } } /** * Pause/resume time */ togglePause() { this.state.time.isPaused = !this.state.time.isPaused; } /** * Set time speed (clamped 0.1 to 5.0) */ setTimeSpeed(speed) { this.state.time.speed = Math.max(0.1, Math.min(5.0, speed)); } /** * Increment time (respects pause and speed) */ incrementTime(delta) { if (this.state.time.isPaused) return; this.state.time.current += delta * this.state.time.speed; } /** * Update real elapsed time */ updateRealElapsed() { const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000; this.state.time.realElapsed = elapsed; } // ========================================================================= // Input Actions System // ========================================================================= /** * Register an input action handler * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause') * @param {Function} handler - Handler function to call */ registerAction(actionName, handler) { this.state.inputActions.actionHandlers.set(actionName, handler); } /** * Map a keyboard key to an action * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space') * @param {string} actionName - Action to trigger */ mapKey(code, actionName) { this.state.inputActions.keyboardMap.set(code, actionName); } /** * Map a mouse button to an action * @param {number} button - Mouse button number (0=left, 1=middle, 2=right) * @param {string} actionName - Action to trigger */ mapMouseButton(button, actionName) { this.state.inputActions.mouseMap.set(button, actionName); } /** * Execute an action by name */ executeAction(actionName, event) { const handler = this.state.inputActions.actionHandlers.get(actionName); if (handler) { handler(event); } else { console.warn(`[State] No handler registered for action: ${actionName}`); } } /** * Handle keyboard event through action system */ handleKeyboardEvent(event) { const actionName = this.state.inputActions.keyboardMap.get(event.code); if (actionName) { this.executeAction(actionName, event); return true; } return false; } /** * Handle mouse button event through action system */ handleMouseButtonEvent(event) { const actionName = this.state.inputActions.mouseMap.get(event.button); if (actionName) { this.executeAction(actionName, event); return true; } return false; } // ========================================================================= // Data Sources // ========================================================================= addDataSource(source) { this.state.dataInput.sources.push(source); } removeDataSource(sourceId) { const sources = this.state.dataInput.sources; const index = sources.findIndex(s => s.id === sourceId); if (index > -1) { sources.splice(index, 1); } } setActiveDataSource(sourceId) { this.state.dataInput.activeSource = sourceId; } // ========================================================================= // Debugging // ========================================================================= dump() { console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state))); } debugEvents() { console.log('[State] Registered events:', Array.from(this.events.keys())); } }