diff options
| author | grothedev <grothedev@gmail.com> | 2025-11-25 21:22:17 -0500 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2025-11-25 21:22:17 -0500 |
| commit | 43420f2987b76aa7ede0012e1998ba8d61419bc9 (patch) | |
| tree | bbb7214c9105fbfeca4ba82056cdfc6fb107b5c2 /web-timeplot/src | |
| parent | 2c1b37a5b0c4962b405a85768b9b8cdd5c4f1097 (diff) | |
pushing claude changes before i take another route
Diffstat (limited to 'web-timeplot/src')
| -rw-r--r-- | web-timeplot/src/data-sources.js | 517 | ||||
| -rw-r--r-- | web-timeplot/src/example-usage.js | 535 | ||||
| -rw-r--r-- | web-timeplot/src/main.js | 1 | ||||
| -rw-r--r-- | web-timeplot/src/plot-connections.js | 392 | ||||
| -rw-r--r-- | web-timeplot/src/test-data-generators.js | 530 | ||||
| -rw-r--r-- | web-timeplot/src/timeseries-plot.js | 277 |
6 files changed, 2252 insertions, 0 deletions
diff --git a/web-timeplot/src/data-sources.js b/web-timeplot/src/data-sources.js new file mode 100644 index 0000000..749a151 --- /dev/null +++ b/web-timeplot/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/web-timeplot/src/example-usage.js b/web-timeplot/src/example-usage.js new file mode 100644 index 0000000..67eff4b --- /dev/null +++ b/web-timeplot/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/web-timeplot/src/main.js b/web-timeplot/src/main.js index 31be4bb..c9d11fb 100644 --- a/web-timeplot/src/main.js +++ b/web-timeplot/src/main.js @@ -2,6 +2,7 @@ import { Application } from 'pixi.js'; import { WaterfallGraph } from './waterfall.js'; import { PerformanceMetrics } from './metrics.js'; import { StateManager } from './state.js'; +import { example8_InteractiveDemo} from './example-usage.js'; // ============================================================================ // GLOBAL STATE diff --git a/web-timeplot/src/plot-connections.js b/web-timeplot/src/plot-connections.js new file mode 100644 index 0000000..0e96dd8 --- /dev/null +++ b/web-timeplot/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/web-timeplot/src/test-data-generators.js b/web-timeplot/src/test-data-generators.js new file mode 100644 index 0000000..02bc0ad --- /dev/null +++ b/web-timeplot/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/web-timeplot/src/timeseries-plot.js b/web-timeplot/src/timeseries-plot.js new file mode 100644 index 0000000..e35a704 --- /dev/null +++ b/web-timeplot/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(), + }; + } +} |
