From c80bb5d0d2b9c517e071254515b0fbe4f0b88cd7 Mon Sep 17 00:00:00 2001
From: Kyle Gill
Date: Sun, 2 Aug 2026 08:36:54 -0600
Subject: [PATCH] Add exact curved path interaction geometry
---
.changeset/exact-curved-picking.md | 9 +
API-FRICTION.md | 71 ++-
benchmarks/interaction/nearest.ts | 115 +++++
docs/concepts/scales-and-d3.md | 6 +-
docs/guides/bundle-size-and-performance.md | 5 +
docs/reference/custom-extensions.md | 39 +-
docs/reference/index.md | 1 +
docs/reference/marks/line-and-area.md | 18 +-
docs/reference/types.md | 4 +
.../src/InteractionGeometryLab.test.ts | 55 ++-
.../sandbox/src/InteractionGeometryLab.tsx | 467 +++++++++++++++++-
.../docs/concepts/scales-and-d3.md | 6 +-
.../guides/bundle-size-and-performance.md | 5 +
.../docs/reference/custom-extensions.md | 39 +-
packages/charts-core/docs/reference/index.md | 1 +
.../docs/reference/marks/line-and-area.md | 18 +-
packages/charts-core/docs/reference/types.md | 4 +
packages/charts-core/package.json | 5 +
packages/charts-core/src/area-x.ts | 11 +-
packages/charts-core/src/area.ts | 4 +-
packages/charts-core/src/canvas.test.ts | 13 +-
packages/charts-core/src/canvas.ts | 12 +-
packages/charts-core/src/d3-area-x.ts | 23 +-
packages/charts-core/src/d3-curve-path.ts | 42 ++
packages/charts-core/src/d3-shape.test.ts | 89 ++++
packages/charts-core/src/d3-shape.ts | 38 +-
packages/charts-core/src/exports.test.ts | 12 +
packages/charts-core/src/index.ts | 5 +
packages/charts-core/src/line.ts | 4 +-
packages/charts-core/src/link.ts | 16 +-
packages/charts-core/src/nearest.test.ts | 81 +++
packages/charts-core/src/nearest.ts | 200 ++++++--
packages/charts-core/src/scene-path.test.ts | 115 +++++
packages/charts-core/src/scene-path.ts | 259 ++++++++++
packages/charts-core/src/svg-renderer.ts | 2 +
packages/charts-core/src/types.ts | 45 ++
packages/charts-core/src/universal-types.ts | 4 +
packages/charts-core/src/universal.ts | 1 +
.../react-native-charts/src/SvgScene.test.tsx | 7 +-
packages/react-native-charts/src/SvgScene.tsx | 12 +-
40 files changed, 1717 insertions(+), 146 deletions(-)
create mode 100644 .changeset/exact-curved-picking.md
create mode 100644 packages/charts-core/src/d3-curve-path.ts
create mode 100644 packages/charts-core/src/d3-shape.test.ts
create mode 100644 packages/charts-core/src/scene-path.test.ts
create mode 100644 packages/charts-core/src/scene-path.ts
diff --git a/.changeset/exact-curved-picking.md b/.changeset/exact-curved-picking.md
new file mode 100644
index 00000000..746ebf41
--- /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 exact 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 484b7303..27f12ebb 100644
--- a/API-FRICTION.md
+++ b/API-FRICTION.md
@@ -214,6 +214,7 @@ Each entry records:
| F-176 | Large marks were focused by distant anchor points | API | monitoring |
| F-177 | Bubble overlap inherited incidental source order | Application | resolved |
| F-178 | Custom-template examples exposed DOM mutation plumbing | Docs/Tooling | resolved |
+| F-179 | D3 curve context types overstate built-in requirements | Tooling | resolved |
## Findings
@@ -4309,7 +4310,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,
@@ -4322,8 +4323,17 @@ 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.
+ The full unit matrix passes 775 tests across 133 files;
typecheck, documentation,
formatting, packed-consumer, seven-adapter, sandbox production-build, and
live browser checks also pass.
@@ -4338,17 +4348,24 @@ Each entry records:
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
+ subsequent Canvas path test. Across two consecutive runs, a dedicated
+ 2k-area fixture measures recorded cubic containment at 3.6–3.7 microseconds
+ per query versus 3.2–3.3 for the same targets' structured polygons, a
+ 0.3–0.5-microsecond median cost for exact curve ownership.
+
+ The isolated scene resolver is 5,815 minified / 2,275 gzip bytes versus 157 /
+ 153 for the anchor-only kernel: a 2,122-byte gzip feature cost. Exact path
+ containment places the isolated kernel 227 bytes over its original 2 KiB
+ budget. The preceding shared scene-geometry costs and aggregate fixture
+ ceilings were reviewed and accepted because painted-geometry interaction is
+ the default contract across DOM, Canvas, and native charts. Against that
+ accepted scene-geometry commit, the curved-path work adds 292 gzip bytes to
+ the DOM host, 55 to representative marks, and 29 to a straight D3 line scene,
+ while the D3 curved-line scene is 674 gzip bytes smaller because one recorded
+ path now serves both rendering and interaction; the core host remains
+ unchanged. The curved-path follow-up leaves the accepted locks unchanged, so
+ `bundle:check` reports these deltas for reviewer approval. 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
@@ -4367,9 +4384,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 path-accurate 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
@@ -4421,3 +4440,21 @@ Each entry records:
authored-source ratios remain 0.80–1.17× of the selected references, and the
measured isolated bundles include React rather than silently treating it as
benchmark-external infrastructure.
+
+### F-179 — 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 every built-in D3 Cartesian curve calls 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 curve 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. 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, monotone, and step curves; strict
+ typecheck passes with no assertion outside the adapter boundary.
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 065f5802..55efef87 100644
--- a/docs/guides/bundle-size-and-performance.md
+++ b/docs/guides/bundle-size-and-performance.md
@@ -24,6 +24,7 @@ import { mountChartRenderer } from '@tanstack/charts/renderer'
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'
@@ -31,6 +32,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..bda2c2c8 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,6 +160,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
+ }
}
```
@@ -165,6 +174,34 @@ The optional bridges `d3Curve` from `@tanstack/charts/d3/shape` and
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/index.md b/docs/reference/index.md
index 32d9f78a..507d6630 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -83,6 +83,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..06c53c59 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
+ }
}
```
@@ -190,10 +197,17 @@ 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
+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..5f077e08 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).
diff --git a/examples/sandbox/src/InteractionGeometryLab.test.ts b/examples/sandbox/src/InteractionGeometryLab.test.ts
index 2b41a2c1..e58da423 100644
--- a/examples/sandbox/src/InteractionGeometryLab.test.ts
+++ b/examples/sandbox/src/InteractionGeometryLab.test.ts
@@ -6,6 +6,7 @@ import type { 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: { 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')
})
})
@@ -178,6 +189,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', () => {
diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx
index 517b3644..75a36379 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 - 95 / 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
+ ? ([102 / 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 065f5802..55efef87 100644
--- a/packages/charts-core/docs/guides/bundle-size-and-performance.md
+++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md
@@ -24,6 +24,7 @@ import { mountChartRenderer } from '@tanstack/charts/renderer'
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'
@@ -31,6 +32,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..bda2c2c8 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,6 +160,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
+ }
}
```
@@ -165,6 +174,34 @@ The optional bridges `d3Curve` from `@tanstack/charts/d3/shape` and
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/index.md b/packages/charts-core/docs/reference/index.md
index 32d9f78a..507d6630 100644
--- a/packages/charts-core/docs/reference/index.md
+++ b/packages/charts-core/docs/reference/index.md
@@ -83,6 +83,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..06c53c59 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
+ }
}
```
@@ -190,10 +197,17 @@ 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
+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..5f077e08 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).
diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json
index d79ff195..4c86b88f 100644
--- a/packages/charts-core/package.json
+++ b/packages/charts-core/package.json
@@ -64,6 +64,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",
@@ -227,6 +228,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 befe2528..87068a5f 100644
--- a/packages/charts-core/src/area-x.ts
+++ b/packages/charts-core/src/area-x.ts
@@ -19,6 +19,7 @@ import type {
ChartPoint,
ChartValue,
OptionChannelOutput,
+ ScenePathGeometry,
SceneNode,
VisualChannel,
} from './types'
@@ -29,6 +30,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 {
@@ -170,12 +177,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 0506d9d7..a0455e84 100644
--- a/packages/charts-core/src/area.ts
+++ b/packages/charts-core/src/area.ts
@@ -164,12 +164,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 2ce595bd..c33983d9 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 f658f727..e16f15c0 100644
--- a/packages/charts-core/src/canvas.ts
+++ b/packages/charts-core/src/canvas.ts
@@ -618,8 +618,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)
@@ -628,8 +629,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)
@@ -974,7 +976,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..92101a96 100644
--- a/packages/charts-core/src/d3-area-x.ts
+++ b/packages/charts-core/src/d3-area-x.ts
@@ -1,22 +1,15 @@
-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'
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..fcaa4365 100644
--- a/packages/charts-core/src/d3-shape.ts
+++ b/packages/charts-core/src/d3-shape.ts
@@ -1,30 +1,20 @@
-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'
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 303f89cb..ed475585 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,
@@ -278,6 +280,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 4bda7f93..bfc65a20 100644
--- a/packages/charts-core/src/line.ts
+++ b/packages/charts-core/src/line.ts
@@ -133,11 +133,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 f9018256..df7917dc 100644
--- a/packages/charts-core/src/link.ts
+++ b/packages/charts-core/src/link.ts
@@ -151,20 +151,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..5b17d159 100644
--- a/packages/charts-core/src/nearest.ts
+++ b/packages/charts-core/src/nearest.ts
@@ -6,6 +6,7 @@ import type {
ChartValue,
SceneInteraction,
SceneNode,
+ ScenePathContour,
} from './types'
type GeometricSceneNode = Exclude
@@ -240,13 +241,26 @@ 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':
+ case 'area': {
+ const geometry = node.pathGeometry
+ return geometry
+ ? containsPathContours(
+ geometry.contours,
+ localX,
+ localY,
+ geometry.tolerance,
+ )
+ : containsPolygon(node.points, localX, localY)
+ }
+ case 'polyline': {
+ const geometry = node.pathGeometry
return (
- squaredDistanceToPolyline(node.points, localX, localY, false) <=
- strokeRadius(node) ** 2
+ (geometry
+ ? squaredDistanceToContours(geometry.contours, localX, localY, false)
+ : squaredDistanceToPolyline(node.points, localX, localY, false)) <=
+ (strokeRadius(node) + (geometry?.tolerance ?? 0)) ** 2
)
+ }
case 'rule':
return (
squaredDistanceToSegment(
@@ -271,43 +285,52 @@ 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 = distanceToPathGeometry(node, node.pathGeometry, localX, localY)
+ } 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
@@ -315,6 +338,27 @@ function distanceToTarget(
: distance
}
+function distanceToPathGeometry(
+ node: Extract,
+ geometry: NonNullable,
+ x: number,
+ y: number,
+) {
+ const raw = squaredDistanceToContours(
+ geometry.contours,
+ x,
+ y,
+ node.kind === 'area',
+ )
+ const amount = Math.max(
+ 0,
+ Math.sqrt(raw) -
+ (node.kind === 'polyline' ? strokeRadius(node) : 0) -
+ geometry.tolerance,
+ )
+ return amount * amount
+}
+
function boundsForNode(node: GeometricSceneNode): ChartBounds | null {
switch (node.kind) {
case 'rect':
@@ -328,11 +372,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(
@@ -417,6 +469,62 @@ function containsPolygon(
return inside
}
+function containsPathContours(
+ contours: readonly ScenePathContour[],
+ x: number,
+ y: number,
+ tolerance: number,
+) {
+ if (
+ squaredDistanceToContours(contours, x, y, true) <=
+ tolerance * tolerance
+ ) {
+ return true
+ }
+ let winding = 0
+ for (const contour of 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
+}
+
+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,
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..98e51bf4
--- /dev/null
+++ b/packages/charts-core/src/scene-path.test.ts
@@ -0,0 +1,115 @@
+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('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..37e8af4c
--- /dev/null
+++ b/packages/charts-core/src/scene-path.ts
@@ -0,0 +1,259 @@
+import type {
+ ChartBounds,
+ ScenePathContext,
+ ScenePathContour,
+ ScenePathGeometry,
+} from './types'
+
+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
+ }
+
+ 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,
+ }
+ }
+
+ 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.moveTo(this.currentX, this.currentY)
+ if (!this.contour) throw new TypeError('Expected an active path contour')
+ return this.contour
+ }
+}
+
+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
+}
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 d9229a83..ce10fdee 100644
--- a/packages/charts-core/src/types.ts
+++ b/packages/charts-core/src/types.ts
@@ -1,12 +1,55 @@
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
+}
+
+/** 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
+}
+
+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 exact scene geometry used by built-in renderers and interaction. */
+ geometry?: ChartCurveGeometry
}
export interface ChartScaleResolveContext {
@@ -802,12 +845,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 deb68c58..ecb85735 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,
@@ -200,6 +201,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(
)