diff --git a/.changeset/shared-chart-runtime.md b/.changeset/shared-chart-runtime.md new file mode 100644 index 00000000..76844a74 --- /dev/null +++ b/.changeset/shared-chart-runtime.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': patch +--- + +Reduce chart bundles by sharing Cartesian guide rendering, mark initialization, +stack ordering, motion tracks, SVG reconciliation, and hit-testing calculations. +Keep the motion renderer independent of the unused standalone tween engine. diff --git a/.gitignore b/.gitignore index 550ea8f4..c7c380fb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ coverage *.log tanstack.com-parity/ tanstack.com-charts-site/ + +output/playwright/ diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index f0ebd0b4..0ceca201 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 53412, - "gzip": 19894 + "bytes": 52086, + "gzip": 19825 }, "D3-scale line + static SVG": { - "bytes": 59566, - "gzip": 22210 + "bytes": 58248, + "gzip": 22132 }, "Representative marks": { - "bytes": 83306, - "gzip": 30340 + "bytes": 80993, + "gzip": 29994 }, "TanStack DOM host": { - "bytes": 81870, - "gzip": 28574 + "bytes": 80633, + "gzip": 28522 }, "React adapter": { - "bytes": 84195, - "gzip": 29518 + "bytes": 82959, + "gzip": 29492 }, "React line consumer": { - "bytes": 107854, - "gzip": 38849 + "bytes": 106656, + "gzip": 38799 }, "Compact-scale line scene": { - "bytes": 35865, - "gzip": 12732 + "bytes": 34536, + "gzip": 12669 }, "React compact-scale line consumer": { - "bytes": 90354, - "gzip": 31746 + "bytes": 89156, + "gzip": 31707 }, "Custom-scale line scene": { - "bytes": 34047, - "gzip": 11995 + "bytes": 32718, + "gzip": 11947 }, "D3 linear-scale line scene": { - "bytes": 53344, - "gzip": 19856 + "bytes": 52018, + "gzip": 19787 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index e85dd9fd..7c2c2a2b 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-10T04:50:31.051Z", + "generatedAt": "2026-09-13T19:48:59.290Z", "packageVersions": { - "tanstack": "0.17.0", + "tanstack": "0.18.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "8bab934c07762e335108b12687e3587cddc1288e", - "inputDigest": "sha256:73e433a9402a39e35a475fac52af5655016beae0c80c3a9950e47425ef4cd965" + "revision": "92c6da359ae47a7d8d37652e365b3f4565cfa8bf", + "inputDigest": "sha256:ae0e348223fa04b2381b59ec6e8a22f8401b740e71246accd7d2cb0273644f87" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 117941, - "gzipBytes": 42555, - "brotliBytes": 37623, - "incrementalGzipBytes": 42555, - "incrementalBrotliBytes": 37623 + "minifiedBytes": 116641, + "gzipBytes": 42471, + "brotliBytes": 37657, + "incrementalGzipBytes": 42471, + "incrementalBrotliBytes": 37657 }, "tanstack-line-interactive": { - "minifiedBytes": 123686, - "gzipBytes": 44460, - "brotliBytes": 39218, - "incrementalGzipBytes": 44460, - "incrementalBrotliBytes": 39218 + "minifiedBytes": 122388, + "gzipBytes": 44372, + "brotliBytes": 39156, + "incrementalGzipBytes": 44372, + "incrementalBrotliBytes": 39156 }, "tanstack-line-advanced": { - "minifiedBytes": 130878, - "gzipBytes": 46798, - "brotliBytes": 41196, - "incrementalGzipBytes": 46798, - "incrementalBrotliBytes": 41196 + "minifiedBytes": 129583, + "gzipBytes": 46712, + "brotliBytes": 41172, + "incrementalGzipBytes": 46712, + "incrementalBrotliBytes": 41172 }, "tanstack-bar-basic": { - "minifiedBytes": 130379, - "gzipBytes": 47284, - "brotliBytes": 41635, - "incrementalGzipBytes": 47284, - "incrementalBrotliBytes": 41635 + "minifiedBytes": 128254, + "gzipBytes": 46980, + "brotliBytes": 41354, + "incrementalGzipBytes": 46980, + "incrementalBrotliBytes": 41354 }, "tanstack-bar-interactive": { - "minifiedBytes": 134983, - "gzipBytes": 48679, - "brotliBytes": 42750, - "incrementalGzipBytes": 48679, - "incrementalBrotliBytes": 42750 + "minifiedBytes": 132860, + "gzipBytes": 48380, + "brotliBytes": 42606, + "incrementalGzipBytes": 48380, + "incrementalBrotliBytes": 42606 }, "tanstack-bar-advanced": { - "minifiedBytes": 135322, - "gzipBytes": 48828, - "brotliBytes": 42851, - "incrementalGzipBytes": 48828, - "incrementalBrotliBytes": 42851 + "minifiedBytes": 133199, + "gzipBytes": 48530, + "brotliBytes": 42692, + "incrementalGzipBytes": 48530, + "incrementalBrotliBytes": 42692 }, "tanstack-area-basic": { - "minifiedBytes": 123121, - "gzipBytes": 44596, - "brotliBytes": 39388, - "incrementalGzipBytes": 44596, - "incrementalBrotliBytes": 39388 + "minifiedBytes": 121643, + "gzipBytes": 44498, + "brotliBytes": 39323, + "incrementalGzipBytes": 44498, + "incrementalBrotliBytes": 39323 }, "tanstack-area-interactive": { - "minifiedBytes": 128876, - "gzipBytes": 46452, - "brotliBytes": 40950, - "incrementalGzipBytes": 46452, - "incrementalBrotliBytes": 40950 + "minifiedBytes": 127398, + "gzipBytes": 46335, + "brotliBytes": 40885, + "incrementalGzipBytes": 46335, + "incrementalBrotliBytes": 40885 }, "tanstack-area-advanced": { - "minifiedBytes": 136250, - "gzipBytes": 48807, - "brotliBytes": 43047, - "incrementalGzipBytes": 48807, - "incrementalBrotliBytes": 43047 + "minifiedBytes": 134774, + "gzipBytes": 48745, + "brotliBytes": 43041, + "incrementalGzipBytes": 48745, + "incrementalBrotliBytes": 43041 }, "tanstack-scatter-basic": { - "minifiedBytes": 118947, - "gzipBytes": 42908, - "brotliBytes": 37974, - "incrementalGzipBytes": 42908, - "incrementalBrotliBytes": 37974 + "minifiedBytes": 117647, + "gzipBytes": 42827, + "brotliBytes": 37990, + "incrementalGzipBytes": 42827, + "incrementalBrotliBytes": 37990 }, "tanstack-scatter-interactive": { - "minifiedBytes": 124696, - "gzipBytes": 44826, - "brotliBytes": 39490, - "incrementalGzipBytes": 44826, - "incrementalBrotliBytes": 39490 + "minifiedBytes": 123398, + "gzipBytes": 44743, + "brotliBytes": 39476, + "incrementalGzipBytes": 44743, + "incrementalBrotliBytes": 39476 }, "tanstack-scatter-advanced": { - "minifiedBytes": 124712, - "gzipBytes": 44832, - "brotliBytes": 39527, - "incrementalGzipBytes": 44832, - "incrementalBrotliBytes": 39527 + "minifiedBytes": 123414, + "gzipBytes": 44750, + "brotliBytes": 39470, + "incrementalGzipBytes": 44750, + "incrementalBrotliBytes": 39470 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg b/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg index e45a795f..a74d4320 100644 --- a/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg +++ b/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg b/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg index a536d1d6..f3565020 100644 --- a/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg +++ b/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg b/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg index 3f91ac67..231b2330 100644 --- a/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg +++ b/benchmarks/conformance/previews/191-shadcn-tooltip-formatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg b/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg index 92998466..a3f74064 100644 --- a/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg +++ b/benchmarks/conformance/previews/192-shadcn-tooltip-icons.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg b/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg index f3fa5dcf..ea3ce186 100644 --- a/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg +++ b/benchmarks/conformance/previews/193-shadcn-tooltip-indicator-line.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg b/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg index 24bc2585..4f2e897e 100644 --- a/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg +++ b/benchmarks/conformance/previews/194-shadcn-tooltip-indicator-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg b/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg index 7d9f4e08..ab1a5767 100644 --- a/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg +++ b/benchmarks/conformance/previews/195-shadcn-tooltip-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg b/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg index 003322dd..64f12719 100644 --- a/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg +++ b/benchmarks/conformance/previews/196-shadcn-tooltip-label-formatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg b/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg index 941b2616..b415f939 100644 --- a/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg +++ b/benchmarks/conformance/previews/197-shadcn-tooltip-label-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index f18c50d3..42c69dea 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "c5e393081d8428d13d2efdeca32ad1e7fc56062b610b5c014d29c28af02bbde9", + "sourceHash": "effdd579df63e4be803a324a318135326e4dccea63385502de6ec945eef94a60", "assets": [ { "id": "01-line-gaps", @@ -616,7 +616,7 @@ }, { "id": "132-shadcn-tooltip-advanced", - "sha256": "97738ec4dd4be6c3e40c7b3bae1fa519e809c5f71f451384d2be9b84ef4be79a", + "sha256": "8762da7358bcfa728e206a9c906cf596f298f483d39f8e01eeb086c1ffc991e4", "bytes": 5019 }, { @@ -906,42 +906,42 @@ }, { "id": "190-shadcn-tooltip-default", - "sha256": "fe48eaec0c292c1338d653953946483982ef1b23ec356478b24bbf11730fd3f9", + "sha256": "51bd9df24b4f9e1b9e1978c870d901dd7c7e861aba5530e786bddfba070247fc", "bytes": 5018 }, { "id": "191-shadcn-tooltip-formatter", - "sha256": "43eb6270aa388f3a3004cec62e53f0fbdb73ff5ddc5c4190649ff64d61730240", + "sha256": "b7e374ccd584b066a7501419d7f021a6397911f90dfc498ac2dc5f79e8b94beb", "bytes": 5020 }, { "id": "192-shadcn-tooltip-icons", - "sha256": "bda712a532d9455b3409e7dd4280e1e9262a1bc805fe6b7ffcc237de9693e39e", + "sha256": "2f61168ff6f8a22b04fdea877adfe9c4e2dfbd2c16fc4df734a0d508fac9b959", "bytes": 5016 }, { "id": "193-shadcn-tooltip-indicator-line", - "sha256": "a46a207d4db94529ad698128a0f348300212593c91517fbeb05a4a6e9de07888", + "sha256": "4be629683b7b8688e318e27e769e5da19732f1a7b58cbdfdab9a33b63f31fb3e", "bytes": 5025 }, { "id": "194-shadcn-tooltip-indicator-none", - "sha256": "ac6fe965bea2a6588cc0597a595fa8624c7f6cac3b38a20201ae8807f0ba4514", + "sha256": "b5606048801c5fc115081a953430c46228ebb6f43dfe79a18891d2fe3f8283fd", "bytes": 5023 }, { "id": "195-shadcn-tooltip-label-custom", - "sha256": "b09982f65a0481e1c2efdee73d3132058594acdc9786d8c1f9ad877816752536", + "sha256": "55ab8ea80d10636234d51d03172a5b32fff6541a51b02ff800f939b346131e6f", "bytes": 5023 }, { "id": "196-shadcn-tooltip-label-formatter", - "sha256": "b18e7ed66963b22aa33bc543ab08d119be452cf7e6f77d485f8756694dd16f2f", + "sha256": "26bcd0b7899a2545c8a3e0bc46f027836d3ef59a6f99e7fe4d36635897c9f951", "bytes": 5026 }, { "id": "197-shadcn-tooltip-label-none", - "sha256": "af6631dd0b4e88a936d9cff1ae28a90b549ef6c2d72513b90e9dadb80d4f437a", + "sha256": "4968486648c607560e8b47274b3fcd628134c2f9453a0d09d9362861e0d011bf", "bytes": 5019 } ] diff --git a/docs/comparison.md b/docs/comparison.md index ea1cb923..015b3f67 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 `8bab934` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92c6da3` | | [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 `8bab934`. +at page render time. The measured TanStack workspace revision is `92c6da3`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-10`. +Baseline date: `2026-09-13`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.48–47.60 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index ea1cb923..015b3f67 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 `8bab934` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `92c6da3` | | [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 `8bab934`. +at page render time. The measured TanStack workspace revision is `92c6da3`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-10`. +Baseline date: `2026-09-13`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 41.56–47.68 KiB | Not applicable | Controlled suite | +| TanStack Charts | 41.48–47.60 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/src/configured-scale.ts b/packages/charts-core/src/configured-scale.ts index 8d4b99bb..3f4e7936 100644 --- a/packages/charts-core/src/configured-scale.ts +++ b/packages/charts-core/src/configured-scale.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import type { ChartContinuousValue, ChartContinuousDomain, @@ -253,12 +254,6 @@ function continuousNumber(value: ChartContinuousValue): number { return value instanceof Date ? value.getTime() : value } -function sameChartValue(left: ChartValue | undefined, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} - function invalidViewportDomain(id: string): never { throw new TypeError( `Chart viewport "${id}" domain must contain two distinct finite numbers or Dates`, diff --git a/packages/charts-core/src/crosshair.ts b/packages/charts-core/src/crosshair.ts index 1dfe9b02..fa5424d7 100644 --- a/packages/charts-core/src/crosshair.ts +++ b/packages/charts-core/src/crosshair.ts @@ -1,3 +1,4 @@ +import { finiteNumber, finiteNonNegative } from './number-internal' import { resolveCrosshairGuide } from './crosshair-resolver' import { createMark } from './mark' import { valueKey } from './scales' @@ -170,12 +171,12 @@ function resolveBand( if (!input) return undefined const options = typeof input === 'object' ? input : undefined return { - bandwidth: finiteNonnegative(scale?.bandwidth, 0), - inset: finite(options?.inset, 0), + bandwidth: finiteNonNegative(scale?.bandwidth, 0), + inset: finiteNumber(options?.inset, 0), radius: options?.radius === undefined ? undefined - : finiteNonnegative(options.radius, 0), + : finiteNonNegative(options.radius, 0), style: { fill: options?.fill ?? fallbackFill, fillOpacity: options?.fillOpacity ?? 0.12, @@ -184,7 +185,7 @@ function resolveBand( strokeWidth: options?.strokeWidth === undefined ? undefined - : finiteNonnegative(options.strokeWidth, 0), + : finiteNonNegative(options.strokeWidth, 0), opacity: options?.opacity, }, } @@ -198,7 +199,7 @@ function resolveRuleStyle( return { stroke: axis?.stroke ?? shared.stroke ?? fallbackStroke, strokeOpacity: axis?.strokeOpacity ?? shared.strokeOpacity ?? 0.35, - strokeWidth: finiteNonnegative(axis?.strokeWidth ?? shared.strokeWidth, 1), + strokeWidth: finiteNonNegative(axis?.strokeWidth ?? shared.strokeWidth, 1), strokeDasharray: axis?.strokeDasharray ?? shared.strokeDasharray, } } @@ -216,15 +217,15 @@ function resolveLabel( : scale ? (value) => formatScaleValue(scale, value) : undefined, - offset: finiteNonnegative(options?.offset, 8), - fontSize: finiteNonnegative(options?.fontSize, 11), + offset: finiteNonNegative(options?.offset, 8), + fontSize: finiteNonNegative(options?.fontSize, 11), fontWeight: options?.fontWeight, style: { fill: options?.fill ?? fallbackFill, fillOpacity: options?.fillOpacity, stroke: options?.stroke ?? 'var(--ts-chart-crosshair-label-halo, Canvas)', strokeOpacity: options?.strokeOpacity, - strokeWidth: finiteNonnegative(options?.strokeWidth, 3), + strokeWidth: finiteNonNegative(options?.strokeWidth, 3), opacity: options?.opacity, }, } @@ -254,24 +255,14 @@ function resolveMarker( if (!input) return undefined const options = typeof input === 'object' ? input : undefined return { - radius: finiteNonnegative(options?.radius, 4), + radius: finiteNonNegative(options?.radius, 4), style: { fill: options?.fill ?? 'var(--ts-chart-crosshair-marker-fill, Canvas)', fillOpacity: options?.fillOpacity, stroke: options?.stroke, strokeOpacity: options?.strokeOpacity, - strokeWidth: finiteNonnegative(options?.strokeWidth, 2), + strokeWidth: finiteNonNegative(options?.strokeWidth, 2), opacity: options?.opacity, }, } } - -function finiteNonnegative(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - -function finite(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback -} diff --git a/packages/charts-core/src/dom-text.ts b/packages/charts-core/src/dom-text.ts index ccfb6660..f9cd2c64 100644 --- a/packages/charts-core/src/dom-text.ts +++ b/packages/charts-core/src/dom-text.ts @@ -1,3 +1,4 @@ +import { finitePositive } from './number-internal' import { estimateSceneText, logicalTextAnchorOffset } from './guide-layout' import type { ChartTextMeasurer, @@ -91,7 +92,7 @@ function configureContext( defaultWeight: string, options: ChartTextMeasureOptions, ): void { - const fontScale = positiveFinite(options.fontScale, 1) + const fontScale = finitePositive(options.fontScale, 1) const fontSize = options.fontSize * fontScale const weight = options.fontWeight ?? defaultWeight context.font = [ @@ -116,7 +117,7 @@ function paintedBounds( measured: TextMetrics, options: ChartTextMeasureOptions, ): ChartTextMetrics { - const fontSize = options.fontSize * positiveFinite(options.fontScale, 1) + const fontSize = options.fontSize * finitePositive(options.fontScale, 1) const left = measured.actualBoundingBoxLeft const right = measured.actualBoundingBoxRight const ascent = measured.actualBoundingBoxAscent @@ -189,9 +190,3 @@ function finiteCssPixels(value: string | undefined): number { const parsed = Number.parseFloat(value ?? '') return Number.isFinite(parsed) ? parsed : 0 } - -function positiveFinite(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) && value > 0 - ? value - : fallback -} diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 205f8994..43d71a10 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -1,3 +1,8 @@ +import { + finiteNumber, + finitePositive, + finiteNonNegative, +} from './number-internal' import type { ChartBounds, ChartMargin, @@ -153,8 +158,8 @@ export function resolveGuideMargins( let bottom = inset let left = inset - visitLabels(axes, 0, 0, (label, translateX, translateY) => { - if (!label.text) return + visitGuideNodes(axes, 0, 0, (label, translateX, translateY) => { + if (label.kind !== 'label' || !label.text) return const bounds = measureSceneLabelBounds(label, measureText) const boundsLeft = bounds.x + translateX @@ -181,7 +186,9 @@ export function includeGuideStrokeMargins( guides: SceneGroup, plot: ChartBounds, ): void { - visitRules(guides, 0, 0, (rule, translateX, translateY) => { + visitGuideNodes(guides, 0, 0, (rule, translateX, translateY) => { + if (rule.kind !== 'rule') return + const style = rule.style const extendsGeometry = style?.strokeWidth !== undefined || @@ -214,37 +221,17 @@ export function includeGuideStrokeMargins( }) } -function visitLabels( - node: SceneNode, - translateX: number, - translateY: number, - visit: (label: SceneLabel, translateX: number, translateY: number) => void, -): void { - if (node.kind === 'label') { - visit(node, translateX, translateY) - return - } - - if (node.kind !== 'group') return - - const childTranslateX = translateX + (node.translateX ?? 0) - const childTranslateY = translateY + (node.translateY ?? 0) - for (const child of node.children) { - visitLabels(child, childTranslateX, childTranslateY, visit) - } -} - -function visitRules( +function visitGuideNodes( node: SceneNode, translateX: number, translateY: number, visit: ( - rule: Extract, + node: Extract, translateX: number, translateY: number, ) => void, ): void { - if (node.kind === 'rule') { + if (node.kind === 'label' || node.kind === 'rule') { visit(node, translateX, translateY) return } @@ -254,7 +241,7 @@ function visitRules( const childTranslateX = translateX + (node.translateX ?? 0) const childTranslateY = translateY + (node.translateY ?? 0) for (const child of node.children) { - visitRules(child, childTranslateX, childTranslateY, visit) + visitGuideNodes(child, childTranslateX, childTranslateY, visit) } } @@ -293,22 +280,3 @@ function estimateCharacterWidth(character: string): number { if (character.codePointAt(0)! > 0x7f) return 1 return 0.54 } - -function finiteNonNegative( - value: number | undefined, - fallback: number, -): number { - return value !== undefined && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - -function finiteNumber(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) ? value : fallback -} - -function finitePositive(value: number | undefined, fallback: number): number { - return value !== undefined && Number.isFinite(value) && value > 0 - ? value - : fallback -} diff --git a/packages/charts-core/src/interaction.ts b/packages/charts-core/src/interaction.ts index a350d0c4..a08ac20c 100644 --- a/packages/charts-core/src/interaction.ts +++ b/packages/charts-core/src/interaction.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import { focusNearestX, focusNearestY, focusGroupX, focusGroupY } from './focus' import { findContainingScenePoint } from './nearest' import type { @@ -250,9 +251,3 @@ function compareNavigationPoints< ) { return left.x - right.x || left.y - right.y || leftIndex - rightIndex } - -function sameChartValue(left: ChartValue, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} diff --git a/packages/charts-core/src/legend-static.ts b/packages/charts-core/src/legend-static.ts index 2ade3fe1..563aa659 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -1,3 +1,4 @@ +import { clampNonnegativeNumber } from './number-internal' import { estimateSceneText, physicalTextAnchor, @@ -382,14 +383,14 @@ function resolveCategoricalLegendPresentation( ): CategoricalLegendPresentation { const labelOptions = options.label const indicatorOptions = options.indicator - const fontSize = finiteNonnegative(labelOptions?.fontSize, 11) + const fontSize = clampNonnegativeNumber(labelOptions?.fontSize, 11) const fontWeight = Number.isFinite(labelOptions?.fontWeight) ? labelOptions?.fontWeight : undefined - const indicatorWidth = finiteNonnegative(indicatorOptions?.width, 8) - const indicatorHeight = finiteNonnegative(indicatorOptions?.height, 8) - const indicatorGap = finiteNonnegative(indicatorOptions?.gap, 5) - const rowGap = finiteNonnegative(options.rowGap, 8) + const indicatorWidth = clampNonnegativeNumber(indicatorOptions?.width, 8) + const indicatorHeight = clampNonnegativeNumber(indicatorOptions?.height, 8) + const indicatorGap = clampNonnegativeNumber(indicatorOptions?.gap, 5) + const rowGap = clampNonnegativeNumber(options.rowGap, 8) const resolvedItems = resolveCategoricalLegendItems( context.colors, labelOptions?.format, @@ -457,7 +458,7 @@ function resolveCategoricalLegendPresentation( const layout = layoutCategoricalLegendFlow( items.map((item) => item.width), context.bounds.width, - finiteNonnegative(options.gap, 16), + clampNonnegativeNumber(options.gap, 16), justify, ) return { @@ -559,12 +560,6 @@ function resolveItemValue( : (input ?? fallback) } -function finiteNonnegative(value: number | undefined, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) - ? Math.max(0, value) - : fallback -} - function validTextMetrics(metrics: ChartTextMetrics) { return ( Number.isFinite(metrics.x) && diff --git a/packages/charts-core/src/mark-with-scale-values.ts b/packages/charts-core/src/mark-with-scale-values.ts index 17a7a488..75f5924a 100644 --- a/packages/charts-core/src/mark-with-scale-values.ts +++ b/packages/charts-core/src/mark-with-scale-values.ts @@ -6,7 +6,7 @@ import type { MarkInitialization, MarkInitializeContext, } from './types' -import { applyMarkRenderer, normalizeMarkInitialization } from './mark' +import { createMarkDefinition } from './mark' export type { ChartMarkPointX, @@ -42,19 +42,5 @@ export function createMarkWithScaleValues< TXScaleId, TYScaleId > { - const normalizedInitialize = (context: MarkInitializeContext) => { - const initialized = normalizeMarkInitialization(initialize(context)) - const withMotion = - motion === undefined || initialized.motion !== undefined - ? initialized - : { ...initialized, motion } - return renderer === undefined - ? withMotion - : applyMarkRenderer(withMotion, renderer) - } - return { - initialize: normalizedInitialize, - ...(motion === undefined ? {} : { motion }), - ...(renderer === undefined ? {} : { renderer }), - } + return createMarkDefinition(initialize, motion, renderer) } diff --git a/packages/charts-core/src/mark.ts b/packages/charts-core/src/mark.ts index d7b0e220..7a2da983 100644 --- a/packages/charts-core/src/mark.ts +++ b/packages/charts-core/src/mark.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from './number-internal' import { isChartKey, valueKey } from './scales' import type { Channel, @@ -19,7 +20,7 @@ declare const process: { env: { NODE_ENV?: string } } | undefined const warnedKeyFallbacks = new WeakSet() -export { isChartKey } +export { isChartKey, isFiniteNumber } export function isChartValue(value: unknown): value is ChartValue { return ( @@ -29,10 +30,6 @@ export function isChartValue(value: unknown): value is ChartValue { ) } -export function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - export function isNonnegativeFiniteNumber(value: unknown): value is number { return isFiniteNumber(value) && value >= 0 } @@ -50,6 +47,20 @@ export function createMark< motion?: ChartMotionDefinition, renderer?: ChartMarkRenderer, ): ChartMark { + return createMarkDefinition(initialize, motion, renderer) +} + +export function createMarkDefinition< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + initialize: ( + context: MarkInitializeContext, + ) => MarkInitialization, + motion?: ChartMotionDefinition, + renderer?: ChartMarkRenderer, +) { const normalizedInitialize = (context: MarkInitializeContext) => { const initialized = normalizeMarkInitialization(initialize(context)) const withMotion = diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index 7b58aa04..0d677aed 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1,7 +1,12 @@ +import { clampNonnegativeNumber } from './number-internal' import { focusedNodeKeys, resolveFocusScene } from './focus-layer' import { resolveFocusGuides } from './focus-presentation' import { resolveMarkStateScene } from './mark-state' -import { reconcileChartSvg, reconcileChartSvgFragment } from './reconcile' +import { + reconcileSvgMarkup, + reconcileSvgFragment, + reconcileElement, +} from './reconcile-internal' import { chartSceneSource } from './scene-source' import { sceneMotionNode, @@ -903,7 +908,7 @@ function createMotionSvgChartRenderer< false) const markup = renderSvg(presented.scene, renderOptions) cancelAnimation = reduced - ? reconcileChartSvg(container, markup) + ? reconcileSvgMarkup(container, markup) : motion.animateSvg({ container, scene: presented.scene as ChartScene, @@ -995,7 +1000,7 @@ function createMotionSvgChartRenderer< dataMotionActive = animate && !viewportMoved pendingStateFocus = dataMotionActive ? desiredStateFocus : undefined if (animate && !viewportMoved) { - if (initial) reconcileChartSvg(container, markup) + if (initial) reconcileSvgMarkup(container, markup) cancelAnimation = motion.animateSvg({ container, scene: nextScene as ChartScene, @@ -1018,7 +1023,7 @@ function createMotionSvgChartRenderer< }, }) } else { - reconcileChartSvg(container, markup) + reconcileSvgMarkup(container, markup) publishPresentationPoints(nextScene.points) dataMotionActive = false } @@ -1185,7 +1190,7 @@ function paintMotionSvgFocusGuides(options: { const markup = renderFocusGuideLayer(nodes, placement, idPrefix) if (reduced || !visible.has(placement)) { - reconcileChartSvgFragment(layer, markup) + reconcileSvgFragment(layer, markup) } else { cancellations.push( motion.animateSvgFragment({ @@ -1229,12 +1234,10 @@ function createBarTracks( groups.forEach((group, seriesIndex) => { const horizontal = group.classList.contains('ts-chart__bar-x') const shapes = barShapeElements(group) - const seriesKey = - group.getAttribute('data-ts-key') ?? `series:${seriesIndex}` + const seriesKey = elementKey(group) ?? `series:${seriesIndex}` shapes.forEach((shape, datumIndex) => { - const key = - shape.getAttribute('data-ts-key') ?? `${seriesKey}:${datumIndex}` + const key = elementKey(shape) ?? `${seriesKey}:${datumIndex}` const point = points.get(key) const geometry = barShapeGeometry(shape, scene) if (!geometry) return @@ -1247,68 +1250,78 @@ function createBarTracks( horizontal, horizontal ? targetX : targetY + targetHeight, ) - const timing = timingFor({ - phase: 'enter', - role: 'bar', - key, - markId: point?.markId ?? motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex, - datumCount: shapes.length, - datum: point?.datum, - point, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role: 'bar', + key, + markId: point?.markId ?? motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + datumIndex, + datumCount: shapes.length, + point, + }), + ) - shape.dataset.tsMotionRole = 'bar' - if (shape.localName === 'path' && geometry.cornerRadii) { - const to = barPathGeometryValues(geometry) - const from = barPathEntranceValues(to, horizontal, baseline) - const states = elementValueStates(runtime, shape, 'bar-geometry', from) - const apply = (values: readonly number[]) => { - applyBarPathGeometry(shape, values) - } - const finish = () => { - finishBarShapeGeometry(shape, geometry) - delete shape.dataset.tsMotionRole - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish, - cancel: () => delete shape.dataset.tsMotionRole, - }) - return - } - const names = horizontal ? ['x', 'width'] : ['y', 'height'] - const from = [baseline, 0] - const to = horizontal ? [targetX, targetWidth] : [targetY, targetHeight] - const states = names.flatMap((name, index) => - elementValueStates(runtime, shape, name, [from[index] ?? 0]), + tracks.push( + createBarEntranceTrack( + shape, + geometry, + horizontal, + baseline, + timing, + runtime, + ), ) - const apply = (values: readonly number[]) => { - applyBarShapeGeometry(shape, geometry, horizontal, values) - } - const finish = () => { - finishBarShapeGeometry(shape, geometry) - delete shape.dataset.tsMotionRole - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish, - cancel: () => delete shape.dataset.tsMotionRole, - }) }) }) return tracks } +function createBarEntranceTrack( + element: BarShapeElement, + geometry: BarShapeGeometry, + horizontal: boolean, + baseline: number, + timing: ResolvedTiming, + runtime: MotionRuntime, +): MotionTrack { + setMotionRole(element, 'bar') + const pathGeometry = + element.localName === 'path' && geometry.cornerRadii + ? barPathGeometryValues(geometry) + : undefined + const to = + pathGeometry ?? + (horizontal ? [geometry.x, geometry.width] : [geometry.y, geometry.height]) + const from = pathGeometry + ? barPathEntranceValues(pathGeometry, horizontal, baseline) + : [baseline, 0] + const states = pathGeometry + ? elementValueStates(runtime, element, 'bar-geometry', from) + : (horizontal ? ['x', 'width'] : ['y', 'height']).flatMap((name, index) => + elementValueStates(runtime, element, name, [from[index] ?? 0]), + ) + const apply = pathGeometry + ? (values: readonly number[]) => applyBarPathGeometry(element, values) + : (values: readonly number[]) => + applyBarShapeGeometry(element, geometry, horizontal, values) + apply(from) + + return { + ...timing, + values: bindMotionValues(states, from, to), + apply, + finish() { + finishBarShapeGeometry(element, geometry) + clearMotionRole(element) + }, + cancel: () => clearMotionRole(element), + } +} + type BarShapeElement = SVGRectElement | SVGPathElement interface BarShapeGeometry { @@ -1408,7 +1421,7 @@ function barShapeGeometry( height: numberAttribute(element, 'height'), } } - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) const node = key ? sceneNodeContext(scene, key)?.node : undefined if (node?.kind === 'rect' && node.cornerRadii) { return { @@ -1490,47 +1503,24 @@ function createCartesianPathTracks( const role: ChartMotionRole = group.classList.contains('ts-chart__area') ? 'area' : 'line' - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` - const timing = timingFor({ - phase: 'enter', - role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) const horizontal = scenePathAffinity(scene, seriesKey) === 'y' const baseline = resolvePathBaseline(scene, horizontal) - const previousTransform = group.getAttribute('transform') - group.dataset.tsMotionRole = role - const apply = (values: readonly number[]) => { - const progress = values[0] ?? 0 - const transform = horizontal + return createTransformEntranceTrack(group, role, timing, (progress) => + horizontal ? `matrix(${formatNumber(progress)} 0 0 1 ${formatNumber(baseline * (1 - progress))} 0)` - : `matrix(1 0 0 ${formatNumber(progress)} 0 ${formatNumber(baseline * (1 - progress))})` - group.setAttribute( - 'transform', - previousTransform ? `${previousTransform} ${transform}` : transform, - ) - } - const cleanup = () => { - if (previousTransform === null) group.removeAttribute('transform') - else group.setAttribute('transform', previousTransform) - delete group.dataset.tsMotionRole - } - apply([0]) - return { - ...timing, - values: bindMotionValues(undefined, [0], [1]), - apply, - finish: cleanup, - cancel: cleanup, - } + : `matrix(1 0 0 ${formatNumber(progress)} 0 ${formatNumber(baseline * (1 - progress))})`, + ) }) } @@ -1552,46 +1542,56 @@ function createRadialPathTracks( : group.classList.contains('ts-chart__radial-dot') ? 'dot' : 'line' - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` - const timing = timingFor({ - phase: 'enter', + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) + return createTransformEntranceTrack( + group, role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) - const previousTransform = group.getAttribute('transform') - group.dataset.tsMotionRole = role - const apply = (values: readonly number[]) => { - const progress = values[0] ?? 0 - const transform = `scale(${formatNumber(progress)})` - group.setAttribute( - 'transform', - previousTransform ? `${previousTransform} ${transform}` : transform, - ) - } - const cleanup = () => { - if (previousTransform === null) group.removeAttribute('transform') - else group.setAttribute('transform', previousTransform) - delete group.dataset.tsMotionRole - } - apply([0]) - return { - ...timing, - values: bindMotionValues(undefined, [0], [1]), - apply, - finish: cleanup, - cancel: cleanup, - } + timing, + (progress) => `scale(${formatNumber(progress)})`, + ) }) } +function createTransformEntranceTrack( + group: SVGGElement, + role: ChartMotionRole, + timing: ResolvedTiming, + transformAt: (progress: number) => string, +): MotionTrack { + const previousTransform = group.getAttribute('transform') + setMotionRole(group, role) + const apply = (values: readonly number[]) => { + const transform = transformAt(values[0] ?? 0) + group.setAttribute( + 'transform', + previousTransform ? `${previousTransform} ${transform}` : transform, + ) + } + const cleanup = () => { + restoreAttribute(group, 'transform', previousTransform) + clearMotionRole(group) + } + apply([0]) + + return { + ...timing, + values: bindMotionValues(undefined, [0], [1]), + apply, + finish: cleanup, + cancel: cleanup, + } +} + function createArcTracks( root: SVGSVGElement, scene: ChartScene, @@ -1599,24 +1599,22 @@ function createArcTracks( ): MotionTrack[] { const groups = [...root.querySelectorAll('g.ts-chart__arc')] return groups.flatMap((group, seriesIndex) => { - const seriesKey = group.getAttribute('data-ts-key') ?? `arc:${seriesIndex}` + const seriesKey = elementKey(group) ?? `arc:${seriesIndex}` const geometry = sceneArcGeometry(scene, seriesKey) if (!geometry) return [] const role: ChartMotionRole = group.classList.contains('ts-chart__bar') ? 'bar' : 'arc' - const timing = timingFor({ - phase: 'enter', - role, - key: seriesKey, - markId: motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: 'enter', + role, + key: seriesKey, + markId: motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex, + }), + ) const document = root.ownerDocument let definitions = root.querySelector('defs') if (!definitions) { @@ -1638,7 +1636,7 @@ function createArcTracks( definitions.append(clip) const previousClip = group.getAttribute('clip-path') group.setAttribute('clip-path', `url(#${id})`) - group.dataset.tsMotionRole = role + setMotionRole(group, role) const apply = (values: readonly number[]) => { const progress = Math.max(0, Math.min(1, values[0] ?? 0)) path.setAttribute( @@ -1651,9 +1649,8 @@ function createArcTracks( ) } const cleanup = () => { - if (previousClip === null) group.removeAttribute('clip-path') - else group.setAttribute('clip-path', previousClip) - delete group.dataset.tsMotionRole + restoreAttribute(group, 'clip-path', previousClip) + clearMotionRole(group) clip.remove() if ( definitions?.dataset.tsMotionDefs !== undefined && @@ -1954,13 +1951,11 @@ function motionPointScaleId( ): string | undefined { if (!point) return undefined const initialized = motionSceneSource(scene)?.[1] - const mark = initialized - ?.filter( - (candidate) => - point.markId === candidate.id || - point.markId.startsWith(`${candidate.id}:`), - ) - .sort((left, right) => right.id.length - left.id.length)[0] + const mark = findLongestKeyPrefix( + initialized ?? [], + point.markId, + (mark) => mark.id, + ) return mark?.channels[channel]?.scale } @@ -2085,51 +2080,12 @@ function reconcileMotionElement( tracks: MotionTrack[], context: MotionReconcileContext, ) { - addUpdateTrack(current, next, tracks, context) - - if (!next.firstElementChild) { - if (current.firstElementChild) { - for (const child of [...current.children]) { - addExitMotionTrack(child, tracks, context) - } - } else if (current.textContent !== next.textContent) { - current.textContent = next.textContent - } - return - } - - const currentChildren = [...current.children] - const nextChildren = [...next.children] - const currentByIdentity = indexMotionChildren(currentChildren) - const nextIdentities = motionIdentities(nextChildren) - const retained = new Set() - let cursor = current.firstElementChild - - nextChildren.forEach((nextChild, index) => { - const matched = currentByIdentity.get(nextIdentities[index]) - let rendered: Element - if ( - matched && - matched.namespaceURI === nextChild.namespaceURI && - matched.localName === nextChild.localName - ) { - rendered = matched - retained.add(matched) - if (rendered !== cursor) current.insertBefore(rendered, cursor) - reconcileMotionElement(rendered, nextChild, tracks, context) - } else { - rendered = nextChild.cloneNode(true) as Element - current.insertBefore(rendered, cursor) - addEnterMotionTrack(rendered, tracks, context) - } - cursor = rendered.nextElementSibling + reconcileElement(current, next, { + update: (current, next) => addUpdateTrack(current, next, tracks, context), + enter: (element) => addEnterMotionTrack(element, tracks, context), + exit: (element) => addExitMotionTrack(element, tracks, context), + replaceDefinitions: false, }) - - for (const child of currentChildren) { - if (!retained.has(child) && child.parentElement === current) { - addExitMotionTrack(child, tracks, context) - } - } } function addUpdateTrack( @@ -2140,7 +2096,7 @@ function addUpdateTrack( ) { let timingContext = elementTimingContext(current, 'update', context.scene) let timing: ResolvedTiming | undefined - const pathKey = current.getAttribute('data-ts-key') + const pathKey = elementKey(current) const rolling = pathKey ? context.pathPlans.elements.get(pathKey) : undefined const rollingTransform = rolling?.outcome.kind === 'transform' @@ -2206,7 +2162,7 @@ function addUpdateTrack( } if (rollingTransform && timingContext && rolling) { - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) const apply = (values: readonly number[]) => { current.setAttribute( 'transform', @@ -2225,10 +2181,10 @@ function addUpdateTrack( apply, finish() { current.removeAttribute('transform') - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) } @@ -2244,7 +2200,7 @@ function addUpdateTrack( pointRolling?.outcome.kind === 'transform' ? pointRolling.timing : context.timingFor(timingContext) - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) const states = attributes.flatMap((attribute) => elementValueStates( context.runtime, @@ -2274,10 +2230,10 @@ function addUpdateTrack( }, finish() { finishMotionAttributes(current, attributes) - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) } @@ -2297,9 +2253,9 @@ function addBarPathUpdateTrack( ) { return false } - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) const targetPath = next.getAttribute('d') - if (!key || next.getAttribute('data-ts-key') !== key || !targetPath) { + if (!key || elementKey(next) !== key || !targetPath) { return false } const previous = sceneNodeContext(context.previousScene, key)?.node @@ -2327,7 +2283,7 @@ function addBarPathUpdateTrack( state.velocity === 0, ) if (current.getAttribute('d') === targetPath && settledAtTarget) return false - current.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(current, timingContext.role) tracks.push({ ...context.timingFor(timingContext), values: bindMotionValues(states, sourceValues, targetValues), @@ -2336,10 +2292,10 @@ function addBarPathUpdateTrack( }, finish() { current.setAttribute('d', targetPath) - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }) return true @@ -2353,7 +2309,7 @@ function addSemanticPathUpdateTrack( timingContext: ChartMotionContext | undefined, ) { if (current.localName !== 'path') return false - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) const targetPath = next.getAttribute('d') if (!key || !targetPath || !context.previousScene) return false const previous = sceneMotionEntry(context.previousScene, key)?.metadata.path @@ -2368,7 +2324,7 @@ function addSemanticPathUpdateTrack( geometry.source, context.runtime, ) - current.setAttribute('data-ts-motion-role', resolvedContext.role) + setMotionRole(current, resolvedContext.role) tracks.push( semanticPathTrack({ element: current, @@ -2378,10 +2334,10 @@ function addSemanticPathUpdateTrack( timing: context.timingFor(resolvedContext), runtime: context.runtime, finish() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, cancel() { - current.removeAttribute('data-ts-motion-role') + clearMotionRole(current) }, }), ) @@ -2442,7 +2398,7 @@ function addEnterMotionTrack( const timingContext = elementTimingContext(element, 'enter', context.scene) if (!timingContext) return const timing = context.timingFor(timingContext) - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) const pointRolling = timingContext.point ? context.pathPlans.points.get(pointIdentity(timingContext.point)) @@ -2450,7 +2406,7 @@ function addEnterMotionTrack( if (element.localName === 'circle' && pointRolling) { if (pointRolling.outcome.kind === 'fallback') { if (pointRolling.outcome.fallback === 'snap') { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) return } } else { @@ -2470,10 +2426,10 @@ function addEnterMotionTrack( apply, finish() { apply(to) - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, cancel() { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, }) return @@ -2488,7 +2444,7 @@ function addEnterMotionTrack( const horizontal = Boolean(element.closest('g.ts-chart__bar-x')) const geometry = barShapeGeometry(element, context.scene) if (!geometry) { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) return } const { x: targetX, y: targetY } = geometry @@ -2500,55 +2456,16 @@ function addEnterMotionTrack( horizontal, horizontal ? targetX : targetY + targetHeight, ) - if (element.localName === 'path' && geometry.cornerRadii) { - const to = barPathGeometryValues(geometry) - const from = barPathEntranceValues(to, horizontal, baseline) - const states = elementValueStates( - context.runtime, + tracks.push( + createBarEntranceTrack( element, - 'bar-geometry', - from, - ) - const apply = (values: readonly number[]) => { - applyBarPathGeometry(element, values) - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish() { - finishBarShapeGeometry(element, geometry) - element.removeAttribute('data-ts-motion-role') - }, - cancel() { - element.removeAttribute('data-ts-motion-role') - }, - }) - return - } - const names = horizontal ? ['x', 'width'] : ['y', 'height'] - const from = [baseline, 0] - const to = horizontal ? [targetX, targetWidth] : [targetY, targetHeight] - const states = names.flatMap((name, index) => - elementValueStates(context.runtime, element, name, [from[index] ?? 0]), + geometry, + horizontal, + baseline, + timing, + context.runtime, + ), ) - const apply = (values: readonly number[]) => { - applyBarShapeGeometry(element, geometry, horizontal, values) - } - apply(from) - tracks.push({ - ...timing, - values: bindMotionValues(states, from, to), - apply, - finish() { - finishBarShapeGeometry(element, geometry) - element.removeAttribute('data-ts-motion-role') - }, - cancel() { - element.removeAttribute('data-ts-motion-role') - }, - }) return } @@ -2591,12 +2508,11 @@ function addEnterMotionTrack( element.setAttribute('opacity', formatNumber(values[0] ?? 0)) }, finish() { - if (targetOpacity === null) element.removeAttribute('opacity') - else element.setAttribute('opacity', targetOpacity) - element.removeAttribute('data-ts-motion-role') + restoreAttribute(element, 'opacity', targetOpacity) + clearMotionRole(element) }, cancel() { - element.removeAttribute('data-ts-motion-role') + clearMotionRole(element) }, }) } @@ -2647,7 +2563,7 @@ function addExitMotionTrack( element.setAttribute('cx', formatNumber(values[0] ?? targetX)) element.setAttribute('cy', formatNumber(values[1] ?? targetY)) } - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) tracks.push({ ...pointRolling.timing, values: bindMotionValues(undefined, [startX, startY], [targetX, targetY]), @@ -2689,7 +2605,7 @@ function addExitMotionTrack( } const target = Number(element.getAttribute('opacity') ?? 1) const opacity = Number.isFinite(target) ? target : 1 - element.setAttribute('data-ts-motion-role', timingContext.role) + setMotionRole(element, timingContext.role) const states = elementValueStates(context.runtime, element, 'opacity', [ opacity, ]) @@ -2737,7 +2653,7 @@ function hierarchyMotionRelation( if (element.localName !== 'path' || !ownerScene || !relatedScene) { return undefined } - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) const ownerPath = element.getAttribute('d') if (!key || !ownerPath) return undefined const owner = sceneMotionEntry(ownerScene, key) @@ -2758,8 +2674,7 @@ function hierarchyMotionRelation( const root = element.closest('svg') const relatedElement = root ? [...root.querySelectorAll('path[data-ts-key]')].find( - (candidate) => - candidate.getAttribute('data-ts-key') === ancestor.node.key, + (candidate) => elementKey(candidate) === ancestor.node.key, ) : undefined const relatedPath = relatedElement?.getAttribute('d') @@ -2882,18 +2797,16 @@ function elementTimingContext( ] : [group] const seriesIndex = Math.max(0, groups.indexOf(group)) - const seriesKey = - group.getAttribute('data-ts-key') ?? `${role}:${seriesIndex}` + const seriesKey = elementKey(group) ?? `${role}:${seriesIndex}` const key = - element.getAttribute('data-ts-key') ?? - (role === 'line' ? seriesKey : `${seriesKey}:0`) + elementKey(element) ?? (role === 'line' ? seriesKey : `${seriesKey}:0`) const point = scene.points.find( (candidate) => candidate.key === key || key === `${candidate.key}:dot`, ) const shapes = barGroup ? barShapeElements(barGroup) : [] const datumIndex = point?.datumIndex ?? Math.max(0, shapes.indexOf(element as BarShapeElement)) - return { + return createMotionContext({ phase, role, key, @@ -2902,9 +2815,8 @@ function elementTimingContext( seriesIndex, datumIndex, datumCount: barGroup ? Math.max(1, shapes.length) : 1, - datum: point?.datum, point, - } + }) } function guideOrMarkTimingContext( @@ -2912,25 +2824,20 @@ function guideOrMarkTimingContext( phase: ChartMotionPhase, scene: ChartScene, ): ChartMotionContext | undefined { - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) if (!key) return undefined const focusGuide = element.closest('g.ts-chart__crosshair') if (focusGuide) { - const ownerKey = focusGuide.getAttribute('data-ts-key') ?? key + const ownerKey = elementKey(focusGuide) ?? key const markId = motionMarkId(scene, ownerKey) - return { + return createMotionContext({ phase, role: markMotionRole(focusGuide, element), key, markId, seriesKey: ownerKey, - seriesIndex: 0, - datumIndex: 0, - datumCount: 1, - datum: undefined, - point: undefined, - } + }) } const presentationFocusLayer = element.closest( @@ -2950,18 +2857,16 @@ function guideOrMarkTimingContext( const markPoints = focusPoints.filter( (candidate) => candidate.markId === point.markId, ) - return { + return createMotionContext({ phase, role: markMotionRole(presentationFocusLayer, element), key, markId: point.markId, seriesKey: `${point.markId}:${motionGroupIdentity(point)}`, - seriesIndex: 0, datumIndex: point.datumIndex, datumCount: Math.max(1, markPoints.length), - datum: point.datum, point, - } + }) } const axes = element.closest('g.ts-chart__axes') @@ -2991,11 +2896,10 @@ function guideOrMarkTimingContext( : key const peers = parent ? [...parent.querySelectorAll('[data-ts-key]')].filter( - (candidate) => - candidate.getAttribute('data-ts-key')?.startsWith(prefix) ?? false, + (candidate) => elementKey(candidate)?.startsWith(prefix) ?? false, ) : [element] - return { + return createMotionContext({ phase, role, key, @@ -3005,9 +2909,7 @@ function guideOrMarkTimingContext( seriesIndex: Math.max(0, Object.keys(scene.scales).indexOf(scaleId)), datumIndex: Math.max(0, peers.indexOf(element)), datumCount: Math.max(1, peers.length), - datum: undefined, - point: undefined, - } + }) } const marks = element.closest('g.ts-chart__marks') @@ -3027,7 +2929,7 @@ function guideOrMarkTimingContext( while (owner.parentElement && owner.parentNode !== ownerParent) { owner = owner.parentElement } - const ownerKey = owner.getAttribute('data-ts-key') ?? key + const ownerKey = elementKey(owner) ?? key const markId = point?.markId ?? focusContext?.markId ?? motionMarkId(scene, ownerKey) const role = markMotionRole(owner, element) @@ -3039,18 +2941,16 @@ function guideOrMarkTimingContext( const seriesKey = point ? `${point.markId}:${String(point.group ?? '')}` : ownerKey - return { + return createMotionContext({ phase, role, key, markId, seriesKey, - seriesIndex: 0, datumIndex: point?.datumIndex ?? 0, datumCount: Math.max(1, markPoints.length), - datum: point?.datum, point, - } + }) } function guideScaleId(scene: ChartScene, key: string): string | undefined { @@ -3086,7 +2986,7 @@ function retargetFocusContext( point: ChartPoint | undefined } | undefined { - const layerKey = focusLayer.getAttribute('data-ts-key') + const layerKey = elementKey(focusLayer) if (!layerKey) return undefined const layer = findSceneGroup(scene.nodes, layerKey) if (!layer?.focus?.retarget) return undefined @@ -3103,7 +3003,7 @@ function retargetFocusContext( let slot: number | undefined let selectionKeySeen = false while (current && current !== focusLayer) { - const key = current.getAttribute('data-ts-key') + const key = elementKey(current) if (key?.startsWith(prefix)) { selectionKeySeen = true const encoded = key.slice(prefix.length).split(':')[0] ?? '' @@ -3162,19 +3062,30 @@ function motionPointForKey( points: readonly ChartPoint[], key: string, ): ChartPoint | undefined { - let match: ChartPoint | undefined - for (const point of points) { - if ( - key !== point.key && - key !== `${point.key}:dot` && - !key.startsWith(`${point.key}:`) - ) { - continue - } - if (!match || point.key.length > match.key.length) match = point - } - return match -} + return findLongestKeyPrefix(points, key, (point) => point.key) +} + +const motionClassRoles = [ + 'area', + 'radial-area', + 'bar', + 'arc', + 'arrow', + 'band', + 'dot', + 'facet', + 'frame', + 'geo', + 'hexagon', + 'line', + 'link', + 'text', + 'rect', + 'waffle', + 'rule', + 'tick', + 'vector', +] as const function markMotionRole(owner: Element, element: Element): ChartMotionRole { let className = '' @@ -3184,30 +3095,13 @@ function markMotionRole(owner: Element, element: Element): ChartMotionRole { if (current === owner) break current = current.parentElement } - if ( - className.includes('ts-chart__area') || - className.includes('ts-chart__radial-area') + // Preserve semantic precedence when a mark carries several geometry classes. + const role = motionClassRoles.find((role) => + className.includes(`ts-chart__${role}`), ) - return 'area' - // Radial bars also carry the arc geometry role for inspection. Keep the - // authored mark role semantic when both classes are present. - if (className.includes('ts-chart__bar')) return 'bar' - if (className.includes('ts-chart__arc')) return 'arc' - if (className.includes('ts-chart__arrow')) return 'arrow' - if (className.includes('ts-chart__band')) return 'band' - if (className.includes('ts-chart__dot')) return 'dot' - if (className.includes('ts-chart__facet')) return 'facet' - if (className.includes('ts-chart__frame')) return 'frame' - if (className.includes('ts-chart__geo')) return 'geo' - if (className.includes('ts-chart__hexagon')) return 'hexagon' - if (className.includes('ts-chart__line')) return 'line' - if (className.includes('ts-chart__link')) return 'link' - if (className.includes('ts-chart__text')) return 'text' - if (className.includes('ts-chart__rect')) return 'rect' - if (className.includes('ts-chart__waffle')) return 'rect' - if (className.includes('ts-chart__rule')) return 'rule' - if (className.includes('ts-chart__tick')) return 'tick' - if (className.includes('ts-chart__vector')) return 'vector' + if (role === 'radial-area') return 'area' + if (role === 'waffle') return 'rect' + if (role) return role if (element.localName === 'circle') return 'dot' if (element.localName === 'text') return 'text' if (element.localName === 'rect') return 'rect' @@ -3217,19 +3111,20 @@ function markMotionRole(owner: Element, element: Element): ChartMotionRole { } function motionMarkId(scene: ChartScene, key: string): string | undefined { - const focusGuide = scene.focusGuides - ?.slice() - .sort((left, right) => right.key.length - left.key.length) - .find((guide) => key === guide.key || key.startsWith(`${guide.key}:`)) + const focusGuide = findLongestKeyPrefix( + scene.focusGuides ?? [], + key, + (guide) => guide.key, + ) if (focusGuide) return focusGuide.markId + const source = motionSceneSource(scene) - const candidates = new Set([ + const candidates = [ ...scene.points.map((point) => point.markId), ...(source?.[1].map((mark) => mark.id) ?? []), - ]) - return [...candidates] - .sort((left, right) => right.length - left.length) - .find((candidate) => key === candidate || key.startsWith(`${candidate}:`)) + ] + + return findLongestKeyPrefix(candidates, key, (candidate) => candidate) } function createPresentationTracks( @@ -3351,18 +3246,19 @@ function createPresentationTracks( previous ?? (horizontal ? { ...point, x: baseline } : { ...point, y: baseline }) presented.set(identity, { ...point, x: start.x, y: start.y }) - const timing = timingFor({ - phase: defaultPhase === 'enter' ? 'enter' : phase, - role: 'bar', - key: point.key, - markId: point.markId, - seriesKey: point.markId, - seriesIndex: Math.max(0, series.indexOf(point.markId)), - datumIndex: point.datumIndex, - datumCount: counts.get(point.markId) ?? 1, - datum: point.datum, - point, - }) + const timing = timingFor( + createMotionContext({ + phase: defaultPhase === 'enter' ? 'enter' : phase, + role: 'bar', + key: point.key, + markId: point.markId, + seriesKey: point.markId, + seriesIndex: Math.max(0, series.indexOf(point.markId)), + datumIndex: point.datumIndex, + datumCount: counts.get(point.markId) ?? 1, + point, + }), + ) const states = pointValueStates(runtime, identity, [start.x, start.y]) tracks.push({ ...timing, @@ -3461,23 +3357,22 @@ function createPresentationTracks( ) const group = pathGroups.get(seriesKey) const role = group ? markMotionRole(group, group) : 'line' - const timing = timingFor({ - phase: - defaultPhase === 'enter' - ? 'enter' - : previous.some(Boolean) - ? 'update' - : 'enter', - role, - key: seriesKey, - markId: points[0]?.markId ?? motionMarkId(scene, seriesKey), - seriesKey, - seriesIndex: Math.max(0, series.indexOf(seriesKey)), - datumIndex: 0, - datumCount: points.length, - datum: undefined, - point: undefined, - }) + const timing = timingFor( + createMotionContext({ + phase: + defaultPhase === 'enter' + ? 'enter' + : previous.some(Boolean) + ? 'update' + : 'enter', + role, + key: seriesKey, + markId: points[0]?.markId ?? motionMarkId(scene, seriesKey), + seriesKey, + seriesIndex: Math.max(0, series.indexOf(seriesKey)), + datumCount: points.length, + }), + ) const from: number[] = [] const to: number[] = [] const states: MotionValueState[] = [] @@ -3538,18 +3433,18 @@ function createPresentationTracks( } } tracks.push({ - ...timingFor({ - phase: 'exit', - role, - key: point.key, - markId: point.markId, - seriesKey: point.markId, - seriesIndex: Math.max(0, series.indexOf(point.markId)), - datumIndex: point.datumIndex, - datumCount: 1, - datum: point.datum, - point, - }), + ...timingFor( + createMotionContext({ + phase: 'exit', + role, + key: point.key, + markId: point.markId, + seriesKey: point.markId, + seriesIndex: Math.max(0, series.indexOf(point.markId)), + datumIndex: point.datumIndex, + point, + }), + ), values: bindMotionValues(undefined, [0], [1]), apply() {}, finish: cleanup, @@ -3569,7 +3464,7 @@ function keyedElements(root: SVGSVGElement, selector: string) { function keyedElementMap(root: ParentNode, selector: string) { const result = new Map() for (const element of root.querySelectorAll(selector)) { - const key = element.getAttribute('data-ts-key') + const key = elementKey(element) if (key && !result.has(key)) result.set(key, element) } return result @@ -3695,26 +3590,6 @@ function bindMotionValues( }) } -function indexMotionChildren(children: readonly Element[]) { - const result = new Map() - motionIdentities(children).forEach((identity, index) => { - const child = children[index] - if (child) result.set(identity, child) - }) - return result -} - -function motionIdentities(children: readonly Element[]) { - const counts = new Map() - return children.map((child) => { - const key = child.getAttribute('data-ts-key') - if (key) return `key:${key}` - const count = counts.get(child.localName) ?? 0 - counts.set(child.localName, count + 1) - return `tag:${child.localName}:${count}` - }) -} - function resolveTiming( options: ResolvedMotionOptions, context: ChartMotionContext, @@ -3751,7 +3626,7 @@ function resolveTiming( ? authored.delay(context) : authored.delay if (authoredDelay !== undefined) { - delay = nonNegative(authoredDelay, delay) + delay = clampNonnegativeNumber(authoredDelay, delay) } if (authored.path !== undefined) path = authored.path transition = resolveTransition( @@ -3762,17 +3637,18 @@ function resolveTiming( ) } - apply(definitions?.default) - if (context.markId && definitions?.marks) { - const markId = Object.keys(definitions.marks) - .filter( - (candidate) => - context.markId === candidate || - context.markId?.startsWith(`${candidate}:`), - ) - .sort((left, right) => right.length - left.length)[0] - if (markId) apply(definitions.marks[markId]) + const applyMark = (marks: SceneMotionDefinitions['marks']) => { + if (!context.markId || !marks) return + const markId = findLongestKeyPrefix( + Object.keys(marks), + context.markId, + (key) => key, + ) + if (markId) apply(marks[markId]) } + + apply(definitions?.default) + applyMark(definitions?.marks) const guideId = context.scaleId ?? context.axis if (guideId) { apply(definitions?.guides?.[`axis:${guideId}`]) @@ -3781,16 +3657,7 @@ function resolveTiming( } } apply(overrides?.default) - if (context.markId && overrides?.marks) { - const markId = Object.keys(overrides.marks) - .filter( - (candidate) => - context.markId === candidate || - context.markId?.startsWith(`${candidate}:`), - ) - .sort((left, right) => right.length - left.length)[0] - if (markId) apply(overrides.marks[markId]) - } + applyMark(overrides?.marks) // A delayed physical retarget would freeze the sampled velocity. Spring // updates therefore begin immediately; use delay for enter/exit choreography. @@ -4069,7 +3936,7 @@ function resolveTransition( } return { type: 'tween', - duration: nonNegative( + duration: clampNonnegativeNumber( transition?.duration, fallback?.type === 'tween' ? fallback.duration : fallbackDuration, ), @@ -4130,10 +3997,62 @@ function numberAttribute(element: Element, name: string) { return Number.isFinite(value) ? value : 0 } -function nonNegative(value: number | undefined, fallback: number) { - return Number.isFinite(value) ? Math.max(0, value!) : fallback -} - function formatNumber(value: number) { return String(Math.round(value * 1_000) / 1_000) } + +function elementKey(element: Element) { + return element.getAttribute('data-ts-key') +} + +function setMotionRole(element: Element, role: ChartMotionRole) { + element.setAttribute('data-ts-motion-role', role) +} + +function clearMotionRole(element: Element) { + element.removeAttribute('data-ts-motion-role') +} + +function restoreAttribute( + element: Element, + name: string, + value: string | null, +) { + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) +} + +function createMotionContext( + context: Pick & + Partial, +): ChartMotionContext { + return { + seriesIndex: 0, + datumIndex: 0, + datumCount: 1, + datum: context.point?.datum, + point: undefined, + ...context, + } +} + +function findLongestKeyPrefix( + values: Iterable, + key: string, + getKey: (value: T) => string, +): T | undefined { + let result: T | undefined + let length = -1 + for (const value of values) { + const candidate = getKey(value) + if ( + candidate.length > length && + (key === candidate || key.startsWith(`${candidate}:`)) + ) { + result = value + length = candidate.length + } + } + + return result +} diff --git a/packages/charts-core/src/nearest.ts b/packages/charts-core/src/nearest.ts index f266cbe8..d86e9da7 100644 --- a/packages/charts-core/src/nearest.ts +++ b/packages/charts-core/src/nearest.ts @@ -352,11 +352,10 @@ function distanceToTarget( 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 = squaredDistanceOutsideRadius( + dx * dx + dy * dy, + Math.max(0, node.radius), ) - distance = amount * amount break } case 'area': @@ -367,8 +366,7 @@ function distanceToTarget( break case 'polyline': { const raw = squaredDistanceToPolyline(node.points, localX, localY, false) - const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node)) - distance = amount * amount + distance = squaredDistanceOutsideRadius(raw, strokeRadius(node)) break } case 'rule': { @@ -380,8 +378,7 @@ function distanceToTarget( localX, localY, ) - const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node)) - distance = amount * amount + distance = squaredDistanceOutsideRadius(raw, strokeRadius(node)) break } } @@ -464,9 +461,11 @@ function squaredDistanceToRoundedRect( const radius = Math.max(0, Math.min(node.radius ?? 0, halfWidth, halfHeight)) const offsetX = Math.abs(x - (bounds.x + halfWidth)) - (halfWidth - radius) const offsetY = Math.abs(y - (bounds.y + halfHeight)) - (halfHeight - radius) - const outside = - Math.sqrt(Math.max(0, offsetX) ** 2 + Math.max(0, offsetY) ** 2) - radius - return Math.max(0, outside) ** 2 + + return squaredDistanceOutsideRadius( + Math.max(0, offsetX) ** 2 + Math.max(0, offsetY) ** 2, + radius, + ) } function containsPolygon( @@ -531,7 +530,7 @@ function squaredDistanceToPolyline( return (point[0] - x) ** 2 + (point[1] - y) ** 2 } let distance = Infinity - const segmentCount = closed ? points.length : Math.max(0, points.length - 1) + const segmentCount = closed ? points.length : points.length - 1 for (let index = 0; index < segmentCount; index += 1) { const start = points[index]! const end = points[(index + 1) % points.length]! @@ -639,7 +638,7 @@ function squaredAxisDistance( value: number, axis: 'x' | 'y', ) { - const start = axis === 'x' ? bounds.x : bounds.y + const start = bounds[axis] const size = axis === 'x' ? bounds.width : bounds.height const distance = value < start @@ -652,19 +651,16 @@ function squaredAxisDistance( function squaredDistanceToBounds(bounds: ChartBounds, x: number, y: number) { const normalized = normalizeRect(bounds) - const dx = - x < normalized.x - ? normalized.x - x - : x > normalized.x + normalized.width - ? x - normalized.x - normalized.width - : 0 - const dy = - y < normalized.y - ? normalized.y - y - : y > normalized.y + normalized.height - ? y - normalized.y - normalized.height - : 0 - return dx * dx + dy * dy + return ( + squaredAxisDistance(normalized, x, 'x') + + squaredAxisDistance(normalized, y, 'y') + ) +} + +function squaredDistanceOutsideRadius(distance: number, radius: number) { + const amount = Math.max(0, Math.sqrt(distance) - radius) + + return amount * amount } function strokeRadius(node: GeometricSceneNode) { diff --git a/packages/charts-core/src/number-internal.test.ts b/packages/charts-core/src/number-internal.test.ts new file mode 100644 index 00000000..ca1360eb --- /dev/null +++ b/packages/charts-core/src/number-internal.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + clampNonnegativeNumber, + isFiniteNumber, + finiteNumber, + finiteNonNegative, + finitePositive, +} from './number-internal' + +describe('numeric option policies', () => { + it('retains valid authored values', () => { + expect(isFiniteNumber(12)).toBe(true) + expect(finiteNumber(-12, 4)).toBe(-12) + expect(finitePositive(12, 4)).toBe(12) + expect(finiteNonNegative(0, 4)).toBe(0) + expect(clampNonnegativeNumber(12, 4)).toBe(12) + }) + + it('keeps rejection and clamping of negative values distinct', () => { + expect(finiteNonNegative(-12, 4)).toBe(4) + expect(clampNonnegativeNumber(-12, 4)).toBe(0) + expect(finitePositive(0, 4)).toBe(4) + expect(finiteNonNegative(-0, 4)).toBe(-0) + expect(clampNonnegativeNumber(-0, 4)).toBe(0) + }) + + it.each([undefined, Number.NaN, Infinity, -Infinity])( + 'uses the fallback for %s', + (value) => { + expect(isFiniteNumber(value)).toBe(false) + expect(finiteNumber(value, 4)).toBe(4) + expect(finitePositive(value, 4)).toBe(4) + expect(finiteNonNegative(value, 4)).toBe(4) + expect(clampNonnegativeNumber(value, 4)).toBe(4) + }, + ) + + it('does not coerce strings or boxed numbers', () => { + expect(isFiniteNumber('12')).toBe(false) + expect(isFiniteNumber(new Number(12))).toBe(false) + }) +}) diff --git a/packages/charts-core/src/number-internal.ts b/packages/charts-core/src/number-internal.ts new file mode 100644 index 00000000..7bcd5530 --- /dev/null +++ b/packages/charts-core/src/number-internal.ts @@ -0,0 +1,31 @@ +export function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function finiteNumber( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) ? value : fallback +} + +export function finitePositive( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) && value > 0 ? value : fallback +} + +export function finiteNonNegative( + value: number | undefined, + fallback: number, +): number { + return isFiniteNumber(value) && value >= 0 ? value : fallback +} + +export function clampNonnegativeNumber( + value: number | undefined, + fallback = 0, +): number { + return isFiniteNumber(value) ? Math.max(0, value) : fallback +} diff --git a/packages/charts-core/src/polar-pie.ts b/packages/charts-core/src/polar-pie.ts index 2f4911cd..2c2b09c2 100644 --- a/packages/charts-core/src/polar-pie.ts +++ b/packages/charts-core/src/polar-pie.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from './number-internal' import type { TransformLineage, TransformOrderOptions, @@ -160,10 +161,6 @@ function assertPieGapCapacity( } } -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - function assertFinite(value: number, name: string): void { if (!Number.isFinite(value)) { throw new TypeError(`pie: ${name} must be finite`) diff --git a/packages/charts-core/src/reconcile-internal.ts b/packages/charts-core/src/reconcile-internal.ts new file mode 100644 index 00000000..4ad80f57 --- /dev/null +++ b/packages/charts-core/src/reconcile-internal.ts @@ -0,0 +1,155 @@ +/** Optional animation callbacks around the shared keyed SVG tree walk. */ +export interface SvgReconcileHooks { + update: (current: Element, next: Element) => void + enter: (current: Element, next: Element) => void + exit: (current: Element) => void + /** Motion retains exiting resource nodes; ordinary reconciliation replaces them. */ + replaceDefinitions?: boolean +} + +export function reconcileSvgMarkup( + container: HTMLElement, + markup: string, + hooks?: SvgReconcileHooks, +): () => void { + const template = container.ownerDocument.createElement('template') + template.innerHTML = markup + const nextRoot = template.content.firstElementChild + if (!nextRoot) return () => {} + + const currentRoot = container.firstElementChild + if ( + !currentRoot || + currentRoot.namespaceURI !== nextRoot.namespaceURI || + currentRoot.localName !== nextRoot.localName + ) { + container.replaceChildren(nextRoot) + return () => {} + } + + reconcileElement(currentRoot, nextRoot, hooks) + return () => {} +} + +/** Reconciles one keyed SVG subtree without reparsing or walking the chart. */ +export function reconcileSvgFragment( + currentRoot: SVGElement, + markup: string, + hooks?: SvgReconcileHooks, +): () => void { + const template = currentRoot.ownerDocument.createElement('template') + template.innerHTML = `${markup}` + const wrapper = template.content.firstElementChild + const nextRoot = wrapper?.firstElementChild + if (!nextRoot) return () => {} + + if ( + currentRoot.namespaceURI !== nextRoot.namespaceURI || + currentRoot.localName !== nextRoot.localName + ) { + currentRoot.replaceWith(nextRoot) + return () => {} + } + + reconcileElement(currentRoot, nextRoot, hooks) + return () => {} +} + +export function reconcileElement( + current: Element, + next: Element, + hooks?: SvgReconcileHooks, +) { + if (hooks) hooks.update(current, next) + else syncAttributes(current, next) + + if (!next.firstElementChild) { + if (current.firstElementChild) { + for (const child of [...current.children]) { + if (hooks) hooks.exit(child) + else child.remove() + } + } else if (current.textContent !== next.textContent) { + current.textContent = next.textContent + } + return + } + + const currentChildren = [...current.children] + const nextChildren = [...next.children] + const currentByIdentity = indexChildren(currentChildren) + const nextIdentities = identities(nextChildren) + const retained = new Set() + let cursor = current.firstElementChild + + nextChildren.forEach((nextChild, index) => { + const identity = nextIdentities[index] + const matched = currentByIdentity.get(identity) + let rendered: Element + + if ( + matched && + matched.namespaceURI === nextChild.namespaceURI && + matched.localName === nextChild.localName + ) { + rendered = matched + retained.add(matched) + if (rendered !== cursor) current.insertBefore(rendered, cursor) + reconcileElement(rendered, nextChild, hooks) + } else if ( + matched && + current.localName === 'defs' && + hooks?.replaceDefinitions !== false + ) { + rendered = nextChild.cloneNode(true) as Element + matched.replaceWith(rendered) + if (matched !== cursor) current.insertBefore(rendered, cursor) + } else { + rendered = nextChild.cloneNode(true) as Element + current.insertBefore(rendered, cursor) + hooks?.enter(rendered, nextChild) + } + + cursor = rendered.nextElementSibling + }) + + for (const child of currentChildren) { + if (!retained.has(child) && child.parentElement === current) { + if (hooks) hooks.exit(child) + else child.remove() + } + } +} + +function syncAttributes(current: Element, next: Element) { + const nextNames = new Set(next.getAttributeNames()) + for (const name of current.getAttributeNames()) { + if (!nextNames.has(name)) current.removeAttribute(name) + } + + for (const name of nextNames) { + const target = next.getAttribute(name) + if (target !== null && target !== current.getAttribute(name)) { + current.setAttribute(name, target) + } + } +} + +function indexChildren(children: readonly Element[]) { + const result = new Map() + identities(children).forEach((identity, index) => { + result.set(identity, children[index]) + }) + return result +} + +function identities(children: readonly Element[]) { + const counts = new Map() + return children.map((child) => { + const explicit = child.getAttribute('data-ts-key') + if (explicit) return `key:${explicit}` + const count = counts.get(child.localName) ?? 0 + counts.set(child.localName, count + 1) + return `tag:${child.localName}:${count}` + }) +} diff --git a/packages/charts-core/src/reconcile.ts b/packages/charts-core/src/reconcile.ts index ff85c008..cd2b6f4f 100644 --- a/packages/charts-core/src/reconcile.ts +++ b/packages/charts-core/src/reconcile.ts @@ -1,3 +1,8 @@ +import { + reconcileSvgMarkup, + reconcileSvgFragment, + type SvgReconcileHooks, +} from './reconcile-internal' import type { ChartAnimationOptions } from './types' interface AttributeTween { @@ -38,23 +43,13 @@ export function reconcileChartSvg( markup: string, animation?: ChartAnimationOptions, ): () => void { - const template = container.ownerDocument.createElement('template') - template.innerHTML = markup - const nextRoot = template.content.firstElementChild - if (!nextRoot) return () => {} - - const currentRoot = container.firstElementChild - if ( - !currentRoot || - currentRoot.namespaceURI !== nextRoot.namespaceURI || - currentRoot.localName !== nextRoot.localName - ) { - container.replaceChildren(nextRoot) - return () => {} - } - const tweens: AttributeTween[] = [] - reconcileElement(currentRoot, nextRoot, animation ? tweens : undefined) + reconcileSvgMarkup( + container, + markup, + animation ? tweenHooks(tweens) : undefined, + ) + return animation ? runTweens(container, tweens, animation) : () => {} } @@ -64,83 +59,21 @@ export function reconcileChartSvgFragment( markup: string, animation?: ChartAnimationOptions, ): () => void { - const template = currentRoot.ownerDocument.createElement('template') - template.innerHTML = `${markup}` - const wrapper = template.content.firstElementChild - const nextRoot = wrapper?.firstElementChild - if (!nextRoot) return () => {} - - if ( - currentRoot.namespaceURI !== nextRoot.namespaceURI || - currentRoot.localName !== nextRoot.localName - ) { - currentRoot.replaceWith(nextRoot) - return () => {} - } - const tweens: AttributeTween[] = [] - reconcileElement(currentRoot, nextRoot, animation ? tweens : undefined) + reconcileSvgFragment( + currentRoot, + markup, + animation ? tweenHooks(tweens) : undefined, + ) + return animation ? runTweens(currentRoot, tweens, animation) : () => {} } -function reconcileElement( - current: Element, - next: Element, - tweens: AttributeTween[] | undefined, -) { - syncAttributes(current, next, tweens) - - if (!next.firstElementChild) { - if (current.firstElementChild) { - for (const child of [...current.children]) { - if (tweens) addExitTween(child, tweens) - else child.remove() - } - } else if (current.textContent !== next.textContent) { - current.textContent = next.textContent - } - return - } - - const currentChildren = [...current.children] - const nextChildren = [...next.children] - const currentByIdentity = indexChildren(currentChildren) - const nextIdentities = identities(nextChildren) - const retained = new Set() - let cursor = current.firstElementChild - - nextChildren.forEach((nextChild, index) => { - const identity = nextIdentities[index] - const matched = currentByIdentity.get(identity) - let rendered: Element - - if ( - matched && - matched.namespaceURI === nextChild.namespaceURI && - matched.localName === nextChild.localName - ) { - rendered = matched - retained.add(matched) - if (rendered !== cursor) current.insertBefore(rendered, cursor) - reconcileElement(rendered, nextChild, tweens) - } else if (matched && current.localName === 'defs') { - rendered = nextChild.cloneNode(true) as Element - matched.replaceWith(rendered) - if (matched !== cursor) current.insertBefore(rendered, cursor) - } else { - rendered = nextChild.cloneNode(true) as Element - current.insertBefore(rendered, cursor) - addEnterTween(rendered, nextChild, tweens) - } - - cursor = rendered.nextElementSibling - }) - - for (const child of currentChildren) { - if (!retained.has(child) && child.parentElement === current) { - if (tweens) addExitTween(child, tweens) - else child.remove() - } +function tweenHooks(tweens: AttributeTween[]): SvgReconcileHooks { + return { + update: (current, next) => syncAttributes(current, next, tweens), + enter: (current, next) => addEnterTween(current, next, tweens), + exit: (current) => addExitTween(current, tweens), } } @@ -318,25 +251,6 @@ function extractNumbers(value: string, path = false) { return { skeleton, values } } -function indexChildren(children: readonly Element[]) { - const result = new Map() - identities(children).forEach((identity, index) => { - result.set(identity, children[index]) - }) - return result -} - -function identities(children: readonly Element[]) { - const counts = new Map() - return children.map((child) => { - const explicit = child.getAttribute('data-ts-key') - if (explicit) return `key:${explicit}` - const count = counts.get(child.localName) ?? 0 - counts.set(child.localName, count + 1) - return `tag:${child.localName}:${count}` - }) -} - function easing(name: NonNullable) { if (typeof name === 'function') return name switch (name) { diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index e1165e13..b8ab3213 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1,3 +1,4 @@ +import { finiteNonNegative, clampNonnegativeNumber } from './number-internal' import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { @@ -1269,12 +1270,13 @@ function resolveMarginLocks( margin: StaticChartDefinition['margin'], ): Partial { if (typeof margin === 'number') { - return uniformMargin(finiteMargin(margin)) + return uniformMargin(clampNonnegativeNumber(margin)) } if (!margin) return {} const locks: Partial = {} for (const side of marginSides) { - if (margin[side] !== undefined) locks[side] = finiteMargin(margin[side]) + if (margin[side] !== undefined) + locks[side] = clampNonnegativeNumber(margin[side]) } return locks } @@ -1301,19 +1303,6 @@ function marginsEqual(left: ChartMargin, right: ChartMargin): boolean { ) } -function finiteMargin(value: number | undefined): number { - return value !== undefined && Number.isFinite(value) ? Math.max(0, value) : 0 -} - -function finiteNonNegative( - value: number | undefined, - fallback: number, -): number { - return value !== undefined && Number.isFinite(value) && value >= 0 - ? value - : fallback -} - function uniformMargin(value: number): ChartMargin { return { top: value, right: value, bottom: value, left: value } } @@ -1354,27 +1343,16 @@ function createGrid( const style = guideLineStyle(guide.options.grid) for (const tick of guide.scale.ticks) { const key = `${guide.id}-grid:${valueKey(tick.value)}` - children.push( - guide.channel === 'x' - ? { - kind: 'rule', - key, - x1: tick.position, - x2: tick.position, - y1: chart.y, - y2: chart.y + chart.height, - ...(style ? { style } : {}), - } - : { - kind: 'rule', - key, - x1: chart.x, - x2: chart.x + chart.width, - y1: tick.position, - y2: tick.position, - ...(style ? { style } : {}), - }, - ) + const horizontal = guide.channel === 'x' + children.push({ + kind: 'rule', + key, + x1: horizontal ? tick.position : chart.x, + x2: horizontal ? tick.position : chart.x + chart.width, + y1: horizontal ? chart.y : tick.position, + y2: horizontal ? chart.y + chart.height : tick.position, + style, + }) } } @@ -1453,11 +1431,7 @@ function createAxes( } } - if (guide.channel === 'x') { - renderXAxis(guide, axisPosition, includeOutward, includeCoordinate) - } else { - renderYAxis(guide, axisPosition, includeOutward, includeCoordinate) - } + renderAxis(guide, axisPosition, includeOutward, includeCoordinate) const distance = guide.side === 'top' @@ -1481,34 +1455,45 @@ function createAxes( includeGuideStrokeMargins(margin, axes, chart) return { axes, margin } - function renderXAxis( + function renderAxis( guide: ResolvedPositionScale, - axisY: number, + axisPosition: number, includeOutward: (bounds: ChartBounds) => void, includeCoordinate: (coordinate: number) => void, ) { const presentation = axisPresentation(guide.options) - const bottom = guide.side === 'bottom' - const direction = bottom ? 1 : -1 + const horizontal = guide.channel === 'x' + const positive = guide.side === 'bottom' || guide.side === 'right' + const direction = positive ? 1 : -1 + const outerCoordinate = (bounds: ChartBounds) => { + const start = horizontal ? bounds.y : bounds.x + const size = horizontal ? bounds.height : bounds.width + + return positive ? start + size : start + } + const farther = (left: number, right: number) => + positive ? Math.max(left, right) : Math.min(left, right) + if (presentation?.line !== false) { children.push({ kind: 'rule', key: `${guide.id}-axis`, - x1: chart.x, - x2: chartRight, - y1: axisY, - y2: axisY, + x1: horizontal ? chart.x : axisPosition, + x2: horizontal ? chartRight : axisPosition, + y1: horizontal ? axisPosition : chart.y, + y2: horizontal ? axisPosition : chartBottom, style: axisStyle(presentation?.line), }) includeCoordinate( - axisY + direction * guideLineHalfWidth(presentation?.line), + axisPosition + direction * guideLineHalfWidth(presentation?.line), ) } + const ticks = presentation?.ticks === false ? [] : guide.scale.ticks - const tickSize = finiteMargin( + const tickSize = clampNonnegativeNumber( presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), ) - const tickPadding = finiteMargin( + const tickPadding = clampNonnegativeNumber( presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), ) const tickLabels = tickLabelPresentation(presentation) @@ -1518,7 +1503,7 @@ function createAxes( : createTickLabelCandidates( guide, withKeptTicks(guide.scale, guide.options, tickLabels), - axisY, + axisPosition, tickSize, tickPadding, tickLabels, @@ -1530,31 +1515,32 @@ function createAxes( const visibleLabels = tickLabels === false ? [] - : thinTickLabels(candidates, tickLabels, guide.scale.type === 'band') - let tickOuter = axisY + : thinTickLabels( + candidates, + tickLabels, + horizontal && guide.scale.type === 'band', + ) + let tickOuter = axisPosition for (const tick of ticks) { if (tickSize <= 0) continue - const tickEnd = axisY + direction * tickSize + const tickEnd = axisPosition + direction * tickSize includeCoordinate(tickEnd) - tickOuter = bottom - ? Math.max(tickOuter, tickEnd) - : Math.min(tickOuter, tickEnd) + tickOuter = farther(tickOuter, tickEnd) children.push({ kind: 'rule', key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, - x1: tick.position, - x2: tick.position, - y1: axisY, - y2: tickEnd, + x1: horizontal ? tick.position : axisPosition, + x2: horizontal ? tick.position : tickEnd, + y1: horizontal ? axisPosition : tick.position, + y2: horizontal ? tickEnd : tick.position, style: axisStyle(), }) } + for (const candidate of visibleLabels) { includeOutward(candidate.bounds) - tickOuter = bottom - ? Math.max(tickOuter, candidate.bounds.y + candidate.bounds.height) - : Math.min(tickOuter, candidate.bounds.y) + tickOuter = farther(tickOuter, outerCoordinate(candidate.bounds)) children.push(candidate.label) } @@ -1562,20 +1548,27 @@ function createAxes( const labelText = typeof axisLabel === 'string' ? axisLabel : axisLabel?.text if (!labelText) return + const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined const labelOffset = labelOptions?.offset ?? 'auto' const explicitOffset = labelOffset !== 'auto' const label: SceneLabel = { kind: 'label', key: `${guide.id}-label`, - x: chart.x + chart.width / 2, - y: explicitOffset - ? axisY + direction * Math.max(0, finiteMargin(labelOffset)) - : tickOuter + direction * 8, + x: horizontal ? chart.x + chart.width / 2 : axisPosition, + y: horizontal + ? explicitOffset + ? axisPosition + direction * clampNonnegativeNumber(labelOffset) + : tickOuter + direction * 8 + : chart.y + chart.height / 2, text: labelText, anchor: 'middle', - baseline: bottom && !explicitOffset ? 'hanging' : 'auto', - fontSize: labelOptions?.fontSize ?? (width < 360 ? 10 : 11), + baseline: horizontal + ? positive && !explicitOffset + ? 'hanging' + : 'auto' + : 'middle', + fontSize: labelOptions?.fontSize ?? (horizontal && width < 360 ? 10 : 11), fontWeight: labelOptions?.fontWeight ?? 600, style: { fill: labelOptions?.fill ?? theme.foreground, @@ -1584,120 +1577,22 @@ function createAxes( : { opacity: labelOptions.opacity }), }, } - includeOutward(measureSceneLabelBounds(label, measureText)) - children.push(label) - } - function renderYAxis( - guide: ResolvedPositionScale, - axisX: number, - includeOutward: (bounds: ChartBounds) => void, - includeCoordinate: (coordinate: number) => void, - ) { - const presentation = axisPresentation(guide.options) - const right = guide.side === 'right' - const direction = right ? 1 : -1 - if (presentation?.line !== false) { - children.push({ - kind: 'rule', - key: `${guide.id}-axis`, - x1: axisX, - x2: axisX, - y1: chart.y, - y2: chartBottom, - style: axisStyle(presentation?.line), - }) - includeCoordinate( - axisX + direction * guideLineHalfWidth(presentation?.line), - ) - } - const ticks = presentation?.ticks === false ? [] : guide.scale.ticks - const tickSize = finiteMargin( - presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), - ) - const tickPadding = finiteMargin( - presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), - ) - const tickLabels = tickLabelPresentation(presentation) - const candidates = - tickLabels === false - ? [] - : createTickLabelCandidates( - guide, - withKeptTicks(guide.scale, guide.options, tickLabels), - axisX, - tickSize, - tickPadding, - tickLabels, - width, - theme, - measureText, - rightToLeft, - ) - const visibleLabels = - tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false) - let tickOuter = axisX - - for (const tick of ticks) { - if (tickSize <= 0) continue - const tickEnd = axisX + direction * tickSize - includeCoordinate(tickEnd) - tickOuter = right - ? Math.max(tickOuter, tickEnd) - : Math.min(tickOuter, tickEnd) - children.push({ - kind: 'rule', - key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, - x1: axisX, - x2: tickEnd, - y1: tick.position, - y2: tick.position, - style: axisStyle(), - }) - } - for (const candidate of visibleLabels) { - includeOutward(candidate.bounds) - tickOuter = right - ? Math.max(tickOuter, candidate.bounds.x + candidate.bounds.width) - : Math.min(tickOuter, candidate.bounds.x) - children.push(candidate.label) + if (!horizontal) { + label.rotate = positive ? 90 : -90 + if (explicitOffset) { + label.x = axisPosition + direction * clampNonnegativeNumber(labelOffset) + } else { + const localBounds = measureSceneLabelBounds( + { ...label, x: 0, y: 0 }, + measureText, + ) + label.x = positive + ? tickOuter + 8 - localBounds.x + : tickOuter - 8 - (localBounds.x + localBounds.width) + } } - const axisLabel = presentation?.label - const labelText = - typeof axisLabel === 'string' ? axisLabel : axisLabel?.text - if (!labelText) return - const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined - const label: SceneLabel = { - kind: 'label', - key: `${guide.id}-label`, - x: axisX, - y: chart.y + chart.height / 2, - text: labelText, - anchor: 'middle', - baseline: 'middle', - rotate: right ? 90 : -90, - fontSize: labelOptions?.fontSize ?? 11, - fontWeight: labelOptions?.fontWeight ?? 600, - style: { - fill: labelOptions?.fill ?? theme.foreground, - ...(labelOptions?.opacity === undefined - ? { fillOpacity: 0.76 } - : { opacity: labelOptions.opacity }), - }, - } - const labelOffset = labelOptions?.offset ?? 'auto' - if (labelOffset !== 'auto') { - label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)) - } else { - const localBounds = measureSceneLabelBounds( - { ...label, x: 0, y: 0 }, - measureText, - ) - label.x = right - ? tickOuter + 8 - localBounds.x - : tickOuter - 8 - (localBounds.x + localBounds.width) - } includeOutward(measureSceneLabelBounds(label, measureText)) children.push(label) } @@ -1763,10 +1658,10 @@ function resolveTickCount( } if (configured.values) return Math.max(1, configured.values.length) if (configured.count !== undefined) { - return Math.max(1, Math.floor(finiteMargin(configured.count))) + return Math.max(1, Math.floor(clampNonnegativeNumber(configured.count))) } if (configured.spacing !== undefined) { - const spacing = Math.max(1, finiteMargin(configured.spacing)) + const spacing = Math.max(1, clampNonnegativeNumber(configured.spacing)) return Math.max(1, Math.floor(length / spacing)) } return Math.max(2, Math.min(maximum, Math.floor(length / defaultSpacing))) @@ -1854,52 +1749,45 @@ function createTickLabelCandidates( const dy = resolveTickLabelValue(options.dy, context) ?? 0 // Automatic anchors preserve a physical placement outside the plot. // Authored anchors remain logical SVG start/end values. - const automaticAnchor: NonNullable = + const anchorSide = guide.channel === 'y' ? positiveSide - ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') - : physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') + ? 'left' + : 'right' : (rotate ?? 0) < 0 - ? physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') + ? 'right' : (rotate ?? 0) > 0 - ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') + ? 'left' : 'middle' + const automaticAnchor = physicalTextAnchor( + anchorSide, + rightToLeft ? 'rtl' : 'ltr', + ) const anchor = resolveTickLabelValue(options.anchor, context) ?? automaticAnchor - const label: SceneLabel = - guide.channel === 'x' - ? { - kind: 'label', - key: `${guide.id}-tick-label:${valueKey(tick.value)}`, - x: tick.position + dx, - y: - axisPosition + direction * (size + padding + fontSize * 0.8) + dy, - text: tick.label, - anchor, - rotate, - fontSize, - fontWeight, - style: { - fill: theme.muted, - ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), - }, - } - : { - kind: 'label', - key: `${guide.id}-tick-label:${valueKey(tick.value)}`, - x: axisPosition + direction * (size + padding) + dx, - y: tick.position + dy, - text: tick.label, - anchor, - baseline: 'middle', - rotate, - fontSize, - fontWeight, - style: { - fill: theme.muted, - ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), - }, - } + const horizontal = guide.channel === 'x' + const label: SceneLabel = { + kind: 'label', + key: `${guide.id}-tick-label:${valueKey(tick.value)}`, + x: + (horizontal + ? tick.position + : axisPosition + direction * (size + padding)) + dx, + y: horizontal + ? axisPosition + direction * (size + padding + fontSize * 0.8) + dy + : tick.position + dy, + text: tick.label, + anchor, + baseline: horizontal ? undefined : 'middle', + rotate, + fontSize, + fontWeight, + style: { + fill: theme.muted, + ...(opacity === undefined ? { fillOpacity: 0.68 } : { opacity }), + }, + } + return { value: tick.value, label, @@ -1929,7 +1817,7 @@ function thinTickLabels( ): TickLabelCandidate[] { if (options.thin === false || candidates.length < 2) return [...candidates] const thin = typeof options.thin === 'object' ? options.thin : {} - const minGap = Math.max(0, finiteMargin(thin.minGap ?? 4)) + const minGap = clampNonnegativeNumber(thin.minGap ?? 4) const selected: TickLabelCandidate[] = candidates.filter( (candidate) => candidate.hard, ) diff --git a/packages/charts-core/src/stack-ends-internal.ts b/packages/charts-core/src/stack-ends-internal.ts index 921e1925..14d58250 100644 --- a/packages/charts-core/src/stack-ends-internal.ts +++ b/packages/charts-core/src/stack-ends-internal.ts @@ -2,13 +2,12 @@ import { stackOrderInsideOut } from 'd3-shape' import type { Series } from 'd3-shape' import { valueKey } from './scales' import type { StackOptions, StackOrder } from './stack' +import { createStackInput, type StackInput } from './stack-internal' +import { orderedSeries } from './stack-order-internal' +import { isFiniteNumber } from './mark' import type { ChartKey, ChartValue } from './types' -interface StackEndInput { - index: number - position: ChartValue - value: number - series: ChartKey +interface StackEndInput extends StackInput { start: number end: number } @@ -120,31 +119,17 @@ function stackEndInput( fallbackSeries: 'value' | 'index', ): StackEndInput[] { const input: StackEndInput[] = [] - for (let index = 0; index < positions.length; index += 1) { - const position = positions[index] - const value = values[index] - const start = starts[index] - const end = ends[index] - if ( - !isChartValue(position) || - !isFiniteNumber(value) || - !isFiniteNumber(start) || - !isFiniteNumber(end) - ) - continue - const seriesValue = series[index] - input.push({ - index, - position, - value, - start, - end, - series: isChartKey(seriesValue) - ? seriesValue - : fallbackSeries === 'index' - ? index - : 'value', - }) + for (const row of createStackInput( + positions, + values, + series, + fallbackSeries, + )) { + const start = starts[row.index] + const end = ends[row.index] + if (!isFiniteNumber(start) || !isFiniteNumber(end)) continue + + input.push({ ...row, start, end }) } return input } @@ -161,14 +146,6 @@ function resolveSeriesOrder( seen.add(identity) firstSeen.push(row.series) } - if (Array.isArray(order)) { - const explicit = [...order] - const explicitKeys = new Set(explicit.map(valueKey)) - return [ - ...explicit, - ...firstSeen.filter((value) => !explicitKeys.has(valueKey(value))), - ] - } if (order === 'inside-out') { const positions: ChartValue[] = [] const positionIndex = new Map() @@ -191,31 +168,5 @@ function resolveSeriesOrder( seriesValues as unknown as Series, string>, ).map((index) => firstSeen[index]!) } - if (order !== 'ascending' && order !== 'descending') return firstSeen - const totals = new Map(firstSeen.map((value) => [valueKey(value), 0])) - for (const row of input) { - const key = valueKey(row.series) - totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) - } - return firstSeen.sort((left, right) => { - const difference = - (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) - return order === 'ascending' ? difference : -difference - }) -} - -function isChartKey(value: unknown): value is ChartKey { - return typeof value === 'string' || typeof value === 'number' -} - -function isChartValue(value: unknown): value is ChartValue { - return ( - typeof value === 'string' || - isFiniteNumber(value) || - (value instanceof Date && Number.isFinite(value.getTime())) - ) -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) + return orderedSeries(input, firstSeen, order) } diff --git a/packages/charts-core/src/stack-internal.ts b/packages/charts-core/src/stack-internal.ts index f15abbaf..6373fbae 100644 --- a/packages/charts-core/src/stack-internal.ts +++ b/packages/charts-core/src/stack-internal.ts @@ -1,3 +1,5 @@ +import { isChartKey, isChartValue, isFiniteNumber } from './mark' +import { orderedSeries } from './stack-order-internal' import { stack as d3Stack, stackOffsetExpand, @@ -8,7 +10,7 @@ import { } from 'd3-shape' import type { Series } from 'd3-shape' import { valueKey } from './scales' -import type { StackOptions, StackOrder } from './stack' +import type { StackOptions } from './stack' import type { ChartKey, ChartValue } from './types' export interface StackInput { @@ -224,23 +226,7 @@ export function stackValues( options: Readonly = {}, fallbackSeries: 'value' | 'index' = 'value', ) { - const input: StackInput[] = [] - for (let index = 0; index < positions.length; index += 1) { - const position = positions[index] - const value = values[index] - if (!isChartValue(position) || !isFiniteNumber(value)) continue - const seriesValue = series[index] - input.push({ - index, - position, - value, - series: isChartKey(seriesValue) - ? seriesValue - : fallbackSeries === 'index' - ? index - : 'value', - }) - } + const input = createStackInput(positions, values, series, fallbackSeries) const extents = stackExtents(input, options) const starts: (number | undefined)[] = Array.from( { length: positions.length }, @@ -257,44 +243,28 @@ export function stackValues( return { starts, ends } } -function isChartKey(value: unknown): value is ChartKey { - return typeof value === 'string' || typeof value === 'number' -} - -function isChartValue(value: unknown): value is ChartValue { - return ( - typeof value === 'string' || - isFiniteNumber(value) || - (value instanceof Date && Number.isFinite(value.getTime())) - ) -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - -function orderedSeries( - rows: readonly StackInput[], - input: readonly ChartKey[], - order: StackOrder | undefined, -): ChartKey[] { - if (Array.isArray(order)) { - const explicit = [...order] - const explicitKeys = new Set(explicit.map(valueKey)) - return [ - ...explicit, - ...input.filter((value) => !explicitKeys.has(valueKey(value))), - ] - } - if (order !== 'ascending' && order !== 'descending') return [...input] - const totals = new Map(input.map((value) => [valueKey(value), 0])) - for (const row of rows) { - const key = valueKey(row.series) - totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) +export function createStackInput( + positions: readonly unknown[], + values: readonly unknown[], + series: readonly unknown[], + fallbackSeries: 'value' | 'index', +): StackInput[] { + const input: StackInput[] = [] + for (let index = 0; index < positions.length; index += 1) { + const position = positions[index] + const value = values[index] + if (!isChartValue(position) || !isFiniteNumber(value)) continue + const seriesValue = series[index] + input.push({ + index, + position, + value, + series: isChartKey(seriesValue) + ? seriesValue + : fallbackSeries === 'index' + ? index + : 'value', + }) } - return [...input].sort((left, right) => { - const difference = - (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) - return order === 'ascending' ? difference : -difference - }) + return input } diff --git a/packages/charts-core/src/stack-order-internal.ts b/packages/charts-core/src/stack-order-internal.ts new file mode 100644 index 00000000..79396c2d --- /dev/null +++ b/packages/charts-core/src/stack-order-internal.ts @@ -0,0 +1,30 @@ +import { valueKey } from './scales' +import type { StackInput } from './stack-internal' +import type { StackOrder } from './stack' +import type { ChartKey } from './types' + +export function orderedSeries( + rows: readonly StackInput[], + input: readonly ChartKey[], + order: StackOrder | undefined, +): ChartKey[] { + if (Array.isArray(order)) { + const explicit = [...order] + const explicitKeys = new Set(explicit.map(valueKey)) + return [ + ...explicit, + ...input.filter((value) => !explicitKeys.has(valueKey(value))), + ] + } + if (order !== 'ascending' && order !== 'descending') return [...input] + const totals = new Map(input.map((value) => [valueKey(value), 0])) + for (const row of rows) { + const key = valueKey(row.series) + totals.set(key, (totals.get(key) ?? 0) + Math.abs(row.value)) + } + return [...input].sort((left, right) => { + const difference = + (totals.get(valueKey(left)) ?? 0) - (totals.get(valueKey(right)) ?? 0) + return order === 'ascending' ? difference : -difference + }) +} diff --git a/packages/charts-core/src/tooltip-model.ts b/packages/charts-core/src/tooltip-model.ts index 802c5e74..4950f918 100644 --- a/packages/charts-core/src/tooltip-model.ts +++ b/packages/charts-core/src/tooltip-model.ts @@ -1,3 +1,4 @@ +import { sameChartValue } from './value-equality-internal' import type { ChartFocusState, ChartPoint, @@ -30,10 +31,10 @@ export function orderChartTooltipPoints< const first = points[0] const sharedX = first !== undefined && - points.every((point) => sameChartTooltipValue(point.xValue, first.xValue)) + points.every((point) => sameChartValue(point.xValue, first.xValue)) const sharedY = first !== undefined && - points.every((point) => sameChartTooltipValue(point.yValue, first.yValue)) + points.every((point) => sameChartValue(point.yValue, first.yValue)) return [...points].sort((left, right) => sharedY && !sharedX ? left.x - right.x || left.y - right.y @@ -181,14 +182,10 @@ function defaultTooltipContent( const group = findTooltipChannelItem(options?.items, 'group') const sharedX = points.length > 1 && - points.every((candidate) => - sameChartTooltipValue(candidate.xValue, point.xValue), - ) + points.every((candidate) => sameChartValue(candidate.xValue, point.xValue)) const sharedY = points.length > 1 && - points.every((candidate) => - sameChartTooltipValue(candidate.yValue, point.yValue), - ) + points.every((candidate) => sameChartValue(candidate.yValue, point.yValue)) if (sharedX || sharedY) { const axis = sharedX ? 'x' : 'y' @@ -359,7 +356,7 @@ function formatPointAxis( interval === 'range' && start !== undefined && end !== undefined && - !sameChartTooltipValue(start, end) + !sameChartValue(start, end) ) { return `${formatChartTooltipValue(start)}–${formatChartTooltipValue(end)}` } @@ -421,9 +418,3 @@ function colorOrder(scene: ChartScene, group: ChartPoint['group']) { const index = group == null ? -1 : scene.colors.domain.indexOf(group) return index < 0 ? Number.MAX_SAFE_INTEGER : index } - -function sameChartTooltipValue(left: ChartValue, right: ChartValue) { - return left instanceof Date && right instanceof Date - ? left.getTime() === right.getTime() - : Object.is(left, right) -} diff --git a/packages/charts-core/src/value-equality-internal.test.ts b/packages/charts-core/src/value-equality-internal.test.ts new file mode 100644 index 00000000..da8d171b --- /dev/null +++ b/packages/charts-core/src/value-equality-internal.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { sameChartValue } from './value-equality-internal' + +describe('sameChartValue', () => { + it('compares primitives and dates by value', () => { + expect(sameChartValue(12, 12)).toBe(true) + expect(sameChartValue('September', 'September')).toBe(true) + expect(sameChartValue(new Date('2026-09-01'), new Date('2026-09-01'))).toBe( + true, + ) + expect(sameChartValue(new Date('2026-09-01'), new Date('2026-09-02'))).toBe( + false, + ) + }) + + it('preserves Object.is semantics and distinguishes dates from timestamps', () => { + expect(sameChartValue(Number.NaN, Number.NaN)).toBe(true) + expect(sameChartValue(-0, 0)).toBe(false) + expect(sameChartValue(new Date(0), 0)).toBe(false) + expect(sameChartValue(undefined, 0)).toBe(false) + expect(sameChartValue(new Date(Number.NaN), new Date(Number.NaN))).toBe( + false, + ) + }) +}) diff --git a/packages/charts-core/src/value-equality-internal.ts b/packages/charts-core/src/value-equality-internal.ts new file mode 100644 index 00000000..06db1d74 --- /dev/null +++ b/packages/charts-core/src/value-equality-internal.ts @@ -0,0 +1,10 @@ +import type { ChartValue } from './types' + +export function sameChartValue( + left: ChartValue | undefined, + right: ChartValue, +): boolean { + return left instanceof Date && right instanceof Date + ? left.getTime() === right.getTime() + : Object.is(left, right) +} diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 489ef19a..48b194c8 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -23,6 +23,7 @@ const rendererBoundaryModules = { ], svg: [ 'packages/charts-core/src/reconcile.ts', + 'packages/charts-core/src/reconcile-internal.ts', 'packages/charts-core/src/svg-focus-guide-layer.ts', 'packages/charts-core/src/svg-focus-guide-serializer.ts', 'packages/charts-core/src/svg-renderer.ts', @@ -45,6 +46,7 @@ const rendererBoundaryModules = { 'packages/charts-core/src/dom-text.ts', 'packages/charts-core/src/export.ts', 'packages/charts-core/src/reconcile.ts', + 'packages/charts-core/src/reconcile-internal.ts', 'packages/charts-core/src/renderer.ts', 'packages/charts-core/src/svg-focus-guide-layer.ts', 'packages/charts-core/src/svg-focus-guide-serializer.ts', @@ -56,6 +58,7 @@ const rendererBoundaryModules = { ], } const retainedInputGroups = { + numericValues: [/(?:^|\/)packages\/charts-core\/src\/number-internal\.ts$/u], crosshairRuntime: [ /(?:^|\/)packages\/charts-core\/src\/crosshair(?:-resolver)?\.ts$/u, ], @@ -64,7 +67,7 @@ const retainedInputGroups = { /(?:^|\/)packages\/charts-core\/src\/focus-layer\.ts$/u, ], platformRendererRuntime: [ - /(?:^|\/)packages\/charts-core\/src\/(?:adapter(?:-renderer)?|canvas|dom(?:-text)?|export|reconcile|renderer|svg(?:-focus-guide-(?:layer|serializer)|-renderer|-resources|-surface)?)\.ts$/u, + /(?:^|\/)packages\/charts-core\/src\/(?:adapter(?:-renderer)?|canvas|dom(?:-text)?|export|reconcile(?:-internal)?|renderer|svg(?:-focus-guide-(?:layer|serializer)|-renderer|-resources|-surface)?)\.ts$/u, /(?:^|\/)packages\/(?:react-charts|react-native-charts)\/src\//u, ], compactLinear: [/(?:^|\/)packages\/charts-scales\/src\/linear\.ts$/u], @@ -220,7 +223,9 @@ const retainedInputGroups = { facetMark: [/(?:^|\/)packages\/charts-core\/src\/facet\.ts$/u], areaYMark: [/(?:^|\/)packages\/charts-core\/src\/area\.ts$/u], areaXMark: [/(?:^|\/)packages\/charts-core\/src\/area-x\.ts$/u], - stackInternal: [/(?:^|\/)packages\/charts-core\/src\/stack-internal\.ts$/u], + stackInternal: [ + /(?:^|\/)packages\/charts-core\/src\/stack(?:-order)?-internal\.ts$/u, + ], transformStatistics: [ /(?:^|\/)packages\/charts-core\/src\/transform-statistics-internal\.ts$/u, ], @@ -265,7 +270,7 @@ const retainedInputGroups = { ], polarPie: [/(?:^|\/)packages\/charts-core\/src\/polar-pie\.ts$/u], markInfrastructure: [ - /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|materialized-channel-internal|scales)\.ts$/u, + /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|materialized-channel-internal|number-internal|scales)\.ts$/u, ], rectMark: [/(?:^|\/)packages\/charts-core\/src\/rect\.ts$/u], rectRadiusState: [ @@ -459,7 +464,7 @@ const entries = [ budgeted( 'Transform: stack', 'benchmarks/entries/charts-transform-stack.ts', - 2.68, + 2.69, { inputBoundary: granularTransformBoundary('transformStack', { allowD3Shape: true, @@ -557,7 +562,7 @@ const entries = [ 'Hierarchy treemap mark', 'benchmarks/entries/charts-hierarchy-treemap.ts', 'D3 hierarchy treemap kernel', - 3.86, + 3.89, { inputBoundary: { require: ['hierarchyFlat', 'hierarchyTreemap', 'd3Hierarchy'], @@ -1076,7 +1081,7 @@ const entries = [ budgeted( 'Custom mark scale-value factory', 'benchmarks/entries/charts-mark-scale-values.ts', - 0.39, + 0.4, ), measured( 'Crosshair mark extension', @@ -1431,7 +1436,7 @@ const entries = [ 'd3GeometryRuntime', ], addedFrom: 'Categorical legend', - allowAdded: [], + allowAdded: ['numericValues'], }, }, ),