const STORAGE_KEY = 'timeplot.app-state.v1'; function clonePanelState(panels) { return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }])); } function cloneNamedState(items) { return Object.fromEntries(Object.entries(items).map(([key, value]) => [key, { ...value }])); } function sanitizePersistedSource(source) { return { type: source.type, preset: source.preset, sampleRateHz: source.sampleRateHz, amplitude: source.amplitude, noise: source.noise, replayRate: source.replayRate, dataFileName: source.dataFileName, wsUrl: source.wsUrl, wsReconnectMs: source.wsReconnectMs, }; } function createPersistableState(state) { return { plot: { showGrid: state.plot.showGrid, showPoints: state.plot.showPoints, windowDurationMs: state.plot.windowDurationMs, maxPoints: state.plot.maxPoints, }, time: { speed: state.time.speed, }, panels: clonePanelState(state.panels), graphs: cloneNamedState(state.graphs), sources: Object.fromEntries(Object.entries(state.sources).map(([key, value]) => [ key, sanitizePersistedSource(value), ])), }; } function mergePersistedState(baseState, persistedState) { if (!persistedState || typeof persistedState !== 'object') { return baseState; } const mergedState = { ...baseState, time: persistedState.time ? { ...baseState.time, speed: persistedState.time.speed ?? baseState.time.speed, paused: false, } : baseState.time, plot: persistedState.plot ? { ...baseState.plot, ...persistedState.plot, valueRange: baseState.plot.valueRange, hoveredPoint: null, tooltip: { ...baseState.plot.tooltip }, } : baseState.plot, panels: persistedState.panels ? clonePanelState(Object.fromEntries(Object.entries(baseState.panels).map(([key, value]) => [ key, { ...value, ...(persistedState.panels[key] ?? {}), }, ]))) : baseState.panels, graphs: persistedState.graphs ? cloneNamedState(Object.fromEntries(Object.entries(baseState.graphs).map(([key, value]) => [ key, { ...value, ...(persistedState.graphs[key] ?? {}), }, ]))) : baseState.graphs, sources: persistedState.sources ? Object.fromEntries(Object.entries(baseState.sources).map(([key, value]) => { const persistedSource = persistedState.sources[key] ?? {}; const nextType = persistedSource.type ?? value.type; return [ key, { ...value, ...persistedSource, type: nextType, dataset: [], datasetPointCount: 0, datasetDurationMs: 0, loadError: nextType === 'csv-replay' && persistedSource.dataFileName ? `Reload ${persistedSource.dataFileName} to restore replay data` : '', wsStatus: 'idle', wsStatusDetail: '', }, ]; })) : baseState.sources, }; return mergedState; } function loadPersistedState() { if (typeof localStorage === 'undefined') { return null; } try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) { return null; } return JSON.parse(raw); } catch (error) { console.warn('[timeplot] failed to load persisted state', error); return null; } } function savePersistedState(state) { if (typeof localStorage === 'undefined') { return; } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(createPersistableState(state))); } catch (error) { console.warn('[timeplot] failed to persist state', error); } } export function createInitialState() { return { app: { title: 'TimePlot', renderer: 'pending', }, time: { realNowMs: Date.now(), realElapsedMs: 0, plotTimeMs: 0, speed: 1, paused: false, }, plot: { showGrid: true, showPoints: true, windowDurationMs: 20000, maxPoints: 1600, valueRange: { min: -1.6, max: 1.6, }, hoveredPoint: null, tooltip: { visible: false, x: 0, y: 0, point: null, }, }, sources: { signalA: { id: 'signal-a', label: 'Signal A', type: 'synthetic-wave', preset: 'telemetry', sampleRateHz: 60, amplitude: 1, noise: 0.08, replayRate: 1, dataset: [], dataFileName: '', datasetPointCount: 0, datasetDurationMs: 0, loadError: '', wsUrl: 'ws://localhost:8080', wsReconnectMs: 2000, wsStatus: 'idle', wsStatusDetail: '', }, signalB: { id: 'signal-b', label: 'Signal B', type: 'synthetic-wave', preset: 'chirp', sampleRateHz: 48, amplitude: 0.8, noise: 0.04, replayRate: 1, dataset: [], dataFileName: '', datasetPointCount: 0, datasetDurationMs: 0, loadError: '', wsUrl: 'ws://localhost:8080', wsReconnectMs: 2000, wsStatus: 'idle', wsStatusDetail: '', }, }, graphs: { primary: { sourceKey: 'signalA', transform: 'raw', title: 'Primary signal', }, secondary: { sourceKey: 'signalB', transform: 'delta', title: 'Secondary signal', }, }, panels: { status: { title: 'Status', visible: true }, source: { title: 'Data Source', visible: true }, config: { title: 'Config', visible: true }, help: { title: 'Help', visible: false }, }, }; } export class Store { constructor(initialState = createInitialState()) { this.state = mergePersistedState(initialState, loadPersistedState()); this.listeners = new Set(); } getState() { return this.state; } subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } setState(updater) { const nextState = typeof updater === 'function' ? updater(this.state) : updater; this.state = nextState; savePersistedState(this.state); for (const listener of this.listeners) { listener(this.state); } } patch(partial) { this.setState((state) => ({ ...state, ...partial, time: partial.time ? { ...state.time, ...partial.time } : state.time, plot: partial.plot ? { ...state.plot, ...partial.plot, valueRange: partial.plot.valueRange ? { ...state.plot.valueRange, ...partial.plot.valueRange } : state.plot.valueRange, tooltip: partial.plot.tooltip ? { ...state.plot.tooltip, ...partial.plot.tooltip } : state.plot.tooltip, } : state.plot, sources: partial.sources ? Object.fromEntries(Object.entries({ ...state.sources, ...partial.sources }).map(([key, value]) => [ key, { ...state.sources[key], ...value }, ])) : state.sources, graphs: partial.graphs ? cloneNamedState(Object.fromEntries(Object.entries({ ...state.graphs, ...partial.graphs }).map(([key, value]) => [ key, { ...state.graphs[key], ...value }, ]))) : state.graphs, panels: partial.panels ? clonePanelState({ ...state.panels, ...partial.panels }) : state.panels, })); } }