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 @@ -351,6 +351,7 @@ Each entry records:
| F-312 | Tick stubs could not be styled independently | API | resolved |
| F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved |
| F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved |
| F-315 | Dense point charts had no shipped spatial index | API | resolved |

## Findings

Expand Down Expand Up @@ -9317,3 +9318,32 @@ Each entry records:
direction changes. A focus dot appears during entrance while the data path
and its running animation remain intact. Concurrent inline states remain a
separate feature; this addresses the documented alternatives requested.

### F-315 - 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-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
6 changes: 3 additions & 3 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "a691036b07655adda97aa0dc3052428989e2b811",
"inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951"
"revision": "789bd1ffbd217939b8450b662e5ac57473136e55",
"inputDigest": "sha256:3f091ac19d3a65bed618c7266c98dd9ea61b65f62a14383d76156d56de63cfe1"
},
"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": "82eecd6c1fbdca5e857b965e09bb53c550520b2e596fbc4e9850266f03fdb9b4",
"sourceHash": "27a3d04aac6ec51a2502677921a2eae01d39a051313666ff1120c8ac2a6e0c94",
"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
12 changes: 6 additions & 6 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 `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

Expand Down Expand Up @@ -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
Expand All @@ -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/) |
Expand All @@ -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 |

Expand Down
Loading