summaryrefslogtreecommitdiff
path: root/src/core/store.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/store.js')
-rw-r--r--src/core/store.js291
1 files changed, 291 insertions, 0 deletions
diff --git a/src/core/store.js b/src/core/store.js
new file mode 100644
index 0000000..38052eb
--- /dev/null
+++ b/src/core/store.js
@@ -0,0 +1,291 @@
+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,
+ }));
+ }
+}