summaryrefslogtreecommitdiff
path: root/web-timeplot/src
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src')
-rw-r--r--web-timeplot/src/data-sources.js517
-rw-r--r--web-timeplot/src/example-usage.js535
-rw-r--r--web-timeplot/src/main.js1
-rw-r--r--web-timeplot/src/plot-connections.js392
-rw-r--r--web-timeplot/src/test-data-generators.js530
-rw-r--r--web-timeplot/src/timeseries-plot.js277
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(),
+ };
+ }
+}