summaryrefslogtreecommitdiff
path: root/web-timeplot/src/app/create-app.js
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2026-05-29 21:34:16 -0400
committergrothedev <grothedev@gmail.com>2026-05-29 21:34:16 -0400
commit27dc5849c3eaf4824d79938e7077abdbe2c82e24 (patch)
tree4a6e963d291132ad6f5a22841ea2404b60949366 /web-timeplot/src/app/create-app.js
parent73d75835e18a33c7f6c1b09bbcef93b16a7a9bfa (diff)
updates from claude. need to review. archiving rust and cpp stuff, going completely TS
Diffstat (limited to 'web-timeplot/src/app/create-app.js')
-rw-r--r--web-timeplot/src/app/create-app.js361
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();
},
};
}