summaryrefslogtreecommitdiff
path: root/web-timeplot/src/ui
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/ui
parent73d75835e18a33c7f6c1b09bbcef93b16a7a9bfa (diff)
updates from claude. need to review. archiving rust and cpp stuff, going completely TS
Diffstat (limited to 'web-timeplot/src/ui')
-rw-r--r--web-timeplot/src/ui/panel-manager.js363
1 files changed, 309 insertions, 54 deletions
diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js
index 8a1b216..ad29697 100644
--- a/web-timeplot/src/ui/panel-manager.js
+++ b/web-timeplot/src/ui/panel-manager.js
@@ -15,6 +15,39 @@ function setToggleState(element, active) {
element.dataset.active = String(active);
}
+function readControlValue(element) {
+ if (element.tagName === 'SELECT') {
+ return element.value;
+ }
+
+ if (element instanceof HTMLInputElement) {
+ if (element.type === 'checkbox') {
+ return element.checked;
+ }
+
+ if (element.type === 'number' || element.type === 'range') {
+ return Number(element.value);
+ }
+
+ return element.value;
+ }
+
+ return element.value;
+}
+
+function syncControlValue(element, value) {
+ if (!element || document.activeElement === element) {
+ return;
+ }
+
+ if (element instanceof HTMLInputElement && element.type === 'checkbox') {
+ element.checked = Boolean(value);
+ return;
+ }
+
+ element.value = String(value ?? '');
+}
+
export class PanelManager {
constructor({ root, store, actions }) {
this.root = root;
@@ -27,14 +60,20 @@ export class PanelManager {
const shell = createElement('div', 'timeplot-shell');
const topbar = createElement('header', 'timeplot-topbar');
const viewport = createElement('section', 'timeplot-viewport');
- const canvasHost = createElement('div', 'timeplot-canvas-host');
+ const plotGrid = createElement('div', 'timeplot-plot-grid');
+ const primaryPlotPanel = createElement('section', 'timeplot-plot-panel');
+ const secondaryPlotPanel = createElement('section', 'timeplot-plot-panel');
+ const primaryCanvasHost = createElement('div', 'timeplot-canvas-host');
+ const secondaryCanvasHost = createElement('div', 'timeplot-canvas-host');
const sidebar = createElement('aside', 'timeplot-sidebar');
- const tooltip = createElement('div', 'timeplot-tooltip');
- tooltip.hidden = true;
+ const primaryTooltip = createElement('div', 'timeplot-tooltip');
+ const secondaryTooltip = createElement('div', 'timeplot-tooltip');
+ primaryTooltip.hidden = true;
+ secondaryTooltip.hidden = true;
const brand = createElement('div', 'timeplot-brand');
const title = createElement('h1', 'timeplot-title', 'TimePlot');
- const subtitle = createElement('div', 'timeplot-subtitle', 'Restarted from scratch with a modular core');
+ const subtitle = createElement('div', 'timeplot-subtitle', 'Dual synchronized signal monitor');
brand.append(title, subtitle);
const toolbar = createElement('div', 'timeplot-toolbar');
@@ -44,7 +83,10 @@ export class PanelManager {
);
topbar.append(brand, toolbar);
- viewport.append(canvasHost, tooltip);
+ primaryPlotPanel.append(primaryCanvasHost, primaryTooltip);
+ secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip);
+ plotGrid.append(primaryPlotPanel, secondaryPlotPanel);
+ viewport.append(plotGrid);
shell.append(topbar, viewport, sidebar);
this.root.replaceChildren(shell);
@@ -53,9 +95,14 @@ export class PanelManager {
shell,
topbar,
viewport,
- canvasHost,
+ plotGrid,
+ primaryPlotPanel,
+ secondaryPlotPanel,
+ primaryCanvasHost,
+ secondaryCanvasHost,
sidebar,
- tooltip,
+ primaryTooltip,
+ secondaryTooltip,
title,
subtitle,
statusPanel: this.createStatusPanel(),
@@ -133,36 +180,142 @@ export class PanelManager {
const panel = createElement('section', 'panel');
panel.innerHTML = `
<h2>Data Source</h2>
- <div class="field-grid">
- <label>
- Preset
- <select data-source-field="preset">
- <option value="telemetry">Telemetry</option>
- <option value="chirp">Chirp</option>
- <option value="burst">Burst</option>
- </select>
- </label>
- <label>
- Sample rate (Hz)
- <input data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
- </label>
- <label>
- Amplitude
- <input data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
- </label>
- <label>
- Noise
- <input data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
- </label>
+ <div class="panel-subsection" data-source-config="signalA">
+ <div class="panel-section-title">Signal A</div>
+ <div class="field-grid">
+ <label>
+ Source type
+ <select data-source-key="signalA" data-source-field="type">
+ <option value="synthetic-wave">Synthetic wave</option>
+ <option value="csv-replay">CSV replay</option>
+ <option value="websocket">WebSocket</option>
+ </select>
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="synthetic-wave">
+ <label>
+ Preset
+ <select data-source-key="signalA" data-source-field="preset">
+ <option value="telemetry">Telemetry</option>
+ <option value="chirp">Chirp</option>
+ <option value="burst">Burst</option>
+ </select>
+ </label>
+ <label>
+ Sample rate (Hz)
+ <input data-source-key="signalA" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
+ </label>
+ <label>
+ Amplitude
+ <input data-source-key="signalA" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
+ </label>
+ <label>
+ Noise
+ <input data-source-key="signalA" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="csv-replay">
+ <label>
+ CSV file
+ <input data-source-key="signalA" data-source-file="dataset" type="file" accept=".csv,text/csv" />
+ </label>
+ <label>
+ Replay rate
+ <input data-source-key="signalA" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" />
+ </label>
+ <div class="source-meta" data-source-key="signalA" data-source-meta></div>
+ </div>
+ <div class="field-grid" data-source-mode="websocket">
+ <label>
+ WebSocket URL
+ <input data-source-key="signalA" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" />
+ </label>
+ <label>
+ Reconnect (ms)
+ <input data-source-key="signalA" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" />
+ </label>
+ <div class="source-meta" data-source-key="signalA" data-source-ws-meta></div>
+ </div>
+ </div>
+ <div class="panel-subsection" data-source-config="signalB">
+ <div class="panel-section-title">Signal B</div>
+ <div class="field-grid">
+ <label>
+ Source type
+ <select data-source-key="signalB" data-source-field="type">
+ <option value="synthetic-wave">Synthetic wave</option>
+ <option value="csv-replay">CSV replay</option>
+ <option value="websocket">WebSocket</option>
+ </select>
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="synthetic-wave">
+ <label>
+ Preset
+ <select data-source-key="signalB" data-source-field="preset">
+ <option value="telemetry">Telemetry</option>
+ <option value="chirp">Chirp</option>
+ <option value="burst">Burst</option>
+ </select>
+ </label>
+ <label>
+ Sample rate (Hz)
+ <input data-source-key="signalB" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
+ </label>
+ <label>
+ Amplitude
+ <input data-source-key="signalB" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
+ </label>
+ <label>
+ Noise
+ <input data-source-key="signalB" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="csv-replay">
+ <label>
+ CSV file
+ <input data-source-key="signalB" data-source-file="dataset" type="file" accept=".csv,text/csv" />
+ </label>
+ <label>
+ Replay rate
+ <input data-source-key="signalB" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" />
+ </label>
+ <div class="source-meta" data-source-key="signalB" data-source-meta></div>
+ </div>
+ <div class="field-grid" data-source-mode="websocket">
+ <label>
+ WebSocket URL
+ <input data-source-key="signalB" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" />
+ </label>
+ <label>
+ Reconnect (ms)
+ <input data-source-key="signalB" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" />
+ </label>
+ <div class="source-meta" data-source-key="signalB" data-source-ws-meta></div>
+ </div>
</div>
`;
panel.querySelectorAll('[data-source-field]').forEach((input) => {
- input.addEventListener('change', () => {
+ const eventName = input.tagName === 'SELECT' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
+ const sourceKey = input.getAttribute('data-source-key');
const field = input.getAttribute('data-source-field');
- const rawValue = input.value;
- const value = input.tagName === 'SELECT' ? rawValue : Number(rawValue);
- this.actions.updateSource(field, value);
+ const value = readControlValue(input);
+ this.actions.updateSource(sourceKey, field, value);
+ });
+ });
+
+ panel.querySelectorAll('[data-source-file]').forEach((input) => {
+ input.addEventListener('change', async () => {
+ const sourceKey = input.getAttribute('data-source-key');
+ const file = input.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ await this.actions.loadSourceFile(sourceKey, file);
+ input.value = '';
});
});
@@ -191,16 +344,60 @@ export class PanelManager {
<input data-plot-field="showPoints" type="checkbox" />
</div>
</div>
+ <div class="panel-subsection">
+ <div class="panel-section-title">Graph routing</div>
+ <div class="field-grid">
+ <label>
+ Primary graph source
+ <select data-graph-id="primary" data-graph-field="sourceKey">
+ <option value="signalA">Signal A</option>
+ <option value="signalB">Signal B</option>
+ </select>
+ </label>
+ <label>
+ Primary graph transform
+ <select data-graph-id="primary" data-graph-field="transform">
+ <option value="raw">Raw</option>
+ <option value="delta">Delta</option>
+ <option value="smooth">Smooth</option>
+ </select>
+ </label>
+ <label>
+ Secondary graph source
+ <select data-graph-id="secondary" data-graph-field="sourceKey">
+ <option value="signalA">Signal A</option>
+ <option value="signalB">Signal B</option>
+ </select>
+ </label>
+ <label>
+ Secondary graph transform
+ <select data-graph-id="secondary" data-graph-field="transform">
+ <option value="raw">Raw</option>
+ <option value="delta">Delta</option>
+ <option value="smooth">Smooth</option>
+ </select>
+ </label>
+ </div>
+ </div>
`;
panel.querySelectorAll('[data-plot-field]').forEach((input) => {
- input.addEventListener('change', () => {
+ const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
const field = input.getAttribute('data-plot-field');
- const value = input.type === 'checkbox' ? input.checked : Number(input.value);
+ const value = readControlValue(input);
this.actions.updatePlot(field, value);
});
});
+ panel.querySelectorAll('[data-graph-field]').forEach((input) => {
+ input.addEventListener('change', () => {
+ const graphId = input.getAttribute('data-graph-id');
+ const field = input.getAttribute('data-graph-field');
+ this.actions.updateGraph(graphId, field, input.value);
+ });
+ });
+
return panel;
}
@@ -209,10 +406,11 @@ export class PanelManager {
panel.innerHTML = `
<h2>Help</h2>
<ol class="help-list">
- <li>Hover the plot to inspect a sample.</li>
- <li>Use Pause and the speed slider to inspect timing behavior.</li>
- <li>Toggle panels from the top bar to focus the workspace.</li>
- <li>Swap presets to exercise the data input system.</li>
+ <li>Each signal can be synthetic or file-backed CSV replay.</li>
+ <li>Each graph can target Signal A or Signal B independently.</li>
+ <li>Each graph can render raw, delta, or smoothed data.</li>
+ <li>Hover either trace to inspect the nearest synchronized sample.</li>
+ <li>Use pause and speed controls to inspect timing behavior.</li>
</ol>
`;
return panel;
@@ -220,9 +418,10 @@ export class PanelManager {
sync(state, visiblePoints) {
this.elements.title.textContent = state.app.title;
- this.elements.subtitle.textContent = 'Synthetic time-series workspace with modular systems';
+ this.elements.subtitle.textContent = 'Dual synchronized signal monitor';
this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause';
- this.elements.speedInput.value = String(state.time.speed);
+ setToggleState(this.elements.pauseButton, state.time.paused);
+ syncControlValue(this.elements.speedInput, state.time.speed);
this.elements.speedValue.textContent = `${state.time.speed.toFixed(1)}×`;
const statusFields = this.elements.statusPanel.querySelectorAll('[data-field]');
@@ -232,7 +431,9 @@ export class PanelManager {
fieldMap.realElapsed.textContent = formatDuration(state.time.realElapsedMs);
fieldMap.plotTime.textContent = formatDuration(state.time.plotTimeMs);
fieldMap.playback.textContent = state.time.paused ? 'Paused' : `${state.time.speed.toFixed(1)}×`;
- fieldMap.points.textContent = `${visiblePoints}`;
+ fieldMap.points.textContent = typeof visiblePoints === 'object'
+ ? `${visiblePoints.primary} / ${visiblePoints.secondary}`
+ : `${visiblePoints}`;
this.syncSourcePanel(state);
this.syncConfigPanel(state);
@@ -241,17 +442,61 @@ export class PanelManager {
}
syncSourcePanel(state) {
- this.elements.sourcePanel.querySelector('[data-source-field="preset"]').value = state.source.preset;
- this.elements.sourcePanel.querySelector('[data-source-field="sampleRateHz"]').value = String(state.source.sampleRateHz);
- this.elements.sourcePanel.querySelector('[data-source-field="amplitude"]').value = String(state.source.amplitude);
- this.elements.sourcePanel.querySelector('[data-source-field="noise"]').value = String(state.source.noise);
+ Object.entries(state.sources).forEach(([sourceKey, sourceConfig]) => {
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="type"]`), sourceConfig.type);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="preset"]`), sourceConfig.preset);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="sampleRateHz"]`), sourceConfig.sampleRateHz);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="amplitude"]`), sourceConfig.amplitude);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="noise"]`), sourceConfig.noise);
+ const replayRateInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="replayRate"]`);
+ if (replayRateInput) {
+ syncControlValue(replayRateInput, sourceConfig.replayRate ?? 1);
+ }
+
+ const sourceSection = this.elements.sourcePanel.querySelector(`[data-source-config="${sourceKey}"]`);
+ sourceSection.querySelectorAll('[data-source-mode]').forEach((modeSection) => {
+ modeSection.hidden = modeSection.getAttribute('data-source-mode') !== sourceConfig.type;
+ });
+
+ const meta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-meta]`);
+ if (meta) {
+ if (sourceConfig.type === 'csv-replay') {
+ meta.innerHTML = sourceConfig.loadError
+ ? `<span class="source-meta-error">${sourceConfig.loadError}</span>`
+ : `${sourceConfig.dataFileName || 'No file loaded'}${sourceConfig.datasetPointCount ? ` · ${sourceConfig.datasetPointCount} pts · ${formatDuration(sourceConfig.datasetDurationMs || 0)}` : ''}`;
+ } else if (sourceConfig.type === 'websocket') {
+ meta.textContent = '';
+ } else {
+ meta.textContent = 'Generates data procedurally in-browser';
+ }
+ }
+
+ const wsUrlInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsUrl"]`);
+ const wsReconnectInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsReconnectMs"]`);
+ const wsMeta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-ws-meta]`);
+ if (wsUrlInput) {
+ syncControlValue(wsUrlInput, sourceConfig.wsUrl ?? '');
+ }
+ if (wsReconnectInput) {
+ syncControlValue(wsReconnectInput, sourceConfig.wsReconnectMs ?? 2000);
+ }
+ if (wsMeta) {
+ wsMeta.innerHTML = sourceConfig.type === 'websocket'
+ ? `status: <span class="source-meta-status source-meta-status-${sourceConfig.wsStatus || 'idle'}">${sourceConfig.wsStatus || 'idle'}</span>${sourceConfig.wsStatusDetail ? ` · ${sourceConfig.wsStatusDetail}` : ''}`
+ : '';
+ }
+ });
}
syncConfigPanel(state) {
- this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]').value = String(state.plot.windowDurationMs);
- this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]').value = String(state.plot.maxPoints);
- this.elements.configPanel.querySelector('[data-plot-field="showGrid"]').checked = state.plot.showGrid;
- this.elements.configPanel.querySelector('[data-plot-field="showPoints"]').checked = state.plot.showPoints;
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]'), state.plot.windowDurationMs);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]'), state.plot.maxPoints);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showGrid"]'), state.plot.showGrid);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showPoints"]'), state.plot.showPoints);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="sourceKey"]'), state.graphs.primary.sourceKey);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="transform"]'), state.graphs.primary.transform);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="sourceKey"]'), state.graphs.secondary.sourceKey);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="transform"]'), state.graphs.secondary.transform);
}
syncPanels(state) {
@@ -270,18 +515,28 @@ export class PanelManager {
syncTooltip(state) {
const tooltipState = state.plot.tooltip;
- this.elements.tooltip.hidden = !tooltipState.visible || !tooltipState.point;
- if (this.elements.tooltip.hidden) {
+ this.elements.primaryTooltip.hidden = true;
+ this.elements.secondaryTooltip.hidden = true;
+
+ if (!tooltipState.visible || !tooltipState.point) {
return;
}
- this.elements.tooltip.style.left = `${tooltipState.x}px`;
- this.elements.tooltip.style.top = `${tooltipState.y}px`;
- this.elements.tooltip.innerHTML = `
+ const tooltip = tooltipState.panelId === 'secondary'
+ ? this.elements.secondaryTooltip
+ : this.elements.primaryTooltip;
+
+ tooltip.hidden = false;
+ tooltip.style.left = `${tooltipState.x}px`;
+ tooltip.style.top = `${tooltipState.y}px`;
+ tooltip.innerHTML = `
<div class="timeplot-tooltip-title">Hovered sample</div>
+ <div class="timeplot-tooltip-row"><span class="muted">Panel</span><span>${tooltipState.panelLabel ?? 'Primary'}</span></div>
<div class="timeplot-tooltip-row"><span class="muted">Plot time</span><span>${formatDuration(tooltipState.point.timeMs)}</span></div>
<div class="timeplot-tooltip-row"><span class="muted">Value</span><span>${formatValue(tooltipState.point.value)}</span></div>
<div class="timeplot-tooltip-row"><span class="muted">Source</span><span>${tooltipState.point.sourceId}</span></div>
+ ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked panel</span><span>${tooltipState.linkedPanelLabel ?? 'Linked'}</span></div>` : ''}
+ ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked value</span><span>${formatValue(tooltipState.linkedPoint.value)}</span></div>` : ''}
`;
}
}