summaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2026-05-29 21:49:20 -0400
committergrothedev <grothedev@gmail.com>2026-05-29 21:49:20 -0400
commit6196004b51a6850909c154f5402ff4858eab479a (patch)
tree126b8bb1600d0a656e0df016e25d08c390f3540e /src/app
parent27dc5849c3eaf4824d79938e7077abdbe2c82e24 (diff)
mv web stuff to root project dirHEADprototypeframeworkmain
Diffstat (limited to 'src/app')
-rw-r--r--src/app/create-app.js449
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();
+ },
+ };
+}