Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/grid-spatial-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/charts': minor
---

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.
30 changes: 30 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ Each entry records:
| F-315 | Orthogonal dependency connectors need a hand-built curve | API | monitoring |
| F-316 | Keyboard focus cannot follow a dependency | API | monitoring |
| F-317 | Time-ranged bars cannot inset only their lane edges | API | monitoring |
| F-318 | Dense point charts had no shipped spatial index | API | monitoring |

## Findings

Expand Down Expand Up @@ -9424,3 +9425,32 @@ Each entry records:
- Verification: a strict compile probe of `barX(rows, { x1: 'start', x2:
'end', y: 'lane' })` with `Date` fields fails with TS2322. Case 198 uses
`rect` with `inset: 0`.

### F-318 - Dense point charts had no shipped spatial index

- Status: monitoring
- 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-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, vertical-strip, thin-strip,
single-point, and non-finite layouts, with half-step queries that force
cross-cell ties. Maintainer-reported exact-anchor regressions cover finite
extents that overflow, singleton axes with opposite extreme queries, and
tiny extents whose scale overflows. Unpartitionable axes collapse to one
cell, and infinite squared-distance ties retain scene order. 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.
3 changes: 3 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,9 @@ builds that keep the TanStack scene and renderer, measure:
| 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.

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
monotone curves in the same SVG line, native versus D3 binning in the same
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "69d70960dd6b29f3706a173d348f2fc8271b0dcc",
"inputDigest": "sha256:8115665ae3c573649f47d236836e28c8cf14d513be1117a8e5c720e3a618680c"
"revision": "83ef665268770d959719870a2ca72c95d1650968",
"inputDigest": "sha256:75a9db356116f63d307734a8ea5faded92fbbe94c37f6258bff50c8a6d183992"
},
"chartjs": {
"kind": "package",
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/conformance/previews/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
"sourceHash": "bd192a70a226c4e49e5f687cdf4572478b7f465baf62fe6f564e86309f669ff0",
"sourceHash": "6a42057ace059d347c9527c523be0eba15be149af061b7aa490fe2e3bfd10332",
"assets": [
{
"id": "01-line-gaps",
Expand Down
1 change: 1 addition & 0 deletions benchmarks/entries/charts-spatial-grid-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { gridSpatialIndex } from '@tanstack/charts/spatial/grid-index'
9 changes: 9 additions & 0 deletions benchmarks/interaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 165 additions & 1 deletion benchmarks/interaction/nearest.ts
Original file line number Diff line number Diff line change
@@ -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-index'
import type {
ChartFocusAffinity,
ChartPoint as CoreChartPoint,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -885,6 +891,164 @@ function printIndexStorage() {
)
}

function printPointIndexScaling() {
console.log('\nPoint-anchor index scaling · anchors in a 960 × 540 plot')
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(
'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(
'| Layout | Points | Radius | Resolver | Median build | p95 build | Median / query | p95 / query | Retained |',
)
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<ChartPoint<Datum, number, number>>()
.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: `${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<string, number[]>()
const retained = new Map<string, number>()
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(
`| ${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)} |`,
)
}
}
}

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,
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ turning untested behavior into a checkmark.

| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `69d7096` |
| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `83ef665` |
| [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 `69d7096`.
at page render time. The measured TanStack workspace revision is `83ef665`.

## Capability matrix

Expand Down
52 changes: 49 additions & 3 deletions docs/guides/large-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,6 +133,51 @@ 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-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-index'

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. 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 | 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 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
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.

## Streaming windows

For a rolling source:
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/custom-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,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-index` 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
Expand Down
Loading