diff options
| author | grothedev <grothedev@gmail.com> | 2026-05-29 21:49:20 -0400 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2026-05-29 21:49:20 -0400 |
| commit | 6196004b51a6850909c154f5402ff4858eab479a (patch) | |
| tree | 126b8bb1600d0a656e0df016e25d08c390f3540e /src/core | |
| parent | 27dc5849c3eaf4824d79938e7077abdbe2c82e24 (diff) | |
mv web stuff to root project dirHEADprototypeframeworkmain
Diffstat (limited to 'src/core')
| -rw-r--r-- | src/core/event-bus.js | 32 | ||||
| -rw-r--r-- | src/core/store.js | 291 | ||||
| -rw-r--r-- | src/core/time-controller.js | 80 |
3 files changed, 403 insertions, 0 deletions
diff --git a/src/core/event-bus.js b/src/core/event-bus.js new file mode 100644 index 0000000..192eb6d --- /dev/null +++ b/src/core/event-bus.js @@ -0,0 +1,32 @@ +export class EventBus { + constructor() { + this.listeners = new Map(); + } + + on(eventName, listener) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const listeners = this.listeners.get(eventName); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, payload) { + const listeners = this.listeners.get(eventName); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(payload); + } + } +} 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, + })); + } +} diff --git a/src/core/time-controller.js b/src/core/time-controller.js new file mode 100644 index 0000000..7cd57c7 --- /dev/null +++ b/src/core/time-controller.js @@ -0,0 +1,80 @@ +export class TimeController { + constructor(store) { + this.store = store; + this.lastFrameTime = performance.now(); + } + + tick(now = performance.now()) { + const deltaMs = now - this.lastFrameTime; + this.lastFrameTime = now; + + this.store.setState((state) => { + const realElapsedMs = state.time.realElapsedMs + deltaMs; + const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed; + + return { + ...state, + time: { + ...state.time, + realNowMs: Date.now(), + realElapsedMs, + plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs), + }, + }; + }); + + return deltaMs; + } + + togglePause() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused: !state.time.paused, + }, + })); + } + + setPaused(paused) { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused, + }, + })); + } + + setSpeed(speed) { + const clampedSpeed = Math.max(0.1, Math.min(12, speed)); + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + speed: clampedSpeed, + }, + })); + } + + reset() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + realElapsedMs: 0, + plotTimeMs: 0, + }, + plot: { + ...state.plot, + hoveredPoint: null, + tooltip: { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + this.lastFrameTime = performance.now(); + } +} |
