diff --git a/README.md b/README.md index eb376e2..cab3f6d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ BlazePlot

-[![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-EA4AAA?logo=githubsponsors)](https://github.com/sponsors/Federicocervelli) +[![Sponsor](https://img.shields.io/github/sponsors/Federicocervelli?logo=githubsponsors&label=sponsor&color=EA4AAA)](https://github.com/sponsors/Federicocervelli) [![npm version](https://img.shields.io/npm/v/blazeplot.svg)](https://www.npmjs.com/package/blazeplot) [![npm downloads](https://img.shields.io/npm/dt/blazeplot.svg)](https://www.npmjs.com/package/blazeplot) [![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) @@ -217,7 +217,7 @@ Generated from `dist/` after the package build. | shared WebGL2Backend chunk | `dist/WebGL2Backend-*.js` | 22 KiB | | shared LinkedChartsCore chunk | `dist/LinkedChartsCore-*.js` | 2 KiB | | lazy screenshot chunk | `dist/screenshot-*.js` | 4 KiB | -| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 3 KiB | +| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 4 KiB | | shared Tooltip chunk | `dist/Tooltip-*.js` | 6 KiB | | shared Crosshair chunk | `dist/Crosshair-*.js` | 10 KiB | @@ -228,7 +228,7 @@ Generated from `dist/index.d.ts` after the package build. | Export | Kind | Source | JSDoc summary | |---|---|---|---| | `AcceleratedDataset` | interface | `./core/types` | Convenience contract for maximum-performance custom datasets. Implement this when a dataset can provide fast exact sample copies, stable viewport sampling, range min/max queries, and renderer-ready min/max buckets. | -| `AppendableDataset` | interface | `./core/types` | Dataset that accepts appended explicit X/Y samples. | +| `AppendableDataset` | interface | `./core/types` | Dataset that accepts appended X/Y samples; implementations may store X values explicitly or use them to seed implicit X spacing. | | `AttributeSpec` | interface | `./render/types` | Vertex attribute binding for a draw call. | | `AxisConfig` | interface | `./ui/Chart` | Axis visibility, placement, scale, tick formatting, and title options. | | `AxisController` | class | `./interaction/AxisController` | Computes axis tick values and labels for a camera. | @@ -317,7 +317,7 @@ Generated from `dist/index.d.ts` after the package build. | `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. | +| `ReglBackend` | const | `./render/WebGL2Backend` | Deprecated alias for WebGL2Backend. This preserves the pre-native-backend public API. Deprecated: Use WebGL2Backend; removal target: 0.4.0. | | `ResolvedChartTheme` | interface | `./ui/theme` | Fully resolved chart theme with concrete RGBA values. | | `RgbaColor` | type | `./ui/theme` | RGBA color tuple with 0-1 channel values. | | `RingBuffer` | class | `./core/RingBuffer` | Fixed-capacity sorted XY buffer for explicit X values. | diff --git a/changelogs/v0.3.14.md b/changelogs/v0.3.14.md new file mode 100644 index 0000000..79a7265 --- /dev/null +++ b/changelogs/v0.3.14.md @@ -0,0 +1,31 @@ +# BlazePlot v0.3.14 + +## Changes + +- Refined overlay helper implementations while preserving optional plugin bundle boundaries. +- Clarified public API documentation for implicit-X appendable datasets, data exports, React option identity, and deprecated compatibility aliases. +- Regenerated README/API documentation and bundle-size tables for the patch release. + +## Benchmarks + +## 2026-05-28T22:55:27.769Z + +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.3.14.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.80 | 2.30 | 4.20 | 5,265 | 4 | 0 | 41.4 | + +### CPU hot spots: ci-smoke + +| Function | Self ms | Total ms | Location | +|---|---:|---:|---| +| (idle) | 515.6 | 515.6 | runtime | +| updateAxis | 83.2 | 86.6 | AxisOverlay.ts:86 | +| (program) | 75.7 | 75.7 | runtime | +| measure | 24.7 | 53.8 | main.ts:287 | +| appendRange | 12.2 | 24.3 | main.ts:360 | +| (garbage collector) | 5.8 | 5.8 | runtime | +| requestAnimationFrame | 5.1 | 5.1 | runtime | +| queryPhysicalMinMax | 5.0 | 5.0 | RingBuffer.ts:205 | + diff --git a/docs/api-reference.md b/docs/api-reference.md index d5d07a2..5cce639 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -74,7 +74,7 @@ Generated from `dist/` after the package build. | shared WebGL2Backend chunk | `dist/WebGL2Backend-*.js` | 22 KiB | | shared LinkedChartsCore chunk | `dist/LinkedChartsCore-*.js` | 2 KiB | | lazy screenshot chunk | `dist/screenshot-*.js` | 4 KiB | -| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 3 KiB | +| shared OverlayUtils chunk | `dist/OverlayUtils-*.js` | 4 KiB | | shared Tooltip chunk | `dist/Tooltip-*.js` | 6 KiB | | shared Crosshair chunk | `dist/Crosshair-*.js` | 10 KiB | @@ -85,7 +85,7 @@ Generated from `dist/index.d.ts` after the package build. | Export | Kind | Source | JSDoc summary | |---|---|---|---| | `AcceleratedDataset` | interface | `./core/types` | Convenience contract for maximum-performance custom datasets. Implement this when a dataset can provide fast exact sample copies, stable viewport sampling, range min/max queries, and renderer-ready min/max buckets. | -| `AppendableDataset` | interface | `./core/types` | Dataset that accepts appended explicit X/Y samples. | +| `AppendableDataset` | interface | `./core/types` | Dataset that accepts appended X/Y samples; implementations may store X values explicitly or use them to seed implicit X spacing. | | `AttributeSpec` | interface | `./render/types` | Vertex attribute binding for a draw call. | | `AxisConfig` | interface | `./ui/Chart` | Axis visibility, placement, scale, tick formatting, and title options. | | `AxisController` | class | `./interaction/AxisController` | Computes axis tick values and labels for a camera. | @@ -174,7 +174,7 @@ Generated from `dist/index.d.ts` after the package build. | `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. | +| `ReglBackend` | const | `./render/WebGL2Backend` | Deprecated alias for WebGL2Backend. This preserves the pre-native-backend public API. Deprecated: Use WebGL2Backend; removal target: 0.4.0. | | `ResolvedChartTheme` | interface | `./ui/theme` | Fully resolved chart theme with concrete RGBA values. | | `RgbaColor` | type | `./ui/theme` | RGBA color tuple with 0-1 channel values. | | `RingBuffer` | class | `./core/RingBuffer` | Fixed-capacity sorted XY buffer for explicit X values. | diff --git a/package.json b/package.json index 878c9b2..31e97ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blazeplot", - "version": "0.3.13", + "version": "0.3.14", "packageManager": "bun@1.3.14", "description": "Real-time LOD time series rendering engine for the browser.", "keywords": [ diff --git a/scripts/benchmark-compare.ts b/scripts/benchmark-compare.ts index c76b219..6e1a1b6 100644 --- a/scripts/benchmark-compare.ts +++ b/scripts/benchmark-compare.ts @@ -3,6 +3,7 @@ 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"; interface Options { scenarios: string[]; @@ -143,11 +144,9 @@ interface LibraryInfo { version: string; } -const OFFICIAL_SCENARIOS = ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"] as const; -const OFFICIAL_LIBRARIES = ["blazeplot", "uplot", "chartjs"] as const; -const RUNTIME_COMPARISONS = [ - { primaryLibrary: "blazeplot", referenceLibrary: "uplot" }, -] as const; +const OFFICIAL_SCENARIOS = officialConfig.scenarios; +const OFFICIAL_LIBRARIES = officialConfig.libraries; +const RUNTIME_COMPARISONS = officialConfig.runtimeComparisons; interface CompareReport { schemaVersion: 1; diff --git a/scripts/benchmark-config.json b/scripts/benchmark-config.json new file mode 100644 index 0000000..c7963b9 --- /dev/null +++ b/scripts/benchmark-config.json @@ -0,0 +1,7 @@ +{ + "libraries": ["blazeplot", "uplot", "chartjs"], + "scenarios": ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"], + "runtimeComparisons": [ + { "primaryLibrary": "blazeplot", "referenceLibrary": "uplot" } + ] +} diff --git a/scripts/bundle-size-check.ts b/scripts/bundle-size-check.ts index 6707a94..0fa05cc 100644 --- a/scripts/bundle-size-check.ts +++ b/scripts/bundle-size-check.ts @@ -157,7 +157,7 @@ function printHelpAndExit(): never { } function displayPath(path: string): string { - return path.replace(/-[A-Za-z0-9_]+\.js$/u, "-*.js"); + return path.replace(/-[A-Za-z0-9_-]+\.js$/u, "-*.js"); } function formatBytes(bytes: number): string { diff --git a/scripts/generate-readme-docs.js b/scripts/generate-readme-docs.js index f747263..02a20e9 100644 --- a/scripts/generate-readme-docs.js +++ b/scripts/generate-readme-docs.js @@ -19,11 +19,10 @@ const docsStartMarker = ""; const docsEndMarker = ""; const performanceStartMarker = ""; const performanceEndMarker = ""; -const officialComparisonLibraries = ["blazeplot", "uplot", "chartjs"]; -const officialComparisonScenarios = ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"]; -const runtimeComparisonPairs = [ - { primaryLibrary: "blazeplot", referenceLibrary: "uplot" }, -]; +const officialComparisonConfig = JSON.parse(readFileSync(resolve(root, "scripts/benchmark-config.json"), "utf-8")); +const officialComparisonLibraries = officialComparisonConfig.libraries; +const officialComparisonScenarios = officialComparisonConfig.scenarios; +const runtimeComparisonPairs = officialComparisonConfig.runtimeComparisons; const args = new Set(process.argv.slice(2)); const check = args.has("--check"); diff --git a/src/core/Histogram.ts b/src/core/Histogram.ts index 7749ce0..22484d7 100644 --- a/src/core/Histogram.ts +++ b/src/core/Histogram.ts @@ -1,3 +1,4 @@ +import { upperBoundArray } from "./search.js"; import { StaticDataset } from "./StaticDataset.js"; import type { XRange, XRangeDataset } from "./types.js"; @@ -130,7 +131,7 @@ export function histogram(values: ArrayLike, options: HistogramOptions = if (binIndex < 0) binIndex = 0; if (binIndex >= counted.length) binIndex = counted.length - 1; } else { - binIndex = upperBound(edges.edges, value) - 1; + binIndex = upperBoundArray(edges.edges, value) - 1; } counted[binIndex]!.count++; @@ -362,17 +363,6 @@ function inferUniformWidth(edges: readonly number[]): number | 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); } diff --git a/src/core/SeriesStore.ts b/src/core/SeriesStore.ts index a666409..a58d7f4 100644 --- a/src/core/SeriesStore.ts +++ b/src/core/SeriesStore.ts @@ -243,7 +243,7 @@ export class SeriesStore { /** Append XY, implicit-X, OHLC, or row-array data and request an on-demand render. */ append(data: SeriesAppendData): void; /** - * @deprecated Effective next patch release. Use `append({ x, y })` instead. + * @deprecated Use `append({ x, y })` instead; removal target: 0.4.0. */ append(x: ArrayLike, y: ArrayLike): void; /** Append data in the supported object, row, or legacy array forms. */ @@ -326,7 +326,7 @@ export class SeriesStore { } /** - * @deprecated Effective next patch release. Use `append({ y })` instead. + * @deprecated Use `append({ y })` instead; removal target: 0.4.0. */ appendY(y: ArrayLike): void { this.appendYArray(y); @@ -342,7 +342,7 @@ export class SeriesStore { } /** - * @deprecated Effective next patch release. Use `append({ x, open, high, low, close })` instead. + * @deprecated Use `append({ x, open, high, low, close })` instead; removal target: 0.4.0. */ appendOhlc( x: ArrayLike, @@ -421,7 +421,7 @@ export class SeriesStore { } /** - * @deprecated Effective next patch release. Use `updateLast({ open, high, low, close })` instead. + * @deprecated Use `updateLast({ open, high, low, close })` instead; removal target: 0.4.0. */ updateLastOhlc(open: number, high: number, low: number, close: number): boolean { return this.updateOhlcAt(this.dataset.length - 1, open, high, low, close); diff --git a/src/core/search.ts b/src/core/search.ts index 18ed333..ff09689 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -21,3 +21,8 @@ export function upperBound(length: number, valueAt: (index: number) => number, v } return lo; } + +/** Return the first array index whose value is greater than `value`. */ +export function upperBoundArray(values: readonly number[], value: number): number { + return upperBound(values.length, (index) => values[index]!, value); +} diff --git a/src/core/types.ts b/src/core/types.ts index f9384b3..b25f8c2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -170,7 +170,7 @@ export interface OhlcDataset extends Dataset { getClose(index: number): number; } -/** Dataset that accepts appended explicit X/Y samples. */ +/** Dataset that accepts appended X/Y samples; implementations may store X values explicitly or use them to seed implicit X spacing. */ export interface AppendableDataset extends Dataset { push(x: number, y: number): void; append(x: ArrayLike, y: ArrayLike): void; diff --git a/src/data.ts b/src/data.ts index fdbae57..9a7fb8b 100644 --- a/src/data.ts +++ b/src/data.ts @@ -152,7 +152,7 @@ export function exportSelectedChartData( }, options); } -/** Collect all raw samples from chart series, optionally constrained by options.series and visibleOnly. */ +/** Collect raw samples from visible chart series by default; pass visibleOnly: false to include hidden series. */ export function exportAllChartData(chart: Chart, options: ChartDataExportOptions = {}): ChartDataExport { return collectChartData(chart, { source: "all", bounds: null }, options); } diff --git a/src/react.ts b/src/react.ts index 73a220b..8e1dedd 100644 --- a/src/react.ts +++ b/src/react.ts @@ -4,6 +4,7 @@ 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; diff --git a/src/render/WebGL2Backend.ts b/src/render/WebGL2Backend.ts index e2a1d27..000bfd3 100644 --- a/src/render/WebGL2Backend.ts +++ b/src/render/WebGL2Backend.ts @@ -445,6 +445,6 @@ export class WebGL2Backend implements GpuBackend { /** * Deprecated alias for WebGL2Backend. This preserves the pre-native-backend public API. - * @deprecated Effective next patch release. Use WebGL2Backend. + * @deprecated Use WebGL2Backend; removal target: 0.4.0. */ export const ReglBackend: typeof WebGL2Backend = WebGL2Backend; diff --git a/src/ui/Annotations.ts b/src/ui/Annotations.ts index 956d5dc..9070767 100644 --- a/src/ui/Annotations.ts +++ b/src/ui/Annotations.ts @@ -156,7 +156,7 @@ export interface AnnotationsPlugin extends ChartPlugin { const SVG_NS = "http://www.w3.org/2000/svg"; -function svg(tag: K): SVGElementTagNameMap[K] { +function createSvgElement(tag: K): SVGElementTagNameMap[K] { return document.createElementNS(SVG_NS, tag); } @@ -316,7 +316,7 @@ export function annotationsPlugin(options: AnnotationsPluginOptions = {}): Annot return { install(chart: ChartPluginContext) { chartRef = chart; - overlay = svg("svg"); + overlay = createSvgElement("svg"); overlay.classList.add(options.className ?? "blazeplot-annotations"); overlay.style.position = "absolute"; overlay.style.inset = "0"; @@ -420,14 +420,14 @@ function drawAnnotation( const viewport = chart.getViewport(annotation.yAxis ?? "left"); const xToPx = (x: number): number => ((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width; const yToPx = (y: number): number => ((viewport.yMax - y) / (viewport.yMax - viewport.yMin)) * height; - const group = svg("g"); + const group = createSvgElement("g"); if (annotation.className) group.classList.add(annotation.className); switch (annotation.type) { case "x-line": { const x = xToPx(annotation.x); if (x < 0 || x > width) return; - const line = svg("line"); + const line = createSvgElement("line"); line.setAttribute("x1", String(x)); line.setAttribute("x2", String(x)); line.setAttribute("y1", "0"); @@ -440,7 +440,7 @@ function drawAnnotation( case "y-line": { const y = yToPx(annotation.y); if (y < 0 || y > height) return; - const line = svg("line"); + const line = createSvgElement("line"); line.setAttribute("x1", "0"); line.setAttribute("x2", String(width)); line.setAttribute("y1", String(y)); @@ -500,7 +500,7 @@ function styleStroke(el: SVGElement, color: string, width: number = 1, dash?: st } function appendRect(group: SVGGElement, rect: { x: number; y: number; w: number; h: number }, fill: string, stroke?: string, strokeWidth: number = 0): void { - const el = svg("rect"); + const el = createSvgElement("rect"); el.setAttribute("x", String(rect.x)); el.setAttribute("y", String(rect.y)); el.setAttribute("width", String(rect.w)); @@ -518,7 +518,7 @@ function appendMarker(group: SVGGElement, x: number, y: number, radius: number, const stroke = annotation.strokeColor ?? "rgba(0,0,0,0.35)"; const strokeWidth = annotation.strokeWidth ?? 1; if (annotation.shape === "diamond") { - const polygon = svg("polygon"); + const polygon = createSvgElement("polygon"); polygon.setAttribute("points", `${x},${y - radius} ${x + radius},${y} ${x},${y + radius} ${x - radius},${y}`); polygon.setAttribute("fill", fill); polygon.setAttribute("stroke", stroke); @@ -529,7 +529,7 @@ function appendMarker(group: SVGGElement, x: number, y: number, radius: number, if (annotation.shape === "cross") { for (const [x1, y1, x2, y2] of [[x - radius, y, x + radius, y], [x, y - radius, x, y + radius]] as const) { - const line = svg("line"); + const line = createSvgElement("line"); line.setAttribute("x1", String(x1)); line.setAttribute("y1", String(y1)); line.setAttribute("x2", String(x2)); @@ -540,7 +540,7 @@ function appendMarker(group: SVGGElement, x: number, y: number, radius: number, return; } - const circle = svg("circle"); + const circle = createSvgElement("circle"); circle.setAttribute("cx", String(x)); circle.setAttribute("cy", String(y)); circle.setAttribute("r", String(radius)); @@ -553,7 +553,7 @@ function appendMarker(group: SVGGElement, x: number, y: number, radius: number, function appendStandaloneLabel(group: SVGGElement, annotation: LabelAnnotation, x: number, y: number, defaultColor: string, defaultFont: string): void { const text = appendText(group, annotation.text, x, y, "start", annotation.color ?? defaultColor, annotation.font ?? defaultFont); if (annotation.backgroundColor) { - const rect = svg("rect"); + const rect = createSvgElement("rect"); rect.setAttribute("x", String(x - 4)); rect.setAttribute("y", String(y - 14)); rect.setAttribute("width", String(Math.max(16, annotation.text.length * 7 + 8))); @@ -572,7 +572,7 @@ function appendLabel(group: SVGGElement, label: string | AnnotationLabelOptions } function appendText(group: SVGGElement, textValue: string, x: number, y: number, anchor: "start" | "middle" | "end", color: string, font: string): SVGTextElement { - const text = svg("text"); + const text = createSvgElement("text"); text.textContent = textValue; text.setAttribute("x", String(x)); text.setAttribute("y", String(y)); diff --git a/src/ui/AxisOverlay.ts b/src/ui/AxisOverlay.ts index f0599a1..2b10365 100644 --- a/src/ui/AxisOverlay.ts +++ b/src/ui/AxisOverlay.ts @@ -15,6 +15,25 @@ type RenderAxis = "x" | "y" | "y2"; const AXIS_LABEL_COLLISION_GAP_PX = 2; +interface AxisLabelInterval { + readonly el: HTMLDivElement; + readonly start: number; + readonly end: number; + readonly edge: boolean; +} + +function hideOverlappingLabels(labels: readonly AxisLabelInterval[]): void { + const placed: Array<{ start: number; end: number }> = []; + for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.start - b.start)) { + const overlaps = placed.some((used) => label.start < used.end + AXIS_LABEL_COLLISION_GAP_PX && label.end > used.start - AXIS_LABEL_COLLISION_GAP_PX); + if (overlaps) { + label.el.style.display = "none"; + } else { + placed.push({ start: label.start, end: label.end }); + } + } +} + /** SVG overlay that renders chart axes and ticks. */ export class AxisOverlay { private xPool: HTMLDivElement[] = []; @@ -120,7 +139,7 @@ export class AxisOverlay { } if (axis === "x") { - const labels: Array<{ el: HTMLDivElement; left: number; right: number; edge: boolean }> = []; + const labels: AxisLabelInterval[] = []; for (let i = 0; i < values.length; i++) { const el = pool[i]!; const value = values[i]!; @@ -148,24 +167,16 @@ export class AxisOverlay { el.style.top = "auto"; el.style.bottom = "4px"; } - labels.push({ el, left: labelLeft, right: labelLeft + labelWidth, edge }); + labels.push({ el, start: labelLeft, end: labelLeft + labelWidth, edge }); } - const placed: Array<{ left: number; right: number }> = []; - for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.left - b.left)) { - const overlaps = placed.some((used) => label.left < used.right + AXIS_LABEL_COLLISION_GAP_PX && label.right > used.left - AXIS_LABEL_COLLISION_GAP_PX); - if (overlaps) { - label.el.style.display = "none"; - } else { - placed.push({ left: label.left, right: label.right }); - } - } + hideOverlappingLabels(labels); return; } const isRight = axis === "y2"; const config = isRight ? this.config.y2 : this.config.y; - const labels: Array<{ el: HTMLDivElement; top: number; bottom: number; edge: boolean }> = []; + const labels: AxisLabelInterval[] = []; for (let i = 0; i < values.length; i++) { const el = pool[i]!; const value = values[i]!; @@ -193,17 +204,9 @@ export class AxisOverlay { el.style.left = isRight ? "auto" : "4px"; el.style.right = isRight ? "4px" : "auto"; } - labels.push({ el, top: labelTop, bottom: labelTop + labelHeight, edge }); + labels.push({ el, start: labelTop, end: labelTop + labelHeight, edge }); } - const placed: Array<{ top: number; bottom: number }> = []; - for (const label of [...labels].sort((a, b) => Number(b.edge) - Number(a.edge) || a.top - b.top)) { - const overlaps = placed.some((used) => label.top < used.bottom + AXIS_LABEL_COLLISION_GAP_PX && label.bottom > used.top - AXIS_LABEL_COLLISION_GAP_PX); - if (overlaps) { - label.el.style.display = "none"; - } else { - placed.push({ top: label.top, bottom: label.bottom }); - } - } + hideOverlappingLabels(labels); } } diff --git a/src/ui/Chart.ts b/src/ui/Chart.ts index 5cf2b9a..680d665 100644 --- a/src/ui/Chart.ts +++ b/src/ui/Chart.ts @@ -574,7 +574,7 @@ export class Chart implements ChartPluginContext { this.lastPointerPlotX = event.offsetX; this.lastPointerPlotY = event.offsetY; this.lastPointerButtons = event.buttons; - this.refreshHover(); + this.scheduleHoverRefresh(); } if (this.pointerSubscribers.pointermove.size > 0) this.emitPointerEvent("pointermove", event); }; @@ -978,12 +978,15 @@ export class Chart implements ChartPluginContext { this.requestRender(); } - /** Resume latest-X following after an interaction pause. */ + /** + * Resume latest-X following after an interaction pause. + * @deprecated Use `resumeLatestXFollow()` for clearer latest-data semantics. + */ resumeXFollow(): void { this.resumeLatestXFollow(); } - /** Alias for `resumeXFollow()`. */ + /** Resume latest-X following after an interaction pause. */ resumeLatestXFollow(): void { this.clearXFollowResumeTimer(); this.xFollowPaused = false; diff --git a/src/ui/Crosshair.ts b/src/ui/Crosshair.ts index 7c9753f..269f8df 100644 --- a/src/ui/Crosshair.ts +++ b/src/ui/Crosshair.ts @@ -1,6 +1,6 @@ import type { SeriesYAxis } from "../core/types.js"; import type { Chart, ChartPickItem, ChartPickMode, ChartPlugin, ChartPluginContext } from "./Chart.js"; -import { createLongPressTouchTracker, createPickMarker, formatCompactNumber, placeAbsoluteWithinBox, renderPickItems, rgba } from "./OverlayUtils.js"; +import { createLongPressTouchTracker, createOverlayLayer, createPickMarker, formatCompactNumber, pickAtDataX, placeAbsoluteWithinBox, renderPickItems, rgba } from "./OverlayUtils.js"; /** Axis drawn by the crosshair overlay. */ export type CrosshairAxis = "x" | "y" | "xy"; @@ -14,7 +14,7 @@ export type CrosshairLabelPlacement = "bottom-right" | "top-right" | "bottom-lef /** Custom renderer for crosshair pick highlights. */ export type CrosshairHighlightRenderer = (position: CrosshairPosition, container: HTMLElement, chart: Chart) => void; -/** Crosshair position in client and data coordinates. */ +/** Crosshair position in data coordinates and plot-relative CSS pixels. */ export interface CrosshairPosition { readonly dataX: number; readonly dataY: number; @@ -88,6 +88,12 @@ export interface CrosshairPlugin extends ChartPlugin { subscribe(event: "measurechange" | "measureend", callback: (measurement: RulerMeasurement) => void): () => void; } +const SVG_NS = "http://www.w3.org/2000/svg"; + +function createSvgElement(tag: K): SVGElementTagNameMap[K] { + return document.createElementNS(SVG_NS, tag); +} + function countSamplesInRange(chart: ChartPluginContext, xMin: number, xMax: number): number { const viewport = { xMin, xMax, yMin: -Infinity, yMax: Infinity }; let total = 0; @@ -132,12 +138,12 @@ function resolvePosition(chart: ChartPluginContext, clientX: number, clientY: nu function resolveSharedPosition(chart: ChartPluginContext, dataX: number, yAxis: SeriesYAxis): CrosshairPosition | null { const rect = chart.canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; - const viewport = chart.getViewport(yAxis); const dataY = viewport.yMin + (viewport.yMax - viewport.yMin) * 0.5; const [plotX, plotY] = chart.dataToPlot(dataX, dataY, yAxis); - const picked = positionFromPick(chart, rect.left + plotX, rect.top + plotY, "nearest-x"); - if (picked) return picked; + const picked = pickAtDataX(chart, dataX, { yAxis, mode: "nearest-x", group: "none" }); + const item = picked?.items[0]; + if (item) return { dataX: item.x, dataY: item.y, plotX: item.plotX, plotY: item.plotY, items: [item] }; return { dataX, dataY, plotX, plotY, items: [] }; } @@ -159,17 +165,6 @@ function createXRangeHighlight(item: ChartPickItem, chart: Chart, strokeColor: s return marker; } -function createCrosshairLayer(className: string, zIndex: number): HTMLDivElement { - const layer = document.createElement("div"); - layer.className = className; - layer.style.position = "absolute"; - layer.style.inset = "0"; - layer.style.display = "none"; - layer.style.pointerEvents = "none"; - layer.style.zIndex = String(zIndex); - return layer; -} - function renderDefaultLabel( position: CrosshairPosition, container: HTMLElement, @@ -374,8 +369,8 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair root.style.display = "none"; root.style.pointerEvents = "none"; - lineLayer = createCrosshairLayer("blazeplot-crosshair-lines", options.zIndex ?? 0); - overlayLayer = createCrosshairLayer("blazeplot-crosshair-overlay", options.zIndex ?? 22); + lineLayer = createOverlayLayer("blazeplot-crosshair-lines", { inset: "0", zIndex: options.zIndex ?? 0 }); + overlayLayer = createOverlayLayer("blazeplot-crosshair-overlay", { inset: "0", zIndex: options.zIndex ?? 22 }); vertical = document.createElement("div"); vertical.style.position = "absolute"; @@ -393,12 +388,7 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair horizontal.style.borderTop = `${width} solid ${color}`; if (dash) horizontal.style.borderTopStyle = "dashed"; - markerLayer = document.createElement("div"); - markerLayer.className = "blazeplot-crosshair-markers"; - markerLayer.style.position = "absolute"; - markerLayer.style.inset = "0"; - markerLayer.style.zIndex = "2"; - markerLayer.style.pointerEvents = "none"; + markerLayer = createOverlayLayer("blazeplot-crosshair-markers", { inset: "0", display: "block", zIndex: 2 }); label = document.createElement("div"); label.style.position = "absolute"; @@ -410,7 +400,7 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair label.style.font = options.labelFont ?? chart.theme.tooltipFont; label.style.whiteSpace = "nowrap"; - rulerSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + rulerSvg = createSvgElement("svg"); rulerSvg.style.position = "absolute"; rulerSvg.style.inset = "0"; rulerSvg.style.width = "100%"; @@ -418,7 +408,7 @@ export function crosshairPlugin(options: CrosshairPluginOptions = {}): Crosshair rulerSvg.style.display = "none"; rulerSvg.style.overflow = "hidden"; rulerSvg.style.zIndex = "1"; - rulerLine = document.createElementNS("http://www.w3.org/2000/svg", "line"); + rulerLine = createSvgElement("line"); rulerLine.setAttribute("stroke", color); rulerLine.setAttribute("stroke-width", String(options.width ?? 1)); if (dash) rulerLine.setAttribute("stroke-dasharray", dash); diff --git a/src/ui/Navigator.ts b/src/ui/Navigator.ts index ab9fae7..d76b8fc 100644 --- a/src/ui/Navigator.ts +++ b/src/ui/Navigator.ts @@ -48,7 +48,7 @@ interface DragState { const SVG_NS = "http://www.w3.org/2000/svg"; -function svg(tag: K): SVGElementTagNameMap[K] { +function createSvgElement(tag: K): SVGElementTagNameMap[K] { return document.createElementNS(SVG_NS, tag); } @@ -169,7 +169,7 @@ export function navigatorPlugin(options: NavigatorPluginOptions = {}): Navigator overlay.setAttribute("preserveAspectRatio", "none"); while (paths.length < selectedSeries.length) { - const path = svg("path"); + const path = createSvgElement("path"); path.setAttribute("fill", "none"); path.setAttribute("vector-effect", "non-scaling-stroke"); overlay.insertBefore(path, windowRect); @@ -265,16 +265,16 @@ export function navigatorPlugin(options: NavigatorPluginOptions = {}): Navigator chart.setLayoutReservation(reservationId, placement === "top" ? { top: height + margin * 2 } : { bottom: height + margin * 2 }); } - overlay = svg("svg"); + overlay = createSvgElement("svg"); overlay.style.width = "100%"; overlay.style.height = "100%"; overlay.setAttribute("aria-hidden", "true"); overlay.style.display = "block"; - windowRect = svg("rect"); - leftHandle = svg("rect"); - rightHandle = svg("rect"); - leftHandleHit = svg("rect"); - rightHandleHit = svg("rect"); + windowRect = createSvgElement("rect"); + leftHandle = createSvgElement("rect"); + rightHandle = createSvgElement("rect"); + leftHandleHit = createSvgElement("rect"); + rightHandleHit = createSvgElement("rect"); for (const handle of [leftHandle, rightHandle]) { handle.style.cursor = "ew-resize"; handle.style.pointerEvents = "none"; diff --git a/src/ui/OverlayUtils.ts b/src/ui/OverlayUtils.ts index ea98938..407a276 100644 --- a/src/ui/OverlayUtils.ts +++ b/src/ui/OverlayUtils.ts @@ -1,4 +1,5 @@ -import type { ChartPickItem } from "./Chart.js"; +import type { SeriesYAxis } from "../core/types.js"; +import type { ChartHoverState, ChartPickGroup, ChartPickItem, ChartPickMode, ChartPluginContext } from "./Chart.js"; /** Return the display label for a picked series item. */ export function labelOfPickItem(item: ChartPickItem): string { @@ -23,14 +24,34 @@ export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +/** Shared absolute overlay layer options. */ +export interface OverlayLayerOptions { + readonly zIndex?: number | string; + readonly display?: string; + readonly inset?: string; + readonly pointerEvents?: string; +} + +/** Create a plot overlay layer with consistent positioning and pointer behavior. */ +export function createOverlayLayer(className: string, options: OverlayLayerOptions = {}): HTMLDivElement { + const layer = document.createElement("div"); + layer.className = className; + layer.style.position = "absolute"; + if (options.inset !== undefined) layer.style.inset = options.inset; + layer.style.display = options.display ?? "none"; + layer.style.pointerEvents = options.pointerEvents ?? "none"; + if (options.zIndex !== undefined) layer.style.zIndex = String(options.zIndex); + return layer; +} + /** Position a fixed element near a client point while keeping it onscreen. */ export function placeFixedWithinViewport( element: HTMLElement, clientX: number, clientY: number, - options: { readonly offsetX: number; readonly offsetY: number; readonly margin?: number }, + options: { readonly offsetX: number; readonly offsetY: number; readonly margin?: number; readonly size?: { readonly width: number; readonly height: number } }, ): void { - const rect = element.getBoundingClientRect(); + const rect = options.size ?? element.getBoundingClientRect(); const margin = options.margin ?? 4; const doc = element.ownerDocument; const viewportWidth = Math.max(1, globalThis.innerWidth || doc.documentElement.clientWidth); @@ -82,6 +103,29 @@ export interface PickMarkerOptions { readonly strokeWidthPx?: number; } +/** Options for picking chart items at a data-X value. */ +export interface PickAtDataXOptions { + readonly yAxis?: SeriesYAxis; + readonly mode?: ChartPickMode; + readonly group?: ChartPickGroup; + readonly maxDistancePx?: number; +} + +/** Pick chart items at a data-X value using midpoint Y for pointer-based pick APIs. */ +export function pickAtDataX(chart: ChartPluginContext, dataX: number, options: PickAtDataXOptions = {}): ChartHoverState | null { + const yAxis = options.yAxis ?? "left"; + const rect = chart.canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + const viewport = chart.getViewport(yAxis); + const dataY = viewport.yMin + (viewport.yMax - viewport.yMin) * 0.5; + const [plotX, plotY] = chart.dataToPlot(dataX, dataY, yAxis); + return chart.pick(rect.left + plotX, rect.top + plotY, { + mode: options.mode ?? "nearest-x", + group: options.group ?? "x", + maxDistancePx: options.maxDistancePx, + }); +} + /** Create a marker element for a picked series point. */ export function createPickMarker(item: ChartPickItem, options: PickMarkerOptions = {}): HTMLDivElement { const marker = document.createElement("div"); diff --git a/src/ui/Selection.ts b/src/ui/Selection.ts index 80aa070..a0afe66 100644 --- a/src/ui/Selection.ts +++ b/src/ui/Selection.ts @@ -1,6 +1,7 @@ import type { SeriesSample, SeriesYAxis, Viewport } from "../core/types.js"; import type { SeriesStore } from "../core/SeriesStore.js"; import type { ChartPlugin, ChartPluginContext, ChartSeriesState } from "./Chart.js"; +import { clamp, createOverlayLayer } from "./OverlayUtils.js"; /** Geometry captured by the selection plugin. */ export type SelectionMode = "x-range" | "y-range" | "xy"; @@ -90,10 +91,6 @@ interface DragState { const DEFAULT_FILL = "rgba(59, 130, 246, 0.16)"; const DEFAULT_STROKE = "rgba(147, 197, 253, 0.95)"; -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(value, max)); -} - function normalizeBounds(a: [number, number], b: [number, number], current: Viewport, mode: SelectionMode): SelectionBounds { const xMin = Math.min(a[0], b[0]); const xMax = Math.max(a[0], b[0]); @@ -233,12 +230,7 @@ export function selectionPlugin(options: SelectionPluginOptions = {}): Selection install(chart: ChartPluginContext) { chartRef = chart; const canvas = chart.canvas; - overlay = document.createElement("div"); - overlay.className = options.className ?? "blazeplot-selection-brush"; - overlay.style.position = "absolute"; - overlay.style.display = "none"; - overlay.style.pointerEvents = "none"; - overlay.style.zIndex = String(options.zIndex ?? 26); + overlay = createOverlayLayer(options.className ?? "blazeplot-selection-brush", { zIndex: options.zIndex ?? 26 }); overlay.style.border = `1px solid ${options.stroke ?? DEFAULT_STROKE}`; overlay.style.background = options.fill ?? DEFAULT_FILL; chart.plotElement.appendChild(overlay); diff --git a/src/ui/Tooltip.ts b/src/ui/Tooltip.ts index 95d83e5..35e7503 100644 --- a/src/ui/Tooltip.ts +++ b/src/ui/Tooltip.ts @@ -1,5 +1,5 @@ import type { Chart, ChartHoverState, ChartPickGroup, ChartPickItem, ChartPickMode, ChartPlugin, ChartPluginContext } from "./Chart.js"; -import { clamp, createLongPressTouchTracker, createPickMarker, formatCompactNumber, rgba, renderPickItems } from "./OverlayUtils.js"; +import { createLongPressTouchTracker, createOverlayLayer, createPickMarker, formatCompactNumber, pickAtDataX, placeFixedWithinViewport, renderPickItems, rgba } from "./OverlayUtils.js"; /** Options for the built-in hover tooltip plugin. */ export interface TooltipPluginOptions { @@ -50,14 +50,14 @@ interface TooltipPeer { const tooltipGroups = new Map>(); function placeTooltip(container: HTMLElement, state: ChartHoverState, options: TooltipPluginOptions, size: { readonly width: number; readonly height: number }): void { - const margin = 4; - const width = Math.max(1, size.width || 240); - const height = Math.max(1, size.height || 80); - const viewportWidth = Math.max(1, globalThis.innerWidth || container.ownerDocument.documentElement.clientWidth); - const viewportHeight = Math.max(1, globalThis.innerHeight || container.ownerDocument.documentElement.clientHeight); - const x = clamp(state.clientX + (options.offsetX ?? 12), margin, Math.max(margin, viewportWidth - width - margin)); - const y = clamp(state.clientY + (options.offsetY ?? 12), margin, Math.max(margin, viewportHeight - height - margin)); - container.style.transform = `translate(${x}px, ${y}px)`; + placeFixedWithinViewport(container, state.clientX, state.clientY, { + offsetX: options.offsetX ?? 12, + offsetY: options.offsetY ?? 12, + size: { + width: Math.max(1, size.width || 240), + height: Math.max(1, size.height || 80), + }, + }); } /** Create a plugin that displays picked data values in a tooltip. */ @@ -82,12 +82,7 @@ export function tooltipPlugin(options: TooltipPluginOptions = {}): ChartPlugin { const tooltipParent = chart.rootElement.ownerDocument.body ?? chart.rootElement; tooltipParent.appendChild(container); - const markerLayer = document.createElement("div"); - markerLayer.className = "blazeplot-tooltip-markers"; - markerLayer.style.position = "absolute"; - markerLayer.style.inset = "0"; - markerLayer.style.zIndex = "25"; - markerLayer.style.pointerEvents = "none"; + const markerLayer = createOverlayLayer("blazeplot-tooltip-markers", { inset: "0", display: "block", zIndex: 25 }); chart.plotElement.appendChild(markerLayer); let lockedTooltipWidth = 0; @@ -171,11 +166,7 @@ export function tooltipPlugin(options: TooltipPluginOptions = {}): ChartPlugin { }; const renderSharedAtX = (dataX: number): void => { - const viewport = chart.getViewport(); - const dataY = viewport.yMin + (viewport.yMax - viewport.yMin) * 0.5; - const [plotX, plotY] = chart.dataToPlot(dataX, dataY); - const rect = chart.canvas.getBoundingClientRect(); - render(chart.pick(rect.left + plotX, rect.top + plotY, { + render(pickAtDataX(chart, dataX, { mode: options.mode ?? "nearest-x", group: options.group ?? "x", maxDistancePx: options.maxDistancePx, @@ -243,9 +234,18 @@ export function tooltipPlugin(options: TooltipPluginOptions = {}): ChartPlugin { chart.canvas.addEventListener("touchend", longPress.clear); chart.canvas.addEventListener("touchcancel", longPress.clear); - const unsubscribeHover = chart.subscribe("hover", (state) => { + let hoverRaf = 0; + let pendingHoverState: ChartHoverState | null = null; + const flushHover = (): void => { + hoverRaf = 0; + const state = pendingHoverState; + pendingHoverState = null; render(state); notifyPeers(state); + }; + const unsubscribeHover = chart.subscribe("hover", (state) => { + pendingHoverState = state; + if (hoverRaf === 0) hoverRaf = requestAnimationFrame(flushHover); }); const unsubscribeTheme = chart.subscribe("themechange", () => { applyTheme(); @@ -262,6 +262,7 @@ export function tooltipPlugin(options: TooltipPluginOptions = {}): ChartPlugin { chart.canvas.removeEventListener("touchmove", longPress.onTouchMove, { capture: true }); chart.canvas.removeEventListener("touchend", longPress.clear); chart.canvas.removeEventListener("touchcancel", longPress.clear); + if (hoverRaf !== 0) cancelAnimationFrame(hoverRaf); unsubscribeHover(); unsubscribeTheme(); if (peer && options.syncGroup) { diff --git a/tests/browser/compare/main.ts b/tests/browser/compare/main.ts index 6f6c00b..ef6678e 100644 --- a/tests/browser/compare/main.ts +++ b/tests/browser/compare/main.ts @@ -14,6 +14,7 @@ import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import { Chart, StaticDataset } from "@/index.ts"; import type { AcceleratedDataset, Dataset, MinMaxSegmentLayout, SampleCopyLayout, SeriesStore, TimeRange, Viewport } from "@/index.ts"; +import officialConfig from "../../../scripts/benchmark-config.json"; ChartJs.register(LineController, LineElement, PointElement, LinearScale, Decimation, Legend, Tooltip); @@ -154,8 +155,8 @@ interface BenchmarkController { }; } -const DEFAULT_LIBRARIES: readonly LibraryId[] = ["blazeplot", "uplot", "chartjs"]; -const DEFAULT_SCENARIOS = ["line-100k-static", "line-1m-static", "line-1m-pan", "line-1m-stream", "line-10m-accelerated-pan"] as const; +const DEFAULT_LIBRARIES = officialConfig.libraries as readonly LibraryId[]; +const DEFAULT_SCENARIOS = officialConfig.scenarios; const SCENARIOS: Record = { "line-100k-static": { name: "line-100k-static", diff --git a/tsconfig.json b/tsconfig.json index 64e4835..506f517 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "jsx": "react-jsx", "allowJs": false, "allowImportingTsExtensions": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "noEmit": true, diff --git a/website/src/blazeplot-site.ts b/website/src/blazeplot-site.ts index 44a850c..bd7dc5d 100644 --- a/website/src/blazeplot-site.ts +++ b/website/src/blazeplot-site.ts @@ -43,7 +43,7 @@ export class BlazeplotSite extends LitElement { const doc = getDocPage(this.docSlug) ?? DOC_PAGES[0]!; return html`
- +
${this.section === "home" ? html`` : nothing} ${this.section === "docs" ? html`` : nothing} diff --git a/website/src/site/components/docs-page.ts b/website/src/site/components/docs-page.ts index bee3785..6470a5f 100644 --- a/website/src/site/components/docs-page.ts +++ b/website/src/site/components/docs-page.ts @@ -19,25 +19,39 @@ export class BlazeplotDocsPage extends LitElement { static override styles = siteStyles; static override properties = { doc: { attribute: false }, + docsNavOpen: { state: true }, }; declare doc: DocPage; + declare private docsNavOpen: boolean; private docCharts: Chart[] = []; constructor() { super(); this.doc = DOC_PAGES[0]!; + this.docsNavOpen = false; } private docDisposers: Array<() => void> = []; private mountedDocSlug: string | null = null; + override connectedCallback(): void { + super.connectedCallback(); + window.addEventListener("blazeplot-docs-nav-toggle", this.toggleDocsNav); + window.addEventListener("keydown", this.onKeyDown); + } + override disconnectedCallback(): void { + window.removeEventListener("blazeplot-docs-nav-toggle", this.toggleDocsNav); + window.removeEventListener("keydown", this.onKeyDown); this.disposeDocCharts(); super.disconnectedCallback(); } override updated(changedProperties: PropertyValues): void { - if (changedProperties.has("doc")) this.disposeDocCharts(); + if (changedProperties.has("doc")) { + this.docsNavOpen = false; + this.disposeDocCharts(); + } this.mountDocCharts(this.doc); } @@ -49,28 +63,30 @@ export class BlazeplotDocsPage extends LitElement { all docs source
-
-