From 01ff033022a3d72652bdc368a568e9d8dd116023 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 13 Oct 2026 19:47:13 -0600 Subject: [PATCH 1/3] perf: skip boundary sampling during sunburst projection --- .changeset/sunburst-path-projection.md | 5 ++ .../charts-core/src/hierarchy-sunburst.ts | 23 +++----- .../src/polar-sector-internal.test.ts | 56 +++++++++++++++++++ .../charts-core/src/polar-sector-internal.ts | 18 +++--- 4 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 .changeset/sunburst-path-projection.md create mode 100644 packages/charts-core/src/polar-sector-internal.test.ts diff --git a/.changeset/sunburst-path-projection.md b/.changeset/sunburst-path-projection.md new file mode 100644 index 00000000..0124d915 --- /dev/null +++ b/.changeset/sunburst-path-projection.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Avoid rebuilding sunburst hit-test boundaries when projecting animation frames. diff --git a/packages/charts-core/src/hierarchy-sunburst.ts b/packages/charts-core/src/hierarchy-sunburst.ts index a48ac106..a51f6df2 100644 --- a/packages/charts-core/src/hierarchy-sunburst.ts +++ b/packages/charts-core/src/hierarchy-sunburst.ts @@ -10,7 +10,7 @@ import { } from './hierarchy-flat-internal' import { channelValues, isChartKey, isFiniteNumber, visualValue } from './mark' import { createPolarMark } from './polar-mark-internal' -import { resolvePolarSector } from './polar-sector-internal' +import { projectPolarSector, resolvePolarSector } from './polar-sector-internal' import { sceneMotionNode, type SceneMotionMetadata, @@ -340,22 +340,13 @@ export function sunburst( } function projectSunburstSectorPath(values: readonly number[]) { - const [startAngle, endAngle, innerRadius, outerRadius] = values - if ( - startAngle === undefined || - endAngle === undefined || - innerRadius === undefined || - outerRadius === undefined - ) { - return undefined - } - return resolvePolarSector({ - startAngle, - endAngle, - innerRadius, - outerRadius, + return projectPolarSector({ + startAngle: values[0]!, + endAngle: values[1]!, + innerRadius: values[2]!, + outerRadius: values[3]!, cornerRadius: 0, - })?.path + }) } function sunburstNodeContext( diff --git a/packages/charts-core/src/polar-sector-internal.test.ts b/packages/charts-core/src/polar-sector-internal.test.ts new file mode 100644 index 00000000..b843ccf5 --- /dev/null +++ b/packages/charts-core/src/polar-sector-internal.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { projectPolarSector, resolvePolarSector } from './polar-sector-internal' + +describe('polar sector projection', () => { + it.each([ + [0, 2, 20, 60, 0], + [2, -3, 80, 40, 8], + [0, Math.PI * 2, 0, 80, 6], + [0, 0.012, 60, 80, 6], + ])( + 'keeps the rendered path without sampling its boundary: %j', + (...values) => { + const [startAngle, endAngle, innerRadius, outerRadius, cornerRadius] = + values + const sector = { + startAngle: startAngle!, + endAngle: endAngle!, + innerRadius: innerRadius!, + outerRadius: outerRadius!, + cornerRadius: cornerRadius!, + } + const cosine = vi.spyOn(Math, 'cos') + try { + const projected = projectPolarSector(sector) + const projectionCalls = cosine.mock.calls.length + cosine.mockClear() + const resolved = resolvePolarSector(sector) + expect(resolved?.path).toBe(projected) + expect(resolved!.points.length).toBeGreaterThan(0) + expect(cosine.mock.calls.length).toBeGreaterThan(projectionCalls) + expect(projectPolarSector(sector)).toBe(projected) + } finally { + cosine.mockRestore() + } + }, + ) + + it.each([ + 'startAngle', + 'endAngle', + 'innerRadius', + 'outerRadius', + 'cornerRadius', + ] as const)('still rejects nonfinite %s', (field) => { + const sector = { + startAngle: 0, + endAngle: 2, + innerRadius: 20, + outerRadius: 60, + cornerRadius: 0, + [field]: Number.NaN, + } + expect(projectPolarSector(sector)).toBeUndefined() + expect(resolvePolarSector(sector)).toBeUndefined() + }) +}) diff --git a/packages/charts-core/src/polar-sector-internal.ts b/packages/charts-core/src/polar-sector-internal.ts index e32e7f9e..1500df5d 100644 --- a/packages/charts-core/src/polar-sector-internal.ts +++ b/packages/charts-core/src/polar-sector-internal.ts @@ -19,10 +19,7 @@ export interface ResolvedPolarSector { let polarSectorArc: Arc | undefined -/** Resolves one D3 sector and samples its painted interaction boundary. */ -export function resolvePolarSector( - sector: PolarSectorGeometry, -): ResolvedPolarSector | undefined { +export function projectPolarSector(sector: PolarSectorGeometry) { if ( !isFiniteNumber(sector.startAngle) || !isFiniteNumber(sector.endAngle) || @@ -38,11 +35,18 @@ export function resolvePolarSector( .innerRadius((value) => value.innerRadius) .outerRadius((value) => value.outerRadius) .cornerRadius((value) => value.cornerRadius)) - const path = generator(sector) - if (typeof path !== 'string' || !path) return undefined + return generator(sector) || undefined +} + +/** Resolves one D3 sector and samples its painted interaction boundary. */ +export function resolvePolarSector( + sector: PolarSectorGeometry, +): ResolvedPolarSector | undefined { + const path = projectPolarSector(sector) + if (!path) return undefined return { path, - points: tracePolarArcBoundary(generator, sector, 0, [sector]), + points: tracePolarArcBoundary(polarSectorArc!, sector, 0, [sector]), } } From cd2a8a7fcab201ca32db60ca6d6c8e92c4dd47b8 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 13 Oct 2026 20:02:41 -0600 Subject: [PATCH 2/3] perf: reuse default D3 sector accessors --- packages/charts-core/src/polar-sector-internal.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/charts-core/src/polar-sector-internal.ts b/packages/charts-core/src/polar-sector-internal.ts index 1500df5d..59800108 100644 --- a/packages/charts-core/src/polar-sector-internal.ts +++ b/packages/charts-core/src/polar-sector-internal.ts @@ -29,12 +29,10 @@ export function projectPolarSector(sector: PolarSectorGeometry) { ) { return undefined } - const generator = (polarSectorArc ??= createArc() - .startAngle((value) => value.startAngle) - .endAngle((value) => value.endAngle) - .innerRadius((value) => value.innerRadius) - .outerRadius((value) => value.outerRadius) - .cornerRadius((value) => value.cornerRadius)) + const generator = (polarSectorArc ??= + createArc().cornerRadius( + (value) => value.cornerRadius, + )) return generator(sector) || undefined } From e9e894f46910dda26c50f6773d5f0d6fbfed705d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 13 Oct 2026 20:12:34 -0600 Subject: [PATCH 3/3] chore: refresh sunburst projection validation artifacts --- benchmarks/comparison/bundle-baseline.json | 6 +++--- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 6 +++--- packages/charts-core/docs/comparison.md | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0406421b..258ab06f 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:35:26.997Z", + "generatedAt": "2026-10-14T02:03:21.704Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "a691036b07655adda97aa0dc3052428989e2b811", - "inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951" + "revision": "cd2a8a7fcab201ca32db60ca6d6c8e92c4dd47b8", + "inputDigest": "sha256:6c5dc96470c53874d724ca231e9e5a552800d04834e584260cc2eac4b504974e" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 300c9bf6..3cd78530 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "82eecd6c1fbdca5e857b965e09bb53c550520b2e596fbc4e9850266f03fdb9b4", + "sourceHash": "9bbb0210cda37cf492ff11d793f0e23d493f9aebc849609518fa4abc1ce7a26a", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index a6ab014c..deaec90b 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cd2a8a7` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `cd2a8a7`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index a6ab014c..deaec90b 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cd2a8a7` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `cd2a8a7`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only