diff options
| author | grothedev <grothedev@gmail.com> | 2026-05-29 21:49:20 -0400 |
|---|---|---|
| committer | grothedev <grothedev@gmail.com> | 2026-05-29 21:49:20 -0400 |
| commit | 6196004b51a6850909c154f5402ff4858eab479a (patch) | |
| tree | 126b8bb1600d0a656e0df016e25d08c390f3540e /web-timeplot | |
| parent | 27dc5849c3eaf4824d79938e7077abdbe2c82e24 (diff) | |
mv web stuff to root project dirHEADprototypeframeworkmain
Diffstat (limited to 'web-timeplot')
41 files changed, 0 insertions, 8859 deletions
diff --git a/web-timeplot/.gitignore b/web-timeplot/.gitignore deleted file mode 100644 index 8b13789..0000000 --- a/web-timeplot/.gitignore +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web-timeplot/AGENTS.md b/web-timeplot/AGENTS.md deleted file mode 100644 index 4926365..0000000 --- a/web-timeplot/AGENTS.md +++ /dev/null @@ -1,46 +0,0 @@ -## Features to implement -### some of the features that i want to implement next - -- time speed adjustment -- pause and resume time -- label of current real time -- label of current plot time -- hover tooltip for data points -- data input system -- multiple panels that can be toggled on and off - - use extension scaffolding src as example https://github.com/MoebiusSolutions/extension-scaffold/tree/main - - possibly use ES directly, but ideally make own system if it can be better and simpler -- config panel - -## Systems - dividing up concerns in a sensible, performant, and easy-to-maintain-and-work-with way -### my initial thinking on the concerns of different architectural components. this very well may change, and each thing does not necessarily map to its own file or even its own class. - -- local configuration: read a config file -- state management: - - user preferences - - service config (websocket url, remote api endpoints) - - UI config (what panels acive, ) - - database stuff - - plots (which attributes of which input data-type are which graph axes, current time range viewable, ) - - data input stuff (structure of input datapoints, source, metadata) - - health (service connections, framerate, db access) - - -- application event system (to notify of state updates) -- rendering -- input data processing -- HID input handling - - input controller maps -- plot handling -- DB handling -- disk handling -- service handling (websockets, mqtt, HTTP REST) - -## Some important data structures used in the program - -- input actions: a set of "single action that the user can perform" -- user input: a single event of user input via hardware like key press or click or move joystick -- input action map: a map of "set of "user input"" => "input action" -- ui element definition: - -### Notes -- we will need a good system for state management, as the state will grow fairly large, including things like user preferences, UI config (what panels are displayed),
\ No newline at end of file diff --git a/web-timeplot/ARCHITECTURE.md b/web-timeplot/ARCHITECTURE.md deleted file mode 100644 index 73c4cb6..0000000 --- a/web-timeplot/ARCHITECTURE.md +++ /dev/null @@ -1,194 +0,0 @@ -# TimePlot Architecture - -## Overview - -The restarted TimePlot app is built around five small systems: - -1. **Store** — single source of truth for app state -2. **Time controller** — advances real time and plot time -3. **Source registry** — owns active data source lifecycle -4. **Plot view** — renders visible samples and handles hover picking -5. **Panel manager** — builds the DOM shell and user controls - -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 -TimeController.tick() - ↓ -Store.time updated - ↓ -SourceRegistry.update(plotTime) - ↓ -Synthetic / CSV replay / WebSocket sources emit samples - ↓ -PlotBuffer stores bounded history - ↓ -TimeplotView renders visible window - ↓ -PanelManager reflects status + tooltip -``` - -## Core principles - -### 1. Time is explicit - -Plot time is not inferred from frame count or rendering. It is advanced by `TimeController`, which makes features like pause, speed changes, replay, and stepping straightforward. - -### 2. Data sources are replaceable - -`SourceRegistry` talks to the active source through a tiny shared contract: - -- `start(startTimeMs)` -- `update(currentPlotTimeMs)` -- `reset(startTimeMs)` -- `updateConfig(partialConfig)` - -That keeps future WebSocket, file replay, database, or simulated sources easy to add. - -### 3. Rendering stays focused - -`TimeplotView` does not own application state or source orchestration. It receives state plus visible points and turns that into pixels. Hover detection also lives close to rendering because it depends on screen-space positions. - -### 4. UI panels stay in the DOM - -The plot is GPU-rendered with PixiJS. Controls, labels, and config panels stay in regular DOM so they are easy to iterate on, inspect, and restyle. - -### 5. Composition happens at the edge - -`create-app.js` is the composition root. It wires together store, time, sources, plot, UI, keyboard shortcuts, and the frame loop. That keeps the rest of the modules simple and testable. - -## Current state shape - -```js -{ - app: { title, renderer }, - time: { - realNowMs, - realElapsedMs, - plotTimeMs, - speed, - paused, - }, - plot: { - showGrid, - showPoints, - windowDurationMs, - maxPoints, - valueRange, - hoveredPoint, - tooltip, - }, - sources: { - signalA: { - type, - preset, - sampleRateHz, - amplitude, - noise, - replayRate, - wsUrl, - wsReconnectMs, - }, - signalB: { - ... - }, - }, - graphs: { - primary: { sourceKey, transform, title }, - secondary: { sourceKey, transform, title }, - }, - panels: { - status, - source, - config, - help, - }, -} -``` - -## Modules - -### `src/core/store.js` - -A tiny centralized store. It currently favors clarity over abstraction-heavy patterns. - -### `src/core/time-controller.js` - -Owns playback semantics: - -- pause/resume -- speed control -- plot time reset -- frame-to-frame delta handling - -### `src/data/synthetic-wave-source.js` - -Generates sample streams from a preset waveform. Right now it supports: - -- `telemetry` -- `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. - -### `src/plot/timeplot-view.js` - -Owns Pixi initialization, plotting, grid drawing, and nearest-point hover selection. - -### `src/ui/panel-manager.js` - -Creates: - -- top transport bar -- panel toggle buttons -- status panel -- data source panel -- config panel -- help panel -- floating tooltip - -## Why this is a better baseline - -The old project had useful ideas but too many concerns were mixed together. The new baseline is better because: - -- transport logic is separate from rendering -- 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 - -### Near term - -- add persisted settings for panel visibility and playback preferences -- support multiple plot panes from a shared timebase -- add line/series definitions instead of a single hard-coded signal - -### Medium term - -- add schema-aware input adapters -- add WebSocket and replay-file sources -- add panel docking/layout persistence -- add markers, cursors, and annotations - -### Longer term - -- multi-stream synchronization -- richer interaction model for HID input mapping -- plug-in style source and panel registration diff --git a/web-timeplot/PROTOTYPING.md b/web-timeplot/PROTOTYPING.md deleted file mode 100644 index e220f9a..0000000 --- a/web-timeplot/PROTOTYPING.md +++ /dev/null @@ -1,146 +0,0 @@ -# PixiJS Prototyping Framework - -A minimal PixiJS framework with core architecture patterns (DOM initialization, Service initialization, State management) for rapid prototyping. - -## Quick Start - -```bash -npm run dev -``` - -Open browser to `http://localhost:5173/` - -## Architecture - -The framework follows a clean initialization pattern: - -1. **DOM Initialization** - Reference DOM elements -2. **Renderer Initialization** - Set up PixiJS with WebGPU/WebGL -3. **Services Initialization** - Start the update loop - -## Global Objects (Available in Console) - -- `window.PIXI` - Complete PixiJS namespace -- `window.pixiApp` - PixiJS Application instance -- `window.state` - StateManager instance (reactive state) -- `window.log` - Logger function - -## Rapid Prototyping Examples - -### Example 1: Draw a Rectangle - -Open browser console: - -```javascript -const graphics = new PIXI.Graphics(); -graphics.rect(100, 100, 200, 150); -graphics.fill(0xff0000); -pixiApp.stage.addChild(graphics); -``` - -### Example 2: Animated Sprite - -```javascript -const graphics = new PIXI.Graphics(); -graphics.circle(0, 0, 50); -graphics.fill(0x00ff00); -pixiApp.stage.addChild(graphics); - -// Add to update loop in main.js: -// graphics.x = Math.sin(state.state.time.current) * 200 + pixiApp.screen.width / 2; -// graphics.y = pixiApp.screen.height / 2; -``` - -### Example 3: Using State System - -The framework includes a reactive state manager: - -```javascript -// Listen to state changes -state.on('time.current', ({ value }) => { - console.log('Time:', value); -}); - -// Modify state (triggers listeners) -state.state.time.speed = 2.0; // Double speed - -// Toggle pause -state.togglePause(); -``` - -### Example 4: Register Input Actions - -```javascript -// In main.js, add to setupControls(): -state.registerAction('myAction', () => { - log('Action triggered!'); -}); - -state.mapKey('KeyP', 'myAction'); -``` - -## Modifying the Update Loop - -Edit `/src/main.js` function `update()`: - -```javascript -function update() { - state.incrementTime(0.016); // ~60fps increment - state.updateRealElapsed(); - state.state.rendering.frameCounter++; - - // YOUR PROTOTYPE CODE GOES HERE - // Example: - mySprite.rotation += 0.01; - myGraphics.x = Math.sin(state.state.time.current) * 100; -} -``` - -## State Structure - -```javascript -state.state = { - userPrefs: { - showGrid: true, - showMetrics: true, - theme: 'dark', - // ... persisted to localStorage - }, - - uiConfig: { - canvasWidth: number, - canvasHeight: number, - // ... - }, - - time: { - current: number, // Increments every frame - realElapsed: number, // Real seconds since start - speed: number, // Time multiplier - isPaused: boolean, - }, - - rendering: { - rendererType: 'webgpu' | 'webgl', - frameCounter: number, - }, - - health: { - fps: number, - updateMs: number, - renderMs: number, - }, -} -``` - -## Tips - -1. **Use the console** - All major objects are exposed globally -2. **Hot reload** - Vite will automatically reload on file changes -3. **State persistence** - userPrefs automatically save to localStorage -4. **Responsive** - Canvas automatically resizes with window -5. **WebGPU fallback** - Automatically falls back to WebGL if WebGPU unavailable - -## Clean Slate - -The framework intentionally draws nothing by default. Start adding your PixiJS objects and see results immediately. diff --git a/web-timeplot/README.md b/web-timeplot/README.md deleted file mode 100644 index 25dfb80..0000000 --- a/web-timeplot/README.md +++ /dev/null @@ -1,147 +0,0 @@ -# TimePlot - -TimePlot is now a clean restart: a small PixiJS time-series sandbox built around a simple state core, a pluggable data source layer, and toggleable UI panels. - -## What it does - -- Real-time scrolling plot with PixiJS -- Pause/resume plot time -- Adjustable playback speed -- 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 -bun install -bun run dev -``` - -Production build: - -```bash -bun run build -bun run preview -``` - -Demo WebSocket source: - -```bash -bun run ws:demo -``` - -## Controls - -- `Space` — pause/resume -- `[` — slow down playback -- `]` — speed up playback -- `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 -src/ -├── app/ -│ └── create-app.js # application composition root -├── core/ -│ ├── event-bus.js # lightweight pub/sub -│ ├── store.js # centralized app state -│ └── 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 -│ └── websocket-source.js -├── plot/ -│ ├── plot-buffer.js # bounded in-memory sample history -│ └── timeplot-view.js # Pixi rendering + hover picking -├── ui/ -│ └── panel-manager.js # DOM shell, controls, panels, tooltip -├── bootstrap.js # startup entry -├── 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 - -This restart intentionally optimizes for a strong foundation instead of feature sprawl: - -- transport and time are first-class systems -- data generation is isolated from rendering -- 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 - -- 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 -- persistent user settings diff --git a/web-timeplot/WEBSOCKET_FORMAT.md b/web-timeplot/WEBSOCKET_FORMAT.md deleted file mode 100644 index 93eead2..0000000 --- a/web-timeplot/WEBSOCKET_FORMAT.md +++ /dev/null @@ -1,117 +0,0 @@ -# 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/bun.lock b/web-timeplot/bun.lock deleted file mode 100644 index 82f672d..0000000 --- a/web-timeplot/bun.lock +++ /dev/null @@ -1,150 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "web-timeplot", - "dependencies": { - "pixi.js": "^8.0.0", - }, - "devDependencies": { - "vite": "^5.0.0", - }, - }, - }, - "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - - "@pixi/colord": ["@pixi/colord@2.9.6", "", {}, "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.3", "", { "os": "android", "cpu": "arm" }, "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.3", "", { "os": "android", "cpu": "arm64" }, "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.3", "", { "os": "linux", "cpu": "arm" }, "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.3", "", { "os": "linux", "cpu": "none" }, "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.3", "", { "os": "none", "cpu": "arm64" }, "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.3", "", { "os": "win32", "cpu": "x64" }, "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.3", "", { "os": "win32", "cpu": "x64" }, "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA=="], - - "@types/css-font-loading-module": ["@types/css-font-loading-module@0.0.12", "", {}, "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA=="], - - "@types/earcut": ["@types/earcut@3.0.0", "", {}, "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@webgpu/types": ["@webgpu/types@0.1.65", "", {}, "sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - - "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], - - "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gifuct-js": ["gifuct-js@2.1.2", "", { "dependencies": { "js-binary-schema-parser": "^2.0.3" } }, "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg=="], - - "ismobilejs": ["ismobilejs@1.1.1", "", {}, "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="], - - "js-binary-schema-parser": ["js-binary-schema-parser@2.0.3", "", {}, "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "pixi.js": ["pixi.js@8.13.2", "", { "dependencies": { "@pixi/colord": "^2.9.6", "@types/css-font-loading-module": "^0.0.12", "@types/earcut": "^3.0.0", "@webgpu/types": "^0.1.40", "@xmldom/xmldom": "^0.8.10", "earcut": "^3.0.2", "eventemitter3": "^5.0.1", "gifuct-js": "^2.1.2", "ismobilejs": "^1.1.1", "parse-svg-path": "^0.1.2", "tiny-lru": "^11.4.5" } }, "sha512-9KVGZ4a99TA5SwUEWs9m5gliX6XUCS1aGc/DOPsXxpqLMDRa+FhzpT5ao9z1UwLYJkSvt3rcQs+aZXECBHSSHg=="], - - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "rollup": ["rollup@4.52.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.3", "@rollup/rollup-android-arm64": "4.52.3", "@rollup/rollup-darwin-arm64": "4.52.3", "@rollup/rollup-darwin-x64": "4.52.3", "@rollup/rollup-freebsd-arm64": "4.52.3", "@rollup/rollup-freebsd-x64": "4.52.3", "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", "@rollup/rollup-linux-arm-musleabihf": "4.52.3", "@rollup/rollup-linux-arm64-gnu": "4.52.3", "@rollup/rollup-linux-arm64-musl": "4.52.3", "@rollup/rollup-linux-loong64-gnu": "4.52.3", "@rollup/rollup-linux-ppc64-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-gnu": "4.52.3", "@rollup/rollup-linux-riscv64-musl": "4.52.3", "@rollup/rollup-linux-s390x-gnu": "4.52.3", "@rollup/rollup-linux-x64-gnu": "4.52.3", "@rollup/rollup-linux-x64-musl": "4.52.3", "@rollup/rollup-openharmony-arm64": "4.52.3", "@rollup/rollup-win32-arm64-msvc": "4.52.3", "@rollup/rollup-win32-ia32-msvc": "4.52.3", "@rollup/rollup-win32-x64-gnu": "4.52.3", "@rollup/rollup-win32-x64-msvc": "4.52.3", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "tiny-lru": ["tiny-lru@11.4.5", "", {}, "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw=="], - - "vite": ["vite@5.4.20", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g=="], - } -} diff --git a/web-timeplot/index.html b/web-timeplot/index.html deleted file mode 100644 index 76e8b87..0000000 --- a/web-timeplot/index.html +++ /dev/null @@ -1,12 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>TimePlot</title> -</head> -<body> - <div id="app"></div> - <script type="module" src="/src/bootstrap.js"></script> -</body> -</html> diff --git a/web-timeplot/package-lock.json b/web-timeplot/package-lock.json deleted file mode 100644 index 7ce7bea..0000000 --- a/web-timeplot/package-lock.json +++ /dev/null @@ -1,1071 +0,0 @@ -{ - "name": "web-timeplot", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "web-timeplot", - "version": "0.1.0", - "dependencies": { - "pixi.js": "^8.0.0", - "ws": "^8.20.0" - }, - "devDependencies": { - "vite": "^5.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@pixi/colord": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", - "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", - "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", - "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", - "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", - "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", - "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", - "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", - "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", - "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", - "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", - "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", - "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", - "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", - "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", - "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", - "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", - "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", - "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", - "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", - "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", - "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", - "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", - "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/css-font-loading-module": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", - "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT" - }, - "node_modules/@types/earcut": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", - "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webgpu/types": { - "version": "0.1.65", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.65.tgz", - "integrity": "sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==", - "license": "BSD-3-Clause" - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gifuct-js": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", - "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", - "license": "MIT", - "dependencies": { - "js-binary-schema-parser": "^2.0.3" - } - }, - "node_modules/ismobilejs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", - "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", - "license": "MIT" - }, - "node_modules/js-binary-schema-parser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", - "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/parse-svg-path": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", - "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/pixi.js": { - "version": "8.13.2", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.13.2.tgz", - "integrity": "sha512-9KVGZ4a99TA5SwUEWs9m5gliX6XUCS1aGc/DOPsXxpqLMDRa+FhzpT5ao9z1UwLYJkSvt3rcQs+aZXECBHSSHg==", - "license": "MIT", - "dependencies": { - "@pixi/colord": "^2.9.6", - "@types/css-font-loading-module": "^0.0.12", - "@types/earcut": "^3.0.0", - "@webgpu/types": "^0.1.40", - "@xmldom/xmldom": "^0.8.10", - "earcut": "^3.0.2", - "eventemitter3": "^5.0.1", - "gifuct-js": "^2.1.2", - "ismobilejs": "^1.1.1", - "parse-svg-path": "^0.1.2", - "tiny-lru": "^11.4.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/pixijs" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", - "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.3", - "@rollup/rollup-android-arm64": "4.52.3", - "@rollup/rollup-darwin-arm64": "4.52.3", - "@rollup/rollup-darwin-x64": "4.52.3", - "@rollup/rollup-freebsd-arm64": "4.52.3", - "@rollup/rollup-freebsd-x64": "4.52.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", - "@rollup/rollup-linux-arm-musleabihf": "4.52.3", - "@rollup/rollup-linux-arm64-gnu": "4.52.3", - "@rollup/rollup-linux-arm64-musl": "4.52.3", - "@rollup/rollup-linux-loong64-gnu": "4.52.3", - "@rollup/rollup-linux-ppc64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-musl": "4.52.3", - "@rollup/rollup-linux-s390x-gnu": "4.52.3", - "@rollup/rollup-linux-x64-gnu": "4.52.3", - "@rollup/rollup-linux-x64-musl": "4.52.3", - "@rollup/rollup-openharmony-arm64": "4.52.3", - "@rollup/rollup-win32-arm64-msvc": "4.52.3", - "@rollup/rollup-win32-ia32-msvc": "4.52.3", - "@rollup/rollup-win32-x64-gnu": "4.52.3", - "@rollup/rollup-win32-x64-msvc": "4.52.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tiny-lru": { - "version": "11.4.5", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", - "integrity": "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/vite": { - "version": "5.4.20", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", - "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "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 deleted file mode 100644 index 9f4220f..0000000 --- a/web-timeplot/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "web-timeplot", - "version": "0.1.0", - "description": "PixiJS waterfall display with WebGPU/WebGL", - "type": "module", - "scripts": { - "dev": "vite --host", - "build": "vite build", - "preview": "vite preview", - "ws:demo": "node ./scripts/demo-websocket-server.mjs" - }, - "devDependencies": { - "vite": "^5.0.0" - }, - "dependencies": { - "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 deleted file mode 100644 index 5e81c10..0000000 --- a/web-timeplot/public/demo-data/chirp-ramp.csv +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index e9dbc3e..0000000 --- a/web-timeplot/public/demo-data/step-bursts.csv +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 8c7d6e3..0000000 --- a/web-timeplot/public/demo-data/telemetry-sweep.csv +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 1bee865..0000000 --- a/web-timeplot/scripts/demo-websocket-server.mjs +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 4f4f0fc..0000000 --- a/web-timeplot/src/app/create-app.js +++ /dev/null @@ -1,449 +0,0 @@ -import { EventBus } from '../core/event-bus.js'; -import { Store, createInitialState } from '../core/store.js'; -import { TimeController } from '../core/time-controller.js'; -import { PlotBuffer } from '../plot/plot-buffer.js'; -import { TimeplotView } from '../plot/timeplot-view.js'; -import { SourceRegistry } from '../data/source-registry.js'; -import { parseReplayCsv } from '../data/parse-replay-csv.js'; -import { PanelManager } from '../ui/panel-manager.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function buildDeltaPoints(points) { - if (points.length < 2) { - return []; - } - - const derived = []; - for (let index = 1; index < points.length; index += 1) { - const previous = points[index - 1]; - const current = points[index]; - const deltaTime = Math.max(1, current.timeMs - previous.timeMs); - derived.push({ - ...current, - value: (current.value - previous.value) / deltaTime * 1000, - sourceId: `${current.sourceId}:delta`, - }); - } - - return derived; -} - -function buildSmoothedPoints(points, windowSize = 5) { - if (points.length === 0) { - return []; - } - - const smoothed = []; - for (let index = 0; index < points.length; index += 1) { - const start = Math.max(0, index - windowSize + 1); - const windowPoints = points.slice(start, index + 1); - const average = windowPoints.reduce((sum, point) => sum + point.value, 0) / windowPoints.length; - smoothed.push({ - ...points[index], - value: average, - sourceId: `${points[index].sourceId}:smooth`, - }); - } - - return smoothed; -} - -function transformPoints(points, transform) { - switch (transform) { - case 'delta': - return buildDeltaPoints(points); - case 'smooth': - return buildSmoothedPoints(points); - case 'raw': - default: - return points; - } -} - -function describeTransform(transform) { - switch (transform) { - case 'delta': - return 'Δvalue / second'; - case 'smooth': - return 'moving average'; - case 'raw': - default: - return 'raw signal'; - } -} - -function deriveValueRange(points, fallbackRange) { - if (points.length === 0) { - return fallbackRange; - } - - let min = Infinity; - let max = -Infinity; - for (const point of points) { - min = Math.min(min, point.value); - max = Math.max(max, point.value); - } - - const maxAbs = Math.max(Math.abs(min), Math.abs(max), 0.1); - return { - min: -maxAbs, - max: maxAbs, - }; -} - -function pickActiveHover(primaryCandidate, secondaryCandidate) { - if (!primaryCandidate && !secondaryCandidate) { - return null; - } - - if (primaryCandidate && !secondaryCandidate) { - return primaryCandidate; - } - - if (!primaryCandidate && secondaryCandidate) { - return secondaryCandidate; - } - - return primaryCandidate.lastPointerEventAt >= secondaryCandidate.lastPointerEventAt - ? primaryCandidate - : secondaryCandidate; -} - -export async function createApp(root) { - const bus = new EventBus(); - const store = new Store(createInitialState()); - const timeController = new TimeController(store); - const sourceBuffers = new Map(Object.keys(store.getState().sources).map((sourceKey) => [sourceKey, new PlotBuffer(store.getState().plot.maxPoints)])); - let sourceRegistry; - - const syncBuffersFromState = () => { - const state = store.getState(); - for (const sourceKey of Object.keys(state.sources)) { - if (!sourceBuffers.has(sourceKey)) { - sourceBuffers.set(sourceKey, new PlotBuffer(state.plot.maxPoints)); - } - sourceBuffers.get(sourceKey).maxPoints = state.plot.maxPoints; - } - - for (const sourceKey of Array.from(sourceBuffers.keys())) { - if (!state.sources[sourceKey]) { - sourceBuffers.delete(sourceKey); - } - } - }; - - const clearSourceBuffer = (sourceKey) => { - sourceBuffers.get(sourceKey)?.clear(); - }; - - const getGraphPoints = (state, graphId) => { - const graphConfig = state.graphs[graphId]; - const sourceBuffer = sourceBuffers.get(graphConfig.sourceKey); - const basePoints = sourceBuffer - ? sourceBuffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs) - : []; - const transformedPoints = transformPoints(basePoints, graphConfig.transform); - return { - graphConfig, - points: transformedPoints, - range: deriveValueRange(transformedPoints, state.plot.valueRange), - }; - }; - - const actions = { - togglePause: () => timeController.togglePause(), - setSpeed: (speed) => timeController.setSpeed(speed), - resetScene: () => { - timeController.reset(); - sourceBuffers.forEach((plotBuffer) => plotBuffer.clear()); - sourceRegistry.reset(); - }, - togglePanel: (panelId) => { - store.setState((state) => ({ - ...state, - panels: { - ...state.panels, - [panelId]: { - ...state.panels[panelId], - visible: !state.panels[panelId].visible, - }, - }, - })); - }, - updateSource: (sourceKey, field, value) => { - store.setState((state) => ({ - ...state, - sources: { - ...state.sources, - [sourceKey]: { - ...state.sources[sourceKey], - [field]: value, - ...(field === 'type' - ? { - loadError: value === 'csv-replay' && state.sources[sourceKey].dataset.length === 0 - ? (state.sources[sourceKey].dataFileName - ? `Reload ${state.sources[sourceKey].dataFileName} to restore replay data` - : 'Load a CSV file to begin replay') - : '', - wsStatus: value === 'websocket' ? state.sources[sourceKey].wsStatus : 'idle', - wsStatusDetail: value === 'websocket' ? state.sources[sourceKey].wsStatusDetail : '', - } - : {}), - }, - }, - })); - sourceRegistry.syncFromState(); - syncBuffersFromState(); - - if (field === 'type' || field === 'wsUrl' || field === 'wsReconnectMs') { - clearSourceBuffer(sourceKey); - sourceRegistry.reset(); - } - }, - loadSourceFile: async (sourceKey, file) => { - try { - const state = store.getState(); - const sampleRateHz = state.sources[sourceKey]?.sampleRateHz ?? 60; - const text = await file.text(); - const { points, metadata } = parseReplayCsv(text, { sampleRateHz }); - - clearSourceBuffer(sourceKey); - store.setState((currentState) => ({ - ...currentState, - sources: { - ...currentState.sources, - [sourceKey]: { - ...currentState.sources[sourceKey], - type: 'csv-replay', - dataset: points, - dataFileName: file.name, - datasetPointCount: metadata.pointCount, - datasetDurationMs: metadata.durationMs, - loadError: '', - wsStatus: 'idle', - wsStatusDetail: '', - }, - }, - })); - sourceRegistry.syncFromState(); - sourceRegistry.reset(); - } catch (error) { - store.setState((currentState) => ({ - ...currentState, - sources: { - ...currentState.sources, - [sourceKey]: { - ...currentState.sources[sourceKey], - loadError: error instanceof Error ? error.message : String(error), - }, - }, - })); - } - }, - updatePlot: (field, value) => { - store.setState((state) => ({ - ...state, - plot: { - ...state.plot, - [field]: value, - }, - })); - - if (field === 'maxPoints') { - buffer.maxPoints = clamp(value, 200, 4000); - sourceBuffers.forEach((plotBuffer) => { - plotBuffer.maxPoints = clamp(value, 200, 4000); - }); - } - }, - updateGraph: (graphId, field, value) => { - store.setState((state) => ({ - ...state, - graphs: { - ...state.graphs, - [graphId]: { - ...state.graphs[graphId], - [field]: value, - }, - }, - })); - }, - }; - - const panelManager = new PanelManager({ root, store, actions }); - const elements = panelManager.mount(); - - const plotView = new TimeplotView({ - host: elements.primaryCanvasHost, - panelId: 'primary', - title: 'Primary signal', - subtitle: null, - showReadouts: true, - lineColor: 0x9fd1ff, - pointColor: 0xe7f2ff, - }); - - const secondaryPlotView = new TimeplotView({ - host: elements.secondaryCanvasHost, - panelId: 'secondary', - title: 'Secondary signal', - subtitle: null, - showReadouts: false, - lineColor: 0xffc46b, - pointColor: 0xffe1b0, - }); - - const renderer = await plotView.init(); - await secondaryPlotView.init(); - store.patch({ - app: { - ...store.getState().app, - renderer, - }, - }); - - sourceRegistry = new SourceRegistry(store, bus); - - bus.on('data:point', (point) => { - sourceBuffers.get(point.sourceId)?.addPoint(point); - }); - - const keyHandler = (event) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement) { - return; - } - - if (event.code === 'Space') { - event.preventDefault(); - actions.togglePause(); - return; - } - - if (event.key === '[') { - actions.setSpeed(store.getState().time.speed - 0.1); - return; - } - - if (event.key === ']') { - actions.setSpeed(store.getState().time.speed + 0.1); - return; - } - - if (event.key.toLowerCase() === 'g') { - actions.updatePlot('showGrid', !store.getState().plot.showGrid); - } - }; - - window.addEventListener('keydown', keyHandler); - - plotView.app.ticker.add(() => { - timeController.tick(); - sourceRegistry.syncFromState(); - syncBuffersFromState(); - sourceRegistry.update(store.getState().time.plotTimeMs); - - const state = store.getState(); - const primaryGraph = getGraphPoints(state, 'primary'); - const secondaryGraph = getGraphPoints(state, 'secondary'); - - plotView.panelTitle = state.graphs.primary.title; - plotView.panelSubtitle = `${state.sources[state.graphs.primary.sourceKey].label} · ${describeTransform(state.graphs.primary.transform)} · time ↓`; - secondaryPlotView.panelTitle = state.graphs.secondary.title; - secondaryPlotView.panelSubtitle = `${state.sources[state.graphs.secondary.sourceKey].label} · ${describeTransform(state.graphs.secondary.transform)} · time ↓`; - - const primaryState = { - ...state, - plot: { - ...state.plot, - valueRange: primaryGraph.range, - }, - }; - - const secondaryState = { - ...state, - plot: { - ...state.plot, - valueRange: secondaryGraph.range, - }, - }; - - plotView.render(primaryState, primaryGraph.points); - secondaryPlotView.render(secondaryState, secondaryGraph.points); - - const primaryHover = plotView.getHoverCandidate(); - const secondaryHover = secondaryPlotView.getHoverCandidate(); - const activeHover = pickActiveHover(primaryHover, secondaryHover); - - if (!activeHover) { - plotView.clearHover(); - secondaryPlotView.clearHover(); - store.setState((currentState) => ({ - ...currentState, - plot: { - ...currentState.plot, - hoveredPoint: null, - tooltip: { - ...currentState.plot.tooltip, - visible: false, - point: null, - linkedPoint: null, - }, - }, - })); - panelManager.sync(store.getState(), { - primary: primaryGraph.points.length, - secondary: secondaryGraph.points.length, - }); - return; - } - - const primaryLinkedPoint = plotView.findNearestScreenPointByTime(activeHover.point.timeMs); - const secondaryLinkedPoint = secondaryPlotView.findNearestScreenPointByTime(activeHover.point.timeMs); - - plotView.renderLinkedHover(primaryLinkedPoint); - secondaryPlotView.renderLinkedHover(secondaryLinkedPoint); - - const activePanelLabel = activeHover.panelId === 'secondary' - ? state.graphs.secondary.title - : state.graphs.primary.title; - const linkedPoint = activeHover.panelId === 'secondary' ? primaryLinkedPoint : secondaryLinkedPoint; - const linkedPanelLabel = activeHover.panelId === 'secondary' - ? state.graphs.primary.title - : state.graphs.secondary.title; - - store.setState((currentState) => ({ - ...currentState, - plot: { - ...currentState.plot, - hoveredPoint: activeHover.point, - tooltip: { - ...currentState.plot.tooltip, - visible: true, - panelId: activeHover.panelId, - panelLabel: activePanelLabel, - x: activeHover.x, - y: activeHover.y, - point: activeHover.point, - linkedPoint, - linkedPanelLabel, - }, - }, - })); - - panelManager.sync(store.getState(), { - primary: primaryGraph.points.length, - secondary: secondaryGraph.points.length, - }); - }); - - return { - destroy() { - window.removeEventListener('keydown', keyHandler); - plotView.destroy(); - secondaryPlotView.destroy(); - }, - }; -} diff --git a/web-timeplot/src/bootstrap.js b/web-timeplot/src/bootstrap.js deleted file mode 100644 index 4b073bc..0000000 --- a/web-timeplot/src/bootstrap.js +++ /dev/null @@ -1,18 +0,0 @@ -import './styles.css'; -import { createApp } from './app/create-app.js'; - -const root = document.getElementById('app'); - -if (!root) { - throw new Error('App root not found'); -} - -createApp(root).catch((error) => { - console.error('Failed to start TimePlot', error); - root.innerHTML = ` - <div style="padding: 24px; color: #fff; font-family: sans-serif;"> - <h1>TimePlot failed to start</h1> - <pre>${String(error)}</pre> - </div> - `; -}); diff --git a/web-timeplot/src/core/event-bus.js b/web-timeplot/src/core/event-bus.js deleted file mode 100644 index 192eb6d..0000000 --- a/web-timeplot/src/core/event-bus.js +++ /dev/null @@ -1,32 +0,0 @@ -export class EventBus { - constructor() { - this.listeners = new Map(); - } - - on(eventName, listener) { - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, new Set()); - } - - const listeners = this.listeners.get(eventName); - listeners.add(listener); - - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - this.listeners.delete(eventName); - } - }; - } - - emit(eventName, payload) { - const listeners = this.listeners.get(eventName); - if (!listeners) { - return; - } - - for (const listener of listeners) { - listener(payload); - } - } -} diff --git a/web-timeplot/src/core/store.js b/web-timeplot/src/core/store.js deleted file mode 100644 index 38052eb..0000000 --- a/web-timeplot/src/core/store.js +++ /dev/null @@ -1,291 +0,0 @@ -const STORAGE_KEY = 'timeplot.app-state.v1'; - -function clonePanelState(panels) { - return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }])); -} - -function cloneNamedState(items) { - return Object.fromEntries(Object.entries(items).map(([key, value]) => [key, { ...value }])); -} - -function sanitizePersistedSource(source) { - return { - type: source.type, - preset: source.preset, - sampleRateHz: source.sampleRateHz, - amplitude: source.amplitude, - noise: source.noise, - replayRate: source.replayRate, - dataFileName: source.dataFileName, - wsUrl: source.wsUrl, - wsReconnectMs: source.wsReconnectMs, - }; -} - -function createPersistableState(state) { - return { - plot: { - showGrid: state.plot.showGrid, - showPoints: state.plot.showPoints, - windowDurationMs: state.plot.windowDurationMs, - maxPoints: state.plot.maxPoints, - }, - time: { - speed: state.time.speed, - }, - panels: clonePanelState(state.panels), - graphs: cloneNamedState(state.graphs), - sources: Object.fromEntries(Object.entries(state.sources).map(([key, value]) => [ - key, - sanitizePersistedSource(value), - ])), - }; -} - -function mergePersistedState(baseState, persistedState) { - if (!persistedState || typeof persistedState !== 'object') { - return baseState; - } - - const mergedState = { - ...baseState, - time: persistedState.time - ? { - ...baseState.time, - speed: persistedState.time.speed ?? baseState.time.speed, - paused: false, - } - : baseState.time, - plot: persistedState.plot - ? { - ...baseState.plot, - ...persistedState.plot, - valueRange: baseState.plot.valueRange, - hoveredPoint: null, - tooltip: { ...baseState.plot.tooltip }, - } - : baseState.plot, - panels: persistedState.panels - ? clonePanelState(Object.fromEntries(Object.entries(baseState.panels).map(([key, value]) => [ - key, - { - ...value, - ...(persistedState.panels[key] ?? {}), - }, - ]))) - : baseState.panels, - graphs: persistedState.graphs - ? cloneNamedState(Object.fromEntries(Object.entries(baseState.graphs).map(([key, value]) => [ - key, - { - ...value, - ...(persistedState.graphs[key] ?? {}), - }, - ]))) - : baseState.graphs, - sources: persistedState.sources - ? Object.fromEntries(Object.entries(baseState.sources).map(([key, value]) => { - const persistedSource = persistedState.sources[key] ?? {}; - const nextType = persistedSource.type ?? value.type; - - return [ - key, - { - ...value, - ...persistedSource, - type: nextType, - dataset: [], - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: nextType === 'csv-replay' && persistedSource.dataFileName - ? `Reload ${persistedSource.dataFileName} to restore replay data` - : '', - wsStatus: 'idle', - wsStatusDetail: '', - }, - ]; - })) - : baseState.sources, - }; - - return mergedState; -} - -function loadPersistedState() { - if (typeof localStorage === 'undefined') { - return null; - } - - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - return null; - } - - return JSON.parse(raw); - } catch (error) { - console.warn('[timeplot] failed to load persisted state', error); - return null; - } -} - -function savePersistedState(state) { - if (typeof localStorage === 'undefined') { - return; - } - - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(createPersistableState(state))); - } catch (error) { - console.warn('[timeplot] failed to persist state', error); - } -} - -export function createInitialState() { - return { - app: { - title: 'TimePlot', - renderer: 'pending', - }, - time: { - realNowMs: Date.now(), - realElapsedMs: 0, - plotTimeMs: 0, - speed: 1, - paused: false, - }, - plot: { - showGrid: true, - showPoints: true, - windowDurationMs: 20000, - maxPoints: 1600, - valueRange: { - min: -1.6, - max: 1.6, - }, - hoveredPoint: null, - tooltip: { - visible: false, - x: 0, - y: 0, - point: null, - }, - }, - sources: { - signalA: { - id: 'signal-a', - label: 'Signal A', - type: 'synthetic-wave', - preset: 'telemetry', - sampleRateHz: 60, - amplitude: 1, - noise: 0.08, - replayRate: 1, - dataset: [], - dataFileName: '', - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: '', - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - wsStatus: 'idle', - wsStatusDetail: '', - }, - signalB: { - id: 'signal-b', - label: 'Signal B', - type: 'synthetic-wave', - preset: 'chirp', - sampleRateHz: 48, - amplitude: 0.8, - noise: 0.04, - replayRate: 1, - dataset: [], - dataFileName: '', - datasetPointCount: 0, - datasetDurationMs: 0, - loadError: '', - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - wsStatus: 'idle', - wsStatusDetail: '', - }, - }, - graphs: { - primary: { - sourceKey: 'signalA', - transform: 'raw', - title: 'Primary signal', - }, - secondary: { - sourceKey: 'signalB', - transform: 'delta', - title: 'Secondary signal', - }, - }, - panels: { - status: { title: 'Status', visible: true }, - source: { title: 'Data Source', visible: true }, - config: { title: 'Config', visible: true }, - help: { title: 'Help', visible: false }, - }, - }; -} - -export class Store { - constructor(initialState = createInitialState()) { - this.state = mergePersistedState(initialState, loadPersistedState()); - this.listeners = new Set(); - } - - getState() { - return this.state; - } - - subscribe(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - setState(updater) { - const nextState = typeof updater === 'function' ? updater(this.state) : updater; - this.state = nextState; - savePersistedState(this.state); - for (const listener of this.listeners) { - listener(this.state); - } - } - - patch(partial) { - this.setState((state) => ({ - ...state, - ...partial, - time: partial.time ? { ...state.time, ...partial.time } : state.time, - plot: partial.plot - ? { - ...state.plot, - ...partial.plot, - valueRange: partial.plot.valueRange - ? { ...state.plot.valueRange, ...partial.plot.valueRange } - : state.plot.valueRange, - tooltip: partial.plot.tooltip - ? { ...state.plot.tooltip, ...partial.plot.tooltip } - : state.plot.tooltip, - } - : state.plot, - sources: partial.sources - ? Object.fromEntries(Object.entries({ ...state.sources, ...partial.sources }).map(([key, value]) => [ - key, - { ...state.sources[key], ...value }, - ])) - : state.sources, - graphs: partial.graphs - ? cloneNamedState(Object.fromEntries(Object.entries({ ...state.graphs, ...partial.graphs }).map(([key, value]) => [ - key, - { ...state.graphs[key], ...value }, - ]))) - : state.graphs, - panels: partial.panels ? clonePanelState({ ...state.panels, ...partial.panels }) : state.panels, - })); - } -} diff --git a/web-timeplot/src/core/time-controller.js b/web-timeplot/src/core/time-controller.js deleted file mode 100644 index 7cd57c7..0000000 --- a/web-timeplot/src/core/time-controller.js +++ /dev/null @@ -1,80 +0,0 @@ -export class TimeController { - constructor(store) { - this.store = store; - this.lastFrameTime = performance.now(); - } - - tick(now = performance.now()) { - const deltaMs = now - this.lastFrameTime; - this.lastFrameTime = now; - - this.store.setState((state) => { - const realElapsedMs = state.time.realElapsedMs + deltaMs; - const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed; - - return { - ...state, - time: { - ...state.time, - realNowMs: Date.now(), - realElapsedMs, - plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs), - }, - }; - }); - - return deltaMs; - } - - togglePause() { - this.store.setState((state) => ({ - ...state, - time: { - ...state.time, - paused: !state.time.paused, - }, - })); - } - - setPaused(paused) { - this.store.setState((state) => ({ - ...state, - time: { - ...state.time, - paused, - }, - })); - } - - setSpeed(speed) { - const clampedSpeed = Math.max(0.1, Math.min(12, speed)); - this.store.setState((state) => ({ - ...state, - time: { - ...state.time, - speed: clampedSpeed, - }, - })); - } - - reset() { - this.store.setState((state) => ({ - ...state, - time: { - ...state.time, - realElapsedMs: 0, - plotTimeMs: 0, - }, - plot: { - ...state.plot, - hoveredPoint: null, - tooltip: { - ...state.plot.tooltip, - visible: false, - point: null, - }, - }, - })); - this.lastFrameTime = performance.now(); - } -} diff --git a/web-timeplot/src/data-sources.js b/web-timeplot/src/data-sources.js deleted file mode 100644 index 749a151..0000000 --- a/web-timeplot/src/data-sources.js +++ /dev/null @@ -1,517 +0,0 @@ -/** - * Data Sources - Components that generate or provide data to plots - * - * This module implements the data provider side of the architecture. - * Data sources know how to generate or fetch data, but don't know - * anything about visualization. - * - * Architecture: - * - DataSource: Base class with event emitting - * - Specific sources: Implement different data generation strategies - * - Connection: Links sources to plots (see plot-connections.js) - */ - -// Simple EventEmitter (same as in state.js, could be extracted to utils) -class EventEmitter { - constructor() { - this.events = new Map(); - } - - on(event, callback) { - if (!this.events.has(event)) { - this.events.set(event, []); - } - this.events.get(event).push(callback); - return () => this.off(event, callback); - } - - off(event, callback) { - if (!this.events.has(event)) return; - const callbacks = this.events.get(event); - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - } - - emit(event, data) { - if (!this.events.has(event)) return; - this.events.get(event).forEach(callback => { - try { - callback(data); - } catch (e) { - console.error(`[DataSource] Error in event handler for '${event}':`, e); - } - }); - } -} - -/** - * Base class for all data sources - * - * Events emitted: - * - 'line': {points: Array, timestamp: number, metadata: Object} - * - 'point': {value: number, timestamp: number} - * - 'error': {error: Error} - */ -export class DataSource extends EventEmitter { - constructor(config = {}) { - super(); - this.config = config; - this.isRunning = false; - this.time = 0; - } - - /** - * Start generating/providing data - */ - start() { - this.isRunning = true; - } - - /** - * Stop generating/providing data - */ - stop() { - this.isRunning = false; - } - - /** - * Reset the data source to initial state - */ - reset() { - this.time = 0; - } - - /** - * Emit a complete line of data - */ - emitLine(points, metadata = {}) { - this.emit('line', { - points, - timestamp: metadata.timestamp || Date.now(), - metadata, - }); - } - - /** - * Emit a single data point - */ - emitPoint(value, timestamp = Date.now()) { - this.emit('point', { - value, - timestamp, - }); - } - - /** - * Emit an error - */ - emitError(error) { - this.emit('error', { error }); - } -} - -/** - * Synthetic data source using test generators - * Uses the generators from test-data-generators.js - */ -export class SyntheticDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.generator = config.generator; // Instance of DataGenerator - this.pointsPerLine = config.pointsPerLine || 100; - this.width = config.width || 800; - this.lineInterval = config.lineInterval || 100; // ms between lines - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - // Generate a new line periodically - this.intervalHandle = setInterval(() => { - this.generateAndEmitLine(); - }, this.lineInterval); - - // Generate initial line immediately - this.generateAndEmitLine(); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitLine() { - if (!this.generator) { - this.emitError(new Error('No generator configured')); - return; - } - - const points = this.generator.generateLine(this.pointsPerLine, this.width); - this.emitLine(points, { - timestamp: Date.now(), - generatorType: this.generator.constructor.name, - }); - } - - setGenerator(generator) { - this.generator = generator; - } -} - -/** - * Function-based data source - * Evaluates a user-provided function to generate data - */ -export class FunctionDataSource extends DataSource { - constructor(config = {}) { - super(config); - // Function should have signature: (x, t) => y - // x: normalized position 0-1 - // t: time in seconds - // returns: y value - this.func = config.func || ((x, t) => Math.sin(x * 10 + t)); - this.pointsPerLine = config.pointsPerLine || 100; - this.width = config.width || 800; - this.amplitude = config.amplitude || 30; - this.lineInterval = config.lineInterval || 100; - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - this.intervalHandle = setInterval(() => { - this.generateAndEmitLine(); - }, this.lineInterval); - - this.generateAndEmitLine(); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitLine() { - const points = []; - const t = this.time; - - for (let i = 0; i < this.pointsPerLine; i++) { - const x = (i / this.pointsPerLine) * this.width; - const normalizedX = i / this.pointsPerLine; - const y = this.func(normalizedX, t) * this.amplitude; - points.push({ x, y }); - } - - this.emitLine(points, { - timestamp: Date.now(), - time: t, - }); - - this.time += this.lineInterval / 1000; - } - - setFunction(func) { - this.func = func; - } -} - -/** - * Streaming data source - * Emits individual data points that get buffered into lines - */ -export class StreamingDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.generator = config.generator; - this.sampleRate = config.sampleRate || 60; // Samples per second - this.intervalHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - - const intervalMs = 1000 / this.sampleRate; - this.intervalHandle = setInterval(() => { - this.generateAndEmitPoint(); - }, intervalMs); - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - generateAndEmitPoint() { - if (!this.generator) { - this.emitError(new Error('No generator configured')); - return; - } - - const value = this.generator.sample(); - this.generator.time += 1 / this.generator.sampleRate; - this.emitPoint(value, Date.now()); - } - - setGenerator(generator) { - this.generator = generator; - } -} - -/** - * WebSocket data source (for real data) - * Receives data from a WebSocket connection - */ -export class WebSocketDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.url = config.url; - this.socket = null; - this.reconnectInterval = config.reconnectInterval || 5000; - this.reconnectHandle = null; - } - - start() { - if (this.isRunning) return; - super.start(); - this.connect(); - } - - stop() { - super.stop(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - if (this.reconnectHandle) { - clearTimeout(this.reconnectHandle); - this.reconnectHandle = null; - } - } - - connect() { - try { - this.socket = new WebSocket(this.url); - - this.socket.onopen = () => { - console.log(`[WebSocketDataSource] Connected to ${this.url}`); - }; - - this.socket.onmessage = (event) => { - this.handleMessage(event.data); - }; - - this.socket.onerror = (error) => { - console.error('[WebSocketDataSource] Error:', error); - this.emitError(error); - }; - - this.socket.onclose = () => { - console.log('[WebSocketDataSource] Connection closed'); - if (this.isRunning) { - // Auto-reconnect - this.reconnectHandle = setTimeout(() => { - this.connect(); - }, this.reconnectInterval); - } - }; - } catch (error) { - console.error('[WebSocketDataSource] Failed to connect:', error); - this.emitError(error); - } - } - - handleMessage(data) { - try { - const parsed = JSON.parse(data); - - // Expect format: {type: 'line', points: [...]} or {type: 'point', value: ...} - if (parsed.type === 'line' && parsed.points) { - this.emitLine(parsed.points, parsed.metadata || {}); - } else if (parsed.type === 'point' && parsed.value !== undefined) { - this.emitPoint(parsed.value, parsed.timestamp); - } else { - console.warn('[WebSocketDataSource] Unknown message format:', parsed); - } - } catch (error) { - console.error('[WebSocketDataSource] Failed to parse message:', error); - this.emitError(error); - } - } - - send(data) { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSON.stringify(data)); - } - } -} - -/** - * CSV File data source - * Reads data from CSV files (for replay/analysis) - */ -export class CSVDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.data = []; // Parsed CSV data - this.currentIndex = 0; - this.playbackRate = config.playbackRate || 1.0; - this.loop = config.loop || false; - this.intervalHandle = null; - } - - /** - * Load CSV data from a string - * Expected format: timestamp,value or x,y format - */ - loadCSV(csvString) { - const lines = csvString.trim().split('\n'); - const headers = lines[0].split(',').map(h => h.trim()); - - this.data = []; - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(',').map(v => parseFloat(v.trim())); - if (values.length >= 2 && !values.some(isNaN)) { - this.data.push({ - timestamp: values[0], - value: values[1], - }); - } - } - - console.log(`[CSVDataSource] Loaded ${this.data.length} data points`); - } - - start() { - if (this.isRunning || this.data.length === 0) return; - super.start(); - - // Play back at specified rate - this.intervalHandle = setInterval(() => { - this.emitNextPoint(); - }, 16 / this.playbackRate); // ~60fps adjusted by playback rate - } - - stop() { - super.stop(); - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = null; - } - } - - reset() { - super.reset(); - this.currentIndex = 0; - } - - emitNextPoint() { - if (this.currentIndex >= this.data.length) { - if (this.loop) { - this.currentIndex = 0; - } else { - this.stop(); - return; - } - } - - const point = this.data[this.currentIndex]; - this.emitPoint(point.value, point.timestamp); - this.currentIndex++; - } -} - -/** - * Multi-source combiner - * Combines data from multiple sources - */ -export class CompositeDataSource extends DataSource { - constructor(config = {}) { - super(config); - this.sources = config.sources || []; - this.combineMode = config.combineMode || 'average'; // 'average', 'sum', 'max', 'min' - this.pointBuffer = new Map(); // sourceId => latest point - } - - start() { - if (this.isRunning) return; - super.start(); - - // Subscribe to all sources - this.sources.forEach((source, idx) => { - source.on('point', (data) => { - this.handleSourcePoint(idx, data); - }); - source.on('line', (data) => { - this.handleSourceLine(idx, data); - }); - source.start(); - }); - } - - stop() { - super.stop(); - this.sources.forEach(source => source.stop()); - } - - handleSourcePoint(sourceIdx, data) { - this.pointBuffer.set(sourceIdx, data.value); - - // If we have data from all sources, combine and emit - if (this.pointBuffer.size === this.sources.length) { - const combined = this.combineValues(Array.from(this.pointBuffer.values())); - this.emitPoint(combined, data.timestamp); - } - } - - handleSourceLine(sourceIdx, data) { - // For lines, just pass through for now - // Could implement line combination if needed - this.emitLine(data.points, data.metadata); - } - - combineValues(values) { - switch (this.combineMode) { - case 'sum': - return values.reduce((a, b) => a + b, 0); - case 'average': - return values.reduce((a, b) => a + b, 0) / values.length; - case 'max': - return Math.max(...values); - case 'min': - return Math.min(...values); - default: - return values[0]; - } - } - - addSource(source) { - this.sources.push(source); - if (this.isRunning) { - source.start(); - } - } - - removeSource(source) { - const idx = this.sources.indexOf(source); - if (idx > -1) { - source.stop(); - this.sources.splice(idx, 1); - } - } -} diff --git a/web-timeplot/src/data/base-source.js b/web-timeplot/src/data/base-source.js deleted file mode 100644 index 55dbdc3..0000000 --- a/web-timeplot/src/data/base-source.js +++ /dev/null @@ -1,21 +0,0 @@ -export class BaseSource { - constructor(config = {}) { - this.config = { ...config }; - this.running = false; - } - - start() { - this.running = true; - } - - stop() { - this.running = false; - } - - updateConfig(nextConfig) { - this.config = { - ...this.config, - ...nextConfig, - }; - } -} diff --git a/web-timeplot/src/data/csv-replay-source.js b/web-timeplot/src/data/csv-replay-source.js deleted file mode 100644 index c4e6a66..0000000 --- a/web-timeplot/src/data/csv-replay-source.js +++ /dev/null @@ -1,60 +0,0 @@ -import { BaseSource } from './base-source.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -export class CsvReplaySource extends BaseSource { - constructor(config = {}) { - super({ - replayRate: 1, - dataset: [], - ...config, - }); - this.sourceType = 'csv-replay'; - this.nextPointIndex = 0; - } - - start(startTimeMs = 0) { - super.start(); - this.reset(startTimeMs); - } - - reset() { - this.nextPointIndex = 0; - } - - updateConfig(nextConfig) { - const datasetChanged = nextConfig.dataset !== this.config.dataset; - super.updateConfig(nextConfig); - if (datasetChanged) { - this.reset(); - } - } - - update(currentPlotTimeMs) { - if (!this.running || !Array.isArray(this.config.dataset) || this.config.dataset.length === 0) { - return []; - } - - const replayRate = clamp(this.config.replayRate ?? 1, 0.1, 8); - const targetDatasetTimeMs = currentPlotTimeMs * replayRate; - const points = []; - - while (this.nextPointIndex < this.config.dataset.length) { - const datasetPoint = this.config.dataset[this.nextPointIndex]; - if (datasetPoint.timeMs > targetDatasetTimeMs) { - break; - } - - points.push({ - timeMs: datasetPoint.timeMs / replayRate, - value: datasetPoint.value, - sourceId: this.config.id ?? 'csv-replay', - }); - this.nextPointIndex += 1; - } - - return points; - } -} diff --git a/web-timeplot/src/data/parse-replay-csv.js b/web-timeplot/src/data/parse-replay-csv.js deleted file mode 100644 index b6ce97a..0000000 --- a/web-timeplot/src/data/parse-replay-csv.js +++ /dev/null @@ -1,108 +0,0 @@ -function splitRow(line) { - return line.split(/[;,\t]/).map((value) => value.trim()); -} - -function isNumeric(value) { - return value !== '' && Number.isFinite(Number(value)); -} - -function detectHeader(rows) { - if (rows.length === 0) { - return { hasHeader: false, headers: [] }; - } - - const [firstRow] = rows; - const hasHeader = firstRow.some((value) => !isNumeric(value)); - return { - hasHeader, - headers: hasHeader ? firstRow.map((value) => value.toLowerCase()) : [], - }; -} - -function detectTimeScale(headers) { - const timeHeader = headers.find((header) => header.includes('time') || header.includes('timestamp')); - if (!timeHeader) { - return 1; - } - - if (timeHeader.includes('sec') && !timeHeader.includes('msec') && !timeHeader.includes('ms')) { - return 1000; - } - - return 1; -} - -function detectColumnIndexes(headers, columnCount) { - if (headers.length === 0) { - return { - timeIndex: columnCount > 1 ? 0 : -1, - valueIndex: columnCount > 1 ? 1 : 0, - }; - } - - const timeIndex = headers.findIndex((header) => header.includes('time') || header.includes('timestamp')); - const valueIndex = headers.findIndex((header) => header.includes('value') || header.includes('signal') || header.includes('y')); - - return { - timeIndex, - valueIndex: valueIndex >= 0 ? valueIndex : (headers.length > 1 ? 1 : 0), - }; -} - -export function parseReplayCsv(text, { sampleRateHz = 60 } = {}) { - const rows = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')) - .map(splitRow) - .filter((row) => row.some((value) => value !== '')); - - if (rows.length === 0) { - throw new Error('CSV file is empty'); - } - - const { hasHeader, headers } = detectHeader(rows); - const dataRows = hasHeader ? rows.slice(1) : rows; - const columnCount = rows[0].length; - const { timeIndex, valueIndex } = detectColumnIndexes(headers, columnCount); - const timeScale = detectTimeScale(headers); - const intervalMs = 1000 / Math.max(1, sampleRateHz); - - const points = dataRows - .map((row, index) => { - const rawValue = row[valueIndex]; - if (!isNumeric(rawValue)) { - return null; - } - - const parsedValue = Number(rawValue); - const parsedTime = timeIndex >= 0 && isNumeric(row[timeIndex]) - ? Number(row[timeIndex]) * timeScale - : index * intervalMs; - - return { - timeMs: parsedTime, - value: parsedValue, - }; - }) - .filter(Boolean) - .sort((left, right) => left.timeMs - right.timeMs); - - if (points.length === 0) { - throw new Error('CSV file did not contain any numeric data points'); - } - - const firstTime = points[0].timeMs; - const normalizedPoints = points.map((point) => ({ - timeMs: point.timeMs - firstTime, - value: point.value, - })); - - return { - points: normalizedPoints, - metadata: { - pointCount: normalizedPoints.length, - durationMs: normalizedPoints.at(-1)?.timeMs ?? 0, - }, - }; -} diff --git a/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js deleted file mode 100644 index 917d06b..0000000 --- a/web-timeplot/src/data/source-registry.js +++ /dev/null @@ -1,90 +0,0 @@ -import { CsvReplaySource } from './csv-replay-source.js'; -import { SyntheticWaveSource } from './synthetic-wave-source.js'; -import { WebSocketSource } from './websocket-source.js'; - -export class SourceRegistry { - constructor(store, bus) { - this.store = store; - this.bus = bus; - this.sources = new Map(); - this.syncFromState(); - } - - syncFromState() { - const state = this.store.getState(); - const sourceEntries = Object.entries(state.sources); - const activeKeys = new Set(sourceEntries.map(([sourceKey]) => sourceKey)); - - for (const [sourceKey, config] of sourceEntries) { - const existingSource = this.sources.get(sourceKey); - - if (!existingSource) { - const nextSource = this.createSource(sourceKey, config); - this.sources.set(sourceKey, nextSource); - nextSource.start(state.time.plotTimeMs); - continue; - } - - if (existingSource.sourceType !== config.type) { - existingSource.stop(); - const replacementSource = this.createSource(sourceKey, config); - this.sources.set(sourceKey, replacementSource); - replacementSource.start(state.time.plotTimeMs); - continue; - } - - existingSource.updateConfig(config); - } - - for (const [sourceKey, source] of this.sources.entries()) { - if (!activeKeys.has(sourceKey)) { - source.stop(); - this.sources.delete(sourceKey); - } - } - } - - createSource(sourceKey, config) { - switch (config.type) { - case 'csv-replay': - return new CsvReplaySource(config); - case 'websocket': - return new WebSocketSource(config, { - onStatusChange: (statusPatch) => { - this.store.setState((state) => ({ - ...state, - sources: { - ...state.sources, - [sourceKey]: { - ...state.sources[sourceKey], - ...statusPatch, - }, - }, - })); - }, - }); - case 'synthetic-wave': - default: - return new SyntheticWaveSource(config); - } - } - - update(currentPlotTimeMs) { - for (const [sourceKey, source] of this.sources.entries()) { - const points = source.update(currentPlotTimeMs); - for (const point of points) { - this.bus.emit('data:point', { - ...point, - sourceId: sourceKey, - }); - } - } - } - - reset() { - const startTimeMs = this.store.getState().time.plotTimeMs; - for (const source of this.sources.values()) { - source.reset(startTimeMs); - } - } -} diff --git a/web-timeplot/src/data/synthetic-wave-source.js b/web-timeplot/src/data/synthetic-wave-source.js deleted file mode 100644 index df53319..0000000 --- a/web-timeplot/src/data/synthetic-wave-source.js +++ /dev/null @@ -1,87 +0,0 @@ -import { BaseSource } from './base-source.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function createDeterministicNoise(seed) { - const x = Math.sin(seed * 12.9898) * 43758.5453; - return x - Math.floor(x); -} - -export class SyntheticWaveSource extends BaseSource { - constructor(config = {}) { - super({ - sampleRateHz: 60, - preset: 'telemetry', - amplitude: 1, - noise: 0.08, - ...config, - }); - this.sourceType = 'synthetic-wave'; - this.lastEmittedPlotTimeMs = 0; - } - - start(startTimeMs = 0) { - super.start(); - this.lastEmittedPlotTimeMs = startTimeMs; - } - - stop() { - super.stop(); - } - - reset(startTimeMs = 0) { - this.lastEmittedPlotTimeMs = startTimeMs; - } - - sampleValue(timeMs) { - const seconds = timeMs / 1000; - const amplitude = this.config.amplitude; - const noise = this.config.noise; - const grain = (createDeterministicNoise(timeMs * 0.017) - 0.5) * 2 * noise; - - switch (this.config.preset) { - case 'chirp': { - const sweep = Math.sin(seconds * seconds * 1.4); - return amplitude * (0.7 * sweep + 0.3 * Math.sin(seconds * 7.5)) + grain; - } - case 'burst': { - const burstPhase = (seconds % 6) - 1.5; - const burst = Math.sin(seconds * 9.5) * Math.exp(-(burstPhase ** 2) * 0.8); - return amplitude * (0.45 * Math.sin(seconds * 2.1) + burst) + grain; - } - case 'telemetry': - default: { - const carrier = Math.sin(seconds * 2.2); - const secondary = 0.35 * Math.cos(seconds * 6.4 + Math.sin(seconds * 0.8)); - const envelope = 0.15 * Math.sin(seconds * 0.33); - return amplitude * (carrier + secondary + envelope) + grain; - } - } - } - - update(currentPlotTimeMs) { - if (!this.running) { - return []; - } - - const intervalMs = 1000 / clamp(this.config.sampleRateHz, 1, 240); - if (currentPlotTimeMs < this.lastEmittedPlotTimeMs) { - this.lastEmittedPlotTimeMs = currentPlotTimeMs; - return []; - } - - const points = []; - while (this.lastEmittedPlotTimeMs + intervalMs <= currentPlotTimeMs) { - this.lastEmittedPlotTimeMs += intervalMs; - points.push({ - timeMs: this.lastEmittedPlotTimeMs, - value: this.sampleValue(this.lastEmittedPlotTimeMs), - sourceId: 'synthetic-wave', - }); - } - - return points; - } -} diff --git a/web-timeplot/src/data/websocket-source.js b/web-timeplot/src/data/websocket-source.js deleted file mode 100644 index 5458fb9..0000000 --- a/web-timeplot/src/data/websocket-source.js +++ /dev/null @@ -1,224 +0,0 @@ -import { BaseSource } from './base-source.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function isFiniteNumber(value) { - return typeof value === 'number' && Number.isFinite(value); -} - -function parsePayload(payload) { - if (Array.isArray(payload)) { - return payload.flatMap((item) => parsePayload(item)); - } - - if (isFiniteNumber(payload)) { - return [{ value: payload, timestampMs: null }]; - } - - if (typeof payload === 'string') { - const trimmed = payload.trim(); - if (!trimmed) { - return []; - } - - const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - return [{ value: numeric, timestampMs: null }]; - } - - try { - return parsePayload(JSON.parse(trimmed)); - } catch { - return []; - } - } - - if (payload && typeof payload === 'object') { - const candidateValue = [payload.value, payload.y, payload.signal, payload.data] - .find((value) => Number.isFinite(Number(value))); - - if (candidateValue === undefined) { - return []; - } - - const candidateTimestamp = [payload.timeMs, payload.timestampMs, payload.timestamp, payload.t] - .find((value) => Number.isFinite(Number(value))); - - return [{ - value: Number(candidateValue), - timestampMs: candidateTimestamp === undefined ? null : Number(candidateTimestamp), - }]; - } - - return []; -} - -export class WebSocketSource extends BaseSource { - constructor(config = {}, { onStatusChange } = {}) { - super({ - wsUrl: 'ws://localhost:8080', - wsReconnectMs: 2000, - ...config, - }); - this.sourceType = 'websocket'; - this.onStatusChange = onStatusChange; - this.socket = null; - this.queue = []; - this.lastPlotTimeMs = 0; - this.reconnectTimer = null; - this.shouldReconnect = false; - this.firstSourceTimestampMs = null; - this.basePlotTimeMs = 0; - } - - start(startTimeMs = 0) { - super.start(); - this.lastPlotTimeMs = startTimeMs; - this.basePlotTimeMs = startTimeMs; - this.shouldReconnect = true; - this.connect(); - } - - stop() { - super.stop(); - this.shouldReconnect = false; - this.clearReconnectTimer(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - this.setStatus('disconnected', 'socket closed'); - } - - reset(startTimeMs = 0) { - this.queue = []; - this.lastPlotTimeMs = startTimeMs; - this.basePlotTimeMs = startTimeMs; - this.firstSourceTimestampMs = null; - } - - updateConfig(nextConfig) { - const previousUrl = this.config.wsUrl; - const previousReconnectMs = this.config.wsReconnectMs; - super.updateConfig(nextConfig); - - if ((previousUrl !== this.config.wsUrl || previousReconnectMs !== this.config.wsReconnectMs) && this.running) { - this.reconnect(); - } - } - - update(currentPlotTimeMs) { - this.lastPlotTimeMs = currentPlotTimeMs; - - if (this.queue.length === 0) { - return []; - } - - const points = []; - while (this.queue.length > 0) { - const nextPoint = this.queue.shift(); - let timeMs = currentPlotTimeMs; - - if (isFiniteNumber(nextPoint.timestampMs)) { - if (this.firstSourceTimestampMs === null) { - this.firstSourceTimestampMs = nextPoint.timestampMs; - this.basePlotTimeMs = currentPlotTimeMs; - } - timeMs = this.basePlotTimeMs + (nextPoint.timestampMs - this.firstSourceTimestampMs); - } - - points.push({ - timeMs, - value: nextPoint.value, - sourceId: this.config.id ?? 'websocket', - }); - } - - return points; - } - - reconnect() { - if (!this.running) { - return; - } - - this.clearReconnectTimer(); - if (this.socket) { - this.socket.close(); - this.socket = null; - } - this.connect(); - } - - connect() { - const url = this.config.wsUrl?.trim(); - if (!url) { - this.setStatus('idle', 'enter a websocket url'); - return; - } - - this.clearReconnectTimer(); - this.setStatus('connecting', url); - - try { - this.socket = new WebSocket(url); - } catch (error) { - this.setStatus('error', error instanceof Error ? error.message : String(error)); - this.scheduleReconnect(); - return; - } - - this.socket.addEventListener('open', () => { - this.setStatus('connected', url); - }); - - this.socket.addEventListener('message', (event) => { - const parsedPoints = parsePayload(event.data); - if (parsedPoints.length === 0) { - return; - } - this.queue.push(...parsedPoints); - }); - - this.socket.addEventListener('error', () => { - this.setStatus('error', 'socket error'); - }); - - this.socket.addEventListener('close', () => { - this.socket = null; - if (!this.running) { - return; - } - this.setStatus('disconnected', 'retrying'); - this.scheduleReconnect(); - }); - } - - scheduleReconnect() { - if (!this.shouldReconnect || !this.running) { - return; - } - - const reconnectMs = clamp(Number(this.config.wsReconnectMs) || 2000, 250, 30000); - this.clearReconnectTimer(); - this.reconnectTimer = window.setTimeout(() => { - this.connect(); - }, reconnectMs); - } - - clearReconnectTimer() { - if (this.reconnectTimer !== null) { - window.clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - } - - setStatus(status, detail = '') { - this.onStatusChange?.({ - wsStatus: status, - wsStatusDetail: detail, - }); - } -} diff --git a/web-timeplot/src/demos.js b/web-timeplot/src/demos.js deleted file mode 100644 index 1dd6785..0000000 --- a/web-timeplot/src/demos.js +++ /dev/null @@ -1,697 +0,0 @@ -/** - * Preloaded Graphics Demos - * - * Each demo exports: - * - name: Display name - * - description: Short description - * - setup(app, state): Called once to create objects - * - update(app, state, objects): Called every frame - * - cleanup(app, objects): Called when switching demos - */ - -// ============================================================================ -// DEMO 1: BOUNCING PARTICLES -// ============================================================================ - -export const bouncingParticles = { - name: "Bouncing Particles", - description: "Colorful particles bouncing around the screen", - - setup(app, state) { - const particles = []; - const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7]; - - for (let i = 0; i < 50; i++) { - const particle = new PIXI.Graphics(); - const size = 5 + Math.random() * 10; - particle.circle(0, 0, size); - particle.fill(colors[Math.floor(Math.random() * colors.length)]); - - particle.x = Math.random() * app.screen.width; - particle.y = Math.random() * app.screen.height; - particle.vx = (Math.random() - 0.5) * 8; - particle.vy = (Math.random() - 0.5) * 8; - particle.size = size; - - app.stage.addChild(particle); - particles.push(particle); - } - - return { particles }; - }, - - update(app, state, objects) { - objects.particles.forEach(p => { - p.x += p.vx; - p.y += p.vy; - - // Bounce off edges - if (p.x < p.size || p.x > app.screen.width - p.size) p.vx *= -1; - if (p.y < p.size || p.y > app.screen.height - p.size) p.vy *= -1; - - // Clamp to screen - p.x = Math.max(p.size, Math.min(app.screen.width - p.size, p.x)); - p.y = Math.max(p.size, Math.min(app.screen.height - p.size, p.y)); - }); - }, - - cleanup(app, objects) { - objects.particles.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// DEMO 2: SPIROGRAPH -// ============================================================================ - -export const spirograph = { - name: "Spirograph", - description: "Mesmerizing geometric spiral patterns", - - setup(app, state) { - const graphics = new PIXI.Graphics(); - app.stage.addChild(graphics); - - return { - graphics, - angle: 0, - points: [] - }; - }, - - update(app, state, objects) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const t = state.state.time.current; - - // Generate new point - const r1 = 150; - const r2 = 50; - const r3 = 30; - - const x = cx + Math.cos(t * 0.5) * r1 + Math.cos(t * 2) * r2 + Math.cos(t * 5) * r3; - const y = cy + Math.sin(t * 0.5) * r1 + Math.sin(t * 2) * r2 + Math.sin(t * 5) * r3; - - objects.points.push({ x, y }); - - // Keep only last 500 points - if (objects.points.length > 500) { - objects.points.shift(); - } - - // Draw trail - objects.graphics.clear(); - if (objects.points.length > 1) { - for (let i = 1; i < objects.points.length; i++) { - const alpha = i / objects.points.length; - const hue = (i / objects.points.length) * 360; - objects.graphics.moveTo(objects.points[i-1].x, objects.points[i-1].y); - objects.graphics.lineTo(objects.points[i].x, objects.points[i].y); - objects.graphics.stroke({ width: 2, color: hslToHex(hue, 100, 60), alpha }); - } - } - }, - - cleanup(app, objects) { - objects.graphics.destroy(); - } -}; - -// ============================================================================ -// DEMO 3: STARFIELD -// ============================================================================ - -export const starfield = { - name: "Starfield", - description: "Flying through space at warp speed", - - setup(app, state) { - const stars = []; - - for (let i = 0; i < 200; i++) { - const star = new PIXI.Graphics(); - star.circle(0, 0, 2); - star.fill(0xffffff); - - star.x = (Math.random() - 0.5) * app.screen.width * 2; - star.y = (Math.random() - 0.5) * app.screen.height * 2; - star.z = Math.random() * 1000; - - app.stage.addChild(star); - stars.push(star); - } - - return { stars }; - }, - - update(app, state, objects) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const speed = 5; - - objects.stars.forEach(star => { - star.z -= speed; - - if (star.z <= 0) { - star.z = 1000; - star.x = (Math.random() - 0.5) * app.screen.width * 2; - star.y = (Math.random() - 0.5) * app.screen.height * 2; - } - - const screenX = cx + (star.x / star.z) * 200; - const screenY = cy + (star.y / star.z) * 200; - const size = (1 - star.z / 1000) * 4 + 1; - - star.x = star.x; - star.y = star.y; - star.position.set(screenX, screenY); - star.scale.set(size); - star.alpha = 1 - star.z / 1000; - }); - }, - - cleanup(app, objects) { - objects.stars.forEach(s => s.destroy()); - } -}; - -// ============================================================================ -// DEMO 4: WAVE INTERFERENCE -// ============================================================================ - -export const waveInterference = { - name: "Wave Interference", - description: "Rippling wave patterns", - - setup(app, state) { - const gridSize = 20; - const cols = Math.floor(app.screen.width / gridSize); - const rows = Math.floor(app.screen.height / gridSize); - const circles = []; - - for (let i = 0; i < cols; i++) { - for (let j = 0; j < rows; j++) { - const circle = new PIXI.Graphics(); - circle.circle(0, 0, 4); - circle.fill(0x4ecdc4); - circle.x = i * gridSize + gridSize / 2; - circle.y = j * gridSize + gridSize / 2; - circle.baseX = circle.x; - circle.baseY = circle.y; - - app.stage.addChild(circle); - circles.push(circle); - } - } - - return { circles, sources: [ - { x: app.screen.width * 0.3, y: app.screen.height * 0.5 }, - { x: app.screen.width * 0.7, y: app.screen.height * 0.5 } - ]}; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.circles.forEach(c => { - let totalOffset = 0; - - objects.sources.forEach(source => { - const dx = c.baseX - source.x; - const dy = c.baseY - source.y; - const dist = Math.sqrt(dx * dx + dy * dy); - totalOffset += Math.sin(dist * 0.05 - t * 3) * 10; - }); - - c.y = c.baseY + totalOffset; - c.alpha = 0.3 + (Math.sin(totalOffset * 0.1) + 1) * 0.35; - }); - }, - - cleanup(app, objects) { - objects.circles.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 5: CIRCLE PACKING -// ============================================================================ - -export const circlePacking = { - name: "Circle Packing", - description: "Organic growth simulation", - - setup(app, state) { - const circles = []; - return { circles, attempts: 0 }; - }, - - update(app, state, objects) { - // Try to add a new circle each frame - const maxAttempts = 100; - const maxCircles = 150; - - if (objects.circles.length >= maxCircles) return; - - for (let i = 0; i < 10; i++) { - const x = Math.random() * app.screen.width; - const y = Math.random() * app.screen.height; - const minRadius = 5; - const maxRadius = 60; - - let valid = true; - let radius = minRadius; - - // Find largest radius that doesn't overlap - for (let r = minRadius; r < maxRadius; r++) { - let overlaps = false; - - for (const other of objects.circles) { - const dx = x - other.x; - const dy = y - other.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - if (dist < r + other.radius + 2) { - overlaps = true; - break; - } - } - - if (overlaps) { - break; - } - radius = r; - } - - if (radius > minRadius) { - const circle = new PIXI.Graphics(); - circle.circle(0, 0, radius); - const hue = (objects.circles.length * 137.5) % 360; - circle.fill(hslToHex(hue, 70, 60)); - circle.x = x; - circle.y = y; - circle.radius = radius; - - app.stage.addChild(circle); - objects.circles.push(circle); - break; - } - } - }, - - cleanup(app, objects) { - objects.circles.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 6: PERLIN FLOW FIELD -// ============================================================================ - -export const flowField = { - name: "Flow Field", - description: "Particles following a noise field", - - setup(app, state) { - const particles = []; - const colors = [0xff6b6b, 0x4ecdc4, 0x45b7d1, 0xf9ca24, 0x6c5ce7, 0xfeca57]; - - for (let i = 0; i < 300; i++) { - const particle = new PIXI.Graphics(); - particle.circle(0, 0, 2); - particle.fill(colors[Math.floor(Math.random() * colors.length)]); - particle.alpha = 0.6; - - particle.x = Math.random() * app.screen.width; - particle.y = Math.random() * app.screen.height; - particle.vx = 0; - particle.vy = 0; - particle.color = colors[Math.floor(Math.random() * colors.length)]; - - app.stage.addChild(particle); - particles.push(particle); - } - - return { particles }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.particles.forEach(p => { - // Simple noise-like function using sin/cos - const angle = noise(p.x * 0.005, p.y * 0.005, t * 0.3) * Math.PI * 2; - - p.vx += Math.cos(angle) * 0.3; - p.vy += Math.sin(angle) * 0.3; - - // Damping - p.vx *= 0.95; - p.vy *= 0.95; - - p.x += p.vx; - p.y += p.vy; - - // Wrap around screen - if (p.x < 0) p.x = app.screen.width; - if (p.x > app.screen.width) p.x = 0; - if (p.y < 0) p.y = app.screen.height; - if (p.y > app.screen.height) p.y = 0; - }); - }, - - cleanup(app, objects) { - objects.particles.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// DEMO 7: DNA HELIX -// ============================================================================ - -export const dnaHelix = { - name: "DNA Helix", - description: "Rotating double helix structure", - - setup(app, state) { - const helix1 = []; - const helix2 = []; - const connectors = []; - const segments = 40; - - for (let i = 0; i < segments; i++) { - const sphere1 = new PIXI.Graphics(); - sphere1.circle(0, 0, 8); - sphere1.fill(0x4ecdc4); - app.stage.addChild(sphere1); - helix1.push(sphere1); - - const sphere2 = new PIXI.Graphics(); - sphere2.circle(0, 0, 8); - sphere2.fill(0xff6b6b); - app.stage.addChild(sphere2); - helix2.push(sphere2); - - const connector = new PIXI.Graphics(); - app.stage.addChild(connector); - connectors.push(connector); - } - - return { helix1, helix2, connectors }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - const radius = 100; - const height = app.screen.height * 0.8; - const spacing = height / objects.helix1.length; - - objects.helix1.forEach((sphere, i) => { - const y = i * spacing - height / 2 + cy; - const angle = t + i * 0.3; - const x = cx + Math.cos(angle) * radius; - const z = Math.sin(angle) * radius; - - sphere.x = x; - sphere.y = y; - sphere.scale.set(1 + z / 200); - sphere.alpha = 0.5 + z / 400; - }); - - objects.helix2.forEach((sphere, i) => { - const y = i * spacing - height / 2 + cy; - const angle = t + i * 0.3 + Math.PI; - const x = cx + Math.cos(angle) * radius; - const z = Math.sin(angle) * radius; - - sphere.x = x; - sphere.y = y; - sphere.scale.set(1 + z / 200); - sphere.alpha = 0.5 + z / 400; - }); - - // Draw connectors - objects.connectors.forEach((connector, i) => { - connector.clear(); - connector.moveTo(objects.helix1[i].x, objects.helix1[i].y); - connector.lineTo(objects.helix2[i].x, objects.helix2[i].y); - connector.stroke({ width: 2, color: 0x666666, alpha: 0.3 }); - }); - }, - - cleanup(app, objects) { - objects.helix1.forEach(s => s.destroy()); - objects.helix2.forEach(s => s.destroy()); - objects.connectors.forEach(c => c.destroy()); - } -}; - -// ============================================================================ -// DEMO 8: FIREWORKS -// ============================================================================ - -export const fireworks = { - name: "Fireworks", - description: "Explosive particle celebration", - - setup(app, state) { - return { - explosions: [], - nextExplosion: 0 - }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - // Create new explosion every second - if (t > objects.nextExplosion) { - objects.nextExplosion = t + 0.5 + Math.random(); - - const explosion = { - x: Math.random() * app.screen.width, - y: Math.random() * app.screen.height * 0.7, - particles: [], - color: Math.random() * 0xffffff, - born: t - }; - - // Create particles - for (let i = 0; i < 50; i++) { - const angle = (i / 50) * Math.PI * 2; - const speed = 2 + Math.random() * 4; - const particle = new PIXI.Graphics(); - particle.circle(0, 0, 3); - particle.fill(explosion.color); - particle.x = explosion.x; - particle.y = explosion.y; - particle.vx = Math.cos(angle) * speed; - particle.vy = Math.sin(angle) * speed; - - app.stage.addChild(particle); - explosion.particles.push(particle); - } - - objects.explosions.push(explosion); - } - - // Update explosions - objects.explosions = objects.explosions.filter(explosion => { - const age = t - explosion.born; - - if (age > 3) { - explosion.particles.forEach(p => p.destroy()); - return false; - } - - explosion.particles.forEach(p => { - p.vx *= 0.98; - p.vy += 0.1; // Gravity - p.x += p.vx; - p.y += p.vy; - p.alpha = 1 - age / 3; - }); - - return true; - }); - }, - - cleanup(app, objects) { - objects.explosions.forEach(explosion => { - explosion.particles.forEach(p => p.destroy()); - }); - } -}; - -// ============================================================================ -// DEMO 9: MATRIX RAIN -// ============================================================================ - -export const matrixRain = { - name: "Matrix Rain", - description: "Falling digital rain effect", - - setup(app, state) { - const fontSize = 16; - const columns = Math.floor(app.screen.width / fontSize); - const drops = []; - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*"; - - for (let i = 0; i < columns; i++) { - const text = new PIXI.Text('', { - fontFamily: 'monospace', - fontSize: fontSize, - fill: 0x00ff00 - }); - text.x = i * fontSize; - text.y = -Math.random() * app.screen.height; - - app.stage.addChild(text); - drops.push({ - text, - speed: 1 + Math.random() * 3, - chars: chars - }); - } - - return { drops }; - }, - - update(app, state, objects) { - objects.drops.forEach(drop => { - drop.y = (drop.y || drop.text.y) + drop.speed; - drop.text.y = drop.y; - - // Random character - if (Math.random() > 0.95) { - drop.text.text = drop.chars[Math.floor(Math.random() * drop.chars.length)]; - } - - // Reset to top - if (drop.y > app.screen.height) { - drop.y = -20; - drop.text.alpha = 1; - } - - // Fade trail - drop.text.alpha = Math.max(0.1, drop.text.alpha - 0.01); - }); - }, - - cleanup(app, objects) { - objects.drops.forEach(d => d.text.destroy()); - } -}; - -// ============================================================================ -// DEMO 10: SOLAR SYSTEM -// ============================================================================ - -export const solarSystem = { - name: "Solar System", - description: "Orbiting planets around a star", - - setup(app, state) { - const cx = app.screen.width / 2; - const cy = app.screen.height / 2; - - // Sun - const sun = new PIXI.Graphics(); - sun.circle(0, 0, 30); - sun.fill(0xffd700); - sun.x = cx; - sun.y = cy; - app.stage.addChild(sun); - - // Planets - const planets = [ - { radius: 60, size: 6, speed: 2.0, color: 0x8b7355 }, - { radius: 100, size: 10, speed: 1.5, color: 0xff6347 }, - { radius: 150, size: 12, speed: 1.0, color: 0x4169e1 }, - { radius: 200, size: 8, speed: 0.7, color: 0xff4500 }, - { radius: 260, size: 18, speed: 0.4, color: 0xdaa520 }, - ]; - - const planetObjects = planets.map(config => { - const planet = new PIXI.Graphics(); - planet.circle(0, 0, config.size); - planet.fill(config.color); - planet.config = config; - app.stage.addChild(planet); - return planet; - }); - - return { sun, planets: planetObjects, cx, cy }; - }, - - update(app, state, objects) { - const t = state.state.time.current; - - objects.planets.forEach((planet, i) => { - const angle = t * planet.config.speed; - planet.x = objects.cx + Math.cos(angle) * planet.config.radius; - planet.y = objects.cy + Math.sin(angle) * planet.config.radius; - }); - }, - - cleanup(app, objects) { - objects.sun.destroy(); - objects.planets.forEach(p => p.destroy()); - } -}; - -// ============================================================================ -// UTILITIES -// ============================================================================ - -function hslToHex(h, s, l) { - s /= 100; - l /= 100; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs((h / 60) % 2 - 1)); - const m = l - c/2; - let r = 0, g = 0, b = 0; - - if (0 <= h && h < 60) { - r = c; g = x; b = 0; - } else if (60 <= h && h < 120) { - r = x; g = c; b = 0; - } else if (120 <= h && h < 180) { - r = 0; g = c; b = x; - } else if (180 <= h && h < 240) { - r = 0; g = x; b = c; - } else if (240 <= h && h < 300) { - r = x; g = 0; b = c; - } else if (300 <= h && h < 360) { - r = c; g = 0; b = x; - } - - r = Math.round((r + m) * 255); - g = Math.round((g + m) * 255); - b = Math.round((b + m) * 255); - - return (r << 16) | (g << 8) | b; -} - -function noise(x, y, z) { - return Math.sin(x + Math.cos(y)) * Math.cos(y + Math.sin(z)) * Math.sin(z + Math.cos(x)); -} - -// ============================================================================ -// EXPORT ALL DEMOS -// ============================================================================ - -export const allDemos = [ - bouncingParticles, - spirograph, - starfield, - waveInterference, - circlePacking, - flowField, - dnaHelix, - fireworks, - matrixRain, - solarSystem -]; diff --git a/web-timeplot/src/example-usage.js b/web-timeplot/src/example-usage.js deleted file mode 100644 index 67eff4b..0000000 --- a/web-timeplot/src/example-usage.js +++ /dev/null @@ -1,535 +0,0 @@ -/** - * Example Usage: Complete examples of the new architecture - * - * This file demonstrates how to use the separated data/visualization architecture: - * - TimeSeriesPlot: Pure visualization - * - DataSource: Data generation/provision - * - Connections: Links between them - */ - -import { Application } from 'pixi.js'; -import { TimeSeriesPlot } from './timeseries-plot.js'; -import { - SyntheticDataSource, - FunctionDataSource, - StreamingDataSource, - WebSocketDataSource, -} from './data-sources.js'; -import { - DirectConnection, - BufferedConnection, - ConnectionManager, - connectSyntheticData, - connectFunction, - createConnectedPlot, -} from './plot-connections.js'; -import { - TestDataFactory, - SineWaveGenerator, - PerlinNoiseGenerator, - ChirpGenerator, -} from './test-data-generators.js'; - -// ============================================================================ -// Example 1: Simple Setup - One plot, one data source -// ============================================================================ - -export async function example1_SimpleSetup() { - console.log('=== Example 1: Simple Setup ==='); - - // Create PixiJS app - const app = new Application(); - await app.init({ - width: 800, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // Create plot (visualization only) - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Simple Sine Wave', - showGrid: true, - }); - app.stage.addChild(plot.container); - - // Create data source - const generator = TestDataFactory.createSimpleSine(30); - const source = new SyntheticDataSource({ - generator: generator, - pointsPerLine: 100, - width: 800, - lineInterval: 100, // New line every 100ms - }); - - // Connect source to plot - const connection = new DirectConnection(source, plot); - connection.connect(); - - // Update plot every frame - app.ticker.add(() => { - plot.update(); - }); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 2: Quick Setup Using Helper Functions -// ============================================================================ - -export async function example2_QuickSetup() { - console.log('=== Example 2: Quick Setup ==='); - - const app = new Application(); - await app.init({ - width: 800, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // One-liner setup! - const { plot, source, connection } = createConnectedPlot( - app, - { - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Quick Setup', - }, - { - generator: TestDataFactory.createComplexPattern(30), - lineInterval: 100, - } - ); - - app.ticker.add(() => plot.update()); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 3: Multiple Plots with Different Data Sources -// ============================================================================ - -export async function example3_MultiplePlots() { - console.log('=== Example 3: Multiple Plots ==='); - - const app = new Application(); - await app.init({ - width: 1600, - height: 600, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - const width = 800; - const height = 600; - - // Left plot: Sine wave - const plot1 = new TimeSeriesPlot({ - x: 0, - y: 0, - width: width, - height: height, - title: 'Sine Wave', - color: 0xff6666, - }); - - // Right plot: Perlin noise - const plot2 = new TimeSeriesPlot({ - x: width, - y: 0, - width: width, - height: height, - title: 'Perlin Noise', - color: 0x66ff66, - }); - - app.stage.addChild(plot1.container); - app.stage.addChild(plot2.container); - - // Connect different data sources - const conn1 = connectSyntheticData( - TestDataFactory.createSimpleSine(30), - plot1, - { lineInterval: 100 } - ); - - const conn2 = connectSyntheticData( - TestDataFactory.createSmoothNoise(30), - plot2, - { lineInterval: 100 } - ); - - app.ticker.add(() => { - plot1.update(); - plot2.update(); - }); - - return { app, plots: [plot1, plot2], connections: [conn1, conn2] }; -} - -// ============================================================================ -// Example 4: Using Function-Based Data Source -// ============================================================================ - -export async function example4_FunctionSource() { - console.log('=== Example 4: Function Source ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Custom Function', - }); - app.stage.addChild(plot.container); - - // Define a custom function: (x, t) => y - // x is normalized 0-1 across the width - // t is time in seconds - const customFunc = (x, t) => { - // Create an interference pattern - const wave1 = Math.sin(x * 10 + t * 2); - const wave2 = Math.sin(x * 15 - t * 3); - const wave3 = Math.cos(x * 8 + t * 1.5); - return (wave1 + wave2 + wave3) / 3; - }; - - const connection = connectFunction(customFunc, plot, { - lineInterval: 100, - amplitude: 30, - }); - - app.ticker.add(() => plot.update()); - - return { app, plot, connection }; -} - -// ============================================================================ -// Example 5: Swapping Data Sources at Runtime -// ============================================================================ - -export async function example5_SwappingSources() { - console.log('=== Example 5: Swapping Sources ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Dynamic Source Switching', - }); - app.stage.addChild(plot.container); - - // Start with sine wave - let currentConnection = connectSyntheticData( - TestDataFactory.createSimpleSine(30), - plot, - { lineInterval: 100 } - ); - - app.ticker.add(() => plot.update()); - - // Function to switch to a different data source - const switchToSource = (generator, title) => { - // Disconnect current source - currentConnection.disconnect(); - - // Connect new source - currentConnection = connectSyntheticData(generator, plot, { - lineInterval: 100, - }); - - plot.setTitle(title); - console.log(`Switched to: ${title}`); - }; - - // Example: Switch sources every 5 seconds - let sourceIndex = 0; - const sources = [ - { gen: TestDataFactory.createSimpleSine(30), title: 'Sine Wave' }, - { gen: TestDataFactory.createComplexPattern(30), title: 'Complex Pattern' }, - { gen: TestDataFactory.createSmoothNoise(30), title: 'Perlin Noise' }, - { gen: TestDataFactory.createFrequencySweep(30), title: 'Frequency Sweep' }, - ]; - - setInterval(() => { - sourceIndex = (sourceIndex + 1) % sources.length; - const source = sources[sourceIndex]; - switchToSource(source.gen, source.title); - }, 5000); - - return { app, plot, switchToSource }; -} - -// ============================================================================ -// Example 6: Streaming Data with Buffering -// ============================================================================ - -export async function example6_StreamingData() { - console.log('=== Example 6: Streaming Data ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Streaming Data (Buffered)', - }); - app.stage.addChild(plot.container); - - // Create streaming source (emits individual points) - const generator = new SineWaveGenerator({ - frequency: 2.0, - amplitude: 1.0, - sampleRate: 60, - }); - - const source = new StreamingDataSource({ - generator: generator, - sampleRate: 60, // 60 points per second - }); - - // Use buffered connection to assemble points into lines - const connection = new BufferedConnection(source, plot, { - bufferSize: 100, // Buffer 100 points before creating a line - bufferTimeout: 1000, // Or timeout after 1 second - }); - connection.connect(); - - app.ticker.add(() => plot.update()); - - return { app, plot, source, connection }; -} - -// ============================================================================ -// Example 7: Connection Manager (Managing Multiple Connections) -// ============================================================================ - -export async function example7_ConnectionManager() { - console.log('=== Example 7: Connection Manager ==='); - - const app = new Application(); - await app.init({ width: 800, height: 600, backgroundColor: 0x1a1a26 }); - document.body.appendChild(app.canvas); - - const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'Managed Connections', - }); - app.stage.addChild(plot.container); - - // Create connection manager - const manager = new ConnectionManager(); - - // Add first connection - const source1 = new SyntheticDataSource({ - generator: TestDataFactory.createSimpleSine(30), - pointsPerLine: 100, - width: 800, - lineInterval: 100, - }); - - const connId1 = manager.connect(source1, plot, { type: 'direct' }); - console.log('Connection ID:', connId1); - - app.ticker.add(() => plot.update()); - - // Later: disconnect and switch to different source - setTimeout(() => { - manager.disconnect(connId1); - - const source2 = new SyntheticDataSource({ - generator: TestDataFactory.createFrequencySweep(30), - pointsPerLine: 100, - width: 800, - lineInterval: 100, - }); - - const connId2 = manager.connect(source2, plot, { type: 'direct' }); - plot.setTitle('Frequency Sweep'); - console.log('Switched to connection:', connId2); - }, 5000); - - return { app, plot, manager }; -} - -// ============================================================================ -// Example 8: Complete Interactive Demo -// ============================================================================ - -export async function example8_InteractiveDemo() { - console.log('=== Example 8: Interactive Demo ==='); - - const app = new Application(); - await app.init({ - width: 1600, - height: 800, - backgroundColor: 0x1a1a26, - }); - document.body.appendChild(app.canvas); - - // Create two plots - const plot1 = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 800, - title: 'Plot 1 - Press 1-5 to change', - color: 0xff6666, - }); - - const plot2 = new TimeSeriesPlot({ - x: 800, - y: 0, - width: 800, - height: 800, - title: 'Plot 2 - Press 6-0 to change', - color: 0x66ff66, - }); - - app.stage.addChild(plot1.container); - app.stage.addChild(plot2.container); - - // Connection manager - const manager = new ConnectionManager(); - - // Available data sources - const dataSources = { - sine: () => TestDataFactory.createSimpleSine(30), - complex: () => TestDataFactory.createComplexPattern(30), - noise: () => TestDataFactory.createSmoothNoise(30), - sweep: () => TestDataFactory.createFrequencySweep(30), - burst: () => TestDataFactory.createBurstySignal(30), - }; - - // Track current connections - let conn1Id = null; - let conn2Id = null; - - // Helper to switch source - const switchSource = (plot, generatorFunc, title) => { - // Disconnect old connection - const connId = plot === plot1 ? conn1Id : conn2Id; - if (connId !== null) { - manager.disconnect(connId); - } - - // Create new connection - const source = new SyntheticDataSource({ - generator: generatorFunc(), - pointsPerLine: 100, - width: plot.width, - lineInterval: 100, - }); - - const newConnId = manager.connect(source, plot, { type: 'direct' }); - plot.setTitle(title); - - // Store connection ID - if (plot === plot1) { - conn1Id = newConnId; - } else { - conn2Id = newConnId; - } - }; - - // Initialize with default sources - switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave'); - switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern'); - - // Keyboard controls - window.addEventListener('keydown', (e) => { - switch (e.key) { - case '1': - switchSource(plot1, dataSources.sine, 'Plot 1 - Sine Wave'); - break; - case '2': - switchSource(plot1, dataSources.complex, 'Plot 1 - Complex Pattern'); - break; - case '3': - switchSource(plot1, dataSources.noise, 'Plot 1 - Perlin Noise'); - break; - case '4': - switchSource(plot1, dataSources.sweep, 'Plot 1 - Frequency Sweep'); - break; - case '5': - switchSource(plot1, dataSources.burst, 'Plot 1 - Burst Signal'); - break; - case '6': - switchSource(plot2, dataSources.sine, 'Plot 2 - Sine Wave'); - break; - case '7': - switchSource(plot2, dataSources.complex, 'Plot 2 - Complex Pattern'); - break; - case '8': - switchSource(plot2, dataSources.noise, 'Plot 2 - Perlin Noise'); - break; - case '9': - switchSource(plot2, dataSources.sweep, 'Plot 2 - Frequency Sweep'); - break; - case '0': - switchSource(plot2, dataSources.burst, 'Plot 2 - Burst Signal'); - break; - case 'g': - plot1.setGridVisible(!plot1.showGrid); - plot2.setGridVisible(!plot2.showGrid); - break; - case 'c': - plot1.clearData(); - plot2.clearData(); - break; - } - }); - - // Update loop - app.ticker.add(() => { - plot1.update(); - plot2.update(); - }); - - console.log('Controls:'); - console.log(' 1-5: Change Plot 1 source'); - console.log(' 6-0: Change Plot 2 source'); - console.log(' G: Toggle grid'); - console.log(' C: Clear data'); - - return { app, plot1, plot2, manager }; -} - -// ============================================================================ -// Quick Test: Run one of the examples -// ============================================================================ - -// Uncomment to run an example: -// example1_SimpleSetup(); -// example2_QuickSetup(); -// example3_MultiplePlots(); -// example4_FunctionSource(); -// example5_SwappingSources(); -// example6_StreamingData(); -// example7_ConnectionManager(); -//example8_InteractiveDemo(); diff --git a/web-timeplot/src/main.js b/web-timeplot/src/main.js deleted file mode 100644 index d2b348e..0000000 --- a/web-timeplot/src/main.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap.js'; diff --git a/web-timeplot/src/metrics.js b/web-timeplot/src/metrics.js deleted file mode 100644 index fdda10a..0000000 --- a/web-timeplot/src/metrics.js +++ /dev/null @@ -1,142 +0,0 @@ -/** - * RollingAverage - Maintains a rolling window of values for smooth averaging - */ -class RollingAverage { - constructor(capacity) { - this.values = []; - this.capacity = capacity; - this.sum = 0; - } - - push(value) { - if (this.values.length >= this.capacity) { - const old = this.values.shift(); - this.sum -= old; - } - this.values.push(value); - this.sum += value; - } - - average() { - return this.values.length > 0 ? this.sum / this.values.length : 0; - } - - min() { - return this.values.length > 0 ? Math.min(...this.values) : 0; - } - - max() { - return this.values.length > 0 ? Math.max(...this.values) : 0; - } -} - -/** - * PerformanceMetrics - Tracks and analyzes frame performance - */ -export class PerformanceMetrics { - constructor(rollingWindow = 60, historyCapacity = 10000) { - // Rolling averages - this.frameTime = new RollingAverage(rollingWindow); - this.updateTime = new RollingAverage(rollingWindow); - this.renderTime = new RollingAverage(rollingWindow); - this.vertexCount = new RollingAverage(rollingWindow); - this.lineCount = new RollingAverage(rollingWindow); - - // History for export - this.history = []; - this.historyCapacity = historyCapacity; - - // Frame timing - this.frameStart = 0; - this.updateStart = 0; - this.renderStart = 0; - - this.totalFrames = 0; - } - - beginFrame() { - this.frameStart = performance.now(); - } - - beginUpdate() { - this.updateStart = performance.now(); - } - - endUpdate() { - const duration = performance.now() - this.updateStart; - return duration; - } - - beginRender() { - this.renderStart = performance.now(); - } - - endRender() { - const duration = performance.now() - this.renderStart; - return duration; - } - - endFrame(updateMs, renderMs, vertexCount, lineCount) { - const totalMs = performance.now() - this.frameStart; - - // Update rolling averages - this.frameTime.push(totalMs); - this.updateTime.push(updateMs); - this.renderTime.push(renderMs); - this.vertexCount.push(vertexCount); - this.lineCount.push(lineCount); - - // Store in history - const record = { - frame: this.totalFrames, - totalMs, - updateMs, - renderMs, - vertexCount, - lineCount, - fps: totalMs > 0 ? 1000 / totalMs : 0, - }; - - if (this.history.length >= this.historyCapacity) { - this.history.shift(); - } - this.history.push(record); - - this.totalFrames++; - } - - getFPS() { - const avg = this.frameTime.average(); - return avg > 0 ? 1000 / avg : 0; - } - - getMinFPS() { - const max = this.frameTime.max(); - return max > 0 ? 1000 / max : 0; - } - - getMaxFPS() { - const min = this.frameTime.min(); - return min > 0 ? 1000 / min : 0; - } - - formatSummary() { - return `FPS: ${this.getFPS().toFixed(1)} (min: ${this.getMinFPS().toFixed(1)}, max: ${this.getMaxFPS().toFixed(1)}) | ` + - `Frame: ${this.frameTime.average().toFixed(2)}ms | ` + - `Update: ${this.updateTime.average().toFixed(2)}ms | ` + - `Render: ${this.renderTime.average().toFixed(2)}ms | ` + - `Vertices: ${Math.round(this.vertexCount.average())} | ` + - `Lines: ${Math.round(this.lineCount.average())}`; - } - - exportToCSV() { - let csv = 'frame,total_ms,update_ms,render_ms,vertex_count,line_count,fps\n'; - - for (const record of this.history) { - csv += `${record.frame},${record.totalMs},${record.updateMs},${record.renderMs},` + - `${record.vertexCount},${record.lineCount},${record.fps}\n`; - } - - return csv; - } -} diff --git a/web-timeplot/src/plot-connections.js b/web-timeplot/src/plot-connections.js deleted file mode 100644 index 0e96dd8..0000000 --- a/web-timeplot/src/plot-connections.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Plot Connections - Links data sources to visualization plots - * - * This module manages the connection between data sources and plots, - * handling buffering, timing, and data flow. - * - * Connection Types: - * - DirectConnection: Lines from source → plot (no buffering) - * - BufferedConnection: Points → buffer → lines → plot - * - SynchronizedConnection: Multiple sources → synchronized output - */ - -/** - * Base connection class - */ -class PlotConnection { - constructor(source, plot, config = {}) { - this.source = source; - this.plot = plot; - this.config = config; - this.isActive = false; - this.subscriptions = []; - } - - /** - * Activate the connection - start data flow - */ - connect() { - if (this.isActive) return; - this.isActive = true; - this.setupSubscriptions(); - this.source.start(); - } - - /** - * Deactivate the connection - stop data flow - */ - disconnect() { - if (!this.isActive) return; - this.isActive = false; - this.cleanup(); - this.source.stop(); - } - - /** - * Setup event subscriptions (override in subclasses) - */ - setupSubscriptions() { - throw new Error('setupSubscriptions() must be implemented by subclass'); - } - - /** - * Cleanup subscriptions - */ - cleanup() { - this.subscriptions.forEach(unsub => unsub()); - this.subscriptions = []; - } -} - -/** - * Direct connection - passes lines directly from source to plot - * Use when source emits complete lines of data - */ -export class DirectConnection extends PlotConnection { - setupSubscriptions() { - const unsubLine = this.source.on('line', (data) => { - this.plot.addLine(data.points, data.metadata); - }); - - const unsubError = this.source.on('error', (data) => { - console.error('[DirectConnection] Source error:', data.error); - }); - - this.subscriptions.push(unsubLine, unsubError); - } -} - -/** - * Buffered connection - buffers individual points into lines - * Use when source emits individual data points that need to be assembled - */ -export class BufferedConnection extends PlotConnection { - constructor(source, plot, config = {}) { - super(source, plot, config); - this.buffer = []; - this.bufferSize = config.bufferSize || 100; - this.bufferTimeout = config.bufferTimeout || 1000; // ms - this.lastFlush = Date.now(); - this.flushHandle = null; - - // Start auto-flush timer - if (config.autoFlush !== false) { - this.startAutoFlush(); - } - } - - setupSubscriptions() { - const unsubPoint = this.source.on('point', (data) => { - this.addToBuffer(data); - }); - - const unsubError = this.source.on('error', (data) => { - console.error('[BufferedConnection] Source error:', data.error); - }); - - this.subscriptions.push(unsubPoint, unsubError); - } - - addToBuffer(data) { - this.buffer.push(data); - - // Flush if buffer is full - if (this.buffer.length >= this.bufferSize) { - this.flush(); - } - } - - flush() { - if (this.buffer.length === 0) return; - - // Convert buffer to line points - const points = this.buffer.map((data, idx) => { - const x = (idx / this.buffer.length) * this.plot.width; - return { x, y: data.value }; - }); - - this.plot.addLine(points, { - timestamp: this.lastFlush, - pointCount: this.buffer.length, - }); - - this.buffer = []; - this.lastFlush = Date.now(); - } - - startAutoFlush() { - this.flushHandle = setInterval(() => { - const timeSinceLastFlush = Date.now() - this.lastFlush; - if (timeSinceLastFlush >= this.bufferTimeout && this.buffer.length > 0) { - this.flush(); - } - }, 100); // Check every 100ms - } - - cleanup() { - super.cleanup(); - if (this.flushHandle) { - clearInterval(this.flushHandle); - this.flushHandle = null; - } - } -} - -/** - * Synchronized connection - synchronizes multiple sources to one plot - * Useful for combining multiple data streams - */ -export class SynchronizedConnection extends PlotConnection { - constructor(sources, plot, config = {}) { - super(null, plot, config); // No single source - this.sources = sources; - this.syncMode = config.syncMode || 'wait-for-all'; // 'wait-for-all', 'first-available' - this.lineBuffers = new Map(); // sourceId => latest line - } - - connect() { - if (this.isActive) return; - this.isActive = true; - - this.sources.forEach((source, idx) => { - const unsubLine = source.on('line', (data) => { - this.handleSourceLine(idx, data); - }); - - const unsubError = source.on('error', (data) => { - console.error(`[SynchronizedConnection] Source ${idx} error:`, data.error); - }); - - this.subscriptions.push(unsubLine, unsubError); - source.start(); - }); - } - - disconnect() { - if (!this.isActive) return; - this.isActive = false; - this.cleanup(); - this.sources.forEach(source => source.stop()); - } - - handleSourceLine(sourceIdx, data) { - this.lineBuffers.set(sourceIdx, data); - - if (this.syncMode === 'wait-for-all') { - // Wait until we have data from all sources - if (this.lineBuffers.size === this.sources.length) { - this.emitSynchronized(); - } - } else if (this.syncMode === 'first-available') { - // Emit immediately - this.plot.addLine(data.points, { - ...data.metadata, - sourceIdx, - }); - } - } - - emitSynchronized() { - // For now, just emit the first source's line - // Could implement more sophisticated merging - const firstLine = this.lineBuffers.get(0); - if (firstLine) { - this.plot.addLine(firstLine.points, firstLine.metadata); - } - this.lineBuffers.clear(); - } -} - -/** - * Connection Manager - manages multiple connections - */ -export class ConnectionManager { - constructor() { - this.connections = new Map(); // connectionId => connection - this.nextId = 0; - } - - /** - * Create and register a connection - * @returns {number} connectionId - */ - connect(source, plot, config = {}) { - const type = config.type || 'direct'; - let connection; - - switch (type) { - case 'direct': - connection = new DirectConnection(source, plot, config); - break; - case 'buffered': - connection = new BufferedConnection(source, plot, config); - break; - case 'synchronized': - connection = new SynchronizedConnection(source, plot, config); - break; - default: - throw new Error(`Unknown connection type: ${type}`); - } - - const id = this.nextId++; - this.connections.set(id, connection); - connection.connect(); - - return id; - } - - /** - * Disconnect and remove a connection - */ - disconnect(connectionId) { - const connection = this.connections.get(connectionId); - if (connection) { - connection.disconnect(); - this.connections.delete(connectionId); - } - } - - /** - * Disconnect all connections - */ - disconnectAll() { - this.connections.forEach(connection => connection.disconnect()); - this.connections.clear(); - } - - /** - * Get statistics about connections - */ - getStats() { - return { - activeConnections: this.connections.size, - connections: Array.from(this.connections.entries()).map(([id, conn]) => ({ - id, - isActive: conn.isActive, - type: conn.constructor.name, - })), - }; - } -} - -/** - * Helper functions for common connection patterns - */ - -/** - * Connect a synthetic data source to a plot - * @param {DataGenerator} generator - Test data generator instance - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {DirectConnection} The connection instance - */ -export function connectSyntheticData(generator, plot, config = {}) { - const { SyntheticDataSource } = require('./data-sources.js'); - - const source = new SyntheticDataSource({ - generator, - pointsPerLine: config.pointsPerLine || 100, - width: plot.width, - lineInterval: config.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot, config); - connection.connect(); - - return connection; -} - -/** - * Connect a function-based source to a plot - * @param {Function} func - Function (x, t) => y - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {DirectConnection} The connection instance - */ -export function connectFunction(func, plot, config = {}) { - const { FunctionDataSource } = require('./data-sources.js'); - - const source = new FunctionDataSource({ - func, - pointsPerLine: config.pointsPerLine || 100, - width: plot.width, - amplitude: config.amplitude || 30, - lineInterval: config.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot, config); - connection.connect(); - - return connection; -} - -/** - * Connect a streaming source to a plot with buffering - * @param {DataGenerator} generator - Test data generator instance - * @param {TimeSeriesPlot} plot - Plot to display data - * @param {Object} config - Configuration options - * @returns {BufferedConnection} The connection instance - */ -export function connectStreamingData(generator, plot, config = {}) { - const { StreamingDataSource } = require('./data-sources.js'); - - const source = new StreamingDataSource({ - generator, - sampleRate: config.sampleRate || 60, - }); - - const connection = new BufferedConnection(source, plot, { - bufferSize: config.bufferSize || 100, - bufferTimeout: config.bufferTimeout || 1000, - }); - connection.connect(); - - return connection; -} - -/** - * Quick setup: Create a plot with a data source in one call - * @param {Application} app - PixiJS application - * @param {Object} plotConfig - Plot configuration - * @param {Object} sourceConfig - Source configuration - * @returns {Object} {plot, source, connection} - */ -export function createConnectedPlot(app, plotConfig, sourceConfig) { - const { TimeSeriesPlot } = require('./timeseries-plot.js'); - const { SyntheticDataSource } = require('./data-sources.js'); - - const plot = new TimeSeriesPlot(plotConfig); - app.stage.addChild(plot.container); - - const source = new SyntheticDataSource({ - generator: sourceConfig.generator, - pointsPerLine: plotConfig.width / 8, // Default: ~8 pixels per point - width: plotConfig.width, - lineInterval: sourceConfig.lineInterval || 100, - }); - - const connection = new DirectConnection(source, plot); - connection.connect(); - - return { plot, source, connection }; -} diff --git a/web-timeplot/src/plot/plot-buffer.js b/web-timeplot/src/plot/plot-buffer.js deleted file mode 100644 index b13cdd8..0000000 --- a/web-timeplot/src/plot/plot-buffer.js +++ /dev/null @@ -1,22 +0,0 @@ -export class PlotBuffer { - constructor(maxPoints = 1600) { - this.maxPoints = maxPoints; - this.points = []; - } - - addPoint(point) { - this.points.push(point); - if (this.points.length > this.maxPoints) { - this.points.splice(0, this.points.length - this.maxPoints); - } - } - - clear() { - this.points = []; - } - - getVisiblePoints(currentPlotTimeMs, windowDurationMs) { - const minTime = currentPlotTimeMs - windowDurationMs; - return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs); - } -} diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js deleted file mode 100644 index ce90a1f..0000000 --- a/web-timeplot/src/plot/timeplot-view.js +++ /dev/null @@ -1,442 +0,0 @@ -import { Application, Container, Graphics, Text } from 'pixi.js'; -import { formatDuration, formatValue, formatWallClock } from '../utils-format.js'; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function roundRect(graphics, x, y, width, height, radius, fill, stroke) { - graphics.roundRect(x, y, width, height, radius); - graphics.fill(fill); - graphics.stroke(stroke); -} - -export class TimeplotView { - constructor({ host, panelId = 'primary', title = 'Vertical plot', subtitle = null, showReadouts = true, lineColor = 0x9fd1ff, pointColor = 0xe7f2ff }) { - this.host = host; - this.panelId = panelId; - this.panelTitle = title; - this.panelSubtitle = subtitle; - this.showReadouts = showReadouts; - this.lineColor = lineColor; - this.pointColor = pointColor; - this.app = new Application(); - this.container = new Container(); - this.background = new Graphics(); - this.grid = new Graphics(); - this.axes = new Graphics(); - this.line = new Graphics(); - this.points = new Graphics(); - this.crosshair = new Graphics(); - this.overlay = new Container(); - this.readoutBackground = new Graphics(); - this.axisLabelLayer = new Container(); - this.titleText = new Text({ - text: 'Plot viewport', - style: { - fill: 0xeef4ff, - fontFamily: 'Inter, sans-serif', - fontSize: 16, - }, - }); - this.subtitleText = new Text({ - text: 'Synthetic data stream', - style: { - fill: 0x8ca3c7, - fontFamily: 'Inter, sans-serif', - fontSize: 12, - }, - }); - this.realTimeText = new Text({ - text: '', - style: { - fill: 0xe8eef7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 11, - }, - }); - this.plotTimeText = new Text({ - text: '', - style: { - fill: 0xe8eef7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 11, - }, - }); - this.axisTitleText = new Text({ - text: '', - style: { - fill: 0x90a0b7, - fontFamily: 'Inter, sans-serif', - fontSize: 10, - fontWeight: '600', - letterSpacing: 1.5, - }, - }); - this.screenPoints = []; - this.bounds = { width: 100, height: 100 }; - this.hoverRadiusPx = 20; - this.pointer = null; - this.lastPointerEventAt = 0; - this.axisLabels = []; - } - - async init() { - const rendererPreference = navigator.gpu ? 'webgpu' : 'webgl'; - await this.app.init({ - preference: rendererPreference, - resizeTo: this.host, - antialias: true, - backgroundAlpha: 0, - resolution: Math.min(window.devicePixelRatio || 1, 2), - }); - - this.app.stage.addChild(this.container); - this.container.addChild(this.background); - this.container.addChild(this.grid); - this.container.addChild(this.axes); - this.container.addChild(this.line); - this.container.addChild(this.points); - this.container.addChild(this.crosshair); - this.container.addChild(this.overlay); - this.overlay.addChild(this.readoutBackground); - this.overlay.addChild(this.axisLabelLayer); - this.overlay.addChild(this.titleText); - this.overlay.addChild(this.subtitleText); - this.overlay.addChild(this.realTimeText); - this.overlay.addChild(this.plotTimeText); - this.overlay.addChild(this.axisTitleText); - this.host.appendChild(this.app.canvas); - this.attachPointerListeners(); - - return rendererPreference; - } - - attachPointerListeners() { - this.host.addEventListener('pointerleave', () => { - this.pointer = null; - this.lastPointerEventAt = performance.now(); - }); - - this.host.addEventListener('pointermove', (event) => { - const rect = this.host.getBoundingClientRect(); - this.pointer = { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - }; - this.lastPointerEventAt = performance.now(); - }); - } - - resize() { - this.bounds = { - width: this.host.clientWidth, - height: this.host.clientHeight, - }; - } - - render(state, points) { - this.resize(); - this.renderFrame(state, points); - this.clearHover(); - } - - clearHover() { - this.crosshair.clear(); - } - - getHoverCandidate() { - if (!this.pointer || this.screenPoints.length === 0) { - return null; - } - - let nearestPoint = null; - let nearestDistance = Infinity; - - for (const point of this.screenPoints) { - const dx = point.x - this.pointer.x; - const dy = point.y - this.pointer.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (distance < nearestDistance) { - nearestPoint = point; - nearestDistance = distance; - } - } - - if (!nearestPoint || nearestDistance > this.hoverRadiusPx) { - return null; - } - - return { - panelId: this.panelId, - point: nearestPoint, - x: clamp(nearestPoint.x, 0, this.bounds.width), - y: clamp(nearestPoint.y, 0, this.bounds.height), - pointerX: this.pointer.x, - pointerY: this.pointer.y, - distance: nearestDistance, - lastPointerEventAt: this.lastPointerEventAt, - }; - } - - hasPointer() { - return this.pointer !== null; - } - - findNearestScreenPointByTime(timeMs) { - if (this.screenPoints.length === 0) { - return null; - } - - let nearestPoint = null; - let nearestDelta = Infinity; - - for (const point of this.screenPoints) { - const delta = Math.abs(point.timeMs - timeMs); - if (delta < nearestDelta) { - nearestPoint = point; - nearestDelta = delta; - } - } - - return nearestPoint; - } - - renderLinkedHover(hoverPoint) { - this.crosshair.clear(); - - if (!hoverPoint) { - return; - } - - const x = clamp(hoverPoint.x, 0, this.bounds.width); - const y = clamp(hoverPoint.y, 0, this.bounds.height); - - this.crosshair.moveTo(x, 0); - this.crosshair.lineTo(x, this.bounds.height); - this.crosshair.moveTo(0, y); - this.crosshair.lineTo(this.bounds.width, y); - this.crosshair.stroke({ color: 0x8cb8ff, width: 1, alpha: 0.24 }); - this.crosshair.rect(x - 4, y - 4, 8, 8); - this.crosshair.stroke({ color: 0xffffff, width: 1.5, alpha: 0.95 }); - } - - ensureAxisLabelCount(count) { - while (this.axisLabels.length < count) { - const label = new Text({ - text: '', - style: { - fill: 0x90a0b7, - fontFamily: 'IBM Plex Mono, monospace', - fontSize: 10, - }, - }); - this.axisLabels.push(label); - this.axisLabelLayer.addChild(label); - } - - while (this.axisLabels.length > count) { - const label = this.axisLabels.pop(); - this.axisLabelLayer.removeChild(label); - label.destroy(); - } - } - - renderAxes({ padding, plotWidth, plotHeight, minTime, maxTime, minValue, maxValue, width }) { - const axisColor = 0x3e4c5f; - const tickColor = 0x4f627a; - const timeTickCount = 5; - const valueTickCount = 5; - const labels = []; - - this.axes.clear(); - this.axes.moveTo(padding.left, padding.top); - this.axes.lineTo(padding.left, padding.top + plotHeight); - this.axes.lineTo(padding.left + plotWidth, padding.top + plotHeight); - this.axes.stroke({ color: axisColor, width: 1, alpha: 1 }); - - for (let index = 0; index < timeTickCount; index += 1) { - const ratio = timeTickCount === 1 ? 0 : index / (timeTickCount - 1); - const y = padding.top + ratio * plotHeight; - const timeMs = minTime + ratio * (maxTime - minTime); - - this.axes.moveTo(padding.left - 8, y); - this.axes.lineTo(padding.left, y); - this.axes.stroke({ color: tickColor, width: 1, alpha: 1 }); - - labels.push({ - text: formatDuration(timeMs), - x: 14, - y: y - 7, - anchorX: 0, - }); - } - - for (let index = 0; index < valueTickCount; index += 1) { - const ratio = valueTickCount === 1 ? 0 : index / (valueTickCount - 1); - const x = padding.left + ratio * plotWidth; - const value = minValue + ratio * (maxValue - minValue); - - this.axes.moveTo(x, padding.top + plotHeight); - this.axes.lineTo(x, padding.top + plotHeight + 8); - this.axes.stroke({ color: tickColor, width: 1, alpha: 1 }); - - labels.push({ - text: formatValue(value), - x, - y: padding.top + plotHeight + 10, - anchorX: 0.5, - }); - } - - this.ensureAxisLabelCount(labels.length); - labels.forEach((config, index) => { - const label = this.axisLabels[index]; - label.text = config.text; - label.x = config.x; - label.y = config.y; - label.anchor.set(config.anchorX, 0); - }); - - this.axisTitleText.text = 'TIME'; - this.axisTitleText.x = 18; - this.axisTitleText.y = padding.top - 18; - this.axisTitleText.rotation = 0; - - this.axes.moveTo(padding.left + plotWidth, padding.top + plotHeight); - this.axes.lineTo(width - 14, padding.top + plotHeight); - this.axes.stroke({ color: 0x202a35, width: 1, alpha: 1 }); - } - - renderReadouts(state, width) { - if (!this.showReadouts) { - this.readoutBackground.clear(); - this.realTimeText.text = ''; - this.plotTimeText.text = ''; - return; - } - - const boxWidth = 168; - const boxHeight = 22; - const gap = 6; - const left = width - boxWidth - 18; - const top = 14; - - this.readoutBackground.clear(); - this.readoutBackground.rect(left, top, boxWidth, boxHeight); - this.readoutBackground.fill({ color: 0x10161d, alpha: 1 }); - this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 }); - this.readoutBackground.rect(left, top + boxHeight + gap, boxWidth, boxHeight); - this.readoutBackground.fill({ color: 0x10161d, alpha: 1 }); - this.readoutBackground.stroke({ color: 0x2f3c4d, width: 1, alpha: 1 }); - - this.realTimeText.text = `REAL ${formatWallClock(state.time.realNowMs)}`; - this.realTimeText.x = left + 10; - this.realTimeText.y = top + 5; - - this.plotTimeText.text = `PLOT ${formatDuration(state.time.plotTimeMs)}`; - this.plotTimeText.x = left + 10; - this.plotTimeText.y = top + boxHeight + gap + 5; - } - - renderFrame(state, points) { - const width = this.bounds.width; - const height = this.bounds.height; - const padding = { top: 72, right: 28, bottom: 46, left: 88 }; - const plotWidth = Math.max(10, width - padding.left - padding.right); - const plotHeight = Math.max(10, height - padding.top - padding.bottom); - const minTime = state.time.plotTimeMs - state.plot.windowDurationMs; - const maxTime = Math.max(state.time.plotTimeMs, minTime + 1); - const { min: minValue, max: maxValue } = state.plot.valueRange; - const valueSpan = Math.max(0.001, maxValue - minValue); - - this.background.clear(); - roundRect( - this.background, - 0, - 0, - width, - height, - 6, - { color: 0x05070b, alpha: 1 }, - { color: 0x2c3b4d, width: 1 }, - ); - - this.grid.clear(); - if (state.plot.showGrid) { - const gridColor = 0x21344a; - for (let x = 0; x <= 6; x += 1) { - const px = padding.left + (plotWidth * x) / 6; - this.grid.moveTo(px, padding.top); - this.grid.lineTo(px, padding.top + plotHeight); - this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 }); - } - - for (let y = 0; y <= 8; y += 1) { - const py = padding.top + (plotHeight * y) / 8; - this.grid.moveTo(padding.left, py); - this.grid.lineTo(padding.left + plotWidth, py); - this.grid.stroke({ color: gridColor, width: 1, alpha: 0.85 }); - } - } - - this.renderAxes({ - padding, - plotWidth, - plotHeight, - minTime, - maxTime, - minValue, - maxValue, - width, - }); - - this.line.clear(); - this.points.clear(); - this.screenPoints = []; - - if (points.length > 0) { - points.forEach((point, index) => { - const x = padding.left + ((point.value - minValue) / valueSpan) * plotWidth; - const y = padding.top + ((point.timeMs - minTime) / (maxTime - minTime)) * plotHeight; - - this.screenPoints.push({ ...point, x, y }); - - if (index === 0) { - this.line.moveTo(x, y); - } else { - this.line.lineTo(x, y); - } - }); - - this.line.stroke({ - color: this.lineColor, - width: 2, - alpha: 0.96, - cap: 'square', - join: 'miter', - }); - - if (state.plot.showPoints) { - for (const point of this.screenPoints) { - this.points.rect(point.x - 2, point.y - 2, 4, 4); - this.points.fill({ color: this.pointColor, alpha: 0.92 }); - } - } - } - - this.titleText.text = this.panelTitle; - this.titleText.x = 20; - this.titleText.y = 14; - - this.subtitleText.text = this.panelSubtitle ?? `value → ${state.source.preset} · ${state.source.sampleRateHz} hz · time ↓`; - this.subtitleText.x = 20; - this.subtitleText.y = 36; - - this.renderReadouts(state, width); - } - - destroy() { - this.app.destroy(true, { children: true }); - } -} diff --git a/web-timeplot/src/state.js b/web-timeplot/src/state.js deleted file mode 100644 index 53d8279..0000000 --- a/web-timeplot/src/state.js +++ /dev/null @@ -1,420 +0,0 @@ -/** - * StateManager - Centralized state management with Proxy-based reactivity - * - * Usage: - * state.time.speed = 2.0 // automatically emits events - * state.on('time.speed', (value) => console.log('Speed changed:', value)) - * state.on('time.*', (change) => console.log('Time domain changed:', change)) - * - * State Domains: - * - userPrefs: showGrid, showMetrics, theme, etc. - * - uiConfig: active panels, layout, dimensions - * - time: current time, speed, paused state, real elapsed time - * - rendering: graphs, renderer info - * - health: framerate, service connections, db access - * - dataInput: sources, structure, metadata - * - inputActions: keyboard/mouse/gamepad action mappings - */ - -// Simple EventEmitter implementation -class EventEmitter { - constructor() { - this.events = new Map(); - } - - on(event, callback) { - if (!this.events.has(event)) { - this.events.set(event, []); - } - this.events.get(event).push(callback); - - // Return unsubscribe function - return () => this.off(event, callback); - } - - off(event, callback) { - if (!this.events.has(event)) return; - const callbacks = this.events.get(event); - const index = callbacks.indexOf(callback); - if (index > -1) { - callbacks.splice(index, 1); - } - } - - emit(event, data) { - if (!this.events.has(event)) return; - this.events.get(event).forEach(callback => { - try { - callback(data); - } catch (e) { - console.error(`[State] Error in event handler for '${event}':`, e); - } - }); - } - - once(event, callback) { - const wrapper = (data) => { - callback(data); - this.off(event, wrapper); - }; - this.on(event, wrapper); - } - - clear() { - this.events.clear(); - } -} - -export class StateManager extends EventEmitter { - constructor() { - super(); - - // Internal state storage (not proxied) - this._state = { - userPrefs: { - showGrid: true, - showMetrics: true, - theme: 'dark', - rollingWindow: 60, - historyCapacity: 10000, - metricsUpdateInterval: 10, - }, - - uiConfig: { - activePanels: ['graph1', 'graph2'], - layout: 'horizontal-split', - canvasWidth: 0, - canvasHeight: 0, - }, - - time: { - current: 0, // Current plot time - realElapsed: 0, // Real time elapsed since start - speed: 1.0, // Time speed multiplier (0.1 to 5.0) - isPaused: false, // Pause state - startTimestamp: Date.now(), // Real timestamp when started - verticalScale: 1.0, // Vertical zoom for time history - }, - - rendering: { - rendererType: 'unknown', // 'webgpu' | 'webgl' | 'canvas' - frameCounter: 0, - // Note: graph instances are NOT stored here to avoid proxy wrapping - }, - - health: { - fps: 0, - updateMs: 0, - renderMs: 0, - vertexCount: 0, - lineCount: 0, - serviceConnections: {}, // e.g., { websocket: 'connected', mqtt: 'disconnected' } - }, - - dataInput: { - sources: [], // Array of data source configs - activeSource: null, // Currently active source - dataStructure: null, // Schema of incoming data - metadata: {}, // Additional metadata - }, - - inputActions: { - keyboardMap: new Map(), // Map of KeyboardEvent.code => action name - mouseMap: new Map(), // Map of mouse button => action name - actionHandlers: new Map(), // Map of action name => handler function - }, - }; - - // Track which domains should be persisted - this._persistedDomains = new Set(['userPrefs']); - - // Load persisted state - this._loadPersistedState(); - - // Create proxied state - this is what users interact with - this.state = this._createProxy(this._state, []); - } - - /** - * Create a reactive Proxy that emits events on property changes - * @param {Object} target - The object to proxy - * @param {Array} path - Current property path (e.g., ['time', 'speed']) - * @private - */ - _createProxy(target, path) { - // Don't proxy non-objects or special objects like Map/Set - if (typeof target !== 'object' || target === null) { - return target; - } - - // Don't proxy Maps and Sets - they need special handling - if (target instanceof Map || target instanceof Set) { - return target; - } - - const self = this; - - return new Proxy(target, { - get(obj, prop) { - const value = obj[prop]; - - // Return primitives and functions as-is - if (typeof value !== 'object' || value === null) { - return value; - } - - // Return nested objects as proxies - return self._createProxy(value, [...path, prop]); - }, - - set(obj, prop, value) { - const oldValue = obj[prop]; - - // Only emit if value actually changed - if (oldValue === value) { - return true; - } - - obj[prop] = value; - - // Build event path - const fullPath = [...path, prop]; - const pathString = fullPath.join('.'); - const domain = fullPath[0]; - - // Emit specific property change: "time.speed" - self.emit(pathString, { - path: fullPath, - value: value, - oldValue: oldValue, - }); - - // Emit domain wildcard: "time.*" - if (domain) { - self.emit(`${domain}.*`, { - path: fullPath, - property: prop, - value: value, - oldValue: oldValue, - }); - } - - // Emit global wildcard: "*" - self.emit('*', { - path: fullPath, - value: value, - oldValue: oldValue, - }); - - // Auto-persist certain domains - if (self._persistedDomains.has(domain)) { - self._persistDomain(domain); - } - - return true; - } - }); - } - - // ========================================================================= - // Persistence - // ========================================================================= - - _persistDomain(domain) { - try { - const data = this._state[domain]; - // Convert Maps to objects for JSON serialization - const serializable = this._makeSerializable(data); - localStorage.setItem(`timeplot-${domain}`, JSON.stringify(serializable)); - } catch (e) { - console.warn(`[State] Failed to persist ${domain}:`, e); - } - } - - _loadPersistedState() { - this._persistedDomains.forEach(domain => { - try { - const saved = localStorage.getItem(`timeplot-${domain}`); - if (saved) { - const data = JSON.parse(saved); - // Deep merge to preserve defaults for new properties - this._state[domain] = this._deepMerge(this._state[domain], data); - } - } catch (e) { - console.warn(`[State] Failed to load ${domain}:`, e); - } - }); - } - - _makeSerializable(obj) { - if (obj instanceof Map) { - return Object.fromEntries(obj); - } - if (obj instanceof Set) { - return Array.from(obj); - } - if (typeof obj === 'object' && obj !== null) { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = this._makeSerializable(value); - } - return result; - } - return obj; - } - - _deepMerge(target, source) { - const result = { ...target }; - for (const key in source) { - if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { - result[key] = this._deepMerge(target[key] || {}, source[key]); - } else { - result[key] = source[key]; - } - } - return result; - } - - // ========================================================================= - // Convenience Methods - // ========================================================================= - - /** - * Toggle a boolean preference - */ - togglePref(key) { - const current = this.state.userPrefs[key]; - if (typeof current === 'boolean') { - this.state.userPrefs[key] = !current; - } - } - - /** - * Pause/resume time - */ - togglePause() { - this.state.time.isPaused = !this.state.time.isPaused; - } - - /** - * Set time speed (clamped 0.1 to 5.0) - */ - setTimeSpeed(speed) { - this.state.time.speed = Math.max(0.1, Math.min(5.0, speed)); - } - - /** - * Increment time (respects pause and speed) - */ - incrementTime(delta) { - if (this.state.time.isPaused) return; - this.state.time.current += delta * this.state.time.speed; - } - - /** - * Update real elapsed time - */ - updateRealElapsed() { - const elapsed = (Date.now() - this.state.time.startTimestamp) / 1000; - this.state.time.realElapsed = elapsed; - } - - // ========================================================================= - // Input Actions System - // ========================================================================= - - /** - * Register an input action handler - * @param {string} actionName - Name of the action (e.g., 'toggleGrid', 'pause') - * @param {Function} handler - Handler function to call - */ - registerAction(actionName, handler) { - this.state.inputActions.actionHandlers.set(actionName, handler); - } - - /** - * Map a keyboard key to an action - * @param {string} code - KeyboardEvent.code (e.g., 'KeyG', 'Space') - * @param {string} actionName - Action to trigger - */ - mapKey(code, actionName) { - this.state.inputActions.keyboardMap.set(code, actionName); - } - - /** - * Map a mouse button to an action - * @param {number} button - Mouse button number (0=left, 1=middle, 2=right) - * @param {string} actionName - Action to trigger - */ - mapMouseButton(button, actionName) { - this.state.inputActions.mouseMap.set(button, actionName); - } - - /** - * Execute an action by name - */ - executeAction(actionName, event) { - const handler = this.state.inputActions.actionHandlers.get(actionName); - if (handler) { - handler(event); - } else { - console.warn(`[State] No handler registered for action: ${actionName}`); - } - } - - /** - * Handle keyboard event through action system - */ - handleKeyboardEvent(event) { - const actionName = this.state.inputActions.keyboardMap.get(event.code); - if (actionName) { - this.executeAction(actionName, event); - return true; - } - return false; - } - - /** - * Handle mouse button event through action system - */ - handleMouseButtonEvent(event) { - const actionName = this.state.inputActions.mouseMap.get(event.button); - if (actionName) { - this.executeAction(actionName, event); - return true; - } - return false; - } - - // ========================================================================= - // Data Sources - // ========================================================================= - - addDataSource(source) { - this.state.dataInput.sources.push(source); - } - - removeDataSource(sourceId) { - const sources = this.state.dataInput.sources; - const index = sources.findIndex(s => s.id === sourceId); - if (index > -1) { - sources.splice(index, 1); - } - } - - setActiveDataSource(sourceId) { - this.state.dataInput.activeSource = sourceId; - } - - // ========================================================================= - // Debugging - // ========================================================================= - - dump() { - console.log('[State] Current state:', JSON.parse(JSON.stringify(this._state))); - } - - debugEvents() { - console.log('[State] Registered events:', Array.from(this.events.keys())); - } -} diff --git a/web-timeplot/src/styles.css b/web-timeplot/src/styles.css deleted file mode 100644 index 6b0477f..0000000 --- a/web-timeplot/src/styles.css +++ /dev/null @@ -1,401 +0,0 @@ -:root { - color-scheme: dark; - --bg: #0a0c10; - --bg-alt: #0f1319; - --surface: #11161d; - --surface-strong: #0d1117; - --surface-raised: #171d26; - --border: #28313d; - --border-strong: #394657; - --text: #edf2f7; - --muted: #97a3b4; - --accent: #9fc7ff; - --accent-strong: #d8e8ff; - --shadow: none; - font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -* { - box-sizing: border-box; -} - -html, -body, -#app { - width: 100%; - height: 100%; - margin: 0; -} - -body { - background: - linear-gradient(180deg, #080a0d 0%, #0d1015 100%); - color: var(--text); - overflow: hidden; -} - -button, -input, -select { - font: inherit; -} - -.timeplot-shell { - display: grid; - grid-template-columns: minmax(0, 1fr) 340px; - grid-template-rows: auto minmax(0, 1fr); - width: 100%; - height: 100%; - gap: 10px; - padding: 10px; -} - -.timeplot-topbar { - grid-column: 1 / -1; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 14px; - border: 1px solid var(--border-strong); - background: var(--surface); - border-radius: 4px; - box-shadow: var(--shadow); -} - -.timeplot-brand { - display: flex; - flex-direction: column; - gap: 2px; -} - -.timeplot-title { - margin: 0; - font-size: 1rem; - letter-spacing: 0.08em; - text-transform: uppercase; - font-weight: 700; -} - -.timeplot-subtitle { - color: var(--muted); - font-size: 0.78rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.timeplot-toolbar { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.control-group { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 8px; - background: var(--surface-raised); - border: 1px solid var(--border); - border-radius: 3px; -} - -.control-group label, -.control-group span { - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.control-group input[type='range'] { - width: 118px; -} - -.control-group input[type='range'] { - accent-color: var(--accent); -} - -.control-button, -.panel-toggle { - color: var(--text); - background: var(--surface); - border: 1px solid var(--border-strong); - border-radius: 2px; - padding: 7px 11px; - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease, color 120ms ease; - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.72rem; - line-height: 1; -} - -.control-button:hover, -.panel-toggle:hover { - border-color: var(--accent); - color: var(--accent-strong); -} - -.control-button[data-active='true'], -.panel-toggle[data-active='true'] { - background: #1a2230; - border-color: var(--accent); - color: var(--accent-strong); -} - -.timeplot-viewport { - position: relative; - min-height: 0; - border-radius: 4px; - overflow: hidden; - border: 1px solid var(--border-strong); - background: #06080b; - box-shadow: var(--shadow); - padding: 10px; -} - -.timeplot-plot-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; - width: 100%; - height: 100%; - min-height: 0; -} - -.timeplot-plot-panel { - position: relative; - min-width: 0; - min-height: 0; - border: 1px solid var(--border); - background: #070a0d; -} - -.timeplot-canvas-host { - width: 100%; - height: 100%; -} - -.timeplot-sidebar { - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; - overflow-y: auto; - padding-right: 2px; -} - -.panel { - border: 1px solid var(--border-strong); - background: var(--surface-strong); - border-radius: 4px; - padding: 14px; -} - -.panel[hidden] { - display: none; -} - -.panel h2 { - margin: 0 0 12px; - font-size: 0.8rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.panel-subsection + .panel-subsection { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--border); -} - -.panel-section-title { - margin-bottom: 10px; - color: var(--accent-strong); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.kv-list { - display: grid; - grid-template-columns: auto 1fr; - gap: 10px 12px; - align-items: center; - margin: 0; -} - -.kv-list dt { - color: var(--muted); - font-size: 0.73rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.kv-list dd { - margin: 0; - text-align: right; - font-variant-numeric: tabular-nums; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; -} - -.field-grid { - display: grid; - gap: 12px; -} - -.field-grid label { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.field-grid[data-source-mode][hidden] { - display: none; -} - -.source-meta { - min-height: 20px; - color: var(--muted); - font-size: 0.76rem; - line-height: 1.4; -} - -.source-meta-error { - color: #ff9d9d; -} - -.source-meta-status { - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.source-meta-status-connected { - color: #99e2b4; -} - -.source-meta-status-connecting { - color: #ffd27f; -} - -.source-meta-status-disconnected, -.source-meta-status-idle { - color: var(--muted); -} - -.source-meta-status-error { - color: #ff9d9d; -} - -.field-grid input, -.field-grid select { - width: 100%; - padding: 9px 10px; - border-radius: 2px; - border: 1px solid var(--border); - background: var(--surface-raised); - color: var(--text); -} - -.field-grid input:focus, -.field-grid select:focus { - outline: none; - border-color: var(--accent); -} - -.panel-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - color: var(--muted); - font-size: 0.74rem; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.panel-row + .panel-row { - margin-top: 10px; -} - -.panel-row input[type='checkbox'] { - inline-size: 16px; - block-size: 16px; - accent-color: var(--accent); -} - -.muted { - color: var(--muted); -} - -.help-list { - display: grid; - gap: 8px; - margin: 0; - padding-left: 18px; - color: var(--muted); - font-size: 0.82rem; -} - -.timeplot-tooltip { - position: absolute; - min-width: 180px; - padding: 10px 12px; - border-radius: 3px; - border: 1px solid var(--border-strong); - background: #0d1218; - box-shadow: var(--shadow); - pointer-events: none; - transform: translate(12px, -50%); - z-index: 10; -} - -.timeplot-tooltip[hidden] { - display: none; -} - -.timeplot-tooltip-title { - margin-bottom: 8px; - font-size: 0.72rem; - color: var(--accent-strong); - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.timeplot-tooltip-row { - display: flex; - justify-content: space-between; - gap: 16px; - font-size: 0.78rem; -} - -.timeplot-tooltip-row + .timeplot-tooltip-row { - margin-top: 4px; -} - -.timeplot-empty { - color: var(--muted); - font-size: 0.85rem; -} - -@media (max-width: 1100px) { - .timeplot-shell { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(360px, 1fr) auto; - } - - .timeplot-plot-grid { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: repeat(2, minmax(260px, 1fr)); - } - - .timeplot-sidebar { - overflow: visible; - } -} diff --git a/web-timeplot/src/template-for-standard-site.js b/web-timeplot/src/template-for-standard-site.js deleted file mode 100644 index 54aacc7..0000000 --- a/web-timeplot/src/template-for-standard-site.js +++ /dev/null @@ -1,75 +0,0 @@ -//import { setupRenderSystem } from './render.js'; - -let ENVURL = "" //remote server from which to grab env -let env = {}; -let cfg = {}; //the user config -let dom = { - input: {}, - label: {}, - box: {}, //an info-containing box - icon: {}, - info: {} -}; - - -//APP START HERE -$(document).ready(async function() { - console.log('asdf'); - //the core loop of the client application - // 1. setup relationship with DOM and grab references to its elements - log('init DOM'); - await initDOM(); - - log('init cfg'); - await initCfg(); - - log('get env vars'); - await getServerEnvVars(); - - log('init services'); - await initServices(); - - //setupRenderSystem(); - - -}); - -//gets user config from local storage if there is any -function initCfg(){ - let localCfg = localStorage.getItem('cfg'); - if (localCfg) { - try { - cfg = JSON.parse(localCfg); - } catch (e) { - cfg = {}; - } - } else { - - } -} - -async function getServerEnvVars(){ - await axios.get(`${ENVURL}`).then((res)=>{ - env = res.data; - //log(env); - }).catch((err)=>{ - //log(err); - }); - log('') -} - -function initServices(){ - //connect to websocket server - //grab endpoints from cfg -} - -function initDOM(){ - dom.body = $('body')[0]; -} - -function log(msg, lvl=1){ - if (dom.debugInfo){ - dom.debugInfo.innerHTML = msg; //TODO running log + timestamp - } - console.log(msg); -}
\ No newline at end of file diff --git a/web-timeplot/src/test-data-generators.js b/web-timeplot/src/test-data-generators.js deleted file mode 100644 index 02bc0ad..0000000 --- a/web-timeplot/src/test-data-generators.js +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Test Data Generators - Classes for generating fake/test data patterns - * - * These generators produce various types of synthetic data for testing - * and visualizing the waterfall graphs with realistic patterns. - */ - -/** - * Base class for all data generators - */ -class DataGenerator { - constructor(config = {}) { - this.sampleRate = config.sampleRate || 100; // Samples per second - this.amplitude = config.amplitude || 1.0; - this.offset = config.offset || 0.0; - this.time = 0; - } - - /** - * Generate a single sample at the current time - * @returns {number} The generated value - */ - sample() { - throw new Error('sample() must be implemented by subclass'); - } - - /** - * Generate an array of samples - * @param {number} count - Number of samples to generate - * @returns {Array<number>} Array of generated values - */ - generateSamples(count) { - const samples = []; - for (let i = 0; i < count; i++) { - samples.push(this.sample()); - this.time += 1 / this.sampleRate; - } - return samples; - } - - /** - * Generate a line of points for waterfall display - * @param {number} pointCount - Number of points in the line - * @param {number} width - Width of the display area - * @returns {Array<{x: number, y: number}>} Array of points - */ - generateLine(pointCount, width) { - const points = []; - const samples = this.generateSamples(pointCount); - - for (let i = 0; i < pointCount; i++) { - const x = (i / pointCount) * width; - const y = samples[i] * this.amplitude + this.offset; - points.push({ x, y }); - } - - return points; - } - - reset() { - this.time = 0; - } -} - -/** - * Sine Wave Generator - Classic sinusoidal wave - */ -export class SineWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; // Hz - this.phase = config.phase || 0.0; // Radians - } - - sample() { - const value = Math.sin(2 * Math.PI * this.frequency * this.time + this.phase); - return value; - } -} - -/** - * Square Wave Generator - Digital-style square wave - */ -export class SquareWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - this.dutyCycle = config.dutyCycle || 0.5; // 0.0 to 1.0 - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return phase < this.dutyCycle ? 1.0 : -1.0; - } -} - -/** - * Sawtooth Wave Generator - Linear ramp wave - */ -export class SawtoothWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return 2 * phase - 1; // -1 to 1 - } -} - -/** - * Triangle Wave Generator - Linear up/down wave - */ -export class TriangleWaveGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - } - - sample() { - const period = 1 / this.frequency; - const phase = (this.time % period) / period; - return phase < 0.5 - ? 4 * phase - 1 - : 3 - 4 * phase; - } -} - -/** - * White Noise Generator - Random noise - */ -export class WhiteNoiseGenerator extends DataGenerator { - sample() { - return Math.random() * 2 - 1; // -1 to 1 - } -} - -/** - * Pink Noise Generator - 1/f noise (more realistic than white noise) - */ -export class PinkNoiseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - // Paul Kellet's refined method - this.b0 = 0; - this.b1 = 0; - this.b2 = 0; - this.b3 = 0; - this.b4 = 0; - this.b5 = 0; - this.b6 = 0; - } - - sample() { - const white = Math.random() * 2 - 1; - this.b0 = 0.99886 * this.b0 + white * 0.0555179; - this.b1 = 0.99332 * this.b1 + white * 0.0750759; - this.b2 = 0.96900 * this.b2 + white * 0.1538520; - this.b3 = 0.86650 * this.b3 + white * 0.3104856; - this.b4 = 0.55000 * this.b4 + white * 0.5329522; - this.b5 = -0.7616 * this.b5 - white * 0.0168980; - const pink = this.b0 + this.b1 + this.b2 + this.b3 + this.b4 + this.b5 + this.b6 + white * 0.5362; - this.b6 = white * 0.115926; - return pink * 0.11; // Normalize - } -} - -/** - * Perlin Noise Generator - Smooth, continuous noise - */ -export class PerlinNoiseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.frequency = config.frequency || 1.0; - this.octaves = config.octaves || 4; - this.persistence = config.persistence || 0.5; - } - - // Simple 1D Perlin-like noise - noise(x) { - const i = Math.floor(x); - const f = x - i; - - // Fade curve - const u = f * f * (3 - 2 * f); - - // Hash function for pseudo-random gradients - const hash = (n) => { - n = (n << 13) ^ n; - return (1.0 - ((n * (n * n * 15731 + 789221) + 1376312589) & 0x7fffffff) / 1073741824.0); - }; - - return (1 - u) * hash(i) + u * hash(i + 1); - } - - sample() { - let value = 0; - let amplitude = 1; - let frequency = this.frequency; - let maxValue = 0; - - for (let i = 0; i < this.octaves; i++) { - value += this.noise(this.time * frequency) * amplitude; - maxValue += amplitude; - amplitude *= this.persistence; - frequency *= 2; - } - - return value / maxValue; - } -} - -/** - * Pulse/Spike Generator - Random spikes/pulses - */ -export class PulseGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.pulseRate = config.pulseRate || 0.05; // Probability per sample - this.pulseWidth = config.pulseWidth || 0.01; // Duration in seconds - this.pulseAmplitude = config.pulseAmplitude || 1.0; - this.currentPulse = null; - } - - sample() { - // Check if we're in a pulse - if (this.currentPulse) { - if (this.time >= this.currentPulse.endTime) { - this.currentPulse = null; - } else { - return this.pulseAmplitude; - } - } - - // Random chance to start new pulse - if (Math.random() < this.pulseRate) { - this.currentPulse = { - startTime: this.time, - endTime: this.time + this.pulseWidth, - }; - return this.pulseAmplitude; - } - - return 0; - } -} - -/** - * Burst Generator - Bursts of activity with quiet periods - */ -export class BurstGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.burstDuration = config.burstDuration || 1.0; // Seconds - this.quietDuration = config.quietDuration || 2.0; // Seconds - this.burstFrequency = config.burstFrequency || 5.0; // Hz during burst - this.currentState = 'quiet'; - this.stateStartTime = 0; - } - - sample() { - const elapsed = this.time - this.stateStartTime; - - // State transitions - if (this.currentState === 'quiet' && elapsed >= this.quietDuration) { - this.currentState = 'burst'; - this.stateStartTime = this.time; - } else if (this.currentState === 'burst' && elapsed >= this.burstDuration) { - this.currentState = 'quiet'; - this.stateStartTime = this.time; - } - - // Generate value based on state - if (this.currentState === 'burst') { - return Math.sin(2 * Math.PI * this.burstFrequency * this.time); - } else { - return 0; - } - } -} - -/** - * Chirp Generator - Frequency sweep signal - */ -export class ChirpGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.startFreq = config.startFreq || 0.5; // Hz - this.endFreq = config.endFreq || 10.0; // Hz - this.duration = config.duration || 5.0; // Seconds - } - - sample() { - const t = this.time % this.duration; - const progress = t / this.duration; - const freq = this.startFreq + (this.endFreq - this.startFreq) * progress; - return Math.sin(2 * Math.PI * freq * t); - } -} - -/** - * Composite Generator - Combine multiple generators - */ -export class CompositeGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.generators = config.generators || []; - this.weights = config.weights || this.generators.map(() => 1.0); - } - - sample() { - let sum = 0; - let weightSum = 0; - - for (let i = 0; i < this.generators.length; i++) { - sum += this.generators[i].sample() * this.weights[i]; - weightSum += this.weights[i]; - } - - return weightSum > 0 ? sum / weightSum : 0; - } - - generateSamples(count) { - const samples = []; - for (let i = 0; i < count; i++) { - samples.push(this.sample()); - // Advance all child generators - this.generators.forEach(gen => gen.time += 1 / gen.sampleRate); - } - return samples; - } -} - -/** - * FM (Frequency Modulation) Generator - One signal modulates another - */ -export class FMGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.carrierFreq = config.carrierFreq || 5.0; // Hz - this.modulatorFreq = config.modulatorFreq || 0.5; // Hz - this.modulationIndex = config.modulationIndex || 2.0; - } - - sample() { - const modulator = Math.sin(2 * Math.PI * this.modulatorFreq * this.time); - const instantFreq = this.carrierFreq + this.modulationIndex * modulator; - return Math.sin(2 * Math.PI * instantFreq * this.time); - } -} - -/** - * Exponential Decay Generator - Exponentially decaying signal - */ -export class ExponentialDecayGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.decayRate = config.decayRate || 1.0; // 1/seconds - this.oscillationFreq = config.oscillationFreq || 5.0; // Hz - } - - sample() { - const envelope = Math.exp(-this.decayRate * this.time); - const oscillation = Math.sin(2 * Math.PI * this.oscillationFreq * this.time); - return envelope * oscillation; - } -} - -/** - * Step Function Generator - Random walk / brownian motion - */ -export class RandomWalkGenerator extends DataGenerator { - constructor(config = {}) { - super(config); - this.stepSize = config.stepSize || 0.1; - this.currentValue = 0; - this.bounds = config.bounds || { min: -5, max: 5 }; - } - - sample() { - // Random step - const step = (Math.random() - 0.5) * this.stepSize; - this.currentValue += step; - - // Apply bounds - this.currentValue = Math.max(this.bounds.min, Math.min(this.bounds.max, this.currentValue)); - - return this.currentValue; - } -} - -// ============================================================================ -// Example Usage and Presets -// ============================================================================ - -/** - * Factory function to create common test scenarios - */ -export class TestDataFactory { - static createSimpleSine(amplitude = 30) { - return new SineWaveGenerator({ - frequency: 2.0, - amplitude: amplitude, - sampleRate: 100, - }); - } - - static createNoisySine(amplitude = 30) { - const sine = new SineWaveGenerator({ - frequency: 2.0, - amplitude: amplitude * 0.8, - sampleRate: 100, - }); - - const noise = new WhiteNoiseGenerator({ - amplitude: amplitude * 0.2, - sampleRate: 100, - }); - - return new CompositeGenerator({ - generators: [sine, noise], - weights: [1.0, 1.0], - }); - } - - static createComplexPattern(amplitude = 30) { - const low = new SineWaveGenerator({ - frequency: 0.5, - amplitude: amplitude * 0.4, - sampleRate: 100, - }); - - const mid = new SineWaveGenerator({ - frequency: 3.0, - amplitude: amplitude * 0.3, - sampleRate: 100, - }); - - const high = new SineWaveGenerator({ - frequency: 8.0, - amplitude: amplitude * 0.2, - sampleRate: 100, - }); - - const noise = new PinkNoiseGenerator({ - amplitude: amplitude * 0.1, - sampleRate: 100, - }); - - return new CompositeGenerator({ - generators: [low, mid, high, noise], - weights: [1.0, 1.0, 1.0, 1.0], - }); - } - - static createBurstySignal(amplitude = 30) { - return new BurstGenerator({ - amplitude: amplitude, - burstDuration: 0.5, - quietDuration: 1.5, - burstFrequency: 10.0, - sampleRate: 100, - }); - } - - static createSmoothNoise(amplitude = 30) { - return new PerlinNoiseGenerator({ - amplitude: amplitude, - frequency: 2.0, - octaves: 3, - persistence: 0.5, - sampleRate: 100, - }); - } - - static createFrequencySweep(amplitude = 30) { - return new ChirpGenerator({ - amplitude: amplitude, - startFreq: 0.5, - endFreq: 10.0, - duration: 3.0, - sampleRate: 100, - }); - } - - static createModulatedSignal(amplitude = 30) { - return new FMGenerator({ - amplitude: amplitude, - carrierFreq: 5.0, - modulatorFreq: 0.3, - modulationIndex: 3.0, - sampleRate: 100, - }); - } - - static createRandomWalk(amplitude = 30) { - return new RandomWalkGenerator({ - stepSize: 0.5, - bounds: { min: -amplitude, max: amplitude }, - sampleRate: 100, - }); - } -} - -/** - * Example: How to use with WaterfallGraph - * - * // Create a generator - * const generator = TestDataFactory.createComplexPattern(30); - * - * // In your graph's addLine method: - * addLine(time, graphIdx) { - * const line = { - * points: generator.generateLine(this.pointsPerLine, this.width), - * yOffset: 0, - * color: this.generateColor(time), - * }; - * this.lines.push(line); - * } - * - * // Or generate custom samples: - * const samples = generator.generateSamples(100); - * const points = samples.map((y, i) => ({ - * x: (i / samples.length) * width, - * y: y - * })); - */ diff --git a/web-timeplot/src/timeseries-plot.js b/web-timeplot/src/timeseries-plot.js deleted file mode 100644 index e35a704..0000000 --- a/web-timeplot/src/timeseries-plot.js +++ /dev/null @@ -1,277 +0,0 @@ -import { Container, Graphics, Text } from 'pixi.js'; - -/** - * TimeSeriesPlot - Pure visualization component for time-series data - * - * This class is responsible ONLY for displaying data, not generating it. - * It receives data points from external sources and renders them as a - * scrolling waterfall display. - * - * Architecture: - * - TimeSeriesPlot: Displays data (this file) - * - DataSource: Generates/provides data (data-sources.js) - * - Connection: Links sources to plots - */ -export class TimeSeriesPlot { - constructor(config) { - this.x = config.x || 0; - this.y = config.y || 0; - this.width = config.width || 800; - this.height = config.height || 600; - this.title = config.title || 'Time Series'; - this.baseColor = config.color || 0xff6666; - - // Container for all graphics - this.container = new Container(); - this.container.x = this.x; - this.container.y = this.y; - - // Graphics layers (order matters for rendering) - this.gridGraphics = new Graphics(); - this.linesGraphics = new Graphics(); - this.borderGraphics = new Graphics(); - - this.container.addChild(this.gridGraphics); - this.container.addChild(this.linesGraphics); - this.container.addChild(this.borderGraphics); - - // Title - this.titleText = new Text({ - text: this.title, - style: { - fontFamily: 'Arial', - fontSize: 18, - fill: 0xeeeeee, - } - }); - this.titleText.x = 10; - this.titleText.y = 10; - this.container.addChild(this.titleText); - - // Display state - this.lines = []; // Array of {points, yOffset, color, metadata} - this.maxLines = config.maxLines || 100; - this.showGrid = config.showGrid !== false; - - // Scrolling and scaling - this.scrollSpeed = config.scrollSpeed || 1.0; - this.verticalScale = config.verticalScale || 1.0; - - // Initial draw - this.draw(); - } - - // ======================================================================== - // Data Input API - This is how external sources send data to the plot - // ======================================================================== - - /** - * Add a new line of data to the plot - * @param {Array<{x: number, y: number}>} points - Array of points - * @param {Object} metadata - Optional metadata (color, timestamp, etc.) - */ - addLine(points, metadata = {}) { - const line = { - points: points, - yOffset: 0, - color: metadata.color || this.generateColor(Date.now() / 1000), - timestamp: metadata.timestamp || Date.now(), - metadata: metadata, - }; - - this.lines.push(line); - - // Limit number of lines - if (this.lines.length > this.maxLines) { - this.lines.shift(); - } - } - - /** - * Add a single data point (will be buffered into a line) - * This is useful for streaming real-time data - * @param {number} timestamp - Time of the data point - * @param {number} value - Value at this time - */ - addDataPoint(timestamp, value) { - // For now, this creates a single-point line - // In a more sophisticated version, this could buffer points - // until a full line is ready - const point = { x: this.width / 2, y: value }; - this.addLine([point], { timestamp }); - } - - /** - * Clear all data from the plot - */ - clearData() { - this.lines = []; - this.drawLines(); - } - - // ======================================================================== - // Update and Rendering - // ======================================================================== - - /** - * Update the plot - called each frame - * This handles scrolling and cleanup, but NOT data generation - */ - update() { - // Scroll existing lines down - this.scrollLines(); - - // Remove off-screen lines - this.lines = this.lines.filter(line => { - const scaledOffset = line.yOffset * this.verticalScale; - return scaledOffset < this.height + 50; - }); - - // Redraw - this.drawLines(); - } - - scrollLines() { - this.lines.forEach(line => { - line.yOffset += this.scrollSpeed; - }); - } - - draw() { - this.drawBorder(); - this.drawGrid(); - this.drawLines(); - } - - drawBorder() { - this.borderGraphics.clear(); - this.borderGraphics.rect(0, 0, this.width, this.height); - this.borderGraphics.stroke({ width: 2, color: 0x606070 }); - } - - drawGrid() { - this.gridGraphics.clear(); - - if (!this.showGrid) return; - - this.gridGraphics.alpha = 0.3; - - const divisions = 10; - const color = 0x4a7a9a; - - // Vertical lines - for (let i = 0; i <= divisions; i++) { - const x = (i / divisions) * this.width; - this.gridGraphics.moveTo(x, 0); - this.gridGraphics.lineTo(x, this.height); - this.gridGraphics.stroke({ width: 1, color }); - } - - // Horizontal lines - for (let i = 0; i <= divisions; i++) { - const y = (i / divisions) * this.height; - this.gridGraphics.moveTo(0, y); - this.gridGraphics.lineTo(this.width, y); - this.gridGraphics.stroke({ width: 1, color }); - } - } - - drawLines() { - this.linesGraphics.clear(); - - for (const line of this.lines) { - if (line.points.length < 2) continue; - - // Apply vertical scale to y positions - const scaledYOffset = line.yOffset * this.verticalScale; - - // Start path - const firstPoint = line.points[0]; - this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset); - - // Draw line strip - for (let i = 1; i < line.points.length; i++) { - const point = line.points[i]; - this.linesGraphics.lineTo(point.x, point.y + scaledYOffset); - } - - this.linesGraphics.stroke({ width: 2, color: line.color }); - } - } - - generateColor(time) { - // Cycle through colors based on time - const hue = (time * 0.1) % 1.0; - const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255); - const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255); - const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255); - - return (r << 16) | (g << 8) | b; - } - - // ======================================================================== - // Configuration and Control - // ======================================================================== - - setGridVisible(visible) { - this.showGrid = visible; - this.drawGrid(); - } - - setScrollSpeed(speed) { - this.scrollSpeed = Math.max(0.1, Math.min(10.0, speed)); - } - - setVerticalScale(scale) { - this.verticalScale = Math.max(0.2, Math.min(3.0, scale)); - } - - setTitle(title) { - this.title = title; - this.titleText.text = title; - } - - resize(x, y, width, height) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - - this.container.x = x; - this.container.y = y; - - this.draw(); - } - - // ======================================================================== - // Statistics and Debugging - // ======================================================================== - - getVertexCount() { - return this.lines.reduce((sum, line) => sum + line.points.length, 0); - } - - getLineCount() { - return this.lines.length; - } - - getOldestTimestamp() { - if (this.lines.length === 0) return null; - return Math.min(...this.lines.map(l => l.timestamp)); - } - - getNewestTimestamp() { - if (this.lines.length === 0) return null; - return Math.max(...this.lines.map(l => l.timestamp)); - } - - getStats() { - return { - lineCount: this.getLineCount(), - vertexCount: this.getVertexCount(), - oldestTimestamp: this.getOldestTimestamp(), - newestTimestamp: this.getNewestTimestamp(), - timeSpan: this.getNewestTimestamp() - this.getOldestTimestamp(), - }; - } -} diff --git a/web-timeplot/src/ui/panel-manager.js b/web-timeplot/src/ui/panel-manager.js deleted file mode 100644 index ad29697..0000000 --- a/web-timeplot/src/ui/panel-manager.js +++ /dev/null @@ -1,542 +0,0 @@ -import { formatDuration, formatValue, formatWallClock } from '../utils-format.js'; - -function createElement(tagName, className, textContent) { - const element = document.createElement(tagName); - if (className) { - element.className = className; - } - if (textContent) { - element.textContent = textContent; - } - return element; -} - -function setToggleState(element, active) { - element.dataset.active = String(active); -} - -function readControlValue(element) { - if (element.tagName === 'SELECT') { - return element.value; - } - - if (element instanceof HTMLInputElement) { - if (element.type === 'checkbox') { - return element.checked; - } - - if (element.type === 'number' || element.type === 'range') { - return Number(element.value); - } - - return element.value; - } - - return element.value; -} - -function syncControlValue(element, value) { - if (!element || document.activeElement === element) { - return; - } - - if (element instanceof HTMLInputElement && element.type === 'checkbox') { - element.checked = Boolean(value); - return; - } - - element.value = String(value ?? ''); -} - -export class PanelManager { - constructor({ root, store, actions }) { - this.root = root; - this.store = store; - this.actions = actions; - this.elements = {}; - } - - mount() { - const shell = createElement('div', 'timeplot-shell'); - const topbar = createElement('header', 'timeplot-topbar'); - const viewport = createElement('section', 'timeplot-viewport'); - const plotGrid = createElement('div', 'timeplot-plot-grid'); - const primaryPlotPanel = createElement('section', 'timeplot-plot-panel'); - const secondaryPlotPanel = createElement('section', 'timeplot-plot-panel'); - const primaryCanvasHost = createElement('div', 'timeplot-canvas-host'); - const secondaryCanvasHost = createElement('div', 'timeplot-canvas-host'); - const sidebar = createElement('aside', 'timeplot-sidebar'); - const primaryTooltip = createElement('div', 'timeplot-tooltip'); - const secondaryTooltip = createElement('div', 'timeplot-tooltip'); - primaryTooltip.hidden = true; - secondaryTooltip.hidden = true; - - const brand = createElement('div', 'timeplot-brand'); - const title = createElement('h1', 'timeplot-title', 'TimePlot'); - const subtitle = createElement('div', 'timeplot-subtitle', 'Dual synchronized signal monitor'); - brand.append(title, subtitle); - - const toolbar = createElement('div', 'timeplot-toolbar'); - toolbar.append( - this.createTransportControls(), - this.createPanelToggles(), - ); - - topbar.append(brand, toolbar); - primaryPlotPanel.append(primaryCanvasHost, primaryTooltip); - secondaryPlotPanel.append(secondaryCanvasHost, secondaryTooltip); - plotGrid.append(primaryPlotPanel, secondaryPlotPanel); - viewport.append(plotGrid); - shell.append(topbar, viewport, sidebar); - this.root.replaceChildren(shell); - - this.elements = { - ...this.elements, - shell, - topbar, - viewport, - plotGrid, - primaryPlotPanel, - secondaryPlotPanel, - primaryCanvasHost, - secondaryCanvasHost, - sidebar, - primaryTooltip, - secondaryTooltip, - title, - subtitle, - statusPanel: this.createStatusPanel(), - sourcePanel: this.createSourcePanel(), - configPanel: this.createConfigPanel(), - helpPanel: this.createHelpPanel(), - }; - - sidebar.append( - this.elements.statusPanel, - this.elements.sourcePanel, - this.elements.configPanel, - this.elements.helpPanel, - ); - - return this.elements; - } - - createTransportControls() { - const wrapper = createElement('div', 'control-group'); - const pauseButton = createElement('button', 'control-button', 'Pause'); - const resetButton = createElement('button', 'control-button', 'Reset'); - const speedLabel = createElement('span', null, 'Speed'); - const speedInput = document.createElement('input'); - speedInput.type = 'range'; - speedInput.min = '0.1'; - speedInput.max = '6'; - speedInput.step = '0.1'; - const speedValue = createElement('span', null, '1.0×'); - - pauseButton.addEventListener('click', () => this.actions.togglePause()); - resetButton.addEventListener('click', () => this.actions.resetScene()); - speedInput.addEventListener('input', (event) => this.actions.setSpeed(Number(event.target.value))); - - wrapper.append(pauseButton, resetButton, speedLabel, speedInput, speedValue); - this.elements.pauseButton = pauseButton; - this.elements.resetButton = resetButton; - this.elements.speedInput = speedInput; - this.elements.speedValue = speedValue; - return wrapper; - } - - createPanelToggles() { - const wrapper = createElement('div', 'control-group'); - const panelIds = ['status', 'source', 'config', 'help']; - this.elements.panelButtons = {}; - - for (const panelId of panelIds) { - const button = createElement('button', 'panel-toggle', panelId); - button.addEventListener('click', () => this.actions.togglePanel(panelId)); - this.elements.panelButtons[panelId] = button; - wrapper.append(button); - } - - return wrapper; - } - - createStatusPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <h2>Status</h2> - <dl class="kv-list"> - <dt>Renderer</dt><dd data-field="renderer">—</dd> - <dt>Real time</dt><dd data-field="realTime">—</dd> - <dt>Real elapsed</dt><dd data-field="realElapsed">—</dd> - <dt>Plot time</dt><dd data-field="plotTime">—</dd> - <dt>Playback</dt><dd data-field="playback">—</dd> - <dt>Points</dt><dd data-field="points">—</dd> - </dl> - `; - return panel; - } - - createSourcePanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <h2>Data Source</h2> - <div class="panel-subsection" data-source-config="signalA"> - <div class="panel-section-title">Signal A</div> - <div class="field-grid"> - <label> - Source type - <select data-source-key="signalA" data-source-field="type"> - <option value="synthetic-wave">Synthetic wave</option> - <option value="csv-replay">CSV replay</option> - <option value="websocket">WebSocket</option> - </select> - </label> - </div> - <div class="field-grid" data-source-mode="synthetic-wave"> - <label> - Preset - <select data-source-key="signalA" data-source-field="preset"> - <option value="telemetry">Telemetry</option> - <option value="chirp">Chirp</option> - <option value="burst">Burst</option> - </select> - </label> - <label> - Sample rate (Hz) - <input data-source-key="signalA" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" /> - </label> - <label> - Amplitude - <input data-source-key="signalA" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" /> - </label> - <label> - Noise - <input data-source-key="signalA" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" /> - </label> - </div> - <div class="field-grid" data-source-mode="csv-replay"> - <label> - CSV file - <input data-source-key="signalA" data-source-file="dataset" type="file" accept=".csv,text/csv" /> - </label> - <label> - Replay rate - <input data-source-key="signalA" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" /> - </label> - <div class="source-meta" data-source-key="signalA" data-source-meta></div> - </div> - <div class="field-grid" data-source-mode="websocket"> - <label> - WebSocket URL - <input data-source-key="signalA" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" /> - </label> - <label> - Reconnect (ms) - <input data-source-key="signalA" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" /> - </label> - <div class="source-meta" data-source-key="signalA" data-source-ws-meta></div> - </div> - </div> - <div class="panel-subsection" data-source-config="signalB"> - <div class="panel-section-title">Signal B</div> - <div class="field-grid"> - <label> - Source type - <select data-source-key="signalB" data-source-field="type"> - <option value="synthetic-wave">Synthetic wave</option> - <option value="csv-replay">CSV replay</option> - <option value="websocket">WebSocket</option> - </select> - </label> - </div> - <div class="field-grid" data-source-mode="synthetic-wave"> - <label> - Preset - <select data-source-key="signalB" data-source-field="preset"> - <option value="telemetry">Telemetry</option> - <option value="chirp">Chirp</option> - <option value="burst">Burst</option> - </select> - </label> - <label> - Sample rate (Hz) - <input data-source-key="signalB" data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" /> - </label> - <label> - Amplitude - <input data-source-key="signalB" data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" /> - </label> - <label> - Noise - <input data-source-key="signalB" data-source-field="noise" type="number" min="0" max="0.5" step="0.01" /> - </label> - </div> - <div class="field-grid" data-source-mode="csv-replay"> - <label> - CSV file - <input data-source-key="signalB" data-source-file="dataset" type="file" accept=".csv,text/csv" /> - </label> - <label> - Replay rate - <input data-source-key="signalB" data-source-field="replayRate" type="number" min="0.1" max="8" step="0.1" /> - </label> - <div class="source-meta" data-source-key="signalB" data-source-meta></div> - </div> - <div class="field-grid" data-source-mode="websocket"> - <label> - WebSocket URL - <input data-source-key="signalB" data-source-field="wsUrl" type="url" placeholder="ws://localhost:8080" /> - </label> - <label> - Reconnect (ms) - <input data-source-key="signalB" data-source-field="wsReconnectMs" type="number" min="250" max="30000" step="250" /> - </label> - <div class="source-meta" data-source-key="signalB" data-source-ws-meta></div> - </div> - </div> - `; - - panel.querySelectorAll('[data-source-field]').forEach((input) => { - const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; - input.addEventListener(eventName, () => { - const sourceKey = input.getAttribute('data-source-key'); - const field = input.getAttribute('data-source-field'); - const value = readControlValue(input); - this.actions.updateSource(sourceKey, field, value); - }); - }); - - panel.querySelectorAll('[data-source-file]').forEach((input) => { - input.addEventListener('change', async () => { - const sourceKey = input.getAttribute('data-source-key'); - const file = input.files?.[0]; - if (!file) { - return; - } - - await this.actions.loadSourceFile(sourceKey, file); - input.value = ''; - }); - }); - - return panel; - } - - createConfigPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <h2>Config</h2> - <div class="field-grid"> - <label> - Visible window (ms) - <input data-plot-field="windowDurationMs" type="number" min="2000" max="120000" step="1000" /> - </label> - <label> - Max points - <input data-plot-field="maxPoints" type="number" min="200" max="4000" step="100" /> - </label> - <div class="panel-row"> - <span>Show grid</span> - <input data-plot-field="showGrid" type="checkbox" /> - </div> - <div class="panel-row"> - <span>Show points</span> - <input data-plot-field="showPoints" type="checkbox" /> - </div> - </div> - <div class="panel-subsection"> - <div class="panel-section-title">Graph routing</div> - <div class="field-grid"> - <label> - Primary graph source - <select data-graph-id="primary" data-graph-field="sourceKey"> - <option value="signalA">Signal A</option> - <option value="signalB">Signal B</option> - </select> - </label> - <label> - Primary graph transform - <select data-graph-id="primary" data-graph-field="transform"> - <option value="raw">Raw</option> - <option value="delta">Delta</option> - <option value="smooth">Smooth</option> - </select> - </label> - <label> - Secondary graph source - <select data-graph-id="secondary" data-graph-field="sourceKey"> - <option value="signalA">Signal A</option> - <option value="signalB">Signal B</option> - </select> - </label> - <label> - Secondary graph transform - <select data-graph-id="secondary" data-graph-field="transform"> - <option value="raw">Raw</option> - <option value="delta">Delta</option> - <option value="smooth">Smooth</option> - </select> - </label> - </div> - </div> - `; - - panel.querySelectorAll('[data-plot-field]').forEach((input) => { - const eventName = input instanceof HTMLInputElement && input.type === 'checkbox' ? 'change' : 'input'; - input.addEventListener(eventName, () => { - const field = input.getAttribute('data-plot-field'); - const value = readControlValue(input); - this.actions.updatePlot(field, value); - }); - }); - - panel.querySelectorAll('[data-graph-field]').forEach((input) => { - input.addEventListener('change', () => { - const graphId = input.getAttribute('data-graph-id'); - const field = input.getAttribute('data-graph-field'); - this.actions.updateGraph(graphId, field, input.value); - }); - }); - - return panel; - } - - createHelpPanel() { - const panel = createElement('section', 'panel'); - panel.innerHTML = ` - <h2>Help</h2> - <ol class="help-list"> - <li>Each signal can be synthetic or file-backed CSV replay.</li> - <li>Each graph can target Signal A or Signal B independently.</li> - <li>Each graph can render raw, delta, or smoothed data.</li> - <li>Hover either trace to inspect the nearest synchronized sample.</li> - <li>Use pause and speed controls to inspect timing behavior.</li> - </ol> - `; - return panel; - } - - sync(state, visiblePoints) { - this.elements.title.textContent = state.app.title; - this.elements.subtitle.textContent = 'Dual synchronized signal monitor'; - this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause'; - setToggleState(this.elements.pauseButton, state.time.paused); - syncControlValue(this.elements.speedInput, state.time.speed); - this.elements.speedValue.textContent = `${state.time.speed.toFixed(1)}×`; - - const statusFields = this.elements.statusPanel.querySelectorAll('[data-field]'); - const fieldMap = Object.fromEntries(Array.from(statusFields).map((field) => [field.getAttribute('data-field'), field])); - fieldMap.renderer.textContent = state.app.renderer; - fieldMap.realTime.textContent = formatWallClock(state.time.realNowMs); - fieldMap.realElapsed.textContent = formatDuration(state.time.realElapsedMs); - fieldMap.plotTime.textContent = formatDuration(state.time.plotTimeMs); - fieldMap.playback.textContent = state.time.paused ? 'Paused' : `${state.time.speed.toFixed(1)}×`; - fieldMap.points.textContent = typeof visiblePoints === 'object' - ? `${visiblePoints.primary} / ${visiblePoints.secondary}` - : `${visiblePoints}`; - - this.syncSourcePanel(state); - this.syncConfigPanel(state); - this.syncPanels(state); - this.syncTooltip(state); - } - - syncSourcePanel(state) { - Object.entries(state.sources).forEach(([sourceKey, sourceConfig]) => { - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="type"]`), sourceConfig.type); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="preset"]`), sourceConfig.preset); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="sampleRateHz"]`), sourceConfig.sampleRateHz); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="amplitude"]`), sourceConfig.amplitude); - syncControlValue(this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="noise"]`), sourceConfig.noise); - const replayRateInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="replayRate"]`); - if (replayRateInput) { - syncControlValue(replayRateInput, sourceConfig.replayRate ?? 1); - } - - const sourceSection = this.elements.sourcePanel.querySelector(`[data-source-config="${sourceKey}"]`); - sourceSection.querySelectorAll('[data-source-mode]').forEach((modeSection) => { - modeSection.hidden = modeSection.getAttribute('data-source-mode') !== sourceConfig.type; - }); - - const meta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-meta]`); - if (meta) { - if (sourceConfig.type === 'csv-replay') { - meta.innerHTML = sourceConfig.loadError - ? `<span class="source-meta-error">${sourceConfig.loadError}</span>` - : `${sourceConfig.dataFileName || 'No file loaded'}${sourceConfig.datasetPointCount ? ` · ${sourceConfig.datasetPointCount} pts · ${formatDuration(sourceConfig.datasetDurationMs || 0)}` : ''}`; - } else if (sourceConfig.type === 'websocket') { - meta.textContent = ''; - } else { - meta.textContent = 'Generates data procedurally in-browser'; - } - } - - const wsUrlInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsUrl"]`); - const wsReconnectInput = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-field="wsReconnectMs"]`); - const wsMeta = this.elements.sourcePanel.querySelector(`[data-source-key="${sourceKey}"][data-source-ws-meta]`); - if (wsUrlInput) { - syncControlValue(wsUrlInput, sourceConfig.wsUrl ?? ''); - } - if (wsReconnectInput) { - syncControlValue(wsReconnectInput, sourceConfig.wsReconnectMs ?? 2000); - } - if (wsMeta) { - wsMeta.innerHTML = sourceConfig.type === 'websocket' - ? `status: <span class="source-meta-status source-meta-status-${sourceConfig.wsStatus || 'idle'}">${sourceConfig.wsStatus || 'idle'}</span>${sourceConfig.wsStatusDetail ? ` · ${sourceConfig.wsStatusDetail}` : ''}` - : ''; - } - }); - } - - syncConfigPanel(state) { - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]'), state.plot.windowDurationMs); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]'), state.plot.maxPoints); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showGrid"]'), state.plot.showGrid); - syncControlValue(this.elements.configPanel.querySelector('[data-plot-field="showPoints"]'), state.plot.showPoints); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="sourceKey"]'), state.graphs.primary.sourceKey); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="primary"][data-graph-field="transform"]'), state.graphs.primary.transform); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="sourceKey"]'), state.graphs.secondary.sourceKey); - syncControlValue(this.elements.configPanel.querySelector('[data-graph-id="secondary"][data-graph-field="transform"]'), state.graphs.secondary.transform); - } - - syncPanels(state) { - const panelMap = { - status: this.elements.statusPanel, - source: this.elements.sourcePanel, - config: this.elements.configPanel, - help: this.elements.helpPanel, - }; - - for (const [panelId, panelState] of Object.entries(state.panels)) { - panelMap[panelId].hidden = !panelState.visible; - setToggleState(this.elements.panelButtons[panelId], panelState.visible); - } - } - - syncTooltip(state) { - const tooltipState = state.plot.tooltip; - this.elements.primaryTooltip.hidden = true; - this.elements.secondaryTooltip.hidden = true; - - if (!tooltipState.visible || !tooltipState.point) { - return; - } - - const tooltip = tooltipState.panelId === 'secondary' - ? this.elements.secondaryTooltip - : this.elements.primaryTooltip; - - tooltip.hidden = false; - tooltip.style.left = `${tooltipState.x}px`; - tooltip.style.top = `${tooltipState.y}px`; - tooltip.innerHTML = ` - <div class="timeplot-tooltip-title">Hovered sample</div> - <div class="timeplot-tooltip-row"><span class="muted">Panel</span><span>${tooltipState.panelLabel ?? 'Primary'}</span></div> - <div class="timeplot-tooltip-row"><span class="muted">Plot time</span><span>${formatDuration(tooltipState.point.timeMs)}</span></div> - <div class="timeplot-tooltip-row"><span class="muted">Value</span><span>${formatValue(tooltipState.point.value)}</span></div> - <div class="timeplot-tooltip-row"><span class="muted">Source</span><span>${tooltipState.point.sourceId}</span></div> - ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked panel</span><span>${tooltipState.linkedPanelLabel ?? 'Linked'}</span></div>` : ''} - ${tooltipState.linkedPoint ? `<div class="timeplot-tooltip-row"><span class="muted">Linked value</span><span>${formatValue(tooltipState.linkedPoint.value)}</span></div>` : ''} - `; - } -} diff --git a/web-timeplot/src/utils-format.js b/web-timeplot/src/utils-format.js deleted file mode 100644 index f4eac88..0000000 --- a/web-timeplot/src/utils-format.js +++ /dev/null @@ -1,22 +0,0 @@ -export function formatDuration(ms) { - const totalSeconds = Math.max(0, ms / 1000); - if (totalSeconds < 60) { - return `${totalSeconds.toFixed(2)}s`; - } - - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}m ${seconds.toFixed(1)}s`; -} - -export function formatWallClock(timestampMs) { - return new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(new Date(timestampMs)); -} - -export function formatValue(value) { - return Number.isFinite(value) ? value.toFixed(3) : '—'; -} diff --git a/web-timeplot/src/waterfall.js b/web-timeplot/src/waterfall.js deleted file mode 100644 index bce0750..0000000 --- a/web-timeplot/src/waterfall.js +++ /dev/null @@ -1,219 +0,0 @@ -import { Container, Graphics, Text } from 'pixi.js'; - -/** - * WaterfallGraph - A scrolling waterfall display - * Starts simple with basic line rendering - */ -export class WaterfallGraph { - constructor(config) { - this.x = config.x; - this.y = config.y; - this.width = config.width; - this.height = config.height; - this.title = config.title; - this.baseColor = config.color || 0xff6666; - - this.container = new Container(); - this.container.x = this.x; - this.container.y = this.y; - - // Graphics layers - this.borderGraphics = new Graphics(); - this.gridGraphics = new Graphics(); - this.linesGraphics = new Graphics(); - - this.container.addChild(this.gridGraphics); - this.container.addChild(this.linesGraphics); - this.container.addChild(this.borderGraphics); - - // Title text - this.titleText = new Text({ - text: this.title, - style: { - fontFamily: 'Arial', - fontSize: 18, - fill: 0xeeeeee, - } - }); - this.titleText.x = 10; - this.titleText.y = 10; - this.container.addChild(this.titleText); - - // Waterfall data - this.lines = []; - this.maxLines = 50; - this.pointsPerLine = 100; - this.frameCounter = 0; - - this.showGrid = true; - - // Time scaling and zoom - this.scrollSpeed = 1.0; // Speed multiplier for scrolling - this.baseScrollSpeed = 1.0; - this.verticalScale = 1.0; // Vertical zoom: >1 = zoomed in (see less history), <1 = zoomed out (see more) - - this.draw(); - } - - draw() { - this.drawBorder(); - this.drawGrid(); - } - - drawBorder() { - this.borderGraphics.clear(); - this.borderGraphics.rect(0, 0, this.width, this.height); - this.borderGraphics.stroke({ width: 2, color: 0x606070 }); - } - - drawGrid() { - this.gridGraphics.clear(); - - if (!this.showGrid) return; - - this.gridGraphics.alpha = 0.3; - - const divisions = 10; - const color = 0x4a7a9a; - - // Vertical lines - for (let i = 0; i <= divisions; i++) { - const x = (i / divisions) * this.width; - this.gridGraphics.moveTo(x, 0); - this.gridGraphics.lineTo(x, this.height); - this.gridGraphics.stroke({ width: 1, color }); - } - - // Horizontal lines - for (let i = 0; i <= divisions; i++) { - const y = (i / divisions) * this.height; - this.gridGraphics.moveTo(0, y); - this.gridGraphics.lineTo(this.width, y); - this.gridGraphics.stroke({ width: 1, color }); - } - } - - update(time, graphIdx) { - this.frameCounter++; - - // Add new line every 10 frames - if (this.frameCounter % 10 === 0 && this.lines.length < this.maxLines) { - this.addLine(time, graphIdx); - } - - // Scroll existing lines down - this.scrollLines(); - - // Remove off-screen lines - this.lines = this.lines.filter(line => line.yOffset < this.height + 50); - - // Redraw all lines - this.drawLines(); - } - - addLine(time, graphIdx) { - const line = { - points: [], - yOffset: 0, - color: this.generateColor(time), - }; - - // Generate sine wave points - const phase = time + (graphIdx * 2); - const freq = 2.0 + Math.sin(time * 0.5 + graphIdx) * 1.0; - - for (let i = 0; i < this.pointsPerLine; i++) { - const x = (i / this.pointsPerLine) * this.width; - const normalizedX = (i / this.pointsPerLine) * 2 - 1; // -1 to 1 - const y = Math.sin(i * 0.1 * freq + phase) * 30; // Amplitude in pixels - - line.points.push({ x, y }); - } - - this.lines.push(line); - } - - scrollLines() { - const speed = this.baseScrollSpeed * this.scrollSpeed; - this.lines.forEach(line => { - line.yOffset += speed; - }); - } - - setScrollSpeed(speed) { - // Clamp between 0.1 (slow) and 5.0 (fast) - this.scrollSpeed = Math.max(0.1, Math.min(5.0, speed)); - } - - getScrollSpeed() { - return this.scrollSpeed; - } - - setVerticalScale(scale) { - // Clamp between 0.2 (zoomed out, see more history) and 3.0 (zoomed in, see less) - this.verticalScale = Math.max(0.2, Math.min(3.0, scale)); - } - - getVerticalScale() { - return this.verticalScale; - } - - drawLines() { - this.linesGraphics.clear(); - - for (const line of this.lines) { - if (line.points.length < 2) continue; - - // Apply vertical scale to y positions - // Current time is at top (y=0), older data has larger yOffset - const scaledYOffset = line.yOffset * this.verticalScale; - - // Start path - const firstPoint = line.points[0]; - this.linesGraphics.moveTo(firstPoint.x, firstPoint.y + scaledYOffset); - - // Draw line strip - for (let i = 1; i < line.points.length; i++) { - const point = line.points[i]; - this.linesGraphics.lineTo(point.x, point.y + scaledYOffset); - } - - this.linesGraphics.stroke({ width: 2, color: line.color }); - } - } - - generateColor(time) { - // Cycle through colors based on time - const hue = (time * 0.1) % 1.0; - const r = Math.floor(Math.abs(Math.sin(hue * Math.PI * 2)) * 255); - const g = Math.floor(Math.abs(Math.sin((hue + 0.33) * Math.PI * 2)) * 255); - const b = Math.floor(Math.abs(Math.sin((hue + 0.66) * Math.PI * 2)) * 255); - - return (r << 16) | (g << 8) | b; - } - - setGridVisible(visible) { - this.showGrid = visible; - this.drawGrid(); - } - - resize(x, y, width, height) { - this.x = x; - this.y = y; - this.width = width; - this.height = height; - - this.container.x = x; - this.container.y = y; - - this.draw(); - } - - getVertexCount() { - return this.lines.reduce((sum, line) => sum + line.points.length, 0); - } - - getLineCount() { - return this.lines.length; - } -} |
