summaryrefslogtreecommitdiff
path: root/src/state.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/state.js')
-rw-r--r--src/state.js420
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()));
+ }
+}