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