import { EventBus } from '../core/event-bus.js'; import { Store, createInitialState } from '../core/store.js'; 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 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(); sourceBuffers.forEach((plotBuffer) => plotBuffer.clear()); sourceRegistry.reset(); }, togglePanel: (panelId) => { store.setState((state) => ({ ...state, panels: { ...state.panels, [panelId]: { ...state.panels[panelId], visible: !state.panels[panelId].visible, }, }, })); }, updateSource: (sourceKey, field, value) => { store.setState((state) => ({ ...state, 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) => ({ ...state, plot: { ...state.plot, [field]: value, }, })); 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.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, renderer, }, }); sourceRegistry = new SourceRegistry(store, bus); bus.on('data:point', (point) => { sourceBuffers.get(point.sourceId)?.addPoint(point); }); const keyHandler = (event) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) { return; } if (event.code === 'Space') { event.preventDefault(); actions.togglePause(); return; } if (event.key === '[') { actions.setSpeed(store.getState().time.speed - 0.1); return; } if (event.key === ']') { actions.setSpeed(store.getState().time.speed + 0.1); return; } if (event.key.toLowerCase() === 'g') { actions.updatePlot('showGrid', !store.getState().plot.showGrid); } }; window.addEventListener('keydown', keyHandler); plotView.app.ticker.add(() => { timeController.tick(); sourceRegistry.syncFromState(); syncBuffersFromState(); sourceRegistry.update(store.getState().time.plotTimeMs); const state = store.getState(); 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(); }, }; }