diff options
Diffstat (limited to 'web-timeplot/src/ui')
| -rw-r--r-- | web-timeplot/src/ui/panel-manager.js | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js new file mode 100644 index 0000000..8a1b216 --- /dev/null +++ b/web-timeplot/src/ui/panel-manager.js @@ -0,0 +1,287 @@ +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); +} + +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 canvasHost = createElement('div', 'timeplot-canvas-host'); + const sidebar = createElement('aside', 'timeplot-sidebar'); + const tooltip = createElement('div', 'timeplot-tooltip'); + tooltip.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'); + brand.append(title, subtitle); + + const toolbar = createElement('div', 'timeplot-toolbar'); + toolbar.append( + this.createTransportControls(), + this.createPanelToggles(), + ); + + topbar.append(brand, toolbar); + viewport.append(canvasHost, tooltip); + shell.append(topbar, viewport, sidebar); + this.root.replaceChildren(shell); + + this.elements = { + ...this.elements, + shell, + topbar, + viewport, + canvasHost, + sidebar, + tooltip, + 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="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> + `; + + panel.querySelectorAll('[data-source-field]').forEach((input) => { + input.addEventListener('change', () => { + const field = input.getAttribute('data-source-field'); + const rawValue = input.value; + const value = input.tagName === 'SELECT' ? rawValue : Number(rawValue); + this.actions.updateSource(field, 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> + `; + + panel.querySelectorAll('[data-plot-field]').forEach((input) => { + input.addEventListener('change', () => { + const field = input.getAttribute('data-plot-field'); + const value = input.type === 'checkbox' ? input.checked : Number(input.value); + this.actions.updatePlot(field, value); + }); + }); + + return panel; + } + + createHelpPanel() { + const panel = createElement('section', 'panel'); + 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> + </ol> + `; + return panel; + } + + sync(state, visiblePoints) { + this.elements.title.textContent = state.app.title; + this.elements.subtitle.textContent = 'Synthetic time-series workspace with modular systems'; + this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause'; + this.elements.speedInput.value = String(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 = `${visiblePoints}`; + + this.syncSourcePanel(state); + this.syncConfigPanel(state); + this.syncPanels(state); + this.syncTooltip(state); + } + + 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); + } + + 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; + } + + 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.tooltip.hidden = !tooltipState.visible || !tooltipState.point; + if (this.elements.tooltip.hidden) { + return; + } + + this.elements.tooltip.style.left = `${tooltipState.x}px`; + this.elements.tooltip.style.top = `${tooltipState.y}px`; + this.elements.tooltip.innerHTML = ` + <div class="timeplot-tooltip-title">Hovered sample</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> + `; + } +} |
