diff options
Diffstat (limited to 'web-timeplot/src')
23 files changed, 1378 insertions, 3258 deletions
diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js new file mode 100644 index 0000000..daf3559 --- /dev/null +++ b/web-timeplot/src/app/create-app.js @@ -0,0 +1,152 @@ +import { EventBus } from '../core/event-bus.js'; +import { Store, createInitialState } from '../core/store.js'; +import { TimeController } from '../core/time-controller.js'; +import { PlotBuffer } from '../plot/plot-buffer.js'; +import { TimeplotView } from '../plot/timeplot-view.js'; +import { SourceRegistry } from '../data/source-registry.js'; +import { PanelManager } from '../ui/panel-manager.js'; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +export async function createApp(root) { + const bus = new EventBus(); + const store = new Store(createInitialState()); + const timeController = new TimeController(store); + const buffer = new PlotBuffer(store.getState().plot.maxPoints); + let sourceRegistry; + + const actions = { + togglePause: () => timeController.togglePause(), + setSpeed: (speed) => timeController.setSpeed(speed), + resetScene: () => { + timeController.reset(); + buffer.clear(); + sourceRegistry.reset(); + }, + togglePanel: (panelId) => { + store.setState((state) => ({ + ...state, + panels: { + ...state.panels, + [panelId]: { + ...state.panels[panelId], + visible: !state.panels[panelId].visible, + }, + }, + })); + }, + updateSource: (field, value) => { + store.setState((state) => ({ + ...state, + source: { + ...state.source, + [field]: value, + }, + })); + sourceRegistry.syncFromState(); + }, + updatePlot: (field, value) => { + store.setState((state) => ({ + ...state, + plot: { + ...state.plot, + [field]: value, + }, + })); + + if (field === 'maxPoints') { + buffer.maxPoints = clamp(value, 200, 4000); + } + }, + }; + + const panelManager = new PanelManager({ root, store, actions }); + const elements = panelManager.mount(); + + const plotView = new TimeplotView({ + host: elements.canvasHost, + onHover: (hoverState) => { + store.setState((state) => ({ + ...state, + plot: { + ...state.plot, + hoveredPoint: hoverState?.point ?? null, + tooltip: hoverState + ? { + visible: true, + x: hoverState.x, + y: hoverState.y, + point: hoverState.point, + } + : { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + }, + }); + + const renderer = await plotView.init(); + store.patch({ + app: { + ...store.getState().app, + renderer, + }, + }); + + sourceRegistry = new SourceRegistry(store, bus); + + bus.on('data:point', (point) => { + buffer.addPoint(point); + }); + + const keyHandler = (event) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) { + return; + } + + if (event.code === 'Space') { + event.preventDefault(); + actions.togglePause(); + return; + } + + if (event.key === '[') { + actions.setSpeed(store.getState().time.speed - 0.1); + return; + } + + if (event.key === ']') { + actions.setSpeed(store.getState().time.speed + 0.1); + return; + } + + if (event.key.toLowerCase() === 'g') { + actions.updatePlot('showGrid', !store.getState().plot.showGrid); + } + }; + + window.addEventListener('keydown', keyHandler); + + plotView.app.ticker.add(() => { + timeController.tick(); + sourceRegistry.syncFromState(); + sourceRegistry.update(store.getState().time.plotTimeMs); + + const state = store.getState(); + const visiblePoints = buffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs); + plotView.render(state, visiblePoints); + panelManager.sync(state, visiblePoints.length); + }); + + return { + destroy() { + window.removeEventListener('keydown', keyHandler); + plotView.destroy(); + }, + }; +} diff --git a/web-timeplot/src/bootstrap.js b/web-timeplot/src/bootstrap.js new file mode 100644 index 0000000..4b073bc --- /dev/null +++ b/web-timeplot/src/bootstrap.js @@ -0,0 +1,18 @@ +import './styles.css'; +import { createApp } from './app/create-app.js'; + +const root = document.getElementById('app'); + +if (!root) { + throw new Error('App root not found'); +} + +createApp(root).catch((error) => { + console.error('Failed to start TimePlot', error); + root.innerHTML = ` + <div style="padding: 24px; color: #fff; font-family: sans-serif;"> + <h1>TimePlot failed to start</h1> + <pre>${String(error)}</pre> + </div> + `; +}); diff --git a/web-timeplot/src/core/event-bus.js b/web-timeplot/src/core/event-bus.js new file mode 100644 index 0000000..192eb6d --- /dev/null +++ b/web-timeplot/src/core/event-bus.js @@ -0,0 +1,32 @@ +export class EventBus { + constructor() { + this.listeners = new Map(); + } + + on(eventName, listener) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const listeners = this.listeners.get(eventName); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, payload) { + const listeners = this.listeners.get(eventName); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(payload); + } + } +} diff --git a/web-timeplot/src/core/store.js b/web-timeplot/src/core/store.js new file mode 100644 index 0000000..9989e5f --- /dev/null +++ b/web-timeplot/src/core/store.js @@ -0,0 +1,95 @@ +function clonePanelState(panels) { + return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }])); +} + +export function createInitialState() { + return { + app: { + title: 'TimePlot', + renderer: 'pending', + }, + time: { + realNowMs: Date.now(), + realElapsedMs: 0, + plotTimeMs: 0, + speed: 1, + paused: false, + }, + plot: { + showGrid: true, + showPoints: true, + windowDurationMs: 20000, + maxPoints: 1600, + valueRange: { + min: -1.6, + max: 1.6, + }, + hoveredPoint: null, + tooltip: { + visible: false, + x: 0, + y: 0, + point: null, + }, + }, + source: { + activeId: 'synthetic-wave', + preset: 'telemetry', + sampleRateHz: 60, + amplitude: 1, + noise: 0.08, + }, + panels: { + status: { title: 'Status', visible: true }, + source: { title: 'Data Source', visible: true }, + config: { title: 'Config', visible: true }, + help: { title: 'Help', visible: false }, + }, + }; +} + +export class Store { + constructor(initialState = createInitialState()) { + this.state = initialState; + this.listeners = new Set(); + } + + getState() { + return this.state; + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setState(updater) { + const nextState = typeof updater === 'function' ? updater(this.state) : updater; + this.state = nextState; + for (const listener of this.listeners) { + listener(this.state); + } + } + + patch(partial) { + this.setState((state) => ({ + ...state, + ...partial, + time: partial.time ? { ...state.time, ...partial.time } : state.time, + plot: partial.plot + ? { + ...state.plot, + ...partial.plot, + valueRange: partial.plot.valueRange + ? { ...state.plot.valueRange, ...partial.plot.valueRange } + : state.plot.valueRange, + tooltip: partial.plot.tooltip + ? { ...state.plot.tooltip, ...partial.plot.tooltip } + : state.plot.tooltip, + } + : state.plot, + source: partial.source ? { ...state.source, ...partial.source } : state.source, + panels: partial.panels ? clonePanelState({ ...state.panels, ...partial.panels }) : state.panels, + })); + } +} diff --git a/web-timeplot/src/core/time-controller.js b/web-timeplot/src/core/time-controller.js new file mode 100644 index 0000000..7cd57c7 --- /dev/null +++ b/web-timeplot/src/core/time-controller.js @@ -0,0 +1,80 @@ +export class TimeController { + constructor(store) { + this.store = store; + this.lastFrameTime = performance.now(); + } + + tick(now = performance.now()) { + const deltaMs = now - this.lastFrameTime; + this.lastFrameTime = now; + + this.store.setState((state) => { + const realElapsedMs = state.time.realElapsedMs + deltaMs; + const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed; + + return { + ...state, + time: { + ...state.time, + realNowMs: Date.now(), + realElapsedMs, + plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs), + }, + }; + }); + + return deltaMs; + } + + togglePause() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused: !state.time.paused, + }, + })); + } + + setPaused(paused) { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused, + }, + })); + } + + setSpeed(speed) { + const clampedSpeed = Math.max(0.1, Math.min(12, speed)); + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + speed: clampedSpeed, + }, + })); + } + + reset() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + realElapsedMs: 0, + plotTimeMs: 0, + }, + plot: { + ...state.plot, + hoveredPoint: null, + tooltip: { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + this.lastFrameTime = performance.now(); + } +} diff --git a/web-timeplot/src/data-sources.js b/web-timeplot/src/data-sources.js deleted file mode 100644 index 749a151..0000000 --- a/web-timeplot/src/data-sources.js +++ /dev/null @@ -1,517 +0,0 @@ -/** - * Data Sources - Components that generate or provide data to plots - * - * This module implements the data provider side of the architecture. - * Data sources know how to generate or fetch data, but don't know - * anything about visualization. - * - * Architecture: - * - DataSource: Base class with event emitting - * - Specific sources: Implement different data generation strategies - * - Connection: Links sources to plots (see plot-connections.js) - */ - -// Simple EventEmitter (same as in state.js, could be extracted to utils) -class EventEmitter { - constructor() { - this.events = new Map(); - } - - on(event, callback) { - if (!this.events.has(event)) { - this.events.set(event, []); - } - this.events.get(event).push(callback); - return () => this.off(event, callback); - } - - off(event, callback) { - if (!this.events.has(event)) return; - const callbacks = this.events.get(event); - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - } - - emit(event, data) { - if (!this.events.has(event)) return; - this.events.get(event).forEach(callback => { - try { - callback(data); - } catch (e) { - console.error(`[DataSource] Error in event handler for '${event}':`, e); - } - }); - } -} - -/** - * Base class for all data sources - * - * Events emitted: - * - 'line': {points: Array, timestamp: number, metadata: Object} - * - 'point': {value: number, timestamp: number} - * - 'error': {error: Error} - */ -export class DataSource extends EventEmitter { - constructor(config = {}) { - super(); - this.config = config; - this.isRunning = false; - this.time = 0; - } - - /** - * Start generating/providing data - */ - start() { - this.isRunning = true; - } - - /** - * Stop generating/providing data - */ - stop() { - this.isRunning = false; - } - - /** - * Reset the data source to initial state - */ - reset() { - this.time = 0; - } - - /** - * Emit a complete line of data - */ - emitLine(points, metadata = {}) { - this.emit('line', { - points, - timestamp: metadata.timestamp || Date.now(), - metadata, - }); - } - - /** - * Emit a single data point - */ - emitPoint(value, timestamp = Date.now()) { - this.emit('point', { - value, - timestamp, - }); - } - - /** - * Emit an error - */ - emitError(error) { - this.emit('error', { error }); - } -} - -/** - * Synthetic data source using test generators - * Uses the generators from test-data-generators.js - */ -export class SyntheticDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.generator = config.generator; // Instance of DataGenerator - this.pointsPerLine = config.pointsPerLine || 100; - this.width = config.width || 800; - this.lineInterval = config.lineInterval || 100; // ms between lines - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - // Generate a new line periodically - this.intervalHandle = setInterval(() => { - this.generateAndEmitLine(); - }, this.lineInterval); - - // Generate initial line immediately - this.generateAndEmitLine(); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitLine() { - if (!this.generator) { - this.emitError(new Error('No generator configured')); - return; - } - - const points = this.generator.generateLine(this.pointsPerLine, this.width); - this.emitLine(points, { - timestamp: Date.now(), - generatorType: this.generator.constructor.name, - }); - } - - setGenerator(generator) { - this.generator = generator; - } -} - -/** - * Function-based data source - * Evaluates a user-provided function to generate data - */ -export class FunctionDataSource extends DataSource { - constructor(config = {}) { - super(config); - // Function should have signature: (x, t) => y - // x: normalized position 0-1 - // t: time in seconds - // returns: y value - this.func = config.func || ((x, t) => Math.sin(x * 10 + t)); - this.pointsPerLine = config.pointsPerLine || 100; - this.width = config.width || 800; - this.amplitude = config.amplitude || 30; - this.lineInterval = config.lineInterval || 100; - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - this.intervalHandle = setInterval(() => { - this.generateAndEmitLine(); - }, this.lineInterval); - - this.generateAndEmitLine(); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitLine() { - const points = []; - const t = this.time; - - for (let i = 0; i < this.pointsPerLine; i++) { - const x = (i / this.pointsPerLine) * this.width; - const normalizedX = i / this.pointsPerLine; - const y = this.func(normalizedX, t) * this.amplitude; - points.push({ x, y }); - } - - this.emitLine(points, { - timestamp: Date.now(), - time: t, - }); - - this.time += this.lineInterval / 1000; - } - - setFunction(func) { - this.func = func; - } -} - -/** - * Streaming data source - * Emits individual data points that get buffered into lines - */ -export class StreamingDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.generator = config.generator; - this.sampleRate = config.sampleRate || 60; // Samples per second - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - const intervalMs = 1000 / this.sampleRate; - this.intervalHandle = setInterval(() => { - this.generateAndEmitPoint(); - }, intervalMs); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitPoint() { - if (!this.generator) { - this.emitError(new Error('No generator configured')); - return; - } - - const value = this.generator.sample(); - this.generator.time += 1 / this.generator.sampleRate; - this.emitPoint(value, Date.now()); - } - - setGenerator(generator) { - this.generator = generator; - } -} - -/** - * WebSocket data source (for real data) - * Receives data from a WebSocket connection - */ -export class WebSocketDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.url = config.url; - this.socket = null; - this.reconnectInterval = config.reconnectInterval || 5000; - this.reconnectHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - this.connect(); - } - - stop() { - super.stop(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - if (this.reconnectHandle) { - clearTimeout(this.reconnectHandle); - this.reconnectHandle = null; - } - } - - connect() { - try { - this.socket = new WebSocket(this.url); - - this.socket.onopen = () => { - console.log(`[WebSocketDataSource] Connected to ${this.url}`); - }; - - this.socket.onmessage = (event) => { - this.handleMessage(event.data); - }; - - this.socket.onerror = (error) => { - console.error('[WebSocketDataSource] Error:', error); - this.emitError(error); - }; - - this.socket.onclose = () => { - console.log('[WebSocketDataSource] Connection closed'); - if (this.isRunning) { - // Auto-reconnect - this.reconnectHandle = setTimeout(() => { - this.connect(); - }, this.reconnectInterval); - } - }; - } catch (error) { - console.error('[WebSocketDataSource] Failed to connect:', error); - this.emitError(error); - } - } - - handleMessage(data) { - try { - const parsed = JSON.parse(data); - - // Expect format: {type: 'line', points: [...]} or {type: 'point', value: ...} - if (parsed.type === 'line' && parsed.points) { - this.emitLine(parsed.points, parsed.metadata || {}); - } else if (parsed.type === 'point' && parsed.value !== undefined) { - this.emitPoint(parsed.value, parsed.timestamp); - } else { - console.warn('[WebSocketDataSource] Unknown message format:', parsed); - } - } catch (error) { - console.error('[WebSocketDataSource] Failed to parse message:', error); - this.emitError(error); - } - } - - send(data) { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify(data)); - } - } -} - -/** - * CSV File data source - * Reads data from CSV files (for replay/analysis) - */ -export class CSVDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.data = []; // Parsed CSV data - this.currentIndex = 0; - this.playbackRate = config.playbackRate || 1.0; - this.loop = config.loop || false; - this.intervalHandle = null; - } - - /** - * Load CSV data from a string - * Expected format: timestamp,value or x,y format - */ - loadCSV(csvString) { - const lines = csvString.trim().split('\n'); - const headers = lines[0].split(',').map(h => h.trim()); - - this.data = []; - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(',').map(v => parseFloat(v.trim())); - if (values.length >= 2 && !values.some(isNaN)) { - this.data.push({ - timestamp: values[0], - value: values[1], - }); - } - } - - console.log(`[CSVDataSource] Loaded ${this.data.length} data points`); - } - - start() { - if (this.isRunning || this.data.length === 0) return; - super.start(); - - // Play back at specified rate - this.intervalHandle = setInterval(() => { - this.emitNextPoint(); - }, 16 / this.playbackRate); // ~60fps adjusted by playback rate - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - reset() { - super.reset(); - this.currentIndex = 0; - } - - emitNextPoint() { - if (this.currentIndex >= this.data.length) { - if (this.loop) { - this.currentIndex = 0; - } else { - this.stop(); - return; - } - } - - const point = this.data[this.currentIndex]; - this.emitPoint(point.value, point.timestamp); - this.currentIndex++; - } -} - -/** - * Multi-source combiner - * Combines data from multiple sources - */ -export class CompositeDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.sources = config.sources || []; - this.combineMode = config.combineMode || 'average'; // 'average', 'sum', 'max', 'min' - this.pointBuffer = new Map(); // sourceId => latest point - } - - start() { - if (this.isRunning) return; - super.start(); - - // Subscribe to all sources - this.sources.forEach((source, idx) => { - source.on('point', (data) => { - this.handleSourcePoint(idx, data); - }); - source.on('line', (data) => { - this.handleSourceLine(idx, data); - }); - source.start(); - }); - } - - stop() { - super.stop(); - this.sources.forEach(source => source.stop()); - } - - handleSourcePoint(sourceIdx, data) { - this.pointBuffer.set(sourceIdx, data.value); - - // If we have data from all sources, combine and emit - if (this.pointBuffer.size === this.sources.length) { - const combined = this.combineValues(Array.from(this.pointBuffer.values())); - this.emitPoint(combined, data.timestamp); - } - } - - handleSourceLine(sourceIdx, data) { - // For lines, just pass through for now - // Could implement line combination if needed - this.emitLine(data.points, data.metadata); - } - - combineValues(values) { - switch (this.combineMode) { - case 'sum': - return values.reduce((a, b) => a + b, 0); - case 'average': - return values.reduce((a, b) => a + b, 0) / values.length; - case 'max': - return Math.max(...values); - case 'min': - return Math.min(...values); - default: - return values[0]; - } - } - - addSource(source) { - this.sources.push(source); - if (this.isRunning) { - source.start(); - } - } - - removeSource(source) { - const idx = this.sources.indexOf(source); - if (idx > -1) { - source.stop(); - this.sources.splice(idx, 1); - } - } -} diff --git a/web-timeplot/src/data/base-source.js b/web-timeplot/src/data/base-source.js new file mode 100644 index 0000000..55dbdc3 --- /dev/null +++ b/web-timeplot/src/data/base-source.js @@ -0,0 +1,21 @@ +export class BaseSource { + constructor(config = {}) { + this.config = { ...config }; + this.running = false; + } + + start() { + this.running = true; + } + + stop() { + this.running = false; + } + + updateConfig(nextConfig) { + this.config = { + ...this.config, + ...nextConfig, + }; + } +} diff --git a/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js new file mode 100644 index 0000000..06f5895 --- /dev/null +++ b/web-timeplot/src/data/source-registry.js @@ -0,0 +1,41 @@ +import { SyntheticWaveSource } from './synthetic-wave-source.js'; + +export class SourceRegistry { + constructor(store, bus) { + this.store = store; + this.bus = bus; + this.sources = new Map([ + ['synthetic-wave', new SyntheticWaveSource(store.getState().source)], + ]); + this.activeSource = this.sources.get(store.getState().source.activeId); + this.activeSource.start(store.getState().time.plotTimeMs); + } + + syncFromState() { + const state = this.store.getState(); + const nextSource = this.sources.get(state.source.activeId); + + if (nextSource !== this.activeSource) { + this.activeSource?.stop(); + this.activeSource = nextSource; + this.activeSource?.start(state.time.plotTimeMs); + } + + this.activeSource?.updateConfig(state.source); + } + + update(currentPlotTimeMs) { + if (!this.activeSource) { + return; + } + + const points = this.activeSource.update(currentPlotTimeMs); + for (const point of points) { + this.bus.emit('data:point', point); + } + } + + reset() { + this.activeSource?.reset(this.store.getState().time.plotTimeMs); + } +} diff --git a/web-timeplot/src/data/synthetic-wave-source.js b/web-timeplot/src/data/synthetic-wave-source.js new file mode 100644 index 0000000..3cf7fb1 --- /dev/null +++ b/web-timeplot/src/data/synthetic-wave-source.js @@ -0,0 +1,86 @@ +import { BaseSource } from './base-source.js'; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function createDeterministicNoise(seed) { + const x = Math.sin(seed * 12.9898) * 43758.5453; + return x - Math.floor(x); +} + +export class SyntheticWaveSource extends BaseSource { + constructor(config = {}) { + super({ + sampleRateHz: 60, + preset: 'telemetry', + amplitude: 1, + noise: 0.08, + ...config, + }); + this.lastEmittedPlotTimeMs = 0; + } + + start(startTimeMs = 0) { + super.start(); + this.lastEmittedPlotTimeMs = startTimeMs; + } + + stop() { + super.stop(); + } + + reset(startTimeMs = 0) { + this.lastEmittedPlotTimeMs = startTimeMs; + } + + sampleValue(timeMs) { + const seconds = timeMs / 1000; + const amplitude = this.config.amplitude; + const noise = this.config.noise; + const grain = (createDeterministicNoise(timeMs * 0.017) - 0.5) * 2 * noise; + + switch (this.config.preset) { + case 'chirp': { + const sweep = Math.sin(seconds * seconds * 1.4); + return amplitude * (0.7 * sweep + 0.3 * Math.sin(seconds * 7.5)) + grain; + } + case 'burst': { + const burstPhase = (seconds % 6) - 1.5; + const burst = Math.sin(seconds * 9.5) * Math.exp(-(burstPhase ** 2) * 0.8); + return amplitude * (0.45 * Math.sin(seconds * 2.1) + burst) + grain; + } + case 'telemetry': + default: { + const carrier = Math.sin(seconds * 2.2); + const secondary = 0.35 * Math.cos(seconds * 6.4 + Math.sin(seconds * 0.8)); + const envelope = 0.15 * Math.sin(seconds * 0.33); + return amplitude * (carrier + secondary + envelope) + grain; + } + } + } + + update(currentPlotTimeMs) { + if (!this.running) { + return []; + } + + const intervalMs = 1000 / clamp(this.config.sampleRateHz, 1, 240); + if (currentPlotTimeMs < this.lastEmittedPlotTimeMs) { + this.lastEmittedPlotTimeMs = currentPlotTimeMs; + return []; + } + + const points = []; + while (this.lastEmittedPlotTimeMs + intervalMs <= currentPlotTimeMs) { + this.lastEmittedPlotTimeMs += intervalMs; + points.push({ + timeMs: this.lastEmittedPlotTimeMs, + value: this.sampleValue(this.lastEmittedPlotTimeMs), + sourceId: 'synthetic-wave', + }); + } + + return points; + } +} diff --git a/web-timeplot/src/example-usage.js b/web-timeplot/src/example-usage.js deleted file mode 100644 index 67eff4b..0000000 --- a/web-timeplot/src/example-usage.js +++ /dev/null @@ -1,535 +0,0 @@ -/** - * Example Usage: Complete examples of the new architecture - * - * This file demonstrates how to use the separated data/visualization architecture: - * - TimeSeriesPlot: Pure visualization - * - DataSource: Data generation/provision - * - Connections: Links between them - */ - -import { Application } from 'pixi.js'; -import { TimeSeriesPlot } from './timeseries-plot.js'; -import { - SyntheticDataSource, - FunctionDataSource, - StreamingDataSource, - WebSocketDataSource, -} from './data-sources.js'; -import { - DirectConnection, - BufferedConnection, - ConnectionManager, - connectSyntheticData, - connectFunction, - createConnectedPlot, -} from './plot-connections.js'; -import { - TestDataFactory, - SineWaveGenerator, - PerlinNoiseGenerator, - ChirpGenerator, -} from './test-data-generators.js'; - -// ============================================================================ -// Example 1: Simple Setup - One plot, one data source -// ============================================================================ - -export async function example1_SimpleSetup() { - console.log('=== Example 1: Simple Setup ==='); - - // Create PixiJS app - const app = new Application(); - await app.init({ - width: 800, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // Create plot (visualization only) - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Simple Sine Wave', - showGrid: true, - }); - app.stage.addChild(plot.container); - - // Create data source - const generator = TestDataFactory.createSimpleSine(30); - const source = new SyntheticDataSource({ - generator: generator, - pointsPerLine: 100, - width: 800, - lineInterval: 100, // New line every 100ms - }); - - // Connect source to plot - const connection = new DirectConnection(source, plot); - connection.connect(); - - // Update plot every frame - app.ticker.add(() => { - plot.update(); - }); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 2: Quick Setup Using Helper Functions -// ============================================================================ - -export async function example2_QuickSetup() { - console.log('=== Example 2: Quick Setup ==='); - - const app = new Application(); - await app.init({ - width: 800, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // One-liner setup! - const { plot, source, connection } = createConnectedPlot( - app, - { - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Quick Setup', - }, - { - generator: TestDataFactory.createComplexPattern(30), - lineInterval: 100, - } - ); - - app.ticker.add(() => plot.update()); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 3: Multiple Plots with Different Data Sources -// ============================================================================ - -export async function example3_MultiplePlots() { - console.log('=== Example 3: Multiple Plots ==='); - - const app = new Application(); - await app.init({ - width: 1600, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - const width = 800; - const height = 600; - - // Left plot: Sine wave - const plot1 = new TimeSeriesPlot({ - x: 0, - y: 0, - width: width, - height: height, - title: 'Sine Wave', - color: 0xff6666, - }); - - // Right plot: Perlin noise - const plot2 = new TimeSeriesPlot({ - x: width, - y: 0, - width: width, - height: height, - title: 'Perlin Noise', - color: 0x66ff66, - }); - - app.stage.addChild(plot1.container); - app.stage.addChild(plot2.container); - - // Connect different data sources - const conn1 = connectSyntheticData( - TestDataFactory.createSimpleSine(30), - plot1, - { lineInterval: 100 } - ); - - const conn2 = connectSyntheticData( - TestDataFactory.createSmoothNoise(30), - plot2, - { lineInterval: 100 } - ); - - app.ticker.add(() => { - plot1.update(); - plot2.update(); - }); - - return { app, plots: [plot1, plot2], connections: [conn1, conn2] }; -} - -// ============================================================================ -// Example 4: Using Function-Based Data Source -// ============================================================================ - -export async function example4_FunctionSource() { - console.log('=== Example 4: Function Source ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Custom Function', - }); - app.stage.addChild(plot.container); - - // Define a custom function: (x, t) => y - // x is normalized 0-1 across the width - // t is time in seconds - const customFunc = (x, t) => { - // Create an interference pattern - const wave1 = Math.sin(x * 10 + t * 2); - const wave2 = Math.sin(x * 15 - t * 3); - const wave3 = Math.cos(x * 8 + t * 1.5); - return (wave1 + wave2 + wave3) / 3; - }; - - const connection = connectFunction(customFunc, plot, { - lineInterval: 100, - amplitude: 30, - }); - - app.ticker.add(() => plot.update()); - - return { app, plot, connection }; -} - -// ============================================================================ -// Example 5: Swapping Data Sources at Runtime -// ============================================================================ - -export async function example5_SwappingSources() { - console.log('=== Example 5: Swapping Sources ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Dynamic Source Switching', - }); - app.stage.addChild(plot.container); - - // Start with sine wave - let currentConnection = connectSyntheticData( - TestDataFactory.createSimpleSine(30), - plot, - { lineInterval: 100 } - ); - - app.ticker.add(() => plot.update()); - - // Function to switch to a different data source - const switchToSource = (generator, title) => { - // Disconnect current source - currentConnection.disconnect(); - - // Connect new source - currentConnection = connectSyntheticData(generator, plot, { - lineInterval: 100, - }); - - plot.setTitle(title); - console.log(`Switched to: ${title}`); - }; - - // Example: Switch sources every 5 seconds - let sourceIndex = 0; - const sources = [ - { gen: TestDataFactory.createSimpleSine(30), title: 'Sine Wave' }, - { gen: TestDataFactory.createComplexPattern(30), title: 'Complex Pattern' }, - { gen: TestDataFactory.createSmoothNoise(30), title: 'Perlin Noise' }, - { gen: TestDataFactory.createFrequencySweep(30), title: 'Frequency Sweep' }, - ]; - - setInterval(() => { - sourceIndex = (sourceIndex + 1) % sources.length; - const source = sources[sourceIndex]; - switchToSource(source.gen, source.title); - }, 5000); - - return { app, plot, switchToSource }; -} - -// ============================================================================ -// Example 6: Streaming Data with Buffering -// ============================================================================ - -export async function example6_StreamingData() { - console.log('=== Example 6: Streaming Data ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Streaming Data (Buffered)', - }); - app.stage.addChild(plot.container); - - // Create streaming source (emits individual points) - const generator = new SineWaveGenerator({ - frequency: 2.0, - amplitude: 1.0, - sampleRate: 60, - }); - - const source = new StreamingDataSource({ - generator: generator, - sampleRate: 60, // 60 points per second - }); - - // Use buffered connection to assemble points into lines - const connection = new BufferedConnection(source, plot, { - bufferSize: 100, // Buffer 100 points before creating a line - bufferTimeout: 1000, // Or timeout after 1 second - }); - connection.connect(); - - app.ticker.add(() => plot.update()); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 7: Connection Manager (Managing Multiple Connections) -// ============================================================================ - -export async function example7_ConnectionManager() { - console.log('=== Example 7: Connection Manager ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Managed Connections', - }); - app.stage.addChild(plot.container); - - // Create connection manager - const manager = new ConnectionManager(); - - // Add first connection - const source1 = new SyntheticDataSource({ - generator: TestDataFactory.createSimpleSine(30), - pointsPerLine: 100, - width: 800, - lineInterval: 100, - }); - - const connId1 = manager.connect(source1, plot, { type: 'direct' }); - console.log('Connection ID:', connId1); - - app.ticker.add(() => plot.update()); - - // Later: disconnect and switch to different source - setTimeout(() => { - manager.disconnect(connId1); - - const source2 = new SyntheticDataSource({ - generator: TestDataFactory.createFrequencySweep(30), - pointsPerLine: 100, - width: 800, - lineInterval: 100, - }); - - const connId2 = manager.connect(source2, plot, { type: 'direct' }); - plot.setTitle('Frequency Sweep'); - console.log('Switched to connection:', connId2); - }, 5000); - - return { app, plot, manager }; -} - -// ============================================================================ -// Example 8: Complete Interactive Demo -// ============================================================================ - -export async function example8_InteractiveDemo() { - console.log('=== Example 8: Interactive Demo ==='); - - const app = new Application(); - await app.init({ - width: 1600, - height: 800, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // Create two plots - const plot1 = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 800, - title: 'Plot 1 - Press 1-5 to change', - color: 0xff6666, - }); - - const plot2 = new TimeSeriesPlot({ - x: 800, - y: 0, - width: 800, - height: 800, - title: 'Plot 2 - Press 6-0 to change', - color: 0x66ff66, - }); - - app.stage.addChild(plot1.container); - app.stage.addChild(plot2.container); - - // Connection manager - const manager = new ConnectionManager(); - - // Available data sources - const dataSources = { - sine: () => TestDataFactory.createSimpleSine(30), - complex: () => TestDataFactory.createComplexPattern(30), - noise: () => TestDataFactory.createSmoothNoise(30), - sweep: () => TestDataFactory.createFrequencySweep(30), - burst: () => TestDataFactory.createBurstySignal(30), - }; - - // Track current connections - let conn1Id = null; - let conn2Id = null; - - // Helper to switch source - const switchSource = (plot, generatorFunc, title) => { - // Disconnect old connection - const connId = plot === plot1 ? conn1Id : conn2Id; - if (connId !== null) { - manager.disconnect(connId); - } - - // Create new connection - const source = new SyntheticDataSource({ - generator: generatorFunc(), - pointsPerLine: 100, - width: plot.width, - lineInterval: 100, - }); - - const newConnId = manager.connect(source, plot, { type: 'direct' }); - plot.setTitle(title); - - // Store connection ID - if (plot === plot1) { - conn1Id = newConnId; - } else { - conn2Id = newConnId; - } - }; - - // Initialize with default sources - switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave'); - switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern'); - - // Keyboard controls - window.addEventListener('keydown', (e) => { - switch (e.key) { - case '1': - switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave'); - break; - case '2': - switchSource(plot1, dataSources.complex, 'Plot 1 - Complex Pattern'); - break; - case '3': - switchSource(plot1, dataSources.noise, 'Plot 1 - Perlin Noise'); - break; - case '4': - switchSource(plot1, dataSources.sweep, 'Plot 1 - Frequency Sweep'); - break; - case '5': - switchSource(plot1, dataSources.burst, 'Plot 1 - Burst Signal'); - break; - case '6': - switchSource(plot2, dataSources.sine, 'Plot 2 - Sine Wave'); - break; - case '7': - switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern'); - break; - case '8': - switchSource(plot2, dataSources.noise, 'Plot 2 - Perlin Noise'); - break; - case '9': - switchSource(plot2, dataSources.sweep, 'Plot 2 - Frequency Sweep'); - break; - case '0': - switchSource(plot2, dataSources.burst, 'Plot 2 - Burst Signal'); - break; - case 'g': - plot1.setGridVisible(!plot1.showGrid); - plot2.setGridVisible(!plot2.showGrid); - break; - case 'c': - plot1.clearData(); - plot2.clearData(); - break; - } - }); - - // Update loop - app.ticker.add(() => { - plot1.update(); - plot2.update(); - }); - - console.log('Controls:'); - console.log(' 1-5: Change Plot 1 source'); - console.log(' 6-0: Change Plot 2 source'); - console.log(' G: Toggle grid'); - console.log(' C: Clear data'); - - return { app, plot1, plot2, manager }; -} - -// ============================================================================ -// Quick Test: Run one of the examples -// ============================================================================ - -// Uncomment to run an example: -// example1_SimpleSetup(); -// example2_QuickSetup(); -// example3_MultiplePlots(); -// example4_FunctionSource(); -// example5_SwappingSources(); -// example6_StreamingData(); -// example7_ConnectionManager(); -//example8_InteractiveDemo(); diff --git a/web-timeplot/src/main.js b/web-timeplot/src/main.js index e435193..d2b348e 100644 --- a/web-timeplot/src/main.js +++ b/web-timeplot/src/main.js @@ -1,151 +1 @@ -import { Application } from 'pixi.js'; -import * as PIXI from 'pixi.js'; -import { StateManager } from './state.js'; - -// ============================================================================ -// GLOBAL STATE -// ============================================================================ - -// Centralized reactive state -const state = new StateManager(); - -// DOM references -let dom = { - container: null, -}; - -// Application instances -let app = null; // PixiJS Application - -// ============================================================================ -// APPLICATION ENTRY POINT -// ============================================================================ - -document.addEventListener('DOMContentLoaded', async function() { - log('Framework starting...'); - - log('init DOM'); - await initDOM(); - - log('init PixiJS renderer'); - await initRenderer(); - - log('init services'); - await initServices(); - - log('Framework ready - start prototyping!'); -}); - -// ============================================================================ -// INITIALIZATION FUNCTIONS -// ============================================================================ - -async function initDOM() { - dom.container = document.getElementById('canvas-container'); - - if (!dom.container) { - throw new Error('Canvas container not found'); - } -} - -async function initRenderer() { - // Check WebGPU availability - let preference = 'webgpu'; - if (!navigator.gpu) { - log('WebGPU not available, using WebGL'); - preference = 'webgl'; - } - - try { - app = new Application(); - - await app.init({ - preference: preference, - width: window.innerWidth, - height: window.innerHeight, - backgroundColor: 0x1a1a26, - antialias: true, - autoDensity: true, - resolution: window.devicePixelRatio || 1, - }); - - dom.container.appendChild(app.canvas); - - // Store renderer info in state - const rendererType = app.renderer.type; - state.state.rendering.rendererType = rendererType; - log(`Using renderer: ${rendererType}`); - - // Store canvas dimensions in state - state.state.uiConfig.canvasWidth = app.screen.width; - state.state.uiConfig.canvasHeight = app.screen.height; - - // Handle window resize - window.addEventListener('resize', handleResize); - - } catch (error) { - log(`Failed to initialize renderer: ${error}`); - throw error; - } -} - -async function initServices() { - // Start animation loop - app.ticker.add(update); - - log('Services initialized'); -} - -// ============================================================================ -// EVENT HANDLERS -// ============================================================================ - -function handleResize() { - const width = window.innerWidth; - const height = window.innerHeight; - - app.renderer.resize(width, height); - - // Update state - state.state.uiConfig.canvasWidth = width; - state.state.uiConfig.canvasHeight = height; -} - -// ============================================================================ -// MAIN UPDATE LOOP -// ============================================================================ - -function update() { - // Update time using state manager - state.incrementTime(0.016); // ~60fps increment - state.updateRealElapsed(); - - state.state.rendering.frameCounter++; - - // YOUR PROTOTYPE CODE GOES HERE - // Example: - // yourSprite.x += 1; - // yourGraphics.rotation += 0.01; -} - -// ============================================================================ -// UTILITIES -// ============================================================================ - -function log(msg) { - console.log(`[Framework] ${msg}`); -} - -// ============================================================================ -// EXPORTS FOR PROTOTYPING -// ============================================================================ - -// Export immediately available objects -window.PIXI = PIXI; -window.state = state; -window.log = log; - -// Export app after initialization (using a getter) -Object.defineProperty(window, 'pixiApp', { - get() { return app; } -}); +import './bootstrap.js'; diff --git a/web-timeplot/src/metrics.js b/web-timeplot/src/metrics.js deleted file mode 100644 index fdda10a..0000000 --- a/web-timeplot/src/metrics.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * RollingAverage - Maintains a rolling window of values for smooth averaging - */ -class RollingAverage { - constructor(capacity) { - this.values = []; - this.capacity = capacity; - this.sum = 0; - } - - push(value) { - if (this.values.length >= this.capacity) { - const old = this.values.shift(); - this.sum -= old; - } - this.values.push(value); - this.sum += value; - } - - average() { - return this.values.length > 0 ? this.sum / this.values.length : 0; - } - - min() { - return this.values.length > 0 ? Math.min(...this.values) : 0; - } - - max() { - return this.values.length > 0 ? Math.max(...this.values) : 0; - } -} - -/** - * PerformanceMetrics - Tracks and analyzes frame performance - */ -export class PerformanceMetrics { - constructor(rollingWindow = 60, historyCapacity = 10000) { - // Rolling averages - this.frameTime = new RollingAverage(rollingWindow); - this.updateTime = new RollingAverage(rollingWindow); - this.renderTime = new RollingAverage(rollingWindow); - this.vertexCount = new RollingAverage(rollingWindow); - this.lineCount = new RollingAverage(rollingWindow); - - // History for export - this.history = []; - this.historyCapacity = historyCapacity; - - // Frame timing - this.frameStart = 0; - this.updateStart = 0; - this.renderStart = 0; - - this.totalFrames = 0; - } - - beginFrame() { - this.frameStart = performance.now(); - } - - beginUpdate() { - this.updateStart = performance.now(); - } - - endUpdate() { - const duration = performance.now() - this.updateStart; - return duration; - } - - beginRender() { - this.renderStart = performance.now(); - } - - endRender() { - const duration = performance.now() - this.renderStart; - return duration; - } - - endFrame(updateMs, renderMs, vertexCount, lineCount) { - const totalMs = performance.now() - this.frameStart; - - // Update rolling averages - this.frameTime.push(totalMs); - this.updateTime.push(updateMs); - this.renderTime.push(renderMs); - this.vertexCount.push(vertexCount); - this.lineCount.push(lineCount); - - // Store in history - const record = { - frame: this.totalFrames, - totalMs, - updateMs, - renderMs, - vertexCount, - lineCount, - fps: totalMs > 0 ? 1000 / totalMs : 0, - }; - - if (this.history.length >= this.historyCapacity) { - this.history.shift(); - } - this.history.push(record); - - this.totalFrames++; - } - - getFPS() { - const avg = this.frameTime.average(); - return avg > 0 ? 1000 / avg : 0; - } - - getMinFPS() { - const max = this.frameTime.max(); - return max > 0 ? 1000 / max : 0; - } - - getMaxFPS() { - const min = this.frameTime.min(); - return min > 0 ? 1000 / min : 0; - } - - formatSummary() { - return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` + - `Frame: ${this.frameTime.average().toFixed(2)}ms | ` + - `Update: ${this.updateTime.average().toFixed(2)}ms | ` + - `Render: ${this.renderTime.average().toFixed(2)}ms | ` + - `Vertices: ${Math.round(this.vertexCount.average())} | ` + - `Lines: ${Math.round(this.lineCount.average())}`; - } - - exportToCSV() { - let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n'; - - for (const record of this.history) { - csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` + - `${record.vertexCount},${record.lineCount},${record.fps}\n`; - } - - return csv; - } -} diff --git a/web-timeplot/src/plot-connections.js b/web-timeplot/src/plot-connections.js deleted file mode 100644 index 0e96dd8..0000000 --- a/web-timeplot/src/plot-connections.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Plot Connections - Links data sources to visualization plots - * - * This module manages the connection between data sources and plots, - * handling buffering, timing, and data flow. - * - * Connection Types: - * - DirectConnection: Lines from source → plot (no buffering) - * - BufferedConnection: Points → buffer → lines → plot - * - SynchronizedConnection: Multiple sources → synchronized output - */ - -/** - * Base connection class - */ -class PlotConnection { - constructor(source, plot, config = {}) { - this.source = source; - this.plot = plot; - this.config = config; - this.isActive = false; - this.subscriptions = []; - } - - /** - * Activate the connection - start data flow - */ - connect() { - if (this.isActive) return; - this.isActive = true; - this.setupSubscriptions(); - this.source.start(); - } - - /** - * Deactivate the connection - stop data flow - */ - disconnect() { - if (!this.isActive) return; - this.isActive = false; - this.cleanup(); - this.source.stop(); - } - - /** - * Setup event subscriptions (override in subclasses) - */ - setupSubscriptions() { - throw new Error('setupSubscriptions() must be implemented by subclass'); - } - - /** - * Cleanup subscriptions - */ - cleanup() { - this.subscriptions.forEach(unsub => unsub()); - this.subscriptions = []; - } -} - -/** - * Direct connection - passes lines directly from source to plot - * Use when source emits complete lines of data - */ -export class DirectConnection extends PlotConnection { - setupSubscriptions() { - const unsubLine = this.source.on('line', (data) => { - this.plot.addLine(data.points, data.metadata); - }); - - const unsubError = this.source.on('error', (data) => { - console.error('[DirectConnection] Source error:', data.error); - }); - - this.subscriptions.push(unsubLine, unsubError); - } -} - -/** - * Buffered connection - buffers individual points into lines - * Use when source emits individual data points that need to be assembled - */ -export class BufferedConnection extends PlotConnection { - constructor(source, plot, config = {}) { - super(source, plot, config); - this.buffer = []; - this.bufferSize = config.bufferSize || 100; - this.bufferTimeout = config.bufferTimeout || 1000; // ms - this.lastFlush = Date.now(); - this.flushHandle = null; - - // Start auto-flush timer - if (config.autoFlush !== false) { - this.startAutoFlush(); - } - } - - setupSubscriptions() { - const unsubPoint = this.source.on('point', (data) => { - this.addToBuffer(data); - }); - - const unsubError = this.source.on('error', (data) => { - console.error('[BufferedConnection] Source error:', data.error); - }); - - this.subscriptions.push(unsubPoint, unsubError); - } - - addToBuffer(data) { - this.buffer.push(data); - - // Flush if buffer is full - if (this.buffer.length >= this.bufferSize) { - this.flush(); - } - } - - flush() { - if (this.buffer.length === 0) return; - - // Convert buffer to line points - const points = this.buffer.map((data, idx) => { - const x = (idx / this.buffer.length) * this.plot.width; - return { x, y: data.value }; - }); - - this.plot.addLine(points, { - timestamp: this.lastFlush, - pointCount: this.buffer.length, - }); - - this.buffer = []; - this.lastFlush = Date.now(); - } - - startAutoFlush() { - this.flushHandle = setInterval(() => { - const timeSinceLastFlush = Date.now() - this.lastFlush; - if (timeSinceLastFlush >= this.bufferTimeout && this.buffer.length > 0) { - this.flush(); - } - }, 100); // Check every 100ms - } - - cleanup() { - super.cleanup(); - if (this.flushHandle) { - clearInterval(this.flushHandle); - this.flushHandle = null; - } - } -} - -/** - * Synchronized connection - synchronizes multiple sources to one plot - * Useful for combining multiple data streams - */ -export class SynchronizedConnection extends PlotConnection { - constructor(sources, plot, config = {}) { - super(null, plot, config); // No single source - this.sources = sources; - this.syncMode = config.syncMode || 'wait-for-all'; // 'wait-for-all', 'first-available' - this.lineBuffers = new Map(); // sourceId => latest line - } - - connect() { - if (this.isActive) return; - this.isActive = true; - - this.sources.forEach((source, idx) => { - const unsubLine = source.on('line', (data) => { - this.handleSourceLine(idx, data); - }); - - const unsubError = source.on('error', (data) => { - console.error(`[SynchronizedConnection] Source ${idx} error:`, data.error); - }); - - this.subscriptions.push(unsubLine, unsubError); - source.start(); - }); - } - - disconnect() { - if (!this.isActive) return; - this.isActive = false; - this.cleanup(); - this.sources.forEach(source => source.stop()); - } - - handleSourceLine(sourceIdx, data) { - this.lineBuffers.set(sourceIdx, data); - - if (this.syncMode === 'wait-for-all') { - // Wait until we have data from all sources - if (this.lineBuffers.size === this.sources.length) { - this.emitSynchronized(); - } - } else if (this.syncMode === 'first-available') { - // Emit immediately - this.plot.addLine(data.points, { - ...data.metadata, - sourceIdx, - }); - } - } - - emitSynchronized() { - // For now, just emit the first source's line - // Could implement more sophisticated merging - const firstLine = this.lineBuffers.get(0); - if (firstLine) { - this.plot.addLine(firstLine.points, firstLine.metadata); - } - this.lineBuffers.clear(); - } -} - -/** - * Connection Manager - manages multiple connections - */ -export class ConnectionManager { - constructor() { - this.connections = new Map(); // connectionId => connection - this.nextId = 0; - } - - /** - * Create and register a connection - * @returns {number} connectionId - */ - connect(source, plot, config = {}) { - const type = config.type || 'direct'; - let connection; - - switch (type) { - case 'direct': - connection = new DirectConnection(source, plot, config); - break; - case 'buffered': - connection = new BufferedConnection(source, plot, config); - break; - case 'synchronized': - connection = new SynchronizedConnection(source, plot, config); - break; - default: - throw new Error(`Unknown connection type: ${type}`); - } - - const id = this.nextId++; - this.connections.set(id, connection); - connection.connect(); - - return id; - } - - /** - * Disconnect and remove a connection - */ - disconnect(connectionId) { - const connection = this.connections.get(connectionId); - if (connection) { - connection.disconnect(); - this.connections.delete(connectionId); - } - } - - /** - * Disconnect all connections - */ - disconnectAll() { - this.connections.forEach(connection => connection.disconnect()); - this.connections.clear(); - } - - /** - * Get statistics about connections - */ - getStats() { - return { - activeConnections: this.connections.size, - connections: Array.from(this.connections.entries()).map(([id, conn]) => ({ - id, - isActive: conn.isActive, - type: conn.constructor.name, - })), - }; - } -} - -/** - * Helper functions for common connection patterns - */ - -/** - * Connect a synthetic data source to a plot - * @param {DataGenerator} generator - Test data generator instance - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {DirectConnection} The connection instance - */ -export function connectSyntheticData(generator, plot, config = {}) { - const { SyntheticDataSource } = require('./data-sources.js'); - - const source = new SyntheticDataSource({ - generator, - pointsPerLine: config.pointsPerLine || 100, - width: plot.width, - lineInterval: config.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot, config); - connection.connect(); - - return connection; -} - -/** - * Connect a function-based source to a plot - * @param {Function} func - Function (x, t) => y - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {DirectConnection} The connection instance - */ -export function connectFunction(func, plot, config = {}) { - const { FunctionDataSource } = require('./data-sources.js'); - - const source = new FunctionDataSource({ - func, - pointsPerLine: config.pointsPerLine || 100, - width: plot.width, - amplitude: config.amplitude || 30, - lineInterval: config.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot, config); - connection.connect(); - - return connection; -} - -/** - * Connect a streaming source to a plot with buffering - * @param {DataGenerator} generator - Test data generator instance - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {BufferedConnection} The connection instance - */ -export function connectStreamingData(generator, plot, config = {}) { - const { StreamingDataSource } = require('./data-sources.js'); - - const source = new StreamingDataSource({ - generator, - sampleRate: config.sampleRate || 60, - }); - - const connection = new BufferedConnection(source, plot, { - bufferSize: config.bufferSize || 100, - bufferTimeout: config.bufferTimeout || 1000, - }); - connection.connect(); - - return connection; -} - -/** - * Quick setup: Create a plot with a data source in one call - * @param {Application} app - PixiJS application - * @param {Object} plotConfig - Plot configuration - * @param {Object} sourceConfig - Source configuration - * @returns {Object} {plot, source, connection} - */ -export function createConnectedPlot(app, plotConfig, sourceConfig) { - const { TimeSeriesPlot } = require('./timeseries-plot.js'); - const { SyntheticDataSource } = require('./data-sources.js'); - - const plot = new TimeSeriesPlot(plotConfig); - app.stage.addChild(plot.container); - - const source = new SyntheticDataSource({ - generator: sourceConfig.generator, - pointsPerLine: plotConfig.width / 8, // Default: ~8 pixels per point - width: plotConfig.width, - lineInterval: sourceConfig.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot); - connection.connect(); - - return { plot, source, connection }; -} diff --git a/web-timeplot/src/plot/plot-buffer.js b/web-timeplot/src/plot/plot-buffer.js new file mode 100644 index 0000000..b13cdd8 --- /dev/null +++ b/web-timeplot/src/plot/plot-buffer.js @@ -0,0 +1,22 @@ +export class PlotBuffer { + constructor(maxPoints = 1600) { + this.maxPoints = maxPoints; + this.points = []; + } + + addPoint(point) { + this.points.push(point); + if (this.points.length > this.maxPoints) { + this.points.splice(0, this.points.length - this.maxPoints); + } + } + + clear() { + this.points = []; + } + + getVisiblePoints(currentPlotTimeMs, windowDurationMs) { + const minTime = currentPlotTimeMs - windowDurationMs; + return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs); + } +} diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js new file mode 100644 index 0000000..9f00b29 --- /dev/null +++ b/web-timeplot/src/plot/timeplot-view.js @@ -0,0 +1,234 @@ +import { Application, Container, Graphics, Text } from 'pixi.js'; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function roundRect(graphics, x, y, width, height, radius, fill, stroke) { + graphics.roundRect(x, y, width, height, radius); + graphics.fill(fill); + graphics.stroke(stroke); +} + +export class TimeplotView { + constructor({ host, onHover }) { + this.host = host; + this.onHover = onHover; + this.app = new Application(); + this.container = new Container(); + this.background = new Graphics(); + this.grid = new Graphics(); + this.line = new Graphics(); + this.points = new Graphics(); + this.crosshair = new Graphics(); + this.overlay = new Container(); + this.titleText = new Text({ + text: 'Plot viewport', + style: { + fill: 0xeef4ff, + fontFamily: 'Inter, sans-serif', + fontSize: 16, + }, + }); + this.subtitleText = new Text({ + text: 'Synthetic data stream', + style: { + fill: 0x8ca3c7, + fontFamily: 'Inter, sans-serif', + fontSize: 12, + }, + }); + this.screenPoints = []; + this.bounds = { width: 100, height: 100 }; + this.hoverRadiusPx = 20; + this.pointer = null; + } + + async init() { + const rendererPreference = navigator.gpu ? 'webgpu' : 'webgl'; + await this.app.init({ + preference: rendererPreference, + resizeTo: this.host, + antialias: true, + backgroundAlpha: 0, + resolution: Math.min(window.devicePixelRatio || 1, 2), + }); + + this.app.stage.addChild(this.container); + this.container.addChild(this.background); + this.container.addChild(this.grid); + this.container.addChild(this.line); + this.container.addChild(this.points); + this.container.addChild(this.crosshair); + this.container.addChild(this.overlay); + this.overlay.addChild(this.titleText); + this.overlay.addChild(this.subtitleText); + this.host.appendChild(this.app.canvas); + this.attachPointerListeners(); + + return rendererPreference; + } + + attachPointerListeners() { + this.host.addEventListener('pointerleave', () => { + this.pointer = null; + this.crosshair.clear(); + this.onHover(null); + }); + + this.host.addEventListener('pointermove', (event) => { + const rect = this.host.getBoundingClientRect(); + this.pointer = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }); + } + + resize() { + this.bounds = { + width: this.host.clientWidth, + height: this.host.clientHeight, + }; + } + + render(state, points) { + this.resize(); + this.renderFrame(state, points); + this.renderHover(state); + } + + renderFrame(state, points) { + const width = this.bounds.width; + const height = this.bounds.height; + const padding = { top: 68, right: 24, bottom: 28, left: 52 }; + const plotWidth = Math.max(10, width - padding.left - padding.right); + const plotHeight = Math.max(10, height - padding.top - padding.bottom); + const minTime = state.time.plotTimeMs - state.plot.windowDurationMs; + const maxTime = Math.max(state.time.plotTimeMs, minTime + 1); + const { min: minValue, max: maxValue } = state.plot.valueRange; + const valueSpan = Math.max(0.001, maxValue - minValue); + + this.background.clear(); + roundRect( + this.background, + 0, + 0, + width, + height, + 24, + { color: 0x050c16, alpha: 1 }, + { color: 0x22344f, width: 1 }, + ); + + this.grid.clear(); + if (state.plot.showGrid) { + const gridColor = 0x1d3555; + for (let x = 0; x <= 8; x += 1) { + const px = padding.left + (plotWidth * x) / 8; + this.grid.moveTo(px, padding.top); + this.grid.lineTo(px, padding.top + plotHeight); + this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 }); + } + + for (let y = 0; y <= 6; y += 1) { + const py = padding.top + (plotHeight * y) / 6; + this.grid.moveTo(padding.left, py); + this.grid.lineTo(padding.left + plotWidth, py); + this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 }); + } + } + + this.line.clear(); + this.points.clear(); + this.screenPoints = []; + + if (points.length > 0) { + points.forEach((point, index) => { + const x = padding.left + ((point.timeMs - minTime) / (maxTime - minTime)) * plotWidth; + const normalizedValue = (point.value - minValue) / valueSpan; + const y = padding.top + (1 - normalizedValue) * plotHeight; + + this.screenPoints.push({ ...point, x, y }); + + if (index === 0) { + this.line.moveTo(x, y); + } else { + this.line.lineTo(x, y); + } + }); + + this.line.stroke({ + color: 0x7af0ff, + width: 2.25, + alpha: 0.95, + cap: 'round', + join: 'round', + }); + + if (state.plot.showPoints) { + for (const point of this.screenPoints) { + this.points.circle(point.x, point.y, 2.5); + this.points.fill({ color: 0xc4f8ff, alpha: 0.95 }); + } + } + } + + this.titleText.text = 'TimePlot viewport'; + this.titleText.x = 18; + this.titleText.y = 16; + + this.subtitleText.text = `${state.source.preset} • ${state.source.sampleRateHz} Hz • ${points.length} visible points`; + this.subtitleText.x = 18; + this.subtitleText.y = 38; + } + + renderHover(state) { + this.crosshair.clear(); + + if (!this.pointer || this.screenPoints.length === 0) { + this.onHover(null); + return; + } + + let nearestPoint = null; + let nearestDistance = Infinity; + + for (const point of this.screenPoints) { + const dx = point.x - this.pointer.x; + const dy = point.y - this.pointer.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < nearestDistance) { + nearestPoint = point; + nearestDistance = distance; + } + } + + if (!nearestPoint || nearestDistance > this.hoverRadiusPx) { + this.onHover(null); + return; + } + + const x = clamp(nearestPoint.x, 0, this.bounds.width); + const y = clamp(nearestPoint.y, 0, this.bounds.height); + + this.crosshair.moveTo(x, 0); + this.crosshair.lineTo(x, this.bounds.height); + this.crosshair.moveTo(0, y); + this.crosshair.lineTo(this.bounds.width, y); + this.crosshair.stroke({ color: 0x6ea8ff, width: 1, alpha: 0.22 }); + this.crosshair.circle(x, y, 5); + this.crosshair.stroke({ color: 0xffffff, width: 2, alpha: 0.95 }); + + this.onHover({ + x, + y, + point: nearestPoint, + paused: state.time.paused, + }); + } + + destroy() { + this.app.destroy(true, { children: true }); + } +} diff --git a/web-timeplot/src/state.js b/web-timeplot/src/state.js deleted file mode 100644 index 53d8279..0000000 --- a/web-timeplot/src/state.js +++ /dev/null @@ -1,420 +0,0 @@ -/** - * StateManager - Centralized state management with Proxy-based reactivity - * - * Usage: - * state.time.speed = 2.0 // automatically emits events - * state.on('time.speed', (value) => console.log('Speed changed:', value)) - * state.on('time.*', (change) => console.log('Time domain changed:', change)) - * - * State Domains: - * - userPrefs: showGrid, showMetrics, theme, etc. - * - uiConfig: active panels, layout, dimensions - * - time: current time, speed, paused state, real elapsed time - * - rendering: graphs, renderer info - * - health: framerate, service connections, db access - * - dataInput: sources, structure, metadata - * - inputActions: keyboard/mouse/gamepad action mappings - */ - -// Simple EventEmitter implementation -class EventEmitter { - constructor() { - this.events = new Map(); - } - - on(event, callback) { - if (!this.events.has(event)) { - this.events.set(event, []); - } - this.events.get(event).push(callback); - - // Return unsubscribe function - return () => this.off(event, callback); - } - - off(event, callback) { - if (!this.events.has(event)) return; - const callbacks = this.events.get(event); - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - } - - emit(event, data) { - if (!this.events.has(event)) return; - this.events.get(event).forEach(callback => { - try { - callback(data); - } catch (e) { - console.error(`[State] Error in event handler for '${event}':`, e); - } - }); - } - - once(event, callback) { - const wrapper = (data) => { - callback(data); - this.off(event, wrapper); - }; - this.on(event, wrapper); - } - - clear() { - this.events.clear(); - } -} - -export class StateManager extends EventEmitter { - constructor() { - super(); - - // Internal state storage (not proxied) - this._state = { - userPrefs: { - showGrid: true, - showMetrics: true, - theme: 'dark', - rollingWindow: 60, - historyCapacity: 10000, - metricsUpdateInterval: 10, - }, - - uiConfig: { - activePanels: ['graph1', 'graph2'], - layout: 'horizontal-split', - canvasWidth: 0, - canvasHeight: 0, - }, - - time: { - current: 0, // Current plot time - realElapsed: 0, // Real time elapsed since start - speed: 1.0, // Time speed multiplier (0.1 to 5.0) - isPaused: false, // Pause state - startTimestamp: Date.now(), // Real timestamp when started - verticalScale: 1.0, // Vertical zoom for time history - }, - - rendering: { - rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas' - frameCounter: 0, - // Note: graph instances are NOT stored here to avoid proxy wrapping - }, - - health: { - fps: 0, - updateMs: 0, - renderMs: 0, - vertexCount: 0, - lineCount: 0, - serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' } - }, - - dataInput: { - sources: [], // Array of data source configs - activeSource: null, // Currently active source - dataStructure: null, // Schema of incoming data - metadata: {}, // Additional metadata - }, - - inputActions: { - keyboardMap: new Map(), // Map of KeyboardEvent.code => action name - mouseMap: new Map(), // Map of mouse button => action name - actionHandlers: new Map(), // Map of action name => handler function - }, - }; - - // Track which domains should be persisted - this._persistedDomains = new Set(['userPrefs']); - - // Load persisted state - this._loadPersistedState(); - - // Create proxied state - this is what users interact with - this.state = this._createProxy(this._state, []); - } - - /** - * Create a reactive Proxy that emits events on property changes - * @param {Object} target - The object to proxy - * @param {Array} path - Current property path (e.g., ['time', 'speed']) - * @private - */ - _createProxy(target, path) { - // Don't proxy non-objects or special objects like Map/Set - if (typeof target !== 'object' || target === null) { - return target; - } - - // Don't proxy Maps and Sets - they need special handling - if (target instanceof Map || target instanceof Set) { - return target; - } - - const self = this; - - return new Proxy(target, { - get(obj, prop) { - const value = obj[prop]; - - // Return primitives and functions as-is - if (typeof value !== 'object' || value === null) { - return value; - } - - // Return nested objects as proxies - return self._createProxy(value, [...path, prop]); - }, - - set(obj, prop, value) { - const oldValue = obj[prop]; - - // Only emit if value actually changed - if (oldValue === value) { - return true; - } - - obj[prop] = value; - - // Build event path - const fullPath = [...path, prop]; - const pathString = fullPath.join('.'); - const domain = fullPath[0]; - - // Emit specific property change: "time.speed" - self.emit(pathString, { - path: fullPath, - value: value, - oldValue: oldValue, - }); - - // Emit domain wildcard: "time.*" - if (domain) { - self.emit(`${domain}.*`, { - path: fullPath, - property: prop, - value: value, - oldValue: oldValue, - }); - } - - // Emit global wildcard: "*" - self.emit('*', { - path: fullPath, - value: value, - oldValue: oldValue, - }); - - // Auto-persist certain domains - if (self._persistedDomains.has(domain)) { - self._persistDomain(domain); - } - - return true; - } - }); - } - - // ========================================================================= - // Persistence - // ========================================================================= - - _persistDomain(domain) { - try { - const data = this._state[domain]; - // Convert Maps to objects for JSON serialization - const serializable = this._makeSerializable(data); - localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable)); - } catch (e) { - console.warn(`[State] Failed to persist ${domain}:`, e); - } - } - - _loadPersistedState() { - this._persistedDomains.forEach(domain => { - try { - const saved = localStorage.getItem(`timeplot-${domain}`); - if (saved) { - const data = JSON.parse(saved); - // Deep merge to preserve defaults for new properties - this._state[domain] = this._deepMerge(this._state[domain], data); - } - } catch (e) { - console.warn(`[State] Failed to load ${domain}:`, e); - } - }); - } - - _makeSerializable(obj) { - if (obj instanceof Map) { - return Object.fromEntries(obj); - } - if (obj instanceof Set) { - return Array.from(obj); - } - if (typeof obj === 'object' && obj !== null) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = this._makeSerializable(value); - } - return result; - } - return obj; - } - - _deepMerge(target, source) { - const result = { ...target }; - for (const key in source) { - if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { - result[key] = this._deepMerge(target[key] || {}, source[key]); - } else { - result[key] = source[key]; - } - } - return result; - } - - // ========================================================================= - // Convenience Methods - // ========================================================================= - - /** - * Toggle a boolean preference - */ - togglePref(key) { - const current = this.state.userPrefs[key]; - if (typeof current === 'boolean') { - this.state.userPrefs[key] = !current; - } - } - - /** - * Pause/resume time - */ - togglePause() { - this.state.time.isPaused = !this.state.time.isPaused; - } - - /** - * Set time speed (clamped 0.1 to 5.0) - */ - setTimeSpeed(speed) { - this.state.time.speed = Math.max(0.1, Math.min(5.0, speed)); - } - - /** - * Increment time (respects pause and speed) - */ - incrementTime(delta) { - if (this.state.time.isPaused) return; - this.state.time.current += delta * this.state.time.speed; - } - - /** - * Update real elapsed time - */ - updateRealElapsed() { - const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000; - this.state.time.realElapsed = elapsed; - } - - // ========================================================================= - // Input Actions System - // ========================================================================= - - /** - * Register an input action handler - * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause') - * @param {Function} handler - Handler function to call - */ - registerAction(actionName, handler) { - this.state.inputActions.actionHandlers.set(actionName, handler); - } - - /** - * Map a keyboard key to an action - * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space') - * @param {string} actionName - Action to trigger - */ - mapKey(code, actionName) { - this.state.inputActions.keyboardMap.set(code, actionName); - } - - /** - * Map a mouse button to an action - * @param {number} button - Mouse button number (0=left, 1=middle, 2=right) - * @param {string} actionName - Action to trigger - */ - mapMouseButton(button, actionName) { - this.state.inputActions.mouseMap.set(button, actionName); - } - - /** - * Execute an action by name - */ - executeAction(actionName, event) { - const handler = this.state.inputActions.actionHandlers.get(actionName); - if (handler) { - handler(event); - } else { - console.warn(`[State] No handler registered for action: ${actionName}`); - } - } - - /** - * Handle keyboard event through action system - */ - handleKeyboardEvent(event) { - const actionName = this.state.inputActions.keyboardMap.get(event.code); - if (actionName) { - this.executeAction(actionName, event); - return true; - } - return false; - } - - /** - * Handle mouse button event through action system - */ - handleMouseButtonEvent(event) { - const actionName = this.state.inputActions.mouseMap.get(event.button); - if (actionName) { - this.executeAction(actionName, event); - return true; - } - return false; - } - - // ========================================================================= - // Data Sources - // ========================================================================= - - addDataSource(source) { - this.state.dataInput.sources.push(source); - } - - removeDataSource(sourceId) { - const sources = this.state.dataInput.sources; - const index = sources.findIndex(s => s.id === sourceId); - if (index > -1) { - sources.splice(index, 1); - } - } - - setActiveDataSource(sourceId) { - this.state.dataInput.activeSource = sourceId; - } - - // ========================================================================= - // Debugging - // ========================================================================= - - dump() { - console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state))); - } - - debugEvents() { - console.log('[State] Registered events:', Array.from(this.events.keys())); - } -} diff --git a/web-timeplot/src/styles.css b/web-timeplot/src/styles.css new file mode 100644 index 0000000..b56e31a --- /dev/null +++ b/web-timeplot/src/styles.css @@ -0,0 +1,287 @@ +:root { + color-scheme: dark; + --bg: #07111f; + --surface: rgba(11, 24, 42, 0.86); + --surface-strong: rgba(9, 18, 32, 0.94); + --border: rgba(133, 168, 255, 0.18); + --text: #eef4ff; + --muted: #8ca3c7; + --accent: #6ea8ff; + --accent-strong: #7af0ff; + --danger: #ff8c8c; + --shadow: 0 20px 40px rgba(0, 0, 0, 0.28); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; +} + +body { + background: + radial-gradient(circle at top left, rgba(122, 240, 255, 0.12), transparent 28%), + radial-gradient(circle at top right, rgba(110, 168, 255, 0.14), transparent 24%), + linear-gradient(180deg, #06101c 0%, #091423 100%); + color: var(--text); + overflow: hidden; +} + +button, +input, +select { + font: inherit; +} + +.timeplot-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + gap: 14px; + padding: 14px; +} + +.timeplot-topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border: 1px solid var(--border); + background: var(--surface); + backdrop-filter: blur(20px); + border-radius: 18px; + box-shadow: var(--shadow); +} + +.timeplot-brand { + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeplot-title { + margin: 0; + font-size: 1.2rem; +} + +.timeplot-subtitle { + color: var(--muted); + font-size: 0.9rem; +} + +.timeplot-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.control-group { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 999px; +} + +.control-group label, +.control-group span { + color: var(--muted); + font-size: 0.85rem; +} + +.control-group input[type='range'] { + width: 130px; +} + +.control-button, +.panel-toggle { + color: var(--text); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 999px; + padding: 8px 14px; + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.control-button:hover, +.panel-toggle:hover { + transform: translateY(-1px); + border-color: rgba(122, 240, 255, 0.45); +} + +.control-button[data-active='true'], +.panel-toggle[data-active='true'] { + background: linear-gradient(135deg, rgba(110, 168, 255, 0.18), rgba(122, 240, 255, 0.18)); + border-color: rgba(122, 240, 255, 0.42); +} + +.timeplot-viewport { + position: relative; + min-height: 0; + border-radius: 24px; + overflow: hidden; + border: 1px solid var(--border); + background: rgba(4, 10, 18, 0.94); + box-shadow: var(--shadow); +} + +.timeplot-canvas-host { + width: 100%; + height: 100%; +} + +.timeplot-sidebar { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow-y: auto; + padding-right: 2px; +} + +.panel { + border: 1px solid var(--border); + background: var(--surface-strong); + border-radius: 18px; + padding: 14px; + backdrop-filter: blur(20px); +} + +.panel[hidden] { + display: none; +} + +.panel h2 { + margin: 0 0 12px; + font-size: 0.95rem; +} + +.kv-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: center; + margin: 0; +} + +.kv-list dt { + color: var(--muted); + font-size: 0.84rem; +} + +.kv-list dd { + margin: 0; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.field-grid { + display: grid; + gap: 12px; +} + +.field-grid label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.84rem; +} + +.field-grid input, +.field-grid select { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.panel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.panel-row + .panel-row { + margin-top: 10px; +} + +.muted { + color: var(--muted); +} + +.help-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 18px; + color: var(--muted); +} + +.timeplot-tooltip { + position: absolute; + min-width: 180px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(122, 240, 255, 0.28); + background: rgba(7, 14, 24, 0.94); + box-shadow: var(--shadow); + pointer-events: none; + transform: translate(14px, -50%); + z-index: 10; +} + +.timeplot-tooltip[hidden] { + display: none; +} + +.timeplot-tooltip-title { + margin-bottom: 6px; + font-size: 0.82rem; + color: var(--accent-strong); +} + +.timeplot-tooltip-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 0.82rem; +} + +.timeplot-tooltip-row + .timeplot-tooltip-row { + margin-top: 4px; +} + +.timeplot-empty { + color: var(--muted); + font-size: 0.85rem; +} + +@media (max-width: 1100px) { + .timeplot-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(360px, 1fr) auto; + } + + .timeplot-sidebar { + overflow: visible; + } +} diff --git a/web-timeplot/src/template-for-standard-site.js b/web-timeplot/src/template-for-standard-site.js deleted file mode 100644 index 54aacc7..0000000 --- a/web-timeplot/src/template-for-standard-site.js +++ /dev/null @@ -1,75 +0,0 @@ -//import { setupRenderSystem } from './render.js'; - -let ENVURL = "" //remote server from which to grab env -let env = {}; -let cfg = {}; //the user config -let dom = { - input: {}, - label: {}, - box: {}, //an info-containing box - icon: {}, - info: {} -}; - - -//APP START HERE -$(document).ready(async function() { - console.log('asdf'); - //the core loop of the client application - // 1. setup relationship with DOM and grab references to its elements - log('init DOM'); - await initDOM(); - - log('init cfg'); - await initCfg(); - - log('get env vars'); - await getServerEnvVars(); - - log('init services'); - await initServices(); - - //setupRenderSystem(); - - -}); - -//gets user config from local storage if there is any -function initCfg(){ - let localCfg = localStorage.getItem('cfg'); - if (localCfg) { - try { - cfg = JSON.parse(localCfg); - } catch (e) { - cfg = {}; - } - } else { - - } -} - -async function getServerEnvVars(){ - await axios.get(`${ENVURL}`).then((res)=>{ - env = res.data; - //log(env); - }).catch((err)=>{ - //log(err); - }); - log('') -} - -function initServices(){ - //connect to websocket server - //grab endpoints from cfg -} - -function initDOM(){ - dom.body = $('body')[0]; -} - -function log(msg, lvl=1){ - if (dom.debugInfo){ - dom.debugInfo.innerHTML = msg; //TODO running log + timestamp - } - console.log(msg); -}
\ No newline at end of file diff --git a/web-timeplot/src/test-data-generators.js b/web-timeplot/src/test-data-generators.js deleted file mode 100644 index 02bc0ad..0000000 --- a/web-timeplot/src/test-data-generators.js +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Test Data Generators - Classes for generating fake/test data patterns - * - * These generators produce various types of synthetic data for testing - * and visualizing the waterfall graphs with realistic patterns. - */ - -/** - * Base class for all data generators - */ -class DataGenerator { - constructor(config = {}) { - this.sampleRate = config.sampleRate || 100; // Samples per second - this.amplitude = config.amplitude || 1.0; - this.offset = config.offset || 0.0; - this.time = 0; - } - - /** - * Generate a single sample at the current time - * @returns {number} The generated value - */ - sample() { - throw new Error('sample() must be implemented by subclass'); - } - - /** - * Generate an array of samples - * @param {number} count - Number of samples to generate - * @returns {Array<number>} Array of generated values - */ - generateSamples(count) { - const samples = []; - for (let i = 0; i < count; i++) { - samples.push(this.sample()); - this.time += 1 / this.sampleRate; - } - return samples; - } - - /** - * Generate a line of points for waterfall display - * @param {number} pointCount - Number of points in the line - * @param {number} width - Width of the display area - * @returns {Array<{x: number, y: number}>} Array of points - */ - generateLine(pointCount, width) { - const points = []; - const samples = this.generateSamples(pointCount); - - for (let i = 0; i < pointCount; i++) { - const x = (i / pointCount) * width; - const y = samples[i] * this.amplitude + this.offset; - points.push({ x, y }); - } - - return points; - } - - reset() { - this.time = 0; - } -} - -/** - * Sine Wave Generator - Classic sinusoidal wave - */ -export class SineWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; // Hz - this.phase = config.phase || 0.0; // Radians - } - - sample() { - const value = Math.sin(2 * Math.PI * this.frequency * this.time + this.phase); - return value; - } -} - -/** - * Square Wave Generator - Digital-style square wave - */ -export class SquareWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - this.dutyCycle = config.dutyCycle || 0.5; // 0.0 to 1.0 - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return phase < this.dutyCycle ? 1.0 : -1.0; - } -} - -/** - * Sawtooth Wave Generator - Linear ramp wave - */ -export class SawtoothWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return 2 * phase - 1; // -1 to 1 - } -} - -/** - * Triangle Wave Generator - Linear up/down wave - */ -export class TriangleWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return phase < 0.5 - ? 4 * phase - 1 - : 3 - 4 * phase; - } -} - -/** - * White Noise Generator - Random noise - */ -export class WhiteNoiseGenerator extends DataGenerator { - sample() { - return Math.random() * 2 - 1; // -1 to 1 - } -} - -/** - * Pink Noise Generator - 1/f noise (more realistic than white noise) - */ -export class PinkNoiseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - // Paul Kellet's refined method - this.b0 = 0; - this.b1 = 0; - this.b2 = 0; - this.b3 = 0; - this.b4 = 0; - this.b5 = 0; - this.b6 = 0; - } - - sample() { - const white = Math.random() * 2 - 1; - this.b0 = 0.99886 * this.b0 + white * 0.0555179; - this.b1 = 0.99332 * this.b1 + white * 0.0750759; - this.b2 = 0.96900 * this.b2 + white * 0.1538520; - this.b3 = 0.86650 * this.b3 + white * 0.3104856; - this.b4 = 0.55000 * this.b4 + white * 0.5329522; - this.b5 = -0.7616 * this.b5 - white * 0.0168980; - const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362; - this.b6 = white * 0.115926; - return pink * 0.11; // Normalize - } -} - -/** - * Perlin Noise Generator - Smooth, continuous noise - */ -export class PerlinNoiseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - this.octaves = config.octaves || 4; - this.persistence = config.persistence || 0.5; - } - - // Simple 1D Perlin-like noise - noise(x) { - const i = Math.floor(x); - const f = x - i; - - // Fade curve - const u = f * f * (3 - 2 * f); - - // Hash function for pseudo-random gradients - const hash = (n) => { - n = (n << 13) ^ n; - return (1.0 - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824.0); - }; - - return (1 - u) * hash(i) + u * hash(i + 1); - } - - sample() { - let value = 0; - let amplitude = 1; - let frequency = this.frequency; - let maxValue = 0; - - for (let i = 0; i < this.octaves; i++) { - value += this.noise(this.time * frequency) * amplitude; - maxValue += amplitude; - amplitude *= this.persistence; - frequency *= 2; - } - - return value / maxValue; - } -} - -/** - * Pulse/Spike Generator - Random spikes/pulses - */ -export class PulseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.pulseRate = config.pulseRate || 0.05; // Probability per sample - this.pulseWidth = config.pulseWidth || 0.01; // Duration in seconds - this.pulseAmplitude = config.pulseAmplitude || 1.0; - this.currentPulse = null; - } - - sample() { - // Check if we're in a pulse - if (this.currentPulse) { - if (this.time >= this.currentPulse.endTime) { - this.currentPulse = null; - } else { - return this.pulseAmplitude; - } - } - - // Random chance to start new pulse - if (Math.random() < this.pulseRate) { - this.currentPulse = { - startTime: this.time, - endTime: this.time + this.pulseWidth, - }; - return this.pulseAmplitude; - } - - return 0; - } -} - -/** - * Burst Generator - Bursts of activity with quiet periods - */ -export class BurstGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.burstDuration = config.burstDuration || 1.0; // Seconds - this.quietDuration = config.quietDuration || 2.0; // Seconds - this.burstFrequency = config.burstFrequency || 5.0; // Hz during burst - this.currentState = 'quiet'; - this.stateStartTime = 0; - } - - sample() { - const elapsed = this.time - this.stateStartTime; - - // State transitions - if (this.currentState === 'quiet' && elapsed >= this.quietDuration) { - this.currentState = 'burst'; - this.stateStartTime = this.time; - } else if (this.currentState === 'burst' && elapsed >= this.burstDuration) { - this.currentState = 'quiet'; - this.stateStartTime = this.time; - } - - // Generate value based on state - if (this.currentState === 'burst') { - return Math.sin(2 * Math.PI * this.burstFrequency * this.time); - } else { - return 0; - } - } -} - -/** - * Chirp Generator - Frequency sweep signal - */ -export class ChirpGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.startFreq = config.startFreq || 0.5; // Hz - this.endFreq = config.endFreq || 10.0; // Hz - this.duration = config.duration || 5.0; // Seconds - } - - sample() { - const t = this.time % this.duration; - const progress = t / this.duration; - const freq = this.startFreq + (this.endFreq - this.startFreq) * progress; - return Math.sin(2 * Math.PI * freq * t); - } -} - -/** - * Composite Generator - Combine multiple generators - */ -export class CompositeGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.generators = config.generators || []; - this.weights = config.weights || this.generators.map(() => 1.0); - } - - sample() { - let sum = 0; - let weightSum = 0; - - for (let i = 0; i < this.generators.length; i++) { - sum += this.generators[i].sample() * this.weights[i]; - weightSum += this.weights[i]; - } - - return weightSum > 0 ? sum / weightSum : 0; - } - - generateSamples(count) { - const samples = []; - for (let i = 0; i < count; i++) { - samples.push(this.sample()); - // Advance all child generators - this.generators.forEach(gen => gen.time += 1 / gen.sampleRate); - } - return samples; - } -} - -/** - * FM (Frequency Modulation) Generator - One signal modulates another - */ -export class FMGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.carrierFreq = config.carrierFreq || 5.0; // Hz - this.modulatorFreq = config.modulatorFreq || 0.5; // Hz - this.modulationIndex = config.modulationIndex || 2.0; - } - - sample() { - const modulator = Math.sin(2 * Math.PI * this.modulatorFreq * this.time); - const instantFreq = this.carrierFreq + this.modulationIndex * modulator; - return Math.sin(2 * Math.PI * instantFreq * this.time); - } -} - -/** - * Exponential Decay Generator - Exponentially decaying signal - */ -export class ExponentialDecayGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.decayRate = config.decayRate || 1.0; // 1/seconds - this.oscillationFreq = config.oscillationFreq || 5.0; // Hz - } - - sample() { - const envelope = Math.exp(-this.decayRate * this.time); - const oscillation = Math.sin(2 * Math.PI * this.oscillationFreq * this.time); - return envelope * oscillation; - } -} - -/** - * Step Function Generator - Random walk / brownian motion - */ -export class RandomWalkGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.stepSize = config.stepSize || 0.1; - this.currentValue = 0; - this.bounds = config.bounds || { min: -5, max: 5 }; - } - - sample() { - // Random step - const step = (Math.random() - 0.5) * this.stepSize; - this.currentValue += step; - - // Apply bounds - this.currentValue = Math.max(this.bounds.min, Math.min(this.bounds.max, this.currentValue)); - - return this.currentValue; - } -} - -// ============================================================================ -// Example Usage and Presets -// ============================================================================ - -/** - * Factory function to create common test scenarios - */ -export class TestDataFactory { - static createSimpleSine(amplitude = 30) { - return new SineWaveGenerator({ - frequency: 2.0, - amplitude: amplitude, - sampleRate: 100, - }); - } - - static createNoisySine(amplitude = 30) { - const sine = new SineWaveGenerator({ - frequency: 2.0, - amplitude: amplitude * 0.8, - sampleRate: 100, - }); - - const noise = new WhiteNoiseGenerator({ - amplitude: amplitude * 0.2, - sampleRate: 100, - }); - - return new CompositeGenerator({ - generators: [sine, noise], - weights: [1.0, 1.0], - }); - } - - static createComplexPattern(amplitude = 30) { - const low = new SineWaveGenerator({ - frequency: 0.5, - amplitude: amplitude * 0.4, - sampleRate: 100, - }); - - const mid = new SineWaveGenerator({ - frequency: 3.0, - amplitude: amplitude * 0.3, - sampleRate: 100, - }); - - const high = new SineWaveGenerator({ - frequency: 8.0, - amplitude: amplitude * 0.2, - sampleRate: 100, - }); - - const noise = new PinkNoiseGenerator({ - amplitude: amplitude * 0.1, - sampleRate: 100, - }); - - return new CompositeGenerator({ - generators: [low, mid, high, noise], - weights: [1.0, 1.0, 1.0, 1.0], - }); - } - - static createBurstySignal(amplitude = 30) { - return new BurstGenerator({ - amplitude: amplitude, - burstDuration: 0.5, - quietDuration: 1.5, - burstFrequency: 10.0, - sampleRate: 100, - }); - } - - static createSmoothNoise(amplitude = 30) { - return new PerlinNoiseGenerator({ - amplitude: amplitude, - frequency: 2.0, - octaves: 3, - persistence: 0.5, - sampleRate: 100, - }); - } - - static createFrequencySweep(amplitude = 30) { - return new ChirpGenerator({ - amplitude: amplitude, - startFreq: 0.5, - endFreq: 10.0, - duration: 3.0, - sampleRate: 100, - }); - } - - static createModulatedSignal(amplitude = 30) { - return new FMGenerator({ - amplitude: amplitude, - carrierFreq: 5.0, - modulatorFreq: 0.3, - modulationIndex: 3.0, - sampleRate: 100, - }); - } - - static createRandomWalk(amplitude = 30) { - return new RandomWalkGenerator({ - stepSize: 0.5, - bounds: { min: -amplitude, max: amplitude }, - sampleRate: 100, - }); - } -} - -/** - * Example: How to use with WaterfallGraph - * - * // Create a generator - * const generator = TestDataFactory.createComplexPattern(30); - * - * // In your graph's addLine method: - * addLine(time, graphIdx) { - * const line = { - * points: generator.generateLine(this.pointsPerLine, this.width), - * yOffset: 0, - * color: this.generateColor(time), - * }; - * this.lines.push(line); - * } - * - * // Or generate custom samples: - * const samples = generator.generateSamples(100); - * const points = samples.map((y, i) => ({ - * x: (i / samples.length) * width, - * y: y - * })); - */ diff --git a/web-timeplot/src/timeseries-plot.js b/web-timeplot/src/timeseries-plot.js deleted file mode 100644 index e35a704..0000000 --- a/web-timeplot/src/timeseries-plot.js +++ /dev/null @@ -1,277 +0,0 @@ -import { Container, Graphics, Text } from 'pixi.js'; - -/** - * TimeSeriesPlot - Pure visualization component for time-series data - * - * This class is responsible ONLY for displaying data, not generating it. - * It receives data points from external sources and renders them as a - * scrolling waterfall display. - * - * Architecture: - * - TimeSeriesPlot: Displays data (this file) - * - DataSource: Generates/provides data (data-sources.js) - * - Connection: Links sources to plots - */ -export class TimeSeriesPlot { - constructor(config) { - this.x = config.x || 0; - this.y = config.y || 0; - this.width = config.width || 800; - this.height = config.height || 600; - this.title = config.title || 'Time Series'; - this.baseColor = config.color || 0xff6666; - - // Container for all graphics - this.container = new Container(); - this.container.x = this.x; - this.container.y = this.y; - - // Graphics layers (order matters for rendering) - this.gridGraphics = new Graphics(); - this.linesGraphics = new Graphics(); - this.borderGraphics = new Graphics(); - - this.container.addChild(this.gridGraphics); - this.container.addChild(this.linesGraphics); - this.container.addChild(this.borderGraphics); - - // Title - this.titleText = new Text({ - text: this.title, - style: { - fontFamily: 'Arial', - fontSize: 18, - fill: 0xeeeeee, - } - }); - this.titleText.x = 10; - this.titleText.y = 10; - this.container.addChild(this.titleText); - - // Display state - this.lines = []; // Array of {points, yOffset, color, metadata} - this.maxLines = config.maxLines || 100; - this.showGrid = config.showGrid !== false; - - // Scrolling and scaling - this.scrollSpeed = config.scrollSpeed || 1.0; - this.verticalScale = config.verticalScale || 1.0; - - // Initial draw - this.draw(); - } - - // ======================================================================== - // Data Input API - This is how external sources send data to the plot - // ======================================================================== - - /** - * Add a new line of data to the plot - * @param {Array<{x: number, y: number}>} points - Array of points - * @param {Object} metadata - Optional metadata (color, timestamp, etc.) - */ - addLine(points, metadata = {}) { - const line = { - points: points, - yOffset: 0, - color: metadata.color || this.generateColor(Date.now() / 1000), - timestamp: metadata.timestamp || Date.now(), - metadata: metadata, - }; - - this.lines.push(line); - - // Limit number of lines - if (this.lines.length > this.maxLines) { - this.lines.shift(); - } - } - - /** - * Add a single data point (will be buffered into a line) - * This is useful for streaming real-time data - * @param {number} timestamp - Time of the data point - * @param {number} value - Value at this time - */ - addDataPoint(timestamp, value) { - // For now, this creates a single-point line - // In a more sophisticated version, this could buffer points - // until a full line is ready - const point = { x: this.width / 2, y: value }; - this.addLine([point], { timestamp }); - } - - /** - * Clear all data from the plot - */ - clearData() { - this.lines = []; - this.drawLines(); - } - - // ======================================================================== - // Update and Rendering - // ======================================================================== - - /** - * Update the plot - called each frame - * This handles scrolling and cleanup, but NOT data generation - */ - update() { - // Scroll existing lines down - this.scrollLines(); - - // Remove off-screen lines - this.lines = this.lines.filter(line => { - const scaledOffset = line.yOffset * this.verticalScale; - return scaledOffset < this.height + 50; - }); - - // Redraw - this.drawLines(); - } - - scrollLines() { - this.lines.forEach(line => { - line.yOffset += this.scrollSpeed; - }); - } - - draw() { - this.drawBorder(); - this.drawGrid(); - this.drawLines(); - } - - drawBorder() { - this.borderGraphics.clear(); - this.borderGraphics.rect(0, 0, this.width, this.height); - this.borderGraphics.stroke({ width: 2, color: 0x606070 }); - } - - drawGrid() { - this.gridGraphics.clear(); - - if (!this.showGrid) return; - - this.gridGraphics.alpha = 0.3; - - const divisions = 10; - const color = 0x4a7a9a; - - // Vertical lines - for (let i = 0; i <= divisions; i++) { - const x = (i / divisions) * this.width; - this.gridGraphics.moveTo(x, 0); - this.gridGraphics.lineTo(x, this.height); - this.gridGraphics.stroke({ width: 1, color }); - } - - // Horizontal lines - for (let i = 0; i <= divisions; i++) { - const y = (i / divisions) * this.height; - this.gridGraphics.moveTo(0, y); - this.gridGraphics.lineTo(this.width, y); - this.gridGraphics.stroke({ width: 1, color }); - } - } - - drawLines() { - this.linesGraphics.clear(); - - for (const line of this.lines) { - if (line.points.length < 2) continue; - - // Apply vertical scale to y positions - const scaledYOffset = line.yOffset * this.verticalScale; - - // Start path - const firstPoint = line.points[0]; - this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset); - - // Draw line strip - for (let i = 1; i < line.points.length; i++) { - const point = line.points[i]; - this.linesGraphics.lineTo(point.x, point.y + scaledYOffset); - } - - this.linesGraphics.stroke({ width: 2, color: line.color }); - } - } - - generateColor(time) { - // Cycle through colors based on time - const hue = (time * 0.1) % 1.0; - const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255); - const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255); - const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255); - - return (r << 16) | (g << 8) | b; - } - - // ======================================================================== - // Configuration and Control - // ======================================================================== - - setGridVisible(visible) { - this.showGrid = visible; - this.drawGrid(); - } - - setScrollSpeed(speed) { - this.scrollSpeed = Math.max(0.1, Math.min(10.0, speed)); - } - - setVerticalScale(scale) { - this.verticalScale = Math.max(0.2, Math.min(3.0, scale)); - } - - setTitle(title) { - this.title = title; - this.titleText.text = title; - } - - resize(x, y, width, height) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - - this.container.x = x; - this.container.y = y; - - this.draw(); - } - - // ======================================================================== - // Statistics and Debugging - // ======================================================================== - - getVertexCount() { - return this.lines.reduce((sum, line) => sum + line.points.length, 0); - } - - getLineCount() { - return this.lines.length; - } - - getOldestTimestamp() { - if (this.lines.length === 0) return null; - return Math.min(...this.lines.map(l => l.timestamp)); - } - - getNewestTimestamp() { - if (this.lines.length === 0) return null; - return Math.max(...this.lines.map(l => l.timestamp)); - } - - getStats() { - return { - lineCount: this.getLineCount(), - vertexCount: this.getVertexCount(), - oldestTimestamp: this.getOldestTimestamp(), - newestTimestamp: this.getNewestTimestamp(), - timeSpan: this.getNewestTimestamp() - this.getOldestTimestamp(), - }; - } -} 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> + `; + } +} diff --git a/web-timeplot/src/utils-format.js b/web-timeplot/src/utils-format.js new file mode 100644 index 0000000..f4eac88 --- /dev/null +++ b/web-timeplot/src/utils-format.js @@ -0,0 +1,22 @@ +export function formatDuration(ms) { + const totalSeconds = Math.max(0, ms / 1000); + if (totalSeconds < 60) { + return `${totalSeconds.toFixed(2)}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toFixed(1)}s`; +} + +export function formatWallClock(timestampMs) { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestampMs)); +} + +export function formatValue(value) { + return Number.isFinite(value) ? value.toFixed(3) : '—'; +} diff --git a/web-timeplot/src/waterfall.js b/web-timeplot/src/waterfall.js deleted file mode 100644 index bce0750..0000000 --- a/web-timeplot/src/waterfall.js +++ /dev/null @@ -1,219 +0,0 @@ -import { Container, Graphics, Text } from 'pixi.js'; - -/** - * WaterfallGraph - A scrolling waterfall display - * Starts simple with basic line rendering - */ -export class WaterfallGraph { - constructor(config) { - this.x = config.x; - this.y = config.y; - this.width = config.width; - this.height = config.height; - this.title = config.title; - this.baseColor = config.color || 0xff6666; - - this.container = new Container(); - this.container.x = this.x; - this.container.y = this.y; - - // Graphics layers - this.borderGraphics = new Graphics(); - this.gridGraphics = new Graphics(); - this.linesGraphics = new Graphics(); - - this.container.addChild(this.gridGraphics); - this.container.addChild(this.linesGraphics); - this.container.addChild(this.borderGraphics); - - // Title text - this.titleText = new Text({ - text: this.title, - style: { - fontFamily: 'Arial', - fontSize: 18, - fill: 0xeeeeee, - } - }); - this.titleText.x = 10; - this.titleText.y = 10; - this.container.addChild(this.titleText); - - // Waterfall data - this.lines = []; - this.maxLines = 50; - this.pointsPerLine = 100; - this.frameCounter = 0; - - this.showGrid = true; - - // Time scaling and zoom - this.scrollSpeed = 1.0; // Speed multiplier for scrolling - this.baseScrollSpeed = 1.0; - this.verticalScale = 1.0; // Vertical zoom: >1 = zoomed in (see less history), <1 = zoomed out (see more) - - this.draw(); - } - - draw() { - this.drawBorder(); - this.drawGrid(); - } - - drawBorder() { - this.borderGraphics.clear(); - this.borderGraphics.rect(0, 0, this.width, this.height); - this.borderGraphics.stroke({ width: 2, color: 0x606070 }); - } - - drawGrid() { - this.gridGraphics.clear(); - - if (!this.showGrid) return; - - this.gridGraphics.alpha = 0.3; - - const divisions = 10; - const color = 0x4a7a9a; - - // Vertical lines - for (let i = 0; i <= divisions; i++) { - const x = (i / divisions) * this.width; - this.gridGraphics.moveTo(x, 0); - this.gridGraphics.lineTo(x, this.height); - this.gridGraphics.stroke({ width: 1, color }); - } - - // Horizontal lines - for (let i = 0; i <= divisions; i++) { - const y = (i / divisions) * this.height; - this.gridGraphics.moveTo(0, y); - this.gridGraphics.lineTo(this.width, y); - this.gridGraphics.stroke({ width: 1, color }); - } - } - - update(time, graphIdx) { - this.frameCounter++; - - // Add new line every 10 frames - if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) { - this.addLine(time, graphIdx); - } - - // Scroll existing lines down - this.scrollLines(); - - // Remove off-screen lines - this.lines = this.lines.filter(line => line.yOffset < this.height + 50); - - // Redraw all lines - this.drawLines(); - } - - addLine(time, graphIdx) { - const line = { - points: [], - yOffset: 0, - color: this.generateColor(time), - }; - - // Generate sine wave points - const phase = time + (graphIdx * 2); - const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0; - - for (let i = 0; i < this.pointsPerLine; i++) { - const x = (i / this.pointsPerLine) * this.width; - const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1 - const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels - - line.points.push({ x, y }); - } - - this.lines.push(line); - } - - scrollLines() { - const speed = this.baseScrollSpeed * this.scrollSpeed; - this.lines.forEach(line => { - line.yOffset += speed; - }); - } - - setScrollSpeed(speed) { - // Clamp between 0.1 (slow) and 5.0 (fast) - this.scrollSpeed = Math.max(0.1, Math.min(5.0, speed)); - } - - getScrollSpeed() { - return this.scrollSpeed; - } - - setVerticalScale(scale) { - // Clamp between 0.2 (zoomed out, see more history) and 3.0 (zoomed in, see less) - this.verticalScale = Math.max(0.2, Math.min(3.0, scale)); - } - - getVerticalScale() { - return this.verticalScale; - } - - drawLines() { - this.linesGraphics.clear(); - - for (const line of this.lines) { - if (line.points.length < 2) continue; - - // Apply vertical scale to y positions - // Current time is at top (y=0), older data has larger yOffset - const scaledYOffset = line.yOffset * this.verticalScale; - - // Start path - const firstPoint = line.points[0]; - this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset); - - // Draw line strip - for (let i = 1; i < line.points.length; i++) { - const point = line.points[i]; - this.linesGraphics.lineTo(point.x, point.y + scaledYOffset); - } - - this.linesGraphics.stroke({ width: 2, color: line.color }); - } - } - - generateColor(time) { - // Cycle through colors based on time - const hue = (time * 0.1) % 1.0; - const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255); - const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255); - const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255); - - return (r << 16) | (g << 8) | b; - } - - setGridVisible(visible) { - this.showGrid = visible; - this.drawGrid(); - } - - resize(x, y, width, height) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - - this.container.x = x; - this.container.y = y; - - this.draw(); - } - - getVertexCount() { - return this.lines.reduce((sum, line) => sum + line.points.length, 0); - } - - getLineCount() { - return this.lines.length; - } -} |
