diff --git a/AGENTS.md b/AGENTS.md index efa9216..ddd562d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,71 +1,98 @@ # AGENTS.md +## Sources of Truth / How to Refresh This File + +Keep this file as a quick operational guide, not the canonical source. When updating it, verify against these files first: + +- Commands, package manager version, npm exports, package files, and dependency constraints: `package.json` and `bun.lock`. +- Library build entries and declaration output: `vite.config.ts`, `tsconfig.build.json`, and `src/*` barrel exports. +- Website build and routing: `vite.pages.config.ts`, `website/`, and `docs/README.md`. +- GitHub CI, Pages, Cloudflare preview, and release automation: `.github/workflows/*.yml` plus `docs/internal/github-workflows.md`. +- Branch/release process and benchmark policy: `docs/release-and-benchmarks.md` and `docs/internal/release-checklist.md`. +- Local validation commands: `docs/internal/local-development.md` and current `package.json#scripts`. +- Documentation ownership and generated-doc rules: `docs/README.md` and `docs/documentation-contributions.md`. +- Current implementation behavior: source under `src/` and focused tests under `tests/`; code and tests beat stale prose. + ## Commands -- Use Bun; `bun.lock` is the lockfile and `bun test` is the test runner. -- Install deps with `bun install` if `node_modules/` is missing. -- Run all tests: `bun test`. +- Use Bun; `package.json#packageManager` pins the expected Bun version and `bun.lock` is the lockfile. +- Install deps with `bun install` locally if `node_modules/` is missing; CI uses `bun install --frozen-lockfile`. +- Run all unit tests: `bun test`. - Run one test file: `bun test tests/core/RingBuffer.test.ts`. - Run one named test: `bun test tests/core/RingBuffer.test.ts -t "wraps around"`. - Typecheck: `bun run typecheck` (`tsc --noEmit`). -- Build the npm package: `bun run build` (Vite/Rolldown library build + declaration emit via `vite-plugin-dts`). +- Build the npm package: `bun run build` (Vite library build plus declaration emit via `vite-plugin-dts`). - Build JS only: `bun run build:js`. -- Full CI locally: `bun run ci` (typecheck + tests + package build + package export smoke test + package contents dry-run + bundle-size check + headless benchmark smoke test + automated chart visual tests + automated interaction tests). -- Benchmark smoke test only: `bun run bench:ci` (`ci-smoke` scenario in a headless Chrome/Chromium/Brave browser). Set `BLAZEPLOT_BENCH_CHROME=/path/to/browser` if auto-detection fails. +- Build the docs/site: `bun run pages:build`; preview with `bun run pages:preview`. +- Dev server: `bun run dev` serves the Lit website (`website/`) with integrated docs and previews. Use `bun run legacy-preview:dev` only for browser fixture debugging under `tests/browser/`. +- Full CI locally: `bun run ci` (typecheck + unit tests + package build + generated-doc checks + docs snippet typecheck + package export smoke test + package contents dry-run + bundle-size check + headless benchmark smoke test + automated chart visual tests + automated interaction tests). +- Generated docs check only: `bun run test:generated-docs`. +- Documentation snippet typecheck only: `bun run test:docs-snippets`. +- Regenerate README/API docs: `bun run docs:readme` (builds `dist/`, regenerates `docs/api-reference.md`, and refreshes generated README docs sections). +- Benchmark smoke test only: `bun run bench:ci` (`ci-smoke` scenario in a headless Chrome/Chromium/Brave browser). Set `BLAZEPLOT_BENCH_CHROME=/path/to/browser` or `CHROME_PATH=/path/to/browser` if auto-detection fails. - Public manual comparison benchmark: `bun run bench:compare` (headed by default, fully automated after launch, compares BlazePlot/uPlot/Chart.js, overwrites `benchmarks/latest.json` and `benchmarks/latest.md`; not part of CI). -- Chart visual tests only: `bun run test:visual` (renders one focused browser case per chart/plugin feature, asserts render/DOM/screenshot output, and writes screenshots to `build/visual-tests/`). -- Browser interaction tests only: `bun run test:interaction` (automates hover, crosshair, wheel zoom, shift-drag pan, box zoom, reset, and selection through Chrome DevTools Protocol input events). +- Run one benchmark scenario: `bun run bench -- --scenario `. - Append benchmark results to the current release changelog: `bun run release:benchmarks`. -- Preview package contents: `bun pm pack --dry-run`. -- Dev server: `bun run dev` serves the Lit website (`website/`) with integrated docs and previews. Use `bun run legacy-preview:dev` only for browser fixture debugging under `tests/browser/`. +- Append benchmark report markdown to docs or another path: `bun run bench:report`. +- Chart visual tests only: `bun run test:visual` (renders focused browser cases per chart/plugin feature and writes screenshots to `build/visual-tests/`). +- Browser interaction tests only: `bun run test:interaction` (automates hover, crosshair, wheel zoom, shift-drag pan, box zoom, reset, and selection through Chrome DevTools Protocol input events). +- Package export smoke test: `bun run test:exports`. +- Package contents dry-run: `bun run test:package` or `bun pm pack --dry-run`. +- Bundle-size budget check: `bun run test:bundle-size`; markdown summary: `bun run docs:bundle-size`; detailed analysis: `bun run bundle:analyze`. - There is no lint or formatter script in `package.json`. ## Branch and Release Flow -- `main` is the protected release branch. It requires PRs, the `validate` status check, up-to-date branches, conversation resolution, linear history, and blocks force-push/deletion. It does **not** require approving reviews so the agent can merge its own tested release PRs when asked. -- `development` is the integration branch for normal work. Open feature/fix PRs into `development`; open release PRs from `development` into `main`. -- Implement each requested feature/fix on its own branch from updated `development` (for example `feature/` or `docs/`), make focused commits there, then merge it back to `development`. Do not stack unrelated changes in one feature branch. -- Prefer `git merge --no-ff ` when merging completed feature branches back to `development` so feature boundaries remain visible in history. -- Do not open PRs to `main` until the user explicitly asks for a release PR. Normal completed work should stop after merging to `development` and pushing it. -- GitHub Pages deploys on pushes to `main` and `development`. The stable site is served at `https://blazeplot.cervelli.dev/`; stable previews are at `https://blazeplot.cervelli.dev/previews`; the in-progress `development` site is served at `https://blazeplot.cervelli.dev/development/`; integrated development previews live under `/development/previews`. -- Releases are merge-to-main based. Do not use tag-push/manual release scripts. +- `development` is the integration branch for normal feature, fix, docs, and workflow work. Branch from an updated `development`. +- Implement each requested feature/fix on its own branch, for example `feature/`, `fix/`, or `docs/`. Keep commits and PRs focused. +- Open a focused feature PR from that branch back to `development` for normal feature/fix/docs work unless the maintainer explicitly asks for a direct local merge. +- Commit `AGENTS.md`/process-guide updates separately from product code, tests, generated docs, or release changes. +- Merge completed feature branches back to `development`; prefer `git merge --no-ff ` when asked to merge locally so feature boundaries remain visible. +- `main` is the protected release branch. Open release PRs from `development` to `main` only when the user explicitly asks for a release PR. +- Do not push tags manually for releases. Tags are outputs of `.github/workflows/release.yml`. +- GitHub Pages deploys on pushes to `main` and `development`. Stable site: `https://blazeplot.cervelli.dev/`; stable previews: `https://blazeplot.cervelli.dev/previews`; development site: `https://blazeplot.cervelli.dev/development/`; development previews: `https://blazeplot.cervelli.dev/development/previews`. +- Maintainers can request feature-branch browser previews with the manual `Cloudflare Pages Preview` workflow. See `docs/release-and-benchmarks.md` and `docs/internal/github-workflows.md` for alias rules and safety notes. - To prepare a release PR: 1. Start on updated `development`. 2. Run `bun run version:patch` (or `version:minor` / `version:major`) to bump `package.json` and create `changelogs/vX.Y.Z.md`. 3. Edit the changelog notes. 4. Run `bun run release:benchmarks` so benchmark tables are included in the version markdown. The release workflow also runs this with `--if-missing` before publishing. - 5. Run `bun run docs:readme` so the README changelog link points at the new version. - 6. Run `bun run ci`, and ideally `bun run pages:build` and `bun pm pack --dry-run`. - 7. Push `development`, open a PR to `main`, wait for `validate`, then merge. -- Merging an unpublished `package.json` version to `main` runs the release workflow: CI, benchmark-result insertion if missing, package pack, npm publish, `vX.Y.Z` tag creation, and GitHub Release creation from `changelogs/vX.Y.Z.md` plus commits. + 5. Run `bun run docs:readme` so generated API docs and README sections are current. + 6. Run `bun run ci`, `bun run pages:build`, and `bun pm pack --dry-run`. + 7. Push `development`, open a PR to `main`, wait for the `validate` check, then merge when approved/authorized. +- Merging an unpublished `package.json` version to `main` runs the release workflow: CI, benchmark-result insertion if missing, package pack, npm publish with provenance, `vX.Y.Z` tag creation, and GitHub Release creation from `changelogs/vX.Y.Z.md` plus commits. - If the `vX.Y.Z` tag already exists, the release workflow skips publishing for that version. ## Project Shape -- Public API exports live in `src/index.ts`. -- npm package output includes the core `dist/index.js` / `dist/index.d.ts` plus separate subpath chunks/declarations for `react`, `linked`, `export`, and built-in plugins; package metadata points `exports`, `main`, `module`, and `types` at `dist/`. +- 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. +- 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. +- `src/interaction/` owns `Camera2D`, `AxisController`, and viewport policy/intent types; interaction mutates the camera, not series data. +- `src/ui/Chart.ts` is the chart orchestrator wiring `SeriesStore`, `Renderer`, `WebGL2Backend`, `Camera2D`, `AxisController`, layout/overlays, and optional `ViewportPolicy.beforeRender`. Public typed helpers (`addLine`, `addArea`, `addScatter`, `addBar`, `addOhlc`, `addCandlestick`) delegate to `addSeries`. +- Built-in plugin implementation classes live in `src/ui/`; package plugin entry points live in `src/plugins/` and should stay optional imports. - `website/` is the docs/previews app served by `bun run dev` and built by `bun run pages:build`. Shared website preview data helpers live in `website/src/`. +- `docs/` contains public docs plus maintainer runbooks under `docs/internal/`. `docs/README.md` is the docs map. - `tests/browser/` contains the Vite-served benchmark, visual, and interaction fixture pages used by `bun run bench:ci`, `bun run test:visual`, and `bun run test:interaction`. `bun run legacy-preview:dev` serves this fixture root for debugging. -- `src/core/` is the data engine and should not depend on UI, DOM, or GPU code. -- `src/render/` owns the GPU abstraction and the native WebGL2 implementation. -- `src/interaction/` owns `Camera2D`, tick helpers, and viewport policy/intent types; interaction mutates the camera, not series data. -- `src/ui/Chart.ts` is the orchestrator wiring `SeriesStore`, `Renderer`, `WebGL2Backend`, `Camera2D`, and optional `ViewportPolicy.beforeRender`; public typed helpers (`addLine`, `addArea`, `addScatter`, `addBar`, `addOhlc`, `addCandlestick`) delegate to `addSeries`. Pointer/wheel interactions live in the optional interactions plugin. ## Current Implementation Gotchas -- `WebGL2Backend` requires WebGL2. It implements buffer creation/update, program handles, instanced attributes, scissor clipping, and resource disposal for current line, min/max segment, scatter, bar, area, OHLC, and candlestick rendering needs. +- `WebGL2Backend` requires WebGL2. It implements buffer creation/update, program handles, instanced attributes, scissor clipping, and resource disposal for current line, min/max segment, scatter, bar, area, OHLC, candlestick, and flamegraph rendering needs. - `WebGL2Backend.viewport()` uses WebGL scissor test to clip draws and refreshes the full drawing-buffer viewport after canvas resizes. `clear()` is unaffected and always clears the full canvas. - `ChartLayout` owns the DOM layout. Outside axes reserve real grid gutters, while the WebGL canvas is sized to the plot area only. - `chart.screenshot()` composites the plot WebGL canvas plus built-in DOM text overlays into one exported image; keep DOM overlay text under the chart root for inclusion. - `AxisOverlay` attaches tick label elements either to the plot layer (`inside`) or to the axis gutter layer (`outside`). - Axis `outside` positioning reserves fixed CSS-pixel gutters: 52px left for Y, 28px bottom for X. Defined by `LEFT_AXIS_GUTTER_CSS` / `BOTTOM_AXIS_GUTTER_CSS` in `ChartLayout.ts`. - `MinMaxPyramid` updates incrementally for tail appends and falls back to full rebuild on explicit rebuild/clear. `SeriesStore` detects ring shifts at fixed capacity and avoids per-frame full pyramid rebuilds; dense min/max extraction then uses the optional `RangeMinMaxDataset.rangeMinMaxY()` capability. `RingBuffer` implements that capability with a physical segment tree, so wrapped streaming queries are logarithmic instead of full raw scans. Dense non-wrapped extraction uses `MinMaxPyramid.rangeMinMax()` over pyramid buckets. +- `UniformRingBuffer` is the fixed-rate/evenly-spaced streaming dataset path. Preserve its implicit X-spacing assumptions and public shorthand behavior when changing live-data code. - Area series skip LOD even when `downsample` is omitted. `downsample: "server"` is for server-pre-sampled min/max datasets and renders supplied buckets directly. Scatter series use exact 2D-culled chunks with `downsample: "none"`; default scatter uses a 2D viewport-aware point sampler with min/max interval pruning and only decimates after exact visible extraction exceeds the point budget. Bar series use min/max LOD by default (unless `downsample: "none"`). Dense sampled bars render as expanded triangle buckets spanning the full screen-space sample bucket and including the configured baseline in the min/max range; do not render dense sampled bars as centered raw-position quads or gaps will appear. Scatter/bar prefer instanced quads when browser instancing is available for raw sparse draws, with non-instanced fallbacks (`gl.POINTS` sprites for scatter, expanded triangle quads for bars); area renders as a triangle strip plus line overlay. - `RingBuffer` wraps at capacity by default and exposes logical-order access after wrap; callers can opt into `"drop-new"` or `"error"` overflow semantics when constructing a buffer or via `SeriesConfig.overflow`. - LOD queries use sorted logical X values via `RingBuffer.lowerBoundX` / `upperBoundX`; preserve that assumption when changing append/query code. - `Chart.render()` calls `SeriesStore.rebuildPyramid()` before drawing visible series and re-extracts visible samples/segments from the current `Camera2D` viewport every frame. - `ViewportPolicy` transforms `PanIntent`/`ZoomIntent` and can update `Camera2D` before render. Keep behavior rules there, not in core/rendering. -- Optional built-ins like interactions, legend, tooltip, annotations, selection, crosshair, and navigator are Chart plugins exported from subpaths (`blazeplot/plugins/*`). `Chart` owns only the lightweight plugin contract and public state/pick/camera APIs; avoid importing built-in plugins into `Chart.ts` or the top-level entry. +- Optional built-ins like interactions, legend, tooltip, annotations, selection, crosshair, navigator, and flamegraph are Chart plugins exported from subpaths (`blazeplot/plugins/*`). `Chart` owns only the lightweight plugin contract and public state/pick/camera APIs; avoid importing built-in plugins into `Chart.ts` or the top-level entry. - Hover state refreshes every render while the pointer is inside the plot, so live-follow charts update tooltips even when the cursor is still. `chart.pick()` returns actual raw sample coordinates plus plot/client coordinates for marker overlays. - In the website preview, synced-X behavior keeps live X follow active while wheel zoom/pan are Y-only. @@ -73,14 +100,24 @@ - Package source under `src/` uses ESM-style `.js` relative import specifiers so emitted JS and declarations line up for npm consumers. - Optional plugin subpath entries live under `src/plugins/` and are configured as separate Vite library entries plus `package.json` subpath exports to keep chart-only imports lean. -- Use the `@/*` alias for `src/*` when it improves clarity; it is configured in both `tsconfig.json` and `vite.config.ts`. +- Use the `@/*` alias for `src/*` when it improves clarity; it is configured in both `tsconfig.json` and Vite configs. - Prefer relative imports inside `src/` package code so declaration output does not leak the `@/*` alias. Browser fixtures under `tests/browser/` can use `@/*`. - `tsconfig.json` is strict and enables `noUncheckedIndexedAccess`, `noUnusedLocals`, and `noUnusedParameters`; unused placeholders are usually prefixed with `_`. - `tsconfig.build.json` scopes declaration generation to `src/`; `vite-plugin-dts` emits package declarations during `vite build`. +## Documentation Rules + +- Use `docs/README.md` to decide where a topic belongs before adding or moving docs. +- Do not hand-edit generated sections in `README.md`, `docs/api-reference.md`, or `docs/benchmarks.md`; run `bun run docs:readme` instead. +- Verify documented APIs against source, tests, or generated declarations. +- Complete docs snippets should include imports and lifecycle cleanup for charts, timers, workers, object URLs, and plugin handles. +- For docs changes, run the smallest relevant checks from `docs/documentation-contributions.md`; run `bun run pages:build` when website routing/rendering changes. + ## Tests -- Unit tests cover core data structures (including raw sample picking helpers), OHLC datasets, series extraction, `Camera2D`, and axis behavior (`tests/core`, `tests/interaction`). -- Browser visual tests (`bun run test:visual`) cover focused WebGL/DOM/plugin rendering cases and write screenshots to `build/visual-tests/`. +- Unit tests cover core data structures (including raw sample picking helpers), OHLC/server/static datasets, data export helpers, render helpers, `Camera2D`, and axis behavior (`tests/core`, `tests/data`, `tests/render`, `tests/interaction`). +- Website/docs tests cover generated documentation automation and markdown links (`tests/website`, `bun run test:generated-docs`, `bun run test:docs-snippets`). +- Browser visual tests (`bun run test:visual`) cover focused WebGL/DOM/plugin rendering cases and write screenshots plus summaries to `build/visual-tests/`. - Browser interaction tests (`bun run test:interaction`) drive Chrome DevTools Protocol input events for hover, crosshair, wheel zoom, shift-drag pan, box zoom, reset, and selection. -- Full local validation is `bun run ci`; use targeted test scripts for focused changes when the full browser suite is unnecessary. Run `bun run test:exports` after `bun run build` when package entry points or Vite library entries change; run `bun run test:package` when package metadata or files change. +- Full local validation is `bun run ci`; use targeted test scripts for focused changes when the full browser suite is unnecessary. +- Run `bun run test:exports` after `bun run build` when package entry points or Vite library entries change; run `bun run test:package` when package metadata or files change; run `bun run test:bundle-size` when bundle composition may change. diff --git a/README.md b/README.md index 18a414e..1c061a5 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 / 34 KiB gzip**. 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 **148 KiB raw / 34 KiB gzip**. 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`. @@ -193,14 +193,14 @@ Generated from `dist/` after the package build. | Chunk | File | Size | |---|---|---:| -| root entry | `dist/index.js` | 1.6 KiB | -| core subpath entry | `dist/core.js` | 0.7 KiB | +| root entry | `dist/index.js` | 2.3 KiB | +| core subpath entry | `dist/core.js` | 0.8 KiB | | interaction subpath entry | `dist/interaction.js` | 0.1 KiB | | render subpath entry | `dist/render.js` | 0.3 KiB | | react entry | `dist/react.js` | 0.7 KiB | | linked entry | `dist/linked.js` | 0.4 KiB | | linked core entry | `dist/linked-core.js` | 0.1 KiB | -| data entry | `dist/data.js` | 4.9 KiB | +| data entry | `dist/data.js` | 5.1 KiB | | export entry | `dist/export.js` | 1.3 KiB | | interactions plugin | `dist/plugins/interactions.js` | 15.4 KiB | | annotations plugin | `dist/plugins/annotations.js` | 9.3 KiB | @@ -210,16 +210,16 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0.1 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0.1 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 20.7 KiB | -| shared Chart chunk | `dist/Chart-BXL4MXf-.js` | 56.5 KiB | -| shared streaming data chunk | `dist/UniformRingBuffer-C5yCnNeR.js` | 44.3 KiB | -| shared OhlcDataset chunk | `dist/OhlcDataset-DmFKmNbM.js` | 11.2 KiB | -| shared AxisController chunk | `dist/AxisController-CCk21uVK.js` | 13.8 KiB | -| shared WebGL2Backend chunk | `dist/WebGL2Backend-DivtLMNz.js` | 22.0 KiB | -| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-CYBw2rB_.js` | 2.1 KiB | -| lazy screenshot chunk | `dist/screenshot-PUXj6UGd.js` | 3.5 KiB | -| shared OverlayUtils chunk | `dist/OverlayUtils-BoCHW3n7.js` | 3.1 KiB | -| shared Tooltip chunk | `dist/Tooltip-D7t8uZIg.js` | 5.8 KiB | -| shared Crosshair chunk | `dist/Crosshair-BViz8JUB.js` | 9.9 KiB | +| shared Chart chunk | `dist/Chart-DstwTTQs.js` | 57.2 KiB | +| shared streaming data chunk | `dist/UniformRingBuffer-DVJiaja6.js` | 44.0 KiB | +| shared OhlcDataset chunk | `dist/OhlcDataset-BzaK030U.js` | 8.6 KiB | +| shared AxisController chunk | `dist/AxisController-B5zX7JBz.js` | 13.8 KiB | +| shared WebGL2Backend chunk | `dist/WebGL2Backend-DDGRc0UJ.js` | 22.0 KiB | +| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-zxImWpgT.js` | 2.1 KiB | +| lazy screenshot chunk | `dist/screenshot-CljRIqNW.js` | 3.5 KiB | +| shared OverlayUtils chunk | `dist/OverlayUtils-YP9KSKL4.js` | 3.1 KiB | +| shared Tooltip chunk | `dist/Tooltip-a9NkZoPg.js` | 5.8 KiB | +| shared Crosshair chunk | `dist/Crosshair-C426MhNr.js` | 9.9 KiB | ### All public exports @@ -278,6 +278,7 @@ Generated from `dist/index.d.ts` after the package build. | `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`. | @@ -293,6 +294,15 @@ Generated from `dist/index.d.ts` after the package build. | `GpuCapabilities` | interface | `./render/types` | Feature flags reported by a GPU backend. | | `GpuProgram` | interface | `./render/types` | Opaque handle for a linked GPU program. | | `GpuResource` | type | `./render/types` | GPU resource accepted by backend disposal. | +| `histogram` | function | `./core/Histogram` | Convert one-dimensional finite values into histogram bins. | +| `HistogramBin` | interface | `./core/Histogram` | One histogram bucket, suitable for rendering as a bar centered at `x`. | +| `HistogramBinThresholds` | type | `./core/Histogram` | Explicit bin edges, or a requested number of equal-width bins. | +| `histogramDataset` | function | `./core/Histogram` | Build a StaticDataset from histogram bucket centers and normalized counts. | +| `HistogramDataset` | class | `./core/Histogram` | Static histogram dataset that preserves each bucket's X interval for picks and tooltips. | +| `HistogramNormalization` | type | `./core/Histogram` | Histogram value normalization modes. | +| `HistogramOptions` | interface | `./core/Histogram` | Options for converting one-dimensional values into histogram bins. | +| `HistogramResult` | interface | `./core/Histogram` | Result of a histogram transform. | +| `HistogramSeriesConfig` | interface | `./ui/Chart` | Series configuration for `Chart.addHistogram(...)` from raw one-dimensional values. | | `isWebGL2Available` | function | `./render/WebGL2Backend` | Return whether the current environment can create a WebGL2 context. | | `LODBucket` | interface | `./core/types` | Min/max aggregate for a contiguous X range. | | `LODStrategy` | type | `./core/types` | Downsampling strategy used when a series is denser than the plot. | @@ -304,6 +314,7 @@ Generated from `dist/index.d.ts` after the package build. | `OhlcRingBuffer` | class | `./core/OhlcDataset` | Fixed-capacity streaming buffer for OHLC/candlestick data. | | `OhlcRingBufferOptions` | interface | `./core/OhlcDataset` | Options for `OhlcRingBuffer`. | | `PanIntent` | interface | `./interaction/types` | Pan request expressed in data units or screen pixels. | +| `PrecomputedHistogramSeriesConfig` | interface | `./ui/Chart` | Series configuration for `Chart.addHistogram(...)` from precomputed bins. | | `RangeMinMaxDataset` | interface | `./core/types` | Dataset that can answer min/max Y queries for index ranges. | | `RangeSampleCopyDataset` | interface | `./core/types` | Optional high-performance extraction capability for datasets that can copy raw samples without going through repeated getX/getY calls. Implement this for very large datasets, implicit-X datasets, or remote/memory-mapped sources. | | `ReglBackend` | const | `./render/WebGL2Backend` | Deprecated alias for WebGL2Backend. This preserves the pre-native-backend public API. Deprecated: Effective next patch release. Use WebGL2Backend. | @@ -355,6 +366,8 @@ Generated from `dist/index.d.ts` after the package build. | `VisibleSampleCopyDataset` | interface | `./core/types` | Optional high-performance stable visible sampling capability. Unlike copySamplesRange, this method may stride/downsample, but should choose samples anchored to data coordinates so streamed appends do not make existing sampled points jitter. | | `WebGL2Backend` | class | `./render/WebGL2Backend` | Native WebGL2 implementation of BlazePlot's GPU backend. | | `WebGL2UnavailableError` | class | `./render/WebGL2Backend` | Error thrown when a WebGL2 backend cannot be created. | +| `XRange` | interface | `./core/types` | Data-domain X interval represented by one dataset sample. | +| `XRangeDataset` | interface | `./core/types` | Dataset whose sample X values represent intervals rather than points. | | `YAppendableDataset` | interface | `./core/types` | Dataset that accepts appended Y samples with implicit X values. | | `YUpdatableDataset` | interface | `./core/types` | Dataset that supports updating existing Y values. | | `ZoomAxis` | type | `./interaction/types` | Axis affected by a zoom operation. | diff --git a/docs/api-reference.md b/docs/api-reference.md index 20b4fe0..bc74657 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -50,14 +50,14 @@ Generated from `dist/` after the package build. | Chunk | File | Size | |---|---|---:| -| root entry | `dist/index.js` | 1.6 KiB | -| core subpath entry | `dist/core.js` | 0.7 KiB | +| root entry | `dist/index.js` | 2.3 KiB | +| core subpath entry | `dist/core.js` | 0.8 KiB | | interaction subpath entry | `dist/interaction.js` | 0.1 KiB | | render subpath entry | `dist/render.js` | 0.3 KiB | | react entry | `dist/react.js` | 0.7 KiB | | linked entry | `dist/linked.js` | 0.4 KiB | | linked core entry | `dist/linked-core.js` | 0.1 KiB | -| data entry | `dist/data.js` | 4.9 KiB | +| data entry | `dist/data.js` | 5.1 KiB | | export entry | `dist/export.js` | 1.3 KiB | | interactions plugin | `dist/plugins/interactions.js` | 15.4 KiB | | annotations plugin | `dist/plugins/annotations.js` | 9.3 KiB | @@ -67,16 +67,16 @@ Generated from `dist/` after the package build. | tooltip plugin entry | `dist/plugins/tooltip.js` | 0.1 KiB | | crosshair plugin entry | `dist/plugins/crosshair.js` | 0.1 KiB | | flamegraph plugin | `dist/plugins/flamegraph.js` | 20.7 KiB | -| shared Chart chunk | `dist/Chart-BXL4MXf-.js` | 56.5 KiB | -| shared streaming data chunk | `dist/UniformRingBuffer-C5yCnNeR.js` | 44.3 KiB | -| shared OhlcDataset chunk | `dist/OhlcDataset-DmFKmNbM.js` | 11.2 KiB | -| shared AxisController chunk | `dist/AxisController-CCk21uVK.js` | 13.8 KiB | -| shared WebGL2Backend chunk | `dist/WebGL2Backend-DivtLMNz.js` | 22.0 KiB | -| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-CYBw2rB_.js` | 2.1 KiB | -| lazy screenshot chunk | `dist/screenshot-PUXj6UGd.js` | 3.5 KiB | -| shared OverlayUtils chunk | `dist/OverlayUtils-BoCHW3n7.js` | 3.1 KiB | -| shared Tooltip chunk | `dist/Tooltip-D7t8uZIg.js` | 5.8 KiB | -| shared Crosshair chunk | `dist/Crosshair-BViz8JUB.js` | 9.9 KiB | +| shared Chart chunk | `dist/Chart-DstwTTQs.js` | 57.2 KiB | +| shared streaming data chunk | `dist/UniformRingBuffer-DVJiaja6.js` | 44.0 KiB | +| shared OhlcDataset chunk | `dist/OhlcDataset-BzaK030U.js` | 8.6 KiB | +| shared AxisController chunk | `dist/AxisController-B5zX7JBz.js` | 13.8 KiB | +| shared WebGL2Backend chunk | `dist/WebGL2Backend-DDGRc0UJ.js` | 22.0 KiB | +| shared LinkedChartsCore chunk | `dist/LinkedChartsCore-zxImWpgT.js` | 2.1 KiB | +| lazy screenshot chunk | `dist/screenshot-CljRIqNW.js` | 3.5 KiB | +| shared OverlayUtils chunk | `dist/OverlayUtils-YP9KSKL4.js` | 3.1 KiB | +| shared Tooltip chunk | `dist/Tooltip-a9NkZoPg.js` | 5.8 KiB | +| shared Crosshair chunk | `dist/Crosshair-C426MhNr.js` | 9.9 KiB | ### All public exports @@ -135,6 +135,7 @@ Generated from `dist/index.d.ts` after the package build. | `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`. | @@ -150,6 +151,15 @@ Generated from `dist/index.d.ts` after the package build. | `GpuCapabilities` | interface | `./render/types` | Feature flags reported by a GPU backend. | | `GpuProgram` | interface | `./render/types` | Opaque handle for a linked GPU program. | | `GpuResource` | type | `./render/types` | GPU resource accepted by backend disposal. | +| `histogram` | function | `./core/Histogram` | Convert one-dimensional finite values into histogram bins. | +| `HistogramBin` | interface | `./core/Histogram` | One histogram bucket, suitable for rendering as a bar centered at `x`. | +| `HistogramBinThresholds` | type | `./core/Histogram` | Explicit bin edges, or a requested number of equal-width bins. | +| `histogramDataset` | function | `./core/Histogram` | Build a StaticDataset from histogram bucket centers and normalized counts. | +| `HistogramDataset` | class | `./core/Histogram` | Static histogram dataset that preserves each bucket's X interval for picks and tooltips. | +| `HistogramNormalization` | type | `./core/Histogram` | Histogram value normalization modes. | +| `HistogramOptions` | interface | `./core/Histogram` | Options for converting one-dimensional values into histogram bins. | +| `HistogramResult` | interface | `./core/Histogram` | Result of a histogram transform. | +| `HistogramSeriesConfig` | interface | `./ui/Chart` | Series configuration for `Chart.addHistogram(...)` from raw one-dimensional values. | | `isWebGL2Available` | function | `./render/WebGL2Backend` | Return whether the current environment can create a WebGL2 context. | | `LODBucket` | interface | `./core/types` | Min/max aggregate for a contiguous X range. | | `LODStrategy` | type | `./core/types` | Downsampling strategy used when a series is denser than the plot. | @@ -161,6 +171,7 @@ Generated from `dist/index.d.ts` after the package build. | `OhlcRingBuffer` | class | `./core/OhlcDataset` | Fixed-capacity streaming buffer for OHLC/candlestick data. | | `OhlcRingBufferOptions` | interface | `./core/OhlcDataset` | Options for `OhlcRingBuffer`. | | `PanIntent` | interface | `./interaction/types` | Pan request expressed in data units or screen pixels. | +| `PrecomputedHistogramSeriesConfig` | interface | `./ui/Chart` | Series configuration for `Chart.addHistogram(...)` from precomputed bins. | | `RangeMinMaxDataset` | interface | `./core/types` | Dataset that can answer min/max Y queries for index ranges. | | `RangeSampleCopyDataset` | interface | `./core/types` | Optional high-performance extraction capability for datasets that can copy raw samples without going through repeated getX/getY calls. Implement this for very large datasets, implicit-X datasets, or remote/memory-mapped sources. | | `ReglBackend` | const | `./render/WebGL2Backend` | Deprecated alias for WebGL2Backend. This preserves the pre-native-backend public API. Deprecated: Effective next patch release. Use WebGL2Backend. | @@ -212,6 +223,8 @@ Generated from `dist/index.d.ts` after the package build. | `VisibleSampleCopyDataset` | interface | `./core/types` | Optional high-performance stable visible sampling capability. Unlike copySamplesRange, this method may stride/downsample, but should choose samples anchored to data coordinates so streamed appends do not make existing sampled points jitter. | | `WebGL2Backend` | class | `./render/WebGL2Backend` | Native WebGL2 implementation of BlazePlot's GPU backend. | | `WebGL2UnavailableError` | class | `./render/WebGL2Backend` | Error thrown when a WebGL2 backend cannot be created. | +| `XRange` | interface | `./core/types` | Data-domain X interval represented by one dataset sample. | +| `XRangeDataset` | interface | `./core/types` | Dataset whose sample X values represent intervals rather than points. | | `YAppendableDataset` | interface | `./core/types` | Dataset that accepts appended Y samples with implicit X values. | | `YUpdatableDataset` | interface | `./core/types` | Dataset that supports updating existing Y values. | | `ZoomAxis` | type | `./interaction/types` | Axis affected by a zoom operation. | diff --git a/docs/data-semantics.md b/docs/data-semantics.md index 4286f36..d4cfd71 100644 --- a/docs/data-semantics.md +++ b/docs/data-semantics.md @@ -13,6 +13,7 @@ BlazePlot expects finite, sorted X values. Y values are normally finite; non-fin | Historical OHLC/candles | `StaticOhlcDataset` | Bounds and fitting use high/low values. | | Live OHLC/candles | `OhlcRingBuffer` | Rolling OHLC history with explicit time values. | | Server-reduced buckets | `ServerSampledDataset` | Use `downsample: "server"` for min/max buckets. | +| One-dimensional values | `histogram(...)` / `chart.addHistogram(...)` | Converts raw values to bucket centers/counts and renders with the bar path. | | Custom remote/procedural data | `Dataset` or `AcceleratedDataset` | Implement sorted logical access and only the fast paths your data can answer cheaply. | ## Empty datasets @@ -58,6 +59,14 @@ For finite-to-finite session breaks, insert an explicit gap marker sample. `UniformRingBuffer` is for fixed-rate data. It stores Y values and derives X as `xStart + index * xStep`; `xStep` must be positive. Prefer it for telemetry or signal data where every sample is evenly spaced. For chart-owned series, `chart.addLine({ capacity, xStart, xStep })` creates this dataset for you. +## 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. + +`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. + +Variable-width explicit histogram thresholds are supported by the pure `histogram(...)` helper. The chart helper uses one `barWidth` for the whole series, so pass an explicit `style.barWidth` or use uniform-width bins when rendering. + ## Server-sampled datasets `ServerSampledDataset` is for data that was already reduced before it reached the browser. diff --git a/docs/examples.md b/docs/examples.md index 75d6c50..d4a7eed 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -9,6 +9,7 @@ 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 }] })` | | 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(...)` | @@ -21,6 +22,7 @@ All built-in datasets expect sorted X values. If source data arrives out of orde ## On this page - [Basic line chart](#basic-line-chart) — static X/Y data and first render loop. +- [Histogram](#histogram) — raw one-dimensional values rendered with bar buckets. - [Live line chart](#live-line-chart) — rolling windows, fixed-rate samples, and cleanup. - [Server-sampled min/max buckets](#server-sampled-minmax-buckets) — backend-reduced dense history. - [Financial OHLC and candlesticks](#financial-ohlc-and-candlesticks) — market-style series. @@ -87,6 +89,38 @@ chart.fitToData(); chart.start(); ``` +## 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"; + +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" }], + axes: { x: { title: "Latency ms" }, y: { title: "Count" } }, +}); +``` + +:::chart histogram Latency histogram + +For manual charts, precompute or inspect bins with the pure helper: + +```ts +import { Chart, histogram } from "blazeplot"; + +const bins = histogram(values, { binCount: 20, normalize: "density" }); + +const chart = new Chart(element); +chart.addHistogram({ histogram: bins, name: "latency density" }); +chart.fitToData({ includeZero: true }); +chart.start(); +``` + +Normalization modes are `"count"`, `"probability"`, `"percent"`, and `"density"`. Bins are configurable with `binSize`, `binCount`, explicit `thresholds`, `min`, `max`, and `align`; fixed-size bins align to `0` by default, and the built-in tooltip presents interval-backed samples as bucket ranges rather than only midpoint coordinates. Use `histogram(...)` for one-dimensional value frequencies; use `binSamples(...)` when you already have X/Y samples and need to reduce Y values into fixed X intervals. + ## Live line chart Use a ring buffer when old samples can fall out of the visible history window. diff --git a/docs/roadmap.md b/docs/roadmap.md index 128e4ce..6cb9f62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,6 +50,7 @@ BlazePlot is a fast WebGL2 plotting engine for dense browser time-series charts. 7. **Data pipeline helpers** - [x] `blazeplot/data` export and transform helpers. + - [x] Add first-class histogram helpers for one-dimensional value distributions. - [ ] Add optional ingestion helpers for CSV, JSON, typed arrays, and worker-fed batches. - [ ] Document worker/server-side transform guidance for high-rate streams. - [ ] Investigate transfer-friendly and `SharedArrayBuffer` dataset update patterns. @@ -61,7 +62,7 @@ BlazePlot is a fast WebGL2 plotting engine for dense browser time-series charts. 9. **Future visualization modes** - [ ] Error bars and confidence bands. - - [ ] Stacked area/bar and histogram helpers. + - [ ] Stacked area/bar overlays and variable-width histogram bar rendering. - [ ] Heatmap, spectrogram, FFT, and waterfall views if they fit the GPU-first dense-data niche. - [ ] Multiple independent Y axes beyond left/right. - [ ] WebGPU backend after the native WebGL2 backend and lifecycle tests are mature. diff --git a/scripts/visual-test.ts b/scripts/visual-test.ts index 5a32ab5..93d576d 100644 --- a/scripts/visual-test.ts +++ b/scripts/visual-test.ts @@ -43,6 +43,7 @@ const DEFAULT_CASES = [ "area", "scatter", "bar", + "histogram", "ohlc", "candlestick", "axes-title-grid", diff --git a/src/core/Histogram.ts b/src/core/Histogram.ts new file mode 100644 index 0000000..7749ce0 --- /dev/null +++ b/src/core/Histogram.ts @@ -0,0 +1,409 @@ +import { StaticDataset } from "./StaticDataset.js"; +import type { XRange, XRangeDataset } from "./types.js"; + +/** Histogram value normalization modes. */ +export type HistogramNormalization = "count" | "probability" | "density" | "percent"; + +/** Explicit bin edges, or a requested number of equal-width bins. */ +export type HistogramBinThresholds = number | readonly number[]; + +/** Options for converting one-dimensional values into histogram bins. */ +export interface HistogramOptions { + /** Fixed bucket width. Mutually exclusive with thresholds/binCount. */ + readonly binSize?: number; + /** Desired number of equal-width bins. Mutually exclusive with thresholds/binSize. */ + readonly binCount?: number; + /** Explicit sorted bin edges, or a desired bin count. */ + readonly thresholds?: HistogramBinThresholds; + /** Inclusive lower bound. Defaults to finite min(values). */ + readonly min?: number; + /** Inclusive upper bound for data range. Defaults to finite max(values). */ + readonly max?: number; + /** Align fixed-width bins to this origin. Defaults to 0. */ + readonly align?: number; + /** Default: "count". */ + readonly normalize?: HistogramNormalization; + /** Include empty bins between min/max. Default true. */ + readonly includeEmpty?: boolean; + /** Put values equal to max into the final bin. Default true. */ + readonly includeMax?: boolean; +} + +/** One histogram bucket, suitable for rendering as a bar centered at `x`. */ +export interface HistogramBin { + /** Bucket center, for StaticDataset/bar rendering. */ + readonly x: number; + /** Count/probability/density/percent according to `HistogramOptions.normalize`. */ + readonly y: number; + /** Inclusive bucket start edge. */ + readonly xStart: number; + /** Bucket end edge. The final bin includes this edge when includeMax is enabled. */ + readonly xEnd: number; + /** Raw count regardless of normalization. */ + readonly count: number; + readonly index: number; +} + +/** Result of a histogram transform. */ +export interface HistogramResult { + readonly bins: readonly HistogramBin[]; + readonly x: Float64Array; + readonly y: Float32Array; + /** `null` for variable-width explicit edges. */ + readonly binWidth: number | null; + /** Finite values included in bins. */ + readonly total: number; + readonly underflow: number; + readonly overflow: number; + /** NaN/infinite/non-number values skipped before binning. */ + readonly invalid: number; + readonly min: number; + readonly max: number; +} + +interface HistogramEdges { + readonly edges: number[]; + readonly binWidth: number | null; + readonly min: number; + readonly max: number; +} + +interface FiniteValues { + readonly values: number[]; + readonly invalid: number; + readonly min: number; + readonly max: number; +} + +interface CountedBin { + readonly xStart: number; + readonly xEnd: number; + count: number; + readonly index: number; +} + +const DEFAULT_MAX_BINS = 512; +const EDGE_EQUALITY_EPSILON = 1e-9; + +/** Convert one-dimensional finite values into histogram bins. */ +export function histogram(values: ArrayLike, options: HistogramOptions = {}): HistogramResult { + const finite = collectFiniteValues(values); + const includeMax = options.includeMax !== false; + const normalize = options.normalize ?? "count"; + validateNormalization(normalize); + + if (finite.values.length === 0 && options.min === undefined && options.max === undefined && !hasExplicitEdgeThresholds(options)) { + return emptyHistogram(finite.invalid, Number.NaN, Number.NaN); + } + + const edges = buildEdges(finite, options); + if (edges.edges.length < 2) { + return emptyHistogram(finite.invalid, Number.NaN, Number.NaN); + } + if (finite.values.length === 0) { + return emptyHistogram(finite.invalid, edges.min, edges.max, edges.binWidth); + } + + const counted = createCountedBins(edges.edges); + let underflow = 0; + let overflow = 0; + let total = 0; + const firstEdge = edges.edges[0]!; + const lastEdge = edges.edges[edges.edges.length - 1]!; + const equalWidth = edges.binWidth !== null ? edges.binWidth : null; + + for (const value of finite.values) { + if (value < edges.min) { + underflow++; + continue; + } + if (value > edges.max || (value === edges.max && !includeMax)) { + overflow++; + continue; + } + + let binIndex: number; + if (value === lastEdge) { + binIndex = counted.length - 1; + } else if (equalWidth !== null) { + binIndex = Math.floor((value - firstEdge) / equalWidth); + if (binIndex < 0) binIndex = 0; + if (binIndex >= counted.length) binIndex = counted.length - 1; + } else { + binIndex = upperBound(edges.edges, value) - 1; + } + + counted[binIndex]!.count++; + total++; + } + + const includeEmpty = options.includeEmpty !== false; + const outputBins = counted.filter((bin) => includeEmpty || bin.count > 0); + const bins = outputBins.map((bin) => toHistogramBin(bin, total, normalize)); + return { + bins, + x: Float64Array.from(bins, (bin) => bin.x), + y: Float32Array.from(bins, (bin) => bin.y), + binWidth: edges.binWidth, + total, + underflow, + overflow, + invalid: finite.invalid, + min: edges.min, + max: edges.max, + }; +} + +/** Static histogram dataset that preserves each bucket's X interval for picks and tooltips. */ +export class HistogramDataset extends StaticDataset implements XRangeDataset { + /** Create a static dataset from precomputed histogram buckets. */ + constructor(readonly result: HistogramResult) { + super(result.x, result.y); + } + + /** Return the value interval represented by a histogram bucket. */ + getXRange(index: number): XRange | null { + const bin = this.result.bins[index]; + return bin ? { xStart: bin.xStart, xEnd: bin.xEnd } : null; + } +} + +/** Build a StaticDataset from histogram bucket centers and normalized counts. */ +export function histogramDataset(values: ArrayLike, options: HistogramOptions = {}): HistogramDataset { + return new HistogramDataset(histogram(values, options)); +} + +function collectFiniteValues(values: ArrayLike): FiniteValues { + const finite: number[] = []; + let invalid = 0; + let min = Infinity; + let max = -Infinity; + for (let index = 0; index < values.length; index++) { + const value = values[index]; + if (typeof value !== "number" || !Number.isFinite(value)) { + invalid++; + continue; + } + finite.push(value); + if (value < min) min = value; + if (value > max) max = value; + } + return { values: finite, invalid, min, max }; +} + +function buildEdges(finite: FiniteValues, options: HistogramOptions): HistogramEdges { + const modeCount = (options.binSize !== undefined ? 1 : 0) + + (options.binCount !== undefined ? 1 : 0) + + (options.thresholds !== undefined ? 1 : 0); + if (modeCount > 1) { + throw new TypeError("Histogram binSize, binCount, and thresholds are mutually exclusive."); + } + + if (Array.isArray(options.thresholds)) { + return explicitEdges(options.thresholds); + } + if (isReadonlyNumberArray(options.thresholds)) { + return explicitEdges(options.thresholds); + } + if (options.binSize !== undefined) { + return fixedSizeEdges(finite, options); + } + + const desiredCount = options.binCount ?? (typeof options.thresholds === "number" ? options.thresholds : defaultBinCount(finite.values)); + return fixedCountEdges(finite, options, desiredCount); +} + +function explicitEdges(thresholds: readonly number[]): HistogramEdges { + if (thresholds.length < 2) { + throw new RangeError("Histogram thresholds must contain at least two edges."); + } + const edges = Array.from(thresholds); + for (let index = 0; index < edges.length; index++) { + const edge = edges[index]!; + if (!Number.isFinite(edge)) { + throw new TypeError("Histogram thresholds must be finite numbers."); + } + if (index > 0 && edge <= edges[index - 1]!) { + throw new RangeError("Histogram thresholds must be strictly increasing."); + } + } + return { edges, binWidth: inferUniformWidth(edges), min: edges[0]!, max: edges[edges.length - 1]! }; +} + +function fixedSizeEdges(finite: FiniteValues, options: HistogramOptions): HistogramEdges { + const binSize = options.binSize!; + if (!Number.isFinite(binSize) || binSize <= 0) { + throw new RangeError("Histogram binSize must be a positive finite number."); + } + + const domain = resolveDomain(finite, options); + const align = Number.isFinite(options.align) ? options.align! : 0; + const first = align + Math.floor((domain.min - align) / binSize) * binSize; + let last = align + Math.ceil((domain.max - align) / binSize) * binSize; + if (last <= first) last = first + binSize; + return { edges: buildLinearEdges(first, last, binSize), binWidth: binSize, min: domain.min, max: domain.max }; +} + +function fixedCountEdges(finite: FiniteValues, options: HistogramOptions, countInput: number): HistogramEdges { + if (!Number.isInteger(countInput) || countInput <= 0) { + throw new RangeError("Histogram binCount/thresholds count must be a positive integer."); + } + const count = Math.min(DEFAULT_MAX_BINS, countInput); + const domain = expandDegenerateDomain(resolveDomain(finite, options)); + const binWidth = (domain.max - domain.min) / count; + return { edges: buildLinearEdges(domain.min, domain.max, binWidth), binWidth, min: domain.min, max: domain.max }; +} + +function resolveDomain(finite: FiniteValues, options: Pick): { min: number; max: number } { + const min = options.min ?? finite.min; + const max = options.max ?? finite.max; + if (!Number.isFinite(min) || !Number.isFinite(max)) { + throw new TypeError("Histogram min/max must be finite numbers when no finite values are available."); + } + if (max < min) { + throw new RangeError("Histogram max must be greater than or equal to min."); + } + return { min, max }; +} + +function expandDegenerateDomain(domain: { min: number; max: number }): { min: number; max: number } { + if (domain.max > domain.min) return domain; + const halfSpan = Math.max(0.5, Math.abs(domain.min) * 0.5); + return { min: domain.min - halfSpan, max: domain.max + halfSpan }; +} + +function buildLinearEdges(first: number, last: number, step: number): number[] { + const span = last - first; + const count = Math.max(1, Math.round(span / step)); + const edges = new Array(count + 1); + for (let index = 0; index <= count; index++) { + edges[index] = index === count ? last : first + index * step; + } + return edges; +} + +function createCountedBins(edges: readonly number[]): CountedBin[] { + const bins: CountedBin[] = []; + for (let index = 0; index < edges.length - 1; index++) { + bins.push({ xStart: edges[index]!, xEnd: edges[index + 1]!, count: 0, index }); + } + return bins; +} + +function toHistogramBin(bin: CountedBin, total: number, normalize: HistogramNormalization): HistogramBin { + const width = bin.xEnd - bin.xStart; + let y: number; + switch (normalize) { + case "probability": + y = total > 0 ? bin.count / total : 0; + break; + case "percent": + y = total > 0 ? (bin.count / total) * 100 : 0; + break; + case "density": + y = total > 0 && width > 0 ? bin.count / (total * width) : 0; + break; + default: + y = bin.count; + break; + } + return { + x: (bin.xStart + bin.xEnd) * 0.5, + y, + xStart: bin.xStart, + xEnd: bin.xEnd, + count: bin.count, + index: bin.index, + }; +} + +function defaultBinCount(values: readonly number[]): number { + const n = values.length; + if (n <= 1) return 1; + + const sorted = [...values].sort((a, b) => a - b); + const q1 = quantileSorted(sorted, 0.25); + const q3 = quantileSorted(sorted, 0.75); + const iqr = q3 - q1; + const min = sorted[0]!; + const max = sorted[sorted.length - 1]!; + if (max > min && iqr > 0) { + const width = 2 * iqr / Math.cbrt(n); + if (Number.isFinite(width) && width > 0) { + return clampBinCount(Math.ceil((max - min) / width)); + } + } + + return clampBinCount(Math.ceil(Math.log2(n) + 1)); +} + +function quantileSorted(sorted: readonly number[], p: number): number { + if (sorted.length === 0) return Number.NaN; + const position = (sorted.length - 1) * p; + const lower = Math.floor(position); + const upper = Math.ceil(position); + if (lower === upper) return sorted[lower]!; + const weight = position - lower; + return sorted[lower]! * (1 - weight) + sorted[upper]! * weight; +} + +function clampBinCount(count: number): number { + if (!Number.isFinite(count)) return 1; + return Math.min(DEFAULT_MAX_BINS, Math.max(1, count)); +} + +function inferUniformWidth(edges: readonly number[]): number | null { + const firstWidth = edges[1]! - edges[0]!; + const scale = Math.max(1, Math.abs(firstWidth)); + for (let index = 2; index < edges.length; index++) { + const width = edges[index]! - edges[index - 1]!; + if (Math.abs(width - firstWidth) > scale * EDGE_EQUALITY_EPSILON) return null; + } + return firstWidth; +} + +function upperBound(values: readonly number[], needle: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const mid = low + ((high - low) >> 1); + if (values[mid]! <= needle) low = mid + 1; + else high = mid; + } + return low; +} + +function hasExplicitEdgeThresholds(options: HistogramOptions): boolean { + return Array.isArray(options.thresholds) || isReadonlyNumberArray(options.thresholds); +} + +function isReadonlyNumberArray(value: unknown): value is readonly number[] { + return typeof value === "object" && value !== null && "length" in value && typeof (value as { length: unknown }).length === "number"; +} + +function validateNormalization(normalize: HistogramNormalization): void { + switch (normalize) { + case "count": + case "probability": + case "density": + case "percent": + return; + default: + throw new TypeError(`Unsupported histogram normalization: ${String(normalize)}.`); + } +} + +function emptyHistogram(invalid: number, min: number, max: number, binWidth: number | null = null): HistogramResult { + return { + bins: [], + x: new Float64Array(0), + y: new Float32Array(0), + binWidth, + total: 0, + underflow: 0, + overflow: 0, + invalid, + min, + max, + }; +} diff --git a/src/core/index.ts b/src/core/index.ts index c58d99b..418ca0c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,4 @@ -export type { Viewport, LODBucket, LODView, TimeRange, SeriesStyle, SeriesMode, SeriesYAxis, SeriesConfig, SeriesSample, LODStrategy, BufferOverflowStrategy, Dataset, AcceleratedDataset, OhlcDataset, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, SampleCopyLayout, MinMaxSegmentLayout } from "./types.js"; +export type { Viewport, LODBucket, LODView, TimeRange, SeriesStyle, SeriesMode, SeriesYAxis, SeriesConfig, SeriesSample, LODStrategy, BufferOverflowStrategy, Dataset, AcceleratedDataset, OhlcDataset, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset, XRange, XRangeDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, SampleCopyLayout, MinMaxSegmentLayout } from "./types.js"; export { ServerSampledDataset } from "./ServerSampledDataset.js"; export type { ServerSampledBuckets, ServerSampledData, ServerSampledDatasetKind, ServerSampledPoints } from "./ServerSampledDataset.js"; @@ -8,6 +8,8 @@ export { UniformRingBuffer } from "./UniformRingBuffer.js"; export type { UniformRingBufferOptions } from "./UniformRingBuffer.js"; export { StaticDataset } from "./StaticDataset.js"; export type { StaticDatasetField, StaticDatasetFromObjectsOptions } from "./StaticDataset.js"; +export { HistogramDataset, histogram, histogramDataset } from "./Histogram.js"; +export type { HistogramBin, HistogramBinThresholds, HistogramNormalization, HistogramOptions, HistogramResult } from "./Histogram.js"; export { OhlcRingBuffer, StaticOhlcDataset } from "./OhlcDataset.js"; export type { OhlcRingBufferOptions } from "./OhlcDataset.js"; export { MinMaxPyramid } from "./MinMaxPyramid.js"; diff --git a/src/createChart.ts b/src/createChart.ts index da6ddde..6e631c6 100644 --- a/src/createChart.ts +++ b/src/createChart.ts @@ -1,11 +1,12 @@ 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; +export type CreateChartSeriesType = Extract | "histogram"; interface CreateChartSeriesBase { readonly type?: CreateChartSeriesType; @@ -42,12 +43,32 @@ export interface CreateChartStreamingSeries extends CreateChartSeriesBase { 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; + | CreateChartStreamingSeries + | CreateChartHistogramSeries; /** * High-level chart configuration for common first-render cases. @@ -82,8 +103,32 @@ export function createChart>( 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), @@ -97,7 +142,7 @@ export function createChart>( } if (autoFit) { - chart.fitToData(typeof autoFit === "object" ? autoFit : undefined); + chart.fitToData(typeof autoFit === "object" ? autoFit : hasHistogram ? { includeZero: true } : undefined); } if (start) { chart.start(); diff --git a/src/data.ts b/src/data.ts index 7649c6f..fdbae57 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,3 +1,6 @@ +export { HistogramDataset, histogram, histogramDataset } from "./core/Histogram.js"; +export type { HistogramBin, HistogramBinThresholds, HistogramNormalization, HistogramOptions, HistogramResult } from "./core/Histogram.js"; + import type { SeriesMode, SeriesYAxis, Viewport } from "./core/types.js"; import type { SeriesStore } from "./core/SeriesStore.js"; import type { Chart, ChartSeriesState } from "./ui/Chart.js"; diff --git a/src/index.ts b/src/index.ts index 459c181..ba856c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ 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, ChartHoverState, ChartPickGroup, ChartPickItem, ChartPickMode, ChartPickOptions, ChartPlugin, ChartPluginContext, ChartPluginHandle, ChartSeriesState } 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, CreateChartObjectSeries, CreateChartOptions, CreateChartSeries, CreateChartSeriesType, CreateChartStreamingSeries } 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"; @@ -13,6 +13,8 @@ export { UniformRingBuffer } from "./core/UniformRingBuffer.js"; export type { UniformRingBufferOptions } from "./core/UniformRingBuffer.js"; export { StaticDataset } from "./core/StaticDataset.js"; export type { StaticDatasetField, StaticDatasetFromObjectsOptions } from "./core/StaticDataset.js"; +export { HistogramDataset, histogram, histogramDataset } from "./core/Histogram.js"; +export type { HistogramBin, HistogramBinThresholds, HistogramNormalization, HistogramOptions, HistogramResult } from "./core/Histogram.js"; export { ServerSampledDataset } from "./core/ServerSampledDataset.js"; export type { ServerSampledBuckets, ServerSampledData, ServerSampledDatasetKind, ServerSampledPoints } from "./core/ServerSampledDataset.js"; export { OhlcRingBuffer, StaticOhlcDataset } from "./core/OhlcDataset.js"; @@ -23,5 +25,5 @@ export type { AttributeSpec, BufferSpec, DrawSpec, GpuBackend, GpuBuffer, GpuCap export { Camera2D } from "./interaction/Camera2D.js"; export { AxisController } from "./interaction/AxisController.js"; export type { AxisControllerAxisOptions, AxisControllerOptions, AxisRenderTarget, AxisScale, AxisTickFormat, AxisTickFormatter, AxisTimeZone, BuiltInAxisScale, CustomAxisScale } from "./interaction/AxisController.js"; -export type { Viewport, LODBucket, LODView, TimeRange, SeriesStyle, SeriesMode, SeriesYAxis, SeriesConfig, SeriesSample, LODStrategy, BufferOverflowStrategy, Dataset, AcceleratedDataset, OhlcDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, SampleCopyLayout, MinMaxSegmentLayout, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset } from "./core/types.js"; +export type { Viewport, LODBucket, LODView, TimeRange, SeriesStyle, SeriesMode, SeriesYAxis, SeriesConfig, SeriesSample, LODStrategy, BufferOverflowStrategy, Dataset, AcceleratedDataset, OhlcDataset, XRange, XRangeDataset, RangeMinMaxDataset, RangeSampleCopyDataset, VisibleSampleCopyDataset, VisiblePointCopyDataset, MinMaxSegmentCopyDataset, SampleCopyLayout, MinMaxSegmentLayout, AppendableDataset, YAppendableDataset, UpdatableDataset, YUpdatableDataset } from "./core/types.js"; export type { PanIntent, ZoomAxis, ZoomIntent, ViewportPolicy } from "./interaction/types.js"; diff --git a/src/ui/Chart.ts b/src/ui/Chart.ts index ce093fa..f55168f 100644 --- a/src/ui/Chart.ts +++ b/src/ui/Chart.ts @@ -2,6 +2,8 @@ import type { SeriesConfig, SeriesStyle, Dataset, SeriesMode, SeriesSample, Seri import { SeriesStore } from "../core/SeriesStore.js"; import { RingBuffer } from "../core/RingBuffer.js"; import { UniformRingBuffer } from "../core/UniformRingBuffer.js"; +import { HistogramDataset, histogram } from "../core/Histogram.js"; +import type { HistogramBinThresholds, HistogramNormalization, HistogramResult } from "../core/Histogram.js"; import { Renderer } from "../render/Renderer.js"; import type { RenderProjection } from "../render/Renderer.js"; import { isWebGL2Available, WebGL2Backend } from "../render/WebGL2Backend.js"; @@ -166,6 +168,26 @@ export interface ChartOptions { /** Series configuration used by typed helpers such as `addLine`. */ export type TypedSeriesConfig = Omit; +/** Series configuration for `Chart.addHistogram(...)` from raw one-dimensional values. */ +export interface HistogramSeriesConfig extends Omit { + readonly values: ArrayLike; + readonly histogram?: never; + 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; +} + +/** Series configuration for `Chart.addHistogram(...)` from precomputed bins. */ +export interface PrecomputedHistogramSeriesConfig extends Omit { + readonly histogram: HistogramResult; +} + /** Runtime state for one chart series. */ export interface ChartSeriesState { readonly series: SeriesStore; @@ -810,6 +832,43 @@ export class Chart implements ChartPluginContext { return this.addSeries({ ...config, mode: "bar" }, style); } + /** Add a histogram series using the existing bar renderer. */ + addHistogram( + config: HistogramSeriesConfig | PrecomputedHistogramSeriesConfig, + style?: Partial, + ): SeriesStore { + const result: HistogramResult = config.histogram !== undefined + ? config.histogram + : histogram(config.values, { + binSize: config.binSize, + binCount: config.binCount, + thresholds: config.thresholds, + min: config.min, + max: config.max, + align: config.align, + normalize: config.normalize, + includeEmpty: config.includeEmpty, + includeMax: config.includeMax, + }); + + if (result.binWidth === null && style?.barWidth === undefined && result.bins.length > 0) { + throw new TypeError("Chart.addHistogram requires style.barWidth for variable-width histogram bins."); + } + + const { id, name, yAxis, downsample } = config; + return this.addBar({ + id, + name, + yAxis, + downsample, + dataset: new HistogramDataset(result), + }, { + ...style, + baseline: style?.baseline ?? 0, + barWidth: style?.barWidth ?? result.binWidth ?? 0.8, + }); + } + /** Add an OHLC series. */ addOhlc(config: TypedSeriesConfig, style?: Partial): SeriesStore { return this.addSeries({ ...config, mode: "ohlc" }, style); diff --git a/tests/browser/visual/main.ts b/tests/browser/visual/main.ts index 11f844e..0af10ad 100644 --- a/tests/browser/visual/main.ts +++ b/tests/browser/visual/main.ts @@ -33,6 +33,7 @@ const CASES = [ "area", "scatter", "bar", + "histogram", "ohlc", "candlestick", "axes-title-grid", @@ -163,6 +164,9 @@ function setupCase(name: VisualCase, chart: Chart): void { case "bar": addBar(chart); break; + case "histogram": + addHistogram(chart); + break; case "ohlc": addOhlc(chart, "ohlc"); break; @@ -270,6 +274,12 @@ function addBar(chart: Chart): void { chart.setViewport({ xMin: -1, xMax: 96, yMin: -0.1, yMax: 1.4 }); } +function addHistogram(chart: Chart): void { + const values = Float64Array.from({ length: 256 }, (_, i) => 50 + Math.sin(i * 0.41) * 18 + Math.cos(i * 0.13) * 8); + chart.addHistogram({ values, binSize: 4, name: "histogram" }, { baseline: 0 }); + chart.fitToData({ includeZero: true, padding: { x: 0.02, y: 0.08 } }); +} + function addOhlc(chart: Chart, mode: "ohlc" | "candlestick"): void { const count = 96; const x = new Float64Array(count); diff --git a/tests/core/Histogram.test.ts b/tests/core/Histogram.test.ts new file mode 100644 index 0000000..c61286e --- /dev/null +++ b/tests/core/Histogram.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "bun:test"; +import { StaticDataset } from "../../src/core/StaticDataset.ts"; +import { histogram, histogramDataset } from "../../src/core/Histogram.ts"; + +function counts(result: ReturnType): number[] { + return result.bins.map((bin) => bin.count); +} + +describe("histogram", () => { + it("bins values by fixed bin size", () => { + const result = histogram([0.1, 0.2, 0.9, 1.1], { binSize: 1, min: 0, max: 2 }); + expect(Array.from(result.x)).toEqual([0.5, 1.5]); + expect(Array.from(result.y)).toEqual([3, 1]); + expect(counts(result)).toEqual([3, 1]); + expect(result.binWidth).toBe(1); + expect(result.total).toBe(4); + }); + + it("aligns fixed-size bins to zero by default", () => { + const result = histogram([19.3838, 20.2, 24.9], { binSize: 5 }); + expect(result.bins.map((bin) => [bin.xStart, bin.xEnd, bin.count])).toEqual([ + [15, 20, 1], + [20, 25, 2], + ]); + }); + + it("bins values by fixed bin count", () => { + const result = histogram([0, 1, 2, 3], { binCount: 3, min: 0, max: 3 }); + expect(counts(result)).toEqual([1, 1, 2]); + expect(result.bins.map((bin) => [bin.xStart, bin.xEnd])).toEqual([[0, 1], [1, 2], [2, 3]]); + }); + + it("supports explicit uniform thresholds", () => { + const result = histogram([0, 5, 10, 24, 25, 100], { thresholds: [0, 10, 25, 50, 100] }); + expect(counts(result)).toEqual([2, 2, 1, 1]); + expect(result.binWidth).toBeNull(); + expect(result.min).toBe(0); + expect(result.max).toBe(100); + }); + + it("uses deterministic default thresholds", () => { + const result = histogram([1, 2, 3, 4, 5, 6, 7, 8]); + expect(result.bins.length).toBeGreaterThan(0); + expect(result.bins.length).toBeLessThanOrEqual(512); + expect(result.total).toBe(8); + expect(result.invalid).toBe(0); + }); + + it("tracks invalid values and underflow/overflow", () => { + const result = histogram([-1, 0, 1, 2, 3, Number.NaN, Infinity, "bad" as unknown as number], { + binSize: 1, + min: 0, + max: 2, + }); + expect(counts(result)).toEqual([1, 2]); + expect(result.underflow).toBe(1); + expect(result.overflow).toBe(1); + expect(result.invalid).toBe(3); + expect(result.total).toBe(3); + }); + + it("can exclude the upper edge", () => { + const result = histogram([0, 1, 2], { binSize: 1, min: 0, max: 2, includeMax: false }); + expect(counts(result)).toEqual([1, 1]); + expect(result.overflow).toBe(1); + }); + + it("can omit empty bins while preserving source bin indexes", () => { + const result = histogram([0.1, 2.1], { binSize: 1, min: 0, max: 3, includeEmpty: false }); + expect(result.bins.map((bin) => bin.index)).toEqual([0, 2]); + expect(Array.from(result.y)).toEqual([1, 1]); + }); + + it("supports probability, percent, and density normalization", () => { + const probability = histogram([0.1, 0.2, 1.1, 1.2], { binSize: 1, min: 0, max: 2, normalize: "probability" }); + const percent = histogram([0.1, 0.2, 1.1, 1.2], { binSize: 1, min: 0, max: 2, normalize: "percent" }); + const density = histogram([0.1, 0.2, 1.1, 1.2], { binSize: 0.5, min: 0, max: 2, normalize: "density" }); + + expect(Array.from(probability.y)).toEqual([0.5, 0.5]); + expect(Array.from(percent.y)).toEqual([50, 50]); + expect(Array.from(density.y)).toEqual([1, 0, 1, 0]); + }); + + it("returns empty arrays for empty input", () => { + const result = histogram([], { binSize: 1, min: 0, max: 4 }); + expect(result.total).toBe(0); + expect(result.bins).toEqual([]); + expect(result.x.length).toBe(0); + expect(result.y.length).toBe(0); + expect(result.binWidth).toBe(1); + expect(result.min).toBe(0); + expect(result.max).toBe(4); + }); + + it("handles constant-value input", () => { + const result = histogram([5, 5, 5], { binCount: 4 }); + expect(result.total).toBe(3); + expect(result.bins.reduce((sum, bin) => sum + bin.count, 0)).toBe(3); + expect(result.min).toBeLessThan(5); + expect(result.max).toBeGreaterThan(5); + }); + + it("supports negative values and non-zero alignment", () => { + const result = histogram([-3.5, -1.1, 0.2, 2.9], { binSize: 2, align: 1 }); + expect(result.bins.map((bin) => [bin.xStart, bin.xEnd, bin.count])).toEqual([ + [-5, -3, 1], + [-3, -1, 1], + [-1, 1, 1], + [1, 3, 1], + ]); + }); + + it("keeps configured min/max as the included data range when bins are aligned outward", () => { + const result = histogram([-0.2, 0.2, 1.2, 2.2], { binSize: 1, min: 0, max: 2.1, align: -0.5 }); + expect(result.bins.map((bin) => [bin.xStart, bin.xEnd, bin.count])).toEqual([ + [-0.5, 0.5, 1], + [0.5, 1.5, 1], + [1.5, 2.5, 0], + ]); + expect(result.underflow).toBe(1); + expect(result.overflow).toBe(1); + expect(result.min).toBe(0); + expect(result.max).toBe(2.1); + }); + + it("accepts typed arrays and can produce a StaticDataset", () => { + const values = new Float64Array([0, 0.2, 1.5]); + const result = histogram(values, { binSize: 1, min: 0, max: 2 }); + const dataset = histogramDataset(values, { binSize: 1, min: 0, max: 2 }); + + expect(result.x).toBeInstanceOf(Float64Array); + expect(result.y).toBeInstanceOf(Float32Array); + expect(dataset).toBeInstanceOf(StaticDataset); + expect(dataset.length).toBe(2); + expect(dataset.getXRange(0)).toEqual({ xStart: 0, xEnd: 1 }); + expect(dataset.getY(0)).toBe(2); + expect(dataset.getY(1)).toBe(1); + }); + + it("rejects invalid bin definitions", () => { + expect(() => histogram([1], { binSize: 0 })).toThrow(RangeError); + expect(() => histogram([1], { binCount: 0 })).toThrow(RangeError); + expect(() => histogram([1], { thresholds: [0, 0] })).toThrow(RangeError); + expect(() => histogram([1], { binSize: 1, binCount: 2 })).toThrow(TypeError); + }); +}); diff --git a/website/src/site/components/docs-page.ts b/website/src/site/components/docs-page.ts index 0db2175..bee3785 100644 --- a/website/src/site/components/docs-page.ts +++ b/website/src/site/components/docs-page.ts @@ -90,6 +90,7 @@ export class BlazeplotDocsPage extends LitElement { const kind = target.dataset.docChart; if (kind === "basic-line") this.mountBasicLineDocChart(target); else if (kind === "object-rows") this.mountObjectRowsDocChart(target); + else if (kind === "histogram") this.mountHistogramDocChart(target); else if (kind === "live-line") this.mountLiveLineDocChart(target); else if (kind === "fixed-rate") this.mountFixedRateDocChart(target); else if (kind === "server-sampled") this.mountServerSampledDocChart(target); @@ -134,6 +135,22 @@ export class BlazeplotDocsPage extends LitElement { chart.start(); } + private mountHistogramDocChart(target: HTMLElement): void { + const values = new Float64Array(360); + for (let i = 0; i < values.length; i += 1) { + const group = i % 3; + const center = group === 0 ? 28 : group === 1 ? 48 : 66; + values[i] = center + Math.sin(i * 0.71) * 6 + Math.cos(i * 0.17) * 3; + } + const chart = this.createDocChart(target, { + axes: { x: { position: "outside", title: "Latency ms" }, y: { position: "outside", title: "Count" } }, + plugins: [interactionsPlugin({ doubleClickReset: true }), tooltipPlugin({ mode: "nearest-x" })], + }); + chart.addHistogram({ values, binSize: 5, name: "latency" }, { color: [0.988, 0.29, 0.02, 0.75] }); + chart.fitToData({ includeZero: true, padding: { x: 0.04, y: 0.1 } }); + chart.start(); + } + private mountLiveLineDocChart(target: HTMLElement): void { const chart = this.createDocChart(target, { axes: { x: { position: "outside", scale: "time" }, y: { position: "outside" } }, diff --git a/website/src/site/components/previews-page.ts b/website/src/site/components/previews-page.ts index da6521f..405ef78 100644 --- a/website/src/site/components/previews-page.ts +++ b/website/src/site/components/previews-page.ts @@ -51,6 +51,7 @@ export class BlazeplotPreviewsPage extends LitElement { private renderSelectedPreview(id: PreviewId): TemplateResult { if (id === "sensor") return this.renderSensorStreamPreview(); if (id === "features") return this.renderFeaturePreview(); + if (id === "histogram") return this.renderHistogramPreview(); if (id === "linked") return this.renderLinkedChartsPreview(); if (id === "server-sampled") return this.renderServerSampledPreview(); if (id === "flamechart") return this.renderFlameChartPreview(); @@ -139,6 +140,23 @@ export class BlazeplotPreviewsPage extends LitElement { `; } + private renderHistogramPreview(): TemplateResult { + return this.renderPreviewPanel( + "Histogram", + "one-dimensional samples · fixed bins · density normalization", + html` +
+
+ Latency distribution rendered through chart.addHistogram(...); hover bars to inspect bucket ranges. +
+
+
+
+
+ `, + ); + } + private renderLinkedChartsPreview(): TemplateResult { return html`
diff --git a/website/src/site/previews-controller.ts b/website/src/site/previews-controller.ts index c28af98..75693b7 100644 --- a/website/src/site/previews-controller.ts +++ b/website/src/site/previews-controller.ts @@ -56,6 +56,7 @@ export class PreviewChartsController implements ReactiveController { if (kind === "live") this.mountLivePreviewChart(target); else if (kind === "sensor") this.mountSensorStreamPreview(target); else if (kind === "feature-hero") this.mountFeatureHeroChart(target); + else if (kind === "histogram") this.mountHistogramPreview(target); else if (kind === "feature-linked") this.mountFeatureLinkedCharts(target); else if (kind === "server-sampled") this.mountServerSampledChart(target); else if (kind === "flamechart") this.mountFlameChartPreview(target); @@ -587,6 +588,39 @@ export class PreviewChartsController implements ReactiveController { }); } + private mountHistogramPreview(target: HTMLElement): void { + const values = new Float64Array(18_000); + for (let index = 0; index < values.length; index++) { + const phase = index / values.length; + const cluster = index % 9; + const baseline = cluster < 5 ? 70 : cluster < 8 ? 115 : 165; + const seasonal = Math.sin(phase * Math.PI * 10) * 8 + Math.sin(index * 0.017) * 5; + const jitter = (Math.sin(index * 12.9898) * 43758.5453 % 1) * 10; + values[index] = baseline + seasonal + jitter + (index % 997 === 0 ? 85 : 0); + } + + const chart = new Chart(target, { + axes: { x: { position: "outside", title: "latency (ms)" }, y: { position: "outside", title: "density" } }, + grid: true, + hover: { mode: "nearest-x", group: "none" }, + plugins: [ + interactionsPlugin({ wheelZoom: true, shiftDragPan: true, boxZoom: true, doubleClickReset: true }), + crosshairPlugin({ snap: "nearest-x", label: true, labelPlacement: "top-right", formatX: (value) => `${value.toFixed(0)} ms`, formatY: (value) => value.toFixed(4) }), + legendPlugin({ position: "top-left" }), + ], + accessibility: { label: "Histogram preview" }, + }); + this.previewCharts.push(chart); + + chart.addHistogram({ values, binSize: 5, min: 40, max: 260, normalize: "density", name: "Latency density", downsample: "none" }, { + color: [0.988, 0.29, 0.02, 0.95], + baseline: 0, + }); + chart.fitToData({ includeZero: true, padding: { y: 0.12 } }); + chart.setViewport({ ...chart.getViewport(), xMin: 40, xMax: 260, yMin: 0 }); + chart.start(); + } + private mountFeatureHeroChart(target: HTMLElement): void { const { xs, cpu, latency, throughput, incidents, initialXMin, initialXMax } = this.featureData(); const formatDate = this.featureFormatDate; diff --git a/website/src/site/shared.ts b/website/src/site/shared.ts index 45c0ecd..01f6bb4 100644 --- a/website/src/site/shared.ts +++ b/website/src/site/shared.ts @@ -1,7 +1,7 @@ export type Section = "home" | "docs" | "previews"; export type HomeDataMode = "static" | "streaming"; export type HomeChartMode = "line" | "ohlc" | "multi"; -export type PreviewId = "live" | "sensor" | "features" | "linked" | "server-sampled" | "flamechart" | "render-loop" | "mobile"; +export type PreviewId = "live" | "sensor" | "features" | "histogram" | "linked" | "server-sampled" | "flamechart" | "render-loop" | "mobile"; export interface PreviewLink { title: string; @@ -12,6 +12,7 @@ export const PREVIEWS: readonly PreviewLink[] = [ { title: "Live performance", id: "live" }, { title: "Sensor stream", id: "sensor" }, { title: "Feature gallery", id: "features" }, + { title: "Histogram", id: "histogram" }, { title: "Linked charts", id: "linked" }, { title: "Server-sampled", id: "server-sampled" }, { title: "Flame chart", id: "flamechart" }, @@ -47,6 +48,7 @@ export function appRouteFromPath(pathname: string): string | null { if (relative === "home") return "home"; if (relative === "previews" || relative.startsWith("previews/") || relative.startsWith("docs/")) return relative; if (relative === "features") return "previews/features"; + if (relative === "histogram") return "previews/histogram"; if (relative === "sensor") return "previews/sensor"; if (relative === "linked") return "previews/linked"; if (relative === "server-sampled") return "previews/server-sampled";