summaryrefslogtreecommitdiff
path: root/web-timeplot/src
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot/src')
-rw-r--r--web-timeplot/src/app/create-app.js449
-rw-r--r--web-timeplot/src/bootstrap.js18
-rw-r--r--web-timeplot/src/core/event-bus.js32
-rw-r--r--web-timeplot/src/core/store.js291
-rw-r--r--web-timeplot/src/core/time-controller.js80
-rw-r--r--web-timeplot/src/data-sources.js517
-rw-r--r--web-timeplot/src/data/base-source.js21
-rw-r--r--web-timeplot/src/data/csv-replay-source.js60
-rw-r--r--web-timeplot/src/data/parse-replay-csv.js108
-rw-r--r--web-timeplot/src/data/source-registry.js90
-rw-r--r--web-timeplot/src/data/synthetic-wave-source.js87
-rw-r--r--web-timeplot/src/data/websocket-source.js224
-rw-r--r--web-timeplot/src/demos.js697
-rw-r--r--web-timeplot/src/example-usage.js535
-rw-r--r--web-timeplot/src/main.js1
-rw-r--r--web-timeplot/src/metrics.js142
-rw-r--r--web-timeplot/src/plot-connections.js392
-rw-r--r--web-timeplot/src/plot/plot-buffer.js22
-rw-r--r--web-timeplot/src/plot/timeplot-view.js442
-rw-r--r--web-timeplot/src/state.js420
-rw-r--r--web-timeplot/src/styles.css401
-rw-r--r--web-timeplot/src/template-for-standard-site.js75
-rw-r--r--web-timeplot/src/test-data-generators.js530
-rw-r--r--web-timeplot/src/timeseries-plot.js277
-rw-r--r--web-timeplot/src/ui/panel-manager.js542
-rw-r--r--web-timeplot/src/utils-format.js22
-rw-r--r--web-timeplot/src/waterfall.js219
27 files changed, 0 insertions, 6694 deletions
diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js
deleted file mode 100644
index 4f4f0fc..0000000
--- a/web-timeplot/src/app/create-app.js
+++ /dev/null
@@ -1,449 +0,0 @@
-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/web-timeplot/src/bootstrap.js b/web-timeplot/src/bootstrap.js
deleted file mode 100644
index 4b073bc..0000000
--- a/web-timeplot/src/bootstrap.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import './styles.css';
-import { createApp } from './app/create-app.js';
-
-const root = document.getElementById('app');
-
-if (!root) {
- throw new Error('App root not found');
-}
-
-createApp(root).catch((error) => {
- console.error('Failed to start TimePlot', error);
- root.innerHTML = `
- <div style="padding: 24px; color: #fff; font-family: sans-serif;">
- <h1>TimePlot failed to start</h1>
- <pre>${String(error)}</pre>
- </div>
- `;
-});
diff --git a/web-timeplot/src/core/event-bus.js b/web-timeplot/src/core/event-bus.js
deleted file mode 100644
index 192eb6d..0000000
--- a/web-timeplot/src/core/event-bus.js
+++ /dev/null
@@ -1,32 +0,0 @@
-export class EventBus {
- constructor() {
- this.listeners = new Map();
- }
-
- on(eventName, listener) {
- if (!this.listeners.has(eventName)) {
- this.listeners.set(eventName, new Set());
- }
-
- const listeners = this.listeners.get(eventName);
- listeners.add(listener);
-
- return () => {
- listeners.delete(listener);
- if (listeners.size === 0) {
- this.listeners.delete(eventName);
- }
- };
- }
-
- emit(eventName, payload) {
- const listeners = this.listeners.get(eventName);
- if (!listeners) {
- return;
- }
-
- for (const listener of listeners) {
- listener(payload);
- }
- }
-}
diff --git a/web-timeplot/src/core/store.js b/web-timeplot/src/core/store.js
deleted file mode 100644
index 38052eb..0000000
--- a/web-timeplot/src/core/store.js
+++ /dev/null
@@ -1,291 +0,0 @@
-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/web-timeplot/src/core/time-controller.js b/web-timeplot/src/core/time-controller.js
deleted file mode 100644
index 7cd57c7..0000000
--- a/web-timeplot/src/core/time-controller.js
+++ /dev/null
@@ -1,80 +0,0 @@
-export class TimeController {
- constructor(store) {
- this.store = store;
- this.lastFrameTime = performance.now();
- }
-
- tick(now = performance.now()) {
- const deltaMs = now - this.lastFrameTime;
- this.lastFrameTime = now;
-
- this.store.setState((state) => {
- const realElapsedMs = state.time.realElapsedMs + deltaMs;
- const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed;
-
- return {
- ...state,
- time: {
- ...state.time,
- realNowMs: Date.now(),
- realElapsedMs,
- plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs),
- },
- };
- });
-
- return deltaMs;
- }
-
- togglePause() {
- this.store.setState((state) => ({
- ...state,
- time: {
- ...state.time,
- paused: !state.time.paused,
- },
- }));
- }
-
- setPaused(paused) {
- this.store.setState((state) => ({
- ...state,
- time: {
- ...state.time,
- paused,
- },
- }));
- }
-
- setSpeed(speed) {
- const clampedSpeed = Math.max(0.1, Math.min(12, speed));
- this.store.setState((state) => ({
- ...state,
- time: {
- ...state.time,
- speed: clampedSpeed,
- },
- }));
- }
-
- reset() {
- this.store.setState((state) => ({
- ...state,
- time: {
- ...state.time,
- realElapsedMs: 0,
- plotTimeMs: 0,
- },
- plot: {
- ...state.plot,
- hoveredPoint: null,
- tooltip: {
- ...state.plot.tooltip,
- visible: false,
- point: null,
- },
- },
- }));
- this.lastFrameTime = performance.now();
- }
-}
diff --git a/web-timeplot/src/data-sources.js b/web-timeplot/src/data-sources.js
deleted file mode 100644
index 749a151..0000000
--- a/web-timeplot/src/data-sources.js
+++ /dev/null
@@ -1,517 +0,0 @@
-/**
- * Data Sources - Components that generate or provide data to plots
- *
- * This module implements the data provider side of the architecture.
- * Data sources know how to generate or fetch data, but don't know
- * anything about visualization.
- *
- * Architecture:
- * - DataSource: Base class with event emitting
- * - Specific sources: Implement different data generation strategies
- * - Connection: Links sources to plots (see plot-connections.js)
- */
-
-// Simple EventEmitter (same as in state.js, could be extracted to utils)
-class EventEmitter {
- constructor() {
- this.events = new Map();
- }
-
- on(event, callback) {
- if (!this.events.has(event)) {
- this.events.set(event, []);
- }
- this.events.get(event).push(callback);
- return () => this.off(event, callback);
- }
-
- off(event, callback) {
- if (!this.events.has(event)) return;
- const callbacks = this.events.get(event);
- const index = callbacks.indexOf(callback);
- if (index > -1) {
- callbacks.splice(index, 1);
- }
- }
-
- emit(event, data) {
- if (!this.events.has(event)) return;
- this.events.get(event).forEach(callback => {
- try {
- callback(data);
- } catch (e) {
- console.error(`[DataSource] Error in event handler for '${event}':`, e);
- }
- });
- }
-}
-
-/**
- * Base class for all data sources
- *
- * Events emitted:
- * - 'line': {points: Array, timestamp: number, metadata: Object}
- * - 'point': {value: number, timestamp: number}
- * - 'error': {error: Error}
- */
-export class DataSource extends EventEmitter {
- constructor(config = {}) {
- super();
- this.config = config;
- this.isRunning = false;
- this.time = 0;
- }
-
- /**
- * Start generating/providing data
- */
- start() {
- this.isRunning = true;
- }
-
- /**
- * Stop generating/providing data
- */
- stop() {
- this.isRunning = false;
- }
-
- /**
- * Reset the data source to initial state
- */
- reset() {
- this.time = 0;
- }
-
- /**
- * Emit a complete line of data
- */
- emitLine(points, metadata = {}) {
- this.emit('line', {
- points,
- timestamp: metadata.timestamp || Date.now(),
- metadata,
- });
- }
-
- /**
- * Emit a single data point
- */
- emitPoint(value, timestamp = Date.now()) {
- this.emit('point', {
- value,
- timestamp,
- });
- }
-
- /**
- * Emit an error
- */
- emitError(error) {
- this.emit('error', { error });
- }
-}
-
-/**
- * Synthetic data source using test generators
- * Uses the generators from test-data-generators.js
- */
-export class SyntheticDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- this.generator = config.generator; // Instance of DataGenerator
- this.pointsPerLine = config.pointsPerLine || 100;
- this.width = config.width || 800;
- this.lineInterval = config.lineInterval || 100; // ms between lines
- this.intervalHandle = null;
- }
-
- start() {
- if (this.isRunning) return;
- super.start();
-
- // Generate a new line periodically
- this.intervalHandle = setInterval(() => {
- this.generateAndEmitLine();
- }, this.lineInterval);
-
- // Generate initial line immediately
- this.generateAndEmitLine();
- }
-
- stop() {
- super.stop();
- if (this.intervalHandle) {
- clearInterval(this.intervalHandle);
- this.intervalHandle = null;
- }
- }
-
- generateAndEmitLine() {
- if (!this.generator) {
- this.emitError(new Error('No generator configured'));
- return;
- }
-
- const points = this.generator.generateLine(this.pointsPerLine, this.width);
- this.emitLine(points, {
- timestamp: Date.now(),
- generatorType: this.generator.constructor.name,
- });
- }
-
- setGenerator(generator) {
- this.generator = generator;
- }
-}
-
-/**
- * Function-based data source
- * Evaluates a user-provided function to generate data
- */
-export class FunctionDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- // Function should have signature: (x, t) => y
- // x: normalized position 0-1
- // t: time in seconds
- // returns: y value
- this.func = config.func || ((x, t) => Math.sin(x * 10 + t));
- this.pointsPerLine = config.pointsPerLine || 100;
- this.width = config.width || 800;
- this.amplitude = config.amplitude || 30;
- this.lineInterval = config.lineInterval || 100;
- this.intervalHandle = null;
- }
-
- start() {
- if (this.isRunning) return;
- super.start();
-
- this.intervalHandle = setInterval(() => {
- this.generateAndEmitLine();
- }, this.lineInterval);
-
- this.generateAndEmitLine();
- }
-
- stop() {
- super.stop();
- if (this.intervalHandle) {
- clearInterval(this.intervalHandle);
- this.intervalHandle = null;
- }
- }
-
- generateAndEmitLine() {
- const points = [];
- const t = this.time;
-
- for (let i = 0; i < this.pointsPerLine; i++) {
- const x = (i / this.pointsPerLine) * this.width;
- const normalizedX = i / this.pointsPerLine;
- const y = this.func(normalizedX, t) * this.amplitude;
- points.push({ x, y });
- }
-
- this.emitLine(points, {
- timestamp: Date.now(),
- time: t,
- });
-
- this.time += this.lineInterval / 1000;
- }
-
- setFunction(func) {
- this.func = func;
- }
-}
-
-/**
- * Streaming data source
- * Emits individual data points that get buffered into lines
- */
-export class StreamingDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- this.generator = config.generator;
- this.sampleRate = config.sampleRate || 60; // Samples per second
- this.intervalHandle = null;
- }
-
- start() {
- if (this.isRunning) return;
- super.start();
-
- const intervalMs = 1000 / this.sampleRate;
- this.intervalHandle = setInterval(() => {
- this.generateAndEmitPoint();
- }, intervalMs);
- }
-
- stop() {
- super.stop();
- if (this.intervalHandle) {
- clearInterval(this.intervalHandle);
- this.intervalHandle = null;
- }
- }
-
- generateAndEmitPoint() {
- if (!this.generator) {
- this.emitError(new Error('No generator configured'));
- return;
- }
-
- const value = this.generator.sample();
- this.generator.time += 1 / this.generator.sampleRate;
- this.emitPoint(value, Date.now());
- }
-
- setGenerator(generator) {
- this.generator = generator;
- }
-}
-
-/**
- * WebSocket data source (for real data)
- * Receives data from a WebSocket connection
- */
-export class WebSocketDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- this.url = config.url;
- this.socket = null;
- this.reconnectInterval = config.reconnectInterval || 5000;
- this.reconnectHandle = null;
- }
-
- start() {
- if (this.isRunning) return;
- super.start();
- this.connect();
- }
-
- stop() {
- super.stop();
- if (this.socket) {
- this.socket.close();
- this.socket = null;
- }
- if (this.reconnectHandle) {
- clearTimeout(this.reconnectHandle);
- this.reconnectHandle = null;
- }
- }
-
- connect() {
- try {
- this.socket = new WebSocket(this.url);
-
- this.socket.onopen = () => {
- console.log(`[WebSocketDataSource] Connected to ${this.url}`);
- };
-
- this.socket.onmessage = (event) => {
- this.handleMessage(event.data);
- };
-
- this.socket.onerror = (error) => {
- console.error('[WebSocketDataSource] Error:', error);
- this.emitError(error);
- };
-
- this.socket.onclose = () => {
- console.log('[WebSocketDataSource] Connection closed');
- if (this.isRunning) {
- // Auto-reconnect
- this.reconnectHandle = setTimeout(() => {
- this.connect();
- }, this.reconnectInterval);
- }
- };
- } catch (error) {
- console.error('[WebSocketDataSource] Failed to connect:', error);
- this.emitError(error);
- }
- }
-
- handleMessage(data) {
- try {
- const parsed = JSON.parse(data);
-
- // Expect format: {type: 'line', points: [...]} or {type: 'point', value: ...}
- if (parsed.type === 'line' && parsed.points) {
- this.emitLine(parsed.points, parsed.metadata || {});
- } else if (parsed.type === 'point' && parsed.value !== undefined) {
- this.emitPoint(parsed.value, parsed.timestamp);
- } else {
- console.warn('[WebSocketDataSource] Unknown message format:', parsed);
- }
- } catch (error) {
- console.error('[WebSocketDataSource] Failed to parse message:', error);
- this.emitError(error);
- }
- }
-
- send(data) {
- if (this.socket && this.socket.readyState === WebSocket.OPEN) {
- this.socket.send(JSON.stringify(data));
- }
- }
-}
-
-/**
- * CSV File data source
- * Reads data from CSV files (for replay/analysis)
- */
-export class CSVDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- this.data = []; // Parsed CSV data
- this.currentIndex = 0;
- this.playbackRate = config.playbackRate || 1.0;
- this.loop = config.loop || false;
- this.intervalHandle = null;
- }
-
- /**
- * Load CSV data from a string
- * Expected format: timestamp,value or x,y format
- */
- loadCSV(csvString) {
- const lines = csvString.trim().split('\n');
- const headers = lines[0].split(',').map(h => h.trim());
-
- this.data = [];
- for (let i = 1; i < lines.length; i++) {
- const values = lines[i].split(',').map(v => parseFloat(v.trim()));
- if (values.length >= 2 && !values.some(isNaN)) {
- this.data.push({
- timestamp: values[0],
- value: values[1],
- });
- }
- }
-
- console.log(`[CSVDataSource] Loaded ${this.data.length} data points`);
- }
-
- start() {
- if (this.isRunning || this.data.length === 0) return;
- super.start();
-
- // Play back at specified rate
- this.intervalHandle = setInterval(() => {
- this.emitNextPoint();
- }, 16 / this.playbackRate); // ~60fps adjusted by playback rate
- }
-
- stop() {
- super.stop();
- if (this.intervalHandle) {
- clearInterval(this.intervalHandle);
- this.intervalHandle = null;
- }
- }
-
- reset() {
- super.reset();
- this.currentIndex = 0;
- }
-
- emitNextPoint() {
- if (this.currentIndex >= this.data.length) {
- if (this.loop) {
- this.currentIndex = 0;
- } else {
- this.stop();
- return;
- }
- }
-
- const point = this.data[this.currentIndex];
- this.emitPoint(point.value, point.timestamp);
- this.currentIndex++;
- }
-}
-
-/**
- * Multi-source combiner
- * Combines data from multiple sources
- */
-export class CompositeDataSource extends DataSource {
- constructor(config = {}) {
- super(config);
- this.sources = config.sources || [];
- this.combineMode = config.combineMode || 'average'; // 'average', 'sum', 'max', 'min'
- this.pointBuffer = new Map(); // sourceId => latest point
- }
-
- start() {
- if (this.isRunning) return;
- super.start();
-
- // Subscribe to all sources
- this.sources.forEach((source, idx) => {
- source.on('point', (data) => {
- this.handleSourcePoint(idx, data);
- });
- source.on('line', (data) => {
- this.handleSourceLine(idx, data);
- });
- source.start();
- });
- }
-
- stop() {
- super.stop();
- this.sources.forEach(source => source.stop());
- }
-
- handleSourcePoint(sourceIdx, data) {
- this.pointBuffer.set(sourceIdx, data.value);
-
- // If we have data from all sources, combine and emit
- if (this.pointBuffer.size === this.sources.length) {
- const combined = this.combineValues(Array.from(this.pointBuffer.values()));
- this.emitPoint(combined, data.timestamp);
- }
- }
-
- handleSourceLine(sourceIdx, data) {
- // For lines, just pass through for now
- // Could implement line combination if needed
- this.emitLine(data.points, data.metadata);
- }
-
- combineValues(values) {
- switch (this.combineMode) {
- case 'sum':
- return values.reduce((a, b) => a + b, 0);
- case 'average':
- return values.reduce((a, b) => a + b, 0) / values.length;
- case 'max':
- return Math.max(...values);
- case 'min':
- return Math.min(...values);
- default:
- return values[0];
- }
- }
-
- addSource(source) {
- this.sources.push(source);
- if (this.isRunning) {
- source.start();
- }
- }
-
- removeSource(source) {
- const idx = this.sources.indexOf(source);
- if (idx > -1) {
- source.stop();
- this.sources.splice(idx, 1);
- }
- }
-}
diff --git a/web-timeplot/src/data/base-source.js b/web-timeplot/src/data/base-source.js
deleted file mode 100644
index 55dbdc3..0000000
--- a/web-timeplot/src/data/base-source.js
+++ /dev/null
@@ -1,21 +0,0 @@
-export class BaseSource {
- constructor(config = {}) {
- this.config = { ...config };
- this.running = false;
- }
-
- start() {
- this.running = true;
- }
-
- stop() {
- this.running = false;
- }
-
- updateConfig(nextConfig) {
- this.config = {
- ...this.config,
- ...nextConfig,
- };
- }
-}
diff --git a/web-timeplot/src/data/csv-replay-source.js b/web-timeplot/src/data/csv-replay-source.js
deleted file mode 100644
index c4e6a66..0000000
--- a/web-timeplot/src/data/csv-replay-source.js
+++ /dev/null
@@ -1,60 +0,0 @@
-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/web-timeplot/src/data/parse-replay-csv.js b/web-timeplot/src/data/parse-replay-csv.js
deleted file mode 100644
index b6ce97a..0000000
--- a/web-timeplot/src/data/parse-replay-csv.js
+++ /dev/null
@@ -1,108 +0,0 @@
-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/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js
deleted file mode 100644
index 917d06b..0000000
--- a/web-timeplot/src/data/source-registry.js
+++ /dev/null
@@ -1,90 +0,0 @@
-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/web-timeplot/src/data/synthetic-wave-source.js b/web-timeplot/src/data/synthetic-wave-source.js
deleted file mode 100644
index df53319..0000000
--- a/web-timeplot/src/data/synthetic-wave-source.js
+++ /dev/null
@@ -1,87 +0,0 @@
-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/web-timeplot/src/data/websocket-source.js b/web-timeplot/src/data/websocket-source.js
deleted file mode 100644
index 5458fb9..0000000
--- a/web-timeplot/src/data/websocket-source.js
+++ /dev/null
@@ -1,224 +0,0 @@
-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/web-timeplot/src/demos.js b/web-timeplot/src/demos.js
deleted file mode 100644
index 1dd6785..0000000
--- a/web-timeplot/src/demos.js
+++ /dev/null
@@ -1,697 +0,0 @@
-/**
- * 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/web-timeplot/src/example-usage.js b/web-timeplot/src/example-usage.js
deleted file mode 100644
index 67eff4b..0000000
--- a/web-timeplot/src/example-usage.js
+++ /dev/null
@@ -1,535 +0,0 @@
-/**
- * Example Usage: Complete examples of the new architecture
- *
- * This file demonstrates how to use the separated data/visualization architecture:
- * - TimeSeriesPlot: Pure visualization
- * - DataSource: Data generation/provision
- * - Connections: Links between them
- */
-
-import { Application } from 'pixi.js';
-import { TimeSeriesPlot } from './timeseries-plot.js';
-import {
- SyntheticDataSource,
- FunctionDataSource,
- StreamingDataSource,
- WebSocketDataSource,
-} from './data-sources.js';
-import {
- DirectConnection,
- BufferedConnection,
- ConnectionManager,
- connectSyntheticData,
- connectFunction,
- createConnectedPlot,
-} from './plot-connections.js';
-import {
- TestDataFactory,
- SineWaveGenerator,
- PerlinNoiseGenerator,
- ChirpGenerator,
-} from './test-data-generators.js';
-
-// ============================================================================
-// Example 1: Simple Setup - One plot, one data source
-// ============================================================================
-
-export async function example1_SimpleSetup() {
- console.log('=== Example 1: Simple Setup ===');
-
- // Create PixiJS app
- const app = new Application();
- await app.init({
- width: 800,
- height: 600,
- backgroundColor: 0x1a1a26,
- });
- document.body.appendChild(app.canvas);
-
- // Create plot (visualization only)
- const plot = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Simple Sine Wave',
- showGrid: true,
- });
- app.stage.addChild(plot.container);
-
- // Create data source
- const generator = TestDataFactory.createSimpleSine(30);
- const source = new SyntheticDataSource({
- generator: generator,
- pointsPerLine: 100,
- width: 800,
- lineInterval: 100, // New line every 100ms
- });
-
- // Connect source to plot
- const connection = new DirectConnection(source, plot);
- connection.connect();
-
- // Update plot every frame
- app.ticker.add(() => {
- plot.update();
- });
-
- return { app, plot, source, connection };
-}
-
-// ============================================================================
-// Example 2: Quick Setup Using Helper Functions
-// ============================================================================
-
-export async function example2_QuickSetup() {
- console.log('=== Example 2: Quick Setup ===');
-
- const app = new Application();
- await app.init({
- width: 800,
- height: 600,
- backgroundColor: 0x1a1a26,
- });
- document.body.appendChild(app.canvas);
-
- // One-liner setup!
- const { plot, source, connection } = createConnectedPlot(
- app,
- {
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Quick Setup',
- },
- {
- generator: TestDataFactory.createComplexPattern(30),
- lineInterval: 100,
- }
- );
-
- app.ticker.add(() => plot.update());
-
- return { app, plot, source, connection };
-}
-
-// ============================================================================
-// Example 3: Multiple Plots with Different Data Sources
-// ============================================================================
-
-export async function example3_MultiplePlots() {
- console.log('=== Example 3: Multiple Plots ===');
-
- const app = new Application();
- await app.init({
- width: 1600,
- height: 600,
- backgroundColor: 0x1a1a26,
- });
- document.body.appendChild(app.canvas);
-
- const width = 800;
- const height = 600;
-
- // Left plot: Sine wave
- const plot1 = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: width,
- height: height,
- title: 'Sine Wave',
- color: 0xff6666,
- });
-
- // Right plot: Perlin noise
- const plot2 = new TimeSeriesPlot({
- x: width,
- y: 0,
- width: width,
- height: height,
- title: 'Perlin Noise',
- color: 0x66ff66,
- });
-
- app.stage.addChild(plot1.container);
- app.stage.addChild(plot2.container);
-
- // Connect different data sources
- const conn1 = connectSyntheticData(
- TestDataFactory.createSimpleSine(30),
- plot1,
- { lineInterval: 100 }
- );
-
- const conn2 = connectSyntheticData(
- TestDataFactory.createSmoothNoise(30),
- plot2,
- { lineInterval: 100 }
- );
-
- app.ticker.add(() => {
- plot1.update();
- plot2.update();
- });
-
- return { app, plots: [plot1, plot2], connections: [conn1, conn2] };
-}
-
-// ============================================================================
-// Example 4: Using Function-Based Data Source
-// ============================================================================
-
-export async function example4_FunctionSource() {
- console.log('=== Example 4: Function Source ===');
-
- const app = new Application();
- await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
- document.body.appendChild(app.canvas);
-
- const plot = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Custom Function',
- });
- app.stage.addChild(plot.container);
-
- // Define a custom function: (x, t) => y
- // x is normalized 0-1 across the width
- // t is time in seconds
- const customFunc = (x, t) => {
- // Create an interference pattern
- const wave1 = Math.sin(x * 10 + t * 2);
- const wave2 = Math.sin(x * 15 - t * 3);
- const wave3 = Math.cos(x * 8 + t * 1.5);
- return (wave1 + wave2 + wave3) / 3;
- };
-
- const connection = connectFunction(customFunc, plot, {
- lineInterval: 100,
- amplitude: 30,
- });
-
- app.ticker.add(() => plot.update());
-
- return { app, plot, connection };
-}
-
-// ============================================================================
-// Example 5: Swapping Data Sources at Runtime
-// ============================================================================
-
-export async function example5_SwappingSources() {
- console.log('=== Example 5: Swapping Sources ===');
-
- const app = new Application();
- await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
- document.body.appendChild(app.canvas);
-
- const plot = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Dynamic Source Switching',
- });
- app.stage.addChild(plot.container);
-
- // Start with sine wave
- let currentConnection = connectSyntheticData(
- TestDataFactory.createSimpleSine(30),
- plot,
- { lineInterval: 100 }
- );
-
- app.ticker.add(() => plot.update());
-
- // Function to switch to a different data source
- const switchToSource = (generator, title) => {
- // Disconnect current source
- currentConnection.disconnect();
-
- // Connect new source
- currentConnection = connectSyntheticData(generator, plot, {
- lineInterval: 100,
- });
-
- plot.setTitle(title);
- console.log(`Switched to: ${title}`);
- };
-
- // Example: Switch sources every 5 seconds
- let sourceIndex = 0;
- const sources = [
- { gen: TestDataFactory.createSimpleSine(30), title: 'Sine Wave' },
- { gen: TestDataFactory.createComplexPattern(30), title: 'Complex Pattern' },
- { gen: TestDataFactory.createSmoothNoise(30), title: 'Perlin Noise' },
- { gen: TestDataFactory.createFrequencySweep(30), title: 'Frequency Sweep' },
- ];
-
- setInterval(() => {
- sourceIndex = (sourceIndex + 1) % sources.length;
- const source = sources[sourceIndex];
- switchToSource(source.gen, source.title);
- }, 5000);
-
- return { app, plot, switchToSource };
-}
-
-// ============================================================================
-// Example 6: Streaming Data with Buffering
-// ============================================================================
-
-export async function example6_StreamingData() {
- console.log('=== Example 6: Streaming Data ===');
-
- const app = new Application();
- await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
- document.body.appendChild(app.canvas);
-
- const plot = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Streaming Data (Buffered)',
- });
- app.stage.addChild(plot.container);
-
- // Create streaming source (emits individual points)
- const generator = new SineWaveGenerator({
- frequency: 2.0,
- amplitude: 1.0,
- sampleRate: 60,
- });
-
- const source = new StreamingDataSource({
- generator: generator,
- sampleRate: 60, // 60 points per second
- });
-
- // Use buffered connection to assemble points into lines
- const connection = new BufferedConnection(source, plot, {
- bufferSize: 100, // Buffer 100 points before creating a line
- bufferTimeout: 1000, // Or timeout after 1 second
- });
- connection.connect();
-
- app.ticker.add(() => plot.update());
-
- return { app, plot, source, connection };
-}
-
-// ============================================================================
-// Example 7: Connection Manager (Managing Multiple Connections)
-// ============================================================================
-
-export async function example7_ConnectionManager() {
- console.log('=== Example 7: Connection Manager ===');
-
- const app = new Application();
- await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
- document.body.appendChild(app.canvas);
-
- const plot = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 600,
- title: 'Managed Connections',
- });
- app.stage.addChild(plot.container);
-
- // Create connection manager
- const manager = new ConnectionManager();
-
- // Add first connection
- const source1 = new SyntheticDataSource({
- generator: TestDataFactory.createSimpleSine(30),
- pointsPerLine: 100,
- width: 800,
- lineInterval: 100,
- });
-
- const connId1 = manager.connect(source1, plot, { type: 'direct' });
- console.log('Connection ID:', connId1);
-
- app.ticker.add(() => plot.update());
-
- // Later: disconnect and switch to different source
- setTimeout(() => {
- manager.disconnect(connId1);
-
- const source2 = new SyntheticDataSource({
- generator: TestDataFactory.createFrequencySweep(30),
- pointsPerLine: 100,
- width: 800,
- lineInterval: 100,
- });
-
- const connId2 = manager.connect(source2, plot, { type: 'direct' });
- plot.setTitle('Frequency Sweep');
- console.log('Switched to connection:', connId2);
- }, 5000);
-
- return { app, plot, manager };
-}
-
-// ============================================================================
-// Example 8: Complete Interactive Demo
-// ============================================================================
-
-export async function example8_InteractiveDemo() {
- console.log('=== Example 8: Interactive Demo ===');
-
- const app = new Application();
- await app.init({
- width: 1600,
- height: 800,
- backgroundColor: 0x1a1a26,
- });
- document.body.appendChild(app.canvas);
-
- // Create two plots
- const plot1 = new TimeSeriesPlot({
- x: 0,
- y: 0,
- width: 800,
- height: 800,
- title: 'Plot 1 - Press 1-5 to change',
- color: 0xff6666,
- });
-
- const plot2 = new TimeSeriesPlot({
- x: 800,
- y: 0,
- width: 800,
- height: 800,
- title: 'Plot 2 - Press 6-0 to change',
- color: 0x66ff66,
- });
-
- app.stage.addChild(plot1.container);
- app.stage.addChild(plot2.container);
-
- // Connection manager
- const manager = new ConnectionManager();
-
- // Available data sources
- const dataSources = {
- sine: () => TestDataFactory.createSimpleSine(30),
- complex: () => TestDataFactory.createComplexPattern(30),
- noise: () => TestDataFactory.createSmoothNoise(30),
- sweep: () => TestDataFactory.createFrequencySweep(30),
- burst: () => TestDataFactory.createBurstySignal(30),
- };
-
- // Track current connections
- let conn1Id = null;
- let conn2Id = null;
-
- // Helper to switch source
- const switchSource = (plot, generatorFunc, title) => {
- // Disconnect old connection
- const connId = plot === plot1 ? conn1Id : conn2Id;
- if (connId !== null) {
- manager.disconnect(connId);
- }
-
- // Create new connection
- const source = new SyntheticDataSource({
- generator: generatorFunc(),
- pointsPerLine: 100,
- width: plot.width,
- lineInterval: 100,
- });
-
- const newConnId = manager.connect(source, plot, { type: 'direct' });
- plot.setTitle(title);
-
- // Store connection ID
- if (plot === plot1) {
- conn1Id = newConnId;
- } else {
- conn2Id = newConnId;
- }
- };
-
- // Initialize with default sources
- switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
- switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
-
- // Keyboard controls
- window.addEventListener('keydown', (e) => {
- switch (e.key) {
- case '1':
- switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
- break;
- case '2':
- switchSource(plot1, dataSources.complex, 'Plot 1 - Complex Pattern');
- break;
- case '3':
- switchSource(plot1, dataSources.noise, 'Plot 1 - Perlin Noise');
- break;
- case '4':
- switchSource(plot1, dataSources.sweep, 'Plot 1 - Frequency Sweep');
- break;
- case '5':
- switchSource(plot1, dataSources.burst, 'Plot 1 - Burst Signal');
- break;
- case '6':
- switchSource(plot2, dataSources.sine, 'Plot 2 - Sine Wave');
- break;
- case '7':
- switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
- break;
- case '8':
- switchSource(plot2, dataSources.noise, 'Plot 2 - Perlin Noise');
- break;
- case '9':
- switchSource(plot2, dataSources.sweep, 'Plot 2 - Frequency Sweep');
- break;
- case '0':
- switchSource(plot2, dataSources.burst, 'Plot 2 - Burst Signal');
- break;
- case 'g':
- plot1.setGridVisible(!plot1.showGrid);
- plot2.setGridVisible(!plot2.showGrid);
- break;
- case 'c':
- plot1.clearData();
- plot2.clearData();
- break;
- }
- });
-
- // Update loop
- app.ticker.add(() => {
- plot1.update();
- plot2.update();
- });
-
- console.log('Controls:');
- console.log(' 1-5: Change Plot 1 source');
- console.log(' 6-0: Change Plot 2 source');
- console.log(' G: Toggle grid');
- console.log(' C: Clear data');
-
- return { app, plot1, plot2, manager };
-}
-
-// ============================================================================
-// Quick Test: Run one of the examples
-// ============================================================================
-
-// Uncomment to run an example:
-// example1_SimpleSetup();
-// example2_QuickSetup();
-// example3_MultiplePlots();
-// example4_FunctionSource();
-// example5_SwappingSources();
-// example6_StreamingData();
-// example7_ConnectionManager();
-//example8_InteractiveDemo();
diff --git a/web-timeplot/src/main.js b/web-timeplot/src/main.js
deleted file mode 100644
index d2b348e..0000000
--- a/web-timeplot/src/main.js
+++ /dev/null
@@ -1 +0,0 @@
-import './bootstrap.js';
diff --git a/web-timeplot/src/metrics.js b/web-timeplot/src/metrics.js
deleted file mode 100644
index fdda10a..0000000
--- a/web-timeplot/src/metrics.js
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * RollingAverage - Maintains a rolling window of values for smooth averaging
- */
-class RollingAverage {
- constructor(capacity) {
- this.values = [];
- this.capacity = capacity;
- this.sum = 0;
- }
-
- push(value) {
- if (this.values.length >= this.capacity) {
- const old = this.values.shift();
- this.sum -= old;
- }
- this.values.push(value);
- this.sum += value;
- }
-
- average() {
- return this.values.length > 0 ? this.sum / this.values.length : 0;
- }
-
- min() {
- return this.values.length > 0 ? Math.min(...this.values) : 0;
- }
-
- max() {
- return this.values.length > 0 ? Math.max(...this.values) : 0;
- }
-}
-
-/**
- * PerformanceMetrics - Tracks and analyzes frame performance
- */
-export class PerformanceMetrics {
- constructor(rollingWindow = 60, historyCapacity = 10000) {
- // Rolling averages
- this.frameTime = new RollingAverage(rollingWindow);
- this.updateTime = new RollingAverage(rollingWindow);
- this.renderTime = new RollingAverage(rollingWindow);
- this.vertexCount = new RollingAverage(rollingWindow);
- this.lineCount = new RollingAverage(rollingWindow);
-
- // History for export
- this.history = [];
- this.historyCapacity = historyCapacity;
-
- // Frame timing
- this.frameStart = 0;
- this.updateStart = 0;
- this.renderStart = 0;
-
- this.totalFrames = 0;
- }
-
- beginFrame() {
- this.frameStart = performance.now();
- }
-
- beginUpdate() {
- this.updateStart = performance.now();
- }
-
- endUpdate() {
- const duration = performance.now() - this.updateStart;
- return duration;
- }
-
- beginRender() {
- this.renderStart = performance.now();
- }
-
- endRender() {
- const duration = performance.now() - this.renderStart;
- return duration;
- }
-
- endFrame(updateMs, renderMs, vertexCount, lineCount) {
- const totalMs = performance.now() - this.frameStart;
-
- // Update rolling averages
- this.frameTime.push(totalMs);
- this.updateTime.push(updateMs);
- this.renderTime.push(renderMs);
- this.vertexCount.push(vertexCount);
- this.lineCount.push(lineCount);
-
- // Store in history
- const record = {
- frame: this.totalFrames,
- totalMs,
- updateMs,
- renderMs,
- vertexCount,
- lineCount,
- fps: totalMs > 0 ? 1000 / totalMs : 0,
- };
-
- if (this.history.length >= this.historyCapacity) {
- this.history.shift();
- }
- this.history.push(record);
-
- this.totalFrames++;
- }
-
- getFPS() {
- const avg = this.frameTime.average();
- return avg > 0 ? 1000 / avg : 0;
- }
-
- getMinFPS() {
- const max = this.frameTime.max();
- return max > 0 ? 1000 / max : 0;
- }
-
- getMaxFPS() {
- const min = this.frameTime.min();
- return min > 0 ? 1000 / min : 0;
- }
-
- formatSummary() {
- return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` +
- `Frame: ${this.frameTime.average().toFixed(2)}ms | ` +
- `Update: ${this.updateTime.average().toFixed(2)}ms | ` +
- `Render: ${this.renderTime.average().toFixed(2)}ms | ` +
- `Vertices: ${Math.round(this.vertexCount.average())} | ` +
- `Lines: ${Math.round(this.lineCount.average())}`;
- }
-
- exportToCSV() {
- let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n';
-
- for (const record of this.history) {
- csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` +
- `${record.vertexCount},${record.lineCount},${record.fps}\n`;
- }
-
- return csv;
- }
-}
diff --git a/web-timeplot/src/plot-connections.js b/web-timeplot/src/plot-connections.js
deleted file mode 100644
index 0e96dd8..0000000
--- a/web-timeplot/src/plot-connections.js
+++ /dev/null
@@ -1,392 +0,0 @@
-/**
- * Plot Connections - Links data sources to visualization plots
- *
- * This module manages the connection between data sources and plots,
- * handling buffering, timing, and data flow.
- *
- * Connection Types:
- * - DirectConnection: Lines from source → plot (no buffering)
- * - BufferedConnection: Points → buffer → lines → plot
- * - SynchronizedConnection: Multiple sources → synchronized output
- */
-
-/**
- * Base connection class
- */
-class PlotConnection {
- constructor(source, plot, config = {}) {
- this.source = source;
- this.plot = plot;
- this.config = config;
- this.isActive = false;
- this.subscriptions = [];
- }
-
- /**
- * Activate the connection - start data flow
- */
- connect() {
- if (this.isActive) return;
- this.isActive = true;
- this.setupSubscriptions();
- this.source.start();
- }
-
- /**
- * Deactivate the connection - stop data flow
- */
- disconnect() {
- if (!this.isActive) return;
- this.isActive = false;
- this.cleanup();
- this.source.stop();
- }
-
- /**
- * Setup event subscriptions (override in subclasses)
- */
- setupSubscriptions() {
- throw new Error('setupSubscriptions() must be implemented by subclass');
- }
-
- /**
- * Cleanup subscriptions
- */
- cleanup() {
- this.subscriptions.forEach(unsub => unsub());
- this.subscriptions = [];
- }
-}
-
-/**
- * Direct connection - passes lines directly from source to plot
- * Use when source emits complete lines of data
- */
-export class DirectConnection extends PlotConnection {
- setupSubscriptions() {
- const unsubLine = this.source.on('line', (data) => {
- this.plot.addLine(data.points, data.metadata);
- });
-
- const unsubError = this.source.on('error', (data) => {
- console.error('[DirectConnection] Source error:', data.error);
- });
-
- this.subscriptions.push(unsubLine, unsubError);
- }
-}
-
-/**
- * Buffered connection - buffers individual points into lines
- * Use when source emits individual data points that need to be assembled
- */
-export class BufferedConnection extends PlotConnection {
- constructor(source, plot, config = {}) {
- super(source, plot, config);
- this.buffer = [];
- this.bufferSize = config.bufferSize || 100;
- this.bufferTimeout = config.bufferTimeout || 1000; // ms
- this.lastFlush = Date.now();
- this.flushHandle = null;
-
- // Start auto-flush timer
- if (config.autoFlush !== false) {
- this.startAutoFlush();
- }
- }
-
- setupSubscriptions() {
- const unsubPoint = this.source.on('point', (data) => {
- this.addToBuffer(data);
- });
-
- const unsubError = this.source.on('error', (data) => {
- console.error('[BufferedConnection] Source error:', data.error);
- });
-
- this.subscriptions.push(unsubPoint, unsubError);
- }
-
- addToBuffer(data) {
- this.buffer.push(data);
-
- // Flush if buffer is full
- if (this.buffer.length >= this.bufferSize) {
- this.flush();
- }
- }
-
- flush() {
- if (this.buffer.length === 0) return;
-
- // Convert buffer to line points
- const points = this.buffer.map((data, idx) => {
- const x = (idx / this.buffer.length) * this.plot.width;
- return { x, y: data.value };
- });
-
- this.plot.addLine(points, {
- timestamp: this.lastFlush,
- pointCount: this.buffer.length,
- });
-
- this.buffer = [];
- this.lastFlush = Date.now();
- }
-
- startAutoFlush() {
- this.flushHandle = setInterval(() => {
- const timeSinceLastFlush = Date.now() - this.lastFlush;
- if (timeSinceLastFlush >= this.bufferTimeout && this.buffer.length > 0) {
- this.flush();
- }
- }, 100); // Check every 100ms
- }
-
- cleanup() {
- super.cleanup();
- if (this.flushHandle) {
- clearInterval(this.flushHandle);
- this.flushHandle = null;
- }
- }
-}
-
-/**
- * Synchronized connection - synchronizes multiple sources to one plot
- * Useful for combining multiple data streams
- */
-export class SynchronizedConnection extends PlotConnection {
- constructor(sources, plot, config = {}) {
- super(null, plot, config); // No single source
- this.sources = sources;
- this.syncMode = config.syncMode || 'wait-for-all'; // 'wait-for-all', 'first-available'
- this.lineBuffers = new Map(); // sourceId => latest line
- }
-
- connect() {
- if (this.isActive) return;
- this.isActive = true;
-
- this.sources.forEach((source, idx) => {
- const unsubLine = source.on('line', (data) => {
- this.handleSourceLine(idx, data);
- });
-
- const unsubError = source.on('error', (data) => {
- console.error(`[SynchronizedConnection] Source ${idx} error:`, data.error);
- });
-
- this.subscriptions.push(unsubLine, unsubError);
- source.start();
- });
- }
-
- disconnect() {
- if (!this.isActive) return;
- this.isActive = false;
- this.cleanup();
- this.sources.forEach(source => source.stop());
- }
-
- handleSourceLine(sourceIdx, data) {
- this.lineBuffers.set(sourceIdx, data);
-
- if (this.syncMode === 'wait-for-all') {
- // Wait until we have data from all sources
- if (this.lineBuffers.size === this.sources.length) {
- this.emitSynchronized();
- }
- } else if (this.syncMode === 'first-available') {
- // Emit immediately
- this.plot.addLine(data.points, {
- ...data.metadata,
- sourceIdx,
- });
- }
- }
-
- emitSynchronized() {
- // For now, just emit the first source's line
- // Could implement more sophisticated merging
- const firstLine = this.lineBuffers.get(0);
- if (firstLine) {
- this.plot.addLine(firstLine.points, firstLine.metadata);
- }
- this.lineBuffers.clear();
- }
-}
-
-/**
- * Connection Manager - manages multiple connections
- */
-export class ConnectionManager {
- constructor() {
- this.connections = new Map(); // connectionId => connection
- this.nextId = 0;
- }
-
- /**
- * Create and register a connection
- * @returns {number} connectionId
- */
- connect(source, plot, config = {}) {
- const type = config.type || 'direct';
- let connection;
-
- switch (type) {
- case 'direct':
- connection = new DirectConnection(source, plot, config);
- break;
- case 'buffered':
- connection = new BufferedConnection(source, plot, config);
- break;
- case 'synchronized':
- connection = new SynchronizedConnection(source, plot, config);
- break;
- default:
- throw new Error(`Unknown connection type: ${type}`);
- }
-
- const id = this.nextId++;
- this.connections.set(id, connection);
- connection.connect();
-
- return id;
- }
-
- /**
- * Disconnect and remove a connection
- */
- disconnect(connectionId) {
- const connection = this.connections.get(connectionId);
- if (connection) {
- connection.disconnect();
- this.connections.delete(connectionId);
- }
- }
-
- /**
- * Disconnect all connections
- */
- disconnectAll() {
- this.connections.forEach(connection => connection.disconnect());
- this.connections.clear();
- }
-
- /**
- * Get statistics about connections
- */
- getStats() {
- return {
- activeConnections: this.connections.size,
- connections: Array.from(this.connections.entries()).map(([id, conn]) => ({
- id,
- isActive: conn.isActive,
- type: conn.constructor.name,
- })),
- };
- }
-}
-
-/**
- * Helper functions for common connection patterns
- */
-
-/**
- * Connect a synthetic data source to a plot
- * @param {DataGenerator} generator - Test data generator instance
- * @param {TimeSeriesPlot} plot - Plot to display data
- * @param {Object} config - Configuration options
- * @returns {DirectConnection} The connection instance
- */
-export function connectSyntheticData(generator, plot, config = {}) {
- const { SyntheticDataSource } = require('./data-sources.js');
-
- const source = new SyntheticDataSource({
- generator,
- pointsPerLine: config.pointsPerLine || 100,
- width: plot.width,
- lineInterval: config.lineInterval || 100,
- });
-
- const connection = new DirectConnection(source, plot, config);
- connection.connect();
-
- return connection;
-}
-
-/**
- * Connect a function-based source to a plot
- * @param {Function} func - Function (x, t) => y
- * @param {TimeSeriesPlot} plot - Plot to display data
- * @param {Object} config - Configuration options
- * @returns {DirectConnection} The connection instance
- */
-export function connectFunction(func, plot, config = {}) {
- const { FunctionDataSource } = require('./data-sources.js');
-
- const source = new FunctionDataSource({
- func,
- pointsPerLine: config.pointsPerLine || 100,
- width: plot.width,
- amplitude: config.amplitude || 30,
- lineInterval: config.lineInterval || 100,
- });
-
- const connection = new DirectConnection(source, plot, config);
- connection.connect();
-
- return connection;
-}
-
-/**
- * Connect a streaming source to a plot with buffering
- * @param {DataGenerator} generator - Test data generator instance
- * @param {TimeSeriesPlot} plot - Plot to display data
- * @param {Object} config - Configuration options
- * @returns {BufferedConnection} The connection instance
- */
-export function connectStreamingData(generator, plot, config = {}) {
- const { StreamingDataSource } = require('./data-sources.js');
-
- const source = new StreamingDataSource({
- generator,
- sampleRate: config.sampleRate || 60,
- });
-
- const connection = new BufferedConnection(source, plot, {
- bufferSize: config.bufferSize || 100,
- bufferTimeout: config.bufferTimeout || 1000,
- });
- connection.connect();
-
- return connection;
-}
-
-/**
- * Quick setup: Create a plot with a data source in one call
- * @param {Application} app - PixiJS application
- * @param {Object} plotConfig - Plot configuration
- * @param {Object} sourceConfig - Source configuration
- * @returns {Object} {plot, source, connection}
- */
-export function createConnectedPlot(app, plotConfig, sourceConfig) {
- const { TimeSeriesPlot } = require('./timeseries-plot.js');
- const { SyntheticDataSource } = require('./data-sources.js');
-
- const plot = new TimeSeriesPlot(plotConfig);
- app.stage.addChild(plot.container);
-
- const source = new SyntheticDataSource({
- generator: sourceConfig.generator,
- pointsPerLine: plotConfig.width / 8, // Default: ~8 pixels per point
- width: plotConfig.width,
- lineInterval: sourceConfig.lineInterval || 100,
- });
-
- const connection = new DirectConnection(source, plot);
- connection.connect();
-
- return { plot, source, connection };
-}
diff --git a/web-timeplot/src/plot/plot-buffer.js b/web-timeplot/src/plot/plot-buffer.js
deleted file mode 100644
index b13cdd8..0000000
--- a/web-timeplot/src/plot/plot-buffer.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export class PlotBuffer {
- constructor(maxPoints = 1600) {
- this.maxPoints = maxPoints;
- this.points = [];
- }
-
- addPoint(point) {
- this.points.push(point);
- if (this.points.length > this.maxPoints) {
- this.points.splice(0, this.points.length - this.maxPoints);
- }
- }
-
- clear() {
- this.points = [];
- }
-
- getVisiblePoints(currentPlotTimeMs, windowDurationMs) {
- const minTime = currentPlotTimeMs - windowDurationMs;
- return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs);
- }
-}
diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js
deleted file mode 100644
index ce90a1f..0000000
--- a/web-timeplot/src/plot/timeplot-view.js
+++ /dev/null
@@ -1,442 +0,0 @@
-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/web-timeplot/src/state.js b/web-timeplot/src/state.js
deleted file mode 100644
index 53d8279..0000000
--- a/web-timeplot/src/state.js
+++ /dev/null
@@ -1,420 +0,0 @@
-/**
- * StateManager - Centralized state management with Proxy-based reactivity
- *
- * Usage:
- * state.time.speed = 2.0 // automatically emits events
- * state.on('time.speed', (value) => console.log('Speed changed:', value))
- * state.on('time.*', (change) => console.log('Time domain changed:', change))
- *
- * State Domains:
- * - userPrefs: showGrid, showMetrics, theme, etc.
- * - uiConfig: active panels, layout, dimensions
- * - time: current time, speed, paused state, real elapsed time
- * - rendering: graphs, renderer info
- * - health: framerate, service connections, db access
- * - dataInput: sources, structure, metadata
- * - inputActions: keyboard/mouse/gamepad action mappings
- */
-
-// Simple EventEmitter implementation
-class EventEmitter {
- constructor() {
- this.events = new Map();
- }
-
- on(event, callback) {
- if (!this.events.has(event)) {
- this.events.set(event, []);
- }
- this.events.get(event).push(callback);
-
- // Return unsubscribe function
- return () => this.off(event, callback);
- }
-
- off(event, callback) {
- if (!this.events.has(event)) return;
- const callbacks = this.events.get(event);
- const index = callbacks.indexOf(callback);
- if (index > -1) {
- callbacks.splice(index, 1);
- }
- }
-
- emit(event, data) {
- if (!this.events.has(event)) return;
- this.events.get(event).forEach(callback => {
- try {
- callback(data);
- } catch (e) {
- console.error(`[State] Error in event handler for '${event}':`, e);
- }
- });
- }
-
- once(event, callback) {
- const wrapper = (data) => {
- callback(data);
- this.off(event, wrapper);
- };
- this.on(event, wrapper);
- }
-
- clear() {
- this.events.clear();
- }
-}
-
-export class StateManager extends EventEmitter {
- constructor() {
- super();
-
- // Internal state storage (not proxied)
- this._state = {
- userPrefs: {
- showGrid: true,
- showMetrics: true,
- theme: 'dark',
- rollingWindow: 60,
- historyCapacity: 10000,
- metricsUpdateInterval: 10,
- },
-
- uiConfig: {
- activePanels: ['graph1', 'graph2'],
- layout: 'horizontal-split',
- canvasWidth: 0,
- canvasHeight: 0,
- },
-
- time: {
- current: 0, // Current plot time
- realElapsed: 0, // Real time elapsed since start
- speed: 1.0, // Time speed multiplier (0.1 to 5.0)
- isPaused: false, // Pause state
- startTimestamp: Date.now(), // Real timestamp when started
- verticalScale: 1.0, // Vertical zoom for time history
- },
-
- rendering: {
- rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas'
- frameCounter: 0,
- // Note: graph instances are NOT stored here to avoid proxy wrapping
- },
-
- health: {
- fps: 0,
- updateMs: 0,
- renderMs: 0,
- vertexCount: 0,
- lineCount: 0,
- serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' }
- },
-
- dataInput: {
- sources: [], // Array of data source configs
- activeSource: null, // Currently active source
- dataStructure: null, // Schema of incoming data
- metadata: {}, // Additional metadata
- },
-
- inputActions: {
- keyboardMap: new Map(), // Map of KeyboardEvent.code => action name
- mouseMap: new Map(), // Map of mouse button => action name
- actionHandlers: new Map(), // Map of action name => handler function
- },
- };
-
- // Track which domains should be persisted
- this._persistedDomains = new Set(['userPrefs']);
-
- // Load persisted state
- this._loadPersistedState();
-
- // Create proxied state - this is what users interact with
- this.state = this._createProxy(this._state, []);
- }
-
- /**
- * Create a reactive Proxy that emits events on property changes
- * @param {Object} target - The object to proxy
- * @param {Array} path - Current property path (e.g., ['time', 'speed'])
- * @private
- */
- _createProxy(target, path) {
- // Don't proxy non-objects or special objects like Map/Set
- if (typeof target !== 'object' || target === null) {
- return target;
- }
-
- // Don't proxy Maps and Sets - they need special handling
- if (target instanceof Map || target instanceof Set) {
- return target;
- }
-
- const self = this;
-
- return new Proxy(target, {
- get(obj, prop) {
- const value = obj[prop];
-
- // Return primitives and functions as-is
- if (typeof value !== 'object' || value === null) {
- return value;
- }
-
- // Return nested objects as proxies
- return self._createProxy(value, [...path, prop]);
- },
-
- set(obj, prop, value) {
- const oldValue = obj[prop];
-
- // Only emit if value actually changed
- if (oldValue === value) {
- return true;
- }
-
- obj[prop] = value;
-
- // Build event path
- const fullPath = [...path, prop];
- const pathString = fullPath.join('.');
- const domain = fullPath[0];
-
- // Emit specific property change: "time.speed"
- self.emit(pathString, {
- path: fullPath,
- value: value,
- oldValue: oldValue,
- });
-
- // Emit domain wildcard: "time.*"
- if (domain) {
- self.emit(`${domain}.*`, {
- path: fullPath,
- property: prop,
- value: value,
- oldValue: oldValue,
- });
- }
-
- // Emit global wildcard: "*"
- self.emit('*', {
- path: fullPath,
- value: value,
- oldValue: oldValue,
- });
-
- // Auto-persist certain domains
- if (self._persistedDomains.has(domain)) {
- self._persistDomain(domain);
- }
-
- return true;
- }
- });
- }
-
- // =========================================================================
- // Persistence
- // =========================================================================
-
- _persistDomain(domain) {
- try {
- const data = this._state[domain];
- // Convert Maps to objects for JSON serialization
- const serializable = this._makeSerializable(data);
- localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable));
- } catch (e) {
- console.warn(`[State] Failed to persist ${domain}:`, e);
- }
- }
-
- _loadPersistedState() {
- this._persistedDomains.forEach(domain => {
- try {
- const saved = localStorage.getItem(`timeplot-${domain}`);
- if (saved) {
- const data = JSON.parse(saved);
- // Deep merge to preserve defaults for new properties
- this._state[domain] = this._deepMerge(this._state[domain], data);
- }
- } catch (e) {
- console.warn(`[State] Failed to load ${domain}:`, e);
- }
- });
- }
-
- _makeSerializable(obj) {
- if (obj instanceof Map) {
- return Object.fromEntries(obj);
- }
- if (obj instanceof Set) {
- return Array.from(obj);
- }
- if (typeof obj === 'object' && obj !== null) {
- const result = {};
- for (const [key, value] of Object.entries(obj)) {
- result[key] = this._makeSerializable(value);
- }
- return result;
- }
- return obj;
- }
-
- _deepMerge(target, source) {
- const result = { ...target };
- for (const key in source) {
- if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
- result[key] = this._deepMerge(target[key] || {}, source[key]);
- } else {
- result[key] = source[key];
- }
- }
- return result;
- }
-
- // =========================================================================
- // Convenience Methods
- // =========================================================================
-
- /**
- * Toggle a boolean preference
- */
- togglePref(key) {
- const current = this.state.userPrefs[key];
- if (typeof current === 'boolean') {
- this.state.userPrefs[key] = !current;
- }
- }
-
- /**
- * Pause/resume time
- */
- togglePause() {
- this.state.time.isPaused = !this.state.time.isPaused;
- }
-
- /**
- * Set time speed (clamped 0.1 to 5.0)
- */
- setTimeSpeed(speed) {
- this.state.time.speed = Math.max(0.1, Math.min(5.0, speed));
- }
-
- /**
- * Increment time (respects pause and speed)
- */
- incrementTime(delta) {
- if (this.state.time.isPaused) return;
- this.state.time.current += delta * this.state.time.speed;
- }
-
- /**
- * Update real elapsed time
- */
- updateRealElapsed() {
- const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000;
- this.state.time.realElapsed = elapsed;
- }
-
- // =========================================================================
- // Input Actions System
- // =========================================================================
-
- /**
- * Register an input action handler
- * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause')
- * @param {Function} handler - Handler function to call
- */
- registerAction(actionName, handler) {
- this.state.inputActions.actionHandlers.set(actionName, handler);
- }
-
- /**
- * Map a keyboard key to an action
- * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space')
- * @param {string} actionName - Action to trigger
- */
- mapKey(code, actionName) {
- this.state.inputActions.keyboardMap.set(code, actionName);
- }
-
- /**
- * Map a mouse button to an action
- * @param {number} button - Mouse button number (0=left, 1=middle, 2=right)
- * @param {string} actionName - Action to trigger
- */
- mapMouseButton(button, actionName) {
- this.state.inputActions.mouseMap.set(button, actionName);
- }
-
- /**
- * Execute an action by name
- */
- executeAction(actionName, event) {
- const handler = this.state.inputActions.actionHandlers.get(actionName);
- if (handler) {
- handler(event);
- } else {
- console.warn(`[State] No handler registered for action: ${actionName}`);
- }
- }
-
- /**
- * Handle keyboard event through action system
- */
- handleKeyboardEvent(event) {
- const actionName = this.state.inputActions.keyboardMap.get(event.code);
- if (actionName) {
- this.executeAction(actionName, event);
- return true;
- }
- return false;
- }
-
- /**
- * Handle mouse button event through action system
- */
- handleMouseButtonEvent(event) {
- const actionName = this.state.inputActions.mouseMap.get(event.button);
- if (actionName) {
- this.executeAction(actionName, event);
- return true;
- }
- return false;
- }
-
- // =========================================================================
- // Data Sources
- // =========================================================================
-
- addDataSource(source) {
- this.state.dataInput.sources.push(source);
- }
-
- removeDataSource(sourceId) {
- const sources = this.state.dataInput.sources;
- const index = sources.findIndex(s => s.id === sourceId);
- if (index > -1) {
- sources.splice(index, 1);
- }
- }
-
- setActiveDataSource(sourceId) {
- this.state.dataInput.activeSource = sourceId;
- }
-
- // =========================================================================
- // Debugging
- // =========================================================================
-
- dump() {
- console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state)));
- }
-
- debugEvents() {
- console.log('[State] Registered events:', Array.from(this.events.keys()));
- }
-}
diff --git a/web-timeplot/src/styles.css b/web-timeplot/src/styles.css
deleted file mode 100644
index 6b0477f..0000000
--- a/web-timeplot/src/styles.css
+++ /dev/null
@@ -1,401 +0,0 @@
-: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/web-timeplot/src/template-for-standard-site.js b/web-timeplot/src/template-for-standard-site.js
deleted file mode 100644
index 54aacc7..0000000
--- a/web-timeplot/src/template-for-standard-site.js
+++ /dev/null
@@ -1,75 +0,0 @@
-//import { setupRenderSystem } from './render.js';
-
-let ENVURL = "" //remote server from which to grab env
-let env = {};
-let cfg = {}; //the user config
-let dom = {
- input: {},
- label: {},
- box: {}, //an info-containing box
- icon: {},
- info: {}
-};
-
-
-//APP START HERE
-$(document).ready(async function() {
- console.log('asdf');
- //the core loop of the client application
- // 1. setup relationship with DOM and grab references to its elements
- log('init DOM');
- await initDOM();
-
- log('init cfg');
- await initCfg();
-
- log('get env vars');
- await getServerEnvVars();
-
- log('init services');
- await initServices();
-
- //setupRenderSystem();
-
-
-});
-
-//gets user config from local storage if there is any
-function initCfg(){
- let localCfg = localStorage.getItem('cfg');
- if (localCfg) {
- try {
- cfg = JSON.parse(localCfg);
- } catch (e) {
- cfg = {};
- }
- } else {
-
- }
-}
-
-async function getServerEnvVars(){
- await axios.get(`${ENVURL}`).then((res)=>{
- env = res.data;
- //log(env);
- }).catch((err)=>{
- //log(err);
- });
- log('')
-}
-
-function initServices(){
- //connect to websocket server
- //grab endpoints from cfg
-}
-
-function initDOM(){
- dom.body = $('body')[0];
-}
-
-function log(msg, lvl=1){
- if (dom.debugInfo){
- dom.debugInfo.innerHTML = msg; //TODO running log + timestamp
- }
- console.log(msg);
-} \ No newline at end of file
diff --git a/web-timeplot/src/test-data-generators.js b/web-timeplot/src/test-data-generators.js
deleted file mode 100644
index 02bc0ad..0000000
--- a/web-timeplot/src/test-data-generators.js
+++ /dev/null
@@ -1,530 +0,0 @@
-/**
- * Test Data Generators - Classes for generating fake/test data patterns
- *
- * These generators produce various types of synthetic data for testing
- * and visualizing the waterfall graphs with realistic patterns.
- */
-
-/**
- * Base class for all data generators
- */
-class DataGenerator {
- constructor(config = {}) {
- this.sampleRate = config.sampleRate || 100; // Samples per second
- this.amplitude = config.amplitude || 1.0;
- this.offset = config.offset || 0.0;
- this.time = 0;
- }
-
- /**
- * Generate a single sample at the current time
- * @returns {number} The generated value
- */
- sample() {
- throw new Error('sample() must be implemented by subclass');
- }
-
- /**
- * Generate an array of samples
- * @param {number} count - Number of samples to generate
- * @returns {Array<number>} Array of generated values
- */
- generateSamples(count) {
- const samples = [];
- for (let i = 0; i < count; i++) {
- samples.push(this.sample());
- this.time += 1 / this.sampleRate;
- }
- return samples;
- }
-
- /**
- * Generate a line of points for waterfall display
- * @param {number} pointCount - Number of points in the line
- * @param {number} width - Width of the display area
- * @returns {Array<{x: number, y: number}>} Array of points
- */
- generateLine(pointCount, width) {
- const points = [];
- const samples = this.generateSamples(pointCount);
-
- for (let i = 0; i < pointCount; i++) {
- const x = (i / pointCount) * width;
- const y = samples[i] * this.amplitude + this.offset;
- points.push({ x, y });
- }
-
- return points;
- }
-
- reset() {
- this.time = 0;
- }
-}
-
-/**
- * Sine Wave Generator - Classic sinusoidal wave
- */
-export class SineWaveGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.frequency = config.frequency || 1.0; // Hz
- this.phase = config.phase || 0.0; // Radians
- }
-
- sample() {
- const value = Math.sin(2 * Math.PI * this.frequency * this.time + this.phase);
- return value;
- }
-}
-
-/**
- * Square Wave Generator - Digital-style square wave
- */
-export class SquareWaveGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.frequency = config.frequency || 1.0;
- this.dutyCycle = config.dutyCycle || 0.5; // 0.0 to 1.0
- }
-
- sample() {
- const period = 1 / this.frequency;
- const phase = (this.time % period) / period;
- return phase < this.dutyCycle ? 1.0 : -1.0;
- }
-}
-
-/**
- * Sawtooth Wave Generator - Linear ramp wave
- */
-export class SawtoothWaveGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.frequency = config.frequency || 1.0;
- }
-
- sample() {
- const period = 1 / this.frequency;
- const phase = (this.time % period) / period;
- return 2 * phase - 1; // -1 to 1
- }
-}
-
-/**
- * Triangle Wave Generator - Linear up/down wave
- */
-export class TriangleWaveGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.frequency = config.frequency || 1.0;
- }
-
- sample() {
- const period = 1 / this.frequency;
- const phase = (this.time % period) / period;
- return phase < 0.5
- ? 4 * phase - 1
- : 3 - 4 * phase;
- }
-}
-
-/**
- * White Noise Generator - Random noise
- */
-export class WhiteNoiseGenerator extends DataGenerator {
- sample() {
- return Math.random() * 2 - 1; // -1 to 1
- }
-}
-
-/**
- * Pink Noise Generator - 1/f noise (more realistic than white noise)
- */
-export class PinkNoiseGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- // Paul Kellet's refined method
- this.b0 = 0;
- this.b1 = 0;
- this.b2 = 0;
- this.b3 = 0;
- this.b4 = 0;
- this.b5 = 0;
- this.b6 = 0;
- }
-
- sample() {
- const white = Math.random() * 2 - 1;
- this.b0 = 0.99886 * this.b0 + white * 0.0555179;
- this.b1 = 0.99332 * this.b1 + white * 0.0750759;
- this.b2 = 0.96900 * this.b2 + white * 0.1538520;
- this.b3 = 0.86650 * this.b3 + white * 0.3104856;
- this.b4 = 0.55000 * this.b4 + white * 0.5329522;
- this.b5 = -0.7616 * this.b5 - white * 0.0168980;
- const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
- this.b6 = white * 0.115926;
- return pink * 0.11; // Normalize
- }
-}
-
-/**
- * Perlin Noise Generator - Smooth, continuous noise
- */
-export class PerlinNoiseGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.frequency = config.frequency || 1.0;
- this.octaves = config.octaves || 4;
- this.persistence = config.persistence || 0.5;
- }
-
- // Simple 1D Perlin-like noise
- noise(x) {
- const i = Math.floor(x);
- const f = x - i;
-
- // Fade curve
- const u = f * f * (3 - 2 * f);
-
- // Hash function for pseudo-random gradients
- const hash = (n) => {
- n = (n << 13) ^ n;
- return (1.0 - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824.0);
- };
-
- return (1 - u) * hash(i) + u * hash(i + 1);
- }
-
- sample() {
- let value = 0;
- let amplitude = 1;
- let frequency = this.frequency;
- let maxValue = 0;
-
- for (let i = 0; i < this.octaves; i++) {
- value += this.noise(this.time * frequency) * amplitude;
- maxValue += amplitude;
- amplitude *= this.persistence;
- frequency *= 2;
- }
-
- return value / maxValue;
- }
-}
-
-/**
- * Pulse/Spike Generator - Random spikes/pulses
- */
-export class PulseGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.pulseRate = config.pulseRate || 0.05; // Probability per sample
- this.pulseWidth = config.pulseWidth || 0.01; // Duration in seconds
- this.pulseAmplitude = config.pulseAmplitude || 1.0;
- this.currentPulse = null;
- }
-
- sample() {
- // Check if we're in a pulse
- if (this.currentPulse) {
- if (this.time >= this.currentPulse.endTime) {
- this.currentPulse = null;
- } else {
- return this.pulseAmplitude;
- }
- }
-
- // Random chance to start new pulse
- if (Math.random() < this.pulseRate) {
- this.currentPulse = {
- startTime: this.time,
- endTime: this.time + this.pulseWidth,
- };
- return this.pulseAmplitude;
- }
-
- return 0;
- }
-}
-
-/**
- * Burst Generator - Bursts of activity with quiet periods
- */
-export class BurstGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.burstDuration = config.burstDuration || 1.0; // Seconds
- this.quietDuration = config.quietDuration || 2.0; // Seconds
- this.burstFrequency = config.burstFrequency || 5.0; // Hz during burst
- this.currentState = 'quiet';
- this.stateStartTime = 0;
- }
-
- sample() {
- const elapsed = this.time - this.stateStartTime;
-
- // State transitions
- if (this.currentState === 'quiet' && elapsed >= this.quietDuration) {
- this.currentState = 'burst';
- this.stateStartTime = this.time;
- } else if (this.currentState === 'burst' && elapsed >= this.burstDuration) {
- this.currentState = 'quiet';
- this.stateStartTime = this.time;
- }
-
- // Generate value based on state
- if (this.currentState === 'burst') {
- return Math.sin(2 * Math.PI * this.burstFrequency * this.time);
- } else {
- return 0;
- }
- }
-}
-
-/**
- * Chirp Generator - Frequency sweep signal
- */
-export class ChirpGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.startFreq = config.startFreq || 0.5; // Hz
- this.endFreq = config.endFreq || 10.0; // Hz
- this.duration = config.duration || 5.0; // Seconds
- }
-
- sample() {
- const t = this.time % this.duration;
- const progress = t / this.duration;
- const freq = this.startFreq + (this.endFreq - this.startFreq) * progress;
- return Math.sin(2 * Math.PI * freq * t);
- }
-}
-
-/**
- * Composite Generator - Combine multiple generators
- */
-export class CompositeGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.generators = config.generators || [];
- this.weights = config.weights || this.generators.map(() => 1.0);
- }
-
- sample() {
- let sum = 0;
- let weightSum = 0;
-
- for (let i = 0; i < this.generators.length; i++) {
- sum += this.generators[i].sample() * this.weights[i];
- weightSum += this.weights[i];
- }
-
- return weightSum > 0 ? sum / weightSum : 0;
- }
-
- generateSamples(count) {
- const samples = [];
- for (let i = 0; i < count; i++) {
- samples.push(this.sample());
- // Advance all child generators
- this.generators.forEach(gen => gen.time += 1 / gen.sampleRate);
- }
- return samples;
- }
-}
-
-/**
- * FM (Frequency Modulation) Generator - One signal modulates another
- */
-export class FMGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.carrierFreq = config.carrierFreq || 5.0; // Hz
- this.modulatorFreq = config.modulatorFreq || 0.5; // Hz
- this.modulationIndex = config.modulationIndex || 2.0;
- }
-
- sample() {
- const modulator = Math.sin(2 * Math.PI * this.modulatorFreq * this.time);
- const instantFreq = this.carrierFreq + this.modulationIndex * modulator;
- return Math.sin(2 * Math.PI * instantFreq * this.time);
- }
-}
-
-/**
- * Exponential Decay Generator - Exponentially decaying signal
- */
-export class ExponentialDecayGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.decayRate = config.decayRate || 1.0; // 1/seconds
- this.oscillationFreq = config.oscillationFreq || 5.0; // Hz
- }
-
- sample() {
- const envelope = Math.exp(-this.decayRate * this.time);
- const oscillation = Math.sin(2 * Math.PI * this.oscillationFreq * this.time);
- return envelope * oscillation;
- }
-}
-
-/**
- * Step Function Generator - Random walk / brownian motion
- */
-export class RandomWalkGenerator extends DataGenerator {
- constructor(config = {}) {
- super(config);
- this.stepSize = config.stepSize || 0.1;
- this.currentValue = 0;
- this.bounds = config.bounds || { min: -5, max: 5 };
- }
-
- sample() {
- // Random step
- const step = (Math.random() - 0.5) * this.stepSize;
- this.currentValue += step;
-
- // Apply bounds
- this.currentValue = Math.max(this.bounds.min, Math.min(this.bounds.max, this.currentValue));
-
- return this.currentValue;
- }
-}
-
-// ============================================================================
-// Example Usage and Presets
-// ============================================================================
-
-/**
- * Factory function to create common test scenarios
- */
-export class TestDataFactory {
- static createSimpleSine(amplitude = 30) {
- return new SineWaveGenerator({
- frequency: 2.0,
- amplitude: amplitude,
- sampleRate: 100,
- });
- }
-
- static createNoisySine(amplitude = 30) {
- const sine = new SineWaveGenerator({
- frequency: 2.0,
- amplitude: amplitude * 0.8,
- sampleRate: 100,
- });
-
- const noise = new WhiteNoiseGenerator({
- amplitude: amplitude * 0.2,
- sampleRate: 100,
- });
-
- return new CompositeGenerator({
- generators: [sine, noise],
- weights: [1.0, 1.0],
- });
- }
-
- static createComplexPattern(amplitude = 30) {
- const low = new SineWaveGenerator({
- frequency: 0.5,
- amplitude: amplitude * 0.4,
- sampleRate: 100,
- });
-
- const mid = new SineWaveGenerator({
- frequency: 3.0,
- amplitude: amplitude * 0.3,
- sampleRate: 100,
- });
-
- const high = new SineWaveGenerator({
- frequency: 8.0,
- amplitude: amplitude * 0.2,
- sampleRate: 100,
- });
-
- const noise = new PinkNoiseGenerator({
- amplitude: amplitude * 0.1,
- sampleRate: 100,
- });
-
- return new CompositeGenerator({
- generators: [low, mid, high, noise],
- weights: [1.0, 1.0, 1.0, 1.0],
- });
- }
-
- static createBurstySignal(amplitude = 30) {
- return new BurstGenerator({
- amplitude: amplitude,
- burstDuration: 0.5,
- quietDuration: 1.5,
- burstFrequency: 10.0,
- sampleRate: 100,
- });
- }
-
- static createSmoothNoise(amplitude = 30) {
- return new PerlinNoiseGenerator({
- amplitude: amplitude,
- frequency: 2.0,
- octaves: 3,
- persistence: 0.5,
- sampleRate: 100,
- });
- }
-
- static createFrequencySweep(amplitude = 30) {
- return new ChirpGenerator({
- amplitude: amplitude,
- startFreq: 0.5,
- endFreq: 10.0,
- duration: 3.0,
- sampleRate: 100,
- });
- }
-
- static createModulatedSignal(amplitude = 30) {
- return new FMGenerator({
- amplitude: amplitude,
- carrierFreq: 5.0,
- modulatorFreq: 0.3,
- modulationIndex: 3.0,
- sampleRate: 100,
- });
- }
-
- static createRandomWalk(amplitude = 30) {
- return new RandomWalkGenerator({
- stepSize: 0.5,
- bounds: { min: -amplitude, max: amplitude },
- sampleRate: 100,
- });
- }
-}
-
-/**
- * Example: How to use with WaterfallGraph
- *
- * // Create a generator
- * const generator = TestDataFactory.createComplexPattern(30);
- *
- * // In your graph's addLine method:
- * addLine(time, graphIdx) {
- * const line = {
- * points: generator.generateLine(this.pointsPerLine, this.width),
- * yOffset: 0,
- * color: this.generateColor(time),
- * };
- * this.lines.push(line);
- * }
- *
- * // Or generate custom samples:
- * const samples = generator.generateSamples(100);
- * const points = samples.map((y, i) => ({
- * x: (i / samples.length) * width,
- * y: y
- * }));
- */
diff --git a/web-timeplot/src/timeseries-plot.js b/web-timeplot/src/timeseries-plot.js
deleted file mode 100644
index e35a704..0000000
--- a/web-timeplot/src/timeseries-plot.js
+++ /dev/null
@@ -1,277 +0,0 @@
-import { Container, Graphics, Text } from 'pixi.js';
-
-/**
- * TimeSeriesPlot - Pure visualization component for time-series data
- *
- * This class is responsible ONLY for displaying data, not generating it.
- * It receives data points from external sources and renders them as a
- * scrolling waterfall display.
- *
- * Architecture:
- * - TimeSeriesPlot: Displays data (this file)
- * - DataSource: Generates/provides data (data-sources.js)
- * - Connection: Links sources to plots
- */
-export class TimeSeriesPlot {
- constructor(config) {
- this.x = config.x || 0;
- this.y = config.y || 0;
- this.width = config.width || 800;
- this.height = config.height || 600;
- this.title = config.title || 'Time Series';
- this.baseColor = config.color || 0xff6666;
-
- // Container for all graphics
- this.container = new Container();
- this.container.x = this.x;
- this.container.y = this.y;
-
- // Graphics layers (order matters for rendering)
- this.gridGraphics = new Graphics();
- this.linesGraphics = new Graphics();
- this.borderGraphics = new Graphics();
-
- this.container.addChild(this.gridGraphics);
- this.container.addChild(this.linesGraphics);
- this.container.addChild(this.borderGraphics);
-
- // Title
- this.titleText = new Text({
- text: this.title,
- style: {
- fontFamily: 'Arial',
- fontSize: 18,
- fill: 0xeeeeee,
- }
- });
- this.titleText.x = 10;
- this.titleText.y = 10;
- this.container.addChild(this.titleText);
-
- // Display state
- this.lines = []; // Array of {points, yOffset, color, metadata}
- this.maxLines = config.maxLines || 100;
- this.showGrid = config.showGrid !== false;
-
- // Scrolling and scaling
- this.scrollSpeed = config.scrollSpeed || 1.0;
- this.verticalScale = config.verticalScale || 1.0;
-
- // Initial draw
- this.draw();
- }
-
- // ========================================================================
- // Data Input API - This is how external sources send data to the plot
- // ========================================================================
-
- /**
- * Add a new line of data to the plot
- * @param {Array<{x: number, y: number}>} points - Array of points
- * @param {Object} metadata - Optional metadata (color, timestamp, etc.)
- */
- addLine(points, metadata = {}) {
- const line = {
- points: points,
- yOffset: 0,
- color: metadata.color || this.generateColor(Date.now() / 1000),
- timestamp: metadata.timestamp || Date.now(),
- metadata: metadata,
- };
-
- this.lines.push(line);
-
- // Limit number of lines
- if (this.lines.length > this.maxLines) {
- this.lines.shift();
- }
- }
-
- /**
- * Add a single data point (will be buffered into a line)
- * This is useful for streaming real-time data
- * @param {number} timestamp - Time of the data point
- * @param {number} value - Value at this time
- */
- addDataPoint(timestamp, value) {
- // For now, this creates a single-point line
- // In a more sophisticated version, this could buffer points
- // until a full line is ready
- const point = { x: this.width / 2, y: value };
- this.addLine([point], { timestamp });
- }
-
- /**
- * Clear all data from the plot
- */
- clearData() {
- this.lines = [];
- this.drawLines();
- }
-
- // ========================================================================
- // Update and Rendering
- // ========================================================================
-
- /**
- * Update the plot - called each frame
- * This handles scrolling and cleanup, but NOT data generation
- */
- update() {
- // Scroll existing lines down
- this.scrollLines();
-
- // Remove off-screen lines
- this.lines = this.lines.filter(line => {
- const scaledOffset = line.yOffset * this.verticalScale;
- return scaledOffset < this.height + 50;
- });
-
- // Redraw
- this.drawLines();
- }
-
- scrollLines() {
- this.lines.forEach(line => {
- line.yOffset += this.scrollSpeed;
- });
- }
-
- draw() {
- this.drawBorder();
- this.drawGrid();
- this.drawLines();
- }
-
- drawBorder() {
- this.borderGraphics.clear();
- this.borderGraphics.rect(0, 0, this.width, this.height);
- this.borderGraphics.stroke({ width: 2, color: 0x606070 });
- }
-
- drawGrid() {
- this.gridGraphics.clear();
-
- if (!this.showGrid) return;
-
- this.gridGraphics.alpha = 0.3;
-
- const divisions = 10;
- const color = 0x4a7a9a;
-
- // Vertical lines
- for (let i = 0; i <= divisions; i++) {
- const x = (i / divisions) * this.width;
- this.gridGraphics.moveTo(x, 0);
- this.gridGraphics.lineTo(x, this.height);
- this.gridGraphics.stroke({ width: 1, color });
- }
-
- // Horizontal lines
- for (let i = 0; i <= divisions; i++) {
- const y = (i / divisions) * this.height;
- this.gridGraphics.moveTo(0, y);
- this.gridGraphics.lineTo(this.width, y);
- this.gridGraphics.stroke({ width: 1, color });
- }
- }
-
- drawLines() {
- this.linesGraphics.clear();
-
- for (const line of this.lines) {
- if (line.points.length < 2) continue;
-
- // Apply vertical scale to y positions
- const scaledYOffset = line.yOffset * this.verticalScale;
-
- // Start path
- const firstPoint = line.points[0];
- this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
-
- // Draw line strip
- for (let i = 1; i < line.points.length; i++) {
- const point = line.points[i];
- this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
- }
-
- this.linesGraphics.stroke({ width: 2, color: line.color });
- }
- }
-
- generateColor(time) {
- // Cycle through colors based on time
- const hue = (time * 0.1) % 1.0;
- const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
- const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
- const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
-
- return (r << 16) | (g << 8) | b;
- }
-
- // ========================================================================
- // Configuration and Control
- // ========================================================================
-
- setGridVisible(visible) {
- this.showGrid = visible;
- this.drawGrid();
- }
-
- setScrollSpeed(speed) {
- this.scrollSpeed = Math.max(0.1, Math.min(10.0, speed));
- }
-
- setVerticalScale(scale) {
- this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
- }
-
- setTitle(title) {
- this.title = title;
- this.titleText.text = title;
- }
-
- resize(x, y, width, height) {
- this.x = x;
- this.y = y;
- this.width = width;
- this.height = height;
-
- this.container.x = x;
- this.container.y = y;
-
- this.draw();
- }
-
- // ========================================================================
- // Statistics and Debugging
- // ========================================================================
-
- getVertexCount() {
- return this.lines.reduce((sum, line) => sum + line.points.length, 0);
- }
-
- getLineCount() {
- return this.lines.length;
- }
-
- getOldestTimestamp() {
- if (this.lines.length === 0) return null;
- return Math.min(...this.lines.map(l => l.timestamp));
- }
-
- getNewestTimestamp() {
- if (this.lines.length === 0) return null;
- return Math.max(...this.lines.map(l => l.timestamp));
- }
-
- getStats() {
- return {
- lineCount: this.getLineCount(),
- vertexCount: this.getVertexCount(),
- oldestTimestamp: this.getOldestTimestamp(),
- newestTimestamp: this.getNewestTimestamp(),
- timeSpan: this.getNewestTimestamp() - this.getOldestTimestamp(),
- };
- }
-}
diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js
deleted file mode 100644
index ad29697..0000000
--- a/web-timeplot/src/ui/panel-manager.js
+++ /dev/null
@@ -1,542 +0,0 @@
-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/web-timeplot/src/utils-format.js b/web-timeplot/src/utils-format.js
deleted file mode 100644
index f4eac88..0000000
--- a/web-timeplot/src/utils-format.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export function formatDuration(ms) {
- const totalSeconds = Math.max(0, ms / 1000);
- if (totalSeconds < 60) {
- return `${totalSeconds.toFixed(2)}s`;
- }
-
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}m ${seconds.toFixed(1)}s`;
-}
-
-export function formatWallClock(timestampMs) {
- return new Intl.DateTimeFormat(undefined, {
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- }).format(new Date(timestampMs));
-}
-
-export function formatValue(value) {
- return Number.isFinite(value) ? value.toFixed(3) : '—';
-}
diff --git a/web-timeplot/src/waterfall.js b/web-timeplot/src/waterfall.js
deleted file mode 100644
index bce0750..0000000
--- a/web-timeplot/src/waterfall.js
+++ /dev/null
@@ -1,219 +0,0 @@
-import { Container, Graphics, Text } from 'pixi.js';
-
-/**
- * WaterfallGraph - A scrolling waterfall display
- * Starts simple with basic line rendering
- */
-export class WaterfallGraph {
- constructor(config) {
- this.x = config.x;
- this.y = config.y;
- this.width = config.width;
- this.height = config.height;
- this.title = config.title;
- this.baseColor = config.color || 0xff6666;
-
- this.container = new Container();
- this.container.x = this.x;
- this.container.y = this.y;
-
- // Graphics layers
- this.borderGraphics = new Graphics();
- this.gridGraphics = new Graphics();
- this.linesGraphics = new Graphics();
-
- this.container.addChild(this.gridGraphics);
- this.container.addChild(this.linesGraphics);
- this.container.addChild(this.borderGraphics);
-
- // Title text
- this.titleText = new Text({
- text: this.title,
- style: {
- fontFamily: 'Arial',
- fontSize: 18,
- fill: 0xeeeeee,
- }
- });
- this.titleText.x = 10;
- this.titleText.y = 10;
- this.container.addChild(this.titleText);
-
- // Waterfall data
- this.lines = [];
- this.maxLines = 50;
- this.pointsPerLine = 100;
- this.frameCounter = 0;
-
- this.showGrid = true;
-
- // Time scaling and zoom
- this.scrollSpeed = 1.0; // Speed multiplier for scrolling
- this.baseScrollSpeed = 1.0;
- this.verticalScale = 1.0; // Vertical zoom: >1 = zoomed in (see less history), <1 = zoomed out (see more)
-
- this.draw();
- }
-
- draw() {
- this.drawBorder();
- this.drawGrid();
- }
-
- drawBorder() {
- this.borderGraphics.clear();
- this.borderGraphics.rect(0, 0, this.width, this.height);
- this.borderGraphics.stroke({ width: 2, color: 0x606070 });
- }
-
- drawGrid() {
- this.gridGraphics.clear();
-
- if (!this.showGrid) return;
-
- this.gridGraphics.alpha = 0.3;
-
- const divisions = 10;
- const color = 0x4a7a9a;
-
- // Vertical lines
- for (let i = 0; i <= divisions; i++) {
- const x = (i / divisions) * this.width;
- this.gridGraphics.moveTo(x, 0);
- this.gridGraphics.lineTo(x, this.height);
- this.gridGraphics.stroke({ width: 1, color });
- }
-
- // Horizontal lines
- for (let i = 0; i <= divisions; i++) {
- const y = (i / divisions) * this.height;
- this.gridGraphics.moveTo(0, y);
- this.gridGraphics.lineTo(this.width, y);
- this.gridGraphics.stroke({ width: 1, color });
- }
- }
-
- update(time, graphIdx) {
- this.frameCounter++;
-
- // Add new line every 10 frames
- if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) {
- this.addLine(time, graphIdx);
- }
-
- // Scroll existing lines down
- this.scrollLines();
-
- // Remove off-screen lines
- this.lines = this.lines.filter(line => line.yOffset < this.height + 50);
-
- // Redraw all lines
- this.drawLines();
- }
-
- addLine(time, graphIdx) {
- const line = {
- points: [],
- yOffset: 0,
- color: this.generateColor(time),
- };
-
- // Generate sine wave points
- const phase = time + (graphIdx * 2);
- const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0;
-
- for (let i = 0; i < this.pointsPerLine; i++) {
- const x = (i / this.pointsPerLine) * this.width;
- const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1
- const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels
-
- line.points.push({ x, y });
- }
-
- this.lines.push(line);
- }
-
- scrollLines() {
- const speed = this.baseScrollSpeed * this.scrollSpeed;
- this.lines.forEach(line => {
- line.yOffset += speed;
- });
- }
-
- setScrollSpeed(speed) {
- // Clamp between 0.1 (slow) and 5.0 (fast)
- this.scrollSpeed = Math.max(0.1, Math.min(5.0, speed));
- }
-
- getScrollSpeed() {
- return this.scrollSpeed;
- }
-
- setVerticalScale(scale) {
- // Clamp between 0.2 (zoomed out, see more history) and 3.0 (zoomed in, see less)
- this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
- }
-
- getVerticalScale() {
- return this.verticalScale;
- }
-
- drawLines() {
- this.linesGraphics.clear();
-
- for (const line of this.lines) {
- if (line.points.length < 2) continue;
-
- // Apply vertical scale to y positions
- // Current time is at top (y=0), older data has larger yOffset
- const scaledYOffset = line.yOffset * this.verticalScale;
-
- // Start path
- const firstPoint = line.points[0];
- this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
-
- // Draw line strip
- for (let i = 1; i < line.points.length; i++) {
- const point = line.points[i];
- this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
- }
-
- this.linesGraphics.stroke({ width: 2, color: line.color });
- }
- }
-
- generateColor(time) {
- // Cycle through colors based on time
- const hue = (time * 0.1) % 1.0;
- const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
- const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
- const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
-
- return (r << 16) | (g << 8) | b;
- }
-
- setGridVisible(visible) {
- this.showGrid = visible;
- this.drawGrid();
- }
-
- resize(x, y, width, height) {
- this.x = x;
- this.y = y;
- this.width = width;
- this.height = height;
-
- this.container.x = x;
- this.container.y = y;
-
- this.draw();
- }
-
- getVertexCount() {
- return this.lines.reduce((sum, line) => sum + line.points.length, 0);
- }
-
- getLineCount() {
- return this.lines.length;
- }
-}