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 = `

Status

Renderer
Real time
Real elapsed
Plot time
Playback
Points
`; return panel; } createSourcePanel() { const panel = createElement('section', 'panel'); panel.innerHTML = `

Data Source

Signal A
Signal B
`; 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 = `

Config

Show grid
Show points
Graph routing
`; 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 = `

Help

  1. Each signal can be synthetic or file-backed CSV replay.
  2. Each graph can target Signal A or Signal B independently.
  3. Each graph can render raw, delta, or smoothed data.
  4. Hover either trace to inspect the nearest synchronized sample.
  5. Use pause and speed controls to inspect timing behavior.
`; 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 ? `${sourceConfig.loadError}` : `${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: ${sourceConfig.wsStatus || 'idle'}${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 = `
Hovered sample
Panel${tooltipState.panelLabel ?? 'Primary'}
Plot time${formatDuration(tooltipState.point.timeMs)}
Value${formatValue(tooltipState.point.value)}
Source${tooltipState.point.sourceId}
${tooltipState.linkedPoint ? `
Linked panel${tooltipState.linkedPanelLabel ?? 'Linked'}
` : ''} ${tooltipState.linkedPoint ? `
Linked value${formatValue(tooltipState.linkedPoint.value)}
` : ''} `; } }