diff options
| author | grothedev <grothedev@gmail.com> | 2026-05-29 21:34:16 -0400 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2026-05-29 21:34:16 -0400 |
| commit | 27dc5849c3eaf4824d79938e7077abdbe2c82e24 (patch) | |
| tree | 4a6e963d291132ad6f5a22841ea2404b60949366 /web-timeplot/src/ui/panel-manager.js | |
| parent | 73d75835e18a33c7f6c1b09bbcef93b16a7a9bfa (diff) | |
updates from claude. need to review. archiving rust and cpp stuff, going completely TS
Diffstat (limited to 'web-timeplot/src/ui/panel-manager.js')
| -rw-r--r-- | web-timeplot/src/ui/panel-manager.js | 363 |
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>` : ''} `; } } |
