diff options
Diffstat (limited to 'web-timeplot/src/ui/panel-manager.js')
| -rw-r--r-- | web-timeplot/src/ui/panel-manager.js | 542 |
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>` : ''} - `; - } -} |
