summaryrefslogtreecommitdiff
path: root/web-timeplot
diff options
context:
space:
mode:
Diffstat (limited to 'web-timeplot')
-rw-r--r--web-timeplot/.gitignore1
-rw-r--r--web-timeplot/ARCHITECTURE.md37
-rw-r--r--web-timeplot/README.md81
-rw-r--r--web-timeplot/WEBSOCKET_FORMAT.md117
-rw-r--r--web-timeplot/package-lock.json24
-rw-r--r--web-timeplot/package.json6
-rw-r--r--web-timeplot/public/demo-data/chirp-ramp.csv47
-rw-r--r--web-timeplot/public/demo-data/step-bursts.csv42
-rw-r--r--web-timeplot/public/demo-data/telemetry-sweep.csv42
-rw-r--r--web-timeplot/scripts/demo-websocket-server.mjs131
-rw-r--r--web-timeplot/src/app/create-app.js361
-rw-r--r--web-timeplot/src/core/store.js212
-rw-r--r--web-timeplot/src/data-sources.js517
-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.js85
-rw-r--r--web-timeplot/src/data/synthetic-wave-source.js1
-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/metrics.js142
-rw-r--r--web-timeplot/src/plot-connections.js392
-rw-r--r--web-timeplot/src/plot/timeplot-view.js360
-rw-r--r--web-timeplot/src/state.js420
-rw-r--r--web-timeplot/src/styles.css232
-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.js363
-rw-r--r--web-timeplot/src/waterfall.js219
30 files changed, 6075 insertions, 263 deletions
diff --git a/web-timeplot/.gitignore b/web-timeplot/.gitignore
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/web-timeplot/.gitignore
@@ -0,0 +1 @@
+
diff --git a/web-timeplot/ARCHITECTURE.md b/web-timeplot/ARCHITECTURE.md
index 1cfc3f1..73c4cb6 100644
--- a/web-timeplot/ARCHITECTURE.md
+++ b/web-timeplot/ARCHITECTURE.md
@@ -12,6 +12,8 @@ The restarted TimePlot app is built around five small systems:
The current implementation is intentionally compact, but each system is already separated enough to grow without turning the app into a monolith again.
+Core workspace configuration is also persisted in `localStorage`, so plot settings, routing, and source setup survive reloads without persisting transient runtime state.
+
## Runtime flow
```text
@@ -21,7 +23,7 @@ Store.time updated
SourceRegistry.update(plotTime)
-SyntheticWaveSource emits samples
+Synthetic / CSV replay / WebSocket sources emit samples
PlotBuffer stores bounded history
@@ -80,12 +82,24 @@ The plot is GPU-rendered with PixiJS. Controls, labels, and config panels stay i
hoveredPoint,
tooltip,
},
- source: {
- activeId,
- preset,
- sampleRateHz,
- amplitude,
- noise,
+ sources: {
+ signalA: {
+ type,
+ preset,
+ sampleRateHz,
+ amplitude,
+ noise,
+ replayRate,
+ wsUrl,
+ wsReconnectMs,
+ },
+ signalB: {
+ ...
+ },
+ },
+ graphs: {
+ primary: { sourceKey, transform, title },
+ secondary: { sourceKey, transform, title },
},
panels: {
status,
@@ -119,6 +133,14 @@ Generates sample streams from a preset waveform. Right now it supports:
- `chirp`
- `burst`
+### `src/data/csv-replay-source.js`
+
+Replays uploaded CSV datasets on the shared plot timebase.
+
+### `src/data/websocket-source.js`
+
+Streams live samples from a WebSocket server and reconnects automatically.
+
### `src/plot/plot-buffer.js`
Maintains bounded history so rendering and hover picking only operate on a manageable number of samples.
@@ -147,6 +169,7 @@ The old project had useful ideas but too many concerns were mixed together. The
- data generation is separate from app wiring
- UI is separate from GPU drawing
- state is centralized and observable
+- persisted configuration is separated from transient runtime state
- adding a new source or panel no longer requires rewriting the whole app
## Recommended next steps
diff --git a/web-timeplot/README.md b/web-timeplot/README.md
index 491753a..25dfb80 100644
--- a/web-timeplot/README.md
+++ b/web-timeplot/README.md
@@ -10,20 +10,29 @@ TimePlot is now a clean restart: a small PixiJS time-series sandbox built around
- Current real-time and plot-time labels
- Hover tooltip for data points
- Modular synthetic data input system
+- CSV replay sources
+- WebSocket live sources
+- Persisted workspace settings
- Toggleable side panels for status, source config, app config, and help
## Getting started
```bash
-npm install
-npm run dev
+bun install
+bun run dev
```
Production build:
```bash
-npm run build
-npm run preview
+bun run build
+bun run preview
+```
+
+Demo WebSocket source:
+
+```bash
+bun run ws:demo
```
## Controls
@@ -34,6 +43,55 @@ npm run preview
- `G` — toggle grid
- Hover plot — inspect nearest sample
+## Demo data
+
+Sample CSV replay files are included in [public/demo-data](public/demo-data):
+
+- [public/demo-data/telemetry-sweep.csv](public/demo-data/telemetry-sweep.csv)
+- [public/demo-data/chirp-ramp.csv](public/demo-data/chirp-ramp.csv)
+- [public/demo-data/step-bursts.csv](public/demo-data/step-bursts.csv)
+
+Use the `CSV replay` source type in the sidebar and upload one of those files.
+
+## WebSocket source
+
+TimePlot includes a local demo WebSocket server in [scripts/demo-websocket-server.mjs](scripts/demo-websocket-server.mjs).
+
+Start it with:
+
+```bash
+bun run ws:demo
+```
+
+Then set a signal source to `WebSocket` and use `ws://localhost:8080`.
+
+Optional environment variables:
+
+```bash
+PORT=8090 TIMEPLOT_PROFILE=chirp TIMEPLOT_INTERVAL_MS=50 bun run ws:demo
+```
+
+Supported demo profiles:
+
+- `telemetry`
+- `chirp`
+- `steps`
+- `burst`
+
+Protocol details and accepted message formats are documented in [WEBSOCKET_FORMAT.md](WEBSOCKET_FORMAT.md).
+
+## Persistence
+
+TimePlot persists core workspace settings in `localStorage`, including:
+
+- plot display settings
+- playback speed
+- panel visibility
+- graph routing and transforms
+- source configuration such as presets and WebSocket URLs
+
+CSV replay files themselves are not persisted in storage. After a reload, TimePlot remembers which CSV file was selected but asks you to reload the file data.
+
## Project structure
```text
@@ -46,8 +104,11 @@ src/
│ └── time-controller.js # real time + plot time transport
├── data/
│ ├── base-source.js # source interface
+│ ├── csv-replay-source.js
+│ ├── parse-replay-csv.js
│ ├── source-registry.js # source lifecycle + routing
-│ └── synthetic-wave-source.js
+│ ├── synthetic-wave-source.js
+│ └── websocket-source.js
├── plot/
│ ├── plot-buffer.js # bounded in-memory sample history
│ └── timeplot-view.js # Pixi rendering + hover picking
@@ -57,6 +118,12 @@ src/
├── main.js # compatibility shim to bootstrap
├── styles.css # global UI styling
└── utils-format.js # display formatting helpers
+
+public/
+└── demo-data/ # sample CSV replay fixtures
+
+scripts/
+└── demo-websocket-server.mjs
```
## Design direction
@@ -68,10 +135,12 @@ This restart intentionally optimizes for a strong foundation instead of feature
- the plot owns visualization only
- DOM panels handle controls and diagnostics
- app composition happens in one predictable bootstrap path
+- synthetic, file replay, and WebSocket sources share one source abstraction
+- core workspace configuration survives reloads
## Next good additions
-- real external data sources (WebSocket, REST replay, files)
+- richer external data sources (REST replay, binary streams, custom adapters)
- richer panel layout system with docking/persistence
- plot annotations and multiple stacked plots
- configurable schemas for incoming data types
diff --git a/web-timeplot/WEBSOCKET_FORMAT.md b/web-timeplot/WEBSOCKET_FORMAT.md
new file mode 100644
index 0000000..93eead2
--- /dev/null
+++ b/web-timeplot/WEBSOCKET_FORMAT.md
@@ -0,0 +1,117 @@
+# WebSocket Data Format
+
+TimePlot's WebSocket source accepts UTF-8 text frames whose contents can be parsed into one of the supported payload shapes below.
+
+## Recommended payload
+
+Send one JSON object per message:
+
+```json
+{
+ "timestampMs": 1250,
+ "value": 0.482
+}
+```
+
+Fields:
+
+- `value` — required numeric sample value
+- `timestampMs` — optional numeric source timestamp in milliseconds
+
+If `timestampMs` is present, TimePlot uses it to preserve the source timing relationship and aligns it onto the app's plot timebase.
+If `timestampMs` is omitted, TimePlot stamps the sample at the current plot time when the message arrives.
+
+## Other accepted object keys
+
+TimePlot also accepts these alternate numeric field names:
+
+- value fields: `value`, `y`, `signal`, `data`
+- time fields: `timeMs`, `timestampMs`, `timestamp`, `t`
+
+Examples:
+
+```json
+{"y": 0.91, "t": 2040}
+```
+
+```json
+{"signal": -0.13, "timestamp": 9810}
+```
+
+## Arrays
+
+A single message may contain an array of supported payloads:
+
+```json
+[
+ {"timestampMs": 1000, "value": 0.2},
+ {"timestampMs": 1100, "value": 0.3},
+ {"timestampMs": 1200, "value": 0.5}
+]
+```
+
+This is useful for batching.
+
+## Bare numeric messages
+
+These also work, though JSON objects are preferred:
+
+```text
+0.418
+```
+
+or:
+
+```json
+42.5
+```
+
+These are treated as samples without an explicit timestamp.
+
+## Unsupported / ignored messages
+
+Messages are ignored if TimePlot cannot find a numeric sample value.
+Examples of ignored payloads:
+
+- empty strings
+- non-numeric strings
+- JSON objects without a numeric `value`-like field
+
+## Demo server compatibility
+
+The included demo server sends messages like:
+
+```json
+{
+ "timestampMs": 1870,
+ "value": 0.735812,
+ "sequence": 19,
+ "profile": "telemetry"
+}
+```
+
+Extra fields are safe. TimePlot ignores anything it does not need.
+
+## Running the demo server
+
+```bash
+bun run ws:demo
+```
+
+Environment options:
+
+- `PORT` — default `8080`
+- `TIMEPLOT_PROFILE` — `telemetry`, `chirp`, `steps`, or `burst`
+- `TIMEPLOT_INTERVAL_MS` — message interval in milliseconds
+
+Example:
+
+```bash
+PORT=8090 TIMEPLOT_PROFILE=chirp TIMEPLOT_INTERVAL_MS=50 bun run ws:demo
+```
+
+Then set a signal source type to `WebSocket` and point it at:
+
+```text
+ws://localhost:8090
+```
diff --git a/web-timeplot/package-lock.json b/web-timeplot/package-lock.json
index b0733b1..7ce7bea 100644
--- a/web-timeplot/package-lock.json
+++ b/web-timeplot/package-lock.json
@@ -8,7 +8,8 @@
"name": "web-timeplot",
"version": "0.1.0",
"dependencies": {
- "pixi.js": "^8.0.0"
+ "pixi.js": "^8.0.0",
+ "ws": "^8.20.0"
},
"devDependencies": {
"vite": "^5.0.0"
@@ -1044,6 +1045,27 @@
"optional": true
}
}
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/web-timeplot/package.json b/web-timeplot/package.json
index 65694ba..9f4220f 100644
--- a/web-timeplot/package.json
+++ b/web-timeplot/package.json
@@ -6,12 +6,14 @@
"scripts": {
"dev": "vite --host",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "ws:demo": "node ./scripts/demo-websocket-server.mjs"
},
"devDependencies": {
"vite": "^5.0.0"
},
"dependencies": {
- "pixi.js": "^8.0.0"
+ "pixi.js": "^8.0.0",
+ "ws": "^8.20.0"
}
}
diff --git a/web-timeplot/public/demo-data/chirp-ramp.csv b/web-timeplot/public/demo-data/chirp-ramp.csv
new file mode 100644
index 0000000..5e81c10
--- /dev/null
+++ b/web-timeplot/public/demo-data/chirp-ramp.csv
@@ -0,0 +1,47 @@
+time_ms,value
+0,-0.04
+120,0.05
+240,0.11
+360,0.07
+480,-0.03
+600,-0.17
+720,-0.26
+840,-0.22
+960,-0.04
+1080,0.23
+1200,0.48
+1320,0.57
+1440,0.38
+1560,-0.01
+1680,-0.43
+1800,-0.67
+1920,-0.55
+2040,-0.07
+2160,0.53
+2280,0.89
+2400,0.76
+2520,0.16
+2640,-0.61
+2760,-1.01
+2880,-0.78
+3000,0.02
+3120,0.87
+3240,1.18
+3360,0.75
+3480,-0.21
+3600,-1.04
+3720,-1.21
+3840,-0.44
+3960,0.63
+4080,1.28
+4200,1.05
+4320,0.01
+4440,-1.01
+4560,-1.34
+4680,-0.69
+4800,0.47
+4920,1.31
+5040,1.26
+5160,0.31
+5280,-0.92
+5400,-1.43
diff --git a/web-timeplot/public/demo-data/step-bursts.csv b/web-timeplot/public/demo-data/step-bursts.csv
new file mode 100644
index 0000000..e9dbc3e
--- /dev/null
+++ b/web-timeplot/public/demo-data/step-bursts.csv
@@ -0,0 +1,42 @@
+time_ms,value
+0,0.0
+200,0.0
+400,0.0
+600,0.4
+800,0.8
+1000,1.2
+1200,1.2
+1400,1.2
+1600,0.3
+1800,-0.2
+2000,-0.7
+2200,-1.1
+2400,-1.1
+2600,-0.5
+2800,0.1
+3000,0.6
+3200,1.0
+3400,0.5
+3600,-0.4
+3800,-1.0
+4000,-0.6
+4200,0.2
+4400,0.7
+4600,1.1
+4800,0.9
+5000,0.1
+5200,-0.8
+5400,-1.3
+5600,-0.9
+5800,-0.1
+6000,0.8
+6200,1.4
+6400,1.1
+6600,0.0
+6800,-0.9
+7000,-1.4
+7200,-1.0
+7400,-0.2
+7600,0.5
+7800,0.9
+8000,0.0
diff --git a/web-timeplot/public/demo-data/telemetry-sweep.csv b/web-timeplot/public/demo-data/telemetry-sweep.csv
new file mode 100644
index 0000000..8c7d6e3
--- /dev/null
+++ b/web-timeplot/public/demo-data/telemetry-sweep.csv
@@ -0,0 +1,42 @@
+time_ms,value
+0,0.12
+150,0.18
+300,0.31
+450,0.44
+600,0.52
+750,0.68
+900,0.83
+1050,0.96
+1200,1.04
+1350,1.08
+1500,1.01
+1650,0.92
+1800,0.77
+1950,0.58
+2100,0.34
+2250,0.12
+2400,-0.08
+2550,-0.22
+2700,-0.35
+2850,-0.48
+3000,-0.59
+3150,-0.66
+3300,-0.72
+3450,-0.64
+3600,-0.49
+3750,-0.27
+3900,-0.02
+4050,0.24
+4200,0.46
+4350,0.67
+4500,0.81
+4650,0.9
+4800,0.95
+4950,0.88
+5100,0.75
+5250,0.54
+5400,0.29
+5550,0.03
+5700,-0.2
+5850,-0.37
+6000,-0.48
diff --git a/web-timeplot/scripts/demo-websocket-server.mjs b/web-timeplot/scripts/demo-websocket-server.mjs
new file mode 100644
index 0000000..1bee865
--- /dev/null
+++ b/web-timeplot/scripts/demo-websocket-server.mjs
@@ -0,0 +1,131 @@
+import { WebSocketServer } from 'ws';
+
+const port = Number(process.env.PORT || 8080);
+const profile = process.env.TIMEPLOT_PROFILE || 'telemetry';
+const sendIntervalMs = Number(process.env.TIMEPLOT_INTERVAL_MS || 100);
+const logEvery = Number(process.env.TIMEPLOT_LOG_EVERY || 10);
+
+const wss = new WebSocketServer({ port });
+const startedAt = Date.now();
+let sampleIndex = 0;
+let activeClientCount = 0;
+
+function log(message, details = '') {
+ const timestamp = new Date().toISOString();
+ if (details) {
+ console.log(`[timeplot-ws ${timestamp}] ${message} ${details}`);
+ return;
+ }
+
+ console.log(`[timeplot-ws ${timestamp}] ${message}`);
+}
+
+function sampleTelemetry(seconds) {
+ return Math.sin(seconds * 2.2) + 0.35 * Math.cos(seconds * 6.4 + Math.sin(seconds * 0.8)) + 0.15 * Math.sin(seconds * 0.33);
+}
+
+function sampleChirp(seconds) {
+ return 0.7 * Math.sin(seconds * seconds * 1.4) + 0.3 * Math.sin(seconds * 7.5);
+}
+
+function sampleSteps(seconds) {
+ const phase = Math.floor((seconds % 8) / 1.0);
+ return [0, 0.4, 0.9, 1.2, 0.2, -0.6, -1.0, 0.3][phase] ?? 0;
+}
+
+function sampleBurst(seconds) {
+ const burstPhase = (seconds % 6) - 1.5;
+ const burst = Math.sin(seconds * 9.5) * Math.exp(-(burstPhase ** 2) * 0.8);
+ return 0.45 * Math.sin(seconds * 2.1) + burst;
+}
+
+function sampleValue(seconds) {
+ switch (profile) {
+ case 'chirp':
+ return sampleChirp(seconds);
+ case 'steps':
+ return sampleSteps(seconds);
+ case 'burst':
+ return sampleBurst(seconds);
+ case 'telemetry':
+ default:
+ return sampleTelemetry(seconds);
+ }
+}
+
+function buildMessage() {
+ const timestampMs = Date.now() - startedAt;
+ const seconds = timestampMs / 1000;
+ sampleIndex += 1;
+
+ return {
+ timestampMs,
+ value: Number(sampleValue(seconds).toFixed(6)),
+ sequence: sampleIndex,
+ profile,
+ };
+}
+
+const interval = setInterval(() => {
+ const message = buildMessage();
+ const payload = JSON.stringify(message);
+ let sentCount = 0;
+
+ for (const client of wss.clients) {
+ if (client.readyState === client.OPEN) {
+ client.send(payload);
+ sentCount += 1;
+ }
+ }
+
+ if (message.sequence === 1 || (logEvery > 0 && message.sequence % logEvery === 0)) {
+ log(
+ 'broadcast',
+ `seq=${message.sequence} clients=${sentCount} timestampMs=${message.timestampMs} value=${message.value}`,
+ );
+ }
+}, sendIntervalMs);
+
+wss.on('connection', (socket, request) => {
+ const clientAddress = request.socket.remoteAddress || 'unknown';
+ activeClientCount += 1;
+ log('client connected', `from=${clientAddress} activeClients=${activeClientCount}`);
+
+ socket.send(JSON.stringify({
+ timestampMs: 0,
+ value: 0,
+ sequence: 0,
+ profile,
+ message: 'connected',
+ }));
+
+ socket.on('error', (error) => {
+ log('client error', `from=${clientAddress} error=${error.message}`);
+ });
+
+ socket.on('close', () => {
+ activeClientCount = Math.max(0, activeClientCount - 1);
+ log('client disconnected', `from=${clientAddress} activeClients=${activeClientCount}`);
+ });
+});
+
+wss.on('error', (error) => {
+ log('server error', error.message);
+});
+
+wss.on('listening', () => {
+ log('listening', `url=ws://localhost:${port}`);
+ log('config', `profile=${profile} intervalMs=${sendIntervalMs} logEvery=${logEvery}`);
+});
+
+function shutdown() {
+ log('shutdown requested', `activeClients=${activeClientCount}`);
+ clearInterval(interval);
+ wss.close(() => {
+ log('server stopped');
+ process.exit(0);
+ });
+}
+
+process.on('SIGINT', shutdown);
+process.on('SIGTERM', shutdown);
diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js
index daf3559..4f4f0fc 100644
--- a/web-timeplot/src/app/create-app.js
+++ b/web-timeplot/src/app/create-app.js
@@ -4,25 +4,161 @@ 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 buffer = new PlotBuffer(store.getState().plot.maxPoints);
+ 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();
- buffer.clear();
+ sourceBuffers.forEach((plotBuffer) => plotBuffer.clear());
sourceRegistry.reset();
},
togglePanel: (panelId) => {
@@ -37,15 +173,75 @@ export async function createApp(root) {
},
}));
},
- updateSource: (field, value) => {
+ updateSource: (sourceKey, field, value) => {
store.setState((state) => ({
...state,
- source: {
- ...state.source,
- [field]: value,
+ 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) => ({
@@ -58,39 +254,50 @@ export async function createApp(root) {
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.canvasHost,
- onHover: (hoverState) => {
- store.setState((state) => ({
- ...state,
- plot: {
- ...state.plot,
- hoveredPoint: hoverState?.point ?? null,
- tooltip: hoverState
- ? {
- visible: true,
- x: hoverState.x,
- y: hoverState.y,
- point: hoverState.point,
- }
- : {
- ...state.plot.tooltip,
- visible: false,
- point: null,
- },
- },
- }));
- },
+ 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,
@@ -101,7 +308,7 @@ export async function createApp(root) {
sourceRegistry = new SourceRegistry(store, bus);
bus.on('data:point', (point) => {
- buffer.addPoint(point);
+ sourceBuffers.get(point.sourceId)?.addPoint(point);
});
const keyHandler = (event) => {
@@ -135,18 +342,108 @@ export async function createApp(root) {
plotView.app.ticker.add(() => {
timeController.tick();
sourceRegistry.syncFromState();
+ syncBuffersFromState();
sourceRegistry.update(store.getState().time.plotTimeMs);
const state = store.getState();
- const visiblePoints = buffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs);
- plotView.render(state, visiblePoints);
- panelManager.sync(state, visiblePoints.length);
+ 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/core/store.js b/web-timeplot/src/core/store.js
index 9989e5f..38052eb 100644
--- a/web-timeplot/src/core/store.js
+++ b/web-timeplot/src/core/store.js
@@ -1,7 +1,146 @@
+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: {
@@ -32,12 +171,57 @@ export function createInitialState() {
point: null,
},
},
- source: {
- activeId: 'synthetic-wave',
- preset: 'telemetry',
- sampleRateHz: 60,
- amplitude: 1,
- noise: 0.08,
+ 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 },
@@ -50,7 +234,7 @@ export function createInitialState() {
export class Store {
constructor(initialState = createInitialState()) {
- this.state = initialState;
+ this.state = mergePersistedState(initialState, loadPersistedState());
this.listeners = new Set();
}
@@ -66,6 +250,7 @@ export class Store {
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);
}
@@ -88,7 +273,18 @@ export class Store {
: state.plot.tooltip,
}
: state.plot,
- source: partial.source ? { ...state.source, ...partial.source } : state.source,
+ 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/data-sources.js b/web-timeplot/src/data-sources.js
new file mode 100644
index 0000000..749a151
--- /dev/null
+++ b/web-timeplot/src/data-sources.js
@@ -0,0 +1,517 @@
+/**
+ * Data Sources - Components that generate or provide data to plots
+ *
+ * This module implements the data provider side of the architecture.
+ * Data sources know how to generate or fetch data, but don't know
+ * anything about visualization.
+ *
+ * Architecture:
+ * - DataSource: Base class with event emitting
+ * - Specific sources: Implement different data generation strategies
+ * - Connection: Links sources to plots (see plot-connections.js)
+ */
+
+// Simple EventEmitter (same as in state.js, could be extracted to utils)
+class EventEmitter {
+ constructor() {
+ this.events = new Map();
+ }
+
+ on(event, callback) {
+ if (!this.events.has(event)) {
+ this.events.set(event, []);
+ }
+ this.events.get(event).push(callback);
+ return () => this.off(event, callback);
+ }
+
+ off(event, callback) {
+ if (!this.events.has(event)) return;
+ const callbacks = this.events.get(event);
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+
+ emit(event, data) {
+ if (!this.events.has(event)) return;
+ this.events.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (e) {
+ console.error(`[DataSource] Error in event handler for '${event}':`, e);
+ }
+ });
+ }
+}
+
+/**
+ * Base class for all data sources
+ *
+ * Events emitted:
+ * - 'line': {points: Array, timestamp: number, metadata: Object}
+ * - 'point': {value: number, timestamp: number}
+ * - 'error': {error: Error}
+ */
+export class DataSource extends EventEmitter {
+ constructor(config = {}) {
+ super();
+ this.config = config;
+ this.isRunning = false;
+ this.time = 0;
+ }
+
+ /**
+ * Start generating/providing data
+ */
+ start() {
+ this.isRunning = true;
+ }
+
+ /**
+ * Stop generating/providing data
+ */
+ stop() {
+ this.isRunning = false;
+ }
+
+ /**
+ * Reset the data source to initial state
+ */
+ reset() {
+ this.time = 0;
+ }
+
+ /**
+ * Emit a complete line of data
+ */
+ emitLine(points, metadata = {}) {
+ this.emit('line', {
+ points,
+ timestamp: metadata.timestamp || Date.now(),
+ metadata,
+ });
+ }
+
+ /**
+ * Emit a single data point
+ */
+ emitPoint(value, timestamp = Date.now()) {
+ this.emit('point', {
+ value,
+ timestamp,
+ });
+ }
+
+ /**
+ * Emit an error
+ */
+ emitError(error) {
+ this.emit('error', { error });
+ }
+}
+
+/**
+ * Synthetic data source using test generators
+ * Uses the generators from test-data-generators.js
+ */
+export class SyntheticDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.generator = config.generator; // Instance of DataGenerator
+ this.pointsPerLine = config.pointsPerLine || 100;
+ this.width = config.width || 800;
+ this.lineInterval = config.lineInterval || 100; // ms between lines
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ // Generate a new line periodically
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitLine();
+ }, this.lineInterval);
+
+ // Generate initial line immediately
+ this.generateAndEmitLine();
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitLine() {
+ if (!this.generator) {
+ this.emitError(new Error('No generator configured'));
+ return;
+ }
+
+ const points = this.generator.generateLine(this.pointsPerLine, this.width);
+ this.emitLine(points, {
+ timestamp: Date.now(),
+ generatorType: this.generator.constructor.name,
+ });
+ }
+
+ setGenerator(generator) {
+ this.generator = generator;
+ }
+}
+
+/**
+ * Function-based data source
+ * Evaluates a user-provided function to generate data
+ */
+export class FunctionDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ // Function should have signature: (x, t) => y
+ // x: normalized position 0-1
+ // t: time in seconds
+ // returns: y value
+ this.func = config.func || ((x, t) => Math.sin(x * 10 + t));
+ this.pointsPerLine = config.pointsPerLine || 100;
+ this.width = config.width || 800;
+ this.amplitude = config.amplitude || 30;
+ this.lineInterval = config.lineInterval || 100;
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitLine();
+ }, this.lineInterval);
+
+ this.generateAndEmitLine();
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitLine() {
+ const points = [];
+ const t = this.time;
+
+ for (let i = 0; i < this.pointsPerLine; i++) {
+ const x = (i / this.pointsPerLine) * this.width;
+ const normalizedX = i / this.pointsPerLine;
+ const y = this.func(normalizedX, t) * this.amplitude;
+ points.push({ x, y });
+ }
+
+ this.emitLine(points, {
+ timestamp: Date.now(),
+ time: t,
+ });
+
+ this.time += this.lineInterval / 1000;
+ }
+
+ setFunction(func) {
+ this.func = func;
+ }
+}
+
+/**
+ * Streaming data source
+ * Emits individual data points that get buffered into lines
+ */
+export class StreamingDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.generator = config.generator;
+ this.sampleRate = config.sampleRate || 60; // Samples per second
+ this.intervalHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ const intervalMs = 1000 / this.sampleRate;
+ this.intervalHandle = setInterval(() => {
+ this.generateAndEmitPoint();
+ }, intervalMs);
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ generateAndEmitPoint() {
+ if (!this.generator) {
+ this.emitError(new Error('No generator configured'));
+ return;
+ }
+
+ const value = this.generator.sample();
+ this.generator.time += 1 / this.generator.sampleRate;
+ this.emitPoint(value, Date.now());
+ }
+
+ setGenerator(generator) {
+ this.generator = generator;
+ }
+}
+
+/**
+ * WebSocket data source (for real data)
+ * Receives data from a WebSocket connection
+ */
+export class WebSocketDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.url = config.url;
+ this.socket = null;
+ this.reconnectInterval = config.reconnectInterval || 5000;
+ this.reconnectHandle = null;
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+ this.connect();
+ }
+
+ stop() {
+ super.stop();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ if (this.reconnectHandle) {
+ clearTimeout(this.reconnectHandle);
+ this.reconnectHandle = null;
+ }
+ }
+
+ connect() {
+ try {
+ this.socket = new WebSocket(this.url);
+
+ this.socket.onopen = () => {
+ console.log(`[WebSocketDataSource] Connected to ${this.url}`);
+ };
+
+ this.socket.onmessage = (event) => {
+ this.handleMessage(event.data);
+ };
+
+ this.socket.onerror = (error) => {
+ console.error('[WebSocketDataSource] Error:', error);
+ this.emitError(error);
+ };
+
+ this.socket.onclose = () => {
+ console.log('[WebSocketDataSource] Connection closed');
+ if (this.isRunning) {
+ // Auto-reconnect
+ this.reconnectHandle = setTimeout(() => {
+ this.connect();
+ }, this.reconnectInterval);
+ }
+ };
+ } catch (error) {
+ console.error('[WebSocketDataSource] Failed to connect:', error);
+ this.emitError(error);
+ }
+ }
+
+ handleMessage(data) {
+ try {
+ const parsed = JSON.parse(data);
+
+ // Expect format: {type: 'line', points: [...]} or {type: 'point', value: ...}
+ if (parsed.type === 'line' && parsed.points) {
+ this.emitLine(parsed.points, parsed.metadata || {});
+ } else if (parsed.type === 'point' && parsed.value !== undefined) {
+ this.emitPoint(parsed.value, parsed.timestamp);
+ } else {
+ console.warn('[WebSocketDataSource] Unknown message format:', parsed);
+ }
+ } catch (error) {
+ console.error('[WebSocketDataSource] Failed to parse message:', error);
+ this.emitError(error);
+ }
+ }
+
+ send(data) {
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
+ this.socket.send(JSON.stringify(data));
+ }
+ }
+}
+
+/**
+ * CSV File data source
+ * Reads data from CSV files (for replay/analysis)
+ */
+export class CSVDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.data = []; // Parsed CSV data
+ this.currentIndex = 0;
+ this.playbackRate = config.playbackRate || 1.0;
+ this.loop = config.loop || false;
+ this.intervalHandle = null;
+ }
+
+ /**
+ * Load CSV data from a string
+ * Expected format: timestamp,value or x,y format
+ */
+ loadCSV(csvString) {
+ const lines = csvString.trim().split('\n');
+ const headers = lines[0].split(',').map(h => h.trim());
+
+ this.data = [];
+ for (let i = 1; i < lines.length; i++) {
+ const values = lines[i].split(',').map(v => parseFloat(v.trim()));
+ if (values.length >= 2 && !values.some(isNaN)) {
+ this.data.push({
+ timestamp: values[0],
+ value: values[1],
+ });
+ }
+ }
+
+ console.log(`[CSVDataSource] Loaded ${this.data.length} data points`);
+ }
+
+ start() {
+ if (this.isRunning || this.data.length === 0) return;
+ super.start();
+
+ // Play back at specified rate
+ this.intervalHandle = setInterval(() => {
+ this.emitNextPoint();
+ }, 16 / this.playbackRate); // ~60fps adjusted by playback rate
+ }
+
+ stop() {
+ super.stop();
+ if (this.intervalHandle) {
+ clearInterval(this.intervalHandle);
+ this.intervalHandle = null;
+ }
+ }
+
+ reset() {
+ super.reset();
+ this.currentIndex = 0;
+ }
+
+ emitNextPoint() {
+ if (this.currentIndex >= this.data.length) {
+ if (this.loop) {
+ this.currentIndex = 0;
+ } else {
+ this.stop();
+ return;
+ }
+ }
+
+ const point = this.data[this.currentIndex];
+ this.emitPoint(point.value, point.timestamp);
+ this.currentIndex++;
+ }
+}
+
+/**
+ * Multi-source combiner
+ * Combines data from multiple sources
+ */
+export class CompositeDataSource extends DataSource {
+ constructor(config = {}) {
+ super(config);
+ this.sources = config.sources || [];
+ this.combineMode = config.combineMode || 'average'; // 'average', 'sum', 'max', 'min'
+ this.pointBuffer = new Map(); // sourceId => latest point
+ }
+
+ start() {
+ if (this.isRunning) return;
+ super.start();
+
+ // Subscribe to all sources
+ this.sources.forEach((source, idx) => {
+ source.on('point', (data) => {
+ this.handleSourcePoint(idx, data);
+ });
+ source.on('line', (data) => {
+ this.handleSourceLine(idx, data);
+ });
+ source.start();
+ });
+ }
+
+ stop() {
+ super.stop();
+ this.sources.forEach(source => source.stop());
+ }
+
+ handleSourcePoint(sourceIdx, data) {
+ this.pointBuffer.set(sourceIdx, data.value);
+
+ // If we have data from all sources, combine and emit
+ if (this.pointBuffer.size === this.sources.length) {
+ const combined = this.combineValues(Array.from(this.pointBuffer.values()));
+ this.emitPoint(combined, data.timestamp);
+ }
+ }
+
+ handleSourceLine(sourceIdx, data) {
+ // For lines, just pass through for now
+ // Could implement line combination if needed
+ this.emitLine(data.points, data.metadata);
+ }
+
+ combineValues(values) {
+ switch (this.combineMode) {
+ case 'sum':
+ return values.reduce((a, b) => a + b, 0);
+ case 'average':
+ return values.reduce((a, b) => a + b, 0) / values.length;
+ case 'max':
+ return Math.max(...values);
+ case 'min':
+ return Math.min(...values);
+ default:
+ return values[0];
+ }
+ }
+
+ addSource(source) {
+ this.sources.push(source);
+ if (this.isRunning) {
+ source.start();
+ }
+ }
+
+ removeSource(source) {
+ const idx = this.sources.indexOf(source);
+ if (idx > -1) {
+ source.stop();
+ this.sources.splice(idx, 1);
+ }
+ }
+}
diff --git a/web-timeplot/src/data/csv-replay-source.js b/web-timeplot/src/data/csv-replay-source.js
new file mode 100644
index 0000000..c4e6a66
--- /dev/null
+++ b/web-timeplot/src/data/csv-replay-source.js
@@ -0,0 +1,60 @@
+import { BaseSource } from './base-source.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export class CsvReplaySource extends BaseSource {
+ constructor(config = {}) {
+ super({
+ replayRate: 1,
+ dataset: [],
+ ...config,
+ });
+ this.sourceType = 'csv-replay';
+ this.nextPointIndex = 0;
+ }
+
+ start(startTimeMs = 0) {
+ super.start();
+ this.reset(startTimeMs);
+ }
+
+ reset() {
+ this.nextPointIndex = 0;
+ }
+
+ updateConfig(nextConfig) {
+ const datasetChanged = nextConfig.dataset !== this.config.dataset;
+ super.updateConfig(nextConfig);
+ if (datasetChanged) {
+ this.reset();
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ if (!this.running || !Array.isArray(this.config.dataset) || this.config.dataset.length === 0) {
+ return [];
+ }
+
+ const replayRate = clamp(this.config.replayRate ?? 1, 0.1, 8);
+ const targetDatasetTimeMs = currentPlotTimeMs * replayRate;
+ const points = [];
+
+ while (this.nextPointIndex < this.config.dataset.length) {
+ const datasetPoint = this.config.dataset[this.nextPointIndex];
+ if (datasetPoint.timeMs > targetDatasetTimeMs) {
+ break;
+ }
+
+ points.push({
+ timeMs: datasetPoint.timeMs / replayRate,
+ value: datasetPoint.value,
+ sourceId: this.config.id ?? 'csv-replay',
+ });
+ this.nextPointIndex += 1;
+ }
+
+ return points;
+ }
+}
diff --git a/web-timeplot/src/data/parse-replay-csv.js b/web-timeplot/src/data/parse-replay-csv.js
new file mode 100644
index 0000000..b6ce97a
--- /dev/null
+++ b/web-timeplot/src/data/parse-replay-csv.js
@@ -0,0 +1,108 @@
+function splitRow(line) {
+ return line.split(/[;,\t]/).map((value) => value.trim());
+}
+
+function isNumeric(value) {
+ return value !== '' && Number.isFinite(Number(value));
+}
+
+function detectHeader(rows) {
+ if (rows.length === 0) {
+ return { hasHeader: false, headers: [] };
+ }
+
+ const [firstRow] = rows;
+ const hasHeader = firstRow.some((value) => !isNumeric(value));
+ return {
+ hasHeader,
+ headers: hasHeader ? firstRow.map((value) => value.toLowerCase()) : [],
+ };
+}
+
+function detectTimeScale(headers) {
+ const timeHeader = headers.find((header) => header.includes('time') || header.includes('timestamp'));
+ if (!timeHeader) {
+ return 1;
+ }
+
+ if (timeHeader.includes('sec') && !timeHeader.includes('msec') && !timeHeader.includes('ms')) {
+ return 1000;
+ }
+
+ return 1;
+}
+
+function detectColumnIndexes(headers, columnCount) {
+ if (headers.length === 0) {
+ return {
+ timeIndex: columnCount > 1 ? 0 : -1,
+ valueIndex: columnCount > 1 ? 1 : 0,
+ };
+ }
+
+ const timeIndex = headers.findIndex((header) => header.includes('time') || header.includes('timestamp'));
+ const valueIndex = headers.findIndex((header) => header.includes('value') || header.includes('signal') || header.includes('y'));
+
+ return {
+ timeIndex,
+ valueIndex: valueIndex >= 0 ? valueIndex : (headers.length > 1 ? 1 : 0),
+ };
+}
+
+export function parseReplayCsv(text, { sampleRateHz = 60 } = {}) {
+ const rows = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'))
+ .map(splitRow)
+ .filter((row) => row.some((value) => value !== ''));
+
+ if (rows.length === 0) {
+ throw new Error('CSV file is empty');
+ }
+
+ const { hasHeader, headers } = detectHeader(rows);
+ const dataRows = hasHeader ? rows.slice(1) : rows;
+ const columnCount = rows[0].length;
+ const { timeIndex, valueIndex } = detectColumnIndexes(headers, columnCount);
+ const timeScale = detectTimeScale(headers);
+ const intervalMs = 1000 / Math.max(1, sampleRateHz);
+
+ const points = dataRows
+ .map((row, index) => {
+ const rawValue = row[valueIndex];
+ if (!isNumeric(rawValue)) {
+ return null;
+ }
+
+ const parsedValue = Number(rawValue);
+ const parsedTime = timeIndex >= 0 && isNumeric(row[timeIndex])
+ ? Number(row[timeIndex]) * timeScale
+ : index * intervalMs;
+
+ return {
+ timeMs: parsedTime,
+ value: parsedValue,
+ };
+ })
+ .filter(Boolean)
+ .sort((left, right) => left.timeMs - right.timeMs);
+
+ if (points.length === 0) {
+ throw new Error('CSV file did not contain any numeric data points');
+ }
+
+ const firstTime = points[0].timeMs;
+ const normalizedPoints = points.map((point) => ({
+ timeMs: point.timeMs - firstTime,
+ value: point.value,
+ }));
+
+ return {
+ points: normalizedPoints,
+ metadata: {
+ pointCount: normalizedPoints.length,
+ durationMs: normalizedPoints.at(-1)?.timeMs ?? 0,
+ },
+ };
+}
diff --git a/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js
index 06f5895..917d06b 100644
--- a/web-timeplot/src/data/source-registry.js
+++ b/web-timeplot/src/data/source-registry.js
@@ -1,41 +1,90 @@
+import { CsvReplaySource } from './csv-replay-source.js';
import { SyntheticWaveSource } from './synthetic-wave-source.js';
+import { WebSocketSource } from './websocket-source.js';
export class SourceRegistry {
constructor(store, bus) {
this.store = store;
this.bus = bus;
- this.sources = new Map([
- ['synthetic-wave', new SyntheticWaveSource(store.getState().source)],
- ]);
- this.activeSource = this.sources.get(store.getState().source.activeId);
- this.activeSource.start(store.getState().time.plotTimeMs);
+ this.sources = new Map();
+ this.syncFromState();
}
syncFromState() {
const state = this.store.getState();
- const nextSource = this.sources.get(state.source.activeId);
+ const sourceEntries = Object.entries(state.sources);
+ const activeKeys = new Set(sourceEntries.map(([sourceKey]) => sourceKey));
- if (nextSource !== this.activeSource) {
- this.activeSource?.stop();
- this.activeSource = nextSource;
- this.activeSource?.start(state.time.plotTimeMs);
+ 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);
}
- this.activeSource?.updateConfig(state.source);
+ for (const [sourceKey, source] of this.sources.entries()) {
+ if (!activeKeys.has(sourceKey)) {
+ source.stop();
+ this.sources.delete(sourceKey);
+ }
+ }
}
- update(currentPlotTimeMs) {
- if (!this.activeSource) {
- return;
+ 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);
}
+ }
- const points = this.activeSource.update(currentPlotTimeMs);
- for (const point of points) {
- this.bus.emit('data:point', point);
+ 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() {
- this.activeSource?.reset(this.store.getState().time.plotTimeMs);
+ 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
index 3cf7fb1..df53319 100644
--- a/web-timeplot/src/data/synthetic-wave-source.js
+++ b/web-timeplot/src/data/synthetic-wave-source.js
@@ -18,6 +18,7 @@ export class SyntheticWaveSource extends BaseSource {
noise: 0.08,
...config,
});
+ this.sourceType = 'synthetic-wave';
this.lastEmittedPlotTimeMs = 0;
}
diff --git a/web-timeplot/src/data/websocket-source.js b/web-timeplot/src/data/websocket-source.js
new file mode 100644
index 0000000..5458fb9
--- /dev/null
+++ b/web-timeplot/src/data/websocket-source.js
@@ -0,0 +1,224 @@
+import { BaseSource } from './base-source.js';
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function isFiniteNumber(value) {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+function parsePayload(payload) {
+ if (Array.isArray(payload)) {
+ return payload.flatMap((item) => parsePayload(item));
+ }
+
+ if (isFiniteNumber(payload)) {
+ return [{ value: payload, timestampMs: null }];
+ }
+
+ if (typeof payload === 'string') {
+ const trimmed = payload.trim();
+ if (!trimmed) {
+ return [];
+ }
+
+ const numeric = Number(trimmed);
+ if (Number.isFinite(numeric)) {
+ return [{ value: numeric, timestampMs: null }];
+ }
+
+ try {
+ return parsePayload(JSON.parse(trimmed));
+ } catch {
+ return [];
+ }
+ }
+
+ if (payload && typeof payload === 'object') {
+ const candidateValue = [payload.value, payload.y, payload.signal, payload.data]
+ .find((value) => Number.isFinite(Number(value)));
+
+ if (candidateValue === undefined) {
+ return [];
+ }
+
+ const candidateTimestamp = [payload.timeMs, payload.timestampMs, payload.timestamp, payload.t]
+ .find((value) => Number.isFinite(Number(value)));
+
+ return [{
+ value: Number(candidateValue),
+ timestampMs: candidateTimestamp === undefined ? null : Number(candidateTimestamp),
+ }];
+ }
+
+ return [];
+}
+
+export class WebSocketSource extends BaseSource {
+ constructor(config = {}, { onStatusChange } = {}) {
+ super({
+ wsUrl: 'ws://localhost:8080',
+ wsReconnectMs: 2000,
+ ...config,
+ });
+ this.sourceType = 'websocket';
+ this.onStatusChange = onStatusChange;
+ this.socket = null;
+ this.queue = [];
+ this.lastPlotTimeMs = 0;
+ this.reconnectTimer = null;
+ this.shouldReconnect = false;
+ this.firstSourceTimestampMs = null;
+ this.basePlotTimeMs = 0;
+ }
+
+ start(startTimeMs = 0) {
+ super.start();
+ this.lastPlotTimeMs = startTimeMs;
+ this.basePlotTimeMs = startTimeMs;
+ this.shouldReconnect = true;
+ this.connect();
+ }
+
+ stop() {
+ super.stop();
+ this.shouldReconnect = false;
+ this.clearReconnectTimer();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ this.setStatus('disconnected', 'socket closed');
+ }
+
+ reset(startTimeMs = 0) {
+ this.queue = [];
+ this.lastPlotTimeMs = startTimeMs;
+ this.basePlotTimeMs = startTimeMs;
+ this.firstSourceTimestampMs = null;
+ }
+
+ updateConfig(nextConfig) {
+ const previousUrl = this.config.wsUrl;
+ const previousReconnectMs = this.config.wsReconnectMs;
+ super.updateConfig(nextConfig);
+
+ if ((previousUrl !== this.config.wsUrl || previousReconnectMs !== this.config.wsReconnectMs) && this.running) {
+ this.reconnect();
+ }
+ }
+
+ update(currentPlotTimeMs) {
+ this.lastPlotTimeMs = currentPlotTimeMs;
+
+ if (this.queue.length === 0) {
+ return [];
+ }
+
+ const points = [];
+ while (this.queue.length > 0) {
+ const nextPoint = this.queue.shift();
+ let timeMs = currentPlotTimeMs;
+
+ if (isFiniteNumber(nextPoint.timestampMs)) {
+ if (this.firstSourceTimestampMs === null) {
+ this.firstSourceTimestampMs = nextPoint.timestampMs;
+ this.basePlotTimeMs = currentPlotTimeMs;
+ }
+ timeMs = this.basePlotTimeMs + (nextPoint.timestampMs - this.firstSourceTimestampMs);
+ }
+
+ points.push({
+ timeMs,
+ value: nextPoint.value,
+ sourceId: this.config.id ?? 'websocket',
+ });
+ }
+
+ return points;
+ }
+
+ reconnect() {
+ if (!this.running) {
+ return;
+ }
+
+ this.clearReconnectTimer();
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ this.connect();
+ }
+
+ connect() {
+ const url = this.config.wsUrl?.trim();
+ if (!url) {
+ this.setStatus('idle', 'enter a websocket url');
+ return;
+ }
+
+ this.clearReconnectTimer();
+ this.setStatus('connecting', url);
+
+ try {
+ this.socket = new WebSocket(url);
+ } catch (error) {
+ this.setStatus('error', error instanceof Error ? error.message : String(error));
+ this.scheduleReconnect();
+ return;
+ }
+
+ this.socket.addEventListener('open', () => {
+ this.setStatus('connected', url);
+ });
+
+ this.socket.addEventListener('message', (event) => {
+ const parsedPoints = parsePayload(event.data);
+ if (parsedPoints.length === 0) {
+ return;
+ }
+ this.queue.push(...parsedPoints);
+ });
+
+ this.socket.addEventListener('error', () => {
+ this.setStatus('error', 'socket error');
+ });
+
+ this.socket.addEventListener('close', () => {
+ this.socket = null;
+ if (!this.running) {
+ return;
+ }
+ this.setStatus('disconnected', 'retrying');
+ this.scheduleReconnect();
+ });
+ }
+
+ scheduleReconnect() {
+ if (!this.shouldReconnect || !this.running) {
+ return;
+ }
+
+ const reconnectMs = clamp(Number(this.config.wsReconnectMs) || 2000, 250, 30000);
+ this.clearReconnectTimer();
+ this.reconnectTimer = window.setTimeout(() => {
+ this.connect();
+ }, reconnectMs);
+ }
+
+ clearReconnectTimer() {
+ if (this.reconnectTimer !== null) {
+ window.clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ }
+
+ setStatus(status, detail = '') {
+ this.onStatusChange?.({
+ wsStatus: status,
+ wsStatusDetail: detail,
+ });
+ }
+}
diff --git a/web-timeplot/src/demos.js b/web-timeplot/src/demos.js
new file mode 100644
index 0000000..1dd6785
--- /dev/null
+++ b/web-timeplot/src/demos.js
@@ -0,0 +1,697 @@
+/**
+ * Preloaded Graphics Demos
+ *
+ * Each demo exports:
+ * - name: Display name
+ * - description: Short description
+ * - setup(app, state): Called once to create objects
+ * - update(app, state, objects): Called every frame
+ * - cleanup(app, objects): Called when switching demos
+ */
+
+// ============================================================================
+// DEMO 1: BOUNCING PARTICLES
+// ============================================================================
+
+export const bouncingParticles = {
+ name: "Bouncing Particles",
+ description: "Colorful particles bouncing around the screen",
+
+ setup(app, state) {
+ const particles = [];
+ const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7];
+
+ for (let i = 0; i < 50; i++) {
+ const particle = new PIXI.Graphics();
+ const size = 5 + Math.random() * 10;
+ particle.circle(0, 0, size);
+ particle.fill(colors[Math.floor(Math.random() * colors.length)]);
+
+ particle.x = Math.random() * app.screen.width;
+ particle.y = Math.random() * app.screen.height;
+ particle.vx = (Math.random() - 0.5) * 8;
+ particle.vy = (Math.random() - 0.5) * 8;
+ particle.size = size;
+
+ app.stage.addChild(particle);
+ particles.push(particle);
+ }
+
+ return { particles };
+ },
+
+ update(app, state, objects) {
+ objects.particles.forEach(p => {
+ p.x += p.vx;
+ p.y += p.vy;
+
+ // Bounce off edges
+ if (p.x < p.size || p.x > app.screen.width - p.size) p.vx *= -1;
+ if (p.y < p.size || p.y > app.screen.height - p.size) p.vy *= -1;
+
+ // Clamp to screen
+ p.x = Math.max(p.size, Math.min(app.screen.width - p.size, p.x));
+ p.y = Math.max(p.size, Math.min(app.screen.height - p.size, p.y));
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.particles.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 2: SPIROGRAPH
+// ============================================================================
+
+export const spirograph = {
+ name: "Spirograph",
+ description: "Mesmerizing geometric spiral patterns",
+
+ setup(app, state) {
+ const graphics = new PIXI.Graphics();
+ app.stage.addChild(graphics);
+
+ return {
+ graphics,
+ angle: 0,
+ points: []
+ };
+ },
+
+ update(app, state, objects) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const t = state.state.time.current;
+
+ // Generate new point
+ const r1 = 150;
+ const r2 = 50;
+ const r3 = 30;
+
+ const x = cx + Math.cos(t * 0.5) * r1 + Math.cos(t * 2) * r2 + Math.cos(t * 5) * r3;
+ const y = cy + Math.sin(t * 0.5) * r1 + Math.sin(t * 2) * r2 + Math.sin(t * 5) * r3;
+
+ objects.points.push({ x, y });
+
+ // Keep only last 500 points
+ if (objects.points.length > 500) {
+ objects.points.shift();
+ }
+
+ // Draw trail
+ objects.graphics.clear();
+ if (objects.points.length > 1) {
+ for (let i = 1; i < objects.points.length; i++) {
+ const alpha = i / objects.points.length;
+ const hue = (i / objects.points.length) * 360;
+ objects.graphics.moveTo(objects.points[i-1].x, objects.points[i-1].y);
+ objects.graphics.lineTo(objects.points[i].x, objects.points[i].y);
+ objects.graphics.stroke({ width: 2, color: hslToHex(hue, 100, 60), alpha });
+ }
+ }
+ },
+
+ cleanup(app, objects) {
+ objects.graphics.destroy();
+ }
+};
+
+// ============================================================================
+// DEMO 3: STARFIELD
+// ============================================================================
+
+export const starfield = {
+ name: "Starfield",
+ description: "Flying through space at warp speed",
+
+ setup(app, state) {
+ const stars = [];
+
+ for (let i = 0; i < 200; i++) {
+ const star = new PIXI.Graphics();
+ star.circle(0, 0, 2);
+ star.fill(0xffffff);
+
+ star.x = (Math.random() - 0.5) * app.screen.width * 2;
+ star.y = (Math.random() - 0.5) * app.screen.height * 2;
+ star.z = Math.random() * 1000;
+
+ app.stage.addChild(star);
+ stars.push(star);
+ }
+
+ return { stars };
+ },
+
+ update(app, state, objects) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const speed = 5;
+
+ objects.stars.forEach(star => {
+ star.z -= speed;
+
+ if (star.z <= 0) {
+ star.z = 1000;
+ star.x = (Math.random() - 0.5) * app.screen.width * 2;
+ star.y = (Math.random() - 0.5) * app.screen.height * 2;
+ }
+
+ const screenX = cx + (star.x / star.z) * 200;
+ const screenY = cy + (star.y / star.z) * 200;
+ const size = (1 - star.z / 1000) * 4 + 1;
+
+ star.x = star.x;
+ star.y = star.y;
+ star.position.set(screenX, screenY);
+ star.scale.set(size);
+ star.alpha = 1 - star.z / 1000;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.stars.forEach(s => s.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 4: WAVE INTERFERENCE
+// ============================================================================
+
+export const waveInterference = {
+ name: "Wave Interference",
+ description: "Rippling wave patterns",
+
+ setup(app, state) {
+ const gridSize = 20;
+ const cols = Math.floor(app.screen.width / gridSize);
+ const rows = Math.floor(app.screen.height / gridSize);
+ const circles = [];
+
+ for (let i = 0; i < cols; i++) {
+ for (let j = 0; j < rows; j++) {
+ const circle = new PIXI.Graphics();
+ circle.circle(0, 0, 4);
+ circle.fill(0x4ecdc4);
+ circle.x = i * gridSize + gridSize / 2;
+ circle.y = j * gridSize + gridSize / 2;
+ circle.baseX = circle.x;
+ circle.baseY = circle.y;
+
+ app.stage.addChild(circle);
+ circles.push(circle);
+ }
+ }
+
+ return { circles, sources: [
+ { x: app.screen.width * 0.3, y: app.screen.height * 0.5 },
+ { x: app.screen.width * 0.7, y: app.screen.height * 0.5 }
+ ]};
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.circles.forEach(c => {
+ let totalOffset = 0;
+
+ objects.sources.forEach(source => {
+ const dx = c.baseX - source.x;
+ const dy = c.baseY - source.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ totalOffset += Math.sin(dist * 0.05 - t * 3) * 10;
+ });
+
+ c.y = c.baseY + totalOffset;
+ c.alpha = 0.3 + (Math.sin(totalOffset * 0.1) + 1) * 0.35;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.circles.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 5: CIRCLE PACKING
+// ============================================================================
+
+export const circlePacking = {
+ name: "Circle Packing",
+ description: "Organic growth simulation",
+
+ setup(app, state) {
+ const circles = [];
+ return { circles, attempts: 0 };
+ },
+
+ update(app, state, objects) {
+ // Try to add a new circle each frame
+ const maxAttempts = 100;
+ const maxCircles = 150;
+
+ if (objects.circles.length >= maxCircles) return;
+
+ for (let i = 0; i < 10; i++) {
+ const x = Math.random() * app.screen.width;
+ const y = Math.random() * app.screen.height;
+ const minRadius = 5;
+ const maxRadius = 60;
+
+ let valid = true;
+ let radius = minRadius;
+
+ // Find largest radius that doesn't overlap
+ for (let r = minRadius; r < maxRadius; r++) {
+ let overlaps = false;
+
+ for (const other of objects.circles) {
+ const dx = x - other.x;
+ const dy = y - other.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < r + other.radius + 2) {
+ overlaps = true;
+ break;
+ }
+ }
+
+ if (overlaps) {
+ break;
+ }
+ radius = r;
+ }
+
+ if (radius > minRadius) {
+ const circle = new PIXI.Graphics();
+ circle.circle(0, 0, radius);
+ const hue = (objects.circles.length * 137.5) % 360;
+ circle.fill(hslToHex(hue, 70, 60));
+ circle.x = x;
+ circle.y = y;
+ circle.radius = radius;
+
+ app.stage.addChild(circle);
+ objects.circles.push(circle);
+ break;
+ }
+ }
+ },
+
+ cleanup(app, objects) {
+ objects.circles.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 6: PERLIN FLOW FIELD
+// ============================================================================
+
+export const flowField = {
+ name: "Flow Field",
+ description: "Particles following a noise field",
+
+ setup(app, state) {
+ const particles = [];
+ const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7, 0xfeca57];
+
+ for (let i = 0; i < 300; i++) {
+ const particle = new PIXI.Graphics();
+ particle.circle(0, 0, 2);
+ particle.fill(colors[Math.floor(Math.random() * colors.length)]);
+ particle.alpha = 0.6;
+
+ particle.x = Math.random() * app.screen.width;
+ particle.y = Math.random() * app.screen.height;
+ particle.vx = 0;
+ particle.vy = 0;
+ particle.color = colors[Math.floor(Math.random() * colors.length)];
+
+ app.stage.addChild(particle);
+ particles.push(particle);
+ }
+
+ return { particles };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.particles.forEach(p => {
+ // Simple noise-like function using sin/cos
+ const angle = noise(p.x * 0.005, p.y * 0.005, t * 0.3) * Math.PI * 2;
+
+ p.vx += Math.cos(angle) * 0.3;
+ p.vy += Math.sin(angle) * 0.3;
+
+ // Damping
+ p.vx *= 0.95;
+ p.vy *= 0.95;
+
+ p.x += p.vx;
+ p.y += p.vy;
+
+ // Wrap around screen
+ if (p.x < 0) p.x = app.screen.width;
+ if (p.x > app.screen.width) p.x = 0;
+ if (p.y < 0) p.y = app.screen.height;
+ if (p.y > app.screen.height) p.y = 0;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.particles.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 7: DNA HELIX
+// ============================================================================
+
+export const dnaHelix = {
+ name: "DNA Helix",
+ description: "Rotating double helix structure",
+
+ setup(app, state) {
+ const helix1 = [];
+ const helix2 = [];
+ const connectors = [];
+ const segments = 40;
+
+ for (let i = 0; i < segments; i++) {
+ const sphere1 = new PIXI.Graphics();
+ sphere1.circle(0, 0, 8);
+ sphere1.fill(0x4ecdc4);
+ app.stage.addChild(sphere1);
+ helix1.push(sphere1);
+
+ const sphere2 = new PIXI.Graphics();
+ sphere2.circle(0, 0, 8);
+ sphere2.fill(0xff6b6b);
+ app.stage.addChild(sphere2);
+ helix2.push(sphere2);
+
+ const connector = new PIXI.Graphics();
+ app.stage.addChild(connector);
+ connectors.push(connector);
+ }
+
+ return { helix1, helix2, connectors };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+ const radius = 100;
+ const height = app.screen.height * 0.8;
+ const spacing = height / objects.helix1.length;
+
+ objects.helix1.forEach((sphere, i) => {
+ const y = i * spacing - height / 2 + cy;
+ const angle = t + i * 0.3;
+ const x = cx + Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+
+ sphere.x = x;
+ sphere.y = y;
+ sphere.scale.set(1 + z / 200);
+ sphere.alpha = 0.5 + z / 400;
+ });
+
+ objects.helix2.forEach((sphere, i) => {
+ const y = i * spacing - height / 2 + cy;
+ const angle = t + i * 0.3 + Math.PI;
+ const x = cx + Math.cos(angle) * radius;
+ const z = Math.sin(angle) * radius;
+
+ sphere.x = x;
+ sphere.y = y;
+ sphere.scale.set(1 + z / 200);
+ sphere.alpha = 0.5 + z / 400;
+ });
+
+ // Draw connectors
+ objects.connectors.forEach((connector, i) => {
+ connector.clear();
+ connector.moveTo(objects.helix1[i].x, objects.helix1[i].y);
+ connector.lineTo(objects.helix2[i].x, objects.helix2[i].y);
+ connector.stroke({ width: 2, color: 0x666666, alpha: 0.3 });
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.helix1.forEach(s => s.destroy());
+ objects.helix2.forEach(s => s.destroy());
+ objects.connectors.forEach(c => c.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 8: FIREWORKS
+// ============================================================================
+
+export const fireworks = {
+ name: "Fireworks",
+ description: "Explosive particle celebration",
+
+ setup(app, state) {
+ return {
+ explosions: [],
+ nextExplosion: 0
+ };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ // Create new explosion every second
+ if (t > objects.nextExplosion) {
+ objects.nextExplosion = t + 0.5 + Math.random();
+
+ const explosion = {
+ x: Math.random() * app.screen.width,
+ y: Math.random() * app.screen.height * 0.7,
+ particles: [],
+ color: Math.random() * 0xffffff,
+ born: t
+ };
+
+ // Create particles
+ for (let i = 0; i < 50; i++) {
+ const angle = (i / 50) * Math.PI * 2;
+ const speed = 2 + Math.random() * 4;
+ const particle = new PIXI.Graphics();
+ particle.circle(0, 0, 3);
+ particle.fill(explosion.color);
+ particle.x = explosion.x;
+ particle.y = explosion.y;
+ particle.vx = Math.cos(angle) * speed;
+ particle.vy = Math.sin(angle) * speed;
+
+ app.stage.addChild(particle);
+ explosion.particles.push(particle);
+ }
+
+ objects.explosions.push(explosion);
+ }
+
+ // Update explosions
+ objects.explosions = objects.explosions.filter(explosion => {
+ const age = t - explosion.born;
+
+ if (age > 3) {
+ explosion.particles.forEach(p => p.destroy());
+ return false;
+ }
+
+ explosion.particles.forEach(p => {
+ p.vx *= 0.98;
+ p.vy += 0.1; // Gravity
+ p.x += p.vx;
+ p.y += p.vy;
+ p.alpha = 1 - age / 3;
+ });
+
+ return true;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.explosions.forEach(explosion => {
+ explosion.particles.forEach(p => p.destroy());
+ });
+ }
+};
+
+// ============================================================================
+// DEMO 9: MATRIX RAIN
+// ============================================================================
+
+export const matrixRain = {
+ name: "Matrix Rain",
+ description: "Falling digital rain effect",
+
+ setup(app, state) {
+ const fontSize = 16;
+ const columns = Math.floor(app.screen.width / fontSize);
+ const drops = [];
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*";
+
+ for (let i = 0; i < columns; i++) {
+ const text = new PIXI.Text('', {
+ fontFamily: 'monospace',
+ fontSize: fontSize,
+ fill: 0x00ff00
+ });
+ text.x = i * fontSize;
+ text.y = -Math.random() * app.screen.height;
+
+ app.stage.addChild(text);
+ drops.push({
+ text,
+ speed: 1 + Math.random() * 3,
+ chars: chars
+ });
+ }
+
+ return { drops };
+ },
+
+ update(app, state, objects) {
+ objects.drops.forEach(drop => {
+ drop.y = (drop.y || drop.text.y) + drop.speed;
+ drop.text.y = drop.y;
+
+ // Random character
+ if (Math.random() > 0.95) {
+ drop.text.text = drop.chars[Math.floor(Math.random() * drop.chars.length)];
+ }
+
+ // Reset to top
+ if (drop.y > app.screen.height) {
+ drop.y = -20;
+ drop.text.alpha = 1;
+ }
+
+ // Fade trail
+ drop.text.alpha = Math.max(0.1, drop.text.alpha - 0.01);
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.drops.forEach(d => d.text.destroy());
+ }
+};
+
+// ============================================================================
+// DEMO 10: SOLAR SYSTEM
+// ============================================================================
+
+export const solarSystem = {
+ name: "Solar System",
+ description: "Orbiting planets around a star",
+
+ setup(app, state) {
+ const cx = app.screen.width / 2;
+ const cy = app.screen.height / 2;
+
+ // Sun
+ const sun = new PIXI.Graphics();
+ sun.circle(0, 0, 30);
+ sun.fill(0xffd700);
+ sun.x = cx;
+ sun.y = cy;
+ app.stage.addChild(sun);
+
+ // Planets
+ const planets = [
+ { radius: 60, size: 6, speed: 2.0, color: 0x8b7355 },
+ { radius: 100, size: 10, speed: 1.5, color: 0xff6347 },
+ { radius: 150, size: 12, speed: 1.0, color: 0x4169e1 },
+ { radius: 200, size: 8, speed: 0.7, color: 0xff4500 },
+ { radius: 260, size: 18, speed: 0.4, color: 0xdaa520 },
+ ];
+
+ const planetObjects = planets.map(config => {
+ const planet = new PIXI.Graphics();
+ planet.circle(0, 0, config.size);
+ planet.fill(config.color);
+ planet.config = config;
+ app.stage.addChild(planet);
+ return planet;
+ });
+
+ return { sun, planets: planetObjects, cx, cy };
+ },
+
+ update(app, state, objects) {
+ const t = state.state.time.current;
+
+ objects.planets.forEach((planet, i) => {
+ const angle = t * planet.config.speed;
+ planet.x = objects.cx + Math.cos(angle) * planet.config.radius;
+ planet.y = objects.cy + Math.sin(angle) * planet.config.radius;
+ });
+ },
+
+ cleanup(app, objects) {
+ objects.sun.destroy();
+ objects.planets.forEach(p => p.destroy());
+ }
+};
+
+// ============================================================================
+// UTILITIES
+// ============================================================================
+
+function hslToHex(h, s, l) {
+ s /= 100;
+ l /= 100;
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1));
+ const m = l - c/2;
+ let r = 0, g = 0, b = 0;
+
+ if (0 <= h && h < 60) {
+ r = c; g = x; b = 0;
+ } else if (60 <= h && h < 120) {
+ r = x; g = c; b = 0;
+ } else if (120 <= h && h < 180) {
+ r = 0; g = c; b = x;
+ } else if (180 <= h && h < 240) {
+ r = 0; g = x; b = c;
+ } else if (240 <= h && h < 300) {
+ r = x; g = 0; b = c;
+ } else if (300 <= h && h < 360) {
+ r = c; g = 0; b = x;
+ }
+
+ r = Math.round((r + m) * 255);
+ g = Math.round((g + m) * 255);
+ b = Math.round((b + m) * 255);
+
+ return (r << 16) | (g << 8) | b;
+}
+
+function noise(x, y, z) {
+ return Math.sin(x + Math.cos(y)) * Math.cos(y + Math.sin(z)) * Math.sin(z + Math.cos(x));
+}
+
+// ============================================================================
+// EXPORT ALL DEMOS
+// ============================================================================
+
+export const allDemos = [
+ bouncingParticles,
+ spirograph,
+ starfield,
+ waveInterference,
+ circlePacking,
+ flowField,
+ dnaHelix,
+ fireworks,
+ matrixRain,
+ solarSystem
+];
diff --git a/web-timeplot/src/example-usage.js b/web-timeplot/src/example-usage.js
new file mode 100644
index 0000000..67eff4b
--- /dev/null
+++ b/web-timeplot/src/example-usage.js
@@ -0,0 +1,535 @@
+/**
+ * Example Usage: Complete examples of the new architecture
+ *
+ * This file demonstrates how to use the separated data/visualization architecture:
+ * - TimeSeriesPlot: Pure visualization
+ * - DataSource: Data generation/provision
+ * - Connections: Links between them
+ */
+
+import { Application } from 'pixi.js';
+import { TimeSeriesPlot } from './timeseries-plot.js';
+import {
+ SyntheticDataSource,
+ FunctionDataSource,
+ StreamingDataSource,
+ WebSocketDataSource,
+} from './data-sources.js';
+import {
+ DirectConnection,
+ BufferedConnection,
+ ConnectionManager,
+ connectSyntheticData,
+ connectFunction,
+ createConnectedPlot,
+} from './plot-connections.js';
+import {
+ TestDataFactory,
+ SineWaveGenerator,
+ PerlinNoiseGenerator,
+ ChirpGenerator,
+} from './test-data-generators.js';
+
+// ============================================================================
+// Example 1: Simple Setup - One plot, one data source
+// ============================================================================
+
+export async function example1_SimpleSetup() {
+ console.log('=== Example 1: Simple Setup ===');
+
+ // Create PixiJS app
+ const app = new Application();
+ await app.init({
+ width: 800,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // Create plot (visualization only)
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Simple Sine Wave',
+ showGrid: true,
+ });
+ app.stage.addChild(plot.container);
+
+ // Create data source
+ const generator = TestDataFactory.createSimpleSine(30);
+ const source = new SyntheticDataSource({
+ generator: generator,
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100, // New line every 100ms
+ });
+
+ // Connect source to plot
+ const connection = new DirectConnection(source, plot);
+ connection.connect();
+
+ // Update plot every frame
+ app.ticker.add(() => {
+ plot.update();
+ });
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 2: Quick Setup Using Helper Functions
+// ============================================================================
+
+export async function example2_QuickSetup() {
+ console.log('=== Example 2: Quick Setup ===');
+
+ const app = new Application();
+ await app.init({
+ width: 800,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // One-liner setup!
+ const { plot, source, connection } = createConnectedPlot(
+ app,
+ {
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Quick Setup',
+ },
+ {
+ generator: TestDataFactory.createComplexPattern(30),
+ lineInterval: 100,
+ }
+ );
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 3: Multiple Plots with Different Data Sources
+// ============================================================================
+
+export async function example3_MultiplePlots() {
+ console.log('=== Example 3: Multiple Plots ===');
+
+ const app = new Application();
+ await app.init({
+ width: 1600,
+ height: 600,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ const width = 800;
+ const height = 600;
+
+ // Left plot: Sine wave
+ const plot1 = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: width,
+ height: height,
+ title: 'Sine Wave',
+ color: 0xff6666,
+ });
+
+ // Right plot: Perlin noise
+ const plot2 = new TimeSeriesPlot({
+ x: width,
+ y: 0,
+ width: width,
+ height: height,
+ title: 'Perlin Noise',
+ color: 0x66ff66,
+ });
+
+ app.stage.addChild(plot1.container);
+ app.stage.addChild(plot2.container);
+
+ // Connect different data sources
+ const conn1 = connectSyntheticData(
+ TestDataFactory.createSimpleSine(30),
+ plot1,
+ { lineInterval: 100 }
+ );
+
+ const conn2 = connectSyntheticData(
+ TestDataFactory.createSmoothNoise(30),
+ plot2,
+ { lineInterval: 100 }
+ );
+
+ app.ticker.add(() => {
+ plot1.update();
+ plot2.update();
+ });
+
+ return { app, plots: [plot1, plot2], connections: [conn1, conn2] };
+}
+
+// ============================================================================
+// Example 4: Using Function-Based Data Source
+// ============================================================================
+
+export async function example4_FunctionSource() {
+ console.log('=== Example 4: Function Source ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Custom Function',
+ });
+ app.stage.addChild(plot.container);
+
+ // Define a custom function: (x, t) => y
+ // x is normalized 0-1 across the width
+ // t is time in seconds
+ const customFunc = (x, t) => {
+ // Create an interference pattern
+ const wave1 = Math.sin(x * 10 + t * 2);
+ const wave2 = Math.sin(x * 15 - t * 3);
+ const wave3 = Math.cos(x * 8 + t * 1.5);
+ return (wave1 + wave2 + wave3) / 3;
+ };
+
+ const connection = connectFunction(customFunc, plot, {
+ lineInterval: 100,
+ amplitude: 30,
+ });
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, connection };
+}
+
+// ============================================================================
+// Example 5: Swapping Data Sources at Runtime
+// ============================================================================
+
+export async function example5_SwappingSources() {
+ console.log('=== Example 5: Swapping Sources ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Dynamic Source Switching',
+ });
+ app.stage.addChild(plot.container);
+
+ // Start with sine wave
+ let currentConnection = connectSyntheticData(
+ TestDataFactory.createSimpleSine(30),
+ plot,
+ { lineInterval: 100 }
+ );
+
+ app.ticker.add(() => plot.update());
+
+ // Function to switch to a different data source
+ const switchToSource = (generator, title) => {
+ // Disconnect current source
+ currentConnection.disconnect();
+
+ // Connect new source
+ currentConnection = connectSyntheticData(generator, plot, {
+ lineInterval: 100,
+ });
+
+ plot.setTitle(title);
+ console.log(`Switched to: ${title}`);
+ };
+
+ // Example: Switch sources every 5 seconds
+ let sourceIndex = 0;
+ const sources = [
+ { gen: TestDataFactory.createSimpleSine(30), title: 'Sine Wave' },
+ { gen: TestDataFactory.createComplexPattern(30), title: 'Complex Pattern' },
+ { gen: TestDataFactory.createSmoothNoise(30), title: 'Perlin Noise' },
+ { gen: TestDataFactory.createFrequencySweep(30), title: 'Frequency Sweep' },
+ ];
+
+ setInterval(() => {
+ sourceIndex = (sourceIndex + 1) % sources.length;
+ const source = sources[sourceIndex];
+ switchToSource(source.gen, source.title);
+ }, 5000);
+
+ return { app, plot, switchToSource };
+}
+
+// ============================================================================
+// Example 6: Streaming Data with Buffering
+// ============================================================================
+
+export async function example6_StreamingData() {
+ console.log('=== Example 6: Streaming Data ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Streaming Data (Buffered)',
+ });
+ app.stage.addChild(plot.container);
+
+ // Create streaming source (emits individual points)
+ const generator = new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: 1.0,
+ sampleRate: 60,
+ });
+
+ const source = new StreamingDataSource({
+ generator: generator,
+ sampleRate: 60, // 60 points per second
+ });
+
+ // Use buffered connection to assemble points into lines
+ const connection = new BufferedConnection(source, plot, {
+ bufferSize: 100, // Buffer 100 points before creating a line
+ bufferTimeout: 1000, // Or timeout after 1 second
+ });
+ connection.connect();
+
+ app.ticker.add(() => plot.update());
+
+ return { app, plot, source, connection };
+}
+
+// ============================================================================
+// Example 7: Connection Manager (Managing Multiple Connections)
+// ============================================================================
+
+export async function example7_ConnectionManager() {
+ console.log('=== Example 7: Connection Manager ===');
+
+ const app = new Application();
+ await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 });
+ document.body.appendChild(app.canvas);
+
+ const plot = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 600,
+ title: 'Managed Connections',
+ });
+ app.stage.addChild(plot.container);
+
+ // Create connection manager
+ const manager = new ConnectionManager();
+
+ // Add first connection
+ const source1 = new SyntheticDataSource({
+ generator: TestDataFactory.createSimpleSine(30),
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100,
+ });
+
+ const connId1 = manager.connect(source1, plot, { type: 'direct' });
+ console.log('Connection ID:', connId1);
+
+ app.ticker.add(() => plot.update());
+
+ // Later: disconnect and switch to different source
+ setTimeout(() => {
+ manager.disconnect(connId1);
+
+ const source2 = new SyntheticDataSource({
+ generator: TestDataFactory.createFrequencySweep(30),
+ pointsPerLine: 100,
+ width: 800,
+ lineInterval: 100,
+ });
+
+ const connId2 = manager.connect(source2, plot, { type: 'direct' });
+ plot.setTitle('Frequency Sweep');
+ console.log('Switched to connection:', connId2);
+ }, 5000);
+
+ return { app, plot, manager };
+}
+
+// ============================================================================
+// Example 8: Complete Interactive Demo
+// ============================================================================
+
+export async function example8_InteractiveDemo() {
+ console.log('=== Example 8: Interactive Demo ===');
+
+ const app = new Application();
+ await app.init({
+ width: 1600,
+ height: 800,
+ backgroundColor: 0x1a1a26,
+ });
+ document.body.appendChild(app.canvas);
+
+ // Create two plots
+ const plot1 = new TimeSeriesPlot({
+ x: 0,
+ y: 0,
+ width: 800,
+ height: 800,
+ title: 'Plot 1 - Press 1-5 to change',
+ color: 0xff6666,
+ });
+
+ const plot2 = new TimeSeriesPlot({
+ x: 800,
+ y: 0,
+ width: 800,
+ height: 800,
+ title: 'Plot 2 - Press 6-0 to change',
+ color: 0x66ff66,
+ });
+
+ app.stage.addChild(plot1.container);
+ app.stage.addChild(plot2.container);
+
+ // Connection manager
+ const manager = new ConnectionManager();
+
+ // Available data sources
+ const dataSources = {
+ sine: () => TestDataFactory.createSimpleSine(30),
+ complex: () => TestDataFactory.createComplexPattern(30),
+ noise: () => TestDataFactory.createSmoothNoise(30),
+ sweep: () => TestDataFactory.createFrequencySweep(30),
+ burst: () => TestDataFactory.createBurstySignal(30),
+ };
+
+ // Track current connections
+ let conn1Id = null;
+ let conn2Id = null;
+
+ // Helper to switch source
+ const switchSource = (plot, generatorFunc, title) => {
+ // Disconnect old connection
+ const connId = plot === plot1 ? conn1Id : conn2Id;
+ if (connId !== null) {
+ manager.disconnect(connId);
+ }
+
+ // Create new connection
+ const source = new SyntheticDataSource({
+ generator: generatorFunc(),
+ pointsPerLine: 100,
+ width: plot.width,
+ lineInterval: 100,
+ });
+
+ const newConnId = manager.connect(source, plot, { type: 'direct' });
+ plot.setTitle(title);
+
+ // Store connection ID
+ if (plot === plot1) {
+ conn1Id = newConnId;
+ } else {
+ conn2Id = newConnId;
+ }
+ };
+
+ // Initialize with default sources
+ switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
+ switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
+
+ // Keyboard controls
+ window.addEventListener('keydown', (e) => {
+ switch (e.key) {
+ case '1':
+ switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave');
+ break;
+ case '2':
+ switchSource(plot1, dataSources.complex, 'Plot 1 - Complex Pattern');
+ break;
+ case '3':
+ switchSource(plot1, dataSources.noise, 'Plot 1 - Perlin Noise');
+ break;
+ case '4':
+ switchSource(plot1, dataSources.sweep, 'Plot 1 - Frequency Sweep');
+ break;
+ case '5':
+ switchSource(plot1, dataSources.burst, 'Plot 1 - Burst Signal');
+ break;
+ case '6':
+ switchSource(plot2, dataSources.sine, 'Plot 2 - Sine Wave');
+ break;
+ case '7':
+ switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern');
+ break;
+ case '8':
+ switchSource(plot2, dataSources.noise, 'Plot 2 - Perlin Noise');
+ break;
+ case '9':
+ switchSource(plot2, dataSources.sweep, 'Plot 2 - Frequency Sweep');
+ break;
+ case '0':
+ switchSource(plot2, dataSources.burst, 'Plot 2 - Burst Signal');
+ break;
+ case 'g':
+ plot1.setGridVisible(!plot1.showGrid);
+ plot2.setGridVisible(!plot2.showGrid);
+ break;
+ case 'c':
+ plot1.clearData();
+ plot2.clearData();
+ break;
+ }
+ });
+
+ // Update loop
+ app.ticker.add(() => {
+ plot1.update();
+ plot2.update();
+ });
+
+ console.log('Controls:');
+ console.log(' 1-5: Change Plot 1 source');
+ console.log(' 6-0: Change Plot 2 source');
+ console.log(' G: Toggle grid');
+ console.log(' C: Clear data');
+
+ return { app, plot1, plot2, manager };
+}
+
+// ============================================================================
+// Quick Test: Run one of the examples
+// ============================================================================
+
+// Uncomment to run an example:
+// example1_SimpleSetup();
+// example2_QuickSetup();
+// example3_MultiplePlots();
+// example4_FunctionSource();
+// example5_SwappingSources();
+// example6_StreamingData();
+// example7_ConnectionManager();
+//example8_InteractiveDemo();
diff --git a/web-timeplot/src/metrics.js b/web-timeplot/src/metrics.js
new file mode 100644
index 0000000..fdda10a
--- /dev/null
+++ b/web-timeplot/src/metrics.js
@@ -0,0 +1,142 @@
+/**
+ * RollingAverage - Maintains a rolling window of values for smooth averaging
+ */
+class RollingAverage {
+ constructor(capacity) {
+ this.values = [];
+ this.capacity = capacity;
+ this.sum = 0;
+ }
+
+ push(value) {
+ if (this.values.length >= this.capacity) {
+ const old = this.values.shift();
+ this.sum -= old;
+ }
+ this.values.push(value);
+ this.sum += value;
+ }
+
+ average() {
+ return this.values.length > 0 ? this.sum / this.values.length : 0;
+ }
+
+ min() {
+ return this.values.length > 0 ? Math.min(...this.values) : 0;
+ }
+
+ max() {
+ return this.values.length > 0 ? Math.max(...this.values) : 0;
+ }
+}
+
+/**
+ * PerformanceMetrics - Tracks and analyzes frame performance
+ */
+export class PerformanceMetrics {
+ constructor(rollingWindow = 60, historyCapacity = 10000) {
+ // Rolling averages
+ this.frameTime = new RollingAverage(rollingWindow);
+ this.updateTime = new RollingAverage(rollingWindow);
+ this.renderTime = new RollingAverage(rollingWindow);
+ this.vertexCount = new RollingAverage(rollingWindow);
+ this.lineCount = new RollingAverage(rollingWindow);
+
+ // History for export
+ this.history = [];
+ this.historyCapacity = historyCapacity;
+
+ // Frame timing
+ this.frameStart = 0;
+ this.updateStart = 0;
+ this.renderStart = 0;
+
+ this.totalFrames = 0;
+ }
+
+ beginFrame() {
+ this.frameStart = performance.now();
+ }
+
+ beginUpdate() {
+ this.updateStart = performance.now();
+ }
+
+ endUpdate() {
+ const duration = performance.now() - this.updateStart;
+ return duration;
+ }
+
+ beginRender() {
+ this.renderStart = performance.now();
+ }
+
+ endRender() {
+ const duration = performance.now() - this.renderStart;
+ return duration;
+ }
+
+ endFrame(updateMs, renderMs, vertexCount, lineCount) {
+ const totalMs = performance.now() - this.frameStart;
+
+ // Update rolling averages
+ this.frameTime.push(totalMs);
+ this.updateTime.push(updateMs);
+ this.renderTime.push(renderMs);
+ this.vertexCount.push(vertexCount);
+ this.lineCount.push(lineCount);
+
+ // Store in history
+ const record = {
+ frame: this.totalFrames,
+ totalMs,
+ updateMs,
+ renderMs,
+ vertexCount,
+ lineCount,
+ fps: totalMs > 0 ? 1000 / totalMs : 0,
+ };
+
+ if (this.history.length >= this.historyCapacity) {
+ this.history.shift();
+ }
+ this.history.push(record);
+
+ this.totalFrames++;
+ }
+
+ getFPS() {
+ const avg = this.frameTime.average();
+ return avg > 0 ? 1000 / avg : 0;
+ }
+
+ getMinFPS() {
+ const max = this.frameTime.max();
+ return max > 0 ? 1000 / max : 0;
+ }
+
+ getMaxFPS() {
+ const min = this.frameTime.min();
+ return min > 0 ? 1000 / min : 0;
+ }
+
+ formatSummary() {
+ return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` +
+ `Frame: ${this.frameTime.average().toFixed(2)}ms | ` +
+ `Update: ${this.updateTime.average().toFixed(2)}ms | ` +
+ `Render: ${this.renderTime.average().toFixed(2)}ms | ` +
+ `Vertices: ${Math.round(this.vertexCount.average())} | ` +
+ `Lines: ${Math.round(this.lineCount.average())}`;
+ }
+
+ exportToCSV() {
+ let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n';
+
+ for (const record of this.history) {
+ csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` +
+ `${record.vertexCount},${record.lineCount},${record.fps}\n`;
+ }
+
+ return csv;
+ }
+}
diff --git a/web-timeplot/src/plot-connections.js b/web-timeplot/src/plot-connections.js
new file mode 100644
index 0000000..0e96dd8
--- /dev/null
+++ b/web-timeplot/src/plot-connections.js
@@ -0,0 +1,392 @@
+/**
+ * Plot Connections - Links data sources to visualization plots
+ *
+ * This module manages the connection between data sources and plots,
+ * handling buffering, timing, and data flow.
+ *
+ * Connection Types:
+ * - DirectConnection: Lines from source → plot (no buffering)
+ * - BufferedConnection: Points → buffer → lines → plot
+ * - SynchronizedConnection: Multiple sources → synchronized output
+ */
+
+/**
+ * Base connection class
+ */
+class PlotConnection {
+ constructor(source, plot, config = {}) {
+ this.source = source;
+ this.plot = plot;
+ this.config = config;
+ this.isActive = false;
+ this.subscriptions = [];
+ }
+
+ /**
+ * Activate the connection - start data flow
+ */
+ connect() {
+ if (this.isActive) return;
+ this.isActive = true;
+ this.setupSubscriptions();
+ this.source.start();
+ }
+
+ /**
+ * Deactivate the connection - stop data flow
+ */
+ disconnect() {
+ if (!this.isActive) return;
+ this.isActive = false;
+ this.cleanup();
+ this.source.stop();
+ }
+
+ /**
+ * Setup event subscriptions (override in subclasses)
+ */
+ setupSubscriptions() {
+ throw new Error('setupSubscriptions() must be implemented by subclass');
+ }
+
+ /**
+ * Cleanup subscriptions
+ */
+ cleanup() {
+ this.subscriptions.forEach(unsub => unsub());
+ this.subscriptions = [];
+ }
+}
+
+/**
+ * Direct connection - passes lines directly from source to plot
+ * Use when source emits complete lines of data
+ */
+export class DirectConnection extends PlotConnection {
+ setupSubscriptions() {
+ const unsubLine = this.source.on('line', (data) => {
+ this.plot.addLine(data.points, data.metadata);
+ });
+
+ const unsubError = this.source.on('error', (data) => {
+ console.error('[DirectConnection] Source error:', data.error);
+ });
+
+ this.subscriptions.push(unsubLine, unsubError);
+ }
+}
+
+/**
+ * Buffered connection - buffers individual points into lines
+ * Use when source emits individual data points that need to be assembled
+ */
+export class BufferedConnection extends PlotConnection {
+ constructor(source, plot, config = {}) {
+ super(source, plot, config);
+ this.buffer = [];
+ this.bufferSize = config.bufferSize || 100;
+ this.bufferTimeout = config.bufferTimeout || 1000; // ms
+ this.lastFlush = Date.now();
+ this.flushHandle = null;
+
+ // Start auto-flush timer
+ if (config.autoFlush !== false) {
+ this.startAutoFlush();
+ }
+ }
+
+ setupSubscriptions() {
+ const unsubPoint = this.source.on('point', (data) => {
+ this.addToBuffer(data);
+ });
+
+ const unsubError = this.source.on('error', (data) => {
+ console.error('[BufferedConnection] Source error:', data.error);
+ });
+
+ this.subscriptions.push(unsubPoint, unsubError);
+ }
+
+ addToBuffer(data) {
+ this.buffer.push(data);
+
+ // Flush if buffer is full
+ if (this.buffer.length >= this.bufferSize) {
+ this.flush();
+ }
+ }
+
+ flush() {
+ if (this.buffer.length === 0) return;
+
+ // Convert buffer to line points
+ const points = this.buffer.map((data, idx) => {
+ const x = (idx / this.buffer.length) * this.plot.width;
+ return { x, y: data.value };
+ });
+
+ this.plot.addLine(points, {
+ timestamp: this.lastFlush,
+ pointCount: this.buffer.length,
+ });
+
+ this.buffer = [];
+ this.lastFlush = Date.now();
+ }
+
+ startAutoFlush() {
+ this.flushHandle = setInterval(() => {
+ const timeSinceLastFlush = Date.now() - this.lastFlush;
+ if (timeSinceLastFlush >= this.bufferTimeout && this.buffer.length > 0) {
+ this.flush();
+ }
+ }, 100); // Check every 100ms
+ }
+
+ cleanup() {
+ super.cleanup();
+ if (this.flushHandle) {
+ clearInterval(this.flushHandle);
+ this.flushHandle = null;
+ }
+ }
+}
+
+/**
+ * Synchronized connection - synchronizes multiple sources to one plot
+ * Useful for combining multiple data streams
+ */
+export class SynchronizedConnection extends PlotConnection {
+ constructor(sources, plot, config = {}) {
+ super(null, plot, config); // No single source
+ this.sources = sources;
+ this.syncMode = config.syncMode || 'wait-for-all'; // 'wait-for-all', 'first-available'
+ this.lineBuffers = new Map(); // sourceId => latest line
+ }
+
+ connect() {
+ if (this.isActive) return;
+ this.isActive = true;
+
+ this.sources.forEach((source, idx) => {
+ const unsubLine = source.on('line', (data) => {
+ this.handleSourceLine(idx, data);
+ });
+
+ const unsubError = source.on('error', (data) => {
+ console.error(`[SynchronizedConnection] Source ${idx} error:`, data.error);
+ });
+
+ this.subscriptions.push(unsubLine, unsubError);
+ source.start();
+ });
+ }
+
+ disconnect() {
+ if (!this.isActive) return;
+ this.isActive = false;
+ this.cleanup();
+ this.sources.forEach(source => source.stop());
+ }
+
+ handleSourceLine(sourceIdx, data) {
+ this.lineBuffers.set(sourceIdx, data);
+
+ if (this.syncMode === 'wait-for-all') {
+ // Wait until we have data from all sources
+ if (this.lineBuffers.size === this.sources.length) {
+ this.emitSynchronized();
+ }
+ } else if (this.syncMode === 'first-available') {
+ // Emit immediately
+ this.plot.addLine(data.points, {
+ ...data.metadata,
+ sourceIdx,
+ });
+ }
+ }
+
+ emitSynchronized() {
+ // For now, just emit the first source's line
+ // Could implement more sophisticated merging
+ const firstLine = this.lineBuffers.get(0);
+ if (firstLine) {
+ this.plot.addLine(firstLine.points, firstLine.metadata);
+ }
+ this.lineBuffers.clear();
+ }
+}
+
+/**
+ * Connection Manager - manages multiple connections
+ */
+export class ConnectionManager {
+ constructor() {
+ this.connections = new Map(); // connectionId => connection
+ this.nextId = 0;
+ }
+
+ /**
+ * Create and register a connection
+ * @returns {number} connectionId
+ */
+ connect(source, plot, config = {}) {
+ const type = config.type || 'direct';
+ let connection;
+
+ switch (type) {
+ case 'direct':
+ connection = new DirectConnection(source, plot, config);
+ break;
+ case 'buffered':
+ connection = new BufferedConnection(source, plot, config);
+ break;
+ case 'synchronized':
+ connection = new SynchronizedConnection(source, plot, config);
+ break;
+ default:
+ throw new Error(`Unknown connection type: ${type}`);
+ }
+
+ const id = this.nextId++;
+ this.connections.set(id, connection);
+ connection.connect();
+
+ return id;
+ }
+
+ /**
+ * Disconnect and remove a connection
+ */
+ disconnect(connectionId) {
+ const connection = this.connections.get(connectionId);
+ if (connection) {
+ connection.disconnect();
+ this.connections.delete(connectionId);
+ }
+ }
+
+ /**
+ * Disconnect all connections
+ */
+ disconnectAll() {
+ this.connections.forEach(connection => connection.disconnect());
+ this.connections.clear();
+ }
+
+ /**
+ * Get statistics about connections
+ */
+ getStats() {
+ return {
+ activeConnections: this.connections.size,
+ connections: Array.from(this.connections.entries()).map(([id, conn]) => ({
+ id,
+ isActive: conn.isActive,
+ type: conn.constructor.name,
+ })),
+ };
+ }
+}
+
+/**
+ * Helper functions for common connection patterns
+ */
+
+/**
+ * Connect a synthetic data source to a plot
+ * @param {DataGenerator} generator - Test data generator instance
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {DirectConnection} The connection instance
+ */
+export function connectSyntheticData(generator, plot, config = {}) {
+ const { SyntheticDataSource } = require('./data-sources.js');
+
+ const source = new SyntheticDataSource({
+ generator,
+ pointsPerLine: config.pointsPerLine || 100,
+ width: plot.width,
+ lineInterval: config.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot, config);
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Connect a function-based source to a plot
+ * @param {Function} func - Function (x, t) => y
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {DirectConnection} The connection instance
+ */
+export function connectFunction(func, plot, config = {}) {
+ const { FunctionDataSource } = require('./data-sources.js');
+
+ const source = new FunctionDataSource({
+ func,
+ pointsPerLine: config.pointsPerLine || 100,
+ width: plot.width,
+ amplitude: config.amplitude || 30,
+ lineInterval: config.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot, config);
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Connect a streaming source to a plot with buffering
+ * @param {DataGenerator} generator - Test data generator instance
+ * @param {TimeSeriesPlot} plot - Plot to display data
+ * @param {Object} config - Configuration options
+ * @returns {BufferedConnection} The connection instance
+ */
+export function connectStreamingData(generator, plot, config = {}) {
+ const { StreamingDataSource } = require('./data-sources.js');
+
+ const source = new StreamingDataSource({
+ generator,
+ sampleRate: config.sampleRate || 60,
+ });
+
+ const connection = new BufferedConnection(source, plot, {
+ bufferSize: config.bufferSize || 100,
+ bufferTimeout: config.bufferTimeout || 1000,
+ });
+ connection.connect();
+
+ return connection;
+}
+
+/**
+ * Quick setup: Create a plot with a data source in one call
+ * @param {Application} app - PixiJS application
+ * @param {Object} plotConfig - Plot configuration
+ * @param {Object} sourceConfig - Source configuration
+ * @returns {Object} {plot, source, connection}
+ */
+export function createConnectedPlot(app, plotConfig, sourceConfig) {
+ const { TimeSeriesPlot } = require('./timeseries-plot.js');
+ const { SyntheticDataSource } = require('./data-sources.js');
+
+ const plot = new TimeSeriesPlot(plotConfig);
+ app.stage.addChild(plot.container);
+
+ const source = new SyntheticDataSource({
+ generator: sourceConfig.generator,
+ pointsPerLine: plotConfig.width / 8, // Default: ~8 pixels per point
+ width: plotConfig.width,
+ lineInterval: sourceConfig.lineInterval || 100,
+ });
+
+ const connection = new DirectConnection(source, plot);
+ connection.connect();
+
+ return { plot, source, connection };
+}
diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js
index 9f00b29..ce90a1f 100644
--- a/web-timeplot/src/plot/timeplot-view.js
+++ b/web-timeplot/src/plot/timeplot-view.js
@@ -1,4 +1,5 @@
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));
@@ -11,17 +12,25 @@ function roundRect(graphics, x, y, width, height, radius, fill, stroke) {
}
export class TimeplotView {
- constructor({ host, onHover }) {
+ constructor({ host, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) {
this.host = host;
- this.onHover = onHover;
+ 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: {
@@ -38,10 +47,38 @@ export class TimeplotView {
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() {
@@ -57,12 +94,18 @@ export class TimeplotView {
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();
@@ -72,8 +115,7 @@ export class TimeplotView {
attachPointerListeners() {
this.host.addEventListener('pointerleave', () => {
this.pointer = null;
- this.crosshair.clear();
- this.onHover(null);
+ this.lastPointerEventAt = performance.now();
});
this.host.addEventListener('pointermove', (event) => {
@@ -82,6 +124,7 @@ export class TimeplotView {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
+ this.lastPointerEventAt = performance.now();
});
}
@@ -95,13 +138,211 @@ export class TimeplotView {
render(state, points) {
this.resize();
this.renderFrame(state, points);
- this.renderHover(state);
+ 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: 68, right: 24, bottom: 28, left: 52 };
+ 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;
@@ -116,38 +357,48 @@ export class TimeplotView {
0,
width,
height,
- 24,
- { color: 0x050c16, alpha: 1 },
- { color: 0x22344f, width: 1 },
+ 6,
+ { color: 0x05070b, alpha: 1 },
+ { color: 0x2c3b4d, width: 1 },
);
this.grid.clear();
if (state.plot.showGrid) {
- const gridColor = 0x1d3555;
- for (let x = 0; x <= 8; x += 1) {
- const px = padding.left + (plotWidth * x) / 8;
+ 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.65 });
+ this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 });
}
- for (let y = 0; y <= 6; y += 1) {
- const py = padding.top + (plotHeight * y) / 6;
+ 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.65 });
+ 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.timeMs - minTime) / (maxTime - minTime)) * plotWidth;
- const normalizedValue = (point.value - minValue) / valueSpan;
- const y = padding.top + (1 - normalizedValue) * plotHeight;
+ 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 });
@@ -159,73 +410,30 @@ export class TimeplotView {
});
this.line.stroke({
- color: 0x7af0ff,
- width: 2.25,
- alpha: 0.95,
- cap: 'round',
- join: 'round',
+ color: this.lineColor,
+ width: 2,
+ alpha: 0.96,
+ cap: 'square',
+ join: 'miter',
});
if (state.plot.showPoints) {
for (const point of this.screenPoints) {
- this.points.circle(point.x, point.y, 2.5);
- this.points.fill({ color: 0xc4f8ff, alpha: 0.95 });
+ this.points.rect(point.x - 2, point.y - 2, 4, 4);
+ this.points.fill({ color: this.pointColor, alpha: 0.92 });
}
}
}
- this.titleText.text = 'TimePlot viewport';
- this.titleText.x = 18;
- this.titleText.y = 16;
+ this.titleText.text = this.panelTitle;
+ this.titleText.x = 20;
+ this.titleText.y = 14;
- this.subtitleText.text = `${state.source.preset} • ${state.source.sampleRateHz} Hz • ${points.length} visible points`;
- this.subtitleText.x = 18;
- this.subtitleText.y = 38;
- }
+ this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`;
+ this.subtitleText.x = 20;
+ this.subtitleText.y = 36;
- renderHover(state) {
- this.crosshair.clear();
-
- if (!this.pointer || this.screenPoints.length === 0) {
- this.onHover(null);
- return;
- }
-
- let nearestPoint = null;
- let nearestDistance = Infinity;
-
- for (const point of this.screenPoints) {
- const dx = point.x - this.pointer.x;
- const dy = point.y - this.pointer.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- if (distance < nearestDistance) {
- nearestPoint = point;
- nearestDistance = distance;
- }
- }
-
- if (!nearestPoint || nearestDistance > this.hoverRadiusPx) {
- this.onHover(null);
- return;
- }
-
- const x = clamp(nearestPoint.x, 0, this.bounds.width);
- const y = clamp(nearestPoint.y, 0, this.bounds.height);
-
- this.crosshair.moveTo(x, 0);
- this.crosshair.lineTo(x, this.bounds.height);
- this.crosshair.moveTo(0, y);
- this.crosshair.lineTo(this.bounds.width, y);
- this.crosshair.stroke({ color: 0x6ea8ff, width: 1, alpha: 0.22 });
- this.crosshair.circle(x, y, 5);
- this.crosshair.stroke({ color: 0xffffff, width: 2, alpha: 0.95 });
-
- this.onHover({
- x,
- y,
- point: nearestPoint,
- paused: state.time.paused,
- });
+ this.renderReadouts(state, width);
}
destroy() {
diff --git a/web-timeplot/src/state.js b/web-timeplot/src/state.js
new file mode 100644
index 0000000..53d8279
--- /dev/null
+++ b/web-timeplot/src/state.js
@@ -0,0 +1,420 @@
+/**
+ * StateManager - Centralized state management with Proxy-based reactivity
+ *
+ * Usage:
+ * state.time.speed = 2.0 // automatically emits events
+ * state.on('time.speed', (value) => console.log('Speed changed:', value))
+ * state.on('time.*', (change) => console.log('Time domain changed:', change))
+ *
+ * State Domains:
+ * - userPrefs: showGrid, showMetrics, theme, etc.
+ * - uiConfig: active panels, layout, dimensions
+ * - time: current time, speed, paused state, real elapsed time
+ * - rendering: graphs, renderer info
+ * - health: framerate, service connections, db access
+ * - dataInput: sources, structure, metadata
+ * - inputActions: keyboard/mouse/gamepad action mappings
+ */
+
+// Simple EventEmitter implementation
+class EventEmitter {
+ constructor() {
+ this.events = new Map();
+ }
+
+ on(event, callback) {
+ if (!this.events.has(event)) {
+ this.events.set(event, []);
+ }
+ this.events.get(event).push(callback);
+
+ // Return unsubscribe function
+ return () => this.off(event, callback);
+ }
+
+ off(event, callback) {
+ if (!this.events.has(event)) return;
+ const callbacks = this.events.get(event);
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+
+ emit(event, data) {
+ if (!this.events.has(event)) return;
+ this.events.get(event).forEach(callback => {
+ try {
+ callback(data);
+ } catch (e) {
+ console.error(`[State] Error in event handler for '${event}':`, e);
+ }
+ });
+ }
+
+ once(event, callback) {
+ const wrapper = (data) => {
+ callback(data);
+ this.off(event, wrapper);
+ };
+ this.on(event, wrapper);
+ }
+
+ clear() {
+ this.events.clear();
+ }
+}
+
+export class StateManager extends EventEmitter {
+ constructor() {
+ super();
+
+ // Internal state storage (not proxied)
+ this._state = {
+ userPrefs: {
+ showGrid: true,
+ showMetrics: true,
+ theme: 'dark',
+ rollingWindow: 60,
+ historyCapacity: 10000,
+ metricsUpdateInterval: 10,
+ },
+
+ uiConfig: {
+ activePanels: ['graph1', 'graph2'],
+ layout: 'horizontal-split',
+ canvasWidth: 0,
+ canvasHeight: 0,
+ },
+
+ time: {
+ current: 0, // Current plot time
+ realElapsed: 0, // Real time elapsed since start
+ speed: 1.0, // Time speed multiplier (0.1 to 5.0)
+ isPaused: false, // Pause state
+ startTimestamp: Date.now(), // Real timestamp when started
+ verticalScale: 1.0, // Vertical zoom for time history
+ },
+
+ rendering: {
+ rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas'
+ frameCounter: 0,
+ // Note: graph instances are NOT stored here to avoid proxy wrapping
+ },
+
+ health: {
+ fps: 0,
+ updateMs: 0,
+ renderMs: 0,
+ vertexCount: 0,
+ lineCount: 0,
+ serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' }
+ },
+
+ dataInput: {
+ sources: [], // Array of data source configs
+ activeSource: null, // Currently active source
+ dataStructure: null, // Schema of incoming data
+ metadata: {}, // Additional metadata
+ },
+
+ inputActions: {
+ keyboardMap: new Map(), // Map of KeyboardEvent.code => action name
+ mouseMap: new Map(), // Map of mouse button => action name
+ actionHandlers: new Map(), // Map of action name => handler function
+ },
+ };
+
+ // Track which domains should be persisted
+ this._persistedDomains = new Set(['userPrefs']);
+
+ // Load persisted state
+ this._loadPersistedState();
+
+ // Create proxied state - this is what users interact with
+ this.state = this._createProxy(this._state, []);
+ }
+
+ /**
+ * Create a reactive Proxy that emits events on property changes
+ * @param {Object} target - The object to proxy
+ * @param {Array} path - Current property path (e.g., ['time', 'speed'])
+ * @private
+ */
+ _createProxy(target, path) {
+ // Don't proxy non-objects or special objects like Map/Set
+ if (typeof target !== 'object' || target === null) {
+ return target;
+ }
+
+ // Don't proxy Maps and Sets - they need special handling
+ if (target instanceof Map || target instanceof Set) {
+ return target;
+ }
+
+ const self = this;
+
+ return new Proxy(target, {
+ get(obj, prop) {
+ const value = obj[prop];
+
+ // Return primitives and functions as-is
+ if (typeof value !== 'object' || value === null) {
+ return value;
+ }
+
+ // Return nested objects as proxies
+ return self._createProxy(value, [...path, prop]);
+ },
+
+ set(obj, prop, value) {
+ const oldValue = obj[prop];
+
+ // Only emit if value actually changed
+ if (oldValue === value) {
+ return true;
+ }
+
+ obj[prop] = value;
+
+ // Build event path
+ const fullPath = [...path, prop];
+ const pathString = fullPath.join('.');
+ const domain = fullPath[0];
+
+ // Emit specific property change: "time.speed"
+ self.emit(pathString, {
+ path: fullPath,
+ value: value,
+ oldValue: oldValue,
+ });
+
+ // Emit domain wildcard: "time.*"
+ if (domain) {
+ self.emit(`${domain}.*`, {
+ path: fullPath,
+ property: prop,
+ value: value,
+ oldValue: oldValue,
+ });
+ }
+
+ // Emit global wildcard: "*"
+ self.emit('*', {
+ path: fullPath,
+ value: value,
+ oldValue: oldValue,
+ });
+
+ // Auto-persist certain domains
+ if (self._persistedDomains.has(domain)) {
+ self._persistDomain(domain);
+ }
+
+ return true;
+ }
+ });
+ }
+
+ // =========================================================================
+ // Persistence
+ // =========================================================================
+
+ _persistDomain(domain) {
+ try {
+ const data = this._state[domain];
+ // Convert Maps to objects for JSON serialization
+ const serializable = this._makeSerializable(data);
+ localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable));
+ } catch (e) {
+ console.warn(`[State] Failed to persist ${domain}:`, e);
+ }
+ }
+
+ _loadPersistedState() {
+ this._persistedDomains.forEach(domain => {
+ try {
+ const saved = localStorage.getItem(`timeplot-${domain}`);
+ if (saved) {
+ const data = JSON.parse(saved);
+ // Deep merge to preserve defaults for new properties
+ this._state[domain] = this._deepMerge(this._state[domain], data);
+ }
+ } catch (e) {
+ console.warn(`[State] Failed to load ${domain}:`, e);
+ }
+ });
+ }
+
+ _makeSerializable(obj) {
+ if (obj instanceof Map) {
+ return Object.fromEntries(obj);
+ }
+ if (obj instanceof Set) {
+ return Array.from(obj);
+ }
+ if (typeof obj === 'object' && obj !== null) {
+ const result = {};
+ for (const [key, value] of Object.entries(obj)) {
+ result[key] = this._makeSerializable(value);
+ }
+ return result;
+ }
+ return obj;
+ }
+
+ _deepMerge(target, source) {
+ const result = { ...target };
+ for (const key in source) {
+ if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
+ result[key] = this._deepMerge(target[key] || {}, source[key]);
+ } else {
+ result[key] = source[key];
+ }
+ }
+ return result;
+ }
+
+ // =========================================================================
+ // Convenience Methods
+ // =========================================================================
+
+ /**
+ * Toggle a boolean preference
+ */
+ togglePref(key) {
+ const current = this.state.userPrefs[key];
+ if (typeof current === 'boolean') {
+ this.state.userPrefs[key] = !current;
+ }
+ }
+
+ /**
+ * Pause/resume time
+ */
+ togglePause() {
+ this.state.time.isPaused = !this.state.time.isPaused;
+ }
+
+ /**
+ * Set time speed (clamped 0.1 to 5.0)
+ */
+ setTimeSpeed(speed) {
+ this.state.time.speed = Math.max(0.1, Math.min(5.0, speed));
+ }
+
+ /**
+ * Increment time (respects pause and speed)
+ */
+ incrementTime(delta) {
+ if (this.state.time.isPaused) return;
+ this.state.time.current += delta * this.state.time.speed;
+ }
+
+ /**
+ * Update real elapsed time
+ */
+ updateRealElapsed() {
+ const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000;
+ this.state.time.realElapsed = elapsed;
+ }
+
+ // =========================================================================
+ // Input Actions System
+ // =========================================================================
+
+ /**
+ * Register an input action handler
+ * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause')
+ * @param {Function} handler - Handler function to call
+ */
+ registerAction(actionName, handler) {
+ this.state.inputActions.actionHandlers.set(actionName, handler);
+ }
+
+ /**
+ * Map a keyboard key to an action
+ * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space')
+ * @param {string} actionName - Action to trigger
+ */
+ mapKey(code, actionName) {
+ this.state.inputActions.keyboardMap.set(code, actionName);
+ }
+
+ /**
+ * Map a mouse button to an action
+ * @param {number} button - Mouse button number (0=left, 1=middle, 2=right)
+ * @param {string} actionName - Action to trigger
+ */
+ mapMouseButton(button, actionName) {
+ this.state.inputActions.mouseMap.set(button, actionName);
+ }
+
+ /**
+ * Execute an action by name
+ */
+ executeAction(actionName, event) {
+ const handler = this.state.inputActions.actionHandlers.get(actionName);
+ if (handler) {
+ handler(event);
+ } else {
+ console.warn(`[State] No handler registered for action: ${actionName}`);
+ }
+ }
+
+ /**
+ * Handle keyboard event through action system
+ */
+ handleKeyboardEvent(event) {
+ const actionName = this.state.inputActions.keyboardMap.get(event.code);
+ if (actionName) {
+ this.executeAction(actionName, event);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handle mouse button event through action system
+ */
+ handleMouseButtonEvent(event) {
+ const actionName = this.state.inputActions.mouseMap.get(event.button);
+ if (actionName) {
+ this.executeAction(actionName, event);
+ return true;
+ }
+ return false;
+ }
+
+ // =========================================================================
+ // Data Sources
+ // =========================================================================
+
+ addDataSource(source) {
+ this.state.dataInput.sources.push(source);
+ }
+
+ removeDataSource(sourceId) {
+ const sources = this.state.dataInput.sources;
+ const index = sources.findIndex(s => s.id === sourceId);
+ if (index > -1) {
+ sources.splice(index, 1);
+ }
+ }
+
+ setActiveDataSource(sourceId) {
+ this.state.dataInput.activeSource = sourceId;
+ }
+
+ // =========================================================================
+ // Debugging
+ // =========================================================================
+
+ dump() {
+ console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state)));
+ }
+
+ debugEvents() {
+ console.log('[State] Registered events:', Array.from(this.events.keys()));
+ }
+}
diff --git a/web-timeplot/src/styles.css b/web-timeplot/src/styles.css
index b56e31a..6b0477f 100644
--- a/web-timeplot/src/styles.css
+++ b/web-timeplot/src/styles.css
@@ -1,15 +1,17 @@
:root {
color-scheme: dark;
- --bg: #07111f;
- --surface: rgba(11, 24, 42, 0.86);
- --surface-strong: rgba(9, 18, 32, 0.94);
- --border: rgba(133, 168, 255, 0.18);
- --text: #eef4ff;
- --muted: #8ca3c7;
- --accent: #6ea8ff;
- --accent-strong: #7af0ff;
- --danger: #ff8c8c;
- --shadow: 0 20px 40px rgba(0, 0, 0, 0.28);
+ --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;
}
@@ -27,9 +29,7 @@ body,
body {
background:
- radial-gradient(circle at top left, rgba(122, 240, 255, 0.12), transparent 28%),
- radial-gradient(circle at top right, rgba(110, 168, 255, 0.14), transparent 24%),
- linear-gradient(180deg, #06101c 0%, #091423 100%);
+ linear-gradient(180deg, #080a0d 0%, #0d1015 100%);
color: var(--text);
overflow: hidden;
}
@@ -46,8 +46,8 @@ select {
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
height: 100%;
- gap: 14px;
- padding: 14px;
+ gap: 10px;
+ padding: 10px;
}
.timeplot-topbar {
@@ -56,28 +56,32 @@ select {
align-items: center;
justify-content: space-between;
gap: 16px;
- padding: 14px 18px;
- border: 1px solid var(--border);
+ padding: 12px 14px;
+ border: 1px solid var(--border-strong);
background: var(--surface);
- backdrop-filter: blur(20px);
- border-radius: 18px;
+ border-radius: 4px;
box-shadow: var(--shadow);
}
.timeplot-brand {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 2px;
}
.timeplot-title {
margin: 0;
- font-size: 1.2rem;
+ font-size: 1rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 700;
}
.timeplot-subtitle {
color: var(--muted);
- font-size: 0.9rem;
+ font-size: 0.78rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
}
.timeplot-toolbar {
@@ -92,53 +96,82 @@ select {
display: flex;
align-items: center;
gap: 8px;
- padding: 8px 12px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 999px;
+ 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.85rem;
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.control-group input[type='range'] {
+ width: 118px;
}
.control-group input[type='range'] {
- width: 130px;
+ accent-color: var(--accent);
}
.control-button,
.panel-toggle {
color: var(--text);
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.09);
- border-radius: 999px;
- padding: 8px 14px;
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: 2px;
+ padding: 7px 11px;
cursor: pointer;
- transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
+ 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 {
- transform: translateY(-1px);
- border-color: rgba(122, 240, 255, 0.45);
+ border-color: var(--accent);
+ color: var(--accent-strong);
}
.control-button[data-active='true'],
.panel-toggle[data-active='true'] {
- background: linear-gradient(135deg, rgba(110, 168, 255, 0.18), rgba(122, 240, 255, 0.18));
- border-color: rgba(122, 240, 255, 0.42);
+ background: #1a2230;
+ border-color: var(--accent);
+ color: var(--accent-strong);
}
.timeplot-viewport {
position: relative;
min-height: 0;
- border-radius: 24px;
+ border-radius: 4px;
overflow: hidden;
- border: 1px solid var(--border);
- background: rgba(4, 10, 18, 0.94);
+ 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 {
@@ -149,18 +182,17 @@ select {
.timeplot-sidebar {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 10px;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.panel {
- border: 1px solid var(--border);
+ border: 1px solid var(--border-strong);
background: var(--surface-strong);
- border-radius: 18px;
+ border-radius: 4px;
padding: 14px;
- backdrop-filter: blur(20px);
}
.panel[hidden] {
@@ -169,26 +201,45 @@ select {
.panel h2 {
margin: 0 0 12px;
- font-size: 0.95rem;
+ 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: 8px 12px;
+ gap: 10px 12px;
align-items: center;
margin: 0;
}
.kv-list dt {
color: var(--muted);
- font-size: 0.84rem;
+ 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 {
@@ -200,30 +251,85 @@ select {
display: grid;
gap: 6px;
color: var(--muted);
- font-size: 0.84rem;
+ 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: 10px 12px;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
+ 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);
}
@@ -234,18 +340,19 @@ select {
margin: 0;
padding-left: 18px;
color: var(--muted);
+ font-size: 0.82rem;
}
.timeplot-tooltip {
position: absolute;
min-width: 180px;
padding: 10px 12px;
- border-radius: 12px;
- border: 1px solid rgba(122, 240, 255, 0.28);
- background: rgba(7, 14, 24, 0.94);
+ border-radius: 3px;
+ border: 1px solid var(--border-strong);
+ background: #0d1218;
box-shadow: var(--shadow);
pointer-events: none;
- transform: translate(14px, -50%);
+ transform: translate(12px, -50%);
z-index: 10;
}
@@ -254,16 +361,18 @@ select {
}
.timeplot-tooltip-title {
- margin-bottom: 6px;
- font-size: 0.82rem;
+ 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.82rem;
+ font-size: 0.78rem;
}
.timeplot-tooltip-row + .timeplot-tooltip-row {
@@ -281,6 +390,11 @@ select {
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
new file mode 100644
index 0000000..54aacc7
--- /dev/null
+++ b/web-timeplot/src/template-for-standard-site.js
@@ -0,0 +1,75 @@
+//import { setupRenderSystem } from './render.js';
+
+let ENVURL = "" //remote server from which to grab env
+let env = {};
+let cfg = {}; //the user config
+let dom = {
+ input: {},
+ label: {},
+ box: {}, //an info-containing box
+ icon: {},
+ info: {}
+};
+
+
+//APP START HERE
+$(document).ready(async function() {
+ console.log('asdf');
+ //the core loop of the client application
+ // 1. setup relationship with DOM and grab references to its elements
+ log('init DOM');
+ await initDOM();
+
+ log('init cfg');
+ await initCfg();
+
+ log('get env vars');
+ await getServerEnvVars();
+
+ log('init services');
+ await initServices();
+
+ //setupRenderSystem();
+
+
+});
+
+//gets user config from local storage if there is any
+function initCfg(){
+ let localCfg = localStorage.getItem('cfg');
+ if (localCfg) {
+ try {
+ cfg = JSON.parse(localCfg);
+ } catch (e) {
+ cfg = {};
+ }
+ } else {
+
+ }
+}
+
+async function getServerEnvVars(){
+ await axios.get(`${ENVURL}`).then((res)=>{
+ env = res.data;
+ //log(env);
+ }).catch((err)=>{
+ //log(err);
+ });
+ log('')
+}
+
+function initServices(){
+ //connect to websocket server
+ //grab endpoints from cfg
+}
+
+function initDOM(){
+ dom.body = $('body')[0];
+}
+
+function log(msg, lvl=1){
+ if (dom.debugInfo){
+ dom.debugInfo.innerHTML = msg; //TODO running log + timestamp
+ }
+ console.log(msg);
+} \ No newline at end of file
diff --git a/web-timeplot/src/test-data-generators.js b/web-timeplot/src/test-data-generators.js
new file mode 100644
index 0000000..02bc0ad
--- /dev/null
+++ b/web-timeplot/src/test-data-generators.js
@@ -0,0 +1,530 @@
+/**
+ * Test Data Generators - Classes for generating fake/test data patterns
+ *
+ * These generators produce various types of synthetic data for testing
+ * and visualizing the waterfall graphs with realistic patterns.
+ */
+
+/**
+ * Base class for all data generators
+ */
+class DataGenerator {
+ constructor(config = {}) {
+ this.sampleRate = config.sampleRate || 100; // Samples per second
+ this.amplitude = config.amplitude || 1.0;
+ this.offset = config.offset || 0.0;
+ this.time = 0;
+ }
+
+ /**
+ * Generate a single sample at the current time
+ * @returns {number} The generated value
+ */
+ sample() {
+ throw new Error('sample() must be implemented by subclass');
+ }
+
+ /**
+ * Generate an array of samples
+ * @param {number} count - Number of samples to generate
+ * @returns {Array<number>} Array of generated values
+ */
+ generateSamples(count) {
+ const samples = [];
+ for (let i = 0; i < count; i++) {
+ samples.push(this.sample());
+ this.time += 1 / this.sampleRate;
+ }
+ return samples;
+ }
+
+ /**
+ * Generate a line of points for waterfall display
+ * @param {number} pointCount - Number of points in the line
+ * @param {number} width - Width of the display area
+ * @returns {Array<{x: number, y: number}>} Array of points
+ */
+ generateLine(pointCount, width) {
+ const points = [];
+ const samples = this.generateSamples(pointCount);
+
+ for (let i = 0; i < pointCount; i++) {
+ const x = (i / pointCount) * width;
+ const y = samples[i] * this.amplitude + this.offset;
+ points.push({ x, y });
+ }
+
+ return points;
+ }
+
+ reset() {
+ this.time = 0;
+ }
+}
+
+/**
+ * Sine Wave Generator - Classic sinusoidal wave
+ */
+export class SineWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0; // Hz
+ this.phase = config.phase || 0.0; // Radians
+ }
+
+ sample() {
+ const value = Math.sin(2 * Math.PI * this.frequency * this.time + this.phase);
+ return value;
+ }
+}
+
+/**
+ * Square Wave Generator - Digital-style square wave
+ */
+export class SquareWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ this.dutyCycle = config.dutyCycle || 0.5; // 0.0 to 1.0
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return phase < this.dutyCycle ? 1.0 : -1.0;
+ }
+}
+
+/**
+ * Sawtooth Wave Generator - Linear ramp wave
+ */
+export class SawtoothWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return 2 * phase - 1; // -1 to 1
+ }
+}
+
+/**
+ * Triangle Wave Generator - Linear up/down wave
+ */
+export class TriangleWaveGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ }
+
+ sample() {
+ const period = 1 / this.frequency;
+ const phase = (this.time % period) / period;
+ return phase < 0.5
+ ? 4 * phase - 1
+ : 3 - 4 * phase;
+ }
+}
+
+/**
+ * White Noise Generator - Random noise
+ */
+export class WhiteNoiseGenerator extends DataGenerator {
+ sample() {
+ return Math.random() * 2 - 1; // -1 to 1
+ }
+}
+
+/**
+ * Pink Noise Generator - 1/f noise (more realistic than white noise)
+ */
+export class PinkNoiseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ // Paul Kellet's refined method
+ this.b0 = 0;
+ this.b1 = 0;
+ this.b2 = 0;
+ this.b3 = 0;
+ this.b4 = 0;
+ this.b5 = 0;
+ this.b6 = 0;
+ }
+
+ sample() {
+ const white = Math.random() * 2 - 1;
+ this.b0 = 0.99886 * this.b0 + white * 0.0555179;
+ this.b1 = 0.99332 * this.b1 + white * 0.0750759;
+ this.b2 = 0.96900 * this.b2 + white * 0.1538520;
+ this.b3 = 0.86650 * this.b3 + white * 0.3104856;
+ this.b4 = 0.55000 * this.b4 + white * 0.5329522;
+ this.b5 = -0.7616 * this.b5 - white * 0.0168980;
+ const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362;
+ this.b6 = white * 0.115926;
+ return pink * 0.11; // Normalize
+ }
+}
+
+/**
+ * Perlin Noise Generator - Smooth, continuous noise
+ */
+export class PerlinNoiseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.frequency = config.frequency || 1.0;
+ this.octaves = config.octaves || 4;
+ this.persistence = config.persistence || 0.5;
+ }
+
+ // Simple 1D Perlin-like noise
+ noise(x) {
+ const i = Math.floor(x);
+ const f = x - i;
+
+ // Fade curve
+ const u = f * f * (3 - 2 * f);
+
+ // Hash function for pseudo-random gradients
+ const hash = (n) => {
+ n = (n << 13) ^ n;
+ return (1.0 - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824.0);
+ };
+
+ return (1 - u) * hash(i) + u * hash(i + 1);
+ }
+
+ sample() {
+ let value = 0;
+ let amplitude = 1;
+ let frequency = this.frequency;
+ let maxValue = 0;
+
+ for (let i = 0; i < this.octaves; i++) {
+ value += this.noise(this.time * frequency) * amplitude;
+ maxValue += amplitude;
+ amplitude *= this.persistence;
+ frequency *= 2;
+ }
+
+ return value / maxValue;
+ }
+}
+
+/**
+ * Pulse/Spike Generator - Random spikes/pulses
+ */
+export class PulseGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.pulseRate = config.pulseRate || 0.05; // Probability per sample
+ this.pulseWidth = config.pulseWidth || 0.01; // Duration in seconds
+ this.pulseAmplitude = config.pulseAmplitude || 1.0;
+ this.currentPulse = null;
+ }
+
+ sample() {
+ // Check if we're in a pulse
+ if (this.currentPulse) {
+ if (this.time >= this.currentPulse.endTime) {
+ this.currentPulse = null;
+ } else {
+ return this.pulseAmplitude;
+ }
+ }
+
+ // Random chance to start new pulse
+ if (Math.random() < this.pulseRate) {
+ this.currentPulse = {
+ startTime: this.time,
+ endTime: this.time + this.pulseWidth,
+ };
+ return this.pulseAmplitude;
+ }
+
+ return 0;
+ }
+}
+
+/**
+ * Burst Generator - Bursts of activity with quiet periods
+ */
+export class BurstGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.burstDuration = config.burstDuration || 1.0; // Seconds
+ this.quietDuration = config.quietDuration || 2.0; // Seconds
+ this.burstFrequency = config.burstFrequency || 5.0; // Hz during burst
+ this.currentState = 'quiet';
+ this.stateStartTime = 0;
+ }
+
+ sample() {
+ const elapsed = this.time - this.stateStartTime;
+
+ // State transitions
+ if (this.currentState === 'quiet' && elapsed >= this.quietDuration) {
+ this.currentState = 'burst';
+ this.stateStartTime = this.time;
+ } else if (this.currentState === 'burst' && elapsed >= this.burstDuration) {
+ this.currentState = 'quiet';
+ this.stateStartTime = this.time;
+ }
+
+ // Generate value based on state
+ if (this.currentState === 'burst') {
+ return Math.sin(2 * Math.PI * this.burstFrequency * this.time);
+ } else {
+ return 0;
+ }
+ }
+}
+
+/**
+ * Chirp Generator - Frequency sweep signal
+ */
+export class ChirpGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.startFreq = config.startFreq || 0.5; // Hz
+ this.endFreq = config.endFreq || 10.0; // Hz
+ this.duration = config.duration || 5.0; // Seconds
+ }
+
+ sample() {
+ const t = this.time % this.duration;
+ const progress = t / this.duration;
+ const freq = this.startFreq + (this.endFreq - this.startFreq) * progress;
+ return Math.sin(2 * Math.PI * freq * t);
+ }
+}
+
+/**
+ * Composite Generator - Combine multiple generators
+ */
+export class CompositeGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.generators = config.generators || [];
+ this.weights = config.weights || this.generators.map(() => 1.0);
+ }
+
+ sample() {
+ let sum = 0;
+ let weightSum = 0;
+
+ for (let i = 0; i < this.generators.length; i++) {
+ sum += this.generators[i].sample() * this.weights[i];
+ weightSum += this.weights[i];
+ }
+
+ return weightSum > 0 ? sum / weightSum : 0;
+ }
+
+ generateSamples(count) {
+ const samples = [];
+ for (let i = 0; i < count; i++) {
+ samples.push(this.sample());
+ // Advance all child generators
+ this.generators.forEach(gen => gen.time += 1 / gen.sampleRate);
+ }
+ return samples;
+ }
+}
+
+/**
+ * FM (Frequency Modulation) Generator - One signal modulates another
+ */
+export class FMGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.carrierFreq = config.carrierFreq || 5.0; // Hz
+ this.modulatorFreq = config.modulatorFreq || 0.5; // Hz
+ this.modulationIndex = config.modulationIndex || 2.0;
+ }
+
+ sample() {
+ const modulator = Math.sin(2 * Math.PI * this.modulatorFreq * this.time);
+ const instantFreq = this.carrierFreq + this.modulationIndex * modulator;
+ return Math.sin(2 * Math.PI * instantFreq * this.time);
+ }
+}
+
+/**
+ * Exponential Decay Generator - Exponentially decaying signal
+ */
+export class ExponentialDecayGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.decayRate = config.decayRate || 1.0; // 1/seconds
+ this.oscillationFreq = config.oscillationFreq || 5.0; // Hz
+ }
+
+ sample() {
+ const envelope = Math.exp(-this.decayRate * this.time);
+ const oscillation = Math.sin(2 * Math.PI * this.oscillationFreq * this.time);
+ return envelope * oscillation;
+ }
+}
+
+/**
+ * Step Function Generator - Random walk / brownian motion
+ */
+export class RandomWalkGenerator extends DataGenerator {
+ constructor(config = {}) {
+ super(config);
+ this.stepSize = config.stepSize || 0.1;
+ this.currentValue = 0;
+ this.bounds = config.bounds || { min: -5, max: 5 };
+ }
+
+ sample() {
+ // Random step
+ const step = (Math.random() - 0.5) * this.stepSize;
+ this.currentValue += step;
+
+ // Apply bounds
+ this.currentValue = Math.max(this.bounds.min, Math.min(this.bounds.max, this.currentValue));
+
+ return this.currentValue;
+ }
+}
+
+// ============================================================================
+// Example Usage and Presets
+// ============================================================================
+
+/**
+ * Factory function to create common test scenarios
+ */
+export class TestDataFactory {
+ static createSimpleSine(amplitude = 30) {
+ return new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: amplitude,
+ sampleRate: 100,
+ });
+ }
+
+ static createNoisySine(amplitude = 30) {
+ const sine = new SineWaveGenerator({
+ frequency: 2.0,
+ amplitude: amplitude * 0.8,
+ sampleRate: 100,
+ });
+
+ const noise = new WhiteNoiseGenerator({
+ amplitude: amplitude * 0.2,
+ sampleRate: 100,
+ });
+
+ return new CompositeGenerator({
+ generators: [sine, noise],
+ weights: [1.0, 1.0],
+ });
+ }
+
+ static createComplexPattern(amplitude = 30) {
+ const low = new SineWaveGenerator({
+ frequency: 0.5,
+ amplitude: amplitude * 0.4,
+ sampleRate: 100,
+ });
+
+ const mid = new SineWaveGenerator({
+ frequency: 3.0,
+ amplitude: amplitude * 0.3,
+ sampleRate: 100,
+ });
+
+ const high = new SineWaveGenerator({
+ frequency: 8.0,
+ amplitude: amplitude * 0.2,
+ sampleRate: 100,
+ });
+
+ const noise = new PinkNoiseGenerator({
+ amplitude: amplitude * 0.1,
+ sampleRate: 100,
+ });
+
+ return new CompositeGenerator({
+ generators: [low, mid, high, noise],
+ weights: [1.0, 1.0, 1.0, 1.0],
+ });
+ }
+
+ static createBurstySignal(amplitude = 30) {
+ return new BurstGenerator({
+ amplitude: amplitude,
+ burstDuration: 0.5,
+ quietDuration: 1.5,
+ burstFrequency: 10.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createSmoothNoise(amplitude = 30) {
+ return new PerlinNoiseGenerator({
+ amplitude: amplitude,
+ frequency: 2.0,
+ octaves: 3,
+ persistence: 0.5,
+ sampleRate: 100,
+ });
+ }
+
+ static createFrequencySweep(amplitude = 30) {
+ return new ChirpGenerator({
+ amplitude: amplitude,
+ startFreq: 0.5,
+ endFreq: 10.0,
+ duration: 3.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createModulatedSignal(amplitude = 30) {
+ return new FMGenerator({
+ amplitude: amplitude,
+ carrierFreq: 5.0,
+ modulatorFreq: 0.3,
+ modulationIndex: 3.0,
+ sampleRate: 100,
+ });
+ }
+
+ static createRandomWalk(amplitude = 30) {
+ return new RandomWalkGenerator({
+ stepSize: 0.5,
+ bounds: { min: -amplitude, max: amplitude },
+ sampleRate: 100,
+ });
+ }
+}
+
+/**
+ * Example: How to use with WaterfallGraph
+ *
+ * // Create a generator
+ * const generator = TestDataFactory.createComplexPattern(30);
+ *
+ * // In your graph's addLine method:
+ * addLine(time, graphIdx) {
+ * const line = {
+ * points: generator.generateLine(this.pointsPerLine, this.width),
+ * yOffset: 0,
+ * color: this.generateColor(time),
+ * };
+ * this.lines.push(line);
+ * }
+ *
+ * // Or generate custom samples:
+ * const samples = generator.generateSamples(100);
+ * const points = samples.map((y, i) => ({
+ * x: (i / samples.length) * width,
+ * y: y
+ * }));
+ */
diff --git a/web-timeplot/src/timeseries-plot.js b/web-timeplot/src/timeseries-plot.js
new file mode 100644
index 0000000..e35a704
--- /dev/null
+++ b/web-timeplot/src/timeseries-plot.js
@@ -0,0 +1,277 @@
+import { Container, Graphics, Text } from 'pixi.js';
+
+/**
+ * TimeSeriesPlot - Pure visualization component for time-series data
+ *
+ * This class is responsible ONLY for displaying data, not generating it.
+ * It receives data points from external sources and renders them as a
+ * scrolling waterfall display.
+ *
+ * Architecture:
+ * - TimeSeriesPlot: Displays data (this file)
+ * - DataSource: Generates/provides data (data-sources.js)
+ * - Connection: Links sources to plots
+ */
+export class TimeSeriesPlot {
+ constructor(config) {
+ this.x = config.x || 0;
+ this.y = config.y || 0;
+ this.width = config.width || 800;
+ this.height = config.height || 600;
+ this.title = config.title || 'Time Series';
+ this.baseColor = config.color || 0xff6666;
+
+ // Container for all graphics
+ this.container = new Container();
+ this.container.x = this.x;
+ this.container.y = this.y;
+
+ // Graphics layers (order matters for rendering)
+ this.gridGraphics = new Graphics();
+ this.linesGraphics = new Graphics();
+ this.borderGraphics = new Graphics();
+
+ this.container.addChild(this.gridGraphics);
+ this.container.addChild(this.linesGraphics);
+ this.container.addChild(this.borderGraphics);
+
+ // Title
+ this.titleText = new Text({
+ text: this.title,
+ style: {
+ fontFamily: 'Arial',
+ fontSize: 18,
+ fill: 0xeeeeee,
+ }
+ });
+ this.titleText.x = 10;
+ this.titleText.y = 10;
+ this.container.addChild(this.titleText);
+
+ // Display state
+ this.lines = []; // Array of {points, yOffset, color, metadata}
+ this.maxLines = config.maxLines || 100;
+ this.showGrid = config.showGrid !== false;
+
+ // Scrolling and scaling
+ this.scrollSpeed = config.scrollSpeed || 1.0;
+ this.verticalScale = config.verticalScale || 1.0;
+
+ // Initial draw
+ this.draw();
+ }
+
+ // ========================================================================
+ // Data Input API - This is how external sources send data to the plot
+ // ========================================================================
+
+ /**
+ * Add a new line of data to the plot
+ * @param {Array<{x: number, y: number}>} points - Array of points
+ * @param {Object} metadata - Optional metadata (color, timestamp, etc.)
+ */
+ addLine(points, metadata = {}) {
+ const line = {
+ points: points,
+ yOffset: 0,
+ color: metadata.color || this.generateColor(Date.now() / 1000),
+ timestamp: metadata.timestamp || Date.now(),
+ metadata: metadata,
+ };
+
+ this.lines.push(line);
+
+ // Limit number of lines
+ if (this.lines.length > this.maxLines) {
+ this.lines.shift();
+ }
+ }
+
+ /**
+ * Add a single data point (will be buffered into a line)
+ * This is useful for streaming real-time data
+ * @param {number} timestamp - Time of the data point
+ * @param {number} value - Value at this time
+ */
+ addDataPoint(timestamp, value) {
+ // For now, this creates a single-point line
+ // In a more sophisticated version, this could buffer points
+ // until a full line is ready
+ const point = { x: this.width / 2, y: value };
+ this.addLine([point], { timestamp });
+ }
+
+ /**
+ * Clear all data from the plot
+ */
+ clearData() {
+ this.lines = [];
+ this.drawLines();
+ }
+
+ // ========================================================================
+ // Update and Rendering
+ // ========================================================================
+
+ /**
+ * Update the plot - called each frame
+ * This handles scrolling and cleanup, but NOT data generation
+ */
+ update() {
+ // Scroll existing lines down
+ this.scrollLines();
+
+ // Remove off-screen lines
+ this.lines = this.lines.filter(line => {
+ const scaledOffset = line.yOffset * this.verticalScale;
+ return scaledOffset < this.height + 50;
+ });
+
+ // Redraw
+ this.drawLines();
+ }
+
+ scrollLines() {
+ this.lines.forEach(line => {
+ line.yOffset += this.scrollSpeed;
+ });
+ }
+
+ draw() {
+ this.drawBorder();
+ this.drawGrid();
+ this.drawLines();
+ }
+
+ drawBorder() {
+ this.borderGraphics.clear();
+ this.borderGraphics.rect(0, 0, this.width, this.height);
+ this.borderGraphics.stroke({ width: 2, color: 0x606070 });
+ }
+
+ drawGrid() {
+ this.gridGraphics.clear();
+
+ if (!this.showGrid) return;
+
+ this.gridGraphics.alpha = 0.3;
+
+ const divisions = 10;
+ const color = 0x4a7a9a;
+
+ // Vertical lines
+ for (let i = 0; i <= divisions; i++) {
+ const x = (i / divisions) * this.width;
+ this.gridGraphics.moveTo(x, 0);
+ this.gridGraphics.lineTo(x, this.height);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+
+ // Horizontal lines
+ for (let i = 0; i <= divisions; i++) {
+ const y = (i / divisions) * this.height;
+ this.gridGraphics.moveTo(0, y);
+ this.gridGraphics.lineTo(this.width, y);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+ }
+
+ drawLines() {
+ this.linesGraphics.clear();
+
+ for (const line of this.lines) {
+ if (line.points.length < 2) continue;
+
+ // Apply vertical scale to y positions
+ const scaledYOffset = line.yOffset * this.verticalScale;
+
+ // Start path
+ const firstPoint = line.points[0];
+ this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
+
+ // Draw line strip
+ for (let i = 1; i < line.points.length; i++) {
+ const point = line.points[i];
+ this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
+ }
+
+ this.linesGraphics.stroke({ width: 2, color: line.color });
+ }
+ }
+
+ generateColor(time) {
+ // Cycle through colors based on time
+ const hue = (time * 0.1) % 1.0;
+ const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
+ const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
+ const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
+
+ return (r << 16) | (g << 8) | b;
+ }
+
+ // ========================================================================
+ // Configuration and Control
+ // ========================================================================
+
+ setGridVisible(visible) {
+ this.showGrid = visible;
+ this.drawGrid();
+ }
+
+ setScrollSpeed(speed) {
+ this.scrollSpeed = Math.max(0.1, Math.min(10.0, speed));
+ }
+
+ setVerticalScale(scale) {
+ this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
+ }
+
+ setTitle(title) {
+ this.title = title;
+ this.titleText.text = title;
+ }
+
+ resize(x, y, width, height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+
+ this.container.x = x;
+ this.container.y = y;
+
+ this.draw();
+ }
+
+ // ========================================================================
+ // Statistics and Debugging
+ // ========================================================================
+
+ getVertexCount() {
+ return this.lines.reduce((sum, line) => sum + line.points.length, 0);
+ }
+
+ getLineCount() {
+ return this.lines.length;
+ }
+
+ getOldestTimestamp() {
+ if (this.lines.length === 0) return null;
+ return Math.min(...this.lines.map(l => l.timestamp));
+ }
+
+ getNewestTimestamp() {
+ if (this.lines.length === 0) return null;
+ return Math.max(...this.lines.map(l => l.timestamp));
+ }
+
+ getStats() {
+ return {
+ lineCount: this.getLineCount(),
+ vertexCount: this.getVertexCount(),
+ oldestTimestamp: this.getOldestTimestamp(),
+ newestTimestamp: this.getNewestTimestamp(),
+ timeSpan: this.getNewestTimestamp() - this.getOldestTimestamp(),
+ };
+ }
+}
diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js
index 8a1b216..ad29697 100644
--- a/web-timeplot/src/ui/panel-manager.js
+++ b/web-timeplot/src/ui/panel-manager.js
@@ -15,6 +15,39 @@ 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;
@@ -27,14 +60,20 @@ export class PanelManager {
const shell = createElement('div', 'timeplot-shell');
const topbar = createElement('header', 'timeplot-topbar');
const viewport = createElement('section', 'timeplot-viewport');
- const canvasHost = createElement('div', 'timeplot-canvas-host');
+ const 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 tooltip = createElement('div', 'timeplot-tooltip');
- tooltip.hidden = true;
+ 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', 'Restarted from scratch with a modular core');
+ const subtitle = createElement('div', 'timeplot-subtitle', 'Dual synchronized signal monitor');
brand.append(title, subtitle);
const toolbar = createElement('div', 'timeplot-toolbar');
@@ -44,7 +83,10 @@ export class PanelManager {
);
topbar.append(brand, toolbar);
- viewport.append(canvasHost, tooltip);
+ primaryPlotPanel.append(primaryCanvasHost, primaryTooltip);
+ secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip);
+ plotGrid.append(primaryPlotPanel, secondaryPlotPanel);
+ viewport.append(plotGrid);
shell.append(topbar, viewport, sidebar);
this.root.replaceChildren(shell);
@@ -53,9 +95,14 @@ export class PanelManager {
shell,
topbar,
viewport,
- canvasHost,
+ plotGrid,
+ primaryPlotPanel,
+ secondaryPlotPanel,
+ primaryCanvasHost,
+ secondaryCanvasHost,
sidebar,
- tooltip,
+ primaryTooltip,
+ secondaryTooltip,
title,
subtitle,
statusPanel: this.createStatusPanel(),
@@ -133,36 +180,142 @@ export class PanelManager {
const panel = createElement('section', 'panel');
panel.innerHTML = `
<h2>Data Source</h2>
- <div class="field-grid">
- <label>
- Preset
- <select data-source-field="preset">
- <option value="telemetry">Telemetry</option>
- <option value="chirp">Chirp</option>
- <option value="burst">Burst</option>
- </select>
- </label>
- <label>
- Sample rate (Hz)
- <input data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" />
- </label>
- <label>
- Amplitude
- <input data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" />
- </label>
- <label>
- Noise
- <input data-source-field="noise" type="number" min="0" max="0.5" step="0.01" />
- </label>
+ <div 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) => {
- input.addEventListener('change', () => {
+ 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 rawValue = input.value;
- const value = input.tagName === 'SELECT' ? rawValue : Number(rawValue);
- this.actions.updateSource(field, value);
+ 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 = '';
});
});
@@ -191,16 +344,60 @@ export class PanelManager {
<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) => {
- input.addEventListener('change', () => {
+ const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
const field = input.getAttribute('data-plot-field');
- const value = input.type === 'checkbox' ? input.checked : Number(input.value);
+ 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;
}
@@ -209,10 +406,11 @@ export class PanelManager {
panel.innerHTML = `
<h2>Help</h2>
<ol class="help-list">
- <li>Hover the plot to inspect a sample.</li>
- <li>Use Pause and the speed slider to inspect timing behavior.</li>
- <li>Toggle panels from the top bar to focus the workspace.</li>
- <li>Swap presets to exercise the data input system.</li>
+ <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;
@@ -220,9 +418,10 @@ export class PanelManager {
sync(state, visiblePoints) {
this.elements.title.textContent = state.app.title;
- this.elements.subtitle.textContent = 'Synthetic time-series workspace with modular systems';
+ this.elements.subtitle.textContent = 'Dual synchronized signal monitor';
this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause';
- this.elements.speedInput.value = String(state.time.speed);
+ 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]');
@@ -232,7 +431,9 @@ export class PanelManager {
fieldMap.realElapsed.textContent = formatDuration(state.time.realElapsedMs);
fieldMap.plotTime.textContent = formatDuration(state.time.plotTimeMs);
fieldMap.playback.textContent = state.time.paused ? 'Paused' : `${state.time.speed.toFixed(1)}×`;
- fieldMap.points.textContent = `${visiblePoints}`;
+ fieldMap.points.textContent = typeof visiblePoints === 'object'
+ ? `${visiblePoints.primary} / ${visiblePoints.secondary}`
+ : `${visiblePoints}`;
this.syncSourcePanel(state);
this.syncConfigPanel(state);
@@ -241,17 +442,61 @@ export class PanelManager {
}
syncSourcePanel(state) {
- this.elements.sourcePanel.querySelector('[data-source-field="preset"]').value = state.source.preset;
- this.elements.sourcePanel.querySelector('[data-source-field="sampleRateHz"]').value = String(state.source.sampleRateHz);
- this.elements.sourcePanel.querySelector('[data-source-field="amplitude"]').value = String(state.source.amplitude);
- this.elements.sourcePanel.querySelector('[data-source-field="noise"]').value = String(state.source.noise);
+ 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) {
- this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]').value = String(state.plot.windowDurationMs);
- this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]').value = String(state.plot.maxPoints);
- this.elements.configPanel.querySelector('[data-plot-field="showGrid"]').checked = state.plot.showGrid;
- this.elements.configPanel.querySelector('[data-plot-field="showPoints"]').checked = state.plot.showPoints;
+ 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) {
@@ -270,18 +515,28 @@ export class PanelManager {
syncTooltip(state) {
const tooltipState = state.plot.tooltip;
- this.elements.tooltip.hidden = !tooltipState.visible || !tooltipState.point;
- if (this.elements.tooltip.hidden) {
+ this.elements.primaryTooltip.hidden = true;
+ this.elements.secondaryTooltip.hidden = true;
+
+ if (!tooltipState.visible || !tooltipState.point) {
return;
}
- this.elements.tooltip.style.left = `${tooltipState.x}px`;
- this.elements.tooltip.style.top = `${tooltipState.y}px`;
- this.elements.tooltip.innerHTML = `
+ 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/waterfall.js b/web-timeplot/src/waterfall.js
new file mode 100644
index 0000000..bce0750
--- /dev/null
+++ b/web-timeplot/src/waterfall.js
@@ -0,0 +1,219 @@
+import { Container, Graphics, Text } from 'pixi.js';
+
+/**
+ * WaterfallGraph - A scrolling waterfall display
+ * Starts simple with basic line rendering
+ */
+export class WaterfallGraph {
+ constructor(config) {
+ this.x = config.x;
+ this.y = config.y;
+ this.width = config.width;
+ this.height = config.height;
+ this.title = config.title;
+ this.baseColor = config.color || 0xff6666;
+
+ this.container = new Container();
+ this.container.x = this.x;
+ this.container.y = this.y;
+
+ // Graphics layers
+ this.borderGraphics = new Graphics();
+ this.gridGraphics = new Graphics();
+ this.linesGraphics = new Graphics();
+
+ this.container.addChild(this.gridGraphics);
+ this.container.addChild(this.linesGraphics);
+ this.container.addChild(this.borderGraphics);
+
+ // Title text
+ this.titleText = new Text({
+ text: this.title,
+ style: {
+ fontFamily: 'Arial',
+ fontSize: 18,
+ fill: 0xeeeeee,
+ }
+ });
+ this.titleText.x = 10;
+ this.titleText.y = 10;
+ this.container.addChild(this.titleText);
+
+ // Waterfall data
+ this.lines = [];
+ this.maxLines = 50;
+ this.pointsPerLine = 100;
+ this.frameCounter = 0;
+
+ this.showGrid = true;
+
+ // Time scaling and zoom
+ this.scrollSpeed = 1.0; // Speed multiplier for scrolling
+ this.baseScrollSpeed = 1.0;
+ this.verticalScale = 1.0; // Vertical zoom: >1 = zoomed in (see less history), <1 = zoomed out (see more)
+
+ this.draw();
+ }
+
+ draw() {
+ this.drawBorder();
+ this.drawGrid();
+ }
+
+ drawBorder() {
+ this.borderGraphics.clear();
+ this.borderGraphics.rect(0, 0, this.width, this.height);
+ this.borderGraphics.stroke({ width: 2, color: 0x606070 });
+ }
+
+ drawGrid() {
+ this.gridGraphics.clear();
+
+ if (!this.showGrid) return;
+
+ this.gridGraphics.alpha = 0.3;
+
+ const divisions = 10;
+ const color = 0x4a7a9a;
+
+ // Vertical lines
+ for (let i = 0; i <= divisions; i++) {
+ const x = (i / divisions) * this.width;
+ this.gridGraphics.moveTo(x, 0);
+ this.gridGraphics.lineTo(x, this.height);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+
+ // Horizontal lines
+ for (let i = 0; i <= divisions; i++) {
+ const y = (i / divisions) * this.height;
+ this.gridGraphics.moveTo(0, y);
+ this.gridGraphics.lineTo(this.width, y);
+ this.gridGraphics.stroke({ width: 1, color });
+ }
+ }
+
+ update(time, graphIdx) {
+ this.frameCounter++;
+
+ // Add new line every 10 frames
+ if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) {
+ this.addLine(time, graphIdx);
+ }
+
+ // Scroll existing lines down
+ this.scrollLines();
+
+ // Remove off-screen lines
+ this.lines = this.lines.filter(line => line.yOffset < this.height + 50);
+
+ // Redraw all lines
+ this.drawLines();
+ }
+
+ addLine(time, graphIdx) {
+ const line = {
+ points: [],
+ yOffset: 0,
+ color: this.generateColor(time),
+ };
+
+ // Generate sine wave points
+ const phase = time + (graphIdx * 2);
+ const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0;
+
+ for (let i = 0; i < this.pointsPerLine; i++) {
+ const x = (i / this.pointsPerLine) * this.width;
+ const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1
+ const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels
+
+ line.points.push({ x, y });
+ }
+
+ this.lines.push(line);
+ }
+
+ scrollLines() {
+ const speed = this.baseScrollSpeed * this.scrollSpeed;
+ this.lines.forEach(line => {
+ line.yOffset += speed;
+ });
+ }
+
+ setScrollSpeed(speed) {
+ // Clamp between 0.1 (slow) and 5.0 (fast)
+ this.scrollSpeed = Math.max(0.1, Math.min(5.0, speed));
+ }
+
+ getScrollSpeed() {
+ return this.scrollSpeed;
+ }
+
+ setVerticalScale(scale) {
+ // Clamp between 0.2 (zoomed out, see more history) and 3.0 (zoomed in, see less)
+ this.verticalScale = Math.max(0.2, Math.min(3.0, scale));
+ }
+
+ getVerticalScale() {
+ return this.verticalScale;
+ }
+
+ drawLines() {
+ this.linesGraphics.clear();
+
+ for (const line of this.lines) {
+ if (line.points.length < 2) continue;
+
+ // Apply vertical scale to y positions
+ // Current time is at top (y=0), older data has larger yOffset
+ const scaledYOffset = line.yOffset * this.verticalScale;
+
+ // Start path
+ const firstPoint = line.points[0];
+ this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset);
+
+ // Draw line strip
+ for (let i = 1; i < line.points.length; i++) {
+ const point = line.points[i];
+ this.linesGraphics.lineTo(point.x, point.y + scaledYOffset);
+ }
+
+ this.linesGraphics.stroke({ width: 2, color: line.color });
+ }
+ }
+
+ generateColor(time) {
+ // Cycle through colors based on time
+ const hue = (time * 0.1) % 1.0;
+ const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255);
+ const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255);
+ const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255);
+
+ return (r << 16) | (g << 8) | b;
+ }
+
+ setGridVisible(visible) {
+ this.showGrid = visible;
+ this.drawGrid();
+ }
+
+ resize(x, y, width, height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+
+ this.container.x = x;
+ this.container.y = y;
+
+ this.draw();
+ }
+
+ getVertexCount() {
+ return this.lines.reduce((sum, line) => sum + line.points.length, 0);
+ }
+
+ getLineCount() {
+ return this.lines.length;
+ }
+}