diff options
| author | Thomas Grothe <grothe.tr@gmail.com> | 2026-04-30 00:53:13 -0400 |
|---|---|---|
| committer | Thomas Grothe <grothe.tr@gmail.com> | 2026-04-30 00:53:13 -0400 |
| commit | 73d75835e18a33c7f6c1b09bbcef93b16a7a9bfa (patch) | |
| tree | e079c6c45416333e29cf900831c07619a87d5c39 | |
| parent | a1c95e72bea26f554eb05916d6fc584927367159 (diff) | |
redo timeplot web
28 files changed, 1727 insertions, 3716 deletions
@@ -1,3 +1,4 @@ /rs/target /cpp-timeplot/build/ /web-timeplot/node_modules/ +/web-timeplot/dist/ diff --git a/web-timeplot/ARCHITECTURE.md b/web-timeplot/ARCHITECTURE.md index b49c0b0..1cfc3f1 100644 --- a/web-timeplot/ARCHITECTURE.md +++ b/web-timeplot/ARCHITECTURE.md @@ -2,433 +2,170 @@ ## Overview -This document describes the clean, modular architecture for TimePlot's data visualization system. - -## Design Principles - -**Separation of Concerns**: Data generation, data provision, and visualization are completely separate. - -- **Plots don't generate data** - They only display it -- **Data sources don't know about visualization** - They only produce data -- **Connections manage the flow** - They link sources to plots - -This architecture allows you to: -- Easily swap data sources without changing visualization -- Reuse plots with different data -- Test components independently -- Support multiple data types (real-time, synthetic, replay, etc.) - -## Architecture Layers - -``` -┌─────────────────────────────────────────────────┐ -│ Application Layer (main.js) │ -│ - Manages app lifecycle │ -│ - Creates plots and sources │ -│ - Sets up connections │ -└─────────────────────────────────────────────────┘ - │ - ├──────────────┬──────────────┐ - ↓ ↓ ↓ - ┌────────────────┐ ┌─────────────┐ ┌─────────────┐ - │ Connections │ │ Plots │ │ Sources │ - │ (Glue Layer) │ │ (Display) │ │ (Data) │ - └────────────────┘ └─────────────┘ └─────────────┘ -``` - -## Core Components - -### 1. Data Generators (`test-data-generators.js`) - -**Purpose**: Generate mathematical patterns for testing - -**Classes**: -- `DataGenerator` - Base class with common functionality -- `SineWaveGenerator` - Classic sine waves -- `SquareWaveGenerator` - Digital square waves -- `PerlinNoiseGenerator` - Smooth noise -- `ChirpGenerator` - Frequency sweeps -- `CompositeGenerator` - Combine multiple generators -- Many more... - -**Usage**: -```javascript -const generator = new SineWaveGenerator({ - frequency: 2.0, - amplitude: 30, - sampleRate: 100, -}); - -// Generate a line of points -const points = generator.generateLine(100, 800); -``` - -### 2. Data Sources (`data-sources.js`) - -**Purpose**: Provide data to plots via events - -**Key Concept**: Sources emit events when data is ready. They know *when* and *how* to provide data, but not *where* it goes. - -**Base Class**: -```javascript -class DataSource extends EventEmitter { - start() // Begin providing data - stop() // Stop providing data - emitLine() // Emit a complete line of data - emitPoint() // Emit a single data point +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. + +## Runtime flow + +```text +TimeController.tick() + ↓ +Store.time updated + ↓ +SourceRegistry.update(plotTime) + ↓ +SyntheticWaveSource emits 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, + }, + source: { + activeId, + preset, + sampleRateHz, + amplitude, + noise, + }, + panels: { + status, + source, + config, + help, + }, } ``` -**Events**: -- `'line'` - Complete line of data ready: `{points, timestamp, metadata}` -- `'point'` - Single data point ready: `{value, timestamp}` -- `'error'` - Error occurred: `{error}` - -**Available Sources**: - -- **SyntheticDataSource** - Uses test generators, emits lines periodically -- **FunctionDataSource** - Evaluates a function (x,t) => y -- **StreamingDataSource** - Emits individual points at a sample rate -- **WebSocketDataSource** - Receives real-time data from WebSocket -- **CSVDataSource** - Replays data from CSV files -- **CompositeDataSource** - Combines multiple sources - -**Example**: -```javascript -// Create source -const source = new SyntheticDataSource({ - generator: new SineWaveGenerator({ frequency: 2.0 }), - pointsPerLine: 100, - width: 800, - lineInterval: 100, // ms between lines -}); - -// Listen to events -source.on('line', (data) => { - console.log('Received line:', data.points); -}); - -// Start generating -source.start(); -``` +## Modules -### 3. Plots (`timeseries-plot.js`) +### `src/core/store.js` -**Purpose**: Pure visualization - display data, nothing else +A tiny centralized store. It currently favors clarity over abstraction-heavy patterns. -**Key Concept**: Plots receive data via method calls. They know *how* to display data, but not *where* it comes from. +### `src/core/time-controller.js` -**API**: -```javascript -class TimeSeriesPlot { - // Data input - addLine(points, metadata) // Add a line of data - addDataPoint(value, timestamp) // Add a single point - clearData() // Clear all data +Owns playback semantics: - // Display control - setGridVisible(visible) - setScrollSpeed(speed) - setVerticalScale(scale) - setTitle(title) +- pause/resume +- speed control +- plot time reset +- frame-to-frame delta handling - // Frame update - update() // Call every frame to scroll/render -} -``` +### `src/data/synthetic-wave-source.js` -**Example**: -```javascript -// Create plot -const plot = new TimeSeriesPlot({ - x: 0, - y: 0, - width: 800, - height: 600, - title: 'My Plot', - showGrid: true, -}); - -// Add to PixiJS stage -app.stage.addChild(plot.container); - -// Receive data -plot.addLine([ - {x: 0, y: 10}, - {x: 100, y: 20}, - {x: 200, y: 15}, -]); - -// Update every frame -app.ticker.add(() => plot.update()); -``` +Generates sample streams from a preset waveform. Right now it supports: -### 4. Connections (`plot-connections.js`) +- `telemetry` +- `chirp` +- `burst` -**Purpose**: Link data sources to plots +### `src/plot/plot-buffer.js` -**Key Concept**: Connections subscribe to source events and forward data to plots. They handle timing, buffering, and data transformation. +Maintains bounded history so rendering and hover picking only operate on a manageable number of samples. -**Connection Types**: - -**DirectConnection** - Lines go straight from source to plot -```javascript -const connection = new DirectConnection(source, plot); -connection.connect(); -``` +### `src/plot/timeplot-view.js` -**BufferedConnection** - Buffers individual points into lines -```javascript -const connection = new BufferedConnection(source, plot, { - bufferSize: 100, // Points per line - bufferTimeout: 1000, // Max time to wait (ms) -}); -connection.connect(); -``` +Owns Pixi initialization, plotting, grid drawing, and nearest-point hover selection. -**SynchronizedConnection** - Synchronizes multiple sources -```javascript -const connection = new SynchronizedConnection([source1, source2], plot, { - syncMode: 'wait-for-all', -}); -connection.connect(); -``` +### `src/ui/panel-manager.js` -**Helper Functions**: -```javascript -// Quick setup for synthetic data -const conn = connectSyntheticData(generator, plot, { - lineInterval: 100, -}); +Creates: -// Quick setup for function-based -const conn = connectFunction((x, t) => Math.sin(x * 10 + t), plot); +- top transport bar +- panel toggle buttons +- status panel +- data source panel +- config panel +- help panel +- floating tooltip -// All-in-one setup -const {plot, source, connection} = createConnectedPlot(app, plotConfig, sourceConfig); -``` +## Why this is a better baseline -## Data Flow +The old project had useful ideas but too many concerns were mixed together. The new baseline is better because: -### Scenario 1: Synthetic Data (Test Pattern) +- transport logic is separate from rendering +- data generation is separate from app wiring +- UI is separate from GPU drawing +- state is centralized and observable +- adding a new source or panel no longer requires rewriting the whole app -``` -DataGenerator SyntheticDataSource DirectConnection TimeSeriesPlot - │ │ │ │ - │ │ start() │ │ - │ ├───────────────────────>│ │ - │ │ │ │ - │ generateLine() │ │ │ - │<────────────────────────┤ │ │ - │ │ │ │ - │ returns points[] │ │ │ - ├────────────────────────>│ │ │ - │ │ │ │ - │ │ emit('line', data) │ │ - │ ├───────────────────────>│ │ - │ │ │ │ - │ │ │ addLine(points) │ - │ │ ├─────────────────────>│ - │ │ │ │ - │ │ │ │ update() - │ │ │ │ (scroll & render) -``` +## Recommended next steps -### Scenario 2: Real-Time Streaming +### Near term -``` -External System WebSocketDataSource BufferedConnection TimeSeriesPlot - │ │ │ │ - │ WebSocket message │ │ │ - ├────────────────────────>│ │ │ - │ │ │ │ - │ │ emit('point', value) │ │ - │ ├───────────────────────>│ │ - │ │ │ │ - │ │ │ buffer.push(value) │ - │ │ │ │ - │ │ emit('point') │ │ - │ ├───────────────────────>│ │ - │ │ │ │ - │ │ (continue...) │ │ - │ │ │ │ - │ │ │ buffer full! │ - │ │ │ │ - │ │ │ flush() │ - │ │ │ addLine(buffered) │ - │ │ ├─────────────────────>│ -``` - -## Usage Patterns - -### Pattern 1: Simple Test Visualization - -```javascript -import { TimeSeriesPlot } from './timeseries-plot.js'; -import { connectSyntheticData } from './plot-connections.js'; -import { TestDataFactory } from './test-data-generators.js'; - -// Create plot -const plot = new TimeSeriesPlot({...}); -app.stage.addChild(plot.container); - -// Connect data -const connection = connectSyntheticData( - TestDataFactory.createSimpleSine(30), - plot, - { lineInterval: 100 } -); - -// Update loop -app.ticker.add(() => plot.update()); -``` - -### Pattern 2: Swap Data Sources - -```javascript -// Start with one source -let connection = connectSyntheticData(generator1, plot); - -// Later, switch to different data -connection.disconnect(); -connection = connectSyntheticData(generator2, plot); -``` - -### Pattern 3: Multiple Plots, Different Data - -```javascript -const plot1 = new TimeSeriesPlot({x: 0, y: 0, ...}); -const plot2 = new TimeSeriesPlot({x: 800, y: 0, ...}); - -connectSyntheticData(sineGenerator, plot1); -connectSyntheticData(noiseGenerator, plot2); - -app.ticker.add(() => { - plot1.update(); - plot2.update(); -}); -``` - -### Pattern 4: Real-Time WebSocket Data - -```javascript -const plot = new TimeSeriesPlot({...}); - -const source = new WebSocketDataSource({ - url: 'ws://localhost:8080/data' -}); - -const connection = new BufferedConnection(source, plot, { - bufferSize: 100, -}); -connection.connect(); - -app.ticker.add(() => plot.update()); -``` - -### Pattern 5: Custom Function - -```javascript -const plot = new TimeSeriesPlot({...}); - -const connection = connectFunction( - (x, t) => Math.sin(x * 10 + t * 2) + Math.cos(x * 5 - t), - plot, - { lineInterval: 100, amplitude: 30 } -); - -app.ticker.add(() => plot.update()); -``` - -## File Organization - -``` -web-timeplot/src/ -├── test-data-generators.js # Math generators for test patterns -├── data-sources.js # Data provision (events) -├── timeseries-plot.js # Pure visualization -├── plot-connections.js # Glue layer -├── example-usage.js # Complete examples -├── main.js # Application entry point -├── state.js # App state management -└── waterfall.js # (Legacy - can be replaced) -``` - -## Migration Path - -### From Old WaterfallGraph to New Architecture - -**Old way** (tightly coupled): -```javascript -const graph = new WaterfallGraph({...}); -// Graph generates its own data -``` - -**New way** (separated): -```javascript -const plot = new TimeSeriesPlot({...}); -const source = new SyntheticDataSource({...}); -const connection = new DirectConnection(source, plot); -connection.connect(); -``` - -## Extension Points - -### Adding New Data Sources - -Extend `DataSource` and implement: -- `start()` - Begin providing data -- `stop()` - Stop providing data -- Call `emitLine()` or `emitPoint()` when data is ready - -Example: -```javascript -class MyCustomSource extends DataSource { - start() { - super.start(); - // Start your data provision mechanism - this.interval = setInterval(() => { - const points = [...]; // Generate points - this.emitLine(points); - }, 100); - } - - stop() { - super.stop(); - clearInterval(this.interval); - } -} -``` - -### Adding New Connection Types - -Extend `PlotConnection` and implement `setupSubscriptions()`: - -```javascript -class MyCustomConnection extends PlotConnection { - setupSubscriptions() { - const unsub = this.source.on('line', (data) => { - // Transform data as needed - const transformed = this.transformData(data); - this.plot.addLine(transformed.points); - }); - this.subscriptions.push(unsub); - } -} -``` +- 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 -## Benefits of This Architecture +### Medium term -1. **Testability** - Each component can be tested independently -2. **Reusability** - Plots work with any data source -3. **Flexibility** - Easy to add new data sources or visualizations -4. **Maintainability** - Clear responsibilities, easy to understand -5. **Performance** - Can optimize each layer independently -6. **Real-world ready** - Supports actual data sources (WebSocket, files, etc.) +- add schema-aware input adapters +- add WebSocket and replay-file sources +- add panel docking/layout persistence +- add markers, cursors, and annotations -## Next Steps +### Longer term -- Replace old `waterfall.js` usage with new `TimeSeriesPlot` -- Create real data sources for your application -- Add more visualization types (heatmap, spectrogram, etc.) -- Add data processing layer (filtering, FFT, etc.) +- multi-stream synchronization +- richer interaction model for HID input mapping +- plug-in style source and panel registration diff --git a/web-timeplot/README.md b/web-timeplot/README.md index 867ae7a..491753a 100644 --- a/web-timeplot/README.md +++ b/web-timeplot/README.md @@ -1,49 +1,78 @@ -# Web TimePlot - PixiJS Implementation +# TimePlot -A web-based waterfall display using PixiJS with WebGPU/WebGL support. +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. -## Features +## What it does -- **Dual Renderer Support**: Automatically uses WebGPU if available, falls back to WebGL -- **Real-time Performance Metrics**: FPS, frame timing, vertex counts -- **Interactive Controls**: Keyboard shortcuts and UI controls -- **Data Export**: Export performance metrics to CSV +- 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 +- Toggleable side panels for status, source config, app config, and help -## Getting Started +## Getting started ```bash -# Install dependencies npm install - -# Start development server npm run dev +``` -# Build for production -npm run build +Production build: -# Preview production build +```bash +npm run build npm run preview ``` ## Controls -- **G** - Toggle grid display -- **M** - Toggle metrics display -- **E** - Export metrics to CSV +- `Space` — pause/resume +- `[` — slow down playback +- `]` — speed up playback +- `G` — toggle grid +- Hover plot — inspect nearest sample -## Architecture +## Project structure -``` +```text src/ -├── main.js - Application entry point and orchestration -├── waterfall.js - Waterfall graph visualization component -└── metrics.js - Performance metrics collection and analysis +├── 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 +│ ├── source-registry.js # source lifecycle + routing +│ └── synthetic-wave-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 ``` -## Performance Comparison +## 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 -This implementation can be directly compared with: -- Rust/wgpu version (`../src/`) -- C++ version (`../cpp-timeplot/`) +## Next good additions -All three implementations track the same metrics for fair comparison. +- real external data sources (WebSocket, REST replay, files) +- 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/bun.lock b/web-timeplot/bun.lock new file mode 100644 index 0000000..82f672d --- /dev/null +++ b/web-timeplot/bun.lock @@ -0,0 +1,150 @@ +{ + "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 index 6f0d26d..76e8b87 100644 --- a/web-timeplot/index.html +++ b/web-timeplot/index.html @@ -3,36 +3,10 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>PixiJS Prototyping Framework</title> - <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1a1a1f; - color: #fff; - overflow: hidden; - } - - #app { - width: 100vw; - height: 100vh; - } - - #canvas-container { - width: 100%; - height: 100%; - } - </style> + <title>TimePlot</title> </head> <body> - <div id="app"> - <div id="canvas-container"></div> - </div> - <script type="module" src="/src/main.js"></script> + <div id="app"></div> + <script type="module" src="/src/bootstrap.js"></script> </body> </html> diff --git a/web-timeplot/src/app/create-app.js b/web-timeplot/src/app/create-app.js new file mode 100644 index 0000000..daf3559 --- /dev/null +++ b/web-timeplot/src/app/create-app.js @@ -0,0 +1,152 @@ +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 { PanelManager } from '../ui/panel-manager.js'; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +export async function createApp(root) { + const bus = new EventBus(); + const store = new Store(createInitialState()); + const timeController = new TimeController(store); + const buffer = new PlotBuffer(store.getState().plot.maxPoints); + let sourceRegistry; + + const actions = { + togglePause: () => timeController.togglePause(), + setSpeed: (speed) => timeController.setSpeed(speed), + resetScene: () => { + timeController.reset(); + buffer.clear(); + sourceRegistry.reset(); + }, + togglePanel: (panelId) => { + store.setState((state) => ({ + ...state, + panels: { + ...state.panels, + [panelId]: { + ...state.panels[panelId], + visible: !state.panels[panelId].visible, + }, + }, + })); + }, + updateSource: (field, value) => { + store.setState((state) => ({ + ...state, + source: { + ...state.source, + [field]: value, + }, + })); + sourceRegistry.syncFromState(); + }, + updatePlot: (field, value) => { + store.setState((state) => ({ + ...state, + plot: { + ...state.plot, + [field]: value, + }, + })); + + if (field === 'maxPoints') { + buffer.maxPoints = clamp(value, 200, 4000); + } + }, + }; + + const panelManager = new PanelManager({ root, store, actions }); + const elements = panelManager.mount(); + + const plotView = new TimeplotView({ + host: elements.canvasHost, + onHover: (hoverState) => { + store.setState((state) => ({ + ...state, + plot: { + ...state.plot, + hoveredPoint: hoverState?.point ?? null, + tooltip: hoverState + ? { + visible: true, + x: hoverState.x, + y: hoverState.y, + point: hoverState.point, + } + : { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + }, + }); + + const renderer = await plotView.init(); + store.patch({ + app: { + ...store.getState().app, + renderer, + }, + }); + + sourceRegistry = new SourceRegistry(store, bus); + + bus.on('data:point', (point) => { + buffer.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(); + sourceRegistry.update(store.getState().time.plotTimeMs); + + const state = store.getState(); + const visiblePoints = buffer.getVisiblePoints(state.time.plotTimeMs, state.plot.windowDurationMs); + plotView.render(state, visiblePoints); + panelManager.sync(state, visiblePoints.length); + }); + + return { + destroy() { + window.removeEventListener('keydown', keyHandler); + plotView.destroy(); + }, + }; +} diff --git a/web-timeplot/src/bootstrap.js b/web-timeplot/src/bootstrap.js new file mode 100644 index 0000000..4b073bc --- /dev/null +++ b/web-timeplot/src/bootstrap.js @@ -0,0 +1,18 @@ +import './styles.css'; +import { createApp } from './app/create-app.js'; + +const root = document.getElementById('app'); + +if (!root) { + throw new Error('App root not found'); +} + +createApp(root).catch((error) => { + console.error('Failed to start TimePlot', error); + root.innerHTML = ` + <div style="padding: 24px; color: #fff; font-family: sans-serif;"> + <h1>TimePlot failed to start</h1> + <pre>${String(error)}</pre> + </div> + `; +}); diff --git a/web-timeplot/src/core/event-bus.js b/web-timeplot/src/core/event-bus.js new file mode 100644 index 0000000..192eb6d --- /dev/null +++ b/web-timeplot/src/core/event-bus.js @@ -0,0 +1,32 @@ +export class EventBus { + constructor() { + this.listeners = new Map(); + } + + on(eventName, listener) { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, new Set()); + } + + const listeners = this.listeners.get(eventName); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(eventName); + } + }; + } + + emit(eventName, payload) { + const listeners = this.listeners.get(eventName); + if (!listeners) { + return; + } + + for (const listener of listeners) { + listener(payload); + } + } +} diff --git a/web-timeplot/src/core/store.js b/web-timeplot/src/core/store.js new file mode 100644 index 0000000..9989e5f --- /dev/null +++ b/web-timeplot/src/core/store.js @@ -0,0 +1,95 @@ +function clonePanelState(panels) { + return Object.fromEntries(Object.entries(panels).map(([key, value]) => [key, { ...value }])); +} + +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, + }, + }, + source: { + activeId: 'synthetic-wave', + preset: 'telemetry', + sampleRateHz: 60, + amplitude: 1, + noise: 0.08, + }, + 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 = initialState; + 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; + 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, + source: partial.source ? { ...state.source, ...partial.source } : state.source, + 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 new file mode 100644 index 0000000..7cd57c7 --- /dev/null +++ b/web-timeplot/src/core/time-controller.js @@ -0,0 +1,80 @@ +export class TimeController { + constructor(store) { + this.store = store; + this.lastFrameTime = performance.now(); + } + + tick(now = performance.now()) { + const deltaMs = now - this.lastFrameTime; + this.lastFrameTime = now; + + this.store.setState((state) => { + const realElapsedMs = state.time.realElapsedMs + deltaMs; + const plotDeltaMs = state.time.paused ? 0 : deltaMs * state.time.speed; + + return { + ...state, + time: { + ...state.time, + realNowMs: Date.now(), + realElapsedMs, + plotTimeMs: Math.max(0, state.time.plotTimeMs + plotDeltaMs), + }, + }; + }); + + return deltaMs; + } + + togglePause() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused: !state.time.paused, + }, + })); + } + + setPaused(paused) { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + paused, + }, + })); + } + + setSpeed(speed) { + const clampedSpeed = Math.max(0.1, Math.min(12, speed)); + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + speed: clampedSpeed, + }, + })); + } + + reset() { + this.store.setState((state) => ({ + ...state, + time: { + ...state.time, + realElapsedMs: 0, + plotTimeMs: 0, + }, + plot: { + ...state.plot, + hoveredPoint: null, + tooltip: { + ...state.plot.tooltip, + visible: false, + point: null, + }, + }, + })); + this.lastFrameTime = performance.now(); + } +} diff --git a/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 new file mode 100644 index 0000000..55dbdc3 --- /dev/null +++ b/web-timeplot/src/data/base-source.js @@ -0,0 +1,21 @@ +export class BaseSource { + constructor(config = {}) { + this.config = { ...config }; + this.running = false; + } + + start() { + this.running = true; + } + + stop() { + this.running = false; + } + + updateConfig(nextConfig) { + this.config = { + ...this.config, + ...nextConfig, + }; + } +} diff --git a/web-timeplot/src/data/source-registry.js b/web-timeplot/src/data/source-registry.js new file mode 100644 index 0000000..06f5895 --- /dev/null +++ b/web-timeplot/src/data/source-registry.js @@ -0,0 +1,41 @@ +import { SyntheticWaveSource } from './synthetic-wave-source.js'; + +export class SourceRegistry { + constructor(store, bus) { + this.store = store; + this.bus = bus; + this.sources = new Map([ + ['synthetic-wave', new SyntheticWaveSource(store.getState().source)], + ]); + this.activeSource = this.sources.get(store.getState().source.activeId); + this.activeSource.start(store.getState().time.plotTimeMs); + } + + syncFromState() { + const state = this.store.getState(); + const nextSource = this.sources.get(state.source.activeId); + + if (nextSource !== this.activeSource) { + this.activeSource?.stop(); + this.activeSource = nextSource; + this.activeSource?.start(state.time.plotTimeMs); + } + + this.activeSource?.updateConfig(state.source); + } + + update(currentPlotTimeMs) { + if (!this.activeSource) { + return; + } + + const points = this.activeSource.update(currentPlotTimeMs); + for (const point of points) { + this.bus.emit('data:point', point); + } + } + + reset() { + this.activeSource?.reset(this.store.getState().time.plotTimeMs); + } +} diff --git a/web-timeplot/src/data/synthetic-wave-source.js b/web-timeplot/src/data/synthetic-wave-source.js new file mode 100644 index 0000000..3cf7fb1 --- /dev/null +++ b/web-timeplot/src/data/synthetic-wave-source.js @@ -0,0 +1,86 @@ +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.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/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 index e435193..d2b348e 100644 --- a/web-timeplot/src/main.js +++ b/web-timeplot/src/main.js @@ -1,151 +1 @@ -import { Application } from 'pixi.js'; -import * as PIXI from 'pixi.js'; -import { StateManager } from './state.js'; - -// ============================================================================ -// GLOBAL STATE -// ============================================================================ - -// Centralized reactive state -const state = new StateManager(); - -// DOM references -let dom = { - container: null, -}; - -// Application instances -let app = null; // PixiJS Application - -// ============================================================================ -// APPLICATION ENTRY POINT -// ============================================================================ - -document.addEventListener('DOMContentLoaded', async function() { - log('Framework starting...'); - - log('init DOM'); - await initDOM(); - - log('init PixiJS renderer'); - await initRenderer(); - - log('init services'); - await initServices(); - - log('Framework ready - start prototyping!'); -}); - -// ============================================================================ -// INITIALIZATION FUNCTIONS -// ============================================================================ - -async function initDOM() { - dom.container = document.getElementById('canvas-container'); - - if (!dom.container) { - throw new Error('Canvas container not found'); - } -} - -async function initRenderer() { - // Check WebGPU availability - let preference = 'webgpu'; - if (!navigator.gpu) { - log('WebGPU not available, using WebGL'); - preference = 'webgl'; - } - - try { - app = new Application(); - - await app.init({ - preference: preference, - width: window.innerWidth, - height: window.innerHeight, - backgroundColor: 0x1a1a26, - antialias: true, - autoDensity: true, - resolution: window.devicePixelRatio || 1, - }); - - dom.container.appendChild(app.canvas); - - // Store renderer info in state - const rendererType = app.renderer.type; - state.state.rendering.rendererType = rendererType; - log(`Using renderer: ${rendererType}`); - - // Store canvas dimensions in state - state.state.uiConfig.canvasWidth = app.screen.width; - state.state.uiConfig.canvasHeight = app.screen.height; - - // Handle window resize - window.addEventListener('resize', handleResize); - - } catch (error) { - log(`Failed to initialize renderer: ${error}`); - throw error; - } -} - -async function initServices() { - // Start animation loop - app.ticker.add(update); - - log('Services initialized'); -} - -// ============================================================================ -// EVENT HANDLERS -// ============================================================================ - -function handleResize() { - const width = window.innerWidth; - const height = window.innerHeight; - - app.renderer.resize(width, height); - - // Update state - state.state.uiConfig.canvasWidth = width; - state.state.uiConfig.canvasHeight = height; -} - -// ============================================================================ -// MAIN UPDATE LOOP -// ============================================================================ - -function update() { - // Update time using state manager - state.incrementTime(0.016); // ~60fps increment - state.updateRealElapsed(); - - state.state.rendering.frameCounter++; - - // YOUR PROTOTYPE CODE GOES HERE - // Example: - // yourSprite.x += 1; - // yourGraphics.rotation += 0.01; -} - -// ============================================================================ -// UTILITIES -// ============================================================================ - -function log(msg) { - console.log(`[Framework] ${msg}`); -} - -// ============================================================================ -// EXPORTS FOR PROTOTYPING -// ============================================================================ - -// Export immediately available objects -window.PIXI = PIXI; -window.state = state; -window.log = log; - -// Export app after initialization (using a getter) -Object.defineProperty(window, 'pixiApp', { - get() { return app; } -}); +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 new file mode 100644 index 0000000..b13cdd8 --- /dev/null +++ b/web-timeplot/src/plot/plot-buffer.js @@ -0,0 +1,22 @@ +export class PlotBuffer { + constructor(maxPoints = 1600) { + this.maxPoints = maxPoints; + this.points = []; + } + + addPoint(point) { + this.points.push(point); + if (this.points.length > this.maxPoints) { + this.points.splice(0, this.points.length - this.maxPoints); + } + } + + clear() { + this.points = []; + } + + getVisiblePoints(currentPlotTimeMs, windowDurationMs) { + const minTime = currentPlotTimeMs - windowDurationMs; + return this.points.filter((point) => point.timeMs >= minTime && point.timeMs <= currentPlotTimeMs); + } +} diff --git a/web-timeplot/src/plot/timeplot-view.js b/web-timeplot/src/plot/timeplot-view.js new file mode 100644 index 0000000..9f00b29 --- /dev/null +++ b/web-timeplot/src/plot/timeplot-view.js @@ -0,0 +1,234 @@ +import { Application, Container, Graphics, Text } from 'pixi.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, onHover }) { + this.host = host; + this.onHover = onHover; + this.app = new Application(); + this.container = new Container(); + this.background = new Graphics(); + this.grid = new Graphics(); + this.line = new Graphics(); + this.points = new Graphics(); + this.crosshair = new Graphics(); + this.overlay = 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.screenPoints = []; + this.bounds = { width: 100, height: 100 }; + this.hoverRadiusPx = 20; + this.pointer = null; + } + + 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.line); + this.container.addChild(this.points); + this.container.addChild(this.crosshair); + this.container.addChild(this.overlay); + this.overlay.addChild(this.titleText); + this.overlay.addChild(this.subtitleText); + this.host.appendChild(this.app.canvas); + this.attachPointerListeners(); + + return rendererPreference; + } + + attachPointerListeners() { + this.host.addEventListener('pointerleave', () => { + this.pointer = null; + this.crosshair.clear(); + this.onHover(null); + }); + + this.host.addEventListener('pointermove', (event) => { + const rect = this.host.getBoundingClientRect(); + this.pointer = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }); + } + + resize() { + this.bounds = { + width: this.host.clientWidth, + height: this.host.clientHeight, + }; + } + + render(state, points) { + this.resize(); + this.renderFrame(state, points); + this.renderHover(state); + } + + renderFrame(state, points) { + const width = this.bounds.width; + const height = this.bounds.height; + const padding = { top: 68, right: 24, bottom: 28, left: 52 }; + 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, + 24, + { color: 0x050c16, alpha: 1 }, + { color: 0x22344f, width: 1 }, + ); + + this.grid.clear(); + if (state.plot.showGrid) { + const gridColor = 0x1d3555; + for (let x = 0; x <= 8; x += 1) { + const px = padding.left + (plotWidth * x) / 8; + this.grid.moveTo(px, padding.top); + this.grid.lineTo(px, padding.top + plotHeight); + this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 }); + } + + for (let y = 0; y <= 6; y += 1) { + const py = padding.top + (plotHeight * y) / 6; + this.grid.moveTo(padding.left, py); + this.grid.lineTo(padding.left + plotWidth, py); + this.grid.stroke({ color: gridColor, width: 1, alpha: 0.65 }); + } + } + + this.line.clear(); + this.points.clear(); + this.screenPoints = []; + + if (points.length > 0) { + points.forEach((point, index) => { + const x = padding.left + ((point.timeMs - minTime) / (maxTime - minTime)) * plotWidth; + const normalizedValue = (point.value - minValue) / valueSpan; + const y = padding.top + (1 - normalizedValue) * plotHeight; + + this.screenPoints.push({ ...point, x, y }); + + if (index === 0) { + this.line.moveTo(x, y); + } else { + this.line.lineTo(x, y); + } + }); + + this.line.stroke({ + color: 0x7af0ff, + width: 2.25, + alpha: 0.95, + cap: 'round', + join: 'round', + }); + + if (state.plot.showPoints) { + for (const point of this.screenPoints) { + this.points.circle(point.x, point.y, 2.5); + this.points.fill({ color: 0xc4f8ff, alpha: 0.95 }); + } + } + } + + this.titleText.text = 'TimePlot viewport'; + this.titleText.x = 18; + this.titleText.y = 16; + + this.subtitleText.text = `${state.source.preset} • ${state.source.sampleRateHz} Hz • ${points.length} visible points`; + this.subtitleText.x = 18; + this.subtitleText.y = 38; + } + + renderHover(state) { + this.crosshair.clear(); + + if (!this.pointer || this.screenPoints.length === 0) { + this.onHover(null); + return; + } + + let nearestPoint = null; + let nearestDistance = Infinity; + + for (const point of this.screenPoints) { + const dx = point.x - this.pointer.x; + const dy = point.y - this.pointer.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < nearestDistance) { + nearestPoint = point; + nearestDistance = distance; + } + } + + if (!nearestPoint || nearestDistance > this.hoverRadiusPx) { + this.onHover(null); + return; + } + + const x = clamp(nearestPoint.x, 0, this.bounds.width); + const y = clamp(nearestPoint.y, 0, this.bounds.height); + + this.crosshair.moveTo(x, 0); + this.crosshair.lineTo(x, this.bounds.height); + this.crosshair.moveTo(0, y); + this.crosshair.lineTo(this.bounds.width, y); + this.crosshair.stroke({ color: 0x6ea8ff, width: 1, alpha: 0.22 }); + this.crosshair.circle(x, y, 5); + this.crosshair.stroke({ color: 0xffffff, width: 2, alpha: 0.95 }); + + this.onHover({ + x, + y, + point: nearestPoint, + paused: state.time.paused, + }); + } + + 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 new file mode 100644 index 0000000..b56e31a --- /dev/null +++ b/web-timeplot/src/styles.css @@ -0,0 +1,287 @@ +:root { + color-scheme: dark; + --bg: #07111f; + --surface: rgba(11, 24, 42, 0.86); + --surface-strong: rgba(9, 18, 32, 0.94); + --border: rgba(133, 168, 255, 0.18); + --text: #eef4ff; + --muted: #8ca3c7; + --accent: #6ea8ff; + --accent-strong: #7af0ff; + --danger: #ff8c8c; + --shadow: 0 20px 40px rgba(0, 0, 0, 0.28); + 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: + radial-gradient(circle at top left, rgba(122, 240, 255, 0.12), transparent 28%), + radial-gradient(circle at top right, rgba(110, 168, 255, 0.14), transparent 24%), + linear-gradient(180deg, #06101c 0%, #091423 100%); + 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: 14px; + padding: 14px; +} + +.timeplot-topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + border: 1px solid var(--border); + background: var(--surface); + backdrop-filter: blur(20px); + border-radius: 18px; + box-shadow: var(--shadow); +} + +.timeplot-brand { + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeplot-title { + margin: 0; + font-size: 1.2rem; +} + +.timeplot-subtitle { + color: var(--muted); + font-size: 0.9rem; +} + +.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: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 999px; +} + +.control-group label, +.control-group span { + color: var(--muted); + font-size: 0.85rem; +} + +.control-group input[type='range'] { + width: 130px; +} + +.control-button, +.panel-toggle { + color: var(--text); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 999px; + padding: 8px 14px; + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} + +.control-button:hover, +.panel-toggle:hover { + transform: translateY(-1px); + border-color: rgba(122, 240, 255, 0.45); +} + +.control-button[data-active='true'], +.panel-toggle[data-active='true'] { + background: linear-gradient(135deg, rgba(110, 168, 255, 0.18), rgba(122, 240, 255, 0.18)); + border-color: rgba(122, 240, 255, 0.42); +} + +.timeplot-viewport { + position: relative; + min-height: 0; + border-radius: 24px; + overflow: hidden; + border: 1px solid var(--border); + background: rgba(4, 10, 18, 0.94); + box-shadow: var(--shadow); +} + +.timeplot-canvas-host { + width: 100%; + height: 100%; +} + +.timeplot-sidebar { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow-y: auto; + padding-right: 2px; +} + +.panel { + border: 1px solid var(--border); + background: var(--surface-strong); + border-radius: 18px; + padding: 14px; + backdrop-filter: blur(20px); +} + +.panel[hidden] { + display: none; +} + +.panel h2 { + margin: 0 0 12px; + font-size: 0.95rem; +} + +.kv-list { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: center; + margin: 0; +} + +.kv-list dt { + color: var(--muted); + font-size: 0.84rem; +} + +.kv-list dd { + margin: 0; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.field-grid { + display: grid; + gap: 12px; +} + +.field-grid label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.84rem; +} + +.field-grid input, +.field-grid select { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} + +.panel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.panel-row + .panel-row { + margin-top: 10px; +} + +.muted { + color: var(--muted); +} + +.help-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 18px; + color: var(--muted); +} + +.timeplot-tooltip { + position: absolute; + min-width: 180px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(122, 240, 255, 0.28); + background: rgba(7, 14, 24, 0.94); + box-shadow: var(--shadow); + pointer-events: none; + transform: translate(14px, -50%); + z-index: 10; +} + +.timeplot-tooltip[hidden] { + display: none; +} + +.timeplot-tooltip-title { + margin-bottom: 6px; + font-size: 0.82rem; + color: var(--accent-strong); +} + +.timeplot-tooltip-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-size: 0.82rem; +} + +.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-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 new file mode 100644 index 0000000..8a1b216 --- /dev/null +++ b/web-timeplot/src/ui/panel-manager.js @@ -0,0 +1,287 @@ +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); +} + +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 canvasHost = createElement('div', 'timeplot-canvas-host'); + const sidebar = createElement('aside', 'timeplot-sidebar'); + const tooltip = createElement('div', 'timeplot-tooltip'); + tooltip.hidden = true; + + const brand = createElement('div', 'timeplot-brand'); + const title = createElement('h1', 'timeplot-title', 'TimePlot'); + const subtitle = createElement('div', 'timeplot-subtitle', 'Restarted from scratch with a modular core'); + brand.append(title, subtitle); + + const toolbar = createElement('div', 'timeplot-toolbar'); + toolbar.append( + this.createTransportControls(), + this.createPanelToggles(), + ); + + topbar.append(brand, toolbar); + viewport.append(canvasHost, tooltip); + shell.append(topbar, viewport, sidebar); + this.root.replaceChildren(shell); + + this.elements = { + ...this.elements, + shell, + topbar, + viewport, + canvasHost, + sidebar, + tooltip, + 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="field-grid"> + <label> + Preset + <select data-source-field="preset"> + <option value="telemetry">Telemetry</option> + <option value="chirp">Chirp</option> + <option value="burst">Burst</option> + </select> + </label> + <label> + Sample rate (Hz) + <input data-source-field="sampleRateHz" type="number" min="1" max="240" step="1" /> + </label> + <label> + Amplitude + <input data-source-field="amplitude" type="number" min="0.1" max="3" step="0.1" /> + </label> + <label> + Noise + <input data-source-field="noise" type="number" min="0" max="0.5" step="0.01" /> + </label> + </div> + `; + + panel.querySelectorAll('[data-source-field]').forEach((input) => { + input.addEventListener('change', () => { + const field = input.getAttribute('data-source-field'); + const rawValue = input.value; + const value = input.tagName === 'SELECT' ? rawValue : Number(rawValue); + this.actions.updateSource(field, 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> + `; + + panel.querySelectorAll('[data-plot-field]').forEach((input) => { + input.addEventListener('change', () => { + const field = input.getAttribute('data-plot-field'); + const value = input.type === 'checkbox' ? input.checked : Number(input.value); + this.actions.updatePlot(field, value); + }); + }); + + return panel; + } + + createHelpPanel() { + const panel = createElement('section', 'panel'); + panel.innerHTML = ` + <h2>Help</h2> + <ol class="help-list"> + <li>Hover the plot to inspect a sample.</li> + <li>Use Pause and the speed slider to inspect timing behavior.</li> + <li>Toggle panels from the top bar to focus the workspace.</li> + <li>Swap presets to exercise the data input system.</li> + </ol> + `; + return panel; + } + + sync(state, visiblePoints) { + this.elements.title.textContent = state.app.title; + this.elements.subtitle.textContent = 'Synthetic time-series workspace with modular systems'; + this.elements.pauseButton.textContent = state.time.paused ? 'Resume' : 'Pause'; + this.elements.speedInput.value = String(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 = `${visiblePoints}`; + + this.syncSourcePanel(state); + this.syncConfigPanel(state); + this.syncPanels(state); + this.syncTooltip(state); + } + + syncSourcePanel(state) { + this.elements.sourcePanel.querySelector('[data-source-field="preset"]').value = state.source.preset; + this.elements.sourcePanel.querySelector('[data-source-field="sampleRateHz"]').value = String(state.source.sampleRateHz); + this.elements.sourcePanel.querySelector('[data-source-field="amplitude"]').value = String(state.source.amplitude); + this.elements.sourcePanel.querySelector('[data-source-field="noise"]').value = String(state.source.noise); + } + + syncConfigPanel(state) { + this.elements.configPanel.querySelector('[data-plot-field="windowDurationMs"]').value = String(state.plot.windowDurationMs); + this.elements.configPanel.querySelector('[data-plot-field="maxPoints"]').value = String(state.plot.maxPoints); + this.elements.configPanel.querySelector('[data-plot-field="showGrid"]').checked = state.plot.showGrid; + this.elements.configPanel.querySelector('[data-plot-field="showPoints"]').checked = state.plot.showPoints; + } + + 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.tooltip.hidden = !tooltipState.visible || !tooltipState.point; + if (this.elements.tooltip.hidden) { + return; + } + + this.elements.tooltip.style.left = `${tooltipState.x}px`; + this.elements.tooltip.style.top = `${tooltipState.y}px`; + this.elements.tooltip.innerHTML = ` + <div class="timeplot-tooltip-title">Hovered sample</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> + `; + } +} diff --git a/web-timeplot/src/utils-format.js b/web-timeplot/src/utils-format.js new file mode 100644 index 0000000..f4eac88 --- /dev/null +++ b/web-timeplot/src/utils-format.js @@ -0,0 +1,22 @@ +export function formatDuration(ms) { + const totalSeconds = Math.max(0, ms / 1000); + if (totalSeconds < 60) { + return `${totalSeconds.toFixed(2)}s`; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toFixed(1)}s`; +} + +export function formatWallClock(timestampMs) { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestampMs)); +} + +export function formatValue(value) { + return Number.isFinite(value) ? value.toFixed(3) : '—'; +} diff --git a/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; - } -} |
