Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .changeset/bright-hit-regions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/charts': patch
---

Resolve default pointer focus against painted mark geometry before applying a
mark's natural x, y, or two-dimensional fallback. Interaction metadata now
lives on the resolved scene primitive, so built-in and custom marks share the
same rectangle, circle, polygon, line, or area geometry used by renderers after
layout, facets, transforms, clipping, and inline state resolution.

Facet-local default markers now stay bound to the primary point even when
another panel has identical channel values; explicit x/y focus marks remain the
opt-in synchronized-cursor path. Animated bar inset states also preserve the
quantitative axis and baseline while changing only categorical width or height.
126 changes: 126 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ Each entry records:
| F-173 | Metro retained the complete universal barrel | API/Tooling | monitoring |
| F-174 | OIDC release cannot claim a new npm package name | Tooling | monitoring |
| F-175 | Native SVG resource normalization collapsed authored IDs | Application | resolved |
| F-176 | Large marks were focused by distant anchor points | API | monitoring |
| F-177 | Bubble overlap inherited incidental source order | Application | resolved |

## Findings

Expand Down Expand Up @@ -4257,3 +4259,127 @@ Each entry records:
- Verification: the native scene regression renders the formerly colliding
IDs plus empty and delimiter-containing IDs, and checks matching definition
IDs and paint references.

### F-176 — Large marks were focused by distant anchor points

- Status: monitoring
- Severity: high
- Owner: API
- Observed in: stacked-bar tooltip report and interaction-geometry lab
- Friction: vertical bars emit their value endpoint as the interaction anchor,
and the default resolver measured `maxFocusDistance` only from that anchor.
A pointer inside a tall bar could therefore select an adjacent endpoint less
than 48 pixels away. Raising the threshold retained the wrong two-dimensional
ranking, while chart-wide nearest-x made off-bar selection too permissive.
Pure x fallback also tied every segment in one stack and selected the bottom
segment when the pointer was above the stack.
- Current decision: use a two-stage scene contract rather than infer a strategy
from chart composition or copy geometry onto `ChartPoint`. A resolved `rect`,
`dot`, `area`, `polyline`, or `rule` attaches its semantic point or points and
natural `x`, `y`, `xy`, or `geometry` fallback. The default resolver collects
those targets from the final scene in paint order, accumulating facet and
group translations and clips. Exact containment wins across all marks before
fallback ranking; axis fallback uses visible primitive bounds first and full
geometry distance to break ties. Inline mark states return their destination
scene to the host, which intentionally uses that scene during animation.
Points not attached to a primitive retain legacy point-distance behavior.
Explicit focus strategies and custom spatial indexes continue to own their
complete search semantics; the spatial-index factory now receives the final
scene as a backward-compatible second argument so bounds, quadtrees, or
Delaunay can remain optional acceleration layers without copying geometry
onto points. Facet layout also scopes the final primitive and focus-layer
keys. Default `primary`/`group` presentation matches canonical focused points
instead of treating equal x/y/series tuples in another panel as the same
point; `whenFocused(..., { match: "x" })` or `match: "y"` remains the
explicit synchronized-cursor contract.
- Verification: focused tests cover containment priority, x/y/xy/geometry
fallback, rounded/reversed rectangles, circles, polygons, rules and lines,
built-in bar affinity, paint-order overlap, stack-edge selection, nested
translation, partial and complete clipping, destination-state scene
selection, spatial-index ownership, legacy tie order, duplicate-valued facet
identity, explicit synchronized x/y facet bands, and axis-correct animated
bar insets. The sandbox adds default-primary, x-synchronized, and
y-synchronized facet focus modes with contextual source, plus a live
destination-animation contract before twenty-four chart-family, grouped-bar,
clipping, polar, facet, and large-geometry comparisons. The lab now includes
dense scatter, pre-binned hexagon, nested-bubble paint-order, and richer
Sankey/network probes; its twenty-eight proof families split evenly between
labelled SVG and Canvas cards, and the destination-animation contract renders
in both so attribute interpolation and buffer crossfading share the same
picking semantics. Four composed cases verify that built-in bars, areas,
lines, rectangles, and dots contribute their natural affinity per primitive
without a chart-wide setting, including topmost containment when unlike marks
overlap. Facet coverage includes plain, grouped, stacked, and bubble marks.
Three mixed-mark cases now include an additional native `group-x` or
`group-y` tooltip card. This exposed that grouped tooltips are not an
independent presentation option: each grouped preset replaces the default
scene-containment resolver with nearest-axis selection as well as returning
the focus group. The lab keeps those cards separate and labelled rather than
claiming that geometry-first primary selection and axis grouping currently
compose.
The full unit matrix passes 745 tests across 131 files;
typecheck, documentation,
formatting, packed-consumer, seven-adapter, sandbox production-build, and
live browser checks also pass.

On Node 24 arm64 on an Apple M4 Pro, the cached scene resolver improves the
unoptimized POC's median query time from 113.5 to 14.2 microseconds for 10k
ordinary points, 62.5 to 16.4 for contained rectangles, 210.6 to 118.0 for
stacked fallback, 64.5 to 16.2 for circles, and 126.2 to 71.8 for 2k
polygons. On an exact-target point fixture, production, scene geometry,
Observable Plot 0.6.17, D3 quadtree, cold D3 Delaunay, and coherent Delaunay
take 13.7, 13.7, 41.4, 2.6, 8.7, and 3.0 microseconds. Quadtree and Delaunay
construction take 1.92 and 2.53 milliseconds for 10k points. A
source-equivalent Vega cached-bounds pass takes 10.0 microseconds versus 15.2
for the generic rectangle resolver, but deliberately excludes Vega's
subsequent Canvas path test.

The isolated scene resolver is 5,010 minified / 2,005 gzip bytes versus 157 /
153 for the anchor-only kernel: a 1,852-byte gzip feature cost under an
explicit 2 KiB ceiling. Against the pre-feature product lock, the complete
DOM host adds 1,840 gzip bytes, the React line consumer adds 1,859, and the
native host adds 1,824. These shared-host costs and the related aggregate
fixture ceilings were reviewed and accepted because painted-geometry
interaction is the default contract across DOM, Canvas, and native charts;
the exact locked baselines now record that decision while the isolated 2 kB
ceiling continues to constrain the resolver itself. A final size audit removed
redundant built-in `MarkScene.points` arrays and explicit default `xy`
affinity fields while retaining the optional point list for custom-mark
compatibility. Against the immediate pre-audit build, that saves 119
minified / 58 gzip / 67 Brotli bytes in the representative-marks entry and 24
/ 10 / 39 bytes in the D3-line scene. The interactive host is unchanged
because it does not bundle those mark encoders. Packing cached interaction
targets into tuples was rejected after the same 10k stacked-fallback fixture
regressed from about 118 to 294 microseconds per query; the larger but
optimizer-friendly object shape remains.
[Observable Plot](https://observablehq.com/plot/interactions/pointer)
documents point-only dead spots and dominant-axis modes;
[D3 quadtree](https://d3js.org/d3-quadtree#quadtree_find) and
[D3 Delaunay](https://d3js.org/d3-delaunay/delaunay#delaunay_find) establish
indexed point lookup and its build/rebuild tradeoff; Vega's
[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)
establish topmost traversal, cached-bounds rejection, and exact path tests.

- Follow-up: exact picking against optional authored SVG path strings and an
interpolated mid-transition scene remain separate refinements. Verify full
SVG/Canvas parity before resolving this entry.

### F-177 — Bubble overlap inherited incidental source order

- Status: resolved
- Severity: medium
- Owner: Application
- Observed in: Palmer penguin bubble-scatter conformance pair
- Friction: translucent bubbles deliberately use paint order to resolve
overlapping containment, but the conformance rows retained incidental source
order. A smaller observation could therefore be painted behind and become
difficult to target even though it remained visually perceptible.
- Decision: share one typed row selector between the Plot and TanStack cases,
filter complete channel values with a type predicate, and paint larger body
masses first so smaller bubbles remain visible and targetable on top. Keep
the library's generic paint-order policy unchanged because authored scene
order can be semantically meaningful.
- Verification: the model regression covers the initial 320-row fixture and
asserts monotonically descending body mass for the paired renderers' shared
row selector.
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ Measured by `pnpm bundle` with esbuild minification and Node gzip. These are loc

| Bundle | Minified | Gzip |
| --------------------------------------- | --------: | --------: |
| Core host | 6.97 kB | 2.85 kB |
| Legacy Plot POC host core | 6.97 kB | 2.85 kB |
| Native `lineY` scene | 7.46 kB | 3.06 kB |
| Native `lineY` plus static SVG | 9.75 kB | 3.93 kB |
| Native DOM host | 9.59 kB | 3.90 kB |
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,15 @@ Run a local example:
pnpm dev:charts-react
pnpm dev:charts-octane
pnpm dev:sandbox
pnpm dev:interaction-geometry
pnpm dev:conformance
```

The sandbox opens its existing integrated data dashboard by default. The
interaction-geometry command opens a before-and-after lab for exercising
scene-owned interaction geometry, paint order, transforms, clipping, and
natural focus fallback across chart families.

The repository includes three complementary benchmark suites:

- [`benchmarks/bundle-size`](./benchmarks/bundle-size) locks ordinary consumer
Expand All @@ -309,6 +315,7 @@ The repository includes three complementary benchmark suites:
```sh
pnpm bundle:check
pnpm performance
pnpm performance:pointer
pnpm benchmark:check
pnpm benchmark:stress:quick
pnpm conformance:quick
Expand Down
16 changes: 11 additions & 5 deletions benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,17 @@ may add only its transport module over the tooltip consumer. Ordinary line,
compact-scale, and tooltip kernels also reject all transform modules.

The compact linear scene and React consumer are both locked and budgeted. The
scene has an 8.1 KiB gzip ceiling. The React line consumer has a 16.8 KiB
ceiling with React and React DOM external. `d3-array` tick helpers are allowed
only in the compact linear path; categorical compact-scale kernels reject
every D3 runtime input. All compact fixtures reject `d3-scale`, `d3-format`,
`d3-interpolate`, `d3-color`, and `internmap`.
scene has an 8.1 KiB gzip ceiling. The React compact-scale line consumer has an
18.6 KiB ceiling with React and React DOM external. `d3-array` tick helpers are
allowed only in the compact linear path; categorical compact-scale kernels
reject every D3 runtime input. All compact fixtures reject `d3-scale`,
`d3-format`, `d3-interpolate`, `d3-color`, and `internmap`.

Painted-geometry interaction is part of the default scene and host contract
across DOM, Canvas, and native rendering. Its isolated resolver has a 2 KiB
gzip ceiling. The locked shared-host entries record the reviewed integration
cost, while noninteractive consumers retain only the small scene-compiler
portion of that contract.

Every public transform family has an isolated budget and retained-input
allowlist. Numeric and 2D bins may retain `d3-array`, and row stacks may retain
Expand Down
40 changes: 20 additions & 20 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,44 @@
"policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.",
"bundles": {
"D3-scale line scene": {
"bytes": 39086,
"gzip": 15175
"bytes": 39461,
"gzip": 15332
},
"D3-scale line + static SVG": {
"bytes": 42027,
"gzip": 16268
"bytes": 42402,
"gzip": 16421
},
"Representative marks": {
"bytes": 57688,
"gzip": 21428
"bytes": 58137,
"gzip": 21586
},
"TanStack DOM host": {
"bytes": 38770,
"gzip": 14229
"bytes": 44140,
"gzip": 16069
},
"React adapter": {
"bytes": 40937,
"gzip": 14924
"bytes": 46308,
"gzip": 16790
},
"React line consumer": {
"bytes": 63516,
"gzip": 23973
"bytes": 68924,
"gzip": 25832
},
"Compact-scale line scene": {
"bytes": 21550,
"gzip": 8118
"bytes": 21925,
"gzip": 8275
},
"React compact-scale line consumer": {
"bytes": 46033,
"gzip": 16876
"bytes": 51437,
"gzip": 18736
},
"Custom-scale line scene": {
"bytes": 19734,
"gzip": 7371
"bytes": 20110,
"gzip": 7529
},
"D3 linear-scale line scene": {
"bytes": 39018,
"gzip": 15139
"bytes": 39393,
"gzip": 15295
}
}
}
15 changes: 15 additions & 0 deletions benchmarks/conformance/cases/scatter-bubble/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { bubbleRows } from './model'

describe('bubbleRows', () => {
it('paints larger bubbles first so smaller overlaps remain targetable', () => {
const rows = bubbleRows(0)

expect(rows).toHaveLength(320)
for (let index = 1; index < rows.length; index += 1) {
expect(rows[index - 1]!.body_mass_g).toBeGreaterThanOrEqual(
rows[index]!.body_mass_g,
)
}
})
})
24 changes: 24 additions & 0 deletions benchmarks/conformance/cases/scatter-bubble/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { penguins } from '@charts-poc/demo-data/penguins'
import type { PenguinsRow } from '@charts-poc/demo-data/penguins'

export type BubblePenguin = PenguinsRow & {
culmen_length_mm: number
culmen_depth_mm: number
body_mass_g: number
}

const completePenguins = penguins.filter(isBubblePenguin)

export function bubbleRows(revision: number): readonly BubblePenguin[] {
return completePenguins
.slice(revision * 8, revision * 8 + 320)
.sort((left, right) => right.body_mass_g - left.body_mass_g)
}

function isBubblePenguin(row: PenguinsRow): row is BubblePenguin {
return (
row.culmen_length_mm !== null &&
row.culmen_depth_mm !== null &&
row.body_mass_g !== null
)
}
13 changes: 2 additions & 11 deletions benchmarks/conformance/cases/scatter-bubble/plot.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { penguins } from '@charts-poc/demo-data/penguins'
import * as Plot from '@observablehq/plot'
import { mountObservablePlot } from '../../shared/mount'
import type { ConformanceMount } from '../../types'
import { bubbleRows } from './model'

const groupRange = ['#2563eb', '#f97316', '#10b981']
const completePenguins = penguins.filter(
(row) =>
row.culmen_length_mm !== null &&
row.culmen_depth_mm !== null &&
row.body_mass_g !== null,
)

export const mount: ConformanceMount = (container, input) =>
mountObservablePlot(container, input, (nextInput) => {
const rows = completePenguins.slice(
nextInput.revision * 8,
nextInput.revision * 8 + 320,
)
const rows = bubbleRows(nextInput.revision)

return Plot.plot({
width: nextInput.width,
Expand Down
13 changes: 2 additions & 11 deletions benchmarks/conformance/cases/scatter-bubble/tanstack.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import { penguins } from '@charts-poc/demo-data/penguins'
import { colorLegend, defineChart, dot } from '@tanstack/charts'
import { scaleLinear, scaleSqrt } from 'd3-scale'
import { tanstackMount } from '../../shared/mount'
import type { ConformanceInput } from '../../types'
import { bubbleRows } from './model'

const groupRange = ['#2563eb', '#f97316', '#10b981']
const completePenguins = penguins.filter(
(row) =>
row.culmen_length_mm !== null &&
row.culmen_depth_mm !== null &&
row.body_mass_g !== null,
)

const definition = (input: ConformanceInput) => {
const rows = completePenguins.slice(
input.revision * 8,
input.revision * 8 + 320,
)
const rows = bubbleRows(input.revision)

return defineChart({
marks: [
Expand Down
30 changes: 30 additions & 0 deletions benchmarks/entries/charts-pointer-anchor-kernel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {
ChartPoint,
ChartValue,
} from '../../packages/charts-core/src/types'

// Historical production baseline retained for isolated bundle comparison.
export function nearestPoint<
TDatum,
TXValue extends ChartValue,
TYValue extends ChartValue,
>(
points: readonly ChartPoint<TDatum, TXValue, TYValue>[],
x: number,
y: number,
maxDistance: number,
): ChartPoint<TDatum, TXValue, TYValue> | null {
let result: ChartPoint<TDatum, TXValue, TYValue> | undefined
let resultDistance = Infinity
for (const point of points) {
const dx = point.x - x
const dy = point.y - y
const distance = dx * dx + dy * dy
if (distance < resultDistance) {
result = point
resultDistance = distance
}
}
if (!result) return null
return resultDistance <= Math.max(0, maxDistance) ** 2 ? result : null
}
1 change: 1 addition & 0 deletions benchmarks/entries/charts-pointer-geometry-kernel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { nearestScenePoint } from '../../packages/charts-core/src/nearest'
Loading