summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorgrothedev <grothedev@gmail.com>2026-05-29 21:49:20 -0400
committergrothedev <grothedev@gmail.com>2026-05-29 21:49:20 -0400
commit6196004b51a6850909c154f5402ff4858eab479a (patch)
tree126b8bb1600d0a656e0df016e25d08c390f3540e /src
parent27dc5849c3eaf4824d79938e7077abdbe2c82e24 (diff)
mv web stuff to root project dirHEADprototypeframeworkmain
Diffstat (limited to 'src')
-rw-r--r--src/app/create-app.js449
-rw-r--r--src/bootstrap.js18
-rw-r--r--src/core/event-bus.js32
-rw-r--r--src/core/store.js291
-rw-r--r--src/core/time-controller.js80
-rw-r--r--src/data-sources.js517
-rw-r--r--src/data/base-source.js21
-rw-r--r--src/data/csv-replay-source.js60
-rw-r--r--src/data/parse-replay-csv.js108
-rw-r--r--src/data/source-registry.js90
-rw-r--r--src/data/synthetic-wave-source.js87
-rw-r--r--src/data/websocket-source.js224
-rw-r--r--src/demos.js697
-rw-r--r--src/example-usage.js535
-rw-r--r--src/main.js1
-rw-r--r--src/metrics.js142
-rw-r--r--src/plot-connections.js392
-rw-r--r--src/plot/plot-buffer.js22
-rw-r--r--src/plot/timeplot-view.js442
-rw-r--r--src/state.js420
-rw-r--r--src/styles.css401
-rw-r--r--src/template-for-standard-site.js75
-rw-r--r--src/test-data-generators.js530
-rw-r--r--src/timeseries-plot.js277
-rw-r--r--src/ui/panel-manager.js542
-rw-r--r--src/utils-format.js22
-rw-r--r--src/waterfall.js219
27 files changed, 6694 insertions, 0 deletions
diff --git a/src/app/create-app.js b/src/app/create-app.js
new file mode 100644
index 0000000..4f4f0fc
--- /dev/null
+++ b/src/app/create-app.js
@@ -0,0 +1,449 @@
+import { EventBus } from '../core/event-bus.js';
+import { Store, createInitialState } from '../core/store.js';
+import { TimeController } from '../core/time-controller.js';
+import { PlotBuffer } from '../plot/plot-buffer.js';
+import { TimeplotView } from '../plot/timeplot-view.js';
+import { SourceRegistry } from '../data/source-registry.js';
+import { parseReplayCsv } from '../data/parse-replay-csv.js';
+import { PanelManager } from '../ui/panel-manager.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function buildDeltaPoints(points) {
+ if (points.length < 2) {
+ return [];
+ }
+
+ const derived = [];
+ for (let index = 1; index < points.length; index += 1) {
+ const previous = points[index - 1];
+ const current = points[index];
+ const deltaTime = Math.max(1, current.timeMs - previous.timeMs);
+ derived.push({
+ ...current,
+ value: (current.value - previous.value) / deltaTime * 1000,
+ sourceId: `${current.sourceId}:delta`,
+ });
+ }
+
+ return derived;
+}
+
+function buildSmoothedPoints(points, windowSize = 5) {
+ if (points.length === 0) {
+ return [];
+ }
+
+ const smoothed = [];
+ for (let index = 0; index < points.length; index += 1) {
+ const start = Math.max(0, index - windowSize + 1);
+ const windowPoints = points.slice(start, index + 1);
+ const average = windowPoints.reduce((sum, point) => sum + point.value, 0) / windowPoints.length;
+ smoothed.push({
+ ...points[index],
+ value: average,
+ sourceId: `${points[index].sourceId}:smooth`,
+ });
+ }
+
+ return smoothed;
+}
+
+function transformPoints(points, transform) {
+ switch (transform) {
+ case 'delta':
+ return buildDeltaPoints(points);
+ case 'smooth':
+ return buildSmoothedPoints(points);
+ case 'raw':
+ default:
+ return points;
+ }
+}
+
+function describeTransform(transform) {
+ switch (transform) {
+ case 'delta':
+ return 'Δvalue / second';
+ case 'smooth':
+ return 'moving average';
+ case 'raw':
+ default:
+ return 'raw signal';
+ }
+}
+
+function deriveValueRange(points, fallbackRange) {
+ if (points.length === 0) {
+ return fallbackRange;
+ }
+
+ let min = Infinity;
+ let max = -Infinity;
+ for (const point of points) {
+ min = Math.min(min, point.value);
+ max = Math.max(max, point.value);
+ }
+
+ const maxAbs = Math.max(Math.abs(min), Math.abs(max), 0.1);
+ return {
+ min: -maxAbs,
+ max: maxAbs,
+ };
+}
+
+function pickActiveHover(primaryCandidate, secondaryCandidate) {
+ if (!primaryCandidate && !secondaryCandidate) {
+ return null;
+ }
+
+ if (primaryCandidate && !secondaryCandidate) {
+ return primaryCandidate;
+ }
+
+ if (!primaryCandidate && secondaryCandidate) {
+ return secondaryCandidate;
+ }
+
+ return primaryCandidate.lastPointerEventAt >= secondaryCandidate.lastPointerEventAt
+ ? primaryCandidate
+ : secondaryCandidate;
+}
+
+export async function createApp(root) {
+ const bus = new EventBus();
+ const store = new Store(createInitialState());
+ const timeController = new TimeController(store);
+ const sourceBuffers = new Map(Object.keys(store.getState().sources).map((sourceKey) => [sourceKey, new PlotBuffer(store.getState().plot.maxPoints)]));
+ let sourceRegistry;
+
+ const syncBuffersFromState = () => {
+ const state = store.getState();
+ for (const sourceKey of Object.keys(state.sources)) {
+ if (!sourceBuffers.has(sourceKey)) {
+ sourceBuffers.set(sourceKey, new PlotBuffer(state.plot.maxPoints));
+ }
+ sourceBuffers.get(sourceKey).maxPoints = state.plot.maxPoints;
+ }
+
+ for (const sourceKey of Array.from(sourceBuffers.keys())) {
+ if (!state.sources[sourceKey]) {
+ sourceBuffers.delete(sourceKey);
+ }
+ }
+ };
+
+ const clearSourceBuffer = (sourceKey) => {
+ sourceBuffers.get(sourceKey)?.clear();
+ };
+
+ const getGraphPoints = (state, graphId) => {
+ const graphConfig = state.graphs[graphId];
+ const sourceBuffer = sourceBuffers.get(graphConfig.sourceKey);
+ const basePoints = sourceBuffer
+ ? sourceBuffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs)
+ : [];
+ const transformedPoints = transformPoints(basePoints, graphConfig.transform);
+ return {
+ graphConfig,
+ points: transformedPoints,
+ range: deriveValueRange(transformedPoints, state.plot.valueRange),
+ };
+ };
+
+ const actions = {
+ togglePause: () => timeController.togglePause(),
+ setSpeed: (speed) => timeController.setSpeed(speed),
+ resetScene: () => {
+ timeController.reset();
+ sourceBuffers.forEach((plotBuffer) => plotBuffer.clear());
+ sourceRegistry.reset();
+ },
+ togglePanel: (panelId) => {
+ store.setState((state) => ({
+ ...state,
+ panels: {
+ ...state.panels,
+ [panelId]: {
+ ...state.panels[panelId],
+ visible: !state.panels[panelId].visible,
+ },
+ },
+ }));
+ },
+ updateSource: (sourceKey, field, value) => {
+ store.setState((state) => ({
+ ...state,
+ sources: {
+ ...state.sources,
+ [sourceKey]: {
+ ...state.sources[sourceKey],
+ [field]: value,
+ ...(field === 'type'
+ ? {
+ loadError: value === 'csv-replay' && state.sources[sourceKey].dataset.length === 0
+ ? (state.sources[sourceKey].dataFileName
+ ? `Reload ${state.sources[sourceKey].dataFileName} to restore replay data`
+ : 'Load a CSV file to begin replay')
+ : '',
+ wsStatus: value === 'websocket' ? state.sources[sourceKey].wsStatus : 'idle',
+ wsStatusDetail: value === 'websocket' ? state.sources[sourceKey].wsStatusDetail : '',
+ }
+ : {}),
+ },
+ },
+ }));
+ sourceRegistry.syncFromState();
+ syncBuffersFromState();
+
+ if (field === 'type' || field === 'wsUrl' || field === 'wsReconnectMs') {
+ clearSourceBuffer(sourceKey);
+ sourceRegistry.reset();
+ }
+ },
+ loadSourceFile: async (sourceKey, file) => {
+ try {
+ const state = store.getState();
+ const sampleRateHz = state.sources[sourceKey]?.sampleRateHz ?? 60;
+ const text = await file.text();
+ const { points, metadata } = parseReplayCsv(text, { sampleRateHz });
+
+ clearSourceBuffer(sourceKey);
+ store.setState((currentState) => ({
+ ...currentState,
+ sources: {
+ ...currentState.sources,
+ [sourceKey]: {
+ ...currentState.sources[sourceKey],
+ type: 'csv-replay',
+ dataset: points,
+ dataFileName: file.name,
+ datasetPointCount: metadata.pointCount,
+ datasetDurationMs: metadata.durationMs,
+ loadError: '',
+ wsStatus: 'idle',
+ wsStatusDetail: '',
+ },
+ },
+ }));
+ sourceRegistry.syncFromState();
+ sourceRegistry.reset();
+ } catch (error) {
+ store.setState((currentState) => ({
+ ...currentState,
+ sources: {
+ ...currentState.sources,
+ [sourceKey]: {
+ ...currentState.sources[sourceKey],
+ loadError: error instanceof Error ? error.message : String(error),
+ },
+ },
+ }));
+ }
+ },
+ updatePlot: (field, value) => {
+ store.setState((state) => ({
+ ...state,
+ plot: {
+ ...state.plot,
+ [field]: value,
+ },
+ }));
+
+ if (field === 'maxPoints') {
+ buffer.maxPoints = clamp(value, 200, 4000);
+ sourceBuffers.forEach((plotBuffer) => {
+ plotBuffer.maxPoints = clamp(value, 200, 4000);
+ });
+ }
+ },
+ updateGraph: (graphId, field, value) => {
+ store.setState((state) => ({
+ ...state,
+ graphs: {
+ ...state.graphs,
+ [graphId]: {
+ ...state.graphs[graphId],
+ [field]: value,
+ },
+ },
+ }));
+ },
+ };
+
+ const panelManager = new PanelManager({ root, store, actions });
+ const elements = panelManager.mount();
+
+ const plotView = new TimeplotView({
+ host: elements.primaryCanvasHost,
+ panelId: 'primary',
+ title: 'Primary signal',
+ subtitle: null,
+ showReadouts: true,
+ lineColor: 0x9fd1ff,
+ pointColor: 0xe7f2ff,
+ });
+
+ const secondaryPlotView = new TimeplotView({
+ host: elements.secondaryCanvasHost,
+ panelId: 'secondary',
+ title: 'Secondary signal',
+ subtitle: null,
+ showReadouts: false,
+ lineColor: 0xffc46b,
+ pointColor: 0xffe1b0,
+ });
+
+ const renderer = await plotView.init();
+ await secondaryPlotView.init();
+ store.patch({
+ app: {
+ ...store.getState().app,
+ renderer,
+ },
+ });
+
+ sourceRegistry = new SourceRegistry(store, bus);
+
+ bus.on('data:point', (point) => {
+ sourceBuffers.get(point.sourceId)?.addPoint(point);
+ });
+
+ const keyHandler = (event) => {
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) {
+ return;
+ }
+
+ if (event.code === 'Space') {
+ event.preventDefault();
+ actions.togglePause();
+ return;
+ }
+
+ if (event.key === '[') {
+ actions.setSpeed(store.getState().time.speed - 0.1);
+ return;
+ }
+
+ if (event.key === ']') {
+ actions.setSpeed(store.getState().time.speed + 0.1);
+ return;
+ }
+
+ if (event.key.toLowerCase() === 'g') {
+ actions.updatePlot('showGrid', !store.getState().plot.showGrid);
+ }
+ };
+
+ window.addEventListener('keydown', keyHandler);
+
+ plotView.app.ticker.add(() => {
+ timeController.tick();
+ sourceRegistry.syncFromState();
+ syncBuffersFromState();
+ sourceRegistry.update(store.getState().time.plotTimeMs);
+
+ const state = store.getState();
+ const primaryGraph = getGraphPoints(state, 'primary');
+ const secondaryGraph = getGraphPoints(state, 'secondary');
+
+ plotView.panelTitle = state.graphs.primary.title;
+ plotView.panelSubtitle = `${state.sources[state.graphs.primary.sourceKey].label} · ${describeTransform(state.graphs.primary.transform)} · time ↓`;
+ secondaryPlotView.panelTitle = state.graphs.secondary.title;
+ secondaryPlotView.panelSubtitle = `${state.sources[state.graphs.secondary.sourceKey].label} · ${describeTransform(state.graphs.secondary.transform)} · time ↓`;
+
+ const primaryState = {
+ ...state,
+ plot: {
+ ...state.plot,
+ valueRange: primaryGraph.range,
+ },
+ };
+
+ const secondaryState = {
+ ...state,
+ plot: {
+ ...state.plot,
+ valueRange: secondaryGraph.range,
+ },
+ };
+
+ plotView.render(primaryState, primaryGraph.points);
+ secondaryPlotView.render(secondaryState, secondaryGraph.points);
+
+ const primaryHover = plotView.getHoverCandidate();
+ const secondaryHover = secondaryPlotView.getHoverCandidate();
+ const activeHover = pickActiveHover(primaryHover, secondaryHover);
+
+ if (!activeHover) {
+ plotView.clearHover();
+ secondaryPlotView.clearHover();
+ store.setState((currentState) => ({
+ ...currentState,
+ plot: {
+ ...currentState.plot,
+ hoveredPoint: null,
+ tooltip: {
+ ...currentState.plot.tooltip,
+ visible: false,
+ point: null,
+ linkedPoint: null,
+ },
+ },
+ }));
+ panelManager.sync(store.getState(), {
+ primary: primaryGraph.points.length,
+ secondary: secondaryGraph.points.length,
+ });
+ return;
+ }
+
+ const primaryLinkedPoint = plotView.findNearestScreenPointByTime(activeHover.point.timeMs);
+ const secondaryLinkedPoint = secondaryPlotView.findNearestScreenPointByTime(activeHover.point.timeMs);
+
+ plotView.renderLinkedHover(primaryLinkedPoint);
+ secondaryPlotView.renderLinkedHover(secondaryLinkedPoint);
+
+ const activePanelLabel = activeHover.panelId === 'secondary'
+ ? state.graphs.secondary.title
+ : state.graphs.primary.title;
+ const linkedPoint = activeHover.panelId === 'secondary' ? primaryLinkedPoint : secondaryLinkedPoint;
+ const linkedPanelLabel = activeHover.panelId === 'secondary'
+ ? state.graphs.primary.title
+ : state.graphs.secondary.title;
+
+ store.setState((currentState) => ({
+ ...currentState,
+ plot: {
+ ...currentState.plot,
+ hoveredPoint: activeHover.point,
+ tooltip: {
+ ...currentState.plot.tooltip,
+ visible: true,
+ panelId: activeHover.panelId,
+ panelLabel: activePanelLabel,
+ x: activeHover.x,
+ y: activeHover.y,
+ point: activeHover.point,
+ linkedPoint,
+ linkedPanelLabel,
+ },
+ },
+ }));
+
+ panelManager.sync(store.getState(), {
+ primary: primaryGraph.points.length,
+ secondary: secondaryGraph.points.length,
+ });
+ });
+
+ return {
+ destroy() {
+ window.removeEventListener('keydown', keyHandler);
+ plotView.destroy();
+ secondaryPlotView.destroy();
+ },
+ };
+}
diff --git a/src/bootstrap.js b/src/bootstrap.js
new file mode 100644
index 0000000..4b073bc
--- /dev/null
+++ b/src/bootstrap.js
@@ -0,0 +1,18 @@
+import './styles.css';
+import { createApp } from './app/create-app.js';
+
+const root = document.getElementById('app');
+
+if (!root) {
+ throw new Error('App root not found');
+}
+
+createApp(root).catch((error) => {
+ console.error('Failed to start TimePlot', error);
+ root.innerHTML = `
+ <div style="padding: 24px; color: #fff; font-family: sans-serif;">
+ <h1>TimePlot failed to start</h1>
+ <pre>${String(error)}</pre>
+ </div>
+ `;
+});
diff --git a/src/core/event-bus.js b/src/core/event-bus.js
new file mode 100644
index 0000000..192eb6d
--- /dev/null
+++ b/src/core/event-bus.js
@@ -0,0 +1,32 @@
+export class EventBus {
+ constructor() {
+ this.listeners = new Map();
+ }
+
+ on(eventName, listener) {
+ if (!this.listeners.has(eventName)) {
+ this.listeners.set(eventName, new Set());
+ }
+
+ const listeners = this.listeners.get(eventName);
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) {
+ this.listeners.delete(eventName);
+ }
+ };
+ }
+
+ emit(eventName, payload) {
+ const listeners = this.listeners.get(eventName);
+ if (!listeners) {
+ return;
+ }
+
+ for (const listener of listeners) {
+ listener(payload);
+ }
+ }
+}
diff --git a/src/core/store.js b/src/core/store.js
new file mode 100644
index 0000000..38052eb
--- /dev/null
+++ b/src/core/store.js
@@ -0,0 +1,291 @@
+const STORAGE_KEY = 'timeplot.app-state.v1';
+
+function clonePanelState(panels) {
+ return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }]));
+}
+
+function cloneNamedState(items) {
+ return Object.fromEntries(Object.entries(items).map(([key, value]) => [key, { ...value }]));
+}
+
+function sanitizePersistedSource(source) {
+ return {
+ type: source.type,
+ preset: source.preset,
+ sampleRateHz: source.sampleRateHz,
+ amplitude: source.amplitude,
+ noise: source.noise,
+ replayRate: source.replayRate,
+ dataFileName: source.dataFileName,
+ wsUrl: source.wsUrl,
+ wsReconnectMs: source.wsReconnectMs,
+ };
+}
+
+function createPersistableState(state) {
+ return {
+ plot: {
+ showGrid: state.plot.showGrid,
+ showPoints: state.plot.showPoints,
+ windowDurationMs: state.plot.windowDurationMs,
+ maxPoints: state.plot.maxPoints,
+ },
+ time: {
+ speed: state.time.speed,
+ },
+ panels: clonePanelState(state.panels),
+ graphs: cloneNamedState(state.graphs),
+ sources: Object.fromEntries(Object.entries(state.sources).map(([key, value]) => [
+ key,
+ sanitizePersistedSource(value),
+ ])),
+ };
+}
+
+function mergePersistedState(baseState, persistedState) {
+ if (!persistedState || typeof persistedState !== 'object') {
+ return baseState;
+ }
+
+ const mergedState = {
+ ...baseState,
+ time: persistedState.time
+ ? {
+ ...baseState.time,
+ speed: persistedState.time.speed ?? baseState.time.speed,
+ paused: false,
+ }
+ : baseState.time,
+ plot: persistedState.plot
+ ? {
+ ...baseState.plot,
+ ...persistedState.plot,
+ valueRange: baseState.plot.valueRange,
+ hoveredPoint: null,
+ tooltip: { ...baseState.plot.tooltip },
+ }
+ : baseState.plot,
+ panels: persistedState.panels
+ ? clonePanelState(Object.fromEntries(Object.entries(baseState.panels).map(([key, value]) => [
+ key,
+ {
+ ...value,
+ ...(persistedState.panels[key] ?? {}),
+ },
+ ])))
+ : baseState.panels,
+ graphs: persistedState.graphs
+ ? cloneNamedState(Object.fromEntries(Object.entries(baseState.graphs).map(([key, value]) => [
+ key,
+ {
+ ...value,
+ ...(persistedState.graphs[key] ?? {}),
+ },
+ ])))
+ : baseState.graphs,
+ sources: persistedState.sources
+ ? Object.fromEntries(Object.entries(baseState.sources).map(([key, value]) => {
+ const persistedSource = persistedState.sources[key] ?? {};
+ const nextType = persistedSource.type ?? value.type;
+
+ return [
+ key,
+ {
+ ...value,
+ ...persistedSource,
+ type: nextType,
+ dataset: [],
+ datasetPointCount: 0,
+ datasetDurationMs: 0,
+ loadError: nextType === 'csv-replay' && persistedSource.dataFileName
+ ? `Reload ${persistedSource.dataFileName} to restore replay data`
+ : '',
+ wsStatus: 'idle',
+ wsStatusDetail: '',
+ },
+ ];
+ }))
+ : baseState.sources,
+ };
+
+ return mergedState;
+}
+
+function loadPersistedState() {
+ if (typeof localStorage === 'undefined') {
+ return null;
+ }
+
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+
+ return JSON.parse(raw);
+ } catch (error) {
+ console.warn('[timeplot] failed to load persisted state', error);
+ return null;
+ }
+}
+
+function savePersistedState(state) {
+ if (typeof localStorage === 'undefined') {
+ return;
+ }
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(createPersistableState(state)));
+ } catch (error) {
+ console.warn('[timeplot] failed to persist state', error);
+ }
+}
+
+export function createInitialState() {
+ return {
+ app: {
+ title: 'TimePlot',
+ renderer: 'pending',
+ },
+ time: {
+ realNowMs: Date.now(),
+ realElapsedMs: 0,
+ plotTimeMs: 0,
+ speed: 1,
+ paused: false,
+ },
+ plot: {
+ showGrid: true,
+ showPoints: true,
+ windowDurationMs: 20000,
+ maxPoints: 1600,
+ valueRange: {
+ min: -1.6,
+ max: 1.6,
+ },
+ hoveredPoint: null,
+ tooltip: {
+ visible: false,
+ x: 0,
+ y: 0,
+ point: null,
+ },
+ },
+ sources: {
+ signalA: {
+ id: 'signal-a',
+ label: 'Signal A',
+ type: 'synthetic-wave',
+ preset: 'telemetry',
+ sampleRateHz: 60,
+ amplitude: 1,
+ noise: 0.08,
+ replayRate: 1,
+ dataset: [],
+ dataFileName: '',
+ datasetPointCount: 0,
+ datasetDurationMs: 0,
+ loadError: '',
+ wsUrl: 'ws://localhost:8080',
+ wsReconnectMs: 2000,
+ wsStatus: 'idle',
+ wsStatusDetail: '',
+ },
+ signalB: {
+ id: 'signal-b',
+ label: 'Signal B',
+ type: 'synthetic-wave',
+ preset: 'chirp',
+ sampleRateHz: 48,
+ amplitude: 0.8,
+ noise: 0.04,
+ replayRate: 1,
+ dataset: [],
+ dataFileName: '',
+ datasetPointCount: 0,
+ datasetDurationMs: 0,
+ loadError: '',
+ wsUrl: 'ws://localhost:8080',
+ wsReconnectMs: 2000,
+ wsStatus: 'idle',
+ wsStatusDetail: '',
+ },
+ },
+ graphs: {
+ primary: {
+ sourceKey: 'signalA',
+ transform: 'raw',
+ title: 'Primary signal',
+ },
+ secondary: {
+ sourceKey: 'signalB',
+ transform: 'delta',
+ title: 'Secondary signal',
+ },
+ },
+ panels: {
+ status: { title: 'Status', visible: true },
+ source: { title: 'Data Source', visible: true },
+ config: { title: 'Config', visible: true },
+ help: { title: 'Help', visible: false },
+ },
+ };
+}
+
+export class Store {
+ constructor(initialState = createInitialState()) {
+ this.state = mergePersistedState(initialState, loadPersistedState());
+ this.listeners = new Set();
+ }
+
+ getState() {
+ return this.state;
+ }
+
+ subscribe(listener) {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ setState(updater) {
+ const nextState = typeof updater === 'function' ? updater(this.state) : updater;
+ this.state = nextState;
+ savePersistedState(this.state);
+ for (const listener of this.listeners) {
+ listener(this.state);
+ }
+ }
+
+ patch(partial) {
+ this.setState((state) => ({
+ ...state,
+ ...partial,
+ time: partial.time ? { ...state.time, ...partial.time } : state.time,
+ plot: partial.plot
+ ? {
+ ...state.plot,
+ ...partial.plot,
+ valueRange: partial.plot.valueRange
+ ? { ...state.plot.valueRange, ...partial.plot.valueRange }
+ : state.plot.valueRange,
+ tooltip: partial.plot.tooltip
+ ? { ...state.plot.tooltip, ...partial.plot.tooltip }
+ : state.plot.tooltip,
+ }
+ : state.plot,
+ sources: partial.sources
+ ? Object.fromEntries(Object.entries({ ...state.sources, ...partial.sources }).map(([key, value]) => [
+ key,
+ { ...state.sources[key], ...value },
+ ]))
+ : state.sources,
+ graphs: partial.graphs
+ ? cloneNamedState(Object.fromEntries(Object.entries({ ...state.graphs, ...partial.graphs }).map(([key, value]) => [
+ key,
+ { ...state.graphs[key], ...value },
+ ])))
+ : state.graphs,
+ panels: partial.panels ? clonePanelState({ ...state.panels, ...partial.panels }) : state.panels,
+ }));
+ }
+}
diff --git a/src/core/time-controller.js b/src/core/time-controller.js
new file mode 100644
index 0000000..7cd57c7
--- /dev/null
+++ b/src/core/time-controller.js
@@ -0,0 +1,80 @@
+export class TimeController {
+ constructor(store) {
+ this.store = store;
+ this.lastFrameTime = performance.now();
+ }
+
+ tick(now = performance.now()) {
+ const deltaMs = now - this.lastFrameTime;
+ this.lastFrameTime = now;
+
+ this.store.setState((state) => {
+ const realElapsedMs = state.time.realElapsedMs + deltaMs;
+ const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed;
+
+ return {
+ ...state,
+ time: {
+ ...state.time,
+ realNowMs: Date.now(),
+ realElapsedMs,
+ plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs),
+ },
+ };
+ });
+
+ return deltaMs;
+ }
+
+ togglePause() {
+ this.store.setState((state) => ({
+ ...state,
+ time: {
+ ...state.time,
+ paused: !state.time.paused,
+ },
+ }));
+ }
+
+ setPaused(paused) {
+ this.store.setState((state) => ({
+ ...state,
+ time: {
+ ...state.time,
+ paused,
+ },
+ }));
+ }
+
+ setSpeed(speed) {
+ const clampedSpeed = Math.max(0.1, Math.min(12, speed));
+ this.store.setState((state) => ({
+ ...state,
+ time: {
+ ...state.time,
+ speed: clampedSpeed,
+ },
+ }));
+ }
+
+ reset() {
+ this.store.setState((state) => ({
+ ...state,
+ time: {
+ ...state.time,
+ realElapsedMs: 0,
+ plotTimeMs: 0,
+ },
+ plot: {
+ ...state.plot,
+ hoveredPoint: null,
+ tooltip: {
+ ...state.plot.tooltip,
+ visible: false,
+ point: null,
+ },
+ },
+ }));
+ this.lastFrameTime = performance.now();
+ }
+}
diff --git a/src/data-sources.js b/src/data-sources.js
new file mode 100644
index 0000000..749a151
--- /dev/null
+++ b/src/data-sources.js
@@ -0,0 +1,517 @@
+/**
+ * Data Sources - Components that generate or provide data to plots
+ *
+ * This module implements the data provider side of the architecture.
+ * Data sources know how to generate or fetch data, but don't know
+ * anything about visualization.
+ *
+ * Architecture:
+ * - DataSource: Base class with event emitting
+ * - Specific sources: Implement different data generation strategies
+ * - Connection: Links sources to plots (see plot-connections.js)
+ */
+
+// Simple EventEmitter (same as in state.js, could be extracted to utils)
+class EventEmitter {
+ constructor() {
+ this.events = new Map();
+ }
+
+ on(event, callback) {
+ if (!this.events.has(event)) {
+ this.events.set(event, []);
+ }
+ this.events.get(event).push(callback);
+ return () => this.off(event, callback);
+ }
+
+ off(event, callback) {
+ if (!this.events.has(event)) return;
+ const callbacks = this.events.get(event);
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+
+ emit(event, data) {
+ if (!this.events.has(event)) return;
+ this.events.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (e) {
+ console.error(`[DataSource] Error in event handler for '${event}':`, e);
+ }
+ });
+ }
+}
+
+/**
+ * Base class for all data sources
+ *
+ * Events emitted:
+ * - 'line': {points: Array, timestamp: number, metadata: Object}
+ * - 'point': {value: number, timestamp: number}
+ * - 'error': {error: Error}
+ */
+export class DataSource extends EventEmitter {
+ constructor(config = {}) {
+ super();
+ this.config = config;
+ this.isRunning = false;
+ this.time = 0;
+ }
+
+ /**
+ * Start generating/providing data
+ */
+ start() {
+ this.isRunning = true;
+ }
+
+ /**
+ * Stop generating/providing data
+ */
+ stop() {
+ this.isRunning = false;
+ }
+
+ /**
+ * Reset the data source to initial state
+ */
+ reset() {
+ this.time = 0;
+ }
+
+ /**
+ * Emit a complete line of data
+ */
+ emitLine(points, metadata = {}) {
+ this.emit('line', {
+ points,
+ timestamp: metadata.timestamp || Date.now(),
+ metadata,
+ });
+ }
+
+ /**
+ * Emit a single data point
+ */
+ emitPoint(value, timestamp = Date.now()) {
+ this.emit('point', {
+ value,
+ timestamp,
+ });
+ }
+
+ /**
+ * Emit an error
+ */
+ emitError(error) {
+ this.emit('error', { error });
+ }
+}
+
+/**
+ * Synthetic data source using test generators
+ * Uses the generators from test-data-generators.js
+ */
+export class SyntheticDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.generator = config.generator; // Instance of DataGenerator
+ this.pointsPerLine = config.pointsPerLine || 100;
+ this.width = config.width || 800;
+ this.lineInterval = config.lineInterval || 100; // ms between lines
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ // Generate a new line periodically
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitLine();
+ }, this.lineInterval);
+
+ // Generate initial line immediately
+ this.generateAndEmitLine();
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitLine() {
+ if (!this.generator) {
+ this.emitError(new Error('No generator configured'));
+ return;
+ }
+
+ const points = this.generator.generateLine(this.pointsPerLine, this.width);
+ this.emitLine(points, {
+ timestamp: Date.now(),
+ generatorType: this.generator.constructor.name,
+ });
+ }
+
+ setGenerator(generator) {
+ this.generator = generator;
+ }
+}
+
+/**
+ * Function-based data source
+ * Evaluates a user-provided function to generate data
+ */
+export class FunctionDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ // Function should have signature: (x, t) => y
+ // x: normalized position 0-1
+ // t: time in seconds
+ // returns: y value
+ this.func = config.func || ((x, t) => Math.sin(x * 10 + t));
+ this.pointsPerLine = config.pointsPerLine || 100;
+ this.width = config.width || 800;
+ this.amplitude = config.amplitude || 30;
+ this.lineInterval = config.lineInterval || 100;
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitLine();
+ }, this.lineInterval);
+
+ this.generateAndEmitLine();
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitLine() {
+ const points = [];
+ const t = this.time;
+
+ for (let i = 0; i < this.pointsPerLine; i++) {
+ const x = (i / this.pointsPerLine) * this.width;
+ const normalizedX = i / this.pointsPerLine;
+ const y = this.func(normalizedX, t) * this.amplitude;
+ points.push({ x, y });
+ }
+
+ this.emitLine(points, {
+ timestamp: Date.now(),
+ time: t,
+ });
+
+ this.time += this.lineInterval / 1000;
+ }
+
+ setFunction(func) {
+ this.func = func;
+ }
+}
+
+/**
+ * Streaming data source
+ * Emits individual data points that get buffered into lines
+ */
+export class StreamingDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.generator = config.generator;
+ this.sampleRate = config.sampleRate || 60; // Samples per second
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ const intervalMs = 1000 / this.sampleRate;
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitPoint();
+ }, intervalMs);
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitPoint() {
+ if (!this.generator) {
+ this.emitError(new Error('No generator configured'));
+ return;
+ }
+
+ const value = this.generator.sample();
+ this.generator.time += 1 / this.generator.sampleRate;
+ this.emitPoint(value, Date.now());
+ }
+
+ setGenerator(generator) {
+ this.generator = generator;
+ }
+}
+
+/**
+ * WebSocket data source (for real data)
+ * Receives data from a WebSocket connection
+ */
+export class WebSocketDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.url = config.url;
+ this.socket = null;
+ this.reconnectInterval = config.reconnectInterval || 5000;
+ this.reconnectHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+ this.connect();
+ }
+
+ stop() {
+ super.stop();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ if (this.reconnectHandle) {
+ clearTimeout(this.reconnectHandle);
+ this.reconnectHandle = null;
+ }
+ }
+
+ connect() {
+ try {
+ this.socket = new WebSocket(this.url);
+
+ this.socket.onopen = () => {
+ console.log(`[WebSocketDataSource] Connected to ${this.url}`);
+ };
+
+ this.socket.onmessage = (event) => {
+ this.handleMessage(event.data);
+ };
+
+ this.socket.onerror = (error) => {
+ console.error('[WebSocketDataSource] Error:', error);
+ this.emitError(error);
+ };
+
+ this.socket.onclose = () => {
+ console.log('[WebSocketDataSource] Connection closed');
+ if (this.isRunning) {
+ // Auto-reconnect
+ this.reconnectHandle = setTimeout(() => {
+ this.connect();
+ }, this.reconnectInterval);
+ }
+ };
+ } catch (error) {
+ console.error('[WebSocketDataSource] Failed to connect:', error);
+ this.emitError(error);
+ }
+ }
+
+ handleMessage(data) {
+ try {
+ const parsed = JSON.parse(data);
+
+ // Expect format: {type: 'line', points: [...]} or {type: 'point', value: ...}
+ if (parsed.type === 'line' && parsed.points) {
+ this.emitLine(parsed.points, parsed.metadata || {});
+ } else if (parsed.type === 'point' && parsed.value !== undefined) {
+ this.emitPoint(parsed.value, parsed.timestamp);
+ } else {
+ console.warn('[WebSocketDataSource] Unknown message format:', parsed);
+ }
+ } catch (error) {
+ console.error('[WebSocketDataSource] Failed to parse message:', error);
+ this.emitError(error);
+ }
+ }
+
+ send(data) {
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
+ this.socket.send(JSON.stringify(data));
+ }
+ }
+}
+
+/**
+ * CSV File data source
+ * Reads data from CSV files (for replay/analysis)
+ */
+export class CSVDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.data = []; // Parsed CSV data
+ this.currentIndex = 0;
+ this.playbackRate = config.playbackRate || 1.0;
+ this.loop = config.loop || false;
+ this.intervalHandle = null;
+ }
+
+ /**
+ * Load CSV data from a string
+ * Expected format: timestamp,value or x,y format
+ */
+ loadCSV(csvString) {
+ const lines = csvString.trim().split('\n');
+ const headers = lines[0].split(',').map(h => h.trim());
+
+ this.data = [];
+ for (let i = 1; i < lines.length; i++) {
+ const values = lines[i].split(',').map(v => parseFloat(v.trim()));
+ if (values.length >= 2 && !values.some(isNaN)) {
+ this.data.push({
+ timestamp: values[0],
+ value: values[1],
+ });
+ }
+ }
+
+ console.log(`[CSVDataSource] Loaded ${this.data.length} data points`);
+ }
+
+ start() {
+ if (this.isRunning || this.data.length === 0) return;
+ super.start();
+
+ // Play back at specified rate
+ this.intervalHandle = setInterval(() => {
+ this.emitNextPoint();
+ }, 16 / this.playbackRate); // ~60fps adjusted by playback rate
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ reset() {
+ super.reset();
+ this.currentIndex = 0;
+ }
+
+ emitNextPoint() {
+ if (this.currentIndex >= this.data.length) {
+ if (this.loop) {
+ this.currentIndex = 0;
+ } else {
+ this.stop();
+ return;
+ }
+ }
+
+ const point = this.data[this.currentIndex];
+ this.emitPoint(point.value, point.timestamp);
+ this.currentIndex++;
+ }
+}
+
+/**
+ * Multi-source combiner
+ * Combines data from multiple sources
+ */
+export class CompositeDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.sources = config.sources || [];
+ this.combineMode = config.combineMode || 'average'; // 'average', 'sum', 'max', 'min'
+ this.pointBuffer = new Map(); // sourceId => latest point
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ // Subscribe to all sources
+ this.sources.forEach((source, idx) => {
+ source.on('point', (data) => {
+ this.handleSourcePoint(idx, data);
+ });
+ source.on('line', (data) => {
+ this.handleSourceLine(idx, data);
+ });
+ source.start();
+ });
+ }
+
+ stop() {
+ super.stop();
+ this.sources.forEach(source => source.stop());
+ }
+
+ handleSourcePoint(sourceIdx, data) {
+ this.pointBuffer.set(sourceIdx, data.value);
+
+ // If we have data from all sources, combine and emit
+ if (this.pointBuffer.size === this.sources.length) {
+ const combined = this.combineValues(Array.from(this.pointBuffer.values()));
+ this.emitPoint(combined, data.timestamp);
+ }
+ }
+
+ handleSourceLine(sourceIdx, data) {
+ // For lines, just pass through for now
+ // Could implement line combination if needed
+ this.emitLine(data.points, data.metadata);
+ }
+
+ combineValues(values) {
+ switch (this.combineMode) {
+ case 'sum':
+ return values.reduce((a, b) => a + b, 0);
+ case 'average':
+ return values.reduce((a, b) => a + b, 0) / values.length;
+ case 'max':
+ return Math.max(...values);
+ case 'min':
+ return Math.min(...values);
+ default:
+ return values[0];
+ }
+ }
+
+ addSource(source) {
+ this.sources.push(source);
+ if (this.isRunning) {
+ source.start();
+ }
+ }
+
+ removeSource(source) {
+ const idx = this.sources.indexOf(source);
+ if (idx > -1) {
+ source.stop();
+ this.sources.splice(idx, 1);
+ }
+ }
+}
diff --git a/src/data/base-source.js b/src/data/base-source.js
new file mode 100644
index 0000000..55dbdc3
--- /dev/null
+++ b/src/data/base-source.js
@@ -0,0 +1,21 @@
+export class BaseSource {
+ constructor(config = {}) {
+ this.config = { ...config };
+ this.running = false;
+ }
+
+ start() {
+ this.running = true;
+ }
+
+ stop() {
+ this.running = false;
+ }
+
+ updateConfig(nextConfig) {
+ this.config = {
+ ...this.config,
+ ...nextConfig,
+ };
+ }
+}
diff --git a/src/data/csv-replay-source.js b/src/data/csv-replay-source.js
new file mode 100644
index 0000000..c4e6a66
--- /dev/null
+++ b/src/data/csv-replay-source.js
@@ -0,0 +1,60 @@
+import { BaseSource } from './base-source.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export class CsvReplaySource extends BaseSource {
+ constructor(config = {}) {
+ super({
+ replayRate: 1,
+ dataset: [],
+ ...config,
+ });
+ this.sourceType = 'csv-replay';
+ this.nextPointIndex = 0;
+ }
+
+ start(startTimeMs = 0) {
+ super.start();
+ this.reset(startTimeMs);
+ }
+
+ reset() {
+ this.nextPointIndex = 0;
+ }
+
+ updateConfig(nextConfig) {
+ const datasetChanged = nextConfig.dataset !== this.config.dataset;
+ super.updateConfig(nextConfig);
+ if (datasetChanged) {
+ this.reset();
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ if (!this.running || !Array.isArray(this.config.dataset) || this.config.dataset.length === 0) {
+ return [];
+ }
+
+ const replayRate = clamp(this.config.replayRate ?? 1, 0.1, 8);
+ const targetDatasetTimeMs = currentPlotTimeMs * replayRate;
+ const points = [];
+
+ while (this.nextPointIndex < this.config.dataset.length) {
+ const datasetPoint = this.config.dataset[this.nextPointIndex];
+ if (datasetPoint.timeMs > targetDatasetTimeMs) {
+ break;
+ }
+
+ points.push({
+ timeMs: datasetPoint.timeMs / replayRate,
+ value: datasetPoint.value,
+ sourceId: this.config.id ?? 'csv-replay',
+ });
+ this.nextPointIndex += 1;
+ }
+
+ return points;
+ }
+}
diff --git a/src/data/parse-replay-csv.js b/src/data/parse-replay-csv.js
new file mode 100644
index 0000000..b6ce97a
--- /dev/null
+++ b/src/data/parse-replay-csv.js
@@ -0,0 +1,108 @@
+function splitRow(line) {
+ return line.split(/[;,\t]/).map((value) => value.trim());
+}
+
+function isNumeric(value) {
+ return value !== '' && Number.isFinite(Number(value));
+}
+
+function detectHeader(rows) {
+ if (rows.length === 0) {
+ return { hasHeader: false, headers: [] };
+ }
+
+ const [firstRow] = rows;
+ const hasHeader = firstRow.some((value) => !isNumeric(value));
+ return {
+ hasHeader,
+ headers: hasHeader ? firstRow.map((value) => value.toLowerCase()) : [],
+ };
+}
+
+function detectTimeScale(headers) {
+ const timeHeader = headers.find((header) => header.includes('time') || header.includes('timestamp'));
+ if (!timeHeader) {
+ return 1;
+ }
+
+ if (timeHeader.includes('sec') && !timeHeader.includes('msec') && !timeHeader.includes('ms')) {
+ return 1000;
+ }
+
+ return 1;
+}
+
+function detectColumnIndexes(headers, columnCount) {
+ if (headers.length === 0) {
+ return {
+ timeIndex: columnCount > 1 ? 0 : -1,
+ valueIndex: columnCount > 1 ? 1 : 0,
+ };
+ }
+
+ const timeIndex = headers.findIndex((header) => header.includes('time') || header.includes('timestamp'));
+ const valueIndex = headers.findIndex((header) => header.includes('value') || header.includes('signal') || header.includes('y'));
+
+ return {
+ timeIndex,
+ valueIndex: valueIndex >= 0 ? valueIndex : (headers.length > 1 ? 1 : 0),
+ };
+}
+
+export function parseReplayCsv(text, { sampleRateHz = 60 } = {}) {
+ const rows = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'))
+ .map(splitRow)
+ .filter((row) => row.some((value) => value !== ''));
+
+ if (rows.length === 0) {
+ throw new Error('CSV file is empty');
+ }
+
+ const { hasHeader, headers } = detectHeader(rows);
+ const dataRows = hasHeader ? rows.slice(1) : rows;
+ const columnCount = rows[0].length;
+ const { timeIndex, valueIndex } = detectColumnIndexes(headers, columnCount);
+ const timeScale = detectTimeScale(headers);
+ const intervalMs = 1000 / Math.max(1, sampleRateHz);
+
+ const points = dataRows
+ .map((row, index) => {
+ const rawValue = row[valueIndex];
+ if (!isNumeric(rawValue)) {
+ return null;
+ }
+
+ const parsedValue = Number(rawValue);
+ const parsedTime = timeIndex >= 0 && isNumeric(row[timeIndex])
+ ? Number(row[timeIndex]) * timeScale
+ : index * intervalMs;
+
+ return {
+ timeMs: parsedTime,
+ value: parsedValue,
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.timeMs - right.timeMs);
+
+ if (points.length === 0) {
+ throw new Error('CSV file did not contain any numeric data points');
+ }
+
+ const firstTime = points[0].timeMs;
+ const normalizedPoints = points.map((point) => ({
+ timeMs: point.timeMs - firstTime,
+ value: point.value,
+ }));
+
+ return {
+ points: normalizedPoints,
+ metadata: {
+ pointCount: normalizedPoints.length,
+ durationMs: normalizedPoints.at(-1)?.timeMs ?? 0,
+ },
+ };
+}
diff --git a/src/data/source-registry.js b/src/data/source-registry.js
new file mode 100644
index 0000000..917d06b
--- /dev/null
+++ b/src/data/source-registry.js
@@ -0,0 +1,90 @@
+import { CsvReplaySource } from './csv-replay-source.js';
+import { SyntheticWaveSource } from './synthetic-wave-source.js';
+import { WebSocketSource } from './websocket-source.js';
+
+export class SourceRegistry {
+ constructor(store, bus) {
+ this.store = store;
+ this.bus = bus;
+ this.sources = new Map();
+ this.syncFromState();
+ }
+
+ syncFromState() {
+ const state = this.store.getState();
+ const sourceEntries = Object.entries(state.sources);
+ const activeKeys = new Set(sourceEntries.map(([sourceKey]) => sourceKey));
+
+ for (const [sourceKey, config] of sourceEntries) {
+ const existingSource = this.sources.get(sourceKey);
+
+ if (!existingSource) {
+ const nextSource = this.createSource(sourceKey, config);
+ this.sources.set(sourceKey, nextSource);
+ nextSource.start(state.time.plotTimeMs);
+ continue;
+ }
+
+ if (existingSource.sourceType !== config.type) {
+ existingSource.stop();
+ const replacementSource = this.createSource(sourceKey, config);
+ this.sources.set(sourceKey, replacementSource);
+ replacementSource.start(state.time.plotTimeMs);
+ continue;
+ }
+
+ existingSource.updateConfig(config);
+ }
+
+ for (const [sourceKey, source] of this.sources.entries()) {
+ if (!activeKeys.has(sourceKey)) {
+ source.stop();
+ this.sources.delete(sourceKey);
+ }
+ }
+ }
+
+ createSource(sourceKey, config) {
+ switch (config.type) {
+ case 'csv-replay':
+ return new CsvReplaySource(config);
+ case 'websocket':
+ return new WebSocketSource(config, {
+ onStatusChange: (statusPatch) => {
+ this.store.setState((state) => ({
+ ...state,
+ sources: {
+ ...state.sources,
+ [sourceKey]: {
+ ...state.sources[sourceKey],
+ ...statusPatch,
+ },
+ },
+ }));
+ },
+ });
+ case 'synthetic-wave':
+ default:
+ return new SyntheticWaveSource(config);
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ for (const [sourceKey, source] of this.sources.entries()) {
+ const points = source.update(currentPlotTimeMs);
+ for (const point of points) {
+ this.bus.emit('data:point', {
+ ...point,
+ sourceId: sourceKey,
+ });
+ }
+ }
+ }
+
+ reset() {
+ const startTimeMs = this.store.getState().time.plotTimeMs;
+ for (const source of this.sources.values()) {
+ source.reset(startTimeMs);
+ }
+ }
+}
diff --git a/src/data/synthetic-wave-source.js b/src/data/synthetic-wave-source.js
new file mode 100644
index 0000000..df53319
--- /dev/null
+++ b/src/data/synthetic-wave-source.js
@@ -0,0 +1,87 @@
+import { BaseSource } from './base-source.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function createDeterministicNoise(seed) {
+ const x = Math.sin(seed * 12.9898) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+export class SyntheticWaveSource extends BaseSource {
+ constructor(config = {}) {
+ super({
+ sampleRateHz: 60,
+ preset: 'telemetry',
+ amplitude: 1,
+ noise: 0.08,
+ ...config,
+ });
+ this.sourceType = 'synthetic-wave';
+ this.lastEmittedPlotTimeMs = 0;
+ }
+
+ start(startTimeMs = 0) {
+ super.start();
+ this.lastEmittedPlotTimeMs = startTimeMs;
+ }
+
+ stop() {
+ super.stop();
+ }
+
+ reset(startTimeMs = 0) {
+ this.lastEmittedPlotTimeMs = startTimeMs;
+ }
+
+ sampleValue(timeMs) {
+ const seconds = timeMs / 1000;
+ const amplitude = this.config.amplitude;
+ const noise = this.config.noise;
+ const grain = (createDeterministicNoise(timeMs * 0.017) - 0.5) * 2 * noise;
+
+ switch (this.config.preset) {
+ case 'chirp': {
+ const sweep = Math.sin(seconds * seconds * 1.4);
+ return amplitude * (0.7 * sweep + 0.3 * Math.sin(seconds * 7.5)) + grain;
+ }
+ case 'burst': {
+ const burstPhase = (seconds % 6) - 1.5;
+ const burst = Math.sin(seconds * 9.5) * Math.exp(-(burstPhase ** 2) * 0.8);
+ return amplitude * (0.45 * Math.sin(seconds * 2.1) + burst) + grain;
+ }
+ case 'telemetry':
+ default: {
+ const carrier = Math.sin(seconds * 2.2);
+ const secondary = 0.35 * Math.cos(seconds * 6.4 + Math.sin(seconds * 0.8));
+ const envelope = 0.15 * Math.sin(seconds * 0.33);
+ return amplitude * (carrier + secondary + envelope) + grain;
+ }
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ if (!this.running) {
+ return [];
+ }
+
+ const intervalMs = 1000 / clamp(this.config.sampleRateHz, 1, 240);
+ if (currentPlotTimeMs < this.lastEmittedPlotTimeMs) {
+ this.lastEmittedPlotTimeMs = currentPlotTimeMs;
+ return [];
+ }
+
+ const points = [];
+ while (this.lastEmittedPlotTimeMs + intervalMs <= currentPlotTimeMs) {
+ this.lastEmittedPlotTimeMs += intervalMs;
+ points.push({
+ timeMs: this.lastEmittedPlotTimeMs,
+ value: this.sampleValue(this.lastEmittedPlotTimeMs),
+ sourceId: 'synthetic-wave',
+ });
+ }
+
+ return points;
+ }
+}
diff --git a/src/data/websocket-source.js b/src/data/websocket-source.js
new file mode 100644
index 0000000..5458fb9
--- /dev/null
+++ b/src/data/websocket-source.js
@@ -0,0 +1,224 @@
+import { BaseSource } from './base-source.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function isFiniteNumber(value) {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+function parsePayload(payload) {
+ if (Array.isArray(payload)) {
+ return payload.flatMap((item) => parsePayload(item));
+ }
+
+ if (isFiniteNumber(payload)) {
+ return [{ value: payload, timestampMs: null }];
+ }
+
+ if (typeof payload === 'string') {
+ const trimmed = payload.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ const numeric = Number(trimmed);
+ if (Number.isFinite(numeric)) {
+ return [{ value: numeric, timestampMs: null }];
+ }
+
+ try {
+ return parsePayload(JSON.parse(trimmed));
+ } catch {
+ return [];
+ }
+ }
+
+ if (payload && typeof payload === 'object') {
+ const candidateValue = [payload.value, payload.y, payload.signal, payload.data]
+ .find((value) => Number.isFinite(Number(value)));
+
+ if (candidateValue === undefined) {
+ return [];
+ }
+
+ const candidateTimestamp = [payload.timeMs, payload.timestampMs, payload.timestamp, payload.t]
+ .find((value) => Number.isFinite(Number(value)));
+
+ return [{
+ value: Number(candidateValue),
+ timestampMs: candidateTimestamp === undefined ? null : Number(candidateTimestamp),
+ }];
+ }
+
+ return [];
+}
+
+export class WebSocketSource extends BaseSource {
+ constructor(config = {}, { onStatusChange } = {}) {
+ super({
+ wsUrl: 'ws://localhost:8080',
+ wsReconnectMs: 2000,
+ ...config,
+ });
+ this.sourceType = 'websocket';
+ this.onStatusChange = onStatusChange;
+ this.socket = null;
+ this.queue = [];
+ this.lastPlotTimeMs = 0;
+ this.reconnectTimer = null;
+ this.shouldReconnect = false;
+ this.firstSourceTimestampMs = null;
+ this.basePlotTimeMs = 0;
+ }
+
+ start(startTimeMs = 0) {
+ super.start();
+ this.lastPlotTimeMs = startTimeMs;
+ this.basePlotTimeMs = startTimeMs;
+ this.shouldReconnect = true;
+ this.connect();
+ }
+
+ stop() {
+ super.stop();
+ this.shouldReconnect = false;
+ this.clearReconnectTimer();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ this.setStatus('disconnected', 'socket closed');
+ }
+
+ reset(startTimeMs = 0) {
+ this.queue = [];
+ this.lastPlotTimeMs = startTimeMs;
+ this.basePlotTimeMs = startTimeMs;
+ this.firstSourceTimestampMs = null;
+ }
+
+ updateConfig(nextConfig) {
+ const previousUrl = this.config.wsUrl;
+ const previousReconnectMs = this.config.wsReconnectMs;
+ super.updateConfig(nextConfig);
+
+ if ((previousUrl !== this.config.wsUrl || previousReconnectMs !== this.config.wsReconnectMs) && this.running) {
+ this.reconnect();
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ this.lastPlotTimeMs = currentPlotTimeMs;
+
+ if (this.queue.length === 0) {
+ return [];
+ }
+
+ const points = [];
+ while (this.queue.length > 0) {
+ const nextPoint = this.queue.shift();
+ let timeMs = currentPlotTimeMs;
+
+ if (isFiniteNumber(nextPoint.timestampMs)) {
+ if (this.firstSourceTimestampMs === null) {
+ this.firstSourceTimestampMs = nextPoint.timestampMs;
+ this.basePlotTimeMs = currentPlotTimeMs;
+ }
+ timeMs = this.basePlotTimeMs + (nextPoint.timestampMs - this.firstSourceTimestampMs);
+ }
+
+ points.push({
+ timeMs,
+ value: nextPoint.value,
+ sourceId: this.config.id ?? 'websocket',
+ });
+ }
+
+ return points;
+ }
+
+ reconnect() {
+ if (!this.running) {
+ return;
+ }
+
+ this.clearReconnectTimer();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ this.connect();
+ }
+
+ connect() {
+ const url = this.config.wsUrl?.trim();
+ if (!url) {
+ this.setStatus('idle', 'enter a websocket url');
+ return;
+ }
+
+ this.clearReconnectTimer();
+ this.setStatus('connecting', url);
+
+ try {
+ this.socket = new WebSocket(url);
+ } catch (error) {
+ this.setStatus('error', error instanceof Error ? error.message : String(error));
+ this.scheduleReconnect();
+ return;
+ }
+
+ this.socket.addEventListener('open', () => {
+ this.setStatus('connected', url);
+ });
+
+ this.socket.addEventListener('message', (event) => {
+ const parsedPoints = parsePayload(event.data);
+ if (parsedPoints.length === 0) {
+ return;
+ }
+ this.queue.push(...parsedPoints);
+ });
+
+ this.socket.addEventListener('error', () => {
+ this.setStatus('error', 'socket error');
+ });
+
+ this.socket.addEventListener('close', () => {
+ this.socket = null;
+ if (!this.running) {
+ return;
+ }
+ this.setStatus('disconnected', 'retrying');
+ this.scheduleReconnect();
+ });
+ }
+
+ scheduleReconnect() {
+ if (!this.shouldReconnect || !this.running) {
+ return;
+ }
+
+ const reconnectMs = clamp(Number(this.config.wsReconnectMs) || 2000, 250, 30000);
+ this.clearReconnectTimer();
+ this.reconnectTimer = window.setTimeout(() => {
+ this.connect();
+ }, reconnectMs);
+ }
+
+ clearReconnectTimer() {
+ if (this.reconnectTimer !== null) {
+ window.clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
+
+ setStatus(status, detail = '') {
+ this.onStatusChange?.({
+ wsStatus: status,
+ wsStatusDetail: detail,
+ });
+ }
+}
diff --git a/src/demos.js b/src/demos.js
new file mode 100644
index 0000000..1dd6785
--- /dev/null
+++ b/src/demos.js
@@ -0,0 +1,697 @@
+/**
+ * Preloaded Graphics Demos
+ *
+ * Each demo exports:
+ * - name: Display name
+ * - description: Short description
+ * - setup(app, state): Called once to create objects
+ * - update(app, state, objects): Called every frame
+ * - cleanup(app, objects): Called when switching demos
+ */
+
+// ============================================================================
+// DEMO 1: BOUNCING PARTICLES
+// ============================================================================
+
+export const bouncingParticles = {
+ name: "Bouncing Particles",
+ description: "Colorful particles bouncing around the screen",
+
+ setup(app, state) {
+ const particles = [];
+ const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7];
+
+ for (let i = 0; i < 50; i++) {
+ const particle = new PIXI.Graphics();
+ const size = 5 + Math.random() * 10;
+ particle.circle(0, 0, size);
+ particle.fill(colors[Math.floor(Math.random() * colors.length)]);
+
+ particle.x = Math.random() * app.screen.width;
+ particle.y = Math.random() * app.screen.height;
+ particle.vx = (Math.random() - 0.5) * 8;
+ particle.vy = (Math.random() - 0.5) * 8;
+ particle.size = size;
+
+ app.stage.addChild(particle);
+ particles.push(particle);
+ }
+
+ return { particles };
+ },
+
+ update(app, state, objects) {
+ objects.particles.forEach(p => {
+ p.x += p.vx;
+ p.y += p.vy;
+
+ // Bounce off edges
+ if (p.x < p.size || p.x > app.screen.width - p.size) p.vx *= -1;
+ if (p.y < p.size || p.y > app.screen.height - p.size) p.vy *= -1;
+
+ // Clamp to screen
+ p.x = Math.max(p.size, Math.min(app.screen.width - p.size, p.x));
+ p.y = Math.max(p.size, Math.min(app.screen.height - p.size, p.y));
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.particles.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 2: SPIROGRAPH
+// ============================================================================
+
+export const spirograph = {
+ name: "Spirograph",
+ description: "Mesmerizing geometric spiral patterns",
+
+ setup(app, state) {
+ const graphics = new PIXI.Graphics();
+ app.stage.addChild(graphics);
+
+ return {
+ graphics,
+ angle: 0,
+ points: []
+ };
+ },
+
+ update(app, state, objects) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const t = state.state.time.current;
+
+ // Generate new point
+ const r1 = 150;
+ const r2 = 50;
+ const r3 = 30;
+
+ const x = cx + Math.cos(t * 0.5) * r1 + Math.cos(t * 2) * r2 + Math.cos(t * 5) * r3;
+ const y = cy + Math.sin(t * 0.5) * r1 + Math.sin(t * 2) * r2 + Math.sin(t * 5) * r3;
+
+ objects.points.push({ x, y });
+
+ // Keep only last 500 points
+ if (objects.points.length > 500) {
+ objects.points.shift();
+ }
+
+ // Draw trail
+ objects.graphics.clear();
+ if (objects.points.length > 1) {
+ for (let i = 1; i < objects.points.length; i++) {
+ const alpha = i / objects.points.length;
+ const hue = (i / objects.points.length) * 360;
+ objects.graphics.moveTo(objects.points[i-1].x, objects.points[i-1].y);
+ objects.graphics.lineTo(objects.points[i].x, objects.points[i].y);
+ objects.graphics.stroke({ width: 2, color: hslToHex(hue, 100, 60), alpha });
+ }
+ }
+ },
+
+ cleanup(app, objects) {
+ objects.graphics.destroy();
+ }
+};
+
+// ============================================================================
+// DEMO 3: STARFIELD
+// ============================================================================
+
+export const starfield = {
+ name: "Starfield",
+ description: "Flying through space at warp speed",
+
+ setup(app, state) {
+ const stars = [];
+
+ for (let i = 0; i < 200; i++) {
+ const star = new PIXI.Graphics();
+ star.circle(0, 0, 2);
+ star.fill(0xffffff);
+
+ star.x = (Math.random() - 0.5) * app.screen.width * 2;
+ star.y = (Math.random() - 0.5) * app.screen.height * 2;
+ star.z = Math.random() * 1000;
+
+ app.stage.addChild(star);
+ stars.push(star);
+ }
+
+ return { stars };
+ },
+
+ update(app, state, objects) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const speed = 5;
+
+ objects.stars.forEach(star => {
+ star.z -= speed;
+
+ if (star.z <= 0) {
+ star.z = 1000;
+ star.x = (Math.random() - 0.5) * app.screen.width * 2;
+ star.y = (Math.random() - 0.5) * app.screen.height * 2;
+ }
+
+ const screenX = cx + (star.x / star.z) * 200;
+ const screenY = cy + (star.y / star.z) * 200;
+ const size = (1 - star.z / 1000) * 4 + 1;
+
+ star.x = star.x;
+ star.y = star.y;
+ star.position.set(screenX, screenY);
+ star.scale.set(size);
+ star.alpha = 1 - star.z / 1000;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.stars.forEach(s => s.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 4: WAVE INTERFERENCE
+// ============================================================================
+
+export const waveInterference = {
+ name: "Wave Interference",
+ description: "Rippling wave patterns",
+
+ setup(app, state) {
+ const gridSize = 20;
+ const cols = Math.floor(app.screen.width / gridSize);
+ const rows = Math.floor(app.screen.height / gridSize);
+ const circles = [];
+
+ for (let i = 0; i < cols; i++) {
+ for (let j = 0; j < rows; j++) {
+ const circle = new PIXI.Graphics();
+ circle.circle(0, 0, 4);
+ circle.fill(0x4ecdc4);
+ circle.x = i * gridSize + gridSize / 2;
+ circle.y = j * gridSize + gridSize / 2;
+ circle.baseX = circle.x;
+ circle.baseY = circle.y;
+
+ app.stage.addChild(circle);
+ circles.push(circle);
+ }
+ }
+
+ return { circles, sources: [
+ { x: app.screen.width * 0.3, y: app.screen.height * 0.5 },
+ { x: app.screen.width * 0.7, y: app.screen.height * 0.5 }
+ ]};
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.circles.forEach(c => {
+ let totalOffset = 0;
+
+ objects.sources.forEach(source => {
+ const dx = c.baseX - source.x;
+ const dy = c.baseY - source.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ totalOffset += Math.sin(dist * 0.05 - t * 3) * 10;
+ });
+
+ c.y = c.baseY + totalOffset;
+ c.alpha = 0.3 + (Math.sin(totalOffset * 0.1) + 1) * 0.35;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.circles.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 5: CIRCLE PACKING
+// ============================================================================
+
+export const circlePacking = {
+ name: "Circle Packing",
+ description: "Organic growth simulation",
+
+ setup(app, state) {
+ const circles = [];
+ return { circles, attempts: 0 };
+ },
+
+ update(app, state, objects) {
+ // Try to add a new circle each frame
+ const maxAttempts = 100;
+ const maxCircles = 150;
+
+ if (objects.circles.length >= maxCircles) return;
+
+ for (let i = 0; i < 10; i++) {
+ const x = Math.random() * app.screen.width;
+ const y = Math.random() * app.screen.height;
+ const minRadius = 5;
+ const maxRadius = 60;
+
+ let valid = true;
+ let radius = minRadius;
+
+ // Find largest radius that doesn't overlap
+ for (let r = minRadius; r < maxRadius; r++) {
+ let overlaps = false;
+
+ for (const other of objects.circles) {
+ const dx = x - other.x;
+ const dy = y - other.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < r + other.radius + 2) {
+ overlaps = true;
+ break;
+ }
+ }
+
+ if (overlaps) {
+ break;
+ }
+ radius = r;
+ }
+
+ if (radius > minRadius) {
+ const circle = new PIXI.Graphics();
+ circle.circle(0, 0, radius);
+ const hue = (objects.circles.length * 137.5) % 360;
+ circle.fill(hslToHex(hue, 70, 60));
+ circle.x = x;
+ circle.y = y;
+ circle.radius = radius;
+
+ app.stage.addChild(circle);
+ objects.circles.push(circle);
+ break;
+ }
+ }
+ },
+
+ cleanup(app, objects) {
+ objects.circles.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 6: PERLIN FLOW FIELD
+// ============================================================================
+
+export const flowField = {
+ name: "Flow Field",
+ description: "Particles following a noise field",
+
+ setup(app, state) {
+ const particles = [];
+ const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7, 0xfeca57];
+
+ for (let i = 0; i < 300; i++) {
+ const particle = new PIXI.Graphics();
+ particle.circle(0, 0, 2);
+ particle.fill(colors[Math.floor(Math.random() * colors.length)]);
+ particle.alpha = 0.6;
+
+ particle.x = Math.random() * app.screen.width;
+ particle.y = Math.random() * app.screen.height;
+ particle.vx = 0;
+ particle.vy = 0;
+ particle.color = colors[Math.floor(Math.random() * colors.length)];
+
+ app.stage.addChild(particle);
+ particles.push(particle);
+ }
+
+ return { particles };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.particles.forEach(p => {
+ // Simple noise-like function using sin/cos
+ const angle = noise(p.x * 0.005, p.y * 0.005, t * 0.3) * Math.PI * 2;
+
+ p.vx += Math.cos(angle) * 0.3;
+ p.vy += Math.sin(angle) * 0.3;
+
+ // Damping
+ p.vx *= 0.95;
+ p.vy *= 0.95;
+
+ p.x += p.vx;
+ p.y += p.vy;
+
+ // Wrap around screen
+ if (p.x < 0) p.x = app.screen.width;
+ if (p.x > app.screen.width) p.x = 0;
+ if (p.y < 0) p.y = app.screen.height;
+ if (p.y > app.screen.height) p.y = 0;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.particles.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 7: DNA HELIX
+// ============================================================================
+
+export const dnaHelix = {
+ name: "DNA Helix",
+ description: "Rotating double helix structure",
+
+ setup(app, state) {
+ const helix1 = [];
+ const helix2 = [];
+ const connectors = [];
+ const segments = 40;
+
+ for (let i = 0; i < segments; i++) {
+ const sphere1 = new PIXI.Graphics();
+ sphere1.circle(0, 0, 8);
+ sphere1.fill(0x4ecdc4);
+ app.stage.addChild(sphere1);
+ helix1.push(sphere1);
+
+ const sphere2 = new PIXI.Graphics();
+ sphere2.circle(0, 0, 8);
+ sphere2.fill(0xff6b6b);
+ app.stage.addChild(sphere2);
+ helix2.push(sphere2);
+
+ const connector = new PIXI.Graphics();
+ app.stage.addChild(connector);
+ connectors.push(connector);
+ }
+
+ return { helix1, helix2, connectors };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const radius = 100;
+ const height = app.screen.height * 0.8;
+ const spacing = height / objects.helix1.length;
+
+ objects.helix1.forEach((sphere, i) => {
+ const y = i * spacing - height / 2 + cy;
+ const angle = t + i * 0.3;
+ const x = cx + Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+
+ sphere.x = x;
+ sphere.y = y;
+ sphere.scale.set(1 + z / 200);
+ sphere.alpha = 0.5 + z / 400;
+ });
+
+ objects.helix2.forEach((sphere, i) => {
+ const y = i * spacing - height / 2 + cy;
+ const angle = t + i * 0.3 + Math.PI;
+ const x = cx + Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+
+ sphere.x = x;
+ sphere.y = y;
+ sphere.scale.set(1 + z / 200);
+ sphere.alpha = 0.5 + z / 400;
+ });
+
+ // Draw connectors
+ objects.connectors.forEach((connector, i) => {
+ connector.clear();
+ connector.moveTo(objects.helix1[i].x, objects.helix1[i].y);
+ connector.lineTo(objects.helix2[i].x, objects.helix2[i].y);
+ connector.stroke({ width: 2, color: 0x666666, alpha: 0.3 });
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.helix1.forEach(s => s.destroy());
+ objects.helix2.forEach(s => s.destroy());
+ objects.connectors.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 8: FIREWORKS
+// ============================================================================
+
+export const fireworks = {
+ name: "Fireworks",
+ description: "Explosive particle celebration",
+
+ setup(app, state) {
+ return {
+ explosions: [],
+ nextExplosion: 0
+ };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ // Create new explosion every second
+ if (t > objects.nextExplosion) {
+ objects.nextExplosion = t + 0.5 + Math.random();
+
+ const explosion = {
+ x: Math.random() * app.screen.width,
+ y: Math.random() * app.screen.height * 0.7,
+ particles: [],
+ color: Math.random() * 0xffffff,
+ born: t
+ };
+
+ // Create particles
+ for (let i = 0; i < 50; i++) {
+ const angle = (i / 50) * Math.PI * 2;
+ const speed = 2 + Math.random() * 4;
+ const particle = new PIXI.Graphics();
+ particle.circle(0, 0, 3);
+ particle.fill(explosion.color);
+ particle.x = explosion.x;
+ particle.y = explosion.y;
+ particle.vx = Math.cos(angle) * speed;
+ particle.vy = Math.sin(angle) * speed;
+
+ app.stage.addChild(particle);
+ explosion.particles.push(particle);
+ }
+
+ objects.explosions.push(explosion);
+ }
+
+ // Update explosions
+ objects.explosions = objects.explosions.filter(explosion => {
+ const age = t - explosion.born;
+
+ if (age > 3) {
+ explosion.particles.forEach(p => p.destroy());
+ return false;
+ }
+
+ explosion.particles.forEach(p => {
+ p.vx *= 0.98;
+ p.vy += 0.1; // Gravity
+ p.x += p.vx;
+ p.y += p.vy;
+ p.alpha = 1 - age / 3;
+ });
+
+ return true;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.explosions.forEach(explosion => {
+ explosion.particles.forEach(p => p.destroy());
+ });
+ }
+};
+
+// ============================================================================
+// DEMO 9: MATRIX RAIN
+// ============================================================================
+
+export const matrixRain = {
+ name: "Matrix Rain",
+ description: "Falling digital rain effect",
+
+ setup(app, state) {
+ const fontSize = 16;
+ const columns = Math.floor(app.screen.width / fontSize);
+ const drops = [];
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*";
+
+ for (let i = 0; i < columns; i++) {
+ const text = new PIXI.Text('', {
+ fontFamily: 'monospace',
+ fontSize: fontSize,
+ fill: 0x00ff00
+ });
+ text.x = i * fontSize;
+ text.y = -Math.random() * app.screen.height;
+
+ app.stage.addChild(text);
+ drops.push({
+ text,
+ speed: 1 + Math.random() * 3,
+ chars: chars
+ });
+ }
+
+ return { drops };
+ },
+
+ update(app, state, objects) {
+ objects.drops.forEach(drop => {
+ drop.y = (drop.y || drop.text.y) + drop.speed;
+ drop.text.y = drop.y;
+
+ // Random character
+ if (Math.random() > 0.95) {
+ drop.text.text = drop.chars[Math.floor(Math.random() * drop.chars.length)];
+ }
+
+ // Reset to top
+ if (drop.y > app.screen.height) {
+ drop.y = -20;
+ drop.text.alpha = 1;
+ }
+
+ // Fade trail
+ drop.text.alpha = Math.max(0.1, drop.text.alpha - 0.01);
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.drops.forEach(d => d.text.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 10: SOLAR SYSTEM
+// ============================================================================
+
+export const solarSystem = {
+ name: "Solar System",
+ description: "Orbiting planets around a star",
+
+ setup(app, state) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+
+ // Sun
+ const sun = new PIXI.Graphics();
+ sun.circle(0, 0, 30);
+ sun.fill(0xffd700);
+ sun.x = cx;
+ sun.y = cy;
+ app.stage.addChild(sun);
+
+ // Planets
+ const planets = [
+ { radius: 60, size: 6, speed: 2.0, color: 0x8b7355 },
+ { radius: 100, size: 10, speed: 1.5, color: 0xff6347 },
+ { radius: 150, size: 12, speed: 1.0, color: 0x4169e1 },
+ { radius: 200, size: 8, speed: 0.7, color: 0xff4500 },
+ { radius: 260, size: 18, speed: 0.4, color: 0xdaa520 },
+ ];
+
+ const planetObjects = planets.map(config => {
+ const planet = new PIXI.Graphics();
+ planet.circle(0, 0, config.size);
+ planet.fill(config.color);
+ planet.config = config;
+ app.stage.addChild(planet);
+ return planet;
+ });
+
+ return { sun, planets: planetObjects, cx, cy };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.planets.forEach((planet, i) => {
+ const angle = t * planet.config.speed;
+ planet.x = objects.cx + Math.cos(angle) * planet.config.radius;
+ planet.y = objects.cy + Math.sin(angle) * planet.config.radius;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.sun.destroy();
+ objects.planets.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// UTILITIES
+// ============================================================================
+
+function hslToHex(h, s, l) {
+ s /= 100;
+ l /= 100;
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
+ const m = l - c/2;
+ let r = 0, g = 0, b = 0;
+
+ if (0 <= h && h < 60) {
+ r = c; g = x; b = 0;
+ } else if (60 <= h && h < 120) {
+ r = x; g = c; b = 0;
+ } else if (120 <= h && h < 180) {
+ r = 0; g = c; b = x;
+ } else if (180 <= h && h < 240) {
+ r = 0; g = x; b = c;
+ } else if (240 <= h && h < 300) {
+ r = x; g = 0; b = c;
+ } else if (300 <= h && h < 360) {
+ r = c; g = 0; b = x;
+ }
+
+ r = Math.round((r + m) * 255);
+ g = Math.round((g + m) * 255);
+ b = Math.round((b + m) * 255);
+
+ return (r << 16) | (g << 8) | b;
+}
+
+function noise(x, y, z) {
+ return Math.sin(x + Math.cos(y)) * Math.cos(y + Math.sin(z)) * Math.sin(z + Math.cos(x));
+}
+
+// ============================================================================
+// EXPORT ALL DEMOS
+// ============================================================================
+
+export const allDemos = [
+ bouncingParticles,
+ spirograph,
+ starfield,
+ waveInterference,
+ circlePacking,
+ flowField,
+ dnaHelix,
+ fireworks,
+ matrixRain,
+ solarSystem
+];
diff --git a/src/example-usage.js b/src/example-usage.js
new file mode 100644
index 0000000..67eff4b
--- /dev/null
+++ b/src/example-usage.js
@@ -0,0 +1,535 @@
+/**
+ * Example Usage: Complete examples of the new architecture
+ *
+ * This file demonstrates how to use the separated data/visualization architecture:
+ * - TimeSeriesPlot: Pure visualization
+ * - DataSource: Data generation/provision
+ * - Connections: Links between them
+ */
+
+import { Application } from 'pixi.js';
+import { TimeSeriesPlot } from './timeseries-plot.js';
+import {
+ SyntheticDataSource,
+ FunctionDataSource,
+ StreamingDataSource,
+ WebSocketDataSource,
+} from './data-sources.js';
+import {
+ DirectConnection,
+ BufferedConnection,
+ ConnectionManager,
+ connectSyntheticData,
+ connectFunction,
+ createConnectedPlot,
+} from './plot-connections.js';
+import {
+ TestDataFactory,
+ SineWaveGenerator,
+ PerlinNoiseGenerator,
+ ChirpGenerator,
+} from './test-data-generators.js';
+
+// ============================================================================
+// Example 1: Simple Setup - One plot, one data source
+// ============================================================================
+
+export async function example1_SimpleSetup() {
+ console.log('=== Example 1: Simple Setup ===');
+
+ // Create PixiJS app
+ const app = new Application();
+ await app.init({
+ width: 800,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // Create plot (visualization only)
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Simple Sine Wave',
+ showGrid: true,
+ });
+ app.stage.addChild(plot.container);
+
+ // Create data source
+ const generator = TestDataFactory.createSimpleSine(30);
+ const source = new SyntheticDataSource({
+ generator: generator,
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100, // New line every 100ms
+ });
+
+ // Connect source to plot
+ const connection = new DirectConnection(source, plot);
+ connection.connect();
+
+ // Update plot every frame
+ app.ticker.add(() => {
+ plot.update();
+ });
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 2: Quick Setup Using Helper Functions
+// ============================================================================
+
+export async function example2_QuickSetup() {
+ console.log('=== Example 2: Quick Setup ===');
+
+ const app = new Application();
+ await app.init({
+ width: 800,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // One-liner setup!
+ const { plot, source, connection } = createConnectedPlot(
+ app,
+ {
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Quick Setup',
+ },
+ {
+ generator: TestDataFactory.createComplexPattern(30),
+ lineInterval: 100,
+ }
+ );
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 3: Multiple Plots with Different Data Sources
+// ============================================================================
+
+export async function example3_MultiplePlots() {
+ console.log('=== Example 3: Multiple Plots ===');
+
+ const app = new Application();
+ await app.init({
+ width: 1600,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ const width = 800;
+ const height = 600;
+
+ // Left plot: Sine wave
+ const plot1 = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: width,
+ height: height,
+ title: 'Sine Wave',
+ color: 0xff6666,
+ });
+
+ // Right plot: Perlin noise
+ const plot2 = new TimeSeriesPlot({
+ x: width,
+ y: 0,
+ width: width,
+ height: height,
+ title: 'Perlin Noise',
+ color: 0x66ff66,
+ });
+
+ app.stage.addChild(plot1.container);
+ app.stage.addChild(plot2.container);
+
+ // Connect different data sources
+ const conn1 = connectSyntheticData(
+ TestDataFactory.createSimpleSine(30),
+ plot1,
+ { lineInterval: 100 }
+ );
+
+ const conn2 = connectSyntheticData(
+ TestDataFactory.createSmoothNoise(30),
+ plot2,
+ { lineInterval: 100 }
+ );
+
+ app.ticker.add(() => {
+ plot1.update();
+ plot2.update();
+ });
+
+ return { app, plots: [plot1, plot2], connections: [conn1, conn2] };
+}
+
+// ============================================================================
+// Example 4: Using Function-Based Data Source
+// ============================================================================
+
+export async function example4_FunctionSource() {
+ console.log('=== Example 4: Function Source ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Custom Function',
+ });
+ app.stage.addChild(plot.container);
+
+ // Define a custom function: (x, t) => y
+ // x is normalized 0-1 across the width
+ // t is time in seconds
+ const customFunc = (x, t) => {
+ // Create an interference pattern
+ const wave1 = Math.sin(x * 10 + t * 2);
+ const wave2 = Math.sin(x * 15 - t * 3);
+ const wave3 = Math.cos(x * 8 + t * 1.5);
+ return (wave1 + wave2 + wave3) / 3;
+ };
+
+ const connection = connectFunction(customFunc, plot, {
+ lineInterval: 100,
+ amplitude: 30,
+ });
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, connection };
+}
+
+// ============================================================================
+// Example 5: Swapping Data Sources at Runtime
+// ============================================================================
+
+export async function example5_SwappingSources() {
+ console.log('=== Example 5: Swapping Sources ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Dynamic Source Switching',
+ });
+ app.stage.addChild(plot.container);
+
+ // Start with sine wave
+ let currentConnection = connectSyntheticData(
+ TestDataFactory.createSimpleSine(30),
+ plot,
+ { lineInterval: 100 }
+ );
+
+ app.ticker.add(() => plot.update());
+
+ // Function to switch to a different data source
+ const switchToSource = (generator, title) => {
+ // Disconnect current source
+ currentConnection.disconnect();
+
+ // Connect new source
+ currentConnection = connectSyntheticData(generator, plot, {
+ lineInterval: 100,
+ });
+
+ plot.setTitle(title);
+ console.log(`Switched to: ${title}`);
+ };
+
+ // Example: Switch sources every 5 seconds
+ let sourceIndex = 0;
+ const sources = [
+ { gen: TestDataFactory.createSimpleSine(30), title: 'Sine Wave' },
+ { gen: TestDataFactory.createComplexPattern(30), title: 'Complex Pattern' },
+ { gen: TestDataFactory.createSmoothNoise(30), title: 'Perlin Noise' },
+ { gen: TestDataFactory.createFrequencySweep(30), title: 'Frequency Sweep' },
+ ];
+
+ setInterval(() => {
+ sourceIndex = (sourceIndex + 1) % sources.length;
+ const source = sources[sourceIndex];
+ switchToSource(source.gen, source.title);
+ }, 5000);
+
+ return { app, plot, switchToSource };
+}
+
+// ============================================================================
+// Example 6: Streaming Data with Buffering
+// ============================================================================
+
+export async function example6_StreamingData() {
+ console.log('=== Example 6: Streaming Data ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Streaming Data (Buffered)',
+ });
+ app.stage.addChild(plot.container);
+
+ // Create streaming source (emits individual points)
+ const generator = new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: 1.0,
+ sampleRate: 60,
+ });
+
+ const source = new StreamingDataSource({
+ generator: generator,
+ sampleRate: 60, // 60 points per second
+ });
+
+ // Use buffered connection to assemble points into lines
+ const connection = new BufferedConnection(source, plot, {
+ bufferSize: 100, // Buffer 100 points before creating a line
+ bufferTimeout: 1000, // Or timeout after 1 second
+ });
+ connection.connect();
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 7: Connection Manager (Managing Multiple Connections)
+// ============================================================================
+
+export async function example7_ConnectionManager() {
+ console.log('=== Example 7: Connection Manager ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Managed Connections',
+ });
+ app.stage.addChild(plot.container);
+
+ // Create connection manager
+ const manager = new ConnectionManager();
+
+ // Add first connection
+ const source1 = new SyntheticDataSource({
+ generator: TestDataFactory.createSimpleSine(30),
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100,
+ });
+
+ const connId1 = manager.connect(source1, plot, { type: 'direct' });
+ console.log('Connection ID:', connId1);
+
+ app.ticker.add(() => plot.update());
+
+ // Later: disconnect and switch to different source
+ setTimeout(() => {
+ manager.disconnect(connId1);
+
+ const source2 = new SyntheticDataSource({
+ generator: TestDataFactory.createFrequencySweep(30),
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100,
+ });
+
+ const connId2 = manager.connect(source2, plot, { type: 'direct' });
+ plot.setTitle('Frequency Sweep');
+ console.log('Switched to connection:', connId2);
+ }, 5000);
+
+ return { app, plot, manager };
+}
+
+// ============================================================================
+// Example 8: Complete Interactive Demo
+// ============================================================================
+
+export async function example8_InteractiveDemo() {
+ console.log('=== Example 8: Interactive Demo ===');
+
+ const app = new Application();
+ await app.init({
+ width: 1600,
+ height: 800,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // Create two plots
+ const plot1 = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 800,
+ title: 'Plot 1 - Press 1-5 to change',
+ color: 0xff6666,
+ });
+
+ const plot2 = new TimeSeriesPlot({
+ x: 800,
+ y: 0,
+ width: 800,
+ height: 800,
+ title: 'Plot 2 - Press 6-0 to change',
+ color: 0x66ff66,
+ });
+
+ app.stage.addChild(plot1.container);
+ app.stage.addChild(plot2.container);
+
+ // Connection manager
+ const manager = new ConnectionManager();
+
+ // Available data sources
+ const dataSources = {
+ sine: () => TestDataFactory.createSimpleSine(30),
+ complex: () => TestDataFactory.createComplexPattern(30),
+ noise: () => TestDataFactory.createSmoothNoise(30),
+ sweep: () => TestDataFactory.createFrequencySweep(30),
+ burst: () => TestDataFactory.createBurstySignal(30),
+ };
+
+ // Track current connections
+ let conn1Id = null;
+ let conn2Id = null;
+
+ // Helper to switch source
+ const switchSource = (plot, generatorFunc, title) => {
+ // Disconnect old connection
+ const connId = plot === plot1 ? conn1Id : conn2Id;
+ if (connId !== null) {
+ manager.disconnect(connId);
+ }
+
+ // Create new connection
+ const source = new SyntheticDataSource({
+ generator: generatorFunc(),
+ pointsPerLine: 100,
+ width: plot.width,
+ lineInterval: 100,
+ });
+
+ const newConnId = manager.connect(source, plot, { type: 'direct' });
+ plot.setTitle(title);
+
+ // Store connection ID
+ if (plot === plot1) {
+ conn1Id = newConnId;
+ } else {
+ conn2Id = newConnId;
+ }
+ };
+
+ // Initialize with default sources
+ switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
+ switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
+
+ // Keyboard controls
+ window.addEventListener('keydown', (e) => {
+ switch (e.key) {
+ case '1':
+ switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
+ break;
+ case '2':
+ switchSource(plot1, dataSources.complex, 'Plot 1 - Complex Pattern');
+ break;
+ case '3':
+ switchSource(plot1, dataSources.noise, 'Plot 1 - Perlin Noise');
+ break;
+ case '4':
+ switchSource(plot1, dataSources.sweep, 'Plot 1 - Frequency Sweep');
+ break;
+ case '5':
+ switchSource(plot1, dataSources.burst, 'Plot 1 - Burst Signal');
+ break;
+ case '6':
+ switchSource(plot2, dataSources.sine, 'Plot 2 - Sine Wave');
+ break;
+ case '7':
+ switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
+ break;
+ case '8':
+ switchSource(plot2, dataSources.noise, 'Plot 2 - Perlin Noise');
+ break;
+ case '9':
+ switchSource(plot2, dataSources.sweep, 'Plot 2 - Frequency Sweep');
+ break;
+ case '0':
+ switchSource(plot2, dataSources.burst, 'Plot 2 - Burst Signal');
+ break;
+ case 'g':
+ plot1.setGridVisible(!plot1.showGrid);
+ plot2.setGridVisible(!plot2.showGrid);
+ break;
+ case 'c':
+ plot1.clearData();
+ plot2.clearData();
+ break;
+ }
+ });
+
+ // Update loop
+ app.ticker.add(() => {
+ plot1.update();
+ plot2.update();
+ });
+
+ console.log('Controls:');
+ console.log(' 1-5: Change Plot 1 source');
+ console.log(' 6-0: Change Plot 2 source');
+ console.log(' G: Toggle grid');
+ console.log(' C: Clear data');
+
+ return { app, plot1, plot2, manager };
+}
+
+// ============================================================================
+// Quick Test: Run one of the examples
+// ============================================================================
+
+// Uncomment to run an example:
+// example1_SimpleSetup();
+// example2_QuickSetup();
+// example3_MultiplePlots();
+// example4_FunctionSource();
+// example5_SwappingSources();
+// example6_StreamingData();
+// example7_ConnectionManager();
+//example8_InteractiveDemo();
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..d2b348e
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1 @@
+import './bootstrap.js';
diff --git a/src/metrics.js b/src/metrics.js
new file mode 100644
index 0000000..fdda10a
--- /dev/null
+++ b/src/metrics.js
@@ -0,0 +1,142 @@
+/**
+ * RollingAverage - Maintains a rolling window of values for smooth averaging
+ */
+class RollingAverage {
+ constructor(capacity) {
+ this.values = [];
+ this.capacity = capacity;
+ this.sum = 0;
+ }
+
+ push(value) {
+ if (this.values.length >= this.capacity) {
+ const old = this.values.shift();
+ this.sum -= old;
+ }
+ this.values.push(value);
+ this.sum += value;
+ }
+
+ average() {
+ return this.values.length > 0 ? this.sum / this.values.length : 0;
+ }
+
+ min() {
+ return this.values.length > 0 ? Math.min(...this.values) : 0;
+ }
+
+ max() {
+ return this.values.length > 0 ? Math.max(...this.values) : 0;
+ }
+}
+
+/**
+ * PerformanceMetrics - Tracks and analyzes frame performance
+ */
+export class PerformanceMetrics {
+ constructor(rollingWindow = 60, historyCapacity = 10000) {
+ // Rolling averages
+ this.frameTime = new RollingAverage(rollingWindow);
+ this.updateTime = new RollingAverage(rollingWindow);
+ this.renderTime = new RollingAverage(rollingWindow);
+ this.vertexCount = new RollingAverage(rollingWindow);
+ this.lineCount = new RollingAverage(rollingWindow);
+
+ // History for export
+ this.history = [];
+ this.historyCapacity = historyCapacity;
+
+ // Frame timing
+ this.frameStart = 0;
+ this.updateStart = 0;
+ this.renderStart = 0;
+
+ this.totalFrames = 0;
+ }
+
+ beginFrame() {
+ this.frameStart = performance.now();
+ }
+
+ beginUpdate() {
+ this.updateStart = performance.now();
+ }
+
+ endUpdate() {
+ const duration = performance.now() - this.updateStart;
+ return duration;
+ }
+
+ beginRender() {
+ this.renderStart = performance.now();
+ }
+
+ endRender() {
+ const duration = performance.now() - this.renderStart;
+ return duration;
+ }
+
+ endFrame(updateMs, renderMs, vertexCount, lineCount) {
+ const totalMs = performance.now() - this.frameStart;
+
+ // Update rolling averages
+ this.frameTime.push(totalMs);
+ this.updateTime.push(updateMs);
+ this.renderTime.push(renderMs);
+ this.vertexCount.push(vertexCount);
+ this.lineCount.push(lineCount);
+
+ // Store in history
+ const record = {
+ frame: this.totalFrames,
+ totalMs,
+ updateMs,
+ renderMs,
+ vertexCount,
+ lineCount,
+ fps: totalMs > 0 ? 1000 / totalMs : 0,
+ };
+
+ if (this.history.length >= this.historyCapacity) {
+ this.history.shift();
+ }
+ this.history.push(record);
+
+ this.totalFrames++;
+ }
+
+ getFPS() {
+ const avg = this.frameTime.average();
+ return avg > 0 ? 1000 / avg : 0;
+ }
+
+ getMinFPS() {
+ const max = this.frameTime.max();
+ return max > 0 ? 1000 / max : 0;
+ }
+
+ getMaxFPS() {
+ const min = this.frameTime.min();
+ return min > 0 ? 1000 / min : 0;
+ }
+
+ formatSummary() {
+ return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` +
+ `Frame: ${this.frameTime.average().toFixed(2)}ms | ` +
+ `Update: ${this.updateTime.average().toFixed(2)}ms | ` +
+ `Render: ${this.renderTime.average().toFixed(2)}ms | ` +
+ `Vertices: ${Math.round(this.vertexCount.average())} | ` +
+ `Lines: ${Math.round(this.lineCount.average())}`;
+ }
+
+ exportToCSV() {
+ let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n';
+
+ for (const record of this.history) {
+ csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` +
+ `${record.vertexCount},${record.lineCount},${record.fps}\n`;
+ }
+
+ return csv;
+ }
+}
diff --git a/src/plot-connections.js b/src/plot-connections.js
new file mode 100644
index 0000000..0e96dd8
--- /dev/null
+++ b/src/plot-connections.js
@@ -0,0 +1,392 @@
+/**
+ * Plot Connections - Links data sources to visualization plots
+ *
+ * This module manages the connection between data sources and plots,
+ * handling buffering, timing, and data flow.
+ *
+ * Connection Types:
+ * - DirectConnection: Lines from source → plot (no buffering)
+ * - BufferedConnection: Points → buffer → lines → plot
+ * - SynchronizedConnection: Multiple sources → synchronized output
+ */
+
+/**
+ * Base connection class
+ */
+class PlotConnection {
+ constructor(source, plot, config = {}) {
+ this.source = source;
+ this.plot = plot;
+ this.config = config;
+ this.isActive = false;
+ this.subscriptions = [];
+ }
+
+ /**
+ * Activate the connection - start data flow
+ */
+ connect() {
+ if (this.isActive) return;
+ this.isActive = true;
+ this.setupSubscriptions();
+ this.source.start();
+ }
+
+ /**
+ * Deactivate the connection - stop data flow
+ */
+ disconnect() {
+ if (!this.isActive) return;
+ this.isActive = false;
+ this.cleanup();
+ this.source.stop();
+ }
+
+ /**
+ * Setup event subscriptions (override in subclasses)
+ */
+ setupSubscriptions() {
+ throw new Error('setupSubscriptions() must be implemented by subclass');
+ }
+
+ /**
+ * Cleanup subscriptions
+ */
+ cleanup() {
+ this.subscriptions.forEach(unsub => unsub());
+ this.subscriptions = [];
+ }
+}
+
+/**
+ * Direct connection - passes lines directly from source to plot
+ * Use when source emits complete lines of data
+ */
+export class DirectConnection extends PlotConnection {
+ setupSubscriptions() {
+ const unsubLine = this.source.on('line', (data) => {
+ this.plot.addLine(data.points, data.metadata);
+ });
+
+ const unsubError = this.source.on('error', (data) => {
+ console.error('[DirectConnection] Source error:', data.error);
+ });
+
+ this.subscriptions.push(unsubLine, unsubError);
+ }
+}
+
+/**
+ * Buffered connection - buffers individual points into lines
+ * Use when source emits individual data points that need to be assembled
+ */
+export class BufferedConnection extends PlotConnection {
+ constructor(source, plot, config = {}) {
+ super(source, plot, config);
+ this.buffer = [];
+ this.bufferSize = config.bufferSize || 100;
+ this.bufferTimeout = config.bufferTimeout || 1000; // ms
+ this.lastFlush = Date.now();
+ this.flushHandle = null;
+
+ // Start auto-flush timer
+ if (config.autoFlush !== false) {
+ this.startAutoFlush();
+ }
+ }
+
+ setupSubscriptions() {
+ const unsubPoint = this.source.on('point', (data) => {
+ this.addToBuffer(data);
+ });
+
+ const unsubError = this.source.on('error', (data) => {
+ console.error('[BufferedConnection] Source error:', data.error);
+ });
+
+ this.subscriptions.push(unsubPoint, unsubError);
+ }
+
+ addToBuffer(data) {
+ this.buffer.push(data);
+
+ // Flush if buffer is full
+ if (this.buffer.length >= this.bufferSize) {
+ this.flush();
+ }
+ }
+
+ flush() {
+ if (this.buffer.length === 0) return;
+
+ // Convert buffer to line points
+ const points = this.buffer.map((data, idx) => {
+ const x = (idx / this.buffer.length) * this.plot.width;
+ return { x, y: data.value };
+ });
+
+ this.plot.addLine(points, {
+ timestamp: this.lastFlush,
+ pointCount: this.buffer.length,
+ });
+
+ this.buffer = [];
+ this.lastFlush = Date.now();
+ }
+
+ startAutoFlush() {
+ this.flushHandle = setInterval(() => {
+ const timeSinceLastFlush = Date.now() - this.lastFlush;
+ if (timeSinceLastFlush >= this.bufferTimeout && this.buffer.length > 0) {
+ this.flush();
+ }
+ }, 100); // Check every 100ms
+ }
+
+ cleanup() {
+ super.cleanup();
+ if (this.flushHandle) {
+ clearInterval(this.flushHandle);
+ this.flushHandle = null;
+ }
+ }
+}
+
+/**
+ * Synchronized connection - synchronizes multiple sources to one plot
+ * Useful for combining multiple data streams
+ */
+export class SynchronizedConnection extends PlotConnection {
+ constructor(sources, plot, config = {}) {
+ super(null, plot, config); // No single source
+ this.sources = sources;
+ this.syncMode = config.syncMode || 'wait-for-all'; // 'wait-for-all', 'first-available'
+ this.lineBuffers = new Map(); // sourceId => latest line
+ }
+
+ connect() {
+ if (this.isActive) return;
+ this.isActive = true;
+
+ this.sources.forEach((source, idx) => {
+ const unsubLine = source.on('line', (data) => {
+ this.handleSourceLine(idx, data);
+ });
+
+ const unsubError = source.on('error', (data) => {
+ console.error(`[SynchronizedConnection] Source ${idx} error:`, data.error);
+ });
+
+ this.subscriptions.push(unsubLine, unsubError);
+ source.start();
+ });
+ }
+
+ disconnect() {
+ if (!this.isActive) return;
+ this.isActive = false;
+ this.cleanup();
+ this.sources.forEach(source => source.stop());
+ }
+
+ handleSourceLine(sourceIdx, data) {
+ this.lineBuffers.set(sourceIdx, data);
+
+ if (this.syncMode === 'wait-for-all') {
+ // Wait until we have data from all sources
+ if (this.lineBuffers.size === this.sources.length) {
+ this.emitSynchronized();
+ }
+ } else if (this.syncMode === 'first-available') {
+ // Emit immediately
+ this.plot.addLine(data.points, {
+ ...data.metadata,
+ sourceIdx,
+ });
+ }
+ }
+
+ emitSynchronized() {
+ // For now, just emit the first source's line
+ // Could implement more sophisticated merging
+ const firstLine = this.lineBuffers.get(0);
+ if (firstLine) {
+ this.plot.addLine(firstLine.points, firstLine.metadata);
+ }
+ this.lineBuffers.clear();
+ }
+}
+
+/**
+ * Connection Manager - manages multiple connections
+ */
+export class ConnectionManager {
+ constructor() {
+ this.connections = new Map(); // connectionId => connection
+ this.nextId = 0;
+ }
+
+ /**
+ * Create and register a connection
+ * @returns {number} connectionId
+ */
+ connect(source, plot, config = {}) {
+ const type = config.type || 'direct';
+ let connection;
+
+ switch (type) {
+ case 'direct':
+ connection = new DirectConnection(source, plot, config);
+ break;
+ case 'buffered':
+ connection = new BufferedConnection(source, plot, config);
+ break;
+ case 'synchronized':
+ connection = new SynchronizedConnection(source, plot, config);
+ break;
+ default:
+ throw new Error(`Unknown connection type: ${type}`);
+ }
+
+ const id = this.nextId++;
+ this.connections.set(id, connection);
+ connection.connect();
+
+ return id;
+ }
+
+ /**
+ * Disconnect and remove a connection
+ */
+ disconnect(connectionId) {
+ const connection = this.connections.get(connectionId);
+ if (connection) {
+ connection.disconnect();
+ this.connections.delete(connectionId);
+ }
+ }
+
+ /**
+ * Disconnect all connections
+ */
+ disconnectAll() {
+ this.connections.forEach(connection => connection.disconnect());
+ this.connections.clear();
+ }
+
+ /**
+ * Get statistics about connections
+ */
+ getStats() {
+ return {
+ activeConnections: this.connections.size,
+ connections: Array.from(this.connections.entries()).map(([id, conn]) => ({
+ id,
+ isActive: conn.isActive,
+ type: conn.constructor.name,
+ })),
+ };
+ }
+}
+
+/**
+ * Helper functions for common connection patterns
+ */
+
+/**
+ * Connect a synthetic data source to a plot
+ * @param {DataGenerator} generator - Test data generator instance
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {DirectConnection} The connection instance
+ */
+export function connectSyntheticData(generator, plot, config = {}) {
+ const { SyntheticDataSource } = require('./data-sources.js');
+
+ const source = new SyntheticDataSource({
+ generator,
+ pointsPerLine: config.pointsPerLine || 100,
+ width: plot.width,
+ lineInterval: config.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot, config);
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Connect a function-based source to a plot
+ * @param {Function} func - Function (x, t) => y
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {DirectConnection} The connection instance
+ */
+export function connectFunction(func, plot, config = {}) {
+ const { FunctionDataSource } = require('./data-sources.js');
+
+ const source = new FunctionDataSource({
+ func,
+ pointsPerLine: config.pointsPerLine || 100,
+ width: plot.width,
+ amplitude: config.amplitude || 30,
+ lineInterval: config.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot, config);
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Connect a streaming source to a plot with buffering
+ * @param {DataGenerator} generator - Test data generator instance
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {BufferedConnection} The connection instance
+ */
+export function connectStreamingData(generator, plot, config = {}) {
+ const { StreamingDataSource } = require('./data-sources.js');
+
+ const source = new StreamingDataSource({
+ generator,
+ sampleRate: config.sampleRate || 60,
+ });
+
+ const connection = new BufferedConnection(source, plot, {
+ bufferSize: config.bufferSize || 100,
+ bufferTimeout: config.bufferTimeout || 1000,
+ });
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Quick setup: Create a plot with a data source in one call
+ * @param {Application} app - PixiJS application
+ * @param {Object} plotConfig - Plot configuration
+ * @param {Object} sourceConfig - Source configuration
+ * @returns {Object} {plot, source, connection}
+ */
+export function createConnectedPlot(app, plotConfig, sourceConfig) {
+ const { TimeSeriesPlot } = require('./timeseries-plot.js');
+ const { SyntheticDataSource } = require('./data-sources.js');
+
+ const plot = new TimeSeriesPlot(plotConfig);
+ app.stage.addChild(plot.container);
+
+ const source = new SyntheticDataSource({
+ generator: sourceConfig.generator,
+ pointsPerLine: plotConfig.width / 8, // Default: ~8 pixels per point
+ width: plotConfig.width,
+ lineInterval: sourceConfig.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot);
+ connection.connect();
+
+ return { plot, source, connection };
+}
diff --git a/src/plot/plot-buffer.js b/src/plot/plot-buffer.js
new file mode 100644
index 0000000..b13cdd8
--- /dev/null
+++ b/src/plot/plot-buffer.js
@@ -0,0 +1,22 @@
+export class PlotBuffer {
+ constructor(maxPoints = 1600) {
+ this.maxPoints = maxPoints;
+ this.points = [];
+ }
+
+ addPoint(point) {
+ this.points.push(point);
+ if (this.points.length > this.maxPoints) {
+ this.points.splice(0, this.points.length - this.maxPoints);
+ }
+ }
+
+ clear() {
+ this.points = [];
+ }
+
+ getVisiblePoints(currentPlotTimeMs, windowDurationMs) {
+ const minTime = currentPlotTimeMs - windowDurationMs;
+ return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs);
+ }
+}
diff --git a/src/plot/timeplot-view.js b/src/plot/timeplot-view.js
new file mode 100644
index 0000000..ce90a1f
--- /dev/null
+++ b/src/plot/timeplot-view.js
@@ -0,0 +1,442 @@
+import { Application, Container, Graphics, Text } from 'pixi.js';
+import { formatDuration, formatValue, formatWallClock } from '../utils-format.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function roundRect(graphics, x, y, width, height, radius, fill, stroke) {
+ graphics.roundRect(x, y, width, height, radius);
+ graphics.fill(fill);
+ graphics.stroke(stroke);
+}
+
+export class TimeplotView {
+ constructor({ host, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) {
+ this.host = host;
+ this.panelId = panelId;
+ this.panelTitle = title;
+ this.panelSubtitle = subtitle;
+ this.showReadouts = showReadouts;
+ this.lineColor = lineColor;
+ this.pointColor = pointColor;
+ this.app = new Application();
+ this.container = new Container();
+ this.background = new Graphics();
+ this.grid = new Graphics();
+ this.axes = new Graphics();
+ this.line = new Graphics();
+ this.points = new Graphics();
+ this.crosshair = new Graphics();
+ this.overlay = new Container();
+ this.readoutBackground = new Graphics();
+ this.axisLabelLayer = new Container();
+ this.titleText = new Text({
+ text: 'Plot viewport',
+ style: {
+ fill: 0xeef4ff,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 16,
+ },
+ });
+ this.subtitleText = new Text({
+ text: 'Synthetic data stream',
+ style: {
+ fill: 0x8ca3c7,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 12,
+ },
+ });
+ this.realTimeText = new Text({
+ text: '',
+ style: {
+ fill: 0xe8eef7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 11,
+ },
+ });
+ this.plotTimeText = new Text({
+ text: '',
+ style: {
+ fill: 0xe8eef7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 11,
+ },
+ });
+ this.axisTitleText = new Text({
+ text: '',
+ style: {
+ fill: 0x90a0b7,
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 10,
+ fontWeight: '600',
+ letterSpacing: 1.5,
+ },
+ });
+ this.screenPoints = [];
+ this.bounds = { width: 100, height: 100 };
+ this.hoverRadiusPx = 20;
+ this.pointer = null;
+ this.lastPointerEventAt = 0;
+ this.axisLabels = [];
+ }
+
+ async init() {
+ const rendererPreference = navigator.gpu ? 'webgpu' : 'webgl';
+ await this.app.init({
+ preference: rendererPreference,
+ resizeTo: this.host,
+ antialias: true,
+ backgroundAlpha: 0,
+ resolution: Math.min(window.devicePixelRatio || 1, 2),
+ });
+
+ this.app.stage.addChild(this.container);
+ this.container.addChild(this.background);
+ this.container.addChild(this.grid);
+ this.container.addChild(this.axes);
+ this.container.addChild(this.line);
+ this.container.addChild(this.points);
+ this.container.addChild(this.crosshair);
+ this.container.addChild(this.overlay);
+ this.overlay.addChild(this.readoutBackground);
+ this.overlay.addChild(this.axisLabelLayer);
+ this.overlay.addChild(this.titleText);
+ this.overlay.addChild(this.subtitleText);
+ this.overlay.addChild(this.realTimeText);
+ this.overlay.addChild(this.plotTimeText);
+ this.overlay.addChild(this.axisTitleText);
+ this.host.appendChild(this.app.canvas);
+ this.attachPointerListeners();
+
+ return rendererPreference;
+ }
+
+ attachPointerListeners() {
+ this.host.addEventListener('pointerleave', () => {
+ this.pointer = null;
+ this.lastPointerEventAt = performance.now();
+ });
+
+ this.host.addEventListener('pointermove', (event) => {
+ const rect = this.host.getBoundingClientRect();
+ this.pointer = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ this.lastPointerEventAt = performance.now();
+ });
+ }
+
+ resize() {
+ this.bounds = {
+ width: this.host.clientWidth,
+ height: this.host.clientHeight,
+ };
+ }
+
+ render(state, points) {
+ this.resize();
+ this.renderFrame(state, points);
+ this.clearHover();
+ }
+
+ clearHover() {
+ this.crosshair.clear();
+ }
+
+ getHoverCandidate() {
+ if (!this.pointer || this.screenPoints.length === 0) {
+ return null;
+ }
+
+ let nearestPoint = null;
+ let nearestDistance = Infinity;
+
+ for (const point of this.screenPoints) {
+ const dx = point.x - this.pointer.x;
+ const dy = point.y - this.pointer.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < nearestDistance) {
+ nearestPoint = point;
+ nearestDistance = distance;
+ }
+ }
+
+ if (!nearestPoint || nearestDistance > this.hoverRadiusPx) {
+ return null;
+ }
+
+ return {
+ panelId: this.panelId,
+ point: nearestPoint,
+ x: clamp(nearestPoint.x, 0, this.bounds.width),
+ y: clamp(nearestPoint.y, 0, this.bounds.height),
+ pointerX: this.pointer.x,
+ pointerY: this.pointer.y,
+ distance: nearestDistance,
+ lastPointerEventAt: this.lastPointerEventAt,
+ };
+ }
+
+ hasPointer() {
+ return this.pointer !== null;
+ }
+
+ findNearestScreenPointByTime(timeMs) {
+ if (this.screenPoints.length === 0) {
+ return null;
+ }
+
+ let nearestPoint = null;
+ let nearestDelta = Infinity;
+
+ for (const point of this.screenPoints) {
+ const delta = Math.abs(point.timeMs - timeMs);
+ if (delta < nearestDelta) {
+ nearestPoint = point;
+ nearestDelta = delta;
+ }
+ }
+
+ return nearestPoint;
+ }
+
+ renderLinkedHover(hoverPoint) {
+ this.crosshair.clear();
+
+ if (!hoverPoint) {
+ return;
+ }
+
+ const x = clamp(hoverPoint.x, 0, this.bounds.width);
+ const y = clamp(hoverPoint.y, 0, this.bounds.height);
+
+ this.crosshair.moveTo(x, 0);
+ this.crosshair.lineTo(x, this.bounds.height);
+ this.crosshair.moveTo(0, y);
+ this.crosshair.lineTo(this.bounds.width, y);
+ this.crosshair.stroke({ color: 0x8cb8ff, width: 1, alpha: 0.24 });
+ this.crosshair.rect(x - 4, y - 4, 8, 8);
+ this.crosshair.stroke({ color: 0xffffff, width: 1.5, alpha: 0.95 });
+ }
+
+ ensureAxisLabelCount(count) {
+ while (this.axisLabels.length < count) {
+ const label = new Text({
+ text: '',
+ style: {
+ fill: 0x90a0b7,
+ fontFamily: 'IBM Plex Mono, monospace',
+ fontSize: 10,
+ },
+ });
+ this.axisLabels.push(label);
+ this.axisLabelLayer.addChild(label);
+ }
+
+ while (this.axisLabels.length > count) {
+ const label = this.axisLabels.pop();
+ this.axisLabelLayer.removeChild(label);
+ label.destroy();
+ }
+ }
+
+ renderAxes({ padding, plotWidth, plotHeight, minTime, maxTime, minValue, maxValue, width }) {
+ const axisColor = 0x3e4c5f;
+ const tickColor = 0x4f627a;
+ const timeTickCount = 5;
+ const valueTickCount = 5;
+ const labels = [];
+
+ this.axes.clear();
+ this.axes.moveTo(padding.left, padding.top);
+ this.axes.lineTo(padding.left, padding.top + plotHeight);
+ this.axes.lineTo(padding.left + plotWidth, padding.top + plotHeight);
+ this.axes.stroke({ color: axisColor, width: 1, alpha: 1 });
+
+ for (let index = 0; index < timeTickCount; index += 1) {
+ const ratio = timeTickCount === 1 ? 0 : index / (timeTickCount - 1);
+ const y = padding.top + ratio * plotHeight;
+ const timeMs = minTime + ratio * (maxTime - minTime);
+
+ this.axes.moveTo(padding.left - 8, y);
+ this.axes.lineTo(padding.left, y);
+ this.axes.stroke({ color: tickColor, width: 1, alpha: 1 });
+
+ labels.push({
+ text: formatDuration(timeMs),
+ x: 14,
+ y: y - 7,
+ anchorX: 0,
+ });
+ }
+
+ for (let index = 0; index < valueTickCount; index += 1) {
+ const ratio = valueTickCount === 1 ? 0 : index / (valueTickCount - 1);
+ const x = padding.left + ratio * plotWidth;
+ const value = minValue + ratio * (maxValue - minValue);
+
+ this.axes.moveTo(x, padding.top + plotHeight);
+ this.axes.lineTo(x, padding.top + plotHeight + 8);
+ this.axes.stroke({ color: tickColor, width: 1, alpha: 1 });
+
+ labels.push({
+ text: formatValue(value),
+ x,
+ y: padding.top + plotHeight + 10,
+ anchorX: 0.5,
+ });
+ }
+
+ this.ensureAxisLabelCount(labels.length);
+ labels.forEach((config, index) => {
+ const label = this.axisLabels[index];
+ label.text = config.text;
+ label.x = config.x;
+ label.y = config.y;
+ label.anchor.set(config.anchorX, 0);
+ });
+
+ this.axisTitleText.text = 'TIME';
+ this.axisTitleText.x = 18;
+ this.axisTitleText.y = padding.top - 18;
+ this.axisTitleText.rotation = 0;
+
+ this.axes.moveTo(padding.left + plotWidth, padding.top + plotHeight);
+ this.axes.lineTo(width - 14, padding.top + plotHeight);
+ this.axes.stroke({ color: 0x202a35, width: 1, alpha: 1 });
+ }
+
+ renderReadouts(state, width) {
+ if (!this.showReadouts) {
+ this.readoutBackground.clear();
+ this.realTimeText.text = '';
+ this.plotTimeText.text = '';
+ return;
+ }
+
+ const boxWidth = 168;
+ const boxHeight = 22;
+ const gap = 6;
+ const left = width - boxWidth - 18;
+ const top = 14;
+
+ this.readoutBackground.clear();
+ this.readoutBackground.rect(left, top, boxWidth, boxHeight);
+ this.readoutBackground.fill({ color: 0x10161d, alpha: 1 });
+ this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 });
+ this.readoutBackground.rect(left, top + boxHeight + gap, boxWidth, boxHeight);
+ this.readoutBackground.fill({ color: 0x10161d, alpha: 1 });
+ this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 });
+
+ this.realTimeText.text = `REAL ${formatWallClock(state.time.realNowMs)}`;
+ this.realTimeText.x = left + 10;
+ this.realTimeText.y = top + 5;
+
+ this.plotTimeText.text = `PLOT ${formatDuration(state.time.plotTimeMs)}`;
+ this.plotTimeText.x = left + 10;
+ this.plotTimeText.y = top + boxHeight + gap + 5;
+ }
+
+ renderFrame(state, points) {
+ const width = this.bounds.width;
+ const height = this.bounds.height;
+ const padding = { top: 72, right: 28, bottom: 46, left: 88 };
+ const plotWidth = Math.max(10, width - padding.left - padding.right);
+ const plotHeight = Math.max(10, height - padding.top - padding.bottom);
+ const minTime = state.time.plotTimeMs - state.plot.windowDurationMs;
+ const maxTime = Math.max(state.time.plotTimeMs, minTime + 1);
+ const { min: minValue, max: maxValue } = state.plot.valueRange;
+ const valueSpan = Math.max(0.001, maxValue - minValue);
+
+ this.background.clear();
+ roundRect(
+ this.background,
+ 0,
+ 0,
+ width,
+ height,
+ 6,
+ { color: 0x05070b, alpha: 1 },
+ { color: 0x2c3b4d, width: 1 },
+ );
+
+ this.grid.clear();
+ if (state.plot.showGrid) {
+ const gridColor = 0x21344a;
+ for (let x = 0; x <= 6; x += 1) {
+ const px = padding.left + (plotWidth * x) / 6;
+ this.grid.moveTo(px, padding.top);
+ this.grid.lineTo(px, padding.top + plotHeight);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
+ }
+
+ for (let y = 0; y <= 8; y += 1) {
+ const py = padding.top + (plotHeight * y) / 8;
+ this.grid.moveTo(padding.left, py);
+ this.grid.lineTo(padding.left + plotWidth, py);
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
+ }
+ }
+
+ this.renderAxes({
+ padding,
+ plotWidth,
+ plotHeight,
+ minTime,
+ maxTime,
+ minValue,
+ maxValue,
+ width,
+ });
+
+ this.line.clear();
+ this.points.clear();
+ this.screenPoints = [];
+
+ if (points.length > 0) {
+ points.forEach((point, index) => {
+ const x = padding.left + ((point.value - minValue) / valueSpan) * plotWidth;
+ const y = padding.top + ((point.timeMs - minTime) / (maxTime - minTime)) * plotHeight;
+
+ this.screenPoints.push({ ...point, x, y });
+
+ if (index === 0) {
+ this.line.moveTo(x, y);
+ } else {
+ this.line.lineTo(x, y);
+ }
+ });
+
+ this.line.stroke({
+ color: this.lineColor,
+ width: 2,
+ alpha: 0.96,
+ cap: 'square',
+ join: 'miter',
+ });
+
+ if (state.plot.showPoints) {
+ for (const point of this.screenPoints) {
+ this.points.rect(point.x - 2, point.y - 2, 4, 4);
+ this.points.fill({ color: this.pointColor, alpha: 0.92 });
+ }
+ }
+ }
+
+ this.titleText.text = this.panelTitle;
+ this.titleText.x = 20;
+ this.titleText.y = 14;
+
+ this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`;
+ this.subtitleText.x = 20;
+ this.subtitleText.y = 36;
+
+ this.renderReadouts(state, width);
+ }
+
+ destroy() {
+ this.app.destroy(true, { children: true });
+ }
+}
diff --git a/src/state.js b/src/state.js
new file mode 100644
index 0000000..53d8279
--- /dev/null
+++ b/src/state.js
@@ -0,0 +1,420 @@
+/**
+ * StateManager - Centralized state management with Proxy-based reactivity
+ *
+ * Usage:
+ * state.time.speed = 2.0 // automatically emits events
+ * state.on('time.speed', (value) => console.log('Speed changed:', value))
+ * state.on('time.*', (change) => console.log('Time domain changed:', change))
+ *
+ * State Domains:
+ * - userPrefs: showGrid, showMetrics, theme, etc.
+ * - uiConfig: active panels, layout, dimensions
+ * - time: current time, speed, paused state, real elapsed time
+ * - rendering: graphs, renderer info
+ * - health: framerate, service connections, db access
+ * - dataInput: sources, structure, metadata
+ * - inputActions: keyboard/mouse/gamepad action mappings
+ */
+
+// Simple EventEmitter implementation
+class EventEmitter {
+ constructor() {
+ this.events = new Map();
+ }
+
+ on(event, callback) {
+ if (!this.events.has(event)) {
+ this.events.set(event, []);
+ }
+ this.events.get(event).push(callback);
+
+ // Return unsubscribe function
+ return () => this.off(event, callback);
+ }
+
+ off(event, callback) {
+ if (!this.events.has(event)) return;
+ const callbacks = this.events.get(event);
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+
+ emit(event, data) {
+ if (!this.events.has(event)) return;
+ this.events.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (e) {
+ console.error(`[State] Error in event handler for '${event}':`, e);
+ }
+ });
+ }
+
+ once(event, callback) {
+ const wrapper = (data) => {
+ callback(data);
+ this.off(event, wrapper);
+ };
+ this.on(event, wrapper);
+ }
+
+ clear() {
+ this.events.clear();
+ }
+}
+
+export class StateManager extends EventEmitter {
+ constructor() {
+ super();
+
+ // Internal state storage (not proxied)
+ this._state = {
+ userPrefs: {
+ showGrid: true,
+ showMetrics: true,
+ theme: 'dark',
+ rollingWindow: 60,
+ historyCapacity: 10000,
+ metricsUpdateInterval: 10,
+ },
+
+ uiConfig: {
+ activePanels: ['graph1', 'graph2'],
+ layout: 'horizontal-split',
+ canvasWidth: 0,
+ canvasHeight: 0,
+ },
+
+ time: {
+ current: 0, // Current plot time
+ realElapsed: 0, // Real time elapsed since start
+ speed: 1.0, // Time speed multiplier (0.1 to 5.0)
+ isPaused: false, // Pause state
+ startTimestamp: Date.now(), // Real timestamp when started
+ verticalScale: 1.0, // Vertical zoom for time history
+ },
+
+ rendering: {
+ rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas'
+ frameCounter: 0,
+ // Note: graph instances are NOT stored here to avoid proxy wrapping
+ },
+
+ health: {
+ fps: 0,
+ updateMs: 0,
+ renderMs: 0,
+ vertexCount: 0,
+ lineCount: 0,
+ serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' }
+ },
+
+ dataInput: {
+ sources: [], // Array of data source configs
+ activeSource: null, // Currently active source
+ dataStructure: null, // Schema of incoming data
+ metadata: {}, // Additional metadata
+ },
+
+ inputActions: {
+ keyboardMap: new Map(), // Map of KeyboardEvent.code => action name
+ mouseMap: new Map(), // Map of mouse button => action name
+ actionHandlers: new Map(), // Map of action name => handler function
+ },
+ };
+
+ // Track which domains should be persisted
+ this._persistedDomains = new Set(['userPrefs']);
+
+ // Load persisted state
+ this._loadPersistedState();
+
+ // Create proxied state - this is what users interact with
+ this.state = this._createProxy(this._state, []);
+ }
+
+ /**
+ * Create a reactive Proxy that emits events on property changes
+ * @param {Object} target - The object to proxy
+ * @param {Array} path - Current property path (e.g., ['time', 'speed'])
+ * @private
+ */
+ _createProxy(target, path) {
+ // Don't proxy non-objects or special objects like Map/Set
+ if (typeof target !== 'object' || target === null) {
+ return target;
+ }
+
+ // Don't proxy Maps and Sets - they need special handling
+ if (target instanceof Map || target instanceof Set) {
+ return target;
+ }
+
+ const self = this;
+
+ return new Proxy(target, {
+ get(obj, prop) {
+ const value = obj[prop];
+
+ // Return primitives and functions as-is
+ if (typeof value !== 'object' || value === null) {
+ return value;
+ }
+
+ // Return nested objects as proxies
+ return self._createProxy(value, [...path, prop]);
+ },
+
+ set(obj, prop, value) {
+ const oldValue = obj[prop];
+
+ // Only emit if value actually changed
+ if (oldValue === value) {
+ return true;
+ }
+
+ obj[prop] = value;
+
+ // Build event path
+ const fullPath = [...path, prop];
+ const pathString = fullPath.join('.');
+ const domain = fullPath[0];
+
+ // Emit specific property change: "time.speed"
+ self.emit(pathString, {
+ path: fullPath,
+ value: value,
+ oldValue: oldValue,
+ });
+
+ // Emit domain wildcard: "time.*"
+ if (domain) {
+ self.emit(`${domain}.*`, {
+ path: fullPath,
+ property: prop,
+ value: value,
+ oldValue: oldValue,
+ });
+ }
+
+ // Emit global wildcard: "*"
+ self.emit('*', {
+ path: fullPath,
+ value: value,
+ oldValue: oldValue,
+ });
+
+ // Auto-persist certain domains
+ if (self._persistedDomains.has(domain)) {
+ self._persistDomain(domain);
+ }
+
+ return true;
+ }
+ });
+ }
+
+ // =========================================================================
+ // Persistence
+ // =========================================================================
+
+ _persistDomain(domain) {
+ try {
+ const data = this._state[domain];
+ // Convert Maps to objects for JSON serialization
+ const serializable = this._makeSerializable(data);
+ localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable));
+ } catch (e) {
+ console.warn(`[State] Failed to persist ${domain}:`, e);
+ }
+ }
+
+ _loadPersistedState() {
+ this._persistedDomains.forEach(domain => {
+ try {
+ const saved = localStorage.getItem(`timeplot-${domain}`);
+ if (saved) {
+ const data = JSON.parse(saved);
+ // Deep merge to preserve defaults for new properties
+ this._state[domain] = this._deepMerge(this._state[domain], data);
+ }
+ } catch (e) {
+ console.warn(`[State] Failed to load ${domain}:`, e);
+ }
+ });
+ }
+
+ _makeSerializable(obj) {
+ if (obj instanceof Map) {
+ return Object.fromEntries(obj);
+ }
+ if (obj instanceof Set) {
+ return Array.from(obj);
+ }
+ if (typeof obj === 'object' && obj !== null) {
+ const result = {};
+ for (const [key, value] of Object.entries(obj)) {
+ result[key] = this._makeSerializable(value);
+ }
+ return result;
+ }
+ return obj;
+ }
+
+ _deepMerge(target, source) {
+ const result = { ...target };
+ for (const key in source) {
+ if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
+ result[key] = this._deepMerge(target[key] || {}, source[key]);
+ } else {
+ result[key] = source[key];
+ }
+ }
+ return result;
+ }
+
+ // =========================================================================
+ // Convenience Methods
+ // =========================================================================
+
+ /**
+ * Toggle a boolean preference
+ */
+ togglePref(key) {
+ const current = this.state.userPrefs[key];
+ if (typeof current === 'boolean') {
+ this.state.userPrefs[key] = !current;
+ }
+ }
+
+ /**
+ * Pause/resume time
+ */
+ togglePause() {
+ this.state.time.isPaused = !this.state.time.isPaused;
+ }
+
+ /**
+ * Set time speed (clamped 0.1 to 5.0)
+ */
+ setTimeSpeed(speed) {
+ this.state.time.speed = Math.max(0.1, Math.min(5.0, speed));
+ }
+
+ /**
+ * Increment time (respects pause and speed)
+ */
+ incrementTime(delta) {
+ if (this.state.time.isPaused) return;
+ this.state.time.current += delta * this.state.time.speed;
+ }
+
+ /**
+ * Update real elapsed time
+ */
+ updateRealElapsed() {
+ const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000;
+ this.state.time.realElapsed = elapsed;
+ }
+
+ // =========================================================================
+ // Input Actions System
+ // =========================================================================
+
+ /**
+ * Register an input action handler
+ * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause')
+ * @param {Function} handler - Handler function to call
+ */
+ registerAction(actionName, handler) {
+ this.state.inputActions.actionHandlers.set(actionName, handler);
+ }
+
+ /**
+ * Map a keyboard key to an action
+ * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space')
+ * @param {string} actionName - Action to trigger
+ */
+ mapKey(code, actionName) {
+ this.state.inputActions.keyboardMap.set(code, actionName);
+ }
+
+ /**
+ * Map a mouse button to an action
+ * @param {number} button - Mouse button number (0=left, 1=middle, 2=right)
+ * @param {string} actionName - Action to trigger
+ */
+ mapMouseButton(button, actionName) {
+ this.state.inputActions.mouseMap.set(button, actionName);
+ }
+
+ /**
+ * Execute an action by name
+ */
+ executeAction(actionName, event) {
+ const handler = this.state.inputActions.actionHandlers.get(actionName);
+ if (handler) {
+ handler(event);
+ } else {
+ console.warn(`[State] No handler registered for action: ${actionName}`);
+ }
+ }
+
+ /**
+ * Handle keyboard event through action system
+ */
+ handleKeyboardEvent(event) {
+ const actionName = this.state.inputActions.keyboardMap.get(event.code);
+ if (actionName) {
+ this.executeAction(actionName, event);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handle mouse button event through action system
+ */
+ handleMouseButtonEvent(event) {
+ const actionName = this.state.inputActions.mouseMap.get(event.button);
+ if (actionName) {
+ this.executeAction(actionName, event);
+ return true;
+ }
+ return false;
+ }
+
+ // =========================================================================
+ // Data Sources
+ // =========================================================================
+
+ addDataSource(source) {
+ this.state.dataInput.sources.push(source);
+ }
+
+ removeDataSource(sourceId) {
+ const sources = this.state.dataInput.sources;
+ const index = sources.findIndex(s => s.id === sourceId);
+ if (index > -1) {
+ sources.splice(index, 1);
+ }
+ }
+
+ setActiveDataSource(sourceId) {
+ this.state.dataInput.activeSource = sourceId;
+ }
+
+ // =========================================================================
+ // Debugging
+ // =========================================================================
+
+ dump() {
+ console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state)));
+ }
+
+ debugEvents() {
+ console.log('[State] Registered events:', Array.from(this.events.keys()));
+ }
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..6b0477f
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,401 @@
+:root {
+ color-scheme: dark;
+ --bg: #0a0c10;
+ --bg-alt: #0f1319;
+ --surface: #11161d;
+ --surface-strong: #0d1117;
+ --surface-raised: #171d26;
+ --border: #28313d;
+ --border-strong: #394657;
+ --text: #edf2f7;
+ --muted: #97a3b4;
+ --accent: #9fc7ff;
+ --accent-strong: #d8e8ff;
+ --shadow: none;
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background:
+ linear-gradient(180deg, #080a0d 0%, #0d1015 100%);
+ color: var(--text);
+ overflow: hidden;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.timeplot-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 340px;
+ grid-template-rows: auto minmax(0, 1fr);
+ width: 100%;
+ height: 100%;
+ gap: 10px;
+ padding: 10px;
+}
+
+.timeplot-topbar {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 14px;
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ border-radius: 4px;
+ box-shadow: var(--shadow);
+}
+
+.timeplot-brand {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.timeplot-title {
+ margin: 0;
+ font-size: 1rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 700;
+}
+
+.timeplot-subtitle {
+ color: var(--muted);
+ font-size: 0.78rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.timeplot-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.control-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+}
+
+.control-group label,
+.control-group span {
+ color: var(--muted);
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.control-group input[type='range'] {
+ width: 118px;
+}
+
+.control-group input[type='range'] {
+ accent-color: var(--accent);
+}
+
+.control-button,
+.panel-toggle {
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: 2px;
+ padding: 7px 11px;
+ cursor: pointer;
+ transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-size: 0.72rem;
+ line-height: 1;
+}
+
+.control-button:hover,
+.panel-toggle:hover {
+ border-color: var(--accent);
+ color: var(--accent-strong);
+}
+
+.control-button[data-active='true'],
+.panel-toggle[data-active='true'] {
+ background: #1a2230;
+ border-color: var(--accent);
+ color: var(--accent-strong);
+}
+
+.timeplot-viewport {
+ position: relative;
+ min-height: 0;
+ border-radius: 4px;
+ overflow: hidden;
+ border: 1px solid var(--border-strong);
+ background: #06080b;
+ box-shadow: var(--shadow);
+ padding: 10px;
+}
+
+.timeplot-plot-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 10px;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+}
+
+.timeplot-plot-panel {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ border: 1px solid var(--border);
+ background: #070a0d;
+}
+
+.timeplot-canvas-host {
+ width: 100%;
+ height: 100%;
+}
+
+.timeplot-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+.panel {
+ border: 1px solid var(--border-strong);
+ background: var(--surface-strong);
+ border-radius: 4px;
+ padding: 14px;
+}
+
+.panel[hidden] {
+ display: none;
+}
+
+.panel h2 {
+ margin: 0 0 12px;
+ font-size: 0.8rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.panel-subsection + .panel-subsection {
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+
+.panel-section-title {
+ margin-bottom: 10px;
+ color: var(--accent-strong);
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.kv-list {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 10px 12px;
+ align-items: center;
+ margin: 0;
+}
+
+.kv-list dt {
+ color: var(--muted);
+ font-size: 0.73rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.kv-list dd {
+ margin: 0;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+}
+
+.field-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.field-grid label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.74rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.field-grid[data-source-mode][hidden] {
+ display: none;
+}
+
+.source-meta {
+ min-height: 20px;
+ color: var(--muted);
+ font-size: 0.76rem;
+ line-height: 1.4;
+}
+
+.source-meta-error {
+ color: #ff9d9d;
+}
+
+.source-meta-status {
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.source-meta-status-connected {
+ color: #99e2b4;
+}
+
+.source-meta-status-connecting {
+ color: #ffd27f;
+}
+
+.source-meta-status-disconnected,
+.source-meta-status-idle {
+ color: var(--muted);
+}
+
+.source-meta-status-error {
+ color: #ff9d9d;
+}
+
+.field-grid input,
+.field-grid select {
+ width: 100%;
+ padding: 9px 10px;
+ border-radius: 2px;
+ border: 1px solid var(--border);
+ background: var(--surface-raised);
+ color: var(--text);
+}
+
+.field-grid input:focus,
+.field-grid select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.panel-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ color: var(--muted);
+ font-size: 0.74rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.panel-row + .panel-row {
+ margin-top: 10px;
+}
+
+.panel-row input[type='checkbox'] {
+ inline-size: 16px;
+ block-size: 16px;
+ accent-color: var(--accent);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.help-list {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding-left: 18px;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.timeplot-tooltip {
+ position: absolute;
+ min-width: 180px;
+ padding: 10px 12px;
+ border-radius: 3px;
+ border: 1px solid var(--border-strong);
+ background: #0d1218;
+ box-shadow: var(--shadow);
+ pointer-events: none;
+ transform: translate(12px, -50%);
+ z-index: 10;
+}
+
+.timeplot-tooltip[hidden] {
+ display: none;
+}
+
+.timeplot-tooltip-title {
+ margin-bottom: 8px;
+ font-size: 0.72rem;
+ color: var(--accent-strong);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.timeplot-tooltip-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ font-size: 0.78rem;
+}
+
+.timeplot-tooltip-row + .timeplot-tooltip-row {
+ margin-top: 4px;
+}
+
+.timeplot-empty {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+@media (max-width: 1100px) {
+ .timeplot-shell {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(360px, 1fr) auto;
+ }
+
+ .timeplot-plot-grid {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: repeat(2, minmax(260px, 1fr));
+ }
+
+ .timeplot-sidebar {
+ overflow: visible;
+ }
+}
diff --git a/src/template-for-standard-site.js b/src/template-for-standard-site.js
new file mode 100644
index 0000000..54aacc7
--- /dev/null
+++ b/src/template-for-standard-site.js
@@ -0,0 +1,75 @@
+//import { setupRenderSystem } from './render.js';
+
+let ENVURL = "" //remote server from which to grab env
+let env = {};
+let cfg = {}; //the user config
+let dom = {
+ input: {},
+ label: {},
+ box: {}, //an info-containing box
+ icon: {},
+ info: {}
+};
+
+
+//APP START HERE
+$(document).ready(async function() {
+ console.log('asdf');
+ //the core loop of the client application
+ // 1. setup relationship with DOM and grab references to its elements
+ log('init DOM');
+ await initDOM();
+
+ log('init cfg');
+ await initCfg();
+
+ log('get env vars');
+ await getServerEnvVars();
+
+ log('init services');
+ await initServices();
+
+ //setupRenderSystem();
+
+
+});
+
+//gets user config from local storage if there is any
+function initCfg(){
+ let localCfg = localStorage.getItem('cfg');
+ if (localCfg) {
+ try {
+ cfg = JSON.parse(localCfg);
+ } catch (e) {
+ cfg = {};
+ }
+ } else {
+
+ }
+}
+
+async function getServerEnvVars(){
+ await axios.get(`${ENVURL}`).then((res)=>{
+ env = res.data;
+ //log(env);
+ }).catch((err)=>{
+ //log(err);
+ });
+ log('')
+}
+
+function initServices(){
+ //connect to websocket server
+ //grab endpoints from cfg
+}
+
+function initDOM(){
+ dom.body = $('body')[0];
+}
+
+function log(msg, lvl=1){
+ if (dom.debugInfo){
+ dom.debugInfo.innerHTML = msg; //TODO running log + timestamp
+ }
+ console.log(msg);
+} \ No newline at end of file
diff --git a/src/test-data-generators.js b/src/test-data-generators.js
new file mode 100644
index 0000000..02bc0ad
--- /dev/null
+++ b/src/test-data-generators.js
@@ -0,0 +1,530 @@
+/**
+ * Test Data Generators - Classes for generating fake/test data patterns
+ *
+ * These generators produce various types of synthetic data for testing
+ * and visualizing the waterfall graphs with realistic patterns.
+ */
+
+/**
+ * Base class for all data generators
+ */
+class DataGenerator {
+ constructor(config = {}) {
+ this.sampleRate = config.sampleRate || 100; // Samples per second
+ this.amplitude = config.amplitude || 1.0;
+ this.offset = config.offset || 0.0;
+ this.time = 0;
+ }
+
+ /**
+ * Generate a single sample at the current time
+ * @returns {number} The generated value
+ */
+ sample() {
+ throw new Error('sample() must be implemented by subclass');
+ }
+
+ /**
+ * Generate an array of samples
+ * @param {number} count - Number of samples to generate
+ * @returns {Array<number>} Array of generated values
+ */
+ generateSamples(count) {
+ const samples = [];
+ for (let i = 0; i < count; i++) {
+ samples.push(this.sample());
+ this.time += 1 / this.sampleRate;
+ }
+ return samples;
+ }
+
+ /**
+ * Generate a line of points for waterfall display
+ * @param {number} pointCount - Number of points in the line
+ * @param {number} width - Width of the display area
+ * @returns {Array<{x: number, y: number}>} Array of points
+ */
+ generateLine(pointCount, width) {
+ const points = [];
+ const samples = this.generateSamples(pointCount);
+
+ for (let i = 0; i < pointCount; i++) {
+ const x = (i / pointCount) * width;
+ const y = samples[i] * this.amplitude + this.offset;
+ points.push({ x, y });
+ }
+
+ return points;
+ }
+
+ reset() {
+ this.time = 0;
+ }
+}
+
+/**
+ * Sine Wave Generator - Classic sinusoidal wave
+ */
+export class SineWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0; // Hz
+ this.phase = config.phase || 0.0; // Radians
+ }
+
+ sample() {
+ const value = Math.sin(2 * Math.PI * this.frequency * this.time + this.phase);
+ return value;
+ }
+}
+
+/**
+ * Square Wave Generator - Digital-style square wave
+ */
+export class SquareWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ this.dutyCycle = config.dutyCycle || 0.5; // 0.0 to 1.0
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return phase < this.dutyCycle ? 1.0 : -1.0;
+ }
+}
+
+/**
+ * Sawtooth Wave Generator - Linear ramp wave
+ */
+export class SawtoothWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return 2 * phase - 1; // -1 to 1
+ }
+}
+
+/**
+ * Triangle Wave Generator - Linear up/down wave
+ */
+export class TriangleWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return phase < 0.5
+ ? 4 * phase - 1
+ : 3 - 4 * phase;
+ }
+}
+
+/**
+ * White Noise Generator - Random noise
+ */
+export class WhiteNoiseGenerator extends DataGenerator {
+ sample() {
+ return Math.random() * 2 - 1; // -1 to 1
+ }
+}
+
+/**
+ * Pink Noise Generator - 1/f noise (more realistic than white noise)
+ */
+export class PinkNoiseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ // Paul Kellet's refined method
+ this.b0 = 0;
+ this.b1 = 0;
+ this.b2 = 0;
+ this.b3 = 0;
+ this.b4 = 0;
+ this.b5 = 0;
+ this.b6 = 0;
+ }
+
+ sample() {
+ const white = Math.random() * 2 - 1;
+ this.b0 = 0.99886 * this.b0 + white * 0.0555179;
+ this.b1 = 0.99332 * this.b1 + white * 0.0750759;
+ this.b2 = 0.96900 * this.b2 + white * 0.1538520;
+ this.b3 = 0.86650 * this.b3 + white * 0.3104856;
+ this.b4 = 0.55000 * this.b4 + white * 0.5329522;
+ this.b5 = -0.7616 * this.b5 - white * 0.0168980;
+ const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
+ this.b6 = white * 0.115926;
+ return pink * 0.11; // Normalize
+ }
+}
+
+/**
+ * Perlin Noise Generator - Smooth, continuous noise
+ */
+export class PerlinNoiseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ this.octaves = config.octaves || 4;
+ this.persistence = config.persistence || 0.5;
+ }
+
+ // Simple 1D Perlin-like noise
+ noise(x) {
+ const i = Math.floor(x);
+ const f = x - i;
+
+ // Fade curve
+ const u = f * f * (3 - 2 * f);
+
+ // Hash function for pseudo-random gradients
+ const hash = (n) => {
+ n = (n << 13) ^ n;
+ return (1.0 - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824.0);
+ };
+
+ return (1 - u) * hash(i) + u * hash(i + 1);
+ }
+
+ sample() {
+ let value = 0;
+ let amplitude = 1;
+ let frequency = this.frequency;
+ let maxValue = 0;
+
+ for (let i = 0; i < this.octaves; i++) {
+ value += this.noise(this.time * frequency) * amplitude;
+ maxValue += amplitude;
+ amplitude *= this.persistence;
+ frequency *= 2;
+ }
+
+ return value / maxValue;
+ }
+}
+
+/**
+ * Pulse/Spike Generator - Random spikes/pulses
+ */
+export class PulseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.pulseRate = config.pulseRate || 0.05; // Probability per sample
+ this.pulseWidth = config.pulseWidth || 0.01; // Duration in seconds
+ this.pulseAmplitude = config.pulseAmplitude || 1.0;
+ this.currentPulse = null;
+ }
+
+ sample() {
+ // Check if we're in a pulse
+ if (this.currentPulse) {
+ if (this.time >= this.currentPulse.endTime) {
+ this.currentPulse = null;
+ } else {
+ return this.pulseAmplitude;
+ }
+ }
+
+ // Random chance to start new pulse
+ if (Math.random() < this.pulseRate) {
+ this.currentPulse = {
+ startTime: this.time,
+ endTime: this.time + this.pulseWidth,
+ };
+ return this.pulseAmplitude;
+ }
+
+ return 0;
+ }
+}
+
+/**
+ * Burst Generator - Bursts of activity with quiet periods
+ */
+export class BurstGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.burstDuration = config.burstDuration || 1.0; // Seconds
+ this.quietDuration = config.quietDuration || 2.0; // Seconds
+ this.burstFrequency = config.burstFrequency || 5.0; // Hz during burst
+ this.currentState = 'quiet';
+ this.stateStartTime = 0;
+ }
+
+ sample() {
+ const elapsed = this.time - this.stateStartTime;
+
+ // State transitions
+ if (this.currentState === 'quiet' && elapsed >= this.quietDuration) {
+ this.currentState = 'burst';
+ this.stateStartTime = this.time;
+ } else if (this.currentState === 'burst' && elapsed >= this.burstDuration) {
+ this.currentState = 'quiet';
+ this.stateStartTime = this.time;
+ }
+
+ // Generate value based on state
+ if (this.currentState === 'burst') {
+ return Math.sin(2 * Math.PI * this.burstFrequency * this.time);
+ } else {
+ return 0;
+ }
+ }
+}
+
+/**
+ * Chirp Generator - Frequency sweep signal
+ */
+export class ChirpGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.startFreq = config.startFreq || 0.5; // Hz
+ this.endFreq = config.endFreq || 10.0; // Hz
+ this.duration = config.duration || 5.0; // Seconds
+ }
+
+ sample() {
+ const t = this.time % this.duration;
+ const progress = t / this.duration;
+ const freq = this.startFreq + (this.endFreq - this.startFreq) * progress;
+ return Math.sin(2 * Math.PI * freq * t);
+ }
+}
+
+/**
+ * Composite Generator - Combine multiple generators
+ */
+export class CompositeGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.generators = config.generators || [];
+ this.weights = config.weights || this.generators.map(() => 1.0);
+ }
+
+ sample() {
+ let sum = 0;
+ let weightSum = 0;
+
+ for (let i = 0; i < this.generators.length; i++) {
+ sum += this.generators[i].sample() * this.weights[i];
+ weightSum += this.weights[i];
+ }
+
+ return weightSum > 0 ? sum / weightSum : 0;
+ }
+
+ generateSamples(count) {
+ const samples = [];
+ for (let i = 0; i < count; i++) {
+ samples.push(this.sample());
+ // Advance all child generators
+ this.generators.forEach(gen => gen.time += 1 / gen.sampleRate);
+ }
+ return samples;
+ }
+}
+
+/**
+ * FM (Frequency Modulation) Generator - One signal modulates another
+ */
+export class FMGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.carrierFreq = config.carrierFreq || 5.0; // Hz
+ this.modulatorFreq = config.modulatorFreq || 0.5; // Hz
+ this.modulationIndex = config.modulationIndex || 2.0;
+ }
+
+ sample() {
+ const modulator = Math.sin(2 * Math.PI * this.modulatorFreq * this.time);
+ const instantFreq = this.carrierFreq + this.modulationIndex * modulator;
+ return Math.sin(2 * Math.PI * instantFreq * this.time);
+ }
+}
+
+/**
+ * Exponential Decay Generator - Exponentially decaying signal
+ */
+export class ExponentialDecayGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.decayRate = config.decayRate || 1.0; // 1/seconds
+ this.oscillationFreq = config.oscillationFreq || 5.0; // Hz
+ }
+
+ sample() {
+ const envelope = Math.exp(-this.decayRate * this.time);
+ const oscillation = Math.sin(2 * Math.PI * this.oscillationFreq * this.time);
+ return envelope * oscillation;
+ }
+}
+
+/**
+ * Step Function Generator - Random walk / brownian motion
+ */
+export class RandomWalkGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.stepSize = config.stepSize || 0.1;
+ this.currentValue = 0;
+ this.bounds = config.bounds || { min: -5, max: 5 };
+ }
+
+ sample() {
+ // Random step
+ const step = (Math.random() - 0.5) * this.stepSize;
+ this.currentValue += step;
+
+ // Apply bounds
+ this.currentValue = Math.max(this.bounds.min, Math.min(this.bounds.max, this.currentValue));
+
+ return this.currentValue;
+ }
+}
+
+// ============================================================================
+// Example Usage and Presets
+// ============================================================================
+
+/**
+ * Factory function to create common test scenarios
+ */
+export class TestDataFactory {
+ static createSimpleSine(amplitude = 30) {
+ return new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: amplitude,
+ sampleRate: 100,
+ });
+ }
+
+ static createNoisySine(amplitude = 30) {
+ const sine = new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: amplitude * 0.8,
+ sampleRate: 100,
+ });
+
+ const noise = new WhiteNoiseGenerator({
+ amplitude: amplitude * 0.2,
+ sampleRate: 100,
+ });
+
+ return new CompositeGenerator({
+ generators: [sine, noise],
+ weights: [1.0, 1.0],
+ });
+ }
+
+ static createComplexPattern(amplitude = 30) {
+ const low = new SineWaveGenerator({
+ frequency: 0.5,
+ amplitude: amplitude * 0.4,
+ sampleRate: 100,
+ });
+
+ const mid = new SineWaveGenerator({
+ frequency: 3.0,
+ amplitude: amplitude * 0.3,
+ sampleRate: 100,
+ });
+
+ const high = new SineWaveGenerator({
+ frequency: 8.0,
+ amplitude: amplitude * 0.2,
+ sampleRate: 100,
+ });
+
+ const noise = new PinkNoiseGenerator({
+ amplitude: amplitude * 0.1,
+ sampleRate: 100,
+ });
+
+ return new CompositeGenerator({
+ generators: [low, mid, high, noise],
+ weights: [1.0, 1.0, 1.0, 1.0],
+ });
+ }
+
+ static createBurstySignal(amplitude = 30) {
+ return new BurstGenerator({
+ amplitude: amplitude,
+ burstDuration: 0.5,
+ quietDuration: 1.5,
+ burstFrequency: 10.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createSmoothNoise(amplitude = 30) {
+ return new PerlinNoiseGenerator({
+ amplitude: amplitude,
+ frequency: 2.0,
+ octaves: 3,
+ persistence: 0.5,
+ sampleRate: 100,
+ });
+ }
+
+ static createFrequencySweep(amplitude = 30) {
+ return new ChirpGenerator({
+ amplitude: amplitude,
+ startFreq: 0.5,
+ endFreq: 10.0,
+ duration: 3.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createModulatedSignal(amplitude = 30) {
+ return new FMGenerator({
+ amplitude: amplitude,
+ carrierFreq: 5.0,
+ modulatorFreq: 0.3,
+ modulationIndex: 3.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createRandomWalk(amplitude = 30) {
+ return new RandomWalkGenerator({
+ stepSize: 0.5,
+ bounds: { min: -amplitude, max: amplitude },
+ sampleRate: 100,
+ });
+ }
+}
+
+/**
+ * Example: How to use with WaterfallGraph
+ *
+ * // Create a generator
+ * const generator = TestDataFactory.createComplexPattern(30);
+ *
+ * // In your graph's addLine method:
+ * addLine(time, graphIdx) {
+ * const line = {
+ * points: generator.generateLine(this.pointsPerLine, this.width),
+ * yOffset: 0,
+ * color: this.generateColor(time),
+ * };
+ * this.lines.push(line);
+ * }
+ *
+ * // Or generate custom samples:
+ * const samples = generator.generateSamples(100);
+ * const points = samples.map((y, i) => ({
+ * x: (i / samples.length) * width,
+ * y: y
+ * }));
+ */
diff --git a/src/timeseries-plot.js b/src/timeseries-plot.js
new file mode 100644
index 0000000..e35a704
--- /dev/null
+++ b/src/timeseries-plot.js
@@ -0,0 +1,277 @@
+import { Container, Graphics, Text } from 'pixi.js';
+
+/**
+ * TimeSeriesPlot - Pure visualization component for time-series data
+ *
+ * This class is responsible ONLY for displaying data, not generating it.
+ * It receives data points from external sources and renders them as a
+ * scrolling waterfall display.
+ *
+ * Architecture:
+ * - TimeSeriesPlot: Displays data (this file)
+ * - DataSource: Generates/provides data (data-sources.js)
+ * - Connection: Links sources to plots
+ */
+export class TimeSeriesPlot {
+ constructor(config) {
+ this.x = config.x || 0;
+ this.y = config.y || 0;
+ this.width = config.width || 800;
+ this.height = config.height || 600;
+ this.title = config.title || 'Time Series';
+ this.baseColor = config.color || 0xff6666;
+
+ // Container for all graphics
+ this.container = new Container();
+ this.container.x = this.x;
+ this.container.y = this.y;
+
+ // Graphics layers (order matters for rendering)
+ this.gridGraphics = new Graphics();
+ this.linesGraphics = new Graphics();
+ this.borderGraphics = new Graphics();
+
+ this.container.addChild(this.gridGraphics);
+ this.container.addChild(this.linesGraphics);
+ this.container.addChild(this.borderGraphics);
+
+ // Title
+ this.titleText = new Text({
+ text: this.title,
+ style: {
+ fontFamily: 'Arial',
+ fontSize: 18,
+ fill: 0xeeeeee,
+ }
+ });
+ this.titleText.x = 10;
+ this.titleText.y = 10;
+ this.container.addChild(this.titleText);
+
+ // Display state
+ this.lines = []; // Array of {points, yOffset, color, metadata}
+ this.maxLines = config.maxLines || 100;
+ this.showGrid = config.showGrid !== false;
+
+ // Scrolling and scaling
+ this.scrollSpeed = config.scrollSpeed || 1.0;
+ this.verticalScale = config.verticalScale || 1.0;
+
+ // Initial draw
+ this.draw();
+ }
+
+ // ========================================================================
+ // Data Input API - This is how external sources send data to the plot
+ // ========================================================================
+
+ /**
+ * Add a new line of data to the plot
+ * @param {Array<{x: number, y: number}>} points - Array of points
+ * @param {Object} metadata - Optional metadata (color, timestamp, etc.)
+ */
+ addLine(points, metadata = {}) {
+ const line = {
+ points: points,
+ yOffset: 0,
+ color: metadata.color || this.generateColor(Date.now() / 1000),
+ timestamp: metadata.timestamp || Date.now(),
+ metadata: metadata,
+ };
+
+ this.lines.push(line);
+
+ // Limit number of lines
+ if (this.lines.length > this.maxLines) {
+ this.lines.shift();
+ }
+ }
+
+ /**
+ * Add a single data point (will be buffered into a line)
+ * This is useful for streaming real-time data
+ * @param {number} timestamp - Time of the data point
+ * @param {number} value - Value at this time
+ */
+ addDataPoint(timestamp, value) {
+ // For now, this creates a single-point line
+ // In a more sophisticated version, this could buffer points
+ // until a full line is ready
+ const point = { x: this.width / 2, y: value };
+ this.addLine([point], { timestamp });
+ }
+
+ /**
+ * Clear all data from the plot
+ */
+ clearData() {
+ this.lines = [];
+ this.drawLines();
+ }
+
+ // ========================================================================
+ // Update and Rendering
+ // ========================================================================
+
+ /**
+ * Update the plot - called each frame
+ * This handles scrolling and cleanup, but NOT data generation
+ */
+ update() {
+ // Scroll existing lines down
+ this.scrollLines();
+
+ // Remove off-screen lines
+ this.lines = this.lines.filter(line => {
+ const scaledOffset = line.yOffset * this.verticalScale;
+ return scaledOffset < this.height + 50;
+ });
+
+ // Redraw
+ this.drawLines();
+ }
+
+ scrollLines() {
+ this.lines.forEach(line => {
+ line.yOffset += this.scrollSpeed;
+ });
+ }
+
+ draw() {
+ this.drawBorder();
+ this.drawGrid();
+ this.drawLines();
+ }
+
+ drawBorder() {
+ this.borderGraphics.clear();
+ this.borderGraphics.rect(0, 0, this.width, this.height);
+ this.borderGraphics.stroke({ width: 2, color: 0x606070 });
+ }
+
+ drawGrid() {
+ this.gridGraphics.clear();
+
+ if (!this.showGrid) return;
+
+ this.gridGraphics.alpha = 0.3;
+
+ const divisions = 10;
+ const color = 0x4a7a9a;
+
+ // Vertical lines
+ for (let i = 0; i <= divisions; i++) {
+ const x = (i / divisions) * this.width;
+ this.gridGraphics.moveTo(x, 0);
+ this.gridGraphics.lineTo(x, this.height);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+
+ // Horizontal lines
+ for (let i = 0; i <= divisions; i++) {
+ const y = (i / divisions) * this.height;
+ this.gridGraphics.moveTo(0, y);
+ this.gridGraphics.lineTo(this.width, y);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+ }
+
+ drawLines() {
+ this.linesGraphics.clear();
+
+ for (const line of this.lines) {
+ if (line.points.length < 2) continue;
+
+ // Apply vertical scale to y positions
+ const scaledYOffset = line.yOffset * this.verticalScale;
+
+ // Start path
+ const firstPoint = line.points[0];
+ this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
+
+ // Draw line strip
+ for (let i = 1; i < line.points.length; i++) {
+ const point = line.points[i];
+ this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
+ }
+
+ this.linesGraphics.stroke({ width: 2, color: line.color });
+ }
+ }
+
+ generateColor(time) {
+ // Cycle through colors based on time
+ const hue = (time * 0.1) % 1.0;
+ const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
+ const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
+ const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
+
+ return (r << 16) | (g << 8) | b;
+ }
+
+ // ========================================================================
+ // Configuration and Control
+ // ========================================================================
+
+ setGridVisible(visible) {
+ this.showGrid = visible;
+ this.drawGrid();
+ }
+
+ setScrollSpeed(speed) {
+ this.scrollSpeed = Math.max(0.1, Math.min(10.0, speed));
+ }
+
+ setVerticalScale(scale) {
+ this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
+ }
+
+ setTitle(title) {
+ this.title = title;
+ this.titleText.text = title;
+ }
+
+ resize(x, y, width, height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+
+ this.container.x = x;
+ this.container.y = y;
+
+ this.draw();
+ }
+
+ // ========================================================================
+ // Statistics and Debugging
+ // ========================================================================
+
+ getVertexCount() {
+ return this.lines.reduce((sum, line) => sum + line.points.length, 0);
+ }
+
+ getLineCount() {
+ return this.lines.length;
+ }
+
+ getOldestTimestamp() {
+ if (this.lines.length === 0) return null;
+ return Math.min(...this.lines.map(l => l.timestamp));
+ }
+
+ getNewestTimestamp() {
+ if (this.lines.length === 0) return null;
+ return Math.max(...this.lines.map(l => l.timestamp));
+ }
+
+ getStats() {
+ return {
+ lineCount: this.getLineCount(),
+ vertexCount: this.getVertexCount(),
+ oldestTimestamp: this.getOldestTimestamp(),
+ newestTimestamp: this.getNewestTimestamp(),
+ timeSpan: this.getNewestTimestamp() - this.getOldestTimestamp(),
+ };
+ }
+}
diff --git a/src/ui/panel-manager.js b/src/ui/panel-manager.js
new file mode 100644
index 0000000..ad29697
--- /dev/null
+++ b/src/ui/panel-manager.js
@@ -0,0 +1,542 @@
+import { formatDuration, formatValue, formatWallClock } from '../utils-format.js';
+
+function createElement(tagName, className, textContent) {
+ const element = document.createElement(tagName);
+ if (className) {
+ element.className = className;
+ }
+ if (textContent) {
+ element.textContent = textContent;
+ }
+ return element;
+}
+
+function setToggleState(element, active) {
+ element.dataset.active = String(active);
+}
+
+function readControlValue(element) {
+ if (element.tagName === 'SELECT') {
+ return element.value;
+ }
+
+ if (element instanceof HTMLInputElement) {
+ if (element.type === 'checkbox') {
+ return element.checked;
+ }
+
+ if (element.type === 'number' || element.type === 'range') {
+ return Number(element.value);
+ }
+
+ return element.value;
+ }
+
+ return element.value;
+}
+
+function syncControlValue(element, value) {
+ if (!element || document.activeElement === element) {
+ return;
+ }
+
+ if (element instanceof HTMLInputElement && element.type === 'checkbox') {
+ element.checked = Boolean(value);
+ return;
+ }
+
+ element.value = String(value ?? '');
+}
+
+export class PanelManager {
+ constructor({ root, store, actions }) {
+ this.root = root;
+ this.store = store;
+ this.actions = actions;
+ this.elements = {};
+ }
+
+ mount() {
+ const shell = createElement('div', 'timeplot-shell');
+ const topbar = createElement('header', 'timeplot-topbar');
+ const viewport = createElement('section', 'timeplot-viewport');
+ const plotGrid = createElement('div', 'timeplot-plot-grid');
+ const primaryPlotPanel = createElement('section', 'timeplot-plot-panel');
+ const secondaryPlotPanel = createElement('section', 'timeplot-plot-panel');
+ const primaryCanvasHost = createElement('div', 'timeplot-canvas-host');
+ const secondaryCanvasHost = createElement('div', 'timeplot-canvas-host');
+ const sidebar = createElement('aside', 'timeplot-sidebar');
+ const primaryTooltip = createElement('div', 'timeplot-tooltip');
+ const secondaryTooltip = createElement('div', 'timeplot-tooltip');
+ primaryTooltip.hidden = true;
+ secondaryTooltip.hidden = true;
+
+ const brand = createElement('div', 'timeplot-brand');
+ const title = createElement('h1', 'timeplot-title', 'TimePlot');
+ const subtitle = createElement('div', 'timeplot-subtitle', 'Dual synchronized signal monitor');
+ brand.append(title, subtitle);
+
+ const toolbar = createElement('div', 'timeplot-toolbar');
+ toolbar.append(
+ this.createTransportControls(),
+ this.createPanelToggles(),
+ );
+
+ topbar.append(brand, toolbar);
+ primaryPlotPanel.append(primaryCanvasHost, primaryTooltip);
+ secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip);
+ plotGrid.append(primaryPlotPanel, secondaryPlotPanel);
+ viewport.append(plotGrid);
+ shell.append(topbar, viewport, sidebar);
+ this.root.replaceChildren(shell);
+
+ this.elements = {
+ ...this.elements,
+ shell,
+ topbar,
+ viewport,
+ plotGrid,
+ primaryPlotPanel,
+ secondaryPlotPanel,
+ primaryCanvasHost,
+ secondaryCanvasHost,
+ sidebar,
+ primaryTooltip,
+ secondaryTooltip,
+ title,
+ subtitle,
+ statusPanel: this.createStatusPanel(),
+ sourcePanel: this.createSourcePanel(),
+ configPanel: this.createConfigPanel(),
+ helpPanel: this.createHelpPanel(),
+ };
+
+ sidebar.append(
+ this.elements.statusPanel,
+ this.elements.sourcePanel,
+ this.elements.configPanel,
+ this.elements.helpPanel,
+ );
+
+ return this.elements;
+ }
+
+ createTransportControls() {
+ const wrapper = createElement('div', 'control-group');
+ const pauseButton = createElement('button', 'control-button', 'Pause');
+ const resetButton = createElement('button', 'control-button', 'Reset');
+ const speedLabel = createElement('span', null, 'Speed');
+ const speedInput = document.createElement('input');
+ speedInput.type = 'range';
+ speedInput.min = '0.1';
+ speedInput.max = '6';
+ speedInput.step = '0.1';
+ const speedValue = createElement('span', null, '1.0×');
+
+ pauseButton.addEventListener('click', () => this.actions.togglePause());
+ resetButton.addEventListener('click', () => this.actions.resetScene());
+ speedInput.addEventListener('input', (event) => this.actions.setSpeed(Number(event.target.value)));
+
+ wrapper.append(pauseButton, resetButton, speedLabel, speedInput, speedValue);
+ this.elements.pauseButton = pauseButton;
+ this.elements.resetButton = resetButton;
+ this.elements.speedInput = speedInput;
+ this.elements.speedValue = speedValue;
+ return wrapper;
+ }
+
+ createPanelToggles() {
+ const wrapper = createElement('div', 'control-group');
+ const panelIds = ['status', 'source', 'config', 'help'];
+ this.elements.panelButtons = {};
+
+ for (const panelId of panelIds) {
+ const button = createElement('button', 'panel-toggle', panelId);
+ button.addEventListener('click', () => this.actions.togglePanel(panelId));
+ this.elements.panelButtons[panelId] = button;
+ wrapper.append(button);
+ }
+
+ return wrapper;
+ }
+
+ createStatusPanel() {
+ const panel = createElement('section', 'panel');
+ panel.innerHTML = `
+ <h2>Status</h2>
+ <dl class="kv-list">
+ <dt>Renderer</dt><dd data-field="renderer">—</dd>
+ <dt>Real time</dt><dd data-field="realTime">—</dd>
+ <dt>Real elapsed</dt><dd data-field="realElapsed">—</dd>
+ <dt>Plot time</dt><dd data-field="plotTime">—</dd>
+ <dt>Playback</dt><dd data-field="playback">—</dd>
+ <dt>Points</dt><dd data-field="points">—</dd>
+ </dl>
+ `;
+ return panel;
+ }
+
+ createSourcePanel() {
+ const panel = createElement('section', 'panel');
+ panel.innerHTML = `
+ <h2>Data Source</h2>
+ <div class="panel-subsection" data-source-config="signalA">
+ <div class="panel-section-title">Signal A</div>
+ <div class="field-grid">
+ <label>
+ Source type
+ <select data-source-key="signalA" data-source-field="type">
+ <option value="synthetic-wave">Synthetic wave</option>
+ <option value="csv-replay">CSV replay</option>
+ <option value="websocket">WebSocket</option>
+ </select>
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="synthetic-wave">
+ <label>
+ Preset
+ <select data-source-key="signalA" data-source-field="preset">
+ <option value="telemetry">Telemetry</option>
+ <option value="chirp">Chirp</option>
+ <option value="burst">Burst</option>
+ </select>
+ </label>
+ <label>
+ Sample rate (Hz)
+ <input data-source-key="signalA" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
+ </label>
+ <label>
+ Amplitude
+ <input data-source-key="signalA" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
+ </label>
+ <label>
+ Noise
+ <input data-source-key="signalA" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="csv-replay">
+ <label>
+ CSV file
+ <input data-source-key="signalA" data-source-file="dataset" type="file" accept=".csv,text/csv" />
+ </label>
+ <label>
+ Replay rate
+ <input data-source-key="signalA" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" />
+ </label>
+ <div class="source-meta" data-source-key="signalA" data-source-meta></div>
+ </div>
+ <div class="field-grid" data-source-mode="websocket">
+ <label>
+ WebSocket URL
+ <input data-source-key="signalA" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" />
+ </label>
+ <label>
+ Reconnect (ms)
+ <input data-source-key="signalA" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" />
+ </label>
+ <div class="source-meta" data-source-key="signalA" data-source-ws-meta></div>
+ </div>
+ </div>
+ <div class="panel-subsection" data-source-config="signalB">
+ <div class="panel-section-title">Signal B</div>
+ <div class="field-grid">
+ <label>
+ Source type
+ <select data-source-key="signalB" data-source-field="type">
+ <option value="synthetic-wave">Synthetic wave</option>
+ <option value="csv-replay">CSV replay</option>
+ <option value="websocket">WebSocket</option>
+ </select>
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="synthetic-wave">
+ <label>
+ Preset
+ <select data-source-key="signalB" data-source-field="preset">
+ <option value="telemetry">Telemetry</option>
+ <option value="chirp">Chirp</option>
+ <option value="burst">Burst</option>
+ </select>
+ </label>
+ <label>
+ Sample rate (Hz)
+ <input data-source-key="signalB" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
+ </label>
+ <label>
+ Amplitude
+ <input data-source-key="signalB" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
+ </label>
+ <label>
+ Noise
+ <input data-source-key="signalB" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
+ </label>
+ </div>
+ <div class="field-grid" data-source-mode="csv-replay">
+ <label>
+ CSV file
+ <input data-source-key="signalB" data-source-file="dataset" type="file" accept=".csv,text/csv" />
+ </label>
+ <label>
+ Replay rate
+ <input data-source-key="signalB" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" />
+ </label>
+ <div class="source-meta" data-source-key="signalB" data-source-meta></div>
+ </div>
+ <div class="field-grid" data-source-mode="websocket">
+ <label>
+ WebSocket URL
+ <input data-source-key="signalB" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" />
+ </label>
+ <label>
+ Reconnect (ms)
+ <input data-source-key="signalB" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" />
+ </label>
+ <div class="source-meta" data-source-key="signalB" data-source-ws-meta></div>
+ </div>
+ </div>
+ `;
+
+ panel.querySelectorAll('[data-source-field]').forEach((input) => {
+ const eventName = input.tagName === 'SELECT' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
+ const sourceKey = input.getAttribute('data-source-key');
+ const field = input.getAttribute('data-source-field');
+ const value = readControlValue(input);
+ this.actions.updateSource(sourceKey, field, value);
+ });
+ });
+
+ panel.querySelectorAll('[data-source-file]').forEach((input) => {
+ input.addEventListener('change', async () => {
+ const sourceKey = input.getAttribute('data-source-key');
+ const file = input.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ await this.actions.loadSourceFile(sourceKey, file);
+ input.value = '';
+ });
+ });
+
+ return panel;
+ }
+
+ createConfigPanel() {
+ const panel = createElement('section', 'panel');
+ panel.innerHTML = `
+ <h2>Config</h2>
+ <div class="field-grid">
+ <label>
+ Visible window (ms)
+ <input data-plot-field="windowDurationMs" type="number" min="2000" max="120000" step="1000" />
+ </label>
+ <label>
+ Max points
+ <input data-plot-field="maxPoints" type="number" min="200" max="4000" step="100" />
+ </label>
+ <div class="panel-row">
+ <span>Show grid</span>
+ <input data-plot-field="showGrid" type="checkbox" />
+ </div>
+ <div class="panel-row">
+ <span>Show points</span>
+ <input data-plot-field="showPoints" type="checkbox" />
+ </div>
+ </div>
+ <div class="panel-subsection">
+ <div class="panel-section-title">Graph routing</div>
+ <div class="field-grid">
+ <label>
+ Primary graph source
+ <select data-graph-id="primary" data-graph-field="sourceKey">
+ <option value="signalA">Signal A</option>
+ <option value="signalB">Signal B</option>
+ </select>
+ </label>
+ <label>
+ Primary graph transform
+ <select data-graph-id="primary" data-graph-field="transform">
+ <option value="raw">Raw</option>
+ <option value="delta">Delta</option>
+ <option value="smooth">Smooth</option>
+ </select>
+ </label>
+ <label>
+ Secondary graph source
+ <select data-graph-id="secondary" data-graph-field="sourceKey">
+ <option value="signalA">Signal A</option>
+ <option value="signalB">Signal B</option>
+ </select>
+ </label>
+ <label>
+ Secondary graph transform
+ <select data-graph-id="secondary" data-graph-field="transform">
+ <option value="raw">Raw</option>
+ <option value="delta">Delta</option>
+ <option value="smooth">Smooth</option>
+ </select>
+ </label>
+ </div>
+ </div>
+ `;
+
+ panel.querySelectorAll('[data-plot-field]').forEach((input) => {
+ const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
+ const field = input.getAttribute('data-plot-field');
+ const value = readControlValue(input);
+ this.actions.updatePlot(field, value);
+ });
+ });
+
+ panel.querySelectorAll('[data-graph-field]').forEach((input) => {
+ input.addEventListener('change', () => {
+ const graphId = input.getAttribute('data-graph-id');
+ const field = input.getAttribute('data-graph-field');
+ this.actions.updateGraph(graphId, field, input.value);
+ });
+ });
+
+ return panel;
+ }
+
+ createHelpPanel() {
+ const panel = createElement('section', 'panel');
+ panel.innerHTML = `
+ <h2>Help</h2>
+ <ol class="help-list">
+ <li>Each signal can be synthetic or file-backed CSV replay.</li>
+ <li>Each graph can target Signal A or Signal B independently.</li>
+ <li>Each graph can render raw, delta, or smoothed data.</li>
+ <li>Hover either trace to inspect the nearest synchronized sample.</li>
+ <li>Use pause and speed controls to inspect timing behavior.</li>
+ </ol>
+ `;
+ return panel;
+ }
+
+ sync(state, visiblePoints) {
+ this.elements.title.textContent = state.app.title;
+ this.elements.subtitle.textContent = 'Dual synchronized signal monitor';
+ this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause';
+ setToggleState(this.elements.pauseButton, state.time.paused);
+ syncControlValue(this.elements.speedInput, state.time.speed);
+ this.elements.speedValue.textContent = `${state.time.speed.toFixed(1)}×`;
+
+ const statusFields = this.elements.statusPanel.querySelectorAll('[data-field]');
+ const fieldMap = Object.fromEntries(Array.from(statusFields).map((field) => [field.getAttribute('data-field'), field]));
+ fieldMap.renderer.textContent = state.app.renderer;
+ fieldMap.realTime.textContent = formatWallClock(state.time.realNowMs);
+ fieldMap.realElapsed.textContent = formatDuration(state.time.realElapsedMs);
+ fieldMap.plotTime.textContent = formatDuration(state.time.plotTimeMs);
+ fieldMap.playback.textContent = state.time.paused ? 'Paused' : `${state.time.speed.toFixed(1)}×`;
+ fieldMap.points.textContent = typeof visiblePoints === 'object'
+ ? `${visiblePoints.primary} / ${visiblePoints.secondary}`
+ : `${visiblePoints}`;
+
+ this.syncSourcePanel(state);
+ this.syncConfigPanel(state);
+ this.syncPanels(state);
+ this.syncTooltip(state);
+ }
+
+ syncSourcePanel(state) {
+ Object.entries(state.sources).forEach(([sourceKey, sourceConfig]) => {
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="type"]`), sourceConfig.type);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="preset"]`), sourceConfig.preset);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="sampleRateHz"]`), sourceConfig.sampleRateHz);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="amplitude"]`), sourceConfig.amplitude);
+ syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="noise"]`), sourceConfig.noise);
+ const replayRateInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="replayRate"]`);
+ if (replayRateInput) {
+ syncControlValue(replayRateInput, sourceConfig.replayRate ?? 1);
+ }
+
+ const sourceSection = this.elements.sourcePanel.querySelector(`[data-source-config="${sourceKey}"]`);
+ sourceSection.querySelectorAll('[data-source-mode]').forEach((modeSection) => {
+ modeSection.hidden = modeSection.getAttribute('data-source-mode') !== sourceConfig.type;
+ });
+
+ const meta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-meta]`);
+ if (meta) {
+ if (sourceConfig.type === 'csv-replay') {
+ meta.innerHTML = sourceConfig.loadError
+ ? `<span class="source-meta-error">${sourceConfig.loadError}</span>`
+ : `${sourceConfig.dataFileName || 'No file loaded'}${sourceConfig.datasetPointCount ? ` · ${sourceConfig.datasetPointCount} pts · ${formatDuration(sourceConfig.datasetDurationMs || 0)}` : ''}`;
+ } else if (sourceConfig.type === 'websocket') {
+ meta.textContent = '';
+ } else {
+ meta.textContent = 'Generates data procedurally in-browser';
+ }
+ }
+
+ const wsUrlInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsUrl"]`);
+ const wsReconnectInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsReconnectMs"]`);
+ const wsMeta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-ws-meta]`);
+ if (wsUrlInput) {
+ syncControlValue(wsUrlInput, sourceConfig.wsUrl ?? '');
+ }
+ if (wsReconnectInput) {
+ syncControlValue(wsReconnectInput, sourceConfig.wsReconnectMs ?? 2000);
+ }
+ if (wsMeta) {
+ wsMeta.innerHTML = sourceConfig.type === 'websocket'
+ ? `status: <span class="source-meta-status source-meta-status-${sourceConfig.wsStatus || 'idle'}">${sourceConfig.wsStatus || 'idle'}</span>${sourceConfig.wsStatusDetail ? ` · ${sourceConfig.wsStatusDetail}` : ''}`
+ : '';
+ }
+ });
+ }
+
+ syncConfigPanel(state) {
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]'), state.plot.windowDurationMs);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]'), state.plot.maxPoints);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showGrid"]'), state.plot.showGrid);
+ syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showPoints"]'), state.plot.showPoints);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="sourceKey"]'), state.graphs.primary.sourceKey);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="transform"]'), state.graphs.primary.transform);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="sourceKey"]'), state.graphs.secondary.sourceKey);
+ syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="transform"]'), state.graphs.secondary.transform);
+ }
+
+ syncPanels(state) {
+ const panelMap = {
+ status: this.elements.statusPanel,
+ source: this.elements.sourcePanel,
+ config: this.elements.configPanel,
+ help: this.elements.helpPanel,
+ };
+
+ for (const [panelId, panelState] of Object.entries(state.panels)) {
+ panelMap[panelId].hidden = !panelState.visible;
+ setToggleState(this.elements.panelButtons[panelId], panelState.visible);
+ }
+ }
+
+ syncTooltip(state) {
+ const tooltipState = state.plot.tooltip;
+ this.elements.primaryTooltip.hidden = true;
+ this.elements.secondaryTooltip.hidden = true;
+
+ if (!tooltipState.visible || !tooltipState.point) {
+ return;
+ }
+
+ const tooltip = tooltipState.panelId === 'secondary'
+ ? this.elements.secondaryTooltip
+ : this.elements.primaryTooltip;
+
+ tooltip.hidden = false;
+ tooltip.style.left = `${tooltipState.x}px`;
+ tooltip.style.top = `${tooltipState.y}px`;
+ tooltip.innerHTML = `
+ <div class="timeplot-tooltip-title">Hovered sample</div>
+ <div class="timeplot-tooltip-row"><span class="muted">Panel</span><span>${tooltipState.panelLabel ?? 'Primary'}</span></div>
+ <div class="timeplot-tooltip-row"><span class="muted">Plot time</span><span>${formatDuration(tooltipState.point.timeMs)}</span></div>
+ <div class="timeplot-tooltip-row"><span class="muted">Value</span><span>${formatValue(tooltipState.point.value)}</span></div>
+ <div class="timeplot-tooltip-row"><span class="muted">Source</span><span>${tooltipState.point.sourceId}</span></div>
+ ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked panel</span><span>${tooltipState.linkedPanelLabel ?? 'Linked'}</span></div>` : ''}
+ ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked value</span><span>${formatValue(tooltipState.linkedPoint.value)}</span></div>` : ''}
+ `;
+ }
+}
diff --git a/src/utils-format.js b/src/utils-format.js
new file mode 100644
index 0000000..f4eac88
--- /dev/null
+++ b/src/utils-format.js
@@ -0,0 +1,22 @@
+export function formatDuration(ms) {
+ const totalSeconds = Math.max(0, ms / 1000);
+ if (totalSeconds < 60) {
+ return `${totalSeconds.toFixed(2)}s`;
+ }
+
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}m ${seconds.toFixed(1)}s`;
+}
+
+export function formatWallClock(timestampMs) {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }).format(new Date(timestampMs));
+}
+
+export function formatValue(value) {
+ return Number.isFinite(value) ? value.toFixed(3) : '—';
+}
diff --git a/src/waterfall.js b/src/waterfall.js
new file mode 100644
index 0000000..bce0750
--- /dev/null
+++ b/src/waterfall.js
@@ -0,0 +1,219 @@
+import { Container, Graphics, Text } from 'pixi.js';
+
+/**
+ * WaterfallGraph - A scrolling waterfall display
+ * Starts simple with basic line rendering
+ */
+export class WaterfallGraph {
+ constructor(config) {
+ this.x = config.x;
+ this.y = config.y;
+ this.width = config.width;
+ this.height = config.height;
+ this.title = config.title;
+ this.baseColor = config.color || 0xff6666;
+
+ this.container = new Container();
+ this.container.x = this.x;
+ this.container.y = this.y;
+
+ // Graphics layers
+ this.borderGraphics = new Graphics();
+ this.gridGraphics = new Graphics();
+ this.linesGraphics = new Graphics();
+
+ this.container.addChild(this.gridGraphics);
+ this.container.addChild(this.linesGraphics);
+ this.container.addChild(this.borderGraphics);
+
+ // Title text
+ this.titleText = new Text({
+ text: this.title,
+ style: {
+ fontFamily: 'Arial',
+ fontSize: 18,
+ fill: 0xeeeeee,
+ }
+ });
+ this.titleText.x = 10;
+ this.titleText.y = 10;
+ this.container.addChild(this.titleText);
+
+ // Waterfall data
+ this.lines = [];
+ this.maxLines = 50;
+ this.pointsPerLine = 100;
+ this.frameCounter = 0;
+
+ this.showGrid = true;
+
+ // Time scaling and zoom
+ this.scrollSpeed = 1.0; // Speed multiplier for scrolling
+ this.baseScrollSpeed = 1.0;
+ this.verticalScale = 1.0; // Vertical zoom: >1 = zoomed in (see less history), <1 = zoomed out (see more)
+
+ this.draw();
+ }
+
+ draw() {
+ this.drawBorder();
+ this.drawGrid();
+ }
+
+ drawBorder() {
+ this.borderGraphics.clear();
+ this.borderGraphics.rect(0, 0, this.width, this.height);
+ this.borderGraphics.stroke({ width: 2, color: 0x606070 });
+ }
+
+ drawGrid() {
+ this.gridGraphics.clear();
+
+ if (!this.showGrid) return;
+
+ this.gridGraphics.alpha = 0.3;
+
+ const divisions = 10;
+ const color = 0x4a7a9a;
+
+ // Vertical lines
+ for (let i = 0; i <= divisions; i++) {
+ const x = (i / divisions) * this.width;
+ this.gridGraphics.moveTo(x, 0);
+ this.gridGraphics.lineTo(x, this.height);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+
+ // Horizontal lines
+ for (let i = 0; i <= divisions; i++) {
+ const y = (i / divisions) * this.height;
+ this.gridGraphics.moveTo(0, y);
+ this.gridGraphics.lineTo(this.width, y);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+ }
+
+ update(time, graphIdx) {
+ this.frameCounter++;
+
+ // Add new line every 10 frames
+ if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) {
+ this.addLine(time, graphIdx);
+ }
+
+ // Scroll existing lines down
+ this.scrollLines();
+
+ // Remove off-screen lines
+ this.lines = this.lines.filter(line => line.yOffset < this.height + 50);
+
+ // Redraw all lines
+ this.drawLines();
+ }
+
+ addLine(time, graphIdx) {
+ const line = {
+ points: [],
+ yOffset: 0,
+ color: this.generateColor(time),
+ };
+
+ // Generate sine wave points
+ const phase = time + (graphIdx * 2);
+ const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0;
+
+ for (let i = 0; i < this.pointsPerLine; i++) {
+ const x = (i / this.pointsPerLine) * this.width;
+ const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1
+ const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels
+
+ line.points.push({ x, y });
+ }
+
+ this.lines.push(line);
+ }
+
+ scrollLines() {
+ const speed = this.baseScrollSpeed * this.scrollSpeed;
+ this.lines.forEach(line => {
+ line.yOffset += speed;
+ });
+ }
+
+ setScrollSpeed(speed) {
+ // Clamp between 0.1 (slow) and 5.0 (fast)
+ this.scrollSpeed = Math.max(0.1, Math.min(5.0, speed));
+ }
+
+ getScrollSpeed() {
+ return this.scrollSpeed;
+ }
+
+ setVerticalScale(scale) {
+ // Clamp between 0.2 (zoomed out, see more history) and 3.0 (zoomed in, see less)
+ this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
+ }
+
+ getVerticalScale() {
+ return this.verticalScale;
+ }
+
+ drawLines() {
+ this.linesGraphics.clear();
+
+ for (const line of this.lines) {
+ if (line.points.length < 2) continue;
+
+ // Apply vertical scale to y positions
+ // Current time is at top (y=0), older data has larger yOffset
+ const scaledYOffset = line.yOffset * this.verticalScale;
+
+ // Start path
+ const firstPoint = line.points[0];
+ this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
+
+ // Draw line strip
+ for (let i = 1; i < line.points.length; i++) {
+ const point = line.points[i];
+ this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
+ }
+
+ this.linesGraphics.stroke({ width: 2, color: line.color });
+ }
+ }
+
+ generateColor(time) {
+ // Cycle through colors based on time
+ const hue = (time * 0.1) % 1.0;
+ const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
+ const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
+ const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
+
+ return (r << 16) | (g << 8) | b;
+ }
+
+ setGridVisible(visible) {
+ this.showGrid = visible;
+ this.drawGrid();
+ }
+
+ resize(x, y, width, height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+
+ this.container.x = x;
+ this.container.y = y;
+
+ this.draw();
+ }
+
+ getVertexCount() {
+ return this.lines.reduce((sum, line) => sum + line.points.length, 0);
+ }
+
+ getLineCount() {
+ return this.lines.length;
+ }
+}