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/app | |
| parent | 27dc5849c3eaf4824d79938e7077abdbe2c82e24 (diff) | |
mv web stuff to root project dirHEADprototypeframeworkmain
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/create-app.js | 449 |
1 files changed, 449 insertions, 0 deletions
diff --git a/src/app/create-app.js b/src/app/create-app.js new file mode 100644 index 0000000..4f4f0fc --- /dev/null +++ b/src/app/create-app.js @@ -0,0 +1,449 @@ +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(); + }, + }; +} |
