From 89ca64418de719c1b59b1ab17232dadafbf81763 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 27 Aug 2026 23:48:23 +0200 Subject: [PATCH 01/10] Allow line marks to configure caps and joins --- .changeset/line-cap-options.md | 6 ++ API-FRICTION.md | 18 ++++ .../bundle-size/universal-baseline.json | 32 +++---- benchmarks/comparison/bundle-baseline.json | 36 ++++---- .../cases/70-composed-chart/case.json | 1 + .../cases/70-composed-chart/example.tsx | 1 + benchmarks/conformance/catalog-index.json | 1 + .../previews/70-composed-chart.svg | 2 +- benchmarks/conformance/previews/manifest.json | 6 +- docs/comparison.md | 4 +- docs/examples/lines-and-areas.md | 5 ++ docs/reference/marks/line-and-area.md | 2 + packages/charts-core/docs/comparison.md | 4 +- .../docs/examples/lines-and-areas.md | 5 ++ .../docs/reference/marks/line-and-area.md | 2 + packages/charts-core/src/canvas.test.ts | 73 +++++++++++++++ packages/charts-core/src/export.test.ts | 4 + packages/charts-core/src/line.test.ts | 89 +++++++++++++++++++ packages/charts-core/src/line.ts | 9 +- packages/charts-core/src/motion.test.ts | 46 ++++++++++ .../react-native-charts/src/SvgScene.test.tsx | 9 +- 21 files changed, 309 insertions(+), 46 deletions(-) create mode 100644 .changeset/line-cap-options.md diff --git a/.changeset/line-cap-options.md b/.changeset/line-cap-options.md new file mode 100644 index 00000000..2ea20a1a --- /dev/null +++ b/.changeset/line-cap-options.md @@ -0,0 +1,6 @@ +--- +'@tanstack/charts': minor +--- + +Add `lineCap` and `lineJoin` options to `lineY` and `lineX`. Both options use +the renderer-supported `SceneStyle` values and continue to default to `round`. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..14c78c25 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -331,6 +331,7 @@ Each entry records: | F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved | | F-293 | Root scale slots blocked named axes | API | resolved | | F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved | +| F-295 | Line marks forced round endpoints | API | resolved | ## Findings @@ -8505,3 +8506,20 @@ Each entry records: React Native Metro gates, and framework package checks pass. Bundle boundary checks keep SVG-only entries free of Canvas and measure the opt-in mixed representative and React consumers at 35.68 KiB and 41.63 KiB gzip. + +### F-295 - Line marks forced round endpoints + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: migrating Rewardo's composed statistics chart from Recharts +- Friction: `lineY` and `lineX` exposed stroke width, opacity, dash pattern, + and curve options, but hard-coded round caps and joins. Reproducing the + previous Recharts line, which inherited SVG's butt cap, required replacing + or patching rendered output. +- Decision: expose `lineCap` and `lineJoin` on the shared line options using + the values from `SceneStyle`. Preserve round as the default for both options. +- Verification: public type tests bind both line APIs to the scene renderer's + supported values. Scene, SVG, Canvas, motion, export, and React Native tests + preserve configured values. The Rewardo chart can use `lineCap: 'butt'` on + its existing pink `lineY` mark. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..8f88756c 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,16 +3,16 @@ "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": 50813, - "gzip": 19070 + "bytes": 50836, + "gzip": 19079 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55567, + "gzip": 20785 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75640, + "gzip": 27591 }, "TanStack DOM host": { "bytes": 74947, @@ -23,24 +23,24 @@ "gzip": 26831 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 100789, + "gzip": 36231 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33288, + "gzip": 11944 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83289, + "gzip": 29153 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31470, + "gzip": 11201 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50768, + "gzip": 19041 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..2087d60d 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-26T21:51:12.832Z", + "generatedAt": "2026-08-27T21:38:32.371Z", "packageVersions": { - "tanstack": "0.15.0", + "tanstack": "0.16.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e" + "inputDigest": "sha256:a5332d205ee6b678314f0a444dc2198cb002c6b55b49c7bf1c5acd0ea7ed1783" }, "chartjs": { "kind": "package", @@ -45,25 +45,25 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110431, - "gzipBytes": 39891, - "brotliBytes": 35325, - "incrementalGzipBytes": 39891, - "incrementalBrotliBytes": 35325 + "minifiedBytes": 110454, + "gzipBytes": 39901, + "brotliBytes": 35367, + "incrementalGzipBytes": 39901, + "incrementalBrotliBytes": 35367 }, "tanstack-line-interactive": { - "minifiedBytes": 115872, - "gzipBytes": 41648, - "brotliBytes": 36746, - "incrementalGzipBytes": 41648, - "incrementalBrotliBytes": 36746 + "minifiedBytes": 115895, + "gzipBytes": 41661, + "brotliBytes": 36828, + "incrementalGzipBytes": 41661, + "incrementalBrotliBytes": 36828 }, "tanstack-line-advanced": { - "minifiedBytes": 123062, - "gzipBytes": 43974, - "brotliBytes": 38764, - "incrementalGzipBytes": 43974, - "incrementalBrotliBytes": 38764 + "minifiedBytes": 123085, + "gzipBytes": 43988, + "brotliBytes": 38803, + "incrementalGzipBytes": 43988, + "incrementalBrotliBytes": 38803 }, "tanstack-bar-basic": { "minifiedBytes": 119225, diff --git a/benchmarks/conformance/cases/70-composed-chart/case.json b/benchmarks/conformance/cases/70-composed-chart/case.json index 62dac152..b80c5320 100644 --- a/benchmarks/conformance/cases/70-composed-chart/case.json +++ b/benchmarks/conformance/cases/70-composed-chart/case.json @@ -13,6 +13,7 @@ "left axis and stacked right axes", "daily observations", "monotone curves", + "butt line cap", "resolved maximum bar thickness" ], "geometry": [ diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index 02187acb..c92d3bbc 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -41,6 +41,7 @@ export const createExampleChart = (input: ChartOptions) => { y: 'temp_min', stroke: '#ff7300', strokeWidth: 2, + lineCap: 'butt', curve: monotone, }), dot(rows, { diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 9d3c84f0..54a371a0 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -2394,6 +2394,7 @@ "left axis and stacked right axes", "daily observations", "monotone curves", + "butt line cap", "resolved maximum bar thickness" ], "geometry": [ diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg index 5de7df71..091ffce8 100644 --- a/benchmarks/conformance/previews/70-composed-chart.svg +++ b/benchmarks/conformance/previews/70-composed-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..8f98afe7 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293", + "sourceHash": "99895d05b94419426e1065524564e39ba82fc1cdeae9c5875b4718ba6aeae5ee", "assets": [ { "id": "01-line-gaps", @@ -306,8 +306,8 @@ }, { "id": "70-composed-chart", - "sha256": "642139631cae72ef76ff81e148e6d40e2ff83225a11b582a32878d0f166beae7", - "bytes": 14436 + "sha256": "db93c404c4e097cad237dca330dab72a1080f1e38e873755ef7ca86a81bbeb30", + "bytes": 14435 }, { "id": "71-recharts-population-pyramid", diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..83f66b70 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. 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 | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.97–44.98 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/docs/examples/lines-and-areas.md b/docs/examples/lines-and-areas.md index 03e8bd35..d72d0323 100644 --- a/docs/examples/lines-and-areas.md +++ b/docs/examples/lines-and-areas.md @@ -48,6 +48,7 @@ const chart = defineChart({ y: 'downloads', points: true, stroke: '#2563eb', + lineCap: 'butt', }), ], scales: { @@ -64,6 +65,10 @@ const chart = defineChart({ export default chart ``` +`lineCap: 'butt'` ends the stroke at each path endpoint. Omit it to retain the +default round endpoints. `lineX` accepts the same `lineCap` and `lineJoin` +options. + ## Compare several series from a common baseline Indexing each series to its first observation compares relative change when the diff --git a/docs/reference/marks/line-and-area.md b/docs/reference/marks/line-and-area.md index 2f776f67..7a3c4978 100644 --- a/docs/reference/marks/line-and-area.md +++ b/docs/reference/marks/line-and-area.md @@ -55,6 +55,8 @@ function lineY( | `strokeOpacity` | `number` | SVG default | Stroke opacity | | `strokeWidth` | `number` | `2.25` | Stroke width | | `strokeDasharray` | `string` | None | SVG dash array | +| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape | +| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape | | `points` | `boolean` | `false` | Draws a radius-`2.5` dot at each valid point | | `curve` | `ChartCurve` | Straight segments | Optional path generator | | `states` | `readonly ChartMarkState[]` | None | Focus-driven presentation overrides | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..83f66b70 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. 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 | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.97–44.98 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/examples/lines-and-areas.md b/packages/charts-core/docs/examples/lines-and-areas.md index 03e8bd35..d72d0323 100644 --- a/packages/charts-core/docs/examples/lines-and-areas.md +++ b/packages/charts-core/docs/examples/lines-and-areas.md @@ -48,6 +48,7 @@ const chart = defineChart({ y: 'downloads', points: true, stroke: '#2563eb', + lineCap: 'butt', }), ], scales: { @@ -64,6 +65,10 @@ const chart = defineChart({ export default chart ``` +`lineCap: 'butt'` ends the stroke at each path endpoint. Omit it to retain the +default round endpoints. `lineX` accepts the same `lineCap` and `lineJoin` +options. + ## Compare several series from a common baseline Indexing each series to its first observation compares relative change when the diff --git a/packages/charts-core/docs/reference/marks/line-and-area.md b/packages/charts-core/docs/reference/marks/line-and-area.md index 2f776f67..7a3c4978 100644 --- a/packages/charts-core/docs/reference/marks/line-and-area.md +++ b/packages/charts-core/docs/reference/marks/line-and-area.md @@ -55,6 +55,8 @@ function lineY( | `strokeOpacity` | `number` | SVG default | Stroke opacity | | `strokeWidth` | `number` | `2.25` | Stroke width | | `strokeDasharray` | `string` | None | SVG dash array | +| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape | +| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape | | `points` | `boolean` | `false` | Draws a radius-`2.5` dot at each valid point | | `curve` | `ChartCurve` | Straight segments | Optional path generator | | `states` | `readonly ChartMarkState[]` | None | Focus-driven presentation overrides | diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 5ce7d73c..99609d49 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -77,6 +77,79 @@ afterEach(() => { }) describe('Canvas renderer', () => { + it('paints authored caps and joins for both line directions', () => { + const scene = createChartScene( + defineChart({ + marks: [ + lineY([4, 9], { lineCap: 'butt', lineJoin: 'bevel' }), + lineX([4, 9], { lineCap: 'square', lineJoin: 'miter' }), + ], + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, + }), + { width: 300, height: 180 }, + ) + const container = document.createElement('div') + const surface = createCanvasChartRenderer().mount(container, () => {}) + + surface.render(scene, renderOptions()) + + const canvas = container.querySelector( + '.ts-chart-canvas__scene', + ) + const painted = canvas ? contexts.get(canvas) : undefined + if (!painted) throw new Error('Expected a painted scene canvas') + expect(painted.operations).toEqual( + expect.arrayContaining([ + 'lineCap:butt', + 'lineJoin:bevel', + 'lineCap:square', + 'lineJoin:miter', + ]), + ) + + surface.destroy() + }) + + it('paints round caps and joins by default for both line directions', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([4, 9]), lineX([4, 9])], + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, + }), + { width: 300, height: 180 }, + ) + const container = document.createElement('div') + const surface = createCanvasChartRenderer().mount(container, () => {}) + + surface.render(scene, renderOptions()) + + const canvas = container.querySelector( + '.ts-chart-canvas__scene', + ) + const painted = canvas ? contexts.get(canvas) : undefined + if (!painted) throw new Error('Expected a painted scene canvas') + expect( + painted.operations.filter((operation) => + operation.startsWith('lineCap:'), + ), + ).toEqual(['lineCap:round', 'lineCap:round']) + expect( + painted.operations.filter((operation) => + operation.startsWith('lineJoin:'), + ), + ).toEqual(['lineJoin:round', 'lineJoin:round']) + + surface.destroy() + }) + it('composes renderer-tagged marks in source order', () => { const data = [ { id: 'a', category: 'A', value: 3 }, diff --git a/packages/charts-core/src/export.test.ts b/packages/charts-core/src/export.test.ts index 0e64ed83..2218614b 100644 --- a/packages/charts-core/src/export.test.ts +++ b/packages/charts-core/src/export.test.ts @@ -18,6 +18,8 @@ describe('optional export', () => { defineChart({ marks: [ lineY([1, 3, 2], { + lineCap: 'butt', + lineJoin: 'bevel', stroke: 'currentColor', }), ], @@ -34,6 +36,8 @@ describe('optional export', () => { expect(result).toContain('width="480"') expect(result).toContain('height="260"') expect(result).toContain('aria-label="Exported chart"') + expect(result).toContain('stroke-linecap="butt"') + expect(result).toContain('stroke-linejoin="bevel"') expect(result).not.toContain('data-ts-focus-layer') }) diff --git a/packages/charts-core/src/line.test.ts b/packages/charts-core/src/line.test.ts index 7d897ad3..3b67d052 100644 --- a/packages/charts-core/src/line.test.ts +++ b/packages/charts-core/src/line.test.ts @@ -5,11 +5,13 @@ import { d3Curve } from './d3-shape' import { lineX, lineY } from './line' import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' +import type { LineXOptions, LineYOptions } from './line' import type { ChartDefinition, ChartMark, SceneNode, ScenePolyline, + SceneStyle, } from './types' interface Row { @@ -40,6 +42,75 @@ const rows: readonly Row[] = [ }, ] +describe('line presentation', () => { + it('applies authored caps and joins to vertical and horizontal lines', () => { + const scene = createChartScene( + defineChart({ + marks: [ + lineY([4, 9], { + id: 'vertical', + lineCap: 'butt', + lineJoin: 'bevel', + }), + lineX([4, 9], { + id: 'horizontal', + lineCap: 'square', + lineJoin: 'miter-clip', + }), + ], + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + }), + { width: 480, height: 260 }, + ) + const lines = flatten(scene.nodes).filter( + (node): node is ScenePolyline => node.kind === 'polyline', + ) + const svg = renderChartSvg(scene, { ariaLabel: 'Line presentation' }) + + expect(lines.map(({ style }) => style)).toEqual([ + expect.objectContaining({ lineCap: 'butt', lineJoin: 'bevel' }), + expect.objectContaining({ lineCap: 'square', lineJoin: 'miter-clip' }), + ]) + expect(svg).toContain('stroke-linecap="butt" stroke-linejoin="bevel"') + expect(svg).toContain( + 'stroke-linecap="square" stroke-linejoin="miter-clip"', + ) + }) + + it('keeps round caps and joins as the default', () => { + const scene = createChartScene( + defineChart({ + marks: [ + lineY([4, 9], { id: 'vertical' }), + lineX([4, 9], { id: 'horizontal' }), + ], + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + }), + { width: 480, height: 260 }, + ) + const lines = flatten(scene.nodes).filter( + (node): node is ScenePolyline => node.kind === 'polyline', + ) + const svg = renderChartSvg(scene, { + ariaLabel: 'Default line presentation', + }) + + expect(lines.map(({ style }) => style)).toEqual([ + expect.objectContaining({ lineCap: 'round', lineJoin: 'round' }), + expect.objectContaining({ lineCap: 'round', lineJoin: 'round' }), + ]) + expect( + svg.match(/stroke-linecap="round" stroke-linejoin="round"/g), + ).toHaveLength(2) + }) +}) + describe('lineX mark', () => { it('transposes the implicit value and index channels from lineY', () => { const values = [4, 9, 7] @@ -232,6 +303,24 @@ if (false) { expectTypeOf(numeric).toEqualTypeOf>() expectTypeOf(temporal).toEqualTypeOf>() + expectTypeOf['lineCap']>().toEqualTypeOf< + SceneStyle['lineCap'] + >() + expectTypeOf['lineJoin']>().toEqualTypeOf< + SceneStyle['lineJoin'] + >() + expectTypeOf['lineCap']>().toEqualTypeOf< + SceneStyle['lineCap'] + >() + expectTypeOf['lineJoin']>().toEqualTypeOf< + SceneStyle['lineJoin'] + >() + + // @ts-expect-error Line caps must use a renderer-supported scene value. + lineY(rows, { x: 'at', y: 'value', lineCap: 'flat' }) + + // @ts-expect-error Line joins must use a renderer-supported scene value. + lineX(rows, { x: 'value', y: 'at', lineJoin: 'sharp' }) // @ts-expect-error Horizontal line values must be numeric. lineX(rows, { x: 'category', y: 'at' }) diff --git a/packages/charts-core/src/line.ts b/packages/charts-core/src/line.ts index 1734f929..937d7e52 100644 --- a/packages/charts-core/src/line.ts +++ b/packages/charts-core/src/line.ts @@ -13,6 +13,7 @@ import type { Channel, CartesianChartMark, CartesianScaleBindings, + ChartCurve, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -24,7 +25,7 @@ import type { MarkChannelOutput, MarkScaleBindings, SceneNode, - ChartCurve, + SceneStyle, VisualChannel, } from './types' @@ -38,6 +39,8 @@ interface LineOptions strokeOpacity?: number strokeWidth?: number strokeDasharray?: string + lineCap?: SceneStyle['lineCap'] + lineJoin?: SceneStyle['lineJoin'] points?: boolean curve?: ChartCurve states?: readonly ChartMarkState>[] @@ -299,8 +302,8 @@ function createLineMark( strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 2.25, strokeDasharray: options.strokeDasharray, - lineCap: 'round', - lineJoin: 'round', + lineCap: options.lineCap || 'round', + lineJoin: options.lineJoin || 'round', }, }) segment = [] diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index a8f24f91..f8e3058a 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -28,6 +28,52 @@ const rows = [ ] describe('SVG motion', () => { + it('applies discrete line cap and join revisions synchronously', () => { + const definition = ( + lineCap: 'butt' | 'round', + lineJoin: 'bevel' | 'round', + ) => + defineChart({ + motion: { + transition: { type: 'tween', duration: 100, easing: 'linear' }, + }, + marks: [ + lineY(rows, { + id: 'series', + x: 'category', + y: 'value', + key: 'id', + lineCap, + lineJoin, + }), + ], + scales: { + x: { scale: scaleBand().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, + }) + const first = createChartScene(definition('round', 'round'), { + width: 300, + height: 200, + }) + const next = createChartScene(definition('butt', 'bevel'), { + width: 300, + height: 200, + }) + const container = document.createElement('div') + const surface = motion({ initial: false }).mount(container, () => {}) + surface.render(first, { ariaLabel: 'Line style motion' }) + const line = container.querySelector('g.ts-chart__line > path') + + surface.render(next, { ariaLabel: 'Line style motion' }) + + expect(container.querySelector('g.ts-chart__line > path')).toBe(line) + expect(line?.getAttribute('stroke-linecap')).toBe('butt') + expect(line?.getAttribute('stroke-linejoin')).toBe('bevel') + surface.destroy() + }) + it('maps client coordinates through the rendered SVG transform', () => { const scene = createChartScene( defineChart({ diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 272eab7a..5e6924db 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -37,6 +37,8 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain(' Date: Thu, 27 Aug 2026 23:49:15 +0200 Subject: [PATCH 02/10] Add Cartesian axis title styling --- .changeset/bright-axis-titles.md | 6 + API-FRICTION.md | 28 +++- benchmarks/bundle-size/README.md | 4 + .../bundle-size/universal-baseline.json | 40 +++--- benchmarks/comparison/bundle-baseline.json | 124 +++++++++--------- .../cases/70-composed-chart/case.json | 1 + .../cases/70-composed-chart/example.tsx | 24 +++- .../cases/70-composed-chart/recharts.ts | 12 ++ .../cases/70-composed-chart/tanstack.test.ts | 32 ++++- benchmarks/conformance/catalog-index.json | 1 + .../previews/70-composed-chart.svg | 2 +- benchmarks/conformance/previews/manifest.json | 6 +- .../entries/charts-axis-label-styles.ts | 32 +++++ docs/concepts/layout-axes-and-coordinates.md | 27 ++-- docs/guides/themes-and-styling.md | 31 +++++ docs/reference/scales-guides-and-color.md | 36 ++++- docs/reference/types.md | 2 +- .../concepts/layout-axes-and-coordinates.md | 27 ++-- .../docs/guides/themes-and-styling.md | 31 +++++ .../docs/reference/scales-guides-and-color.md | 36 ++++- packages/charts-core/docs/reference/types.md | 2 +- packages/charts-core/src/canvas.test.ts | 61 ++++++++- packages/charts-core/src/facet.test.ts | 19 ++- packages/charts-core/src/motion.test.ts | 66 ++++++++++ packages/charts-core/src/scene-layout.test.ts | 92 +++++++++++++ packages/charts-core/src/scene.ts | 48 +++---- packages/charts-core/src/svg-renderer.test.ts | 33 +++++ packages/charts-core/src/types.ts | 5 + .../react-native-charts/src/SvgScene.test.tsx | 39 ++++++ scripts/measure-bundles.mjs | 47 +++++-- 30 files changed, 758 insertions(+), 156 deletions(-) create mode 100644 .changeset/bright-axis-titles.md create mode 100644 benchmarks/entries/charts-axis-label-styles.ts diff --git a/.changeset/bright-axis-titles.md b/.changeset/bright-axis-titles.md new file mode 100644 index 00000000..2274bf4b --- /dev/null +++ b/.changeset/bright-axis-titles.md @@ -0,0 +1,6 @@ +--- +'@tanstack/charts': minor +--- + +Allow Cartesian axis titles to configure font size, font weight, fill, and +opacity while retaining automatic guide layout. diff --git a/API-FRICTION.md b/API-FRICTION.md index 14c78c25..15f694e4 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-26 +Last updated: 2026-08-27 ## Triage rule @@ -332,6 +332,7 @@ Each entry records: | F-293 | Root scale slots blocked named axes | API | resolved | | F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved | | F-295 | Line marks forced round endpoints | API | resolved | +| F-296 | Axis titles could not carry authored typography or paint | API | resolved | ## Findings @@ -8523,3 +8524,28 @@ Each entry records: supported values. Scene, SVG, Canvas, motion, export, and React Native tests preserve configured values. The Rewardo chart can use `lineCap: 'butt'` on its existing pink `lineY` mark. + +### F-296 - Axis titles could not carry authored typography or paint + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: migrating Rewardo dashboard charts to TanStack Charts on the + `experimental-tanstack-charts` branch and issue #93 +- Friction: tick labels accepted font size, weight, and opacity, but + `axis.label` accepted only text, offset, and motion. Rewardo's 14-pixel chart + typography and foreground treatment therefore required a generated-node CSS + override that Canvas and React Native could not honor. +- Decision: extend the existing axis-label object with `fontSize`, + `fontWeight`, `fill`, and `opacity`. Keep the string form and every omitted + object field on the existing defaults. Resolve the options into the shared + scene label so automatic guide measurement and every renderer use one + contract. +- Verification: scene layout covers configured typography, paint, default + compatibility, automatic margins, and shared facet titles. SVG, Canvas, + React Native, and motion tests exercise the same public definition. The + composed weather catalog case uses independently styled titles on three y + axes without renderer callbacks or CSS selectors. The reviewed shared-path + delta is 169 minified bytes and 29 gzip bytes for the locked line plus static + SVG consumer. A dedicated styled-title fixture adds 0.06 KiB gzip and no + retained modules over that consumer. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index bb9c0e54..0b9230aa 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -37,6 +37,10 @@ gzip ceiling. The locked shared-host entries record the reviewed integration cost, while noninteractive consumers retain only the small scene-compiler portion of that contract. +Cartesian axis-title styling is also part of the shared scene contract. Its +static SVG fixture adds no retained modules over the ordinary line consumer +and keeps authored title options under a 0.25 KiB incremental gzip ceiling. + Continuous viewports and the controlled interaction controller are also part of the default scene and host contracts. Default static SVG consumes scene clips and gradients. Their reviewed shared-path cost is recorded in the locked diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 8f88756c..7c6584b7 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": 50836, - "gzip": 19079 + "bytes": 50982, + "gzip": 19097 }, "D3-scale line + static SVG": { - "bytes": 55567, - "gzip": 20785 + "bytes": 55713, + "gzip": 20805 }, "Representative marks": { - "bytes": 75640, - "gzip": 27591 + "bytes": 75788, + "gzip": 27619 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 75118, + "gzip": 26064 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77301, + "gzip": 26854 }, "React line consumer": { - "bytes": 100789, - "gzip": 36231 + "bytes": 100937, + "gzip": 36262 }, "Compact-scale line scene": { - "bytes": 33288, - "gzip": 11944 + "bytes": 33436, + "gzip": 11953 }, "React compact-scale line consumer": { - "bytes": 83289, - "gzip": 29153 + "bytes": 83437, + "gzip": 29177 }, "Custom-scale line scene": { - "bytes": 31470, - "gzip": 11201 + "bytes": 31618, + "gzip": 11219 }, "D3 linear-scale line scene": { - "bytes": 50768, - "gzip": 19041 + "bytes": 50914, + "gzip": 19060 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 2087d60d..71f88136 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-27T21:38:32.371Z", + "generatedAt": "2026-08-27T21:42:45.654Z", "packageVersions": { "tanstack": "0.16.0", "chartjs": "4.5.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:a5332d205ee6b678314f0a444dc2198cb002c6b55b49c7bf1c5acd0ea7ed1783" + "inputDigest": "sha256:f6e6651fe8f30068b56208e243a35ea634593f85c7364b8403dfebfcb1e0d0f8" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110454, - "gzipBytes": 39901, - "brotliBytes": 35367, - "incrementalGzipBytes": 39901, - "incrementalBrotliBytes": 35367 + "minifiedBytes": 110602, + "gzipBytes": 39906, + "brotliBytes": 35329, + "incrementalGzipBytes": 39906, + "incrementalBrotliBytes": 35329 }, "tanstack-line-interactive": { - "minifiedBytes": 115895, - "gzipBytes": 41661, - "brotliBytes": 36828, - "incrementalGzipBytes": 41661, - "incrementalBrotliBytes": 36828 + "minifiedBytes": 116043, + "gzipBytes": 41675, + "brotliBytes": 36794, + "incrementalGzipBytes": 41675, + "incrementalBrotliBytes": 36794 }, "tanstack-line-advanced": { - "minifiedBytes": 123085, - "gzipBytes": 43988, - "brotliBytes": 38803, - "incrementalGzipBytes": 43988, - "incrementalBrotliBytes": 38803 + "minifiedBytes": 123233, + "gzipBytes": 44002, + "brotliBytes": 38748, + "incrementalGzipBytes": 44002, + "incrementalBrotliBytes": 38748 }, "tanstack-bar-basic": { - "minifiedBytes": 119225, - "gzipBytes": 43210, - "brotliBytes": 38168, - "incrementalGzipBytes": 43210, - "incrementalBrotliBytes": 38168 + "minifiedBytes": 119396, + "gzipBytes": 43218, + "brotliBytes": 38198, + "incrementalGzipBytes": 43218, + "incrementalBrotliBytes": 38198 }, "tanstack-bar-interactive": { - "minifiedBytes": 123521, - "gzipBytes": 44585, - "brotliBytes": 39223, - "incrementalGzipBytes": 44585, - "incrementalBrotliBytes": 39223 + "minifiedBytes": 123692, + "gzipBytes": 44605, + "brotliBytes": 39267, + "incrementalGzipBytes": 44605, + "incrementalBrotliBytes": 39267 }, "tanstack-bar-advanced": { - "minifiedBytes": 123860, - "gzipBytes": 44735, - "brotliBytes": 39349, - "incrementalGzipBytes": 44735, - "incrementalBrotliBytes": 39349 + "minifiedBytes": 124031, + "gzipBytes": 44749, + "brotliBytes": 39341, + "incrementalGzipBytes": 44749, + "incrementalBrotliBytes": 39341 }, "tanstack-area-basic": { - "minifiedBytes": 115640, - "gzipBytes": 41867, - "brotliBytes": 37073, - "incrementalGzipBytes": 41867, - "incrementalBrotliBytes": 37073 + "minifiedBytes": 115811, + "gzipBytes": 41866, + "brotliBytes": 37072, + "incrementalGzipBytes": 41866, + "incrementalBrotliBytes": 37072 }, "tanstack-area-interactive": { - "minifiedBytes": 121085, - "gzipBytes": 43594, - "brotliBytes": 38493, - "incrementalGzipBytes": 43594, - "incrementalBrotliBytes": 38493 + "minifiedBytes": 121256, + "gzipBytes": 43620, + "brotliBytes": 38585, + "incrementalGzipBytes": 43620, + "incrementalBrotliBytes": 38585 }, "tanstack-area-advanced": { - "minifiedBytes": 128457, - "gzipBytes": 46057, - "brotliBytes": 40527, - "incrementalGzipBytes": 46057, - "incrementalBrotliBytes": 40527 + "minifiedBytes": 128628, + "gzipBytes": 46064, + "brotliBytes": 40628, + "incrementalGzipBytes": 46064, + "incrementalBrotliBytes": 40628 }, "tanstack-scatter-basic": { - "minifiedBytes": 111460, - "gzipBytes": 40266, - "brotliBytes": 35596, - "incrementalGzipBytes": 40266, - "incrementalBrotliBytes": 35596 + "minifiedBytes": 111631, + "gzipBytes": 40284, + "brotliBytes": 35628, + "incrementalGzipBytes": 40284, + "incrementalBrotliBytes": 35628 }, "tanstack-scatter-interactive": { - "minifiedBytes": 116901, - "gzipBytes": 42015, - "brotliBytes": 36976, - "incrementalGzipBytes": 42015, - "incrementalBrotliBytes": 36976 + "minifiedBytes": 117072, + "gzipBytes": 42044, + "brotliBytes": 37051, + "incrementalGzipBytes": 42044, + "incrementalBrotliBytes": 37051 }, "tanstack-scatter-advanced": { - "minifiedBytes": 116917, - "gzipBytes": 42020, - "brotliBytes": 37012, - "incrementalGzipBytes": 42020, - "incrementalBrotliBytes": 37012 + "minifiedBytes": 117088, + "gzipBytes": 42050, + "brotliBytes": 37043, + "incrementalGzipBytes": 42050, + "incrementalBrotliBytes": 37043 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/70-composed-chart/case.json b/benchmarks/conformance/cases/70-composed-chart/case.json index b80c5320..bf70b2e6 100644 --- a/benchmarks/conformance/cases/70-composed-chart/case.json +++ b/benchmarks/conformance/cases/70-composed-chart/case.json @@ -11,6 +11,7 @@ "mixed marks", "independent y scales", "left axis and stacked right axes", + "styled axis titles", "daily observations", "monotone curves", "butt line cap", diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index c92d3bbc..8490d6d8 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -64,7 +64,13 @@ export const createExampleChart = (input: ChartOptions) => { scale: scaleLinear, grid: true, axis: { - label: 'Temperature (°C)', + label: { + text: 'Temperature (°C)', + fontSize: 12, + fontWeight: 700, + fill: '#8884d8', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, @@ -73,7 +79,13 @@ export const createExampleChart = (input: ChartOptions) => { side: 'right', scale: scaleLinear, axis: { - label: 'Precipitation (mm)', + label: { + text: 'Precipitation (mm)', + fontSize: 12, + fontWeight: 700, + fill: '#413ea0', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, @@ -82,7 +94,13 @@ export const createExampleChart = (input: ChartOptions) => { side: 'right', scale: scaleLinear, axis: { - label: 'Wind (m/s)', + label: { + text: 'Wind (m/s)', + fontSize: 12, + fontWeight: 700, + fill: '#ef4444', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, diff --git a/benchmarks/conformance/cases/70-composed-chart/recharts.ts b/benchmarks/conformance/cases/70-composed-chart/recharts.ts index 24a7d097..71443f44 100644 --- a/benchmarks/conformance/cases/70-composed-chart/recharts.ts +++ b/benchmarks/conformance/cases/70-composed-chart/recharts.ts @@ -55,6 +55,10 @@ function chart(input: ConformanceInput) { value: 'Temperature (°C)', angle: -90, position: 'insideLeft', + fill: '#8884d8', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(YAxis, { @@ -67,6 +71,10 @@ function chart(input: ConformanceInput) { value: 'Precipitation (mm)', angle: 90, position: 'insideRight', + fill: '#413ea0', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(YAxis, { @@ -79,6 +87,10 @@ function chart(input: ConformanceInput) { value: 'Wind (m/s)', angle: 90, position: 'insideRight', + fill: '#ef4444', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(Area, { diff --git a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts index ec59bc74..7df727ab 100644 --- a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts +++ b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts @@ -2,7 +2,7 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' import { loadTanStackSources } from '../../native-catalog' import { createExampleChart } from './tanstack' -import type { SceneNode, SceneRule } from '@tanstack/charts' +import type { SceneLabel, SceneNode, SceneRule } from '@tanstack/charts' import type { ConformanceInput } from '../../types' const input = { @@ -67,6 +67,36 @@ describe('native composed-chart bar sizing', () => { expect(windAxis?.x1).toBeGreaterThan(precipitationAxis?.x1 ?? Infinity) }) + it('styles each axis title through the chart definition', () => { + const titles = flatten(render(input).nodes).filter( + (node): node is SceneLabel => + node.kind === 'label' && node.key.endsWith('-label'), + ) + + expect(titles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Temperature (°C)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#8884d8', opacity: 0.8 }, + }), + expect.objectContaining({ + text: 'Precipitation (mm)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#413ea0', opacity: 0.8 }, + }), + expect.objectContaining({ + text: 'Wind (m/s)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#ef4444', opacity: 0.8 }, + }), + ]), + ) + }) + it('does not hide responsive bar geometry outside the definition', async () => { const closure = await loadTanStackSources('70-composed-chart') const source = closure.files.map((file) => file.source).join('\n') diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 54a371a0..600b1fca 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -2392,6 +2392,7 @@ "mixed marks", "independent y scales", "left axis and stacked right axes", + "styled axis titles", "daily observations", "monotone curves", "butt line cap", diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg index 091ffce8..3bb5b3d1 100644 --- a/benchmarks/conformance/previews/70-composed-chart.svg +++ b/benchmarks/conformance/previews/70-composed-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 8f98afe7..487e8ed8 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "99895d05b94419426e1065524564e39ba82fc1cdeae9c5875b4718ba6aeae5ee", + "sourceHash": "e8fb24cd96d6fbfbb8327eb97ab506f7f60fd4870baa986a725da75d736c87b9", "assets": [ { "id": "01-line-gaps", @@ -306,8 +306,8 @@ }, { "id": "70-composed-chart", - "sha256": "db93c404c4e097cad237dca330dab72a1080f1e38e873755ef7ca86a81bbeb30", - "bytes": 14435 + "sha256": "9d79b12f0fbcf14f8bfa4115ea0ee75147fe0d9fa27ca2ec20a0f29a50857fc0", + "bytes": 14480 }, { "id": "71-recharts-population-pyramid", diff --git a/benchmarks/entries/charts-axis-label-styles.ts b/benchmarks/entries/charts-axis-label-styles.ts new file mode 100644 index 00000000..a0c82ad6 --- /dev/null +++ b/benchmarks/entries/charts-axis-label-styles.ts @@ -0,0 +1,32 @@ +import { + createChartScene, + defineChart, + lineY, + renderChartSvg, +} from '@tanstack/charts' +import { scaleLinear } from 'd3-scale' + +const definition = defineChart({ + marks: [lineY([4, 9, 7])], + scales: { + x: { + scale: scaleLinear().domain([0, 2]), + axis: { + label: { + text: 'Quarter', + fontSize: 13, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.8, + }, + }, + }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Styled axis title', + }) +} diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 9aa67858..ae21a276 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -152,20 +152,27 @@ const x = { format: (date: Date) => monthFormatter.format(date), }, tickLabels: { rotate: -30 }, - label: { text: 'Month', offset: 12 }, + label: { + text: 'Month', + offset: 12, + fontSize: 13, + fontWeight: 600, + fill: '#334155', + opacity: 0.9, + }, }, } ``` -| Option | Purpose | -| ----------------- | ---------------------------------------------------- | -| `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | -| `axis.ticks` | Configure candidates, stubs, padding, and formatting | -| `axis.tickLabels` | Configure label rotation and collision thinning | -| `axis.label` | Configure the axis title and offset | -| `grid` | Draw grid lines at semantic candidates | -| `reverse` | Reverse the responsive range | +| Option | Purpose | +| ----------------- | -------------------------------------------------------- | +| `axis` | Configure the axis or hide it with `false` | +| `axis.line` | Show or hide the baseline | +| `axis.ticks` | Configure candidates, stubs, padding, and formatting | +| `axis.tickLabels` | Configure label rotation and collision thinning | +| `axis.label` | Configure axis title text, typography, paint, and offset | +| `grid` | Draw grid lines at semantic candidates | +| `reverse` | Reverse the responsive range | The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index cbea3392..1cd79e40 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -93,6 +93,37 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Axis title styling + +String axis titles use the chart foreground and built-in title typography. +Use the object form when one title needs an explicit text color, opacity, size, +weight, or offset: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale, axis: { label: 'Month' } }, + y: { + scale: yScale, + axis: { + label: { + text: 'Revenue (USD)', + fontSize: 14, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.85, + }, + }, + }, + }, +}) +``` + +`fill` is the title's text color. The layout measures the configured font size +and weight before resolving automatic guide margins, so larger titles do not +need matching manual margins. + ## Canvas styling The Canvas renderer resolves scene paints such as `currentColor` and CSS diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index 69af1cab..b8913e52 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -228,7 +228,17 @@ interface ChartAxisOptions { keep?: readonly TValue[] } } - label?: string | { text: string; offset?: number | 'auto' } + label?: + | string + | { + text: string + offset?: number | 'auto' + fontSize?: number + fontWeight?: number + fill?: string + opacity?: number + motion?: ChartMotionDefinition + } } } ``` @@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +Axis titles keep the compact string form when only text is needed. Use the +object form for title typography, paint, offset, or motion: + +```ts +const y = { + scale: scaleLinear, + axis: { + label: { + text: 'Average order value (PLN)', + fontSize: 14, + fontWeight: 500, + fill: '#363636', + opacity: 0.9, + }, + }, +} +``` + +`fill` sets the text color. Omitted presentation fields retain the existing +title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight +600, theme foreground fill, and 0.76 fill opacity. Configured font size and +weight participate in text measurement, automatic offsets, and automatic +margins for SVG, Canvas, and native rendering. + ## Named scales and multiple axes The reserved `x` and `y` entries are the default bindings for Cartesian marks. diff --git a/docs/reference/types.md b/docs/reference/types.md index f219b23b..34536583 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | | `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property | | `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept | -| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset | +| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset | | `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain | | `ChartScaleInput` | Factory or configured positional scale instance | | `InferableScaleLike` | Domain-configurable scale returned by a factory | diff --git a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md index 9aa67858..ae21a276 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -152,20 +152,27 @@ const x = { format: (date: Date) => monthFormatter.format(date), }, tickLabels: { rotate: -30 }, - label: { text: 'Month', offset: 12 }, + label: { + text: 'Month', + offset: 12, + fontSize: 13, + fontWeight: 600, + fill: '#334155', + opacity: 0.9, + }, }, } ``` -| Option | Purpose | -| ----------------- | ---------------------------------------------------- | -| `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | -| `axis.ticks` | Configure candidates, stubs, padding, and formatting | -| `axis.tickLabels` | Configure label rotation and collision thinning | -| `axis.label` | Configure the axis title and offset | -| `grid` | Draw grid lines at semantic candidates | -| `reverse` | Reverse the responsive range | +| Option | Purpose | +| ----------------- | -------------------------------------------------------- | +| `axis` | Configure the axis or hide it with `false` | +| `axis.line` | Show or hide the baseline | +| `axis.ticks` | Configure candidates, stubs, padding, and formatting | +| `axis.tickLabels` | Configure label rotation and collision thinning | +| `axis.label` | Configure axis title text, typography, paint, and offset | +| `grid` | Draw grid lines at semantic candidates | +| `reverse` | Reverse the responsive range | The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index cbea3392..1cd79e40 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -93,6 +93,37 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Axis title styling + +String axis titles use the chart foreground and built-in title typography. +Use the object form when one title needs an explicit text color, opacity, size, +weight, or offset: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale, axis: { label: 'Month' } }, + y: { + scale: yScale, + axis: { + label: { + text: 'Revenue (USD)', + fontSize: 14, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.85, + }, + }, + }, + }, +}) +``` + +`fill` is the title's text color. The layout measures the configured font size +and weight before resolving automatic guide margins, so larger titles do not +need matching manual margins. + ## Canvas styling The Canvas renderer resolves scene paints such as `currentColor` and CSS diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index 69af1cab..b8913e52 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -228,7 +228,17 @@ interface ChartAxisOptions { keep?: readonly TValue[] } } - label?: string | { text: string; offset?: number | 'auto' } + label?: + | string + | { + text: string + offset?: number | 'auto' + fontSize?: number + fontWeight?: number + fill?: string + opacity?: number + motion?: ChartMotionDefinition + } } } ``` @@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +Axis titles keep the compact string form when only text is needed. Use the +object form for title typography, paint, offset, or motion: + +```ts +const y = { + scale: scaleLinear, + axis: { + label: { + text: 'Average order value (PLN)', + fontSize: 14, + fontWeight: 500, + fill: '#363636', + opacity: 0.9, + }, + }, +} +``` + +`fill` sets the text color. Omitted presentation fields retain the existing +title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight +600, theme foreground fill, and 0.76 fill opacity. Configured font size and +weight participate in text measurement, automatic offsets, and automatic +margins for SVG, Canvas, and native rendering. + ## Named scales and multiple axes The reserved `x` and `y` entries are the default bindings for Cartesian marks. diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index f219b23b..34536583 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | | `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property | | `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept | -| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset | +| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset | | `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain | | `ChartScaleInput` | Factory or configured positional scale instance | | `InferableScaleLike` | Domain-configurable scale returned by a factory | diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 99609d49..190b9fa9 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -37,6 +37,11 @@ import type { ChartScene, SceneNode } from './types' interface FakeCanvasContext { operations: string[] gradientStops: Array<[number, string]> + textPaints: Array<{ + text: string + globalAlpha: number + font: string + }> context: CanvasRenderingContext2D } @@ -1061,6 +1066,49 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints Cartesian axis-title typography and paint', () => { + const definition = defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }) + const container = document.createElement('div') + const surface = canvasChartRenderer.mount(container, () => {}) + const generated = createChartScene(definition, { + width: 480, + height: 260, + }) + const title = generated.nodes + .flatMap((node) => (node.kind === 'group' ? node.children : [node])) + .find((node) => node.key === 'y-label') + + expect(title?.style?.fill).toBe('#0f766e') + surface.render(generated, { ariaLabel: 'Revenue chart' }) + + const painted = contexts + .get(surface.sceneCanvas) + ?.textPaints.find(({ text }) => text === 'Revenue') + expect(painted).toMatchObject({ + globalAlpha: 0.6, + }) + expect(painted?.font).toMatch(/650 17px/) + surface.destroy() + }) + it('snaps retargeting focus candidates without adding interaction points', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -2505,6 +2553,7 @@ function renderOptions(): ChartSurfaceRenderOptions { function fakeContext(): FakeCanvasContext { const operations: string[] = [] const gradientStops: Array<[number, string]> = [] + const textPaints: FakeCanvasContext['textPaints'] = [] let fillStyle: string | CanvasGradient = '#000000' let strokeStyle: string | CanvasGradient = '#000000' let lineWidth = 1 @@ -2553,8 +2602,14 @@ function fakeContext(): FakeCanvasContext { `stroke:${String(strokeStyle)}:${lineWidth}:${globalAlpha}`, ) }, - fillText: (text: string, x: number, y: number) => - operations.push(`fillText:${text},${x},${y}`), + fillText: (text: string, x: number, y: number) => { + operations.push(`fillText:${text},${x},${y}`) + textPaints.push({ + text, + globalAlpha, + font: context.font, + }) + }, strokeText: (text: string, x: number, y: number) => operations.push(`strokeText:${text},${x},${y}`), setLineDash: (values: number[]) => @@ -2610,5 +2665,5 @@ function fakeContext(): FakeCanvasContext { textAlign: 'left', textBaseline: 'alphabetic', } as unknown as CanvasRenderingContext2D - return { operations, gradientStops, context } + return { operations, gradientStops, textPaints, context } } diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index e1d0fd43..0089eb40 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -178,7 +178,15 @@ describe('facets', () => { }, y: { scale: scaleLinear().domain([0, 6]), - axis: { label: 'Vertical' }, + axis: { + label: { + text: 'Vertical', + fontSize: 18, + fontWeight: 700, + fill: '#7c3aed', + opacity: 0.5, + }, + }, }, }, }), @@ -201,6 +209,10 @@ describe('facets', () => { ) const aPoints = scene.points.filter((point) => point.datum.group === 'A') const bPoints = scene.points.filter((point) => point.datum.group === 'B') + const yTitle = nodes.find( + (node): node is Extract => + node.kind === 'label' && node.key === 'facet-0:y-label', + ) expect(yAxes).toHaveLength(1) expect(xAxes).toHaveLength(2) @@ -210,6 +222,11 @@ describe('facets', () => { expect(nodes.filter((node) => node.key === 'facet-0:y-label')).toHaveLength( 1, ) + expect(yTitle).toMatchObject({ + fontSize: 18, + fontWeight: 700, + style: { fill: '#7c3aed', opacity: 0.5 }, + }) expect(yLabels).toHaveLength( new Set(yLabels.map((node) => (node.kind === 'label' ? node.text : ''))) .size, diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index f8e3058a..5a9a6772 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -2152,6 +2152,72 @@ describe('SVG motion', () => { frames.restore() }) + it('applies axis-title motion to configured typography and opacity', () => { + const definition = ( + fontSize: number, + fontWeight: number, + opacity: number, + ) => + defineChart({ + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize, + fontWeight, + opacity, + motion: { + transition: { + type: 'tween', + duration: 100, + easing: 'linear', + }, + }, + }, + }, + }, + }, + }) + const first = createChartScene(definition(10, 400, 0.4), { + width: 300, + height: 200, + }) + const next = createChartScene(definition(20, 700, 0.8), { + width: 300, + height: 200, + }) + const container = document.createElement('div') + const surface = motion({ initial: false }).mount(container, () => {}) + surface.render(first, { ariaLabel: 'Axis title typography' }) + const frames = installManagedFrames() + surface.render(next, { ariaLabel: 'Axis title typography' }) + const label = container.querySelector( + '[data-ts-key="y-label"]', + ) + + expect(label?.getAttribute('font-size')).toBe('10') + expect(label?.getAttribute('font-weight')).toBe('400') + expect(label?.getAttribute('opacity')).toBe('0.4') + frames.run(0) + frames.run(50) + expect(Number(label?.getAttribute('font-size'))).toBeCloseTo(15) + expect(Number(label?.getAttribute('font-weight'))).toBeCloseTo(550) + expect(Number(label?.getAttribute('opacity'))).toBeCloseTo(0.6) + frames.run(100) + expect(label?.getAttribute('font-size')).toBe('20') + expect(label?.getAttribute('font-weight')).toBe('700') + expect(label?.getAttribute('opacity')).toBe('0.8') + + surface.destroy() + frames.restore() + }) + it('reports radial-bar paths with the semantic bar motion role', () => { const roles: string[] = [] const definition = (value: number) => diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts index 8d2d8492..9b3b7aeb 100644 --- a/packages/charts-core/src/scene-layout.test.ts +++ b/packages/charts-core/src/scene-layout.test.ts @@ -6,6 +6,7 @@ import { createMark } from './mark' import { createChartScene, defineChart } from './scene' import { text } from './text' import type { + ChartAxisLabelOptions, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -240,6 +241,97 @@ describe('automatic scene guide layout', () => { expect(yLabels.every(({ anchor }) => anchor === 'end')).toBe(true) }) + it('applies axis-title typography and paint while preserving string-label defaults', () => { + const xLabel = { + text: 'Styled horizontal title', + fontSize: 18, + fontWeight: 700, + fill: '#7c3aed', + opacity: 0.45, + } satisfies ChartAxisLabelOptions + const scene = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { label: xLabel }, + }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { label: 'Default vertical title' }, + }, + }, + }), + { width: 480, height: 240 }, + { measureText }, + ) + const labels = flatten(scene.nodes).filter( + (node): node is SceneLabel => node.kind === 'label', + ) + const styled = labels.find(({ key }) => key === 'x-label') + const defaulted = labels.find(({ key }) => key === 'y-label') + + expect(styled).toMatchObject({ + text: xLabel.text, + fontSize: 18, + fontWeight: 700, + style: { fill: '#7c3aed', opacity: 0.45 }, + }) + expect(defaulted).toMatchObject({ + text: 'Default vertical title', + fontSize: 11, + fontWeight: 600, + style: { fill: 'currentColor', fillOpacity: 0.76 }, + }) + }) + + it('measures configured axis-title typography into automatic margins', () => { + const definition = (fontSize: number, fontWeight: number) => + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: false, + label: { + text: 'Average order value', + fontSize, + fontWeight, + }, + }, + }, + }, + }) + const measured = vi.fn(measureText) + const regular = createChartScene( + definition(11, 600), + { width: 480, height: 240 }, + { measureText: measured }, + ) + const enlarged = createChartScene( + definition(24, 750), + { width: 480, height: 240 }, + { measureText: measured }, + ) + + expect(enlarged.margin.left).toBeGreaterThan(regular.margin.left) + expect(measured).toHaveBeenCalledWith( + 'Average order value', + expect.objectContaining({ fontSize: 24, fontWeight: 750 }), + ) + const title = flatten(enlarged.nodes).find( + (node): node is SceneLabel => + node.kind === 'label' && node.key === 'y-label', + ) + if (!title) throw new Error('Expected a y-axis title') + const bounds = measureSceneLabelBounds(title, measureText) + expect(bounds.x).toBeGreaterThanOrEqual(3.99) + }) + it('measures accessor-resolved tick-label bounds into automatic margins', () => { const definition = (fontSize: number, dx: number) => defineChart({ diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..267bfef4 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1501,9 +1501,12 @@ function createAxes( children.push(candidate.label) } - const labelText = axisLabelText(presentation) + const axisLabel = presentation?.label + const labelText = + typeof axisLabel === 'string' ? axisLabel : axisLabel?.text if (!labelText) return - const labelOffset = axisLabelOffset(presentation) + const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined + const labelOffset = labelOptions?.offset ?? 'auto' const explicitOffset = labelOffset !== 'auto' const label: SceneLabel = { kind: 'label', @@ -1515,9 +1518,13 @@ function createAxes( text: labelText, anchor: 'middle', baseline: bottom && !explicitOffset ? 'hanging' : 'auto', - fontSize: width < 360 ? 10 : 11, - fontWeight: 600, - style: { fill: theme.foreground, fillOpacity: 0.76 }, + fontSize: labelOptions?.fontSize ?? (width < 360 ? 10 : 11), + fontWeight: labelOptions?.fontWeight ?? 600, + style: { + fill: labelOptions?.fill ?? theme.foreground, + fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, + opacity: labelOptions?.opacity, + }, } includeOutward(measureSceneLabelBounds(label, measureText)) children.push(label) @@ -1594,8 +1601,11 @@ function createAxes( children.push(candidate.label) } - const labelText = axisLabelText(presentation) + 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`, @@ -1605,11 +1615,15 @@ function createAxes( anchor: 'middle', baseline: 'middle', rotate: right ? 90 : -90, - fontSize: 11, - fontWeight: 600, - style: { fill: theme.foreground, fillOpacity: 0.76 }, + fontSize: labelOptions?.fontSize ?? 11, + fontWeight: labelOptions?.fontWeight ?? 600, + style: { + fill: labelOptions?.fill ?? theme.foreground, + fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, + opacity: labelOptions?.opacity, + }, } - const labelOffset = axisLabelOffset(presentation) + const labelOffset = labelOptions?.offset ?? 'auto' if (labelOffset !== 'auto') { label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)) } else { @@ -1676,20 +1690,6 @@ function tickLabelPresentation( return axis?.tickLabels ?? {} } -function axisLabelText( - axis: ChartAxisPresentationOptions | undefined, -): string | undefined { - return typeof axis?.label === 'string' ? axis.label : axis?.label?.text -} - -function axisLabelOffset( - axis: ChartAxisPresentationOptions | undefined, -): number | 'auto' { - return typeof axis?.label === 'object' - ? (axis.label.offset ?? 'auto') - : 'auto' -} - interface TickLabelCandidate { value: ChartValue label: SceneLabel diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index af0d7c39..a3ecbb2d 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -1,4 +1,7 @@ +import { scaleLinear } from 'd3-scale' import { describe, expect, it } from 'vitest' +import { lineY } from './line' +import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' import type { ChartScene } from './types' @@ -13,6 +16,36 @@ describe('SVG scene renderer', () => { ) expect(svg).not.toContain('M99,99Z') }) + + it('serializes Cartesian axis-title typography and paint', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }), + { width: 480, height: 260 }, + ) + const svg = renderChartSvg(scene, { ariaLabel: 'Revenue chart' }) + + expect(svg).toMatch( + /]* fill="#0f766e" opacity="0\.6"[^>]* font-size="17" font-weight="650"/, + ) + }) }) function testScene(): ChartScene { diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 3ed32bb9..276d5a17 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -381,6 +381,11 @@ export interface ChartAxisTickLabelOptions { export interface ChartAxisLabelOptions { text: string offset?: number | 'auto' + fontSize?: number + fontWeight?: number + /** Text fill color. Defaults to the theme foreground. */ + fill?: string + opacity?: number motion?: ChartMotionDefinition } diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 5e6924db..6c23e377 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -1,6 +1,8 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' +import { createChartScene, defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' import type { ChartScene, SceneNode } from '@tanstack/charts/types' import { NativeChartFocusOverlay } from './FocusOverlay' import { resolveNativePaint } from './paint' @@ -72,6 +74,43 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain('font-size="24"') }) + it('renders Cartesian axis-title typography and paint', () => { + const generated = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }), + { width: 480, height: 260 }, + ) + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toMatch( + /]*fill="#0f766e"[^>]*opacity="0\.6"[^>]*font-size="17" font-weight="650"[^>]*>Revenue<\/text>/, + ) + }) + it('makes the two unsupported SVG joins an explicit lossy mapping', () => { expect(resolveNativeLineJoin('arcs')).toBe('round') expect(resolveNativeLineJoin('miter-clip')).toBe('miter') diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..f51c5ca9 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -696,7 +696,7 @@ const entries = [ budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 24.25, + 24.28, ), budgeted( 'D3-scale dots + static SVG', @@ -761,6 +761,27 @@ const entries = [ }, }, ), + incrementalBudgeted( + 'Axis title styles + static SVG', + 'benchmarks/entries/charts-axis-label-styles.ts', + 'D3-scale line + static SVG', + 0.25, + { + inputBoundary: { + forbid: [ + 'focusGuide', + 'focusMark', + 'motionRuntime', + 'springRuntime', + 'tooltipRuntime', + 'tooltipPortal', + 'd3GeometryRuntime', + ], + addedFrom: 'D3-scale line + static SVG', + allowAdded: [], + }, + }, + ), budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', @@ -903,7 +924,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 27.75, + 27.77, { inputBoundary: { require: [ @@ -1072,7 +1093,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 20.11, + 20.13, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1102,7 +1123,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.1, + 20.11, { inputBoundary: { forbid: [ @@ -1198,7 +1219,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 26.8, + 26.81, ), locked( 'Representative marks', @@ -1661,7 +1682,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 28.47, + 28.5, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1829,7 +1850,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 52.82, + 52.84, ), locked( 'Custom-scale line scene', @@ -1855,7 +1876,7 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 20.9, + 20.91, ), budgeted( 'D3 time-scale line scene', @@ -1865,12 +1886,12 @@ const entries = [ budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 22.61, + 22.63, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 21.5, + 21.53, ), budgeted( 'Direct D3 time + TanStack UTC line', @@ -1880,12 +1901,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 36.84, + 36.87, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 42.07, + 42.11, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2089,7 +2110,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 53.72, + 53.74, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From 0ae09c6f87d3c05ab3c54fafbe8123ed4172ea63 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 27 Aug 2026 23:57:06 +0200 Subject: [PATCH 03/10] Allow configuring focus ring appearance --- .changeset/focus-ring-options.md | 5 + API-FRICTION.md | 22 +++++ .../cases/34-pointer-tooltip/example.tsx | 5 + .../previews/34-pointer-tooltip.svg | 2 +- docs/examples/interactive-charts.md | 15 +++ docs/reference/chart-definitions.md | 5 + docs/reference/dom-host.md | 2 +- docs/reference/focus-and-interaction.md | 31 +++++++ .../docs/examples/interactive-charts.md | 15 +++ .../docs/reference/chart-definitions.md | 5 + .../charts-core/docs/reference/dom-host.md | 2 +- .../docs/reference/focus-and-interaction.md | 31 +++++++ packages/charts-core/src/canvas.test.ts | 51 +++++++++- packages/charts-core/src/index.ts | 1 + packages/charts-core/src/scene.test.ts | 93 +++++++++++++++++++ packages/charts-core/src/scene.ts | 6 +- packages/charts-core/src/svg-surface.test.ts | 56 +++++++++++ packages/charts-core/src/types.ts | 15 ++- packages/charts-core/src/universal-types.ts | 1 + .../react-native-charts/src/Chart.test.tsx | 48 ++++++++++ scripts/measure-bundles.mjs | 6 +- 21 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 .changeset/focus-ring-options.md diff --git a/.changeset/focus-ring-options.md b/.changeset/focus-ring-options.md new file mode 100644 index 00000000..a2597a29 --- /dev/null +++ b/.changeset/focus-ring-options.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Allow the built-in focus ring to configure its radius, stroke width, fill, and stroke while preserving the existing boolean behavior and defaults. diff --git a/API-FRICTION.md b/API-FRICTION.md index 15f694e4..db69aed1 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -333,6 +333,7 @@ Each entry records: | F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved | | F-295 | Line marks forced round endpoints | API | resolved | | F-296 | Axis titles could not carry authored typography or paint | API | resolved | +| F-297 | Focus ring paint required generated SVG selectors | API | resolved | ## Findings @@ -8549,3 +8550,24 @@ Each entry records: delta is 169 minified bytes and 29 gzip bytes for the locked line plus static SVG consumer. A dedicated styled-title fixture adds 0.06 KiB gzip and no retained modules over that consumer. + +### F-297 - Focus ring paint required generated SVG selectors + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: Rewardo's TanStack Charts migration and issue #94 +- Friction: the built-in primary-point ring exposed only a boolean definition + option. Rewardo had to target `.ts-chart__focus-layer--default circle` to + reduce the fixed 5-pixel radius, tying application styling to generated SVG + structure and leaving Canvas and React Native without the same control. +- Decision: extend `focusRing` to accept a reusable `ChartFocusRingOptions` + object with radius, stroke width, fill, and stroke. Preserve `true`, `false`, + and every existing default. An omitted stroke keeps each point's resolved + series color. +- Verification: scene, SVG, Canvas, and React Native tests cover configured + geometry and paint, series-color fallback, keyboard focus, and hidden + accessibility presentation. The catalog's pointer-tooltip case uses the + object form, and the reference docs record the complete contract. Locked + universal consumers add 37-48 minified bytes and 14-28 gzip bytes; the + reviewed bundle baselines and budgets pass. diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx index 319b366e..8d989259 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx +++ b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx @@ -69,6 +69,11 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) { }, }, { + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, keyboard: true, tooltip: { use: tooltip, ...interactiveTooltip }, }, diff --git a/benchmarks/conformance/previews/34-pointer-tooltip.svg b/benchmarks/conformance/previews/34-pointer-tooltip.svg index fdb6d046..4bc5eb3a 100644 --- a/benchmarks/conformance/previews/34-pointer-tooltip.svg +++ b/benchmarks/conformance/previews/34-pointer-tooltip.svg @@ -1 +1 @@ - + diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index 62f3b914..9a3bef96 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -49,6 +49,21 @@ dot(rows, { }) ``` +The same definition can make the built-in indicator smaller while retaining +its series-colored outline: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + A focused band emphasizes the shared x value for every series. Its position diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index 74996619..c18c84fe 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -103,6 +103,11 @@ does not return or own them. belong to both static and responsive definitions. Hosts and framework adapters do not override them. +`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with +`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in +defaults, including the focused point's resolved color for `stroke`. See +[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). + Each `ChartControl` resolves after final scales and plot bounds exist. It can provide renderer-neutral fallback nodes and an optional host control. Control IDs and host-control identities must be unique. Browser hosts remove a diff --git a/docs/reference/dom-host.md b/docs/reference/dom-host.md index 85633106..e094e536 100644 --- a/docs/reference/dom-host.md +++ b/docs/reference/dom-host.md @@ -116,7 +116,7 @@ The definition owns these chart controls: | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- | | `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus | | `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer | -| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only | +| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only | | `cursor` | None | Focus-snapped or free application-owned cursor binding | | `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index | | `svgAnimation` | `false` | Keyed attribute, enter, and exit animation | diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 96ddf62d..9ba1695a 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set definition `focusRing: false` only when authored focus geometry replaces that indicator. +### Built-in focus ring + +Pass an options object to change the built-in ring without targeting generated +SVG: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + +| Option | Default | +| ------------- | ----------------------------------------- | +| `radius` | `5` | +| `strokeWidth` | `2.5` | +| `fill` | `var(--ts-chart-focus-fill, Canvas)` | +| `stroke` | The focused point's resolved series color | + +The object form still enables the ring. `true` keeps every default and `false` +removes only the built-in ring. The definition compiles this presentation into +ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom +mark renderers, and the optional motion renderer share the same values. The +ring remains hidden from the accessibility tree; the chart root continues to +report focus and values. + Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable structural keys, so the optional motion renderer can animate rapid focus diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index 62f3b914..9a3bef96 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -49,6 +49,21 @@ dot(rows, { }) ``` +The same definition can make the built-in indicator smaller while retaining +its series-colored outline: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + A focused band emphasizes the shared x value for every series. Its position diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index 74996619..c18c84fe 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -103,6 +103,11 @@ does not return or own them. belong to both static and responsive definitions. Hosts and framework adapters do not override them. +`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with +`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in +defaults, including the focused point's resolved color for `stroke`. See +[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). + Each `ChartControl` resolves after final scales and plot bounds exist. It can provide renderer-neutral fallback nodes and an optional host control. Control IDs and host-control identities must be unique. Browser hosts remove a diff --git a/packages/charts-core/docs/reference/dom-host.md b/packages/charts-core/docs/reference/dom-host.md index 85633106..e094e536 100644 --- a/packages/charts-core/docs/reference/dom-host.md +++ b/packages/charts-core/docs/reference/dom-host.md @@ -116,7 +116,7 @@ The definition owns these chart controls: | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- | | `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus | | `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer | -| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only | +| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only | | `cursor` | None | Focus-snapped or free application-owned cursor binding | | `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index | | `svgAnimation` | `false` | Keyed attribute, enter, and exit animation | diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 96ddf62d..9ba1695a 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set definition `focusRing: false` only when authored focus geometry replaces that indicator. +### Built-in focus ring + +Pass an options object to change the built-in ring without targeting generated +SVG: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + +| Option | Default | +| ------------- | ----------------------------------------- | +| `radius` | `5` | +| `strokeWidth` | `2.5` | +| `fill` | `var(--ts-chart-focus-fill, Canvas)` | +| `stroke` | The focused point's resolved series color | + +The object form still enables the ring. `true` keeps every default and `false` +removes only the built-in ring. The definition compiles this presentation into +ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom +mark renderers, and the optional motion renderer share the same values. The +ring remains hidden from the accessibility tree; the chart root continues to +report focus and values. + Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable structural keys, so the optional motion renderer can animate rapid focus diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 190b9fa9..9fdd85ee 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -1109,6 +1109,51 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints configured focus-ring geometry and paint on Canvas', () => { + const chart = createChartScene( + defineChart({ + marks: [dot([{ x: 1, y: 2 }], { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 200, height: 120 }, + ) + const point = chart.points[0] + if (!point) throw new Error('Expected a focus point') + const container = document.createElement('div') + document.body.append(container) + const surface = createCanvasChartRenderer().mount(container, () => {}) + surface.render(chart, renderOptions()) + const focus = contexts.get(surface.focusCanvas) + if (!focus) throw new Error('Expected a Canvas focus layer') + + surface.paintFocus({ + primary: point, + group: [point], + source: 'pointer', + pinned: false, + }) + + expect(focus.operations).toContain(`arc:${point.x},${point.y},4`) + expect(focus.operations).toEqual( + expect.arrayContaining([expect.stringMatching(/^stroke:.*:1\.5:1$/)]), + ) + expect(focus.operations).toEqual( + expect.arrayContaining([expect.stringMatching(/^fill:.*(?:ffffff|255)/)]), + ) + surface.destroy() + container.remove() + }) + it('snaps retargeting focus candidates without adding interaction points', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -2588,14 +2633,16 @@ function fakeContext(): FakeCanvasContext { operations.push(`translate:${values.join(',')}`), rotate: (value: number) => operations.push(`rotate:${value}`), clip: () => operations.push('clip'), - fill: (pathOrRule?: Path2D | CanvasFillRule) => + fill: (pathOrRule?: Path2D | CanvasFillRule) => { operations.push( pathOrRule === 'evenodd' ? 'fill:evenodd' : pathOrRule ? 'fill:path' : 'fill:current', - ), + ) + operations.push(`fill:${String(fillStyle)}:${globalAlpha}`) + }, stroke: (path?: Path2D) => { operations.push(path ? 'stroke:path' : 'stroke:current') operations.push( diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 192eb680..f8b24647 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -336,6 +336,7 @@ export type { ChartFocusAnchor, ChartFocusCursorBinding, ChartFocusPresentation, + ChartFocusRingOptions, ChartFocusMode, ChartFocusFilter, ChartFocusAffinity, diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 77158588..c7908221 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -173,6 +173,99 @@ describe('native mark and channel scene', () => { ) }) + it('configures the built-in focus ring while retaining point-color defaults', () => { + const rows = [ + { id: 'alpha', series: 'Alpha', x: 0, y: 1 }, + { id: 'beta', series: 'Beta', x: 1, y: 2 }, + ] + const spec = { + marks: [ + lineY(rows, { + id: 'series', + x: 'x', + y: 'y', + z: 'series', + key: 'id', + }), + ], + color: { + domain: ['Alpha', 'Beta'], + range: ['#2563eb', '#f97316'], + }, + theme: { background: '#f8fafc', foreground: '#0f172a' }, + ...linearAxes([0, 1], [0, 2]), + } as const + const scene = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + }, + }), + { width: 320, height: 180 }, + ) + const focusDots = flatten(scene.nodes).filter( + (node) => + node.kind === 'dot' && + scene.points.some((point) => point.key === node.key) && + node.radius === 4, + ) + + expect(scene.theme).toMatchObject({ + background: '#f8fafc', + foreground: '#0f172a', + }) + expect(focusDots.map((node) => node.style)).toEqual([ + { fill: '#ffffff', stroke: '#2563eb', strokeWidth: 1.5 }, + { fill: '#ffffff', stroke: '#f97316', strokeWidth: 1.5 }, + ]) + + const fixedStroke = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: 3, + strokeWidth: 2, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 320, height: 180 }, + ) + const fixedDots = flatten(fixedStroke.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 3, + ) + + expect(fixedDots.map((node) => node.style)).toEqual([ + { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 }, + { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 }, + ]) + + const booleanRing = createChartScene( + defineChart({ ...spec, focusRing: true }), + { width: 320, height: 180 }, + ) + const booleanDots = flatten(booleanRing.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 5, + ) + + expect(booleanDots.map((node) => node.style)).toEqual([ + { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#2563eb', + strokeWidth: 2.5, + }, + { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#f97316', + strokeWidth: 2.5, + }, + ]) + expectTypeOf(scene).toMatchTypeOf>() + }) + it('rejects duplicate control ids', () => { const behavior = { id: 'duplicate', resolve: () => ({}) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 267bfef4..87ec0bff 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -27,6 +27,7 @@ import type { ResponsiveChartConfig, ChartColorLegend, ChartFocusFilter, + ChartFocusRingOptions, ChartLayoutOptions, ChartMargin, ChartMark, @@ -529,6 +530,8 @@ function createChartSceneWithScaleResolver< definition.focusRing !== false && points.length ) { + const { radius = 5, ...focusRingStyle } = (definition.focusRing || + {}) as ChartFocusRingOptions for (const entry of defaultFocusEntries) { nodes.push({ kind: 'group', @@ -547,11 +550,12 @@ function createChartSceneWithScaleResolver< key: point.key, x: point.x, y: point.y, - radius: 5, + radius, style: { fill: 'var(--ts-chart-focus-fill, Canvas)', stroke: point.color, strokeWidth: 2.5, + ...focusRingStyle, }, })), }) diff --git a/packages/charts-core/src/svg-surface.test.ts b/packages/charts-core/src/svg-surface.test.ts index 0f23946f..847e9f3d 100644 --- a/packages/charts-core/src/svg-surface.test.ts +++ b/packages/charts-core/src/svg-surface.test.ts @@ -9,6 +9,62 @@ import { renderChartSvgWithResources } from './svg-resources' import { createSvgChartRenderer, svgChartRenderer } from './svg-surface' describe('SVG surface coordinates', () => { + it('paints a configured focus ring without exposing it to accessibility', () => { + const scene = createChartScene( + defineChart({ + marks: [ + lineY([{ x: 1, y: 2 }], { + id: 'trend', + x: 'x', + y: 'y', + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 200, height: 120 }, + ) + const point = scene.points[0] + if (!point) throw new Error('Expected a focus point') + const container = document.createElement('div') + const surface = svgChartRenderer.mount(container, () => {}) + surface.render(scene, { ariaLabel: 'Configured focus ring' }) + const svg = container.querySelector('svg') + const layer = container.querySelector( + '.ts-chart__focus-layer--default', + ) + const ring = layer?.querySelector('circle') + + expect(svg?.getAttribute('role')).toBe('img') + expect(svg?.getAttribute('aria-label')).toBe('Configured focus ring') + expect(layer?.getAttribute('aria-hidden')).toBe('true') + expect(layer?.getAttribute('visibility')).toBe('hidden') + expect(ring?.getAttribute('r')).toBe('4') + expect(ring?.getAttribute('fill')).toBe('#ffffff') + expect(ring?.getAttribute('stroke')).toBe('#0f172a') + expect(ring?.getAttribute('stroke-width')).toBe('1.5') + + surface.paintFocus({ + primary: point, + group: [point], + source: 'keyboard', + pinned: false, + }) + + expect(layer?.getAttribute('visibility')).toBe('visible') + expect(ring?.getAttribute('visibility')).toBe('visible') + surface.destroy() + }) + it('mounts viewport content with the fixed authored plot clip', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 276d5a17..cab6955b 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -586,6 +586,15 @@ export interface ChartTheme { palette: readonly string[] } +/** Paint and geometry for the built-in primary-point focus indicator. */ +export interface ChartFocusRingOptions { + radius?: number + fill?: string + /** Defaults to the focused point's resolved color. */ + stroke?: string + strokeWidth?: number +} + export interface ChartGradientStop { offset: number color: string @@ -884,8 +893,8 @@ export interface ChartDefinitionOptions< > { maxFocusDistance?: number focus?: ChartFocusMode, NoInfer, NoInfer> - /** Shows the built-in primary-point focus ring. Defaults to true. */ - focusRing?: boolean + /** Shows and optionally styles the built-in primary-point focus ring. Defaults to true. */ + focusRing?: boolean | ChartFocusRingOptions /** Optional app-owned cursor shared by one or more chart definitions. */ cursor?: ChartCursorBinding< NoInfer, @@ -918,7 +927,7 @@ export interface ChartDefinitionOptions< interface StoredChartDefinitionOptions { maxFocusDistance?: number focus?: ChartFocusMode - focusRing?: boolean + focusRing?: boolean | ChartFocusRingOptions cursor?: ChartCursorBinding spatialIndex?: ChartSpatialIndexFactory svgAnimation?: boolean | ChartAnimationOptions diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 3aef282c..8aed7dbe 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -225,6 +225,7 @@ export type { ChartFocusAnchor, ChartFocusCursorBinding, ChartFocusPresentation, + ChartFocusRingOptions, ChartFocusFilter, ChartFocusAffinity, ChartFocusGroupContext, diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx index 4c62d636..31385a8e 100644 --- a/packages/react-native-charts/src/Chart.test.tsx +++ b/packages/react-native-charts/src/Chart.test.tsx @@ -878,6 +878,54 @@ describe('React Native Chart', () => { } }) + it('paints configured focus-ring geometry and paint in native SVG', async () => { + const configuredDefinition = defineChart({ + marks: [lineY(data, { x: 'month', y: 'value' })], + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }) + const container = document.createElement('div') + const root = createRoot(container) + + try { + await React.act(() => { + root.render( + , + ) + }) + await React.act(() => { + container.firstElementChild?.dispatchEvent( + new FocusEvent('focusin', { bubbles: true }), + ) + }) + + const ring = container.querySelector('circle') + expect(ring?.getAttribute('r')).toBe('4') + expect(ring?.getAttribute('fill')).toBe('#ffffff') + expect(ring?.getAttribute('stroke')).toBe('#0f172a') + expect(ring?.getAttribute('stroke-width')).toBe('1.5') + expect( + container.firstElementChild?.getAttribute('data-accessibility-role'), + ).toBe('adjustable') + } finally { + await React.act(() => root.unmount()) + } + }) + it('renders a clipped two-axis crosshair, marker, labels, and focus fill', async () => { const crosshairDefinition = defineChart({ marks: [ diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index f51c5ca9..24c16b3c 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -671,7 +671,7 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 20.24, + 20.25, ), budgeted( 'D3-scale UTC line + static SVG', @@ -691,7 +691,7 @@ const entries = [ budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 20.2, + 20.21, ), budgeted( 'D3-scale areaX + static SVG', @@ -1178,7 +1178,7 @@ const entries = [ budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 16.56, + 16.57, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( From 7067223252f16a8eff6d4faffc89e8bd1868a28d Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Fri, 28 Aug 2026 00:19:39 +0200 Subject: [PATCH 04/10] Preserve focus ring defaults for undefined options --- API-FRICTION.md | 2 +- packages/charts-core/src/scene.test.ts | 20 ++++++++++++++ packages/charts-core/src/scene.ts | 15 ++++++----- scripts/measure-bundles.mjs | 36 +++++++++++++------------- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index db69aed1..0ed817ca 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -8569,5 +8569,5 @@ Each entry records: geometry and paint, series-color fallback, keyboard focus, and hidden accessibility presentation. The catalog's pointer-tooltip case uses the object form, and the reference docs record the complete contract. Locked - universal consumers add 37-48 minified bytes and 14-28 gzip bytes; the + universal consumers add 65-79 minified bytes and 25-36 gzip bytes; the reviewed bundle baselines and budgets pass. diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index c7908221..1e1a9817 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -263,6 +263,26 @@ describe('native mark and channel scene', () => { strokeWidth: 2.5, }, ]) + + const undefinedFields = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: undefined, + fill: undefined, + stroke: undefined, + strokeWidth: undefined, + }, + }), + { width: 320, height: 180 }, + ) + const undefinedDots = flatten(undefinedFields.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 5, + ) + + expect(undefinedDots.map((node) => node.style)).toEqual( + booleanDots.map((node) => node.style), + ) expectTypeOf(scene).toMatchTypeOf>() }) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 87ec0bff..3ec324a7 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -530,8 +530,12 @@ function createChartSceneWithScaleResolver< definition.focusRing !== false && points.length ) { - const { radius = 5, ...focusRingStyle } = (definition.focusRing || - {}) as ChartFocusRingOptions + const { + radius = 5, + fill = 'var(--ts-chart-focus-fill, Canvas)', + stroke, + strokeWidth = 2.5, + } = (definition.focusRing || {}) as ChartFocusRingOptions for (const entry of defaultFocusEntries) { nodes.push({ kind: 'group', @@ -552,10 +556,9 @@ function createChartSceneWithScaleResolver< y: point.y, radius, style: { - fill: 'var(--ts-chart-focus-fill, Canvas)', - stroke: point.color, - strokeWidth: 2.5, - ...focusRingStyle, + fill, + stroke: stroke ?? point.color, + strokeWidth, }, })), }) diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 24c16b3c..cb5ce896 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -671,7 +671,7 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 20.25, + 20.26, ), budgeted( 'D3-scale UTC line + static SVG', @@ -681,7 +681,7 @@ const entries = [ budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 22.78, + 22.79, ), budgeted( 'D3-scale facets + static SVG', @@ -691,7 +691,7 @@ const entries = [ budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 20.21, + 20.22, ), budgeted( 'D3-scale areaX + static SVG', @@ -701,7 +701,7 @@ const entries = [ budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 20.67, + 20.68, { inputBoundary: { forbid: [ @@ -785,7 +785,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 11.63, + 11.64, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -924,7 +924,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 27.77, + 27.79, { inputBoundary: { require: [ @@ -964,7 +964,7 @@ const entries = [ 'Linear regression + static SVG', 'benchmarks/entries/charts-regression-svg.ts', 'D3-scale line + static SVG', - 6.59, + 6.6, { inputBoundary: { require: [ @@ -1123,7 +1123,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.11, + 20.12, { inputBoundary: { forbid: [ @@ -1163,22 +1163,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 21.13, + 21.14, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 20.31, + 20.32, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 17.58, + 17.59, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 16.57, + 16.58, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1197,7 +1197,7 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 25.69, + 25.7, ), budgeted( 'Radial labels + static SVG', @@ -1208,7 +1208,7 @@ const entries = [ budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 25.7, + 25.71, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1663,7 +1663,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 11.68, + 11.69, { inputBoundary: { require: ['compactLinear'], @@ -1850,7 +1850,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 52.84, + 52.86, ), locked( 'Custom-scale line scene', @@ -1876,7 +1876,7 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 20.91, + 20.92, ), budgeted( 'D3 time-scale line scene', @@ -2110,7 +2110,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 53.74, + 53.75, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From 8024dd2977f5dc698fffdcd4bf5c61a1e904b9a2 Mon Sep 17 00:00:00 2001 From: 3li7alaki Date: Thu, 27 Aug 2026 12:29:12 +0300 Subject: [PATCH 05/10] Reserve the axis gutter in a right-to-left container A y scale with `side: 'right'` took its tick-label anchor from the physical side alone. That anchor reaches the DOM as SVG `text-anchor`, which resolves against inline base direction, so an RTL container painted the labels leftward into the plot. The margin pass then read those bounds correctly and reserved nothing on the right, widening the plot until it ran under its own labels. Anchor the far side with `end` once the container reads right to left, and mirror the same relation in the text estimator so a host without a DOM measurer resolves the identical layout. --- .changeset/rtl-axis-side-gutter.md | 9 ++++ .../charts-core/src/cartesian-scales.test.ts | 45 ++++++++++++++++++- packages/charts-core/src/guide-layout.test.ts | 18 ++++++++ packages/charts-core/src/guide-layout.ts | 10 ++++- packages/charts-core/src/scene.ts | 14 ++++-- 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 .changeset/rtl-axis-side-gutter.md diff --git a/.changeset/rtl-axis-side-gutter.md b/.changeset/rtl-axis-side-gutter.md new file mode 100644 index 00000000..ad5758d6 --- /dev/null +++ b/.changeset/rtl-axis-side-gutter.md @@ -0,0 +1,9 @@ +--- +'@tanstack/charts': patch +--- + +Reserve the correct gutter for a positioned axis in a right-to-left container. +A y scale with `side: 'right'` anchored its tick labels for a left-to-right +inline direction, so an RTL host painted them across the plot instead of beside +it. Text estimation now mirrors with the same direction the DOM measurer +already reports, keeping a host without one on the same layout. diff --git a/packages/charts-core/src/cartesian-scales.test.ts b/packages/charts-core/src/cartesian-scales.test.ts index 94f657cb..860aa0de 100644 --- a/packages/charts-core/src/cartesian-scales.test.ts +++ b/packages/charts-core/src/cartesian-scales.test.ts @@ -4,7 +4,7 @@ import { crosshair } from './crosshair' import { lineY } from './line' import { ruleY } from './rule' import { createChartScene, defineChart } from './scene' -import type { SceneNode, SceneRule } from './types' +import type { ChartTextMeasurer, SceneNode, SceneRule } from './types' describe('Cartesian scale registry', () => { it('binds marks to named y scales and stacks axes sharing the right side', () => { @@ -67,6 +67,16 @@ describe('Cartesian scale registry', () => { expect(rules.find((node) => node.key === 'y-axis')?.x1).toBe(scene.chart.x) }) + it('reserves the gutter for a right-side axis reading right to left', () => { + const leftToRight = createRightAxisScene('ltr') + const rightToLeft = createRightAxisScene('rtl') + + expect(rightToLeft.chart.width).toBeCloseTo(leftToRight.chart.width) + expect( + sceneWidth - rightToLeft.chart.x - rightToLeft.chart.width, + ).toBeGreaterThanOrEqual(labelWidth) + }) + it('keeps named grids off by default', () => { const keys = flatten(createNamedScaleScene().nodes).map((node) => node.key) @@ -275,6 +285,39 @@ function createNamedScaleScene() { ) } +const sceneWidth = 480 +const labelWidth = 40 + +/** + * Reports the painted box the way a DOM text measurer does: `anchor` resolves + * against inline base direction, so the box sits on the opposite side of the + * origin once the container reads right to left. + */ +const mirroredText: ChartTextMeasurer = (_text, options) => { + const leftwardAnchor = options.direction === 'rtl' ? 'start' : 'end' + const x = + options.anchor === 'middle' + ? -labelWidth / 2 + : options.anchor === leftwardAnchor + ? -labelWidth + : 0 + return { x, y: -8, width: labelWidth, height: 10 } +} + +function createRightAxisScene(direction: 'ltr' | 'rtl') { + return createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]), side: 'right' }, + }, + }), + { width: sceneWidth, height: 260 }, + { measureText: mirroredText, typography: { direction } }, + ) +} + function flatten(nodes: readonly SceneNode[]): SceneNode[] { return nodes.flatMap((node) => node.kind === 'group' ? [node, ...flatten(node.children)] : [node], diff --git a/packages/charts-core/src/guide-layout.test.ts b/packages/charts-core/src/guide-layout.test.ts index dcb9c997..345181b5 100644 --- a/packages/charts-core/src/guide-layout.test.ts +++ b/packages/charts-core/src/guide-layout.test.ts @@ -106,6 +106,24 @@ describe('guide layout', () => { }) }) + it('mirrors the estimated painted box in a right-to-left direction', () => { + const options = { + ...typography, + direction: 'rtl' as const, + fontSize: 10, + fontWeight: 400, + baseline: 'auto' as const, + } + const start = estimateSceneText('Margin 100', { + ...options, + anchor: 'start', + }) + const end = estimateSceneText('Margin 100', { ...options, anchor: 'end' }) + + expect(start.x).toBeCloseTo(-start.width) + expect(end.x).toBe(0) + }) + it('rotates the measured rectangle around the label position', () => { const bounds = measureSceneLabelBounds( label({ diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index ac1f6b6b..2fb936d0 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -55,8 +55,16 @@ export function estimateSceneText( Math.max(0, Array.from(text).length - 1) * letterSpacing, ) const height = fontSize + // `anchor` resolves against inline base direction, so which side of the + // origin the painted box occupies mirrors with that direction. The DOM + // measurer already reports the mirrored origin; match it here so a host + // without one lays out the same chart. const x = - style.anchor === 'middle' ? -width / 2 : style.anchor === 'end' ? -width : 0 + style.anchor === 'middle' + ? -width / 2 + : (style.anchor === 'end') === (style.direction !== 'rtl') + ? -width + : 0 const y = style.baseline === 'middle' ? -height / 2 diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 3ec324a7..a45cd0b0 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1104,6 +1104,7 @@ function resolveSceneLayout( theme, width, layout.measureText, + layout.typography?.direction === 'rtl', ) return { margin, @@ -1362,6 +1363,7 @@ function createAxes( theme: ChartTheme, width: number, measureText?: ChartTextMeasurer, + rightToLeft = false, ): ResolvedAxes { const children: SceneNode[] = [] const inset = guides.length ? automaticGuideInset : 0 @@ -1476,6 +1478,7 @@ function createAxes( width, theme, measureText, + rightToLeft, ) const visibleLabels = tickLabels === false @@ -1578,6 +1581,7 @@ function createAxes( width, theme, measureText, + rightToLeft, ) const visibleLabels = tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false) @@ -1744,6 +1748,7 @@ function createTickLabelCandidates( width: number, theme: ChartTheme, measureText: ChartTextMeasurer | undefined, + rightToLeft = false, ): TickLabelCandidate[] { const defaultFontSize = width < 360 ? 10 : 11 const positiveSide = guide.side === 'bottom' || guide.side === 'right' @@ -1762,11 +1767,14 @@ function createTickLabelCandidates( const opacity = resolveTickLabelValue(options.opacity, context) const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 + // A y axis anchors its labels away from the plot, a physical relation, + // while `text-anchor` resolves against inline base direction. The two + // agree only left to right, so the far side takes `end` once they differ. const defaultAnchor = guide.channel === 'y' - ? positiveSide - ? 'start' - : 'end' + ? positiveSide === rightToLeft + ? 'end' + : 'start' : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 From 7035bdeb0fd7ac4977e963a23ff72614f7b1b47b Mon Sep 17 00:00:00 2001 From: 3li7alaki Date: Thu, 27 Aug 2026 12:29:12 +0300 Subject: [PATCH 06/10] Relock the difference-mark bundle increment The layout fix costs 73 B raw in shared code, which puts the difference-mark increment 0.02 kB over its ceiling. Gzip falls on most entries. --- scripts/measure-bundles.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index cb5ce896..6de2ced9 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -998,7 +998,7 @@ const entries = [ 'Difference mark + static SVG', 'benchmarks/entries/charts-difference-svg.ts', 'D3-scale line + static SVG', - 7.03, + 7.06, { inputBoundary: { require: [ From 4a1b3d1b0a771b4f5c32570bef0a3da470e3fa5d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 2 Sep 2026 15:59:46 -0600 Subject: [PATCH 07/10] Harden focus styling and RTL text anchors --- .changeset/focus-ring-options.md | 3 +- .changeset/rtl-axis-side-gutter.md | 4 +- API-FRICTION.md | 19 +++ docs/reference/focus-and-interaction.md | 3 +- docs/reference/scales-guides-and-color.md | 9 +- packages/charts-core/src/canvas.test.ts | 37 +++++ packages/charts-core/src/canvas.ts | 6 +- .../charts-core/src/cartesian-scales.test.ts | 119 ++++++++++++++- packages/charts-core/src/dom-text.test.ts | 52 +++++++ packages/charts-core/src/dom-text.ts | 9 +- packages/charts-core/src/export.test.ts | 31 ++++ packages/charts-core/src/export.ts | 9 ++ packages/charts-core/src/guide-layout.test.ts | 10 ++ packages/charts-core/src/guide-layout.ts | 17 ++- packages/charts-core/src/scene-layout.test.ts | 43 ++++++ packages/charts-core/src/scene.test.ts | 75 +++++++++- packages/charts-core/src/scene.ts | 58 +++++--- packages/react-native-charts/README.md | 11 +- .../react-native-charts/src/Chart.test.tsx | 1 + .../react-native-charts/src/SvgScene.test.tsx | 137 +++++++++++++++++- packages/react-native-charts/src/SvgScene.tsx | 58 +++++++- 21 files changed, 653 insertions(+), 58 deletions(-) diff --git a/.changeset/focus-ring-options.md b/.changeset/focus-ring-options.md index a2597a29..9afd6e0f 100644 --- a/.changeset/focus-ring-options.md +++ b/.changeset/focus-ring-options.md @@ -2,4 +2,5 @@ '@tanstack/charts': minor --- -Allow the built-in focus ring to configure its radius, stroke width, fill, and stroke while preserving the existing boolean behavior and defaults. +Allow the built-in focus ring to configure its radius, stroke width, fill, and +stroke while preserving the existing boolean behavior and defaults. diff --git a/.changeset/rtl-axis-side-gutter.md b/.changeset/rtl-axis-side-gutter.md index ad5758d6..21004beb 100644 --- a/.changeset/rtl-axis-side-gutter.md +++ b/.changeset/rtl-axis-side-gutter.md @@ -5,5 +5,5 @@ Reserve the correct gutter for a positioned axis in a right-to-left container. A y scale with `side: 'right'` anchored its tick labels for a left-to-right inline direction, so an RTL host painted them across the plot instead of beside -it. Text estimation now mirrors with the same direction the DOM measurer -already reports, keeping a host without one on the same layout. +it. Automatic anchors, fallback measurement, Canvas, native rendering, and SVG +export now agree on logical `start` and `end` anchors for the host direction. diff --git a/API-FRICTION.md b/API-FRICTION.md index 0ed817ca..85694078 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -334,6 +334,7 @@ Each entry records: | F-295 | Line marks forced round endpoints | API | resolved | | F-296 | Axis titles could not carry authored typography or paint | API | resolved | | F-297 | Focus ring paint required generated SVG selectors | API | resolved | +| F-298 | Physical axis sides depended on logical text direction | API | resolved | ## Findings @@ -8571,3 +8572,21 @@ Each entry records: object form, and the reference docs record the complete contract. Locked universal consumers add 65-79 minified bytes and 25-36 gzip bytes; the reviewed bundle baselines and budgets pass. + +### F-298 - Physical axis sides depended on logical text direction + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: issue #117, with a right-side y axis inside an RTL host +- Friction: automatic tick-label anchors encoded a physical outward placement + with logical `start` and `end` values that change sides under RTL. Browser + layout could therefore measure a right-axis label over the plot and reserve + no right gutter. Canvas paint, fallback measurement, exported SVG, and React + Native also disagreed about whether those values were logical or physical. +- Decision: keep scene anchors logical, mirror only automatic physical axis + defaults under RTL, and normalize at renderer boundaries that do not support + logical anchors. Explicit authored anchors retain their SVG meaning. +- Verification: layout tests cover both y-axis sides, both rotated x-axis + directions, and explicit anchors. Estimator, DOM fallback, Canvas, SVG + export, and React Native tests cover their direction-specific boundaries. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 9ba1695a..06f5c7c6 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -65,7 +65,8 @@ removes only the built-in ring. The definition compiles this presentation into ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom mark renderers, and the optional motion renderer share the same values. The ring remains hidden from the accessibility tree; the chart root continues to -report focus and values. +report focus and values. `radius` and `strokeWidth` accept finite nonnegative +numbers, including zero. Invalid values use their defaults. Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index b8913e52..0bfb7097 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -353,7 +353,8 @@ const x = { } ``` -`anchor` defaults to the rotation-derived x anchor or `end` on y. `dx` and +`anchor` defaults to an outward anchor derived from x rotation or the y-axis +side. The automatic value accounts for the host's inline direction. `dx` and `dy` apply after the normal tick position and padding. Resolved font size, weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; @@ -430,6 +431,12 @@ mapping should not draw another axis. X scales can use the top or bottom side, and y scales can use the left or right side. Axes on the same side stack outward and contribute their measured size to the automatic margin. +Axis sides stay physical in a right-to-left container, so `side: 'right'` +still puts the axis on the right. Automatic tick anchors account for the +container's inline direction and keep labels outside the plot. An explicitly +authored `start` or `end` anchor remains logical. Text measurement, SVG, +Canvas, native rendering, and standalone SVG export preserve that direction. + ## Automatic guide layout When a margin side is omitted, the scene compiler measures: diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 9fdd85ee..55e4019b 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -41,6 +41,8 @@ interface FakeCanvasContext { text: string globalAlpha: number font: string + direction: CanvasDirection + textAlign: CanvasTextAlign }> context: CanvasRenderingContext2D } @@ -1109,6 +1111,39 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints start and end as logical anchors in right-to-left hosts', () => { + const container = document.createElement('div') + const surface = canvasChartRenderer.mount(container, () => {}) + surface.element.style.direction = 'rtl' + surface.render( + scene([ + { + kind: 'label', + key: 'start', + x: 20, + y: 20, + text: 'Start', + anchor: 'start', + }, + { + kind: 'label', + key: 'end', + x: 80, + y: 40, + text: 'End', + anchor: 'end', + }, + ]), + renderOptions(), + ) + + expect(contexts.get(surface.sceneCanvas)?.textPaints).toMatchObject([ + { text: 'Start', direction: 'rtl', textAlign: 'start' }, + { text: 'End', direction: 'rtl', textAlign: 'end' }, + ]) + surface.destroy() + }) + it('paints configured focus-ring geometry and paint on Canvas', () => { const chart = createChartScene( defineChart({ @@ -2655,6 +2690,8 @@ function fakeContext(): FakeCanvasContext { text, globalAlpha, font: context.font, + direction: context.direction, + textAlign: context.textAlign, }) }, strokeText: (text: string, x: number, y: number) => diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts index 9c0c06c3..72cac3c0 100644 --- a/packages/charts-core/src/canvas.ts +++ b/packages/charts-core/src/canvas.ts @@ -1509,11 +1509,7 @@ function paintLabel( } context.direction = painter.font.direction context.textAlign = - node.anchor === 'middle' - ? 'center' - : node.anchor === 'end' - ? 'right' - : 'left' + node.anchor === 'middle' ? 'center' : (node.anchor ?? 'start') context.textBaseline = node.baseline === 'middle' ? 'middle' diff --git a/packages/charts-core/src/cartesian-scales.test.ts b/packages/charts-core/src/cartesian-scales.test.ts index 860aa0de..2fad842a 100644 --- a/packages/charts-core/src/cartesian-scales.test.ts +++ b/packages/charts-core/src/cartesian-scales.test.ts @@ -4,7 +4,12 @@ import { crosshair } from './crosshair' import { lineY } from './line' import { ruleY } from './rule' import { createChartScene, defineChart } from './scene' -import type { ChartTextMeasurer, SceneNode, SceneRule } from './types' +import type { + ChartTextMeasurer, + SceneLabel, + SceneNode, + SceneRule, +} from './types' describe('Cartesian scale registry', () => { it('binds marks to named y scales and stacks axes sharing the right side', () => { @@ -77,6 +82,75 @@ describe('Cartesian scale registry', () => { ).toBeGreaterThanOrEqual(labelWidth) }) + it('keeps automatic axis labels physically outward in both directions', () => { + const leftToRight = createDirectionalAxisScene('ltr') + const rightToLeft = createDirectionalAxisScene('rtl') + const anchors = (nodes: readonly SceneNode[]) => + Object.fromEntries( + flatten(nodes) + .filter( + (node): node is SceneLabel => + node.kind === 'label' && node.key.includes('-tick-label:'), + ) + .map((node) => [node.key.split('-tick-label:')[0], node.anchor]), + ) + + expect(anchors(leftToRight.nodes)).toMatchObject({ + x: 'end', + y: 'end', + top: 'start', + right: 'start', + }) + expect(anchors(rightToLeft.nodes)).toMatchObject({ + x: 'start', + y: 'start', + top: 'end', + right: 'end', + }) + expect(rightToLeft.chart).toEqual(leftToRight.chart) + }) + + it('stacks multiple right-side axes outward under RTL', () => { + const scene = createNamedScaleScene('rtl') + const rules = flatten(scene.nodes).filter( + (node): node is SceneRule => node.kind === 'rule', + ) + const inner = rules.find((node) => node.key === 'percent-axis') + const outer = rules.find((node) => node.key === 'temperature-axis') + if (!inner || !outer) throw new Error('Expected both right-side axes') + + expect(inner.x1).toBe(scene.chart.x + scene.chart.width) + expect(outer.x1).toBeGreaterThan(inner.x1) + expect(outer.x1).toBeLessThan(scene.width) + }) + + it('keeps an authored logical anchor unchanged in RTL', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2])], + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { + scale: scaleLinear().domain([0, 2]), + side: 'right', + axis: { + ticks: { values: [1] }, + tickLabels: { anchor: 'start' }, + }, + }, + }, + }), + { width: sceneWidth, height: 260 }, + { measureText: mirroredText, typography: { direction: 'rtl' } }, + ) + const label = flatten(scene.nodes).find( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('y-tick-label:'), + ) + + expect(label?.anchor).toBe('start') + }) + it('keeps named grids off by default', () => { const keys = flatten(createNamedScaleScene().nodes).map((node) => node.key) @@ -248,7 +322,7 @@ describe('Cartesian scale registry', () => { }) }) -function createNamedScaleScene() { +function createNamedScaleScene(direction: 'ltr' | 'rtl' = 'ltr') { return createChartScene( defineChart({ marks: [ @@ -282,6 +356,7 @@ function createNamedScaleScene() { }, }), { width: 640, height: 320 }, + { measureText: mirroredText, typography: { direction } }, ) } @@ -318,6 +393,46 @@ function createRightAxisScene(direction: 'ltr' | 'rtl') { ) } +function createDirectionalAxisScene(direction: 'ltr' | 'rtl') { + return createChartScene( + defineChart({ + marks: [lineY([1, 2])], + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + side: 'bottom', + axis: { + ticks: { values: [0.5] }, + tickLabels: { rotate: -30 }, + }, + }, + y: { + scale: scaleLinear().domain([0, 2]), + side: 'left', + axis: { ticks: { values: [1] } }, + }, + top: { + channel: 'x', + side: 'top', + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: { values: [0.5] }, + tickLabels: { rotate: 30 }, + }, + }, + right: { + channel: 'y', + side: 'right', + scale: scaleLinear().domain([0, 2]), + axis: { ticks: { values: [1] } }, + }, + }, + }), + { width: sceneWidth, height: 260 }, + { measureText: mirroredText, typography: { direction } }, + ) +} + function flatten(nodes: readonly SceneNode[]): SceneNode[] { return nodes.flatMap((node) => node.kind === 'group' ? [node, ...flatten(node.children)] : [node], diff --git a/packages/charts-core/src/dom-text.test.ts b/packages/charts-core/src/dom-text.test.ts index 8586fe73..e397516d 100644 --- a/packages/charts-core/src/dom-text.test.ts +++ b/packages/charts-core/src/dom-text.test.ts @@ -78,4 +78,56 @@ describe('DOM text measurement', () => { value: previousCanvasContext, }) }) + + it('mirrors logical anchors when Canvas omits exact painted bounds', () => { + const context = { + direction: 'inherit', + font: '', + fontStretch: 'normal', + letterSpacing: '0px', + textAlign: 'start', + textBaseline: 'alphabetic', + measureText: vi.fn(() => ({ width: 32 })), + } as unknown as CanvasRenderingContext2D + const previousCanvasContext = window.CanvasRenderingContext2D + Object.defineProperty(window, 'CanvasRenderingContext2D', { + configurable: true, + value: class TestCanvasContext {}, + }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context) + + try { + const measureText = createDomTextMeasurer( + document.createElement('div'), + ).measureText + const options = { + fontSize: 10, + fontFamily: 'sans-serif', + fontStyle: 'normal', + fontStretch: 'normal', + letterSpacing: 0, + direction: 'rtl' as const, + fontScale: 1, + baseline: 'auto' as const, + } + + expect(measureText('Start', { ...options, anchor: 'start' })).toEqual({ + x: -32, + y: -8, + width: 32, + height: 10, + }) + expect(measureText('End', { ...options, anchor: 'end' })).toEqual({ + x: 0, + y: -8, + width: 32, + height: 10, + }) + } finally { + Object.defineProperty(window, 'CanvasRenderingContext2D', { + configurable: true, + value: previousCanvasContext, + }) + } + }) }) diff --git a/packages/charts-core/src/dom-text.ts b/packages/charts-core/src/dom-text.ts index a09f9a52..ccfb6660 100644 --- a/packages/charts-core/src/dom-text.ts +++ b/packages/charts-core/src/dom-text.ts @@ -1,4 +1,4 @@ -import { estimateSceneText } from './guide-layout' +import { estimateSceneText, logicalTextAnchorOffset } from './guide-layout' import type { ChartTextMeasurer, ChartTextMeasureOptions, @@ -137,12 +137,7 @@ function paintedBounds( const width = Number.isFinite(measured.width) ? Math.max(0, measured.width) : 0 - const x = - options.anchor === 'middle' - ? -width / 2 - : options.anchor === 'end' - ? -width - : 0 + const x = logicalTextAnchorOffset(width, options.anchor, options.direction) const y = options.baseline === 'middle' ? -fontSize / 2 diff --git a/packages/charts-core/src/export.test.ts b/packages/charts-core/src/export.test.ts index 2218614b..a8296e6a 100644 --- a/packages/charts-core/src/export.test.ts +++ b/packages/charts-core/src/export.test.ts @@ -93,6 +93,37 @@ describe('optional export', () => { readStyle.mockRestore() }) + it('preserves the computed text direction in standalone SVG', () => { + const container = document.createElement('div') + container.innerHTML = renderChartSvg( + createChartScene( + defineChart({ + marks: [lineY([1, 3, 2])], + ...linearAxes([0, 2], [0, 3]), + }), + { width: 480, height: 260 }, + ), + { ariaLabel: 'RTL export' }, + ) + const svg = container.querySelector('svg') + if (!svg) throw new Error('Expected an SVG chart') + const readStyle = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element) => { + return { + direction: element === svg ? 'rtl' : '', + getPropertyValue() { + return '' + }, + } as unknown as CSSStyleDeclaration + }) + + const result = serializeChartSvg(container) + + expect(result).toContain('direction="rtl"') + readStyle.mockRestore() + }) + it('includes the currently painted crosshair only when requested', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/export.ts b/packages/charts-core/src/export.ts index 09a8b16f..3e6e03e2 100644 --- a/packages/charts-core/src/export.ts +++ b/packages/charts-core/src/export.ts @@ -34,6 +34,7 @@ export function serializeChartSvg( const svg = resolveSvg(target) const clone = svg.cloneNode(true) as SVGSVGElement inlinePresentation(svg, clone) + inlineComputedDirection(svg, clone) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') if (!options.includeFocus) { clone @@ -50,6 +51,14 @@ export function serializeChartSvg( return new Serializer().serializeToString(clone) } +function inlineComputedDirection(source: SVGSVGElement, clone: SVGSVGElement) { + const direction = + source.ownerDocument.defaultView?.getComputedStyle(source).direction + if (direction === 'ltr' || direction === 'rtl') { + clone.setAttribute('direction', direction) + } +} + export function downloadChartSvg( target: Element, filename = 'chart.svg', diff --git a/packages/charts-core/src/guide-layout.test.ts b/packages/charts-core/src/guide-layout.test.ts index 345181b5..15ee6ea7 100644 --- a/packages/charts-core/src/guide-layout.test.ts +++ b/packages/charts-core/src/guide-layout.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { estimateSceneText, + logicalTextAnchorOffset, measureSceneLabelBounds, resolveGuideMargins, } from './guide-layout' @@ -124,6 +125,15 @@ describe('guide layout', () => { expect(end.x).toBe(0) }) + it('resolves logical anchors against the inline direction', () => { + expect(logicalTextAnchorOffset(40, 'start', 'ltr')).toBe(0) + expect(logicalTextAnchorOffset(40, 'end', 'ltr')).toBe(-40) + expect(logicalTextAnchorOffset(40, 'start', 'rtl')).toBe(-40) + expect(logicalTextAnchorOffset(40, 'end', 'rtl')).toBe(0) + expect(logicalTextAnchorOffset(40, 'middle', 'rtl')).toBe(-20) + expect(logicalTextAnchorOffset(40, 'start', 'inherit')).toBe(0) + }) + it('rotates the measured rectangle around the label position', () => { const bounds = measureSceneLabelBounds( label({ diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 2fb936d0..61a61553 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -27,6 +27,16 @@ export interface GuideMarginOptions { measureText?: ChartTextMeasurer } +export function logicalTextAnchorOffset( + width: number, + anchor: ChartTextMeasureOptions['anchor'], + direction: ChartTextMeasureOptions['direction'], +): number { + if (anchor === 'middle') return -width / 2 + const startsAtLeft = direction !== 'rtl' + return (anchor === 'start') === startsAtLeft ? 0 : -width +} + export function estimateSceneText( text: string, style: ChartTextMeasureOptions, @@ -59,12 +69,7 @@ export function estimateSceneText( // origin the painted box occupies mirrors with that direction. The DOM // measurer already reports the mirrored origin; match it here so a host // without one lays out the same chart. - const x = - style.anchor === 'middle' - ? -width / 2 - : (style.anchor === 'end') === (style.direction !== 'rtl') - ? -width - : 0 + const x = logicalTextAnchorOffset(width, style.anchor, style.direction) const y = style.baseline === 'middle' ? -height / 2 diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts index 9b3b7aeb..c393a186 100644 --- a/packages/charts-core/src/scene-layout.test.ts +++ b/packages/charts-core/src/scene-layout.test.ts @@ -285,6 +285,49 @@ describe('automatic scene guide layout', () => { fontWeight: 600, style: { fill: 'currentColor', fillOpacity: 0.76 }, }) + expect(styled?.style).toStrictEqual({ + fill: '#7c3aed', + opacity: 0.45, + }) + expect(defaulted?.style).toStrictEqual({ + fill: 'currentColor', + fillOpacity: 0.76, + }) + + const xTitle = (label: string | ChartAxisLabelOptions) => { + const titleScene = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { label }, + }, + y: { scale: scaleLinear().domain([0, 1]), axis: false }, + }, + }), + { width: 480, height: 240 }, + { measureText }, + ) + const title = flatten(titleScene.nodes).find( + (node): node is SceneLabel => + node.kind === 'label' && node.key === 'x-label', + ) + if (!title) throw new Error('Expected an x-axis title') + return title + } + const defaultStringTitle = xTitle('Default horizontal title') + const defaultObjectTitle = xTitle({ text: 'Default horizontal title' }) + const transparentTitle = xTitle({ + text: 'Transparent horizontal title', + opacity: 0, + }) + + expect(defaultObjectTitle).toStrictEqual(defaultStringTitle) + expect(transparentTitle.style).toStrictEqual({ + fill: 'currentColor', + opacity: 0, + }) }) it('measures configured axis-title typography into automatic margins', () => { diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 1e1a9817..d3a808b0 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -19,6 +19,7 @@ import type { ChartDefinition, ChartPoint, ChartScene, + SceneDot, SceneNode, } from './types' @@ -248,7 +249,7 @@ describe('native mark and channel scene', () => { { width: 320, height: 180 }, ) const booleanDots = flatten(booleanRing.nodes).filter( - (node) => node.kind === 'dot' && node.radius === 5, + (node): node is SceneDot => node.kind === 'dot' && node.radius === 5, ) expect(booleanDots.map((node) => node.style)).toEqual([ @@ -283,6 +284,78 @@ describe('native mark and channel scene', () => { expect(undefinedDots.map((node) => node.style)).toEqual( booleanDots.map((node) => node.style), ) + + const zeroFields = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: 0, + strokeWidth: 0, + }, + }), + { width: 320, height: 180 }, + ) + const zeroDots = flatten(zeroFields.nodes).filter( + (node): node is SceneDot => + node.kind === 'dot' && + zeroFields.points.some((point) => point.key === node.key), + ) + + expect( + zeroDots.map((node) => ({ radius: node.radius, style: node.style })), + ).toEqual([ + { + radius: 0, + style: { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#2563eb', + strokeWidth: 0, + }, + }, + { + radius: 0, + style: { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#f97316', + strokeWidth: 0, + }, + }, + ]) + + for (const invalid of [ + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ]) { + const invalidFields = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: invalid, + strokeWidth: invalid, + }, + }), + { width: 320, height: 180 }, + ) + const invalidDots = flatten(invalidFields.nodes).filter( + (node): node is SceneDot => + node.kind === 'dot' && + invalidFields.points.some((point) => point.key === node.key), + ) + + expect( + invalidDots.map((node) => ({ + radius: node.radius, + style: node.style, + })), + ).toEqual( + booleanDots.map((node) => ({ + radius: node.radius, + style: node.style, + })), + ) + } expectTypeOf(scene).toMatchTypeOf>() }) diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index a45cd0b0..aa89697b 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -27,7 +27,6 @@ import type { ResponsiveChartConfig, ChartColorLegend, ChartFocusFilter, - ChartFocusRingOptions, ChartLayoutOptions, ChartMargin, ChartMark, @@ -530,12 +529,14 @@ function createChartSceneWithScaleResolver< definition.focusRing !== false && points.length ) { - const { - radius = 5, - fill = 'var(--ts-chart-focus-fill, Canvas)', - stroke, - strokeWidth = 2.5, - } = (definition.focusRing || {}) as ChartFocusRingOptions + const focusRing = + typeof definition.focusRing === 'object' + ? definition.focusRing + : undefined + const radius = finiteNonNegative(focusRing?.radius, 5) + const fill = focusRing?.fill ?? 'var(--ts-chart-focus-fill, Canvas)' + const stroke = focusRing?.stroke + const strokeWidth = finiteNonNegative(focusRing?.strokeWidth, 2.5) for (const entry of defaultFocusEntries) { nodes.push({ kind: 'group', @@ -1282,6 +1283,15 @@ 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 } } @@ -1532,8 +1542,9 @@ function createAxes( fontWeight: labelOptions?.fontWeight ?? 600, style: { fill: labelOptions?.fill ?? theme.foreground, - fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, - opacity: labelOptions?.opacity, + ...(labelOptions?.opacity === undefined + ? { fillOpacity: 0.76 } + : { opacity: labelOptions.opacity }), }, } includeOutward(measureSceneLabelBounds(label, measureText)) @@ -1630,8 +1641,9 @@ function createAxes( fontWeight: labelOptions?.fontWeight ?? 600, style: { fill: labelOptions?.fill ?? theme.foreground, - fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, - opacity: labelOptions?.opacity, + ...(labelOptions?.opacity === undefined + ? { fillOpacity: 0.76 } + : { opacity: labelOptions.opacity }), }, } const labelOffset = labelOptions?.offset ?? 'auto' @@ -1767,21 +1779,21 @@ function createTickLabelCandidates( const opacity = resolveTickLabelValue(options.opacity, context) const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 - // A y axis anchors its labels away from the plot, a physical relation, - // while `text-anchor` resolves against inline base direction. The two - // agree only left to right, so the far side takes `end` once they differ. - const defaultAnchor = + // Automatic anchors preserve a physical placement outside the plot. + // Authored anchors remain logical SVG start/end values. + const automaticAnchor: NonNullable = guide.channel === 'y' - ? positiveSide === rightToLeft - ? 'end' - : 'start' + ? positiveSide + ? 'start' + : 'end' : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 ? 'start' : 'middle' const anchor = - resolveTickLabelValue(options.anchor, context) ?? defaultAnchor + resolveTickLabelValue(options.anchor, context) ?? + mirrorAutomaticAnchor(automaticAnchor, rightToLeft) const label: SceneLabel = guide.channel === 'x' ? { @@ -1825,6 +1837,14 @@ function createTickLabelCandidates( }) } +function mirrorAutomaticAnchor( + anchor: NonNullable, + rightToLeft: boolean, +): NonNullable { + if (!rightToLeft || anchor === 'middle') return anchor + return anchor === 'start' ? 'end' : 'start' +} + function resolveTickLabelValue( value: ChartAxisTickLabelValue | undefined, context: ChartAxisTickLabelContext, diff --git a/packages/react-native-charts/README.md b/packages/react-native-charts/README.md index 9d751d8b..6f10ec3e 100644 --- a/packages/react-native-charts/README.md +++ b/packages/react-native-charts/README.md @@ -59,14 +59,19 @@ export function RevenueChart() { `fontFamily`, `fontStyle`, `fontStretch`, `letterSpacing`, `direction`, `locale`, and `fontScale` are passed to the synchronous `measureText` contract. The SVG painter applies the corresponding family, style, stretch, spacing, and -font scale. If native font metrics become available asynchronously, keep them -in application state and render the chart again with an updated `measureText` -function or typography prop. +font scale. Set `direction="rtl"` explicitly when the chart reads right to left; +the native painter preserves logical `start` and `end` anchors. If native font +metrics become available asynchronously, keep them in application state and +render the chart again with an updated `measureText` function or typography +prop. Exact core subpaths keep Metro from retaining unrelated universal-entry exports. The `/universal` barrel remains valid when portability matters more than the native bundle floor. +`focusFill` supplies the native fallback for the built-in focus ring's default +CSS-variable fill. A literal `definition.focusRing.fill` takes precedence. + The bare fixture uses React Native 0.86.2 with `react-native-svg` 15.15.5. The Expo 57 fixture uses `react-native-svg` 15.15.4 and renders in Expo Go on an iOS simulator. It remains experimental: bare-native and Android simulators, diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx index 31385a8e..64f7143f 100644 --- a/packages/react-native-charts/src/Chart.test.tsx +++ b/packages/react-native-charts/src/Chart.test.tsx @@ -69,6 +69,7 @@ const nativeViewState = vi.hoisted(() => ({ vi.mock('react-native', async () => { const ReactModule = await import('react') return { + Platform: { OS: 'ios' }, Text: 'span', View: ReactModule.forwardRef( function MockView(props, ref) { diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 6c23e377..3138344a 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -1,12 +1,20 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createChartScene, defineChart, lineY } from '@tanstack/charts' import { scaleLinear } from '@tanstack/charts/scales/linear' import type { ChartScene, SceneNode } from '@tanstack/charts/types' import { NativeChartFocusOverlay } from './FocusOverlay' import { resolveNativePaint } from './paint' -import { NativeChartScene, resolveNativeLineJoin } from './SvgScene' +import { + NativeChartScene, + NativeChartSceneNodes, + resolveNativeLineJoin, +} from './SvgScene' + +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('react-native', () => ({ Platform: platform })) vi.mock('react-native-svg', () => ({ Circle: 'circle', @@ -22,6 +30,10 @@ vi.mock('react-native-svg', () => ({ Text: 'text', })) +afterEach(() => { + platform.OS = 'ios' +}) + describe('React Native SVG scene renderer', () => { it('maps every scene primitive, gradients, clipping, and authored paths', () => { const markup = renderToStaticMarkup( @@ -74,6 +86,101 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain('font-size="24"') }) + it('converts logical RTL anchors for native labels in every scene layer', () => { + const rtlScene = scene() + rtlScene.nodes = [ + label('start', 'start'), + label('middle', 'middle'), + label('end', 'end'), + label('undefined'), + { + kind: 'group', + key: 'nested', + children: [label('nested-start', 'start')], + }, + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(renderedTextAnchor(markup, 'start')).toBe('end') + expect(renderedTextAnchor(markup, 'middle')).toBe('middle') + expect(renderedTextAnchor(markup, 'end')).toBe('start') + expect(renderedTextAnchor(markup, 'undefined')).toBe('end') + expect(renderedTextAnchor(markup, 'nested-start')).toBe('end') + expect(renderedTextAnchor(markup, 'under-end')).toBe('start') + expect(renderedTextAnchor(markup, 'over-start')).toBe('end') + }) + + it('keeps logical anchors unchanged outside native RTL rendering', () => { + const anchorScene = scene() + anchorScene.nodes = [ + label('start', 'start'), + label('middle', 'middle'), + label('end', 'end'), + label('undefined'), + ] + + const ltrMarkup = renderToStaticMarkup( + , + ) + expect(renderedTextAnchor(ltrMarkup, 'start')).toBe('start') + expect(renderedTextAnchor(ltrMarkup, 'middle')).toBe('middle') + expect(renderedTextAnchor(ltrMarkup, 'end')).toBe('end') + expect(renderedTextAnchor(ltrMarkup, 'undefined')).toBeUndefined() + + platform.OS = 'web' + const webMarkup = renderToStaticMarkup( + , + ) + expect(renderedTextAnchor(webMarkup, 'start')).toBe('start') + expect(renderedTextAnchor(webMarkup, 'middle')).toBe('middle') + expect(renderedTextAnchor(webMarkup, 'end')).toBe('end') + expect(renderedTextAnchor(webMarkup, 'undefined')).toBeUndefined() + expect(renderedTextAttributes(webMarkup, 'start')).toContain( + 'direction="rtl"', + ) + }) + + it('passes direction through NativeChartSceneNodes', () => { + const base = scene() + const markup = renderToStaticMarkup( + , + ) + + expect(renderedTextAnchor(markup, 'standalone-start')).toBe('end') + }) + it('renders Cartesian axis-title typography and paint', () => { const generated = createChartScene( defineChart({ @@ -486,3 +593,29 @@ function scene(): ChartScene { }, } } + +function label( + key: string, + anchor?: Extract['anchor'], +): Extract { + return { + kind: 'label', + key, + x: 10, + y: 10, + text: key, + anchor, + baseline: 'middle', + } +} + +function renderedTextAttributes(markup: string, text: string) { + return new RegExp(`]*)>${text}`).exec(markup)?.[1] +} + +function renderedTextAnchor(markup: string, text: string) { + const attributes = renderedTextAttributes(markup, text) + return attributes === undefined + ? undefined + : /text-anchor="([^"]+)"/.exec(attributes)?.[1] +} diff --git a/packages/react-native-charts/src/SvgScene.tsx b/packages/react-native-charts/src/SvgScene.tsx index aa6c4cba..97e3c9a8 100644 --- a/packages/react-native-charts/src/SvgScene.tsx +++ b/packages/react-native-charts/src/SvgScene.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import type { ColorValue } from 'react-native' +import { Platform, type ColorValue } from 'react-native' import { Circle, ClipPath, @@ -48,6 +48,7 @@ export function NativeChartSceneNodes({ nodes, color, focusFill, + direction, fontScale, idPrefix, resolvePaint, @@ -71,7 +72,13 @@ export function NativeChartSceneNodes({ return ( <> {nodes.map((node) => - renderSceneNode(node, idPrefix, paint, positiveFinite(fontScale, 1)), + renderSceneNode( + node, + idPrefix, + paint, + positiveFinite(fontScale, 1), + direction, + ), )} ) @@ -84,6 +91,7 @@ export const NativeChartScene = React.memo(function NativeChartScene({ fontStyle, fontStretch, letterSpacing, + direction, fontScale, idPrefix, resolvePaint, @@ -157,13 +165,31 @@ export const NativeChartScene = React.memo(function NativeChartScene({ /> )} {focusPresentation?.under.map((node) => - renderSceneNode(node, idPrefix, paint, positiveFinite(fontScale, 1)), + renderSceneNode( + node, + idPrefix, + paint, + positiveFinite(fontScale, 1), + direction, + ), )} {scene.nodes.map((node) => - renderSceneNode(node, idPrefix, paint, positiveFinite(fontScale, 1)), + renderSceneNode( + node, + idPrefix, + paint, + positiveFinite(fontScale, 1), + direction, + ), )} {focusPresentation?.over.map((node) => - renderSceneNode(node, idPrefix, paint, positiveFinite(fontScale, 1)), + renderSceneNode( + node, + idPrefix, + paint, + positiveFinite(fontScale, 1), + direction, + ), )} ) @@ -174,13 +200,14 @@ function renderSceneNode( idPrefix: string, paint: (value: string) => ColorValue, fontScale: number, + direction: ChartTextTypography['direction'], ): React.ReactNode { if (node.kind === 'group' && node.focus) return null const style = nativeSceneStyle(node.style, paint) switch (node.kind) { case 'group': - return renderGroup(node, idPrefix, paint, style, fontScale) + return renderGroup(node, idPrefix, paint, style, fontScale, direction) case 'rule': return ( ColorValue, style: ReturnType, fontScale: number, + direction: ChartTextTypography['direction'], ) { const clipId = node.clip ? scopedId(idPrefix, `clip-${stableId(node.key)}`) @@ -297,7 +326,7 @@ function renderGroup( ) : null} {node.children.map((child) => - renderSceneNode(child, idPrefix, paint, fontScale), + renderSceneNode(child, idPrefix, paint, fontScale, direction), )} ) @@ -329,6 +358,19 @@ export function resolveNativeLineJoin( return lineJoin } +function resolveNativeTextAnchor( + anchor: Extract['anchor'], + direction: ChartTextTypography['direction'], +) { + if (Platform.OS === 'web' || direction !== 'rtl') return anchor + if (anchor === 'end') return 'start' + return anchor === 'middle' ? 'middle' : 'end' +} + +function webTextDirection(direction: ChartTextTypography['direction']) { + return Platform.OS === 'web' && direction !== undefined ? { direction } : {} +} + function resolveScenePaint( value: string, gradientIds: ReadonlySet, From 699b4be656a22ad99618466a3b46b7966e9fb4ff Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 2 Sep 2026 16:28:37 -0600 Subject: [PATCH 08/10] Complete RTL text direction support --- API-FRICTION.md | 21 +++- packages/charts-core/src/color-scale.test.ts | 61 ++++++++++ .../charts-core/src/crosshair-resolver.ts | 4 +- packages/charts-core/src/crosshair.test.ts | 49 ++++++++ packages/charts-core/src/crosshair.ts | 1 + packages/charts-core/src/guide-layout.test.ts | 11 ++ packages/charts-core/src/guide-layout.ts | 10 ++ packages/charts-core/src/legend-static.ts | 32 +++-- .../charts-core/src/polar-mark-internal.ts | 2 + packages/charts-core/src/polar.test.ts | 113 ++++++++++++------ packages/charts-core/src/polar.ts | 28 +++-- packages/charts-core/src/scene.ts | 26 ++-- packages/charts-core/src/svg-renderer.test.ts | 25 ++++ packages/charts-core/src/svg-renderer.ts | 6 +- packages/charts-core/src/types.ts | 3 + .../react-native-charts/src/FocusOverlay.tsx | 4 + .../react-native-charts/src/SvgScene.test.tsx | 49 ++++++++ 17 files changed, 368 insertions(+), 77 deletions(-) diff --git a/API-FRICTION.md b/API-FRICTION.md index 85694078..eb91250a 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -8584,9 +8584,20 @@ Each entry records: layout could therefore measure a right-axis label over the plot and reserve no right gutter. Canvas paint, fallback measurement, exported SVG, and React Native also disagreed about whether those values were logical or physical. -- Decision: keep scene anchors logical, mirror only automatic physical axis - defaults under RTL, and normalize at renderer boundaries that do not support - logical anchors. Explicit authored anchors retain their SVG meaning. +- Decision: keep scene anchors logical, map every automatic physical text + placement through the resolved inline direction, and normalize at renderer + boundaries that do not support logical anchors. This covers Cartesian axes, + crosshair labels, static legends, and polar outside labels. Carry the + resolved direction with the scene so direct static SVG rendering preserves + the same layout. Explicit authored anchors retain their SVG meaning. - Verification: layout tests cover both y-axis sides, both rotated x-axis - directions, and explicit anchors. Estimator, DOM fallback, Canvas, SVG - export, and React Native tests cover their direction-specific boundaries. + directions, crosshair labels, categorical and quantitative legends, polar + outside labels, explicit anchors, and direct static SVG. Estimator, DOM + fallback, Canvas, SVG export, and React Native tests cover their + direction-specific boundaries. A Chromium reproduction with the issue's + data measures the same 51.24 px right gutter in LTR and RTL, keeps labels 8 + px beyond the plot, and produces byte-identical LTR and RTL Canvas output. + A second Chromium pass confirms that direct static SVG matches the live + mount, the crosshair label stays 8 px left of the plot, quantitative legend + endpoints stay inside their physical bounds, and polar outside labels stay + 8 px beyond both physical edges. diff --git a/packages/charts-core/src/color-scale.test.ts b/packages/charts-core/src/color-scale.test.ts index 77b967c7..f6639558 100644 --- a/packages/charts-core/src/color-scale.test.ts +++ b/packages/charts-core/src/color-scale.test.ts @@ -330,6 +330,55 @@ describe('continuous color', () => { ) }) + it.each([ + { direction: 'ltr' as const, left: 'start', right: 'end' }, + { direction: 'rtl' as const, left: 'end', right: 'start' }, + ])( + 'keeps automatic legend labels on their physical sides in $direction', + ({ direction, left, right }) => { + const categorical = createColorScale( + ['Alpha', 'Beta'], + {}, + defaultChartTheme, + ) + const stepped = createColorScale( + [0, 12], + { + scale: scaleQuantize, + range: ['#eff6ff', '#93c5fd', '#1d4ed8'], + }, + defaultChartTheme, + ) + const continuous = createColorScale( + [10, 20], + { scale: scaleLinear, range: ['#eff6ff', '#1d4ed8'] }, + defaultChartTheme, + ) + + expect( + legendLabelAnchors( + colorLegend({ label: 'Category' }), + categorical, + direction, + ), + ).toEqual([left, left, left]) + expect( + legendLabelAnchors( + colorLegend({ label: 'Intensity' }), + stepped, + direction, + ), + ).toEqual([left, left, 'middle', 'middle', right]) + expect( + legendLabelAnchors( + colorGradientLegend({ label: 'Intensity', steps: 4 }), + continuous, + direction, + ), + ).toEqual([left, left, right]) + }, + ) + it.each([ { kind: 'quantize', @@ -403,6 +452,7 @@ describe('continuous color', () => { function renderLegend( legend: ReturnType, colors: ReturnType, + direction?: 'ltr' | 'rtl', ) { const node = legend.render({ colors, @@ -411,11 +461,22 @@ function renderLegend( theme: defaultChartTheme, width: 480, height: 320, + direction, }) if (node.kind !== 'group') throw new Error('Expected a legend group') return node } +function legendLabelAnchors( + legend: ReturnType, + colors: ReturnType, + direction: 'ltr' | 'rtl', +) { + return renderLegend(legend, colors, direction) + .children.filter((child) => child.kind === 'label') + .map((child) => child.anchor) +} + function legendRects( legend: ReturnType, colors: ReturnType, diff --git a/packages/charts-core/src/crosshair-resolver.ts b/packages/charts-core/src/crosshair-resolver.ts index f65ce54a..423292f9 100644 --- a/packages/charts-core/src/crosshair-resolver.ts +++ b/packages/charts-core/src/crosshair-resolver.ts @@ -1,4 +1,4 @@ -import { measureSceneLabelBounds } from './guide-layout' +import { measureSceneLabelBounds, physicalTextAnchor } from './guide-layout' import { valueKey } from './scales' import type { ChartBounds, @@ -303,7 +303,7 @@ function resolveFocusGuide( target.yValue, guide.y.label.format, ), - anchor: 'end', + anchor: physicalTextAnchor('right', guide.direction), baseline: 'middle', fontSize: guide.y.label.fontSize, fontWeight: guide.y.label.fontWeight, diff --git a/packages/charts-core/src/crosshair.test.ts b/packages/charts-core/src/crosshair.test.ts index f9b6d6e2..7eaa2990 100644 --- a/packages/charts-core/src/crosshair.test.ts +++ b/packages/charts-core/src/crosshair.test.ts @@ -6,6 +6,7 @@ import { dot } from './dot' import { facet } from './facet' import { resolveFocusPresentation } from './focus-presentation' import { group } from './group' +import { measureSceneLabelBounds } from './guide-layout' import { createChartScene, defineChart } from './scene' import { stack } from './stack' import { linearAxes } from './test-scales' @@ -283,6 +284,54 @@ describe('crosshair', () => { ).toBe(true) }) + it('keeps y labels physically left in both inline directions', () => { + const resolved = (direction: 'ltr' | 'rtl') => { + const scene = createChartScene( + defineChart({ + marks: [ + dot([{ x: 1, y: 2 }], { x: 'x', y: 'y' }), + crosshair({ x: false, y: { label: true } }), + ], + ...linearAxes([0, 2], [0, 4]), + guides: false, + focusRing: false, + margin: { top: 20, right: 20, bottom: 20, left: 80 }, + }), + { width: 240, height: 160 }, + { typography: { direction } }, + ) + const presentation = resolveFocusPresentation( + scene, + focus(scene.points[0]!), + ) + const label = findNode( + presentation.over, + 'crosshair-1:y-label:text', + ) as SceneLabel + const guide = scene.focusGuides![0]! + const bounds = measureSceneLabelBounds(label, guide.measureText) + return { scene, label, bounds } + } + + const leftToRight = resolved('ltr') + const rightToLeft = resolved('rtl') + + expect(leftToRight.label.anchor).toBe('end') + expect(rightToLeft.label.anchor).toBe('start') + expect(leftToRight.bounds.x + leftToRight.bounds.width).toBeCloseTo( + leftToRight.label.x, + ) + expect(rightToLeft.bounds.x + rightToLeft.bounds.width).toBeCloseTo( + rightToLeft.label.x, + ) + expect(leftToRight.bounds.x + leftToRight.bounds.width).toBeLessThan( + leftToRight.scene.chart.x, + ) + expect(rightToLeft.bounds.x + rightToLeft.bounds.width).toBeLessThan( + rightToLeft.scene.chart.x, + ) + }) + it('labels stacked difference bars with their plotted endpoint', () => { const rows = [ { id: 'first', category: 'A', series: 'first', value: 40 }, diff --git a/packages/charts-core/src/crosshair.ts b/packages/charts-core/src/crosshair.ts index 5f1e9969..1dfe9b02 100644 --- a/packages/charts-core/src/crosshair.ts +++ b/packages/charts-core/src/crosshair.ts @@ -135,6 +135,7 @@ export function crosshair< projectY: projector(scales[yScale]), motion: options.motion, measureText: layout.measureText, + direction: layout.typography?.direction, resolve: resolveCrosshairGuide, }, ], diff --git a/packages/charts-core/src/guide-layout.test.ts b/packages/charts-core/src/guide-layout.test.ts index 15ee6ea7..ee2df832 100644 --- a/packages/charts-core/src/guide-layout.test.ts +++ b/packages/charts-core/src/guide-layout.test.ts @@ -3,6 +3,7 @@ import { estimateSceneText, logicalTextAnchorOffset, measureSceneLabelBounds, + physicalTextAnchor, resolveGuideMargins, } from './guide-layout' import type { ChartTextMeasurer, SceneGroup, SceneLabel } from './types' @@ -134,6 +135,16 @@ describe('guide layout', () => { expect(logicalTextAnchorOffset(40, 'start', 'inherit')).toBe(0) }) + it('maps physical text sides to logical anchors', () => { + expect(physicalTextAnchor('left', 'ltr')).toBe('start') + expect(physicalTextAnchor('right', 'ltr')).toBe('end') + expect(physicalTextAnchor('left', 'rtl')).toBe('end') + expect(physicalTextAnchor('right', 'rtl')).toBe('start') + expect(physicalTextAnchor('left', 'inherit')).toBe('start') + expect(physicalTextAnchor('right', undefined)).toBe('end') + expect(physicalTextAnchor('middle', 'rtl')).toBe('middle') + }) + it('rotates the measured rectangle around the label position', () => { const bounds = measureSceneLabelBounds( label({ diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index 61a61553..22ec3599 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -27,6 +27,16 @@ export interface GuideMarginOptions { measureText?: ChartTextMeasurer } +export function physicalTextAnchor( + side: 'left' | 'middle' | 'right', + direction: ChartTextMeasureOptions['direction'] | undefined, +): ChartTextMeasureOptions['anchor'] { + if (side === 'middle') return 'middle' + const startsAtLeft = direction !== 'rtl' + if (side === 'left') return startsAtLeft ? 'start' : 'end' + return startsAtLeft ? 'end' : 'start' +} + export function logicalTextAnchorOffset( width: number, anchor: ChartTextMeasureOptions['anchor'], diff --git a/packages/charts-core/src/legend-static.ts b/packages/charts-core/src/legend-static.ts index cf7e2e6f..6f2f682d 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -2,6 +2,7 @@ import { layoutCategoricalLegendItems, resolveCategoricalLegendItems, } from './legend-layout-internal' +import { physicalTextAnchor } from './guide-layout' import type { ChartColorLegend, ChartLegendPlacement, @@ -56,7 +57,7 @@ export function colorLegend( if (isSteppedLegend(context.colors.kind)) { return renderSteppedLegend(options, context) } - const { colors, bounds, theme } = context + const { colors, bounds, theme, direction } = context const items = resolveCategoricalLegendItems(colors) const layout = layoutCategoricalLegendItems( items.length, @@ -71,6 +72,7 @@ export function colorLegend( x: bounds.x, y: bounds.y + 11, text: options.label, + anchor: physicalTextAnchor('left', direction), fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.78 }, @@ -96,6 +98,7 @@ export function colorLegend( x: x + 13, y, text: item.label, + anchor: physicalTextAnchor('left', direction), baseline: 'middle', fontSize: 11, style: { fill: theme.foreground, fillOpacity: 0.76 }, @@ -130,7 +133,12 @@ function isQuantitativeLegend( function renderSteppedLegend( options: ColorLegendOptions, - { colors, bounds, theme }: Parameters[0], + { + colors, + bounds, + theme, + direction, + }: Parameters[0], ): SceneNode { const width = Math.min(bounds.width, Math.max(80, options.width ?? 240)) const x = bounds.x @@ -146,6 +154,7 @@ function renderSteppedLegend( x, y: bounds.y + 10, text: options.label, + anchor: physicalTextAnchor('left', direction), fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.78 }, @@ -172,36 +181,36 @@ function renderSteppedLegend( ? thresholds.map((value, index) => ({ value, index: index + 1, - anchor: 'middle' as const, + side: 'middle' as const, })) : [ ...(typeof first === 'number' - ? [{ value: first, index: 0, anchor: 'start' as const }] + ? [{ value: first, index: 0, side: 'left' as const }] : []), ...thresholds.map((value, index) => ({ value, index: index + 1, - anchor: 'middle' as const, + side: 'middle' as const, })), ...(typeof last === 'number' ? [ { value: last, index: colors.range.length, - anchor: 'end' as const, + side: 'right' as const, }, ] : []), ] - boundaries.forEach(({ value, index, anchor }) => { + boundaries.forEach(({ value, index, side }) => { children.push({ kind: 'label', key: `legend-step-label:${index}:${value}`, x: x + index * itemWidth, y: y + 21, text: format(value), - anchor, + anchor: physicalTextAnchor(side, direction), fontSize: 10, style: { fill: theme.muted, fillOpacity: 0.72 }, }) @@ -266,7 +275,7 @@ export function colorGradientLegend( height() { return options.label ? 55 : 42 }, - render({ colors, bounds, theme }) { + render({ colors, bounds, theme, direction }) { const first = colors.domain[0] const last = colors.domain.at(-1) if (typeof first !== 'number' || typeof last !== 'number') { @@ -289,6 +298,7 @@ export function colorGradientLegend( x, y: bounds.y + 10, text: options.label, + anchor: physicalTextAnchor('left', direction), fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.78 }, @@ -314,7 +324,7 @@ export function colorGradientLegend( x, y: y + 21, text: format(first), - anchor: 'start', + anchor: physicalTextAnchor('left', direction), fontSize: 10, style: { fill: theme.muted, fillOpacity: 0.72 }, }, @@ -324,7 +334,7 @@ export function colorGradientLegend( x: x + width, y: y + 21, text: format(last), - anchor: 'end', + anchor: physicalTextAnchor('right', direction), fontSize: 10, style: { fill: theme.muted, fillOpacity: 0.72 }, }, diff --git a/packages/charts-core/src/polar-mark-internal.ts b/packages/charts-core/src/polar-mark-internal.ts index 54fa2211..673ca8a1 100644 --- a/packages/charts-core/src/polar-mark-internal.ts +++ b/packages/charts-core/src/polar-mark-internal.ts @@ -4,6 +4,7 @@ import type { ChartMarkRenderer, ChartMotionDefinition, ChartTheme, + ChartTextTypography, ChartValue, MarkScene, } from './types' @@ -25,6 +26,7 @@ export interface PolarLayoutContext { radius: number startAngle: number endAngle: number + direction?: ChartTextTypography['direction'] scales: Readonly> } diff --git a/packages/charts-core/src/polar.test.ts b/packages/charts-core/src/polar.test.ts index 3f1d68a8..58a577b4 100644 --- a/packages/charts-core/src/polar.test.ts +++ b/packages/charts-core/src/polar.test.ts @@ -687,40 +687,46 @@ describe('polar marks', () => { angle, radius: 1, })) - const scene = createChartScene( - defineChart({ - scales: { x: null, y: null }, - marks: [ - polar({ - scales: { - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, - }, - guides: [ - angleGrid({ values }), - angleGrid({ - id: 'authored-anchor', - values: [Math.PI / 2], - labelAnchor: 'end', - }), - ], - marks: [ - radialText(rows, { - id: 'outside-labels', - angle: 'angle', - radius: 'radius', - text: 'id', - key: 'id', - radiusOffset: 8, - anchor: 'outside', - }), - ], - }), - ], - margin: 0, - }), - { width: 200, height: 200 }, - ) + const definition = defineChart({ + scales: { x: null, y: null }, + marks: [ + polar({ + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + guides: [ + angleGrid({ values }), + angleGrid({ + id: 'authored-anchor', + values: [Math.PI / 2], + labelAnchor: 'end', + }), + ], + marks: [ + radialText(rows, { + id: 'outside-labels', + angle: 'angle', + radius: 'radius', + text: 'id', + key: 'id', + radiusOffset: 8, + anchor: 'outside', + }), + radialText([{ id: 'authored', angle: Math.PI / 2, radius: 1 }], { + id: 'authored-text-anchor', + angle: 'angle', + radius: 'radius', + text: 'id', + key: 'id', + anchor: 'start', + }), + ], + }), + ], + margin: 0, + }) + const scene = createChartScene(definition, { width: 200, height: 200 }) const labels = flatten(scene.nodes).filter((node) => node.kind === 'label') const textAnchors = rows.map((row) => labels.find((node) => @@ -743,6 +749,45 @@ describe('polar marks', () => { expect(labels.every((node) => node.anchor !== ('outside' as never))).toBe( true, ) + + const rtlScene = createChartScene( + definition, + { width: 200, height: 200 }, + { typography: { direction: 'rtl' } }, + ) + const rtlLabels = flatten(rtlScene.nodes).filter( + (node) => node.kind === 'label', + ) + const rtlExpected = expected.map((anchor) => + anchor === 'start' ? 'end' : anchor === 'end' ? 'start' : anchor, + ) + expect( + rows.map( + (row) => + rtlLabels.find((node) => + node.key.endsWith(`string:${row.id.length}:${row.id}`), + )?.anchor, + ), + ).toEqual(rtlExpected) + expect( + values.map( + (value) => + rtlLabels.find((node) => node.key === `angle-label:number:${value}`) + ?.anchor, + ), + ).toEqual(rtlExpected) + const authoredGroup = flatten(rtlScene.nodes).find( + (node) => node.kind === 'group' && node.key === 'authored-anchor:labels', + ) + expect( + authoredGroup?.kind === 'group' + ? authoredGroup.children.find((node) => node.kind === 'label')?.anchor + : undefined, + ).toBe('end') + expect( + rtlLabels.find((node) => node.key.startsWith('authored-text-anchor:')) + ?.anchor, + ).toBe('start') }) it('omits every nonfinite radial offset before paint callbacks', () => { diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index 9f21807c..7008c67b 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -24,6 +24,7 @@ import { } from './polar-sector-internal' import { resolveNumericScale, resolveScaleInput } from './scale-input' import { valueKey } from './scales' +import { physicalTextAnchor } from './guide-layout' import type { Arc, CurveFactory, CurveFactoryLineOnly } from 'd3-shape' import type { Channel, @@ -280,8 +281,13 @@ export function polar( values: marks.flatMap((mark) => mark.colorValues), }, }, - render: ({ chart, color, theme }) => { - const layout = resolvePolarLayout(options, chart, marks) + render: ({ chart, color, theme, layout: chartLayout }) => { + const layout = resolvePolarLayout( + options, + chart, + marks, + chartLayout.typography?.direction, + ) for (const mark of marks) { if (mark.angleScale) { requiredPolarScale( @@ -1720,7 +1726,7 @@ export function radialText( text: String(textValue), anchor: authoredAnchor === 'outside' - ? outsideRadialAnchor(anglePosition) + ? outsideRadialAnchor(anglePosition, layout.direction) : authoredAnchor, baseline: visualValue( options.baseline, @@ -2408,7 +2414,7 @@ export function angleGrid(options: AngleGridOptions = {}): PolarGuide { anchor: guideLabelOption( options.labelAnchor, labelContext, - outsideRadialAnchor(position), + outsideRadialAnchor(position, layout.direction), ), baseline: guideLabelOption( options.labelBaseline, @@ -2452,6 +2458,7 @@ function resolvePolarLayout( options: PolarOptions, chart: ChartBounds, marks: readonly InitializedPolarMark[], + direction: PolarLayoutContext['direction'], ): PolarLayoutContext { const startAngle = finite(options.startAngle, 0) const endAngle = finite(options.endAngle, tau) @@ -2468,6 +2475,7 @@ function resolvePolarLayout( radius, startAngle, endAngle, + direction, scales, } @@ -2794,13 +2802,13 @@ function isCompleteRevolution(startAngle: number, endAngle: number): boolean { return Math.abs(Math.abs(endAngle - startAngle) - tau) <= 1e-12 } -function outsideRadialAnchor(angle: number): 'start' | 'middle' | 'end' { +function outsideRadialAnchor( + angle: number, + direction: PolarLayoutContext['direction'], +): 'start' | 'middle' | 'end' { const horizontal = Math.sin(angle) - return Math.abs(horizontal) <= 1e-6 - ? 'middle' - : horizontal < 0 - ? 'end' - : 'start' + if (Math.abs(horizontal) <= 1e-6) return 'middle' + return physicalTextAnchor(horizontal < 0 ? 'right' : 'left', direction) } function guideLabelOption( diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index aa89697b..a05ac812 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -2,6 +2,7 @@ import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { measureSceneLabelBounds, + physicalTextAnchor, withChartTextTypography, } from './guide-layout' import { nearestScenePoint } from './nearest' @@ -512,6 +513,7 @@ function createChartSceneWithScaleResolver< theme, width, height, + direction: layoutOptions.typography?.direction, } nodes.push(legend.render(legendContext)) if (legend.control) controls.push(legend.control(legendContext)) @@ -577,6 +579,9 @@ function createChartSceneWithScaleResolver< colors, gradients: definition.gradients ?? [], theme, + ...(layoutOptions.typography?.direction === undefined + ? {} + : { direction: layoutOptions.typography.direction }), ...(controls.length ? { controls } : {}), ...(focusGuides.length ? { focusGuides } : {}), [chartSceneSource]: [definition, initialized], @@ -1085,6 +1090,7 @@ function resolveSceneLayout( theme, width, height, + direction: layout.typography?.direction, }) const legendBounds = legend && legendHeight !== undefined @@ -1140,6 +1146,7 @@ function resolveSceneLayout( theme, width, height, + direction: layout.typography?.direction, }, ) if (resolved.legend.placement === 'bottom') { @@ -1784,16 +1791,15 @@ function createTickLabelCandidates( const automaticAnchor: NonNullable = guide.channel === 'y' ? positiveSide - ? 'start' - : 'end' + ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') + : physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') : (rotate ?? 0) < 0 - ? 'end' + ? physicalTextAnchor('right', rightToLeft ? 'rtl' : 'ltr') : (rotate ?? 0) > 0 - ? 'start' + ? physicalTextAnchor('left', rightToLeft ? 'rtl' : 'ltr') : 'middle' const anchor = - resolveTickLabelValue(options.anchor, context) ?? - mirrorAutomaticAnchor(automaticAnchor, rightToLeft) + resolveTickLabelValue(options.anchor, context) ?? automaticAnchor const label: SceneLabel = guide.channel === 'x' ? { @@ -1837,14 +1843,6 @@ function createTickLabelCandidates( }) } -function mirrorAutomaticAnchor( - anchor: NonNullable, - rightToLeft: boolean, -): NonNullable { - if (!rightToLeft || anchor === 'middle') return anchor - return anchor === 'start' ? 'end' : 'start' -} - function resolveTickLabelValue( value: ChartAxisTickLabelValue | undefined, context: ChartAxisTickLabelContext, diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index a3ecbb2d..36e18e9e 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -46,6 +46,31 @@ describe('SVG scene renderer', () => { /]* fill="#0f766e" opacity="0\.6"[^>]* font-size="17" font-weight="650"/, ) }) + + it('serializes right-to-left scene direction on the root SVG', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + side: 'right', + }, + }, + }), + { width: 480, height: 260 }, + { typography: { direction: 'rtl' } }, + ) + + const svg = renderChartSvg(scene, { ariaLabel: 'RTL chart' }) + + expect(scene.direction).toBe('rtl') + expect(svg).toMatch(/^]* direction="rtl"[^>]*>/) + expect(svg).toMatch( + /data-ts-key="y-tick-label:[^"]+"[^>]*text-anchor="end"/, + ) + }) }) function testScene(): ChartScene { diff --git a/packages/charts-core/src/svg-renderer.ts b/packages/charts-core/src/svg-renderer.ts index aa191bf5..4e09f45a 100644 --- a/packages/charts-core/src/svg-renderer.ts +++ b/packages/charts-core/src/svg-renderer.ts @@ -29,6 +29,10 @@ export function renderChartSvgWithHooks( ? `${escapeText(options.ariaDescription)}` : '' const definitions = hooks?.renderDefinitions?.(scene, idPrefix) ?? '' + const direction = + scene.direction === 'ltr' || scene.direction === 'rtl' + ? ` direction="${scene.direction}"` + : '' const background = scene.theme.background === 'transparent' ? '' @@ -46,7 +50,7 @@ export function renderChartSvgWithHooks( idPrefix, ) - return `${description}${definitions}${background}${renderSceneNodes(scene.nodes, idPrefix, hooks)}` + return `${description}${definitions}${background}${renderSceneNodes(scene.nodes, idPrefix, hooks)}` } export function renderSceneNodes( diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index cab6955b..36d293a3 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -524,6 +524,7 @@ export interface ChartColorLegendContext { theme: ChartTheme width: number height: number + direction?: ChartTextTypography['direction'] } export type ChartLegendPlacement = 'top' | 'bottom' @@ -1398,6 +1399,7 @@ export interface SceneFocusGuide { projectY?: (value: ChartValue) => number | undefined motion?: ChartMotionDefinition measureText?: ChartTextMeasurer + direction?: ChartTextTypography['direction'] /** Facet-owned point-key prefix. Omitted for a top-level guide. */ scope?: string /** Resolves this guide's dynamic presentation without retaining its implementation in every renderer bundle. */ @@ -1541,6 +1543,7 @@ export interface ChartScene< colors: ResolvedColorScale gradients: readonly ChartLinearGradient[] theme: ChartTheme + direction?: ChartTextTypography['direction'] controls?: readonly ChartHostControl[] focusGuides?: readonly SceneFocusGuide[] } diff --git a/packages/react-native-charts/src/FocusOverlay.tsx b/packages/react-native-charts/src/FocusOverlay.tsx index 657369af..e5200976 100644 --- a/packages/react-native-charts/src/FocusOverlay.tsx +++ b/packages/react-native-charts/src/FocusOverlay.tsx @@ -9,6 +9,7 @@ import type { ChartFocusSource, ChartPoint, ChartScene, + ChartTextTypography, ChartValue, } from '@tanstack/charts/types' import { resolveNativeSolidPaint, type NativePaintResolver } from './paint' @@ -30,6 +31,7 @@ export interface NativeChartFocusOverlayProps< color: ColorValue fill: ColorValue fontFamily?: string + direction?: ChartTextTypography['direction'] idPrefix: string resolvePaint: NativePaintResolver } @@ -50,6 +52,7 @@ export function NativeChartFocusOverlay< color, fill, fontFamily, + direction, idPrefix, resolvePaint, }: NativeChartFocusOverlayProps) { @@ -81,6 +84,7 @@ export function NativeChartFocusOverlay< nodes={nodes} color={color} fontFamily={fontFamily} + direction={direction} idPrefix={idPrefix} resolvePaint={resolvePaint} /> diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 3138344a..2a290be4 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -399,6 +399,55 @@ describe('React Native SVG scene renderer', () => { expect(render([second])).not.toContain('x1="10"') }) + it('passes RTL direction through the native focus overlay', () => { + const focusScene = scene() + const point = { + key: 'focused-point', + markId: 'focused-mark', + group: null, + groupLabel: 'focused-mark', + datum: { id: 'focused-point' }, + datumIndex: 0, + xValue: 1, + yValue: 2, + x: 40, + y: 30, + color: '#2563eb', + } + focusScene.nodes = [ + { + kind: 'group', + key: 'focus:label', + children: [label(point.key, 'start')], + focus: { + match: 'primary', + points: [point], + placement: 'over', + }, + }, + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(renderedTextAnchor(markup, point.key)).toBe('end') + }) + it('paints focus underlays and overlays around the base scene in one SVG', () => { const base = scene() base.nodes = [ From 5e2bfccd1eea7ae9caaf889437ddf12f9ffc90e7 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 2 Sep 2026 16:39:39 -0600 Subject: [PATCH 09/10] Update combined release baselines --- .../bundle-size/universal-baseline.json | 40 +- benchmarks/comparison/bundle-baseline.json | 126 +-- .../conformance/previews/01-line-gaps.svg | 2 +- .../previews/02-multi-line-end-labels.svg | 2 +- .../previews/03-temperature-range-band.svg | 2 +- .../previews/04-stacked-time-area.svg | 2 +- .../conformance/previews/100-radial-bars.svg | 2 +- .../conformance/previews/101-sunburst.svg | 2 +- .../previews/102-world-choropleth.svg | 2 +- .../conformance/previews/103-bubble-map.svg | 2 +- .../previews/104-orthographic-globe.svg | 2 +- .../conformance/previews/105-route-map.svg | 2 +- .../conformance/previews/106-polar-line.svg | 2 +- .../previews/107-polar-scatter.svg | 2 +- .../previews/108-country-choropleth.svg | 2 +- .../previews/109-us-state-choropleth.svg | 2 +- .../previews/110-projection-gallery.svg | 2 +- .../conformance/previews/111-basic-sankey.svg | 2 +- .../conformance/previews/111-sankey-flow.svg | 2 +- .../previews/112-motion-entrance.svg | 2 +- .../previews/113-motion-updates.svg | 2 +- .../previews/114-spring-line-motion.svg | 2 +- .../previews/115-definition-motion.svg | 2 +- .../previews/116-geometry-morph.svg | 2 +- .../previews/117-focus-cursor-motion.svg | 2 +- .../previews/118-token-usage-calendar.svg | 2 +- .../previews/119-stacked-bar-band-cursor.svg | 2 +- .../previews/120-themed-interactive-area.svg | 2 +- .../previews/121-active-bar-dashboard.svg | 2 +- .../previews/122-premium-kpi-sparklines.svg | 2 +- .../previews/123-active-donut-metric.svg | 2 +- .../previews/124-theme-palette-matrix.svg | 2 +- .../conformance/previews/125-sales-funnel.svg | 2 +- .../previews/126-drillable-sunburst.svg | 2 +- .../previews/127-shadcn-dashboard.svg | 2 +- .../previews/128-shadcn-bar-multiple.svg | 2 +- .../previews/129-shadcn-pie-donut-text.svg | 2 +- .../previews/13-interval-timeline.svg | 2 +- .../previews/130-shadcn-radar-multiple.svg | 2 +- .../previews/131-shadcn-radial-text.svg | 2 +- .../previews/132-shadcn-tooltip-advanced.svg | 2 +- .../previews/133-shadcn-area-axes.svg | 2 +- .../previews/134-shadcn-area-default.svg | 2 +- .../previews/135-shadcn-area-gradient.svg | 2 +- .../previews/136-shadcn-area-icons.svg | 2 +- .../previews/137-shadcn-area-interactive.svg | 2 +- .../previews/138-shadcn-area-legend.svg | 2 +- .../previews/139-shadcn-area-linear.svg | 2 +- .../conformance/previews/14-error-bars.svg | 2 +- .../140-shadcn-area-stacked-expand.svg | 2 +- .../previews/141-shadcn-area-stacked.svg | 2 +- .../previews/142-shadcn-area-step.svg | 2 +- .../previews/143-shadcn-bar-active.svg | 2 +- .../previews/144-shadcn-bar-default.svg | 2 +- .../previews/145-shadcn-bar-horizontal.svg | 2 +- .../previews/146-shadcn-bar-interactive.svg | 2 +- .../previews/147-shadcn-bar-label-custom.svg | 2 +- .../previews/148-shadcn-bar-label.svg | 2 +- .../previews/149-shadcn-bar-mixed.svg | 2 +- .../conformance/previews/15-boxplot.svg | 2 +- .../previews/150-shadcn-bar-negative.svg | 2 +- .../previews/151-shadcn-bar-stacked.svg | 2 +- .../previews/152-shadcn-line-default.svg | 2 +- .../previews/153-shadcn-line-dots-colors.svg | 2 +- .../previews/154-shadcn-line-dots-custom.svg | 2 +- .../previews/155-shadcn-line-dots.svg | 2 +- .../previews/156-shadcn-line-interactive.svg | 2 +- .../previews/157-shadcn-line-label-custom.svg | 2 +- .../previews/158-shadcn-line-label.svg | 2 +- .../previews/159-shadcn-line-linear.svg | 2 +- .../conformance/previews/16-lollipop.svg | 2 +- .../previews/160-shadcn-line-multiple.svg | 2 +- .../previews/161-shadcn-line-step.svg | 2 +- .../previews/162-shadcn-pie-donut-active.svg | 2 +- .../previews/163-shadcn-pie-donut.svg | 2 +- .../previews/164-shadcn-pie-interactive.svg | 2 +- .../previews/165-shadcn-pie-label-custom.svg | 2 +- .../previews/166-shadcn-pie-label-list.svg | 2 +- .../previews/167-shadcn-pie-label.svg | 2 +- .../previews/168-shadcn-pie-legend.svg | 2 +- .../169-shadcn-pie-separator-none.svg | 2 +- .../conformance/previews/17-dumbbell.svg | 2 +- .../previews/170-shadcn-pie-simple.svg | 2 +- .../previews/171-shadcn-pie-stacked.svg | 2 +- .../previews/172-shadcn-radar-default.svg | 2 +- .../previews/173-shadcn-radar-dots.svg | 2 +- .../174-shadcn-radar-grid-circle-fill.svg | 2 +- .../175-shadcn-radar-grid-circle-no-lines.svg | 2 +- .../previews/176-shadcn-radar-grid-circle.svg | 2 +- .../previews/177-shadcn-radar-grid-custom.svg | 2 +- .../previews/178-shadcn-radar-grid-fill.svg | 2 +- .../previews/179-shadcn-radar-grid-none.svg | 2 +- .../previews/18-cumulative-histogram.svg | 2 +- .../previews/180-shadcn-radar-icons.svg | 2 +- .../181-shadcn-radar-label-custom.svg | 2 +- .../previews/182-shadcn-radar-legend.svg | 2 +- .../previews/183-shadcn-radar-lines-only.svg | 2 +- .../previews/184-shadcn-radar-radius.svg | 2 +- .../previews/185-shadcn-radial-grid.svg | 2 +- .../previews/186-shadcn-radial-label.svg | 2 +- .../previews/187-shadcn-radial-shape.svg | 2 +- .../previews/188-shadcn-radial-simple.svg | 2 +- .../previews/189-shadcn-radial-stacked.svg | 2 +- .../previews/19-moving-average-line.svg | 2 +- .../previews/190-shadcn-tooltip-default.svg | 2 +- .../previews/191-shadcn-tooltip-formatter.svg | 2 +- .../previews/192-shadcn-tooltip-icons.svg | 2 +- .../193-shadcn-tooltip-indicator-line.svg | 2 +- .../194-shadcn-tooltip-indicator-none.svg | 2 +- .../195-shadcn-tooltip-label-custom.svg | 2 +- .../196-shadcn-tooltip-label-formatter.svg | 2 +- .../197-shadcn-tooltip-label-none.svg | 2 +- .../previews/20-normalized-stacked-area.svg | 2 +- .../conformance/previews/21-streamgraph.svg | 2 +- .../previews/22-bollinger-band.svg | 2 +- .../24-quantitative-binned-heatmap.svg | 2 +- .../previews/25-calendar-heatmap.svg | 2 +- .../previews/26-diverging-likert.svg | 2 +- .../previews/27-parallel-coordinates.svg | 2 +- .../conformance/previews/28-candlestick.svg | 2 +- .../conformance/previews/29-waterfall.svg | 2 +- .../conformance/previews/30-slopegraph.svg | 2 +- .../previews/31-linear-regression.svg | 2 +- .../conformance/previews/32-change-arrows.svg | 2 +- .../previews/33-difference-chart.svg | 2 +- .../previews/34-pointer-tooltip.svg | 2 +- .../previews/35-grouped-tooltip.svg | 2 +- .../previews/36-hierarchy-tree.svg | 2 +- .../previews/37-delaunay-network.svg | 2 +- .../previews/38-contour-topography.svg | 2 +- .../previews/39-density-contours.svg | 2 +- .../previews/40-force-directed-network.svg | 2 +- .../conformance/previews/40-geojson-map.svg | 2 +- .../previews/41-waffle-unit-chart.svg | 2 +- .../conformance/previews/42-vector-field.svg | 2 +- .../previews/43-hexbin-density.svg | 2 +- .../previews/44-framed-scatter.svg | 2 +- .../conformance/previews/50-empirical-cdf.svg | 2 +- .../previews/51-faceted-distributions.svg | 2 +- .../previews/52-beeswarm-dodge.svg | 2 +- .../previews/53-log-scale-scatter.svg | 2 +- .../conformance/previews/54-bump-ranking.svg | 2 +- .../previews/55-indexed-multi-line.svg | 2 +- .../previews/56-connected-scatter.svg | 2 +- .../57-scatter-marginal-histograms.svg | 2 +- .../previews/58-select-extrema.svg | 2 +- .../previews/59-grouped-reducer-bars.svg | 2 +- .../previews/60-lag-autocorrelation.svg | 2 +- .../previews/61-quantile-ribbon.svg | 2 +- .../previews/62-ridgeline-density.svg | 2 +- .../previews/63-violin-distributions.svg | 2 +- .../previews/64-marimekko-mosaic.svg | 2 +- .../previews/65-voronoi-nearest-tooltip.svg | 2 +- .../previews/70-composed-chart.svg | 2 +- .../71-recharts-population-pyramid.svg | 2 +- .../previews/72-recharts-mixed-bars.svg | 2 +- .../previews/73-many-point-scatter.svg | 2 +- .../previews/74-recharts-treemap.svg | 2 +- benchmarks/conformance/previews/75-radar.svg | 2 +- benchmarks/conformance/previews/76-pie.svg | 2 +- benchmarks/conformance/previews/77-donut.svg | 2 +- benchmarks/conformance/previews/78-gauge.svg | 2 +- .../previews/80-echarts-axis-pointer.svg | 2 +- .../81-recharts-interactive-legend.svg | 2 +- .../previews/82-chart-table-selection.svg | 2 +- .../previews/83-focus-context-window.svg | 2 +- .../84-pinned-nested-chart-tooltip.svg | 2 +- .../previews/85-scrollable-resource-lanes.svg | 2 +- .../86-streaming-window-preservation.svg | 2 +- .../87-echarts-synchronized-cursors.svg | 2 +- .../previews/88-echarts-free-cursor.svg | 2 +- .../previews/89-brush-range-selection.svg | 2 +- .../previews/90-zoomable-time-window.svg | 2 +- .../91-timeline-playback-scrubber.svg | 2 +- .../previews/92-editable-event-range.svg | 2 +- .../conformance/previews/93-labeled-pie.svg | 2 +- .../conformance/previews/94-center-donut.svg | 2 +- .../conformance/previews/95-rounded-donut.svg | 2 +- .../conformance/previews/96-nested-donut.svg | 2 +- benchmarks/conformance/previews/97-rose.svg | 2 +- .../conformance/previews/98-needle-gauge.svg | 2 +- .../previews/99-comparative-radar.svg | 2 +- .../conformance/previews/bar-grouped.svg | 2 +- .../previews/bar-horizontal-ranking.svg | 2 +- .../conformance/previews/bar-stacked.svg | 2 +- .../previews/bar-vertical-sorted.svg | 2 +- .../conformance/previews/facets-anscombe.svg | 2 +- .../conformance/previews/heatmap-labeled.svg | 2 +- benchmarks/conformance/previews/histogram.svg | 2 +- benchmarks/conformance/previews/manifest.json | 754 +++++++++--------- .../conformance/previews/scatter-bubble.svg | 2 +- docs/comparison.md | 8 +- packages/charts-core/docs/comparison.md | 8 +- .../docs/reference/focus-and-interaction.md | 3 +- .../docs/reference/scales-guides-and-color.md | 9 +- scripts/measure-bundles.mjs | 68 +- 196 files changed, 700 insertions(+), 692 deletions(-) diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 7c6584b7..dcea8692 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": 50982, - "gzip": 19097 + "bytes": 51661, + "gzip": 19316 }, "D3-scale line + static SVG": { - "bytes": 55713, - "gzip": 20805 + "bytes": 56472, + "gzip": 21076 }, "Representative marks": { - "bytes": 75788, - "gzip": 27619 + "bytes": 76536, + "gzip": 27844 }, "TanStack DOM host": { - "bytes": 75118, - "gzip": 26064 + "bytes": 75844, + "gzip": 26314 }, "React adapter": { - "bytes": 77301, - "gzip": 26854 + "bytes": 78028, + "gzip": 27086 }, "React line consumer": { - "bytes": 100937, - "gzip": 36262 + "bytes": 101679, + "gzip": 36487 }, "Compact-scale line scene": { - "bytes": 33436, - "gzip": 11953 + "bytes": 34114, + "gzip": 12160 }, "React compact-scale line consumer": { - "bytes": 83437, - "gzip": 29177 + "bytes": 84187, + "gzip": 29402 }, "Custom-scale line scene": { - "bytes": 31618, - "gzip": 11219 + "bytes": 32296, + "gzip": 11424 }, "D3 linear-scale line scene": { - "bytes": 50914, - "gzip": 19060 + "bytes": 51593, + "gzip": 19278 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 71f88136..5500ea71 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-27T21:42:45.654Z", + "generatedAt": "2026-09-02T22:29:44.477Z", "packageVersions": { "tanstack": "0.16.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:f6e6651fe8f30068b56208e243a35ea634593f85c7364b8403dfebfcb1e0d0f8" + "revision": "699b4be656a22ad99618466a3b46b7966e9fb4ff", + "inputDigest": "sha256:8b13c0942041f80ae1b644ffad32ba2c285a687ce3df73868554c83b2c553538" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110602, - "gzipBytes": 39906, - "brotliBytes": 35329, - "incrementalGzipBytes": 39906, - "incrementalBrotliBytes": 35329 + "minifiedBytes": 111349, + "gzipBytes": 40157, + "brotliBytes": 35531, + "incrementalGzipBytes": 40157, + "incrementalBrotliBytes": 35531 }, "tanstack-line-interactive": { - "minifiedBytes": 116043, - "gzipBytes": 41675, - "brotliBytes": 36794, - "incrementalGzipBytes": 41675, - "incrementalBrotliBytes": 36794 + "minifiedBytes": 116912, + "gzipBytes": 41957, + "brotliBytes": 36983, + "incrementalGzipBytes": 41957, + "incrementalBrotliBytes": 36983 }, "tanstack-line-advanced": { - "minifiedBytes": 123233, - "gzipBytes": 44002, - "brotliBytes": 38748, - "incrementalGzipBytes": 44002, - "incrementalBrotliBytes": 38748 + "minifiedBytes": 124102, + "gzipBytes": 44307, + "brotliBytes": 38984, + "incrementalGzipBytes": 44307, + "incrementalBrotliBytes": 38984 }, "tanstack-bar-basic": { - "minifiedBytes": 119396, - "gzipBytes": 43218, - "brotliBytes": 38198, - "incrementalGzipBytes": 43218, - "incrementalBrotliBytes": 38198 + "minifiedBytes": 120119, + "gzipBytes": 43468, + "brotliBytes": 38325, + "incrementalGzipBytes": 43468, + "incrementalBrotliBytes": 38325 }, "tanstack-bar-interactive": { - "minifiedBytes": 123692, - "gzipBytes": 44605, - "brotliBytes": 39267, - "incrementalGzipBytes": 44605, - "incrementalBrotliBytes": 39267 + "minifiedBytes": 124541, + "gzipBytes": 44862, + "brotliBytes": 39515, + "incrementalGzipBytes": 44862, + "incrementalBrotliBytes": 39515 }, "tanstack-bar-advanced": { - "minifiedBytes": 124031, - "gzipBytes": 44749, - "brotliBytes": 39341, - "incrementalGzipBytes": 44749, - "incrementalBrotliBytes": 39341 + "minifiedBytes": 124880, + "gzipBytes": 45011, + "brotliBytes": 39617, + "incrementalGzipBytes": 45011, + "incrementalBrotliBytes": 39617 }, "tanstack-area-basic": { - "minifiedBytes": 115811, - "gzipBytes": 41866, - "brotliBytes": 37072, - "incrementalGzipBytes": 41866, - "incrementalBrotliBytes": 37072 + "minifiedBytes": 116529, + "gzipBytes": 42105, + "brotliBytes": 37275, + "incrementalGzipBytes": 42105, + "incrementalBrotliBytes": 37275 }, "tanstack-area-interactive": { - "minifiedBytes": 121256, - "gzipBytes": 43620, - "brotliBytes": 38585, - "incrementalGzipBytes": 43620, - "incrementalBrotliBytes": 38585 + "minifiedBytes": 122100, + "gzipBytes": 43889, + "brotliBytes": 38741, + "incrementalGzipBytes": 43889, + "incrementalBrotliBytes": 38741 }, "tanstack-area-advanced": { - "minifiedBytes": 128628, - "gzipBytes": 46064, - "brotliBytes": 40628, - "incrementalGzipBytes": 46064, - "incrementalBrotliBytes": 40628 + "minifiedBytes": 129473, + "gzipBytes": 46345, + "brotliBytes": 40805, + "incrementalGzipBytes": 46345, + "incrementalBrotliBytes": 40805 }, "tanstack-scatter-basic": { - "minifiedBytes": 111631, - "gzipBytes": 40284, - "brotliBytes": 35628, - "incrementalGzipBytes": 40284, - "incrementalBrotliBytes": 35628 + "minifiedBytes": 112355, + "gzipBytes": 40544, + "brotliBytes": 35809, + "incrementalGzipBytes": 40544, + "incrementalBrotliBytes": 35809 }, "tanstack-scatter-interactive": { - "minifiedBytes": 117072, - "gzipBytes": 42044, - "brotliBytes": 37051, - "incrementalGzipBytes": 42044, - "incrementalBrotliBytes": 37051 + "minifiedBytes": 117922, + "gzipBytes": 42328, + "brotliBytes": 37305, + "incrementalGzipBytes": 42328, + "incrementalBrotliBytes": 37305 }, "tanstack-scatter-advanced": { - "minifiedBytes": 117088, - "gzipBytes": 42050, - "brotliBytes": 37043, - "incrementalGzipBytes": 42050, - "incrementalBrotliBytes": 37043 + "minifiedBytes": 117938, + "gzipBytes": 42334, + "brotliBytes": 37328, + "incrementalGzipBytes": 42334, + "incrementalBrotliBytes": 37328 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/01-line-gaps.svg b/benchmarks/conformance/previews/01-line-gaps.svg index 09d185e1..0b277690 100644 --- a/benchmarks/conformance/previews/01-line-gaps.svg +++ b/benchmarks/conformance/previews/01-line-gaps.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/02-multi-line-end-labels.svg b/benchmarks/conformance/previews/02-multi-line-end-labels.svg index 69a8a44a..5dd2a99e 100644 --- a/benchmarks/conformance/previews/02-multi-line-end-labels.svg +++ b/benchmarks/conformance/previews/02-multi-line-end-labels.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/03-temperature-range-band.svg b/benchmarks/conformance/previews/03-temperature-range-band.svg index e152affa..e84a3ff6 100644 --- a/benchmarks/conformance/previews/03-temperature-range-band.svg +++ b/benchmarks/conformance/previews/03-temperature-range-band.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/04-stacked-time-area.svg b/benchmarks/conformance/previews/04-stacked-time-area.svg index a01a4faa..552259ab 100644 --- a/benchmarks/conformance/previews/04-stacked-time-area.svg +++ b/benchmarks/conformance/previews/04-stacked-time-area.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/100-radial-bars.svg b/benchmarks/conformance/previews/100-radial-bars.svg index 92bdbd04..e13f8b20 100644 --- a/benchmarks/conformance/previews/100-radial-bars.svg +++ b/benchmarks/conformance/previews/100-radial-bars.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/101-sunburst.svg b/benchmarks/conformance/previews/101-sunburst.svg index d89af500..f8dd8b39 100644 --- a/benchmarks/conformance/previews/101-sunburst.svg +++ b/benchmarks/conformance/previews/101-sunburst.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/102-world-choropleth.svg b/benchmarks/conformance/previews/102-world-choropleth.svg index 8b6ebf5f..ec331dbb 100644 --- a/benchmarks/conformance/previews/102-world-choropleth.svg +++ b/benchmarks/conformance/previews/102-world-choropleth.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/103-bubble-map.svg b/benchmarks/conformance/previews/103-bubble-map.svg index 8230330b..ecec392a 100644 --- a/benchmarks/conformance/previews/103-bubble-map.svg +++ b/benchmarks/conformance/previews/103-bubble-map.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/104-orthographic-globe.svg b/benchmarks/conformance/previews/104-orthographic-globe.svg index 5110c9dc..0b59316a 100644 --- a/benchmarks/conformance/previews/104-orthographic-globe.svg +++ b/benchmarks/conformance/previews/104-orthographic-globe.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/105-route-map.svg b/benchmarks/conformance/previews/105-route-map.svg index b55607a0..f2659e3c 100644 --- a/benchmarks/conformance/previews/105-route-map.svg +++ b/benchmarks/conformance/previews/105-route-map.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/106-polar-line.svg b/benchmarks/conformance/previews/106-polar-line.svg index cc0d6cde..a04d292b 100644 --- a/benchmarks/conformance/previews/106-polar-line.svg +++ b/benchmarks/conformance/previews/106-polar-line.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/107-polar-scatter.svg b/benchmarks/conformance/previews/107-polar-scatter.svg index 8bbdc5eb..5b43b482 100644 --- a/benchmarks/conformance/previews/107-polar-scatter.svg +++ b/benchmarks/conformance/previews/107-polar-scatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/108-country-choropleth.svg b/benchmarks/conformance/previews/108-country-choropleth.svg index 984adb5e..878dd044 100644 --- a/benchmarks/conformance/previews/108-country-choropleth.svg +++ b/benchmarks/conformance/previews/108-country-choropleth.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/109-us-state-choropleth.svg b/benchmarks/conformance/previews/109-us-state-choropleth.svg index f02d330f..26c6836c 100644 --- a/benchmarks/conformance/previews/109-us-state-choropleth.svg +++ b/benchmarks/conformance/previews/109-us-state-choropleth.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/110-projection-gallery.svg b/benchmarks/conformance/previews/110-projection-gallery.svg index 51c29db4..e199d3ab 100644 --- a/benchmarks/conformance/previews/110-projection-gallery.svg +++ b/benchmarks/conformance/previews/110-projection-gallery.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/111-basic-sankey.svg b/benchmarks/conformance/previews/111-basic-sankey.svg index c1a83685..f46aebb6 100644 --- a/benchmarks/conformance/previews/111-basic-sankey.svg +++ b/benchmarks/conformance/previews/111-basic-sankey.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/111-sankey-flow.svg b/benchmarks/conformance/previews/111-sankey-flow.svg index d94c0a6f..af3f4f75 100644 --- a/benchmarks/conformance/previews/111-sankey-flow.svg +++ b/benchmarks/conformance/previews/111-sankey-flow.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/112-motion-entrance.svg b/benchmarks/conformance/previews/112-motion-entrance.svg index aa6c7162..ecf7cae8 100644 --- a/benchmarks/conformance/previews/112-motion-entrance.svg +++ b/benchmarks/conformance/previews/112-motion-entrance.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/113-motion-updates.svg b/benchmarks/conformance/previews/113-motion-updates.svg index 41a62652..79bab72b 100644 --- a/benchmarks/conformance/previews/113-motion-updates.svg +++ b/benchmarks/conformance/previews/113-motion-updates.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/114-spring-line-motion.svg b/benchmarks/conformance/previews/114-spring-line-motion.svg index 4c5baa64..2c6c64b5 100644 --- a/benchmarks/conformance/previews/114-spring-line-motion.svg +++ b/benchmarks/conformance/previews/114-spring-line-motion.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/115-definition-motion.svg b/benchmarks/conformance/previews/115-definition-motion.svg index df184ba6..5ce964fe 100644 --- a/benchmarks/conformance/previews/115-definition-motion.svg +++ b/benchmarks/conformance/previews/115-definition-motion.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/116-geometry-morph.svg b/benchmarks/conformance/previews/116-geometry-morph.svg index e9f4d17a..374023f0 100644 --- a/benchmarks/conformance/previews/116-geometry-morph.svg +++ b/benchmarks/conformance/previews/116-geometry-morph.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/117-focus-cursor-motion.svg b/benchmarks/conformance/previews/117-focus-cursor-motion.svg index a1cb7d28..8aa55b65 100644 --- a/benchmarks/conformance/previews/117-focus-cursor-motion.svg +++ b/benchmarks/conformance/previews/117-focus-cursor-motion.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/118-token-usage-calendar.svg b/benchmarks/conformance/previews/118-token-usage-calendar.svg index eb4533b3..87e237ca 100644 --- a/benchmarks/conformance/previews/118-token-usage-calendar.svg +++ b/benchmarks/conformance/previews/118-token-usage-calendar.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/119-stacked-bar-band-cursor.svg b/benchmarks/conformance/previews/119-stacked-bar-band-cursor.svg index 8171243d..f97f95fc 100644 --- a/benchmarks/conformance/previews/119-stacked-bar-band-cursor.svg +++ b/benchmarks/conformance/previews/119-stacked-bar-band-cursor.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/120-themed-interactive-area.svg b/benchmarks/conformance/previews/120-themed-interactive-area.svg index b1abf55d..2fa649e7 100644 --- a/benchmarks/conformance/previews/120-themed-interactive-area.svg +++ b/benchmarks/conformance/previews/120-themed-interactive-area.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/121-active-bar-dashboard.svg b/benchmarks/conformance/previews/121-active-bar-dashboard.svg index 56f5afbf..bab9c428 100644 --- a/benchmarks/conformance/previews/121-active-bar-dashboard.svg +++ b/benchmarks/conformance/previews/121-active-bar-dashboard.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/122-premium-kpi-sparklines.svg b/benchmarks/conformance/previews/122-premium-kpi-sparklines.svg index e30a6a89..e6c67b30 100644 --- a/benchmarks/conformance/previews/122-premium-kpi-sparklines.svg +++ b/benchmarks/conformance/previews/122-premium-kpi-sparklines.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/123-active-donut-metric.svg b/benchmarks/conformance/previews/123-active-donut-metric.svg index c9252528..4e8d231e 100644 --- a/benchmarks/conformance/previews/123-active-donut-metric.svg +++ b/benchmarks/conformance/previews/123-active-donut-metric.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/124-theme-palette-matrix.svg b/benchmarks/conformance/previews/124-theme-palette-matrix.svg index 2708cdb6..bb9252cb 100644 --- a/benchmarks/conformance/previews/124-theme-palette-matrix.svg +++ b/benchmarks/conformance/previews/124-theme-palette-matrix.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/125-sales-funnel.svg b/benchmarks/conformance/previews/125-sales-funnel.svg index a45e54b3..581fa500 100644 --- a/benchmarks/conformance/previews/125-sales-funnel.svg +++ b/benchmarks/conformance/previews/125-sales-funnel.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/126-drillable-sunburst.svg b/benchmarks/conformance/previews/126-drillable-sunburst.svg index 14373832..efcd9349 100644 --- a/benchmarks/conformance/previews/126-drillable-sunburst.svg +++ b/benchmarks/conformance/previews/126-drillable-sunburst.svg @@ -1 +1 @@ -Use arrow keys to inspect segments and Enter or Space to drill into a branch. Use the center button to move up. +Use arrow keys to inspect segments and Enter or Space to drill into a branch. Use the center button to move up. diff --git a/benchmarks/conformance/previews/127-shadcn-dashboard.svg b/benchmarks/conformance/previews/127-shadcn-dashboard.svg index a9e00819..d0737ee8 100644 --- a/benchmarks/conformance/previews/127-shadcn-dashboard.svg +++ b/benchmarks/conformance/previews/127-shadcn-dashboard.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/128-shadcn-bar-multiple.svg b/benchmarks/conformance/previews/128-shadcn-bar-multiple.svg index 8d1068e1..7ff0113b 100644 --- a/benchmarks/conformance/previews/128-shadcn-bar-multiple.svg +++ b/benchmarks/conformance/previews/128-shadcn-bar-multiple.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/129-shadcn-pie-donut-text.svg b/benchmarks/conformance/previews/129-shadcn-pie-donut-text.svg index d35321f1..36d58c5c 100644 --- a/benchmarks/conformance/previews/129-shadcn-pie-donut-text.svg +++ b/benchmarks/conformance/previews/129-shadcn-pie-donut-text.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/13-interval-timeline.svg b/benchmarks/conformance/previews/13-interval-timeline.svg index 642cc785..fd8fde51 100644 --- a/benchmarks/conformance/previews/13-interval-timeline.svg +++ b/benchmarks/conformance/previews/13-interval-timeline.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/130-shadcn-radar-multiple.svg b/benchmarks/conformance/previews/130-shadcn-radar-multiple.svg index b58ae9d0..19c904f7 100644 --- a/benchmarks/conformance/previews/130-shadcn-radar-multiple.svg +++ b/benchmarks/conformance/previews/130-shadcn-radar-multiple.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/131-shadcn-radial-text.svg b/benchmarks/conformance/previews/131-shadcn-radial-text.svg index 528c93fa..43116bb4 100644 --- a/benchmarks/conformance/previews/131-shadcn-radial-text.svg +++ b/benchmarks/conformance/previews/131-shadcn-radial-text.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg b/benchmarks/conformance/previews/132-shadcn-tooltip-advanced.svg index b2a90b54..e45a795f 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/133-shadcn-area-axes.svg b/benchmarks/conformance/previews/133-shadcn-area-axes.svg index 9eec3ad9..b01ac019 100644 --- a/benchmarks/conformance/previews/133-shadcn-area-axes.svg +++ b/benchmarks/conformance/previews/133-shadcn-area-axes.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/134-shadcn-area-default.svg b/benchmarks/conformance/previews/134-shadcn-area-default.svg index 8a148ac0..d7f18d96 100644 --- a/benchmarks/conformance/previews/134-shadcn-area-default.svg +++ b/benchmarks/conformance/previews/134-shadcn-area-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/135-shadcn-area-gradient.svg b/benchmarks/conformance/previews/135-shadcn-area-gradient.svg index 86f7fddc..dd199ece 100644 --- a/benchmarks/conformance/previews/135-shadcn-area-gradient.svg +++ b/benchmarks/conformance/previews/135-shadcn-area-gradient.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/136-shadcn-area-icons.svg b/benchmarks/conformance/previews/136-shadcn-area-icons.svg index abd86f10..1e680ca6 100644 --- a/benchmarks/conformance/previews/136-shadcn-area-icons.svg +++ b/benchmarks/conformance/previews/136-shadcn-area-icons.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/137-shadcn-area-interactive.svg b/benchmarks/conformance/previews/137-shadcn-area-interactive.svg index d104581b..b8b5524e 100644 --- a/benchmarks/conformance/previews/137-shadcn-area-interactive.svg +++ b/benchmarks/conformance/previews/137-shadcn-area-interactive.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/138-shadcn-area-legend.svg b/benchmarks/conformance/previews/138-shadcn-area-legend.svg index 7cc1a8d4..78eeea75 100644 --- a/benchmarks/conformance/previews/138-shadcn-area-legend.svg +++ b/benchmarks/conformance/previews/138-shadcn-area-legend.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/139-shadcn-area-linear.svg b/benchmarks/conformance/previews/139-shadcn-area-linear.svg index c79da36c..31ecda7d 100644 --- a/benchmarks/conformance/previews/139-shadcn-area-linear.svg +++ b/benchmarks/conformance/previews/139-shadcn-area-linear.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/14-error-bars.svg b/benchmarks/conformance/previews/14-error-bars.svg index 501aa3c4..cfaafba8 100644 --- a/benchmarks/conformance/previews/14-error-bars.svg +++ b/benchmarks/conformance/previews/14-error-bars.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/140-shadcn-area-stacked-expand.svg b/benchmarks/conformance/previews/140-shadcn-area-stacked-expand.svg index 3de72562..fe37bfb0 100644 --- a/benchmarks/conformance/previews/140-shadcn-area-stacked-expand.svg +++ b/benchmarks/conformance/previews/140-shadcn-area-stacked-expand.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/141-shadcn-area-stacked.svg b/benchmarks/conformance/previews/141-shadcn-area-stacked.svg index 707daf99..3716053a 100644 --- a/benchmarks/conformance/previews/141-shadcn-area-stacked.svg +++ b/benchmarks/conformance/previews/141-shadcn-area-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/142-shadcn-area-step.svg b/benchmarks/conformance/previews/142-shadcn-area-step.svg index 59d3e82c..23ba6e78 100644 --- a/benchmarks/conformance/previews/142-shadcn-area-step.svg +++ b/benchmarks/conformance/previews/142-shadcn-area-step.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/143-shadcn-bar-active.svg b/benchmarks/conformance/previews/143-shadcn-bar-active.svg index 6cd8f65c..b4efc083 100644 --- a/benchmarks/conformance/previews/143-shadcn-bar-active.svg +++ b/benchmarks/conformance/previews/143-shadcn-bar-active.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/144-shadcn-bar-default.svg b/benchmarks/conformance/previews/144-shadcn-bar-default.svg index ce563605..f6f0a55a 100644 --- a/benchmarks/conformance/previews/144-shadcn-bar-default.svg +++ b/benchmarks/conformance/previews/144-shadcn-bar-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/145-shadcn-bar-horizontal.svg b/benchmarks/conformance/previews/145-shadcn-bar-horizontal.svg index 4c7a9562..b2b8ec64 100644 --- a/benchmarks/conformance/previews/145-shadcn-bar-horizontal.svg +++ b/benchmarks/conformance/previews/145-shadcn-bar-horizontal.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/146-shadcn-bar-interactive.svg b/benchmarks/conformance/previews/146-shadcn-bar-interactive.svg index 64f3a14e..5f637eaf 100644 --- a/benchmarks/conformance/previews/146-shadcn-bar-interactive.svg +++ b/benchmarks/conformance/previews/146-shadcn-bar-interactive.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/147-shadcn-bar-label-custom.svg b/benchmarks/conformance/previews/147-shadcn-bar-label-custom.svg index 70fd82ac..858397cf 100644 --- a/benchmarks/conformance/previews/147-shadcn-bar-label-custom.svg +++ b/benchmarks/conformance/previews/147-shadcn-bar-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/148-shadcn-bar-label.svg b/benchmarks/conformance/previews/148-shadcn-bar-label.svg index 20809761..1d9fe12e 100644 --- a/benchmarks/conformance/previews/148-shadcn-bar-label.svg +++ b/benchmarks/conformance/previews/148-shadcn-bar-label.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/149-shadcn-bar-mixed.svg b/benchmarks/conformance/previews/149-shadcn-bar-mixed.svg index 71dd0fff..17b58a7e 100644 --- a/benchmarks/conformance/previews/149-shadcn-bar-mixed.svg +++ b/benchmarks/conformance/previews/149-shadcn-bar-mixed.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/15-boxplot.svg b/benchmarks/conformance/previews/15-boxplot.svg index 1ba5461a..8021b796 100644 --- a/benchmarks/conformance/previews/15-boxplot.svg +++ b/benchmarks/conformance/previews/15-boxplot.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/150-shadcn-bar-negative.svg b/benchmarks/conformance/previews/150-shadcn-bar-negative.svg index 8c11bdf0..35cf1e4e 100644 --- a/benchmarks/conformance/previews/150-shadcn-bar-negative.svg +++ b/benchmarks/conformance/previews/150-shadcn-bar-negative.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/151-shadcn-bar-stacked.svg b/benchmarks/conformance/previews/151-shadcn-bar-stacked.svg index 87aa235c..f70969dc 100644 --- a/benchmarks/conformance/previews/151-shadcn-bar-stacked.svg +++ b/benchmarks/conformance/previews/151-shadcn-bar-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/152-shadcn-line-default.svg b/benchmarks/conformance/previews/152-shadcn-line-default.svg index aa0326d7..db79dbdd 100644 --- a/benchmarks/conformance/previews/152-shadcn-line-default.svg +++ b/benchmarks/conformance/previews/152-shadcn-line-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/153-shadcn-line-dots-colors.svg b/benchmarks/conformance/previews/153-shadcn-line-dots-colors.svg index 140c1111..b9794e01 100644 --- a/benchmarks/conformance/previews/153-shadcn-line-dots-colors.svg +++ b/benchmarks/conformance/previews/153-shadcn-line-dots-colors.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/154-shadcn-line-dots-custom.svg b/benchmarks/conformance/previews/154-shadcn-line-dots-custom.svg index d62d119b..61d6a5b7 100644 --- a/benchmarks/conformance/previews/154-shadcn-line-dots-custom.svg +++ b/benchmarks/conformance/previews/154-shadcn-line-dots-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/155-shadcn-line-dots.svg b/benchmarks/conformance/previews/155-shadcn-line-dots.svg index c8fd0ab1..495fc0fb 100644 --- a/benchmarks/conformance/previews/155-shadcn-line-dots.svg +++ b/benchmarks/conformance/previews/155-shadcn-line-dots.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/156-shadcn-line-interactive.svg b/benchmarks/conformance/previews/156-shadcn-line-interactive.svg index 754ac2ee..1e40fbd9 100644 --- a/benchmarks/conformance/previews/156-shadcn-line-interactive.svg +++ b/benchmarks/conformance/previews/156-shadcn-line-interactive.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/157-shadcn-line-label-custom.svg b/benchmarks/conformance/previews/157-shadcn-line-label-custom.svg index 50676a5a..3723fd5b 100644 --- a/benchmarks/conformance/previews/157-shadcn-line-label-custom.svg +++ b/benchmarks/conformance/previews/157-shadcn-line-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/158-shadcn-line-label.svg b/benchmarks/conformance/previews/158-shadcn-line-label.svg index b7961974..95dc05ee 100644 --- a/benchmarks/conformance/previews/158-shadcn-line-label.svg +++ b/benchmarks/conformance/previews/158-shadcn-line-label.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/159-shadcn-line-linear.svg b/benchmarks/conformance/previews/159-shadcn-line-linear.svg index 0cf52e54..e14fd236 100644 --- a/benchmarks/conformance/previews/159-shadcn-line-linear.svg +++ b/benchmarks/conformance/previews/159-shadcn-line-linear.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/16-lollipop.svg b/benchmarks/conformance/previews/16-lollipop.svg index 761083d9..33f4c778 100644 --- a/benchmarks/conformance/previews/16-lollipop.svg +++ b/benchmarks/conformance/previews/16-lollipop.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/160-shadcn-line-multiple.svg b/benchmarks/conformance/previews/160-shadcn-line-multiple.svg index cb7f3b51..eef91b73 100644 --- a/benchmarks/conformance/previews/160-shadcn-line-multiple.svg +++ b/benchmarks/conformance/previews/160-shadcn-line-multiple.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/161-shadcn-line-step.svg b/benchmarks/conformance/previews/161-shadcn-line-step.svg index a78a6ec2..b786c746 100644 --- a/benchmarks/conformance/previews/161-shadcn-line-step.svg +++ b/benchmarks/conformance/previews/161-shadcn-line-step.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/162-shadcn-pie-donut-active.svg b/benchmarks/conformance/previews/162-shadcn-pie-donut-active.svg index 36bbfc9c..b225f503 100644 --- a/benchmarks/conformance/previews/162-shadcn-pie-donut-active.svg +++ b/benchmarks/conformance/previews/162-shadcn-pie-donut-active.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/163-shadcn-pie-donut.svg b/benchmarks/conformance/previews/163-shadcn-pie-donut.svg index d6c0a3a7..600aa145 100644 --- a/benchmarks/conformance/previews/163-shadcn-pie-donut.svg +++ b/benchmarks/conformance/previews/163-shadcn-pie-donut.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/164-shadcn-pie-interactive.svg b/benchmarks/conformance/previews/164-shadcn-pie-interactive.svg index 6ef5221d..8dc34fdc 100644 --- a/benchmarks/conformance/previews/164-shadcn-pie-interactive.svg +++ b/benchmarks/conformance/previews/164-shadcn-pie-interactive.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/165-shadcn-pie-label-custom.svg b/benchmarks/conformance/previews/165-shadcn-pie-label-custom.svg index ee171642..284497ec 100644 --- a/benchmarks/conformance/previews/165-shadcn-pie-label-custom.svg +++ b/benchmarks/conformance/previews/165-shadcn-pie-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/166-shadcn-pie-label-list.svg b/benchmarks/conformance/previews/166-shadcn-pie-label-list.svg index 5f7504e3..6470c4fb 100644 --- a/benchmarks/conformance/previews/166-shadcn-pie-label-list.svg +++ b/benchmarks/conformance/previews/166-shadcn-pie-label-list.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/167-shadcn-pie-label.svg b/benchmarks/conformance/previews/167-shadcn-pie-label.svg index 07f97928..9eceb000 100644 --- a/benchmarks/conformance/previews/167-shadcn-pie-label.svg +++ b/benchmarks/conformance/previews/167-shadcn-pie-label.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/168-shadcn-pie-legend.svg b/benchmarks/conformance/previews/168-shadcn-pie-legend.svg index 615d8f7c..9d1bae84 100644 --- a/benchmarks/conformance/previews/168-shadcn-pie-legend.svg +++ b/benchmarks/conformance/previews/168-shadcn-pie-legend.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/169-shadcn-pie-separator-none.svg b/benchmarks/conformance/previews/169-shadcn-pie-separator-none.svg index 8e3aca63..0303f3ce 100644 --- a/benchmarks/conformance/previews/169-shadcn-pie-separator-none.svg +++ b/benchmarks/conformance/previews/169-shadcn-pie-separator-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/17-dumbbell.svg b/benchmarks/conformance/previews/17-dumbbell.svg index 14d1e610..1db652a9 100644 --- a/benchmarks/conformance/previews/17-dumbbell.svg +++ b/benchmarks/conformance/previews/17-dumbbell.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/170-shadcn-pie-simple.svg b/benchmarks/conformance/previews/170-shadcn-pie-simple.svg index 73fdbafb..d946d3e2 100644 --- a/benchmarks/conformance/previews/170-shadcn-pie-simple.svg +++ b/benchmarks/conformance/previews/170-shadcn-pie-simple.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/171-shadcn-pie-stacked.svg b/benchmarks/conformance/previews/171-shadcn-pie-stacked.svg index 697bcc9e..1f0d9dd9 100644 --- a/benchmarks/conformance/previews/171-shadcn-pie-stacked.svg +++ b/benchmarks/conformance/previews/171-shadcn-pie-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/172-shadcn-radar-default.svg b/benchmarks/conformance/previews/172-shadcn-radar-default.svg index 41620652..3f3de3b4 100644 --- a/benchmarks/conformance/previews/172-shadcn-radar-default.svg +++ b/benchmarks/conformance/previews/172-shadcn-radar-default.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/173-shadcn-radar-dots.svg b/benchmarks/conformance/previews/173-shadcn-radar-dots.svg index 8072d2f4..f47e27a2 100644 --- a/benchmarks/conformance/previews/173-shadcn-radar-dots.svg +++ b/benchmarks/conformance/previews/173-shadcn-radar-dots.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/174-shadcn-radar-grid-circle-fill.svg b/benchmarks/conformance/previews/174-shadcn-radar-grid-circle-fill.svg index b5ca4695..eae15b93 100644 --- a/benchmarks/conformance/previews/174-shadcn-radar-grid-circle-fill.svg +++ b/benchmarks/conformance/previews/174-shadcn-radar-grid-circle-fill.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/175-shadcn-radar-grid-circle-no-lines.svg b/benchmarks/conformance/previews/175-shadcn-radar-grid-circle-no-lines.svg index e99ab066..0b9405dc 100644 --- a/benchmarks/conformance/previews/175-shadcn-radar-grid-circle-no-lines.svg +++ b/benchmarks/conformance/previews/175-shadcn-radar-grid-circle-no-lines.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/176-shadcn-radar-grid-circle.svg b/benchmarks/conformance/previews/176-shadcn-radar-grid-circle.svg index 175ec845..28928799 100644 --- a/benchmarks/conformance/previews/176-shadcn-radar-grid-circle.svg +++ b/benchmarks/conformance/previews/176-shadcn-radar-grid-circle.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/177-shadcn-radar-grid-custom.svg b/benchmarks/conformance/previews/177-shadcn-radar-grid-custom.svg index aa745677..f8d1c314 100644 --- a/benchmarks/conformance/previews/177-shadcn-radar-grid-custom.svg +++ b/benchmarks/conformance/previews/177-shadcn-radar-grid-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/178-shadcn-radar-grid-fill.svg b/benchmarks/conformance/previews/178-shadcn-radar-grid-fill.svg index 6e77ccd3..6bdfda1f 100644 --- a/benchmarks/conformance/previews/178-shadcn-radar-grid-fill.svg +++ b/benchmarks/conformance/previews/178-shadcn-radar-grid-fill.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/179-shadcn-radar-grid-none.svg b/benchmarks/conformance/previews/179-shadcn-radar-grid-none.svg index 1b02c46c..c6d1ce69 100644 --- a/benchmarks/conformance/previews/179-shadcn-radar-grid-none.svg +++ b/benchmarks/conformance/previews/179-shadcn-radar-grid-none.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/18-cumulative-histogram.svg b/benchmarks/conformance/previews/18-cumulative-histogram.svg index 2e5d5a4d..ea517bce 100644 --- a/benchmarks/conformance/previews/18-cumulative-histogram.svg +++ b/benchmarks/conformance/previews/18-cumulative-histogram.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/180-shadcn-radar-icons.svg b/benchmarks/conformance/previews/180-shadcn-radar-icons.svg index a519a708..69d97e1e 100644 --- a/benchmarks/conformance/previews/180-shadcn-radar-icons.svg +++ b/benchmarks/conformance/previews/180-shadcn-radar-icons.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg b/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg index 61b2840e..1b372e88 100644 --- a/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg +++ b/benchmarks/conformance/previews/181-shadcn-radar-label-custom.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/182-shadcn-radar-legend.svg b/benchmarks/conformance/previews/182-shadcn-radar-legend.svg index 79bf673d..dff34218 100644 --- a/benchmarks/conformance/previews/182-shadcn-radar-legend.svg +++ b/benchmarks/conformance/previews/182-shadcn-radar-legend.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/183-shadcn-radar-lines-only.svg b/benchmarks/conformance/previews/183-shadcn-radar-lines-only.svg index 2dd89904..69dc0695 100644 --- a/benchmarks/conformance/previews/183-shadcn-radar-lines-only.svg +++ b/benchmarks/conformance/previews/183-shadcn-radar-lines-only.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/184-shadcn-radar-radius.svg b/benchmarks/conformance/previews/184-shadcn-radar-radius.svg index d16c873e..efb5db24 100644 --- a/benchmarks/conformance/previews/184-shadcn-radar-radius.svg +++ b/benchmarks/conformance/previews/184-shadcn-radar-radius.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/185-shadcn-radial-grid.svg b/benchmarks/conformance/previews/185-shadcn-radial-grid.svg index 25224b4f..2969dc75 100644 --- a/benchmarks/conformance/previews/185-shadcn-radial-grid.svg +++ b/benchmarks/conformance/previews/185-shadcn-radial-grid.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/186-shadcn-radial-label.svg b/benchmarks/conformance/previews/186-shadcn-radial-label.svg index d3f798f6..d7c38e7b 100644 --- a/benchmarks/conformance/previews/186-shadcn-radial-label.svg +++ b/benchmarks/conformance/previews/186-shadcn-radial-label.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/187-shadcn-radial-shape.svg b/benchmarks/conformance/previews/187-shadcn-radial-shape.svg index fbaef355..3ae8e020 100644 --- a/benchmarks/conformance/previews/187-shadcn-radial-shape.svg +++ b/benchmarks/conformance/previews/187-shadcn-radial-shape.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/188-shadcn-radial-simple.svg b/benchmarks/conformance/previews/188-shadcn-radial-simple.svg index 6330e8f6..c71b221a 100644 --- a/benchmarks/conformance/previews/188-shadcn-radial-simple.svg +++ b/benchmarks/conformance/previews/188-shadcn-radial-simple.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg b/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg index 3b55c3b4..ed2802ba 100644 --- a/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg +++ b/benchmarks/conformance/previews/189-shadcn-radial-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/19-moving-average-line.svg b/benchmarks/conformance/previews/19-moving-average-line.svg index 173510b5..a6eaf171 100644 --- a/benchmarks/conformance/previews/19-moving-average-line.svg +++ b/benchmarks/conformance/previews/19-moving-average-line.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg b/benchmarks/conformance/previews/190-shadcn-tooltip-default.svg index d6ca753d..a536d1d6 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 d49e1a52..3f91ac67 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 57a41054..92998466 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 93ad3553..f3fa5dcf 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 9c5dd3e2..24bc2585 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 300d7da4..7d9f4e08 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 60706447..003322dd 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 33e669ff..941b2616 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/20-normalized-stacked-area.svg b/benchmarks/conformance/previews/20-normalized-stacked-area.svg index f7f98eb8..9d8c7496 100644 --- a/benchmarks/conformance/previews/20-normalized-stacked-area.svg +++ b/benchmarks/conformance/previews/20-normalized-stacked-area.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/21-streamgraph.svg b/benchmarks/conformance/previews/21-streamgraph.svg index 5cc6f0db..6acd4a45 100644 --- a/benchmarks/conformance/previews/21-streamgraph.svg +++ b/benchmarks/conformance/previews/21-streamgraph.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/22-bollinger-band.svg b/benchmarks/conformance/previews/22-bollinger-band.svg index 00564eba..f2d3e256 100644 --- a/benchmarks/conformance/previews/22-bollinger-band.svg +++ b/benchmarks/conformance/previews/22-bollinger-band.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/24-quantitative-binned-heatmap.svg b/benchmarks/conformance/previews/24-quantitative-binned-heatmap.svg index 5063cf65..84afc106 100644 --- a/benchmarks/conformance/previews/24-quantitative-binned-heatmap.svg +++ b/benchmarks/conformance/previews/24-quantitative-binned-heatmap.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/25-calendar-heatmap.svg b/benchmarks/conformance/previews/25-calendar-heatmap.svg index b273fb56..0111e7df 100644 --- a/benchmarks/conformance/previews/25-calendar-heatmap.svg +++ b/benchmarks/conformance/previews/25-calendar-heatmap.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/26-diverging-likert.svg b/benchmarks/conformance/previews/26-diverging-likert.svg index f2c7f6ac..84a44777 100644 --- a/benchmarks/conformance/previews/26-diverging-likert.svg +++ b/benchmarks/conformance/previews/26-diverging-likert.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/27-parallel-coordinates.svg b/benchmarks/conformance/previews/27-parallel-coordinates.svg index 4317680f..11d104c9 100644 --- a/benchmarks/conformance/previews/27-parallel-coordinates.svg +++ b/benchmarks/conformance/previews/27-parallel-coordinates.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/28-candlestick.svg b/benchmarks/conformance/previews/28-candlestick.svg index d19af45f..eb92030f 100644 --- a/benchmarks/conformance/previews/28-candlestick.svg +++ b/benchmarks/conformance/previews/28-candlestick.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/29-waterfall.svg b/benchmarks/conformance/previews/29-waterfall.svg index ce264e98..eb1f6659 100644 --- a/benchmarks/conformance/previews/29-waterfall.svg +++ b/benchmarks/conformance/previews/29-waterfall.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/30-slopegraph.svg b/benchmarks/conformance/previews/30-slopegraph.svg index 1b8da897..7ab12b9b 100644 --- a/benchmarks/conformance/previews/30-slopegraph.svg +++ b/benchmarks/conformance/previews/30-slopegraph.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/31-linear-regression.svg b/benchmarks/conformance/previews/31-linear-regression.svg index 0b736559..6396f1c3 100644 --- a/benchmarks/conformance/previews/31-linear-regression.svg +++ b/benchmarks/conformance/previews/31-linear-regression.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/32-change-arrows.svg b/benchmarks/conformance/previews/32-change-arrows.svg index ee79e300..c231c013 100644 --- a/benchmarks/conformance/previews/32-change-arrows.svg +++ b/benchmarks/conformance/previews/32-change-arrows.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/33-difference-chart.svg b/benchmarks/conformance/previews/33-difference-chart.svg index fbd62ccf..108051b3 100644 --- a/benchmarks/conformance/previews/33-difference-chart.svg +++ b/benchmarks/conformance/previews/33-difference-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/34-pointer-tooltip.svg b/benchmarks/conformance/previews/34-pointer-tooltip.svg index 4bc5eb3a..74411cad 100644 --- a/benchmarks/conformance/previews/34-pointer-tooltip.svg +++ b/benchmarks/conformance/previews/34-pointer-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/35-grouped-tooltip.svg b/benchmarks/conformance/previews/35-grouped-tooltip.svg index 7cedf5a4..17246717 100644 --- a/benchmarks/conformance/previews/35-grouped-tooltip.svg +++ b/benchmarks/conformance/previews/35-grouped-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/36-hierarchy-tree.svg b/benchmarks/conformance/previews/36-hierarchy-tree.svg index 3cb7b53a..2aa27944 100644 --- a/benchmarks/conformance/previews/36-hierarchy-tree.svg +++ b/benchmarks/conformance/previews/36-hierarchy-tree.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/37-delaunay-network.svg b/benchmarks/conformance/previews/37-delaunay-network.svg index 9307d34c..c4498bda 100644 --- a/benchmarks/conformance/previews/37-delaunay-network.svg +++ b/benchmarks/conformance/previews/37-delaunay-network.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/38-contour-topography.svg b/benchmarks/conformance/previews/38-contour-topography.svg index 43478055..09760f49 100644 --- a/benchmarks/conformance/previews/38-contour-topography.svg +++ b/benchmarks/conformance/previews/38-contour-topography.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/39-density-contours.svg b/benchmarks/conformance/previews/39-density-contours.svg index 0b6dd171..c470a6d3 100644 --- a/benchmarks/conformance/previews/39-density-contours.svg +++ b/benchmarks/conformance/previews/39-density-contours.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/40-force-directed-network.svg b/benchmarks/conformance/previews/40-force-directed-network.svg index ff1859f7..695d112b 100644 --- a/benchmarks/conformance/previews/40-force-directed-network.svg +++ b/benchmarks/conformance/previews/40-force-directed-network.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/40-geojson-map.svg b/benchmarks/conformance/previews/40-geojson-map.svg index ce7843f7..ab88cd84 100644 --- a/benchmarks/conformance/previews/40-geojson-map.svg +++ b/benchmarks/conformance/previews/40-geojson-map.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/41-waffle-unit-chart.svg b/benchmarks/conformance/previews/41-waffle-unit-chart.svg index c14125d7..c3ec844b 100644 --- a/benchmarks/conformance/previews/41-waffle-unit-chart.svg +++ b/benchmarks/conformance/previews/41-waffle-unit-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/42-vector-field.svg b/benchmarks/conformance/previews/42-vector-field.svg index 763693cb..70a0adde 100644 --- a/benchmarks/conformance/previews/42-vector-field.svg +++ b/benchmarks/conformance/previews/42-vector-field.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/43-hexbin-density.svg b/benchmarks/conformance/previews/43-hexbin-density.svg index 62172df3..3c71c8b4 100644 --- a/benchmarks/conformance/previews/43-hexbin-density.svg +++ b/benchmarks/conformance/previews/43-hexbin-density.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/44-framed-scatter.svg b/benchmarks/conformance/previews/44-framed-scatter.svg index e3b8d164..a7d80f7c 100644 --- a/benchmarks/conformance/previews/44-framed-scatter.svg +++ b/benchmarks/conformance/previews/44-framed-scatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/50-empirical-cdf.svg b/benchmarks/conformance/previews/50-empirical-cdf.svg index e0781c0c..0ead6a7c 100644 --- a/benchmarks/conformance/previews/50-empirical-cdf.svg +++ b/benchmarks/conformance/previews/50-empirical-cdf.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/51-faceted-distributions.svg b/benchmarks/conformance/previews/51-faceted-distributions.svg index 4b9e0277..75c0057b 100644 --- a/benchmarks/conformance/previews/51-faceted-distributions.svg +++ b/benchmarks/conformance/previews/51-faceted-distributions.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/52-beeswarm-dodge.svg b/benchmarks/conformance/previews/52-beeswarm-dodge.svg index 03ba53fe..4f26b515 100644 --- a/benchmarks/conformance/previews/52-beeswarm-dodge.svg +++ b/benchmarks/conformance/previews/52-beeswarm-dodge.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/53-log-scale-scatter.svg b/benchmarks/conformance/previews/53-log-scale-scatter.svg index 8f5b8ace..91e72fcd 100644 --- a/benchmarks/conformance/previews/53-log-scale-scatter.svg +++ b/benchmarks/conformance/previews/53-log-scale-scatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/54-bump-ranking.svg b/benchmarks/conformance/previews/54-bump-ranking.svg index 132167e7..75f78d09 100644 --- a/benchmarks/conformance/previews/54-bump-ranking.svg +++ b/benchmarks/conformance/previews/54-bump-ranking.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/55-indexed-multi-line.svg b/benchmarks/conformance/previews/55-indexed-multi-line.svg index d548451c..d9329911 100644 --- a/benchmarks/conformance/previews/55-indexed-multi-line.svg +++ b/benchmarks/conformance/previews/55-indexed-multi-line.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/56-connected-scatter.svg b/benchmarks/conformance/previews/56-connected-scatter.svg index 942bb57c..de6b62a9 100644 --- a/benchmarks/conformance/previews/56-connected-scatter.svg +++ b/benchmarks/conformance/previews/56-connected-scatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/57-scatter-marginal-histograms.svg b/benchmarks/conformance/previews/57-scatter-marginal-histograms.svg index d79c6f92..293aa0c4 100644 --- a/benchmarks/conformance/previews/57-scatter-marginal-histograms.svg +++ b/benchmarks/conformance/previews/57-scatter-marginal-histograms.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/58-select-extrema.svg b/benchmarks/conformance/previews/58-select-extrema.svg index 1e89e658..172b3679 100644 --- a/benchmarks/conformance/previews/58-select-extrema.svg +++ b/benchmarks/conformance/previews/58-select-extrema.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/59-grouped-reducer-bars.svg b/benchmarks/conformance/previews/59-grouped-reducer-bars.svg index 3c70e8a0..9d7fd668 100644 --- a/benchmarks/conformance/previews/59-grouped-reducer-bars.svg +++ b/benchmarks/conformance/previews/59-grouped-reducer-bars.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/60-lag-autocorrelation.svg b/benchmarks/conformance/previews/60-lag-autocorrelation.svg index 943ca1b9..8029d2de 100644 --- a/benchmarks/conformance/previews/60-lag-autocorrelation.svg +++ b/benchmarks/conformance/previews/60-lag-autocorrelation.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/61-quantile-ribbon.svg b/benchmarks/conformance/previews/61-quantile-ribbon.svg index d171a68c..769aa51d 100644 --- a/benchmarks/conformance/previews/61-quantile-ribbon.svg +++ b/benchmarks/conformance/previews/61-quantile-ribbon.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/62-ridgeline-density.svg b/benchmarks/conformance/previews/62-ridgeline-density.svg index d36e2a00..090b6624 100644 --- a/benchmarks/conformance/previews/62-ridgeline-density.svg +++ b/benchmarks/conformance/previews/62-ridgeline-density.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/63-violin-distributions.svg b/benchmarks/conformance/previews/63-violin-distributions.svg index f1fb64af..5a1bc4a0 100644 --- a/benchmarks/conformance/previews/63-violin-distributions.svg +++ b/benchmarks/conformance/previews/63-violin-distributions.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/64-marimekko-mosaic.svg b/benchmarks/conformance/previews/64-marimekko-mosaic.svg index c328a8a1..24fb707c 100644 --- a/benchmarks/conformance/previews/64-marimekko-mosaic.svg +++ b/benchmarks/conformance/previews/64-marimekko-mosaic.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg b/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg index 6efb7a1b..5545bf7a 100644 --- a/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg +++ b/benchmarks/conformance/previews/65-voronoi-nearest-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg index 3bb5b3d1..ed1c28c0 100644 --- a/benchmarks/conformance/previews/70-composed-chart.svg +++ b/benchmarks/conformance/previews/70-composed-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/71-recharts-population-pyramid.svg b/benchmarks/conformance/previews/71-recharts-population-pyramid.svg index 73ea2f80..56f637f1 100644 --- a/benchmarks/conformance/previews/71-recharts-population-pyramid.svg +++ b/benchmarks/conformance/previews/71-recharts-population-pyramid.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/72-recharts-mixed-bars.svg b/benchmarks/conformance/previews/72-recharts-mixed-bars.svg index 967cf0f0..abc47d53 100644 --- a/benchmarks/conformance/previews/72-recharts-mixed-bars.svg +++ b/benchmarks/conformance/previews/72-recharts-mixed-bars.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/73-many-point-scatter.svg b/benchmarks/conformance/previews/73-many-point-scatter.svg index bd7aa70d..8e213d15 100644 --- a/benchmarks/conformance/previews/73-many-point-scatter.svg +++ b/benchmarks/conformance/previews/73-many-point-scatter.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/74-recharts-treemap.svg b/benchmarks/conformance/previews/74-recharts-treemap.svg index b1703830..2e6af9ae 100644 --- a/benchmarks/conformance/previews/74-recharts-treemap.svg +++ b/benchmarks/conformance/previews/74-recharts-treemap.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/75-radar.svg b/benchmarks/conformance/previews/75-radar.svg index ff56685b..45e62a8a 100644 --- a/benchmarks/conformance/previews/75-radar.svg +++ b/benchmarks/conformance/previews/75-radar.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/76-pie.svg b/benchmarks/conformance/previews/76-pie.svg index a533261e..129bcf7f 100644 --- a/benchmarks/conformance/previews/76-pie.svg +++ b/benchmarks/conformance/previews/76-pie.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/77-donut.svg b/benchmarks/conformance/previews/77-donut.svg index aa723867..ad52e085 100644 --- a/benchmarks/conformance/previews/77-donut.svg +++ b/benchmarks/conformance/previews/77-donut.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/78-gauge.svg b/benchmarks/conformance/previews/78-gauge.svg index d7ab0e53..e4c753a0 100644 --- a/benchmarks/conformance/previews/78-gauge.svg +++ b/benchmarks/conformance/previews/78-gauge.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/80-echarts-axis-pointer.svg b/benchmarks/conformance/previews/80-echarts-axis-pointer.svg index 7b7eab29..b32f7b3c 100644 --- a/benchmarks/conformance/previews/80-echarts-axis-pointer.svg +++ b/benchmarks/conformance/previews/80-echarts-axis-pointer.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/81-recharts-interactive-legend.svg b/benchmarks/conformance/previews/81-recharts-interactive-legend.svg index 376082d6..d356b212 100644 --- a/benchmarks/conformance/previews/81-recharts-interactive-legend.svg +++ b/benchmarks/conformance/previews/81-recharts-interactive-legend.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/82-chart-table-selection.svg b/benchmarks/conformance/previews/82-chart-table-selection.svg index fb7aa686..b120bbd2 100644 --- a/benchmarks/conformance/previews/82-chart-table-selection.svg +++ b/benchmarks/conformance/previews/82-chart-table-selection.svg @@ -1 +1 @@ -Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections. +Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections. diff --git a/benchmarks/conformance/previews/83-focus-context-window.svg b/benchmarks/conformance/previews/83-focus-context-window.svg index c847dfa6..cf534bf1 100644 --- a/benchmarks/conformance/previews/83-focus-context-window.svg +++ b/benchmarks/conformance/previews/83-focus-context-window.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg b/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg index 9bf5e4c1..49c0014e 100644 --- a/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg +++ b/benchmarks/conformance/previews/84-pinned-nested-chart-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/85-scrollable-resource-lanes.svg b/benchmarks/conformance/previews/85-scrollable-resource-lanes.svg index 6030d76d..0d35394b 100644 --- a/benchmarks/conformance/previews/85-scrollable-resource-lanes.svg +++ b/benchmarks/conformance/previews/85-scrollable-resource-lanes.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/86-streaming-window-preservation.svg b/benchmarks/conformance/previews/86-streaming-window-preservation.svg index ed99c241..8938c2f4 100644 --- a/benchmarks/conformance/previews/86-streaming-window-preservation.svg +++ b/benchmarks/conformance/previews/86-streaming-window-preservation.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg b/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg index 58f9b59f..05582764 100644 --- a/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg +++ b/benchmarks/conformance/previews/87-echarts-synchronized-cursors.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/88-echarts-free-cursor.svg b/benchmarks/conformance/previews/88-echarts-free-cursor.svg index 06e8dd1c..feb6bdaa 100644 --- a/benchmarks/conformance/previews/88-echarts-free-cursor.svg +++ b/benchmarks/conformance/previews/88-echarts-free-cursor.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/89-brush-range-selection.svg b/benchmarks/conformance/previews/89-brush-range-selection.svg index 7526f0f9..2167d61c 100644 --- a/benchmarks/conformance/previews/89-brush-range-selection.svg +++ b/benchmarks/conformance/previews/89-brush-range-selection.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/90-zoomable-time-window.svg b/benchmarks/conformance/previews/90-zoomable-time-window.svg index 405e40a1..c7439934 100644 --- a/benchmarks/conformance/previews/90-zoomable-time-window.svg +++ b/benchmarks/conformance/previews/90-zoomable-time-window.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/91-timeline-playback-scrubber.svg b/benchmarks/conformance/previews/91-timeline-playback-scrubber.svg index 0899ae28..602887aa 100644 --- a/benchmarks/conformance/previews/91-timeline-playback-scrubber.svg +++ b/benchmarks/conformance/previews/91-timeline-playback-scrubber.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/92-editable-event-range.svg b/benchmarks/conformance/previews/92-editable-event-range.svg index 2fec41b8..4e5aaaea 100644 --- a/benchmarks/conformance/previews/92-editable-event-range.svg +++ b/benchmarks/conformance/previews/92-editable-event-range.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/93-labeled-pie.svg b/benchmarks/conformance/previews/93-labeled-pie.svg index 116e099f..f405620c 100644 --- a/benchmarks/conformance/previews/93-labeled-pie.svg +++ b/benchmarks/conformance/previews/93-labeled-pie.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/94-center-donut.svg b/benchmarks/conformance/previews/94-center-donut.svg index 4966358e..a0495747 100644 --- a/benchmarks/conformance/previews/94-center-donut.svg +++ b/benchmarks/conformance/previews/94-center-donut.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/95-rounded-donut.svg b/benchmarks/conformance/previews/95-rounded-donut.svg index ee7ce4f8..b5412e87 100644 --- a/benchmarks/conformance/previews/95-rounded-donut.svg +++ b/benchmarks/conformance/previews/95-rounded-donut.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/96-nested-donut.svg b/benchmarks/conformance/previews/96-nested-donut.svg index d06987f2..ff7e77d0 100644 --- a/benchmarks/conformance/previews/96-nested-donut.svg +++ b/benchmarks/conformance/previews/96-nested-donut.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/97-rose.svg b/benchmarks/conformance/previews/97-rose.svg index 253ca956..d9a93edc 100644 --- a/benchmarks/conformance/previews/97-rose.svg +++ b/benchmarks/conformance/previews/97-rose.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/98-needle-gauge.svg b/benchmarks/conformance/previews/98-needle-gauge.svg index 02f91353..98a99d8c 100644 --- a/benchmarks/conformance/previews/98-needle-gauge.svg +++ b/benchmarks/conformance/previews/98-needle-gauge.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/99-comparative-radar.svg b/benchmarks/conformance/previews/99-comparative-radar.svg index f856962b..3d6290d4 100644 --- a/benchmarks/conformance/previews/99-comparative-radar.svg +++ b/benchmarks/conformance/previews/99-comparative-radar.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/bar-grouped.svg b/benchmarks/conformance/previews/bar-grouped.svg index d36e3237..e948e827 100644 --- a/benchmarks/conformance/previews/bar-grouped.svg +++ b/benchmarks/conformance/previews/bar-grouped.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/bar-horizontal-ranking.svg b/benchmarks/conformance/previews/bar-horizontal-ranking.svg index a3f2b90a..1847131c 100644 --- a/benchmarks/conformance/previews/bar-horizontal-ranking.svg +++ b/benchmarks/conformance/previews/bar-horizontal-ranking.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/bar-stacked.svg b/benchmarks/conformance/previews/bar-stacked.svg index 36b101fe..87947f07 100644 --- a/benchmarks/conformance/previews/bar-stacked.svg +++ b/benchmarks/conformance/previews/bar-stacked.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/bar-vertical-sorted.svg b/benchmarks/conformance/previews/bar-vertical-sorted.svg index 8c737dbc..850599e5 100644 --- a/benchmarks/conformance/previews/bar-vertical-sorted.svg +++ b/benchmarks/conformance/previews/bar-vertical-sorted.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/facets-anscombe.svg b/benchmarks/conformance/previews/facets-anscombe.svg index 2b3fd3ed..244514b3 100644 --- a/benchmarks/conformance/previews/facets-anscombe.svg +++ b/benchmarks/conformance/previews/facets-anscombe.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/heatmap-labeled.svg b/benchmarks/conformance/previews/heatmap-labeled.svg index 11478b36..84afb2f9 100644 --- a/benchmarks/conformance/previews/heatmap-labeled.svg +++ b/benchmarks/conformance/previews/heatmap-labeled.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/histogram.svg b/benchmarks/conformance/previews/histogram.svg index 7c86d4a5..d0ebc02d 100644 --- a/benchmarks/conformance/previews/histogram.svg +++ b/benchmarks/conformance/previews/histogram.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 487e8ed8..6b05d5f7 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,947 +2,947 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "e8fb24cd96d6fbfbb8327eb97ab506f7f60fd4870baa986a725da75d736c87b9", + "sourceHash": "b4140425c4b646286f0ca986b9d387a886ef61e2b39b5ff4741835ab150b89bf", "assets": [ { "id": "01-line-gaps", - "sha256": "78a0c5fd96c90b3b049f23b0a91f1f04d7636527e07a1f888328e69acc603e5b", - "bytes": 14974 + "sha256": "361a4da4acdad9db8af094efff5e4b6f7eb09e062333cc1b02f6c0ca26116716", + "bytes": 14990 }, { "id": "02-multi-line-end-labels", - "sha256": "d665593cb9a586fb10b68c1fdf1e011181cf2a069d8e6153ea26befade263520", - "bytes": 4539 + "sha256": "04b1ef477bd9b892f2b2984bb9cca41e72f682239fdfcfb1bc0cb13c8af62830", + "bytes": 4555 }, { "id": "03-temperature-range-band", - "sha256": "c163ecbd39d1451b0e302d7c1ba2f59c81483ce0199e05023606c09027068408", - "bytes": 40075 + "sha256": "4cf49d2cef353b96b40a9bd59a54134087973ce3d19549a0f9309ab197ffbe3d", + "bytes": 40091 }, { "id": "04-stacked-time-area", - "sha256": "3a105d8307cb13254d8b434bd712ba50731b5cf765f6580df2cb6a328abf5f52", - "bytes": 49211 + "sha256": "583910cb57cbe96e1d49c60e0d0398cdbad81dc7f75a2c2b0e281bca4b14a72c", + "bytes": 49227 }, { "id": "bar-vertical-sorted", - "sha256": "531a961de019b487765fa061ad31be5c040c6b7ab408356e6707a03befc2cd6f", - "bytes": 6005 + "sha256": "868c1e9ccd7cd6f6d55cdf33298666b548eb74710e85742e06a7b37d58038c1d", + "bytes": 6021 }, { "id": "bar-horizontal-ranking", - "sha256": "062085685118d5154e0b5ab6eee1d695347e63871a5f82e2302e86121ccacc8c", - "bytes": 6734 + "sha256": "7c903173f3eee66bd2b3d0f3fc1bd89ad35c59a01bc8a409194ed5647b6a076a", + "bytes": 6750 }, { "id": "bar-grouped", - "sha256": "29499157e163b7068b1f6b2c47b9e947014ffff695eb94e4ab8b3376e3efdb3b", - "bytes": 2614 + "sha256": "05977e545ebc1300878165c162ae20183b5d7d02d59b1af26acdb2e7d8f6bd47", + "bytes": 2630 }, { "id": "bar-stacked", - "sha256": "10107c15a3cfccc8f4c3a45941737769dd6e9bec3b21bb7d0c31ea10240831ea", - "bytes": 16464 + "sha256": "b8913d36eb2b6adf857b5554b08fa4f7ed5d55c174b1a182c5ae95086c251d38", + "bytes": 16480 }, { "id": "scatter-bubble", - "sha256": "18409ba00bd56ba9b180f0eebcc495803c9a3f01cd27309ec5b7164fd9cdf4ec", - "bytes": 80411 + "sha256": "878a6c97b7fa40262131822a62cac0b59b5a9ce2a2d50ba0fd03d59d741f3e57", + "bytes": 80427 }, { "id": "histogram", - "sha256": "2336fc4104a73940374fb743396b69383700a1fe2b8ccca99dc3b4ea1419ce43", - "bytes": 3315 + "sha256": "4970bc0a6ae9a2e326a871035dfa6c761b57fe7da30fd14a3313c34ea6d803c9", + "bytes": 3331 }, { "id": "heatmap-labeled", - "sha256": "5197df2d338fd535350a937da7a76d894ca130932fc188fc1364eab874da324e", - "bytes": 12829 + "sha256": "67cfbb288daab38e52bbe28318ec45962a40298324aed12512b822e80576d844", + "bytes": 12845 }, { "id": "facets-anscombe", - "sha256": "da4963ab14c5a0206a8d1ddafac5fb16c030f7b6c7451ef39414e4e00f54da56", - "bytes": 8799 + "sha256": "20c719db945789d77546980279f1b0fa2eafe10537ee3aebd4e6dfe03d647e60", + "bytes": 8815 }, { "id": "13-interval-timeline", - "sha256": "de60949c8d0d4c8f68a8a2ebc3761bfb6434013b7b6aa5719c19cfa21aedfa1a", - "bytes": 3061 + "sha256": "6e91a97627ce9bc1b86a4d39650f3c9d46a9eccc0b68abd43420fcc7ca291feb", + "bytes": 3077 }, { "id": "14-error-bars", - "sha256": "c295fe340ce8fbe40697c1377767e0ac56844826452e4d8b8fac5d85ad8dc7cf", - "bytes": 3730 + "sha256": "db601bd99de8a260a3085399ac075903178111f51daae476536b0194a86a5f8c", + "bytes": 3746 }, { "id": "15-boxplot", - "sha256": "40c9001ba5742c7711bf31563f0a802668f2dca056375a808c916bf29115ebd4", - "bytes": 6141 + "sha256": "d7357fd95366d3e73bd7d78ec27a4df5359d598ae668a8a34372393b71fcc8d9", + "bytes": 6157 }, { "id": "16-lollipop", - "sha256": "8bbf4fad276d081e8e4bf16c9787d09c46547ec5cc4f4d8e8d618bf9b41c8b7c", - "bytes": 10201 + "sha256": "7284893c1ae919ce1d52f95eb387607d9c78250ae6e0821a9c4b1e595d16b8b6", + "bytes": 10217 }, { "id": "17-dumbbell", - "sha256": "b046f7e2c2765589281f2a88ac2c435ce2caf30a229cedd553cd587113e25ac5", - "bytes": 5387 + "sha256": "4fa7863a5562739bd5c86a5ff7a4df4f61d37eab50697376cce22de3b4894513", + "bytes": 5403 }, { "id": "18-cumulative-histogram", - "sha256": "60d0dc1a252a726e0fdeae0ca181a461d959c244c23b4925d8dc105e412894eb", - "bytes": 17758 + "sha256": "9a126b048c10848913c4d7b902ae252d5be9a164862c4e85d006dac5584f52ac", + "bytes": 17774 }, { "id": "19-moving-average-line", - "sha256": "fa363928d88be401ef0a91d30d1f106b5e04647e77e7cd17f844bd2ac6079a71", - "bytes": 20366 + "sha256": "96774e0a235add53f32516ff31d608c785b2d4293b3a787de2ffba992c2d4c59", + "bytes": 20382 }, { "id": "20-normalized-stacked-area", - "sha256": "18a084c8d339035cb65aa73937ed1bf581567356ba9c766819359d27de9ef27a", - "bytes": 46124 + "sha256": "f31b75a87ccecf74b8fd6a494ae8e464018a83dbefceeb24d8ddd26da1f64da1", + "bytes": 46140 }, { "id": "21-streamgraph", - "sha256": "cb760551a58cba6002ab11884548d5dda7dd00d780a8f6ff9670abf8684d3535", - "bytes": 48111 + "sha256": "56ba2e5a8670995d5ec3599d1fe243f2cccdd6ee12025157a3c8e8bc1e678def", + "bytes": 48127 }, { "id": "22-bollinger-band", - "sha256": "8d034999ea6109423b45c8e443dc68f8c85e2aa074756cdb1f4c434e834fcebf", - "bytes": 50229 + "sha256": "43848eb4c26fdf693d8fb3a69ba85ad4b6aa6b37bedbf0a6d98e05b52018135c", + "bytes": 50245 }, { "id": "24-quantitative-binned-heatmap", - "sha256": "2c5e024807217b280193372c00ce8eb884151209671581f75576fd58bd027cdb", - "bytes": 12578 + "sha256": "add1849eb75614a48a1d45fa56ad5d1db0fa78befddbec204f4742fb2aed574d", + "bytes": 12594 }, { "id": "25-calendar-heatmap", - "sha256": "c21abf21f1d60ef99f37cb38d7125860e4dce3095dfe4a106e6cdbb60f461d1f", - "bytes": 28500 + "sha256": "b3bb4417a0803be9baefdd4ce0f9eadb98cb086218b22a2ccf7e95b5db5ddb91", + "bytes": 28516 }, { "id": "26-diverging-likert", - "sha256": "22e2d90e9df49afc6277df005f3755837008001f5323d20c7119c54243f51c81", - "bytes": 7197 + "sha256": "18b237ed537920be09a0d843ebf52388827ac1a3ef7023f8e032972c7fb79211", + "bytes": 7213 }, { "id": "27-parallel-coordinates", - "sha256": "f54c2116228dbc3af792e468410c2e7b2318ccc546ebfa1a1a2ccd906f59365a", - "bytes": 8501 + "sha256": "dd766613dad6c711b8ab86e3d96796cc1f4ae14f48328dd24693de596874d1de", + "bytes": 8517 }, { "id": "28-candlestick", - "sha256": "e94fff171160f6a9bdd7103e8916154f82ed33c7ddfe172634b96f1d66c32e06", - "bytes": 14350 + "sha256": "495de35eea9d898c13edccc4962b5336491aac69b5104a6e71b89da405ea98aa", + "bytes": 14366 }, { "id": "29-waterfall", - "sha256": "ba0a9ea33481c51c2eb7dbb4c5697c8318d301ada9ded3e07f97d6bda12e7f68", - "bytes": 3137 + "sha256": "1356c6b9cb5d07698dc6c0253b0778914c6ec6d99659ebb596b3b26e38b56779", + "bytes": 3153 }, { "id": "30-slopegraph", - "sha256": "5b1a1f1bc41393d6207018aabc2f3b4afea022bd2345283ddbf775e7f0b36c00", - "bytes": 9454 + "sha256": "f74a29008ef5d2f85c2c8d76af7b4cf576060548d04db3eebf6561442cace21c", + "bytes": 9470 }, { "id": "31-linear-regression", - "sha256": "5a4d4612197c089d4facf108edba5e1eae86079921707ed5caf97c153b682ed8", - "bytes": 67630 + "sha256": "7d3c6a040e0702e1a556e591ff2b3ba11f36287b4cedd40358d0938943f3e126", + "bytes": 67646 }, { "id": "32-change-arrows", - "sha256": "22821ed5be171c8369d7abefb09ee7e0c6542f4cb1bbcaade6a2c2187c1f144a", - "bytes": 10679 + "sha256": "7e9a42b5bed4ca9d46959b871be1e4ebb3217d5aa94ecec38daafb29553b8b5c", + "bytes": 10695 }, { "id": "33-difference-chart", - "sha256": "10a59544dd243a1f820503a988b40f89ca80f5f321f2b7463003171aa727ccbd", - "bytes": 11281 + "sha256": "1472231d7a3740bf711c11e315da8aa033e5a21a398afcc15e380ab97f331745", + "bytes": 11297 }, { "id": "34-pointer-tooltip", - "sha256": "e4ff132c905893b271d67984435b50620b267447c8081c2ac9f96295d99482e5", - "bytes": 15814 + "sha256": "37e1f45b9db3fe069368fbbec62d953aa1a7b2c389f848e6ee63559298b62a62", + "bytes": 15796 }, { "id": "35-grouped-tooltip", - "sha256": "8a6d29b3745685108a1d2486d18dc7ee62daa6645543740b80c8a0eaf564e1b1", - "bytes": 56607 + "sha256": "28ff3021123a3432b3f9446aac8a6f59d87b37ec6b62c3b349c5d24440bdde24", + "bytes": 56623 }, { "id": "36-hierarchy-tree", - "sha256": "6f1659fec2b14b3d7ab0c45f259ce58695f594fa8feaa965c0b7e236822fb282", - "bytes": 8271 + "sha256": "12fecee033795499e60d61bf0a2a802e9385b615c99596a3ad45fcd9341afaf3", + "bytes": 8287 }, { "id": "37-delaunay-network", - "sha256": "0889b44c69be54d73796de9217eb6f56be417af7af58a9d1121c94a41e8f18f4", - "bytes": 29129 + "sha256": "b5401e96b921f21a619f2e2820d24c61040d5f3911fd8a408f1bafea8488622a", + "bytes": 29145 }, { "id": "38-contour-topography", - "sha256": "09461b319dbd30312dd73debec669f20570ba0359cf406264d0c2178a70afcb3", - "bytes": 20685 + "sha256": "a8cd1cc9225f8d56c47000bfb88b081cb2cd705d37933f0f6c24adc895c6bbdd", + "bytes": 20701 }, { "id": "39-density-contours", - "sha256": "cd88c4a73927f328de96ecfffa909ae7212a8c2d1be55f6c6e847bc593b2459a", - "bytes": 20325 + "sha256": "c7f106b309bc66ae0f2474ce3710af57d07e5b26675be9517bcbf8d262ba6a29", + "bytes": 20341 }, { "id": "40-geojson-map", - "sha256": "b52d1999c5c8576f898056a2a1bd629514279c1ecff85078b21f1f7c34985520", - "bytes": 39250 + "sha256": "1e26d1983fc3fbe1506f0f84cc1dac2bb0826301cad4b729bc21d9e151061d1f", + "bytes": 39266 }, { "id": "40-force-directed-network", - "sha256": "80f5e1c0880ce771298c0bab5636581d8bd3f6f2e24412cb8ecbf0a936603f1e", - "bytes": 8306 + "sha256": "7dfbe724861020abb38feb5b0683d37bf4a95fb917fd8f47a1b8589683c15c37", + "bytes": 8322 }, { "id": "41-waffle-unit-chart", - "sha256": "55139cc3631b3decb1daf4f3980bd06c7669ad3615f9a0753dbd2fe47e4b8946", - "bytes": 17546 + "sha256": "b606468770f8b25d561a00a11e83031265836e64dfee9cbdce34107c23cef34c", + "bytes": 17562 }, { "id": "42-vector-field", - "sha256": "1a61c91001f112afeaf3af9328d80b4dac271e1a5cc4e615e8bfffd45cc3058f", - "bytes": 25073 + "sha256": "e0b6475317159a2c5dd33e2451a3be8f462b7e8159d2c33f30a8516d160c74d8", + "bytes": 25089 }, { "id": "43-hexbin-density", - "sha256": "f744004e1a69c34904939f1a5b350db2072bef8477025219822b709761e00bf3", - "bytes": 13722 + "sha256": "af2ddbd4725442ced235d7ec9568e60d5a6348f915984c7190d7083ce4a26ef6", + "bytes": 13738 }, { "id": "44-framed-scatter", - "sha256": "a12653214d4f29a57c005b3ac21966d53dc719aa8f6e00f3d20d265d99346b59", - "bytes": 56002 + "sha256": "56165f9ae5238a5758f8779c1c1c9802f9f64d9ddac92905508403da2b9c7150", + "bytes": 56018 }, { "id": "50-empirical-cdf", - "sha256": "e7c147a07f71b865b5757692f66fc11b9be15f9059bf32ea4c5f76e341c428ff", - "bytes": 13298 + "sha256": "07913c18e9a81437aac15cbda96d6881360298402a032f01952cbfff1feded57", + "bytes": 13314 }, { "id": "51-faceted-distributions", - "sha256": "43b93993eb1c5745c7dd6a67db01b57a53e3cac300e2a9c78bb64b5f3c913def", - "bytes": 6748 + "sha256": "540f6774fadb4083abb7237894309d06910e3ea6e7a113d9634193817d8e062a", + "bytes": 6764 }, { "id": "52-beeswarm-dodge", - "sha256": "91ac917ba409eba7f43c3eb5c44428681c6fb63843a2108f354f07930af72673", - "bytes": 16142 + "sha256": "8fd624ba8d5bbfac733931afd59337c991e2e54e888f06444fa8130f84e606dd", + "bytes": 16158 }, { "id": "53-log-scale-scatter", - "sha256": "1cd2b67270ac873a4088f4c9c0c98cd889ef41018c3f71f190ae2017a47c90ad", - "bytes": 45921 + "sha256": "62c8f1abc7e732679dbba7cf2b8a7d55a0493dce9cd2bd0042f2e628d0e48b57", + "bytes": 45937 }, { "id": "54-bump-ranking", - "sha256": "ff6ddf1053f5c06ca9dd02ea0eb82544ea326f2ecb1d26686db5d2642a0416f8", - "bytes": 11508 + "sha256": "448a7cc25f33f117d5b34f0fd9a8329634f9f86917ae1fd57ab4af93b6646dd7", + "bytes": 11524 }, { "id": "55-indexed-multi-line", - "sha256": "40e62d6250aec4b755483ca97ce3bdf0817b2c82f32e529789188852529e7ac5", - "bytes": 4310 + "sha256": "2093b1d4fbcd50e3339fa11b7cbdb071419550ae5466284d3f1522bed241cad9", + "bytes": 4326 }, { "id": "56-connected-scatter", - "sha256": "d0849475f9c48598ff60736788402aacf07d06c8e3ecdd7cbe39a46d38a04e08", - "bytes": 14323 + "sha256": "a9847c63f8bf1c0495cfe46e106270859f6ae958ce5fecc2b9af6269b921ab48", + "bytes": 14339 }, { "id": "57-scatter-marginal-histograms", - "sha256": "e0f7f961f06630eced467d48e121793e75211db3651c569cf58477a1a5ad3178", - "bytes": 84836 + "sha256": "36f47b62c995502f047697d3206ac230ebc578193e9f068ef344fbd0bf8b086c", + "bytes": 84852 }, { "id": "58-select-extrema", - "sha256": "ae0d6386349a51cba4284bc1e68bf56c59e7223a4747860af4db59b7ebdf07aa", - "bytes": 18985 + "sha256": "f150f229e2aa972358ebe2d392c548aaddd78ee0ed55889378f8a37e834bac07", + "bytes": 19001 }, { "id": "59-grouped-reducer-bars", - "sha256": "64d82e265b8c99c9e13d7a0c72d5e761ac1531a487f85ba9f368e27430a7996d", - "bytes": 2630 + "sha256": "0923bc526e28cdd383b7062be85c6b6cea528740c7f3575f3450df6fcd7b0298", + "bytes": 2646 }, { "id": "60-lag-autocorrelation", - "sha256": "b2682fbadc7cce9e9e8b555171c611f506b130f02c68af2e7889836906adb85f", - "bytes": 25808 + "sha256": "ffead0dd4ced6317301a97177b1a69047500285e09f2418fbaefc4c2f9a2a068", + "bytes": 25824 }, { "id": "61-quantile-ribbon", - "sha256": "3e97982535d5d84ffd3dfd608c4ddd01d8718290b04e76d0699840f56c9315e5", - "bytes": 6698 + "sha256": "e77612d1610f5ebf1b3afb776cc77664650e51b1162e3ea8e99239001d961216", + "bytes": 6714 }, { "id": "62-ridgeline-density", - "sha256": "0f3946061cdb2166d0fd0ec589bf28229dd64c20f714d274b0156953ab144137", - "bytes": 7708 + "sha256": "0c4e31eabde674cb34f64de211f56644cd661fc71f02732a620603378fb4831e", + "bytes": 7724 }, { "id": "63-violin-distributions", - "sha256": "208a2389c7a98fe23f96b8767babf9cb229bcf14a9485e8ff4116f47ff6bd664", - "bytes": 7352 + "sha256": "178eefb2506ffbd7259c770152b9179f5be88f46c462c3a90c4af2c390534e41", + "bytes": 7368 }, { "id": "64-marimekko-mosaic", - "sha256": "2dbcfe86a88683f3e8e353782b214fac3cddffc94e1d9c848cf740170cd8ef52", - "bytes": 5659 + "sha256": "df7fb1d22c8ae4f47bd3a3bdc1f2192ae6115f652e6f38a460771d5c72c68116", + "bytes": 5675 }, { "id": "65-voronoi-nearest-tooltip", - "sha256": "fbdc48535e8997ef0ccb47ccd85f14c3443c26f869b95e9b79fd317414bb9c88", - "bytes": 16224 + "sha256": "134f926141203ed363ef523c698331717cf653d23b0858621d5ab713dc277018", + "bytes": 16240 }, { "id": "70-composed-chart", - "sha256": "9d79b12f0fbcf14f8bfa4115ea0ee75147fe0d9fa27ca2ec20a0f29a50857fc0", - "bytes": 14480 + "sha256": "bde720b0d2cfd752e3a9600f469c2a1ec5a3fdc3232292ad5e4c6c45319994c6", + "bytes": 14495 }, { "id": "71-recharts-population-pyramid", - "sha256": "2a301e45dba4848b0cc0644cb4eb5acd72966310b124246bf8504d53bdd7486d", - "bytes": 2637 + "sha256": "7f66b3792aeebeed1f0fa546b4d8f1769f55f7500228acfa6174674484e19add", + "bytes": 2653 }, { "id": "72-recharts-mixed-bars", - "sha256": "9f8e93c1a78b6786766c8752b9251757485d34dc27df033c5f329bf1cccfac5b", - "bytes": 5955 + "sha256": "5ccdf45b0751d39b53401708d768d7b738a16801c7dba92428ad9e58b92fb07d", + "bytes": 5971 }, { "id": "73-many-point-scatter", - "sha256": "300438e80ec220a50605cbdb298dbcc8826aa53a5f1147b7857edb77ecc95f89", - "bytes": 56130 + "sha256": "7e0f2cbac09823e3672c7e709216bd7dc00e2cc47acfe7307075fc6b91cb26f7", + "bytes": 56146 }, { "id": "74-recharts-treemap", - "sha256": "24e6e1680ecb558600fdf114d65f1db589bcb30b95b4f653fc1d9481899db723", - "bytes": 3869 + "sha256": "7a8d03b2ddb4c3cf9a043e5936676ba0f38b3ac5ac63fcc5c4716b4ed66e67f4", + "bytes": 3885 }, { "id": "75-radar", - "sha256": "b8ca29100d7bd1dcb9f182bca64cab7f2d1563ba1932ea1b420a1a8fa78a0c5d", - "bytes": 3489 + "sha256": "818d77720e57f76464a739a141e73b71a4b73d2f65a64133ccfb7f74b891cc0c", + "bytes": 3505 }, { "id": "76-pie", - "sha256": "b47572d797a7c673f8683c8f239cae1d8ed4c50f190bf0dd3cff953121e35208", - "bytes": 2488 + "sha256": "78f6ab67bac81dec8d39eb77125f60320866018b9db87eec476bd6461b11c21d", + "bytes": 2504 }, { "id": "77-donut", - "sha256": "6272fc2126f293ea1a35a9812d424fe701c4f84b28c6abac2baa9316c36f064f", - "bytes": 2988 + "sha256": "4a91aa414e97ce26110fb16c3436aeb096ba4cfab36fdf44a64996ee3464e7c5", + "bytes": 3004 }, { "id": "78-gauge", - "sha256": "1fc5a67762dbdd05589b910e9c4642d48057d9f92e849864989d5241514e9f1c", - "bytes": 2031 + "sha256": "84de33a695f5038762744e881296dd3910d2f2bb28c02f2d2ed8014b310cc96a", + "bytes": 2047 }, { "id": "80-echarts-axis-pointer", - "sha256": "ed410449ee25240faf6640bfe12f4968357c68d68bd9b47bd12ffcf79d06ba71", - "bytes": 11445 + "sha256": "2501316a8b75b5f54cc07e4ef3ae1e698092b84045b38e4fd35cb539e5165b66", + "bytes": 11461 }, { "id": "81-recharts-interactive-legend", - "sha256": "101eb6796ec9aa0ec5844e685f1b76c993b38259fbf4e22dffc7dbe9031b944e", - "bytes": 5840 + "sha256": "ea05bf6f149a97a9199a99679e972e59665e79c4e3e759c256b7031f203000e0", + "bytes": 5916 }, { "id": "82-chart-table-selection", - "sha256": "ff310136b7d15c327b6d924c7907793e8c4b291947c8aa1bd744b2a5af334219", - "bytes": 3857 + "sha256": "b5b6dc512efc9b1896b5af92166246fce135bc35b61254c4a154d38a456741da", + "bytes": 3873 }, { "id": "83-focus-context-window", - "sha256": "42cb178cd3c96993548968c7779b71fa54e64141c09a8ea2b2cb6a2aaccdf571", - "bytes": 9681 + "sha256": "a12bc78a58208002ed7825ae5420c01da3e37f3f1b21400d6814120f94a87780", + "bytes": 9713 }, { "id": "84-pinned-nested-chart-tooltip", - "sha256": "e83f4a5f64646c627af03099e0796ddfc0d8699a2ac7b0be848552715ed06dde", - "bytes": 26174 + "sha256": "1299d9d5f4c7d04c433f1c2658d00e46660109a20b7acb5d5ee62c37ab50e98e", + "bytes": 26206 }, { "id": "85-scrollable-resource-lanes", - "sha256": "ef4913fad9e8b92ba255828a1b3b1e89c759a7e948aa30a5ccbd092f1c30bbb2", - "bytes": 3766 + "sha256": "4991c55242bcf537967a214e39e068d8f9231fe1744fc4ae5a5eb65d30d02249", + "bytes": 3782 }, { "id": "86-streaming-window-preservation", - "sha256": "0d45437aed02ed7e219227c5b4ccb638e1d073e680578fd6975c83e520d65440", - "bytes": 5311 + "sha256": "e331f854d835e27d886b3e26ac31b8348987315e0f78682114dbcd9651f0ff26", + "bytes": 5327 }, { "id": "87-echarts-synchronized-cursors", - "sha256": "1d3a678b5cead58648a9837a16dd736eab42b069ce310eebb612fccd7104d586", - "bytes": 9533 + "sha256": "fa3b541222d23791cd6f30a3e58acd607827fa2ffbe65a9c032bbb92d9c3aac3", + "bytes": 9549 }, { "id": "88-echarts-free-cursor", - "sha256": "57d2348aa412d8f8e79c92fd6fa2edb74c5cc129037c03fcccd200d03a475844", - "bytes": 4994 + "sha256": "1c13247bae0400063ae43666632c1faf13251031637cb21c97b617324c7ceb76", + "bytes": 5010 }, { "id": "89-brush-range-selection", - "sha256": "1bad646a0a35650f060215a3f13573c2259d21844c55d070b2a263fd81e24bea", - "bytes": 5975 + "sha256": "faedd7a2d8e22f92937328c2c88e30bbcac2fc5c221b9298b0e9a823ee550c8b", + "bytes": 5991 }, { "id": "90-zoomable-time-window", - "sha256": "0278fb0d29f8cf41fb2f3309b8d5850b525a14b2a701d78824d86afdd526ca5c", - "bytes": 3875 + "sha256": "80c67449ee4092a369d75aea9e5eb33f128f4271011e9ef51c427badc5c588b3", + "bytes": 3891 }, { "id": "91-timeline-playback-scrubber", - "sha256": "97838f8752e739d0f88453253c55142c75306b3b99da721507019d1280ddb34b", - "bytes": 4196 + "sha256": "29ac117ef03918b82ede2bd69d486b8087936400dbce3e4c6560231657211817", + "bytes": 4212 }, { "id": "92-editable-event-range", - "sha256": "5a5c8bfddfc18ef0aa75c772e5f943a52c81487dae21ccbc013f0e40efad00c7", - "bytes": 3149 + "sha256": "fdb8f21a901378f058472e1fa5875053db7958241f6bbf81e2894ff1486bbae2", + "bytes": 3165 }, { "id": "93-labeled-pie", - "sha256": "6f71a585142965c9eb55730fd1ece1cb3d77aeca2f2cd6ad665d6f66b7052258", - "bytes": 4431 + "sha256": "1421d5db4def6c252961c94b56c748c07bf1d3693e8e2b2fe101889b1fb98013", + "bytes": 4447 }, { "id": "94-center-donut", - "sha256": "28623dc05a6ea865284762c3ba189fd5456ffce836ef734b5d9ec96318ebd894", - "bytes": 2729 + "sha256": "feeb89d56b9a2e706a589629dbbd32d1c5e0545a0d4778a9c5032e73a60894ae", + "bytes": 2745 }, { "id": "95-rounded-donut", - "sha256": "9e04bb83eecfc4d92d32ca4a0e4e6190c7f0b427b9e2b512b97d6db618367d2e", - "bytes": 3487 + "sha256": "4e31b462ea95c5c47f3eb8a8a8086025ddf6e9f13de432bc211ac354591de531", + "bytes": 3503 }, { "id": "96-nested-donut", - "sha256": "90539da1b271c47fa1dd7b1ca5a323d7e3907fd945d238be9c035745bf00f191", - "bytes": 3492 + "sha256": "64f9a554b23ea80abfc92e2edbeffc0511b93d25a321f10c2edc21df8104020f", + "bytes": 3508 }, { "id": "97-rose", - "sha256": "f4a7341e1ba7172ef9b7885a8a1890982feadd7bb0f2d0c04401ef7edf7eaa93", - "bytes": 3560 + "sha256": "006a91f2aa93a19c5abc1c5d297b4a6b5beade5e468b9c895d27db3685d93053", + "bytes": 3576 }, { "id": "98-needle-gauge", - "sha256": "cb7cf9b16760931eb62e7d6ca88a6e73964f25671f75856abb066166cb0412d7", - "bytes": 5530 + "sha256": "c8bc517c44f473d3397abe00c82d30ccf3e92502673f5f2cc5a0c6ab2202ec6a", + "bytes": 5546 }, { "id": "99-comparative-radar", - "sha256": "036469db7a38abcd2c5f75e64be3a4e36268aed8267b68452c966c65b620903c", - "bytes": 3741 + "sha256": "37ad67a05df8102796cf576c030cb25d528abb4b0aca0a1fb718f345bab180f4", + "bytes": 3757 }, { "id": "100-radial-bars", - "sha256": "aab758d3f8329bfdde1f0e4afd40acec291c4f815606c8100e951763e80f4e1c", - "bytes": 3071 + "sha256": "e04b93235df9fb21ffafa2b4cb01eaf5f74f312b654e723b94e9da91172d6590", + "bytes": 3087 }, { "id": "101-sunburst", - "sha256": "51a1d6c0a0d5b67cf4a915bc3dd474c0bc3f047f2602f472a6305b6b41935c78", - "bytes": 5076 + "sha256": "005504a78661d48b7ed3cf4648e6a36bb32a91f597fc3e4aa2dbf887a4859cb4", + "bytes": 5092 }, { "id": "102-world-choropleth", - "sha256": "9c55ddeb117e8225cddbf8b5e88e9bd4f6565f6bb2007d0660787eed96879a70", - "bytes": 63419 + "sha256": "a0a0bc3347c32e656d86c4811fad6cf693bcc2287f79bbfdf76f7af6aaf890bb", + "bytes": 63435 }, { "id": "103-bubble-map", - "sha256": "f5619f18fd8ffea5cebdc4bd07e0e6f1de68f94725f0936bc61612fecf7e9d78", - "bytes": 49104 + "sha256": "16ec6c79adfb2c9c963c58bd3bfc99cf1d7531dbaaf11e7c3c2126dec40336bd", + "bytes": 49120 }, { "id": "104-orthographic-globe", - "sha256": "6203a546e0c2eb07aaee46b201b1023f1cc4387179306502f438fb06aa3408e2", - "bytes": 21647 + "sha256": "e4caa3113596705cb7bac9e480fb8d6aaf14c6fc9c667291d1032c0686a08341", + "bytes": 21663 }, { "id": "105-route-map", - "sha256": "9982445ad8a4f2cd46c1b0d95f1b17ca6e8c4eef2c4c5c132e32a75882c83ace", - "bytes": 35788 + "sha256": "bbb85ed655645dbc90997c887bc9381120a22f0ddad9e8763f32008db12f105a", + "bytes": 35804 }, { "id": "106-polar-line", - "sha256": "04b292cc7a427390604d7d21ad85db258d8f8a35d900152cacc3d2da6b28eb2c", - "bytes": 9610 + "sha256": "e2a8f8ff1ab563684fcae6a7f4ad1d521db3e8210a0e5055cb393febaace3a37", + "bytes": 9626 }, { "id": "107-polar-scatter", - "sha256": "1b9c2311d966106e398f8178fceb5e5ca33bf45a076ef6e563b1a9c3bf4e1a0f", - "bytes": 19659 + "sha256": "21d619a28500d93f0fae9571fe3c2defa72d16c86d15a8c89ad3b08fbd3059f1", + "bytes": 19675 }, { "id": "108-country-choropleth", - "sha256": "b2dab3c05a7a682521e7c60877e1bc9daa07efaa9e6c23cb51ea8e1e3e644bfc", - "bytes": 64626 + "sha256": "228a41eb6c6010a2a255e0e8e44ab0a05a97d874a0d69c8b1420a1b5bcdb276d", + "bytes": 64642 }, { "id": "109-us-state-choropleth", - "sha256": "b1fbca0f9413b129fafd9e7365598614320c7eebdf7bced621d42cab79e74461", - "bytes": 1643004 + "sha256": "2b32385b66b21d1068ed906324a43081c6a5a1f57f06ecef522f5d9ccc857fd6", + "bytes": 1643020 }, { "id": "110-projection-gallery", - "sha256": "dff48aa16fc4392971671f6c27dd659a5f077c44c4017ea73b1c7f4409b9efae", - "bytes": 288504 + "sha256": "e27afd5a7536035eac914b14ce484caee3a4927f729368eb1546068e69ddb7ff", + "bytes": 288520 }, { "id": "111-basic-sankey", - "sha256": "e54b0261ddbb7fe1f891a95747d033b98f932b1aff80e1c2b616283722fb3d49", - "bytes": 3232 + "sha256": "6a98e28e81e7f8ef0c124e88dba7b1bea3f1a421c3ae6d968e092ade71fc6124", + "bytes": 3248 }, { "id": "111-sankey-flow", - "sha256": "9b6f8b67dd9168b7e89d92f5d2cbb17aed913fa301af376bdbd9e52b2d8862b1", - "bytes": 10992 + "sha256": "ec10d765655eb581177db5b8baf3d1ab5fc51cf7ba4ecfa58876c34a2099e7e1", + "bytes": 11008 }, { "id": "112-motion-entrance", - "sha256": "69cc8efe0e358c4b08ef58dae8a741c63bf5c425dedd08eb8776bbbda360f107", - "bytes": 3261 + "sha256": "a7546fb25b86ba1fc74f15ade08ae85b303d48e4010daf93bd97461af620c57a", + "bytes": 3277 }, { "id": "113-motion-updates", - "sha256": "e249da4cf2c0e9fa5db6acdcd88364d8786ea87e2edf9781ba58f1e35f61c7f4", - "bytes": 3259 + "sha256": "4fa312e18bcac8de4816430c61bf5744aa4443576ad86047d23e548b47138537", + "bytes": 3275 }, { "id": "114-spring-line-motion", - "sha256": "54046521d92711610e6b6ddfab84f9c44482c9f91b68c9510cc56b625e8b3c30", - "bytes": 2113 + "sha256": "805956658ffcb2d17bd64f197bd5cf619ff9e329158b0a9c351450dcc7341039", + "bytes": 2129 }, { "id": "115-definition-motion", - "sha256": "79c5a21713f55e561938157f728be9a29943f6b495a617c34dcda50e899a075b", - "bytes": 6708 + "sha256": "cec9e3316e9cd66de74ad8cc9c6b0f572bfba3f5bfffd318740121c9c314ef05", + "bytes": 6724 }, { "id": "116-geometry-morph", - "sha256": "03f2531dfc5b664051f4026ed8b4f1d7045869f44971a8f454d203e89aa922ca", - "bytes": 6537 + "sha256": "02e24c0c753956d757946e5984d82fa024d4f20e78dec805ce81f23568e284a2", + "bytes": 6553 }, { "id": "117-focus-cursor-motion", - "sha256": "83bc77c8f5cde5b7ce1cddb1245f26cbfa319fdb2fc423387ac15c89a21d1f6b", - "bytes": 9468 + "sha256": "788ab2811fe8c3c0a5dc32f5ece4c59ab69a59228c5a268b548a753c6b1202ec", + "bytes": 9484 }, { "id": "118-token-usage-calendar", - "sha256": "38a469fba8dfcc2c6eb8d63116481f5cfacfea8bea5450dda2c9c0de9f7eed1a", - "bytes": 54442 + "sha256": "a823d473fda26de3817c41551755b78d81ff7e71c4c2b251e8b2bd8e22b29527", + "bytes": 54458 }, { "id": "119-stacked-bar-band-cursor", - "sha256": "0257543342681ad1161ff6011ebee48abd3dd6b4d2f3ee46b751a4f54fe95407", - "bytes": 9709 + "sha256": "6026285d9999725965c969d4865ee16ff9433df25e90ed93f60e2cbcfe6b4519", + "bytes": 9725 }, { "id": "120-themed-interactive-area", - "sha256": "d6512a266c0234f1743e876b66edd8eff1fac25c4158602224ce4c22e627d652", - "bytes": 15205 + "sha256": "3a753cb69b712f5d387d6652a770f7fe549c3617820ca1f41186b643d269681b", + "bytes": 15221 }, { "id": "121-active-bar-dashboard", - "sha256": "f30ba8a97d5a293637a0a0dc345324bdb3eeec2a391d00d81cd1b6363683469a", - "bytes": 12508 + "sha256": "eaae4eae5c94c9c2106fefe7df50f62ce200cbd10d830324f3358a964b574d69", + "bytes": 12524 }, { "id": "122-premium-kpi-sparklines", - "sha256": "ee9a430fb58aa6f1980866e5c7f47804538133283650dae149c5def1c57316bf", - "bytes": 8223 + "sha256": "b3d6a0c9f7f07363ff0d925fffa5510f20eeda937bc35cc372414a61e275d0ae", + "bytes": 8271 }, { "id": "123-active-donut-metric", - "sha256": "47621d2d1d3361436394442863e8a578369d2f9edd78d8442d92ba04a32e1bec", - "bytes": 4915 + "sha256": "22b24eb976ec1fad49832fb2cf01da01370d86d0369e59fa9b94649c0bdc4b25", + "bytes": 4931 }, { "id": "124-theme-palette-matrix", - "sha256": "1a28df6af085136ad606f445bbf66296ab03d6eccedf31e5197dab8bd1968602", - "bytes": 9934 + "sha256": "3ddfc66eadf1d400b321a80cc2f328df7065f2866fa183869e37dd128ffdaa5e", + "bytes": 9982 }, { "id": "125-sales-funnel", - "sha256": "ac9f936671b1b43bf6b8c5ad2c159aeb410fda69b83fb93e2029a72cae541827", - "bytes": 3893 + "sha256": "33f7db88bf672cc67ac2cc17b8ace5ca9c71d057d7d6b61dcfe9888db076de38", + "bytes": 3909 }, { "id": "126-drillable-sunburst", - "sha256": "a3e94670675af7a48a372df9a94138c34922eb55f2dbf62af1a306d0128a8962", - "bytes": 9249 + "sha256": "38972c982cdc75e95215f74c817c075e60a9e74c92c3e6d5b8618d7f746ab8bf", + "bytes": 9265 }, { "id": "127-shadcn-dashboard", - "sha256": "bbfb55aa262775b1e3cf3ea890cb3200a26b78499d03425efc526c1d9d7e7c85", - "bytes": 17976 + "sha256": "ab40ae8ec5fd2838d2631db05d2d1f67945413766ef6a4fdd7fd04e7a910e3ad", + "bytes": 17992 }, { "id": "128-shadcn-bar-multiple", - "sha256": "97e63f098e3f08b81a8ce91007c480c14b07a1f52d7f6c7dd62b9b15043a7040", - "bytes": 5352 + "sha256": "ece980acaebbbcf1e36bae4cd1aa295000393dfeffddf7a9bb204a063ab6d414", + "bytes": 5368 }, { "id": "129-shadcn-pie-donut-text", - "sha256": "f181e7545d6258011099fe5452554036be1db897f5c320a08336783455fc04ed", - "bytes": 3474 + "sha256": "1d61080a0eef84fa06ed82c1102d1bde39c9f9b233bc7faec9f1cb0fa77befa6", + "bytes": 3490 }, { "id": "130-shadcn-radar-multiple", - "sha256": "7a0779c02233c76836c018c0cebe0eba54270cf6c78d8e4aa08c8220cb7d3e9d", - "bytes": 5531 + "sha256": "b0a25db33a560fb216c4fe2743f6ac1b0dc0ec98a3c405d0d784d844feb71207", + "bytes": 5547 }, { "id": "131-shadcn-radial-text", - "sha256": "99acaaf226fbea7acbc041e7803fb7aa69895bd79213d9c328df25733db2f657", - "bytes": 2918 + "sha256": "5fa0d68696a47ef0690be889f87ee91efb30cd48b2237cff39bed0d6d2a2d2c3", + "bytes": 2934 }, { "id": "132-shadcn-tooltip-advanced", - "sha256": "b653e418fb8dbfe8e0b2a5cbdb6e852bc6dbe907bfa2bba5d897178f65676279", - "bytes": 5003 + "sha256": "97738ec4dd4be6c3e40c7b3bae1fa519e809c5f71f451384d2be9b84ef4be79a", + "bytes": 5019 }, { "id": "133-shadcn-area-axes", - "sha256": "542e34eb4fd56f243ef9685298a12f3b4dbe5aaf79387fa3a4e51aa3a4b81b6a", - "bytes": 4598 + "sha256": "2df8a88b2daf1e8457159be6b9f54ab9a0f7eabd1754756ec69f4658e1560df5", + "bytes": 4614 }, { "id": "134-shadcn-area-default", - "sha256": "e808879e63ade0da237182d3082d67967aaef7edfa75afe92b7732f307af2e12", - "bytes": 3367 + "sha256": "bcf17936e81cdb26371b461f69cad3fbaf51a43e3a1c0c99129fc32f771f56d0", + "bytes": 3383 }, { "id": "135-shadcn-area-gradient", - "sha256": "411d7e51d2372ec4084f5fb901de48a16c04cf0bfcf89e106fdf9c0e1aa7cc9a", - "bytes": 4761 + "sha256": "eb23150834e8d8d4a23ff3467b0b3150de56d849381daba3167d19e2237ebf26", + "bytes": 4777 }, { "id": "136-shadcn-area-icons", - "sha256": "51599128da7ec91ecd34537e08f07ec63a257f78dd23ed768ab367fa71d6ed51", - "bytes": 3928 + "sha256": "7a44595cc0b82716448326a34a323f239c41e644db59e03982f7e1443112dd92", + "bytes": 3944 }, { "id": "137-shadcn-area-interactive", - "sha256": "7a270f4945b32689f9e2ece9dcf0436f5bbbc6afedf6b6dda4ef295f9ac55ca9", - "bytes": 19509 + "sha256": "f19a53acc1ad455283fb294a7e4c3c060959351f0b1bdaacfc353aa4018884e2", + "bytes": 19525 }, { "id": "138-shadcn-area-legend", - "sha256": "d62b6416a44df16c5a1b70121f7fbc126d8f36ef45c73df2096fd769407b8f22", - "bytes": 3929 + "sha256": "839c43611f74fab3e0a9145008c6e34cbaf4c83d350197f30f7d59d0a172ff01", + "bytes": 3945 }, { "id": "139-shadcn-area-linear", - "sha256": "84fc918897879509baf73dcec17e97d91da7c355ce30d50c10f4eee4d17e543a", - "bytes": 3160 + "sha256": "703e732227e5a77169c866f0e9a79f4a277e96f99ae8d246d8388c53eec9af85", + "bytes": 3176 }, { "id": "140-shadcn-area-stacked-expand", - "sha256": "6b8c90b4d05de702ba0e97a15d880b339551e04e30a22433ca1702e22eb5e5f0", - "bytes": 4579 + "sha256": "92c67f5aa3057fa857e55c55027cef962eadb5750788fa7be3f935a630c84e2f", + "bytes": 4595 }, { "id": "141-shadcn-area-stacked", - "sha256": "2c041387a9a4694f536473dd1169210af1ce0be6969ec26774a1944759f267b6", - "bytes": 3930 + "sha256": "852a52f3c194a04147576ea99df217aa9ea7f01fa32b500770495293d3299d56", + "bytes": 3946 }, { "id": "142-shadcn-area-step", - "sha256": "4b0dd63afca3fae361b74e321bc4013f406c6c684a5d59824d57c41718e86ec0", - "bytes": 3277 + "sha256": "0dcd92b5f3213f305f7c1acfa30c897e02d25383653ee882c8298a6fc4c9f6a3", + "bytes": 3293 }, { "id": "143-shadcn-bar-active", - "sha256": "6f5806c2c075dffee8dd4315cdca6d21c8050124f883edbc166b1e056a9b7041", - "bytes": 3730 + "sha256": "7da6c2200bd075cdb1022e9d262c1b5a58f253e615f5bf25d568ad082eec1aa2", + "bytes": 3746 }, { "id": "144-shadcn-bar-default", - "sha256": "f8d84982b4e65699eace793751a5b54c209b6384894103140a17651b3022561c", - "bytes": 4004 + "sha256": "7b3c88dba890fae63600b16c9481bb0295f5b44f8801c9bb6b0f0fcec6cfc188", + "bytes": 4020 }, { "id": "145-shadcn-bar-horizontal", - "sha256": "b5e71b01af8631c0efde5eab8cf9d26826b4e382a95e59b1a278b92cb4ec18c8", - "bytes": 3724 + "sha256": "8a76b453a4647ad5e4b85c551a0b5ca27ee860bdb7fbe8e36323c64d0f486ea8", + "bytes": 3740 }, { "id": "146-shadcn-bar-interactive", - "sha256": "c381cb6116c33c388b1b00437eb98d5bfd33677e88e5a7e2152638e1f4accf79", - "bytes": 20654 + "sha256": "e59d7880c6a580984495c2926026a3bcc1afca4770a50dfc6681903bc6e321f4", + "bytes": 20670 }, { "id": "147-shadcn-bar-label-custom", - "sha256": "c72c269e64025ebbad36f9a502275226e6f83c0e31fe480a9d926f06bbc27dc4", - "bytes": 5256 + "sha256": "d3d92faa536fb6a518e0536abdec2c546e60a67da505684ccd204d12bce4f089", + "bytes": 5272 }, { "id": "148-shadcn-bar-label", - "sha256": "5b30abe83bd7cf32b3aded78f318cc0886b02d39c2d5220c8c134187033ba48b", - "bytes": 5150 + "sha256": "2c61d2dc70ace00c7a1697ebf1e457a94b254c7bf5860285a3cca62c75917957", + "bytes": 5166 }, { "id": "149-shadcn-bar-mixed", - "sha256": "83de97ad7c5dbbe17d31a4ff9a280656832247739a1f9e29683546622f59a254", - "bytes": 3327 + "sha256": "01b88eea0805ed92293efb038f930ba76da6a23e2839c2f9192de2a5a16eca2e", + "bytes": 3343 }, { "id": "150-shadcn-bar-negative", - "sha256": "524a4f51cc5a3e5f787c0c0da2805a64fb15b14229201d2db6fc2342604f98b9", - "bytes": 4387 + "sha256": "117285f70baf2b44b96a56023a8b016b4c34ef0b0e44e0f12a23e3311cfbc0d9", + "bytes": 4403 }, { "id": "151-shadcn-bar-stacked", - "sha256": "481d635d087cd8d651b30fdde2678f658390bf73585642ba534bad5cd8c059bd", - "bytes": 5276 + "sha256": "cfa3064537fcdf4ca15d4fcaf6decc6547455af16d9fa69bfb9c4a105e7e2a97", + "bytes": 5292 }, { "id": "152-shadcn-line-default", - "sha256": "bbb7b127cd0475b8407a90ccbc31025e26e5aa5079b42bc3bf7021f112542571", - "bytes": 3303 + "sha256": "1fc4c202fb6393191157d452347efb59d7e96cbab22b346f43f0387d3878703e", + "bytes": 3319 }, { "id": "153-shadcn-line-dots-colors", - "sha256": "d21c0ef9544a45b8a4150e2a0e4ed251e18f645e72ef921453aca8a20a50ce9e", - "bytes": 2944 + "sha256": "b30f2abef04a73e86fd5002084a6e8346c5a3cc6efd6a9c99302a1901c78768e", + "bytes": 2960 }, { "id": "154-shadcn-line-dots-custom", - "sha256": "d9a0b32b1da0c9674163e0dcf52f7d9642dc2fee0b92210505b0d989b86f93f3", - "bytes": 5853 + "sha256": "4ffc3d4c4abf8c4e13a717ef45ce21c9958cc05cecb9935e1be79dc4d09df5fd", + "bytes": 5869 }, { "id": "155-shadcn-line-dots", - "sha256": "4f3670ad28f6eb33bf38a884e72392d370b1430d4b7cd160336839dfb02addf5", - "bytes": 4534 + "sha256": "4b453318da0cca23ee300a3b52f75417f7e1690510ad5cef0c559464321a9149", + "bytes": 4550 }, { "id": "156-shadcn-line-interactive", - "sha256": "2f29937ee3faa87d3529b6188dc26e675e9f3366d32d8a46a3df20b73e7c124d", - "bytes": 6741 + "sha256": "4fa8a838970cd890334bf1f75a1417ae0e1ae1776903e4a49286733c6aa5b107", + "bytes": 6757 }, { "id": "157-shadcn-line-label-custom", - "sha256": "5cf8c894bf7a245294aa430346f35f784eb31ca851adc20003f3e8e2b0a2dfdf", - "bytes": 3311 + "sha256": "482f0889f5d2f9d9e1990b20a037669e685a64b5563a1355dc9b2cb0a864cd44", + "bytes": 3327 }, { "id": "158-shadcn-line-label", - "sha256": "7c5a761408b8e87f4754fc3c2a2b83405c5a98c0d9b182b3c8248a2165291fe5", - "bytes": 4599 + "sha256": "49cb34092b35739f2440d447b300cb1bd4232a66ce25d3dec33a6882da88eca0", + "bytes": 4615 }, { "id": "159-shadcn-line-linear", - "sha256": "e9587089ec0163c6545b51d9ee95a1b3da10273a6c5f7a9f5f406be7943bb1f7", - "bytes": 3189 + "sha256": "0f0b6cc3b0ad04848afc77e6fdafa5ff171d944544ca92cbc822b813fc5f5eb1", + "bytes": 3205 }, { "id": "160-shadcn-line-multiple", - "sha256": "57b3225b9243f2eb3fd5b11161fb2f1a8633ef14f0d8742a266e36f0022f801c", - "bytes": 3839 + "sha256": "2b8203e67c4ae380a933558a3b644d9b162b67932f0b3094470ccac670875cf6", + "bytes": 3855 }, { "id": "161-shadcn-line-step", - "sha256": "cf59d3d81c032133e4e8baf220c794a307db94f435d30c9c74e9f5bbd2ff1bd7", - "bytes": 3259 + "sha256": "4a36ff0ccb69585689d43deae51b9794131b1ec7086b6e3e2cde6117191c513f", + "bytes": 3275 }, { "id": "162-shadcn-pie-donut-active", - "sha256": "d125c3442aec632ba9a2d134b357e80374d6d3711158f6f0d95801090995e227", - "bytes": 3231 + "sha256": "7766f139b2ac4cca286d7be14dd56be707e7d3a1460d5778aff935e721ace554", + "bytes": 3247 }, { "id": "163-shadcn-pie-donut", - "sha256": "1e11d8f7d142e8a212cd0edda65eda1438d53ebb337fd5ea4ea9921544eef12c", - "bytes": 2847 + "sha256": "ea3681b806aec12aa4fa6a131dcf4a3b6ea87766bcef23ea3297cf47ddab791e", + "bytes": 2863 }, { "id": "164-shadcn-pie-interactive", - "sha256": "64ec9558e3bb84f3c9379fa84ce06374852277107cf0234f3fa041ecb6e44874", - "bytes": 4203 + "sha256": "3c10bf06412993f8bb64b77b4f7f7d5d62e24097c58d55d5e03b4ce7c9ded02a", + "bytes": 4219 }, { "id": "165-shadcn-pie-label-custom", - "sha256": "a0f3095ba32ab14a23d0b194af2f2f362f33cd14751c1ffc221e6dc949d1b865", - "bytes": 3798 + "sha256": "fd7ef66385f2b56ca4b88aa1dc7f2433cbbd83603b6bfa708c6dfd4461eaa6c0", + "bytes": 3814 }, { "id": "166-shadcn-pie-label-list", - "sha256": "b5900b56dca82724c7cf79ab9da5c879c0c8920009c8f7c6a0cab147803072b0", - "bytes": 3909 + "sha256": "031cfc2d3ee8754bc3d6053bf5f0659b29656247bc979a3aafe84ac37a96b8b0", + "bytes": 3925 }, { "id": "167-shadcn-pie-label", - "sha256": "6e03bd9b9b73e7e21cf3cfc6e29b6ae0f2f226616f48e313acd646753880ed6d", - "bytes": 4765 + "sha256": "dd1923f46633301045625c06e06e4a1feaaf8e6409c09fd96997ce64171391e6", + "bytes": 4781 }, { "id": "168-shadcn-pie-legend", - "sha256": "0a1a72cbce6b626cd56cd838ab673431cf1ebbb65f8bc359b464dd5f1a6367c0", - "bytes": 2686 + "sha256": "5db75dd923761942ff4327fe88db0f89359ae9146b98abe47ded6fd9ea87d8c7", + "bytes": 2702 }, { "id": "169-shadcn-pie-separator-none", - "sha256": "66b7baa6996a258b43616df0bb80250b22f403e452bb20598fa151cd8a1464c9", - "bytes": 2694 + "sha256": "795c1653d78f6abd95faa2e06d4954b700553e35f9217bb03ad7be2d028d6edb", + "bytes": 2710 }, { "id": "170-shadcn-pie-simple", - "sha256": "fc94d7c6d4b9b7d5d5e7c2d7509d31e9a45e8952a371ab23652179740ed0ab4f", - "bytes": 2677 + "sha256": "8829187c02c3cc68f17576fdfe432d22276d44cc84914f2bdd8d18c41b88e890", + "bytes": 2693 }, { "id": "171-shadcn-pie-stacked", - "sha256": "a5a3af184ff5fc62f0d8f3ffc02b5c9073fc38c550cb2a798d7e6489f838b6d0", - "bytes": 4176 + "sha256": "5ae69c976cfe4f692ac8ebfeeac1cec84e3a16a67e512f1ca832345c6ce045a4", + "bytes": 4192 }, { "id": "172-shadcn-radar-default", - "sha256": "7c13ad5e33e9969614c79130b678a23e4689c73e902990456056fe6feab12137", - "bytes": 5091 + "sha256": "8378b452ee0447ab9d42ddfa366a7df8b64a99b02a65af892130aa3766eebd47", + "bytes": 5107 }, { "id": "173-shadcn-radar-dots", - "sha256": "32fed94017e38d4a30df7b66d774bf74bf057ee1887856b9e16320f9eb173f83", - "bytes": 5999 + "sha256": "5caa44008f965378baa822b94b2e8d0fd2c95162bb8f45c5b8a0567ef6ffeebd", + "bytes": 6015 }, { "id": "174-shadcn-radar-grid-circle-fill", - "sha256": "da657106ffa05bd2736af664b0fccdc52b3aac1719952b31c0beb0af11886166", - "bytes": 5244 + "sha256": "5f5571ae33271a0757d4caa72619a3d24e8368915d6fd170076e686a6158d5d0", + "bytes": 5260 }, { "id": "175-shadcn-radar-grid-circle-no-lines", - "sha256": "22638099aae601857378a2db16b9f78f35dec1ba5eb4336a7b2d098cc7bdb766", - "bytes": 5965 + "sha256": "0efef3a0ff2e1b1a0b4494d2765a466d754ce95a74a9b1b787bb88b857565596", + "bytes": 5981 }, { "id": "176-shadcn-radar-grid-circle", - "sha256": "c3b6eacfe95022e0cdba6931b2980362d23fcf691629e04c7f2f24277e0c8caa", - "bytes": 5966 + "sha256": "912fc166a6b9fc138bee5b3342f9eafcc34cccc6c4afb6267d83b38adebc88f1", + "bytes": 5982 }, { "id": "177-shadcn-radar-grid-custom", - "sha256": "dc02f8d475f6fb647611a3a9fb0c676825357cc91c541ef20b3bd07594872bd7", - "bytes": 4438 + "sha256": "e41a45b7ef82a9593b667b8f0e7e23446ff8e67890a71149fa9168d8520ca2ae", + "bytes": 4454 }, { "id": "178-shadcn-radar-grid-fill", - "sha256": "3ea97459476e282528d6d3f29628bd038fb484edabfec3f0bf6006fdc122af01", - "bytes": 5277 + "sha256": "f6f48b73a27616bb3e1e5c116759943ed20fae3a4ae590508b75b6484e840a4d", + "bytes": 5293 }, { "id": "179-shadcn-radar-grid-none", - "sha256": "aa750772afe6ddf4eaa2ed2a3d41e836b833866764318cde8404fecd784b4144", - "bytes": 5017 + "sha256": "212323aac7bd9361bde297b601dd416dab26b13a32559c828dac44bee1beb6c9", + "bytes": 5033 }, { "id": "180-shadcn-radar-icons", - "sha256": "229b4e91ee95a4b62b8ce88d69b39f3859e3b19d722200a10a98fe41db6c5b5c", - "bytes": 5528 + "sha256": "10a703a323eb10090051560b52f11317468006e9f7d7d18486322c58a0ecd8d4", + "bytes": 5544 }, { "id": "181-shadcn-radar-label-custom", - "sha256": "cfb621726fb1a43e809e0c3331005b637a0b6f287665b72940e6ab024d3c89f7", - "bytes": 6893 + "sha256": "a01a6cb688b465ba300b0b176bce46e2423c76d648358294f6caae6ebb5a199a", + "bytes": 6909 }, { "id": "182-shadcn-radar-legend", - "sha256": "8b0e06871c7fd91f65fe16ed4fdcbf414e39cad810a00ba3f2496e2c845cd596", - "bytes": 5529 + "sha256": "e1b179b9b1d938a1ef7f9c2cdb21ff965391a3c0346928fa53fb1dd708cdd180", + "bytes": 5545 }, { "id": "183-shadcn-radar-lines-only", - "sha256": "644b114c0f83eaef993a4ca21b6cbdcaf6466943e5ffbbfd4dee4afdd9ff08f8", - "bytes": 5526 + "sha256": "8ab56689c65af262672a3d50510446fb380e34ff4d21e6dfa7207d15c7acca73", + "bytes": 5542 }, { "id": "184-shadcn-radar-radius", - "sha256": "88bccc24fa557d8eb6d9e2f3f79da0030da630fef112300e61bc4cda45202167", - "bytes": 4520 + "sha256": "5eaa50e5efb14e29e25c6c5e2967b6a4a72c1e8f1ccc24fb59947a457c1d0570", + "bytes": 4536 }, { "id": "185-shadcn-radial-grid", - "sha256": "7b19c0c5c92e1619073b98977ccb802e029a1491e2b1dbc4134cf8650baaf804", - "bytes": 5207 + "sha256": "f503eb6272c054e951b6c2137454fe1b3671a7182738bd6b7d18a8f262eb4a7c", + "bytes": 5223 }, { "id": "186-shadcn-radial-label", - "sha256": "d7707821c9e1ee0cddc56c1887fe997336fd28e88cc160cf224d7bd82ab79811", - "bytes": 5840 + "sha256": "68f8a11ee336cbe0ef34bdb79bdbd82ebb919794b0fde948e354b0cb02275a86", + "bytes": 5856 }, { "id": "187-shadcn-radial-shape", - "sha256": "bc48c8ca4252afc7ddd6efefe36d0b96621611b849bebc7d6003453e3365483f", - "bytes": 2829 + "sha256": "841af90860d36aa1b3e05860fa0ef893dd1db42793cdb8006020fe125bace66b", + "bytes": 2845 }, { "id": "188-shadcn-radial-simple", - "sha256": "fb8a00785b0632a4baf7718010820b9b23439ed599960758c831ffecec35cae9", - "bytes": 4144 + "sha256": "d50da1169fd4ca661b1d2704e70810087077773f61c80dbf0b7ade8ef2fd8c6a", + "bytes": 4160 }, { "id": "189-shadcn-radial-stacked", - "sha256": "c2529831101e64b2fe2e8ede8e5f6f562e74fd6987bbe89dd6116fc3ec2f26f2", - "bytes": 2955 + "sha256": "b280f74e86ffef5e6865462a9be2d51b4e35479223f49df6fc73c64b063b91dd", + "bytes": 2971 }, { "id": "190-shadcn-tooltip-default", - "sha256": "397ad7d5f553609bc3546171d6db679cdd46d72daa3b841b1bb33063a4021622", - "bytes": 5002 + "sha256": "fe48eaec0c292c1338d653953946483982ef1b23ec356478b24bbf11730fd3f9", + "bytes": 5018 }, { "id": "191-shadcn-tooltip-formatter", - "sha256": "ce18e3fa959861959737c03962b9ab09935a5bd32c1df60697ad2f34a001771a", - "bytes": 5004 + "sha256": "43eb6270aa388f3a3004cec62e53f0fbdb73ff5ddc5c4190649ff64d61730240", + "bytes": 5020 }, { "id": "192-shadcn-tooltip-icons", - "sha256": "79727762928ce8a1fd484ffd56a47b7e6b6e8ebe9bf43ffdb3522592f26469f4", - "bytes": 5000 + "sha256": "bda712a532d9455b3409e7dd4280e1e9262a1bc805fe6b7ffcc237de9693e39e", + "bytes": 5016 }, { "id": "193-shadcn-tooltip-indicator-line", - "sha256": "a21c33af271d750cf8b7d8b9816c5aae0c5510feebdf25d702201de7f8c0b9f9", - "bytes": 5009 + "sha256": "a46a207d4db94529ad698128a0f348300212593c91517fbeb05a4a6e9de07888", + "bytes": 5025 }, { "id": "194-shadcn-tooltip-indicator-none", - "sha256": "df8dfc52f4dbb10ce2c75477b2f1014b48e71b5ffda81327adff7c794236ccc3", - "bytes": 5007 + "sha256": "ac6fe965bea2a6588cc0597a595fa8624c7f6cac3b38a20201ae8807f0ba4514", + "bytes": 5023 }, { "id": "195-shadcn-tooltip-label-custom", - "sha256": "ca9e23c3c2caad08b2f1b93713d7fe51ec0422912746aff2111cfa3cc335dc70", - "bytes": 5007 + "sha256": "b09982f65a0481e1c2efdee73d3132058594acdc9786d8c1f9ad877816752536", + "bytes": 5023 }, { "id": "196-shadcn-tooltip-label-formatter", - "sha256": "3a1cc49b5994d586fda7d6767d0b09a653aee93fa6b507b00a6f66f6c32c841d", - "bytes": 5010 + "sha256": "b18e7ed66963b22aa33bc543ab08d119be452cf7e6f77d485f8756694dd16f2f", + "bytes": 5026 }, { "id": "197-shadcn-tooltip-label-none", - "sha256": "9eb0e1ea46d89e6752804d62cf5d4fdcb04c3633508895269f28c1d7aa88fc7a", - "bytes": 5003 + "sha256": "af6631dd0b4e88a936d9cff1ae28a90b549ef6c2d72513b90e9dadb80d4f437a", + "bytes": 5019 } ] } diff --git a/benchmarks/conformance/previews/scatter-bubble.svg b/benchmarks/conformance/previews/scatter-bubble.svg index 5812a7f0..8a354d3b 100644 --- a/benchmarks/conformance/previews/scatter-bubble.svg +++ b/benchmarks/conformance/previews/scatter-bubble.svg @@ -1 +1 @@ - + diff --git a/docs/comparison.md b/docs/comparison.md index 83f66b70..64e8dfad 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `699b4be` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `699b4be`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-27`. +Baseline date: `2026-09-02`. 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 | 38.97–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.22–45.26 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 83f66b70..64e8dfad 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `699b4be` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `699b4be`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-27`. +Baseline date: `2026-09-02`. 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 | 38.97–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.22–45.26 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/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 9ba1695a..06f5c7c6 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -65,7 +65,8 @@ removes only the built-in ring. The definition compiles this presentation into ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom mark renderers, and the optional motion renderer share the same values. The ring remains hidden from the accessibility tree; the chart root continues to -report focus and values. +report focus and values. `radius` and `strokeWidth` accept finite nonnegative +numbers, including zero. Invalid values use their defaults. Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index b8913e52..0bfb7097 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -353,7 +353,8 @@ const x = { } ``` -`anchor` defaults to the rotation-derived x anchor or `end` on y. `dx` and +`anchor` defaults to an outward anchor derived from x rotation or the y-axis +side. The automatic value accounts for the host's inline direction. `dx` and `dy` apply after the normal tick position and padding. Resolved font size, weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; @@ -430,6 +431,12 @@ mapping should not draw another axis. X scales can use the top or bottom side, and y scales can use the left or right side. Axes on the same side stack outward and contribute their measured size to the automatic margin. +Axis sides stay physical in a right-to-left container, so `side: 'right'` +still puts the axis on the right. Automatic tick anchors account for the +container's inline direction and keep labels outside the plot. An explicitly +authored `start` or `end` anchor remains logical. Text measurement, SVG, +Canvas, native rendering, and standalone SVG export preserve that direction. + ## Automatic guide layout When a margin side is omitted, the scene compiler measures: diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 6de2ced9..1669925e 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -552,7 +552,7 @@ const entries = [ 'Hierarchy treemap mark', 'benchmarks/entries/charts-hierarchy-treemap.ts', 'D3 hierarchy treemap kernel', - 3.74, + 3.77, { inputBoundary: { require: ['hierarchyFlat', 'hierarchyTreemap', 'd3Hierarchy'], @@ -671,37 +671,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 20.26, + 20.48, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 24.9, + 25.12, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 22.79, + 23.01, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 23.94, + 24.16, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 20.22, + 20.43, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 24.28, + 24.52, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 20.68, + 20.88, { inputBoundary: { forbid: [ @@ -785,7 +785,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 11.64, + 11.86, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -924,7 +924,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 27.79, + 27.98, { inputBoundary: { require: [ @@ -1093,7 +1093,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 20.13, + 20.36, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1123,7 +1123,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.12, + 20.33, { inputBoundary: { forbid: [ @@ -1163,22 +1163,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 21.14, + 21.35, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 20.32, + 20.54, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 17.59, + 17.81, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 16.58, + 16.79, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1197,18 +1197,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 25.7, + 25.93, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 22.2, + 22.43, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 25.71, + 25.95, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1219,7 +1219,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 26.81, + 27.04, ), locked( 'Representative marks', @@ -1246,7 +1246,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 35.8, + 35.96, { rendererBoundary: 'mixed', inputBoundary: { @@ -1583,7 +1583,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 41.8, + 41.94, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1663,7 +1663,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 11.69, + 11.89, { inputBoundary: { require: ['compactLinear'], @@ -1682,7 +1682,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 28.5, + 28.72, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1744,7 +1744,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 20.91, + 20.94, { rendererBoundary: 'svg', inputBoundary: { @@ -1850,7 +1850,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 52.86, + 53.1, ), locked( 'Custom-scale line scene', @@ -1876,37 +1876,37 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 20.92, + 21.11, ), budgeted( 'D3 time-scale line scene', 'benchmarks/entries/charts-d3-time-scene.ts', - 23.21, + 23.42, ), budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 22.63, + 22.83, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 21.53, + 21.75, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 24.9, + 25.12, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 36.87, + 37.1, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 42.11, + 42.32, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2110,7 +2110,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 53.75, + 54, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), From 554e356eb619fe33e970023efd01e785f48059e9 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Thu, 3 Sep 2026 00:24:07 -0600 Subject: [PATCH 10/10] Address final review findings --- .changeset/focus-ring-options.md | 5 +- API-FRICTION.md | 30 ++-- .../bundle-size/universal-baseline.json | 40 ++--- benchmarks/comparison/bundle-baseline.json | 124 +++++++-------- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 4 +- docs/reference/chart-definitions.md | 4 +- docs/reference/chart-spec.md | 5 +- docs/reference/focus-and-interaction.md | 17 ++- docs/reference/marks/line-and-area.md | 7 + docs/reference/runtime-and-scene.md | 3 +- docs/reference/types.md | 2 +- packages/charts-core/docs/comparison.md | 4 +- .../docs/reference/chart-definitions.md | 4 +- .../charts-core/docs/reference/chart-spec.md | 5 +- .../docs/reference/focus-and-interaction.md | 17 ++- .../docs/reference/marks/line-and-area.md | 7 + .../docs/reference/runtime-and-scene.md | 3 +- packages/charts-core/docs/reference/types.md | 2 +- packages/charts-core/src/facet.test.ts | 70 ++++++++- packages/charts-core/src/facet.ts | 48 +++++- packages/charts-core/src/scene.test.ts | 144 ++++++++++++++++++ packages/charts-core/src/scene.ts | 56 ++++--- .../charts-core/src/type-contract.test.ts | 33 ++++ packages/charts-core/src/types.ts | 4 +- packages/charts-core/src/view-compose.test.ts | 121 +++++++++++++-- packages/charts-core/src/view.ts | 56 ++++++- scripts/measure-bundles.mjs | 38 ++--- scripts/public-callback-contract.mjs | 4 + 29 files changed, 662 insertions(+), 197 deletions(-) diff --git a/.changeset/focus-ring-options.md b/.changeset/focus-ring-options.md index 9afd6e0f..544922df 100644 --- a/.changeset/focus-ring-options.md +++ b/.changeset/focus-ring-options.md @@ -2,5 +2,6 @@ '@tanstack/charts': minor --- -Allow the built-in focus ring to configure its radius, stroke width, fill, and -stroke while preserving the existing boolean behavior and defaults. +Allow definitions and themes to configure the built-in focus ring's radius, +stroke width, fill, and stroke while preserving the existing boolean behavior +and defaults. diff --git a/API-FRICTION.md b/API-FRICTION.md index eb91250a..0bd2ca5d 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-27 +Last updated: 2026-09-03 ## Triage rule @@ -7199,7 +7199,7 @@ Each entry records: threshold generators, and consumer-called service methods as classified exceptions. - Verification: the public callback inventory follows exported types, - functions, and values into nested package-owned types. It classifies all 778 + functions, and values into nested package-owned types. It classifies all 799 reachable callable surfaces, including Alpine's external directive protocol, Vue's nested tooltip slot, live-chart interaction and presentation service handles, cursor controllers and host sessions, focus-guide resolvers, the @@ -7232,6 +7232,10 @@ Each entry records: supplies one stable context shape and all first-party examples use it. - Follow-up verification: transform regressions cover datum, index, and complete data; the public callback inventory and root TypeScript pass. +- Follow-up verification: narrowing the public composable-responsive chart + callback exposed it as a separately reachable surface. It is classified as an + application callback with one `ChartBuildContext` object, and the full + 799-surface inventory passes. ### F-239 — Example keys collapsed distinct source rows @@ -8563,15 +8567,21 @@ Each entry records: reduce the fixed 5-pixel radius, tying application styling to generated SVG structure and leaving Canvas and React Native without the same control. - Decision: extend `focusRing` to accept a reusable `ChartFocusRingOptions` - object with radius, stroke width, fill, and stroke. Preserve `true`, `false`, - and every existing default. An omitted stroke keeps each point's resolved - series color. + object with radius, stroke width, fill, and stroke on both chart definitions + and themes. Preserve `true`, `false`, and every existing default. An omitted + stroke keeps each point's resolved series color. An explicitly supplied + definition value takes precedence over the theme default. Composed views and + facets take that default from their outer host and reject child ownership + instead of silently discarding it while rebuilding the shared focus layer. - Verification: scene, SVG, Canvas, and React Native tests cover configured - geometry and paint, series-color fallback, keyboard focus, and hidden - accessibility presentation. The catalog's pointer-tooltip case uses the - object form, and the reference docs record the complete contract. Locked - universal consumers add 65-79 minified bytes and 25-36 gzip bytes; the - reviewed bundle baselines and budgets pass. + geometry and paint, theme defaults, definition override and disable behavior, + invalid theme geometry, outer view propagation, rejected child ownership, + series-color fallback, keyboard focus, and hidden accessibility presentation. + The catalog's pointer-tooltip case uses the object form, and the reference + docs record the complete contract. Against the reviewed feature baseline, + theme fallback adds 7 minified bytes and 5 gzip bytes to the locked D3 line + scene. The isolated facet entry adds 0.09 KiB gzip for explicit child + ownership validation. ### F-298 - Physical axis sides depended on logical text direction diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index dcea8692..615f0c44 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": 51661, - "gzip": 19316 + "bytes": 51668, + "gzip": 19321 }, "D3-scale line + static SVG": { - "bytes": 56472, - "gzip": 21076 + "bytes": 56479, + "gzip": 21089 }, "Representative marks": { - "bytes": 76536, - "gzip": 27844 + "bytes": 76547, + "gzip": 27858 }, "TanStack DOM host": { - "bytes": 75844, - "gzip": 26314 + "bytes": 75837, + "gzip": 26325 }, "React adapter": { - "bytes": 78028, - "gzip": 27086 + "bytes": 78017, + "gzip": 27096 }, "React line consumer": { - "bytes": 101679, - "gzip": 36487 + "bytes": 101674, + "gzip": 36491 }, "Compact-scale line scene": { - "bytes": 34114, - "gzip": 12160 + "bytes": 34121, + "gzip": 12168 }, "React compact-scale line consumer": { - "bytes": 84187, - "gzip": 29402 + "bytes": 84176, + "gzip": 29414 }, "Custom-scale line scene": { - "bytes": 32296, - "gzip": 11424 + "bytes": 32303, + "gzip": 11426 }, "D3 linear-scale line scene": { - "bytes": 51593, - "gzip": 19278 + "bytes": 51600, + "gzip": 19283 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 5500ea71..5c2b6c41 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-09-02T22:29:44.477Z", + "generatedAt": "2026-09-03T06:14:17.086Z", "packageVersions": { "tanstack": "0.16.0", "chartjs": "4.5.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "699b4be656a22ad99618466a3b46b7966e9fb4ff", - "inputDigest": "sha256:8b13c0942041f80ae1b644ffad32ba2c285a687ce3df73868554c83b2c553538" + "inputDigest": "sha256:8c71a2cb2c02481f11c3c6d23a087af327397726f09ec1fb21b9bf394d458f02" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 111349, - "gzipBytes": 40157, - "brotliBytes": 35531, - "incrementalGzipBytes": 40157, - "incrementalBrotliBytes": 35531 + "minifiedBytes": 111345, + "gzipBytes": 40162, + "brotliBytes": 35522, + "incrementalGzipBytes": 40162, + "incrementalBrotliBytes": 35522 }, "tanstack-line-interactive": { - "minifiedBytes": 116912, - "gzipBytes": 41957, - "brotliBytes": 36983, - "incrementalGzipBytes": 41957, - "incrementalBrotliBytes": 36983 + "minifiedBytes": 116908, + "gzipBytes": 41958, + "brotliBytes": 36977, + "incrementalGzipBytes": 41958, + "incrementalBrotliBytes": 36977 }, "tanstack-line-advanced": { - "minifiedBytes": 124102, - "gzipBytes": 44307, - "brotliBytes": 38984, - "incrementalGzipBytes": 44307, - "incrementalBrotliBytes": 38984 + "minifiedBytes": 124100, + "gzipBytes": 44318, + "brotliBytes": 39054, + "incrementalGzipBytes": 44318, + "incrementalBrotliBytes": 39054 }, "tanstack-bar-basic": { - "minifiedBytes": 120119, - "gzipBytes": 43468, - "brotliBytes": 38325, - "incrementalGzipBytes": 43468, - "incrementalBrotliBytes": 38325 + "minifiedBytes": 120114, + "gzipBytes": 43480, + "brotliBytes": 38367, + "incrementalGzipBytes": 43480, + "incrementalBrotliBytes": 38367 }, "tanstack-bar-interactive": { - "minifiedBytes": 124541, - "gzipBytes": 44862, - "brotliBytes": 39515, - "incrementalGzipBytes": 44862, - "incrementalBrotliBytes": 39515 + "minifiedBytes": 124536, + "gzipBytes": 44871, + "brotliBytes": 39500, + "incrementalGzipBytes": 44871, + "incrementalBrotliBytes": 39500 }, "tanstack-bar-advanced": { - "minifiedBytes": 124880, - "gzipBytes": 45011, - "brotliBytes": 39617, - "incrementalGzipBytes": 45011, - "incrementalBrotliBytes": 39617 + "minifiedBytes": 124875, + "gzipBytes": 45026, + "brotliBytes": 39535, + "incrementalGzipBytes": 45026, + "incrementalBrotliBytes": 39535 }, "tanstack-area-basic": { - "minifiedBytes": 116529, - "gzipBytes": 42105, - "brotliBytes": 37275, - "incrementalGzipBytes": 42105, - "incrementalBrotliBytes": 37275 + "minifiedBytes": 116525, + "gzipBytes": 42109, + "brotliBytes": 37343, + "incrementalGzipBytes": 42109, + "incrementalBrotliBytes": 37343 }, "tanstack-area-interactive": { - "minifiedBytes": 122100, - "gzipBytes": 43889, - "brotliBytes": 38741, - "incrementalGzipBytes": 43889, - "incrementalBrotliBytes": 38741 + "minifiedBytes": 122096, + "gzipBytes": 43896, + "brotliBytes": 38714, + "incrementalGzipBytes": 43896, + "incrementalBrotliBytes": 38714 }, "tanstack-area-advanced": { - "minifiedBytes": 129473, - "gzipBytes": 46345, - "brotliBytes": 40805, - "incrementalGzipBytes": 46345, - "incrementalBrotliBytes": 40805 + "minifiedBytes": 129471, + "gzipBytes": 46367, + "brotliBytes": 40792, + "incrementalGzipBytes": 46367, + "incrementalBrotliBytes": 40792 }, "tanstack-scatter-basic": { - "minifiedBytes": 112355, - "gzipBytes": 40544, - "brotliBytes": 35809, - "incrementalGzipBytes": 40544, - "incrementalBrotliBytes": 35809 + "minifiedBytes": 112351, + "gzipBytes": 40554, + "brotliBytes": 35815, + "incrementalGzipBytes": 40554, + "incrementalBrotliBytes": 35815 }, "tanstack-scatter-interactive": { - "minifiedBytes": 117922, - "gzipBytes": 42328, - "brotliBytes": 37305, - "incrementalGzipBytes": 42328, - "incrementalBrotliBytes": 37305 + "minifiedBytes": 117918, + "gzipBytes": 42329, + "brotliBytes": 37299, + "incrementalGzipBytes": 42329, + "incrementalBrotliBytes": 37299 }, "tanstack-scatter-advanced": { - "minifiedBytes": 117938, - "gzipBytes": 42334, - "brotliBytes": 37328, - "incrementalGzipBytes": 42334, - "incrementalBrotliBytes": 37328 + "minifiedBytes": 117934, + "gzipBytes": 42335, + "brotliBytes": 37379, + "incrementalGzipBytes": 42335, + "incrementalBrotliBytes": 37379 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 6b05d5f7..cf5ab427 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "b4140425c4b646286f0ca986b9d387a886ef61e2b39b5ff4741835ab150b89bf", + "sourceHash": "4867bae8ac8f2c7eca12d00da48db7acef44832dede91c418498939e2d54b9aa", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 64e8dfad..a3d86897 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-02`. +Baseline date: `2026-09-03`. 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 | 39.22–45.26 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.22–45.28 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/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index c18c84fe..9257c6cc 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -105,7 +105,9 @@ do not override them. `focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with `radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in -defaults, including the focused point's resolved color for `stroke`. See +defaults, including the focused point's resolved color for `stroke`. The same +value on `theme.focusRing` provides a default, while an explicitly supplied +definition-level value takes precedence. See [Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). Each `ChartControl` resolves after final scales and plot bounds exist. It can diff --git a/docs/reference/chart-spec.md b/docs/reference/chart-spec.md index a8bf4c20..b29735c0 100644 --- a/docs/reference/chart-spec.md +++ b/docs/reference/chart-spec.md @@ -178,11 +178,14 @@ interface ChartTheme { grid: string background: string palette: readonly string[] + focusRing?: boolean | ChartFocusRingOptions } ``` `theme` is partial. The palette is replaced as one value rather than merged by -index. The default palette uses CSS custom-property fallbacks: +index. `focusRing` supplies the default built-in focus ring presentation for +every chart using the theme. A definition-level `focusRing` value overrides it. +The default palette uses CSS custom-property fallbacks: ```css .dashboard { diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 06f5c7c6..89d4f754 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -61,12 +61,17 @@ const definition = defineChart({ | `stroke` | The focused point's resolved series color | The object form still enables the ring. `true` keeps every default and `false` -removes only the built-in ring. The definition compiles this presentation into -ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom -mark renderers, and the optional motion renderer share the same values. The -ring remains hidden from the accessibility tree; the chart root continues to -report focus and values. `radius` and `strokeWidth` accept finite nonnegative -numbers, including zero. Invalid values use their defaults. +removes only the built-in ring. Set the same value on `theme.focusRing` to +provide a theme default. An explicitly supplied definition-level `focusRing` +value takes precedence, including `true` or `false`. Composed views and facet +cells share the outer chart's focus layer, so configure their focus ring on +the outer definition or theme. Their child specs reject both `focusRing` and +`theme.focusRing`. The definition compiles this presentation into ordinary +scene dots, so SVG, Canvas, React Native, facets, polar marks, custom mark +renderers, and the optional motion renderer share the same values. The ring +remains hidden from the accessibility tree; the chart root continues to report +focus and values. `radius` and `strokeWidth` accept finite nonnegative numbers, +including zero. Invalid values use their defaults. Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable diff --git a/docs/reference/marks/line-and-area.md b/docs/reference/marks/line-and-area.md index 7a3c4978..a54b015d 100644 --- a/docs/reference/marks/line-and-area.md +++ b/docs/reference/marks/line-and-area.md @@ -97,6 +97,13 @@ input order remains path order. Grouping, paint, points, curves, states, and motion use the same contract. Line interaction follows y affinity so keyboard and pointer traversal match the longitudinal axis. +`lineX` inherits these stroke-shape options from the shared line contract: + +| Option | Type | Default | Meaning | +| ---------- | ------------------------ | --------- | --------------------- | +| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape | +| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape | + ## `areaY` `areaY` fills between a numeric upper channel and a numeric lower baseline diff --git a/docs/reference/runtime-and-scene.md b/docs/reference/runtime-and-scene.md index ab5be291..a8836b22 100644 --- a/docs/reference/runtime-and-scene.md +++ b/docs/reference/runtime-and-scene.md @@ -114,7 +114,8 @@ The exported theme contains: - six CSS-variable-backed palette entries Definitions merge partial overrides into this value. A supplied palette -replaces the default palette. See [Chart spec](./chart-spec.md#theme). +replaces the default palette. An omitted focus ring defaults to enabled. See +[Chart spec](./chart-spec.md#theme). ## `findNearestPoint` diff --git a/docs/reference/types.md b/docs/reference/types.md index 34536583..4dd31039 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -247,7 +247,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `CrosshairBandOptions` | Categorical cursor-band inset, radius, fill, stroke, and opacity | | `CrosshairLabelOptions` | Guide label formatting, spacing, text, and halo paint | | `CrosshairMarkerOptions` | Primary-coordinate marker geometry and paint | -| `ChartTheme` | Foreground, muted, grid, background, and palette | +| `ChartTheme` | Foreground, muted, grid, background, palette, and focus ring defaults | | `ChartLinearGradient` | Named linear-gradient resource | | `ChartGradientStop` | Gradient offset, color, and optional opacity | | `ChartCurve` | Line and y-area path generation | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 64e8dfad..a3d86897 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-09-02`. +Baseline date: `2026-09-03`. 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 | 39.22–45.26 KiB | Not applicable | Controlled suite | +| TanStack Charts | 39.22–45.28 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/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index c18c84fe..9257c6cc 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -105,7 +105,9 @@ do not override them. `focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with `radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in -defaults, including the focused point's resolved color for `stroke`. See +defaults, including the focused point's resolved color for `stroke`. The same +value on `theme.focusRing` provides a default, while an explicitly supplied +definition-level value takes precedence. See [Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). Each `ChartControl` resolves after final scales and plot bounds exist. It can diff --git a/packages/charts-core/docs/reference/chart-spec.md b/packages/charts-core/docs/reference/chart-spec.md index a8bf4c20..b29735c0 100644 --- a/packages/charts-core/docs/reference/chart-spec.md +++ b/packages/charts-core/docs/reference/chart-spec.md @@ -178,11 +178,14 @@ interface ChartTheme { grid: string background: string palette: readonly string[] + focusRing?: boolean | ChartFocusRingOptions } ``` `theme` is partial. The palette is replaced as one value rather than merged by -index. The default palette uses CSS custom-property fallbacks: +index. `focusRing` supplies the default built-in focus ring presentation for +every chart using the theme. A definition-level `focusRing` value overrides it. +The default palette uses CSS custom-property fallbacks: ```css .dashboard { diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 06f5c7c6..89d4f754 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -61,12 +61,17 @@ const definition = defineChart({ | `stroke` | The focused point's resolved series color | The object form still enables the ring. `true` keeps every default and `false` -removes only the built-in ring. The definition compiles this presentation into -ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom -mark renderers, and the optional motion renderer share the same values. The -ring remains hidden from the accessibility tree; the chart root continues to -report focus and values. `radius` and `strokeWidth` accept finite nonnegative -numbers, including zero. Invalid values use their defaults. +removes only the built-in ring. Set the same value on `theme.focusRing` to +provide a theme default. An explicitly supplied definition-level `focusRing` +value takes precedence, including `true` or `false`. Composed views and facet +cells share the outer chart's focus layer, so configure their focus ring on +the outer definition or theme. Their child specs reject both `focusRing` and +`theme.focusRing`. The definition compiles this presentation into ordinary +scene dots, so SVG, Canvas, React Native, facets, polar marks, custom mark +renderers, and the optional motion renderer share the same values. The ring +remains hidden from the accessibility tree; the chart root continues to report +focus and values. `radius` and `strokeWidth` accept finite nonnegative numbers, +including zero. Invalid values use their defaults. Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable diff --git a/packages/charts-core/docs/reference/marks/line-and-area.md b/packages/charts-core/docs/reference/marks/line-and-area.md index 7a3c4978..a54b015d 100644 --- a/packages/charts-core/docs/reference/marks/line-and-area.md +++ b/packages/charts-core/docs/reference/marks/line-and-area.md @@ -97,6 +97,13 @@ input order remains path order. Grouping, paint, points, curves, states, and motion use the same contract. Line interaction follows y affinity so keyboard and pointer traversal match the longitudinal axis. +`lineX` inherits these stroke-shape options from the shared line contract: + +| Option | Type | Default | Meaning | +| ---------- | ------------------------ | --------- | --------------------- | +| `lineCap` | `SceneStyle["lineCap"]` | `"round"` | Stroke endpoint shape | +| `lineJoin` | `SceneStyle["lineJoin"]` | `"round"` | Stroke corner shape | + ## `areaY` `areaY` fills between a numeric upper channel and a numeric lower baseline diff --git a/packages/charts-core/docs/reference/runtime-and-scene.md b/packages/charts-core/docs/reference/runtime-and-scene.md index ab5be291..a8836b22 100644 --- a/packages/charts-core/docs/reference/runtime-and-scene.md +++ b/packages/charts-core/docs/reference/runtime-and-scene.md @@ -114,7 +114,8 @@ The exported theme contains: - six CSS-variable-backed palette entries Definitions merge partial overrides into this value. A supplied palette -replaces the default palette. See [Chart spec](./chart-spec.md#theme). +replaces the default palette. An omitted focus ring defaults to enabled. See +[Chart spec](./chart-spec.md#theme). ## `findNearestPoint` diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 34536583..4dd31039 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -247,7 +247,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `CrosshairBandOptions` | Categorical cursor-band inset, radius, fill, stroke, and opacity | | `CrosshairLabelOptions` | Guide label formatting, spacing, text, and halo paint | | `CrosshairMarkerOptions` | Primary-coordinate marker geometry and paint | -| `ChartTheme` | Foreground, muted, grid, background, and palette | +| `ChartTheme` | Foreground, muted, grid, background, palette, and focus ring defaults | | `ChartLinearGradient` | Named linear-gradient resource | | `ChartGradientStop` | Gradient offset, color, and optional opacity | | `ChartCurve` | Line and y-area path generation | diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index 0089eb40..40ba07ba 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { scaleBand, scaleLinear } from 'd3-scale' import { bandX, bandY } from './band' import { barY } from './bar' -import { facet, facetChart } from './facet' +import { facet, facetChart, type FacetOptions } from './facet' import { whenFocused } from './focus-mark' import { measureSceneLabelBounds } from './guide-layout' import { lineY } from './line' @@ -115,6 +115,74 @@ describe('facets', () => { expect(svg).toContain('Beta') }) + it('rejects focus ring options owned by a facet cell', () => { + type Row = { group: string; value: number } + const data: Row[] = [{ group: 'Alpha', value: 2 }] + const definition = ( + childOptions: { focusRing: false } | { theme: { focusRing: false } }, + ) => { + const options = { + by: 'group', + chart: (rows: readonly [Row, ...Row[]]) => ({ + marks: [lineY(rows, { y: 'value' })], + ...linearAxes([0, 1], [0, 2]), + ...childOptions, + }), + } as unknown as FacetOptions + return facetChart(data, options) + } + + expect(() => + createChartScene(definition({ focusRing: false }), { + width: 320, + height: 180, + }), + ).toThrow(/host option "focusRing"/) + expect(() => + createChartScene(definition({ theme: { focusRing: false } }), { + width: 320, + height: 180, + }), + ).toThrow(/cannot own a focus ring theme/) + }) + + it('uses the outer focus ring theme for faceted points', () => { + const data = [{ id: 'a', group: 'Alpha', x: 0, y: 2 }] + const definition = facetChart(data, { + by: 'group', + label: false, + chart: (rows) => ({ + marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], + ...linearAxes([0, 1], [0, 2]), + }), + }) + const scene = createChartScene( + { + ...definition, + theme: { + focusRing: { + radius: 9, + strokeWidth: 1, + fill: '#f8fafc', + stroke: '#0f172a', + }, + }, + }, + { width: 320, height: 180 }, + ) + const focusDots = flatten(scene.nodes).filter( + (node) => + node.kind === 'dot' && + node.radius === 9 && + scene.points.some((point) => point.key === node.key), + ) + + expect(focusDots).toHaveLength(1) + expect(focusDots[0]).toMatchObject({ + style: { fill: '#f8fafc', stroke: '#0f172a', strokeWidth: 1 }, + }) + }) + it('stacks panels when the available width is narrow', () => { const data = [ { group: 'A', value: 1 }, diff --git a/packages/charts-core/src/facet.ts b/packages/charts-core/src/facet.ts index 0d2963f1..fb5e6ba2 100644 --- a/packages/charts-core/src/facet.ts +++ b/packages/charts-core/src/facet.ts @@ -59,12 +59,32 @@ type FacetOptionsInput> = Omit< FacetOptions, 'chart' | 'motion' > & { - chart: TChart + chart: TChart & ValidFacetChartFunction> motion?: ChartMotionDefinition< ChartSpecDatum, ChartSpec>> > } & ([ReturnType] extends [ChartSpec] ? unknown : never) +type InvalidFacetChartResult = TResult extends unknown + ? 'focusRing' extends keyof TResult + ? StaticChartDefinition extends TResult + ? never + : TResult + : TResult extends { theme: infer TTheme } + ? [TTheme] extends [never] + ? never + : 'focusRing' extends keyof TTheme + ? TResult + : never + : never + : never + +type ValidFacetChartFunction> = [ + InvalidFacetChartResult>, +] extends [never] + ? unknown + : never + interface FacetEntry { key: ChartKey data: [TDatum, ...TDatum[]] @@ -134,9 +154,11 @@ export function facet( const cellHeight = cellSize(chart.height, gap, rows) const showLabel = options.label !== false const labelHeight = showLabel ? 22 : 0 - const definitions = entries.map((entry) => - mergeTheme(options.chart(entry.data, { key: entry.key }), theme), - ) + const definitions = entries.map((entry) => { + const definition = options.chart(entry.data, { key: entry.key }) + assertFacetChildDefinition(id, entry.key, definition) + return mergeTheme(definition, theme) + }) if ( options.axes === 'cell' || @@ -642,6 +664,24 @@ function createFacetScene( } } +function assertFacetChildDefinition( + id: string, + key: ChartKey, + definition: ChartSpec, +): void { + const child = definition as ChartSpec & { focusRing?: unknown } + if (child.focusRing !== undefined) { + throw new TypeError( + `Facet "${id}" cell "${String(key)}" cannot own chart host option "focusRing"; configure it on the outer definition`, + ) + } + if (child.theme?.focusRing !== undefined) { + throw new TypeError( + `Facet "${id}" cell "${String(key)}" cannot own a focus ring theme; configure theme.focusRing on the outer definition`, + ) + } +} + function withoutMarkRendering( mark: ChartMark, ): ChartMark { diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index d3a808b0..3022a2f7 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -359,6 +359,150 @@ describe('native mark and channel scene', () => { expectTypeOf(scene).toMatchTypeOf>() }) + it('uses the theme focus ring unless the definition overrides it', () => { + const spec = { + marks: [lineY([1, 2])], + ...linearAxes([0, 1], [0, 2]), + } as const + const focusDots = (scene: ChartScene) => + flatten(scene.nodes).filter( + (node): node is SceneDot => + node.kind === 'dot' && + scene.points.some((point) => point.key === node.key), + ) + + const themed = createChartScene( + defineChart({ + ...spec, + theme: { + focusRing: { + radius: 8, + strokeWidth: 4, + fill: '#f8fafc', + stroke: '#0f172a', + }, + }, + }), + { width: 320, height: 180 }, + ) + + expect(themed.theme.focusRing).toEqual({ + radius: 8, + strokeWidth: 4, + fill: '#f8fafc', + stroke: '#0f172a', + }) + expect( + focusDots(themed).map((node) => ({ + radius: node.radius, + style: node.style, + })), + ).toEqual([ + { + radius: 8, + style: { + fill: '#f8fafc', + stroke: '#0f172a', + strokeWidth: 4, + }, + }, + { + radius: 8, + style: { + fill: '#f8fafc', + stroke: '#0f172a', + strokeWidth: 4, + }, + }, + ]) + + const themeDisabled = createChartScene( + defineChart({ ...spec, theme: { focusRing: false } }), + { width: 320, height: 180 }, + ) + expect(focusDots(themeDisabled)).toHaveLength(0) + + const definitionOptions = createChartScene( + defineChart({ + ...spec, + theme: { focusRing: false }, + focusRing: { radius: 3, strokeWidth: 1 }, + }), + { width: 320, height: 180 }, + ) + expect( + focusDots(definitionOptions).map((node) => ({ + radius: node.radius, + style: node.style, + })), + ).toEqual([ + { + radius: 3, + style: { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: 'var(--ts-chart-1, #2563eb)', + strokeWidth: 1, + }, + }, + { + radius: 3, + style: { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: 'var(--ts-chart-1, #2563eb)', + strokeWidth: 1, + }, + }, + ]) + + const definitionDisabled = createChartScene( + defineChart({ + ...spec, + theme: { focusRing: { radius: 8 } }, + focusRing: false, + }), + { width: 320, height: 180 }, + ) + expect(focusDots(definitionDisabled)).toHaveLength(0) + + const definitionEnabled = createChartScene( + defineChart({ + ...spec, + theme: { focusRing: false }, + focusRing: true, + }), + { width: 320, height: 180 }, + ) + expect(focusDots(definitionEnabled).map((node) => node.radius)).toEqual([ + 5, 5, + ]) + + for (const invalid of [ + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ]) { + const invalidTheme = createChartScene( + defineChart({ + ...spec, + theme: { + focusRing: { radius: invalid, strokeWidth: invalid }, + }, + }), + { width: 320, height: 180 }, + ) + expect( + focusDots(invalidTheme).map((node) => ({ + radius: node.radius, + strokeWidth: node.style?.strokeWidth, + })), + ).toEqual([ + { radius: 5, strokeWidth: 2.5 }, + { radius: 5, strokeWidth: 2.5 }, + ]) + } + }) + it('rejects duplicate control ids', () => { const behavior = { id: 'duplicate', resolve: () => ({}) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index a05ac812..02a16ca1 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -119,17 +119,29 @@ type ChartDefinitionWithOptions = Omit< > & TOptions +declare const definedResponsiveChart: unique symbol + type DefinedResponsiveChart = Omit< ResponsiveChartDefinition< ChartSpecDatum, ChartSpecXValue, ChartSpecYValue >, - keyof ChartDefinitionOptions + keyof ChartDefinitionOptions | 'chart' > & { - chart: (context: ChartBuildContext) => CheckedChartSpec + readonly [definedResponsiveChart]: TSpec + chart: (context: ChartBuildContext) => TSpec } +type ValidResponsiveDefinitionInput< + TDefinition extends ResponsiveChartDefinition, +> = + ErasedChartSpec extends ReturnType + ? unknown + : typeof definedResponsiveChart extends keyof TDefinition + ? unknown + : never + export function defineChart< const TMarks extends readonly ChartMark[], const TSpec extends ChartSpec, @@ -142,18 +154,11 @@ export function defineChart< ): DefinedStaticChart export function defineChart< const TSpec extends ErasedChartSpec, - TTooltipHost extends string = never, + TTooltipHost extends string, + const TConfig extends ResponsiveChartConfig, >( - config: ResponsiveChartConfig, -): Omit< - ResponsiveChartDefinition< - ChartSpecDatum, - ChartSpecXValue, - ChartSpecYValue - >, - keyof ChartDefinitionOptions -> & - ResponsiveChartConfig + config: ResponsiveChartConfig & TConfig, +): DefinedResponsiveChart> & Omit export function defineChart( chart: (context: ChartBuildContext) => CheckedChartSpec, ): DefinedResponsiveChart @@ -232,9 +237,7 @@ export function defineChart< >, >( definition: TDefinition & - (ErasedChartSpec extends ReturnType - ? unknown - : never), + ValidResponsiveDefinitionInput>, options: TOptions, ): ChartDefinitionWithOptions, NoInfer> export function defineChart(definition?: any, options?: any): any { @@ -526,19 +529,14 @@ function createChartSceneWithScaleResolver< } hostControlIds.add(identity) } - if ( - definition.focus !== false && - definition.focusRing !== false && - points.length - ) { - const focusRing = - typeof definition.focusRing === 'object' - ? definition.focusRing - : undefined - const radius = finiteNonNegative(focusRing?.radius, 5) - const fill = focusRing?.fill ?? 'var(--ts-chart-focus-fill, Canvas)' - const stroke = focusRing?.stroke - const strokeWidth = finiteNonNegative(focusRing?.strokeWidth, 2.5) + const focusRing = definition.focusRing ?? theme.focusRing + if (definition.focus !== false && focusRing !== false && points.length) { + const focusRingOptions = + typeof focusRing === 'object' ? focusRing : undefined + const radius = finiteNonNegative(focusRingOptions?.radius, 5) + const fill = focusRingOptions?.fill ?? 'var(--ts-chart-focus-fill, Canvas)' + const stroke = focusRingOptions?.stroke + const strokeWidth = finiteNonNegative(focusRingOptions?.strokeWidth, 2.5) for (const entry of defaultFocusEntries) { nodes.push({ kind: 'group', diff --git a/packages/charts-core/src/type-contract.test.ts b/packages/charts-core/src/type-contract.test.ts index 0c1223c6..dfdc7317 100644 --- a/packages/charts-core/src/type-contract.test.ts +++ b/packages/charts-core/src/type-contract.test.ts @@ -418,6 +418,39 @@ const facetedMark = facet(rows, { return categoricalSpec }, }) +if (false) { + facet(rows, { + by: 'category', + // @ts-expect-error Facet cell focus rings belong to the outer theme. + chart: () => ({ ...categoricalSpec, theme: { focusRing: false } }), + }) + facet(rows, { + by: 'category', + // @ts-expect-error Facet cells cannot override the outer focus ring. + chart: () => ({ ...categoricalSpec, focusRing: false }), + }) + const storedFocusRingChild = defineChart({ + ...categoricalSpec, + focusRing: false, + }) + facet(rows, { + by: 'category', + // @ts-expect-error Stored facet children cannot own the outer focus ring. + chart: () => storedFocusRingChild, + }) + const mixedFocusRingChild = Math.random() + ? { ...categoricalSpec, kind: 'valid' as const } + : { + ...categoricalSpec, + kind: 'invalid' as const, + theme: { focusRing: false as const }, + } + facet(rows, { + by: 'category', + // @ts-expect-error Every possible facet child must leave the focus ring to the outer chart. + chart: () => mixedFocusRingChild, + }) +} const customMark = createMark(() => ({ id: 'custom', channels: {}, diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 36d293a3..77056344 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -585,6 +585,8 @@ export interface ChartTheme { grid: string background: string palette: readonly string[] + /** Default presentation for the built-in primary-point focus ring. */ + focusRing?: boolean | ChartFocusRingOptions } /** Paint and geometry for the built-in primary-point focus indicator. */ @@ -894,7 +896,7 @@ export interface ChartDefinitionOptions< > { maxFocusDistance?: number focus?: ChartFocusMode, NoInfer, NoInfer> - /** Shows and optionally styles the built-in primary-point focus ring. Defaults to true. */ + /** Shows and optionally styles the built-in primary-point focus ring. Overrides the theme default. */ focusRing?: boolean | ChartFocusRingOptions /** Optional app-owned cursor shared by one or more chart definitions. */ cursor?: ChartCursorBinding< diff --git a/packages/charts-core/src/view-compose.test.ts b/packages/charts-core/src/view-compose.test.ts index 49ce0986..fadd4ff1 100644 --- a/packages/charts-core/src/view-compose.test.ts +++ b/packages/charts-core/src/view-compose.test.ts @@ -27,7 +27,11 @@ import type { SceneNode, StaticChartDefinition, } from './types' -import type { ComposeViewsOptions, ViewScaleLink } from './view' +import type { + ComposableResponsiveChartDefinition, + ComposeViewsOptions, + ViewScaleLink, +} from './view' import type { PieDatum } from './polar' interface MainRow { @@ -71,26 +75,34 @@ describe('composed views', () => { height: number foreground: string }> = [] - const responsive = defineChart(({ width, height, defaultTheme }) => { - contexts.push({ - width, - height, - foreground: defaultTheme.foreground, - }) - return { - marks: [dot([{ x: width, y: height }], { x: 'x', y: 'y' })], - scales: { - x: { scale: scaleLinear().domain([0, width]) }, - y: { scale: scaleLinear().domain([0, height]) }, - }, - guides: false, - margin: 0, - } + const responsive = defineChart({ + chart: ({ width, height, defaultTheme }) => { + contexts.push({ + width, + height, + foreground: defaultTheme.foreground, + }) + return { + marks: [dot([{ x: width, y: height }], { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, width]) }, + y: { scale: scaleLinear().domain([0, height]) }, + }, + guides: false, + margin: 0, + } + }, }) const definition = composeViews({ views: { responsive }, layout: fill('responsive'), }) + expectTypeOf>().toEqualTypeOf<{ + x: number + y: number + }>() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() const scene = createChartScene( { ...definition, theme: { foreground: '#123456' } }, @@ -573,6 +585,20 @@ describe('composed views', () => { ...child, theme: { background: '#fff' }, } as unknown as StaticChartDefinition + const focusThemeChild = { + ...child, + theme: { focusRing: false }, + } as unknown as StaticChartDefinition + const responsiveFocusThemeChild = defineChart(() => ({ + marks: [dot([{ id: 'point', x: 0, y: 1 }], { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 10]) }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, + guides: false, + margin: 0, + theme: { focusRing: false }, + })) expect(() => unsafeCompose({ @@ -604,6 +630,21 @@ describe('composed views', () => { layout: fill('child'), }), ).toThrow(/cannot own a scene background/) + expect(() => + unsafeCompose({ + views: { child: focusThemeChild }, + layout: fill('child'), + }), + ).toThrow(/cannot own a focus ring theme/) + expect(() => + createChartScene( + unsafeCompose({ + views: { child: responsiveFocusThemeChild }, + layout: fill('child'), + }), + { width: 300, height: 200 }, + ), + ).toThrow(/cannot own a focus ring theme/) const controlled = composeViews({ views: { child: controlledLegendChild() }, @@ -620,6 +661,38 @@ describe('composed views', () => { summary: linearChild([0, 10]), } if (false) { + const focusThemeChild = { + ...linearChild([0, 10]), + theme: { focusRing: false as const }, + } + const responsiveFocusThemeChild = defineChart(() => ({ + ...linearChild([0, 10]), + theme: { focusRing: false as const }, + })) + const annotatedResponsiveFocusThemeChild: ComposableResponsiveChartDefinition = + { + chart: () => ({ + marks: [], + scales: { x: null, y: null }, + theme: { + // @ts-expect-error A composable responsive child cannot resolve an outer focus ring theme. + focusRing: false, + }, + }), + } + void annotatedResponsiveFocusThemeChild + composeViews({ + // @ts-expect-error A composed child cannot own the outer focus ring theme. + views: { child: focusThemeChild }, + // @ts-expect-error A composed child cannot own the outer focus ring theme. + layout: fill('child'), + }) + composeViews({ + // @ts-expect-error A responsive child cannot return the outer focus ring theme. + views: { child: responsiveFocusThemeChild }, + // @ts-expect-error The rejected responsive child cannot be placed. + layout: fill('child'), + }) composeViews({ views, // @ts-expect-error "ghost" is not a named view @@ -640,6 +713,22 @@ describe('composed views', () => { expect(Object.keys(views)).toEqual(['main', 'summary']) }) + + it('uses the outer focus ring theme for composed points', () => { + const definition = composeViews({ + views: { child: linearChild([0, 10]) }, + layout: fill('child'), + }) + const scene = createChartScene( + { ...definition, theme: { focusRing: { radius: 9 } } }, + { width: 300, height: 200 }, + ) + const focusDots = flatten(scene.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 9, + ) + + expect(focusDots).toHaveLength(1) + }) }) function mixedDefinition(rows: readonly MainRow[] = mainRows) { diff --git a/packages/charts-core/src/view.ts b/packages/charts-core/src/view.ts index e02e122f..81c40ab2 100644 --- a/packages/charts-core/src/view.ts +++ b/packages/charts-core/src/view.ts @@ -13,6 +13,7 @@ import { resolveViewLayoutInternal, } from './view-layout' import type { + ChartBuildContext, ChartMargin, ChartDefinition, ChartMotionDefinition, @@ -73,6 +74,14 @@ type WithoutEmbeddedHostOptions = Omit< [TOption in EmbeddedHostOption]?: never } +type ComposableChartTheme = Omit< + Partial, + 'background' | 'focusRing' +> & { + background?: never + focusRing?: never +} + /** A chart definition that can be embedded without creating a second host. */ export type ComposableStaticChartDefinition< TDatum = unknown, @@ -83,7 +92,7 @@ export type ComposableStaticChartDefinition< 'gradients' | 'theme' > & { gradients?: readonly [] - theme?: Omit, 'background'> & { background?: never } + theme?: ComposableChartTheme } /** A responsive chart definition whose resolved spec can be embedded in a view. */ @@ -91,9 +100,16 @@ export type ComposableResponsiveChartDefinition< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> = WithoutEmbeddedHostOptions< - ResponsiveChartDefinition -> +> = Omit< + WithoutEmbeddedHostOptions< + ResponsiveChartDefinition + >, + 'chart' +> & { + chart: ( + context: ChartBuildContext, + ) => ComposableStaticChartDefinition +} export type ComposableChartDefinition< TDatum = unknown, @@ -204,6 +220,23 @@ type NamedViewYValue = DefinitionYValue< NamedViewDefinition > +type InvalidResponsiveViewDefinition = + TDefinition extends ResponsiveChartDefinition + ? ReturnType extends infer TResult + ? TResult extends { theme: infer TTheme } + ? 'focusRing' extends keyof TTheme + ? TResult + : never + : never + : never + : never + +type ValidResponsiveViewDefinitions = [ + InvalidResponsiveViewDefinition, +] extends [never] + ? unknown + : never + type ValidViewLayout< TViews extends ViewDefinitions, TLayout extends ViewLayout, @@ -318,9 +351,10 @@ export function composeViews< const TViews extends ViewDefinitions, const TLayout extends ViewLayout, >( - options: ComposeViewsOptions & { - layout: TLayout & ValidViewLayout, TLayout> - }, + options: ComposeViewsOptions & + ValidResponsiveViewDefinitions> & { + layout: TLayout & ValidViewLayout, TLayout> + }, ): StaticChartDefinition< NamedViewDatum, NamedViewXValue, @@ -355,7 +389,8 @@ export function viewGrid< TrackId >[], >( - options: ViewGridOptions, + options: ViewGridOptions & + ValidResponsiveViewDefinitions>, ): StaticChartDefinition< ViewDatum, ViewXValue, @@ -758,6 +793,11 @@ function assertChildDefinition( `View "${id}" cannot own a scene background; use an ordinary background mark inside the child definition`, ) } + if (definition.theme?.focusRing !== undefined) { + throw new TypeError( + `View "${id}" cannot own a focus ring theme; configure theme.focusRing on the outer definition`, + ) + } for (const axis of ['x', 'y'] as const) { const configured = definition.scales[axis] const presentation = diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 1669925e..be6907d4 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -671,27 +671,27 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 20.48, + 20.51, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 25.12, + 25.14, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 23.01, + 23.02, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 24.16, + 24.26, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 20.43, + 20.45, ), budgeted( 'D3-scale areaX + static SVG', @@ -701,7 +701,7 @@ const entries = [ budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 20.88, + 20.9, { inputBoundary: { forbid: [ @@ -1093,7 +1093,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 20.36, + 20.37, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1123,7 +1123,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.33, + 20.34, { inputBoundary: { forbid: [ @@ -1163,7 +1163,7 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 21.35, + 21.37, ), budgeted( 'D3-scale vectors + static SVG', @@ -1208,7 +1208,7 @@ const entries = [ budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 25.95, + 25.96, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1219,7 +1219,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 27.04, + 27.05, ), locked( 'Representative marks', @@ -1246,7 +1246,7 @@ const entries = [ budgeted( 'Representative marks + mark Canvas renderer', 'benchmarks/entries/charts-representative-mark-canvas.ts', - 35.96, + 35.98, { rendererBoundary: 'mixed', inputBoundary: { @@ -1583,7 +1583,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 41.94, + 41.95, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1682,7 +1682,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 28.72, + 28.73, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1876,7 +1876,7 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 21.11, + 21.12, ), budgeted( 'D3 time-scale line scene', @@ -1886,22 +1886,22 @@ const entries = [ budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 22.83, + 22.85, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 21.75, + 21.77, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 25.12, + 25.14, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 37.1, + 37.11, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 3d47bf56..0896e460 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -371,6 +371,10 @@ const callbackInventory = { ['@tanstack/charts:src/types.ts:ResponsiveChartConfig', 'chart'], ['@tanstack/charts:src/types.ts:ResponsiveChartDefinition', 'chart'], ['@tanstack/charts:src/scene.ts:DefinedResponsiveChart', 'chart'], + [ + '@tanstack/charts:src/view.ts:ComposableResponsiveChartDefinition', + 'chart', + ], ['@tanstack/charts:src/types.ts:InitializedMark', 'render resolveLayout'], [ '@tanstack/charts:src/types.ts:InitializedMarkBase',