diff options
| author | grothedev <grothedev@gmail.com> | 2026-05-29 21:34:16 -0400 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2026-05-29 21:34:16 -0400 |
| commit | 27dc5849c3eaf4824d79938e7077abdbe2c82e24 (patch) | |
| tree | 4a6e963d291132ad6f5a22841ea2404b60949366 /web-timeplot/src/app | |
| parent | 73d75835e18a33c7f6c1b09bbcef93b16a7a9bfa (diff) | |
updates from claude. need to review. archiving rust and cpp stuff, going completely TS
Diffstat (limited to 'web-timeplot/src/app')
| -rw-r--r-- | web-timeplot/src/app/create-app.js | 361 |
1 files changed, 329 insertions, 32 deletions
diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js index daf3559..4f4f0fc 100644 --- a/web-timeplot/src/app/create-app.js +++ b/web-timeplot/src/app/create-app.js @@ -4,25 +4,161 @@ import { TimeController } from '../core/time-controller.js'; import { PlotBuffer } from '../plot/plot-buffer.js'; import { TimeplotView } from '../plot/timeplot-view.js'; import { SourceRegistry } from '../data/source-registry.js'; +import { parseReplayCsv } from '../data/parse-replay-csv.js'; import { PanelManager } from '../ui/panel-manager.js'; function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } +function buildDeltaPoints(points) { + if (points.length < 2) { + return []; + } + + const derived = []; + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const deltaTime = Math.max(1, current.timeMs - previous.timeMs); + derived.push({ + ...current, + value: (current.value - previous.value) / deltaTime * 1000, + sourceId: `${current.sourceId}:delta`, + }); + } + + return derived; +} + +function buildSmoothedPoints(points, windowSize = 5) { + if (points.length === 0) { + return []; + } + + const smoothed = []; + for (let index = 0; index < points.length; index += 1) { + const start = Math.max(0, index - windowSize + 1); + const windowPoints = points.slice(start, index + 1); + const average = windowPoints.reduce((sum, point) => sum + point.value, 0) / windowPoints.length; + smoothed.push({ + ...points[index], + value: average, + sourceId: `${points[index].sourceId}:smooth`, + }); + } + + return smoothed; +} + +function transformPoints(points, transform) { + switch (transform) { + case 'delta': + return buildDeltaPoints(points); + case 'smooth': + return buildSmoothedPoints(points); + case 'raw': + default: + return points; + } +} + +function describeTransform(transform) { + switch (transform) { + case 'delta': + return 'Δvalue / second'; + case 'smooth': + return 'moving average'; + case 'raw': + default: + return 'raw signal'; + } +} + +function deriveValueRange(points, fallbackRange) { + if (points.length === 0) { + return fallbackRange; + } + + let min = Infinity; + let max = -Infinity; + for (const point of points) { + min = Math.min(min, point.value); + max = Math.max(max, point.value); + } + + const maxAbs = Math.max(Math.abs(min), Math.abs(max), 0.1); + return { + min: -maxAbs, + max: maxAbs, + }; +} + +function pickActiveHover(primaryCandidate, secondaryCandidate) { + if (!primaryCandidate && !secondaryCandidate) { + return null; + } + + if (primaryCandidate && !secondaryCandidate) { + return primaryCandidate; + } + + if (!primaryCandidate && secondaryCandidate) { + return secondaryCandidate; + } + + return primaryCandidate.lastPointerEventAt >= secondaryCandidate.lastPointerEventAt + ? primaryCandidate + : secondaryCandidate; +} + export async function createApp(root) { const bus = new EventBus(); const store = new Store(createInitialState()); const timeController = new TimeController(store); - const buffer = new PlotBuffer(store.getState().plot.maxPoints); + const sourceBuffers = new Map(Object.keys(store.getState().sources).map((sourceKey) => [sourceKey, new PlotBuffer(store.getState().plot.maxPoints)])); let sourceRegistry; + const syncBuffersFromState = () => { + const state = store.getState(); + for (const sourceKey of Object.keys(state.sources)) { + if (!sourceBuffers.has(sourceKey)) { + sourceBuffers.set(sourceKey, new PlotBuffer(state.plot.maxPoints)); + } + sourceBuffers.get(sourceKey).maxPoints = state.plot.maxPoints; + } + + for (const sourceKey of Array.from(sourceBuffers.keys())) { + if (!state.sources[sourceKey]) { + sourceBuffers.delete(sourceKey); + } + } + }; + + const clearSourceBuffer = (sourceKey) => { + sourceBuffers.get(sourceKey)?.clear(); + }; + + const getGraphPoints = (state, graphId) => { + const graphConfig = state.graphs[graphId]; + const sourceBuffer = sourceBuffers.get(graphConfig.sourceKey); + const basePoints = sourceBuffer + ? sourceBuffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs) + : []; + const transformedPoints = transformPoints(basePoints, graphConfig.transform); + return { + graphConfig, + points: transformedPoints, + range: deriveValueRange(transformedPoints, state.plot.valueRange), + }; + }; + const actions = { togglePause: () => timeController.togglePause(), setSpeed: (speed) => timeController.setSpeed(speed), resetScene: () => { timeController.reset(); - buffer.clear(); + sourceBuffers.forEach((plotBuffer) => plotBuffer.clear()); sourceRegistry.reset(); }, togglePanel: (panelId) => { @@ -37,15 +173,75 @@ export async function createApp(root) { }, })); }, - updateSource: (field, value) => { + updateSource: (sourceKey, field, value) => { store.setState((state) => ({ ...state, - source: { - ...state.source, - [field]: value, + sources: { + ...state.sources, + [sourceKey]: { + ...state.sources[sourceKey], + [field]: value, + ...(field === 'type' + ? { + loadError: value === 'csv-replay' && state.sources[sourceKey].dataset.length === 0 + ? (state.sources[sourceKey].dataFileName + ? `Reload ${state.sources[sourceKey].dataFileName} to restore replay data` + : 'Load a CSV file to begin replay') + : '', + wsStatus: value === 'websocket' ? state.sources[sourceKey].wsStatus : 'idle', + wsStatusDetail: value === 'websocket' ? state.sources[sourceKey].wsStatusDetail : '', + } + : {}), + }, }, })); sourceRegistry.syncFromState(); + syncBuffersFromState(); + + if (field === 'type' || field === 'wsUrl' || field === 'wsReconnectMs') { + clearSourceBuffer(sourceKey); + sourceRegistry.reset(); + } + }, + loadSourceFile: async (sourceKey, file) => { + try { + const state = store.getState(); + const sampleRateHz = state.sources[sourceKey]?.sampleRateHz ?? 60; + const text = await file.text(); + const { points, metadata } = parseReplayCsv(text, { sampleRateHz }); + + clearSourceBuffer(sourceKey); + store.setState((currentState) => ({ + ...currentState, + sources: { + ...currentState.sources, + [sourceKey]: { + ...currentState.sources[sourceKey], + type: 'csv-replay', + dataset: points, + dataFileName: file.name, + datasetPointCount: metadata.pointCount, + datasetDurationMs: metadata.durationMs, + loadError: '', + wsStatus: 'idle', + wsStatusDetail: '', + }, + }, + })); + sourceRegistry.syncFromState(); + sourceRegistry.reset(); + } catch (error) { + store.setState((currentState) => ({ + ...currentState, + sources: { + ...currentState.sources, + [sourceKey]: { + ...currentState.sources[sourceKey], + loadError: error instanceof Error ? error.message : String(error), + }, + }, + })); + } }, updatePlot: (field, value) => { store.setState((state) => ({ @@ -58,39 +254,50 @@ export async function createApp(root) { if (field === 'maxPoints') { buffer.maxPoints = clamp(value, 200, 4000); + sourceBuffers.forEach((plotBuffer) => { + plotBuffer.maxPoints = clamp(value, 200, 4000); + }); } }, + updateGraph: (graphId, field, value) => { + store.setState((state) => ({ + ...state, + graphs: { + ...state.graphs, + [graphId]: { + ...state.graphs[graphId], + [field]: value, + }, + }, + })); + }, }; const panelManager = new PanelManager({ root, store, actions }); const elements = panelManager.mount(); const plotView = new TimeplotView({ - host: elements.canvasHost, - onHover: (hoverState) => { - store.setState((state) => ({ - ...state, - plot: { - ...state.plot, - hoveredPoint: hoverState?.point ?? null, - tooltip: hoverState - ? { - visible: true, - x: hoverState.x, - y: hoverState.y, - point: hoverState.point, - } - : { - ...state.plot.tooltip, - visible: false, - point: null, - }, - }, - })); - }, + host: elements.primaryCanvasHost, + panelId: 'primary', + title: 'Primary signal', + subtitle: null, + showReadouts: true, + lineColor: 0x9fd1ff, + pointColor: 0xe7f2ff, + }); + + const secondaryPlotView = new TimeplotView({ + host: elements.secondaryCanvasHost, + panelId: 'secondary', + title: 'Secondary signal', + subtitle: null, + showReadouts: false, + lineColor: 0xffc46b, + pointColor: 0xffe1b0, }); const renderer = await plotView.init(); + await secondaryPlotView.init(); store.patch({ app: { ...store.getState().app, @@ -101,7 +308,7 @@ export async function createApp(root) { sourceRegistry = new SourceRegistry(store, bus); bus.on('data:point', (point) => { - buffer.addPoint(point); + sourceBuffers.get(point.sourceId)?.addPoint(point); }); const keyHandler = (event) => { @@ -135,18 +342,108 @@ export async function createApp(root) { plotView.app.ticker.add(() => { timeController.tick(); sourceRegistry.syncFromState(); + syncBuffersFromState(); sourceRegistry.update(store.getState().time.plotTimeMs); const state = store.getState(); - const visiblePoints = buffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs); - plotView.render(state, visiblePoints); - panelManager.sync(state, visiblePoints.length); + const primaryGraph = getGraphPoints(state, 'primary'); + const secondaryGraph = getGraphPoints(state, 'secondary'); + + plotView.panelTitle = state.graphs.primary.title; + plotView.panelSubtitle = `${state.sources[state.graphs.primary.sourceKey].label} · ${describeTransform(state.graphs.primary.transform)} · time ↓`; + secondaryPlotView.panelTitle = state.graphs.secondary.title; + secondaryPlotView.panelSubtitle = `${state.sources[state.graphs.secondary.sourceKey].label} · ${describeTransform(state.graphs.secondary.transform)} · time ↓`; + + const primaryState = { + ...state, + plot: { + ...state.plot, + valueRange: primaryGraph.range, + }, + }; + + const secondaryState = { + ...state, + plot: { + ...state.plot, + valueRange: secondaryGraph.range, + }, + }; + + plotView.render(primaryState, primaryGraph.points); + secondaryPlotView.render(secondaryState, secondaryGraph.points); + + const primaryHover = plotView.getHoverCandidate(); + const secondaryHover = secondaryPlotView.getHoverCandidate(); + const activeHover = pickActiveHover(primaryHover, secondaryHover); + + if (!activeHover) { + plotView.clearHover(); + secondaryPlotView.clearHover(); + store.setState((currentState) => ({ + ...currentState, + plot: { + ...currentState.plot, + hoveredPoint: null, + tooltip: { + ...currentState.plot.tooltip, + visible: false, + point: null, + linkedPoint: null, + }, + }, + })); + panelManager.sync(store.getState(), { + primary: primaryGraph.points.length, + secondary: secondaryGraph.points.length, + }); + return; + } + + const primaryLinkedPoint = plotView.findNearestScreenPointByTime(activeHover.point.timeMs); + const secondaryLinkedPoint = secondaryPlotView.findNearestScreenPointByTime(activeHover.point.timeMs); + + plotView.renderLinkedHover(primaryLinkedPoint); + secondaryPlotView.renderLinkedHover(secondaryLinkedPoint); + + const activePanelLabel = activeHover.panelId === 'secondary' + ? state.graphs.secondary.title + : state.graphs.primary.title; + const linkedPoint = activeHover.panelId === 'secondary' ? primaryLinkedPoint : secondaryLinkedPoint; + const linkedPanelLabel = activeHover.panelId === 'secondary' + ? state.graphs.primary.title + : state.graphs.secondary.title; + + store.setState((currentState) => ({ + ...currentState, + plot: { + ...currentState.plot, + hoveredPoint: activeHover.point, + tooltip: { + ...currentState.plot.tooltip, + visible: true, + panelId: activeHover.panelId, + panelLabel: activePanelLabel, + x: activeHover.x, + y: activeHover.y, + point: activeHover.point, + linkedPoint, + linkedPanelLabel, + }, + }, + })); + + panelManager.sync(store.getState(), { + primary: primaryGraph.points.length, + secondary: secondaryGraph.points.length, + }); }); return { destroy() { window.removeEventListener('keydown', keyHandler); plotView.destroy(); + secondaryPlotView.destroy(); }, }; } |
