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( )