diff options
Diffstat (limited to 'web-timeplot/src')
27 files changed, 0 insertions, 6694 deletions
diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js deleted file mode 100644 index 4f4f0fc..0000000 --- a/web-timeplot/src/app/create-app.js +++ /dev/null @@ -1,449 +0,0 @@ -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 { parseReplayCsv } from '../data/parse-replay-csv.js'; -import { PanelManager } from '../ui/panel-manager.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function buildDeltaPoints(points) { - if (points.length < 2) { - return []; - } - - const derived = []; - for (let index = 1; index < points.length; index += 1) { - const previous = points[index - 1]; - const current = points[index]; - const deltaTime = Math.max(1, current.timeMs - previous.timeMs); - derived.push({ - ...current, - value: (current.value - previous.value) / deltaTime * 1000, - sourceId: `${current.sourceId}:delta`, - }); - } - - return derived; -} - -function buildSmoothedPoints(points, windowSize = 5) { - if (points.length === 0) { - return []; - } - - const smoothed = []; - for (let index = 0; index < points.length; index += 1) { - const start = Math.max(0, index - windowSize + 1); - const windowPoints = points.slice(start, index + 1); - const average = windowPoints.reduce((sum, point) => sum + point.value, 0) / windowPoints.length; - smoothed.push({ - ...points[index], - value: average, - sourceId: `${points[index].sourceId}:smooth`, - }); - } - - return smoothed; -} - -function transformPoints(points, transform) { - switch (transform) { - case 'delta': - return buildDeltaPoints(points); - case 'smooth': - return buildSmoothedPoints(points); - case 'raw': - default: - return points; - } -} - -function describeTransform(transform) { - switch (transform) { - case 'delta': - return 'Δvalue / second'; - case 'smooth': - return 'moving average'; - case 'raw': - default: - return 'raw signal'; - } -} - -function deriveValueRange(points, fallbackRange) { - if (points.length === 0) { - return fallbackRange; - } - - let min = Infinity; - let max = -Infinity; - for (const point of points) { - min = Math.min(min, point.value); - max = Math.max(max, point.value); - } - - const maxAbs = Math.max(Math.abs(min), Math.abs(max), 0.1); - return { - min: -maxAbs, - max: maxAbs, - }; -} - -function pickActiveHover(primaryCandidate, secondaryCandidate) { - if (!primaryCandidate && !secondaryCandidate) { - return null; - } - - if (primaryCandidate && !secondaryCandidate) { - return primaryCandidate; - } - - if (!primaryCandidate && secondaryCandidate) { - return secondaryCandidate; - } - - return primaryCandidate.lastPointerEventAt >= secondaryCandidate.lastPointerEventAt - ? primaryCandidate - : secondaryCandidate; -} - -export async function createApp(root) { - const bus = new EventBus(); - const store = new Store(createInitialState()); - const timeController = new TimeController(store); - const sourceBuffers = new Map(Object.keys(store.getState().sources).map((sourceKey) => [sourceKey, new PlotBuffer(store.getState().plot.maxPoints)])); - let sourceRegistry; - - const syncBuffersFromState = () => { - const state = store.getState(); - for (const sourceKey of Object.keys(state.sources)) { - if (!sourceBuffers.has(sourceKey)) { - sourceBuffers.set(sourceKey, new PlotBuffer(state.plot.maxPoints)); - } - sourceBuffers.get(sourceKey).maxPoints = state.plot.maxPoints; - } - - for (const sourceKey of Array.from(sourceBuffers.keys())) { - if (!state.sources[sourceKey]) { - sourceBuffers.delete(sourceKey); - } - } - }; - - const clearSourceBuffer = (sourceKey) => { - sourceBuffers.get(sourceKey)?.clear(); - }; - - const getGraphPoints = (state, graphId) => { - const graphConfig = state.graphs[graphId]; - const sourceBuffer = sourceBuffers.get(graphConfig.sourceKey); - const basePoints = sourceBuffer - ? sourceBuffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs) - : []; - const transformedPoints = transformPoints(basePoints, graphConfig.transform); - return { - graphConfig, - points: transformedPoints, - range: deriveValueRange(transformedPoints, state.plot.valueRange), - }; - }; - - const actions = { - togglePause: () => timeController.togglePause(), - setSpeed: (speed) => timeController.setSpeed(speed), - resetScene: () => { - timeController.reset(); - sourceBuffers.forEach((plotBuffer) => plotBuffer.clear()); - sourceRegistry.reset(); - }, - togglePanel: (panelId) => { - store.setState((state) => ({ - ...state, - panels: { - ...state.panels, - [panelId]: { - ...state.panels[panelId], - visible: !state.panels[panelId].visible, - }, - }, - })); - }, - updateSource: (sourceKey, field, value) => { - store.setState((state) => ({ - ...state, - sources: { - ...state.sources, - [sourceKey]: { - ...state.sources[sourceKey], - [field]: value, - ...(field === 'type' - ? { - loadError: value === 'csv-replay' && state.sources[sourceKey].dataset.length === 0 - ? (state.sources[sourceKey].dataFileName - ? `Reload ${state.sources[sourceKey].dataFileName} to restore replay data` - : 'Load a CSV file to begin replay') - : '', - wsStatus: value === 'websocket' ? state.sources[sourceKey].wsStatus : 'idle', - wsStatusDetail: value === 'websocket' ? state.sources[sourceKey].wsStatusDetail : '', - } - : {}), - }, - }, - })); - sourceRegistry.syncFromState(); - syncBuffersFromState(); - - if (field === 'type' || field === 'wsUrl' || field === 'wsReconnectMs') { - clearSourceBuffer(sourceKey); - sourceRegistry.reset(); - } - }, - loadSourceFile: async (sourceKey, file) => { - try { - const state = store.getState(); - const sampleRateHz = state.sources[sourceKey]?.sampleRateHz ?? 60; - const text = await file.text(); - const { points, metadata } = parseReplayCsv(text, { sampleRateHz }); - - clearSourceBuffer(sourceKey); - store.setState((currentState) => ({ - ...currentState, - sources: { - ...currentState.sources, - [sourceKey]: { - ...currentState.sources[sourceKey], - type: 'csv-replay', - dataset: points, - dataFileName: file.name, - datasetPointCount: metadata.pointCount, - datasetDurationMs: metadata.durationMs, - loadError: '', - wsStatus: 'idle', - wsStatusDetail: '', - }, - }, - })); - sourceRegistry.syncFromState(); - sourceRegistry.reset(); - } catch (error) { - store.setState((currentState) => ({ - ...currentState, - sources: { - ...currentState.sources, - [sourceKey]: { - ...currentState.sources[sourceKey], - loadError: error instanceof Error ? error.message : String(error), - }, - }, - })); - } - }, - updatePlot: (field, value) => { - store.setState((state) => ({ - ...state, - plot: { - ...state.plot, - [field]: value, - }, - })); - - if (field === 'maxPoints') { - buffer.maxPoints = clamp(value, 200, 4000); - sourceBuffers.forEach((plotBuffer) => { - plotBuffer.maxPoints = clamp(value, 200, 4000); - }); - } - }, - updateGraph: (graphId, field, value) => { - store.setState((state) => ({ - ...state, - graphs: { - ...state.graphs, - [graphId]: { - ...state.graphs[graphId], - [field]: value, - }, - }, - })); - }, - }; - - const panelManager = new PanelManager({ root, store, actions }); - const elements = panelManager.mount(); - - const plotView = new TimeplotView({ - host: elements.primaryCanvasHost, - panelId: 'primary', - title: 'Primary signal', - subtitle: null, - showReadouts: true, - lineColor: 0x9fd1ff, - pointColor: 0xe7f2ff, - }); - - const secondaryPlotView = new TimeplotView({ - host: elements.secondaryCanvasHost, - panelId: 'secondary', - title: 'Secondary signal', - subtitle: null, - showReadouts: false, - lineColor: 0xffc46b, - pointColor: 0xffe1b0, - }); - - const renderer = await plotView.init(); - await secondaryPlotView.init(); - store.patch({ - app: { - ...store.getState().app, - renderer, - }, - }); - - sourceRegistry = new SourceRegistry(store, bus); - - bus.on('data:point', (point) => { - sourceBuffers.get(point.sourceId)?.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(); - syncBuffersFromState(); - sourceRegistry.update(store.getState().time.plotTimeMs); - - const state = store.getState(); - const primaryGraph = getGraphPoints(state, 'primary'); - const secondaryGraph = getGraphPoints(state, 'secondary'); - - plotView.panelTitle = state.graphs.primary.title; - plotView.panelSubtitle = `${state.sources[state.graphs.primary.sourceKey].label} · ${describeTransform(state.graphs.primary.transform)} · time ↓`; - secondaryPlotView.panelTitle = state.graphs.secondary.title; - secondaryPlotView.panelSubtitle = `${state.sources[state.graphs.secondary.sourceKey].label} · ${describeTransform(state.graphs.secondary.transform)} · time ↓`; - - const primaryState = { - ...state, - plot: { - ...state.plot, - valueRange: primaryGraph.range, - }, - }; - - const secondaryState = { - ...state, - plot: { - ...state.plot, - valueRange: secondaryGraph.range, - }, - }; - - plotView.render(primaryState, primaryGraph.points); - secondaryPlotView.render(secondaryState, secondaryGraph.points); - - const primaryHover = plotView.getHoverCandidate(); - const secondaryHover = secondaryPlotView.getHoverCandidate(); - const activeHover = pickActiveHover(primaryHover, secondaryHover); - - if (!activeHover) { - plotView.clearHover(); - secondaryPlotView.clearHover(); - store.setState((currentState) => ({ - ...currentState, - plot: { - ...currentState.plot, - hoveredPoint: null, - tooltip: { - ...currentState.plot.tooltip, - visible: false, - point: null, - linkedPoint: null, - }, - }, - })); - panelManager.sync(store.getState(), { - primary: primaryGraph.points.length, - secondary: secondaryGraph.points.length, - }); - return; - } - - const primaryLinkedPoint = plotView.findNearestScreenPointByTime(activeHover.point.timeMs); - const secondaryLinkedPoint = secondaryPlotView.findNearestScreenPointByTime(activeHover.point.timeMs); - - plotView.renderLinkedHover(primaryLinkedPoint); - secondaryPlotView.renderLinkedHover(secondaryLinkedPoint); - - const activePanelLabel = activeHover.panelId === 'secondary' - ? state.graphs.secondary.title - : state.graphs.primary.title; - const linkedPoint = activeHover.panelId === 'secondary' ? primaryLinkedPoint : secondaryLinkedPoint; - const linkedPanelLabel = activeHover.panelId === 'secondary' - ? state.graphs.primary.title - : state.graphs.secondary.title; - - store.setState((currentState) => ({ - ...currentState, - plot: { - ...currentState.plot, - hoveredPoint: activeHover.point, - tooltip: { - ...currentState.plot.tooltip, - visible: true, - panelId: activeHover.panelId, - panelLabel: activePanelLabel, - x: activeHover.x, - y: activeHover.y, - point: activeHover.point, - linkedPoint, - linkedPanelLabel, - }, - }, - })); - - panelManager.sync(store.getState(), { - primary: primaryGraph.points.length, - secondary: secondaryGraph.points.length, - }); - }); - - return { - destroy() { - window.removeEventListener('keydown', keyHandler); - plotView.destroy(); - secondaryPlotView.destroy(); - }, - }; -} diff --git a/web-timeplot/src/bootstrap.js b/web-timeplot/src/bootstrap.js deleted file mode 100644 index 4b073bc..0000000 --- a/web-timeplot/src/bootstrap.js +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 192eb6d..0000000 --- a/web-timeplot/src/core/event-bus.js +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 38052eb..0000000 --- a/web-timeplot/src/core/store.js +++ /dev/null @@ -1,291 +0,0 @@ -const STORAGE_KEY = 'timeplot.app-state.v1'; - -function clonePanelState(panels) { - return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }])); -} - -function cloneNamedState(items) { - return Object.fromEntries(Object.entries(items).map(([key, value]) => [key, { ...value }])); -} - -function sanitizePersistedSource(source) { - return { - type: source.type, - preset: source.preset, - sampleRateHz: source.sampleRateHz, - amplitude: source.amplitude, - noise: source.noise, - replayRate: source.replayRate, - dataFileName: source.dataFileName, - wsUrl: source.wsUrl, - wsReconnectMs: source.wsReconnectMs, - }; -} - -function createPersistableState(state) { - return { - plot: { - showGrid: state.plot.showGrid, - showPoints: state.plot.showPoints, - windowDurationMs: state.plot.windowDurationMs, - maxPoints: state.plot.maxPoints, - }, - time: { - speed: state.time.speed, - }, - panels: clonePanelState(state.panels), - graphs: cloneNamedState(state.graphs), - sources: Object.fromEntries(Object.entries(state.sources).map(([key, value]) => [ - key, - sanitizePersistedSource(value), - ])), - }; -} - -function mergePersistedState(baseState, persistedState) { - if (!persistedState || typeof persistedState !== 'object') { - return baseState; - } - - const mergedState = { - ...baseState, - time: persistedState.time - ? { - ...baseState.time, - speed: persistedState.time.speed ?? baseState.time.speed, - paused: false, - } - : baseState.time, - plot: persistedState.plot - ? { - ...baseState.plot, - ...persistedState.plot, - valueRange: baseState.plot.valueRange, - hoveredPoint: null, - tooltip: { ...baseState.plot.tooltip }, - } - : baseState.plot, - panels: persistedState.panels - ? clonePanelState(Object.fromEntries(Object.entries(baseState.panels).map(([key, value]) => [ - key, - { - ...value, - ...(persistedState.panels[key] ?? {}), - }, - ]))) - : baseState.panels, - graphs: persistedState.graphs - ? cloneNamedState(Object.fromEntries(Object.entries(baseState.graphs).map(([key, value]) => [ - key, - { - ...value, - ...(persistedState.graphs[key] ?? {}), - }, - ]))) - : baseState.graphs, - sources: persistedState.sources - ? Object.fromEntries(Object.entries(baseState.sources).map(([key, value]) => { - const persistedSource = persistedState.sources[key] ?? {}; - const nextType = persistedSource.type ?? value.type; - - return [ - key, - { - ...value, - ...persistedSource, - type: nextType, - dataset: [], - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: nextType === 'csv-replay' && persistedSource.dataFileName - ? `Reload ${persistedSource.dataFileName} to restore replay data` - : '', - wsStatus: 'idle', - wsStatusDetail: '', - }, - ]; - })) - : baseState.sources, - }; - - return mergedState; -} - -function loadPersistedState() { - if (typeof localStorage === 'undefined') { - return null; - } - - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - return null; - } - - return JSON.parse(raw); - } catch (error) { - console.warn('[timeplot] failed to load persisted state', error); - return null; - } -} - -function savePersistedState(state) { - if (typeof localStorage === 'undefined') { - return; - } - - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(createPersistableState(state))); - } catch (error) { - console.warn('[timeplot] failed to persist state', error); - } -} - -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, - }, - }, - sources: { - signalA: { - id: 'signal-a', - label: 'Signal A', - type: 'synthetic-wave', - preset: 'telemetry', - sampleRateHz: 60, - amplitude: 1, - noise: 0.08, - replayRate: 1, - dataset: [], - dataFileName: '', - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: '', - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - wsStatus: 'idle', - wsStatusDetail: '', - }, - signalB: { - id: 'signal-b', - label: 'Signal B', - type: 'synthetic-wave', - preset: 'chirp', - sampleRateHz: 48, - amplitude: 0.8, - noise: 0.04, - replayRate: 1, - dataset: [], - dataFileName: '', - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: '', - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - wsStatus: 'idle', - wsStatusDetail: '', - }, - }, - graphs: { - primary: { - sourceKey: 'signalA', - transform: 'raw', - title: 'Primary signal', - }, - secondary: { - sourceKey: 'signalB', - transform: 'delta', - title: 'Secondary signal', - }, - }, - 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 = mergePersistedState(initialState, loadPersistedState()); - 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; - savePersistedState(this.state); - 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, - sources: partial.sources - ? Object.fromEntries(Object.entries({ ...state.sources, ...partial.sources }).map(([key, value]) => [ - key, - { ...state.sources[key], ...value }, - ])) - : state.sources, - graphs: partial.graphs - ? cloneNamedState(Object.fromEntries(Object.entries({ ...state.graphs, ...partial.graphs }).map(([key, value]) => [ - key, - { ...state.graphs[key], ...value }, - ]))) - : state.graphs, - 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 deleted file mode 100644 index 7cd57c7..0000000 --- a/web-timeplot/src/core/time-controller.js +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 55dbdc3..0000000 --- a/web-timeplot/src/data/base-source.js +++ /dev/null @@ -1,21 +0,0 @@ -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/csv-replay-source.js b/web-timeplot/src/data/csv-replay-source.js deleted file mode 100644 index c4e6a66..0000000 --- a/web-timeplot/src/data/csv-replay-source.js +++ /dev/null @@ -1,60 +0,0 @@ -import { BaseSource } from './base-source.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -export class CsvReplaySource extends BaseSource { - constructor(config = {}) { - super({ - replayRate: 1, - dataset: [], - ...config, - }); - this.sourceType = 'csv-replay'; - this.nextPointIndex = 0; - } - - start(startTimeMs = 0) { - super.start(); - this.reset(startTimeMs); - } - - reset() { - this.nextPointIndex = 0; - } - - updateConfig(nextConfig) { - const datasetChanged = nextConfig.dataset !== this.config.dataset; - super.updateConfig(nextConfig); - if (datasetChanged) { - this.reset(); - } - } - - update(currentPlotTimeMs) { - if (!this.running || !Array.isArray(this.config.dataset) || this.config.dataset.length === 0) { - return []; - } - - const replayRate = clamp(this.config.replayRate ?? 1, 0.1, 8); - const targetDatasetTimeMs = currentPlotTimeMs * replayRate; - const points = []; - - while (this.nextPointIndex < this.config.dataset.length) { - const datasetPoint = this.config.dataset[this.nextPointIndex]; - if (datasetPoint.timeMs > targetDatasetTimeMs) { - break; - } - - points.push({ - timeMs: datasetPoint.timeMs / replayRate, - value: datasetPoint.value, - sourceId: this.config.id ?? 'csv-replay', - }); - this.nextPointIndex += 1; - } - - return points; - } -} diff --git a/web-timeplot/src/data/parse-replay-csv.js b/web-timeplot/src/data/parse-replay-csv.js deleted file mode 100644 index b6ce97a..0000000 --- a/web-timeplot/src/data/parse-replay-csv.js +++ /dev/null @@ -1,108 +0,0 @@ -function splitRow(line) { - return line.split(/[;,\t]/).map((value) => value.trim()); -} - -function isNumeric(value) { - return value !== '' && Number.isFinite(Number(value)); -} - -function detectHeader(rows) { - if (rows.length === 0) { - return { hasHeader: false, headers: [] }; - } - - const [firstRow] = rows; - const hasHeader = firstRow.some((value) => !isNumeric(value)); - return { - hasHeader, - headers: hasHeader ? firstRow.map((value) => value.toLowerCase()) : [], - }; -} - -function detectTimeScale(headers) { - const timeHeader = headers.find((header) => header.includes('time') || header.includes('timestamp')); - if (!timeHeader) { - return 1; - } - - if (timeHeader.includes('sec') && !timeHeader.includes('msec') && !timeHeader.includes('ms')) { - return 1000; - } - - return 1; -} - -function detectColumnIndexes(headers, columnCount) { - if (headers.length === 0) { - return { - timeIndex: columnCount > 1 ? 0 : -1, - valueIndex: columnCount > 1 ? 1 : 0, - }; - } - - const timeIndex = headers.findIndex((header) => header.includes('time') || header.includes('timestamp')); - const valueIndex = headers.findIndex((header) => header.includes('value') || header.includes('signal') || header.includes('y')); - - return { - timeIndex, - valueIndex: valueIndex >= 0 ? valueIndex : (headers.length > 1 ? 1 : 0), - }; -} - -export function parseReplayCsv(text, { sampleRateHz = 60 } = {}) { - const rows = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map(splitRow) - .filter((row) => row.some((value) => value !== '')); - - if (rows.length === 0) { - throw new Error('CSV file is empty'); - } - - const { hasHeader, headers } = detectHeader(rows); - const dataRows = hasHeader ? rows.slice(1) : rows; - const columnCount = rows[0].length; - const { timeIndex, valueIndex } = detectColumnIndexes(headers, columnCount); - const timeScale = detectTimeScale(headers); - const intervalMs = 1000 / Math.max(1, sampleRateHz); - - const points = dataRows - .map((row, index) => { - const rawValue = row[valueIndex]; - if (!isNumeric(rawValue)) { - return null; - } - - const parsedValue = Number(rawValue); - const parsedTime = timeIndex >= 0 && isNumeric(row[timeIndex]) - ? Number(row[timeIndex]) * timeScale - : index * intervalMs; - - return { - timeMs: parsedTime, - value: parsedValue, - }; - }) - .filter(Boolean) - .sort((left, right) => left.timeMs - right.timeMs); - - if (points.length === 0) { - throw new Error('CSV file did not contain any numeric data points'); - } - - const firstTime = points[0].timeMs; - const normalizedPoints = points.map((point) => ({ - timeMs: point.timeMs - firstTime, - value: point.value, - })); - - return { - points: normalizedPoints, - metadata: { - pointCount: normalizedPoints.length, - durationMs: normalizedPoints.at(-1)?.timeMs ?? 0, - }, - }; -} diff --git a/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js deleted file mode 100644 index 917d06b..0000000 --- a/web-timeplot/src/data/source-registry.js +++ /dev/null @@ -1,90 +0,0 @@ -import { CsvReplaySource } from './csv-replay-source.js'; -import { SyntheticWaveSource } from './synthetic-wave-source.js'; -import { WebSocketSource } from './websocket-source.js'; - -export class SourceRegistry { - constructor(store, bus) { - this.store = store; - this.bus = bus; - this.sources = new Map(); - this.syncFromState(); - } - - syncFromState() { - const state = this.store.getState(); - const sourceEntries = Object.entries(state.sources); - const activeKeys = new Set(sourceEntries.map(([sourceKey]) => sourceKey)); - - for (const [sourceKey, config] of sourceEntries) { - const existingSource = this.sources.get(sourceKey); - - if (!existingSource) { - const nextSource = this.createSource(sourceKey, config); - this.sources.set(sourceKey, nextSource); - nextSource.start(state.time.plotTimeMs); - continue; - } - - if (existingSource.sourceType !== config.type) { - existingSource.stop(); - const replacementSource = this.createSource(sourceKey, config); - this.sources.set(sourceKey, replacementSource); - replacementSource.start(state.time.plotTimeMs); - continue; - } - - existingSource.updateConfig(config); - } - - for (const [sourceKey, source] of this.sources.entries()) { - if (!activeKeys.has(sourceKey)) { - source.stop(); - this.sources.delete(sourceKey); - } - } - } - - createSource(sourceKey, config) { - switch (config.type) { - case 'csv-replay': - return new CsvReplaySource(config); - case 'websocket': - return new WebSocketSource(config, { - onStatusChange: (statusPatch) => { - this.store.setState((state) => ({ - ...state, - sources: { - ...state.sources, - [sourceKey]: { - ...state.sources[sourceKey], - ...statusPatch, - }, - }, - })); - }, - }); - case 'synthetic-wave': - default: - return new SyntheticWaveSource(config); - } - } - - update(currentPlotTimeMs) { - for (const [sourceKey, source] of this.sources.entries()) { - const points = source.update(currentPlotTimeMs); - for (const point of points) { - this.bus.emit('data:point', { - ...point, - sourceId: sourceKey, - }); - } - } - } - - reset() { - const startTimeMs = this.store.getState().time.plotTimeMs; - for (const source of this.sources.values()) { - source.reset(startTimeMs); - } - } -} diff --git a/web-timeplot/src/data/synthetic-wave-source.js b/web-timeplot/src/data/synthetic-wave-source.js deleted file mode 100644 index df53319..0000000 --- a/web-timeplot/src/data/synthetic-wave-source.js +++ /dev/null @@ -1,87 +0,0 @@ -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.sourceType = 'synthetic-wave'; - 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/data/websocket-source.js b/web-timeplot/src/data/websocket-source.js deleted file mode 100644 index 5458fb9..0000000 --- a/web-timeplot/src/data/websocket-source.js +++ /dev/null @@ -1,224 +0,0 @@ -import { BaseSource } from './base-source.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function isFiniteNumber(value) { - return typeof value === 'number' && Number.isFinite(value); -} - -function parsePayload(payload) { - if (Array.isArray(payload)) { - return payload.flatMap((item) => parsePayload(item)); - } - - if (isFiniteNumber(payload)) { - return [{ value: payload, timestampMs: null }]; - } - - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (!trimmed) { - return []; - } - - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - return [{ value: numeric, timestampMs: null }]; - } - - try { - return parsePayload(JSON.parse(trimmed)); - } catch { - return []; - } - } - - if (payload && typeof payload === 'object') { - const candidateValue = [payload.value, payload.y, payload.signal, payload.data] - .find((value) => Number.isFinite(Number(value))); - - if (candidateValue === undefined) { - return []; - } - - const candidateTimestamp = [payload.timeMs, payload.timestampMs, payload.timestamp, payload.t] - .find((value) => Number.isFinite(Number(value))); - - return [{ - value: Number(candidateValue), - timestampMs: candidateTimestamp === undefined ? null : Number(candidateTimestamp), - }]; - } - - return []; -} - -export class WebSocketSource extends BaseSource { - constructor(config = {}, { onStatusChange } = {}) { - super({ - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - ...config, - }); - this.sourceType = 'websocket'; - this.onStatusChange = onStatusChange; - this.socket = null; - this.queue = []; - this.lastPlotTimeMs = 0; - this.reconnectTimer = null; - this.shouldReconnect = false; - this.firstSourceTimestampMs = null; - this.basePlotTimeMs = 0; - } - - start(startTimeMs = 0) { - super.start(); - this.lastPlotTimeMs = startTimeMs; - this.basePlotTimeMs = startTimeMs; - this.shouldReconnect = true; - this.connect(); - } - - stop() { - super.stop(); - this.shouldReconnect = false; - this.clearReconnectTimer(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - this.setStatus('disconnected', 'socket closed'); - } - - reset(startTimeMs = 0) { - this.queue = []; - this.lastPlotTimeMs = startTimeMs; - this.basePlotTimeMs = startTimeMs; - this.firstSourceTimestampMs = null; - } - - updateConfig(nextConfig) { - const previousUrl = this.config.wsUrl; - const previousReconnectMs = this.config.wsReconnectMs; - super.updateConfig(nextConfig); - - if ((previousUrl !== this.config.wsUrl || previousReconnectMs !== this.config.wsReconnectMs) && this.running) { - this.reconnect(); - } - } - - update(currentPlotTimeMs) { - this.lastPlotTimeMs = currentPlotTimeMs; - - if (this.queue.length === 0) { - return []; - } - - const points = []; - while (this.queue.length > 0) { - const nextPoint = this.queue.shift(); - let timeMs = currentPlotTimeMs; - - if (isFiniteNumber(nextPoint.timestampMs)) { - if (this.firstSourceTimestampMs === null) { - this.firstSourceTimestampMs = nextPoint.timestampMs; - this.basePlotTimeMs = currentPlotTimeMs; - } - timeMs = this.basePlotTimeMs + (nextPoint.timestampMs - this.firstSourceTimestampMs); - } - - points.push({ - timeMs, - value: nextPoint.value, - sourceId: this.config.id ?? 'websocket', - }); - } - - return points; - } - - reconnect() { - if (!this.running) { - return; - } - - this.clearReconnectTimer(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - this.connect(); - } - - connect() { - const url = this.config.wsUrl?.trim(); - if (!url) { - this.setStatus('idle', 'enter a websocket url'); - return; - } - - this.clearReconnectTimer(); - this.setStatus('connecting', url); - - try { - this.socket = new WebSocket(url); - } catch (error) { - this.setStatus('error', error instanceof Error ? error.message : String(error)); - this.scheduleReconnect(); - return; - } - - this.socket.addEventListener('open', () => { - this.setStatus('connected', url); - }); - - this.socket.addEventListener('message', (event) => { - const parsedPoints = parsePayload(event.data); - if (parsedPoints.length === 0) { - return; - } - this.queue.push(...parsedPoints); - }); - - this.socket.addEventListener('error', () => { - this.setStatus('error', 'socket error'); - }); - - this.socket.addEventListener('close', () => { - this.socket = null; - if (!this.running) { - return; - } - this.setStatus('disconnected', 'retrying'); - this.scheduleReconnect(); - }); - } - - scheduleReconnect() { - if (!this.shouldReconnect || !this.running) { - return; - } - - const reconnectMs = clamp(Number(this.config.wsReconnectMs) || 2000, 250, 30000); - this.clearReconnectTimer(); - this.reconnectTimer = window.setTimeout(() => { - this.connect(); - }, reconnectMs); - } - - clearReconnectTimer() { - if (this.reconnectTimer !== null) { - window.clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } - - setStatus(status, detail = '') { - this.onStatusChange?.({ - wsStatus: status, - wsStatusDetail: detail, - }); - } -} diff --git a/web-timeplot/src/demos.js b/web-timeplot/src/demos.js deleted file mode 100644 index 1dd6785..0000000 --- a/web-timeplot/src/demos.js +++ /dev/null @@ -1,697 +0,0 @@ -/** - * Preloaded Graphics Demos - * - * Each demo exports: - * - name: Display name - * - description: Short description - * - setup(app, state): Called once to create objects - * - update(app, state, objects): Called every frame - * - cleanup(app, objects): Called when switching demos - */ - -// ============================================================================ -// DEMO 1: BOUNCING PARTICLES -// ============================================================================ - -export const bouncingParticles = { - name: "Bouncing Particles", - description: "Colorful particles bouncing around the screen", - - setup(app, state) { - const particles = []; - const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7]; - - for (let i = 0; i < 50; i++) { - const particle = new PIXI.Graphics(); - const size = 5 + Math.random() * 10; - particle.circle(0, 0, size); - particle.fill(colors[Math.floor(Math.random() * colors.length)]); - - particle.x = Math.random() * app.screen.width; - particle.y = Math.random() * app.screen.height; - particle.vx = (Math.random() - 0.5) * 8; - particle.vy = (Math.random() - 0.5) * 8; - particle.size = size; - - app.stage.addChild(particle); - particles.push(particle); - } - - return { particles }; - }, - - update(app, state, objects) { - objects.particles.forEach(p => { - p.x += p.vx; - p.y += p.vy; - - // Bounce off edges - if (p.x < p.size || p.x > app.screen.width - p.size) p.vx *= -1; - if (p.y < p.size || p.y > app.screen.height - p.size) p.vy *= -1; - - // Clamp to screen - p.x = Math.max(p.size, Math.min(app.screen.width - p.size, p.x)); - p.y = Math.max(p.size, Math.min(app.screen.height - p.size, p.y)); - }); - }, - - cleanup(app, objects) { - objects.particles.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// DEMO 2: SPIROGRAPH -// ============================================================================ - -export const spirograph = { - name: "Spirograph", - description: "Mesmerizing geometric spiral patterns", - - setup(app, state) { - const graphics = new PIXI.Graphics(); - app.stage.addChild(graphics); - - return { - graphics, - angle: 0, - points: [] - }; - }, - - update(app, state, objects) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const t = state.state.time.current; - - // Generate new point - const r1 = 150; - const r2 = 50; - const r3 = 30; - - const x = cx + Math.cos(t * 0.5) * r1 + Math.cos(t * 2) * r2 + Math.cos(t * 5) * r3; - const y = cy + Math.sin(t * 0.5) * r1 + Math.sin(t * 2) * r2 + Math.sin(t * 5) * r3; - - objects.points.push({ x, y }); - - // Keep only last 500 points - if (objects.points.length > 500) { - objects.points.shift(); - } - - // Draw trail - objects.graphics.clear(); - if (objects.points.length > 1) { - for (let i = 1; i < objects.points.length; i++) { - const alpha = i / objects.points.length; - const hue = (i / objects.points.length) * 360; - objects.graphics.moveTo(objects.points[i-1].x, objects.points[i-1].y); - objects.graphics.lineTo(objects.points[i].x, objects.points[i].y); - objects.graphics.stroke({ width: 2, color: hslToHex(hue, 100, 60), alpha }); - } - } - }, - - cleanup(app, objects) { - objects.graphics.destroy(); - } -}; - -// ============================================================================ -// DEMO 3: STARFIELD -// ============================================================================ - -export const starfield = { - name: "Starfield", - description: "Flying through space at warp speed", - - setup(app, state) { - const stars = []; - - for (let i = 0; i < 200; i++) { - const star = new PIXI.Graphics(); - star.circle(0, 0, 2); - star.fill(0xffffff); - - star.x = (Math.random() - 0.5) * app.screen.width * 2; - star.y = (Math.random() - 0.5) * app.screen.height * 2; - star.z = Math.random() * 1000; - - app.stage.addChild(star); - stars.push(star); - } - - return { stars }; - }, - - update(app, state, objects) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const speed = 5; - - objects.stars.forEach(star => { - star.z -= speed; - - if (star.z <= 0) { - star.z = 1000; - star.x = (Math.random() - 0.5) * app.screen.width * 2; - star.y = (Math.random() - 0.5) * app.screen.height * 2; - } - - const screenX = cx + (star.x / star.z) * 200; - const screenY = cy + (star.y / star.z) * 200; - const size = (1 - star.z / 1000) * 4 + 1; - - star.x = star.x; - star.y = star.y; - star.position.set(screenX, screenY); - star.scale.set(size); - star.alpha = 1 - star.z / 1000; - }); - }, - - cleanup(app, objects) { - objects.stars.forEach(s => s.destroy()); - } -}; - -// ============================================================================ -// DEMO 4: WAVE INTERFERENCE -// ============================================================================ - -export const waveInterference = { - name: "Wave Interference", - description: "Rippling wave patterns", - - setup(app, state) { - const gridSize = 20; - const cols = Math.floor(app.screen.width / gridSize); - const rows = Math.floor(app.screen.height / gridSize); - const circles = []; - - for (let i = 0; i < cols; i++) { - for (let j = 0; j < rows; j++) { - const circle = new PIXI.Graphics(); - circle.circle(0, 0, 4); - circle.fill(0x4ecdc4); - circle.x = i * gridSize + gridSize / 2; - circle.y = j * gridSize + gridSize / 2; - circle.baseX = circle.x; - circle.baseY = circle.y; - - app.stage.addChild(circle); - circles.push(circle); - } - } - - return { circles, sources: [ - { x: app.screen.width * 0.3, y: app.screen.height * 0.5 }, - { x: app.screen.width * 0.7, y: app.screen.height * 0.5 } - ]}; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.circles.forEach(c => { - let totalOffset = 0; - - objects.sources.forEach(source => { - const dx = c.baseX - source.x; - const dy = c.baseY - source.y; - const dist = Math.sqrt(dx * dx + dy * dy); - totalOffset += Math.sin(dist * 0.05 - t * 3) * 10; - }); - - c.y = c.baseY + totalOffset; - c.alpha = 0.3 + (Math.sin(totalOffset * 0.1) + 1) * 0.35; - }); - }, - - cleanup(app, objects) { - objects.circles.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 5: CIRCLE PACKING -// ============================================================================ - -export const circlePacking = { - name: "Circle Packing", - description: "Organic growth simulation", - - setup(app, state) { - const circles = []; - return { circles, attempts: 0 }; - }, - - update(app, state, objects) { - // Try to add a new circle each frame - const maxAttempts = 100; - const maxCircles = 150; - - if (objects.circles.length >= maxCircles) return; - - for (let i = 0; i < 10; i++) { - const x = Math.random() * app.screen.width; - const y = Math.random() * app.screen.height; - const minRadius = 5; - const maxRadius = 60; - - let valid = true; - let radius = minRadius; - - // Find largest radius that doesn't overlap - for (let r = minRadius; r < maxRadius; r++) { - let overlaps = false; - - for (const other of objects.circles) { - const dx = x - other.x; - const dy = y - other.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist < r + other.radius + 2) { - overlaps = true; - break; - } - } - - if (overlaps) { - break; - } - radius = r; - } - - if (radius > minRadius) { - const circle = new PIXI.Graphics(); - circle.circle(0, 0, radius); - const hue = (objects.circles.length * 137.5) % 360; - circle.fill(hslToHex(hue, 70, 60)); - circle.x = x; - circle.y = y; - circle.radius = radius; - - app.stage.addChild(circle); - objects.circles.push(circle); - break; - } - } - }, - - cleanup(app, objects) { - objects.circles.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 6: PERLIN FLOW FIELD -// ============================================================================ - -export const flowField = { - name: "Flow Field", - description: "Particles following a noise field", - - setup(app, state) { - const particles = []; - const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7, 0xfeca57]; - - for (let i = 0; i < 300; i++) { - const particle = new PIXI.Graphics(); - particle.circle(0, 0, 2); - particle.fill(colors[Math.floor(Math.random() * colors.length)]); - particle.alpha = 0.6; - - particle.x = Math.random() * app.screen.width; - particle.y = Math.random() * app.screen.height; - particle.vx = 0; - particle.vy = 0; - particle.color = colors[Math.floor(Math.random() * colors.length)]; - - app.stage.addChild(particle); - particles.push(particle); - } - - return { particles }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.particles.forEach(p => { - // Simple noise-like function using sin/cos - const angle = noise(p.x * 0.005, p.y * 0.005, t * 0.3) * Math.PI * 2; - - p.vx += Math.cos(angle) * 0.3; - p.vy += Math.sin(angle) * 0.3; - - // Damping - p.vx *= 0.95; - p.vy *= 0.95; - - p.x += p.vx; - p.y += p.vy; - - // Wrap around screen - if (p.x < 0) p.x = app.screen.width; - if (p.x > app.screen.width) p.x = 0; - if (p.y < 0) p.y = app.screen.height; - if (p.y > app.screen.height) p.y = 0; - }); - }, - - cleanup(app, objects) { - objects.particles.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// DEMO 7: DNA HELIX -// ============================================================================ - -export const dnaHelix = { - name: "DNA Helix", - description: "Rotating double helix structure", - - setup(app, state) { - const helix1 = []; - const helix2 = []; - const connectors = []; - const segments = 40; - - for (let i = 0; i < segments; i++) { - const sphere1 = new PIXI.Graphics(); - sphere1.circle(0, 0, 8); - sphere1.fill(0x4ecdc4); - app.stage.addChild(sphere1); - helix1.push(sphere1); - - const sphere2 = new PIXI.Graphics(); - sphere2.circle(0, 0, 8); - sphere2.fill(0xff6b6b); - app.stage.addChild(sphere2); - helix2.push(sphere2); - - const connector = new PIXI.Graphics(); - app.stage.addChild(connector); - connectors.push(connector); - } - - return { helix1, helix2, connectors }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const radius = 100; - const height = app.screen.height * 0.8; - const spacing = height / objects.helix1.length; - - objects.helix1.forEach((sphere, i) => { - const y = i * spacing - height / 2 + cy; - const angle = t + i * 0.3; - const x = cx + Math.cos(angle) * radius; - const z = Math.sin(angle) * radius; - - sphere.x = x; - sphere.y = y; - sphere.scale.set(1 + z / 200); - sphere.alpha = 0.5 + z / 400; - }); - - objects.helix2.forEach((sphere, i) => { - const y = i * spacing - height / 2 + cy; - const angle = t + i * 0.3 + Math.PI; - const x = cx + Math.cos(angle) * radius; - const z = Math.sin(angle) * radius; - - sphere.x = x; - sphere.y = y; - sphere.scale.set(1 + z / 200); - sphere.alpha = 0.5 + z / 400; - }); - - // Draw connectors - objects.connectors.forEach((connector, i) => { - connector.clear(); - connector.moveTo(objects.helix1[i].x, objects.helix1[i].y); - connector.lineTo(objects.helix2[i].x, objects.helix2[i].y); - connector.stroke({ width: 2, color: 0x666666, alpha: 0.3 }); - }); - }, - - cleanup(app, objects) { - objects.helix1.forEach(s => s.destroy()); - objects.helix2.forEach(s => s.destroy()); - objects.connectors.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 8: FIREWORKS -// ============================================================================ - -export const fireworks = { - name: "Fireworks", - description: "Explosive particle celebration", - - setup(app, state) { - return { - explosions: [], - nextExplosion: 0 - }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - // Create new explosion every second - if (t > objects.nextExplosion) { - objects.nextExplosion = t + 0.5 + Math.random(); - - const explosion = { - x: Math.random() * app.screen.width, - y: Math.random() * app.screen.height * 0.7, - particles: [], - color: Math.random() * 0xffffff, - born: t - }; - - // Create particles - for (let i = 0; i < 50; i++) { - const angle = (i / 50) * Math.PI * 2; - const speed = 2 + Math.random() * 4; - const particle = new PIXI.Graphics(); - particle.circle(0, 0, 3); - particle.fill(explosion.color); - particle.x = explosion.x; - particle.y = explosion.y; - particle.vx = Math.cos(angle) * speed; - particle.vy = Math.sin(angle) * speed; - - app.stage.addChild(particle); - explosion.particles.push(particle); - } - - objects.explosions.push(explosion); - } - - // Update explosions - objects.explosions = objects.explosions.filter(explosion => { - const age = t - explosion.born; - - if (age > 3) { - explosion.particles.forEach(p => p.destroy()); - return false; - } - - explosion.particles.forEach(p => { - p.vx *= 0.98; - p.vy += 0.1; // Gravity - p.x += p.vx; - p.y += p.vy; - p.alpha = 1 - age / 3; - }); - - return true; - }); - }, - - cleanup(app, objects) { - objects.explosions.forEach(explosion => { - explosion.particles.forEach(p => p.destroy()); - }); - } -}; - -// ============================================================================ -// DEMO 9: MATRIX RAIN -// ============================================================================ - -export const matrixRain = { - name: "Matrix Rain", - description: "Falling digital rain effect", - - setup(app, state) { - const fontSize = 16; - const columns = Math.floor(app.screen.width / fontSize); - const drops = []; - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*"; - - for (let i = 0; i < columns; i++) { - const text = new PIXI.Text('', { - fontFamily: 'monospace', - fontSize: fontSize, - fill: 0x00ff00 - }); - text.x = i * fontSize; - text.y = -Math.random() * app.screen.height; - - app.stage.addChild(text); - drops.push({ - text, - speed: 1 + Math.random() * 3, - chars: chars - }); - } - - return { drops }; - }, - - update(app, state, objects) { - objects.drops.forEach(drop => { - drop.y = (drop.y || drop.text.y) + drop.speed; - drop.text.y = drop.y; - - // Random character - if (Math.random() > 0.95) { - drop.text.text = drop.chars[Math.floor(Math.random() * drop.chars.length)]; - } - - // Reset to top - if (drop.y > app.screen.height) { - drop.y = -20; - drop.text.alpha = 1; - } - - // Fade trail - drop.text.alpha = Math.max(0.1, drop.text.alpha - 0.01); - }); - }, - - cleanup(app, objects) { - objects.drops.forEach(d => d.text.destroy()); - } -}; - -// ============================================================================ -// DEMO 10: SOLAR SYSTEM -// ============================================================================ - -export const solarSystem = { - name: "Solar System", - description: "Orbiting planets around a star", - - setup(app, state) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - - // Sun - const sun = new PIXI.Graphics(); - sun.circle(0, 0, 30); - sun.fill(0xffd700); - sun.x = cx; - sun.y = cy; - app.stage.addChild(sun); - - // Planets - const planets = [ - { radius: 60, size: 6, speed: 2.0, color: 0x8b7355 }, - { radius: 100, size: 10, speed: 1.5, color: 0xff6347 }, - { radius: 150, size: 12, speed: 1.0, color: 0x4169e1 }, - { radius: 200, size: 8, speed: 0.7, color: 0xff4500 }, - { radius: 260, size: 18, speed: 0.4, color: 0xdaa520 }, - ]; - - const planetObjects = planets.map(config => { - const planet = new PIXI.Graphics(); - planet.circle(0, 0, config.size); - planet.fill(config.color); - planet.config = config; - app.stage.addChild(planet); - return planet; - }); - - return { sun, planets: planetObjects, cx, cy }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.planets.forEach((planet, i) => { - const angle = t * planet.config.speed; - planet.x = objects.cx + Math.cos(angle) * planet.config.radius; - planet.y = objects.cy + Math.sin(angle) * planet.config.radius; - }); - }, - - cleanup(app, objects) { - objects.sun.destroy(); - objects.planets.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// UTILITIES -// ============================================================================ - -function hslToHex(h, s, l) { - s /= 100; - l /= 100; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs((h / 60) % 2 - 1)); - const m = l - c/2; - let r = 0, g = 0, b = 0; - - if (0 <= h && h < 60) { - r = c; g = x; b = 0; - } else if (60 <= h && h < 120) { - r = x; g = c; b = 0; - } else if (120 <= h && h < 180) { - r = 0; g = c; b = x; - } else if (180 <= h && h < 240) { - r = 0; g = x; b = c; - } else if (240 <= h && h < 300) { - r = x; g = 0; b = c; - } else if (300 <= h && h < 360) { - r = c; g = 0; b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return (r << 16) | (g << 8) | b; -} - -function noise(x, y, z) { - return Math.sin(x + Math.cos(y)) * Math.cos(y + Math.sin(z)) * Math.sin(z + Math.cos(x)); -} - -// ============================================================================ -// EXPORT ALL DEMOS -// ============================================================================ - -export const allDemos = [ - bouncingParticles, - spirograph, - starfield, - waveInterference, - circlePacking, - flowField, - dnaHelix, - fireworks, - matrixRain, - solarSystem -]; 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 deleted file mode 100644 index d2b348e..0000000 --- a/web-timeplot/src/main.js +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index b13cdd8..0000000 --- a/web-timeplot/src/plot/plot-buffer.js +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index ce90a1f..0000000 --- a/web-timeplot/src/plot/timeplot-view.js +++ /dev/null @@ -1,442 +0,0 @@ -import { Application, Container, Graphics, Text } from 'pixi.js'; -import { formatDuration, formatValue, formatWallClock } from '../utils-format.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, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) { - this.host = host; - this.panelId = panelId; - this.panelTitle = title; - this.panelSubtitle = subtitle; - this.showReadouts = showReadouts; - this.lineColor = lineColor; - this.pointColor = pointColor; - this.app = new Application(); - this.container = new Container(); - this.background = new Graphics(); - this.grid = new Graphics(); - this.axes = new Graphics(); - this.line = new Graphics(); - this.points = new Graphics(); - this.crosshair = new Graphics(); - this.overlay = new Container(); - this.readoutBackground = new Graphics(); - this.axisLabelLayer = 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.realTimeText = new Text({ - text: '', - style: { - fill: 0xe8eef7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 11, - }, - }); - this.plotTimeText = new Text({ - text: '', - style: { - fill: 0xe8eef7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 11, - }, - }); - this.axisTitleText = new Text({ - text: '', - style: { - fill: 0x90a0b7, - fontFamily: 'Inter, sans-serif', - fontSize: 10, - fontWeight: '600', - letterSpacing: 1.5, - }, - }); - this.screenPoints = []; - this.bounds = { width: 100, height: 100 }; - this.hoverRadiusPx = 20; - this.pointer = null; - this.lastPointerEventAt = 0; - this.axisLabels = []; - } - - 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.axes); - this.container.addChild(this.line); - this.container.addChild(this.points); - this.container.addChild(this.crosshair); - this.container.addChild(this.overlay); - this.overlay.addChild(this.readoutBackground); - this.overlay.addChild(this.axisLabelLayer); - this.overlay.addChild(this.titleText); - this.overlay.addChild(this.subtitleText); - this.overlay.addChild(this.realTimeText); - this.overlay.addChild(this.plotTimeText); - this.overlay.addChild(this.axisTitleText); - this.host.appendChild(this.app.canvas); - this.attachPointerListeners(); - - return rendererPreference; - } - - attachPointerListeners() { - this.host.addEventListener('pointerleave', () => { - this.pointer = null; - this.lastPointerEventAt = performance.now(); - }); - - this.host.addEventListener('pointermove', (event) => { - const rect = this.host.getBoundingClientRect(); - this.pointer = { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - this.lastPointerEventAt = performance.now(); - }); - } - - resize() { - this.bounds = { - width: this.host.clientWidth, - height: this.host.clientHeight, - }; - } - - render(state, points) { - this.resize(); - this.renderFrame(state, points); - this.clearHover(); - } - - clearHover() { - this.crosshair.clear(); - } - - getHoverCandidate() { - if (!this.pointer || this.screenPoints.length === 0) { - return null; - } - - 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) { - return null; - } - - return { - panelId: this.panelId, - point: nearestPoint, - x: clamp(nearestPoint.x, 0, this.bounds.width), - y: clamp(nearestPoint.y, 0, this.bounds.height), - pointerX: this.pointer.x, - pointerY: this.pointer.y, - distance: nearestDistance, - lastPointerEventAt: this.lastPointerEventAt, - }; - } - - hasPointer() { - return this.pointer !== null; - } - - findNearestScreenPointByTime(timeMs) { - if (this.screenPoints.length === 0) { - return null; - } - - let nearestPoint = null; - let nearestDelta = Infinity; - - for (const point of this.screenPoints) { - const delta = Math.abs(point.timeMs - timeMs); - if (delta < nearestDelta) { - nearestPoint = point; - nearestDelta = delta; - } - } - - return nearestPoint; - } - - renderLinkedHover(hoverPoint) { - this.crosshair.clear(); - - if (!hoverPoint) { - return; - } - - const x = clamp(hoverPoint.x, 0, this.bounds.width); - const y = clamp(hoverPoint.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: 0x8cb8ff, width: 1, alpha: 0.24 }); - this.crosshair.rect(x - 4, y - 4, 8, 8); - this.crosshair.stroke({ color: 0xffffff, width: 1.5, alpha: 0.95 }); - } - - ensureAxisLabelCount(count) { - while (this.axisLabels.length < count) { - const label = new Text({ - text: '', - style: { - fill: 0x90a0b7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 10, - }, - }); - this.axisLabels.push(label); - this.axisLabelLayer.addChild(label); - } - - while (this.axisLabels.length > count) { - const label = this.axisLabels.pop(); - this.axisLabelLayer.removeChild(label); - label.destroy(); - } - } - - renderAxes({ padding, plotWidth, plotHeight, minTime, maxTime, minValue, maxValue, width }) { - const axisColor = 0x3e4c5f; - const tickColor = 0x4f627a; - const timeTickCount = 5; - const valueTickCount = 5; - const labels = []; - - this.axes.clear(); - this.axes.moveTo(padding.left, padding.top); - this.axes.lineTo(padding.left, padding.top + plotHeight); - this.axes.lineTo(padding.left + plotWidth, padding.top + plotHeight); - this.axes.stroke({ color: axisColor, width: 1, alpha: 1 }); - - for (let index = 0; index < timeTickCount; index += 1) { - const ratio = timeTickCount === 1 ? 0 : index / (timeTickCount - 1); - const y = padding.top + ratio * plotHeight; - const timeMs = minTime + ratio * (maxTime - minTime); - - this.axes.moveTo(padding.left - 8, y); - this.axes.lineTo(padding.left, y); - this.axes.stroke({ color: tickColor, width: 1, alpha: 1 }); - - labels.push({ - text: formatDuration(timeMs), - x: 14, - y: y - 7, - anchorX: 0, - }); - } - - for (let index = 0; index < valueTickCount; index += 1) { - const ratio = valueTickCount === 1 ? 0 : index / (valueTickCount - 1); - const x = padding.left + ratio * plotWidth; - const value = minValue + ratio * (maxValue - minValue); - - this.axes.moveTo(x, padding.top + plotHeight); - this.axes.lineTo(x, padding.top + plotHeight + 8); - this.axes.stroke({ color: tickColor, width: 1, alpha: 1 }); - - labels.push({ - text: formatValue(value), - x, - y: padding.top + plotHeight + 10, - anchorX: 0.5, - }); - } - - this.ensureAxisLabelCount(labels.length); - labels.forEach((config, index) => { - const label = this.axisLabels[index]; - label.text = config.text; - label.x = config.x; - label.y = config.y; - label.anchor.set(config.anchorX, 0); - }); - - this.axisTitleText.text = 'TIME'; - this.axisTitleText.x = 18; - this.axisTitleText.y = padding.top - 18; - this.axisTitleText.rotation = 0; - - this.axes.moveTo(padding.left + plotWidth, padding.top + plotHeight); - this.axes.lineTo(width - 14, padding.top + plotHeight); - this.axes.stroke({ color: 0x202a35, width: 1, alpha: 1 }); - } - - renderReadouts(state, width) { - if (!this.showReadouts) { - this.readoutBackground.clear(); - this.realTimeText.text = ''; - this.plotTimeText.text = ''; - return; - } - - const boxWidth = 168; - const boxHeight = 22; - const gap = 6; - const left = width - boxWidth - 18; - const top = 14; - - this.readoutBackground.clear(); - this.readoutBackground.rect(left, top, boxWidth, boxHeight); - this.readoutBackground.fill({ color: 0x10161d, alpha: 1 }); - this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 }); - this.readoutBackground.rect(left, top + boxHeight + gap, boxWidth, boxHeight); - this.readoutBackground.fill({ color: 0x10161d, alpha: 1 }); - this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 }); - - this.realTimeText.text = `REAL ${formatWallClock(state.time.realNowMs)}`; - this.realTimeText.x = left + 10; - this.realTimeText.y = top + 5; - - this.plotTimeText.text = `PLOT ${formatDuration(state.time.plotTimeMs)}`; - this.plotTimeText.x = left + 10; - this.plotTimeText.y = top + boxHeight + gap + 5; - } - - renderFrame(state, points) { - const width = this.bounds.width; - const height = this.bounds.height; - const padding = { top: 72, right: 28, bottom: 46, left: 88 }; - 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, - 6, - { color: 0x05070b, alpha: 1 }, - { color: 0x2c3b4d, width: 1 }, - ); - - this.grid.clear(); - if (state.plot.showGrid) { - const gridColor = 0x21344a; - for (let x = 0; x <= 6; x += 1) { - const px = padding.left + (plotWidth * x) / 6; - this.grid.moveTo(px, padding.top); - this.grid.lineTo(px, padding.top + plotHeight); - this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 }); - } - - for (let y = 0; y <= 8; y += 1) { - const py = padding.top + (plotHeight * y) / 8; - this.grid.moveTo(padding.left, py); - this.grid.lineTo(padding.left + plotWidth, py); - this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 }); - } - } - - this.renderAxes({ - padding, - plotWidth, - plotHeight, - minTime, - maxTime, - minValue, - maxValue, - width, - }); - - this.line.clear(); - this.points.clear(); - this.screenPoints = []; - - if (points.length > 0) { - points.forEach((point, index) => { - const x = padding.left + ((point.value - minValue) / valueSpan) * plotWidth; - const y = padding.top + ((point.timeMs - minTime) / (maxTime - minTime)) * 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: this.lineColor, - width: 2, - alpha: 0.96, - cap: 'square', - join: 'miter', - }); - - if (state.plot.showPoints) { - for (const point of this.screenPoints) { - this.points.rect(point.x - 2, point.y - 2, 4, 4); - this.points.fill({ color: this.pointColor, alpha: 0.92 }); - } - } - } - - this.titleText.text = this.panelTitle; - this.titleText.x = 20; - this.titleText.y = 14; - - this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`; - this.subtitleText.x = 20; - this.subtitleText.y = 36; - - this.renderReadouts(state, width); - } - - 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 deleted file mode 100644 index 6b0477f..0000000 --- a/web-timeplot/src/styles.css +++ /dev/null @@ -1,401 +0,0 @@ -:root { - color-scheme: dark; - --bg: #0a0c10; - --bg-alt: #0f1319; - --surface: #11161d; - --surface-strong: #0d1117; - --surface-raised: #171d26; - --border: #28313d; - --border-strong: #394657; - --text: #edf2f7; - --muted: #97a3b4; - --accent: #9fc7ff; - --accent-strong: #d8e8ff; - --shadow: none; - 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: - linear-gradient(180deg, #080a0d 0%, #0d1015 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: 10px; - padding: 10px; -} - -.timeplot-topbar { - grid-column: 1 / -1; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 14px; - border: 1px solid var(--border-strong); - background: var(--surface); - border-radius: 4px; - box-shadow: var(--shadow); -} - -.timeplot-brand { - display: flex; - flex-direction: column; - gap: 2px; -} - -.timeplot-title { - margin: 0; - font-size: 1rem; - letter-spacing: 0.08em; - text-transform: uppercase; - font-weight: 700; -} - -.timeplot-subtitle { - color: var(--muted); - font-size: 0.78rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.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: 6px 8px; - background: var(--surface-raised); - border: 1px solid var(--border); - border-radius: 3px; -} - -.control-group label, -.control-group span { - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.control-group input[type='range'] { - width: 118px; -} - -.control-group input[type='range'] { - accent-color: var(--accent); -} - -.control-button, -.panel-toggle { - color: var(--text); - background: var(--surface); - border: 1px solid var(--border-strong); - border-radius: 2px; - padding: 7px 11px; - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease, color 120ms ease; - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.72rem; - line-height: 1; -} - -.control-button:hover, -.panel-toggle:hover { - border-color: var(--accent); - color: var(--accent-strong); -} - -.control-button[data-active='true'], -.panel-toggle[data-active='true'] { - background: #1a2230; - border-color: var(--accent); - color: var(--accent-strong); -} - -.timeplot-viewport { - position: relative; - min-height: 0; - border-radius: 4px; - overflow: hidden; - border: 1px solid var(--border-strong); - background: #06080b; - box-shadow: var(--shadow); - padding: 10px; -} - -.timeplot-plot-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - width: 100%; - height: 100%; - min-height: 0; -} - -.timeplot-plot-panel { - position: relative; - min-width: 0; - min-height: 0; - border: 1px solid var(--border); - background: #070a0d; -} - -.timeplot-canvas-host { - width: 100%; - height: 100%; -} - -.timeplot-sidebar { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - overflow-y: auto; - padding-right: 2px; -} - -.panel { - border: 1px solid var(--border-strong); - background: var(--surface-strong); - border-radius: 4px; - padding: 14px; -} - -.panel[hidden] { - display: none; -} - -.panel h2 { - margin: 0 0 12px; - font-size: 0.8rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.panel-subsection + .panel-subsection { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--border); -} - -.panel-section-title { - margin-bottom: 10px; - color: var(--accent-strong); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.kv-list { - display: grid; - grid-template-columns: auto 1fr; - gap: 10px 12px; - align-items: center; - margin: 0; -} - -.kv-list dt { - color: var(--muted); - font-size: 0.73rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.kv-list dd { - margin: 0; - text-align: right; - font-variant-numeric: tabular-nums; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; -} - -.field-grid { - display: grid; - gap: 12px; -} - -.field-grid label { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.field-grid[data-source-mode][hidden] { - display: none; -} - -.source-meta { - min-height: 20px; - color: var(--muted); - font-size: 0.76rem; - line-height: 1.4; -} - -.source-meta-error { - color: #ff9d9d; -} - -.source-meta-status { - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.source-meta-status-connected { - color: #99e2b4; -} - -.source-meta-status-connecting { - color: #ffd27f; -} - -.source-meta-status-disconnected, -.source-meta-status-idle { - color: var(--muted); -} - -.source-meta-status-error { - color: #ff9d9d; -} - -.field-grid input, -.field-grid select { - width: 100%; - padding: 9px 10px; - border-radius: 2px; - border: 1px solid var(--border); - background: var(--surface-raised); - color: var(--text); -} - -.field-grid input:focus, -.field-grid select:focus { - outline: none; - border-color: var(--accent); -} - -.panel-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.panel-row + .panel-row { - margin-top: 10px; -} - -.panel-row input[type='checkbox'] { - inline-size: 16px; - block-size: 16px; - accent-color: var(--accent); -} - -.muted { - color: var(--muted); -} - -.help-list { - display: grid; - gap: 8px; - margin: 0; - padding-left: 18px; - color: var(--muted); - font-size: 0.82rem; -} - -.timeplot-tooltip { - position: absolute; - min-width: 180px; - padding: 10px 12px; - border-radius: 3px; - border: 1px solid var(--border-strong); - background: #0d1218; - box-shadow: var(--shadow); - pointer-events: none; - transform: translate(12px, -50%); - z-index: 10; -} - -.timeplot-tooltip[hidden] { - display: none; -} - -.timeplot-tooltip-title { - margin-bottom: 8px; - font-size: 0.72rem; - color: var(--accent-strong); - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.timeplot-tooltip-row { - display: flex; - justify-content: space-between; - gap: 16px; - font-size: 0.78rem; -} - -.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-plot-grid { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: repeat(2, minmax(260px, 1fr)); - } - - .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 deleted file mode 100644 index ad29697..0000000 --- a/web-timeplot/src/ui/panel-manager.js +++ /dev/null @@ -1,542 +0,0 @@ -import { formatDuration, formatValue, formatWallClock } from '../utils-format.js'; - -function createElement(tagName, className, textContent) { - const element = document.createElement(tagName); - if (className) { - element.className = className; - } - if (textContent) { - element.textContent = textContent; - } - return element; -} - -function setToggleState(element, active) { - element.dataset.active = String(active); -} - -function readControlValue(element) { - if (element.tagName === 'SELECT') { - return element.value; - } - - if (element instanceof HTMLInputElement) { - if (element.type === 'checkbox') { - return element.checked; - } - - if (element.type === 'number' || element.type === 'range') { - return Number(element.value); - } - - return element.value; - } - - return element.value; -} - -function syncControlValue(element, value) { - if (!element || document.activeElement === element) { - return; - } - - if (element instanceof HTMLInputElement && element.type === 'checkbox') { - element.checked = Boolean(value); - return; - } - - element.value = String(value ?? ''); -} - -export class PanelManager { - constructor({ root, store, actions }) { - this.root = root; - this.store = store; - this.actions = actions; - this.elements = {}; - } - - mount() { - const shell = createElement('div', 'timeplot-shell'); - const topbar = createElement('header', 'timeplot-topbar'); - const viewport = createElement('section', 'timeplot-viewport'); - const plotGrid = createElement('div', 'timeplot-plot-grid'); - const primaryPlotPanel = createElement('section', 'timeplot-plot-panel'); - const secondaryPlotPanel = createElement('section', 'timeplot-plot-panel'); - const primaryCanvasHost = createElement('div', 'timeplot-canvas-host'); - const secondaryCanvasHost = createElement('div', 'timeplot-canvas-host'); - const sidebar = createElement('aside', 'timeplot-sidebar'); - const primaryTooltip = createElement('div', 'timeplot-tooltip'); - const secondaryTooltip = createElement('div', 'timeplot-tooltip'); - primaryTooltip.hidden = true; - secondaryTooltip.hidden = true; - - const brand = createElement('div', 'timeplot-brand'); - const title = createElement('h1', 'timeplot-title', 'TimePlot'); - const subtitle = createElement('div', 'timeplot-subtitle', 'Dual synchronized signal monitor'); - brand.append(title, subtitle); - - const toolbar = createElement('div', 'timeplot-toolbar'); - toolbar.append( - this.createTransportControls(), - this.createPanelToggles(), - ); - - topbar.append(brand, toolbar); - primaryPlotPanel.append(primaryCanvasHost, primaryTooltip); - secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip); - plotGrid.append(primaryPlotPanel, secondaryPlotPanel); - viewport.append(plotGrid); - shell.append(topbar, viewport, sidebar); - this.root.replaceChildren(shell); - - this.elements = { - ...this.elements, - shell, - topbar, - viewport, - plotGrid, - primaryPlotPanel, - secondaryPlotPanel, - primaryCanvasHost, - secondaryCanvasHost, - sidebar, - primaryTooltip, - secondaryTooltip, - title, - subtitle, - statusPanel: this.createStatusPanel(), - sourcePanel: this.createSourcePanel(), - configPanel: this.createConfigPanel(), - helpPanel: this.createHelpPanel(), - }; - - sidebar.append( - this.elements.statusPanel, - this.elements.sourcePanel, - this.elements.configPanel, - this.elements.helpPanel, - ); - - return this.elements; - } - - createTransportControls() { - const wrapper = createElement('div', 'control-group'); - const pauseButton = createElement('button', 'control-button', 'Pause'); - const resetButton = createElement('button', 'control-button', 'Reset'); - const speedLabel = createElement('span', null, 'Speed'); - const speedInput = document.createElement('input'); - speedInput.type = 'range'; - speedInput.min = '0.1'; - speedInput.max = '6'; - speedInput.step = '0.1'; - const speedValue = createElement('span', null, '1.0×'); - - pauseButton.addEventListener('click', () => this.actions.togglePause()); - resetButton.addEventListener('click', () => this.actions.resetScene()); - speedInput.addEventListener('input', (event) => this.actions.setSpeed(Number(event.target.value))); - - wrapper.append(pauseButton, resetButton, speedLabel, speedInput, speedValue); - this.elements.pauseButton = pauseButton; - this.elements.resetButton = resetButton; - this.elements.speedInput = speedInput; - this.elements.speedValue = speedValue; - return wrapper; - } - - createPanelToggles() { - const wrapper = createElement('div', 'control-group'); - const panelIds = ['status', 'source', 'config', 'help']; - this.elements.panelButtons = {}; - - for (const panelId of panelIds) { - const button = createElement('button', 'panel-toggle', panelId); - button.addEventListener('click', () => this.actions.togglePanel(panelId)); - this.elements.panelButtons[panelId] = button; - wrapper.append(button); - } - - return wrapper; - } - - createStatusPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <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="panel-subsection" data-source-config="signalA"> - <div class="panel-section-title">Signal A</div> - <div class="field-grid"> - <label> - Source type - <select data-source-key="signalA" data-source-field="type"> - <option value="synthetic-wave">Synthetic wave</option> - <option value="csv-replay">CSV replay</option> - <option value="websocket">WebSocket</option> - </select> - </label> - </div> - <div class="field-grid" data-source-mode="synthetic-wave"> - <label> - Preset - <select data-source-key="signalA" 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-key="signalA" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" /> - </label> - <label> - Amplitude - <input data-source-key="signalA" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" /> - </label> - <label> - Noise - <input data-source-key="signalA" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" /> - </label> - </div> - <div class="field-grid" data-source-mode="csv-replay"> - <label> - CSV file - <input data-source-key="signalA" data-source-file="dataset" type="file" accept=".csv,text/csv" /> - </label> - <label> - Replay rate - <input data-source-key="signalA" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" /> - </label> - <div class="source-meta" data-source-key="signalA" data-source-meta></div> - </div> - <div class="field-grid" data-source-mode="websocket"> - <label> - WebSocket URL - <input data-source-key="signalA" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" /> - </label> - <label> - Reconnect (ms) - <input data-source-key="signalA" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" /> - </label> - <div class="source-meta" data-source-key="signalA" data-source-ws-meta></div> - </div> - </div> - <div class="panel-subsection" data-source-config="signalB"> - <div class="panel-section-title">Signal B</div> - <div class="field-grid"> - <label> - Source type - <select data-source-key="signalB" data-source-field="type"> - <option value="synthetic-wave">Synthetic wave</option> - <option value="csv-replay">CSV replay</option> - <option value="websocket">WebSocket</option> - </select> - </label> - </div> - <div class="field-grid" data-source-mode="synthetic-wave"> - <label> - Preset - <select data-source-key="signalB" 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-key="signalB" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" /> - </label> - <label> - Amplitude - <input data-source-key="signalB" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" /> - </label> - <label> - Noise - <input data-source-key="signalB" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" /> - </label> - </div> - <div class="field-grid" data-source-mode="csv-replay"> - <label> - CSV file - <input data-source-key="signalB" data-source-file="dataset" type="file" accept=".csv,text/csv" /> - </label> - <label> - Replay rate - <input data-source-key="signalB" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" /> - </label> - <div class="source-meta" data-source-key="signalB" data-source-meta></div> - </div> - <div class="field-grid" data-source-mode="websocket"> - <label> - WebSocket URL - <input data-source-key="signalB" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" /> - </label> - <label> - Reconnect (ms) - <input data-source-key="signalB" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" /> - </label> - <div class="source-meta" data-source-key="signalB" data-source-ws-meta></div> - </div> - </div> - `; - - panel.querySelectorAll('[data-source-field]').forEach((input) => { - const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; - input.addEventListener(eventName, () => { - const sourceKey = input.getAttribute('data-source-key'); - const field = input.getAttribute('data-source-field'); - const value = readControlValue(input); - this.actions.updateSource(sourceKey, field, value); - }); - }); - - panel.querySelectorAll('[data-source-file]').forEach((input) => { - input.addEventListener('change', async () => { - const sourceKey = input.getAttribute('data-source-key'); - const file = input.files?.[0]; - if (!file) { - return; - } - - await this.actions.loadSourceFile(sourceKey, file); - input.value = ''; - }); - }); - - return panel; - } - - createConfigPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <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> - <div class="panel-subsection"> - <div class="panel-section-title">Graph routing</div> - <div class="field-grid"> - <label> - Primary graph source - <select data-graph-id="primary" data-graph-field="sourceKey"> - <option value="signalA">Signal A</option> - <option value="signalB">Signal B</option> - </select> - </label> - <label> - Primary graph transform - <select data-graph-id="primary" data-graph-field="transform"> - <option value="raw">Raw</option> - <option value="delta">Delta</option> - <option value="smooth">Smooth</option> - </select> - </label> - <label> - Secondary graph source - <select data-graph-id="secondary" data-graph-field="sourceKey"> - <option value="signalA">Signal A</option> - <option value="signalB">Signal B</option> - </select> - </label> - <label> - Secondary graph transform - <select data-graph-id="secondary" data-graph-field="transform"> - <option value="raw">Raw</option> - <option value="delta">Delta</option> - <option value="smooth">Smooth</option> - </select> - </label> - </div> - </div> - `; - - panel.querySelectorAll('[data-plot-field]').forEach((input) => { - const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input'; - input.addEventListener(eventName, () => { - const field = input.getAttribute('data-plot-field'); - const value = readControlValue(input); - this.actions.updatePlot(field, value); - }); - }); - - panel.querySelectorAll('[data-graph-field]').forEach((input) => { - input.addEventListener('change', () => { - const graphId = input.getAttribute('data-graph-id'); - const field = input.getAttribute('data-graph-field'); - this.actions.updateGraph(graphId, field, input.value); - }); - }); - - return panel; - } - - createHelpPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <h2>Help</h2> - <ol class="help-list"> - <li>Each signal can be synthetic or file-backed CSV replay.</li> - <li>Each graph can target Signal A or Signal B independently.</li> - <li>Each graph can render raw, delta, or smoothed data.</li> - <li>Hover either trace to inspect the nearest synchronized sample.</li> - <li>Use pause and speed controls to inspect timing behavior.</li> - </ol> - `; - return panel; - } - - sync(state, visiblePoints) { - this.elements.title.textContent = state.app.title; - this.elements.subtitle.textContent = 'Dual synchronized signal monitor'; - this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause'; - setToggleState(this.elements.pauseButton, state.time.paused); - syncControlValue(this.elements.speedInput, state.time.speed); - this.elements.speedValue.textContent = `${state.time.speed.toFixed(1)}×`; - - const statusFields = this.elements.statusPanel.querySelectorAll('[data-field]'); - const fieldMap = Object.fromEntries(Array.from(statusFields).map((field) => [field.getAttribute('data-field'), field])); - fieldMap.renderer.textContent = state.app.renderer; - fieldMap.realTime.textContent = formatWallClock(state.time.realNowMs); - fieldMap.realElapsed.textContent = formatDuration(state.time.realElapsedMs); - fieldMap.plotTime.textContent = formatDuration(state.time.plotTimeMs); - fieldMap.playback.textContent = state.time.paused ? 'Paused' : `${state.time.speed.toFixed(1)}×`; - fieldMap.points.textContent = typeof visiblePoints === 'object' - ? `${visiblePoints.primary} / ${visiblePoints.secondary}` - : `${visiblePoints}`; - - this.syncSourcePanel(state); - this.syncConfigPanel(state); - this.syncPanels(state); - this.syncTooltip(state); - } - - syncSourcePanel(state) { - Object.entries(state.sources).forEach(([sourceKey, sourceConfig]) => { - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="type"]`), sourceConfig.type); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="preset"]`), sourceConfig.preset); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="sampleRateHz"]`), sourceConfig.sampleRateHz); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="amplitude"]`), sourceConfig.amplitude); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="noise"]`), sourceConfig.noise); - const replayRateInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="replayRate"]`); - if (replayRateInput) { - syncControlValue(replayRateInput, sourceConfig.replayRate ?? 1); - } - - const sourceSection = this.elements.sourcePanel.querySelector(`[data-source-config="${sourceKey}"]`); - sourceSection.querySelectorAll('[data-source-mode]').forEach((modeSection) => { - modeSection.hidden = modeSection.getAttribute('data-source-mode') !== sourceConfig.type; - }); - - const meta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-meta]`); - if (meta) { - if (sourceConfig.type === 'csv-replay') { - meta.innerHTML = sourceConfig.loadError - ? `<span class="source-meta-error">${sourceConfig.loadError}</span>` - : `${sourceConfig.dataFileName || 'No file loaded'}${sourceConfig.datasetPointCount ? ` · ${sourceConfig.datasetPointCount} pts · ${formatDuration(sourceConfig.datasetDurationMs || 0)}` : ''}`; - } else if (sourceConfig.type === 'websocket') { - meta.textContent = ''; - } else { - meta.textContent = 'Generates data procedurally in-browser'; - } - } - - const wsUrlInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsUrl"]`); - const wsReconnectInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsReconnectMs"]`); - const wsMeta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-ws-meta]`); - if (wsUrlInput) { - syncControlValue(wsUrlInput, sourceConfig.wsUrl ?? ''); - } - if (wsReconnectInput) { - syncControlValue(wsReconnectInput, sourceConfig.wsReconnectMs ?? 2000); - } - if (wsMeta) { - wsMeta.innerHTML = sourceConfig.type === 'websocket' - ? `status: <span class="source-meta-status source-meta-status-${sourceConfig.wsStatus || 'idle'}">${sourceConfig.wsStatus || 'idle'}</span>${sourceConfig.wsStatusDetail ? ` · ${sourceConfig.wsStatusDetail}` : ''}` - : ''; - } - }); - } - - syncConfigPanel(state) { - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]'), state.plot.windowDurationMs); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]'), state.plot.maxPoints); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showGrid"]'), state.plot.showGrid); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showPoints"]'), state.plot.showPoints); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="sourceKey"]'), state.graphs.primary.sourceKey); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="transform"]'), state.graphs.primary.transform); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="sourceKey"]'), state.graphs.secondary.sourceKey); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="transform"]'), state.graphs.secondary.transform); - } - - syncPanels(state) { - const panelMap = { - status: this.elements.statusPanel, - source: this.elements.sourcePanel, - config: this.elements.configPanel, - help: this.elements.helpPanel, - }; - - for (const [panelId, panelState] of Object.entries(state.panels)) { - panelMap[panelId].hidden = !panelState.visible; - setToggleState(this.elements.panelButtons[panelId], panelState.visible); - } - } - - syncTooltip(state) { - const tooltipState = state.plot.tooltip; - this.elements.primaryTooltip.hidden = true; - this.elements.secondaryTooltip.hidden = true; - - if (!tooltipState.visible || !tooltipState.point) { - return; - } - - const tooltip = tooltipState.panelId === 'secondary' - ? this.elements.secondaryTooltip - : this.elements.primaryTooltip; - - tooltip.hidden = false; - tooltip.style.left = `${tooltipState.x}px`; - tooltip.style.top = `${tooltipState.y}px`; - tooltip.innerHTML = ` - <div class="timeplot-tooltip-title">Hovered sample</div> - <div class="timeplot-tooltip-row"><span class="muted">Panel</span><span>${tooltipState.panelLabel ?? 'Primary'}</span></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> - ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked panel</span><span>${tooltipState.linkedPanelLabel ?? 'Linked'}</span></div>` : ''} - ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked value</span><span>${formatValue(tooltipState.linkedPoint.value)}</span></div>` : ''} - `; - } -} diff --git a/web-timeplot/src/utils-format.js b/web-timeplot/src/utils-format.js deleted file mode 100644 index f4eac88..0000000 --- a/web-timeplot/src/utils-format.js +++ /dev/null @@ -1,22 +0,0 @@ -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; - } -} |
