diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/create-app.js | 449 | ||||
| -rw-r--r-- | src/bootstrap.js | 18 | ||||
| -rw-r--r-- | src/core/event-bus.js | 32 | ||||
| -rw-r--r-- | src/core/store.js | 291 | ||||
| -rw-r--r-- | src/core/time-controller.js | 80 | ||||
| -rw-r--r-- | src/data-sources.js | 517 | ||||
| -rw-r--r-- | src/data/base-source.js | 21 | ||||
| -rw-r--r-- | src/data/csv-replay-source.js | 60 | ||||
| -rw-r--r-- | src/data/parse-replay-csv.js | 108 | ||||
| -rw-r--r-- | src/data/source-registry.js | 90 | ||||
| -rw-r--r-- | src/data/synthetic-wave-source.js | 87 | ||||
| -rw-r--r-- | src/data/websocket-source.js | 224 | ||||
| -rw-r--r-- | src/demos.js | 697 | ||||
| -rw-r--r-- | src/example-usage.js | 535 | ||||
| -rw-r--r-- | src/main.js | 1 | ||||
| -rw-r--r-- | src/metrics.js | 142 | ||||
| -rw-r--r-- | src/plot-connections.js | 392 | ||||
| -rw-r--r-- | src/plot/plot-buffer.js | 22 | ||||
| -rw-r--r-- | src/plot/timeplot-view.js | 442 | ||||
| -rw-r--r-- | src/state.js | 420 | ||||
| -rw-r--r-- | src/styles.css | 401 | ||||
| -rw-r--r-- | src/template-for-standard-site.js | 75 | ||||
| -rw-r--r-- | src/test-data-generators.js | 530 | ||||
| -rw-r--r-- | src/timeseries-plot.js | 277 | ||||
| -rw-r--r-- | src/ui/panel-manager.js | 542 | ||||
| -rw-r--r-- | src/utils-format.js | 22 | ||||
| -rw-r--r-- | src/waterfall.js | 219 |
27 files changed, 6694 insertions, 0 deletions
diff --git a/src/app/create-app.js b/src/app/create-app.js new file mode 100644 index 0000000..4f4f0fc --- /dev/null +++ b/src/app/create-app.js @@ -0,0 +1,449 @@ +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/src/bootstrap.js b/src/bootstrap.js new file mode 100644 index 0000000..4b073bc --- /dev/null +++ b/src/bootstrap.js @@ -0,0 +1,18 @@ +import './styles.css'; +import { createApp } from './app/create-app.js'; + +const root = document.getElementById('app'); + +if (!root) { + throw new Error('App root not found'); +} + +createApp(root).catch((error) => { + console.error('Failed to start TimePlot', error); + root.innerHTML = ` + <div style="padding: 24px; color: #fff; font-family: sans-serif;"> + <h1>TimePlot failed to start</h1> + <pre>${String(error)}</pre> + </div> + `; +}); diff --git a/src/core/event-bus.js b/src/core/event-bus.js new file mode 100644 index 0000000..192eb6d --- /dev/null +++ b/src/core/event-bus.js @@ -0,0 +1,32 @@ +export class EventBus { + constructor() { + this.listeners = new Map(); + } + + on(eventName, listener) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const listeners = this.listeners.get(eventName); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, payload) { + const listeners = this.listeners.get(eventName); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(payload); + } + } +} diff --git a/src/core/store.js b/src/core/store.js new file mode 100644 index 0000000..38052eb --- /dev/null +++ b/src/core/store.js @@ -0,0 +1,291 @@ +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/src/core/time-controller.js b/src/core/time-controller.js new file mode 100644 index 0000000..7cd57c7 --- /dev/null +++ b/src/core/time-controller.js @@ -0,0 +1,80 @@ +export class TimeController { + constructor(store) { + this.store = store; + this.lastFrameTime = performance.now(); + } + + tick(now = performance.now()) { + const deltaMs = now - this.lastFrameTime; + this.lastFrameTime = now; + + this.store.setState((state) => { + const realElapsedMs = state.time.realElapsedMs + deltaMs; + const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed; + + return { + ...state, + time: { + ...state.time, + realNowMs: Date.now(), + realElapsedMs, + plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs), + }, + }; + }); + + return deltaMs; + } + + togglePause() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused: !state.time.paused, + }, + })); + } + + setPaused(paused) { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused, + }, + })); + } + + setSpeed(speed) { + const clampedSpeed = Math.max(0.1, Math.min(12, speed)); + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + speed: clampedSpeed, + }, + })); + } + + reset() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + realElapsedMs: 0, + plotTimeMs: 0, + }, + plot: { + ...state.plot, + hoveredPoint: null, + tooltip: { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + this.lastFrameTime = performance.now(); + } +} diff --git a/src/data-sources.js b/src/data-sources.js new file mode 100644 index 0000000..749a151 --- /dev/null +++ b/src/data-sources.js @@ -0,0 +1,517 @@ +/** + * 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/src/data/base-source.js b/src/data/base-source.js new file mode 100644 index 0000000..55dbdc3 --- /dev/null +++ b/src/data/base-source.js @@ -0,0 +1,21 @@ +export class BaseSource { + constructor(config = {}) { + this.config = { ...config }; + this.running = false; + } + + start() { + this.running = true; + } + + stop() { + this.running = false; + } + + updateConfig(nextConfig) { + this.config = { + ...this.config, + ...nextConfig, + }; + } +} diff --git a/src/data/csv-replay-source.js b/src/data/csv-replay-source.js new file mode 100644 index 0000000..c4e6a66 --- /dev/null +++ b/src/data/csv-replay-source.js @@ -0,0 +1,60 @@ +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/src/data/parse-replay-csv.js b/src/data/parse-replay-csv.js new file mode 100644 index 0000000..b6ce97a --- /dev/null +++ b/src/data/parse-replay-csv.js @@ -0,0 +1,108 @@ +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/src/data/source-registry.js b/src/data/source-registry.js new file mode 100644 index 0000000..917d06b --- /dev/null +++ b/src/data/source-registry.js @@ -0,0 +1,90 @@ +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/src/data/synthetic-wave-source.js b/src/data/synthetic-wave-source.js new file mode 100644 index 0000000..df53319 --- /dev/null +++ b/src/data/synthetic-wave-source.js @@ -0,0 +1,87 @@ +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/src/data/websocket-source.js b/src/data/websocket-source.js new file mode 100644 index 0000000..5458fb9 --- /dev/null +++ b/src/data/websocket-source.js @@ -0,0 +1,224 @@ +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/src/demos.js b/src/demos.js new file mode 100644 index 0000000..1dd6785 --- /dev/null +++ b/src/demos.js @@ -0,0 +1,697 @@ +/** + * 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/src/example-usage.js b/src/example-usage.js new file mode 100644 index 0000000..67eff4b --- /dev/null +++ b/src/example-usage.js @@ -0,0 +1,535 @@ +/** + * 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/src/main.js b/src/main.js new file mode 100644 index 0000000..d2b348e --- /dev/null +++ b/src/main.js @@ -0,0 +1 @@ +import './bootstrap.js'; diff --git a/src/metrics.js b/src/metrics.js new file mode 100644 index 0000000..fdda10a --- /dev/null +++ b/src/metrics.js @@ -0,0 +1,142 @@ +/** + * 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/src/plot-connections.js b/src/plot-connections.js new file mode 100644 index 0000000..0e96dd8 --- /dev/null +++ b/src/plot-connections.js @@ -0,0 +1,392 @@ +/** + * 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/src/plot/plot-buffer.js b/src/plot/plot-buffer.js new file mode 100644 index 0000000..b13cdd8 --- /dev/null +++ b/src/plot/plot-buffer.js @@ -0,0 +1,22 @@ +export class PlotBuffer { + constructor(maxPoints = 1600) { + this.maxPoints = maxPoints; + this.points = []; + } + + addPoint(point) { + this.points.push(point); + if (this.points.length > this.maxPoints) { + this.points.splice(0, this.points.length - this.maxPoints); + } + } + + clear() { + this.points = []; + } + + getVisiblePoints(currentPlotTimeMs, windowDurationMs) { + const minTime = currentPlotTimeMs - windowDurationMs; + return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs); + } +} diff --git a/src/plot/timeplot-view.js b/src/plot/timeplot-view.js new file mode 100644 index 0000000..ce90a1f --- /dev/null +++ b/src/plot/timeplot-view.js @@ -0,0 +1,442 @@ +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/src/state.js b/src/state.js new file mode 100644 index 0000000..53d8279 --- /dev/null +++ b/src/state.js @@ -0,0 +1,420 @@ +/** + * 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/src/styles.css b/src/styles.css new file mode 100644 index 0000000..6b0477f --- /dev/null +++ b/src/styles.css @@ -0,0 +1,401 @@ +: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/src/template-for-standard-site.js b/src/template-for-standard-site.js new file mode 100644 index 0000000..54aacc7 --- /dev/null +++ b/src/template-for-standard-site.js @@ -0,0 +1,75 @@ +//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/src/test-data-generators.js b/src/test-data-generators.js new file mode 100644 index 0000000..02bc0ad --- /dev/null +++ b/src/test-data-generators.js @@ -0,0 +1,530 @@ +/** + * 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/src/timeseries-plot.js b/src/timeseries-plot.js new file mode 100644 index 0000000..e35a704 --- /dev/null +++ b/src/timeseries-plot.js @@ -0,0 +1,277 @@ +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/src/ui/panel-manager.js b/src/ui/panel-manager.js new file mode 100644 index 0000000..ad29697 --- /dev/null +++ b/src/ui/panel-manager.js @@ -0,0 +1,542 @@ +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/src/utils-format.js b/src/utils-format.js new file mode 100644 index 0000000..f4eac88 --- /dev/null +++ b/src/utils-format.js @@ -0,0 +1,22 @@ +export function formatDuration(ms) { + const totalSeconds = Math.max(0, ms / 1000); + if (totalSeconds < 60) { + return `${totalSeconds.toFixed(2)}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toFixed(1)}s`; +} + +export function formatWallClock(timestampMs) { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestampMs)); +} + +export function formatValue(value) { + return Number.isFinite(value) ? value.toFixed(3) : '—'; +} diff --git a/src/waterfall.js b/src/waterfall.js new file mode 100644 index 0000000..bce0750 --- /dev/null +++ b/src/waterfall.js @@ -0,0 +1,219 @@ +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; + } +} |
