Skip to content

feat(spatial): add opt-in grid spatial index - #165

Open
greglum wants to merge 11 commits into
TanStack:mainfrom
greglum:greg/grid-spatial-index
Open

greglum wants to merge 11 commits into
TanStack:mainfrom
greglum:greg/grid-spatial-index

Conversation

@greglum

@greglum greglum commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

This adds gridSpatialIndex at @tanstack/charts/spatial/grid-index, an opt-in factory for the existing spatialIndex option. It returns exactly the point the default linear scan returns:

  • smallest squared distance
  • earliest point on a tie
  • the same maxDistance rules
  • the same handling of non-finite anchors

It looks up the nearest point center; it does not hit-test shapes, and the docs say so. Only the default 'nearest' focus mode reaches the hook. The axis and group modes resolve before it.

Default bundles do not change. bundle:check budgets the subpath at 1.2 KiB gzip; it measures 1.01 kB. nearest.ts is untouched, so this should not conflict with #42 or #153. If either changes point-anchor semantics, this index and its tests should follow.

Verification:

  • Seeded property tests compare it with nearestPoint on uniform, lattice, clustered, strip and non-finite layouts, including ties across cells.
  • A cell-visit counter keeps one-column and one-row grids cheap. An earlier version of this branch degraded badly on vertical strips, and this test would catch that.
  • A separate adversarial probe ran 129,600 queries across 16 layouts and many maxDistance values, with zero mismatches against nearestPoint. It was not committed.
  • pnpm performance:pointer checks every index against the linear result before timing.
  • typecheck, test, docs:check, bundle:check and format:check pass.

Benchmark (pnpm performance:pointer, 960×540 plot, radius 48, query medians):

Layout Points Linear Grid build Grid query Quadtree build Quadtree query
uniform 1k 1.8 µs 29 µs 0.1 µs 140 µs 0.5 µs
uniform 10k 16.6 µs 113 µs 0.3 µs 1.04 ms 1.2 µs
uniform 100k 187 µs 1.14 ms 2.4 µs 15.7 ms 5.2 µs
vertical strip 10k 16.0 µs 111 µs 0.1 µs 1.30 ms 6.8 µs
vertical strip 100k 188 µs 1.12 ms 0.5 µs 20.8 ms 110 µs
horizontal strip 100k 182 µs 1.11 ms 0.6 µs 25.2 ms 99 µs
clustered 100k 183 µs 1.24 ms 2.5 µs 16.9 ms 3.4 µs
pointer in cluster 1k 1.7 µs 20 µs 3.8 µs 114 µs 0.9 µs
pointer in cluster 10k 17.6 µs 158 µs 20.4 µs 1.78 ms 1.7 µs
pointer in cluster 100k 214 µs 1.29 ms 112 µs 24.6 ms 8.0 µs

The grid keeps about 24 bytes per point. The quadtree keeps about 90 per point on uniform data and about 145 on strips.

These numbers come from one Apple M5 Max running Node 24.18.0 while other work was running (load average 6 to 9), so read them as relative. The worst case is a pointer inside a dense cluster: about 2× slower than linear at 1k points, roughly even at 10k, and 2× faster at 100k. D3 quadtree does better there, and the large-data guide says so.

Includes a minor changeset, a PLAN.md note that the default stays the linear scan, and API-FRICTION entry F-307. That ID will need renumbering if another friction entry lands first.

Summary by CodeRabbit

  • New Features
    • Added an optional spatial index for faster nearest-point lookups in dense charts. It preserves linear-search results, including tie-breaking and distance limits, and does not perform shape hit testing.
  • Documentation
    • Added setup guidance, behavior details, and performance comparisons for the 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.
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.
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 2 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f47ae188-df81-454d-9323-90c6a718eaf5
📥 Commits

Reviewing files that changed from the base of the PR and between a94e0e4 and 7d8c770.

📒 Files selected for processing: 25
  • .changeset/grid-spatial-index.md
  • API-FRICTION.md
  • PLAN.md
  • benchmarks/comparison/bundle-baseline.json
  • benchmarks/conformance/previews/manifest.json
  • benchmarks/entries/charts-spatial-grid-index.ts
  • benchmarks/interaction/README.md
  • benchmarks/interaction/nearest.ts
  • docs/comparison.md
  • docs/guides/large-data.md
  • docs/reference/custom-extensions.md
  • docs/reference/focus-and-interaction.md
  • docs/reference/index.md
  • packages/charts-core/docs/comparison.md
  • packages/charts-core/docs/guides/large-data.md
  • packages/charts-core/docs/reference/custom-extensions.md
  • packages/charts-core/docs/reference/focus-and-interaction.md
  • packages/charts-core/docs/reference/index.md
  • packages/charts-core/package.json
  • packages/charts-core/src/exports.test.ts
  • packages/charts-core/src/spatial-grid-index-internal.ts
  • packages/charts-core/src/spatial-grid-index.test.ts
  • packages/charts-core/src/spatial-grid-index.ts
  • scripts/check-packed-consumers.mjs
  • scripts/measure-bundles.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 27763b5c-965d-4fdd-b303-68d9d687ae9a
📥 Commits

Reviewing files that changed from the base of the PR and between 277cd05 and a94e0e4.

📒 Files selected for processing: 14
  • API-FRICTION.md
  • benchmarks/comparison/bundle-baseline.json
  • benchmarks/conformance/previews/manifest.json
  • docs/comparison.md
  • docs/reference/custom-extensions.md
  • docs/reference/focus-and-interaction.md
  • packages/charts-core/docs/comparison.md
  • packages/charts-core/docs/reference/custom-extensions.md
  • packages/charts-core/docs/reference/focus-and-interaction.md
  • packages/charts-core/src/spatial-grid-index-internal.ts
  • packages/charts-core/src/spatial-grid-index.test.ts
  • packages/charts-core/src/spatial-grid-index.ts
  • scripts/check-packed-consumers.mjs
  • scripts/measure-bundles.mjs

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

📝 Walkthrough

The package adds an opt-in grid spatial index for nearest point-anchor lookup. It publishes the factory through a dedicated subpath, tests it against a linear scan, measures its scaling, and documents its behavior and limitations.

Changes: Grid spatial index

Layer / File(s) Summary
Grid implementation and package export
packages/charts-core/src/spatial-grid-index*, packages/charts-core/package.json, packages/charts-core/src/exports.test.ts, scripts/check-packed-consumers.mjs, scripts/measure-bundles.mjs
Adds the grid index and exposes gridSpatialIndex through @tanstack/charts/spatial/grid-index. Bundle checks set boundaries for retaining the index, and a bundle measurement applies a 1.2 KiB gzip budget to the subpath.
Correctness and scaling validation
packages/charts-core/src/spatial-grid-index.test.ts, benchmarks/entries/charts-spatial-grid-index.ts, benchmarks/interaction/nearest.ts, benchmarks/interaction/README.md
Tests compare results with a linear scan across point layouts and query conditions. Benchmarks compare the grid index with linear scans and D3 quadtrees for multiple point counts and distributions.
Usage documentation and release notes
.changeset/grid-spatial-index.md, API-FRICTION.md, PLAN.md, docs/guides/large-data.md, docs/reference/*, packages/charts-core/docs/guides/large-data.md, packages/charts-core/docs/reference/*
Documents the opt-in API, nearest-anchor behavior, distance limits, tie handling, non-finite anchors, and shape-hit-testing limitations. The changeset records a minor release entry.
Bundle and comparison baselines
benchmarks/comparison/bundle-baseline.json, benchmarks/conformance/previews/manifest.json, docs/comparison.md, packages/charts-core/docs/comparison.md
Updates generated source metadata and comparison-page revision, date, and React-externalization values.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: tannerlinsley

Merge Risk: ⚪ Minimal · up to a94e0

No actionable issue is established for the opt-in grid index; it appears ready for normal merge checks.

Security architecture risk: 🔵 Low · up to 277cd

The explicit opt-in import limits exposure to consumers that choose the index. No added credential, permission, or cross-service access was identified. Numeric edge cases, input-lifetime assumptions, and incomplete security coverage prevent a minimal-risk assessment.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated exposure is confined to point selection and local computation in consumers opting into the factory. No cross-tenant, credential, data-store, or service authority expansion was established by the inspected call path.

Trust Boundaries and Controls

  • observed — Index use remains controlled by application configuration and scene identity. Presentation-point lookup bypasses the index. Viewport filtering restricts clipped points when configured, but does not universally bound coordinates.

Resilience and Maintainability Implications

  • observed — Query selection state is local to each invocation, and the grid owns no external resource requiring disposal. Scene replacement precedes factory completion, so a throwing factory can leave the previous index installed; that ordering also exists on main and is not a newly introduced lifecycle change.

Hardening Proposals

  • proposed — Consider documenting stable point-array and point-identity ownership for the index lifetime, and falling back to linear lookup when grid dimensions are non-finite. These are contract-hardening proposals, not verified security findings.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (9 skipped: 9… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: adding an opt-in grid spatial index for spatial interactions.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 files. (9 skipped: 9 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @greglum, the opt-in boundary and strip-query coverage are useful. I reproduced one remaining numeric edge case at 277cd05b536334f71bee4c2992187094933e187d in an isolated Chromium context.

With two finite anchors at (-1e308, 0) and (1e308, 0), querying either exact anchor with maxDistance: 0 returns null. The linear scan returns the matching point. The same failure occurs for the equivalent vertical and diagonal layouts.

maxX - minX overflows to Infinity. The grid calculation then reaches Infinity / Infinity or Infinity * 0, producing a non-finite cell count or cell coordinate, so the points never become searchable grid members. Ordinary coordinates and the [0, 1e200] control passed.

Please handle non-finite derived grid dimensions and add these exact-anchor regressions before merging. The public promise is equivalence to the linear scan, including finite input coordinates whose extent overflows. This is a correctness edge case, not a measured security issue. I have not completed the full PR validation yet.

@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @greglum, the numeric fix at a94e0e4ea9a2d7c00956d2c412b458895310ed69 resolves the exact-anchor failure I reported above.

I checked 22,056 queries against the linear scan in an isolated Chromium context, including overflowing extents, tiny coordinates, zero-width axes, duplicate points, empty inputs and distance limits. They all matched. The standalone production ESM build measures 1.076 kB gzip and 0.970 kB Brotli, below this new entry's 1.2288 kB gzip ceiling.

The performance tradeoff is worth keeping in the docs: for 256 queries over 10,000 tightly clustered points, my seven-trial median was 4.3 ms for the grid versus 2.1 ms for the linear scan. Uniform points at the same count were below 0.1 ms for the grid versus 1.9 ms for the scan. This stays opt-in, so it does not replace the default scan.

Full repository and packed-consumer validation are still pending. The two GitHub workflows currently require approval, so this is confirmation of the numeric fix, not a merge-ready claim.

@changeset-bot

changeset-bot Bot commented Oct 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7d8c770

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@tanstack/charts Minor
@tanstack/alpine-charts Minor
@tanstack/angular-charts Minor
@tanstack/lit-charts Minor
@tanstack/octane-charts Minor
@tanstack/preact-charts Minor
@tanstack/react-charts Minor
@tanstack/react-native-charts Minor
@tanstack/solid-charts Minor
@tanstack/svelte-charts Minor
@tanstack/vue-charts Minor
@tanstack/charts-scales Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @greglum, I found an optional improvement to the clustered-point case at 7d8c7708107ed7ed677cf5fc05bf6fc9fb8870e1.

Inlining the distance comparison inside visitCell, instead of calling visit for each member, improved my isolated query tests without changing the grid search or adding a density heuristic:

const index = members[slot]!
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
}

For 4,096 queries over 100,000 points, with half the points in a 2-pixel cluster, seven paired trial medians were 135.9 → 46.2 ms in Chromium, 192 → 57 ms in Firefox, and 58 → 42 ms in WebKit. Queries mixed near-cluster and uniform positions. These are lookup-only measurements, not chart frame times.

The prototype matched the linear scan on 22,056 edge-case queries in each browser. I also checked multiple cluster densities and positions, plus radii 0, 1, 48 and Infinity. The standalone production ESM entry changes from 1.076 to 1.110 kB gzip and 0.970 to 0.989 kB Brotli, still below the proposed new-entry gzip ceiling. Existing-entry isolation and full repository validation are not covered by this prototype.

This is a suggestion, not another correctness blocker. The documented clustered-data caveat should stay, and CI approval and full packed-consumer validation are still pending.

@tannerlinsley

Copy link
Copy Markdown
Member

Thanks @greglum, the full isolated checkout now passes all 2,245 DOM tests, the six additional framework targets, TypeScript, docs and bundle checks. The full packed-package gate also passes, including bare React Native, Expo, seven adapters and the final unified artifact.

I checked the grid export from that final tarball against a linear scan for 112 edge-case queries, including ties, overflowing extents, tiny coordinates and distance limits. A deliberately broken lookup fails the verifier. This adds packed-package evidence to the earlier browser checks, without changing the optional performance suggestion above. Required CI and the proposed new-entry budget remain separate merge gates.

Verified head: 7d8c7708107ed7ed677cf5fc05bf6fc9fb8870e1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants