summaryrefslogtreecommitdiff
path: root/web-timeplot/src/ui/panel-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src/ui/panel-manager.js')
-rw-r--r--web-timeplot/src/ui/panel-manager.js542
1 files changed, 0 insertions, 542 deletions
diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js
deleted file mode 100644
index ad29697..0000000
--- a/web-timeplot/src/ui/panel-manager.js
+++ /dev/null
@@ -1,542 +0,0 @@
-import { formatDuration, formatValue, formatWallClock } from '../utils-format.js';
-
-function createElement(tagName, className, textContent) {
- const element = document.createElement(tagName);
- if (className) {
- element.className = className;
- }
- if (textContent) {
- element.textContent = textContent;
- }
- return element;
-}
-
-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;
- this.store = store;
- this.actions = actions;
- this.elements = {};
- }
-
- mount() {
- const shell = createElement('div', 'timeplot-shell');
- const topbar = createElement('header', 'timeplot-topbar');
- const viewport = createElement('section', 'timeplot-viewport');
- 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 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', 'Dual synchronized signal monitor');
- brand.append(title, subtitle);
-
- const toolbar = createElement('div', 'timeplot-toolbar');
- toolbar.append(
- this.createTransportControls(),
- this.createPanelToggles(),
- );
-
- topbar.append(brand, toolbar);
- primaryPlotPanel.append(primaryCanvasHost, primaryTooltip);
- secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip);
- plotGrid.append(primaryPlotPanel, secondaryPlotPanel);
- viewport.append(plotGrid);
- shell.append(topbar, viewport, sidebar);
- this.root.replaceChildren(shell);
-
- this.elements = {
- ...this.elements,
- shell,
- topbar,
- viewport,
- plotGrid,
- primaryPlotPanel,
- secondaryPlotPanel,
- primaryCanvasHost,
- secondaryCanvasHost,
- sidebar,
- primaryTooltip,
- secondaryTooltip,
- title,
- subtitle,
- statusPanel: this.createStatusPanel(),
- sourcePanel: this.createSourcePanel(),
- configPanel: this.createConfigPanel(),
- helpPanel: this.createHelpPanel(),
- };
-
- sidebar.append(
- this.elements.statusPanel,
- this.elements.sourcePanel,
- this.elements.configPanel,
- this.elements.helpPanel,
- );
-
- return this.elements;
- }
-
- createTransportControls() {
- const wrapper = createElement('div', 'control-group');
- const pauseButton = createElement('button', 'control-button', 'Pause');
- const resetButton = createElement('button', 'control-button', 'Reset');
- const speedLabel = createElement('span', null, 'Speed');
- const speedInput = document.createElement('input');
- speedInput.type = 'range';
- speedInput.min = '0.1';
- speedInput.max = '6';
- speedInput.step = '0.1';
- const speedValue = createElement('span', null, '1.0×');
-
- pauseButton.addEventListener('click', () => this.actions.togglePause());
- resetButton.addEventListener('click', () => this.actions.resetScene());
- speedInput.addEventListener('input', (event) => this.actions.setSpeed(Number(event.target.value)));
-
- wrapper.append(pauseButton, resetButton, speedLabel, speedInput, speedValue);
- this.elements.pauseButton = pauseButton;
- this.elements.resetButton = resetButton;
- this.elements.speedInput = speedInput;
- this.elements.speedValue = speedValue;
- return wrapper;
- }
-
- createPanelToggles() {
- const wrapper = createElement('div', 'control-group');
- const panelIds = ['status', 'source', 'config', 'help'];
- this.elements.panelButtons = {};
-
- for (const panelId of panelIds) {
- const button = createElement('button', 'panel-toggle', panelId);
- button.addEventListener('click', () => this.actions.togglePanel(panelId));
- this.elements.panelButtons[panelId] = button;
- wrapper.append(button);
- }
-
- return wrapper;
- }
-
- createStatusPanel() {
- const panel = createElement('section', 'panel');
- panel.innerHTML = `
- <h2>Status</h2>
- <dl class="kv-list">
- <dt>Renderer</dt><dd data-field="renderer">—</dd>
- <dt>Real time</dt><dd data-field="realTime">—</dd>
- <dt>Real elapsed</dt><dd data-field="realElapsed">—</dd>
- <dt>Plot time</dt><dd data-field="plotTime">—</dd>
- <dt>Playback</dt><dd data-field="playback">—</dd>
- <dt>Points</dt><dd data-field="points">—</dd>
- </dl>
- `;
- return panel;
- }
-
- createSourcePanel() {
- const panel = createElement('section', 'panel');
- panel.innerHTML = `
- <h2>Data Source</h2>
- <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) => {
- 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 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 = '';
- });
- });
-
- return panel;
- }
-
- createConfigPanel() {
- const panel = createElement('section', 'panel');
- panel.innerHTML = `
- <h2>Config</h2>
- <div class="field-grid">
- <label>
- Visible window (ms)
- <input data-plot-field="windowDurationMs" type="number" min="2000" max="120000" step="1000" />
- </label>
- <label>
- Max points
- <input data-plot-field="maxPoints" type="number" min="200" max="4000" step="100" />
- </label>
- <div class="panel-row">
- <span>Show grid</span>
- <input data-plot-field="showGrid" type="checkbox" />
- </div>
- <div class="panel-row">
- <span>Show points</span>
- <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) => {
- const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input';
- input.addEventListener(eventName, () => {
- const field = input.getAttribute('data-plot-field');
- 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;
- }
-
- createHelpPanel() {
- const panel = createElement('section', 'panel');
- panel.innerHTML = `
- <h2>Help</h2>
- <ol class="help-list">
- <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;
- }
-
- sync(state, visiblePoints) {
- this.elements.title.textContent = state.app.title;
- this.elements.subtitle.textContent = 'Dual synchronized signal monitor';
- this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause';
- 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]');
- const fieldMap = Object.fromEntries(Array.from(statusFields).map((field) => [field.getAttribute('data-field'), field]));
- fieldMap.renderer.textContent = state.app.renderer;
- fieldMap.realTime.textContent = formatWallClock(state.time.realNowMs);
- 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 = typeof visiblePoints === 'object'
- ? `${visiblePoints.primary} / ${visiblePoints.secondary}`
- : `${visiblePoints}`;
-
- this.syncSourcePanel(state);
- this.syncConfigPanel(state);
- this.syncPanels(state);
- this.syncTooltip(state);
- }
-
- syncSourcePanel(state) {
- 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) {
- 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) {
- const panelMap = {
- status: this.elements.statusPanel,
- source: this.elements.sourcePanel,
- config: this.elements.configPanel,
- help: this.elements.helpPanel,
- };
-
- for (const [panelId, panelState] of Object.entries(state.panels)) {
- panelMap[panelId].hidden = !panelState.visible;
- setToggleState(this.elements.panelButtons[panelId], panelState.visible);
- }
- }
-
- syncTooltip(state) {
- const tooltipState = state.plot.tooltip;
- this.elements.primaryTooltip.hidden = true;
- this.elements.secondaryTooltip.hidden = true;
-
- if (!tooltipState.visible || !tooltipState.point) {
- return;
- }
-
- 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>` : ''}
- `;
- }
-}