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
`;
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
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
- Each signal can be synthetic or file-backed CSV replay.
- Each graph can target Signal A or Signal B independently.
- Each graph can render raw, delta, or smoothed data.
- Hover either trace to inspect the nearest synchronized sample.
- 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)}
` : ''}
`;
}
}