diff options
Diffstat (limited to 'src/state.js')
| -rw-r--r-- | src/state.js | 420 |
1 files changed, 420 insertions, 0 deletions
diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..53d8279 --- /dev/null +++ b/src/state.js @@ -0,0 +1,420 @@ +/** + * 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())); + } +} |
