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.js287
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>
+ `;
+ }
+}