diff --git a/.changeset/exact-curved-picking.md b/.changeset/exact-curved-picking.md
new file mode 100644
index 00000000..734ec6a9
--- /dev/null
+++ b/.changeset/exact-curved-picking.md
@@ -0,0 +1,9 @@
+---
+'@tanstack/charts': patch
+'@tanstack/react-native-charts': patch
+---
+
+Resolve curved line and area focus against the same recorded path geometry used
+by SVG, Canvas, and React Native renderers. Built-in D3 curves and custom
+`scenePath` marks now preserve subpixel containment and stroke-distance behavior
+instead of falling back to straight source-point chords or polygons.
diff --git a/API-FRICTION.md b/API-FRICTION.md
index 9d83ce5c..a552364d 100644
--- a/API-FRICTION.md
+++ b/API-FRICTION.md
@@ -233,6 +233,7 @@ Each entry records:
| F-195 | Release versions matched dependency substrings | Tooling | resolved |
| F-196 | Focus decorations suppressed the primary indicator | API | resolved |
| F-197 | Workspace validation omitted comparison provenance | Tooling | resolved |
+| F-198 | D3 curve context types overstate built-in requirements | Tooling | resolved |
## Findings
@@ -4303,9 +4304,9 @@ Each entry records:
`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
+ group translations and clips. Painted-geometry 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
@@ -4328,7 +4329,7 @@ Each entry records:
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
+ Sankey/network probes; its thirty 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,
@@ -4341,33 +4342,56 @@ Each entry records:
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;
+ compose. Two dedicated cards render an authored cubic line in SVG and a
+ curved filled area in Canvas while overlaying the old structured chord or
+ polygon. Their fixed probes land on or inside the painted curve but outside
+ that approximation and now focus successfully through a typed `scenePath`.
+ The same recorder powers `d3Curve` and `d3AreaXCurve`: each resolved scene
+ primitive owns the rendered path data plus a subpixel contour derived from
+ the same commands. Two additional before/after families exercise vertical
+ and horizontal curved stacked areas where the old straight/source-point
+ model chooses the lower layer and the rendered spline correctly chooses the
+ upper layer before x or y affinity selects its semantic sample. Their fixed
+ probes sit visibly inside the upper fill; regressions additionally sample
+ both sides of the recorded boundary and define the shared edge as the
+ later-painted upper layer. After the motion renderer landed on `main`, a host
+ regression caught interpolated presentation points bypassing scene geometry.
+ Selection now keeps destination-scene identity while the matching
+ presentation point moves the marker and tooltip with the rendered frame.
+ The full DOM unit matrix passes 807 tests across 138 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
+ On Node 24 arm64 on an Apple M4 Pro, two consecutive runs put the cached scene
+ resolver at 15.1–15.6 microseconds for 10k ordinary points, 16.0–23.9 for
+ contained rectangles, 123.3–128.6 for stacked x fallback, 16.6–18.2 for
+ circles, and 73.6–78.3 for 2k polygons. The corresponding unoptimized POC
+ takes 121.2–126.3, 65.6–86.8, 225.7–233.2, 68.5–69.0, and 134.4–135.4
+ microseconds. On an exact-target point fixture, production and scene geometry
+ both take about 14.2–14.3 microseconds; Observable Plot 0.6.17 takes
+ 42.2–43.2, D3 quadtree 2.9, cold D3 Delaunay 9.1, and coherent Delaunay
+ 3.0–3.1. Quadtree and Delaunay construction take 2.29–2.42 and 2.67–2.79
+ milliseconds for 10k points. A source-equivalent Vega cached-bounds pass
+ takes 10.5–10.6 microseconds versus 15.2–15.5 for the generic rectangle
+ resolver, but deliberately excludes Vega's subsequent Canvas path test.
+ Recorded cubic containment measures 3.7–3.8 microseconds per query versus
+ 3.5 for the same targets' structured polygons, a 0.2–0.3-microsecond median
+ cost for render-consistent curve ownership.
+
+ The isolated scene resolver is 5.25 kB minified / 2.08 kB gzip versus 0.15 /
+ 0.15 kB for the anchor-only kernel: about a 1.93 kB gzip feature cost. Path
+ winding, stroke, and contour-distance code now lives behind the opaque
+ `scenePath` capability, so straight charts do not retain the recorder's
+ interaction math. Against `origin/main`'s locked fixtures, a straight line
+ scene adds 30 gzip bytes, static SVG 42, representative marks 58, the default
+ DOM host 141, the React adapter 136, and a React line consumer 164. Moving
+ curve-specific math out of the host reduced the earlier post-merge DOM delta
+ from 294 to 141 gzip bytes. The compact React consumer is 18.63 kB gzip;
+ combining this feature with `main`'s later SVG pointer fix required reviewing
+ its product ceiling at 18.7 kB. The reviewed 2.1, 18.7, 26.6, and 31.9 kB gzip
+ ceilings plus exact fixture locks record these costs, and `bundle:check`
+ passes. A prior 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
@@ -4386,9 +4410,11 @@ Each entry records:
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.
+- Follow-up: opaque authored SVG path strings intentionally retain structured
+ point fallback; authors who need render-consistent curved interaction can use
+ `scenePath`. Parsing arbitrary SVG strings and using 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
@@ -4908,3 +4934,23 @@ Each entry records:
- Verification: the 60-case comparison baseline records revision `e4b5249`
and package version 0.6.2, the canonical comparison page identifies the same
revision, and `pnpm benchmark:check` passes locally and in pull-request CI.
+
+### F-198 — D3 curve context types overstate built-in requirements
+
+- Status: resolved
+- Severity: low
+- Owner: Tooling
+- Observed in: recording D3 line and area curves as resolved scene geometry
+- Friction: `CurveFactory` accepts the full Canvas or `d3-path` context type,
+ while the built-in D3 Cartesian curves exercised here call only `moveTo`,
+ `lineTo`, `bezierCurveTo`, and `closePath`. Passing the smaller typed
+ `ScenePathContext` therefore requires an assertion even though it implements
+ the complete runtime surface used by those built-in factories.
+- Resolution: isolate the assertion in the D3 adapter boundary instead of
+ weakening the public scene-path builder or pretending it is a Canvas
+ context. Document the adapter as a bridge for D3's built-in Cartesian curves;
+ custom marks use the assertion-free `scenePath` API.
+- Verification: parity tests compare recorded line and area strings with D3's
+ native generator output for basis, bump, cardinal, Catmull-Rom, linear,
+ monotone, natural, and step families; strict typecheck passes with no
+ assertion outside the adapter boundary.
diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json
index 2ff87528..4fa74da6 100644
--- a/benchmarks/bundle-size/universal-baseline.json
+++ b/benchmarks/bundle-size/universal-baseline.json
@@ -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": 39446,
- "gzip": 15338
+ "bytes": 39511,
+ "gzip": 15368
},
"D3-scale line + static SVG": {
- "bytes": 42387,
- "gzip": 16429
+ "bytes": 42496,
+ "gzip": 16471
},
"Representative marks": {
- "bytes": 58176,
- "gzip": 21599
+ "bytes": 58340,
+ "gzip": 21657
},
"TanStack DOM host": {
- "bytes": 44461,
- "gzip": 16247
+ "bytes": 44872,
+ "gzip": 16388
},
"React adapter": {
- "bytes": 46629,
- "gzip": 16949
+ "bytes": 47040,
+ "gzip": 17085
},
"React line consumer": {
- "bytes": 69295,
- "gzip": 26026
+ "bytes": 69769,
+ "gzip": 26190
},
"Compact-scale line scene": {
- "bytes": 21910,
- "gzip": 8283
+ "bytes": 21975,
+ "gzip": 8312
},
"React compact-scale line consumer": {
- "bytes": 51805,
- "gzip": 18924
+ "bytes": 52281,
+ "gzip": 19078
},
"Custom-scale line scene": {
- "bytes": 20094,
- "gzip": 7544
+ "bytes": 20159,
+ "gzip": 7573
},
"D3 linear-scale line scene": {
- "bytes": 39378,
- "gzip": 15301
+ "bytes": 39443,
+ "gzip": 15332
}
}
}
diff --git a/benchmarks/interaction/nearest.ts b/benchmarks/interaction/nearest.ts
index f00e9254..f8ab558b 100644
--- a/benchmarks/interaction/nearest.ts
+++ b/benchmarks/interaction/nearest.ts
@@ -2,6 +2,7 @@ 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 { scenePath } from '../../packages/charts-core/src/scene-path'
import type {
ChartFocusAffinity,
ChartPoint as CoreChartPoint,
@@ -188,6 +189,13 @@ const rectangleComparisonCase: PointerCase = {
}),
repetitions: 16,
}
+const curvedAreaComparison = createCurvedAreaComparison(2_000)
+const curvedAreaComparisonCase: PointerCase = {
+ label: '2k curved areas · contained probe',
+ points: curvedAreaComparison.points,
+ queries: curvedAreaComparison.queries,
+ repetitions: 8,
+}
const vegaRectItems: readonly VegaRectItem[] = rectangles.map((point) => {
if (point.hitRegion?.kind !== 'rect') {
throw new Error('Expected rectangle benchmark geometry')
@@ -231,11 +239,24 @@ const rectangleComparisonImplementations: readonly Implementation[] = [
['new geometry · generic rect', sceneNearestPoint],
['Vega 5.2.1 · bounds-only lower bound', vegaBoundsNearestPoint],
] as const
+const curvedAreaComparisonImplementations: readonly Implementation[] = [
+ [
+ 'structured polygon',
+ (_points, x, y, maxDistance) =>
+ nearestScenePoint(curvedAreaComparison.structured, x, y, maxDistance),
+ ],
+ [
+ 'recorded cubic path',
+ (_points, x, y, maxDistance) =>
+ nearestScenePoint(curvedAreaComparison.curved, x, y, maxDistance),
+ ],
+] as const
const collectGarbage = (globalThis as { gc?: () => void }).gc
verifyEquivalentResults()
verifyComparisonResults()
verifyRectangleComparisonResults()
+verifyCurvedAreaComparisonResults()
console.log(`Pointer resolution · ${process.version} · ${process.arch}`)
console.log(
'Production is a speed baseline; geometry rows intentionally add semantics it cannot return.',
@@ -260,6 +281,24 @@ for (const benchmark of cases) {
}
printScenarioComparisons(scenarioMeasurements)
+console.log('\nCurved-area containment · identical targets on this fixture')
+console.log('| Resolver | Median / query | p95 / query |')
+console.log('| --- | ---: | ---: |')
+const curvedAreaComparisonMeasurements = measure(
+ curvedAreaComparisonCase,
+ curvedAreaComparisonImplementations,
+)
+for (const [label] of curvedAreaComparisonImplementations) {
+ const samples = curvedAreaComparisonMeasurements.get(label)!
+ console.log(
+ `| ${label} | ${formatDuration(percentile(samples, 0.5))} | ${formatDuration(percentile(samples, 0.95))} |`,
+ )
+}
+printAsciiDurations(
+ curvedAreaComparisonMeasurements,
+ curvedAreaComparisonImplementations,
+)
+
console.log('\nPoint-only comparison · identical targets on this fixture')
console.log('| Resolver | Median / query | p95 / query |')
console.log('| --- | ---: | ---: |')
@@ -694,6 +733,60 @@ function ordered(left: number, right: number): readonly [number, number] {
return left <= right ? [left, right] : [right, left]
}
+function createCurvedAreaComparison(count: number) {
+ const points = Array.from({ length: count }, (_, index) => {
+ const x = (index % 100) * 20
+ const y = Math.floor(index / 100) * 20
+ return point(index, x + 8, y + 8)
+ })
+ const structuredNodes: SceneNode[] = []
+ const curvedNodes: SceneNode[] = []
+ for (const chartPoint of points) {
+ const index = chartPoint.datum.index
+ const x = (index % 100) * 20
+ const y = Math.floor(index / 100) * 20
+ const points = [
+ [x, y + 8],
+ [x + 16, y + 8],
+ [x + 16, y + 16],
+ [x, y + 16],
+ ] as const
+ const interaction = { point: chartPoint, affinity: 'geometry' } as const
+ const structured = {
+ kind: 'area',
+ key: chartPoint.key,
+ points,
+ interaction,
+ } as const
+ structuredNodes.push(structured)
+ curvedNodes.push({
+ ...structured,
+ pathGeometry: scenePath((path) => {
+ path.moveTo(x, y + 8)
+ path.bezierCurveTo(x + 4, y - 2, x + 12, y + 18, x + 16, y + 8)
+ path.lineTo(x + 16, y + 16)
+ path.lineTo(x, y + 16)
+ path.closePath()
+ }),
+ })
+ }
+ return {
+ points,
+ queries: queries(64, (index) => {
+ const target = points[(index * 613) % points.length]!
+ return { x: target.x, y: target.y + 6, maxDistance: 48 }
+ }),
+ structured: {
+ nodes: structuredNodes,
+ points,
+ } as unknown as ChartScene,
+ curved: {
+ nodes: curvedNodes,
+ points,
+ } as unknown as ChartScene,
+ }
+}
+
function verifyEquivalentResults() {
for (const benchmark of cases) {
for (const query of benchmark.queries) {
@@ -718,6 +811,28 @@ function verifyEquivalentResults() {
}
}
+function verifyCurvedAreaComparisonResults() {
+ for (const query of curvedAreaComparisonCase.queries) {
+ const structured = nearestScenePoint(
+ curvedAreaComparison.structured,
+ query.x,
+ query.y,
+ query.maxDistance,
+ )
+ const curved = nearestScenePoint(
+ curvedAreaComparison.curved,
+ query.x,
+ query.y,
+ query.maxDistance,
+ )
+ if (structured?.key !== curved?.key) {
+ throw new Error(
+ `Curved-area comparison changed the target: ${String(structured?.key)} !== ${String(curved?.key)}`,
+ )
+ }
+ }
+}
+
function verifyComparisonResults() {
delaunayCursor = 0
for (const query of comparisonCase.queries) {
diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md
index cdee1589..0040a665 100644
--- a/docs/concepts/scales-and-d3.md
+++ b/docs/concepts/scales-and-d3.md
@@ -290,7 +290,11 @@ lineY(rows, {
})
```
-`d3Curve` adapts a D3 curve factory to the small line-and-area curve contract. Importing it is explicit so a straight chart does not need the shape path.
+`d3Curve` adapts a D3 curve factory to the small line-and-area curve contract.
+It records the rendered path and a subpixel interaction contour from the same
+D3 context calls, so curved lines and stacked areas target their painted
+geometry. Importing it is explicit so a straight chart does not need the shape
+path.
Horizontal `areaX` marks use the separate `d3AreaXCurve` bridge from `@tanstack/charts/d3/area-x`.
diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md
index 791f59b2..21d44539 100644
--- a/docs/guides/bundle-size-and-performance.md
+++ b/docs/guides/bundle-size-and-performance.md
@@ -26,6 +26,7 @@ import { createChartSpring } from '@tanstack/charts/spring'
import { renderChartImage } from '@tanstack/charts/export'
import { focusX } from '@tanstack/charts/focus'
import { d3Curve } from '@tanstack/charts/d3/shape'
+import { scenePath } from '@tanstack/charts/scene/path'
import { tooltip } from '@tanstack/charts/tooltip'
import { portal } from '@tanstack/charts/tooltip/portal'
import { scaleLinear } from '@tanstack/charts-scales/linear'
@@ -33,6 +34,10 @@ import { groupBy } from '@tanstack/charts/transform/group'
import { window } from '@tanstack/charts/transform/window'
```
+`scenePath` is only needed when a custom mark authors curved geometry. Built-in
+D3 curve adapters record their geometry internally; straight marks do not run
+the path recorder or allocate flattened contours.
+
Canvas is opt-in. The default core and every default framework entry remain
SVG-based. Canvas enters the module graph only through
`@tanstack/charts/canvas`, `@tanstack/react-charts/canvas`, or
diff --git a/docs/reference/custom-extensions.md b/docs/reference/custom-extensions.md
index 50e05d09..d15acbd8 100644
--- a/docs/reference/custom-extensions.md
+++ b/docs/reference/custom-extensions.md
@@ -137,7 +137,9 @@ real; ordinary custom marks should use `createMark`.
## Curves
-`ChartCurve` supplies precomputed path data for line and y-oriented area marks:
+`ChartCurve` supplies path data for line and y-oriented area marks. Its optional
+geometry companion lets the renderer and pointer resolver consume one resolved
+curve instead of independently approximating the source points:
```ts
interface ChartCurve {
@@ -146,6 +148,7 @@ interface ChartCurve {
top: readonly (readonly [number, number])[],
bottom: readonly (readonly [number, number])[],
): string
+ geometry?: ChartCurveGeometry
}
```
@@ -157,14 +160,48 @@ interface AreaXCurve {
right: readonly (readonly [number, number])[],
left: readonly (readonly [number, number])[],
): string
+ geometry?: {
+ areaX(
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ): ScenePathGeometry
+ }
}
```
The optional bridges `d3Curve` from `@tanstack/charts/d3/shape` and
-`d3AreaXCurve` from `@tanstack/charts/d3/area-x` adapt a supplied curve factory
-to these contracts. D3 module ownership and granular imports are documented in
+`d3AreaXCurve` from `@tanstack/charts/d3/area-x` adapt a built-in Cartesian
+`d3-shape` curve factory to these contracts. D3 module ownership and granular imports are documented in
[Scales and D3](../concepts/scales-and-d3.md).
+The D3 adapters attach resolved geometry automatically. A custom scene path can
+do the same without maintaining a separate SVG string and hit polygon:
+
+```ts
+import { scenePath } from '@tanstack/charts/scene/path'
+
+const geometry = scenePath((path) => {
+ path.moveTo(20, 160)
+ path.bezierCurveTo(20, 20, 180, 20, 180, 160)
+ path.closePath()
+})
+
+const node = {
+ kind: 'area' as const,
+ key: 'curved-region',
+ points: [
+ [20, 160],
+ [180, 160],
+ ],
+ pathGeometry: geometry,
+}
+```
+
+`scenePath` records the rendered path data and derives a subpixel contour from
+the same commands. An opaque legacy `path: string` remains supported, but its
+interaction fallback uses `points` because core deliberately does not retain a
+general SVG path parser.
+
## Custom positional scales
A custom `ChartScale` resolves semantic values and the responsive range into a
diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md
index 1232f700..6a353cf9 100644
--- a/docs/reference/focus-and-interaction.md
+++ b/docs/reference/focus-and-interaction.md
@@ -75,9 +75,12 @@ second geometry copy. Inline mark states similarly return a resolved scene to
the host; during a transition, pointer selection intentionally follows that
destination scene rather than interpolating a second hit-test scene.
-For curved `polyline` and `area` nodes, the current resolver uses the
-primitive's structured point geometry. Exact picking against an optional
-authored SVG path string remains a separate refinement.
+Curved `polyline` and `area` nodes can carry `pathGeometry` recorded from the
+same commands used to paint them. Built-in D3 curve adapters attach that
+geometry automatically, and custom marks can create it with `scenePath` from
+`@tanstack/charts/scene/path`. Opaque authored `path: string` values remain
+compatible but fall back to their structured `points`; core does not parse a
+second copy of arbitrary SVG path syntax.
An explicit focus preset or custom strategy replaces this default resolver.
diff --git a/docs/reference/index.md b/docs/reference/index.md
index eba75ce3..30fffbda 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -84,6 +84,7 @@ capabilities and individual marks independently tree-shakeable.
| `@tanstack/charts/focus/mark` | `whenFocused` |
| `@tanstack/charts/frame` | `frame` |
| `@tanstack/charts/geo` | `geoShape` and geographic projection types |
+| `@tanstack/charts/scene/path` | `scenePath` for shared rendered and interaction path geometry |
| `@tanstack/charts/group` | `group`, `GroupLayout`, `GroupOptions` |
| `@tanstack/charts/hexagon` | `hexagon` |
| `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend` |
diff --git a/docs/reference/marks/line-and-area.md b/docs/reference/marks/line-and-area.md
index 717befcd..e0a2f59b 100644
--- a/docs/reference/marks/line-and-area.md
+++ b/docs/reference/marks/line-and-area.md
@@ -172,6 +172,7 @@ interface ChartCurve {
top: readonly (readonly [number, number])[],
bottom: readonly (readonly [number, number])[],
): string
+ geometry?: ChartCurveGeometry
}
interface AreaXCurve {
@@ -179,6 +180,12 @@ interface AreaXCurve {
right: readonly (readonly [number, number])[],
left: readonly (readonly [number, number])[],
): string
+ geometry?: {
+ areaX(
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ): ScenePathGeometry
+ }
}
```
@@ -189,11 +196,18 @@ import { d3AreaXCurve } from '@tanstack/charts/d3/area-x'
import { d3Curve } from '@tanstack/charts/d3/shape'
```
-They accept a supplied curve factory and return the corresponding TanStack
-contract. Which granular D3 module to install and why these algorithms remain
-injected is documented once in
+They accept a built-in Cartesian `d3-shape` curve factory and return the
+corresponding TanStack contract. These adapters also record the rendered curve as scene-owned,
+subpixel interaction geometry. Curved stacked areas therefore resolve the
+painted layer first and then apply their natural x or y affinity to choose a
+semantic sample. Which granular D3 module to install and why these algorithms
+remain injected is documented once in
[Scales and D3](../../concepts/scales-and-d3.md).
+Custom marks can create the same shared geometry with `scenePath` from
+`@tanstack/charts/scene/path`. Opaque `path: string` values remain compatible
+and fall back to their structured `points` for interaction.
+
## Layering area and line
Area marks do not automatically draw their upper line. Compose the layers:
diff --git a/docs/reference/types.md b/docs/reference/types.md
index 0a640106..5cd4441e 100644
--- a/docs/reference/types.md
+++ b/docs/reference/types.md
@@ -197,6 +197,10 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes).
| `ChartLinearGradient` | Named linear-gradient resource |
| `ChartGradientStop` | Gradient offset, color, and optional opacity |
| `ChartCurve` | Line and y-area path generation |
+| `ChartCurveGeometry` | Resolved line and y-area path geometry |
+| `ScenePathContext` | Typed commands accepted by `scenePath` |
+| `ScenePathContour` | Derived interaction contour for one subpath |
+| `ScenePathGeometry` | Rendered path data and subpixel interaction acceleration |
See [Scales, guides, and color](./scales-guides-and-color.md).
@@ -228,7 +232,7 @@ See [DOM host](./dom-host.md) and
| `ChartFocusSource` | Pointer, keyboard, programmatic, or restored source |
| `ChartFocusFilter` | Focus-filtered mark matching configuration |
| `ChartFocusMatch` | Primary, group, key, x, y, or series matching |
-| `ChartFocusAffinity` | Primitive fallback axis after exact geometry containment |
+| `ChartFocusAffinity` | Primitive fallback axis after painted-geometry containment |
| `ChartSpatialIndex` | Nearest-point query |
| `ChartSpatialIndexFactory` | Builds an index from current scene points and resolved scene |
| `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input |
diff --git a/examples/sandbox/src/InteractionGeometryLab.test.ts b/examples/sandbox/src/InteractionGeometryLab.test.ts
index 148c18a0..d28056f8 100644
--- a/examples/sandbox/src/InteractionGeometryLab.test.ts
+++ b/examples/sandbox/src/InteractionGeometryLab.test.ts
@@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { createChartScene, findNearestPoint } from '@tanstack/charts'
-import type { SceneNode } from '@tanstack/charts'
+import type { ChartPoint, SceneNode } from '@tanstack/charts'
import { focusX, focusY } from '@tanstack/charts/focus'
import {
animatedDestinationDefinition,
+ curvedPathLimitCases,
facetFocusDefinitions,
InteractionGeometryLab,
legacyPointFocus,
@@ -19,21 +20,24 @@ describe('interaction geometry source disclosure', () => {
const groupedProofs = proofCases.filter((proof) => proof.grouped)
expect(markup.match(/class="hit-region-proof__source"/g)).toHaveLength(
- proofCases.length * 2 + groupedProofs.length + 5,
+ proofCases.length * 2 + groupedProofs.length + 7,
)
expect(groupedProofs).toHaveLength(3)
- expect(proofCases).toHaveLength(28)
+ expect(proofCases).toHaveLength(30)
expect(
proofCases.filter((proof) => proof.renderer === 'canvas'),
- ).toHaveLength(14)
+ ).toHaveLength(15)
expect(proofCases.filter((proof) => proof.renderer === 'svg')).toHaveLength(
- 14,
+ 15,
)
- expect(markup.match(/data-proof-renderer="canvas"/g)).toHaveLength(30)
- expect(markup.match(/data-proof-renderer="svg"/g)).toHaveLength(29)
+ expect(markup.match(/data-proof-renderer="canvas"/g)).toHaveLength(32)
+ expect(markup.match(/data-proof-renderer="svg"/g)).toHaveLength(31)
expect(markup.match(/data-animation-renderer=/g)).toHaveLength(2)
expect(markup).toContain('data-animation-renderer="svg"')
expect(markup).toContain('data-animation-renderer="canvas"')
+ expect(markup.match(/data-curved-path-case=/g)).toHaveLength(2)
+ expect(markup).toContain('data-curved-path-renderer="svg"')
+ expect(markup).toContain('data-curved-path-renderer="canvas"')
expect(markup).toContain('class="ts-chart-canvas__scene"')
expect(markup).toContain('<CanvasChart')
expect(markup).toContain('<Chart')
@@ -86,6 +90,13 @@ describe('interaction geometry source disclosure', () => {
expect(markup).toContain(
'transition: { type: 'tween', duration: 700, easing: 'ease-out' }',
)
+ expect(markup).toContain(
+ 'authoredCurveMark('authored-curved-line', curvedLineDatum, 'line')',
+ )
+ expect(markup).toContain(
+ 'authoredCurveMark('authored-curved-area', curvedAreaDatum, 'area')',
+ )
+ expect(markup).toContain('pathGeometry: scenePath((path) => {')
expect(markup).not.toContain('packages/charts-core/src/nearest.ts')
})
})
@@ -184,6 +195,38 @@ describe('intentional interaction contracts', () => {
expect(middle).not.toBeNull()
expect(middle?.node.width).toBeLessThan(250)
})
+
+ it('resolves authored curves in both SVG and Canvas examples', () => {
+ expect(curvedPathLimitCases.map((proof) => proof.renderer)).toEqual([
+ 'svg',
+ 'canvas',
+ ])
+
+ for (const proof of curvedPathLimitCases) {
+ const scene = createChartScene(proof.definition, {
+ width: 520,
+ height: 230,
+ })
+ const probe = proof.probe(scene)
+ const target = flatten(scene.nodes).find(
+ (node) =>
+ (node.kind === 'polyline' || node.kind === 'area') &&
+ node.interaction !== undefined,
+ )
+
+ expect(probe).not.toBeNull()
+ expect(target?.kind === 'polyline' || target?.kind === 'area').toBe(true)
+ if (!probe || !target || target.kind === 'group') continue
+ if (target.kind !== 'polyline' && target.kind !== 'area') continue
+
+ expect(target.pathGeometry?.data).toContain('C')
+ expect(target.pathGeometry?.contours[0]?.points.length).toBeGreaterThan(
+ target.points.length,
+ )
+ expect(target.points).toHaveLength(proof.renderer === 'svg' ? 2 : 4)
+ expect(findNearestPoint(scene, probe.x, probe.y, 48)).not.toBeNull()
+ }
+ })
})
describe('interaction geometry proof gallery', () => {
@@ -252,6 +295,64 @@ describe('interaction geometry proof gallery', () => {
).toBe('october-disease')
})
+ it('selects the painted side of each curved stack boundary', () => {
+ for (const { id, orientation } of [
+ { id: 'curved-vertical-stack', orientation: 'vertical' },
+ { id: 'curved-horizontal-stack', orientation: 'horizontal' },
+ ] as const) {
+ const proof = proofCases.find((candidate) => candidate.id === id)
+ expect(proof).toBeDefined()
+ if (!proof) continue
+
+ const scene = createChartScene(proof.after, { width: 520, height: 230 })
+ const lower = flatten(scene.nodes).find(
+ (node) =>
+ node.kind === 'area' &&
+ node.interaction !== undefined &&
+ interactionPoints(node).some(
+ (point) => (point.datum as ProofDatum).series === 'lower',
+ ),
+ )
+ const sample = scene.points.find(
+ (point) =>
+ point.datum.series === 'upper' &&
+ (orientation === 'vertical'
+ ? point.datum.x === 2
+ : point.datum.y === 2),
+ )
+ expect(lower?.kind).toBe('area')
+ expect(sample).toBeDefined()
+ if (!lower || lower.kind !== 'area' || !sample) continue
+
+ const contour = lower.pathGeometry?.contours[0]
+ expect(contour).toBeDefined()
+ if (!contour) continue
+
+ const boundary =
+ orientation === 'vertical'
+ ? Math.min(...contourIntersections(contour.points, sample.x, 'x'))
+ : Math.max(...contourIntersections(contour.points, sample.y, 'y'))
+ const probes =
+ orientation === 'vertical'
+ ? [
+ { x: sample.x, y: boundary + 4, series: 'lower' },
+ { x: sample.x, y: boundary, series: 'upper' },
+ { x: sample.x, y: boundary - 4, series: 'upper' },
+ ]
+ : [
+ { x: boundary - 4, y: sample.y, series: 'lower' },
+ { x: boundary, y: sample.y, series: 'upper' },
+ { x: boundary + 4, y: sample.y, series: 'upper' },
+ ]
+
+ for (const probe of probes) {
+ expect(
+ findNearestPoint(scene, probe.x, probe.y, 48)?.datum.series,
+ ).toBe(probe.series)
+ }
+ }
+ })
+
it('keeps the large rectangle and polygon stress fixtures intact', () => {
const rectangleProof = proofCases.find(
(candidate) => candidate.id === 'dense-rectangles',
@@ -291,6 +392,47 @@ function flatten(nodes: readonly SceneNode[]): SceneNode[] {
)
}
+function interactionPoints(
+ node: Extract,
+): readonly ChartPoint[] {
+ const interaction = node.interaction
+ if (!interaction) return []
+ return interaction.point ? [interaction.point] : interaction.points
+}
+
+function contourIntersections(
+ points: readonly (readonly [number, number])[],
+ coordinate: number,
+ axis: 'x' | 'y',
+): number[] {
+ const intersections: number[] = []
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]
+ const end = points[(index + 1) % points.length]
+ if (!start || !end) continue
+ const primaryStart = axis === 'x' ? start[0] : start[1]
+ const primaryEnd = axis === 'x' ? end[0] : end[1]
+ if (
+ coordinate < Math.min(primaryStart, primaryEnd) ||
+ coordinate > Math.max(primaryStart, primaryEnd)
+ ) {
+ continue
+ }
+ if (primaryStart === primaryEnd) {
+ intersections.push(axis === 'x' ? start[1] : start[0])
+ intersections.push(axis === 'x' ? end[1] : end[0])
+ continue
+ }
+ const amount = (coordinate - primaryStart) / (primaryEnd - primaryStart)
+ intersections.push(
+ axis === 'x'
+ ? start[1] + (end[1] - start[1]) * amount
+ : start[0] + (end[0] - start[0]) * amount,
+ )
+ }
+ return intersections
+}
+
function findRect(
nodes: readonly SceneNode[],
datumId: string,
diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx
index bd83dbc3..72e1d94e 100644
--- a/examples/sandbox/src/InteractionGeometryLab.tsx
+++ b/examples/sandbox/src/InteractionGeometryLab.tsx
@@ -1,11 +1,14 @@
import * as React from 'react'
import {
+ areaX,
areaY,
bandX,
bandY,
barX,
barY,
createMark,
+ d3AreaXCurve,
+ d3Curve,
defineChart,
dot,
facet,
@@ -13,6 +16,7 @@ import {
hexagon,
lineY,
rect,
+ scenePath,
stack,
whenFocused,
type ChartFocusAffinity,
@@ -26,6 +30,7 @@ import { tooltip } from '@tanstack/charts/tooltip'
import { Chart } from '@tanstack/react-charts'
import { Chart as CanvasChart } from '@tanstack/react-charts/canvas'
import { scaleBand, scaleLinear } from 'd3-scale'
+import { curveBasis } from 'd3-shape'
import proofSource from './InteractionGeometryLab.tsx?raw'
export interface ProofDatum {
@@ -74,8 +79,23 @@ interface ProofCase {
type ProofRenderer = 'svg' | 'canvas'
+interface CurvedPathCase {
+ id: string
+ title: string
+ renderer: ProofRenderer
+ renderedGeometry: string
+ interactionGeometry: string
+ definitionName: string
+ definition: StaticChartDefinition
+ source: string
+ probe: (
+ scene: ChartScene,
+ ) => { x: number; y: number } | null
+}
+
const canvasProofIds = new Set([
'grouped-horizontal-bars',
+ 'curved-horizontal-stack',
'line-area',
'mixed-horizontal-bars-dots',
'mixed-area-line-dots',
@@ -263,6 +283,63 @@ export const animatedDestinationDefinition = defineChart(
)
// source:animated-destination:end
+// source:curved-line-limit:start
+const curvedLineDatum = {
+ id: 'authored-curved-line',
+ label: 'Authored cubic line',
+ probe: [0.5, 0.2325],
+} satisfies ProofDatum
+
+const curvedLineBase = baseDefinition(
+ [authoredCurveMark('authored-curved-line', curvedLineDatum, 'line')],
+ scaleLinear().domain([0, 1]),
+ scaleLinear().domain([0, 1]),
+)
+
+const curvedLineDefinition = curvedPathDefinition(curvedLineBase)
+// source:curved-line-limit:end
+
+// source:curved-area-limit:start
+const curvedAreaDatum = {
+ id: 'authored-curved-area',
+ label: 'Authored curved area',
+ probe: [0.5, 0.38],
+} satisfies ProofDatum
+
+const curvedAreaBase = baseDefinition(
+ [authoredCurveMark('authored-curved-area', curvedAreaDatum, 'area')],
+ scaleLinear().domain([0, 1]),
+ scaleLinear().domain([0, 1]),
+)
+
+const curvedAreaDefinition = curvedPathDefinition(curvedAreaBase)
+// source:curved-area-limit:end
+
+export const curvedPathLimitCases = [
+ {
+ id: 'curved-line-path',
+ title: 'Cubic line · SVG',
+ renderer: 'svg',
+ renderedGeometry: 'Cubic Bézier stroke',
+ interactionGeometry: 'Recorded subpixel contour',
+ definitionName: 'curvedLineDefinition',
+ definition: curvedLineDefinition,
+ source: sourceSection('curved-line-limit'),
+ probe: normalizedDatumProbe('authored-curved-line'),
+ },
+ {
+ id: 'curved-area-path',
+ title: 'Curved area · Canvas',
+ renderer: 'canvas',
+ renderedGeometry: 'Cubic filled boundary',
+ interactionGeometry: 'Recorded subpixel contour',
+ definitionName: 'curvedAreaDefinition',
+ definition: curvedAreaDefinition,
+ source: sourceSection('curved-area-limit'),
+ probe: normalizedDatumProbe('authored-curved-area'),
+ },
+] satisfies readonly CurvedPathCase[]
+
export const facetFocusDefinitions = {
primary: interactiveDefinition(facetedBase),
x: interactiveDefinition(synchronizedXFacetedBase),
@@ -283,21 +360,22 @@ export function InteractionGeometryLab() {
its natural interaction axis. Facets, nested transforms, and clips
prove that interaction follows layout without copied hit bounds. Every
card names its renderer, the proof families are split evenly between
- SVG and Canvas, the first two sections make facet focus scope, axis
- synchronization, and destination-scene animation explicit, and the
- final comparisons scale the geometry contract to thousands of shapes.
- Built-in marks attach their natural affinity automatically; mixed-mark
- charts therefore resolve each rendered primitive independently without
- a chart-level affinity setting. Selected mixed cases add a third
- grouped-focus card: it uses the existing group-x or group-y preset to
- return every series at the shared axis value and render them as native
- tooltip rows.
+ SVG and Canvas, and the opening sections make facet scope,
+ destination-scene animation, and the authored-path approximation
+ explicit. The final comparisons scale the geometry contract to
+ thousands of shapes. Built-in marks attach their natural affinity
+ automatically; mixed-mark charts therefore resolve each rendered
+ primitive independently without a chart-level affinity setting.
+ Selected mixed cases add a third grouped-focus card: it uses the
+ existing group-x or group-y preset to return every series at the
+ shared axis value and render them as native tooltip rows.
+
{proofCases.map((proof) => (
+
+ Authored curves share render and interaction geometry
+ svg + canvas · resolved path
+
+
+ The dark curve and its subpixel interaction contour now come from one
+ typed scenePath. The dashed chord or polygon shows the old
+ source-point approximation. Each fixed probe lies on or inside the
+ painted curve but outside that approximation, proving SVG and Canvas now
+ resolve the authored path itself.
+
+
+ {curvedPathLimitCases.map((proof) => (
+
+ ))}
+
+
+ )
+}
+
+function CurvedPathLimitChart({ proof }: { proof: CurvedPathCase }) {
+ const [focused, setFocused] = React.useState
(null)
+ const [probe, setProbe] = React.useState<{ x: number; y: number } | null>(
+ null,
+ )
+ const source = React.useMemo(
+ () =>
+ [
+ proof.source,
+ ...sourceHelpers(proof.source),
+ chartComponentSource(proof.renderer, proof.definitionName),
+ ].join('\n\n'),
+ [proof],
+ )
+ const onFocusChange = React.useCallback(
+ (point: ChartPoint | null) => {
+ setFocused(point?.datum ?? null)
+ },
+ [],
+ )
+ const onRender = React.useCallback(
+ (context: { scene: ChartScene }) => {
+ const next = proof.probe(context.scene)
+ if (!next) return
+ setProbe((current) =>
+ current && current.x === next.x && current.y === next.y
+ ? current
+ : next,
+ )
+ },
+ [proof],
+ )
+ const chartProps = {
+ definition: proof.definition,
+ height: 230,
+ initialWidth: 520,
+ ariaLabel: `${proof.title}: authored path and structured interaction geometry`,
+ onFocusChange,
+ onRender,
+ }
+
+ return (
+
+
+ {proof.title}
+ path = hit geometry
+
+
+ {proof.renderer === 'canvas' ? (
+
+ ) : (
+
+ )}
+ {probe ? (
+
+
+
+ ) : null}
+
+
+
+
- Shared geometry
+ - {`${proof.renderedGeometry} → ${proof.interactionGeometry}`}
+
+
+
- Live focus
+ -
+ {focused?.label ?? 'Hover the fixed probe'}
+
+
+
+
+
+ )
+}
+
function FacetFocusModes() {
return (
+ [30, 30, 110, 30, 30].map((lower, x) => {
+ const value = series === 'lower' ? lower : 50
+ return {
+ id: `curved-v-${series}-${x}`,
+ label: `Curved vertical ${series} layer · ${value}`,
+ x,
+ y: value,
+ series,
+ probe:
+ series === 'upper' && x === 2
+ ? ([0.5, 1 - 105 / 180] as const)
+ : undefined,
+ }
+ }),
+ ) satisfies ProofDatum[]
+ const curvedVerticalStackBase = baseDefinition(
+ [
+ areaY(curvedVerticalStackRows, {
+ id: 'curved-vertical-stack',
+ x: 'x',
+ y: 'y',
+ z: 'series',
+ color: 'series',
+ key: 'id',
+ layout: stack({ order: ['lower', 'upper'] }),
+ curve: d3Curve(curveBasis),
+ fillOpacity: 0.62,
+ stroke: '#171717',
+ strokeWidth: 2,
+ }),
+ ],
+ scaleLinear().domain([0, 4]),
+ scaleLinear().domain([0, 180]),
+ )
+ // source:curved-vertical-stack:end
+
+ // source:curved-horizontal-stack:start
+ const curvedHorizontalStackRows = ['lower', 'upper'].flatMap((series) =>
+ [30, 30, 110, 30, 30].map((lower, y) => {
+ const value = series === 'lower' ? lower : 50
+ return {
+ id: `curved-h-${series}-${y}`,
+ label: `Curved horizontal ${series} layer · ${value}`,
+ x: value,
+ y,
+ series,
+ probe:
+ series === 'upper' && y === 2
+ ? ([105 / 180, 0.5] as const)
+ : undefined,
+ }
+ }),
+ ) satisfies ProofDatum[]
+ const curvedHorizontalStackBase = baseDefinition(
+ [
+ areaX(curvedHorizontalStackRows, {
+ id: 'curved-horizontal-stack',
+ x: 'x',
+ y: 'y',
+ z: 'series',
+ color: 'series',
+ key: 'id',
+ layout: stack({ order: ['lower', 'upper'] }),
+ curve: d3AreaXCurve(curveBasis),
+ fillOpacity: 0.62,
+ stroke: '#171717',
+ strokeWidth: 2,
+ }),
+ ],
+ scaleLinear().domain([0, 180]),
+ scaleLinear().domain([0, 4]),
+ )
+ // source:curved-horizontal-stack:end
+
// source:bubbles:start
const bubbles = [
{
@@ -2334,6 +2611,28 @@ function createProofCases(): ProofCase[] {
base: areaBase,
probe: (scene) => pointAtValue(scene, 'middle', 'y', 8),
}),
+ makeCase({
+ id: 'curved-vertical-stack',
+ title: 'Curved vertical stacked areas',
+ affinity: 'x',
+ explanation:
+ 'The recorded basis-spline boundary identifies the upper painted layer where the old straight source polygon still classified the pointer as lower. X affinity then selects the authored sample in that layer.',
+ beforeExpected: 'Curved vertical lower layer · 110',
+ afterExpected: 'Curved vertical upper layer · 50',
+ base: curvedVerticalStackBase,
+ probe: normalizedDatumProbe('curved-v-upper-2'),
+ }),
+ makeCase({
+ id: 'curved-horizontal-stack',
+ title: 'Curved horizontal stacked areas',
+ affinity: 'y',
+ explanation:
+ 'Horizontal stacks use the same resolved path contract: the spline chooses the rendered layer first, then Y affinity selects the semantic sample in that layer.',
+ beforeExpected: 'Curved horizontal lower layer · 110',
+ afterExpected: 'Curved horizontal upper layer · 50',
+ base: curvedHorizontalStackBase,
+ probe: normalizedDatumProbe('curved-h-upper-2'),
+ }),
makeCase({
id: 'bubbles',
title: 'Scatterplots and bubbles',
@@ -2622,6 +2921,24 @@ function interactiveDefinition(
})
}
+// source:curved-path-definition:start
+function curvedPathDefinition(
+ base: StaticChartDefinition,
+) {
+ return defineChart(base, {
+ maxFocusDistance: 48,
+ animate: false,
+ tooltip: {
+ use: tooltip,
+ className: 'hit-region-proof__tooltip',
+ sticky: false,
+ placement: ['top', 'right', 'left', 'bottom'],
+ format: (point) => point.datum.label,
+ },
+ })
+}
+// source:curved-path-definition:end
+
function groupedInteractiveDefinition(
base: StaticChartDefinition,
axis: 'x' | 'y',
@@ -2640,6 +2957,138 @@ function groupedInteractiveDefinition(
})
}
+// source:authored-curve-mark:start
+function authoredCurveMark(
+ id: string,
+ datum: ProofDatum,
+ kind: 'line' | 'area',
+) {
+ return createMark(() => ({
+ id,
+ channels: {},
+ render: ({ chart }) => {
+ const mapPoint = ([x, y]: readonly [number, number]) =>
+ [chart.x + x * chart.width, chart.y + y * chart.height] as const
+ const normalizedPoints =
+ kind === 'line'
+ ? ([
+ [0.12, 0.78],
+ [0.88, 0.78],
+ ] as const)
+ : ([
+ [0.12, 0.72],
+ [0.88, 0.72],
+ [0.88, 0.9],
+ [0.12, 0.9],
+ ] as const)
+ const points = normalizedPoints.map(mapPoint)
+ const anchor = mapPoint(kind === 'line' ? [0.5, 0.78] : [0.5, 0.81])
+ const point: ChartPoint = {
+ key: datum.id,
+ markId: id,
+ group: null,
+ groupLabel: id,
+ datum,
+ datumIndex: 0,
+ xValue: 0.5,
+ yValue: kind === 'line' ? 0.78 : 0.81,
+ x: anchor[0],
+ y: anchor[1],
+ color: '#171717',
+ }
+ const nodes: SceneNode[] = []
+
+ if (kind === 'line') {
+ const [start, end] = points
+ const control1 = mapPoint([0.12, 0.05])
+ const control2 = mapPoint([0.88, 0.05])
+ nodes.push({
+ kind: 'polyline',
+ key: datum.id,
+ points,
+ pathGeometry: scenePath((path) => {
+ path.moveTo(start[0], start[1])
+ path.bezierCurveTo(
+ control1[0],
+ control1[1],
+ control2[0],
+ control2[1],
+ end[0],
+ end[1],
+ )
+ }),
+ interaction: { point, affinity: 'geometry' },
+ style: {
+ fill: 'none',
+ stroke: '#171717',
+ strokeWidth: 10,
+ lineCap: 'round',
+ },
+ })
+ } else {
+ const [topLeft, topRight, bottomRight, bottomLeft] = points
+ const control1 = mapPoint([0.12, 0.06])
+ const control2 = mapPoint([0.88, 0.06])
+ nodes.push({
+ kind: 'area',
+ key: datum.id,
+ points,
+ pathGeometry: scenePath((path) => {
+ path.moveTo(bottomLeft[0], bottomLeft[1])
+ path.lineTo(topLeft[0], topLeft[1])
+ path.bezierCurveTo(
+ control1[0],
+ control1[1],
+ control2[0],
+ control2[1],
+ topRight[0],
+ topRight[1],
+ )
+ path.lineTo(bottomRight[0], bottomRight[1])
+ path.closePath()
+ }),
+ interaction: { point, affinity: 'geometry' },
+ style: {
+ fill: '#525252',
+ fillOpacity: 0.55,
+ stroke: '#171717',
+ strokeWidth: 3,
+ },
+ })
+ }
+
+ nodes.push({
+ kind: kind === 'line' ? 'polyline' : 'area',
+ key: `${datum.id}:structured-geometry`,
+ points,
+ style: {
+ fill: 'none',
+ stroke: '#a3a3a3',
+ strokeWidth: 2,
+ strokeDasharray: '5 5',
+ },
+ })
+ points.forEach(([x, y], index) => {
+ nodes.push({
+ kind: 'dot',
+ key: `${datum.id}:structured-point:${index}`,
+ x,
+ y,
+ radius: 5,
+ style: {
+ fill: '#ffffff',
+ stroke: '#737373',
+ strokeWidth: 2,
+ },
+ })
+ })
+
+ return { nodes }
+ },
+ }))
+}
+// source:authored-curve-mark:end
+
// source:normalized-rect-mark:start
function normalizedRectMark(
id: string,
diff --git a/packages/charts-core/docs/concepts/scales-and-d3.md b/packages/charts-core/docs/concepts/scales-and-d3.md
index cdee1589..0040a665 100644
--- a/packages/charts-core/docs/concepts/scales-and-d3.md
+++ b/packages/charts-core/docs/concepts/scales-and-d3.md
@@ -290,7 +290,11 @@ lineY(rows, {
})
```
-`d3Curve` adapts a D3 curve factory to the small line-and-area curve contract. Importing it is explicit so a straight chart does not need the shape path.
+`d3Curve` adapts a D3 curve factory to the small line-and-area curve contract.
+It records the rendered path and a subpixel interaction contour from the same
+D3 context calls, so curved lines and stacked areas target their painted
+geometry. Importing it is explicit so a straight chart does not need the shape
+path.
Horizontal `areaX` marks use the separate `d3AreaXCurve` bridge from `@tanstack/charts/d3/area-x`.
diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md
index 791f59b2..21d44539 100644
--- a/packages/charts-core/docs/guides/bundle-size-and-performance.md
+++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md
@@ -26,6 +26,7 @@ import { createChartSpring } from '@tanstack/charts/spring'
import { renderChartImage } from '@tanstack/charts/export'
import { focusX } from '@tanstack/charts/focus'
import { d3Curve } from '@tanstack/charts/d3/shape'
+import { scenePath } from '@tanstack/charts/scene/path'
import { tooltip } from '@tanstack/charts/tooltip'
import { portal } from '@tanstack/charts/tooltip/portal'
import { scaleLinear } from '@tanstack/charts-scales/linear'
@@ -33,6 +34,10 @@ import { groupBy } from '@tanstack/charts/transform/group'
import { window } from '@tanstack/charts/transform/window'
```
+`scenePath` is only needed when a custom mark authors curved geometry. Built-in
+D3 curve adapters record their geometry internally; straight marks do not run
+the path recorder or allocate flattened contours.
+
Canvas is opt-in. The default core and every default framework entry remain
SVG-based. Canvas enters the module graph only through
`@tanstack/charts/canvas`, `@tanstack/react-charts/canvas`, or
diff --git a/packages/charts-core/docs/reference/custom-extensions.md b/packages/charts-core/docs/reference/custom-extensions.md
index 50e05d09..d15acbd8 100644
--- a/packages/charts-core/docs/reference/custom-extensions.md
+++ b/packages/charts-core/docs/reference/custom-extensions.md
@@ -137,7 +137,9 @@ real; ordinary custom marks should use `createMark`.
## Curves
-`ChartCurve` supplies precomputed path data for line and y-oriented area marks:
+`ChartCurve` supplies path data for line and y-oriented area marks. Its optional
+geometry companion lets the renderer and pointer resolver consume one resolved
+curve instead of independently approximating the source points:
```ts
interface ChartCurve {
@@ -146,6 +148,7 @@ interface ChartCurve {
top: readonly (readonly [number, number])[],
bottom: readonly (readonly [number, number])[],
): string
+ geometry?: ChartCurveGeometry
}
```
@@ -157,14 +160,48 @@ interface AreaXCurve {
right: readonly (readonly [number, number])[],
left: readonly (readonly [number, number])[],
): string
+ geometry?: {
+ areaX(
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ): ScenePathGeometry
+ }
}
```
The optional bridges `d3Curve` from `@tanstack/charts/d3/shape` and
-`d3AreaXCurve` from `@tanstack/charts/d3/area-x` adapt a supplied curve factory
-to these contracts. D3 module ownership and granular imports are documented in
+`d3AreaXCurve` from `@tanstack/charts/d3/area-x` adapt a built-in Cartesian
+`d3-shape` curve factory to these contracts. D3 module ownership and granular imports are documented in
[Scales and D3](../concepts/scales-and-d3.md).
+The D3 adapters attach resolved geometry automatically. A custom scene path can
+do the same without maintaining a separate SVG string and hit polygon:
+
+```ts
+import { scenePath } from '@tanstack/charts/scene/path'
+
+const geometry = scenePath((path) => {
+ path.moveTo(20, 160)
+ path.bezierCurveTo(20, 20, 180, 20, 180, 160)
+ path.closePath()
+})
+
+const node = {
+ kind: 'area' as const,
+ key: 'curved-region',
+ points: [
+ [20, 160],
+ [180, 160],
+ ],
+ pathGeometry: geometry,
+}
+```
+
+`scenePath` records the rendered path data and derives a subpixel contour from
+the same commands. An opaque legacy `path: string` remains supported, but its
+interaction fallback uses `points` because core deliberately does not retain a
+general SVG path parser.
+
## Custom positional scales
A custom `ChartScale` resolves semantic values and the responsive range into a
diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md
index 1232f700..6a353cf9 100644
--- a/packages/charts-core/docs/reference/focus-and-interaction.md
+++ b/packages/charts-core/docs/reference/focus-and-interaction.md
@@ -75,9 +75,12 @@ second geometry copy. Inline mark states similarly return a resolved scene to
the host; during a transition, pointer selection intentionally follows that
destination scene rather than interpolating a second hit-test scene.
-For curved `polyline` and `area` nodes, the current resolver uses the
-primitive's structured point geometry. Exact picking against an optional
-authored SVG path string remains a separate refinement.
+Curved `polyline` and `area` nodes can carry `pathGeometry` recorded from the
+same commands used to paint them. Built-in D3 curve adapters attach that
+geometry automatically, and custom marks can create it with `scenePath` from
+`@tanstack/charts/scene/path`. Opaque authored `path: string` values remain
+compatible but fall back to their structured `points`; core does not parse a
+second copy of arbitrary SVG path syntax.
An explicit focus preset or custom strategy replaces this default resolver.
diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md
index eba75ce3..30fffbda 100644
--- a/packages/charts-core/docs/reference/index.md
+++ b/packages/charts-core/docs/reference/index.md
@@ -84,6 +84,7 @@ capabilities and individual marks independently tree-shakeable.
| `@tanstack/charts/focus/mark` | `whenFocused` |
| `@tanstack/charts/frame` | `frame` |
| `@tanstack/charts/geo` | `geoShape` and geographic projection types |
+| `@tanstack/charts/scene/path` | `scenePath` for shared rendered and interaction path geometry |
| `@tanstack/charts/group` | `group`, `GroupLayout`, `GroupOptions` |
| `@tanstack/charts/hexagon` | `hexagon` |
| `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend` |
diff --git a/packages/charts-core/docs/reference/marks/line-and-area.md b/packages/charts-core/docs/reference/marks/line-and-area.md
index 717befcd..e0a2f59b 100644
--- a/packages/charts-core/docs/reference/marks/line-and-area.md
+++ b/packages/charts-core/docs/reference/marks/line-and-area.md
@@ -172,6 +172,7 @@ interface ChartCurve {
top: readonly (readonly [number, number])[],
bottom: readonly (readonly [number, number])[],
): string
+ geometry?: ChartCurveGeometry
}
interface AreaXCurve {
@@ -179,6 +180,12 @@ interface AreaXCurve {
right: readonly (readonly [number, number])[],
left: readonly (readonly [number, number])[],
): string
+ geometry?: {
+ areaX(
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ): ScenePathGeometry
+ }
}
```
@@ -189,11 +196,18 @@ import { d3AreaXCurve } from '@tanstack/charts/d3/area-x'
import { d3Curve } from '@tanstack/charts/d3/shape'
```
-They accept a supplied curve factory and return the corresponding TanStack
-contract. Which granular D3 module to install and why these algorithms remain
-injected is documented once in
+They accept a built-in Cartesian `d3-shape` curve factory and return the
+corresponding TanStack contract. These adapters also record the rendered curve as scene-owned,
+subpixel interaction geometry. Curved stacked areas therefore resolve the
+painted layer first and then apply their natural x or y affinity to choose a
+semantic sample. Which granular D3 module to install and why these algorithms
+remain injected is documented once in
[Scales and D3](../../concepts/scales-and-d3.md).
+Custom marks can create the same shared geometry with `scenePath` from
+`@tanstack/charts/scene/path`. Opaque `path: string` values remain compatible
+and fall back to their structured `points` for interaction.
+
## Layering area and line
Area marks do not automatically draw their upper line. Compose the layers:
diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md
index 0a640106..5cd4441e 100644
--- a/packages/charts-core/docs/reference/types.md
+++ b/packages/charts-core/docs/reference/types.md
@@ -197,6 +197,10 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes).
| `ChartLinearGradient` | Named linear-gradient resource |
| `ChartGradientStop` | Gradient offset, color, and optional opacity |
| `ChartCurve` | Line and y-area path generation |
+| `ChartCurveGeometry` | Resolved line and y-area path geometry |
+| `ScenePathContext` | Typed commands accepted by `scenePath` |
+| `ScenePathContour` | Derived interaction contour for one subpath |
+| `ScenePathGeometry` | Rendered path data and subpixel interaction acceleration |
See [Scales, guides, and color](./scales-guides-and-color.md).
@@ -228,7 +232,7 @@ See [DOM host](./dom-host.md) and
| `ChartFocusSource` | Pointer, keyboard, programmatic, or restored source |
| `ChartFocusFilter` | Focus-filtered mark matching configuration |
| `ChartFocusMatch` | Primary, group, key, x, y, or series matching |
-| `ChartFocusAffinity` | Primitive fallback axis after exact geometry containment |
+| `ChartFocusAffinity` | Primitive fallback axis after painted-geometry containment |
| `ChartSpatialIndex` | Nearest-point query |
| `ChartSpatialIndexFactory` | Builds an index from current scene points and resolved scene |
| `ChartExtensionInput` | Generic bare-token or `{ use, ...options }` extension input |
diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json
index a57842fe..20068ba3 100644
--- a/packages/charts-core/package.json
+++ b/packages/charts-core/package.json
@@ -65,6 +65,7 @@
"./rule": "./src/rule.ts",
"./runtime": "./src/runtime.ts",
"./scene": "./src/scene.ts",
+ "./scene/path": "./src/scene-path.ts",
"./svg": "./src/svg.ts",
"./svg/renderer": "./src/svg-surface.ts",
"./svg/resources": "./src/svg-resources.ts",
@@ -237,6 +238,10 @@
"types": "./dist/scene.d.ts",
"import": "./dist/scene.js"
},
+ "./scene/path": {
+ "types": "./dist/scene-path.d.ts",
+ "import": "./dist/scene-path.js"
+ },
"./svg": {
"types": "./dist/svg.d.ts",
"import": "./dist/svg.js"
diff --git a/packages/charts-core/src/area-x.ts b/packages/charts-core/src/area-x.ts
index beaeec79..3b497591 100644
--- a/packages/charts-core/src/area-x.ts
+++ b/packages/charts-core/src/area-x.ts
@@ -20,6 +20,7 @@ import type {
ChartPoint,
ChartValue,
OptionChannelOutput,
+ ScenePathGeometry,
SceneNode,
VisualChannel,
} from './types'
@@ -30,6 +31,12 @@ export interface AreaXCurve {
right: readonly (readonly [number, number])[],
left: readonly (readonly [number, number])[],
) => string
+ geometry?: {
+ areaX: (
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ) => ScenePathGeometry
+ }
}
export interface AreaXOptions extends ChartMarkMotionOptions {
@@ -171,12 +178,14 @@ export function areaX(
const flush = () => {
if (!right.length) return
const lower = [...left].reverse()
- const path = options.curve?.areaX(right, left)
+ const pathGeometry = options.curve?.geometry?.areaX(right, left)
+ const path = pathGeometry?.data ?? options.curve?.areaX(right, left)
nodes.push({
kind: 'area',
key: `${id}:${groupKey}:segment:${segmentIndex}`,
points: [...right, ...lower],
path,
+ pathGeometry,
interaction: { points: segmentPoints, affinity: 'y' },
style: {
fill,
diff --git a/packages/charts-core/src/area.ts b/packages/charts-core/src/area.ts
index 7d204d07..c66024b4 100644
--- a/packages/charts-core/src/area.ts
+++ b/packages/charts-core/src/area.ts
@@ -165,12 +165,14 @@ export function areaY(
const flush = () => {
if (!top.length) return
const lower = [...bottom].reverse()
- const path = options.curve?.area(top, bottom)
+ const pathGeometry = options.curve?.geometry?.area(top, bottom)
+ const path = pathGeometry?.data ?? options.curve?.area(top, bottom)
nodes.push({
kind: 'area',
key: `${id}:${groupKey}:segment:${segmentIndex}`,
points: [...top, ...lower],
path,
+ pathGeometry,
interaction: { points: segmentPoints, affinity: 'x' },
style: {
fill,
diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts
index 84dadd3f..b6958e11 100644
--- a/packages/charts-core/src/canvas.test.ts
+++ b/packages/charts-core/src/canvas.test.ts
@@ -7,6 +7,7 @@ import {
} from './canvas'
import { lineY } from './line'
import { defineChart } from './scene'
+import { scenePath } from './scene-path'
import { tooltip } from './tooltip'
import type { ChartSurfaceRenderOptions } from './dom-types'
import type { ChartScene, SceneNode } from './types'
@@ -18,17 +19,19 @@ interface FakeCanvasContext {
}
const contexts = new Map()
+const constructedPaths: string[] = []
let getContextSpy: ReturnType
let originalPath: typeof Path2D | undefined
beforeEach(() => {
contexts.clear()
+ constructedPaths.length = 0
originalPath = window.Path2D
Object.defineProperty(window, 'Path2D', {
configurable: true,
value: class {
constructor(data?: string) {
- void data
+ if (data) constructedPaths.push(data)
}
},
})
@@ -112,7 +115,11 @@ describe('Canvas renderer', () => {
kind: 'polyline',
key: 'curved-line',
points: [],
- path: 'M0,0C10,20,20,20,30,0',
+ path: 'M0,0L30,0',
+ pathGeometry: scenePath((path) => {
+ path.moveTo(0, 0)
+ path.bezierCurveTo(10, 20, 20, 20, 30, 0)
+ }),
style: {
fill: 'none',
stroke: '#334455',
@@ -212,6 +219,8 @@ describe('Canvas renderer', () => {
)
expect(fake.operations).toContain('fill:path')
expect(fake.operations).toContain('stroke:path')
+ expect(constructedPaths).toContain('M0,0C10,20,20,20,30,0')
+ expect(constructedPaths).not.toContain('M0,0L30,0')
expect(fake.operations).toContain('arc:50,30,5')
expect(fake.operations).toContain('arcTo')
expect(fake.operations).toContain('fillText:Canvas,0,0')
diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts
index b33e74e8..c14a9b0d 100644
--- a/packages/charts-core/src/canvas.ts
+++ b/packages/charts-core/src/canvas.ts
@@ -619,8 +619,9 @@ function paintNode(
strokeCurrentPath(painter, state, boundsForNode(node))
return
case 'polyline': {
- if (node.path) {
- const path = pathFromData(painter, node.path)
+ const pathData = node.pathGeometry?.data ?? node.path
+ if (pathData) {
+ const path = pathFromData(painter, pathData)
paintPath(painter, path, state, boundsForNode(node))
} else {
beginPointPath(context, node.points, false)
@@ -629,8 +630,9 @@ function paintNode(
return
}
case 'area': {
- if (node.path) {
- const path = pathFromData(painter, node.path)
+ const pathData = node.pathGeometry?.data ?? node.path
+ if (pathData) {
+ const path = pathFromData(painter, pathData)
paintPath(painter, path, state, boundsForNode(node))
} else {
beginPointPath(context, node.points, true)
@@ -975,7 +977,7 @@ function boundsForNode(node: Exclude) {
])
case 'polyline':
case 'area':
- return boundsFromPoints(node.points)
+ return node.pathGeometry?.bounds ?? boundsFromPoints(node.points)
case 'dot':
return {
x: node.x - node.radius,
diff --git a/packages/charts-core/src/d3-area-x.ts b/packages/charts-core/src/d3-area-x.ts
index 1ea2d494..8933094b 100644
--- a/packages/charts-core/src/d3-area-x.ts
+++ b/packages/charts-core/src/d3-area-x.ts
@@ -1,22 +1,16 @@
-import { area as createAreaPath, type CurveFactory } from 'd3-shape'
+import type { CurveFactory } from 'd3-shape'
import type { AreaXCurve } from './area-x'
+import { recordD3AreaPath } from './d3-curve-path'
+/** Adapts a built-in Cartesian d3-shape curve factory to horizontal areas. */
export function d3AreaXCurve(curve: CurveFactory): AreaXCurve {
- const areaPath = createAreaPath<
- readonly [y: number, x1: number, x2: number]
- >()
- .x0((point) => point[1])
- .x1((point) => point[2])
- .y((point) => point[0])
- .curve(curve)
+ const areaX = (
+ right: readonly (readonly [number, number])[],
+ left: readonly (readonly [number, number])[],
+ ) => recordD3AreaPath(curve, right, left)
return {
- areaX: (right, left) =>
- areaPath(
- right.map(
- (point, index) =>
- [point[1], left[index]?.[0] ?? point[0], point[0]] as const,
- ),
- ) ?? '',
+ areaX: (right, left) => areaX(right, left).data,
+ geometry: { areaX },
}
}
diff --git a/packages/charts-core/src/d3-curve-path.ts b/packages/charts-core/src/d3-curve-path.ts
new file mode 100644
index 00000000..9a45bb8c
--- /dev/null
+++ b/packages/charts-core/src/d3-curve-path.ts
@@ -0,0 +1,42 @@
+import type { CurveFactory } from 'd3-shape'
+import { scenePath } from './scene-path'
+import type { ScenePathContext, ScenePathGeometry } from './types'
+
+export function recordD3LinePath(
+ curve: CurveFactory,
+ points: readonly (readonly [number, number])[],
+): ScenePathGeometry {
+ return scenePath((path) => {
+ const output = curve(d3PathContext(path))
+ output.lineStart()
+ for (const point of points) output.point(point[0], point[1])
+ output.lineEnd()
+ })
+}
+
+export function recordD3AreaPath(
+ curve: CurveFactory,
+ top: readonly (readonly [number, number])[],
+ bottom: readonly (readonly [number, number])[],
+): ScenePathGeometry {
+ return scenePath((path) => {
+ const output = curve(d3PathContext(path))
+ output.areaStart()
+ output.lineStart()
+ for (const point of top) output.point(point[0], point[1])
+ output.lineEnd()
+ output.lineStart()
+ for (let index = bottom.length; index--;) {
+ const point = bottom[index]!
+ output.point(point[0], point[1])
+ }
+ output.lineEnd()
+ output.areaEnd()
+ })
+}
+
+function d3PathContext(path: ScenePathContext): CanvasRenderingContext2D {
+ // D3 types name the complete Canvas context even though curve factories use
+ // only these four path methods. Keep that over-broad boundary isolated here.
+ return path as unknown as CanvasRenderingContext2D
+}
diff --git a/packages/charts-core/src/d3-shape.test.ts b/packages/charts-core/src/d3-shape.test.ts
new file mode 100644
index 00000000..9c00220e
--- /dev/null
+++ b/packages/charts-core/src/d3-shape.test.ts
@@ -0,0 +1,89 @@
+import {
+ area as createAreaPath,
+ curveBasis,
+ curveBasisClosed,
+ curveBasisOpen,
+ curveBumpX,
+ curveBumpY,
+ curveCardinal,
+ curveCardinalClosed,
+ curveCardinalOpen,
+ curveCatmullRom,
+ curveCatmullRomClosed,
+ curveCatmullRomOpen,
+ curveLinear,
+ curveLinearClosed,
+ curveMonotoneX,
+ curveMonotoneY,
+ curveNatural,
+ curveStep,
+ curveStepAfter,
+ curveStepBefore,
+ line as createLinePath,
+ type CurveFactory,
+} from 'd3-shape'
+import { describe, expect, it } from 'vitest'
+import { d3Curve } from './d3-shape'
+
+const points = [
+ [10, 80],
+ [55, 20],
+ [105, 65],
+ [160, 25],
+] as const
+const bottom = points.map(([x]) => [x, 110] as const)
+
+describe('d3Curve resolved geometry', () => {
+ for (const [name, curve] of [
+ ['basis', curveBasis],
+ ['basis-closed', curveBasisClosed],
+ ['basis-open', curveBasisOpen],
+ ['bump-x', curveBumpX],
+ ['bump-y', curveBumpY],
+ ['cardinal', curveCardinal],
+ ['cardinal-closed', curveCardinalClosed],
+ ['cardinal-open', curveCardinalOpen],
+ ['catmull-rom', curveCatmullRom],
+ ['catmull-rom-closed', curveCatmullRomClosed],
+ ['catmull-rom-open', curveCatmullRomOpen],
+ ['linear', curveLinear],
+ ['linear-closed', curveLinearClosed],
+ ['monotone-x', curveMonotoneX],
+ ['monotone-y', curveMonotoneY],
+ ['natural', curveNatural],
+ ['step', curveStep],
+ ['step-after', curveStepAfter],
+ ['step-before', curveStepBefore],
+ ] satisfies readonly (readonly [string, CurveFactory])[]) {
+ it(`preserves D3 ${name} path data while recording hit contours`, () => {
+ const adapter = d3Curve(curve)
+ const expectedLine =
+ createLinePath().curve(curve)(points)
+ const expectedArea = createAreaPath<
+ readonly [x: number, y0: number, y1: number]
+ >()
+ .x((point) => point[0])
+ .y0((point) => point[1])
+ .y1((point) => point[2])
+ .curve(curve)(
+ points.map(
+ (point, index) =>
+ [point[0], bottom[index]?.[1] ?? point[1], point[1]] as const,
+ ),
+ )
+ const lineGeometry = adapter.geometry?.line(points)
+ const areaGeometry = adapter.geometry?.area(points, bottom)
+
+ expect(adapter.line(points)).toBe(expectedLine)
+ expect(adapter.area(points, bottom)).toBe(expectedArea)
+ expect(lineGeometry?.data).toBe(expectedLine)
+ expect(areaGeometry?.data).toBe(expectedArea)
+ expect(lineGeometry?.contours[0]?.points.length).toBeGreaterThanOrEqual(
+ points.length,
+ )
+ expect(areaGeometry?.contours.some((contour) => contour.closed)).toBe(
+ expectedArea?.includes('Z'),
+ )
+ })
+ }
+})
diff --git a/packages/charts-core/src/d3-shape.ts b/packages/charts-core/src/d3-shape.ts
index 90f89596..cbc03cc0 100644
--- a/packages/charts-core/src/d3-shape.ts
+++ b/packages/charts-core/src/d3-shape.ts
@@ -1,30 +1,21 @@
-import {
- area as createAreaPath,
- line as createLinePath,
- type CurveFactory,
-} from 'd3-shape'
+import type { CurveFactory } from 'd3-shape'
+import { recordD3AreaPath, recordD3LinePath } from './d3-curve-path'
import type { ChartCurve } from './types'
+/** Adapts a built-in Cartesian d3-shape curve factory to scene geometry. */
export function d3Curve(curve: CurveFactory): ChartCurve {
- const linePath = createLinePath()
- .x((point) => point[0])
- .y((point) => point[1])
- .curve(curve)
- const areaPath = createAreaPath<
- readonly [x: number, y1: number, y2: number]
- >()
- .x((point) => point[0])
- .y0((point) => point[1])
- .y1((point) => point[2])
- .curve(curve)
+ const geometry = {
+ line: (points: readonly (readonly [number, number])[]) =>
+ recordD3LinePath(curve, points),
+ area: (
+ top: readonly (readonly [number, number])[],
+ bottom: readonly (readonly [number, number])[],
+ ) => recordD3AreaPath(curve, top, bottom),
+ }
+
return {
- line: (points) => linePath(points) ?? '',
- area: (top, bottom) =>
- areaPath(
- top.map(
- (point, index) =>
- [point[0], bottom[index]?.[1] ?? point[1], point[1]] as const,
- ),
- ) ?? '',
+ line: (points) => geometry.line(points).data,
+ area: (top, bottom) => geometry.area(top, bottom).data,
+ geometry,
}
}
diff --git a/packages/charts-core/src/exports.test.ts b/packages/charts-core/src/exports.test.ts
index c99e2ee7..66962fdc 100644
--- a/packages/charts-core/src/exports.test.ts
+++ b/packages/charts-core/src/exports.test.ts
@@ -71,4 +71,16 @@ describe('public package exports', () => {
expect(shape).toHaveProperty('d3Curve')
expect(areaX).toHaveProperty('d3AreaXCurve')
})
+
+ it('keeps typed scene paths available from barrels and an exact subpath', async () => {
+ const [root, universal, path] = await Promise.all([
+ import('@tanstack/charts'),
+ import('@tanstack/charts/universal'),
+ import('@tanstack/charts/scene/path'),
+ ])
+
+ expect(root).toHaveProperty('scenePath')
+ expect(universal).toHaveProperty('scenePath')
+ expect(path).toHaveProperty('scenePath')
+ })
})
diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts
index 31dacfdd..ef69eeef 100644
--- a/packages/charts-core/src/index.ts
+++ b/packages/charts-core/src/index.ts
@@ -42,6 +42,7 @@ export {
defineChart,
findNearestPoint,
} from './scene'
+export { scenePath } from './scene-path'
export { renderChartSvg } from './svg'
export { stack } from './stack'
export type {
@@ -184,6 +185,7 @@ export type {
ChartColorLegend,
ChartColorLegendContext,
ChartCurve,
+ ChartCurveGeometry,
ChartDefinition,
ChartDefinitionOptions,
ChartExtensionInput,
@@ -288,6 +290,9 @@ export type {
SceneGroup,
SceneLabel,
SceneNode,
+ ScenePathContext,
+ ScenePathContour,
+ ScenePathGeometry,
ScenePolyline,
SceneRect,
SceneRule,
diff --git a/packages/charts-core/src/line.ts b/packages/charts-core/src/line.ts
index fdcab8d9..d8eb8864 100644
--- a/packages/charts-core/src/line.ts
+++ b/packages/charts-core/src/line.ts
@@ -134,11 +134,13 @@ export function lineY(
const flushSegment = () => {
if (!segment.length) return
+ const pathGeometry = options.curve?.geometry?.line(segment)
children.push({
kind: 'polyline',
key: `${id}:${groupKey}:segment:${segmentIndex}`,
points: segment,
- path: options.curve?.line(segment),
+ path: pathGeometry?.data ?? options.curve?.line(segment),
+ pathGeometry,
interaction: { points: segmentPoints, affinity: 'x' },
style: {
fill: 'none',
diff --git a/packages/charts-core/src/link.ts b/packages/charts-core/src/link.ts
index 4443a3a9..7bb214bd 100644
--- a/packages/charts-core/src/link.ts
+++ b/packages/charts-core/src/link.ts
@@ -152,20 +152,20 @@ export function link(
lineCap: options.lineCap ?? ('round' as const),
lineJoin: 'round' as const,
}
+ const curvePoints = [
+ [x1, y1],
+ [x2, y2],
+ ] as const
+ const pathGeometry = options.curve?.geometry?.line(curvePoints)
nodes.push(
options.curve
? {
kind: 'polyline',
key,
- points: [
- [x1, y1],
- [x2, y2],
- ],
- path: options.curve.line([
- [x1, y1],
- [x2, y2],
- ]),
+ points: curvePoints,
+ path: pathGeometry?.data ?? options.curve.line(curvePoints),
+ pathGeometry,
style,
}
: {
diff --git a/packages/charts-core/src/nearest.test.ts b/packages/charts-core/src/nearest.test.ts
index 086bf35f..690c90de 100644
--- a/packages/charts-core/src/nearest.test.ts
+++ b/packages/charts-core/src/nearest.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { barX, barY } from './bar'
import { nearestPoint, nearestScenePoint } from './nearest'
import { createChartScene, defineChart } from './scene'
+import { scenePath } from './scene-path'
import type {
ChartFocusAffinity,
ChartPoint,
@@ -194,6 +195,86 @@ describe('scene interaction geometry', () => {
expect(nearestScenePoint(scene, 102, 100, 0)).toBeNull()
})
+ it('targets a cubic stroke where its source-point chord does not reach', () => {
+ const curved = point('curved-line', 100, 180)
+ const scene = testScene(
+ [
+ {
+ kind: 'polyline',
+ key: curved.key,
+ points: [
+ [20, 180],
+ [180, 180],
+ ],
+ pathGeometry: scenePath((path) => {
+ path.moveTo(20, 180)
+ path.bezierCurveTo(20, 20, 180, 20, 180, 180)
+ }),
+ interaction: { point: curved, affinity: 'geometry' },
+ style: { strokeWidth: 8 },
+ },
+ ],
+ [curved],
+ )
+
+ expect(nearestScenePoint(scene, 100, 60, 0)?.key).toBe('curved-line')
+ expect(nearestScenePoint(scene, 100, 90, 48)).toBeNull()
+ })
+
+ it('uses the curved shared boundary to select the proper stacked area', () => {
+ const lower = point('lower-curve', 100, 120)
+ const upper = point('upper-curve', 100, 20)
+ const lowerGeometry = scenePath((path) => {
+ path.moveTo(20, 200)
+ path.lineTo(20, 120)
+ path.bezierCurveTo(20, 40, 180, 40, 180, 120)
+ path.lineTo(180, 200)
+ path.closePath()
+ })
+ const upperGeometry = scenePath((path) => {
+ path.moveTo(20, 120)
+ path.lineTo(20, 20)
+ path.lineTo(180, 20)
+ path.lineTo(180, 120)
+ path.bezierCurveTo(180, 40, 20, 40, 20, 120)
+ path.closePath()
+ })
+ const scene = testScene(
+ [
+ {
+ kind: 'area',
+ key: lower.key,
+ points: [
+ [20, 120],
+ [180, 120],
+ [180, 200],
+ [20, 200],
+ ],
+ pathGeometry: lowerGeometry,
+ interaction: { point: lower, affinity: 'x' },
+ },
+ {
+ kind: 'area',
+ key: upper.key,
+ points: [
+ [20, 20],
+ [180, 20],
+ [180, 120],
+ [20, 120],
+ ],
+ pathGeometry: upperGeometry,
+ interaction: { point: upper, affinity: 'x' },
+ },
+ ],
+ [lower, upper],
+ )
+
+ // The straight polygons call this upper; the rendered curved boundary
+ // places the pointer inside the lower layer.
+ expect(nearestScenePoint(scene, 100, 80, 0)?.key).toBe('lower-curve')
+ expect(nearestScenePoint(scene, 100, 40, 0)?.key).toBe('upper-curve')
+ })
+
it('respects rounded corners from the rendered rectangle', () => {
const rounded = point('rounded', 100, 100)
const scene = testScene(
diff --git a/packages/charts-core/src/nearest.ts b/packages/charts-core/src/nearest.ts
index 5484c571..7f2ab479 100644
--- a/packages/charts-core/src/nearest.ts
+++ b/packages/charts-core/src/nearest.ts
@@ -7,6 +7,7 @@ import type {
SceneInteraction,
SceneNode,
} from './types'
+import { scenePathInteraction } from './scene-path-internal'
type GeometricSceneNode = Exclude
type InteractiveSceneNode = GeometricSceneNode & {
@@ -240,13 +241,25 @@ function containsTarget(target: SceneInteractionTarget, x: number, y: number) {
const radius = Math.max(0, node.radius)
return dx * dx + dy * dy <= radius * radius
}
- case 'area':
- return containsPolygon(node.points, localX, localY)
- case 'polyline':
- return (
- squaredDistanceToPolyline(node.points, localX, localY, false) <=
- strokeRadius(node) ** 2
- )
+ case 'area': {
+ const geometry = node.pathGeometry
+ return geometry
+ ? geometry[scenePathInteraction](geometry, 0, localX, localY, 0) === 1
+ : containsPolygon(node.points, localX, localY)
+ }
+ case 'polyline': {
+ const geometry = node.pathGeometry
+ return geometry
+ ? geometry[scenePathInteraction](
+ geometry,
+ 1,
+ localX,
+ localY,
+ strokeRadius(node),
+ ) === 1
+ : squaredDistanceToPolyline(node.points, localX, localY, false) <=
+ strokeRadius(node) ** 2
+ }
case 'rule':
return (
squaredDistanceToSegment(
@@ -271,43 +284,58 @@ function distanceToTarget(
const localY = y - target.offsetY
const { node } = target
let distance: number
- switch (node.kind) {
- case 'rect':
- distance = node.radius
- ? squaredDistanceToRoundedRect(node, localX, localY)
- : squaredDistanceToBounds(node, localX, localY)
- break
- case 'dot': {
- const dx = localX - node.x
- const dy = localY - node.y
- const amount = Math.max(
- 0,
- Math.sqrt(dx * dx + dy * dy) - Math.max(0, node.radius),
- )
- distance = amount * amount
- break
- }
- case 'area':
- distance = squaredDistanceToPolyline(node.points, localX, localY, true)
- break
- case 'polyline': {
- const raw = squaredDistanceToPolyline(node.points, localX, localY, false)
- const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node))
- distance = amount * amount
- break
- }
- case 'rule': {
- const raw = squaredDistanceToSegment(
- node.x1,
- node.y1,
- node.x2,
- node.y2,
- localX,
- localY,
- )
- const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node))
- distance = amount * amount
- break
+ if ((node.kind === 'area' || node.kind === 'polyline') && node.pathGeometry) {
+ distance = node.pathGeometry[scenePathInteraction](
+ node.pathGeometry,
+ node.kind === 'area' ? 2 : 3,
+ localX,
+ localY,
+ node.kind === 'polyline' ? strokeRadius(node) : 0,
+ )
+ } else {
+ switch (node.kind) {
+ case 'rect':
+ distance = node.radius
+ ? squaredDistanceToRoundedRect(node, localX, localY)
+ : squaredDistanceToBounds(node, localX, localY)
+ break
+ case 'dot': {
+ const dx = localX - node.x
+ const dy = localY - node.y
+ const amount = Math.max(
+ 0,
+ Math.sqrt(dx * dx + dy * dy) - Math.max(0, node.radius),
+ )
+ distance = amount * amount
+ break
+ }
+ case 'area':
+ distance = squaredDistanceToPolyline(node.points, localX, localY, true)
+ break
+ case 'polyline': {
+ const raw = squaredDistanceToPolyline(
+ node.points,
+ localX,
+ localY,
+ false,
+ )
+ const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node))
+ distance = amount * amount
+ break
+ }
+ case 'rule': {
+ const raw = squaredDistanceToSegment(
+ node.x1,
+ node.y1,
+ node.x2,
+ node.y2,
+ localX,
+ localY,
+ )
+ const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node))
+ distance = amount * amount
+ break
+ }
}
}
return target.clip
@@ -328,11 +356,19 @@ function boundsForNode(node: GeometricSceneNode): ChartBounds | null {
height: radius * 2,
}
}
- case 'area':
- return boundsFromPoints(node.points)
+ case 'area': {
+ const geometry = node.pathGeometry
+ const bounds = geometry ? geometry.bounds : boundsFromPoints(node.points)
+ return bounds && geometry
+ ? expandBounds(bounds, geometry.tolerance)
+ : bounds
+ }
case 'polyline': {
- const bounds = boundsFromPoints(node.points)
- return bounds ? expandBounds(bounds, strokeRadius(node)) : null
+ const geometry = node.pathGeometry
+ const bounds = geometry ? geometry.bounds : boundsFromPoints(node.points)
+ return bounds
+ ? expandBounds(bounds, strokeRadius(node) + (geometry?.tolerance ?? 0))
+ : null
}
case 'rule':
return expandBounds(
diff --git a/packages/charts-core/src/renderer.test.ts b/packages/charts-core/src/renderer.test.ts
index d68bd740..96f207d0 100644
--- a/packages/charts-core/src/renderer.test.ts
+++ b/packages/charts-core/src/renderer.test.ts
@@ -236,7 +236,7 @@ describe('renderer-neutral chart host', () => {
host.destroy()
})
- it('resolves pointer focus against renderer presentation geometry', () => {
+ it('selects destination geometry and presents the matching animated point', () => {
const fake = createFakeRenderer()
const container = document.createElement('div')
const onFocusChange = vi.fn()
@@ -265,8 +265,10 @@ describe('renderer-neutral chart host', () => {
}),
)
- expect(onFocusChange.mock.calls.at(-1)?.[0]?.datum).toBe(data[1])
- expect(fake.paintFocus.mock.calls.at(-1)?.[0]?.primary.x).toBe(first.x)
+ expect(onFocusChange.mock.calls.at(-1)?.[0]?.datum).toBe(data[0])
+ expect(fake.paintFocus.mock.calls.at(-1)?.[0]?.primary.x).toBe(
+ first.x + 100,
+ )
host.destroy()
})
diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts
index fcbf449d..a56b6642 100644
--- a/packages/charts-core/src/renderer.ts
+++ b/packages/charts-core/src/renderer.ts
@@ -2,7 +2,6 @@ import { createChartRuntime } from './runtime'
import { createDomTextMeasurer } from './dom-text'
import { findNearestPoint } from './scene'
import { focusNearestX, focusNearestY, focusX, focusY } from './focus'
-import { nearestPoint } from './nearest'
import type {
ChartRendererHost,
ChartRendererHostOptions,
@@ -432,18 +431,22 @@ export function mountChartRenderer<
y: number,
maxDistance: number,
): readonly ChartPoint[] {
- const points = interactionPoints()
+ const presentationPoints = interactionPoints()
const focus = resolveFocusStrategy(options.definition.focus)
if (focus) {
- return focus.resolve(points, x, y, maxDistance)
+ return focus.resolve(presentationPoints, x, y, maxDistance)
}
const point =
- points !== interactionScene.points
- ? nearestPoint(points, x, y, maxDistance)
- : spatialIndex && interactionScene === scene
- ? spatialIndex.findNearest(x, y, maxDistance)
- : findNearestPoint(interactionScene, x, y, maxDistance)
- return point ? [point] : []
+ spatialIndex && interactionScene === scene
+ ? spatialIndex.findNearest(x, y, maxDistance)
+ : findNearestPoint(interactionScene, x, y, maxDistance)
+ if (!point) return []
+ if (presentationPoints === interactionScene.points) return [point]
+
+ // Interaction identity comes from the destination scene so animation
+ // cannot temporarily fall back to anchor-only picking. Presentation
+ // points only move the focus marker and tooltip with the rendered frame.
+ return [restoreFocusedPoint(presentationPoints, point) ?? point]
}
function interactionPoints() {
diff --git a/packages/charts-core/src/scene-path-internal.ts b/packages/charts-core/src/scene-path-internal.ts
new file mode 100644
index 00000000..b0e40b3f
--- /dev/null
+++ b/packages/charts-core/src/scene-path-internal.ts
@@ -0,0 +1,2 @@
+/** @internal Shared capability key for recorded path interaction. */
+export const scenePathInteraction: unique symbol = Symbol()
diff --git a/packages/charts-core/src/scene-path.test.ts b/packages/charts-core/src/scene-path.test.ts
new file mode 100644
index 00000000..0e053fb5
--- /dev/null
+++ b/packages/charts-core/src/scene-path.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from 'vitest'
+import { scenePath } from './scene-path'
+
+describe('scenePath', () => {
+ it('emits one rendered cubic and its subpixel interaction contour', () => {
+ const geometry = scenePath((path) => {
+ path.moveTo(20, 180)
+ path.bezierCurveTo(20, 20, 180, 20, 180, 180)
+ })
+
+ expect(geometry.data).toBe('M20,180C20,20,180,20,180,180')
+ expect(geometry.tolerance).toBe(0.25)
+ expect(geometry.contours).toHaveLength(1)
+ expect(geometry.bounds).toMatchObject({
+ x: 20,
+ y: expect.closeTo(60, 0),
+ width: 160,
+ height: expect.closeTo(120, 0),
+ })
+ expect(geometry.contours[0]?.closed).toBe(false)
+ expect(geometry.contours[0]?.points.length).toBeGreaterThan(2)
+ expect(geometry.contours[0]?.points.at(0)).toEqual([20, 180])
+ expect(geometry.contours[0]?.points.at(-1)).toEqual([180, 180])
+ })
+
+ it('records closed areas and quadratic curves from the same commands', () => {
+ const geometry = scenePath((path) => {
+ path.moveTo(10, 90)
+ path.lineTo(10, 50)
+ path.quadraticCurveTo(50, 10, 90, 50)
+ path.lineTo(90, 90)
+ path.closePath()
+ })
+
+ expect(geometry.data).toBe('M10,90L10,50Q50,10,90,50L90,90Z')
+ expect(geometry.contours[0]?.closed).toBe(true)
+ expect(geometry.contours[0]?.points.length).toBeGreaterThan(4)
+ })
+
+ it('continues from a closed subpath without replacing its closing edge', () => {
+ const geometry = scenePath((path) => {
+ path.moveTo(0, 0)
+ path.lineTo(10, 0)
+ path.closePath()
+ path.lineTo(20, 0)
+ })
+
+ expect(geometry.data).toBe('M0,0L10,0ZL20,0')
+ expect(geometry.contours).toEqual([
+ {
+ points: [
+ [0, 0],
+ [10, 0],
+ ],
+ closed: true,
+ },
+ {
+ points: [
+ [0, 0],
+ [20, 0],
+ ],
+ closed: false,
+ },
+ ])
+ })
+
+ it('keeps sampled cubic points within its declared hit tolerance', () => {
+ const curves = [
+ [20, 180, 20, 20, 180, 20, 180, 180],
+ [40, 40, 300, -180, -120, 260, 40, 40],
+ [0, 0, 400, 1, -400, -1, 1, 0],
+ ] as const
+
+ for (const [x0, y0, x1, y1, x2, y2, x3, y3] of curves) {
+ const geometry = scenePath((path) => {
+ path.moveTo(x0, y0)
+ path.bezierCurveTo(x1, y1, x2, y2, x3, y3)
+ })
+ const contour = geometry.contours[0]?.points ?? []
+ let maximumDistance = 0
+
+ for (let index = 0; index <= 1_000; index += 1) {
+ const amount = index / 1_000
+ const point = cubicPoint(x0, y0, x1, y1, x2, y2, x3, y3, amount)
+ maximumDistance = Math.max(
+ maximumDistance,
+ distanceToPolyline(contour, point[0], point[1]),
+ )
+ }
+
+ expect(maximumDistance).toBeLessThanOrEqual(geometry.tolerance)
+ }
+ })
+})
+
+function cubicPoint(
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ x3: number,
+ y3: number,
+ amount: number,
+) {
+ const inverse = 1 - amount
+ const a = inverse ** 3
+ const b = 3 * inverse * inverse * amount
+ const c = 3 * inverse * amount * amount
+ const d = amount ** 3
+ return [
+ a * x0 + b * x1 + c * x2 + d * x3,
+ a * y0 + b * y1 + c * y2 + d * y3,
+ ] as const
+}
+
+function distanceToPolyline(
+ points: readonly (readonly [number, number])[],
+ x: number,
+ y: number,
+) {
+ let distance = Infinity
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]!
+ const end = points[index]!
+ const dx = end[0] - start[0]
+ const dy = end[1] - start[1]
+ const length = dx * dx + dy * dy
+ const amount = length
+ ? Math.max(
+ 0,
+ Math.min(1, ((x - start[0]) * dx + (y - start[1]) * dy) / length),
+ )
+ : 0
+ distance = Math.min(
+ distance,
+ Math.hypot(x - (start[0] + amount * dx), y - (start[1] + amount * dy)),
+ )
+ }
+ return distance
+}
diff --git a/packages/charts-core/src/scene-path.ts b/packages/charts-core/src/scene-path.ts
new file mode 100644
index 00000000..5538c3ea
--- /dev/null
+++ b/packages/charts-core/src/scene-path.ts
@@ -0,0 +1,385 @@
+import type {
+ ChartBounds,
+ ScenePathContext,
+ ScenePathContour,
+ ScenePathGeometry,
+} from './types'
+import { scenePathInteraction } from './scene-path-internal'
+
+const pathTolerance = 0.25
+const maxSubdivisionDepth = 12
+const pathDigits = 3
+
+interface MutableContour {
+ points: (readonly [number, number])[]
+ closed: boolean
+}
+
+/**
+ * Records one path for rendering and derives a subpixel contour for interaction.
+ * Both representations are emitted by the same drawing commands.
+ */
+export function scenePath(
+ draw: (path: ScenePathContext) => void,
+): ScenePathGeometry {
+ const recorder = new ScenePathRecorder()
+ draw(recorder)
+ return recorder.result()
+}
+
+class ScenePathRecorder implements ScenePathContext {
+ private data = ''
+ private contours: MutableContour[] = []
+ private contour: MutableContour | undefined
+ private startX = 0
+ private startY = 0
+ private currentX = 0
+ private currentY = 0
+ private hasCurrentPoint = false
+
+ moveTo(rawX: number, rawY: number): void {
+ const x = rounded(rawX)
+ const y = rounded(rawY)
+ this.data += `M${x},${y}`
+ this.contour = { points: [[x, y]], closed: false }
+ this.contours.push(this.contour)
+ this.startX = this.currentX = x
+ this.startY = this.currentY = y
+ this.hasCurrentPoint = true
+ }
+
+ lineTo(rawX: number, rawY: number): void {
+ const x = rounded(rawX)
+ const y = rounded(rawY)
+ if (!this.hasCurrentPoint) {
+ this.moveTo(x, y)
+ return
+ }
+ this.data += `L${x},${y}`
+ this.appendPoint(x, y)
+ }
+
+ quadraticCurveTo(
+ rawControlX: number,
+ rawControlY: number,
+ rawX: number,
+ rawY: number,
+ ): void {
+ const controlX = rounded(rawControlX)
+ const controlY = rounded(rawControlY)
+ const x = rounded(rawX)
+ const y = rounded(rawY)
+ if (!this.hasCurrentPoint) this.moveTo(controlX, controlY)
+ this.data += `Q${controlX},${controlY},${x},${y}`
+ const startX = this.currentX
+ const startY = this.currentY
+ appendFlattenedCubic(
+ this.requiredContour().points,
+ startX,
+ startY,
+ startX + (2 / 3) * (controlX - startX),
+ startY + (2 / 3) * (controlY - startY),
+ x + (2 / 3) * (controlX - x),
+ y + (2 / 3) * (controlY - y),
+ x,
+ y,
+ 0,
+ )
+ this.currentX = x
+ this.currentY = y
+ }
+
+ bezierCurveTo(
+ rawControl1X: number,
+ rawControl1Y: number,
+ rawControl2X: number,
+ rawControl2Y: number,
+ rawX: number,
+ rawY: number,
+ ): void {
+ const control1X = rounded(rawControl1X)
+ const control1Y = rounded(rawControl1Y)
+ const control2X = rounded(rawControl2X)
+ const control2Y = rounded(rawControl2Y)
+ const x = rounded(rawX)
+ const y = rounded(rawY)
+ if (!this.hasCurrentPoint) this.moveTo(control1X, control1Y)
+ this.data += `C${control1X},${control1Y},${control2X},${control2Y},${x},${y}`
+ appendFlattenedCubic(
+ this.requiredContour().points,
+ this.currentX,
+ this.currentY,
+ control1X,
+ control1Y,
+ control2X,
+ control2Y,
+ x,
+ y,
+ 0,
+ )
+ this.currentX = x
+ this.currentY = y
+ }
+
+ closePath(): void {
+ if (!this.hasCurrentPoint || !this.contour) return
+ this.data += 'Z'
+ this.contour.closed = true
+ this.currentX = this.startX
+ this.currentY = this.startY
+ // A later drawing command continues from the closed subpath's start, but
+ // it must not replace the already-recorded closing segment.
+ this.contour = undefined
+ }
+
+ result(): ScenePathGeometry {
+ const contours = this.contours.map((contour): ScenePathContour => ({
+ points: contour.points,
+ closed: contour.closed,
+ }))
+ return {
+ data: this.data,
+ contours,
+ bounds: pathBounds(contours),
+ tolerance: pathTolerance,
+ [scenePathInteraction]: pathInteraction,
+ }
+ }
+
+ private appendPoint(x: number, y: number): void {
+ this.requiredContour().points.push([x, y])
+ this.currentX = x
+ this.currentY = y
+ }
+
+ private requiredContour(): MutableContour {
+ if (this.contour) return this.contour
+ this.contour = {
+ points: [[this.currentX, this.currentY]],
+ closed: false,
+ }
+ this.contours.push(this.contour)
+ this.startX = this.currentX
+ this.startY = this.currentY
+ return this.contour
+ }
+}
+
+const pathInteraction: ScenePathGeometry[typeof scenePathInteraction] = (
+ geometry,
+ operation,
+ x,
+ y,
+ radius,
+) => {
+ if (operation === 0) {
+ if (
+ squaredDistanceToContours(geometry.contours, x, y, true) <=
+ geometry.tolerance * geometry.tolerance
+ ) {
+ return 1
+ }
+ let winding = 0
+ for (const contour of geometry.contours) {
+ const points = contour.points
+ for (
+ let index = 0, previous = points.length - 1;
+ index < points.length;
+ previous = index++
+ ) {
+ const start = points[previous]!
+ const end = points[index]!
+ const side =
+ (end[0] - start[0]) * (y - start[1]) -
+ (x - start[0]) * (end[1] - start[1])
+ if (start[1] <= y) {
+ if (end[1] > y && side > 0) winding += 1
+ } else if (end[1] <= y && side < 0) {
+ winding -= 1
+ }
+ }
+ }
+ return winding === 0 ? 0 : 1
+ }
+ if (operation === 1) {
+ return Number(
+ squaredDistanceToContours(geometry.contours, x, y, false) <=
+ (radius + geometry.tolerance) ** 2,
+ )
+ }
+ const raw = squaredDistanceToContours(
+ geometry.contours,
+ x,
+ y,
+ operation === 2,
+ )
+ const amount = Math.max(0, Math.sqrt(raw) - radius - geometry.tolerance)
+ return amount * amount
+}
+
+function appendFlattenedCubic(
+ points: (readonly [number, number])[],
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ x3: number,
+ y3: number,
+ depth: number,
+): void {
+ if (
+ depth >= maxSubdivisionDepth ||
+ cubicIsFlat(x0, y0, x1, y1, x2, y2, x3, y3)
+ ) {
+ points.push([x3, y3])
+ return
+ }
+
+ const x01 = (x0 + x1) / 2
+ const y01 = (y0 + y1) / 2
+ const x12 = (x1 + x2) / 2
+ const y12 = (y1 + y2) / 2
+ const x23 = (x2 + x3) / 2
+ const y23 = (y2 + y3) / 2
+ const x012 = (x01 + x12) / 2
+ const y012 = (y01 + y12) / 2
+ const x123 = (x12 + x23) / 2
+ const y123 = (y12 + y23) / 2
+ const x0123 = (x012 + x123) / 2
+ const y0123 = (y012 + y123) / 2
+
+ appendFlattenedCubic(
+ points,
+ x0,
+ y0,
+ x01,
+ y01,
+ x012,
+ y012,
+ x0123,
+ y0123,
+ depth + 1,
+ )
+ appendFlattenedCubic(
+ points,
+ x0123,
+ y0123,
+ x123,
+ y123,
+ x23,
+ y23,
+ x3,
+ y3,
+ depth + 1,
+ )
+}
+
+function cubicIsFlat(
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ x3: number,
+ y3: number,
+): boolean {
+ const ux = 3 * x1 - 2 * x0 - x3
+ const uy = 3 * y1 - 2 * y0 - y3
+ const vx = 3 * x2 - 2 * x3 - x0
+ const vy = 3 * y2 - 2 * y3 - y0
+ return (
+ Math.max(ux * ux, vx * vx) + Math.max(uy * uy, vy * vy) <=
+ 16 * pathTolerance * pathTolerance
+ )
+}
+
+function rounded(value: number): number {
+ const factor = 10 ** pathDigits
+ return Math.round(value * factor) / factor
+}
+
+function pathBounds(contours: readonly ScenePathContour[]): ChartBounds | null {
+ let minX = Infinity
+ let minY = Infinity
+ let maxX = -Infinity
+ let maxY = -Infinity
+ for (const contour of contours) {
+ for (const point of contour.points) {
+ if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])) continue
+ minX = Math.min(minX, point[0])
+ minY = Math.min(minY, point[1])
+ maxX = Math.max(maxX, point[0])
+ maxY = Math.max(maxY, point[1])
+ }
+ }
+ return Number.isFinite(minX)
+ ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
+ : null
+}
+
+function squaredDistanceToContours(
+ contours: readonly ScenePathContour[],
+ x: number,
+ y: number,
+ forceClosed: boolean,
+) {
+ let distance = Infinity
+ for (const contour of contours) {
+ distance = Math.min(
+ distance,
+ squaredDistanceToPolyline(
+ contour.points,
+ x,
+ y,
+ forceClosed || contour.closed,
+ ),
+ )
+ }
+ return distance
+}
+
+function squaredDistanceToPolyline(
+ points: readonly (readonly [number, number])[],
+ x: number,
+ y: number,
+ closed: boolean,
+) {
+ if (!points.length) return Infinity
+ if (points.length === 1) {
+ const point = points[0]!
+ return (point[0] - x) ** 2 + (point[1] - y) ** 2
+ }
+ let distance = Infinity
+ const segmentCount = closed ? points.length : points.length - 1
+ for (let index = 0; index < segmentCount; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ distance = Math.min(
+ distance,
+ squaredDistanceToSegment(start[0], start[1], end[0], end[1], x, y),
+ )
+ }
+ return distance
+}
+
+function squaredDistanceToSegment(
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number,
+ x: number,
+ y: number,
+) {
+ const dx = x2 - x1
+ const dy = y2 - y1
+ const length = dx * dx + dy * dy
+ const amount = length
+ ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / length))
+ : 0
+ const offsetX = x - (x1 + amount * dx)
+ const offsetY = y - (y1 + amount * dy)
+ return offsetX * offsetX + offsetY * offsetY
+}
diff --git a/packages/charts-core/src/svg-renderer.ts b/packages/charts-core/src/svg-renderer.ts
index 4e5a2abf..f5acd79b 100644
--- a/packages/charts-core/src/svg-renderer.ts
+++ b/packages/charts-core/src/svg-renderer.ts
@@ -71,6 +71,7 @@ function renderNode(
return ``
case 'polyline': {
const path =
+ node.pathGeometry?.data ??
node.path ??
node.points
.map(
@@ -82,6 +83,7 @@ function renderNode(
}
case 'area': {
const path =
+ node.pathGeometry?.data ??
node.path ??
`${node.points
.map(
diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts
index ec68e142..3dd87571 100644
--- a/packages/charts-core/src/types.ts
+++ b/packages/charts-core/src/types.ts
@@ -1,12 +1,66 @@
+import type { scenePathInteraction } from './scene-path-internal'
+
export type ChartValue = number | string | Date
export type ChartKey = string | number
+export interface ScenePathContext {
+ moveTo: (x: number, y: number) => void
+ lineTo: (x: number, y: number) => void
+ quadraticCurveTo: (
+ controlX: number,
+ controlY: number,
+ x: number,
+ y: number,
+ ) => void
+ bezierCurveTo: (
+ control1X: number,
+ control1Y: number,
+ control2X: number,
+ control2Y: number,
+ x: number,
+ y: number,
+ ) => void
+ closePath: () => void
+}
+
+export interface ScenePathContour {
+ readonly points: readonly (readonly [number, number])[]
+ readonly closed: boolean
+}
+
+type ScenePathInteraction = (
+ geometry: ScenePathGeometry,
+ operation: 0 | 1 | 2 | 3,
+ x: number,
+ y: number,
+ radius: number,
+) => number
+
+/** One authored path plus its derived, subpixel interaction acceleration. */
+export interface ScenePathGeometry {
+ readonly data: string
+ readonly contours: readonly ScenePathContour[]
+ readonly bounds: ChartBounds | null
+ readonly tolerance: number
+ readonly [scenePathInteraction]: ScenePathInteraction
+}
+
+export interface ChartCurveGeometry {
+ line: (points: readonly (readonly [number, number])[]) => ScenePathGeometry
+ area: (
+ top: readonly (readonly [number, number])[],
+ bottom: readonly (readonly [number, number])[],
+ ) => ScenePathGeometry
+}
+
export interface ChartCurve {
line: (points: readonly (readonly [number, number])[]) => string
area: (
top: readonly (readonly [number, number])[],
bottom: readonly (readonly [number, number])[],
) => string
+ /** Optional shared scene geometry used by renderers and interaction. */
+ geometry?: ChartCurveGeometry
}
export interface ChartScaleResolveContext {
@@ -818,13 +872,13 @@ export type SceneInteraction =
| {
point: ChartPoint
points?: never
- /** Natural pointer fallback after exact geometry containment. */
+ /** Natural pointer fallback after painted-geometry containment. */
affinity?: ChartFocusAffinity
}
| {
point?: never
points: readonly ChartPoint[]
- /** Natural pointer fallback after exact geometry containment. */
+ /** Natural pointer fallback after painted-geometry containment. */
affinity?: ChartFocusAffinity
}
@@ -888,12 +942,14 @@ export interface ScenePolyline extends InteractiveSceneNodeBase {
kind: 'polyline'
points: readonly (readonly [number, number])[]
path?: string
+ pathGeometry?: ScenePathGeometry
}
export interface SceneArea extends InteractiveSceneNodeBase {
kind: 'area'
points: readonly (readonly [number, number])[]
path?: string
+ pathGeometry?: ScenePathGeometry
}
export interface SceneDot extends InteractiveSceneNodeBase {
diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts
index d2296470..45e25eac 100644
--- a/packages/charts-core/src/universal-types.ts
+++ b/packages/charts-core/src/universal-types.ts
@@ -106,6 +106,7 @@ export type {
ChartColorLegend,
ChartColorLegendContext,
ChartCurve,
+ ChartCurveGeometry,
ChartDefinition,
ChartDefinitionOptions,
ChartFocusFilter,
@@ -210,6 +211,9 @@ export type {
SceneGroup,
SceneLabel,
SceneNode,
+ ScenePathContext,
+ ScenePathContour,
+ ScenePathGeometry,
ScenePolyline,
SceneRect,
SceneRule,
diff --git a/packages/charts-core/src/universal.ts b/packages/charts-core/src/universal.ts
index 5cd9aa36..85b1d038 100644
--- a/packages/charts-core/src/universal.ts
+++ b/packages/charts-core/src/universal.ts
@@ -23,6 +23,7 @@ export {
defineChart,
findNearestPoint,
} from './scene'
+export { scenePath } from './scene-path'
export { renderChartSvg } from './svg'
export { stack } from './stack'
export { binX, binY } from './transform-bin'
diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx
index 30ce6c42..d94974e3 100644
--- a/packages/react-native-charts/src/SvgScene.test.tsx
+++ b/packages/react-native-charts/src/SvgScene.test.tsx
@@ -1,6 +1,7 @@
import * as React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
+import { scenePath } from '@tanstack/charts/scene/path'
import type { ChartScene, SceneNode } from '@tanstack/charts/types'
import { resolveNativePaint } from './paint'
import { NativeChartScene, resolveNativeLineJoin } from './SvgScene'
@@ -120,7 +121,11 @@ function scene(): ChartScene {
kind: 'polyline',
key: 'curved-line',
points: [],
- path: 'M0,0C10,20,20,20,30,0',
+ path: 'M0,0L30,0',
+ pathGeometry: scenePath((path) => {
+ path.moveTo(0, 0)
+ path.bezierCurveTo(10, 20, 20, 20, 30, 0)
+ }),
style: {
fill: 'none',
stroke: '#334455',
diff --git a/packages/react-native-charts/src/SvgScene.tsx b/packages/react-native-charts/src/SvgScene.tsx
index 7a5f3bc2..d733f6f0 100644
--- a/packages/react-native-charts/src/SvgScene.tsx
+++ b/packages/react-native-charts/src/SvgScene.tsx
@@ -121,7 +121,11 @@ function renderSceneNode(
)
@@ -130,7 +134,11 @@ function renderSceneNode(
)
diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs
index 1dfb9a8f..7dfe669f 100644
--- a/scripts/measure-bundles.mjs
+++ b/scripts/measure-bundles.mjs
@@ -449,7 +449,7 @@ const entries = [
lockedBudgeted(
'React compact-scale line consumer',
'benchmarks/entries/charts-react-compact-line.ts',
- 18.6,
+ 18.7,
{
external: ['react', 'react/jsx-runtime', 'react-dom'],
rendererBoundary: 'svg',
@@ -644,12 +644,12 @@ const entries = [
budgeted(
'Direct D3 quadtree + TanStack DOM host',
'benchmarks/entries/charts-d3-quadtree-dom.ts',
- 26.5,
+ 26.6,
),
budgeted(
'Direct D3 Delaunay + TanStack DOM host',
'benchmarks/entries/charts-d3-delaunay-dom.ts',
- 31.8,
+ 31.9,
),
measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'),
measured(
@@ -789,7 +789,7 @@ const entries = [
budgeted(
'Geometry pointer resolver kernel',
'benchmarks/entries/charts-pointer-geometry-kernel.ts',
- 2,
+ 2.1,
),
budgeted(
'D3 brush controller kernel',