From 27dc5849c3eaf4824d79938e7077abdbe2c82e24 Mon Sep 17 00:00:00 2001 From: grothedev Date: Fri, 29 May 2026 21:34:16 -0400 Subject: updates from claude. need to review. archiving rust and cpp stuff, going completely TS --- web-timeplot/.gitignore | 1 + web-timeplot/ARCHITECTURE.md | 37 +- web-timeplot/README.md | 81 ++- web-timeplot/WEBSOCKET_FORMAT.md | 117 ++++ web-timeplot/package-lock.json | 24 +- web-timeplot/package.json | 6 +- web-timeplot/public/demo-data/chirp-ramp.csv | 47 ++ web-timeplot/public/demo-data/step-bursts.csv | 42 ++ web-timeplot/public/demo-data/telemetry-sweep.csv | 42 ++ web-timeplot/scripts/demo-websocket-server.mjs | 131 ++++ web-timeplot/src/app/create-app.js | 361 ++++++++++- web-timeplot/src/core/store.js | 212 ++++++- web-timeplot/src/data-sources.js | 517 ++++++++++++++++ web-timeplot/src/data/csv-replay-source.js | 60 ++ web-timeplot/src/data/parse-replay-csv.js | 108 ++++ web-timeplot/src/data/source-registry.js | 85 ++- web-timeplot/src/data/synthetic-wave-source.js | 1 + web-timeplot/src/data/websocket-source.js | 224 +++++++ web-timeplot/src/demos.js | 697 ++++++++++++++++++++++ web-timeplot/src/example-usage.js | 535 +++++++++++++++++ web-timeplot/src/metrics.js | 142 +++++ web-timeplot/src/plot-connections.js | 392 ++++++++++++ web-timeplot/src/plot/timeplot-view.js | 360 ++++++++--- web-timeplot/src/state.js | 420 +++++++++++++ web-timeplot/src/styles.css | 232 +++++-- web-timeplot/src/template-for-standard-site.js | 75 +++ web-timeplot/src/test-data-generators.js | 530 ++++++++++++++++ web-timeplot/src/timeseries-plot.js | 277 +++++++++ web-timeplot/src/ui/panel-manager.js | 363 +++++++++-- web-timeplot/src/waterfall.js | 219 +++++++ 30 files changed, 6075 insertions(+), 263 deletions(-) create mode 100644 web-timeplot/.gitignore create mode 100644 web-timeplot/WEBSOCKET_FORMAT.md create mode 100644 web-timeplot/public/demo-data/chirp-ramp.csv create mode 100644 web-timeplot/public/demo-data/step-bursts.csv create mode 100644 web-timeplot/public/demo-data/telemetry-sweep.csv create mode 100644 web-timeplot/scripts/demo-websocket-server.mjs create mode 100644 web-timeplot/src/data-sources.js create mode 100644 web-timeplot/src/data/csv-replay-source.js create mode 100644 web-timeplot/src/data/parse-replay-csv.js create mode 100644 web-timeplot/src/data/websocket-source.js create mode 100644 web-timeplot/src/demos.js create mode 100644 web-timeplot/src/example-usage.js create mode 100644 web-timeplot/src/metrics.js create mode 100644 web-timeplot/src/plot-connections.js create mode 100644 web-timeplot/src/state.js create mode 100644 web-timeplot/src/template-for-standard-site.js create mode 100644 web-timeplot/src/test-data-generators.js create mode 100644 web-timeplot/src/timeseries-plot.js create mode 100644 web-timeplot/src/waterfall.js (limited to 'web-timeplot') 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} 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 = `

Data Source

-
- - - - +
+
Signal A
+
+ +
+
+ + + + +
+
+ + +
+
+
+ + +
+
+
+
+
Signal B
+
+ +
+
+ + + + +
+
+ + +
+
+
+ + +
+
`; 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 {
+
+
Graph routing
+
+ + + + +
+
`; 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 = `

Help

    -
  1. Hover the plot to inspect a sample.
  2. -
  3. Use Pause and the speed slider to inspect timing behavior.
  4. -
  5. Toggle panels from the top bar to focus the workspace.
  6. -
  7. Swap presets to exercise the data input system.
  8. +
  9. Each signal can be synthetic or file-backed CSV replay.
  10. +
  11. Each graph can target Signal A or Signal B independently.
  12. +
  13. Each graph can render raw, delta, or smoothed data.
  14. +
  15. Hover either trace to inspect the nearest synchronized sample.
  16. +
  17. Use pause and speed controls to inspect timing behavior.
`; 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 + ? `${sourceConfig.loadError}` + : `${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: ${sourceConfig.wsStatus || 'idle'}${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 = `
Hovered sample
+
Panel${tooltipState.panelLabel ?? 'Primary'}
Plot time${formatDuration(tooltipState.point.timeMs)}
Value${formatValue(tooltipState.point.value)}
Source${tooltipState.point.sourceId}
+ ${tooltipState.linkedPoint ? `
Linked panel${tooltipState.linkedPanelLabel ?? 'Linked'}
` : ''} + ${tooltipState.linkedPoint ? `
Linked value${formatValue(tooltipState.linkedPoint.value)}
` : ''} `; } } 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; + } +} -- cgit v1.2.3