From 3e25beaf92e2da1e65f95c2e190b6bcac4b2ea55 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:09:55 -1000 Subject: [PATCH 1/8] feat(spatial): add opt-in grid spatial index Add gridSpatialIndex at @tanstack/charts/spatial/grid. It returns the same point as the linear anchor scan, including the earliest-point tie, radius rules, and non-finite anchors. It is nearest-center lookup, not shape hit testing. Extend benchmarks/interaction/nearest.ts with 1k, 10k, and 100k point scaling rows against the linear scan and D3 quadtree. --- .changeset/grid-spatial-index.md | 5 + API-FRICTION.md | 23 +++ benchmarks/interaction/nearest.ts | 137 +++++++++++++- docs/guides/large-data.md | 36 +++- docs/reference/custom-extensions.md | 2 + docs/reference/focus-and-interaction.md | 25 +++ docs/reference/index.md | 1 + .../charts-core/docs/guides/large-data.md | 36 +++- .../docs/reference/custom-extensions.md | 2 + .../docs/reference/focus-and-interaction.md | 25 +++ packages/charts-core/docs/reference/index.md | 1 + packages/charts-core/package.json | 5 + packages/charts-core/src/exports.test.ts | 12 ++ packages/charts-core/src/spatial-grid.test.ts | 92 +++++++++ packages/charts-core/src/spatial-grid.ts | 177 ++++++++++++++++++ scripts/check-packed-consumers.mjs | 3 + 16 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 .changeset/grid-spatial-index.md create mode 100644 packages/charts-core/src/spatial-grid.test.ts create mode 100644 packages/charts-core/src/spatial-grid.ts diff --git a/.changeset/grid-spatial-index.md b/.changeset/grid-spatial-index.md new file mode 100644 index 00000000..826394d4 --- /dev/null +++ b/.changeset/grid-spatial-index.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add `gridSpatialIndex` at `@tanstack/charts/spatial/grid`, an opt-in `spatialIndex` factory for dense point charts. It returns exactly the point a linear anchor scan returns, including the earliest-point tie and the `maxDistance` limit, and finds the nearest point center rather than testing mark shapes. Charts that do not import it do not grow. diff --git a/API-FRICTION.md b/API-FRICTION.md index 1c43acbb..d2b5d13a 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -343,6 +343,7 @@ Each entry records: | F-304 | Gradient resources hid cross-renderer ownership | API | resolved | | F-305 | Categorical legend styling required a replacement renderer | API | resolved | | F-306 | Inferred consumer declarations could not name core types | Tooling | resolved | +| F-307 | Dense point charts had no shipped spatial index | API | resolved | ## Findings @@ -9045,3 +9046,25 @@ Each entry records: with the pinned TypeScript compiler and typechecks the generated declarations as a downstream consumer. Packed runtime, React Native, and seven framework adapter gates pass. All 60 comparison bundle measurements are unchanged. + +### F-307 - Dense point charts had no shipped spatial index + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: measuring nearest-point lookup for 1k to 100k dot anchors +- Friction: the large-data guide told dense charts to supply a measured + `spatialIndex`, but core shipped none. The only grid lived privately in + `charts-core-d3`, and it did not keep the linear scan's earliest-point tie + when equidistant points fell in different cells. Each application had to + write and verify its own index or bring D3 quadtree, which built 4 to 18 + times slower than a grid on the same 1k to 100k points. +- Decision: publish `gridSpatialIndex` at the exact + `@tanstack/charts/spatial/grid` subpath. It returns the linear anchor scan's + result, including ties, radius rules, and non-finite anchors, and documents + that it is nearest-center lookup, not shape hit testing. +- Verification: seeded property tests compare it with `nearestPoint` across + uniform, lattice, clustered, collinear, single-point, and non-finite layouts, + with half-step queries that force cross-cell ties. `pnpm performance:pointer` + verifies identical results before timing. Default chart bundles are + unchanged. diff --git a/benchmarks/interaction/nearest.ts b/benchmarks/interaction/nearest.ts index f00e9254..7db6a485 100644 --- a/benchmarks/interaction/nearest.ts +++ b/benchmarks/interaction/nearest.ts @@ -1,7 +1,11 @@ import { performance } from 'node:perf_hooks' import { Delaunay } from 'd3-delaunay' import { quadtree } from 'd3-quadtree' -import { nearestScenePoint } from '../../packages/charts-core/src/nearest' +import { + nearestPoint, + nearestScenePoint, +} from '../../packages/charts-core/src/nearest' +import { gridSpatialIndex } from '../../packages/charts-core/src/spatial-grid' import type { ChartFocusAffinity, ChartPoint as CoreChartPoint, @@ -232,6 +236,7 @@ const rectangleComparisonImplementations: readonly Implementation[] = [ ['Vega 5.2.1 · bounds-only lower bound', vegaBoundsNearestPoint], ] as const const collectGarbage = (globalThis as { gc?: () => void }).gc +let retainedIndexes: unknown[] = [] verifyEquivalentResults() verifyComparisonResults() @@ -276,6 +281,7 @@ for (const [label] of comparisonImplementations) { printAsciiDurations(comparisonMeasurements, comparisonImplementations) printIndexBuildTimes() printIndexStorage() +printPointIndexScaling() console.log( '\nRectangle containment comparison · identical targets on this fixture', @@ -885,6 +891,135 @@ function printIndexStorage() { ) } +function printPointIndexScaling() { + console.log( + '\nPoint-anchor index scaling · uniform random anchors in 960 × 540', + ) + console.log( + 'Every row returns the linear scan result; lookup is the per-query mean of one round, reported as the median and p95 across 18 rounds.', + ) + console.log( + '| Points | Radius | Resolver | Median build | p95 build | Median / query | p95 / query | Retained |', + ) + console.log('| ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: |') + for (const size of [1_000, 10_000, 100_000]) { + const random = seededRandom(size) + const points = Array.from({ length: size }, (_, index) => + point(index, random() * 960, random() * 540), + ) + const builders = [ + ['linear anchor scan (default)', undefined], + ['TanStack grid index', () => gridSpatialIndex(points)], + [ + 'D3 quadtree 3.0.1', + () => { + const tree = quadtree>() + .x((candidate) => candidate.x) + .y((candidate) => candidate.y) + .addAll([...points]) + return { + findNearest: (x: number, y: number, maxDistance = Infinity) => + tree.find(x, y, Math.max(0, maxDistance)) ?? null, + } + }, + ], + ] as const + for (const maxDistance of [48, Infinity]) { + const scalingCase: PointerCase = { + label: `${size} points`, + points, + queries: queries(256, () => ({ + x: random() * 1_000 - 20, + y: random() * 580 - 20, + maxDistance, + })), + repetitions: Math.max(1, Math.round(100_000 / size)), + } + const resolvers: Implementation[] = [] + const builds = new Map() + const retained = new Map() + for (const [label, build] of builders) { + if (!build) { + resolvers.push([label, nearestPoint]) + continue + } + builds.set(label, measureBuild(build)) + retained.set(label, measureRetained(build)) + const index = build() + resolvers.push([ + label, + (_points, x, y, distance) => index.findNearest(x, y, distance), + ]) + } + for (const query of scalingCase.queries) { + const reference = nearestPoint(points, query.x, query.y, maxDistance) + for (const [label, resolver] of resolvers) { + const candidate = resolver(points, query.x, query.y, maxDistance) + if (candidate !== reference) { + throw new Error( + `Point index scaling changed ${label}: ${String(reference?.key)} !== ${String(candidate?.key)}`, + ) + } + } + } + const measurements = measure(scalingCase, resolvers) + for (const [label] of resolvers) { + const samples = measurements.get(label)! + const buildSamples = builds.get(label) + const bytes = retained.get(label) + console.log( + `| ${size.toLocaleString()} | ${maxDistance} | ${label} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.5)) : '—'} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.95)) : '—'} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} | ${bytes === undefined ? '—' : bytes < 0 ? 'n/a' : formatStorage(bytes)} |`, + ) + } + } + } + console.log( + 'Retained is the median heap plus ArrayBuffer growth while one index is alive; n/a means --expose-gc was absent.', + ) +} + +function measureBuild(build: () => unknown) { + for (let index = 0; index < 3; index += 1) build() + const samples: number[] = [] + for (let index = 0; index < 18; index += 1) { + collectGarbage?.() + const startedAt = performance.now() + checksum += build() ? 1 : 0 + samples.push(performance.now() - startedAt) + } + return samples.sort((left, right) => left - right) +} + +function measureRetained(build: () => unknown) { + if (!collectGarbage) return -1 + const samples: number[] = [] + for (let round = 0; round < 5; round += 1) { + collectGarbage() + const before = allocatedBytes() + retainedIndexes = Array.from({ length: 4 }, build) + collectGarbage() + samples.push((allocatedBytes() - before) / retainedIndexes.length) + retainedIndexes = [] + } + return percentile( + samples.sort((left, right) => left - right), + 0.5, + ) +} + +function allocatedBytes() { + const usage = process.memoryUsage() + return usage.heapUsed + usage.arrayBuffers +} + +function seededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + return state / 0x1_0000_0000 + } +} + function point( index: number, x: number, diff --git a/docs/guides/large-data.md b/docs/guides/large-data.md index 03d12d5e..ac112faf 100644 --- a/docs/guides/large-data.md +++ b/docs/guides/large-data.md @@ -69,9 +69,10 @@ compilation, path construction, or `ChartPoint` creation. A dot mark still produces one scene node and one interaction point per observation. Default nearest-point focus still scans those points linearly. -Supply a measured `spatialIndex` when individual points are still the correct -pointer target, and use a focus strategy with a bounded `navigation` order -when every observation is not a useful keyboard stop. +Supply a measured `spatialIndex`, such as +[`gridSpatialIndex`](#indexed-nearest-point-lookup), when individual points +are still the correct pointer target, and use a focus strategy with a bounded +`navigation` order when every observation is not a useful keyboard stop. Do not treat Canvas as permission to promise a million independently interactive marks. Compare source, represented, prepared, and rendered counts; @@ -132,6 +133,35 @@ target. An index can accelerate nearest-point lookup. It does not reduce scene size, DOM or draw count, path construction, paint cost, or visual overplotting. +## Indexed nearest-point lookup + +`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is an opt-in +`spatialIndex` factory. Charts that do not import it do not ship it. + +```ts +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' + +const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) +``` + +It returns exactly what a linear scan of point anchors returns, including the +earliest-point tie and the `maxDistance` limit. It finds the nearest point +center. It does not test bars, areas, or lines for containment, so keep the +default lookup for marks whose painted shape is the target. + +The host rebuilds the index after each scene change. On uniform random +anchors in a 960 × 540 plot, with the default 48-pixel radius, Node 24 on an +Apple M5 Max measured these medians with `pnpm performance:pointer`: + +| Points | Linear scan / query | Grid build | Grid / query | D3 quadtree build | D3 quadtree / query | +| ------: | ------------------: | ---------: | -----------: | ----------------: | ------------------: | +| 1,000 | 2.0 µs | 0.04 ms | 0.1 µs | 0.15 ms | 0.5 µs | +| 10,000 | 18 µs | 0.15 ms | 0.1 µs | 1.2 ms | 1.3 µs | +| 100,000 | 0.21 ms | 1.2 ms | 0.8 µs | 21 ms | 5.5 µs | + +The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear +scan is already fast; measure before adding an index there. + ## Streaming windows For a rolling source: diff --git a/docs/reference/custom-extensions.md b/docs/reference/custom-extensions.md index 9bee922b..3282ad70 100644 --- a/docs/reference/custom-extensions.md +++ b/docs/reference/custom-extensions.md @@ -358,6 +358,8 @@ changing scene compilation. Build a point-only index from its first argument, or use `context.scene` from its second argument to index resolved primitive bounds. Return the nearest original point within the requested distance. The host recreates the index when the scene or factory changes. +`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is a built-in +point-only factory with exact linear-scan results. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The appropriate granular spatial primitive can be brought through the boundary diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 1c33c30a..e5623eaf 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -1005,6 +1005,31 @@ bounds and perform exact shape checks on its candidates. A custom `focus` strategy takes precedence over `spatialIndex` for pointer resolution. +### Grid index + +`@tanstack/charts/spatial/grid` exports `gridSpatialIndex`, a ready-made +point-only factory: + +```ts +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' + +const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) +``` + +It buckets each point's `x`/`y` anchor into a uniform grid with about one +point per cell, then searches outward from the pointer's cell. Each query +returns the same point as a linear anchor scan: the smallest squared anchor +distance, the earliest point in `points` order on a tie, and `null` when that +distance exceeds `maxDistance`. A negative `maxDistance` matches only exact +anchors, and points with non-finite anchors stay searchable. + +It is nearest-center lookup, not shape hit testing. Like any index, it +replaces primitive containment and affinity ranking, so a pointer inside a +wide bar can resolve to a neighboring bar whose anchor is closer. Use it for +dots and other marks whose anchor is the useful target. See +[Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for +measured costs. + ## Application-owned gestures Dragging, scrolling, custom crosshair overlays, and freeform range or lasso diff --git a/docs/reference/index.md b/docs/reference/index.md index aa294b54..a27a3dd6 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -182,6 +182,7 @@ aggregate `/scales` export. | `@tanstack/charts/stack` | `stack`, `StackAnchor`, `StackLayout`, `StackOptions`, `StackOrder`, `StackOffset` | | `@tanstack/charts/spring` | `createChartSpring` and scalar spring types | | `@tanstack/charts/spatial/contour` | `contour`, `ContourOptions`, and `ContourDatum` | +| `@tanstack/charts/spatial/grid` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | | `@tanstack/charts/spatial/hexbin` | `hexbin`, `HexbinOptions`, and `HexbinDatum` | | `@tanstack/charts/spatial/density` | `densityContour`, `DensityContourOptions`, and `DensityContourDatum` | | `@tanstack/charts/spatial/delaunay` | `delaunayLink`, `DelaunayLinkOptions`, and `DelaunayLinkDatum` | diff --git a/packages/charts-core/docs/guides/large-data.md b/packages/charts-core/docs/guides/large-data.md index 03d12d5e..ac112faf 100644 --- a/packages/charts-core/docs/guides/large-data.md +++ b/packages/charts-core/docs/guides/large-data.md @@ -69,9 +69,10 @@ compilation, path construction, or `ChartPoint` creation. A dot mark still produces one scene node and one interaction point per observation. Default nearest-point focus still scans those points linearly. -Supply a measured `spatialIndex` when individual points are still the correct -pointer target, and use a focus strategy with a bounded `navigation` order -when every observation is not a useful keyboard stop. +Supply a measured `spatialIndex`, such as +[`gridSpatialIndex`](#indexed-nearest-point-lookup), when individual points +are still the correct pointer target, and use a focus strategy with a bounded +`navigation` order when every observation is not a useful keyboard stop. Do not treat Canvas as permission to promise a million independently interactive marks. Compare source, represented, prepared, and rendered counts; @@ -132,6 +133,35 @@ target. An index can accelerate nearest-point lookup. It does not reduce scene size, DOM or draw count, path construction, paint cost, or visual overplotting. +## Indexed nearest-point lookup + +`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is an opt-in +`spatialIndex` factory. Charts that do not import it do not ship it. + +```ts +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' + +const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) +``` + +It returns exactly what a linear scan of point anchors returns, including the +earliest-point tie and the `maxDistance` limit. It finds the nearest point +center. It does not test bars, areas, or lines for containment, so keep the +default lookup for marks whose painted shape is the target. + +The host rebuilds the index after each scene change. On uniform random +anchors in a 960 × 540 plot, with the default 48-pixel radius, Node 24 on an +Apple M5 Max measured these medians with `pnpm performance:pointer`: + +| Points | Linear scan / query | Grid build | Grid / query | D3 quadtree build | D3 quadtree / query | +| ------: | ------------------: | ---------: | -----------: | ----------------: | ------------------: | +| 1,000 | 2.0 µs | 0.04 ms | 0.1 µs | 0.15 ms | 0.5 µs | +| 10,000 | 18 µs | 0.15 ms | 0.1 µs | 1.2 ms | 1.3 µs | +| 100,000 | 0.21 ms | 1.2 ms | 0.8 µs | 21 ms | 5.5 µs | + +The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear +scan is already fast; measure before adding an index there. + ## Streaming windows For a rolling source: diff --git a/packages/charts-core/docs/reference/custom-extensions.md b/packages/charts-core/docs/reference/custom-extensions.md index 9bee922b..3282ad70 100644 --- a/packages/charts-core/docs/reference/custom-extensions.md +++ b/packages/charts-core/docs/reference/custom-extensions.md @@ -358,6 +358,8 @@ changing scene compilation. Build a point-only index from its first argument, or use `context.scene` from its second argument to index resolved primitive bounds. Return the nearest original point within the requested distance. The host recreates the index when the scene or factory changes. +`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is a built-in +point-only factory with exact linear-scan results. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The appropriate granular spatial primitive can be brought through the boundary diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 1c33c30a..e5623eaf 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -1005,6 +1005,31 @@ bounds and perform exact shape checks on its candidates. A custom `focus` strategy takes precedence over `spatialIndex` for pointer resolution. +### Grid index + +`@tanstack/charts/spatial/grid` exports `gridSpatialIndex`, a ready-made +point-only factory: + +```ts +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' + +const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) +``` + +It buckets each point's `x`/`y` anchor into a uniform grid with about one +point per cell, then searches outward from the pointer's cell. Each query +returns the same point as a linear anchor scan: the smallest squared anchor +distance, the earliest point in `points` order on a tie, and `null` when that +distance exceeds `maxDistance`. A negative `maxDistance` matches only exact +anchors, and points with non-finite anchors stay searchable. + +It is nearest-center lookup, not shape hit testing. Like any index, it +replaces primitive containment and affinity ranking, so a pointer inside a +wide bar can resolve to a neighboring bar whose anchor is closer. Use it for +dots and other marks whose anchor is the useful target. See +[Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for +measured costs. + ## Application-owned gestures Dragging, scrolling, custom crosshair overlays, and freeform range or lasso diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index aa294b54..a27a3dd6 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -182,6 +182,7 @@ aggregate `/scales` export. | `@tanstack/charts/stack` | `stack`, `StackAnchor`, `StackLayout`, `StackOptions`, `StackOrder`, `StackOffset` | | `@tanstack/charts/spring` | `createChartSpring` and scalar spring types | | `@tanstack/charts/spatial/contour` | `contour`, `ContourOptions`, and `ContourDatum` | +| `@tanstack/charts/spatial/grid` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | | `@tanstack/charts/spatial/hexbin` | `hexbin`, `HexbinOptions`, and `HexbinDatum` | | `@tanstack/charts/spatial/density` | `densityContour`, `DensityContourOptions`, and `DensityContourDatum` | | `@tanstack/charts/spatial/delaunay` | `delaunayLink`, `DelaunayLinkOptions`, and `DelaunayLinkDatum` | diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index f93100f9..091571bf 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -195,6 +195,7 @@ "./spatial/contour": "./src/spatial-contour.ts", "./spatial/delaunay": "./src/spatial-delaunay.ts", "./spatial/density": "./src/spatial-density.ts", + "./spatial/grid": "./src/spatial-grid.ts", "./spatial/hexbin": "./src/spatial-hexbin.ts", "./spatial/voronoi": "./src/spatial-voronoi.ts", "./svelte": "./src/svelte.ts", @@ -461,6 +462,10 @@ "types": "./dist/spatial-density.d.ts", "import": "./dist/spatial-density.js" }, + "./spatial/grid": { + "types": "./dist/spatial-grid.d.ts", + "import": "./dist/spatial-grid.js" + }, "./spatial/hexbin": { "types": "./dist/spatial-hexbin.d.ts", "import": "./dist/spatial-hexbin.js" diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 978653ba..9d2bc292 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -403,6 +403,18 @@ describe('public package exports', () => { expect(spatial).toHaveProperty('hexbin') }) + it('keeps the optional grid index on its exact spatial subpath', async () => { + const [root, universal, spatial] = await Promise.all([ + import('@tanstack/charts'), + import('@tanstack/charts/universal'), + import('@tanstack/charts/spatial/grid'), + ]) + + expect(root).not.toHaveProperty('gridSpatialIndex') + expect(universal).not.toHaveProperty('gridSpatialIndex') + expect(spatial).toHaveProperty('gridSpatialIndex') + }) + it('keeps the optional Delaunay algorithm on its exact spatial subpath', async () => { const [root, universal, spatial] = await Promise.all([ import('@tanstack/charts'), diff --git a/packages/charts-core/src/spatial-grid.test.ts b/packages/charts-core/src/spatial-grid.test.ts new file mode 100644 index 00000000..1aaacbd6 --- /dev/null +++ b/packages/charts-core/src/spatial-grid.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { nearestPoint } from './nearest' +import { gridSpatialIndex } from './spatial-grid' +import type { ChartPoint, ChartSpatialIndexFactory } from './types' + +type Layout = (random: () => number, index: number) => readonly [number, number] + +// Each layout stresses a different grid shape: uniform, ties on integer +// lattices, dense clusters with outliers, collinear points, and non-finite +// anchors that must stay outside the grid. +const layouts: Record = { + uniform: (random) => [random() * 800, random() * 400], + lattice: (random) => [Math.floor(random() * 12), Math.floor(random() * 12)], + clustered: (random, index) => + index % 50 === 0 + ? [random() * 1e5 - 5e4, random() * 1e5 - 5e4] + : [300 + random() * 2, 200 + random() * 2], + horizontal: (random) => [random() * 1_000, 42], + vertical: (random) => [7, Math.floor(random() * 30)], + single: () => [5, 5], + nonFinite: (random, index) => + index % 7 === 0 + ? [[NaN, Infinity, -Infinity][index % 3]!, random() * 100] + : [random() * 100, random() * 100], +} + +// The factory must plug into the definition option without a wrapper. +gridSpatialIndex satisfies ChartSpatialIndexFactory + +describe('grid spatial index', () => { + it.each(Object.entries(layouts))( + 'matches the linear anchor scan for %s points', + (_name, layout) => { + for (let seed = 1; seed <= 12; seed += 1) { + const random = seededRandom(seed * 7_919) + const count = [0, 1, 2, 17, 300, 2_000][seed % 6]! + const points = Array.from({ length: count }, (_, index) => + point(index, ...layout(random, index)), + ) + const index = gridSpatialIndex(points) + for (let query = 0; query < 200; query += 1) { + const source = points[Math.floor(random() * count)] + let x = + query % 5 === 0 && source + ? source.x + : random() * 1_400 - 300 + (query % 11 === 0 ? 1e5 : 0) + let y = query % 5 === 0 && source ? source.y : random() * 800 - 200 + if (query % 3 === 0) { + // Half steps sit equidistant between lattice points in + // neighboring cells, so ties cross cell boundaries. + x = Math.round((x % 14) * 2) / 2 + y = Math.round((y % 14) * 2) / 2 + } + const maxDistance = [Infinity, 0, 3, 48, 250, -1, NaN][query % 7]! + expect(index.findNearest(x, y, maxDistance)).toBe( + nearestPoint(points, x, y, maxDistance), + ) + } + expect(index.findNearest(NaN, 1)).toBe( + nearestPoint(points, NaN, 1, Infinity), + ) + expect(index.findNearest(Infinity, 1)).toBe( + nearestPoint(points, Infinity, 1, Infinity), + ) + } + }, + ) +}) + +function point(index: number, x: number, y: number): ChartPoint { + return { + key: `point:${index}`, + markId: 'points', + group: null, + groupLabel: 'points', + datum: index, + datumIndex: index, + xValue: x, + yValue: y, + x, + y, + color: 'currentColor', + } +} + +function seededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0 + return state / 0x1_0000_0000 + } +} diff --git a/packages/charts-core/src/spatial-grid.ts b/packages/charts-core/src/spatial-grid.ts new file mode 100644 index 00000000..9fae721b --- /dev/null +++ b/packages/charts-core/src/spatial-grid.ts @@ -0,0 +1,177 @@ +import type { + ChartPoint, + ChartSpatialIndex, + ChartSpatialIndexFactoryContext, + ChartValue, +} from './types' + +/** + * Builds a uniform-grid nearest-point index for the `spatialIndex` option. + * + * It returns exactly the point a linear anchor scan returns: the smallest + * squared distance from each point's `x`/`y` anchor, the earliest point on a + * tie, and `null` beyond `maxDistance`. It does not test scene shapes, so + * rectangles, areas, and lines are matched by their anchor points only. + */ +export function gridSpatialIndex< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + _context?: ChartSpatialIndexFactoryContext, +): ChartSpatialIndex { + const count = points.length + const xs = new Float64Array(count) + const ys = new Float64Array(count) + // Points with a non-finite anchor stay outside the grid and are scanned. + const outside: number[] = [] + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (let index = 0; index < count; index += 1) { + const { x, y } = points[index]! + xs[index] = x + ys[index] = y + if (!Number.isFinite(x) || !Number.isFinite(y)) { + outside.push(index) + continue + } + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + + const inside = count - outside.length + const width = inside ? maxX - minX : 0 + const height = inside ? maxY - minY : 0 + // Aim for about one point per cell; a degenerate axis gets one column/row. + const cellSize = + width > 0 && height > 0 + ? Math.sqrt((width * height) / Math.max(1, inside)) + : Math.max(width, height) / Math.max(1, inside) + const columns = axisCells(width, cellSize, inside) + const rows = axisCells(height, cellSize, inside) + const cellWidth = columns > 1 ? width / columns : 0 + const cellHeight = rows > 1 ? height / rows : 0 + const scaleX = columns > 1 ? columns / width : 0 + const scaleY = rows > 1 ? rows / height : 0 + // A float can land a point one ulp across a cell edge; pad search bounds. + const slackX = cellWidth * 1e-6 + const slackY = cellHeight * 1e-6 + + // Counting sort into a compact cell table keeps each cell in point order. + const cellOf = new Int32Array(count) + const starts = new Int32Array(columns * rows + 1) + for (let index = 0; index < count; index += 1) { + const x = xs[index]! + const y = ys[index]! + if (!Number.isFinite(x) || !Number.isFinite(y)) { + cellOf[index] = -1 + continue + } + const cell = + clampCell((y - minY) * scaleY, rows) * columns + + clampCell((x - minX) * scaleX, columns) + cellOf[index] = cell + starts[cell + 1]! += 1 + } + for (let cell = 0; cell < columns * rows; cell += 1) { + starts[cell + 1]! += starts[cell]! + } + const fill = starts.slice(0, columns * rows) + const members = new Int32Array(inside) + for (let index = 0; index < count; index += 1) { + const cell = cellOf[index]! + if (cell >= 0) members[fill[cell]!++] = index + } + + return { + findNearest(x, y, maxDistance = Infinity) { + const limit = Math.max(0, maxDistance) ** 2 + let best = -1 + let bestDistance = Infinity + + const visit = (index: number) => { + const dx = xs[index]! - x + const dy = ys[index]! - y + const distance = dx * dx + dy * dy + if ( + distance < bestDistance || + (distance === bestDistance && (best < 0 || index < best)) + ) { + best = index + bestDistance = distance + } + } + + if (!Number.isFinite(x) || !Number.isFinite(y)) { + for (let index = 0; index < count; index += 1) visit(index) + } else if (inside) { + const column = clampCell((x - minX) * scaleX, columns) + const row = clampCell((y - minY) * scaleY, rows) + for (let ring = 0; ; ring += 1) { + const left = column - ring + const right = column + ring + const top = row - ring + const bottom = row + ring + const firstRow = Math.max(0, top) + const lastRow = Math.min(rows - 1, bottom) + const firstColumn = Math.max(0, left) + const lastColumn = Math.min(columns - 1, right) + for (let cellRow = firstRow; cellRow <= lastRow; cellRow += 1) { + const edgeRow = cellRow === top || cellRow === bottom + const step = edgeRow ? 1 : Math.max(1, right - left) + for ( + let cellColumn = edgeRow ? firstColumn : left; + cellColumn <= lastColumn; + cellColumn += step + ) { + if (cellColumn < 0) continue + const cell = cellRow * columns + cellColumn + for (let slot = starts[cell]!; slot < starts[cell + 1]!; slot++) { + visit(members[slot]!) + } + } + } + + // Lower bound on the distance to any cell outside this square. + let reach = Infinity + if (left > 0) { + reach = Math.min(reach, x - (minX + left * cellWidth) - slackX) + } + if (right < columns - 1) { + reach = Math.min(reach, minX + (right + 1) * cellWidth - x - slackX) + } + if (top > 0) { + reach = Math.min(reach, y - (minY + top * cellHeight) - slackY) + } + if (bottom < rows - 1) { + reach = Math.min( + reach, + minY + (bottom + 1) * cellHeight - y - slackY, + ) + } + if (reach === Infinity) break + const bound = Math.max(0, reach) ** 2 + if (bound > bestDistance || bound > limit) break + } + } + for (const index of outside) visit(index) + + return best >= 0 && bestDistance <= limit ? points[best]! : null + }, + } +} + +function axisCells(extent: number, cellSize: number, count: number): number { + if (!(extent > 0) || !(cellSize > 0)) return 1 + return Math.max(1, Math.min(count, Math.ceil(extent / cellSize))) +} + +function clampCell(position: number, cells: number): number { + const cell = Math.floor(position) + return cell < 0 ? 0 : cell >= cells ? cells - 1 : cell +} diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index a5352789..4697bd3f 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -3931,6 +3931,7 @@ async function verifyProductionBundles() { '/@tanstack/charts/dist/transform-rolling-window.js', ], spatialHexbin: ['/@tanstack/charts/dist/spatial-hexbin.js'], + spatialGrid: ['/@tanstack/charts/dist/spatial-grid.js'], spatialDensity: ['/@tanstack/charts/dist/spatial-density.js'], spatialContour: [ '/@tanstack/charts/dist/spatial-contour.js', @@ -4007,6 +4008,7 @@ async function verifyProductionBundles() { 'spatialGrouping', 'd3Contour', 'spatialDelaunay', + 'spatialGrid', 'spatialVoronoi', 'd3Delaunay', 'polarPie', @@ -4031,6 +4033,7 @@ async function verifyProductionBundles() { 'spatialContour', 'spatialGrouping', 'spatialDelaunay', + 'spatialGrid', 'spatialVoronoi', 'd3Delaunay', 'transformFold', From 0b930681e95fa0975d2150cff6905d1910785556 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 15:52:01 -1000 Subject: [PATCH 2/8] fix(spatial): bound grid index work on strips and rename subpath Visit only the in-grid cells of each search ring and bound unvisited cells by their distance in both axes. A one-column or one-row grid no longer walks every ring row: 10k points on a vertical strip went from 754 us to 2 us per query at radius 48 and from 16.5 ms to 0.9 us with an unbounded radius. Rename the subpath to @tanstack/charts/spatial/grid-index, budget it in bundle:check, and add strip and cluster layouts to the pointer benchmark. Document the dense-cluster worst case. --- .changeset/grid-spatial-index.md | 2 +- API-FRICTION.md | 14 +- PLAN.md | 7 +- .../entries/charts-spatial-grid-index.ts | 1 + benchmarks/interaction/README.md | 9 + benchmarks/interaction/nearest.ts | 175 ++++++++++-------- docs/guides/large-data.md | 32 ++-- docs/reference/custom-extensions.md | 2 +- docs/reference/focus-and-interaction.md | 8 +- docs/reference/index.md | 2 +- .../charts-core/docs/guides/large-data.md | 32 ++-- .../docs/reference/custom-extensions.md | 2 +- .../docs/reference/focus-and-interaction.md | 8 +- packages/charts-core/docs/reference/index.md | 2 +- packages/charts-core/package.json | 8 +- packages/charts-core/src/exports.test.ts | 2 +- ...grid.ts => spatial-grid-index-internal.ts} | 115 +++++++----- ...rid.test.ts => spatial-grid-index.test.ts} | 35 +++- .../charts-core/src/spatial-grid-index.ts | 26 +++ scripts/check-packed-consumers.mjs | 9 +- scripts/measure-bundles.mjs | 13 ++ 21 files changed, 335 insertions(+), 169 deletions(-) create mode 100644 benchmarks/entries/charts-spatial-grid-index.ts rename packages/charts-core/src/{spatial-grid.ts => spatial-grid-index-internal.ts} (57%) rename packages/charts-core/src/{spatial-grid.test.ts => spatial-grid-index.test.ts} (70%) create mode 100644 packages/charts-core/src/spatial-grid-index.ts diff --git a/.changeset/grid-spatial-index.md b/.changeset/grid-spatial-index.md index 826394d4..7696bdb4 100644 --- a/.changeset/grid-spatial-index.md +++ b/.changeset/grid-spatial-index.md @@ -2,4 +2,4 @@ '@tanstack/charts': minor --- -Add `gridSpatialIndex` at `@tanstack/charts/spatial/grid`, an opt-in `spatialIndex` factory for dense point charts. It returns exactly the point a linear anchor scan returns, including the earliest-point tie and the `maxDistance` limit, and finds the nearest point center rather than testing mark shapes. Charts that do not import it do not grow. +Add `gridSpatialIndex` at `@tanstack/charts/spatial/grid-index`, an opt-in `spatialIndex` factory for dense point charts. It returns exactly the point a linear anchor scan returns, including the earliest-point tie and the `maxDistance` limit, and finds the nearest point center rather than testing mark shapes. Charts that do not import it do not grow. diff --git a/API-FRICTION.md b/API-FRICTION.md index d2b5d13a..430b31eb 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -9060,11 +9060,15 @@ Each entry records: write and verify its own index or bring D3 quadtree, which built 4 to 18 times slower than a grid on the same 1k to 100k points. - Decision: publish `gridSpatialIndex` at the exact - `@tanstack/charts/spatial/grid` subpath. It returns the linear anchor scan's + `@tanstack/charts/spatial/grid-index` subpath. It returns the linear anchor scan's result, including ties, radius rules, and non-finite anchors, and documents that it is nearest-center lookup, not shape hit testing. - Verification: seeded property tests compare it with `nearestPoint` across - uniform, lattice, clustered, collinear, single-point, and non-finite layouts, - with half-step queries that force cross-cell ties. `pnpm performance:pointer` - verifies identical results before timing. Default chart bundles are - unchanged. + uniform, lattice, clustered, collinear, vertical-strip, thin-strip, + single-point, and non-finite layouts, with half-step queries that force + cross-cell ties. A cell-visit counter bounds strip queries; the first + version averaged 878 to 6,973 cells per query there because its ring sweep + and distance bound ignored the grid's shape. `pnpm performance:pointer` + checks identical results before timing four layouts at three sizes. + `pnpm bundle:check` budgets the subpath at 1.2 KiB gzip and forbids it from + the default DOM host. diff --git a/PLAN.md b/PLAN.md index e5f1f3b1..601e82d3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -638,8 +638,11 @@ builds that keep the TanStack scene and renderer, measure: | Common curves | 0.61 kB | 2.05 kB | +1.51 kB | Keep native common curves; adapt advanced D3 curves | | Group, bin, stack | 1.38 kB | 2.40 kB | +1.37 kB | Reasonable optional transform profile | | Spatial lookup | 0.37 kB | 1.92 kB | +1.70 kB | Keep grid default; quadtree optional | -| Compact number formatting | 0.21 kB | 2.14 kB | +1.93 kB | Keep native formatter | -| General interpolation | — | 3.82 kB | — | Too broad for mandatory motion | + +Note: the shipped default is still the linear scan. The grid became the opt-in +`@tanstack/charts/spatial/grid-index` factory rather than a default. +| Compact number formatting | 0.21 kB | 2.14 kB | +1.93 kB | Keep native formatter | +| General interpolation | — | 3.82 kB | — | Too broad for mandatory motion | The consumer deltas are measured directly where a meaningful chart fixture exists: native versus D3 time ticks in the same UTC line, native versus D3 diff --git a/benchmarks/entries/charts-spatial-grid-index.ts b/benchmarks/entries/charts-spatial-grid-index.ts new file mode 100644 index 00000000..9ea9000e --- /dev/null +++ b/benchmarks/entries/charts-spatial-grid-index.ts @@ -0,0 +1 @@ +export { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index' diff --git a/benchmarks/interaction/README.md b/benchmarks/interaction/README.md index 6ae9f952..25587305 100644 --- a/benchmarks/interaction/README.md +++ b/benchmarks/interaction/README.md @@ -79,6 +79,15 @@ The comparison follows the primary implementations and contracts documented by [reverse visitor](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/visit.js) and [Canvas picker](https://github.com/vega/vega/blob/main/packages/vega-scenegraph/src/util/canvas/pick.js). +The point-anchor scaling table measures the opt-in grid index from +`@tanstack/charts/spatial/grid-index` against the linear anchor scan and D3 +quadtree 3.0.1 at 1,000, 10,000, and 100,000 points, with a 48-pixel and an +unbounded radius. It covers uniform anchors, a vertical strip, a horizontal +strip, and a dense cluster with outliers, once with pointers across the plot +and once with pointers inside the cluster, which is the grid's worst case. It +reports build time, query median and p95, and retained memory, and it checks +that every index returns the linear result before timing. + `pnpm bundle:check` reports isolated anchor-only and geometry resolver entries beside the existing D3 quadtree and Delaunay kernels. The geometry resolver has a 2 KiB gzip ceiling; the historical anchor-only entry stays unbudgeted so the diff --git a/benchmarks/interaction/nearest.ts b/benchmarks/interaction/nearest.ts index 7db6a485..da101d14 100644 --- a/benchmarks/interaction/nearest.ts +++ b/benchmarks/interaction/nearest.ts @@ -5,7 +5,7 @@ import { nearestPoint, nearestScenePoint, } from '../../packages/charts-core/src/nearest' -import { gridSpatialIndex } from '../../packages/charts-core/src/spatial-grid' +import { gridSpatialIndex } from '../../packages/charts-core/src/spatial-grid-index' import type { ChartFocusAffinity, ChartPoint as CoreChartPoint, @@ -892,90 +892,119 @@ function printIndexStorage() { } function printPointIndexScaling() { + console.log('\nPoint-anchor index scaling · anchors in a 960 × 540 plot') console.log( - '\nPoint-anchor index scaling · uniform random anchors in 960 × 540', + 'Every row returns the linear scan result; lookup is the per-query mean of one round, reported as the median and p95 across 18 rounds.', ) console.log( - 'Every row returns the linear scan result; lookup is the per-query mean of one round, reported as the median and p95 across 18 rounds.', + 'Layouts: uniform; vertical strip (one x); horizontal strip (one y); clustered (98% within 2 px of the center, 2% uniform outliers). Pointers sweep the plot except in the last layout, where they stay inside the cluster.', ) console.log( - '| Points | Radius | Resolver | Median build | p95 build | Median / query | p95 / query | Retained |', + '| Layout | Points | Radius | Resolver | Median build | p95 build | Median / query | p95 / query | Retained |', ) - console.log('| ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: |') - for (const size of [1_000, 10_000, 100_000]) { - const random = seededRandom(size) - const points = Array.from({ length: size }, (_, index) => - point(index, random() * 960, random() * 540), - ) - const builders = [ - ['linear anchor scan (default)', undefined], - ['TanStack grid index', () => gridSpatialIndex(points)], - [ - 'D3 quadtree 3.0.1', - () => { - const tree = quadtree>() - .x((candidate) => candidate.x) - .y((candidate) => candidate.y) - .addAll([...points]) - return { - findNearest: (x: number, y: number, maxDistance = Infinity) => - tree.find(x, y, Math.max(0, maxDistance)) ?? null, - } - }, - ], - ] as const - for (const maxDistance of [48, Infinity]) { - const scalingCase: PointerCase = { - label: `${size} points`, - points, - queries: queries(256, () => ({ - x: random() * 1_000 - 20, - y: random() * 580 - 20, - maxDistance, - })), - repetitions: Math.max(1, Math.round(100_000 / size)), - } - const resolvers: Implementation[] = [] - const builds = new Map() - const retained = new Map() - for (const [label, build] of builders) { - if (!build) { - resolvers.push([label, nearestPoint]) - continue + console.log('| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: |') + const clustered = (random: () => number, index: number) => + index % 50 === 0 + ? ([random() * 960, random() * 540] as const) + : ([479 + random() * 2, 269 + random() * 2] as const) + const layouts: readonly (readonly [ + string, + (random: () => number, index: number) => readonly [number, number], + pointerArea?: readonly [number, number, number, number], + ])[] = [ + ['uniform', (random) => [random() * 960, random() * 540]], + ['vertical strip', (random) => [480, random() * 540]], + ['horizontal strip', (random) => [random() * 960, 270]], + ['clustered', clustered], + ['clustered · pointer in cluster', clustered, [479, 269, 2, 2]], + ] + for (const [layout, place, pointerArea] of layouts) { + for (const size of [1_000, 10_000, 100_000]) { + printPointIndexScalingCase(layout, size, place, pointerArea) + } + } + console.log( + 'Retained is the median heap plus ArrayBuffer growth while one index is alive; n/a means --expose-gc was absent.', + ) +} + +function printPointIndexScalingCase( + layout: string, + size: number, + place: (random: () => number, index: number) => readonly [number, number], + [left, top, width, height]: readonly [number, number, number, number] = [ + -20, -20, 1_000, 580, + ], +) { + const random = seededRandom(size) + const points = Array.from({ length: size }, (_, index) => + point(index, ...place(random, index)), + ) + const builders = [ + ['linear anchor scan (default)', undefined], + ['TanStack grid index', () => gridSpatialIndex(points)], + [ + 'D3 quadtree 3.0.1', + () => { + const tree = quadtree>() + .x((candidate) => candidate.x) + .y((candidate) => candidate.y) + .addAll([...points]) + return { + findNearest: (x: number, y: number, maxDistance = Infinity) => + tree.find(x, y, Math.max(0, maxDistance)) ?? null, } - builds.set(label, measureBuild(build)) - retained.set(label, measureRetained(build)) - const index = build() - resolvers.push([ - label, - (_points, x, y, distance) => index.findNearest(x, y, distance), - ]) + }, + ], + ] as const + for (const maxDistance of [48, Infinity]) { + const scalingCase: PointerCase = { + label: `${layout} ${size} points`, + points, + queries: queries(256, () => ({ + x: left + random() * width, + y: top + random() * height, + maxDistance, + })), + repetitions: Math.max(1, Math.round(100_000 / size)), + } + const resolvers: Implementation[] = [] + const builds = new Map() + const retained = new Map() + for (const [label, build] of builders) { + if (!build) { + resolvers.push([label, nearestPoint]) + continue } - for (const query of scalingCase.queries) { - const reference = nearestPoint(points, query.x, query.y, maxDistance) - for (const [label, resolver] of resolvers) { - const candidate = resolver(points, query.x, query.y, maxDistance) - if (candidate !== reference) { - throw new Error( - `Point index scaling changed ${label}: ${String(reference?.key)} !== ${String(candidate?.key)}`, - ) - } + builds.set(label, measureBuild(build)) + retained.set(label, measureRetained(build)) + const index = build() + resolvers.push([ + label, + (_points, x, y, distance) => index.findNearest(x, y, distance), + ]) + } + for (const query of scalingCase.queries) { + const reference = nearestPoint(points, query.x, query.y, maxDistance) + for (const [label, resolver] of resolvers) { + const candidate = resolver(points, query.x, query.y, maxDistance) + if (candidate !== reference) { + throw new Error( + `Point index scaling changed ${label}: ${String(reference?.key)} !== ${String(candidate?.key)}`, + ) } } - const measurements = measure(scalingCase, resolvers) - for (const [label] of resolvers) { - const samples = measurements.get(label)! - const buildSamples = builds.get(label) - const bytes = retained.get(label) - console.log( - `| ${size.toLocaleString()} | ${maxDistance} | ${label} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.5)) : '—'} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.95)) : '—'} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} | ${bytes === undefined ? '—' : bytes < 0 ? 'n/a' : formatStorage(bytes)} |`, - ) - } + } + const measurements = measure(scalingCase, resolvers) + for (const [label] of resolvers) { + const samples = measurements.get(label)! + const buildSamples = builds.get(label) + const bytes = retained.get(label) + console.log( + `| ${layout} | ${size.toLocaleString()} | ${maxDistance} | ${label} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.5)) : '—'} | ${buildSamples ? formatDuration(percentile(buildSamples, 0.95)) : '—'} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} | ${bytes === undefined ? '—' : bytes < 0 ? 'n/a' : formatStorage(bytes)} |`, + ) } } - console.log( - 'Retained is the median heap plus ArrayBuffer growth while one index is alive; n/a means --expose-gc was absent.', - ) } function measureBuild(build: () => unknown) { diff --git a/docs/guides/large-data.md b/docs/guides/large-data.md index ac112faf..cb7ed59f 100644 --- a/docs/guides/large-data.md +++ b/docs/guides/large-data.md @@ -135,11 +135,11 @@ DOM or draw count, path construction, paint cost, or visual overplotting. ## Indexed nearest-point lookup -`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is an opt-in +`gridSpatialIndex` from `@tanstack/charts/spatial/grid-index` is an opt-in `spatialIndex` factory. Charts that do not import it do not ship it. ```ts -import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index' const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) ``` @@ -149,18 +149,28 @@ earliest-point tie and the `maxDistance` limit. It finds the nearest point center. It does not test bars, areas, or lines for containment, so keep the default lookup for marks whose painted shape is the target. -The host rebuilds the index after each scene change. On uniform random -anchors in a 960 × 540 plot, with the default 48-pixel radius, Node 24 on an -Apple M5 Max measured these medians with `pnpm performance:pointer`: +The host rebuilds the index after each scene change. With the default +48-pixel radius, Node 24 on an Apple M5 Max measured these medians with +`pnpm performance:pointer`. Anchors are uniform in a 960 × 540 plot and +pointers sweep the plot. | Points | Linear scan / query | Grid build | Grid / query | D3 quadtree build | D3 quadtree / query | | ------: | ------------------: | ---------: | -----------: | ----------------: | ------------------: | -| 1,000 | 2.0 µs | 0.04 ms | 0.1 µs | 0.15 ms | 0.5 µs | -| 10,000 | 18 µs | 0.15 ms | 0.1 µs | 1.2 ms | 1.3 µs | -| 100,000 | 0.21 ms | 1.2 ms | 0.8 µs | 21 ms | 5.5 µs | - -The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear -scan is already fast; measure before adding an index there. +| 1,000 | 1.8 µs | 0.03 ms | 0.1 µs | 0.14 ms | 0.5 µs | +| 10,000 | 17 µs | 0.11 ms | 0.3 µs | 1.0 ms | 1.2 µs | +| 100,000 | 0.19 ms | 1.1 ms | 2.4 µs | 16 ms | 5.2 µs | + +Points on one vertical or horizontal line stay under 1 µs per query at +100,000 points. The worst case is a dense cluster with the pointer inside it: +the grid cannot split points that share a cell, so it scans the cluster. With +98% of points within 2 pixels, it measured about 2× slower than the linear +scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at +100,000. D3 quadtree handles that case better. The benchmark prints every +layout, radius, and size. + +The grid keeps about 24 bytes per point, against about 90 for the quadtree. +At a few hundred points the linear scan is already fast; measure before +adding an index there. ## Streaming windows diff --git a/docs/reference/custom-extensions.md b/docs/reference/custom-extensions.md index 3282ad70..eb60ee42 100644 --- a/docs/reference/custom-extensions.md +++ b/docs/reference/custom-extensions.md @@ -358,7 +358,7 @@ changing scene compilation. Build a point-only index from its first argument, or use `context.scene` from its second argument to index resolved primitive bounds. Return the nearest original point within the requested distance. The host recreates the index when the scene or factory changes. -`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is a built-in +`gridSpatialIndex` from `@tanstack/charts/spatial/grid-index` is a built-in point-only factory with exact linear-scan results. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index e5623eaf..ef9b4750 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -1007,11 +1007,11 @@ resolution. ### Grid index -`@tanstack/charts/spatial/grid` exports `gridSpatialIndex`, a ready-made +`@tanstack/charts/spatial/grid-index` exports `gridSpatialIndex`, a ready-made point-only factory: ```ts -import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index' const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) ``` @@ -1026,7 +1026,9 @@ anchors, and points with non-finite anchors stay searchable. It is nearest-center lookup, not shape hit testing. Like any index, it replaces primitive containment and affinity ranking, so a pointer inside a wide bar can resolve to a neighboring bar whose anchor is closer. Use it for -dots and other marks whose anchor is the useful target. See +dots and other marks whose anchor is the useful target. Points packed into +one cell are scanned in order, so a pointer inside a dense cluster costs about +as much as the linear scan. See [Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for measured costs. diff --git a/docs/reference/index.md b/docs/reference/index.md index a27a3dd6..024b3f1f 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -182,7 +182,7 @@ aggregate `/scales` export. | `@tanstack/charts/stack` | `stack`, `StackAnchor`, `StackLayout`, `StackOptions`, `StackOrder`, `StackOffset` | | `@tanstack/charts/spring` | `createChartSpring` and scalar spring types | | `@tanstack/charts/spatial/contour` | `contour`, `ContourOptions`, and `ContourDatum` | -| `@tanstack/charts/spatial/grid` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | +| `@tanstack/charts/spatial/grid-index` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | | `@tanstack/charts/spatial/hexbin` | `hexbin`, `HexbinOptions`, and `HexbinDatum` | | `@tanstack/charts/spatial/density` | `densityContour`, `DensityContourOptions`, and `DensityContourDatum` | | `@tanstack/charts/spatial/delaunay` | `delaunayLink`, `DelaunayLinkOptions`, and `DelaunayLinkDatum` | diff --git a/packages/charts-core/docs/guides/large-data.md b/packages/charts-core/docs/guides/large-data.md index ac112faf..cb7ed59f 100644 --- a/packages/charts-core/docs/guides/large-data.md +++ b/packages/charts-core/docs/guides/large-data.md @@ -135,11 +135,11 @@ DOM or draw count, path construction, paint cost, or visual overplotting. ## Indexed nearest-point lookup -`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is an opt-in +`gridSpatialIndex` from `@tanstack/charts/spatial/grid-index` is an opt-in `spatialIndex` factory. Charts that do not import it do not ship it. ```ts -import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index' const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) ``` @@ -149,18 +149,28 @@ earliest-point tie and the `maxDistance` limit. It finds the nearest point center. It does not test bars, areas, or lines for containment, so keep the default lookup for marks whose painted shape is the target. -The host rebuilds the index after each scene change. On uniform random -anchors in a 960 × 540 plot, with the default 48-pixel radius, Node 24 on an -Apple M5 Max measured these medians with `pnpm performance:pointer`: +The host rebuilds the index after each scene change. With the default +48-pixel radius, Node 24 on an Apple M5 Max measured these medians with +`pnpm performance:pointer`. Anchors are uniform in a 960 × 540 plot and +pointers sweep the plot. | Points | Linear scan / query | Grid build | Grid / query | D3 quadtree build | D3 quadtree / query | | ------: | ------------------: | ---------: | -----------: | ----------------: | ------------------: | -| 1,000 | 2.0 µs | 0.04 ms | 0.1 µs | 0.15 ms | 0.5 µs | -| 10,000 | 18 µs | 0.15 ms | 0.1 µs | 1.2 ms | 1.3 µs | -| 100,000 | 0.21 ms | 1.2 ms | 0.8 µs | 21 ms | 5.5 µs | - -The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear -scan is already fast; measure before adding an index there. +| 1,000 | 1.8 µs | 0.03 ms | 0.1 µs | 0.14 ms | 0.5 µs | +| 10,000 | 17 µs | 0.11 ms | 0.3 µs | 1.0 ms | 1.2 µs | +| 100,000 | 0.19 ms | 1.1 ms | 2.4 µs | 16 ms | 5.2 µs | + +Points on one vertical or horizontal line stay under 1 µs per query at +100,000 points. The worst case is a dense cluster with the pointer inside it: +the grid cannot split points that share a cell, so it scans the cluster. With +98% of points within 2 pixels, it measured about 2× slower than the linear +scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at +100,000. D3 quadtree handles that case better. The benchmark prints every +layout, radius, and size. + +The grid keeps about 24 bytes per point, against about 90 for the quadtree. +At a few hundred points the linear scan is already fast; measure before +adding an index there. ## Streaming windows diff --git a/packages/charts-core/docs/reference/custom-extensions.md b/packages/charts-core/docs/reference/custom-extensions.md index 3282ad70..eb60ee42 100644 --- a/packages/charts-core/docs/reference/custom-extensions.md +++ b/packages/charts-core/docs/reference/custom-extensions.md @@ -358,7 +358,7 @@ changing scene compilation. Build a point-only index from its first argument, or use `context.scene` from its second argument to index resolved primitive bounds. Return the nearest original point within the requested distance. The host recreates the index when the scene or factory changes. -`gridSpatialIndex` from `@tanstack/charts/spatial/grid` is a built-in +`gridSpatialIndex` from `@tanstack/charts/spatial/grid-index` is a built-in point-only factory with exact linear-scan results. See [Spatial indexes](./focus-and-interaction.md#spatial-indexes). The diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index e5623eaf..ef9b4750 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -1007,11 +1007,11 @@ resolution. ### Grid index -`@tanstack/charts/spatial/grid` exports `gridSpatialIndex`, a ready-made +`@tanstack/charts/spatial/grid-index` exports `gridSpatialIndex`, a ready-made point-only factory: ```ts -import { gridSpatialIndex } from '@tanstack/charts/spatial/grid' +import { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index' const chart = defineChart(spec, { spatialIndex: gridSpatialIndex }) ``` @@ -1026,7 +1026,9 @@ anchors, and points with non-finite anchors stay searchable. It is nearest-center lookup, not shape hit testing. Like any index, it replaces primitive containment and affinity ranking, so a pointer inside a wide bar can resolve to a neighboring bar whose anchor is closer. Use it for -dots and other marks whose anchor is the useful target. See +dots and other marks whose anchor is the useful target. Points packed into +one cell are scanned in order, so a pointer inside a dense cluster costs about +as much as the linear scan. See [Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for measured costs. diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index a27a3dd6..024b3f1f 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -182,7 +182,7 @@ aggregate `/scales` export. | `@tanstack/charts/stack` | `stack`, `StackAnchor`, `StackLayout`, `StackOptions`, `StackOrder`, `StackOffset` | | `@tanstack/charts/spring` | `createChartSpring` and scalar spring types | | `@tanstack/charts/spatial/contour` | `contour`, `ContourOptions`, and `ContourDatum` | -| `@tanstack/charts/spatial/grid` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | +| `@tanstack/charts/spatial/grid-index` | `gridSpatialIndex`, a point-anchor `spatialIndex` factory | | `@tanstack/charts/spatial/hexbin` | `hexbin`, `HexbinOptions`, and `HexbinDatum` | | `@tanstack/charts/spatial/density` | `densityContour`, `DensityContourOptions`, and `DensityContourDatum` | | `@tanstack/charts/spatial/delaunay` | `delaunayLink`, `DelaunayLinkOptions`, and `DelaunayLinkDatum` | diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index 091571bf..8af62720 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -195,7 +195,7 @@ "./spatial/contour": "./src/spatial-contour.ts", "./spatial/delaunay": "./src/spatial-delaunay.ts", "./spatial/density": "./src/spatial-density.ts", - "./spatial/grid": "./src/spatial-grid.ts", + "./spatial/grid-index": "./src/spatial-grid-index.ts", "./spatial/hexbin": "./src/spatial-hexbin.ts", "./spatial/voronoi": "./src/spatial-voronoi.ts", "./svelte": "./src/svelte.ts", @@ -462,9 +462,9 @@ "types": "./dist/spatial-density.d.ts", "import": "./dist/spatial-density.js" }, - "./spatial/grid": { - "types": "./dist/spatial-grid.d.ts", - "import": "./dist/spatial-grid.js" + "./spatial/grid-index": { + "types": "./dist/spatial-grid-index.d.ts", + "import": "./dist/spatial-grid-index.js" }, "./spatial/hexbin": { "types": "./dist/spatial-hexbin.d.ts", diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts index 9d2bc292..cd2e0706 100644 --- a/packages/charts-core/src/exports.test.ts +++ b/packages/charts-core/src/exports.test.ts @@ -407,7 +407,7 @@ describe('public package exports', () => { const [root, universal, spatial] = await Promise.all([ import('@tanstack/charts'), import('@tanstack/charts/universal'), - import('@tanstack/charts/spatial/grid'), + import('@tanstack/charts/spatial/grid-index'), ]) expect(root).not.toHaveProperty('gridSpatialIndex') diff --git a/packages/charts-core/src/spatial-grid.ts b/packages/charts-core/src/spatial-grid-index-internal.ts similarity index 57% rename from packages/charts-core/src/spatial-grid.ts rename to packages/charts-core/src/spatial-grid-index-internal.ts index 9fae721b..8e56ab08 100644 --- a/packages/charts-core/src/spatial-grid.ts +++ b/packages/charts-core/src/spatial-grid-index-internal.ts @@ -1,25 +1,17 @@ -import type { - ChartPoint, - ChartSpatialIndex, - ChartSpatialIndexFactoryContext, - ChartValue, -} from './types' +import type { ChartPoint, ChartSpatialIndex, ChartValue } from './types' -/** - * Builds a uniform-grid nearest-point index for the `spatialIndex` option. - * - * It returns exactly the point a linear anchor scan returns: the smallest - * squared distance from each point's `x`/`y` anchor, the earliest point on a - * tie, and `null` beyond `maxDistance`. It does not test scene shapes, so - * rectangles, areas, and lines are matched by their anchor points only. - */ -export function gridSpatialIndex< +/** Counts grid cells examined by queries; tests use it to bound work. */ +export interface GridIndexProbe { + cells: number +} + +export function createGridIndex< TDatum, TXValue extends ChartValue, TYValue extends ChartValue, >( points: readonly ChartPoint[], - _context?: ChartSpatialIndexFactoryContext, + probe?: GridIndexProbe, ): ChartSpatialIndex { const count = points.length const xs = new Float64Array(count) @@ -47,7 +39,8 @@ export function gridSpatialIndex< const inside = count - outside.length const width = inside ? maxX - minX : 0 const height = inside ? maxY - minY : 0 - // Aim for about one point per cell; a degenerate axis gets one column/row. + // Aim for about one point per cell. Each axis is capped at the point count, + // so the table and the number of rings stay linear in the point count. const cellSize = width > 0 && height > 0 ? Math.sqrt((width * height) / Math.max(1, inside)) @@ -63,8 +56,9 @@ export function gridSpatialIndex< const slackY = cellHeight * 1e-6 // Counting sort into a compact cell table keeps each cell in point order. + const cellCount = columns * rows const cellOf = new Int32Array(count) - const starts = new Int32Array(columns * rows + 1) + const starts = new Int32Array(cellCount + 1) for (let index = 0; index < count; index += 1) { const x = xs[index]! const y = ys[index]! @@ -78,10 +72,10 @@ export function gridSpatialIndex< cellOf[index] = cell starts[cell + 1]! += 1 } - for (let cell = 0; cell < columns * rows; cell += 1) { + for (let cell = 0; cell < cellCount; cell += 1) { starts[cell + 1]! += starts[cell]! } - const fill = starts.slice(0, columns * rows) + const fill = starts.slice(0, cellCount) const members = new Int32Array(inside) for (let index = 0; index < count; index += 1) { const cell = cellOf[index]! @@ -106,6 +100,24 @@ export function gridSpatialIndex< bestDistance = distance } } + const visitCell = (column: number, row: number) => { + if (probe) probe.cells += 1 + const cell = row * columns + column + for (let slot = starts[cell]!; slot < starts[cell + 1]!; slot += 1) { + visit(members[slot]!) + } + } + // Squared distance from the query to an axis-aligned rectangle. + const rectDistance = ( + left: number, + top: number, + right: number, + bottom: number, + ) => { + const dx = x < left ? left - x : x > right ? x - right : 0 + const dy = y < top ? top - y : y > bottom ? y - bottom : 0 + return dx * dx + dy * dy + } if (!Number.isFinite(x) || !Number.isFinite(y)) { for (let index = 0; index < count; index += 1) visit(index) @@ -117,46 +129,55 @@ export function gridSpatialIndex< const right = column + ring const top = row - ring const bottom = row + ring - const firstRow = Math.max(0, top) - const lastRow = Math.min(rows - 1, bottom) const firstColumn = Math.max(0, left) const lastColumn = Math.min(columns - 1, right) - for (let cellRow = firstRow; cellRow <= lastRow; cellRow += 1) { - const edgeRow = cellRow === top || cellRow === bottom - const step = edgeRow ? 1 : Math.max(1, right - left) - for ( - let cellColumn = edgeRow ? firstColumn : left; - cellColumn <= lastColumn; - cellColumn += step - ) { - if (cellColumn < 0) continue - const cell = cellRow * columns + cellColumn - for (let slot = starts[cell]!; slot < starts[cell + 1]!; slot++) { - visit(members[slot]!) - } + // Visit only the ring's cells that exist: its top and bottom rows, + // then its left and right columns between them. + if (top >= 0) { + for (let cell = firstColumn; cell <= lastColumn; cell += 1) { + visitCell(cell, top) + } + } + if (ring > 0 && bottom < rows) { + for (let cell = firstColumn; cell <= lastColumn; cell += 1) { + visitCell(cell, bottom) + } + } + const firstRow = Math.max(0, top + 1) + const lastRow = Math.min(rows - 1, bottom - 1) + if (left >= 0) { + for (let cell = firstRow; cell <= lastRow; cell += 1) { + visitCell(left, cell) + } + } + if (ring > 0 && right < columns) { + for (let cell = firstRow; cell <= lastRow; cell += 1) { + visitCell(right, cell) } } - // Lower bound on the distance to any cell outside this square. - let reach = Infinity + // Lower bound on the distance to any unvisited cell: the nearest of + // the grid strips beyond each side of the visited square. + let bound = Infinity if (left > 0) { - reach = Math.min(reach, x - (minX + left * cellWidth) - slackX) + const edge = minX + left * cellWidth + slackX + bound = Math.min(bound, rectDistance(minX, minY, edge, maxY)) } if (right < columns - 1) { - reach = Math.min(reach, minX + (right + 1) * cellWidth - x - slackX) + const edge = minX + (right + 1) * cellWidth - slackX + bound = Math.min(bound, rectDistance(edge, minY, maxX, maxY)) } if (top > 0) { - reach = Math.min(reach, y - (minY + top * cellHeight) - slackY) + const edge = minY + top * cellHeight + slackY + bound = Math.min(bound, rectDistance(minX, minY, maxX, edge)) } if (bottom < rows - 1) { - reach = Math.min( - reach, - minY + (bottom + 1) * cellHeight - y - slackY, - ) + const edge = minY + (bottom + 1) * cellHeight - slackY + bound = Math.min(bound, rectDistance(minX, edge, maxX, maxY)) + } + if (bound === Infinity || bound > bestDistance || bound > limit) { + break } - if (reach === Infinity) break - const bound = Math.max(0, reach) ** 2 - if (bound > bestDistance || bound > limit) break } } for (const index of outside) visit(index) diff --git a/packages/charts-core/src/spatial-grid.test.ts b/packages/charts-core/src/spatial-grid-index.test.ts similarity index 70% rename from packages/charts-core/src/spatial-grid.test.ts rename to packages/charts-core/src/spatial-grid-index.test.ts index 1aaacbd6..9b7001ef 100644 --- a/packages/charts-core/src/spatial-grid.test.ts +++ b/packages/charts-core/src/spatial-grid-index.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { nearestPoint } from './nearest' -import { gridSpatialIndex } from './spatial-grid' +import { gridSpatialIndex } from './spatial-grid-index' +import { createGridIndex } from './spatial-grid-index-internal' import type { ChartPoint, ChartSpatialIndexFactory } from './types' type Layout = (random: () => number, index: number) => readonly [number, number] @@ -17,6 +18,8 @@ const layouts: Record = { : [300 + random() * 2, 200 + random() * 2], horizontal: (random) => [random() * 1_000, 42], vertical: (random) => [7, Math.floor(random() * 30)], + verticalStrip: (random) => [480, random() * 540], + thinStrip: (random) => [480 + random() * 2, random() * 540], single: () => [5, 5], nonFinite: (random, index) => index % 7 === 0 @@ -28,6 +31,36 @@ const layouts: Record = { gridSpatialIndex satisfies ChartSpatialIndexFactory describe('grid spatial index', () => { + it.each([ + ['one column', (random: () => number) => [480, random() * 540] as const], + ['one row', (random: () => number) => [random() * 960, 270] as const], + [ + 'a thin strip', + (random: () => number) => [480 + random() * 2, random() * 540] as const, + ], + ])('keeps queries cheap on %s', (_name, layout) => { + const random = seededRandom(31) + const points = Array.from({ length: 10_000 }, (_, index) => + point(index, ...layout(random)), + ) + const probe = { cells: 0 } + const index = createGridIndex(points, probe) + let queries = 0 + for (const maxDistance of [48, Infinity]) { + for (let query = 0; query < 100; query += 1) { + const x = random() * 1_000 - 20 + const y = random() * 580 - 20 + expect(index.findNearest(x, y, maxDistance)).toBe( + nearestPoint(points, x, y, maxDistance), + ) + queries += 1 + } + } + // A strip has thousands of cells. Before ring sweeps were clamped to the + // grid and bounded by distance, these layouts averaged 878-6,973 cells. + expect(probe.cells / queries).toBeLessThan(250) + }) + it.each(Object.entries(layouts))( 'matches the linear anchor scan for %s points', (_name, layout) => { diff --git a/packages/charts-core/src/spatial-grid-index.ts b/packages/charts-core/src/spatial-grid-index.ts new file mode 100644 index 00000000..df7d8207 --- /dev/null +++ b/packages/charts-core/src/spatial-grid-index.ts @@ -0,0 +1,26 @@ +import { createGridIndex } from './spatial-grid-index-internal' +import type { + ChartPoint, + ChartSpatialIndex, + ChartSpatialIndexFactoryContext, + ChartValue, +} from './types' + +/** + * Builds a uniform-grid nearest-point index for the `spatialIndex` option. + * + * It returns exactly the point a linear anchor scan returns: the smallest + * squared distance from each point's `x`/`y` anchor, the earliest point on a + * tie, and `null` beyond `maxDistance`. It does not test scene shapes, so + * rectangles, areas, and lines are matched by their anchor points only. + */ +export function gridSpatialIndex< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + points: readonly ChartPoint[], + _context?: ChartSpatialIndexFactoryContext, +): ChartSpatialIndex { + return createGridIndex(points) +} diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index 4697bd3f..5a1553db 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -3931,7 +3931,10 @@ async function verifyProductionBundles() { '/@tanstack/charts/dist/transform-rolling-window.js', ], spatialHexbin: ['/@tanstack/charts/dist/spatial-hexbin.js'], - spatialGrid: ['/@tanstack/charts/dist/spatial-grid.js'], + spatialGridIndex: [ + '/@tanstack/charts/dist/spatial-grid-index.js', + '/@tanstack/charts/dist/spatial-grid-index-internal.js', + ], spatialDensity: ['/@tanstack/charts/dist/spatial-density.js'], spatialContour: [ '/@tanstack/charts/dist/spatial-contour.js', @@ -4008,7 +4011,7 @@ async function verifyProductionBundles() { 'spatialGrouping', 'd3Contour', 'spatialDelaunay', - 'spatialGrid', + 'spatialGridIndex', 'spatialVoronoi', 'd3Delaunay', 'polarPie', @@ -4033,7 +4036,7 @@ async function verifyProductionBundles() { 'spatialContour', 'spatialGrouping', 'spatialDelaunay', - 'spatialGrid', + 'spatialGridIndex', 'spatialVoronoi', 'd3Delaunay', 'transformFold', diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..3f0faef8 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -180,6 +180,9 @@ const retainedInputGroups = { /(?:^|\/)packages\/charts-core\/src\/proportional-interval-internal\.ts$/u, ], spatialHexbin: [/(?:^|\/)packages\/charts-core\/src\/spatial-hexbin\.ts$/u], + spatialGridIndex: [ + /(?:^|\/)packages\/charts-core\/src\/spatial-grid-index(?:-internal)?\.ts$/u, + ], spatialDensity: [/(?:^|\/)packages\/charts-core\/src\/spatial-density\.ts$/u], spatialContour: [ /(?:^|\/)packages\/charts-core\/src\/spatial-contour(?:-internal)?\.ts$/u, @@ -1294,6 +1297,7 @@ const entries = [ 'tooltipPortal', 'd3GeometryRuntime', 'spatialVoronoi', + 'spatialGridIndex', ], }, }), @@ -1945,6 +1949,15 @@ const entries = [ 'benchmarks/entries/charts-d3-time-svg.ts', 26.24, ), + budgeted( + 'Grid spatial index subpath', + 'benchmarks/entries/charts-spatial-grid-index.ts', + 1.2, + { + rendererBoundary: 'neutral', + inputBoundary: { require: ['spatialGridIndex'], forbid: ['d3Runtime'] }, + }, + ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', From 277cd05b536334f71bee4c2992187094933e187d Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Wed, 30 Sep 2026 16:04:05 -1000 Subject: [PATCH 3/8] docs: keep the spatial lookup note outside the capability table --- PLAN.md | 4 ++-- docs/guides/large-data.md | 2 +- packages/charts-core/docs/guides/large-data.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 601e82d3..249c27ae 100644 --- a/PLAN.md +++ b/PLAN.md @@ -638,11 +638,11 @@ builds that keep the TanStack scene and renderer, measure: | Common curves | 0.61 kB | 2.05 kB | +1.51 kB | Keep native common curves; adapt advanced D3 curves | | Group, bin, stack | 1.38 kB | 2.40 kB | +1.37 kB | Reasonable optional transform profile | | Spatial lookup | 0.37 kB | 1.92 kB | +1.70 kB | Keep grid default; quadtree optional | +| Compact number formatting | 0.21 kB | 2.14 kB | +1.93 kB | Keep native formatter | +| General interpolation | — | 3.82 kB | — | Too broad for mandatory motion | Note: the shipped default is still the linear scan. The grid became the opt-in `@tanstack/charts/spatial/grid-index` factory rather than a default. -| Compact number formatting | 0.21 kB | 2.14 kB | +1.93 kB | Keep native formatter | -| General interpolation | — | 3.82 kB | — | Too broad for mandatory motion | The consumer deltas are measured directly where a meaningful chart fixture exists: native versus D3 time ticks in the same UTC line, native versus D3 diff --git a/docs/guides/large-data.md b/docs/guides/large-data.md index cb7ed59f..9cee04d2 100644 --- a/docs/guides/large-data.md +++ b/docs/guides/large-data.md @@ -161,7 +161,7 @@ pointers sweep the plot. | 100,000 | 0.19 ms | 1.1 ms | 2.4 µs | 16 ms | 5.2 µs | Points on one vertical or horizontal line stay under 1 µs per query at -100,000 points. The worst case is a dense cluster with the pointer inside it: +100,000 points with the 48-pixel radius. The worst case is a dense cluster with the pointer inside it: the grid cannot split points that share a cell, so it scans the cluster. With 98% of points within 2 pixels, it measured about 2× slower than the linear scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at diff --git a/packages/charts-core/docs/guides/large-data.md b/packages/charts-core/docs/guides/large-data.md index cb7ed59f..9cee04d2 100644 --- a/packages/charts-core/docs/guides/large-data.md +++ b/packages/charts-core/docs/guides/large-data.md @@ -161,7 +161,7 @@ pointers sweep the plot. | 100,000 | 0.19 ms | 1.1 ms | 2.4 µs | 16 ms | 5.2 µs | Points on one vertical or horizontal line stay under 1 µs per query at -100,000 points. The worst case is a dense cluster with the pointer inside it: +100,000 points with the 48-pixel radius. The worst case is a dense cluster with the pointer inside it: the grid cannot split points that share a cell, so it scans the cluster. With 98% of points within 2 pixels, it measured about 2× slower than the linear scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at From 5d2762c48e7629763f19a69c8662a56a5a8a6cc3 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:40:02 -1000 Subject: [PATCH 4/8] Refresh comparison evidence for merged upstream source --- benchmarks/comparison/bundle-baseline.json | 19 ++++++++++++++----- docs/comparison.md | 10 +++++----- packages/charts-core/docs/comparison.md | 10 +++++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 6d3d4ace..b0e053b4 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T21:06:51.663Z", + "generatedAt": "2026-10-13T23:39:20.721Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "3b8e03cf7225aa61ca53f85323abc99b796aa7b4", - "inputDigest": "sha256:6ffd46993814f072a66eaf1e8e31ab609fd6f2ccaa4db4babc2d84919ae6d77e" + "revision": "042e330f85afe36ecbffcbe0e215cefaf2609fba", + "inputDigest": "sha256:aabc2b1e7cef67061f692e7b417c72e7f0c5f8e0fc7694be22e6a2f4d388c093" }, "chartjs": { "kind": "package", @@ -36,8 +36,17 @@ } }, "matrix": { - "chartTypes": ["line", "bar", "area", "scatter"], - "tiers": ["basic", "interactive", "advanced"] + "chartTypes": [ + "line", + "bar", + "area", + "scatter" + ], + "tiers": [ + "basic", + "interactive", + "advanced" + ] }, "tolerance": { "relative": 0.03, diff --git a/docs/comparison.md b/docs/comparison.md index e2687c22..fcc56e2b 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3b8e03c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `042e330` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `3b8e03c`. +at page render time. The measured TanStack workspace revision is `042e330`. ## Capability matrix @@ -108,8 +108,8 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | | TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index e2687c22..fcc56e2b 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3b8e03c` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `042e330` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `3b8e03c`. +at page render time. The measured TanStack workspace revision is `042e330`. ## Capability matrix @@ -108,8 +108,8 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | | TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | From 54b4e9d0e4fcc3863f6e74ec8f7ca3727619b02b Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:45:43 -1000 Subject: [PATCH 5/8] Keep extreme-coordinate regression queries explicitly typed --- benchmarks/conformance/previews/manifest.json | 2 +- packages/charts-core/src/spatial-grid-index.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 128ffd78..a0787e84 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "275b1d5a8ea67187622216d8aea16ad813bb9c7e5c644bc7370c1120b9c9237a", + "sourceHash": "f3586a69bc47b70dfe2375240eb929e0551c05bdf2a80b47a23148b44e6415b8", "assets": [ { "id": "01-line-gaps", diff --git a/packages/charts-core/src/spatial-grid-index.test.ts b/packages/charts-core/src/spatial-grid-index.test.ts index 8b98d980..79ad73b9 100644 --- a/packages/charts-core/src/spatial-grid-index.test.ts +++ b/packages/charts-core/src/spatial-grid-index.test.ts @@ -86,10 +86,10 @@ describe('grid spatial index', () => { (_name, anchors) => { const points = anchors.map(([x, y], index) => point(index, x, y)) const index = gridSpatialIndex(points) - for (const query of [...anchors, [1e308, 1e308] as const]) { + for (const [x, y] of [...anchors, [1e308, 1e308] as const]) { for (const maxDistance of [0, Infinity]) { - expect(index.findNearest(...query, maxDistance)).toBe( - nearestPoint(points, ...query, maxDistance), + expect(index.findNearest(x, y, maxDistance)).toBe( + nearestPoint(points, x, y, maxDistance), ) } } From a75dc91b8e4136b10ed848bd636bf8d79047b410 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 13:46:12 -1000 Subject: [PATCH 6/8] Format refreshed comparison baseline --- benchmarks/comparison/bundle-baseline.json | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index b0e053b4..4ea0a0a1 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -36,17 +36,8 @@ } }, "matrix": { - "chartTypes": [ - "line", - "bar", - "area", - "scatter" - ], - "tiers": [ - "basic", - "interactive", - "advanced" - ] + "chartTypes": ["line", "bar", "area", "scatter"], + "tiers": ["basic", "interactive", "advanced"] }, "tolerance": { "relative": 0.03, From a94e0e4ea9a2d7c00956d2c412b458895310ed69 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 14:07:02 -1000 Subject: [PATCH 7/8] Refresh comparison and preview provenance after motion merge --- benchmarks/comparison/bundle-baseline.json | 6 +++--- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 12 ++++++------ packages/charts-core/docs/comparison.md | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0406421b..508ac96c 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:35:26.997Z", + "generatedAt": "2026-10-14T00:05:45.220Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "a691036b07655adda97aa0dc3052428989e2b811", - "inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951" + "revision": "789bd1ffbd217939b8450b662e5ac57473136e55", + "inputDigest": "sha256:3f091ac19d3a65bed618c7266c98dd9ea61b65f62a14383d76156d56de63cfe1" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 5ad78811..4a08d58a 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "5cb9f7c0512bf98f18bb88dbb7c2ec524b0239530a64d5a9b1d5a00ab5f9b3d3", + "sourceHash": "7df7b82e2210edfabfa3649d00cc8ae0479bf52c08a0659ecb51d5c85aa7173e", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index a6ab014c..cc7809e4 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `789bd1f` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `789bd1f`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -108,8 +108,8 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | | TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index a6ab014c..cc7809e4 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `789bd1f` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `789bd1f`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -108,8 +108,8 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | | TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | From 7d8c7708107ed7ed677cf5fc05bf6fc9fb8870e1 Mon Sep 17 00:00:00 2001 From: Greg Lum Date: Tue, 13 Oct 2026 14:55:20 -1000 Subject: [PATCH 8/8] Document workload-specific grid index tradeoffs --- docs/guides/large-data.md | 6 ++++++ docs/reference/focus-and-interaction.md | 5 +++-- packages/charts-core/docs/guides/large-data.md | 6 ++++++ .../charts-core/docs/reference/focus-and-interaction.md | 5 +++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/guides/large-data.md b/docs/guides/large-data.md index 9cee04d2..819f5159 100644 --- a/docs/guides/large-data.md +++ b/docs/guides/large-data.md @@ -168,6 +168,12 @@ scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at 100,000. D3 quadtree handles that case better. The benchmark prints every layout, radius, and size. +A [maintainer Chromium check](https://github.com/TanStack/charts/pull/165#issuecomment-6158225504) +also found the grid slower than the scan for 10,000 tightly clustered points, +while uniform points were much faster. These results depend on the point +distribution and query pattern. Measure your workload before choosing this +opt-in index. + The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear scan is already fast; measure before adding an index there. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 2ba3bc3d..02ed305a 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -1036,8 +1036,9 @@ It is nearest-center lookup, not shape hit testing. Like any index, it replaces primitive containment and affinity ranking, so a pointer inside a wide bar can resolve to a neighboring bar whose anchor is closer. Use it for dots and other marks whose anchor is the useful target. Points packed into -one cell are scanned in order, so a pointer inside a dense cluster costs about -as much as the linear scan. See +one cell are scanned in order, so a pointer inside a dense cluster can cost +more than the linear scan. Measure your workload before choosing this +opt-in index. See [Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for measured costs. diff --git a/packages/charts-core/docs/guides/large-data.md b/packages/charts-core/docs/guides/large-data.md index 9cee04d2..819f5159 100644 --- a/packages/charts-core/docs/guides/large-data.md +++ b/packages/charts-core/docs/guides/large-data.md @@ -168,6 +168,12 @@ scan at 1,000 points, about 1.2× slower at 10,000, and about 2× faster at 100,000. D3 quadtree handles that case better. The benchmark prints every layout, radius, and size. +A [maintainer Chromium check](https://github.com/TanStack/charts/pull/165#issuecomment-6158225504) +also found the grid slower than the scan for 10,000 tightly clustered points, +while uniform points were much faster. These results depend on the point +distribution and query pattern. Measure your workload before choosing this +opt-in index. + The grid keeps about 24 bytes per point, against about 90 for the quadtree. At a few hundred points the linear scan is already fast; measure before adding an index there. diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 2ba3bc3d..02ed305a 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -1036,8 +1036,9 @@ It is nearest-center lookup, not shape hit testing. Like any index, it replaces primitive containment and affinity ranking, so a pointer inside a wide bar can resolve to a neighboring bar whose anchor is closer. Use it for dots and other marks whose anchor is the useful target. Points packed into -one cell are scanned in order, so a pointer inside a dense cluster costs about -as much as the linear scan. See +one cell are scanned in order, so a pointer inside a dense cluster can cost +more than the linear scan. Measure your workload before choosing this +opt-in index. See [Large Data](../guides/large-data.md#indexed-nearest-point-lookup) for measured costs.