summaryrefslogtreecommitdiff
path: root/web-timeplot/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src/core')
-rw-r--r--web-timeplot/src/core/store.js212
1 files changed, 204 insertions, 8 deletions
diff --git a/web-timeplot/src/core/store.js b/web-timeplot/src/core/store.js
index 9989e5f..38052eb 100644
--- a/web-timeplot/src/core/store.js
+++ b/web-timeplot/src/core/store.js
@@ -1,7 +1,146 @@
+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: {
@@ -32,12 +171,57 @@ export function createInitialState() {
point: null,
},
},
- source: {
- activeId: 'synthetic-wave',
- preset: 'telemetry',
- sampleRateHz: 60,
- amplitude: 1,
- noise: 0.08,
+ 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 },
@@ -50,7 +234,7 @@ export function createInitialState() {
export class Store {
constructor(initialState = createInitialState()) {
- this.state = initialState;
+ this.state = mergePersistedState(initialState, loadPersistedState());
this.listeners = new Set();
}
@@ -66,6 +250,7 @@ export class Store {
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);
}
@@ -88,7 +273,18 @@ export class Store {
: state.plot.tooltip,
}
: state.plot,
- source: partial.source ? { ...state.source, ...partial.source } : state.source,
+ 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,
}));
}