diff --git a/AGENTS.md b/AGENTS.md index ddd562d..d0f07ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,8 @@ Keep this file as a quick operational guide, not the canonical source. When upda ## Project Shape -- Public top-level API exports live in `src/index.ts`; convenient chart creation lives in `src/createChart.ts`. -- npm package output includes `dist/index.js` / `dist/index.d.ts` plus subpath entries for `core`, `interaction`, `render`, `react`, `linked`, `linked-core`, `data`, `export`, and built-in plugins under `plugins/*`. Keep `package.json#exports` and `vite.config.ts#build.lib.entry` in sync. +- Public top-level API exports live in `src/index.ts`; charts are created with the `Chart` constructor. +- npm package output includes `dist/index.js` / `dist/index.d.ts` plus subpath entries for `core`, `interaction`, `render`, `linked`, `linked-core`, `data`, `export`, and built-in plugins under `plugins/*`. Keep `package.json#exports` and `vite.config.ts#build.lib.entry` in sync. - Optional plugin subpaths currently include `legend`, `tooltip`, `interactions`, `annotations`, `selection`, `crosshair`, `navigator`, and `flamegraph`. - `src/core/` is the data engine and should not depend on UI, DOM, or GPU code. - `src/render/` owns the GPU abstraction, renderer orchestration, shader programs, WebGL2 resources, and native WebGL2 backend. diff --git a/README.md b/README.md index f1cbcb6..acec274 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Built on native WebGL2 with no rendering runtime dependency. ## Performance -The core chart runtime is intentionally compact: the production build for `blazeplot` (without optional plugins) is about **149 KiB raw**. Optional plugins and helpers ship as separate subpath entries. +The core chart runtime is intentionally compact: the production build for `blazeplot` (without optional plugins) is about **152 KiB raw**. Optional plugins and helpers ship as separate subpath entries. Latest manual headed comparison: 2026-05-22T15:20:02.565Z on AMD Ryzen 5 5600H with Radeon Graphics (12 logical CPUs), ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 3050 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0), Chrome/148.0.7778.167. The harness prewarms each selected library before measured runs (317.4 ms total) and discards 1 setup warmup run(s) before each displayed row. Source: `benchmarks/latest.json`. @@ -67,13 +67,13 @@ bun add blazeplot ## Quick start -A chart only needs a sized host element. `createChart(...)` accepts simple array data, fits the viewport, and starts rendering for the common first-chart case. +A chart only needs a sized host element and the `Chart` constructor. ```html
``` -Use the lower-level `Chart` and dataset classes when you need explicit lifecycle control, custom datasets, streaming buffers, or specialized viewport policies. +Dispose the chart when its owning page, component, or panel is removed. ## Features @@ -101,7 +102,7 @@ Use the lower-level `Chart` and dataset classes when you need explicit lifecycle | **Axis labels** | Smart tick generation with DOM labels. Per-axis `inside`/`outside` positioning; outside axes reserve real layout gutters. | | **Multi-series** | Independent buffers, styles, and visibility per series. Line, area, scatter, bar, OHLC, and candlestick modes are supported. | | **Plugin-ready UI** | Optional built-in legend, tooltip, interactions, annotations, selection, crosshair, and navigator plugins use the same public APIs available to custom plugins. | -| **React and linked charts** | First-party `blazeplot/react` and `blazeplot/linked` subpaths support React usage and synchronized multi-panel layouts. | +| **Framework and linked charts** | The same `Chart` constructor works in framework lifecycle hooks; `blazeplot/linked` supports synchronized multi-panel layouts. | | **Export helpers** | `chart.screenshot()` composites WebGL output with built-in DOM/SVG overlays. `blazeplot/data` provides lightweight CSV/JSON data export and pure transform helpers; `blazeplot/export` provides download/clipboard helpers. | | **Frame stats** | `chart.getFrameStats()` reports fps, frame time, vertex count, and draw calls for custom diagnostics. | | **ResizeObserver** | Automatic DPR-aware canvas sizing. | @@ -149,15 +150,15 @@ This page is generated from the built package. Use it as an index of import path | Task | Start here | |---|---| -| Create and render a chart | `createChart(...)` for common static charts; `Chart`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` for manual lifecycle control | -| Static X/Y arrays or object rows | `createChart(...)`, `StaticDataset`, `StaticDataset.fromObjects(...)` | +| Create and render a chart | `new Chart(...)`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` | +| Static X/Y arrays or object rows | `StaticDataset`, `StaticDataset.fromObjects(...)` | | Live irregular data | `chart.addLine({ capacity })`, `RingBuffer`, [Live data](docs/live-data.md) | | Live fixed-rate data | `chart.addLine({ capacity, xStep })`, `UniformRingBuffer`, [Live data](docs/live-data.md) | | OHLC/candlesticks | `StaticOhlcDataset`, `OhlcRingBuffer`, `chart.addOhlc(...)`, `chart.addCandlestick(...)` | | Custom high-performance data | `Dataset`, `AcceleratedDataset`, range/copy dataset interfaces | | Pan/zoom and user interaction | `blazeplot/plugins/interactions`, `Camera2D`, viewport APIs | | Tooltips, legends, annotations, selection, flame graphs | `blazeplot/plugins/*` subpaths | -| React | `blazeplot/react` and `BlazeChart` | +| React | Create and dispose `Chart` in an effect | | Linked dashboards | `blazeplot/linked` or `blazeplot/linked-core` | | Image/data export | `chart.screenshot()`, `blazeplot/export`, `blazeplot/data` | @@ -171,7 +172,6 @@ Guides: [Overview](docs/overview.md), [Docs map](docs/README.md), [Examples](doc | `blazeplot/core` | Data structures, datasets, LOD helpers, and series storage without chart UI. | | `blazeplot/interaction` | Camera, axis, pan/zoom intent, and viewport policy helpers without chart UI. | | `blazeplot/render` | Renderer and WebGL backend primitives without chart UI. | -| `blazeplot/react` | React wrapper component. | | `blazeplot/linked` | Linked chart layout helpers with tooltip/crosshair sync factories. | | `blazeplot/linked-core` | Lean linked chart layout helpers without tooltip/crosshair sync imports. | | `blazeplot/data` | Pure chart data export and transform helpers. | @@ -193,11 +193,10 @@ Generated from `dist/` after the package build. | Chunk | File | Size | |---|---|---:| -| root entry | `dist/index.js` | 2 KiB | +| root entry | `dist/index.js` | 1 KiB | | core subpath entry | `dist/core.js` | 1 KiB | | interaction subpath entry | `dist/interaction.js` | 0 KiB | | render subpath entry | `dist/render.js` | 0 KiB | -| react entry | `dist/react.js` | 1 KiB | | linked entry | `dist/linked.js` | 0 KiB | | linked core entry | `dist/linked-*.js` | 0 KiB | | data entry | `dist/data.js` | 5 KiB | @@ -210,10 +209,10 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 21 KiB | -| shared Chart chunk | `dist/Chart-*.js` | 58 KiB | -| shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 44 KiB | +| shared Chart chunk | `dist/Chart-*.js` | 59 KiB | +| shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 45 KiB | | shared OhlcDataset chunk | `dist/OhlcDataset-*.js` | 9 KiB | -| shared AxisController chunk | `dist/AxisController-*.js` | 14 KiB | +| shared AxisController chunk | `dist/AxisController-*.js` | 17 KiB | | shared WebGL2Backend chunk | `dist/WebGL2Backend-*.js` | 22 KiB | | shared LinkedChartsCore chunk | `dist/LinkedChartsCore-*.js` | 2 KiB | | lazy screenshot chunk | `dist/screenshot-*.js` | 4 KiB | @@ -275,15 +274,6 @@ Generated from `dist/index.d.ts` after the package build. | `ChartTheme` | interface | `./ui/theme` | Partial chart theme supplied by callers. | | `ChartTitleConfig` | interface | `./ui/Chart` | Chart title or subtitle text and alignment. | | `ChartViewportChangeEvent` | interface | `./ui/Chart` | Emitted after the visible domain changes. | -| `createChart` | function | `./createChart` | Create a chart from a compact declarative config. This helper is intentionally thin: it returns the underlying `Chart` instance, so advanced code can still use the full imperative API after setup. | -| `CreateChartArraySeries` | interface | `./createChart` | Declarative series backed by parallel X and Y arrays. | -| `CreateChartDatasetSeries` | interface | `./createChart` | Declarative series backed by an existing BlazePlot dataset. | -| `CreateChartHistogramSeries` | type | `./createChart` | Declarative histogram series backed by raw one-dimensional values. | -| `CreateChartObjectSeries` | interface | `./createChart` | Declarative series backed by object rows and field selectors. | -| `CreateChartOptions` | interface | `./createChart` | High-level chart configuration for common first-render cases. Use `createChart(...)` when you have static arrays, object rows, or a simple streaming buffer and want BlazePlot to create the chart, add series, fit the initial viewport, and start rendering in one call. | -| `CreateChartSeries` | type | `./createChart` | Any series shape accepted by `createChart`. | -| `CreateChartSeriesType` | type | `./createChart` | Series modes supported by the declarative `createChart` helper. | -| `CreateChartStreamingSeries` | interface | `./createChart` | Declarative empty streaming series with an internally-created ring buffer. | | `CssColor` | type | `./ui/theme` | CSS color string accepted by theme options. | | `CustomAxisScale` | interface | `./interaction/AxisController` | Custom scale hooks for tick generation, formatting, and coordinate mapping. | | `Dataset` | interface | `./core/types` | Sorted XY data source consumed by chart series. | diff --git a/bun.lock b/bun.lock index 8185091..28e61f5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,24 +8,16 @@ "@tailwindcss/vite": "^4.3.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "chart.js": "^4.5.1", "highlight.js": "^11.11.1", "lit": "^3.3.3", "react": "^19.2.6", - "react-dom": "^19.2.6", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "uplot": "^1.6.32", "vite": "^8.0.13", "vite-plugin-dts": "^5.0.0", }, - "peerDependencies": { - "react": ">=18", - }, - "optionalPeers": [ - "react", - ], }, }, "packages": { @@ -129,8 +121,6 @@ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -229,12 +219,8 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - "rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], diff --git a/changelogs/v0.4.0.md b/changelogs/v0.4.0.md new file mode 100644 index 0000000..808a7cc --- /dev/null +++ b/changelogs/v0.4.0.md @@ -0,0 +1,38 @@ +# BlazePlot v0.4.0 + +## Changes + +- Applied logarithmic, symlog, reversed, custom, and right-axis transforms consistently to rendering, ticks, picking, pan, and zoom. +- Improved OHLC and candlestick up/down styling and expanded the financial chart examples. +- Accelerated data-bound queries for datasets with range min/max support and reduced per-frame WebGL uniform allocations. +- Consolidated duplicated browser test and benchmark infrastructure. +- Standardized documentation and examples on one chart initialization path: `new Chart(...)`. + +## Breaking changes + +- Removed the declarative `createChart(...)` helper and its `CreateChart*` types. Construct `Chart`, add dataset-backed series, fit or set the viewport, then call `start()`. +- Removed the `blazeplot/react` entry and `BlazeChart`. React applications should construct and dispose `Chart` in an effect. + +## Benchmarks + +## 2026-07-10T06:49:50.391Z + +Command: `bun run bench:report --scenario ci-smoke --measure-ms 750 --warmup-ms 100 --width 800 --height 450 --top 12 --setup-timeout-ms 60000 --out-md changelogs/v0.4.0.md` + +| Scenario | Browser | Canvas | Renderer | RAF FPS | RAF p95 ms | Chart p50 ms | Chart p95 ms | Points | Draws | Batched | Upload KB | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| ci-smoke | brave | 748x341 | mixed | 60.0 | 16.70 | 1.40 | 2.40 | 24,783 | 4 | 0 | 193.9 | + +### CPU hot spots: ci-smoke + +| Function | Self ms | Total ms | Location | +|---|---:|---:|---| +| (idle) | 567.6 | 567.6 | runtime | +| measure | 73.8 | 78.7 | main.ts:287 | +| (program) | 58.6 | 58.6 | runtime | +| queryPhysicalMinMax | 10.2 | 10.2 | RingBuffer.ts:206 | +| updateAxis | 10.1 | 13.4 | AxisOverlay.ts:87 | +| writeBarBucketTriangles | 8.9 | 8.9 | Chart.ts:1591 | +| (garbage collector) | 7.5 | 7.5 | runtime | +| copyMinMaxInstanced | 6.9 | 26.1 | SeriesStore.ts:607 | + diff --git a/docs/api-reference.md b/docs/api-reference.md index 67357e6..cb52b86 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -6,15 +6,15 @@ This page is generated from the built package. Use it as an index of import path | Task | Start here | |---|---| -| Create and render a chart | `createChart(...)` for common static charts; `Chart`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` for manual lifecycle control | -| Static X/Y arrays or object rows | `createChart(...)`, `StaticDataset`, `StaticDataset.fromObjects(...)` | +| Create and render a chart | `new Chart(...)`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` | +| Static X/Y arrays or object rows | `StaticDataset`, `StaticDataset.fromObjects(...)` | | Live irregular data | `chart.addLine({ capacity })`, `RingBuffer`, [Live data](./live-data.md) | | Live fixed-rate data | `chart.addLine({ capacity, xStep })`, `UniformRingBuffer`, [Live data](./live-data.md) | | OHLC/candlesticks | `StaticOhlcDataset`, `OhlcRingBuffer`, `chart.addOhlc(...)`, `chart.addCandlestick(...)` | | Custom high-performance data | `Dataset`, `AcceleratedDataset`, range/copy dataset interfaces | | Pan/zoom and user interaction | `blazeplot/plugins/interactions`, `Camera2D`, viewport APIs | | Tooltips, legends, annotations, selection, flame graphs | `blazeplot/plugins/*` subpaths | -| React | `blazeplot/react` and `BlazeChart` | +| React | Create and dispose `Chart` in an effect | | Linked dashboards | `blazeplot/linked` or `blazeplot/linked-core` | | Image/data export | `chart.screenshot()`, `blazeplot/export`, `blazeplot/data` | @@ -28,7 +28,6 @@ Guides: [Overview](./overview.md), [Docs map](./README.md), [Examples](./example | `blazeplot/core` | Data structures, datasets, LOD helpers, and series storage without chart UI. | | `blazeplot/interaction` | Camera, axis, pan/zoom intent, and viewport policy helpers without chart UI. | | `blazeplot/render` | Renderer and WebGL backend primitives without chart UI. | -| `blazeplot/react` | React wrapper component. | | `blazeplot/linked` | Linked chart layout helpers with tooltip/crosshair sync factories. | | `blazeplot/linked-core` | Lean linked chart layout helpers without tooltip/crosshair sync imports. | | `blazeplot/data` | Pure chart data export and transform helpers. | @@ -50,11 +49,10 @@ Generated from `dist/` after the package build. | Chunk | File | Size | |---|---|---:| -| root entry | `dist/index.js` | 2 KiB | +| root entry | `dist/index.js` | 1 KiB | | core subpath entry | `dist/core.js` | 1 KiB | | interaction subpath entry | `dist/interaction.js` | 0 KiB | | render subpath entry | `dist/render.js` | 0 KiB | -| react entry | `dist/react.js` | 1 KiB | | linked entry | `dist/linked.js` | 0 KiB | | linked core entry | `dist/linked-*.js` | 0 KiB | | data entry | `dist/data.js` | 5 KiB | @@ -67,10 +65,10 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 21 KiB | -| shared Chart chunk | `dist/Chart-*.js` | 58 KiB | -| shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 44 KiB | +| shared Chart chunk | `dist/Chart-*.js` | 59 KiB | +| shared streaming data chunk | `dist/UniformRingBuffer-*.js` | 45 KiB | | shared OhlcDataset chunk | `dist/OhlcDataset-*.js` | 9 KiB | -| shared AxisController chunk | `dist/AxisController-*.js` | 14 KiB | +| shared AxisController chunk | `dist/AxisController-*.js` | 17 KiB | | shared WebGL2Backend chunk | `dist/WebGL2Backend-*.js` | 22 KiB | | shared LinkedChartsCore chunk | `dist/LinkedChartsCore-*.js` | 2 KiB | | lazy screenshot chunk | `dist/screenshot-*.js` | 4 KiB | @@ -132,15 +130,6 @@ Generated from `dist/index.d.ts` after the package build. | `ChartTheme` | interface | `./ui/theme` | Partial chart theme supplied by callers. | | `ChartTitleConfig` | interface | `./ui/Chart` | Chart title or subtitle text and alignment. | | `ChartViewportChangeEvent` | interface | `./ui/Chart` | Emitted after the visible domain changes. | -| `createChart` | function | `./createChart` | Create a chart from a compact declarative config. This helper is intentionally thin: it returns the underlying `Chart` instance, so advanced code can still use the full imperative API after setup. | -| `CreateChartArraySeries` | interface | `./createChart` | Declarative series backed by parallel X and Y arrays. | -| `CreateChartDatasetSeries` | interface | `./createChart` | Declarative series backed by an existing BlazePlot dataset. | -| `CreateChartHistogramSeries` | type | `./createChart` | Declarative histogram series backed by raw one-dimensional values. | -| `CreateChartObjectSeries` | interface | `./createChart` | Declarative series backed by object rows and field selectors. | -| `CreateChartOptions` | interface | `./createChart` | High-level chart configuration for common first-render cases. Use `createChart(...)` when you have static arrays, object rows, or a simple streaming buffer and want BlazePlot to create the chart, add series, fit the initial viewport, and start rendering in one call. | -| `CreateChartSeries` | type | `./createChart` | Any series shape accepted by `createChart`. | -| `CreateChartSeriesType` | type | `./createChart` | Series modes supported by the declarative `createChart` helper. | -| `CreateChartStreamingSeries` | interface | `./createChart` | Declarative empty streaming series with an internally-created ring buffer. | | `CssColor` | type | `./ui/theme` | CSS color string accepted by theme options. | | `CustomAxisScale` | interface | `./interaction/AxisController` | Custom scale hooks for tick generation, formatting, and coordinate mapping. | | `Dataset` | interface | `./core/types` | Sorted XY data source consumed by chart series. | diff --git a/docs/browser-support.md b/docs/browser-support.md index 628ec35..36115ba 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -91,4 +91,4 @@ export function ClientOnlyChart({ x, y }: { x: number[]; y: number[] }) { ## Dependencies -The core renderer uses native WebGL2 directly and has no runtime rendering dependency. React is optional and is only needed when importing `blazeplot/react`. +The renderer uses native WebGL2 directly and has no runtime rendering dependency. diff --git a/docs/data-semantics.md b/docs/data-semantics.md index d4cfd71..4b73573 100644 --- a/docs/data-semantics.md +++ b/docs/data-semantics.md @@ -7,7 +7,7 @@ BlazePlot expects finite, sorted X values. Y values are normally finite; non-fin | Source shape | Dataset | Notes | |---|---|---| | Fixed X/Y arrays | `StaticDataset` | Best for already-loaded history or immutable snapshots. | -| Object rows | `StaticDataset.fromObjects(...)` or `createChart(...)` | Copies row fields or accessor results into sorted X/Y arrays. | +| Object rows | `StaticDataset.fromObjects(...)` | Copies row fields or accessor results into sorted X/Y arrays. | | Irregular live samples | `RingBuffer` | Stores explicit X/Y pairs and keeps a bounded history. | | Fixed-rate live samples | `UniformRingBuffer` | Stores Y values only and derives X from `xStart + index * xStep`. | | Historical OHLC/candles | `StaticOhlcDataset` | Bounds and fitting use high/low values. | @@ -61,7 +61,7 @@ For finite-to-finite session breaks, insert an explicit gap marker sample. ## Histograms and X/Y binning -`histogram(values, options)` bins one-dimensional finite values by value range. It skips `NaN`, infinities, and non-number values, tracks underflow/overflow outside the chosen bin edges, and can normalize bucket heights as counts, probability, percent, or density. Fixed-size bins align to origin `0` by default; pass `align` to use another origin. `chart.addHistogram(...)` and declarative `type: "histogram"` series turn those buckets into a histogram dataset and render them as bars. Each rendered sample is centered at the bucket midpoint for the bar renderer, while the dataset exposes generic X-interval metadata that tooltip and picking code can present as a range. +`histogram(values, options)` bins one-dimensional finite values by value range. It skips `NaN`, infinities, and non-number values, tracks underflow/overflow outside the chosen bin edges, and can normalize bucket heights as counts, probability, percent, or density. Fixed-size bins align to origin `0` by default; pass `align` to use another origin. `chart.addHistogram(...)` turns those buckets into a histogram dataset and renders them as bars. Each rendered sample is centered at the bucket midpoint for the bar renderer, while the dataset exposes generic X-interval metadata that tooltip and picking code can present as a range. `binSamples(samples, binSize, options)` is different: it expects existing `{ x, y }` samples and groups them by X interval with a Y reducer such as mean, sum, min, or max. diff --git a/docs/examples.md b/docs/examples.md index 162592f..122739b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Examples -These are small patterns you can copy into an app. The public docs instantiate the same kind of chart next to the snippets so you can see the result before copying the code. For larger runnable cases, open the [interactive previews](https://blazeplot.cervelli.dev/previews). If you are new to BlazePlot, start with the [Overview](./overview.md) first. +These are small patterns you can copy into an app. Standalone charts consistently use `new Chart(...)`; the linked helper only builds a synchronized multi-panel layout around chart instances. The public docs instantiate the same kind of chart next to the snippets so you can see the result before copying the code. For larger runnable cases, open the [interactive previews](https://blazeplot.cervelli.dev/previews). If you are new to BlazePlot, start with the [Overview](./overview.md) first. ## Choose a starting point @@ -8,13 +8,13 @@ Use this table before reaching for a generic chart example. The dataset choice d | If you have | Use | |---|---| -| Fixed X/Y arrays or object rows | `createChart(...)` for the shortest setup, or `StaticDataset` with `chart.addLine(...)`, `chart.addScatter(...)`, `chart.addBar(...)`, or `chart.addArea(...)` when you need manual control | -| One-dimensional values that need a frequency distribution | `histogram(...)`, `chart.addHistogram(...)`, or `createChart({ series: [{ type: "histogram", values }] })` | +| Fixed X/Y arrays or object rows | `StaticDataset` with `chart.addLine(...)`, `chart.addScatter(...)`, `chart.addBar(...)`, or `chart.addArea(...)` | +| One-dimensional values that need a frequency distribution | `histogram(...)` or `chart.addHistogram(...)` | | Irregular live samples | `RingBuffer` with `overflow: "wrap"` for a rolling window | | Fixed-rate telemetry | `UniformRingBuffer` with `series.append({ y })` so repeated X values are derived, not stored | | Historical OHLC data | `StaticOhlcDataset` with `chart.addOhlc(...)` or `chart.addCandlestick(...)` | | Server-reduced min/max buckets | `ServerSampledDataset` with `downsample: "server"` | -| React ownership of the DOM | `BlazeChart` from `blazeplot/react` | +| React ownership of the DOM | Create and dispose `Chart` in an effect | | Multiple charts sharing an X range | `createLinkedCharts` from `blazeplot/linked` | All built-in datasets expect sorted X values. If source data arrives out of order, sort it before constructing the dataset or write a custom dataset that exposes sorted logical access. @@ -29,27 +29,28 @@ All built-in datasets expect sorted X values. If source data arrives out of orde - [Linked charts](#linked-charts) — dashboards with shared X ranges. - [Built-in plugins](#built-in-plugins) — interactions, tooltip, legend, annotations, selection, crosshair, and navigator. - [Export image and data](#export-image-and-data) — screenshots, CSV, and JSON helpers. -- [React](#react) — using `BlazeChart` with stable options. +- [React](#react) — creating and disposing the same `Chart` API in an effect. ## Example structure Most examples follow the same lifecycle: 1. create a sized host element; -2. use `createChart(...)` for static data, or create a dataset that matches the data source; -3. add one or more series; -4. initialize the viewport with `fitToData()`, `autoFit`, or live-window options; -5. call `chart.start()` once if you are using the lower-level constructor; +2. create the chart with `new Chart(...)`; +3. create a dataset that matches the data source and add one or more series; +4. initialize the viewport with `fitToData()`, `setViewport()`, or live-window options; +5. call `chart.start()` once; 6. clean up timers, subscriptions, workers, plugin handles, and the chart when the owner unmounts. ## Basic line chart ```ts -import { createChart } from "blazeplot"; +import { Chart, StaticDataset } from "blazeplot"; -const chart = createChart(element, { - series: [{ type: "line", x: [0, 1, 2], y: [3, 6, 4], name: "values" }], -}); +const chart = new Chart(element); +chart.addLine({ dataset: new StaticDataset([0, 1, 2], [3, 6, 4]), name: "values" }); +chart.fitToData(); +chart.start(); ``` :::chart basic-line Basic line chart @@ -63,7 +64,7 @@ chart.dispose(); Object rows are accepted without writing a dataset class: ```ts -import { createChart } from "blazeplot"; +import { Chart, StaticDataset } from "blazeplot"; const rows = [ { time: 1700000000000, requests: 120 }, @@ -71,42 +72,36 @@ const rows = [ { time: 1700000002000, requests: 118 }, ]; -const chart = createChart(element, { - series: [{ type: "line", data: rows, x: "time", y: "requests", sort: true }], -}); -``` - -:::chart object-rows Object rows with timestamp X values - -Use the lower-level API when you want explicit lifecycle control: - -```ts -import { Chart, StaticDataset } from "blazeplot"; - const chart = new Chart(element); -chart.addLine({ dataset: new StaticDataset([0, 1, 2], [3, 6, 4]), name: "values" }); +chart.addLine({ + dataset: StaticDataset.fromObjects(rows, { x: "time", y: "requests", sort: true }), + name: "requests", +}); chart.fitToData(); chart.start(); ``` +:::chart object-rows Object rows with timestamp X values + ## Histogram Use histograms when you have one-dimensional measurements and want a frequency distribution. BlazePlot computes bucket centers/counts and renders them through the existing bar renderer. ```ts -import { createChart } from "blazeplot"; +import { Chart } from "blazeplot"; const values = new Float64Array([12, 18, 19, 20, 21, 28, 33, 35, 36, 42]); - -const chart = createChart(element, { - series: [{ type: "histogram", values, binSize: 10, name: "latency" }], +const chart = new Chart(element, { axes: { x: { title: "Latency ms" }, y: { title: "Count" } }, }); +chart.addHistogram({ values, binSize: 10, name: "latency" }); +chart.fitToData({ includeZero: true }); +chart.start(); ``` :::chart histogram Latency histogram -For manual charts, precompute or inspect bins with the pure helper: +Precompute or inspect bins with the pure helper: ```ts import { Chart, histogram } from "blazeplot"; @@ -350,33 +345,27 @@ downloadBlob(new Blob([csv], { type: "text/csv" }), "visible-data.csv"); ## React -Use `blazeplot/react` when you want React to own the container while BlazePlot owns the chart instance. +Use the same `Chart` constructor in an effect when React owns the container. ```tsx -import { useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { Chart, StaticDataset } from "blazeplot"; -import { BlazeChart } from "blazeplot/react"; import { interactionsPlugin } from "blazeplot/plugins/interactions"; export function PriceChart() { - const x = [0, 1, 2]; - const y = [10, 12, 11]; - const chartRef = useRef(null); - const options = useMemo(() => ({ plugins: [interactionsPlugin()] }), []); - - return ( - { - chart.addLine({ dataset: new StaticDataset(x, y), name: "price" }); - chart.fitToData(); - chart.start(); - }} - /> - ); + const hostRef = useRef(null); + + useEffect(() => { + if (!hostRef.current) return; + const chart = new Chart(hostRef.current, { plugins: [interactionsPlugin()] }); + chart.addLine({ dataset: new StaticDataset([0, 1, 2], [10, 12, 11]), name: "price" }); + chart.fitToData(); + chart.start(); + return () => chart.dispose(); + }, []); + + return
; } ``` -Keep `options` stable with `useMemo`; changing its identity recreates the chart. `BlazeChart` disposes the chart on unmount. Clean up your own timers, workers, and subscriptions in React effects. +Clean up timers, workers, subscriptions, and the chart from the effect cleanup. diff --git a/docs/overview.md b/docs/overview.md index c236485..b5eac8a 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -13,13 +13,13 @@ bun add blazeplot ## Quick start -Create a sized container and pass simple array data to `createChart(...)`. The helper creates the chart, adds the series, fits the camera, and starts the render loop. +Create a sized container, construct `Chart`, add a dataset-backed series, fit the camera, and start rendering. ```html
``` Call `chart.dispose()` when the chart is removed from the page. -For streaming data, custom datasets, or manual lifecycle control, use the lower-level `Chart` constructor with `StaticDataset`, `RingBuffer`, or another dataset type. - If the chart appears blank, check that the host element has a non-zero height, WebGL2 is available, and the viewport has been initialized. See [Troubleshooting](./troubleshooting.md) for the full checklist. ## Documentation map @@ -67,7 +66,7 @@ For a maintainer-oriented page list, see [Documentation map](./README.md). | Live viewport helpers | `followX` for rolling windows and `autoFitY` for visible-range Y fitting. | | Plugins | Legend, tooltip, crosshair, annotations, selection, and navigator plugins. See [Built-in plugins](./built-in-plugins.md). | | Layout and themes | Theme tokens, inside/outside axes, titles, and plugin layout reservations. See [Theming and layout](./theming-and-layout.md). | -| React | `blazeplot/react` for the `BlazeChart` React component. | +| React | Create and dispose the same `Chart` API in an effect. | | Exports | Screenshot, clipboard, and CSV/JSON data helpers. | ## Main tradeoffs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b300872..eb670fd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -94,14 +94,16 @@ const chart = new Chart(element, { }); ``` -## React chart recreates unexpectedly +## React chart is duplicated or leaks -`BlazeChart` recreates the chart when the `options` object identity changes. Keep options stable with `useMemo`, and clean up your own timers, workers, and subscriptions in effects. +Create `Chart` once inside an effect and dispose it from that effect's cleanup. Do not construct charts during render. ```tsx -const options = useMemo(() => ({ plugins: [interactionsPlugin()] }), []); - -return ; +useEffect(() => { + if (!hostRef.current) return; + const chart = new Chart(hostRef.current); + return () => chart.dispose(); +}, []); ``` ## Screenshots miss external UI diff --git a/docs/versioning-and-migration.md b/docs/versioning-and-migration.md index aec2b38..72e9b70 100644 --- a/docs/versioning-and-migration.md +++ b/docs/versioning-and-migration.md @@ -24,7 +24,7 @@ BlazePlot follows npm semver. Use this page to decide whether a change is patch/ 2. Check the [API reference](./api-reference.md) for renamed, moved, or newly added exports. 3. Run your chart interaction flows, not just unit tests. Pan, zoom, tooltips, selection, screenshots, and exports can depend on browser behavior. 4. If you use custom datasets, re-check the assumptions in [Data semantics](./data-semantics.md). -5. If you use React, verify chart mount/unmount behavior and that options passed to `BlazeChart` are stable enough to avoid unintended recreation. +5. If you use React, verify that the effect creating `Chart` disposes it on cleanup. 6. If you use subpath imports, run your bundler against the production build so export-map mistakes are caught early. ## Migration-risk checklist diff --git a/package.json b/package.json index 31e97ba..230b1f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blazeplot", - "version": "0.3.14", + "version": "0.4.0", "packageManager": "bun@1.3.14", "description": "Real-time LOD time series rendering engine for the browser.", "keywords": [ @@ -50,10 +50,6 @@ "types": "./dist/render.d.ts", "import": "./dist/render.js" }, - "./react": { - "types": "./dist/react.d.ts", - "import": "./dist/react.js" - }, "./linked": { "types": "./dist/linked.d.ts", "import": "./dist/linked.js" @@ -142,24 +138,14 @@ "test:generated-docs": "node scripts/generate-readme-docs.js --check && bun scripts/generate-docs-registry.ts --check", "test:docs-snippets": "bun scripts/typecheck-doc-snippets.ts" }, - "peerDependencies": { - "react": ">=18" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - }, "devDependencies": { "@tailwindcss/vite": "^4.3.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "chart.js": "^4.5.1", "highlight.js": "^11.11.1", "lit": "^3.3.3", "react": "^19.2.6", - "react-dom": "^19.2.6", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "uplot": "^1.6.32", diff --git a/scripts/benchmark-compare.ts b/scripts/benchmark-compare.ts index 6e1a1b6..d071f7e 100644 --- a/scripts/benchmark-compare.ts +++ b/scripts/benchmark-compare.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun -import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { arch, cpus, platform, release, tmpdir, totalmem } from "node:os"; import { basename, join, resolve } from "node:path"; import officialConfig from "./benchmark-config.json"; +import { CdpClient, attachConsoleLogging, createTarget, evaluate, readNonNegativeInteger as readPositiveInteger, resolveChrome, sleep, spawnChrome, startVite, throwIfPageErrored, waitForHttp } from "./browser-harness.js"; interface Options { scenarios: string[]; @@ -24,19 +24,6 @@ interface Options { keepBrowser: boolean; } -interface CdpResponse { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: { message: string; data?: string }; -} - -interface RemoteObjectResult { - result?: { value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { text?: string; exception?: { description?: string } }; -} - interface CompareSnapshot { state: "prewarming" | "ready" | "running" | "done" | "error"; progress: number; @@ -337,14 +324,6 @@ function readCsv(raw: string): string[] { return raw.split(",").map((value) => value.trim()).filter(Boolean); } -function readPositiveInteger(flag: string, raw: string): number { - const value = Number(raw); - if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) { - throw new Error(`${flag} expects a non-negative integer, got ${raw}`); - } - return value; -} - async function createReport(options: Options, chromePath: string, browser: BrowserVersion, pageResult: PageBenchmarkResult): Promise { const warnings = collectWarnings(options, pageResult); const libraries = await readLibraryInfo(); @@ -674,18 +653,6 @@ function firstFrameDetails(result: LibraryResult): string { return details.join(", ") || "—"; } -function startVite(port: number): Bun.Subprocess { - const proc = Bun.spawn({ - cmd: ["bunx", "vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, BLAZEPLOT_BENCH: "1" }, - }); - drain(proc.stdout, "vite"); - drain(proc.stderr, "vite"); - return proc; -} - function launchChrome(chromePath: string, userDataDir: string, opts: Options): Bun.Subprocess { const cmd = [ chromePath, @@ -706,36 +673,7 @@ function launchChrome(chromePath: string, userDataDir: string, opts: Options): B "about:blank", ]; if (opts.headless) cmd.splice(1, 0, "--headless=new"); - const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }); - drain(proc.stdout, "chrome"); - drain(proc.stderr, "chrome"); - return proc; -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - const startedAt = Date.now(); - let lastError: unknown = null; - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.status < 500) return; - lastError = new Error(`HTTP ${response.status}`); - } catch (error) { - lastError = error; - } - await sleep(250); - } - throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -async function createTarget(debugPort: number, url: string): Promise<{ webSocketDebuggerUrl: string }> { - const endpoint = `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`; - let response = await fetch(endpoint, { method: "PUT" }); - if (!response.ok) response = await fetch(endpoint); - if (!response.ok) throw new Error(`Could not create Chrome target: HTTP ${response.status}`); - const payload = await response.json() as { webSocketDebuggerUrl?: string }; - if (!payload.webSocketDebuggerUrl) throw new Error("Chrome target response did not include webSocketDebuggerUrl"); - return { webSocketDebuggerUrl: payload.webSocketDebuggerUrl }; + return spawnChrome(cmd); } async function waitForBenchmarkState(cdp: CdpClient, desiredState: CompareSnapshot["state"], timeoutMs: number): Promise { @@ -750,93 +688,6 @@ async function waitForBenchmarkState(cdp: CdpClient, desiredState: CompareSnapsh throw new Error(`Timed out waiting for comparison benchmark state '${desiredState}'. Last snapshot: ${JSON.stringify(snapshot)}`); } -async function evaluate(cdp: CdpClient, expression: string, awaitPromise: boolean): Promise { - const response = await cdp.send("Runtime.evaluate", { - expression, - awaitPromise, - returnByValue: true, - userGesture: false, - }) as RemoteObjectResult; - if (response.exceptionDetails) { - throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Runtime.evaluate failed"); - } - return response.result?.value; -} - -function attachConsoleLogging(cdp: CdpClient, pageErrors: string[]): void { - cdp.on("Runtime.consoleAPICalled", (params) => { - const event = params as { type?: string; args?: Array<{ value?: unknown; description?: string }> }; - const text = event.args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? ""; - if (text) process.stderr.write(`[page:${event.type ?? "log"}] ${text}\n`); - }); - cdp.on("Runtime.exceptionThrown", (params) => { - const text = JSON.stringify(params); - pageErrors.push(text); - process.stderr.write(`[page:exception] ${text}\n`); - }); -} - -function throwIfPageErrored(pageErrors: readonly string[]): void { - if (pageErrors.length === 0) return; - throw new Error(`Benchmark page threw ${pageErrors.length} exception(s). First exception: ${pageErrors[0]}`); -} - -function resolveChrome(explicit: string | undefined): string { - const envPath = explicit ?? process.env.BLAZEPLOT_BENCH_CHROME ?? process.env.CHROME_PATH; - if (envPath) { - if (!existsSync(envPath)) throw new Error(`Chrome executable does not exist: ${envPath}`); - return envPath; - } - - const candidates = [ - "google-chrome-stable", - "google-chrome", - "chromium-browser", - "chromium", - "chrome", - "brave-browser", - "brave-browser-stable", - "brave", - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ]; - - for (const candidate of candidates) { - if (candidate.startsWith("/") && existsSync(candidate)) return candidate; - if (!candidate.startsWith("/")) { - const found = which(candidate); - if (found) return found; - } - } - - throw new Error("Could not find Chrome/Chromium/Brave. Pass --chrome or set BLAZEPLOT_BENCH_CHROME."); -} - -function which(command: string): string | null { - const proc = Bun.spawnSync({ cmd: ["which", command], stdout: "pipe", stderr: "ignore" }); - if (proc.exitCode !== 0) return null; - const path = proc.stdout.toString().trim(); - return path.length > 0 ? path : null; -} - -function drain(stream: ReadableStream | null, label: string): void { - if (!stream) return; - void (async () => { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { value, done } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }).trimEnd(); - if (text) process.stderr.write(`[${label}] ${text}\n`); - } - })(); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function fixed(value: number, digits: number): string { return Number.isFinite(value) ? value.toFixed(digits) : "0"; } @@ -860,65 +711,4 @@ function escapeMd(value: string | number): string { return String(value).replaceAll("|", "\\|"); } -class CdpClient { - private nextId = 1; - private readonly pending = new Map void; reject: (reason: unknown) => void }>(); - private readonly handlers = new Map void>>(); - - private constructor(private readonly socket: WebSocket) { - socket.addEventListener("message", (event) => this.handleMessage(event.data)); - socket.addEventListener("close", () => { - for (const { reject } of this.pending.values()) reject(new Error("CDP socket closed")); - this.pending.clear(); - }); - } - - static connect(url: string): Promise { - const socket = new WebSocket(url); - return new Promise((resolvePromise, reject) => { - socket.addEventListener("open", () => resolvePromise(new CdpClient(socket)), { once: true }); - socket.addEventListener("error", () => reject(new Error(`Could not connect to CDP websocket ${url}`)), { once: true }); - }); - } - - send(method: string, params?: Record): Promise { - const id = this.nextId++; - const payload = params === undefined ? { id, method } : { id, method, params }; - return new Promise((resolvePromise, reject) => { - this.pending.set(id, { resolve: resolvePromise, reject }); - this.socket.send(JSON.stringify(payload)); - }); - } - - on(method: string, handler: (params: unknown) => void): void { - const handlers = this.handlers.get(method) ?? []; - handlers.push(handler); - this.handlers.set(method, handlers); - } - - close(): void { - this.socket.close(); - } - - private handleMessage(data: unknown): void { - const text = typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer); - const message = JSON.parse(text) as CdpResponse; - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) { - pending.reject(new Error(`${message.error.message}${message.error.data ? `: ${message.error.data}` : ""}`)); - } else { - pending.resolve(message.result); - } - return; - } - - if (message.method) { - for (const handler of this.handlers.get(message.method) ?? []) handler(message.params); - } - } -} - void main(); diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index b7fb2e4..3a01a76 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { CdpClient, attachConsoleLogging, createTarget, evaluate, readNonNegativeInteger as readPositiveInteger, resolveChrome, sleep, spawnChrome, startVite, throwIfPageErrored, waitForHttp } from "./browser-harness.js"; interface Options { scenario: string; @@ -21,19 +21,6 @@ interface Options { keepBrowser: boolean; } -interface CdpResponse { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: { message: string; data?: string }; -} - -interface RemoteObjectResult { - result?: { value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { text?: string; exception?: { description?: string } }; -} - interface CpuProfile { nodes: CpuProfileNode[]; startTime: number; @@ -221,31 +208,11 @@ function parseArgs(args: readonly string[]): Options { return parsed; } -function readPositiveInteger(flag: string, raw: string): number { - const value = Number(raw); - if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) { - throw new Error(`${flag} expects a non-negative integer, got ${raw}`); - } - return value; -} - function printHelpAndExit(): never { process.stdout.write(`Usage: bun run bench [options]\n\nOptions:\n --scenario Benchmark scene scenario (default: mixed-1m-live)\n --measure-ms Override scenario measurement duration\n --warmup-ms Override scenario warmup duration\n --width Browser viewport width (default: 1280)\n --height Browser viewport height (default: 720)\n --port Vite server port (default: 41731)\n --debug-port Chrome DevTools port (default: 9223)\n --setup-timeout-ms Max time for data load + warmup (default: 120000)\n --top Number of bottom-up CPU frames to emit (default: 40)\n --out Also write JSON report to this path\n --url Use an already-running Vite server instead of starting one\n --chrome Chrome/Chromium/Brave executable path\n --headed Run browser visibly instead of headless\n --keep-browser Leave browser profile/process around for debugging\n`); process.exit(0); } -function startVite(port: number): Bun.Subprocess { - const proc = Bun.spawn({ - cmd: ["bunx", "vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, BLAZEPLOT_BENCH: "1" }, - }); - drain(proc.stdout, "vite"); - drain(proc.stderr, "vite"); - return proc; -} - function launchChrome(chromePath: string, userDataDir: string, opts: Options): Bun.Subprocess { const cmd = [ chromePath, @@ -263,36 +230,7 @@ function launchChrome(chromePath: string, userDataDir: string, opts: Options): B "about:blank", ]; if (opts.headless) cmd.splice(1, 0, "--headless=new"); - const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }); - drain(proc.stdout, "chrome"); - drain(proc.stderr, "chrome"); - return proc; -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - const startedAt = Date.now(); - let lastError: unknown = null; - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.status < 500) return; - lastError = new Error(`HTTP ${response.status}`); - } catch (error) { - lastError = error; - } - await sleep(250); - } - throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -async function createTarget(debugPort: number, url: string): Promise<{ webSocketDebuggerUrl: string }> { - const endpoint = `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`; - let response = await fetch(endpoint, { method: "PUT" }); - if (!response.ok) response = await fetch(endpoint); - if (!response.ok) throw new Error(`Could not create Chrome target: HTTP ${response.status}`); - const payload = await response.json() as { webSocketDebuggerUrl?: string }; - if (!payload.webSocketDebuggerUrl) throw new Error("Chrome target response did not include webSocketDebuggerUrl"); - return { webSocketDebuggerUrl: payload.webSocketDebuggerUrl }; + return spawnChrome(cmd); } async function waitForBenchmarkState(cdp: CdpClient, desiredState: string, timeoutMs: number): Promise { @@ -307,37 +245,6 @@ async function waitForBenchmarkState(cdp: CdpClient, desiredState: string, timeo throw new Error(`Timed out waiting for benchmark state '${desiredState}'. Last snapshot: ${JSON.stringify(snapshot)}`); } -async function evaluate(cdp: CdpClient, expression: string, awaitPromise: boolean): Promise { - const response = await cdp.send("Runtime.evaluate", { - expression, - awaitPromise, - returnByValue: true, - userGesture: false, - }) as RemoteObjectResult; - if (response.exceptionDetails) { - throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Runtime.evaluate failed"); - } - return response.result?.value; -} - -function attachConsoleLogging(cdp: CdpClient, pageErrors: string[]): void { - cdp.on("Runtime.consoleAPICalled", (params) => { - const event = params as { type?: string; args?: Array<{ value?: unknown; description?: string }> }; - const text = event.args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? ""; - if (text) process.stderr.write(`[page:${event.type ?? "log"}] ${text}\n`); - }); - cdp.on("Runtime.exceptionThrown", (params) => { - const text = JSON.stringify(params); - pageErrors.push(text); - process.stderr.write(`[page:exception] ${text}\n`); - }); -} - -function throwIfPageErrored(pageErrors: readonly string[]): void { - if (pageErrors.length === 0) return; - throw new Error(`Benchmark page threw ${pageErrors.length} exception(s). First exception: ${pageErrors[0]}`); -} - function assertRenderableBenchmarkResult(value: unknown): void { if (!value || typeof value !== "object") throw new Error("Benchmark did not return an object result"); const result = value as { finalStats?: { renderMode?: unknown; drawCalls?: unknown; pointsRendered?: unknown }; flameChartFrames?: unknown }; @@ -431,125 +338,8 @@ function simplifyUrl(url: string): string { } } -function resolveChrome(explicit: string | undefined): string { - const envPath = explicit ?? process.env.BLAZEPLOT_BENCH_CHROME ?? process.env.CHROME_PATH; - if (envPath) { - if (!existsSync(envPath)) throw new Error(`Chrome executable does not exist: ${envPath}`); - return envPath; - } - - const candidates = [ - "google-chrome-stable", - "google-chrome", - "chromium-browser", - "chromium", - "chrome", - "brave-browser", - "brave-browser-stable", - "brave", - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ]; - - for (const candidate of candidates) { - if (candidate.startsWith("/") && existsSync(candidate)) return candidate; - if (!candidate.startsWith("/")) { - const found = which(candidate); - if (found) return found; - } - } - - throw new Error("Could not find Chrome/Chromium/Brave. Pass --chrome or set BLAZEPLOT_BENCH_CHROME."); -} - -function which(command: string): string | null { - const proc = Bun.spawnSync({ cmd: ["which", command], stdout: "pipe", stderr: "ignore" }); - if (proc.exitCode !== 0) return null; - const path = proc.stdout.toString().trim(); - return path.length > 0 ? path : null; -} - -function drain(stream: ReadableStream | null, label: string): void { - if (!stream) return; - void (async () => { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { value, done } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }).trimEnd(); - if (text) process.stderr.write(`[${label}] ${text}\n`); - } - })(); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function round(value: number): number { return Math.round(value * 1000) / 1000; } -class CdpClient { - private nextId = 1; - private readonly pending = new Map void; reject: (reason: unknown) => void }>(); - private readonly handlers = new Map void>>(); - - private constructor(private readonly socket: WebSocket) { - socket.addEventListener("message", (event) => this.handleMessage(event.data)); - socket.addEventListener("close", () => { - for (const { reject } of this.pending.values()) reject(new Error("CDP socket closed")); - this.pending.clear(); - }); - } - - static connect(url: string): Promise { - const socket = new WebSocket(url); - return new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(new CdpClient(socket)), { once: true }); - socket.addEventListener("error", () => reject(new Error(`Could not connect to CDP websocket ${url}`)), { once: true }); - }); - } - - send(method: string, params?: Record): Promise { - const id = this.nextId++; - const payload = params === undefined ? { id, method } : { id, method, params }; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.socket.send(JSON.stringify(payload)); - }); - } - - on(method: string, handler: (params: unknown) => void): void { - const handlers = this.handlers.get(method) ?? []; - handlers.push(handler); - this.handlers.set(method, handlers); - } - - close(): void { - this.socket.close(); - } - - private handleMessage(data: unknown): void { - const text = typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer); - const message = JSON.parse(text) as CdpResponse; - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) { - pending.reject(new Error(`${message.error.message}${message.error.data ? `: ${message.error.data}` : ""}`)); - } else { - pending.resolve(message.result); - } - return; - } - - if (message.method) { - for (const handler of this.handlers.get(message.method) ?? []) handler(message.params); - } - } -} - void main(); diff --git a/scripts/browser-harness.ts b/scripts/browser-harness.ts new file mode 100644 index 0000000..c4eb16e --- /dev/null +++ b/scripts/browser-harness.ts @@ -0,0 +1,204 @@ +import { existsSync } from "node:fs"; + +interface CdpResponse { + id?: number; + method?: string; + params?: unknown; + result?: unknown; + error?: { message: string; data?: string }; +} + +interface RemoteObjectResult { + result?: { value?: unknown }; + exceptionDetails?: { text?: string; exception?: { description?: string } }; +} + +/** Minimal Chrome DevTools Protocol client shared by browser scripts. */ +export class CdpClient { + private nextId = 1; + private readonly pending = new Map void; reject: (reason: unknown) => void }>(); + private readonly handlers = new Map void>>(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener("message", (event) => this.handleMessage(event.data)); + socket.addEventListener("close", () => { + for (const { reject } of this.pending.values()) reject(new Error("CDP socket closed")); + this.pending.clear(); + }); + } + + static connect(url: string): Promise { + const socket = new WebSocket(url); + return new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(new CdpClient(socket)), { once: true }); + socket.addEventListener("error", () => reject(new Error(`Could not connect to CDP websocket ${url}`)), { once: true }); + }); + } + + send(method: string, params?: Record): Promise { + const id = this.nextId++; + const payload = params === undefined ? { id, method } : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify(payload)); + }); + } + + on(method: string, handler: (params: unknown) => void): void { + const handlers = this.handlers.get(method) ?? []; + handlers.push(handler); + this.handlers.set(method, handlers); + } + + close(): void { + this.socket.close(); + } + + private handleMessage(raw: string | BufferSource): void { + const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw); + const message = JSON.parse(text) as CdpResponse; + if (message.id !== undefined) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(`${message.error.message}${message.error.data ? `: ${message.error.data}` : ""}`)); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method) { + for (const handler of this.handlers.get(message.method) ?? []) handler(message.params); + } + } +} + +export function readPositiveInteger(flag: string, raw: string): number { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${flag} expects a positive integer, got ${raw}`); + return value; +} + +export function readNonNegativeInteger(flag: string, raw: string): number { + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) throw new Error(`${flag} expects a non-negative integer, got ${raw}`); + return value; +} + +export function startVite(port: number): Bun.Subprocess { + const proc = Bun.spawn({ + cmd: ["bunx", "vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, BLAZEPLOT_BENCH: "1" }, + }); + drain(proc.stdout, "vite"); + drain(proc.stderr, "vite"); + return proc; +} + +export async function waitForHttp(url: string, timeoutMs: number): Promise { + const startedAt = Date.now(); + let lastError: unknown = null; + while (Date.now() - startedAt < timeoutMs) { + try { + const response = await fetch(url); + if (response.status < 500) return; + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await sleep(250); + } + throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +export async function createTarget(debugPort: number, url: string): Promise<{ webSocketDebuggerUrl: string }> { + const endpoint = `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`; + let response = await fetch(endpoint, { method: "PUT" }); + if (!response.ok) response = await fetch(endpoint); + if (!response.ok) throw new Error(`Could not create Chrome target: HTTP ${response.status}`); + const payload = await response.json() as { webSocketDebuggerUrl?: string }; + if (!payload.webSocketDebuggerUrl) throw new Error("Chrome target response did not include webSocketDebuggerUrl"); + return { webSocketDebuggerUrl: payload.webSocketDebuggerUrl }; +} + +export async function evaluate(cdp: CdpClient, expression: string, awaitPromise: boolean): Promise { + const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise, returnByValue: true, userGesture: false }) as RemoteObjectResult; + if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Runtime.evaluate failed"); + return response.result?.value; +} + +export function attachConsoleLogging(cdp: CdpClient, pageErrors: string[], label?: string): void { + const prefix = label ? `page:${label}` : "page"; + cdp.on("Runtime.consoleAPICalled", (params) => { + const event = params as { type?: string; args?: Array<{ value?: unknown; description?: string }> }; + const text = event.args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? ""; + if (text) process.stderr.write(`[${prefix}:${event.type ?? "log"}] ${text}\n`); + }); + cdp.on("Runtime.exceptionThrown", (params) => { + const text = JSON.stringify(params); + pageErrors.push(text); + process.stderr.write(`[${prefix}:exception] ${text}\n`); + }); +} + +export function throwIfPageErrored(pageErrors: readonly string[]): void { + if (pageErrors.length) throw new Error(`Benchmark page threw ${pageErrors.length} exception(s). First exception: ${pageErrors[0]}`); +} + +export function resolveChrome(explicit: string | undefined): string { + const envPath = explicit ?? process.env.BLAZEPLOT_BENCH_CHROME ?? process.env.CHROME_PATH; + if (envPath) { + if (!existsSync(envPath)) throw new Error(`Chrome executable does not exist: ${envPath}`); + return envPath; + } + + for (const candidate of [ + "google-chrome-stable", + "google-chrome", + "chromium-browser", + "chromium", + "chrome", + "brave-browser", + "brave-browser-stable", + "brave", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]) { + if (candidate.startsWith("/") && existsSync(candidate)) return candidate; + if (!candidate.startsWith("/")) { + const proc = Bun.spawnSync({ cmd: ["which", candidate], stdout: "pipe", stderr: "ignore" }); + if (proc.exitCode === 0 && proc.stdout.toString().trim()) return proc.stdout.toString().trim(); + } + } + + throw new Error("Could not find Chrome/Chromium/Brave. Pass --chrome or set BLAZEPLOT_BENCH_CHROME."); +} + +export function spawnChrome(cmd: string[]): Bun.Subprocess { + const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }); + drain(proc.stdout, "chrome"); + drain(proc.stderr, "chrome"); + return proc; +} + +function drain(stream: ReadableStream | null, label: string): void { + if (!stream) return; + void (async () => { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const text = decoder.decode(value, { stream: true }).trimEnd(); + if (text) process.stderr.write(`[${label}] ${text}\n`); + } + })(); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/bundle-size-check.ts b/scripts/bundle-size-check.ts index 0fa05cc..32341ae 100644 --- a/scripts/bundle-size-check.ts +++ b/scripts/bundle-size-check.ts @@ -33,7 +33,6 @@ const budgets: Budget[] = [ { label: "core subpath entry", path: "dist/core.js", maxBytes: 4_000 }, { label: "interaction subpath entry", path: "dist/interaction.js", maxBytes: 2_000 }, { label: "render subpath entry", path: "dist/render.js", maxBytes: 2_000 }, - { label: "react entry", path: "dist/react.js", maxBytes: 8_000 }, { label: "linked entry", path: "dist/linked.js", maxBytes: 16_000 }, { label: "linked core entry", path: "dist/linked-core.js", maxBytes: 8_000 }, { label: "data entry", path: "dist/data.js", maxBytes: 12_000 }, diff --git a/scripts/generate-readme-docs.js b/scripts/generate-readme-docs.js index 02a20e9..8e126b6 100644 --- a/scripts/generate-readme-docs.js +++ b/scripts/generate-readme-docs.js @@ -35,7 +35,6 @@ const exportDescriptions = new Map([ ["./core", "Data structures, datasets, LOD helpers, and series storage without chart UI."], ["./interaction", "Camera, axis, pan/zoom intent, and viewport policy helpers without chart UI."], ["./render", "Renderer and WebGL backend primitives without chart UI."], - ["./react", "React wrapper component."], ["./linked", "Linked chart layout helpers with tooltip/crosshair sync factories."], ["./linked-core", "Lean linked chart layout helpers without tooltip/crosshair sync imports."], ["./data", "Pure chart data export and transform helpers."], @@ -257,15 +256,15 @@ function renderGeneratedDocs(options = {}) { "", "| Task | Start here |", "|---|---|", - "| Create and render a chart | `createChart(...)` for common static charts; `Chart`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` for manual lifecycle control |", - "| Static X/Y arrays or object rows | `createChart(...)`, `StaticDataset`, `StaticDataset.fromObjects(...)` |", + "| Create and render a chart | `new Chart(...)`, `chart.addLine(...)`, `chart.fitToData()`, and `chart.start()` |", + "| Static X/Y arrays or object rows | `StaticDataset`, `StaticDataset.fromObjects(...)` |", "| Live irregular data | `chart.addLine({ capacity })`, `RingBuffer`, [Live data](" + guideLink(guideBasePath, "live-data.md") + ") |", "| Live fixed-rate data | `chart.addLine({ capacity, xStep })`, `UniformRingBuffer`, [Live data](" + guideLink(guideBasePath, "live-data.md") + ") |", "| OHLC/candlesticks | `StaticOhlcDataset`, `OhlcRingBuffer`, `chart.addOhlc(...)`, `chart.addCandlestick(...)` |", "| Custom high-performance data | `Dataset`, `AcceleratedDataset`, range/copy dataset interfaces |", "| Pan/zoom and user interaction | `blazeplot/plugins/interactions`, `Camera2D`, viewport APIs |", "| Tooltips, legends, annotations, selection, flame graphs | `blazeplot/plugins/*` subpaths |", - "| React | `blazeplot/react` and `BlazeChart` |", + "| React | Create and dispose `Chart` in an effect |", "| Linked dashboards | `blazeplot/linked` or `blazeplot/linked-core` |", "| Image/data export | `chart.screenshot()`, `blazeplot/export`, `blazeplot/data` |", "", diff --git a/scripts/interaction-test.ts b/scripts/interaction-test.ts index f5ab2c8..39ca030 100644 --- a/scripts/interaction-test.ts +++ b/scripts/interaction-test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun -import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { CdpClient, createTarget, evaluate, readPositiveInteger, resolveChrome, sleep, spawnChrome, startVite, waitForHttp } from "./browser-harness.js"; interface Options { width: number; @@ -15,19 +15,6 @@ interface Options { keepBrowser: boolean; } -interface CdpResponse { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: { message: string; data?: string }; -} - -interface RemoteObjectResult { - result?: { value?: unknown; description?: string }; - exceptionDetails?: { text?: string; exception?: { description?: string } }; -} - interface RectSnapshot { left: number; top: number; @@ -407,56 +394,9 @@ function printHelpAndExit(): never { process.exit(0); } -function readPositiveInteger(flag: string, raw: string): number { - const value = Number(raw); - if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) throw new Error(`${flag} expects a positive integer, got ${raw}`); - return value; -} - -function startVite(port: number): Bun.Subprocess { - const proc = Bun.spawn({ - cmd: ["bunx", "vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, BLAZEPLOT_BENCH: "1" }, - }); - drain(proc.stdout, "vite"); - drain(proc.stderr, "vite"); - return proc; -} - function launchChrome(chromePath: string, userDataDir: string, opts: Options): Bun.Subprocess { const cmd = [chromePath, "--headless=new", `--remote-debugging-port=${opts.debugPort}`, `--user-data-dir=${userDataDir}`, `--window-size=${opts.width},${opts.height}`, "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-dev-shm-usage", "--no-sandbox", "--ignore-gpu-blocklist", "--enable-unsafe-swiftshader", "--use-angle=swiftshader", "about:blank"]; - const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }); - drain(proc.stdout, "chrome"); - drain(proc.stderr, "chrome"); - return proc; -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - const startedAt = Date.now(); - let lastError: unknown = null; - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.status < 500) return; - lastError = new Error(`HTTP ${response.status}`); - } catch (error) { - lastError = error; - } - await sleep(250); - } - throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -async function createTarget(debugPort: number, url: string): Promise<{ webSocketDebuggerUrl: string }> { - const endpoint = `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`; - let response = await fetch(endpoint, { method: "PUT" }); - if (!response.ok) response = await fetch(endpoint); - if (!response.ok) throw new Error(`Could not create Chrome target: HTTP ${response.status}`); - const payload = await response.json() as { webSocketDebuggerUrl?: string }; - if (!payload.webSocketDebuggerUrl) throw new Error("Chrome target response did not include webSocketDebuggerUrl"); - return { webSocketDebuggerUrl: payload.webSocketDebuggerUrl }; + return spawnChrome(cmd); } async function waitForReady(cdp: CdpClient, timeoutMs: number): Promise { @@ -480,12 +420,6 @@ async function getSnapshot(cdp: CdpClient): Promise return await evaluate(cdp, "window.__blazeplotInteractionTest?.snapshot?.() ?? null", true) as InteractionSnapshot | null; } -async function evaluate(cdp: CdpClient, expression: string, awaitPromise: boolean): Promise { - const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise, returnByValue: true, userGesture: false }) as RemoteObjectResult; - if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Runtime.evaluate failed"); - return response.result?.value; -} - async function mouseMove(cdp: CdpClient, x: number, y: number, modifiers = 0): Promise { await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, modifiers, pointerType: "mouse" }); } @@ -585,102 +519,3 @@ function close(a: number, b: number, tolerance: number): boolean { function assert(condition: boolean, label: string): void { if (!condition) throw new Error(`Interaction assertion failed: ${label}`); } - -function resolveChrome(explicit: string | undefined): string { - const envPath = explicit ?? process.env.BLAZEPLOT_BENCH_CHROME ?? process.env.CHROME_PATH; - if (envPath) { - if (!existsSync(envPath)) throw new Error(`Chrome executable does not exist: ${envPath}`); - return envPath; - } - const candidates = ["google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome", "brave-browser", "brave-browser-stable", "brave", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"]; - for (const candidate of candidates) { - if (candidate.startsWith("/") && existsSync(candidate)) return candidate; - if (!candidate.startsWith("/")) { - const found = which(candidate); - if (found) return found; - } - } - throw new Error("Could not find Chrome/Chromium/Brave. Pass --chrome or set BLAZEPLOT_BENCH_CHROME."); -} - -function which(command: string): string | null { - const proc = Bun.spawnSync({ cmd: ["which", command], stdout: "pipe", stderr: "ignore" }); - if (proc.exitCode !== 0) return null; - const path = proc.stdout.toString().trim(); - return path.length > 0 ? path : null; -} - -function drain(stream: ReadableStream | null, label: string): void { - if (!stream) return; - void (async () => { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { value, done } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }).trimEnd(); - if (text) process.stderr.write(`[${label}] ${text}\n`); - } - })(); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -class CdpClient { - private nextId = 1; - private readonly pending = new Map void; reject: (reason: unknown) => void }>(); - private readonly handlers = new Map void>>(); - - private constructor(private readonly socket: WebSocket) { - socket.addEventListener("message", (event) => this.handleMessage(event.data)); - socket.addEventListener("close", () => { - for (const { reject } of this.pending.values()) reject(new Error("CDP socket closed")); - this.pending.clear(); - }); - } - - static connect(url: string): Promise { - const socket = new WebSocket(url); - return new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(new CdpClient(socket)), { once: true }); - socket.addEventListener("error", () => reject(new Error(`Could not connect to CDP websocket ${url}`)), { once: true }); - }); - } - - send(method: string, params?: Record): Promise { - const id = this.nextId++; - const payload = params === undefined ? { id, method } : { id, method, params }; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.socket.send(JSON.stringify(payload)); - }); - } - - on(method: string, handler: (params: unknown) => void): void { - const handlers = this.handlers.get(method) ?? []; - handlers.push(handler); - this.handlers.set(method, handlers); - } - - close(): void { - this.socket.close(); - } - - private handleMessage(raw: string | BufferSource): void { - const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw); - const message = JSON.parse(text) as CdpResponse; - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) pending.reject(new Error(message.error.message)); - else pending.resolve(message.result); - return; - } - if (message.method) { - for (const handler of this.handlers.get(message.method) ?? []) handler(message.params); - } - } -} diff --git a/scripts/package-export-smoke.ts b/scripts/package-export-smoke.ts index 79649fc..f2d0d79 100644 --- a/scripts/package-export-smoke.ts +++ b/scripts/package-export-smoke.ts @@ -7,11 +7,10 @@ type PackageJson = { }; const expectedExports = { - "blazeplot": ["Chart", "createChart", "RingBuffer", "StaticDataset", "ServerSampledDataset", "WebGL2Backend", "ReglBackend"], + "blazeplot": ["Chart", "RingBuffer", "StaticDataset", "ServerSampledDataset", "WebGL2Backend", "ReglBackend"], "blazeplot/core": ["RingBuffer", "UniformRingBuffer", "StaticDataset", "ServerSampledDataset", "SeriesStore", "MinMaxPyramid"], "blazeplot/interaction": ["Camera2D", "AxisController"], "blazeplot/render": ["Renderer", "WebGL2Backend", "ReglBackend", "WebGL2Resources", "ShaderPrograms", "isWebGL2Available", "WebGL2UnavailableError"], - "blazeplot/react": ["BlazeChart"], "blazeplot/linked": ["createLinkedCharts", "linkedChartsPlugin"], "blazeplot/linked-core": ["createLinkedCharts", "linkedChartsPlugin"], "blazeplot/data": ["exportVisibleChartData", "exportSelectedChartData", "chartDataToCSV", "binSamples", "rollingMean"], diff --git a/scripts/typecheck-doc-snippets.ts b/scripts/typecheck-doc-snippets.ts index cb1dcd7..d71862d 100644 --- a/scripts/typecheck-doc-snippets.ts +++ b/scripts/typecheck-doc-snippets.ts @@ -127,7 +127,6 @@ function writeSnippetProject(temp: string, snippets: readonly Snippet[]): void { "blazeplot/core": [join(root, "src/core/index.ts")], "blazeplot/interaction": [join(root, "src/interaction/index.ts")], "blazeplot/render": [join(root, "src/render/index.ts")], - "blazeplot/react": [join(root, "src/react.tsx")], "blazeplot/linked": [join(root, "src/linked.ts")], "blazeplot/linked-core": [join(root, "src/linked-core.ts")], "blazeplot/data": [join(root, "src/data.ts")], diff --git a/scripts/visual-test.ts b/scripts/visual-test.ts index 93d576d..5a35e4b 100644 --- a/scripts/visual-test.ts +++ b/scripts/visual-test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun -import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { CdpClient, attachConsoleLogging, createTarget, evaluate, readPositiveInteger, resolveChrome, sleep, spawnChrome, startVite, waitForHttp } from "./browser-harness.js"; interface Options { cases: string[]; @@ -17,19 +17,6 @@ interface Options { keepBrowser: boolean; } -interface CdpResponse { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: { message: string; data?: string }; -} - -interface RemoteObjectResult { - result?: { value?: unknown; description?: string }; - exceptionDetails?: { text?: string; exception?: { description?: string } }; -} - interface VisualSnapshot { state?: string; caseName?: string; @@ -190,24 +177,6 @@ function printHelpAndExit(): never { process.exit(0); } -function readPositiveInteger(flag: string, raw: string): number { - const value = Number(raw); - if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) throw new Error(`${flag} expects a positive integer, got ${raw}`); - return value; -} - -function startVite(port: number): Bun.Subprocess { - const proc = Bun.spawn({ - cmd: ["bunx", "vite", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, BLAZEPLOT_BENCH: "1" }, - }); - drain(proc.stdout, "vite"); - drain(proc.stderr, "vite"); - return proc; -} - function launchChrome(chromePath: string, userDataDir: string, opts: Options): Bun.Subprocess { const cmd = [ chromePath, @@ -225,36 +194,7 @@ function launchChrome(chromePath: string, userDataDir: string, opts: Options): B "--use-angle=swiftshader", "about:blank", ]; - const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" }); - drain(proc.stdout, "chrome"); - drain(proc.stderr, "chrome"); - return proc; -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - const startedAt = Date.now(); - let lastError: unknown = null; - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.status < 500) return; - lastError = new Error(`HTTP ${response.status}`); - } catch (error) { - lastError = error; - } - await sleep(250); - } - throw new Error(`Timed out waiting for ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -async function createTarget(debugPort: number, url: string): Promise<{ webSocketDebuggerUrl: string }> { - const endpoint = `http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`; - let response = await fetch(endpoint, { method: "PUT" }); - if (!response.ok) response = await fetch(endpoint); - if (!response.ok) throw new Error(`Could not create Chrome target: HTTP ${response.status}`); - const payload = await response.json() as { webSocketDebuggerUrl?: string }; - if (!payload.webSocketDebuggerUrl) throw new Error("Chrome target response did not include webSocketDebuggerUrl"); - return { webSocketDebuggerUrl: payload.webSocketDebuggerUrl }; + return spawnChrome(cmd); } async function waitForVisualReady(cdp: CdpClient, timeoutMs: number): Promise { @@ -277,137 +217,8 @@ function assertVisualSnapshot(snapshot: VisualSnapshot, caseName: string): void if (snapshot.stats.renderMode === "none") throw new Error(`No render mode for ${caseName}`); } -async function evaluate(cdp: CdpClient, expression: string, awaitPromise: boolean): Promise { - const response = await cdp.send("Runtime.evaluate", { expression, awaitPromise, returnByValue: true, userGesture: false }) as RemoteObjectResult; - if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Runtime.evaluate failed"); - return response.result?.value; -} - async function saveScreenshot(cdp: CdpClient, path: string): Promise { const response = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }) as { data?: string }; if (!response.data) throw new Error("Page.captureScreenshot returned no data"); await writeFile(path, Buffer.from(response.data, "base64")); } - -function attachConsoleLogging(cdp: CdpClient, pageErrors: string[], label: string): void { - cdp.on("Runtime.consoleAPICalled", (params) => { - const event = params as { type?: string; args?: Array<{ value?: unknown; description?: string }> }; - const text = event.args?.map((arg) => String(arg.value ?? arg.description ?? "")).join(" ") ?? ""; - if (text) process.stderr.write(`[page:${label}:${event.type ?? "log"}] ${text}\n`); - }); - cdp.on("Runtime.exceptionThrown", (params) => { - const text = JSON.stringify(params); - pageErrors.push(text); - process.stderr.write(`[page:${label}:exception] ${text}\n`); - }); -} - -function resolveChrome(explicit: string | undefined): string { - const envPath = explicit ?? process.env.BLAZEPLOT_BENCH_CHROME ?? process.env.CHROME_PATH; - if (envPath) { - if (!existsSync(envPath)) throw new Error(`Chrome executable does not exist: ${envPath}`); - return envPath; - } - const candidates = [ - "google-chrome-stable", - "google-chrome", - "chromium-browser", - "chromium", - "chrome", - "brave-browser", - "brave-browser-stable", - "brave", - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ]; - for (const candidate of candidates) { - if (candidate.startsWith("/") && existsSync(candidate)) return candidate; - if (!candidate.startsWith("/")) { - const found = which(candidate); - if (found) return found; - } - } - throw new Error("Could not find Chrome/Chromium/Brave. Pass --chrome or set BLAZEPLOT_BENCH_CHROME."); -} - -function which(command: string): string | null { - const proc = Bun.spawnSync({ cmd: ["which", command], stdout: "pipe", stderr: "ignore" }); - if (proc.exitCode !== 0) return null; - const path = proc.stdout.toString().trim(); - return path.length > 0 ? path : null; -} - -function drain(stream: ReadableStream | null, label: string): void { - if (!stream) return; - void (async () => { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { value, done } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }).trimEnd(); - if (text) process.stderr.write(`[${label}] ${text}\n`); - } - })(); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -class CdpClient { - private nextId = 1; - private readonly pending = new Map void; reject: (reason: unknown) => void }>(); - private readonly handlers = new Map void>>(); - - private constructor(private readonly socket: WebSocket) { - socket.addEventListener("message", (event) => this.handleMessage(event.data)); - socket.addEventListener("close", () => { - for (const { reject } of this.pending.values()) reject(new Error("CDP socket closed")); - this.pending.clear(); - }); - } - - static connect(url: string): Promise { - const socket = new WebSocket(url); - return new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(new CdpClient(socket)), { once: true }); - socket.addEventListener("error", () => reject(new Error(`Could not connect to CDP websocket ${url}`)), { once: true }); - }); - } - - send(method: string, params?: Record): Promise { - const id = this.nextId++; - const payload = params === undefined ? { id, method } : { id, method, params }; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.socket.send(JSON.stringify(payload)); - }); - } - - on(method: string, handler: (params: unknown) => void): void { - const handlers = this.handlers.get(method) ?? []; - handlers.push(handler); - this.handlers.set(method, handlers); - } - - close(): void { - this.socket.close(); - } - - private handleMessage(raw: string | BufferSource): void { - const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw); - const message = JSON.parse(text) as CdpResponse; - if (message.id !== undefined) { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - if (message.error) pending.reject(new Error(message.error.message)); - else pending.resolve(message.result); - return; - } - if (message.method) { - for (const handler of this.handlers.get(message.method) ?? []) handler(message.params); - } - } -} diff --git a/src/core/RingBuffer.ts b/src/core/RingBuffer.ts index bb0356c..e72f107 100644 --- a/src/core/RingBuffer.ts +++ b/src/core/RingBuffer.ts @@ -13,6 +13,7 @@ export interface RingBufferOptions { export class RingBuffer { /** Maximum number of retained samples. */ readonly capacity: number; + readonly rangeMinMaxExcludesGaps = true; private _length: number = 0; private _head: number = 0; diff --git a/src/core/SeriesStore.ts b/src/core/SeriesStore.ts index a58d7f4..98b2eb3 100644 --- a/src/core/SeriesStore.ts +++ b/src/core/SeriesStore.ts @@ -80,6 +80,7 @@ function isOhlcUpdateData(data: SeriesUpdateData): data is SeriesOhlcUpdateData const NEAREST_POINT_LEAF_SIZE = 64; const SCATTER_INTERVAL_LEAF_SIZE = 64; const SCATTER_BUCKET_RANGE_PRUNE_SIZE = 1024; +const identity = (value: number): number => value; /** Single numeric sample value or a batch of values. */ export type SeriesScalarOrArray = number | ArrayLike; @@ -567,23 +568,39 @@ export class SeriesStore { let yMin = Infinity; let yMax = -Infinity; const ohlc = isOhlcDataset(this.dataset) ? this.dataset : null; - const rangeMinMax = !ohlc && hasRangeMinMaxY(this.dataset) ? this.dataset : null; - for (let i = start; i < end; i++) { - const x = this.dataset.getX(i); - const y = this.dataset.getY(i); - if (this.isGap(i, y)) continue; - const range = rangeMinMax?.rangeMinMaxY(i, i + 1); - const low = ohlc ? ohlc.getLow(i) : range?.minY ?? y; - const high = ohlc ? ohlc.getHigh(i) : range?.maxY ?? low; - if (!Number.isFinite(x) || !Number.isFinite(low) || !Number.isFinite(high)) continue; - const xRange = this.xRangeAt(i); - const sampleXMin = xRange && Number.isFinite(xRange.xStart) ? xRange.xStart : x; - const sampleXMax = xRange && Number.isFinite(xRange.xEnd) ? xRange.xEnd : x; - xMin = Math.min(xMin, sampleXMin, sampleXMax); - xMax = Math.max(xMax, sampleXMin, sampleXMax); - yMin = Math.min(yMin, low, high); - yMax = Math.max(yMax, low, high); + + if (rangeMinMax && !hasXRange(this.dataset) && (!hasExplicitGaps(this.dataset) || rangeMinMax.rangeMinMaxExcludesGaps === true)) { + let first = start; + let last = end - 1; + while (first < end && this.isGap(first)) first++; + while (last >= first && this.isGap(last)) last--; + const range = first <= last ? rangeMinMax.rangeMinMaxY(first, last + 1) : null; + if (range) { + xMin = this.dataset.getX(first); + xMax = this.dataset.getX(last); + yMin = range.minY; + yMax = range.maxY; + } + } + + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { + for (let i = start; i < end; i++) { + const x = this.dataset.getX(i); + const y = this.dataset.getY(i); + if (this.isGap(i, y)) continue; + const range = rangeMinMax?.rangeMinMaxY(i, i + 1); + const low = ohlc ? ohlc.getLow(i) : range?.minY ?? y; + const high = ohlc ? ohlc.getHigh(i) : range?.maxY ?? low; + if (!Number.isFinite(x) || !Number.isFinite(low) || !Number.isFinite(high)) continue; + const xRange = this.xRangeAt(i); + const sampleXMin = xRange && Number.isFinite(xRange.xStart) ? xRange.xStart : x; + const sampleXMax = xRange && Number.isFinite(xRange.xEnd) ? xRange.xEnd : x; + xMin = Math.min(xMin, sampleXMin, sampleXMax); + xMax = Math.max(xMax, sampleXMin, sampleXMax); + yMin = Math.min(yMin, low, high); + yMax = Math.max(yMax, low, high); + } } if (!Number.isFinite(xMin) || !Number.isFinite(xMax) || !Number.isFinite(yMin) || !Number.isFinite(yMax)) return null; @@ -630,10 +647,14 @@ export class SeriesStore { plotWidth: number, plotHeight: number, maxDistancePx: number = Infinity, + xTransform: (value: number) => number = identity, + yTransform: (value: number) => number = identity, ): SeriesSample | null { const range = this.visibleIndexRange(viewport); - const xRange = viewport.xMax - viewport.xMin; - const yRange = viewport.yMax - viewport.yMin; + const transformedX = xTransform(x); + const transformedY = yTransform(y); + const xRange = xTransform(viewport.xMax) - xTransform(viewport.xMin); + const yRange = yTransform(viewport.yMax) - yTransform(viewport.yMin); if (range.start >= range.end || plotWidth <= 0 || plotHeight <= 0 || xRange <= 0 || yRange <= 0) return null; const xScale = plotWidth / xRange; @@ -648,8 +669,8 @@ export class SeriesStore { const visitSample = (index: number): void => { const sampleY = this.dataset.getY(index); if (this.isGap(index, sampleY)) return; - const dx = (this.dataset.getX(index) - x) * xScale; - const dy = (sampleY - y) * yScale; + const dx = (xTransform(this.dataset.getX(index)) - transformedX) * xScale; + const dy = (yTransform(sampleY) - transformedY) * yScale; const d2 = dx * dx + dy * dy; if (d2 < bestDistanceSq || (bestIndex < 0 && d2 <= bestDistanceSq)) { bestDistanceSq = d2; @@ -664,7 +685,7 @@ export class SeriesStore { if (nearest + 1 < range.end) visitSample(nearest + 1); if (this.hasPointIntervalBounds() && range.end - range.start > NEAREST_POINT_LEAF_SIZE) { - const rootBound = this.pointIntervalDistanceSq(range.start, range.end, x, y, xScale, yScale); + const rootBound = this.pointIntervalDistanceSq(range.start, range.end, transformedX, transformedY, xScale, yScale, xTransform, yTransform); const stack: PointSearchInterval[] = rootBound <= bestDistanceSq ? [{ start: range.start, end: range.end, lowerBoundSq: rootBound }] : []; @@ -680,8 +701,8 @@ export class SeriesStore { } const mid = interval.start + (length >> 1); - const leftBound = this.pointIntervalDistanceSq(interval.start, mid, x, y, xScale, yScale); - const rightBound = this.pointIntervalDistanceSq(mid, interval.end, x, y, xScale, yScale); + const leftBound = this.pointIntervalDistanceSq(interval.start, mid, transformedX, transformedY, xScale, yScale, xTransform, yTransform); + const rightBound = this.pointIntervalDistanceSq(mid, interval.end, transformedX, transformedY, xScale, yScale, xTransform, yTransform); const left: PointSearchInterval = { start: interval.start, end: mid, lowerBoundSq: leftBound }; const right: PointSearchInterval = { start: mid, end: interval.end, lowerBoundSq: rightBound }; @@ -697,8 +718,8 @@ export class SeriesStore { let left = Math.min(lower - 1, range.end - 1); let right = Math.max(lower, range.start); while (left >= range.start || right < range.end) { - const leftDxSq = left >= range.start ? this.pointXDistanceSq(left, x, xScale) : Infinity; - const rightDxSq = right < range.end ? this.pointXDistanceSq(right, x, xScale) : Infinity; + const leftDxSq = left >= range.start ? this.pointXDistanceSq(left, transformedX, xScale, xTransform) : Infinity; + const rightDxSq = right < range.end ? this.pointXDistanceSq(right, transformedX, xScale, xTransform) : Infinity; if (leftDxSq > bestDistanceSq && rightDxSq > bestDistanceSq) break; if (leftDxSq <= rightDxSq) { @@ -873,8 +894,8 @@ export class SeriesStore { return !Number.isFinite(value) || (hasExplicitGaps(this.dataset) && this.dataset.isGap(index)); } - private pointXDistanceSq(index: number, x: number, xScale: number): number { - const dx = (this.dataset.getX(index) - x) * xScale; + private pointXDistanceSq(index: number, x: number, xScale: number, xTransform: (value: number) => number): number { + const dx = (xTransform(this.dataset.getX(index)) - x) * xScale; return dx * dx; } @@ -885,17 +906,20 @@ export class SeriesStore { y: number, xScale: number, yScale: number, + xTransform: (value: number) => number, + yTransform: (value: number) => number, ): number { if (end <= start) return Infinity; - const x0 = this.dataset.getX(start); - const x1 = this.dataset.getX(end - 1); + const x0 = xTransform(this.dataset.getX(start)); + const x1 = xTransform(this.dataset.getX(end - 1)); const dx = x < x0 ? (x0 - x) * xScale : x > x1 ? (x - x1) * xScale : 0; const range = this.pointIntervalMinMaxY(start, end); if (!range) return Infinity; - - const dy = y < range.minY ? (range.minY - y) * yScale : y > range.maxY ? (y - range.maxY) * yScale : 0; + const minY = yTransform(range.minY); + const maxY = yTransform(range.maxY); + const dy = y < minY ? (minY - y) * yScale : y > maxY ? (y - maxY) * yScale : 0; return dx * dx + dy * dy; } diff --git a/src/core/ServerSampledDataset.ts b/src/core/ServerSampledDataset.ts index e3f285d..b6244fb 100644 --- a/src/core/ServerSampledDataset.ts +++ b/src/core/ServerSampledDataset.ts @@ -39,6 +39,7 @@ export type ServerSampledDatasetKind = "points" | "minmax"; * instead of applying another client-side sampler. */ export class ServerSampledDataset implements Dataset, RangeMinMaxDataset, MinMaxSegmentCopyDataset { + readonly rangeMinMaxExcludesGaps = true; private kind: ServerSampledDatasetKind = "points"; private x = new Float64Array(0); private y = new Float32Array(0); diff --git a/src/core/StaticDataset.ts b/src/core/StaticDataset.ts index f9147ad..b9f0ad3 100644 --- a/src/core/StaticDataset.ts +++ b/src/core/StaticDataset.ts @@ -22,6 +22,8 @@ function readNumericField(row: Row, index: number, field: StaticDatasetFiel /** Immutable sorted XY dataset backed by typed arrays. */ export class StaticDataset implements Dataset { + readonly rangeMinMaxExcludesGaps = true; + /** * Copy object rows into a static X/Y dataset. * diff --git a/src/core/UniformRingBuffer.ts b/src/core/UniformRingBuffer.ts index f07877e..a5e4f8e 100644 --- a/src/core/UniformRingBuffer.ts +++ b/src/core/UniformRingBuffer.ts @@ -28,6 +28,7 @@ type MinMaxLayout = MinMaxSegmentLayout; export class UniformRingBuffer implements AppendableDataset, AcceleratedDataset { /** Maximum number of retained samples. */ readonly capacity: number; + readonly rangeMinMaxExcludesGaps = true; /** Distance between consecutive derived X values. */ readonly xStep: number; private readonly blockSize: number; diff --git a/src/core/types.ts b/src/core/types.ts index b25f8c2..9625a08 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -76,6 +76,8 @@ export interface XRangeDataset extends Dataset { /** Dataset that can answer min/max Y queries for index ranges. */ export interface RangeMinMaxDataset extends Dataset { + /** Set when range queries exclude samples marked by `isGap()`. */ + readonly rangeMinMaxExcludesGaps?: boolean; rangeMinMaxY(start: number, end: number): { minY: number; maxY: number } | null; } diff --git a/src/createChart.ts b/src/createChart.ts deleted file mode 100644 index 6e631c6..0000000 --- a/src/createChart.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { StaticDataset } from "./core/StaticDataset.js"; -import type { StaticDatasetField } from "./core/StaticDataset.js"; -import type { HistogramBinThresholds, HistogramNormalization } from "./core/Histogram.js"; -import type { BufferOverflowStrategy, Dataset, LODStrategy, SeriesMode, SeriesStyle, SeriesYAxis } from "./core/types.js"; -import { Chart } from "./ui/Chart.js"; -import type { ChartFitToDataOptions, ChartOptions } from "./ui/Chart.js"; - -/** Series modes supported by the declarative `createChart` helper. */ -export type CreateChartSeriesType = Extract | "histogram"; - -interface CreateChartSeriesBase { - readonly type?: CreateChartSeriesType; - readonly mode?: CreateChartSeriesType; - readonly id?: string; - readonly name?: string; - readonly yAxis?: SeriesYAxis; - readonly downsample?: LODStrategy; - readonly style?: Partial; -} - -/** Declarative series backed by an existing BlazePlot dataset. */ -export interface CreateChartDatasetSeries extends CreateChartSeriesBase { - readonly dataset: Dataset; -} - -/** Declarative series backed by parallel X and Y arrays. */ -export interface CreateChartArraySeries extends CreateChartSeriesBase { - readonly x: ArrayLike; - readonly y: ArrayLike; -} - -/** Declarative series backed by object rows and field selectors. */ -export interface CreateChartObjectSeries extends CreateChartSeriesBase { - readonly data: readonly Row[]; - readonly x: StaticDatasetField; - readonly y: StaticDatasetField; - readonly sort?: boolean; -} - -/** Declarative empty streaming series with an internally-created ring buffer. */ -export interface CreateChartStreamingSeries extends CreateChartSeriesBase { - readonly capacity: number; - readonly overflow?: BufferOverflowStrategy; -} - -interface CreateChartHistogramSeriesOptions extends CreateChartSeriesBase { - readonly values: ArrayLike; - readonly binSize?: number; - readonly binCount?: number; - readonly thresholds?: HistogramBinThresholds; - readonly min?: number; - readonly max?: number; - readonly align?: number; - readonly normalize?: HistogramNormalization; - readonly includeEmpty?: boolean; - readonly includeMax?: boolean; -} - -/** Declarative histogram series backed by raw one-dimensional values. */ -export type CreateChartHistogramSeries = CreateChartHistogramSeriesOptions & ( - | { readonly type: "histogram"; readonly mode?: "histogram" } - | { readonly mode: "histogram"; readonly type?: "histogram" } -); - -/** Any series shape accepted by `createChart`. */ -export type CreateChartSeries> = - | CreateChartDatasetSeries - | CreateChartArraySeries - | CreateChartObjectSeries - | CreateChartStreamingSeries - | CreateChartHistogramSeries; - -/** - * High-level chart configuration for common first-render cases. - * - * Use `createChart(...)` when you have static arrays, object rows, or a simple - * streaming buffer and want BlazePlot to create the chart, add series, fit the - * initial viewport, and start rendering in one call. - */ -export interface CreateChartOptions> extends ChartOptions { - readonly series?: readonly CreateChartSeries[]; - /** - * Fit the viewport after adding initial series. Enabled by default because - * this helper is optimized for first-render ergonomics. - */ - readonly autoFit?: boolean | ChartFitToDataOptions; - /** - * Start the render loop before returning. Enabled by default. - */ - readonly start?: boolean; -} - -/** - * Create a chart from a compact declarative config. - * - * This helper is intentionally thin: it returns the underlying `Chart` instance, - * so advanced code can still use the full imperative API after setup. - */ -export function createChart>( - target: HTMLElement, - options: CreateChartOptions = {}, -): Chart { - const { series = [], autoFit = true, start = true, ...chartOptions } = options; - const chart = new Chart(target, chartOptions); - - let hasHistogram = false; - for (const item of series) { - const mode = resolveSeriesMode(item); - if (mode === "histogram") { - if (!("values" in item)) { - throw new TypeError("createChart histogram series require a values array."); - } - hasHistogram = true; - chart.addHistogram({ - values: item.values, - binSize: item.binSize, - binCount: item.binCount, - thresholds: item.thresholds, - min: item.min, - max: item.max, - align: item.align, - normalize: item.normalize, - includeEmpty: item.includeEmpty, - includeMax: item.includeMax, - downsample: item.downsample, - id: item.id, - name: item.name, - yAxis: item.yAxis, - }, item.style); - continue; - } - chart.addSeries({ - mode, - dataset: resolveSeriesDataset(item), - capacity: "capacity" in item ? item.capacity : undefined, - overflow: "overflow" in item ? item.overflow : undefined, - downsample: item.downsample, - id: item.id, - name: item.name, - yAxis: item.yAxis, - }, item.style); - } - - if (autoFit) { - chart.fitToData(typeof autoFit === "object" ? autoFit : hasHistogram ? { includeZero: true } : undefined); - } - if (start) { - chart.start(); - } - - return chart; -} - -function resolveSeriesMode(series: CreateChartSeries): CreateChartSeriesType { - const mode = series.mode ?? series.type ?? "line"; - if (series.mode && series.type && series.mode !== series.type) { - throw new TypeError(`createChart series mode mismatch: received mode "${series.mode}" and type "${series.type}".`); - } - return mode; -} - -function resolveSeriesDataset(series: CreateChartSeries): Dataset | undefined { - if ("dataset" in series) return series.dataset; - if ("data" in series) return StaticDataset.fromObjects(series.data, { - x: series.x, - y: series.y, - sort: series.sort, - }); - if ("x" in series) return new StaticDataset(series.x, series.y); - return undefined; -} diff --git a/src/index.ts b/src/index.ts index ba856c9..38b7854 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ export { Chart } from "./ui/Chart.js"; export type { AxisConfig, AxisTitleConfig, ChartAccessibilityOptions, ChartAutoFitYOptions, ChartBackendFactory, ChartBackendFactoryContext, ChartFollowXOptions, ChartFitToDataOptions, ChartFitToDataPadding, ChartFrameStats, ChartKeyboardOptions, ChartLayoutReservation, ChartOptions, ChartPointerEventState, ChartPointerEventType, ChartScreenshotOptions, ChartScreenshotPreset, ChartSelectEvent, ChartSeriesClickEvent, ChartTitleConfig, ChartViewportChangeEvent, TextOverlayConfig, TypedSeriesConfig, HistogramSeriesConfig, PrecomputedHistogramSeriesConfig, ChartHoverState, ChartPickGroup, ChartPickItem, ChartPickMode, ChartPickOptions, ChartPlugin, ChartPluginContext, ChartPluginHandle, ChartSeriesState } from "./ui/Chart.js"; -export { createChart } from "./createChart.js"; -export type { CreateChartArraySeries, CreateChartDatasetSeries, CreateChartHistogramSeries, CreateChartObjectSeries, CreateChartOptions, CreateChartSeries, CreateChartSeriesType, CreateChartStreamingSeries } from "./createChart.js"; export { DEFAULT_CHART_THEME } from "./ui/theme.js"; export type { ChartTheme, ResolvedChartTheme, RgbaColor, CssColor, ThemeColor } from "./ui/theme.js"; export type { AxisPosition } from "./ui/ChartLayout.js"; diff --git a/src/interaction/AxisController.ts b/src/interaction/AxisController.ts index cbbb895..4b96beb 100644 --- a/src/interaction/AxisController.ts +++ b/src/interaction/AxisController.ts @@ -136,6 +136,105 @@ export class AxisController { const min = axis === "x" ? this.camera.xMin : this.camera.yMin; const max = axis === "x" ? this.camera.xMax : this.camera.yMax; AxisController.validateAxisDomain(axis, min, max, options); + const scaledMin = this.scaleValue(min, axis); + const scaledMax = this.scaleValue(max, axis); + if (!Number.isFinite(scaledMin) || !Number.isFinite(scaledMax) || scaledMax <= scaledMin) { + throw new RangeError(`Axis ${axis} scale must map its domain to finite ascending values.`); + } + } + + /** Return whether an axis needs a non-linear coordinate transform. */ + isNonlinear(axis: AxisRenderTarget): boolean { + const scale = (axis === "x" ? this.options.x : this.options.y)?.scale; + return scale === "log" || scale === "symlog" || (typeof scale === "object" && typeof scale.toScreen === "function"); + } + + /** Map a data value into the configured scale's coordinate space. */ + scaleValue(value: number, axis: AxisRenderTarget): number { + const options = axis === "x" ? this.options.x : this.options.y; + const scale = options?.scale; + if (scale === "log") return Math.log(value) / Math.log(options?.logBase ?? 10); + if (scale === "symlog") { + const constant = options?.symlogConstant ?? 1; + return Math.sign(value) * Math.log1p(Math.abs(value) / constant); + } + if (scale && typeof scale === "object") return scale.toScreen?.(value) ?? value; + return value; + } + + /** Map a scale-space coordinate back to its data value. */ + unscaleValue(value: number, axis: AxisRenderTarget): number { + const options = axis === "x" ? this.options.x : this.options.y; + const scale = options?.scale; + if (scale === "log") return (options?.logBase ?? 10) ** value; + if (scale === "symlog") { + const constant = options?.symlogConstant ?? 1; + return Math.sign(value) * constant * Math.expm1(Math.abs(value)); + } + if (scale && typeof scale === "object") { + if (scale.toScreen && !scale.fromScreen) { + throw new TypeError(`Axis ${axis} custom scale requires fromScreen() for pointer interaction.`); + } + return scale.fromScreen?.(value) ?? value; + } + return value; + } + + /** Convert one data value to clip space using the configured scale and direction. */ + valueToClip(value: number, axis: AxisRenderTarget): number { + const min = axis === "x" ? this.camera.xMin : this.camera.yMin; + const max = axis === "x" ? this.camera.xMax : this.camera.yMax; + const scaledMin = this.scaleValue(min, axis); + const scaledMax = this.scaleValue(max, axis); + let normalized = (this.scaleValue(value, axis) - scaledMin) / (scaledMax - scaledMin); + if (axis === "x" ? this.camera.xReversed : this.camera.yReversed) normalized = 1 - normalized; + return normalized * 2 - 1; + } + + /** Convert one clip-space coordinate back to a data value. */ + clipToValue(clip: number, axis: AxisRenderTarget): number { + const min = axis === "x" ? this.camera.xMin : this.camera.yMin; + const max = axis === "x" ? this.camera.xMax : this.camera.yMax; + let normalized = (clip + 1) * 0.5; + if (axis === "x" ? this.camera.xReversed : this.camera.yReversed) normalized = 1 - normalized; + const scaledMin = this.scaleValue(min, axis); + const scaledMax = this.scaleValue(max, axis); + return this.unscaleValue(scaledMin + normalized * (scaledMax - scaledMin), axis); + } + + /** Pan in scale space so logarithmic and custom axes move consistently. */ + pan(intent: { readonly dx: number; readonly dy: number }): void { + const xMin = this.scaleValue(this.camera.xMin, "x"); + const xMax = this.scaleValue(this.camera.xMax, "x"); + const yMin = this.scaleValue(this.camera.yMin, "y"); + const yMax = this.scaleValue(this.camera.yMax, "y"); + const dx = intent.dx * (xMax - xMin); + const dy = intent.dy * (yMax - yMin); + this.camera.setViewport({ + xMin: this.unscaleValue(xMin + dx, "x"), + xMax: this.unscaleValue(xMax + dx, "x"), + yMin: this.unscaleValue(yMin + dy, "y"), + yMax: this.unscaleValue(yMax + dy, "y"), + }); + } + + /** Zoom in scale space around normalized data-domain anchors. */ + zoom(intent: { readonly factor: number; readonly cx: number; readonly cy: number; readonly axis: "x" | "y" | "xy" }): void { + if (!Number.isFinite(intent.factor) || intent.factor <= 0) throw new RangeError("Axis zoom factor must be > 0."); + const xMin = this.scaleValue(this.camera.xMin, "x"); + const xMax = this.scaleValue(this.camera.xMax, "x"); + const yMin = this.scaleValue(this.camera.yMin, "y"); + const yMax = this.scaleValue(this.camera.yMax, "y"); + const xCenter = xMin + (xMax - xMin) * intent.cx; + const yCenter = yMin + (yMax - yMin) * intent.cy; + const xSpan = intent.axis === "y" ? xMax - xMin : (xMax - xMin) / intent.factor; + const ySpan = intent.axis === "x" ? yMax - yMin : (yMax - yMin) / intent.factor; + this.camera.setViewport({ + xMin: this.unscaleValue(xCenter - xSpan * intent.cx, "x"), + xMax: this.unscaleValue(xCenter + xSpan * (1 - intent.cx), "x"), + yMin: this.unscaleValue(yCenter - ySpan * intent.cy, "y"), + yMax: this.unscaleValue(yCenter + ySpan * (1 - intent.cy), "y"), + }); } private static validateAxisDomain(axis: AxisRenderTarget, min: number, max: number, options: AxisControllerAxisOptions | undefined): void { diff --git a/src/react.ts b/src/react.ts deleted file mode 100644 index 8e1dedd..0000000 --- a/src/react.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from "react"; -import { Chart } from "./ui/Chart.js"; -import type { ChartOptions } from "./ui/Chart.js"; - -/** Props for the React chart host component. */ -export interface BlazeChartProps { - /** Chart options used at construction time. Changing object identity disposes and recreates the chart; keep stable with useMemo. */ - readonly options?: ChartOptions; - readonly className?: string; - readonly style?: React.CSSProperties; - readonly chartRef?: React.Ref; - readonly onChart?: (chart: Chart) => void; -} - -function setRef(ref: React.Ref | undefined, value: T | null): void { - if (!ref) return; - if (typeof ref === "function") { - ref(value); - } else { - (ref as { current: T | null }).current = value; - } -} - -/** React component that creates and owns an imperative `Chart` instance. */ -export const BlazeChart = React.forwardRef(function BlazeChart(props, forwardedRef) { - const hostRef = React.useRef(null); - const chartRef = React.useRef(null); - - React.useLayoutEffect(() => { - const host = hostRef.current; - if (!host) return; - - const chart = new Chart(host, props.options); - chartRef.current = chart; - setRef(forwardedRef, chart); - setRef(props.chartRef, chart); - props.onChart?.(chart); - - return () => { - chart.dispose(); - chartRef.current = null; - setRef(forwardedRef, null); - setRef(props.chartRef, null); - }; - }, [props.options]); - - React.useEffect(() => { - chartRef.current?.resize(); - }); - - return React.createElement("div", { - ref: hostRef, - className: props.className, - style: props.style ?? { width: "100%", height: "100%" }, - }); -}); - -export default BlazeChart; diff --git a/src/render/WebGL2Backend.ts b/src/render/WebGL2Backend.ts index 000bfd3..50bbd5d 100644 --- a/src/render/WebGL2Backend.ts +++ b/src/render/WebGL2Backend.ts @@ -328,12 +328,18 @@ export class WebGL2Backend implements GpuBackend { switch (type) { case this.gl.FLOAT: return value => this.gl.uniform1f(location, this.toNumber(value)); - case this.gl.FLOAT_VEC2: - return value => this.gl.uniform2fv(location, this.toFloatList(value, 2)); - case this.gl.FLOAT_VEC3: - return value => this.gl.uniform3fv(location, this.toFloatList(value, 3)); - case this.gl.FLOAT_VEC4: - return value => this.gl.uniform4fv(location, this.toFloatList(value, 4)); + case this.gl.FLOAT_VEC2: { + const scratch = new Float32Array(2); + return value => this.gl.uniform2fv(location, this.toFloatList(value, scratch)); + } + case this.gl.FLOAT_VEC3: { + const scratch = new Float32Array(3); + return value => this.gl.uniform3fv(location, this.toFloatList(value, scratch)); + } + case this.gl.FLOAT_VEC4: { + const scratch = new Float32Array(4); + return value => this.gl.uniform4fv(location, this.toFloatList(value, scratch)); + } case this.gl.INT: case this.gl.BOOL: return value => this.gl.uniform1i(location, this.toNumber(value)); @@ -375,14 +381,16 @@ export class WebGL2Backend implements GpuBackend { } } - private toFloatList(value: UniformValue, expectedLength: number): Float32List { + private toFloatList(value: UniformValue, scratch: Float32Array): Float32List { if (typeof value === "number" || typeof value === "boolean") { - throw new TypeError(`Expected a float vector uniform with ${expectedLength} components.`); + throw new TypeError(`Expected a float vector uniform with ${scratch.length} components.`); } - if (value.length !== expectedLength) { - throw new TypeError(`Expected a float vector uniform with ${expectedLength} components, received ${value.length}.`); + if (value.length !== scratch.length) { + throw new TypeError(`Expected a float vector uniform with ${scratch.length} components, received ${value.length}.`); } - return value instanceof Float32Array ? value : new Float32Array(value); + if (value instanceof Float32Array) return value; + scratch.set(value); + return scratch; } private toNumber(value: UniformValue): number { diff --git a/src/ui/AxisOverlay.ts b/src/ui/AxisOverlay.ts index 2b10365..13ac9b8 100644 --- a/src/ui/AxisOverlay.ts +++ b/src/ui/AxisOverlay.ts @@ -1,4 +1,3 @@ -import type { Camera2D } from "../interaction/Camera2D.js"; import type { AxisController } from "../interaction/AxisController.js"; import type { ChartLayoutElements, ChartLayoutConfig } from "./ChartLayout.js"; @@ -42,6 +41,7 @@ export class AxisOverlay { private readonly xTicks: number[] = []; private readonly yTicks: number[] = []; private readonly y2Ticks: number[] = []; + private readonly measureContext = document.createElement("canvas").getContext("2d"); /** Create an axis overlay attached to a chart layout. */ constructor( @@ -60,7 +60,7 @@ export class AxisOverlay { } /** Render axis ticks from the latest camera and axis controller state. */ - update(camera: Camera2D, axis: AxisController, rightCamera: Camera2D = camera, rightAxis: AxisController = axis): void { + update(axis: AxisController, rightAxis: AxisController = axis): void { const plotW = Math.max(1, this.layout.plot.clientWidth); const plotH = Math.max(1, this.layout.plot.clientHeight); @@ -82,9 +82,9 @@ export class AxisOverlay { this.y2Ticks.length = 0; } - this.updateAxis(this.xPool, this.xTicks, "x", camera, plotW, plotH, axis); - this.updateAxis(this.yPool, this.yTicks, "y", camera, plotW, plotH, axis); - this.updateAxis(this.y2Pool, this.y2Ticks, "y2", rightCamera, plotW, plotH, rightAxis); + this.updateAxis(this.xPool, this.xTicks, "x", plotW, plotH, axis); + this.updateAxis(this.yPool, this.yTicks, "y", plotW, plotH, axis); + this.updateAxis(this.y2Pool, this.y2Ticks, "y2", plotW, plotH, rightAxis); } /** Remove all axis overlay DOM nodes. */ @@ -111,7 +111,6 @@ export class AxisOverlay { pool: HTMLDivElement[], values: number[], axis: RenderAxis, - camera: Camera2D, plotW: number, plotH: number, controller: AxisController, @@ -145,14 +144,13 @@ export class AxisOverlay { const value = values[i]!; const text = controller.formatValue(value, "x"); if (el.textContent !== text) el.textContent = text; - const [clipX] = camera.toClip(value, camera.yMin); - const screenX = (clipX + 1) * 0.5 * plotW; + const screenX = (controller.valueToClip(value, "x") + 1) * 0.5 * plotW; if (screenX < 0 || screenX > plotW) { el.style.display = "none"; continue; } el.style.display = "block"; - const labelWidth = Math.max(1, el.offsetWidth); + const labelWidth = this.measureLabel(text, "width"); const centeredLeft = screenX - labelWidth * 0.5; const maxLeft = Math.max(0, plotW - labelWidth); const labelLeft = Math.min(Math.max(0, centeredLeft), maxLeft); @@ -182,14 +180,13 @@ export class AxisOverlay { const value = values[i]!; const text = controller.formatValue(value, "y"); if (el.textContent !== text) el.textContent = text; - const [, clipY] = camera.toClip(camera.xMin, value); - const screenY = (1 - clipY) * 0.5 * plotH; + const screenY = (1 - controller.valueToClip(value, "y")) * 0.5 * plotH; if (screenY < 0 || screenY > plotH) { el.style.display = "none"; continue; } el.style.display = "block"; - const labelHeight = Math.max(1, el.offsetHeight); + const labelHeight = this.measureLabel(text, "height"); const centeredTop = screenY - labelHeight * 0.5; const maxTop = Math.max(0, plotH - labelHeight); const labelTop = Math.min(Math.max(0, centeredTop), maxTop); @@ -209,4 +206,14 @@ export class AxisOverlay { hideOverlappingLabels(labels); } + + private measureLabel(text: string, dimension: "width" | "height"): number { + const context = this.measureContext; + if (!context) return 12; + context.font = this.options.font ?? "11px ui-monospace, monospace, sans-serif"; + const metrics = context.measureText(text); + return Math.max(1, dimension === "width" + ? Math.ceil(metrics.width) + : Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent)); + } } diff --git a/src/ui/Chart.ts b/src/ui/Chart.ts index 98ec902..5c883c0 100644 --- a/src/ui/Chart.ts +++ b/src/ui/Chart.ts @@ -20,8 +20,6 @@ import type { ChartTheme, ResolvedChartTheme } from "./theme.js"; const RAW_LINE_VERTEX_CAPACITY = 16_384; const AREA_POINT_CAPACITY = RAW_LINE_VERTEX_CAPACITY >> 1; -const MINMAX_SEGMENT_CAPACITY = RAW_LINE_VERTEX_CAPACITY >> 1; -const MINMAX_BATCH_SEGMENT_CAPACITY = MINMAX_SEGMENT_CAPACITY * 4; const FLOATS_PER_MINMAX_SEGMENT_INSTANCE = 3; const BAR_TRIANGLE_CAPACITY = 4_096; const FLOATS_PER_BAR_TRIANGLE = 12; @@ -362,8 +360,8 @@ export interface ChartPluginContext { dataToPlot(x: number, y: number, yAxis?: SeriesYAxis): [number, number]; clientToData(clientX: number, clientY: number, yAxis?: SeriesYAxis): [number, number] | null; getViewport(yAxis?: SeriesYAxis): Viewport; - pan(intent: PanIntent): void; - zoom(intent: ZoomIntent): void; + pan(intent: PanIntent, yAxis?: SeriesYAxis): void; + zoom(intent: ZoomIntent, yAxis?: SeriesYAxis): void; setSeriesVisible(series: SeriesStore, visible: boolean): boolean; getSeriesState(): ChartSeriesState[]; setViewport(v: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }): void; @@ -476,19 +474,10 @@ interface ChartPickRect { interface ChartGpuResources { readonly renderer: Renderer; readonly rawLineBuffer: GpuBuffer; - readonly minMaxInstanceBuffer: GpuBuffer; readonly barTriangleBuffer: GpuBuffer; readonly gridBuffer: GpuBuffer; } -interface MinMaxLineBatch { - readonly camera: Camera2D; - readonly style: SeriesStyle; - readonly color: readonly [number, number, number, number]; - segmentCount: number; - seriesCount: number; -} - /** Imperative WebGL chart instance for rendering, interaction, and plugins. */ export class Chart implements ChartPluginContext { /** Return whether the current environment can create a WebGL2 context. */ @@ -504,7 +493,6 @@ export class Chart implements ChartPluginContext { private renderer!: Renderer; private rawLineBuffer!: GpuBuffer; private rawLineData: Float32Array; - private minMaxInstanceBuffer!: GpuBuffer; private minMaxInstanceData: Float32Array; private barTriangleBuffer!: GpuBuffer; private barTriangleData: Float32Array; @@ -555,7 +543,6 @@ export class Chart implements ChartPluginContext { private pointerInPlot: boolean = false; private lastFrameAt: number = 0; private currentXOrigin: number = 0; - private minMaxLineBatch: MinMaxLineBatch | null = null; private followXConfig: ChartFollowXOptions | null = null; private xFollowPaused: boolean = false; private xFollowResumeTimer: ReturnType | null = null; @@ -653,7 +640,7 @@ export class Chart implements ChartPluginContext { this.axis = new AxisController(this.camera, { x: this.normalizedAxes.x, y: this.normalizedAxes.y }); this.rightAxis = new AxisController(this.rightCamera, { x: this.normalizedAxes.x, y: this.normalizedAxes.y2 }); this.rawLineData = new Float32Array(RAW_LINE_VERTEX_CAPACITY * 2); - this.minMaxInstanceData = new Float32Array(MINMAX_BATCH_SEGMENT_CAPACITY * FLOATS_PER_MINMAX_SEGMENT_INSTANCE); + this.minMaxInstanceData = new Float32Array(BAR_TRIANGLE_CAPACITY * FLOATS_PER_MINMAX_SEGMENT_INSTANCE); this.barTriangleData = new Float32Array(BAR_TRIANGLE_CAPACITY * FLOATS_PER_BAR_TRIANGLE); this.gridData = new Float32Array(GRID_LINE_VERTEX_CAPACITY * 2); this.installGpuResources(this.createGpuResources()); @@ -685,13 +672,18 @@ export class Chart implements ChartPluginContext { this.resizeObserver.observe(this.layout.plot); } - for (const plugin of options.plugins ?? []) { - const installed = plugin.install(this); - if (typeof installed === "function") { - this.pluginDisposers.push(installed); - } else if (installed) { - this.pluginDisposers.push(() => installed.dispose()); + try { + for (const plugin of options.plugins ?? []) { + const installed = plugin.install(this); + if (typeof installed === "function") { + this.pluginDisposers.push(installed); + } else if (installed) { + this.pluginDisposers.push(() => installed.dispose()); + } } + } catch (error) { + this.dispose(); + throw error; } } @@ -746,9 +738,14 @@ export class Chart implements ChartPluginContext { /** Convert data coordinates to plot-local CSS-pixel coordinates. */ dataToPlot(x: number, y: number, yAxis: SeriesYAxis = "left"): [number, number] { - const camera = this.getCamera(yAxis); - const [clipX, clipY] = camera.toClip(x, y); - return camera.toScreen(clipX, clipY, this.canvas.clientWidth, this.canvas.clientHeight); + const controller = yAxis === "right" ? this.rightAxis : this.axis; + const rect = this.canvas.getBoundingClientRect(); + return this.getCamera(yAxis).toScreen( + controller.valueToClip(x, "x"), + controller.valueToClip(y, "y"), + rect.width, + rect.height, + ); } /** Convert viewport client coordinates to data coordinates, or `null` outside the plot. */ @@ -760,7 +757,11 @@ export class Chart implements ChartPluginContext { const plotY = clientY - rect.top; if (plotX < 0 || plotY < 0 || plotX > rect.width || plotY > rect.height) return null; - return this.getCamera(yAxis).screenToData(plotX, plotY, rect.width, rect.height); + const controller = yAxis === "right" ? this.rightAxis : this.axis; + return [ + controller.clipToValue((plotX / rect.width) * 2 - 1, "x"), + controller.clipToValue(1 - (plotY / rect.height) * 2, "y"), + ]; } /** Return the visible data domain for the requested Y axis. */ @@ -768,19 +769,29 @@ export class Chart implements ChartPluginContext { return this.getCamera(yAxis).viewport; } - /** Pan the chart by data-domain or screen-pixel deltas. */ - pan(intent: PanIntent): void { + /** Pan the chart in scale space, optionally targeting the right Y axis. */ + pan(intent: PanIntent, yAxis: SeriesYAxis = "left"): void { this.pauseXFollowForInteraction(); - this.camera.pan(intent); + if (yAxis === "right") { + if (intent.dx !== 0) this.axis.pan({ dx: intent.dx, dy: 0 }); + if (intent.dy !== 0) this.rightAxis.pan({ dx: 0, dy: intent.dy }); + } else { + this.axis.pan(intent); + } this.syncRightCameraX(); this.emitViewportChange(); this.scheduleHoverRefresh(); } - /** Zoom around a data or screen anchor. */ - zoom(intent: ZoomIntent): void { + /** Zoom in scale space, optionally targeting the right Y axis. */ + zoom(intent: ZoomIntent, yAxis: SeriesYAxis = "left"): void { this.pauseXFollowForInteraction(); - this.camera.zoom(intent); + if (yAxis === "right") { + if (intent.axis !== "y") this.axis.zoom({ ...intent, axis: "x" }); + if (intent.axis !== "x") this.rightAxis.zoom({ ...intent, axis: "y" }); + } else { + this.axis.zoom(intent); + } this.syncRightCameraX(); this.emitViewportChange(); this.scheduleHoverRefresh(); @@ -930,7 +941,7 @@ export class Chart implements ChartPluginContext { setViewport(v: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }): void { if (v.xMin !== undefined || v.xMax !== undefined) this.pauseXFollowForInteraction(); this.camera.setViewport(v); - this.rightCamera.setViewport(v); + this.rightCamera.setViewport({ xMin: v.xMin, xMax: v.xMax }); this.emitViewportChange(); this.refreshHover(); } @@ -1306,9 +1317,8 @@ export class Chart implements ChartPluginContext { if (rect.width <= 0 || rect.height <= 0) return null; if (plotX < 0 || plotY < 0 || plotX > rect.width || plotY > rect.height) return null; - const viewport = this.camera.viewport; - const dataX = viewport.xMin + (plotX / rect.width) * (viewport.xMax - viewport.xMin); - const dataY = viewport.yMax - (plotY / rect.height) * (viewport.yMax - viewport.yMin); + const dataX = this.axis.clipToValue((plotX / rect.width) * 2 - 1, "x"); + const dataY = this.axis.clipToValue(1 - (plotY / rect.height) * 2, "y"); const mode = options.mode ?? this.options.hover?.mode ?? "nearest-x"; const group = options.group ?? this.options.hover?.group ?? "x"; const maxDistancePx = options.maxDistancePx ?? this.options.hover?.maxDistancePx ?? Infinity; @@ -1384,6 +1394,9 @@ export class Chart implements ChartPluginContext { this.syncRightCameraX(); this.applyFollowXPolicy(); this.applyAutoFitYPolicy(); + this.axis.validateDomain("x"); + this.axis.validateDomain("y"); + this.rightAxis.validateDomain("y"); try { this.renderer.viewport(0, 0, this.canvas.width, this.canvas.height); @@ -1392,7 +1405,7 @@ export class Chart implements ChartPluginContext { const viewport = this.camera.viewport; this.currentXOrigin = viewport.xMin; if (this._gridVisible) { - const gridVertexCount = this.writeGridVertices(viewport); + const gridVertexCount = this.writeGridVertices(); if (gridVertexCount > 0) { this.uploadGridData(gridVertexCount); this.renderer.drawClipLines(this.gridBuffer, gridVertexCount, this.gridStyle); @@ -1403,13 +1416,10 @@ export class Chart implements ChartPluginContext { for (const s of this.series) { if (!s.visible) continue; s.rebuildPyramid(); - if (this.queueMinMaxLineBatch(s)) continue; - this.flushMinMaxLineBatch(); this.drawSeries(s); } - this.flushMinMaxLineBatch(); - this.axisOverlay?.update(this.camera, this.axis, this.rightCamera, this.rightAxis); + this.axisOverlay?.update(this.axis, this.rightAxis); this.emitRender(); } catch (error) { if (this.renderer.getWebGLContext()?.isContextLost() === true) { @@ -1452,7 +1462,13 @@ export class Chart implements ChartPluginContext { this.canvas.removeEventListener("webglcontextlost", this.handleWebGLContextLost); this.canvas.removeEventListener("webglcontextrestored", this.handleWebGLContextRestored); this.layout.root.removeEventListener("keydown", this.handleKeyDown); - for (const dispose of this.pluginDisposers.splice(0)) dispose(); + for (const dispose of this.pluginDisposers.splice(0)) { + try { + dispose(); + } catch { + // Plugin cleanup must not prevent chart-owned resources from being released. + } + } this.axisOverlay?.dispose(); this.disposeRenderer(this.renderer); this.layout.dispose(); @@ -1464,7 +1480,6 @@ export class Chart implements ChartPluginContext { return { renderer, rawLineBuffer: renderer.createFloatBuffer(this.rawLineData.length), - minMaxInstanceBuffer: renderer.createFloatBuffer(this.minMaxInstanceData.length), barTriangleBuffer: renderer.createFloatBuffer(this.barTriangleData.length), gridBuffer: renderer.createFloatBuffer(this.gridData.length), }; @@ -1477,7 +1492,6 @@ export class Chart implements ChartPluginContext { private installGpuResources(resources: ChartGpuResources): void { this.renderer = resources.renderer; this.rawLineBuffer = resources.rawLineBuffer; - this.minMaxInstanceBuffer = resources.minMaxInstanceBuffer; this.barTriangleBuffer = resources.barTriangleBuffer; this.gridBuffer = resources.gridBuffer; } @@ -1705,14 +1719,23 @@ export class Chart implements ChartPluginContext { return series.config.yAxis === "right" ? this.rightCamera : this.camera; } + private controllerForCamera(camera: Camera2D): AxisController { + return camera === this.rightCamera ? this.rightAxis : this.axis; + } + private projectionForCamera(camera: Camera2D): RenderProjection { - const projection = camera === this.rightCamera ? this.rightProjection : this.leftProjection; - const shiftedXMin = camera.xMin - this.currentXOrigin; - const shiftedXMax = camera.xMax - this.currentXOrigin; - projection.scaleX = camera.xScale; - projection.scaleY = camera.yScale; - projection.offsetX = -(shiftedXMin + shiftedXMax) / (shiftedXMax - shiftedXMin); - projection.offsetY = camera.yOffset; + const right = camera === this.rightCamera; + const projection = right ? this.rightProjection : this.leftProjection; + const controller = this.controllerForCamera(camera); + const scaledOrigin = controller.scaleValue(this.currentXOrigin, "x"); + const xMin = controller.scaleValue(camera.xMin, "x") - scaledOrigin; + const xMax = controller.scaleValue(camera.xMax, "x") - scaledOrigin; + const yMin = controller.scaleValue(camera.yMin, "y"); + const yMax = controller.scaleValue(camera.yMax, "y"); + projection.scaleX = (camera.xReversed ? -2 : 2) / (xMax - xMin); + projection.scaleY = (camera.yReversed ? -2 : 2) / (yMax - yMin); + projection.offsetX = (camera.xReversed ? 1 : -1) * (xMin + xMax) / (xMax - xMin); + projection.offsetY = (camera.yReversed ? 1 : -1) * (yMin + yMax) / (yMax - yMin); return projection; } @@ -1727,36 +1750,6 @@ export class Chart implements ChartPluginContext { this.rightCamera.setReversed({ x: xReversed, y: this.normalizedAxes.y2.reversed === true }); } - private queueMinMaxLineBatch(series: SeriesStore): boolean { - void series; - // Dense min/max lines are rendered as data-space bucket rectangles in - // drawLineSeries so adjacent 1px buckets move with data while panning/live - // following. The old batched instanced path rendered pixel-width center - // columns; that made neighboring buckets appear to swap/stick on the pixel - // grid when the viewport moved. - return false; - } - - private flushMinMaxLineBatch(): void { - const batch = this.minMaxLineBatch; - this.minMaxLineBatch = null; - if (!batch || batch.segmentCount <= 0) return; - - this.uploadMinMaxInstanceData(batch.segmentCount); - this.renderer.drawMinMaxSegmentsInstanced( - this.minMaxInstanceBuffer, - batch.segmentCount, - batch.style, - this.projectionForCamera(batch.camera), - this.canvas.width, - this.canvas.height, - ); - this.recordDraw("minmax", batch.segmentCount * 2); - if (batch.seriesCount > 1) { - this.stats.batchedDrawCalls = (this.stats.batchedDrawCalls ?? 0) + batch.seriesCount - 1; - } - } - private drawSeries(series: SeriesStore): void { const camera = this.cameraForSeries(series); const viewport = camera.viewport; @@ -1785,27 +1778,18 @@ export class Chart implements ChartPluginContext { private drawLineSeries(series: SeriesStore, viewport: Viewport, projection: RenderProjection): void { const visibleSamples = series.visibleSampleCount(viewport); const dense = series.hasServerMinMax || (series.hasLOD && visibleSamples > RAW_LINE_VERTEX_CAPACITY - 2); - if (dense && this.renderer.supportsInstancedSegments) { - const segmentCount = series.copyMinMaxInstanced(viewport, this.minMaxInstanceData, this.maxMinMaxSegments(), this.currentXOrigin); + if (dense) { + const segmentCount = series.copyMinMaxInstanced(viewport, this.minMaxInstanceData, this.maxBarTriangleBars(), this.currentXOrigin); if (segmentCount <= 0) return; const vertexCount = this.writeBarBucketTriangles(segmentCount, viewport); this.drawBarTriangles(vertexCount, series.style, projection, "minmax"); return; } - if (dense) { - const count = series.copyMinMaxVisible(viewport, this.rawLineData, this.maxMinMaxSegments(), this.currentXOrigin); - if (count < 2) return; - this.uploadRawLineData(count); - this.renderer.drawMinMaxSegments(this.rawLineBuffer, count, series.style, projection); - this.recordDraw("minmax", count); - return; - } - - const count = series.copyRawVisibleClipSpace(viewport, this.rawLineData, RAW_LINE_VERTEX_CAPACITY); + const count = series.copyRawVisibleClipped(viewport, this.rawLineData, RAW_LINE_VERTEX_CAPACITY, this.currentXOrigin); if (count < 2) return; - this.uploadRawLineData(count); - this.renderer.drawClipLineStrip(this.rawLineBuffer, count, series.style); + this.uploadRawLineData(count, projection); + this.renderer.drawLineStrip(this.rawLineBuffer, count, series.style, projection); this.recordDraw("raw", count); } @@ -1817,14 +1801,14 @@ export class Chart implements ChartPluginContext { if (range.end - range.start > AREA_POINT_CAPACITY) { const areaVertexCount = series.copyAreaVisible(viewport, this.rawLineData, AREA_POINT_CAPACITY, baseline, this.currentXOrigin); if (areaVertexCount >= 4) { - this.uploadRawLineData(areaVertexCount); + this.uploadRawLineData(areaVertexCount, projection); this.renderer.drawAreaStrip(this.rawLineBuffer, areaVertexCount, series.style, projection); this.recordDraw("area", areaVertexCount); } const lineVertexCount = series.copyRawVisible(viewport, this.rawLineData, AREA_POINT_CAPACITY, this.currentXOrigin); if (lineVertexCount >= 2) { - this.uploadRawLineData(lineVertexCount); + this.uploadRawLineData(lineVertexCount, projection); this.renderer.drawLineStrip(this.rawLineBuffer, lineVertexCount, series.style, projection); this.recordDraw("area", lineVertexCount); } @@ -1835,7 +1819,7 @@ export class Chart implements ChartPluginContext { const areaVertexCount = series.copyAreaRange(start, range.end, this.rawLineData, AREA_POINT_CAPACITY, baseline, this.currentXOrigin); if (areaVertexCount < 4) break; - this.uploadRawLineData(areaVertexCount); + this.uploadRawLineData(areaVertexCount, projection); this.renderer.drawAreaStrip(this.rawLineBuffer, areaVertexCount, series.style, projection); this.recordDraw("area", areaVertexCount); start += Math.max(1, (areaVertexCount >> 1) - 1); @@ -1845,7 +1829,7 @@ export class Chart implements ChartPluginContext { const lineVertexCount = series.copyRawRange(start, range.end, this.rawLineData, AREA_POINT_CAPACITY, this.currentXOrigin); if (lineVertexCount < 2) break; - this.uploadRawLineData(lineVertexCount); + this.uploadRawLineData(lineVertexCount, projection); this.renderer.drawLineStrip(this.rawLineBuffer, lineVertexCount, series.style, projection); this.recordDraw("area", lineVertexCount); start += Math.max(1, lineVertexCount - 1); @@ -1889,7 +1873,7 @@ export class Chart implements ChartPluginContext { const wickVertexCount = this.writeCandlestickWicks(candleCount); if (wickVertexCount > 0) { - this.uploadBarTriangleData(wickVertexCount); + this.uploadBarTriangleData(wickVertexCount, projection); this.renderer.drawLines(this.barTriangleBuffer, wickVertexCount, wickStyle, projection); this.recordDraw("raw", wickVertexCount); } @@ -1920,7 +1904,7 @@ export class Chart implements ChartPluginContext { ); if (count <= 0) continue; - this.uploadRawLineData(count); + this.uploadRawLineData(count, projection); this.renderer.drawPoints(this.rawLineBuffer, count, series.style, projection, this.canvas.width, this.canvas.height); this.recordDraw("points", count); } @@ -1938,7 +1922,7 @@ export class Chart implements ChartPluginContext { ); if (count <= 0) return; - this.uploadRawLineData(count); + this.uploadRawLineData(count, projection); this.renderer.drawPoints(this.rawLineBuffer, count, series.style, projection, this.canvas.width, this.canvas.height); this.recordDraw("points", count); } @@ -1960,8 +1944,9 @@ export class Chart implements ChartPluginContext { const count = series.copyRawRange(range.start, range.end, this.rawLineData, rawBarCapacity, this.currentXOrigin); if (count <= 0) return; - if (this.renderer.supportsInstancedBars) { - this.uploadRawLineData(count); + const controller = this.controllerForProjection(projection); + if (this.renderer.supportsInstancedBars && !controller.isNonlinear("x") && !controller.isNonlinear("y")) { + this.uploadRawLineData(count, projection); this.renderer.drawBarsInstanced(this.rawLineBuffer, count, series.style, projection); this.recordDraw("bars", count); return; @@ -1971,15 +1956,31 @@ export class Chart implements ChartPluginContext { this.drawBarTriangles(vertexCount, series.style, projection); } - private uploadRawLineData(vertexCount: number): void { - this.uploadFloatData(this.rawLineBuffer, this.rawLineData, vertexCount * 2); + private controllerForProjection(projection: RenderProjection): AxisController { + return projection === this.rightProjection ? this.rightAxis : this.axis; } - private uploadMinMaxInstanceData(instanceCount: number): void { - this.uploadFloatData(this.minMaxInstanceBuffer, this.minMaxInstanceData, instanceCount * FLOATS_PER_MINMAX_SEGMENT_INSTANCE); + private transformVertices(data: Float32Array, vertexCount: number, projection: RenderProjection): void { + const controller = this.controllerForProjection(projection); + const transformX = controller.isNonlinear("x"); + const transformY = controller.isNonlinear("y"); + if (!transformX && !transformY) return; + + const scaledOrigin = transformX ? controller.scaleValue(this.currentXOrigin, "x") : 0; + for (let i = 0; i < vertexCount; i++) { + const offset = i * 2; + if (transformX) data[offset] = controller.scaleValue(data[offset]! + this.currentXOrigin, "x") - scaledOrigin; + if (transformY) data[offset + 1] = controller.scaleValue(data[offset + 1]!, "y"); + } + } + + private uploadRawLineData(vertexCount: number, projection: RenderProjection): void { + this.transformVertices(this.rawLineData, vertexCount, projection); + this.uploadFloatData(this.rawLineBuffer, this.rawLineData, vertexCount * 2); } - private uploadBarTriangleData(vertexCount: number): void { + private uploadBarTriangleData(vertexCount: number, projection: RenderProjection): void { + this.transformVertices(this.barTriangleData, vertexCount, projection); this.uploadFloatData(this.barTriangleBuffer, this.barTriangleData, vertexCount * 2); } @@ -2114,7 +2115,7 @@ export class Chart implements ChartPluginContext { } if (vertexCount <= 0) return; - this.uploadBarTriangleData(vertexCount); + this.uploadBarTriangleData(vertexCount, projection); this.renderer.drawLines(this.barTriangleBuffer, vertexCount, style, projection); this.recordDraw("raw", vertexCount); } @@ -2166,7 +2167,7 @@ export class Chart implements ChartPluginContext { mode: "bars" | "area" | "minmax" = "bars", ): void { if (vertexCount <= 0) return; - this.uploadBarTriangleData(vertexCount); + this.uploadBarTriangleData(vertexCount, projection); this.renderer.drawBarTriangles(this.barTriangleBuffer, vertexCount, style, projection); this.recordDraw(mode, vertexCount); } @@ -2188,11 +2189,15 @@ export class Chart implements ChartPluginContext { for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { const series = this.series[seriesIndex]!; if (!series.visible) continue; - const viewport = this.cameraForSeries(series).viewport; - const xScale = plotWidth / (viewport.xMax - viewport.xMin); + const camera = this.cameraForSeries(series); + const viewport = camera.viewport; + const controller = this.controllerForCamera(camera); + const scaledMin = controller.scaleValue(viewport.xMin, "x"); + const scaledMax = controller.scaleValue(viewport.xMax, "x"); + const xScale = plotWidth / (scaledMax - scaledMin); const sample = series.nearestSampleByX(dataX, viewport); if (!sample) continue; - const distancePx = Math.abs(sample.x - dataX) * xScale; + const distancePx = Math.abs(controller.scaleValue(sample.x, "x") - controller.scaleValue(dataX, "x")) * xScale; if (distancePx < bestDistancePx) { best = { sample, series, seriesIndex }; bestDistancePx = distancePx; @@ -2213,9 +2218,20 @@ export class Chart implements ChartPluginContext { for (let seriesIndex = 0; seriesIndex < this.series.length; seriesIndex++) { const series = this.series[seriesIndex]!; if (!series.visible) continue; - const viewport = this.cameraForSeries(series).viewport; - const dataY = viewport.yMax - (plotY / plotHeight) * (viewport.yMax - viewport.yMin); - const sample = series.nearestSampleByPoint(dataX, dataY, viewport, plotWidth, plotHeight, maxDistancePx); + const camera = this.cameraForSeries(series); + const viewport = camera.viewport; + const controller = this.controllerForCamera(camera); + const dataY = controller.clipToValue(1 - (plotY / plotHeight) * 2, "y"); + const sample = series.nearestSampleByPoint( + dataX, + dataY, + viewport, + plotWidth, + plotHeight, + maxDistancePx, + controller.isNonlinear("x") ? (value) => controller.scaleValue(value, "x") : undefined, + controller.isNonlinear("y") ? (value) => controller.scaleValue(value, "y") : undefined, + ); if (!sample) continue; if (!best || (sample.distancePx ?? Infinity) < (best.sample.distancePx ?? Infinity)) { best = { sample, series, seriesIndex }; @@ -2251,8 +2267,13 @@ export class Chart implements ChartPluginContext { rect: ChartPickRect, ): ChartPickItem { const camera = this.cameraForSeries(series); - const [clipX, clipY] = camera.toClip(sample.x, sample.y); - const [plotX, plotY] = camera.toScreen(clipX, clipY, rect.width, rect.height); + const controller = this.controllerForCamera(camera); + const [plotX, plotY] = camera.toScreen( + controller.valueToClip(sample.x, "x"), + controller.valueToClip(sample.y, "y"), + rect.width, + rect.height, + ); const itemClientX = rect.left + plotX; const itemClientY = rect.top + plotY; const dx = itemClientX - clientX; @@ -2349,9 +2370,8 @@ export class Chart implements ChartPluginContext { const plotY = source.clientY - rect.top; if (plotX < 0 || plotY < 0 || plotX > rect.width || plotY > rect.height) return null; - const viewport = this.camera.viewport; - const dataX = viewport.xMin + (plotX / rect.width) * (viewport.xMax - viewport.xMin); - const dataY = viewport.yMax - (plotY / rect.height) * (viewport.yMax - viewport.yMin); + const dataX = this.axis.clipToValue((plotX / rect.width) * 2 - 1, "x"); + const dataY = this.axis.clipToValue(1 - (plotY / rect.height) * 2, "y"); const hover = this.pick(source.clientX, source.clientY, this.options.hover); const event: ChartPointerEventState = { type, @@ -2401,10 +2421,6 @@ export class Chart implements ChartPluginContext { for (const callback of this.renderSubscribers) callback(this); } - private maxMinMaxSegments(): number { - return Math.min(this.canvas.width, MINMAX_SEGMENT_CAPACITY); - } - private maxBarTriangleBars(): number { return Math.min(BAR_TRIANGLE_CAPACITY, RAW_LINE_VERTEX_CAPACITY); } @@ -2413,7 +2429,7 @@ export class Chart implements ChartPluginContext { return this.renderer.supportsInstancedBars ? RAW_LINE_VERTEX_CAPACITY : this.maxBarTriangleBars(); } - private writeGridVertices(viewport: Viewport): number { + private writeGridVertices(): number { const plotW = Math.max(1, this.canvas.clientWidth); const plotH = Math.max(1, this.canvas.clientHeight); this.axis.getXTickValues(plotW, 12, this.xTicks); @@ -2422,10 +2438,10 @@ export class Chart implements ChartPluginContext { let vertexCount = 0; for (const x of this.xTicks) { if (vertexCount + 2 > GRID_LINE_VERTEX_CAPACITY) return vertexCount; - this.gridData[vertexCount * 2] = this.xToClip(x, viewport); + this.gridData[vertexCount * 2] = this.axis.valueToClip(x, "x"); this.gridData[vertexCount * 2 + 1] = -1; vertexCount++; - this.gridData[vertexCount * 2] = this.xToClip(x, viewport); + this.gridData[vertexCount * 2] = this.axis.valueToClip(x, "x"); this.gridData[vertexCount * 2 + 1] = 1; vertexCount++; } @@ -2433,24 +2449,16 @@ export class Chart implements ChartPluginContext { for (const y of this.yTicks) { if (vertexCount + 2 > GRID_LINE_VERTEX_CAPACITY) return vertexCount; this.gridData[vertexCount * 2] = -1; - this.gridData[vertexCount * 2 + 1] = this.yToClip(y, viewport); + this.gridData[vertexCount * 2 + 1] = this.axis.valueToClip(y, "y"); vertexCount++; this.gridData[vertexCount * 2] = 1; - this.gridData[vertexCount * 2 + 1] = this.yToClip(y, viewport); + this.gridData[vertexCount * 2 + 1] = this.axis.valueToClip(y, "y"); vertexCount++; } return vertexCount; } - private xToClip(x: number, viewport: Viewport): number { - return ((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * 2 - 1; - } - - private yToClip(y: number, viewport: Viewport): number { - return ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * 2 - 1; - } - private recordRenderMode(mode: "raw" | "minmax" | "points" | "bars" | "area"): void { if (this.stats.renderMode === "none") { this.stats.renderMode = mode; diff --git a/src/ui/Interactions.ts b/src/ui/Interactions.ts index 3b8db28..69fc09b 100644 --- a/src/ui/Interactions.ts +++ b/src/ui/Interactions.ts @@ -106,16 +106,13 @@ function clientToDataClamped( clientX: number, clientY: number, rect: DOMRect, - viewport: Viewport, + chart: ChartPluginContext, ): [number, number] | null { if (rect.width <= 0 || rect.height <= 0) return null; - - const plotX = Math.max(0, Math.min(clientX - rect.left, rect.width)); - const plotY = Math.max(0, Math.min(clientY - rect.top, rect.height)); - return [ - viewport.xMin + (plotX / rect.width) * (viewport.xMax - viewport.xMin), - viewport.yMax - (plotY / rect.height) * (viewport.yMax - viewport.yMin), - ]; + return chart.clientToData( + rect.left + Math.max(0, Math.min(clientX - rect.left, rect.width)), + rect.top + Math.max(0, Math.min(clientY - rect.top, rect.height)), + ); } function touchCenter(touches: TouchList): { x: number; y: number } | null { @@ -185,6 +182,7 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha let drag: DragState | null = null; let touchGesture: TouchGestureState | null = null; let resetViewport: Viewport | null = null; + let resetRightViewport: Viewport | null = null; let lastTapTime = 0; let lastTapX = 0; let lastTapY = 0; @@ -223,15 +221,27 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha const captureResetViewport = (): void => { resetViewport ??= normalizeViewport(chart.getViewport()); + resetRightViewport ??= normalizeViewport(chart.getViewport("right")); }; const applyPanPolicy = (intent: PanIntent, panAxis: ZoomAxis, targetYAxis: SeriesYAxis = "left"): PanIntent | null => { + const camera = chart.getCamera(targetYAxis); const constrained = constrainPan(intent, panAxis); - return options.viewportPolicy?.beforePan?.(chart.getCamera(targetYAxis), constrained) ?? constrained; + const directed = { + dx: camera.xReversed ? -constrained.dx : constrained.dx, + dy: camera.yReversed ? -constrained.dy : constrained.dy, + }; + return options.viewportPolicy?.beforePan?.(camera, directed) ?? directed; }; const applyZoomPolicy = (intent: ZoomIntent, targetYAxis: SeriesYAxis = "left"): ZoomIntent | null => { - return options.viewportPolicy?.beforeZoom?.(chart.getCamera(targetYAxis), intent) ?? intent; + const camera = chart.getCamera(targetYAxis); + const directed = { + ...intent, + cx: camera.xReversed ? 1 - intent.cx : intent.cx, + cy: camera.yReversed ? 1 - intent.cy : intent.cy, + }; + return options.viewportPolicy?.beforeZoom?.(camera, directed) ?? directed; }; const hideSelection = (): void => { @@ -345,15 +355,7 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha const dx = rect.width > 0 ? (drag.lastX - event.clientX) / rect.width : 0; const dy = rect.height > 0 ? (event.clientY - drag.lastY) / rect.height : 0; const intent = applyPanPolicy({ dx, dy }, drag.axis, drag.yAxis ?? "left"); - if (intent) { - if (drag.yAxis && drag.axis === "y") { - const next = chart.getCamera(drag.yAxis).clone(); - next.pan({ dx: 0, dy: intent.dy }); - chart.setYViewport(drag.yAxis, { yMin: next.yMin, yMax: next.yMax }); - } else { - chart.pan(intent); - } - } + if (intent) chart.pan(intent, drag.yAxis); drag.lastX = event.clientX; drag.lastY = event.clientY; return; @@ -381,8 +383,8 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha const current = chart.getViewport(); const rect = canvas.getBoundingClientRect(); - const start = clientToDataClamped(completed.startX, completed.startY, rect, current); - const end = clientToDataClamped(event.clientX, event.clientY, rect, current); + const start = clientToDataClamped(completed.startX, completed.startY, rect, chart); + const end = clientToDataClamped(event.clientX, event.clientY, rect, chart); if (!start || !end) return; const next = applySelectionAxis(current, start, end, resolveAxis(options.axis)); @@ -410,13 +412,7 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha dy: rect.height > 0 && zoomAxis !== "x" ? (-event.deltaY * sensitivity) / rect.height : 0, }, zoomAxis, targetYAxis ?? "left"); if (!panIntent || (Math.abs(panIntent.dx) < 1e-6 && Math.abs(panIntent.dy) < 1e-6)) return; - if (targetYAxis && zoomAxis === "y") { - const next = chart.getCamera(targetYAxis).clone(); - next.pan({ dx: 0, dy: panIntent.dy }); - chart.setYViewport(targetYAxis, { yMin: next.yMin, yMax: next.yMax }); - } else { - chart.pan(panIntent); - } + chart.pan(panIntent, targetYAxis); return; } @@ -431,13 +427,7 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha if (Math.abs(1 - factor) < 1e-4) return; const intent = applyZoomPolicy({ factor, cx, cy, axis: zoomAxis }, targetYAxis ?? "left"); if (!intent) return; - if (targetYAxis && zoomAxis === "y") { - const next = chart.getCamera(targetYAxis).clone(); - next.zoom(intent); - chart.setYViewport(targetYAxis, { yMin: next.yMin, yMax: next.yMax }); - } else { - chart.zoom(intent); - } + chart.zoom(intent, targetYAxis); }; const onCanvasWheel = (event: WheelEvent): void => { @@ -462,6 +452,7 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha const resetToCapturedViewport = (): void => { const target = options.resetViewport?.() ?? resetViewport ?? normalizeViewport(chart.getViewport()); chart.setViewport(target); + if (resetRightViewport) chart.setYViewport("right", resetRightViewport); if (options.resumeFollowOnReset !== false) chart.resumeLatestXFollow(); }; @@ -482,25 +473,13 @@ export function interactionsPlugin(options: InteractionsPluginOptions = {}): Cha const applyTouchPan = (axis: ZoomAxis, yAxis: SeriesYAxis | undefined, dx: number, dy: number): void => { const intent = applyPanPolicy({ dx, dy }, axis, yAxis ?? "left"); if (!intent) return; - if (yAxis && axis === "y") { - const next = chart.getCamera(yAxis).clone(); - next.pan({ dx: 0, dy: intent.dy }); - chart.setYViewport(yAxis, { yMin: next.yMin, yMax: next.yMax }); - } else { - chart.pan(intent); - } + chart.pan(intent, yAxis); }; const applyTouchZoom = (axis: ZoomAxis, yAxis: SeriesYAxis | undefined, factor: number, cx: number, cy: number): void => { const intent = applyZoomPolicy({ factor, cx, cy, axis }, yAxis ?? "left"); if (!intent) return; - if (yAxis && axis === "y") { - const next = chart.getCamera(yAxis).clone(); - next.zoom(intent); - chart.setYViewport(yAxis, { yMin: next.yMin, yMax: next.yMax }); - } else { - chart.zoom(intent); - } + chart.zoom(intent, yAxis); }; const onTouchStart = (event: TouchEvent): void => { diff --git a/tests/browser/visual/main.ts b/tests/browser/visual/main.ts index 0af10ad..47013bb 100644 --- a/tests/browser/visual/main.ts +++ b/tests/browser/visual/main.ts @@ -134,6 +134,7 @@ function optionsForCase(name: VisualCase): ConstructorParameters[1 axes: { x: { position: "outside", scale: "log", logBase: 2, reversed: true, title: "log2 reversed" }, y: { position: "outside", scale: "symlog", symlogConstant: 2, reversed: true, title: "symlog reversed" }, + y2: { visible: true, position: "outside", scale: "log", title: "log right" }, }, grid: true, }; @@ -314,7 +315,9 @@ function addScaleOptions(chart: Chart): void { const x = Float64Array.from({ length: 256 }, (_, i) => 2 ** (i / 32)); const y = Float32Array.from({ length: 256 }, (_, i) => Math.sin(i * 0.12) * 8); chart.addLine({ dataset: new StaticDataset(x, y), name: "scale options" }, { lineWidth: 2 }); + chart.addLine({ dataset: new StaticDataset(x, Float32Array.from(y, (value) => Math.abs(value) + 1)), yAxis: "right", name: "right log" }, { lineWidth: 1 }); chart.setViewport({ xMin: 1, xMax: 256, yMin: -10, yMax: 10 }); + chart.setYViewport("right", { yMin: 1, yMax: 100 }); } function wave(count: number, phase = 0): { x: Float64Array; y: Float32Array } { @@ -354,5 +357,17 @@ function assertCaseDom(name: VisualCase, chart: Chart): void { if (name === "scale-options") { assert(chart.getCamera().xReversed, "x axis reversed"); assert(chart.getCamera().yReversed, "y axis reversed"); + const [plotX, plotY] = chart.dataToPlot(8, 3); + const rect = chart.canvas.getBoundingClientRect(); + const roundTrip = chart.clientToData(rect.left + plotX, rect.top + plotY); + assert(!!roundTrip && Math.abs(roundTrip[0] - 8) < 1e-5 && Math.abs(roundTrip[1] - 3) < 1e-5, "scaled coordinates round-trip"); + const x1 = chart.dataToPlot(1, 0)[0]; + const x2 = chart.dataToPlot(2, 0)[0]; + const x4 = chart.dataToPlot(4, 0)[0]; + assert(Math.abs((x1 - x2) - (x2 - x4)) < 0.01, "log geometry is evenly spaced"); + const leftBefore = chart.getViewport(); + chart.pan({ dx: 0, dy: 0.5 }, "right"); + assert(Math.abs(chart.getViewport("right").yMin - 10) < 1e-5, "right log axis pans in scale space"); + assert(chart.getViewport().yMin === leftBefore.yMin && chart.getViewport().yMax === leftBefore.yMax, "right pan preserves left axis"); } } diff --git a/tests/core/SeriesStore.test.ts b/tests/core/SeriesStore.test.ts index ee90afd..0ccd52d 100644 --- a/tests/core/SeriesStore.test.ts +++ b/tests/core/SeriesStore.test.ts @@ -132,6 +132,12 @@ class TrackingRangeDataset implements RangeMinMaxDataset { } } +class FiniteGapRangeDataset extends TrackingRangeDataset { + isGap(index: number): boolean { + return index === 1; + } +} + describe("SeriesStore", () => { it("appends numeric typed arrays", () => { const series = makeSeries(); @@ -352,6 +358,31 @@ describe("SeriesStore", () => { expect(dataset.getYCalls).toBe(0); }); + it("uses one range query for accelerated data bounds", () => { + const dataset = new TrackingRangeDataset([0, 1, 2, 3, 4, 5], [3, 7, 1, 9, 5, 2]); + const series = new SeriesStore( + dataset, + { mode: "line", dataset, downsample: "minmax" }, + { color: [1, 1, 1, 1], lineWidth: 1 }, + ); + + expect(series.dataBounds({ xMin: 1, xMax: 4 })).toEqual({ xMin: 1, xMax: 4, yMin: 1, yMax: 9 }); + expect(dataset.rangeCalls).toBe(1); + expect(dataset.getYCalls).toBe(2); + }); + + it("does not aggregate range bounds across finite explicit gaps without an opt-in", () => { + const dataset = new FiniteGapRangeDataset([0, 1, 2, 3], [3, 100, 1, 9]); + const series = new SeriesStore( + dataset, + { mode: "line", dataset, downsample: "minmax" }, + { color: [1, 1, 1, 1], lineWidth: 1 }, + ); + + expect(series.dataBounds()).toEqual({ xMin: 0, xMax: 3, yMin: 1, yMax: 9 }); + expect(dataset.rangeCalls).toBe(3); + }); + it("queries LOD buckets through dataset range min/max aggregation when available", () => { const dataset = new TrackingRangeDataset([0, 1, 2, 3, 4, 5], [3, 7, 1, 9, 5, 2]); const series = new SeriesStore( diff --git a/tests/interaction/AxisController.test.ts b/tests/interaction/AxisController.test.ts index f4c045b..690e62a 100644 --- a/tests/interaction/AxisController.test.ts +++ b/tests/interaction/AxisController.test.ts @@ -142,6 +142,36 @@ describe("AxisController", () => { expect(() => wideLinearAxis.validateDomain("x")).not.toThrow(); }); + it("maps logarithmic and reversed axes through clip space", () => { + const camera = new Camera2D(); + camera.setViewport({ xMin: 1, xMax: 100, yMin: -10, yMax: 10 }); + camera.setReversed({ x: true }); + const axis = new AxisController(camera, { + x: { scale: "log" }, + y: { scale: "symlog", symlogConstant: 1 }, + }); + + expect(axis.valueToClip(1, "x")).toBeCloseTo(1); + expect(axis.valueToClip(10, "x")).toBeCloseTo(0); + expect(axis.valueToClip(100, "x")).toBeCloseTo(-1); + expect(axis.clipToValue(0, "x")).toBeCloseTo(10); + expect(axis.clipToValue(axis.valueToClip(-4, "y"), "y")).toBeCloseTo(-4); + }); + + it("pans and zooms nonlinear domains in scale space", () => { + const camera = new Camera2D(); + camera.setViewport({ xMin: 1, xMax: 100, yMin: 1, yMax: 100 }); + const axis = new AxisController(camera, { x: { scale: "log" }, y: { scale: "log" } }); + + axis.pan({ dx: 0.5, dy: 0 }); + expect(camera.xMin).toBeCloseTo(10); + expect(camera.xMax).toBeCloseTo(1000); + + axis.zoom({ factor: 2, cx: 0.5, cy: 0.5, axis: "y" }); + expect(camera.yMin).toBeCloseTo(Math.sqrt(10)); + expect(camera.yMax).toBeCloseTo(10 * Math.sqrt(10)); + }); + it("formats categorical ticks from labels", () => { const camera = new Camera2D(); camera.setViewport({ xMin: 0, xMax: 3, yMin: -1, yMax: 1 }); diff --git a/vite.config.ts b/vite.config.ts index f5145cf..075264f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,7 +31,6 @@ export default defineConfig(({ command, mode }) => { core: resolve(__dirname, "src/core/index.ts"), interaction: resolve(__dirname, "src/interaction/index.ts"), render: resolve(__dirname, "src/render/index.ts"), - react: resolve(__dirname, "src/react.ts"), linked: resolve(__dirname, "src/linked.ts"), "linked-core": resolve(__dirname, "src/linked-core.ts"), data: resolve(__dirname, "src/data.ts"), @@ -48,9 +47,6 @@ export default defineConfig(({ command, mode }) => { formats: ["es"], fileName: (_format, entryName) => `${entryName}.js`, }, - rollupOptions: { - external: ["react"], - }, }, server: { open: process.env.BLAZEPLOT_BENCH !== "1", diff --git a/website/src/site/components/home-page.ts b/website/src/site/components/home-page.ts index dd966c5..d2d7ef2 100644 --- a/website/src/site/components/home-page.ts +++ b/website/src/site/components/home-page.ts @@ -7,7 +7,7 @@ import { tooltipPlugin } from "../../../../src/plugins/tooltip.ts"; import { renderMarkdown } from "../../markdown.ts"; import overviewMarkdown from "../../../../docs/overview.md?raw"; import logoUrl from "../../blazeplot-dark-cropped.png"; -import { demoOhlcValues, demoSignal } from "../charts/signals.ts"; +import { demoOhlcValues, demoSignal, lineData } from "../charts/signals.ts"; import { showChartFallback } from "../charts/dom.ts"; import { siteStyles } from "../styles.ts"; import type { HomeChartMode, HomeDataMode } from "../shared.ts"; @@ -193,12 +193,7 @@ export class BlazeplotHomePage extends LitElement { return { append: (x) => series.append({ x, y: demoSignal(x, 0) }) }; } - const x = new Float32Array(count); - const y = new Float32Array(count); - for (let i = 0; i < count; i += 1) { - x[i] = i; - y[i] = demoSignal(i, 0); - } + const { x, y } = lineData(count); chart.addLine({ dataset: new StaticDataset(x, y), name: "line" }, { color: [0.988, 0.29, 0.02, 1], lineWidth: 2 }); return null; } @@ -213,12 +208,7 @@ export class BlazeplotHomePage extends LitElement { } for (let series = 0; series < colors.length; series += 1) { - const x = new Float32Array(count); - const y = new Float32Array(count); - for (let i = 0; i < count; i += 1) { - x[i] = i; - y[i] = demoSignal(i, series); - } + const { x, y } = lineData(count, series); chart.addLine({ dataset: new StaticDataset(x, y), name: `series ${series + 1}` }, { color: colors[series]!, lineWidth: 1.5 }); } return null; diff --git a/website/src/site/previews-controller.ts b/website/src/site/previews-controller.ts index bd6bf63..4ff50aa 100644 --- a/website/src/site/previews-controller.ts +++ b/website/src/site/previews-controller.ts @@ -10,8 +10,8 @@ import { navigatorPlugin } from "../../../src/plugins/navigator.ts"; import { tooltipPlugin } from "../../../src/plugins/tooltip.ts"; import { ProceduralLineDataset } from "../ProceduralLineDataset.ts"; import { DEFAULT_APPEND_RATE, LIVE_BATCH_SIZE, MAX_VIEW_SAMPLES, OHLC_INTERVAL, SPARSE_INTERVAL, VIEW_SAMPLES, Y_VIEW, type PreviewDataBatch } from "../preview-data-config.ts"; -import { showChartFallback } from "./charts/dom.ts"; -import { demoSignal } from "./charts/signals.ts"; +import { addDisposableListener, showChartFallback } from "./charts/dom.ts"; +import { lineData } from "./charts/signals.ts"; import { PREVIEWS, type PreviewId } from "./shared.ts"; interface PreviewHost extends ReactiveControllerHost { @@ -44,11 +44,6 @@ export class PreviewChartsController implements ReactiveController { return control; } - private addDisposableListener(element: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): void { - element.addEventListener(type, listener as EventListener); - this.previewDisposers.push(() => element.removeEventListener(type, listener as EventListener)); - } - private get previewIndex(): number { const index = PREVIEWS.findIndex((preview) => preview.id === this.host.previewId); return index >= 0 ? index : 0; @@ -338,7 +333,7 @@ export class PreviewChartsController implements ReactiveController { dataWorker.addEventListener("message", onWorkerMessage); this.previewDisposers.push(() => dataWorker.removeEventListener("message", onWorkerMessage)); - const addListener = (element: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): void => this.addDisposableListener(element, type, listener); + const addListener = (element: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): void => addDisposableListener(this.previewDisposers, element, type, listener); const historySamples = (): number => Math.max(1, viewSamples); const sparseHistoryCapacity = (): number => Math.ceil(historySamples() / SPARSE_INTERVAL) + 2; @@ -888,7 +883,7 @@ export class PreviewChartsController implements ReactiveController { liveChart.plotElement.appendChild(candleHighlightOverlay); this.previewDisposers.push(() => candleHighlightOverlay.remove()); - const addListener = (element: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): void => this.addDisposableListener(element, type, listener); + const addListener = (element: HTMLElement, type: K, listener: (event: HTMLElementEventMap[K]) => void): void => addDisposableListener(this.previewDisposers, element, type, listener); const loadKlines = async (): Promise => { const symbol = symbolSelect.value; @@ -1165,12 +1160,7 @@ export class PreviewChartsController implements ReactiveController { }); this.previewCharts.push(chart); const count = 420; - const x = new Float32Array(count); - const y = new Float32Array(count); - for (let i = 0; i < count; i += 1) { - x[i] = i; - y[i] = demoSignal(i, 0); - } + const { x, y } = lineData(count); chart.addLine({ dataset: new StaticDataset(x, y), name: "mobile" }, { color: [0.988, 0.29, 0.02, 1], lineWidth: 2 }); chart.setViewport({ xMin: 0, xMax: count - 1, yMin: -1.35, yMax: 1.35 }); chart.start();