diff --git a/.changeset/named-scales-canvas-layers.md b/.changeset/named-scales-canvas-layers.md new file mode 100644 index 00000000..6f8875ff --- /dev/null +++ b/.changeset/named-scales-canvas-layers.md @@ -0,0 +1,8 @@ +--- +'@tanstack/charts': minor +--- + +Add named Cartesian and polar scale registries with multiple axes, guided +legacy scale migration, and opt-in per-mark Canvas rendering that composes with +the default renderer across DOM framework adapters, SSR, focus, and image +export. diff --git a/API-FRICTION.md b/API-FRICTION.md index 51c8c1f7..358e85da 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-15 +Last updated: 2026-08-26 ## Triage rule @@ -329,6 +329,8 @@ Each entry records: | F-290 | Public examples imported a private workspace package | Tooling | resolved | | F-291 | Renderer capability injection depended on module identity | API/Tooling | resolved | | 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 | ## Findings @@ -8429,3 +8431,56 @@ Each entry records: - Follow-up verification: the generator contract covers all four tokens in both palettes, and regenerated ShadCN donut, radial, radar, and authored-label previews render readable dark text and theme-matched separators. + +### F-293 - Root scale slots blocked named axes + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: auditing the claimed TanStack Charts gaps against ECharts, + Observable Plot, and the existing React chart implementation +- Friction: one root `x` slot and one root `y` slot could not describe multiple + unit-specific axes or bind different marks to independent scales. Polar marks + repeated the same limitation with one `angle` and one `radius` slot. Adding a + registry alone was not enough because composite marks could hide positional + identity, named callbacks could not see the resolved polar registry, and a + misspelled binding otherwise failed later with an undefined scale. +- Decision: make `scales` the canonical Cartesian and polar registry, reserve + `x`, `y`, `angle`, and `radius` as readable defaults, and let marks bind to + named entries. Each named scale declares its positional channel and guide + side. Axes support all four sides and stack when they share one side. Keep the + old root options only as temporary compatibility, warn once with exact + migration instructions, and remove the warning text from production bundles. + Preserve positional identity through composite channel namespacing and expose + the public resolved polar registry to length callbacks. +- Verification: named scale, axis, grid, focus, facet, motion, composite, box, + regression, polar, and type-contract regressions pass. All authored examples, + benchmarks, fixtures, adapters, and docs use the canonical registry, while + focused compatibility tests are the only intentional legacy syntax. The full + 1,920-test suite, workspace typecheck, documentation contract, and packed + package gate pass. + +### F-294 - Automatic mark renderers imposed shared host plumbing + +- Status: resolved +- Severity: high +- Owner: API +- Observed in: adding Canvas rendering for dense marks while retaining SVG + guides, labels, focus, and accessibility +- Friction: renderer choice belonged to the whole chart, and every DOM adapter + assumed one surface. A dense mark could not opt into Canvas without moving + axes and labels too, while nested compositions, SSR adoption, focus updates, + pointer geometry, and image export all depended on the original single-root + contract. +- Decision: add a small universal renderer token to mark options and keep the + DOM compositor contract in the DOM layer. The compositor preserves authored + source order across nested SVG and Canvas layers, retains a default surface + for existing adapter callbacks, rebuilds complete interaction geometry after + focus presentation, and supports SSR, responsive updates, accessibility, and + mixed image export without importing Canvas into SVG-only bundles. +- Verification: Canvas unit coverage includes nested ordering, remounting, + focus-guide layers, and a second geometry-only pointer resolution after a + mixed focus paint. The browser Canvas gate, packed declarations and runtime, + 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. diff --git a/README.md b/README.md index d303e8ca..2c22af9b 100644 --- a/README.md +++ b/README.md @@ -90,15 +90,18 @@ const revenueChart = defineChart({ y: 'value', }), ], - x: { - scale: () => scaleBand().padding(0.2), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Revenue' }, + scales: { + x: { + scale: () => scaleBand().padding(0.2), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Revenue' }, + }, }, + tooltip, }) diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 27be02dc..f4e885f6 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": 47151, - "gzip": 17902 + "bytes": 50871, + "gzip": 19096 }, "D3-scale line + static SVG": { - "bytes": 51884, - "gzip": 19619 + "bytes": 55602, + "gzip": 20795 }, "Representative marks": { - "bytes": 71572, - "gzip": 26228 + "bytes": 75552, + "gzip": 27551 }, "TanStack DOM host": { - "bytes": 70791, - "gzip": 24832 + "bytes": 74740, + "gzip": 26000 }, "React adapter": { - "bytes": 72950, - "gzip": 25604 + "bytes": 76923, + "gzip": 26780 }, "React line consumer": { - "bytes": 96180, - "gzip": 34832 + "bytes": 100559, + "gzip": 36186 }, "Compact-scale line scene": { - "bytes": 29608, - "gzip": 10773 + "bytes": 33323, + "gzip": 11958 }, "React compact-scale line consumer": { - "bytes": 78681, - "gzip": 27762 + "bytes": 83059, + "gzip": 29082 }, "Custom-scale line scene": { - "bytes": 27792, - "gzip": 10035 + "bytes": 31505, + "gzip": 11222 }, "D3 linear-scale line scene": { - "bytes": 47083, - "gzip": 17866 + "bytes": 50803, + "gzip": 19059 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 312ce1b7..7a65c6f5 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "91e2eef1772dc06118ce5aa1e0b388d12bf91918", - "inputDigest": "sha256:07856952e86ead8b51b7517ecd2bcfd8af9fb47ff152125855b752cd51f2f94c" + "inputDigest": "sha256:08292488d4f7f01b2135f740b2047727a5711134fe05af96612f90bc73a63cee" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 105868, - "gzipBytes": 38506, - "brotliBytes": 34131, - "incrementalGzipBytes": 38506, - "incrementalBrotliBytes": 34131 + "minifiedBytes": 110224, + "gzipBytes": 39836, + "brotliBytes": 35281, + "incrementalGzipBytes": 39836, + "incrementalBrotliBytes": 35281 }, "tanstack-line-interactive": { - "minifiedBytes": 111309, - "gzipBytes": 40288, - "brotliBytes": 35520, - "incrementalGzipBytes": 40288, - "incrementalBrotliBytes": 35520 + "minifiedBytes": 115665, + "gzipBytes": 41593, + "brotliBytes": 36763, + "incrementalGzipBytes": 41593, + "incrementalBrotliBytes": 36763 }, "tanstack-line-advanced": { - "minifiedBytes": 118499, - "gzipBytes": 42603, - "brotliBytes": 37538, - "incrementalGzipBytes": 42603, - "incrementalBrotliBytes": 37538 + "minifiedBytes": 122855, + "gzipBytes": 43923, + "brotliBytes": 38701, + "incrementalGzipBytes": 43923, + "incrementalBrotliBytes": 38701 }, "tanstack-bar-basic": { - "minifiedBytes": 114536, - "gzipBytes": 41765, - "brotliBytes": 36861, - "incrementalGzipBytes": 41765, - "incrementalBrotliBytes": 36861 + "minifiedBytes": 118895, + "gzipBytes": 43111, + "brotliBytes": 38052, + "incrementalGzipBytes": 43111, + "incrementalBrotliBytes": 38052 }, "tanstack-bar-interactive": { - "minifiedBytes": 118828, - "gzipBytes": 43107, - "brotliBytes": 37909, - "incrementalGzipBytes": 43107, - "incrementalBrotliBytes": 37909 + "minifiedBytes": 123191, + "gzipBytes": 44478, + "brotliBytes": 39092, + "incrementalGzipBytes": 44478, + "incrementalBrotliBytes": 39092 }, "tanstack-bar-advanced": { - "minifiedBytes": 119167, - "gzipBytes": 43235, - "brotliBytes": 38037, - "incrementalGzipBytes": 43235, - "incrementalBrotliBytes": 38037 + "minifiedBytes": 123530, + "gzipBytes": 44634, + "brotliBytes": 39245, + "incrementalGzipBytes": 44634, + "incrementalBrotliBytes": 39245 }, "tanstack-area-basic": { - "minifiedBytes": 110952, - "gzipBytes": 40437, - "brotliBytes": 35806, - "incrementalGzipBytes": 40437, - "incrementalBrotliBytes": 35806 + "minifiedBytes": 115310, + "gzipBytes": 41751, + "brotliBytes": 36954, + "incrementalGzipBytes": 41751, + "incrementalBrotliBytes": 36954 }, "tanstack-area-interactive": { - "minifiedBytes": 116393, - "gzipBytes": 42169, - "brotliBytes": 37237, - "incrementalGzipBytes": 42169, - "incrementalBrotliBytes": 37237 + "minifiedBytes": 120755, + "gzipBytes": 43491, + "brotliBytes": 38370, + "incrementalGzipBytes": 43491, + "incrementalBrotliBytes": 38370 }, "tanstack-area-advanced": { - "minifiedBytes": 123765, - "gzipBytes": 44603, - "brotliBytes": 39264, - "incrementalGzipBytes": 44603, - "incrementalBrotliBytes": 39264 + "minifiedBytes": 128127, + "gzipBytes": 45958, + "brotliBytes": 40454, + "incrementalGzipBytes": 45958, + "incrementalBrotliBytes": 40454 }, "tanstack-scatter-basic": { - "minifiedBytes": 106907, - "gzipBytes": 38897, - "brotliBytes": 34396, - "incrementalGzipBytes": 38897, - "incrementalBrotliBytes": 34396 + "minifiedBytes": 111253, + "gzipBytes": 40212, + "brotliBytes": 35564, + "incrementalGzipBytes": 40212, + "incrementalBrotliBytes": 35564 }, "tanstack-scatter-interactive": { - "minifiedBytes": 112348, - "gzipBytes": 40640, - "brotliBytes": 35797, - "incrementalGzipBytes": 40640, - "incrementalBrotliBytes": 35797 + "minifiedBytes": 116694, + "gzipBytes": 41969, + "brotliBytes": 37033, + "incrementalGzipBytes": 41969, + "incrementalBrotliBytes": 37033 }, "tanstack-scatter-advanced": { - "minifiedBytes": 112364, - "gzipBytes": 40646, - "brotliBytes": 35846, - "incrementalGzipBytes": 40646, - "incrementalBrotliBytes": 35846 + "minifiedBytes": 116710, + "gzipBytes": 41975, + "brotliBytes": 37003, + "incrementalGzipBytes": 41975, + "incrementalBrotliBytes": 37003 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/comparison/libraries/tanstack/area.ts b/benchmarks/comparison/libraries/tanstack/area.ts index 5c324a60..d36db8f3 100644 --- a/benchmarks/comparison/libraries/tanstack/area.ts +++ b/benchmarks/comparison/libraries/tanstack/area.ts @@ -37,20 +37,23 @@ const definition = (input: BenchmarkInput) => stroke: BENCHMARK_INTERACTIVE ? undefined : color, }), ], - x: { - scale: scaleLinear().domain( - BENCHMARK_STRESS - ? [xMinimum(input), xMaximum(input)] - : [0, Math.max(1, input.rows.length - 1)], - ), - grid: true, - axis: { ticks: { count: 6 } }, - }, - y: { - scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), - grid: true, - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: scaleLinear().domain( + BENCHMARK_STRESS + ? [xMinimum(input), xMaximum(input)] + : [0, Math.max(1, input.rows.length - 1)], + ), + grid: true, + axis: { ticks: { count: 6 } }, + }, + y: { + scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), + grid: true, + axis: { ticks: { count: 5 } }, + }, }, + color: BENCHMARK_INTERACTIVE ? { scale: scaleOrdinal() diff --git a/benchmarks/comparison/libraries/tanstack/bar.ts b/benchmarks/comparison/libraries/tanstack/bar.ts index 7e0d3a54..d6cc4025 100644 --- a/benchmarks/comparison/libraries/tanstack/bar.ts +++ b/benchmarks/comparison/libraries/tanstack/bar.ts @@ -32,15 +32,18 @@ const definition = (input: BenchmarkInput) => fill: BENCHMARK_INTERACTIVE ? undefined : color, }), ], - x: { - scale: () => scaleBand().padding(0.1), - grid: true, - }, - y: { - scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), - grid: true, - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: () => scaleBand().padding(0.1), + grid: true, + }, + y: { + scale: scaleLinear().domain([0, BENCHMARK_ADVANCED ? 200 : 100]), + grid: true, + axis: { ticks: { count: 5 } }, + }, }, + color: BENCHMARK_INTERACTIVE ? { scale: scaleOrdinal() diff --git a/benchmarks/comparison/libraries/tanstack/line.ts b/benchmarks/comparison/libraries/tanstack/line.ts index b36459ce..209dd4f1 100644 --- a/benchmarks/comparison/libraries/tanstack/line.ts +++ b/benchmarks/comparison/libraries/tanstack/line.ts @@ -32,20 +32,23 @@ const definition = (input: BenchmarkInput) => }, ), ], - x: { - scale: scaleLinear().domain( - BENCHMARK_STRESS - ? [xMinimum(input), xMaximum(input)] - : [0, Math.max(1, input.rows.length - 1)], - ), - grid: true, - axis: { ticks: { count: 6 } }, - }, - y: { - scale: scaleLinear().domain([0, 100]), - grid: true, - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: scaleLinear().domain( + BENCHMARK_STRESS + ? [xMinimum(input), xMaximum(input)] + : [0, Math.max(1, input.rows.length - 1)], + ), + grid: true, + axis: { ticks: { count: 6 } }, + }, + y: { + scale: scaleLinear().domain([0, 100]), + grid: true, + axis: { ticks: { count: 5 } }, + }, }, + color: BENCHMARK_INTERACTIVE ? { scale: scaleOrdinal() diff --git a/benchmarks/comparison/libraries/tanstack/scatter.ts b/benchmarks/comparison/libraries/tanstack/scatter.ts index 8296114f..40e613b2 100644 --- a/benchmarks/comparison/libraries/tanstack/scatter.ts +++ b/benchmarks/comparison/libraries/tanstack/scatter.ts @@ -24,20 +24,23 @@ const definition = (input: BenchmarkInput) => r: BENCHMARK_ADVANCED || BENCHMARK_VARIABLE_SIZE ? 'size' : 2, }), ], - x: { - scale: scaleLinear().domain( - BENCHMARK_STRESS - ? [xMinimum(input), xMaximum(input)] - : [0, Math.max(1, input.rows.length - 1)], - ), - grid: true, - axis: { ticks: { count: 6 } }, - }, - y: { - scale: scaleLinear().domain([0, 100]), - grid: true, - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: scaleLinear().domain( + BENCHMARK_STRESS + ? [xMinimum(input), xMaximum(input)] + : [0, Math.max(1, input.rows.length - 1)], + ), + grid: true, + axis: { ticks: { count: 6 } }, + }, + y: { + scale: scaleLinear().domain([0, 100]), + grid: true, + axis: { ticks: { count: 5 } }, + }, }, + color: BENCHMARK_INTERACTIVE ? { scale: scaleOrdinal() diff --git a/benchmarks/conformance/api-friction.test.ts b/benchmarks/conformance/api-friction.test.ts index 6c19fa9e..5ba3e332 100644 --- a/benchmarks/conformance/api-friction.test.ts +++ b/benchmarks/conformance/api-friction.test.ts @@ -12,9 +12,9 @@ describe('API friction log', () => { const indexIds = [...content.matchAll(/^\| (F-\d{3}) \|/gm)].map( ([, id]) => id, ) - const findingIds = [...content.matchAll(/^### (F-\d{3}) —/gm)].map( - ([, id]) => id, - ) + const findingIds = [ + ...content.matchAll(/^### (F-\d{3}) (?:-|\u2014)/gm), + ].map(([, id]) => id) expect(new Set(indexIds).size).toBe(indexIds.length) expect(new Set(findingIds).size).toBe(findingIds.length) @@ -33,7 +33,9 @@ describe('API friction log', () => { ([, id, status]) => [id, status], ), ) - const headings = [...content.matchAll(/^### (F-\d{3}) —[^\n]*$/gm)] + const headings = [ + ...content.matchAll(/^### (F-\d{3}) (?:-|\u2014)[^\n]*$/gm), + ] const findingStatuses = new Map() headings.forEach((heading, index) => { diff --git a/benchmarks/conformance/cases/01-line-gaps/example.tsx b/benchmarks/conformance/cases/01-line-gaps/example.tsx index 7b4cb6a2..88c00a56 100644 --- a/benchmarks/conformance/cases/01-line-gaps/example.tsx +++ b/benchmarks/conformance/cases/01-line-gaps/example.tsx @@ -18,8 +18,10 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 2.25, }), ], - x: { scale: scaleUtc, axis: { label: 'Week' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Close (USD)' } }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Week' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Close (USD)' } }, + }, }, { keyboard: true, tooltip: exampleTooltip }, ) diff --git a/benchmarks/conformance/cases/02-multi-line-end-labels/example.tsx b/benchmarks/conformance/cases/02-multi-line-end-labels/example.tsx index c42891ec..e576178b 100644 --- a/benchmarks/conformance/cases/02-multi-line-end-labels/example.tsx +++ b/benchmarks/conformance/cases/02-multi-line-end-labels/example.tsx @@ -42,12 +42,15 @@ export const createExampleChart = (input: ChartOptions) => { fontWeight: 600, }), ], - x: { scale: scaleUtc, axis: { label: 'Week' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Unemployed (thousands)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Week' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Unemployed (thousands)' }, + }, }, + color: { range: colors, }, diff --git a/benchmarks/conformance/cases/03-temperature-range-band/example.tsx b/benchmarks/conformance/cases/03-temperature-range-band/example.tsx index 1c9ad000..bbc5ab6b 100644 --- a/benchmarks/conformance/cases/03-temperature-range-band/example.tsx +++ b/benchmarks/conformance/cases/03-temperature-range-band/example.tsx @@ -30,11 +30,13 @@ export const createExampleChart = () => { strokeWidth: 1.75, }), ], - x: { scale: scaleUtc, axis: { label: 'Week' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Temperature (°F)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Week' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Temperature (°F)' }, + }, }, }, { diff --git a/benchmarks/conformance/cases/04-stacked-time-area/example.tsx b/benchmarks/conformance/cases/04-stacked-time-area/example.tsx index 934dd057..bd00838f 100644 --- a/benchmarks/conformance/cases/04-stacked-time-area/example.tsx +++ b/benchmarks/conformance/cases/04-stacked-time-area/example.tsx @@ -30,12 +30,15 @@ export const createExampleChart = (input: ChartOptions) => }), ruleY([0]), ], - x: { scale: scaleUtc, axis: { label: 'Month' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Unemployed (thousands)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Month' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Unemployed (thousands)' }, + }, }, + color: { range: colors, ...(input.preview === true diff --git a/benchmarks/conformance/cases/100-radial-bars/example.tsx b/benchmarks/conformance/cases/100-radial-bars/example.tsx index e00008c0..bab14c00 100644 --- a/benchmarks/conformance/cases/100-radial-bars/example.tsx +++ b/benchmarks/conformance/cases/100-radial-bars/example.tsx @@ -19,17 +19,20 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio: 0.84, - angle: { - scale: scaleLinear().domain([0, maximumFrequency]), - }, - radius: { - scale: () => - scaleBand().paddingInner(0.38).paddingOuter(0.19), - range: [ - ({ radius }) => radius * innerRadiusRatio, - ({ radius }) => radius, - ], + scales: { + angle: { + scale: scaleLinear().domain([0, maximumFrequency]), + }, + radius: { + scale: () => + scaleBand().paddingInner(0.38).paddingOuter(0.19), + range: [ + ({ radius }) => radius * innerRadiusRatio, + ({ radius }) => radius, + ], + }, }, + marks: [ radialBarAngle(data, { id: 'letter-bars', @@ -43,6 +46,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/101-sunburst/example.tsx b/benchmarks/conformance/cases/101-sunburst/example.tsx index 9ce5bcb5..88c60e77 100644 --- a/benchmarks/conformance/cases/101-sunburst/example.tsx +++ b/benchmarks/conformance/cases/101-sunburst/example.tsx @@ -34,8 +34,16 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 2, }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: ['#7c3aed', '#0ea5e9', '#14b8a6'], }, diff --git a/benchmarks/conformance/cases/102-world-choropleth/example.tsx b/benchmarks/conformance/cases/102-world-choropleth/example.tsx index 1ea3e9cb..5d01fa7f 100644 --- a/benchmarks/conformance/cases/102-world-choropleth/example.tsx +++ b/benchmarks/conformance/cases/102-world-choropleth/example.tsx @@ -58,6 +58,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, color: { scale: scaleThreshold, domain: thresholds, diff --git a/benchmarks/conformance/cases/103-bubble-map/example.tsx b/benchmarks/conformance/cases/103-bubble-map/example.tsx index 734e45e5..1fdfbd55 100644 --- a/benchmarks/conformance/cases/103-bubble-map/example.tsx +++ b/benchmarks/conformance/cases/103-bubble-map/example.tsx @@ -51,6 +51,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, margin: 10, }, { diff --git a/benchmarks/conformance/cases/104-orthographic-globe/example.tsx b/benchmarks/conformance/cases/104-orthographic-globe/example.tsx index b032bba8..7f0569a4 100644 --- a/benchmarks/conformance/cases/104-orthographic-globe/example.tsx +++ b/benchmarks/conformance/cases/104-orthographic-globe/example.tsx @@ -52,6 +52,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, margin: 10, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/105-route-map/example.tsx b/benchmarks/conformance/cases/105-route-map/example.tsx index 4222b673..7c83c322 100644 --- a/benchmarks/conformance/cases/105-route-map/example.tsx +++ b/benchmarks/conformance/cases/105-route-map/example.tsx @@ -55,6 +55,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, margin: 10, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/106-polar-line/example.tsx b/benchmarks/conformance/cases/106-polar-line/example.tsx index 87026ee8..5386a7f9 100644 --- a/benchmarks/conformance/cases/106-polar-line/example.tsx +++ b/benchmarks/conformance/cases/106-polar-line/example.tsx @@ -26,8 +26,11 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio: 0.72, - angle: { scale: scaleLinear().domain(angleDomain) }, - radius: { scale: scaleLinear().domain(radiusDomain) }, + scales: { + angle: { scale: scaleLinear().domain(angleDomain) }, + radius: { scale: scaleLinear().domain(radiusDomain) }, + }, + guides: [ radialGrid({ values: radiusGridValues, @@ -52,6 +55,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { diff --git a/benchmarks/conformance/cases/107-polar-scatter/example.tsx b/benchmarks/conformance/cases/107-polar-scatter/example.tsx index 3799b298..23ff76de 100644 --- a/benchmarks/conformance/cases/107-polar-scatter/example.tsx +++ b/benchmarks/conformance/cases/107-polar-scatter/example.tsx @@ -21,8 +21,11 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio: 0.72, - angle: { scale: scaleLinear().domain(angleDomain) }, - radius: { scale: scaleLinear().domain(radiusDomain) }, + scales: { + angle: { scale: scaleLinear().domain(angleDomain) }, + radius: { scale: scaleLinear().domain(radiusDomain) }, + }, + guides: [ radialGrid({ values: radiusGridValues, @@ -49,6 +52,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { diff --git a/benchmarks/conformance/cases/108-country-choropleth/example.tsx b/benchmarks/conformance/cases/108-country-choropleth/example.tsx index 649596b8..4ccb4067 100644 --- a/benchmarks/conformance/cases/108-country-choropleth/example.tsx +++ b/benchmarks/conformance/cases/108-country-choropleth/example.tsx @@ -58,6 +58,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, color: { scale: scaleQuantize, range: colorRanges[input.revision % 2] ?? colorRanges[0], diff --git a/benchmarks/conformance/cases/109-us-state-choropleth/example.tsx b/benchmarks/conformance/cases/109-us-state-choropleth/example.tsx index 8bd16c0e..b628ba4d 100644 --- a/benchmarks/conformance/cases/109-us-state-choropleth/example.tsx +++ b/benchmarks/conformance/cases/109-us-state-choropleth/example.tsx @@ -49,6 +49,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 0.35, }), ], + scales: { + x: null, + y: null, + }, color: { scale: scaleQuantile, range: colorRanges[input.revision % 2] ?? colorRanges[0], diff --git a/benchmarks/conformance/cases/110-projection-gallery/example.tsx b/benchmarks/conformance/cases/110-projection-gallery/example.tsx index 61828e43..2b962857 100644 --- a/benchmarks/conformance/cases/110-projection-gallery/example.tsx +++ b/benchmarks/conformance/cases/110-projection-gallery/example.tsx @@ -58,6 +58,10 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 0.45, }), ], + scales: { + x: null, + y: null, + }, color, guides: false, margin: 0, diff --git a/benchmarks/conformance/cases/111-basic-sankey/example.tsx b/benchmarks/conformance/cases/111-basic-sankey/example.tsx index 55985438..008e2e0f 100644 --- a/benchmarks/conformance/cases/111-basic-sankey/example.tsx +++ b/benchmarks/conformance/cases/111-basic-sankey/example.tsx @@ -103,6 +103,10 @@ export const createExampleChart = (input: ChartOptions) => { }, }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }, diff --git a/benchmarks/conformance/cases/111-sankey-flow/example.tsx b/benchmarks/conformance/cases/111-sankey-flow/example.tsx index f2cda781..77e8ff55 100644 --- a/benchmarks/conformance/cases/111-sankey-flow/example.tsx +++ b/benchmarks/conformance/cases/111-sankey-flow/example.tsx @@ -206,6 +206,10 @@ export const createExampleChart = (input: ChartOptions) => { }, }), ], + scales: { + x: null, + y: null, + }, color: { domain: toneDomain, range: toneDomain.map((tone) => toneColors[tone]), diff --git a/benchmarks/conformance/cases/112-motion-entrance/example.tsx b/benchmarks/conformance/cases/112-motion-entrance/example.tsx index 018ebf20..10305b2e 100644 --- a/benchmarks/conformance/cases/112-motion-entrance/example.tsx +++ b/benchmarks/conformance/cases/112-motion-entrance/example.tsx @@ -59,8 +59,11 @@ export function motionEntranceDefinition(settings: MotionSettings) { : undefined, }), ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: { top: 20, right: 20, bottom: 20, left: 20 }, }) diff --git a/benchmarks/conformance/cases/113-motion-updates/example.tsx b/benchmarks/conformance/cases/113-motion-updates/example.tsx index 30100d7b..a7c30f08 100644 --- a/benchmarks/conformance/cases/113-motion-updates/example.tsx +++ b/benchmarks/conformance/cases/113-motion-updates/example.tsx @@ -122,8 +122,11 @@ export function motionUpdatesDefinition( }, }), ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: { top: 20, right: 20, bottom: 20, left: 20 }, maxFocusDistance: 28, diff --git a/benchmarks/conformance/cases/114-spring-line-motion/example.tsx b/benchmarks/conformance/cases/114-spring-line-motion/example.tsx index 53e1ffdf..0b9cdcbf 100644 --- a/benchmarks/conformance/cases/114-spring-line-motion/example.tsx +++ b/benchmarks/conformance/cases/114-spring-line-motion/example.tsx @@ -56,8 +56,11 @@ export function springLineMotionDefinition( }, }), ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: { top: 24, right: 24, bottom: 24, left: 24 }, }) diff --git a/benchmarks/conformance/cases/115-definition-motion/example.tsx b/benchmarks/conformance/cases/115-definition-motion/example.tsx index e1ffb02c..ae1ed387 100644 --- a/benchmarks/conformance/cases/115-definition-motion/example.tsx +++ b/benchmarks/conformance/cases/115-definition-motion/example.tsx @@ -63,32 +63,35 @@ export function definitionMotionDefinition( }, }), ], - x: { - scale: scaleBand().domain(rows.map((row) => row.period)), - axis: { - motion: guideMotion, - ticks: { motion: guideMotion }, - tickLabels: { - motion(context) { - return { - delay: context.datumIndex * 18, - transition: { type: 'tween', duration: 220 }, - } + scales: { + x: { + scale: scaleBand().domain(rows.map((row) => row.period)), + axis: { + motion: guideMotion, + ticks: { motion: guideMotion }, + tickLabels: { + motion(context) { + return { + delay: context.datumIndex * 18, + transition: { type: 'tween', duration: 220 }, + } + }, }, + label: { text: 'Period', motion: guideMotion }, }, - label: { text: 'Period', motion: guideMotion }, }, - }, - y: { - scale: scaleLinear().domain([0, yMaximum]), - grid: true, - axis: { - motion: guideMotion, - ticks: { motion: guideMotion }, - tickLabels: { motion: guideMotion }, - label: { text: 'Value', motion: guideMotion }, + y: { + scale: scaleLinear().domain([0, yMaximum]), + grid: true, + axis: { + motion: guideMotion, + ticks: { motion: guideMotion }, + tickLabels: { motion: guideMotion }, + label: { text: 'Value', motion: guideMotion }, + }, }, }, + margin: preview ? { top: 12, right: 4, bottom: 40, left: 46 } : { top: 20, right: 24 }, diff --git a/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts b/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts index fd95b074..918b07f6 100644 --- a/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts +++ b/benchmarks/conformance/cases/115-definition-motion/tanstack.test.ts @@ -104,8 +104,8 @@ describe('declarative definition-owned motion', () => { }, }) - const xAxis = requiredAxis(definition.x) - const yAxis = requiredAxis(definition.y) + const xAxis = requiredAxis(definition.scales.x) + const yAxis = requiredAxis(definition.scales.y) const xTicks = requiredTicks(xAxis.ticks) const xTickLabels = requiredTickLabels(xAxis.tickLabels) const xLabel = requiredLabel(xAxis.label) diff --git a/benchmarks/conformance/cases/116-geometry-morph/example.tsx b/benchmarks/conformance/cases/116-geometry-morph/example.tsx index 9e355b81..3bbeb1df 100644 --- a/benchmarks/conformance/cases/116-geometry-morph/example.tsx +++ b/benchmarks/conformance/cases/116-geometry-morph/example.tsx @@ -55,8 +55,11 @@ export function geometryMorphDefinition( }, }), ], - x: { scale: scaleLinear().domain([0, data.length - 1]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, data.length - 1]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: 0, }) diff --git a/benchmarks/conformance/cases/117-focus-cursor-motion/example.tsx b/benchmarks/conformance/cases/117-focus-cursor-motion/example.tsx index b30d621f..8be4b882 100644 --- a/benchmarks/conformance/cases/117-focus-cursor-motion/example.tsx +++ b/benchmarks/conformance/cases/117-focus-cursor-motion/example.tsx @@ -117,10 +117,13 @@ export function focusCursorMotionDefinition() { }, }), ], - x: { - scale: scaleBand().domain(focusMotionPeriods).padding(0.1), + scales: { + x: { + scale: scaleBand().domain(focusMotionPeriods).padding(0.1), + }, + y: { scale: scaleLinear().domain([20, 90]), grid: true }, }, - y: { scale: scaleLinear().domain([20, 90]), grid: true }, + color: { domain: focusMotionSeries, range: colors }, focus: focusGroupX, focusRing: false, diff --git a/benchmarks/conformance/cases/118-token-usage-calendar/example.tsx b/benchmarks/conformance/cases/118-token-usage-calendar/example.tsx index 1d23c82c..3aa75f50 100644 --- a/benchmarks/conformance/cases/118-token-usage-calendar/example.tsx +++ b/benchmarks/conformance/cases/118-token-usage-calendar/example.tsx @@ -62,36 +62,39 @@ export function createExampleChart(input: ChartOptions) { radius: 3, }), ], - x: { - scale: scaleBand() - .domain(weekDomain) - .paddingInner(calendarBandPaddingInner) - .paddingOuter(calendarBandPaddingOuter), - axis: { - line: false, - ticks: { - values: monthTicks.values, - size: 0, - padding: input.preview ? 3 : 7, - format: (week: number) => monthTicks.labels.get(week) ?? '', - }, - tickLabels: { - fontSize: input.preview ? 8 : 13, - opacity: 0.62, - anchor: ({ index }) => (index === 0 ? 'start' : undefined), - dx: ({ index, bandwidth }) => - index === 0 ? -bandwidth / 2 : undefined, - thin: { minGap: input.preview ? 2 : 8, priority: 'ends' }, + scales: { + x: { + scale: scaleBand() + .domain(weekDomain) + .paddingInner(calendarBandPaddingInner) + .paddingOuter(calendarBandPaddingOuter), + axis: { + line: false, + ticks: { + values: monthTicks.values, + size: 0, + padding: input.preview ? 3 : 7, + format: (week: number) => monthTicks.labels.get(week) ?? '', + }, + tickLabels: { + fontSize: input.preview ? 8 : 13, + opacity: 0.62, + anchor: ({ index }) => (index === 0 ? 'start' : undefined), + dx: ({ index, bandwidth }) => + index === 0 ? -bandwidth / 2 : undefined, + thin: { minGap: input.preview ? 2 : 8, priority: 'ends' }, + }, }, }, + y: { + scale: scaleBand() + .domain(weekdays) + .paddingInner(calendarBandPaddingInner) + .paddingOuter(calendarBandPaddingOuter), + axis: false, + }, }, - y: { - scale: scaleBand() - .domain(weekdays) - .paddingInner(calendarBandPaddingInner) - .paddingOuter(calendarBandPaddingOuter), - axis: false, - }, + color: { scale: scaleOrdinal() .domain(usageLevels) diff --git a/benchmarks/conformance/cases/119-stacked-bar-band-cursor/example.tsx b/benchmarks/conformance/cases/119-stacked-bar-band-cursor/example.tsx index 8ceb5d3a..bcc1d610 100644 --- a/benchmarks/conformance/cases/119-stacked-bar-band-cursor/example.tsx +++ b/benchmarks/conformance/cases/119-stacked-bar-band-cursor/example.tsx @@ -97,14 +97,17 @@ export const createExampleChart = (rows: readonly StackedCursorRow[]) => motion: { transition: cursorTransition }, }), ], - x: { - scale: scaleBand().domain(stackedCursorPeriods).padding(0.18), - }, - y: { - scale: scaleLinear().domain([0, stackedCursorMaximum]), - grid: true, - axis: { ticks: { count: 5 }, label: 'Deaths' }, + scales: { + x: { + scale: scaleBand().domain(stackedCursorPeriods).padding(0.18), + }, + y: { + scale: scaleLinear().domain([0, stackedCursorMaximum]), + grid: true, + axis: { ticks: { count: 5 }, label: 'Deaths' }, + }, }, + color: { domain: stackedCursorCauses, range: stackedCursorColors, diff --git a/benchmarks/conformance/cases/120-themed-interactive-area/example.tsx b/benchmarks/conformance/cases/120-themed-interactive-area/example.tsx index f7fabb1e..300eca29 100644 --- a/benchmarks/conformance/cases/120-themed-interactive-area/example.tsx +++ b/benchmarks/conformance/cases/120-themed-interactive-area/example.tsx @@ -131,35 +131,38 @@ export function createExampleChart( motion: { transition: themedAreaSpring }, }), ], - x: { - scale: scaleUtc().domain([first.date, last.date]), - axis: { - line: false, - ticks: { - values: dateTicks, - size: 0, - padding: preview ? 5 : 8, - format: formatThemedAreaTick, - }, - tickLabels: { - fontSize: preview ? 8 : 10, - opacity: 0.62, - thin: { minGap: preview ? 5 : 12, priority: 'ends' }, + scales: { + x: { + scale: scaleUtc().domain([first.date, last.date]), + axis: { + line: false, + ticks: { + values: dateTicks, + size: 0, + padding: preview ? 5 : 8, + format: formatThemedAreaTick, + }, + tickLabels: { + fontSize: preview ? 8 : 10, + opacity: 0.62, + thin: { minGap: preview ? 5 : 12, priority: 'ends' }, + }, }, }, - }, - y: { - scale: scaleLinear().domain([0, yMaximum]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, yMaximum / 3, (yMaximum * 2) / 3, yMaximum], - size: 0, + y: { + scale: scaleLinear().domain([0, yMaximum]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, yMaximum / 3, (yMaximum * 2) / 3, yMaximum], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + gradients: [ { id: 'themed-area-fill', diff --git a/benchmarks/conformance/cases/120-themed-interactive-area/tanstack.test.ts b/benchmarks/conformance/cases/120-themed-interactive-area/tanstack.test.ts index 155497ac..50734125 100644 --- a/benchmarks/conformance/cases/120-themed-interactive-area/tanstack.test.ts +++ b/benchmarks/conformance/cases/120-themed-interactive-area/tanstack.test.ts @@ -41,14 +41,14 @@ describe('themed interactive area', () => { expect(definition.motion).toMatchObject({ transition: { type: 'spring', stiffness: 190, damping: 24 }, }) - expect(definition.x?.axis).toMatchObject({ + expect(definition.scales.x?.axis).toMatchObject({ line: false, ticks: { values: expect.any(Array), size: 0 }, }) - expect(definition.x?.axis).toMatchObject({ + expect(definition.scales.x?.axis).toMatchObject({ ticks: { values: expect.objectContaining({ length: 3 }) }, }) - expect(definition.y?.axis).toMatchObject({ + expect(definition.scales.y?.axis).toMatchObject({ line: false, ticks: { values: expect.arrayContaining([0]), size: 0 }, tickLabels: false, diff --git a/benchmarks/conformance/cases/121-active-bar-dashboard/example.tsx b/benchmarks/conformance/cases/121-active-bar-dashboard/example.tsx index dd0938e1..c2b2da59 100644 --- a/benchmarks/conformance/cases/121-active-bar-dashboard/example.tsx +++ b/benchmarks/conformance/cases/121-active-bar-dashboard/example.tsx @@ -107,30 +107,34 @@ export function activeBarDashboardDefinition( motion: { transition: dashboardSpring }, }), ], - x: { - scale: scaleBand() - .domain(rows.map((row) => row.id)) - .paddingInner(0.18) - .paddingOuter(0.08), - axis: { - line: false, - ticks: { - values: tickIds, - size: 0, - padding: input.preview ? 4 : 8, - format: (value) => rows.find((row) => row.id === value)?.day ?? '', - }, - tickLabels: { - fontSize: input.preview ? 8 : 10, - opacity: 0.52, + scales: { + x: { + scale: scaleBand() + .domain(rows.map((row) => row.id)) + .paddingInner(0.18) + .paddingOuter(0.08), + axis: { + line: false, + ticks: { + values: tickIds, + size: 0, + padding: input.preview ? 4 : 8, + format: (value) => + rows.find((row) => row.id === value)?.day ?? '', + }, + tickLabels: { + fontSize: input.preview ? 8 : 10, + opacity: 0.52, + }, }, }, + y: { + scale: scaleLinear().domain([0, Math.ceil(maximum * 1.12)]), + grid: true, + axis: false, + }, }, - y: { - scale: scaleLinear().domain([0, Math.ceil(maximum * 1.12)]), - grid: true, - axis: false, - }, + gradients: [ { id: 'visitor-bars', diff --git a/benchmarks/conformance/cases/122-premium-kpi-sparklines/example.tsx b/benchmarks/conformance/cases/122-premium-kpi-sparklines/example.tsx index 2b62fd2f..a691a70d 100644 --- a/benchmarks/conformance/cases/122-premium-kpi-sparklines/example.tsx +++ b/benchmarks/conformance/cases/122-premium-kpi-sparklines/example.tsx @@ -308,16 +308,19 @@ export function premiumKpiDefinition(metric: PremiumKpiMetric) { }, ] : [], - x: { - scale: scalePoint() - .domain(metric.rows.map((row) => row.period)) - .padding(0.08), - axis: false, - }, - y: { - scale: scaleLinear().domain([baseline, maximum + padding]), - axis: false, + scales: { + x: { + scale: scalePoint() + .domain(metric.rows.map((row) => row.period)) + .padding(0.08), + axis: false, + }, + y: { + scale: scaleLinear().domain([baseline, maximum + padding]), + axis: false, + }, }, + margin: { top: 4, right: 3, bottom: 3, left: 3 }, clip: true, motion: { transition: premiumKpiSpring }, diff --git a/benchmarks/conformance/cases/123-active-donut-metric/example.tsx b/benchmarks/conformance/cases/123-active-donut-metric/example.tsx index ecf04a2e..d2ed6b91 100644 --- a/benchmarks/conformance/cases/123-active-donut-metric/example.tsx +++ b/benchmarks/conformance/cases/123-active-donut-metric/example.tsx @@ -91,6 +91,10 @@ export function activeDonutDefinition( motion: { transition: donutSpring }, }), ], + scales: { + angle: null, + radius: null, + }, }), decorative( polar({ @@ -121,14 +125,21 @@ export function activeDonutDefinition( }, }), ], + scales: { + angle: null, + radius: null, + }, }), ), decorative( polar({ id: 'donut-center', radiusRatio: 0.8, - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialText(centerRows.slice(0, 1), { id: 'donut-center-value', @@ -158,6 +169,10 @@ export function activeDonutDefinition( }), ), ], + scales: { + x: null, + y: null, + }, color: { domain: rows.map((row) => row.id), range: [ diff --git a/benchmarks/conformance/cases/124-theme-palette-matrix/example.tsx b/benchmarks/conformance/cases/124-theme-palette-matrix/example.tsx index fbdbead9..328a4cfa 100644 --- a/benchmarks/conformance/cases/124-theme-palette-matrix/example.tsx +++ b/benchmarks/conformance/cases/124-theme-palette-matrix/example.tsx @@ -91,13 +91,16 @@ export function paletteMatrixDefinition( motion: { transition: { type: 'spring', mass: 1.15 } }, }), ], - x: { - scale: () => - scalePoint() - .domain(rows.map((row) => row.period)) - .padding(0.12), + scales: { + x: { + scale: () => + scalePoint() + .domain(rows.map((row) => row.period)) + .padding(0.12), + }, + y: { scale: scaleLinear().domain([20, 100]) }, }, - y: { scale: scaleLinear().domain([20, 100]) }, + guides: false, margin: preview ? 5 : { top: 12, right: 14, bottom: 10, left: 14 }, gradients: [ diff --git a/benchmarks/conformance/cases/125-sales-funnel/example.tsx b/benchmarks/conformance/cases/125-sales-funnel/example.tsx index c7dfa770..117db1a0 100644 --- a/benchmarks/conformance/cases/125-sales-funnel/example.tsx +++ b/benchmarks/conformance/cases/125-sales-funnel/example.tsx @@ -37,8 +37,11 @@ export const createExampleChart = (input: ChartOptions) => { fontWeight: 600, }), ], - x: { scale: scaleLinear().domain(layout.xDomain), axis: false }, - y: { scale: scaleLinear().domain(layout.yDomain), axis: false }, + scales: { + x: { scale: scaleLinear().domain(layout.xDomain), axis: false }, + y: { scale: scaleLinear().domain(layout.yDomain), axis: false }, + }, + color: { domain: stages.map((stage) => stage.id), range: colors }, margin: 12, }, diff --git a/benchmarks/conformance/cases/126-drillable-sunburst/example.tsx b/benchmarks/conformance/cases/126-drillable-sunburst/example.tsx index c3e53026..50b354d0 100644 --- a/benchmarks/conformance/cases/126-drillable-sunburst/example.tsx +++ b/benchmarks/conformance/cases/126-drillable-sunburst/example.tsx @@ -75,8 +75,16 @@ export function drillableSunburstDefinition(rootId: string) { }, }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, motion: { transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, }, diff --git a/benchmarks/conformance/cases/127-shadcn-dashboard/example.tsx b/benchmarks/conformance/cases/127-shadcn-dashboard/example.tsx index 0107c230..33cd5585 100644 --- a/benchmarks/conformance/cases/127-shadcn-dashboard/example.tsx +++ b/benchmarks/conformance/cases/127-shadcn-dashboard/example.tsx @@ -69,36 +69,39 @@ export function createExampleChart( strokeWidth: 1, }), ], - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { - values: tickValues, - size: 0, - padding: 8, - format: formatDashboardDate, - }, - tickLabels: { - fontSize: 12, - opacity: 0.72, - dy: 5, - anchor: ({ value }) => - value === data.at(-1)?.date ? 'end' : 'middle', - dx: ({ value }) => (value === data.at(-1)?.date ? 5 : 0), - thin: { minGap: 32, priority: 'ends' }, + scales: { + x: { + scale: scalePoint, + axis: { + line: false, + ticks: { + values: tickValues, + size: 0, + padding: 8, + format: formatDashboardDate, + }, + tickLabels: { + fontSize: 12, + opacity: 0.72, + dy: 5, + anchor: ({ value }) => + value === data.at(-1)?.date ? 'end' : 'middle', + dx: ({ value }) => (value === data.at(-1)?.date ? 5 : 0), + thin: { minGap: 32, priority: 'ends' }, + }, }, }, - }, - y: { - scale: scaleLinear().domain([0, 1_200]), - grid: true, - axis: { - line: false, - ticks: { values: [0, 300, 600, 900, 1_200], size: 0 }, - tickLabels: false, + y: { + scale: scaleLinear().domain([0, 1_200]), + grid: true, + axis: { + line: false, + ticks: { values: [0, 300, 600, 900, 1_200], size: 0 }, + tickLabels: false, + }, }, }, + color: { domain: seriesOrder, range: [primaryColor, primaryColor] }, gradients: [ { diff --git a/benchmarks/conformance/cases/128-shadcn-bar-multiple/example.tsx b/benchmarks/conformance/cases/128-shadcn-bar-multiple/example.tsx index 13214666..8c55a29f 100644 --- a/benchmarks/conformance/cases/128-shadcn-bar-multiple/example.tsx +++ b/benchmarks/conformance/cases/128-shadcn-bar-multiple/example.tsx @@ -28,8 +28,11 @@ export function createExampleChart() { radius: 4, }), ], - x: shadcnXAxis(), - y: { scale: scaleLinear, grid: true, axis: false }, + scales: { + x: shadcnXAxis(), + y: { scale: scaleLinear, grid: true, axis: false }, + }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: { top: 5, right: 5, bottom: 35, left: 5 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/129-shadcn-pie-donut-text/example.tsx b/benchmarks/conformance/cases/129-shadcn-pie-donut-text/example.tsx index 950f5946..668e4f98 100644 --- a/benchmarks/conformance/cases/129-shadcn-pie-donut-text/example.tsx +++ b/benchmarks/conformance/cases/129-shadcn-pie-donut-text/example.tsx @@ -56,11 +56,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/13-interval-timeline/example.tsx b/benchmarks/conformance/cases/13-interval-timeline/example.tsx index 65e9fc2c..329fdb58 100644 --- a/benchmarks/conformance/cases/13-interval-timeline/example.tsx +++ b/benchmarks/conformance/cases/13-interval-timeline/example.tsx @@ -27,11 +27,18 @@ export const createExampleChart = (input: ChartOptions) => { radius: 3, }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Share price ($)' } }, - y: { - scale: () => scaleBand().paddingInner(0.16), - axis: { ticks: { format: (value) => date.format(value) } }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Share price ($)' }, + }, + y: { + scale: () => scaleBand().paddingInner(0.16), + axis: { ticks: { format: (value) => date.format(value) } }, + }, }, + color: { range: colors, legend: colorLegend({ label: 'Session' }), diff --git a/benchmarks/conformance/cases/130-shadcn-radar-multiple/example.tsx b/benchmarks/conformance/cases/130-shadcn-radar-multiple/example.tsx index 785f6e3e..4ca4c4e7 100644 --- a/benchmarks/conformance/cases/130-shadcn-radar-multiple/example.tsx +++ b/benchmarks/conformance/cases/130-shadcn-radar-multiple/example.tsx @@ -44,8 +44,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -78,6 +81,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/131-shadcn-radial-text/example.tsx b/benchmarks/conformance/cases/131-shadcn-radial-text/example.tsx index e5d2fbba..0d3de859 100644 --- a/benchmarks/conformance/cases/131-shadcn-radial-text/example.tsx +++ b/benchmarks/conformance/cases/131-shadcn-radial-text/example.tsx @@ -29,15 +29,22 @@ export function createExampleChart() { fill: 'var(--muted)', }), ], + scales: { + angle: null, + radius: null, + }, }), polar({ startAngle: rechartsPolarAngle(0), endAngle: rechartsPolarAngle(250), - angle: { scale: scaleLinear().domain([0, 200]) }, - radius: { - scale: scaleBand().domain(['visitors']), - range: [80, 90], + scales: { + angle: { scale: scaleLinear().domain([0, 200]) }, + radius: { + scale: scaleBand().domain(['visitors']), + range: [80, 90], + }, }, + marks: [ radialBarAngle(rows, { id: `${'text'}-value`, @@ -51,6 +58,10 @@ export function createExampleChart() { }), radialCenterLabels('200', 36, 0, 24), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { @@ -75,8 +86,11 @@ function radialCenterLabels( labelDy: number, ) { return polar({ - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialText([{ id: 'total', angle: 0, radius: 0, text: total }], { id: `radial-total-${total}`, diff --git a/benchmarks/conformance/cases/132-shadcn-tooltip-advanced/example.tsx b/benchmarks/conformance/cases/132-shadcn-tooltip-advanced/example.tsx index 1e51c05c..f8e0dd86 100644 --- a/benchmarks/conformance/cases/132-shadcn-tooltip-advanced/example.tsx +++ b/benchmarks/conformance/cases/132-shadcn-tooltip-advanced/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/133-shadcn-area-axes/example.tsx b/benchmarks/conformance/cases/133-shadcn-area-axes/example.tsx index 8718aee3..c96a6bc0 100644 --- a/benchmarks/conformance/cases/133-shadcn-area-axes/example.tsx +++ b/benchmarks/conformance/cases/133-shadcn-area-axes/example.tsx @@ -41,12 +41,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 600]), - grid: true, - axis: { line: false, ticks: { size: 0, values: [0, 300, 600] } }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 600]), + grid: true, + axis: { line: false, ticks: { size: 0, values: [0, 300, 600] } }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/134-shadcn-area-default/example.tsx b/benchmarks/conformance/cases/134-shadcn-area-default/example.tsx index 8f0b4e75..5c3ee674 100644 --- a/benchmarks/conformance/cases/134-shadcn-area-default/example.tsx +++ b/benchmarks/conformance/cases/134-shadcn-area-default/example.tsx @@ -28,12 +28,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/135-shadcn-area-gradient/example.tsx b/benchmarks/conformance/cases/135-shadcn-area-gradient/example.tsx index 5a633cae..805d631c 100644 --- a/benchmarks/conformance/cases/135-shadcn-area-gradient/example.tsx +++ b/benchmarks/conformance/cases/135-shadcn-area-gradient/example.tsx @@ -42,12 +42,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/136-shadcn-area-icons/example.tsx b/benchmarks/conformance/cases/136-shadcn-area-icons/example.tsx index de079524..d2317441 100644 --- a/benchmarks/conformance/cases/136-shadcn-area-icons/example.tsx +++ b/benchmarks/conformance/cases/136-shadcn-area-icons/example.tsx @@ -41,12 +41,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/137-shadcn-area-interactive/example.tsx b/benchmarks/conformance/cases/137-shadcn-area-interactive/example.tsx index f4101280..6f6941b5 100644 --- a/benchmarks/conformance/cases/137-shadcn-area-interactive/example.tsx +++ b/benchmarks/conformance/cases/137-shadcn-area-interactive/example.tsx @@ -49,27 +49,30 @@ export function createExampleChart(timeRange: InteractiveTimeRange = '90d') { strokeWidth: 1, }), ], - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { - values: interactiveDateTicks(timeRange), - size: 0, - padding: 10, - format: formatMonthDay, + scales: { + x: { + scale: scalePoint, + axis: { + line: false, + ticks: { + values: interactiveDateTicks(timeRange), + size: 0, + padding: 10, + format: formatMonthDay, + }, }, }, - }, - y: { - scale: scaleLinear().domain([0, 1200]), - grid: true, - axis: { - line: false, - ticks: { values: [0, 300, 600, 900, 1200], size: 0 }, - tickLabels: false, + y: { + scale: scaleLinear().domain([0, 1200]), + grid: true, + axis: { + line: false, + ticks: { values: [0, 300, 600, 900, 1200], size: 0 }, + tickLabels: false, + }, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, gradients: twoSeries.map((series, index) => ({ id: `shadcn-interactive-${series}`, diff --git a/benchmarks/conformance/cases/138-shadcn-area-legend/example.tsx b/benchmarks/conformance/cases/138-shadcn-area-legend/example.tsx index 1ea23914..834c2157 100644 --- a/benchmarks/conformance/cases/138-shadcn-area-legend/example.tsx +++ b/benchmarks/conformance/cases/138-shadcn-area-legend/example.tsx @@ -41,12 +41,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/139-shadcn-area-linear/example.tsx b/benchmarks/conformance/cases/139-shadcn-area-linear/example.tsx index 46b279e2..f99d71f0 100644 --- a/benchmarks/conformance/cases/139-shadcn-area-linear/example.tsx +++ b/benchmarks/conformance/cases/139-shadcn-area-linear/example.tsx @@ -28,12 +28,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/14-error-bars/example.tsx b/benchmarks/conformance/cases/14-error-bars/example.tsx index e8f78d27..31398d36 100644 --- a/benchmarks/conformance/cases/14-error-bars/example.tsx +++ b/benchmarks/conformance/cases/14-error-bars/example.tsx @@ -72,10 +72,12 @@ export const createExampleChart = (input: ChartOptions) => { r: 3.5, }), ], - x: { - scale: () => scaleBand().padding(0.22), + scales: { + x: { + scale: () => scaleBand().padding(0.22), + }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, }, { keyboard: true, diff --git a/benchmarks/conformance/cases/140-shadcn-area-stacked-expand/example.tsx b/benchmarks/conformance/cases/140-shadcn-area-stacked-expand/example.tsx index 4cd2fc7e..3735efe7 100644 --- a/benchmarks/conformance/cases/140-shadcn-area-stacked-expand/example.tsx +++ b/benchmarks/conformance/cases/140-shadcn-area-stacked-expand/example.tsx @@ -57,12 +57,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 1]), - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 1]), + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: ['other', 'mobile', 'desktop'], range: [shadcnColors[2], shadcnColors[1], shadcnColors[0]], diff --git a/benchmarks/conformance/cases/141-shadcn-area-stacked/example.tsx b/benchmarks/conformance/cases/141-shadcn-area-stacked/example.tsx index 15e870cd..73147cff 100644 --- a/benchmarks/conformance/cases/141-shadcn-area-stacked/example.tsx +++ b/benchmarks/conformance/cases/141-shadcn-area-stacked/example.tsx @@ -41,12 +41,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/142-shadcn-area-step/example.tsx b/benchmarks/conformance/cases/142-shadcn-area-step/example.tsx index 52ab5e60..0db037c6 100644 --- a/benchmarks/conformance/cases/142-shadcn-area-step/example.tsx +++ b/benchmarks/conformance/cases/142-shadcn-area-step/example.tsx @@ -28,12 +28,15 @@ export function createExampleChart() { strokeWidth: 1.5, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear, - grid: true, - axis: { line: false, ticks: false, tickLabels: false }, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear, + grid: true, + axis: { line: false, ticks: false, tickLabels: false }, + }, }, + color: { domain: monthSeries, range: shadcnColors.slice(0, 3), diff --git a/benchmarks/conformance/cases/143-shadcn-bar-active/example.tsx b/benchmarks/conformance/cases/143-shadcn-bar-active/example.tsx index 389c15b9..790294d8 100644 --- a/benchmarks/conformance/cases/143-shadcn-bar-active/example.tsx +++ b/benchmarks/conformance/cases/143-shadcn-bar-active/example.tsx @@ -34,8 +34,11 @@ export function createExampleChart() { radius: 8, }), ], - x: shadcnBrowserXAxis(), - y: { scale: scaleLinear, grid: true, axis: false }, + scales: { + x: shadcnBrowserXAxis(), + y: { scale: scaleLinear, grid: true, axis: false }, + }, + color: { domain: browserNames, range: shadcnColors }, margin: { top: 5, right: 5, bottom: 35, left: 5 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/144-shadcn-bar-default/example.tsx b/benchmarks/conformance/cases/144-shadcn-bar-default/example.tsx index e78115fb..f1576628 100644 --- a/benchmarks/conformance/cases/144-shadcn-bar-default/example.tsx +++ b/benchmarks/conformance/cases/144-shadcn-bar-default/example.tsx @@ -20,8 +20,11 @@ export function createExampleChart() { return defineChart( { marks, - x: shadcnXAxis(), - y: { scale: scaleLinear, grid: true, axis: false }, + scales: { + x: shadcnXAxis(), + y: { scale: scaleLinear, grid: true, axis: false }, + }, + margin: { top: 5, right: 5, bottom: 35, left: 5 }, theme: shadcnTheme(), }, diff --git a/benchmarks/conformance/cases/145-shadcn-bar-horizontal/example.tsx b/benchmarks/conformance/cases/145-shadcn-bar-horizontal/example.tsx index d8099aed..af513643 100644 --- a/benchmarks/conformance/cases/145-shadcn-bar-horizontal/example.tsx +++ b/benchmarks/conformance/cases/145-shadcn-bar-horizontal/example.tsx @@ -19,11 +19,15 @@ export function createExampleChart() { return defineChart( { marks, - x: { scale: scaleLinear, axis: false }, - y: { - scale: () => scaleBand().paddingInner(0.18).paddingOuter(0.08), - axis: { line: false, ticks: { size: 0 } }, + scales: { + x: { scale: scaleLinear, axis: false }, + y: { + scale: () => + scaleBand().paddingInner(0.18).paddingOuter(0.08), + axis: { line: false, ticks: { size: 0 } }, + }, }, + margin: { top: 5, right: 5, bottom: 5, left: 72 }, theme: shadcnTheme(), }, diff --git a/benchmarks/conformance/cases/146-shadcn-bar-interactive/example.tsx b/benchmarks/conformance/cases/146-shadcn-bar-interactive/example.tsx index 3962f208..ae8d0972 100644 --- a/benchmarks/conformance/cases/146-shadcn-bar-interactive/example.tsx +++ b/benchmarks/conformance/cases/146-shadcn-bar-interactive/example.tsx @@ -40,29 +40,32 @@ export function createExampleChart( fill: activeSeries === 'desktop' ? shadcnColors[1] : shadcnColors[0], }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { - values: [ - '2024-04-01', - '2024-04-11', - '2024-04-22', - '2024-05-03', - '2024-05-14', - '2024-05-26', - '2024-06-06', - '2024-06-17', - '2024-06-29', - ], - size: 0, - padding: 10, - format: formatMonthDay, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { + values: [ + '2024-04-01', + '2024-04-11', + '2024-04-22', + '2024-05-03', + '2024-05-14', + '2024-05-26', + '2024-06-06', + '2024-06-17', + '2024-06-29', + ], + size: 0, + padding: 10, + format: formatMonthDay, + }, }, }, + y: { scale: scaleLinear, grid: true, axis: false }, }, - y: { scale: scaleLinear, grid: true, axis: false }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: { top: 5, right: 12, bottom: 25, left: 12 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/147-shadcn-bar-label-custom/example.tsx b/benchmarks/conformance/cases/147-shadcn-bar-label-custom/example.tsx index d97c4482..fb7cfe27 100644 --- a/benchmarks/conformance/cases/147-shadcn-bar-label-custom/example.tsx +++ b/benchmarks/conformance/cases/147-shadcn-bar-label-custom/example.tsx @@ -37,11 +37,15 @@ export function createExampleChart() { fontSize: 12, }), ], - x: { scale: scaleLinear, grid: true, axis: false }, - y: { - scale: () => scaleBand().paddingInner(0.18).paddingOuter(0.08), - axis: false, + scales: { + x: { scale: scaleLinear, grid: true, axis: false }, + y: { + scale: () => + scaleBand().paddingInner(0.18).paddingOuter(0.08), + axis: false, + }, }, + margin: { top: 5, right: 36, bottom: 5, left: 0 }, theme: shadcnTheme(), }, diff --git a/benchmarks/conformance/cases/148-shadcn-bar-label/example.tsx b/benchmarks/conformance/cases/148-shadcn-bar-label/example.tsx index 171e25a6..6e7c9445 100644 --- a/benchmarks/conformance/cases/148-shadcn-bar-label/example.tsx +++ b/benchmarks/conformance/cases/148-shadcn-bar-label/example.tsx @@ -31,8 +31,11 @@ export function createExampleChart() { return defineChart( { marks, - x: shadcnXAxis(), - y: { scale: scaleLinear, grid: true, axis: false }, + scales: { + x: shadcnXAxis(), + y: { scale: scaleLinear, grid: true, axis: false }, + }, + margin: { top: 24, right: 5, bottom: 35, left: 5 }, theme: shadcnTheme(), }, diff --git a/benchmarks/conformance/cases/149-shadcn-bar-mixed/example.tsx b/benchmarks/conformance/cases/149-shadcn-bar-mixed/example.tsx index 1714ec0e..f999749a 100644 --- a/benchmarks/conformance/cases/149-shadcn-bar-mixed/example.tsx +++ b/benchmarks/conformance/cases/149-shadcn-bar-mixed/example.tsx @@ -18,8 +18,11 @@ export function createExampleChart() { radius: 5, }), ], - x: { scale: scaleLinear, axis: false }, - y: shadcnBrowserYAxis(), + scales: { + x: { scale: scaleLinear, axis: false }, + y: shadcnBrowserYAxis(), + }, + color: { domain: browserNames, range: shadcnColors }, margin: { top: 5, right: 5, bottom: 5, left: 60 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/15-boxplot/example.tsx b/benchmarks/conformance/cases/15-boxplot/example.tsx index dbac5488..caef16ba 100644 --- a/benchmarks/conformance/cases/15-boxplot/example.tsx +++ b/benchmarks/conformance/cases/15-boxplot/example.tsx @@ -20,14 +20,16 @@ export const createExampleChart = () => r: 2.5, }), ], - x: { - scale: () => scaleBand().padding(0.22), - axis: { label: 'Experiment' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Speed of light (km/s minus 299,000)' }, + scales: { + x: { + scale: () => scaleBand().padding(0.22), + axis: { label: 'Experiment' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Speed of light (km/s minus 299,000)' }, + }, }, }, { diff --git a/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx b/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx index 343ee81b..3d447a98 100644 --- a/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx +++ b/benchmarks/conformance/cases/150-shadcn-bar-negative/example.tsx @@ -42,11 +42,14 @@ export function createExampleChart() { return defineChart( { marks, - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: false, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: false, + }, + y: { scale: scaleLinear, grid: true, axis: false }, }, - y: { scale: scaleLinear, grid: true, axis: false }, + margin: { top: 24, right: 5, bottom: 24, left: 5 }, theme: shadcnTheme(), }, diff --git a/benchmarks/conformance/cases/151-shadcn-bar-stacked/example.tsx b/benchmarks/conformance/cases/151-shadcn-bar-stacked/example.tsx index 19f525bd..a5cdf1f8 100644 --- a/benchmarks/conformance/cases/151-shadcn-bar-stacked/example.tsx +++ b/benchmarks/conformance/cases/151-shadcn-bar-stacked/example.tsx @@ -22,8 +22,11 @@ export function createExampleChart() { radius: 4, }), ], - x: shadcnXAxis(), - y: { scale: scaleLinear, grid: true, axis: false }, + scales: { + x: shadcnXAxis(), + y: { scale: scaleLinear, grid: true, axis: false }, + }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: { top: 5, right: 5, bottom: 35, left: 5 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/152-shadcn-line-default/example.tsx b/benchmarks/conformance/cases/152-shadcn-line-default/example.tsx index d2a94a99..0795ce95 100644 --- a/benchmarks/conformance/cases/152-shadcn-line-default/example.tsx +++ b/benchmarks/conformance/cases/152-shadcn-line-default/example.tsx @@ -26,19 +26,22 @@ export function createExampleChart() { strokeWidth: 2, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/153-shadcn-line-dots-colors/example.tsx b/benchmarks/conformance/cases/153-shadcn-line-dots-colors/example.tsx index 82571584..f662f711 100644 --- a/benchmarks/conformance/cases/153-shadcn-line-dots-colors/example.tsx +++ b/benchmarks/conformance/cases/153-shadcn-line-dots-colors/example.tsx @@ -42,19 +42,22 @@ export function createExampleChart() { }), ...dotMarks, ], - x: { scale: scalePoint, axis: false }, - y: { - scale: scaleLinear().domain([0, 300]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 75, 150, 225, 300], - size: 0, + scales: { + x: { scale: scalePoint, axis: false }, + y: { + scale: scaleLinear().domain([0, 300]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 75, 150, 225, 300], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: browserNames, range: shadcnColors, diff --git a/benchmarks/conformance/cases/154-shadcn-line-dots-custom/example.tsx b/benchmarks/conformance/cases/154-shadcn-line-dots-custom/example.tsx index dd219114..142d74c1 100644 --- a/benchmarks/conformance/cases/154-shadcn-line-dots-custom/example.tsx +++ b/benchmarks/conformance/cases/154-shadcn-line-dots-custom/example.tsx @@ -53,19 +53,22 @@ export function createExampleChart() { }), ...dotMarks, ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/155-shadcn-line-dots/example.tsx b/benchmarks/conformance/cases/155-shadcn-line-dots/example.tsx index 3ff6da79..3a035157 100644 --- a/benchmarks/conformance/cases/155-shadcn-line-dots/example.tsx +++ b/benchmarks/conformance/cases/155-shadcn-line-dots/example.tsx @@ -44,19 +44,22 @@ export function createExampleChart() { }), ...dotMarks, ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/156-shadcn-line-interactive/example.tsx b/benchmarks/conformance/cases/156-shadcn-line-interactive/example.tsx index 3c8314f9..f2426ba0 100644 --- a/benchmarks/conformance/cases/156-shadcn-line-interactive/example.tsx +++ b/benchmarks/conformance/cases/156-shadcn-line-interactive/example.tsx @@ -44,36 +44,39 @@ export function createExampleChart( strokeWidth: 2, }), ], - x: { - scale: scalePoint, - axis: { - line: false, - ticks: { - values: [ - '2024-04-10', - '2024-04-21', - '2024-05-02', - '2024-05-13', - '2024-05-25', - '2024-06-05', - '2024-06-16', - '2024-06-29', - ], - size: 0, - padding: 10, - format: formatMonthDay, + scales: { + x: { + scale: scalePoint, + axis: { + line: false, + ticks: { + values: [ + '2024-04-10', + '2024-04-21', + '2024-05-02', + '2024-05-13', + '2024-05-25', + '2024-06-05', + '2024-06-16', + '2024-06-29', + ], + size: 0, + padding: 10, + format: formatMonthDay, + }, }, }, - }, - y: { - scale: scaleLinear().domain([0, 600]), - grid: true, - axis: { - line: false, - ticks: { values: [0, 150, 300, 450, 600], size: 0 }, - tickLabels: false, + y: { + scale: scaleLinear().domain([0, 600]), + grid: true, + axis: { + line: false, + ticks: { values: [0, 150, 300, 450, 600], size: 0 }, + tickLabels: false, + }, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: { top: 5, diff --git a/benchmarks/conformance/cases/157-shadcn-line-label-custom/example.tsx b/benchmarks/conformance/cases/157-shadcn-line-label-custom/example.tsx index 55b49bc2..4fdc1cab 100644 --- a/benchmarks/conformance/cases/157-shadcn-line-label-custom/example.tsx +++ b/benchmarks/conformance/cases/157-shadcn-line-label-custom/example.tsx @@ -44,19 +44,22 @@ export function createExampleChart() { }), ...labelMarks, ], - x: { scale: scalePoint, axis: false }, - y: { - scale: scaleLinear().domain([0, 300]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 75, 150, 225, 300], - size: 0, + scales: { + x: { scale: scalePoint, axis: false }, + y: { + scale: scaleLinear().domain([0, 300]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 75, 150, 225, 300], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: browserNames, range: shadcnColors, diff --git a/benchmarks/conformance/cases/158-shadcn-line-label/example.tsx b/benchmarks/conformance/cases/158-shadcn-line-label/example.tsx index 98126500..81baeefe 100644 --- a/benchmarks/conformance/cases/158-shadcn-line-label/example.tsx +++ b/benchmarks/conformance/cases/158-shadcn-line-label/example.tsx @@ -44,19 +44,22 @@ export function createExampleChart() { }), ...labelMarks, ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/159-shadcn-line-linear/example.tsx b/benchmarks/conformance/cases/159-shadcn-line-linear/example.tsx index 3b016950..7c92aeae 100644 --- a/benchmarks/conformance/cases/159-shadcn-line-linear/example.tsx +++ b/benchmarks/conformance/cases/159-shadcn-line-linear/example.tsx @@ -26,19 +26,22 @@ export function createExampleChart() { strokeWidth: 2, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/16-lollipop/example.tsx b/benchmarks/conformance/cases/16-lollipop/example.tsx index 2258bcfe..19be036b 100644 --- a/benchmarks/conformance/cases/16-lollipop/example.tsx +++ b/benchmarks/conformance/cases/16-lollipop/example.tsx @@ -29,15 +29,17 @@ export const createExampleChart = () => { r: 4, }), ], - x: { - scale: () => scaleBand().padding(0.3), - }, - y: { - scale: scaleLinear, - grid: true, - axis: { - ticks: { format: (value) => percent.format(value) }, - label: 'Frequency', + scales: { + x: { + scale: () => scaleBand().padding(0.3), + }, + y: { + scale: scaleLinear, + grid: true, + axis: { + ticks: { format: (value) => percent.format(value) }, + label: 'Frequency', + }, }, }, }, diff --git a/benchmarks/conformance/cases/160-shadcn-line-multiple/example.tsx b/benchmarks/conformance/cases/160-shadcn-line-multiple/example.tsx index 3780eb59..80bfc6e3 100644 --- a/benchmarks/conformance/cases/160-shadcn-line-multiple/example.tsx +++ b/benchmarks/conformance/cases/160-shadcn-line-multiple/example.tsx @@ -28,19 +28,22 @@ export function createExampleChart() { strokeWidth: 2, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/161-shadcn-line-step/example.tsx b/benchmarks/conformance/cases/161-shadcn-line-step/example.tsx index ecfa7333..0c1b062e 100644 --- a/benchmarks/conformance/cases/161-shadcn-line-step/example.tsx +++ b/benchmarks/conformance/cases/161-shadcn-line-step/example.tsx @@ -26,19 +26,22 @@ export function createExampleChart() { strokeWidth: 2, }), ], - x: shadcnPointXAxis(), - y: { - scale: scaleLinear().domain([0, 320]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 80, 160, 240, 320], - size: 0, + scales: { + x: shadcnPointXAxis(), + y: { + scale: scaleLinear().domain([0, 320]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 80, 160, 240, 320], + size: 0, + }, + tickLabels: false, }, - tickLabels: false, }, }, + color: { domain: twoSeries, range: shadcnColors.slice(0, 2), diff --git a/benchmarks/conformance/cases/162-shadcn-pie-donut-active/example.tsx b/benchmarks/conformance/cases/162-shadcn-pie-donut-active/example.tsx index 3c797295..2ad3d3d3 100644 --- a/benchmarks/conformance/cases/162-shadcn-pie-donut-active/example.tsx +++ b/benchmarks/conformance/cases/162-shadcn-pie-donut-active/example.tsx @@ -38,11 +38,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/163-shadcn-pie-donut/example.tsx b/benchmarks/conformance/cases/163-shadcn-pie-donut/example.tsx index 4077c7a1..56feb8b1 100644 --- a/benchmarks/conformance/cases/163-shadcn-pie-donut/example.tsx +++ b/benchmarks/conformance/cases/163-shadcn-pie-donut/example.tsx @@ -29,11 +29,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/164-shadcn-pie-interactive/example.tsx b/benchmarks/conformance/cases/164-shadcn-pie-interactive/example.tsx index 1fc5cbe5..9d2e6ce1 100644 --- a/benchmarks/conformance/cases/164-shadcn-pie-interactive/example.tsx +++ b/benchmarks/conformance/cases/164-shadcn-pie-interactive/example.tsx @@ -41,8 +41,11 @@ export function createExampleChart(activeMonth = 'january') { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(arcs, { id: 'month-slices', @@ -107,6 +110,10 @@ export function createExampleChart(activeMonth = 'january') { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: interactivePieRows.map((row) => row.browser), range: shadcnColors, diff --git a/benchmarks/conformance/cases/165-shadcn-pie-label-custom/example.tsx b/benchmarks/conformance/cases/165-shadcn-pie-label-custom/example.tsx index 46af83db..ff86f7fe 100644 --- a/benchmarks/conformance/cases/165-shadcn-pie-label-custom/example.tsx +++ b/benchmarks/conformance/cases/165-shadcn-pie-label-custom/example.tsx @@ -47,11 +47,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/166-shadcn-pie-label-list/example.tsx b/benchmarks/conformance/cases/166-shadcn-pie-label-list/example.tsx index 04651724..2f404955 100644 --- a/benchmarks/conformance/cases/166-shadcn-pie-label-list/example.tsx +++ b/benchmarks/conformance/cases/166-shadcn-pie-label-list/example.tsx @@ -47,11 +47,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/167-shadcn-pie-label/example.tsx b/benchmarks/conformance/cases/167-shadcn-pie-label/example.tsx index df36d4e0..cebdb0bc 100644 --- a/benchmarks/conformance/cases/167-shadcn-pie-label/example.tsx +++ b/benchmarks/conformance/cases/167-shadcn-pie-label/example.tsx @@ -57,11 +57,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx index 6c7007ff..3970dc55 100644 --- a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx +++ b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx @@ -29,11 +29,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/169-shadcn-pie-separator-none/example.tsx b/benchmarks/conformance/cases/169-shadcn-pie-separator-none/example.tsx index ae539697..1cb2e506 100644 --- a/benchmarks/conformance/cases/169-shadcn-pie-separator-none/example.tsx +++ b/benchmarks/conformance/cases/169-shadcn-pie-separator-none/example.tsx @@ -29,11 +29,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/17-dumbbell/example.tsx b/benchmarks/conformance/cases/17-dumbbell/example.tsx index 4b306f11..a6d6d295 100644 --- a/benchmarks/conformance/cases/17-dumbbell/example.tsx +++ b/benchmarks/conformance/cases/17-dumbbell/example.tsx @@ -31,13 +31,15 @@ export const createExampleChart = (input: ChartOptions) => { r: 4, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: '90th/10th percentile wage ratio' }, - }, - y: { - scale: () => scaleBand().padding(0.22), + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: '90th/10th percentile wage ratio' }, + }, + y: { + scale: () => scaleBand().padding(0.22), + }, }, }, { diff --git a/benchmarks/conformance/cases/170-shadcn-pie-simple/example.tsx b/benchmarks/conformance/cases/170-shadcn-pie-simple/example.tsx index 8bcc6c2a..d64e7bce 100644 --- a/benchmarks/conformance/cases/170-shadcn-pie-simple/example.tsx +++ b/benchmarks/conformance/cases/170-shadcn-pie-simple/example.tsx @@ -29,11 +29,18 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.78, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks, }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/171-shadcn-pie-stacked/example.tsx b/benchmarks/conformance/cases/171-shadcn-pie-stacked/example.tsx index fe4bcca7..4c0f7b40 100644 --- a/benchmarks/conformance/cases/171-shadcn-pie-stacked/example.tsx +++ b/benchmarks/conformance/cases/171-shadcn-pie-stacked/example.tsx @@ -31,8 +31,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 1, - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(desktopArcs, { id: 'desktop-slices', @@ -54,6 +57,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: months, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/172-shadcn-radar-default/example.tsx b/benchmarks/conformance/cases/172-shadcn-radar-default/example.tsx index 880e9aec..f29b8cbd 100644 --- a/benchmarks/conformance/cases/172-shadcn-radar-default/example.tsx +++ b/benchmarks/conformance/cases/172-shadcn-radar-default/example.tsx @@ -44,8 +44,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -65,6 +68,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/173-shadcn-radar-dots/example.tsx b/benchmarks/conformance/cases/173-shadcn-radar-dots/example.tsx index 93004de8..f4e41d7a 100644 --- a/benchmarks/conformance/cases/173-shadcn-radar-dots/example.tsx +++ b/benchmarks/conformance/cases/173-shadcn-radar-dots/example.tsx @@ -45,8 +45,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -75,6 +78,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/174-shadcn-radar-grid-circle-fill/example.tsx b/benchmarks/conformance/cases/174-shadcn-radar-grid-circle-fill/example.tsx index 43d9a67d..d904530e 100644 --- a/benchmarks/conformance/cases/174-shadcn-radar-grid-circle-fill/example.tsx +++ b/benchmarks/conformance/cases/174-shadcn-radar-grid-circle-fill/example.tsx @@ -45,8 +45,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -66,6 +69,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/175-shadcn-radar-grid-circle-no-lines/example.tsx b/benchmarks/conformance/cases/175-shadcn-radar-grid-circle-no-lines/example.tsx index 36b4e304..d027f76b 100644 --- a/benchmarks/conformance/cases/175-shadcn-radar-grid-circle-no-lines/example.tsx +++ b/benchmarks/conformance/cases/175-shadcn-radar-grid-circle-no-lines/example.tsx @@ -47,8 +47,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -77,6 +80,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/176-shadcn-radar-grid-circle/example.tsx b/benchmarks/conformance/cases/176-shadcn-radar-grid-circle/example.tsx index d80b78b3..43253bc5 100644 --- a/benchmarks/conformance/cases/176-shadcn-radar-grid-circle/example.tsx +++ b/benchmarks/conformance/cases/176-shadcn-radar-grid-circle/example.tsx @@ -45,8 +45,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -75,6 +78,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/177-shadcn-radar-grid-custom/example.tsx b/benchmarks/conformance/cases/177-shadcn-radar-grid-custom/example.tsx index f66d8d56..92f48c3b 100644 --- a/benchmarks/conformance/cases/177-shadcn-radar-grid-custom/example.tsx +++ b/benchmarks/conformance/cases/177-shadcn-radar-grid-custom/example.tsx @@ -41,8 +41,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -62,6 +65,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/178-shadcn-radar-grid-fill/example.tsx b/benchmarks/conformance/cases/178-shadcn-radar-grid-fill/example.tsx index 03a55010..60521227 100644 --- a/benchmarks/conformance/cases/178-shadcn-radar-grid-fill/example.tsx +++ b/benchmarks/conformance/cases/178-shadcn-radar-grid-fill/example.tsx @@ -45,8 +45,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -66,6 +69,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/179-shadcn-radar-grid-none/example.tsx b/benchmarks/conformance/cases/179-shadcn-radar-grid-none/example.tsx index 25dc787c..013d92f6 100644 --- a/benchmarks/conformance/cases/179-shadcn-radar-grid-none/example.tsx +++ b/benchmarks/conformance/cases/179-shadcn-radar-grid-none/example.tsx @@ -35,8 +35,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -65,6 +68,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/18-cumulative-histogram/example.tsx b/benchmarks/conformance/cases/18-cumulative-histogram/example.tsx index 9d6e9142..f3763821 100644 --- a/benchmarks/conformance/cases/18-cumulative-histogram/example.tsx +++ b/benchmarks/conformance/cases/18-cumulative-histogram/example.tsx @@ -35,11 +35,13 @@ export const createExampleChart = (input: ChartOptions) => { inset: 1, }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (kg)' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Cumulative count' }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (kg)' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Cumulative count' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/180-shadcn-radar-icons/example.tsx b/benchmarks/conformance/cases/180-shadcn-radar-icons/example.tsx index 93bdf396..b054f943 100644 --- a/benchmarks/conformance/cases/180-shadcn-radar-icons/example.tsx +++ b/benchmarks/conformance/cases/180-shadcn-radar-icons/example.tsx @@ -44,8 +44,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -78,6 +81,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx b/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx index ceca04b2..95591749 100644 --- a/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx +++ b/benchmarks/conformance/cases/181-shadcn-radar-label-custom/example.tsx @@ -45,8 +45,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: height < 220 ? 0.64 : 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -106,6 +109,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }), diff --git a/benchmarks/conformance/cases/182-shadcn-radar-legend/example.tsx b/benchmarks/conformance/cases/182-shadcn-radar-legend/example.tsx index ada8687c..30f44d53 100644 --- a/benchmarks/conformance/cases/182-shadcn-radar-legend/example.tsx +++ b/benchmarks/conformance/cases/182-shadcn-radar-legend/example.tsx @@ -44,8 +44,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -78,6 +81,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/183-shadcn-radar-lines-only/example.tsx b/benchmarks/conformance/cases/183-shadcn-radar-lines-only/example.tsx index 347f1e95..04b1ebbb 100644 --- a/benchmarks/conformance/cases/183-shadcn-radar-lines-only/example.tsx +++ b/benchmarks/conformance/cases/183-shadcn-radar-lines-only/example.tsx @@ -44,8 +44,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -78,6 +81,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/184-shadcn-radar-radius/example.tsx b/benchmarks/conformance/cases/184-shadcn-radar-radius/example.tsx index 957d33b7..0758a4df 100644 --- a/benchmarks/conformance/cases/184-shadcn-radar-radius/example.tsx +++ b/benchmarks/conformance/cases/184-shadcn-radar-radius/example.tsx @@ -38,8 +38,11 @@ export function createExampleChart() { marks: [ polar({ radiusRatio: 0.76, - angle: { scale: scalePoint().domain(months), wrap: true }, - radius: { scale: scaleLinear().domain([0, radiusMax]) }, + scales: { + angle: { scale: scalePoint().domain(months), wrap: true }, + radius: { scale: scaleLinear().domain([0, radiusMax]) }, + }, + guides, marks: [ radialArea(rows, { @@ -72,6 +75,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, diff --git a/benchmarks/conformance/cases/185-shadcn-radial-grid/example.tsx b/benchmarks/conformance/cases/185-shadcn-radial-grid/example.tsx index fab6e6b4..acb53460 100644 --- a/benchmarks/conformance/cases/185-shadcn-radial-grid/example.tsx +++ b/benchmarks/conformance/cases/185-shadcn-radial-grid/example.tsx @@ -23,11 +23,14 @@ export function createExampleChart() { radiusRatio: 0.8, startAngle, endAngle, - angle: { scale: scaleLinear().domain([0, 300]) }, - radius: { - scale: scaleBand().domain(browserNames).paddingInner(0.2), - range: [30, 100], + scales: { + angle: { scale: scaleLinear().domain([0, 300]) }, + radius: { + scale: scaleBand().domain(browserNames).paddingInner(0.2), + range: [30, 100], + }, }, + guides: [ radialGrid({ values: browserNames, @@ -51,6 +54,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/186-shadcn-radial-label/example.tsx b/benchmarks/conformance/cases/186-shadcn-radial-label/example.tsx index 6083fa76..11277c9a 100644 --- a/benchmarks/conformance/cases/186-shadcn-radial-label/example.tsx +++ b/benchmarks/conformance/cases/186-shadcn-radial-label/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radiusRatio: 1, startAngle, endAngle, - angle: { scale: scaleLinear().domain([0, 300]) }, - radius: { - scale: scaleBand().domain(browserNames).paddingInner(0.2), - range: [ - 30 * (height < 220 ? 0.8 : 1), - 110 * (height < 220 ? 0.8 : 1), - ], + scales: { + angle: { scale: scaleLinear().domain([0, 300]) }, + radius: { + scale: scaleBand().domain(browserNames).paddingInner(0.2), + range: [ + 30 * (height < 220 ? 0.8 : 1), + 110 * (height < 220 ? 0.8 : 1), + ], + }, }, + guides: [], marks: [ radialBarAngle(backgroundRows, { @@ -55,11 +58,14 @@ export function createExampleChart() { polar({ startAngle, endAngle, - angle: { scale: scaleLinear().domain([0, 300]) }, - radius: { - scale: scaleLinear().domain([0, 110]), - range: [0, 110 * (height < 220 ? 0.8 : 1)], + scales: { + angle: { scale: scaleLinear().domain([0, 300]) }, + radius: { + scale: scaleLinear().domain([0, 110]), + range: [0, 110 * (height < 220 ? 0.8 : 1)], + }, }, + marks: [ radialText( shadcnBrowsers.map((row, index) => ({ @@ -81,6 +87,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }), diff --git a/benchmarks/conformance/cases/187-shadcn-radial-shape/example.tsx b/benchmarks/conformance/cases/187-shadcn-radial-shape/example.tsx index 4a2f87a8..9d95f509 100644 --- a/benchmarks/conformance/cases/187-shadcn-radial-shape/example.tsx +++ b/benchmarks/conformance/cases/187-shadcn-radial-shape/example.tsx @@ -29,15 +29,22 @@ export function createExampleChart() { fill: 'var(--muted)', }), ], + scales: { + angle: null, + radius: null, + }, }), polar({ startAngle: rechartsPolarAngle(0), endAngle: rechartsPolarAngle(100), - angle: { scale: scaleLinear().domain([0, 1260]) }, - radius: { - scale: scaleBand().domain(['visitors']), - range: [65, 95], + scales: { + angle: { scale: scaleLinear().domain([0, 1260]) }, + radius: { + scale: scaleBand().domain(['visitors']), + range: [65, 95], + }, }, + marks: [ radialBarAngle(rows, { id: `${'shape'}-value`, @@ -51,6 +58,10 @@ export function createExampleChart() { }), radialCenterLabels('1,260', 36, 0, 24), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { @@ -75,8 +86,11 @@ function radialCenterLabels( labelDy: number, ) { return polar({ - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialText([{ id: 'total', angle: 0, radius: 0, text: total }], { id: `radial-total-${total}`, diff --git a/benchmarks/conformance/cases/188-shadcn-radial-simple/example.tsx b/benchmarks/conformance/cases/188-shadcn-radial-simple/example.tsx index eb9b0978..a8e44c15 100644 --- a/benchmarks/conformance/cases/188-shadcn-radial-simple/example.tsx +++ b/benchmarks/conformance/cases/188-shadcn-radial-simple/example.tsx @@ -21,11 +21,14 @@ export function createExampleChart() { radiusRatio: 1, startAngle, endAngle, - angle: { scale: scaleLinear().domain([0, 300]) }, - radius: { - scale: scaleBand().domain(browserNames).paddingInner(0.2), - range: [30, 110], + scales: { + angle: { scale: scaleLinear().domain([0, 300]) }, + radius: { + scale: scaleBand().domain(browserNames).paddingInner(0.2), + range: [30, 110], + }, }, + guides: [], marks: [ radialBarAngle(backgroundRows, { @@ -45,6 +48,10 @@ export function createExampleChart() { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: browserNames, range: shadcnColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx b/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx index 552e794f..1b220d38 100644 --- a/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx +++ b/benchmarks/conformance/cases/189-shadcn-radial-stacked/example.tsx @@ -35,11 +35,14 @@ export function createExampleChart() { polar({ startAngle: rechartsPolarAngle(0), endAngle: rechartsPolarAngle(180), - angle: { scale: scaleLinear().domain([0, 1260]) }, - radius: { - scale: scaleBand().domain(['visitors']), - range: [80, 110], + scales: { + angle: { scale: scaleLinear().domain([0, 1260]) }, + radius: { + scale: scaleBand().domain(['visitors']), + range: [80, 110], + }, }, + marks: [ radialBarAngle(rows, { id: 'stacked-values', @@ -57,6 +60,10 @@ export function createExampleChart() { }), radialCenterLabels('1,830', 24, -16, 4), ], + scales: { + x: null, + y: null, + }, color: { domain: twoSeries, range: shadcnColors.slice(0, 2) }, margin: 0, }, @@ -82,8 +89,11 @@ function radialCenterLabels( labelDy: number, ) { return polar({ - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialText([{ id: 'total', angle: 0, radius: 0, text: total }], { id: `radial-total-${total}`, diff --git a/benchmarks/conformance/cases/19-moving-average-line/example.tsx b/benchmarks/conformance/cases/19-moving-average-line/example.tsx index 2d312966..910e18da 100644 --- a/benchmarks/conformance/cases/19-moving-average-line/example.tsx +++ b/benchmarks/conformance/cases/19-moving-average-line/example.tsx @@ -38,11 +38,13 @@ export const createExampleChart = () => { strokeDasharray: '4 4', }), ], - x: { scale: scaleUtc, axis: { label: 'Date' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fourteen-day average temperature (°F)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Date' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fourteen-day average temperature (°F)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/190-shadcn-tooltip-default/example.tsx b/benchmarks/conformance/cases/190-shadcn-tooltip-default/example.tsx index 625607a3..813db47d 100644 --- a/benchmarks/conformance/cases/190-shadcn-tooltip-default/example.tsx +++ b/benchmarks/conformance/cases/190-shadcn-tooltip-default/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/191-shadcn-tooltip-formatter/example.tsx b/benchmarks/conformance/cases/191-shadcn-tooltip-formatter/example.tsx index abf74c3c..96c1e176 100644 --- a/benchmarks/conformance/cases/191-shadcn-tooltip-formatter/example.tsx +++ b/benchmarks/conformance/cases/191-shadcn-tooltip-formatter/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/192-shadcn-tooltip-icons/example.tsx b/benchmarks/conformance/cases/192-shadcn-tooltip-icons/example.tsx index 024f3d1d..b726c880 100644 --- a/benchmarks/conformance/cases/192-shadcn-tooltip-icons/example.tsx +++ b/benchmarks/conformance/cases/192-shadcn-tooltip-icons/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/193-shadcn-tooltip-indicator-line/example.tsx b/benchmarks/conformance/cases/193-shadcn-tooltip-indicator-line/example.tsx index 11f304a8..dd6423f3 100644 --- a/benchmarks/conformance/cases/193-shadcn-tooltip-indicator-line/example.tsx +++ b/benchmarks/conformance/cases/193-shadcn-tooltip-indicator-line/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/194-shadcn-tooltip-indicator-none/example.tsx b/benchmarks/conformance/cases/194-shadcn-tooltip-indicator-none/example.tsx index 3a0ae86d..d747f000 100644 --- a/benchmarks/conformance/cases/194-shadcn-tooltip-indicator-none/example.tsx +++ b/benchmarks/conformance/cases/194-shadcn-tooltip-indicator-none/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/195-shadcn-tooltip-label-custom/example.tsx b/benchmarks/conformance/cases/195-shadcn-tooltip-label-custom/example.tsx index 05e98d15..7aba5688 100644 --- a/benchmarks/conformance/cases/195-shadcn-tooltip-label-custom/example.tsx +++ b/benchmarks/conformance/cases/195-shadcn-tooltip-label-custom/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/196-shadcn-tooltip-label-formatter/example.tsx b/benchmarks/conformance/cases/196-shadcn-tooltip-label-formatter/example.tsx index 6748bf9f..5141beee 100644 --- a/benchmarks/conformance/cases/196-shadcn-tooltip-label-formatter/example.tsx +++ b/benchmarks/conformance/cases/196-shadcn-tooltip-label-formatter/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx b/benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx index b122afc6..38afbfff 100644 --- a/benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx +++ b/benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx @@ -26,14 +26,17 @@ export function createExampleChart() { radius: 4, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), - axis: { - line: false, - ticks: { size: 0, padding: 10, format: formatWeekday }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.1), + axis: { + line: false, + ticks: { size: 0, padding: 10, format: formatWeekday }, + }, }, + y: { scale: scaleLinear().domain([0, 1000]), axis: false }, }, - y: { scale: scaleLinear().domain([0, 1000]), axis: false }, + color: { domain: activityNames, range: shadcnColors.slice(0, 2) }, margin: { top: 0, right: 7, bottom: 32, left: 7 }, theme: shadcnTheme(), diff --git a/benchmarks/conformance/cases/20-normalized-stacked-area/example.tsx b/benchmarks/conformance/cases/20-normalized-stacked-area/example.tsx index 5677a12b..a9c63411 100644 --- a/benchmarks/conformance/cases/20-normalized-stacked-area/example.tsx +++ b/benchmarks/conformance/cases/20-normalized-stacked-area/example.tsx @@ -33,12 +33,15 @@ export const createExampleChart = (input: ChartOptions) => }), ruleY([0]), ], - x: { scale: scaleUtc, axis: { label: 'Month' } }, - y: { - scale: scaleLinear().domain([0, 1]), - grid: true, - axis: { ticks: { format: percent }, label: 'Share of unemployment' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Month' } }, + y: { + scale: scaleLinear().domain([0, 1]), + grid: true, + axis: { ticks: { format: percent }, label: 'Share of unemployment' }, + }, }, + color: { range: colors, ...(input.preview === true diff --git a/benchmarks/conformance/cases/21-streamgraph/example.tsx b/benchmarks/conformance/cases/21-streamgraph/example.tsx index d6b543c9..5f531b71 100644 --- a/benchmarks/conformance/cases/21-streamgraph/example.tsx +++ b/benchmarks/conformance/cases/21-streamgraph/example.tsx @@ -34,12 +34,15 @@ export const createExampleChart = () => { fillOpacity: 0.85, }), ], - x: { scale: scaleUtc, axis: { label: 'Month' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Unemployed (thousands)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Month' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Unemployed (thousands)' }, + }, }, + color: { range: colors, ...(showLegend ? { legend: colorLegend({ label: 'Industry' }) } : {}), diff --git a/benchmarks/conformance/cases/22-bollinger-band/example.tsx b/benchmarks/conformance/cases/22-bollinger-band/example.tsx index 2fd11e54..6e6a0892 100644 --- a/benchmarks/conformance/cases/22-bollinger-band/example.tsx +++ b/benchmarks/conformance/cases/22-bollinger-band/example.tsx @@ -51,11 +51,13 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 2.25, }), ], - x: { scale: scaleUtc, axis: { label: 'Date' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Apple close (USD)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Date' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Apple close (USD)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/example.tsx b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/example.tsx index d0b75e56..240afaaf 100644 --- a/benchmarks/conformance/cases/24-quantitative-binned-heatmap/example.tsx +++ b/benchmarks/conformance/cases/24-quantitative-binned-heatmap/example.tsx @@ -40,16 +40,19 @@ export const createExampleChart = (input: ChartOptions) => { inset: 0.75, }), ], - x: { - scale: scaleLinear().domain([30, 62]), - grid: true, - axis: { label: 'Bill length (mm)' }, - }, - y: { - scale: scaleLinear().domain([12, 23]), - grid: true, - axis: { label: 'Bill depth (mm)' }, + scales: { + x: { + scale: scaleLinear().domain([30, 62]), + grid: true, + axis: { label: 'Bill length (mm)' }, + }, + y: { + scale: scaleLinear().domain([12, 23]), + grid: true, + axis: { label: 'Bill depth (mm)' }, + }, }, + color: { scale: scaleSequential, range: ['#eff6ff', '#1d4ed8'], diff --git a/benchmarks/conformance/cases/25-calendar-heatmap/example.tsx b/benchmarks/conformance/cases/25-calendar-heatmap/example.tsx index 26411490..3f57b325 100644 --- a/benchmarks/conformance/cases/25-calendar-heatmap/example.tsx +++ b/benchmarks/conformance/cases/25-calendar-heatmap/example.tsx @@ -27,16 +27,23 @@ export const createExampleChart = (input: ChartOptions) => { radius: 2, }), ], - x: { - scale: () => scaleBand().paddingInner(0.06).paddingOuter(0.03), - axis: { ticks: { format: (value) => `W${value + 1}` }, label: 'Week' }, - }, - y: { - scale: scaleBand() - .domain(weekdays) - .paddingInner(0.06) - .paddingOuter(0.03), + scales: { + x: { + scale: () => + scaleBand().paddingInner(0.06).paddingOuter(0.03), + axis: { + ticks: { format: (value) => `W${value + 1}` }, + label: 'Week', + }, + }, + y: { + scale: scaleBand() + .domain(weekdays) + .paddingInner(0.06) + .paddingOuter(0.03), + }, }, + color: { scale: scaleSequential, range: ['#ecfdf5', '#047857'], diff --git a/benchmarks/conformance/cases/26-diverging-likert/example.tsx b/benchmarks/conformance/cases/26-diverging-likert/example.tsx index f991af0f..621bce2a 100644 --- a/benchmarks/conformance/cases/26-diverging-likert/example.tsx +++ b/benchmarks/conformance/cases/26-diverging-likert/example.tsx @@ -40,17 +40,21 @@ export const createExampleChart = () => }), ruleX([0], { stroke: '#64748b' }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { - ticks: { format: (value) => `${Math.abs(value)}` }, - label: '← more disagree · Number of responses · more agree →', + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { + ticks: { format: (value) => `${Math.abs(value)}` }, + label: '← more disagree · Number of responses · more agree →', + }, + }, + y: { + scale: () => + scaleBand().paddingInner(0.14).paddingOuter(0.08), }, }, - y: { - scale: () => scaleBand().paddingInner(0.14).paddingOuter(0.08), - }, + color: { domain: likertResponses, range: colors, diff --git a/benchmarks/conformance/cases/27-parallel-coordinates/example.tsx b/benchmarks/conformance/cases/27-parallel-coordinates/example.tsx index 169f2921..1c348107 100644 --- a/benchmarks/conformance/cases/27-parallel-coordinates/example.tsx +++ b/benchmarks/conformance/cases/27-parallel-coordinates/example.tsx @@ -61,14 +61,17 @@ export const createExampleChart = () => r: 2.75, }), ], - x: { - scale: scaleBand().domain(decathlonEvents).padding(0.1), - }, - y: { - scale: scaleLinear().domain([0, 100]), - grid: true, - axis: { label: 'Relative performance within sample' }, + scales: { + x: { + scale: scaleBand().domain(decathlonEvents).padding(0.1), + }, + y: { + scale: scaleLinear().domain([0, 100]), + grid: true, + axis: { label: 'Relative performance within sample' }, + }, }, + color: { range: colors, legend: colorLegend({ label: 'Country' }), diff --git a/benchmarks/conformance/cases/28-candlestick/example.tsx b/benchmarks/conformance/cases/28-candlestick/example.tsx index 229604c1..0386f813 100644 --- a/benchmarks/conformance/cases/28-candlestick/example.tsx +++ b/benchmarks/conformance/cases/28-candlestick/example.tsx @@ -49,8 +49,10 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 5, }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Price' } }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Price' } }, + }, }, { keyboard: true, diff --git a/benchmarks/conformance/cases/29-waterfall/example.tsx b/benchmarks/conformance/cases/29-waterfall/example.tsx index 06bdf72a..975071a4 100644 --- a/benchmarks/conformance/cases/29-waterfall/example.tsx +++ b/benchmarks/conformance/cases/29-waterfall/example.tsx @@ -70,15 +70,18 @@ export const createExampleChart = () => }), decorative(ruleY([0], { stroke: '#64748b', strokeOpacity: 0.6 })), ] as const, - x: { - scale: () => scaleBand().padding(0.14), - axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Change in gasoline price (USD per gallon)' }, + scales: { + x: { + scale: () => scaleBand().padding(0.14), + axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Change in gasoline price (USD per gallon)' }, + }, }, + color: { domain: kinds, range: colors, diff --git a/benchmarks/conformance/cases/30-slopegraph/example.tsx b/benchmarks/conformance/cases/30-slopegraph/example.tsx index 9ca8965a..d701776c 100644 --- a/benchmarks/conformance/cases/30-slopegraph/example.tsx +++ b/benchmarks/conformance/cases/30-slopegraph/example.tsx @@ -77,14 +77,17 @@ export const createExampleChart = (input: ChartOptions) => { fontSize: input.preview === true ? 9 : 12, }), ], - x: { - scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.08), - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: '90th/10th percentile wage ratio' }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.2).paddingOuter(0.08), + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: '90th/10th percentile wage ratio' }, + }, }, + color: { range: colors, }, diff --git a/benchmarks/conformance/cases/31-linear-regression/example.tsx b/benchmarks/conformance/cases/31-linear-regression/example.tsx index 5e443499..c8c46ef7 100644 --- a/benchmarks/conformance/cases/31-linear-regression/example.tsx +++ b/benchmarks/conformance/cases/31-linear-regression/example.tsx @@ -41,11 +41,13 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 2, }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Power (hp)' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fuel economy (mpg)' }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { label: 'Power (hp)' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, }, }, { diff --git a/benchmarks/conformance/cases/32-change-arrows/example.tsx b/benchmarks/conformance/cases/32-change-arrows/example.tsx index f467559d..8cad360a 100644 --- a/benchmarks/conformance/cases/32-change-arrows/example.tsx +++ b/benchmarks/conformance/cases/32-change-arrows/example.tsx @@ -39,15 +39,17 @@ export const createExampleChart = () => { headLength: 8, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Log₁₀ population' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: '90th-to-10th-percentile wage ratio' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Log₁₀ population' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: '90th-to-10th-percentile wage ratio' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/33-difference-chart/example.tsx b/benchmarks/conformance/cases/33-difference-chart/example.tsx index a5bcc9c8..43875a04 100644 --- a/benchmarks/conformance/cases/33-difference-chart/example.tsx +++ b/benchmarks/conformance/cases/33-difference-chart/example.tsx @@ -37,11 +37,14 @@ export const createExampleChart = (input: ChartOptions) => { comparisonStrokeWidth: 2, }), ], - x: { - scale: scaleUtc, - axis: { ticks: { count: 9, format: formatDifferenceMonth } }, + scales: { + x: { + scale: scaleUtc, + axis: { ticks: { count: 9, format: formatDifferenceMonth } }, + }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, }, - y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, + margin: { top: 20, right: 20, bottom: 30, left: 80 }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx index 3f965e5e..319b366e 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx +++ b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx @@ -59,11 +59,13 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) { ], }), ], - x: { scale: scaleUtc }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Apple close (USD)' }, + scales: { + x: { scale: scaleUtc }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Apple close (USD)' }, + }, }, }, { diff --git a/benchmarks/conformance/cases/35-grouped-tooltip/example.tsx b/benchmarks/conformance/cases/35-grouped-tooltip/example.tsx index 963e2ecb..e84849fc 100644 --- a/benchmarks/conformance/cases/35-grouped-tooltip/example.tsx +++ b/benchmarks/conformance/cases/35-grouped-tooltip/example.tsx @@ -58,12 +58,15 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) { ], }), ], - x: { scale: scaleUtc }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Unemployed (thousands)' }, + scales: { + x: { scale: scaleUtc }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Unemployed (thousands)' }, + }, }, + color: { domain: industryNames, range: colors }, }, { diff --git a/benchmarks/conformance/cases/36-hierarchy-tree/example.tsx b/benchmarks/conformance/cases/36-hierarchy-tree/example.tsx index 1d48c1eb..3befe642 100644 --- a/benchmarks/conformance/cases/36-hierarchy-tree/example.tsx +++ b/benchmarks/conformance/cases/36-hierarchy-tree/example.tsx @@ -63,8 +63,11 @@ export const createExampleChart = (input: ChartOptions) => { input.preview !== true ? 0 : node.y >= 1 ? 5 : node.y <= 0 ? -5 : 0, }), ], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, margin: { top: 22, right: 140, bottom: 22, left: 50 }, }, diff --git a/benchmarks/conformance/cases/37-delaunay-network/example.tsx b/benchmarks/conformance/cases/37-delaunay-network/example.tsx index 13cf028d..51c93bc5 100644 --- a/benchmarks/conformance/cases/37-delaunay-network/example.tsx +++ b/benchmarks/conformance/cases/37-delaunay-network/example.tsx @@ -36,11 +36,13 @@ export const createExampleChart = (input: ChartOptions) => { r: 4, }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fuel economy (mpg)' }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/38-contour-topography/example.tsx b/benchmarks/conformance/cases/38-contour-topography/example.tsx index 2f9ebdf5..11ccaf9e 100644 --- a/benchmarks/conformance/cases/38-contour-topography/example.tsx +++ b/benchmarks/conformance/cases/38-contour-topography/example.tsx @@ -23,6 +23,10 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, color: { scale: scaleThreshold, domain: contourThresholds.slice(1), diff --git a/benchmarks/conformance/cases/39-density-contours/example.tsx b/benchmarks/conformance/cases/39-density-contours/example.tsx index 40a32fb4..31541381 100644 --- a/benchmarks/conformance/cases/39-density-contours/example.tsx +++ b/benchmarks/conformance/cases/39-density-contours/example.tsx @@ -38,12 +38,15 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 1, }), ], - x: { - scale: scaleLinear().domain(densityXDomain), - }, - y: { - scale: scaleLinear().domain(densityYDomain), + scales: { + x: { + scale: scaleLinear().domain(densityXDomain), + }, + y: { + scale: scaleLinear().domain(densityYDomain), + }, }, + guides: false, margin: densityBandwidth * 1.5, }, diff --git a/benchmarks/conformance/cases/40-force-directed-network/example.tsx b/benchmarks/conformance/cases/40-force-directed-network/example.tsx index f01f8668..a0fec729 100644 --- a/benchmarks/conformance/cases/40-force-directed-network/example.tsx +++ b/benchmarks/conformance/cases/40-force-directed-network/example.tsx @@ -72,12 +72,15 @@ export const createExampleChart = (input: ChartOptions) => { }), ]), ], - x: { - scale: scaleLinear().domain(graph.xDomain), - }, - y: { - scale: scaleLinear().domain(graph.yDomain), + scales: { + x: { + scale: scaleLinear().domain(graph.xDomain), + }, + y: { + scale: scaleLinear().domain(graph.yDomain), + }, }, + guides: false, color: { range: colors, diff --git a/benchmarks/conformance/cases/40-geojson-map/example.tsx b/benchmarks/conformance/cases/40-geojson-map/example.tsx index 5331bced..bc591d66 100644 --- a/benchmarks/conformance/cases/40-geojson-map/example.tsx +++ b/benchmarks/conformance/cases/40-geojson-map/example.tsx @@ -23,6 +23,10 @@ export const createExampleChart = (input: ChartOptions) => strokeWidth: 1, }), ], + scales: { + x: null, + y: null, + }, margin: 10, }, { diff --git a/benchmarks/conformance/cases/41-waffle-unit-chart/example.tsx b/benchmarks/conformance/cases/41-waffle-unit-chart/example.tsx index f9a11ab1..4fba1069 100644 --- a/benchmarks/conformance/cases/41-waffle-unit-chart/example.tsx +++ b/benchmarks/conformance/cases/41-waffle-unit-chart/example.tsx @@ -28,6 +28,10 @@ export const createExampleChart = () => radius: 2, }), ], + scales: { + x: null, + y: null, + }, guides: false, color: { domain: letters, diff --git a/benchmarks/conformance/cases/42-vector-field/example.tsx b/benchmarks/conformance/cases/42-vector-field/example.tsx index 2ba11c10..969c2790 100644 --- a/benchmarks/conformance/cases/42-vector-field/example.tsx +++ b/benchmarks/conformance/cases/42-vector-field/example.tsx @@ -22,8 +22,10 @@ export const createExampleChart = () => stroke: '#2563eb', }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Longitude' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Latitude' } }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { label: 'Longitude' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Latitude' } }, + }, }, { keyboard: true, tooltip: exampleTooltip }, ) diff --git a/benchmarks/conformance/cases/43-hexbin-density/example.tsx b/benchmarks/conformance/cases/43-hexbin-density/example.tsx index 31e2d412..fc73d414 100644 --- a/benchmarks/conformance/cases/43-hexbin-density/example.tsx +++ b/benchmarks/conformance/cases/43-hexbin-density/example.tsx @@ -35,16 +35,19 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 0.75, }), ], - x: { - scale: scaleLinear().domain([1500, 5500]), - grid: true, - axis: { label: 'Weight (lb)' }, - }, - y: { - scale: scaleLinear().domain([5, 50]), - grid: true, - axis: { label: 'Fuel economy (mpg)' }, + scales: { + x: { + scale: scaleLinear().domain([1500, 5500]), + grid: true, + axis: { label: 'Weight (lb)' }, + }, + y: { + scale: scaleLinear().domain([5, 50]), + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, }, + color: { scale: scaleThreshold, domain: [5, 12, 24], diff --git a/benchmarks/conformance/cases/44-framed-scatter/example.tsx b/benchmarks/conformance/cases/44-framed-scatter/example.tsx index c8fe4768..747cca93 100644 --- a/benchmarks/conformance/cases/44-framed-scatter/example.tsx +++ b/benchmarks/conformance/cases/44-framed-scatter/example.tsx @@ -31,8 +31,11 @@ export const createExampleChart = (input: ChartOptions) => { r: 3, }), ], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, margin: 20, }, diff --git a/benchmarks/conformance/cases/50-empirical-cdf/example.tsx b/benchmarks/conformance/cases/50-empirical-cdf/example.tsx index 3bea9c8a..f7c6a67a 100644 --- a/benchmarks/conformance/cases/50-empirical-cdf/example.tsx +++ b/benchmarks/conformance/cases/50-empirical-cdf/example.tsx @@ -42,17 +42,19 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 2, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fuel economy (mpg)' }, - }, - y: { - scale: scaleLinear().domain([0, 1]), - grid: true, - axis: { - ticks: { format: (value) => percent.format(value) }, - label: 'Cumulative proportion', + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, + y: { + scale: scaleLinear().domain([0, 1]), + grid: true, + axis: { + ticks: { format: (value) => percent.format(value) }, + label: 'Cumulative proportion', + }, }, }, }, diff --git a/benchmarks/conformance/cases/51-faceted-distributions/example.tsx b/benchmarks/conformance/cases/51-faceted-distributions/example.tsx index 4510a3b4..aecd60a7 100644 --- a/benchmarks/conformance/cases/51-faceted-distributions/example.tsx +++ b/benchmarks/conformance/cases/51-faceted-distributions/example.tsx @@ -68,22 +68,28 @@ export const createExampleChart = (input: ChartOptions) => { }), ], ...(input.preview ? { guides: false, margin: 0 } : {}), - x: { - scale: scaleLinear().domain([2500, 6500]), - grid: true, - axis: { label: 'Body mass (g)' }, - }, - y: { - scale: scaleLinear().domain([0, 0.4]), - grid: true, - axis: { - ticks: { count: 3, format: (value) => percent.format(value) }, - label: 'Proportion', + scales: { + x: { + scale: scaleLinear().domain([2500, 6500]), + grid: true, + axis: { label: 'Body mass (g)' }, + }, + y: { + scale: scaleLinear().domain([0, 0.4]), + grid: true, + axis: { + ticks: { count: 3, format: (value) => percent.format(value) }, + label: 'Proportion', + }, }, }, }), }), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { diff --git a/benchmarks/conformance/cases/52-beeswarm-dodge/example.tsx b/benchmarks/conformance/cases/52-beeswarm-dodge/example.tsx index 2dfa7852..4aac3eea 100644 --- a/benchmarks/conformance/cases/52-beeswarm-dodge/example.tsx +++ b/benchmarks/conformance/cases/52-beeswarm-dodge/example.tsx @@ -38,8 +38,11 @@ export const createExampleChart = (input: ChartOptions) => ], guides: false, margin, - x: { - scale: scaleLinear().domain([5, 50]), + scales: { + x: { + scale: scaleLinear().domain([5, 50]), + }, + y: null, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/53-log-scale-scatter/example.tsx b/benchmarks/conformance/cases/53-log-scale-scatter/example.tsx index ad25b5a1..8f31799f 100644 --- a/benchmarks/conformance/cases/53-log-scale-scatter/example.tsx +++ b/benchmarks/conformance/cases/53-log-scale-scatter/example.tsx @@ -37,12 +37,18 @@ export const createExampleChart = (input: ChartOptions) => { bottom: 40, left: 50, }, - x: { - scale: scaleLog().domain([200, 30_000]), - grid: true, - axis: { label: 'Class size' }, + scales: { + x: { + scale: scaleLog().domain([200, 30_000]), + grid: true, + axis: { label: 'Class size' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Hierarchy depth' }, + }, }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Hierarchy depth' } }, }, { keyboard: true, tooltip: exampleTooltip }, ) diff --git a/benchmarks/conformance/cases/54-bump-ranking/example.tsx b/benchmarks/conformance/cases/54-bump-ranking/example.tsx index e24f6ae3..dfcf7f43 100644 --- a/benchmarks/conformance/cases/54-bump-ranking/example.tsx +++ b/benchmarks/conformance/cases/54-bump-ranking/example.tsx @@ -75,21 +75,24 @@ export const createExampleChart = (input?: ChartOptions) => { }), ]), ], - x: { - scale: scaleUtc, - axis: { - ticks: { count: 7, format: (date) => `${date.getUTCFullYear()}` }, - label: 'Year', + scales: { + x: { + scale: scaleUtc, + axis: { + ticks: { count: 7, format: (date) => `${date.getUTCFullYear()}` }, + label: 'Year', + }, }, - }, - y: { - scale: scaleLinear().domain([5.2, 0.8]), - grid: true, - axis: { - ticks: { count: 5, format: (value) => `#${value}` }, - label: 'Rank', + y: { + scale: scaleLinear().domain([5.2, 0.8]), + grid: true, + axis: { + ticks: { count: 5, format: (value) => `#${value}` }, + label: 'Rank', + }, }, }, + color: { domain: includedIndustries, range: colors, diff --git a/benchmarks/conformance/cases/55-indexed-multi-line/example.tsx b/benchmarks/conformance/cases/55-indexed-multi-line/example.tsx index 4f21d0ee..86306eae 100644 --- a/benchmarks/conformance/cases/55-indexed-multi-line/example.tsx +++ b/benchmarks/conformance/cases/55-indexed-multi-line/example.tsx @@ -71,15 +71,18 @@ export const createExampleChart = (input: ChartOptions) => { }), ]), ], - x: { scale: scaleUtc, axis: { label: 'Month' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { - ticks: { format: formatIndex }, - label: 'Change from January 2008', + scales: { + x: { scale: scaleUtc, axis: { label: 'Month' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { + ticks: { format: formatIndex }, + label: 'Change from January 2008', + }, }, }, + color: { range: colors, }, diff --git a/benchmarks/conformance/cases/56-connected-scatter/example.tsx b/benchmarks/conformance/cases/56-connected-scatter/example.tsx index d46a1a42..eb334b0c 100644 --- a/benchmarks/conformance/cases/56-connected-scatter/example.tsx +++ b/benchmarks/conformance/cases/56-connected-scatter/example.tsx @@ -71,15 +71,17 @@ export const createExampleChart = (input?: ChartOptions) => }), ]), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Miles driven per person' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Cost of gasoline ($ per gallon)' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Miles driven per person' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Cost of gasoline ($ per gallon)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/57-scatter-marginal-histograms/example.tsx b/benchmarks/conformance/cases/57-scatter-marginal-histograms/example.tsx index 6151c212..4d02dbef 100644 --- a/benchmarks/conformance/cases/57-scatter-marginal-histograms/example.tsx +++ b/benchmarks/conformance/cases/57-scatter-marginal-histograms/example.tsx @@ -96,16 +96,19 @@ export const createExampleChart = (input: ChartOptions) => { fillOpacity: 0.78, }), ], - x: { - scale: flipperScale, - grid: true, - axis: { label: 'Flipper length (mm)' }, - }, - y: { - scale: massScale, - grid: true, - axis: { label: 'Body mass (g)' }, + scales: { + x: { + scale: flipperScale, + grid: true, + axis: { label: 'Flipper length (mm)' }, + }, + y: { + scale: massScale, + grid: true, + axis: { label: 'Body mass (g)' }, + }, }, + color: { range: colors, ...(showLegend @@ -135,8 +138,11 @@ export const createExampleChart = (input: ChartOptions) => { inset: 1, }), ], - x: { scale: flipperScale }, - y: { scale: scaleLinear }, + scales: { + x: { scale: flipperScale }, + y: { scale: scaleLinear }, + }, + guides: false, }), }, @@ -160,8 +166,11 @@ export const createExampleChart = (input: ChartOptions) => { inset: 1, }), ], - x: { scale: scaleLinear }, - y: { scale: massScale }, + scales: { + x: { scale: scaleLinear }, + y: { scale: massScale }, + }, + guides: false, }), }, diff --git a/benchmarks/conformance/cases/58-select-extrema/example.tsx b/benchmarks/conformance/cases/58-select-extrema/example.tsx index 1b987eab..e73edb06 100644 --- a/benchmarks/conformance/cases/58-select-extrema/example.tsx +++ b/benchmarks/conformance/cases/58-select-extrema/example.tsx @@ -81,11 +81,13 @@ export const createExampleChart = ({ compact = false }: ChartOptions = {}) => { }), ), ], - x: { scale: scaleUtc, axis: { label: 'Date' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Apple close (USD)' }, + scales: { + x: { scale: scaleUtc, axis: { label: 'Date' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Apple close (USD)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/59-grouped-reducer-bars/example.tsx b/benchmarks/conformance/cases/59-grouped-reducer-bars/example.tsx index 5895a140..4a1f9cc8 100644 --- a/benchmarks/conformance/cases/59-grouped-reducer-bars/example.tsx +++ b/benchmarks/conformance/cases/59-grouped-reducer-bars/example.tsx @@ -39,13 +39,15 @@ export const createExampleChart = (input?: ChartOptions) => { dy: input?.preview === true ? 10 : -8, }), ], - x: { - scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Mean body mass (g)' }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Mean body mass (g)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/60-lag-autocorrelation/example.tsx b/benchmarks/conformance/cases/60-lag-autocorrelation/example.tsx index 1e45b5ef..318feebc 100644 --- a/benchmarks/conformance/cases/60-lag-autocorrelation/example.tsx +++ b/benchmarks/conformance/cases/60-lag-autocorrelation/example.tsx @@ -42,15 +42,17 @@ export const createExampleChart = () => { r: 4, }), ], - x: { - scale: scaleLinear().domain(closeDomain), - grid: true, - axis: { label: 'Previous close (USD)' }, - }, - y: { - scale: scaleLinear().domain(closeDomain), - grid: true, - axis: { label: 'Current close (USD)' }, + scales: { + x: { + scale: scaleLinear().domain(closeDomain), + grid: true, + axis: { label: 'Previous close (USD)' }, + }, + y: { + scale: scaleLinear().domain(closeDomain), + grid: true, + axis: { label: 'Current close (USD)' }, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/61-quantile-ribbon/example.tsx b/benchmarks/conformance/cases/61-quantile-ribbon/example.tsx index 338836c8..1c49a1c6 100644 --- a/benchmarks/conformance/cases/61-quantile-ribbon/example.tsx +++ b/benchmarks/conformance/cases/61-quantile-ribbon/example.tsx @@ -41,16 +41,18 @@ export const createExampleChart = () => { strokeWidth: 2.25, }), ], - x: { - scale: scaleUtc, - axis: showAxisLabels ? { label: 'Month' } : {}, - }, - y: { - scale: scaleLinear, - grid: true, - axis: showAxisLabels - ? { label: 'Unemployed people by industry (thousands)' } - : {}, + scales: { + x: { + scale: scaleUtc, + axis: showAxisLabels ? { label: 'Month' } : {}, + }, + y: { + scale: scaleLinear, + grid: true, + axis: showAxisLabels + ? { label: 'Unemployed people by industry (thousands)' } + : {}, + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/62-ridgeline-density/example.tsx b/benchmarks/conformance/cases/62-ridgeline-density/example.tsx index e64640c8..2204a9b1 100644 --- a/benchmarks/conformance/cases/62-ridgeline-density/example.tsx +++ b/benchmarks/conformance/cases/62-ridgeline-density/example.tsx @@ -59,21 +59,24 @@ export const createExampleChart = (input: ChartOptions) => { curve, }), ], - x: { - scale: scaleLinear().domain([4, 10]), - grid: true, - axis: { label: 'IMDb rating' }, - }, - y: { - scale: scalePoint().domain(seasons).padding(overlap), - reverse: true, - axis: { - ticks: { - values: seasons, - format: (season) => `Season ${season}`, + scales: { + x: { + scale: scaleLinear().domain([4, 10]), + grid: true, + axis: { label: 'IMDb rating' }, + }, + y: { + scale: scalePoint().domain(seasons).padding(overlap), + reverse: true, + axis: { + ticks: { + values: seasons, + format: (season) => `Season ${season}`, + }, }, }, }, + color: { range: colors, }, diff --git a/benchmarks/conformance/cases/63-violin-distributions/example.tsx b/benchmarks/conformance/cases/63-violin-distributions/example.tsx index f145243b..9a038681 100644 --- a/benchmarks/conformance/cases/63-violin-distributions/example.tsx +++ b/benchmarks/conformance/cases/63-violin-distributions/example.tsx @@ -76,10 +76,13 @@ export const createExampleChart = (input: ChartOptions) => { r: 3.5, }), ], - x: { - scale: scalePoint().domain(violinSpecies).padding(0.5), + scales: { + x: { + scale: scalePoint().domain(violinSpecies).padding(0.5), + }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Body mass (g)' } }, + color: { domain: violinSpecies, range: colors, diff --git a/benchmarks/conformance/cases/64-marimekko-mosaic/example.tsx b/benchmarks/conformance/cases/64-marimekko-mosaic/example.tsx index b68b398b..155aefef 100644 --- a/benchmarks/conformance/cases/64-marimekko-mosaic/example.tsx +++ b/benchmarks/conformance/cases/64-marimekko-mosaic/example.tsx @@ -67,14 +67,17 @@ export const createExampleChart = (input?: ChartOptions) => { }), ]), ], - x: { - scale: scaleLinear().domain([0, 1]), - axis: { ticks: { format: percent }, label: 'Share of responses' }, - }, - y: { - scale: scaleLinear().domain([0, 1.12]), - axis: { ticks: { format: percent }, label: 'Within-question share' }, + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { ticks: { format: percent }, label: 'Share of responses' }, + }, + y: { + scale: scaleLinear().domain([0, 1.12]), + axis: { ticks: { format: percent }, label: 'Within-question share' }, + }, }, + color: { domain: mosaicResponses, range: colors, diff --git a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/example.tsx b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/example.tsx index b7e4d205..cfeee888 100644 --- a/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/example.tsx +++ b/benchmarks/conformance/cases/65-voronoi-nearest-tooltip/example.tsx @@ -57,12 +57,15 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) { ], }), ], - x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fuel economy (mpg)' }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { label: 'Weight (lb)' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, }, + color: { range: colors }, }, { diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index 3758097e..ff24f8fe 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -49,13 +49,16 @@ export const createExampleChart = (input: ChartOptions) => { r: 4.5, }), ], - x: { - scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - axis: { - ticks: { format: (value: Date) => dateFormat.format(value) }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + axis: { + ticks: { format: (value: Date) => dateFormat.format(value) }, + }, }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, }, - y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, + margin: { top: 20, right: 20, bottom: 50, left: 80 }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/71-recharts-population-pyramid/example.tsx b/benchmarks/conformance/cases/71-recharts-population-pyramid/example.tsx index 4047710b..1857e5c4 100644 --- a/benchmarks/conformance/cases/71-recharts-population-pyramid/example.tsx +++ b/benchmarks/conformance/cases/71-recharts-population-pyramid/example.tsx @@ -33,20 +33,24 @@ export const createExampleChart = (input: ChartOptions) => { inset: 0.5, }), ], - x: { - scale: scaleLinear().domain([-80, 80]), - grid: true, - axis: { - ticks: { - count: 5, - format: (value) => Math.abs(value).toLocaleString('en-US'), + scales: { + x: { + scale: scaleLinear().domain([-80, 80]), + grid: true, + axis: { + ticks: { + count: 5, + format: (value) => Math.abs(value).toLocaleString('en-US'), + }, + label: 'Penguins observed', }, - label: 'Penguins observed', + }, + y: { + scale: () => + scaleBand().paddingInner(0.02).paddingOuter(0.01), }, }, - y: { - scale: () => scaleBand().paddingInner(0.02).paddingOuter(0.01), - }, + color: { domain: pyramidSexes, range: colors, diff --git a/benchmarks/conformance/cases/72-recharts-mixed-bars/example.tsx b/benchmarks/conformance/cases/72-recharts-mixed-bars/example.tsx index ab597a5d..69601118 100644 --- a/benchmarks/conformance/cases/72-recharts-mixed-bars/example.tsx +++ b/benchmarks/conformance/cases/72-recharts-mixed-bars/example.tsx @@ -50,11 +50,14 @@ export const createExampleChart = (input: ChartOptions) => { inset: 1, }), ], - x: { - scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - axis: { ticks: { format: (value) => dateFormat.format(value) } }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + axis: { ticks: { format: (value) => dateFormat.format(value) } }, + }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, }, - y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 5 } } }, + margin: { top: 20, right: 20, bottom: 50, left: 80 }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/73-many-point-scatter/example.tsx b/benchmarks/conformance/cases/73-many-point-scatter/example.tsx index 2b179e2e..e3ec946f 100644 --- a/benchmarks/conformance/cases/73-many-point-scatter/example.tsx +++ b/benchmarks/conformance/cases/73-many-point-scatter/example.tsx @@ -29,8 +29,11 @@ export const createExampleChart = (input: ChartOptions) => { fillOpacity: 0.72, }), ], - x: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, - y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, + scales: { + x: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, + y: { scale: scaleLinear, grid: true, axis: { ticks: { count: 6 } } }, + }, + color: { range: colors, }, diff --git a/benchmarks/conformance/cases/74-recharts-treemap/example.tsx b/benchmarks/conformance/cases/74-recharts-treemap/example.tsx index 8c5b515f..3a529b80 100644 --- a/benchmarks/conformance/cases/74-recharts-treemap/example.tsx +++ b/benchmarks/conformance/cases/74-recharts-treemap/example.tsx @@ -31,6 +31,10 @@ export const createExampleChart = (input?: ChartOptions) => labelFontWeight: 600, }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, guides: false, margin: 0, diff --git a/benchmarks/conformance/cases/75-radar/example.tsx b/benchmarks/conformance/cases/75-radar/example.tsx index 9c0339d4..d5295b22 100644 --- a/benchmarks/conformance/cases/75-radar/example.tsx +++ b/benchmarks/conformance/cases/75-radar/example.tsx @@ -54,8 +54,11 @@ export const createExampleChart = (input: ChartOptions) => { { marks: [ polar({ - angle: { scale: angleScale, wrap: true }, - radius: { scale: radiusScale }, + scales: { + angle: { scale: angleScale, wrap: true }, + radius: { scale: radiusScale }, + }, + inset: 0, radiusRatio: input.preview === true ? 0.94 : 0.8, guides: [ @@ -98,6 +101,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, margin: input.preview === true ? 0 : margin, }, { diff --git a/benchmarks/conformance/cases/76-pie/example.tsx b/benchmarks/conformance/cases/76-pie/example.tsx index c215870b..b45ae387 100644 --- a/benchmarks/conformance/cases/76-pie/example.tsx +++ b/benchmarks/conformance/cases/76-pie/example.tsx @@ -30,8 +30,16 @@ export const createExampleChart = (input: ChartOptions) => { color: 'letter', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/77-donut/example.tsx b/benchmarks/conformance/cases/77-donut/example.tsx index ee35345d..48d8d724 100644 --- a/benchmarks/conformance/cases/77-donut/example.tsx +++ b/benchmarks/conformance/cases/77-donut/example.tsx @@ -31,8 +31,16 @@ export const createExampleChart = (input: ChartOptions) => { color: 'letter', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/78-gauge/example.tsx b/benchmarks/conformance/cases/78-gauge/example.tsx index 5ee22a08..ce22a3dc 100644 --- a/benchmarks/conformance/cases/78-gauge/example.tsx +++ b/benchmarks/conformance/cases/78-gauge/example.tsx @@ -36,8 +36,16 @@ export const createExampleChart = (input: ChartOptions) => { color: 'id', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { domain: ids, range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/80-echarts-axis-pointer/example.tsx b/benchmarks/conformance/cases/80-echarts-axis-pointer/example.tsx index 1e5ea8ba..82d54154 100644 --- a/benchmarks/conformance/cases/80-echarts-axis-pointer/example.tsx +++ b/benchmarks/conformance/cases/80-echarts-axis-pointer/example.tsx @@ -83,20 +83,23 @@ export function createExampleChart(input: ExampleChartInput) { }, }), ], - x: { - scale: scaleUtc, - axis: { ticks: { format: (value) => month.format(value) } }, - }, - y: { - scale: scaleLinear, - grid: input.preview !== true, - axis: { - ticks: { count: 5 }, - ...(input.preview === true - ? {} - : { label: 'Unemployed (thousands)' }), + scales: { + x: { + scale: scaleUtc, + axis: { ticks: { format: (value) => month.format(value) } }, + }, + y: { + scale: scaleLinear, + grid: input.preview !== true, + axis: { + ticks: { count: 5 }, + ...(input.preview === true + ? {} + : { label: 'Unemployed (thousands)' }), + }, }, }, + color: { domain: axisPointerIndustries, range: axisPointerIndustries.map( diff --git a/benchmarks/conformance/cases/81-recharts-interactive-legend/example.tsx b/benchmarks/conformance/cases/81-recharts-interactive-legend/example.tsx index 53ad27ad..0ebca718 100644 --- a/benchmarks/conformance/cases/81-recharts-interactive-legend/example.tsx +++ b/benchmarks/conformance/cases/81-recharts-interactive-legend/example.tsx @@ -41,23 +41,26 @@ export function interactiveLegendDefinition( strokeWidth: 2.5, }), ], - x: { - scale: scaleUtc, - axis: { - ticks: { - format: (date) => - date.toLocaleDateString('en-US', { - month: 'short', - timeZone: 'UTC', - }), + scales: { + x: { + scale: scaleUtc, + axis: { + ticks: { + format: (date) => + date.toLocaleDateString('en-US', { + month: 'short', + timeZone: 'UTC', + }), + }, }, }, + y: { + scale: scaleLinear().domain(yDomain), + grid: true, + axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' }, + }, }, - y: { - scale: scaleLinear().domain(yDomain), - grid: true, - axis: { ticks: { count: 5 }, label: 'Unemployed (thousands)' }, - }, + color: { domain: legendSeries.map((series) => series.id), range: legendSeries.map((series) => seriesColors[series.id]), @@ -107,8 +110,11 @@ export function interactiveLegendPreviewDefinition(revision: number) { strokeWidth: 2.5, }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear().domain(yDomain) }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear().domain(yDomain) }, + }, + color: { domain: legendSeries.map((series) => series.id), range: legendSeries.map((series) => seriesColors[series.id]), diff --git a/benchmarks/conformance/cases/82-chart-table-selection/example.tsx b/benchmarks/conformance/cases/82-chart-table-selection/example.tsx index 0f85ce1a..37446b6d 100644 --- a/benchmarks/conformance/cases/82-chart-table-selection/example.tsx +++ b/benchmarks/conformance/cases/82-chart-table-selection/example.tsx @@ -60,12 +60,15 @@ export function chartTableSelectionDefinition( selection, ), ], - x: { scale: scaleLinear, axis: { label: 'Flipper length (mm)' } }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Body mass (g)' }, + scales: { + x: { scale: scaleLinear, axis: { label: 'Flipper length (mm)' } }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Body mass (g)' }, + }, }, + margin: { top: 16, right: 24, bottom: 42, left: 62 }, svgAnimation: false, keyboard: true, diff --git a/benchmarks/conformance/cases/83-focus-context-window/example.tsx b/benchmarks/conformance/cases/83-focus-context-window/example.tsx index 1f00f3ad..9ded975f 100644 --- a/benchmarks/conformance/cases/83-focus-context-window/example.tsx +++ b/benchmarks/conformance/cases/83-focus-context-window/example.tsx @@ -79,15 +79,18 @@ export function focusContextDetailDefinition(window: FocusContextWindow) { selection, ), ], - x: { - scale: scaleUtc().domain([window.start, window.end]), - axis: { label: 'Selected time window' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Close ($)' }, + scales: { + x: { + scale: scaleUtc().domain([window.start, window.end]), + axis: { label: 'Selected time window' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Close ($)' }, + }, }, + margin: detailMargin, }, { svgAnimation: false, keyboard: false }, @@ -109,20 +112,23 @@ export function focusContextOverviewDefinition( strokeWidth: 1.75, }), ], - x: { - scale: scaleUtc().domain(fullDomain), - axis: { - ticks: { - count: 4, - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - timeZone: 'UTC', - }), + scales: { + x: { + scale: scaleUtc().domain(fullDomain), + axis: { + ticks: { + count: 4, + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + timeZone: 'UTC', + }), + }, }, }, + y: { scale: scaleLinear, axis: false }, }, - y: { scale: scaleLinear, axis: false }, + margin: overviewMargin, controls: [ brushX({ diff --git a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/example.tsx b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/example.tsx index 0634b1bf..fa5e15f6 100644 --- a/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/example.tsx +++ b/benchmarks/conformance/cases/84-pinned-nested-chart-tooltip/example.tsx @@ -147,29 +147,32 @@ export function energyDefinition( ], }), ], - x: { - scale: scaleBand() - .domain(months) - .paddingInner(0.16) - .paddingOuter(0.06), - axis: { - line: false, - ticks: { size: 0, padding: 8 }, + scales: { + x: { + scale: scaleBand() + .domain(months) + .paddingInner(0.16) + .paddingOuter(0.06), + axis: { + line: false, + ticks: { size: 0, padding: 8 }, + }, }, - }, - y: { - scale: scaleLinear().domain([0, 2600]), - grid: true, - axis: { - line: false, - ticks: { - values: [0, 650, 1300, 1950, 2600], - size: 0, - padding: 7, - format: (value) => `${value.toLocaleString('en-US')} kWh`, + y: { + scale: scaleLinear().domain([0, 2600]), + grid: true, + axis: { + line: false, + ticks: { + values: [0, 650, 1300, 1950, 2600], + size: 0, + padding: 7, + format: (value) => `${value.toLocaleString('en-US')} kWh`, + }, }, }, }, + margin: { top: 82, right: 24, bottom: 38, left: 72 }, gradients: [ { @@ -222,14 +225,17 @@ export function ConsumptionMixChart({ inset: 0, }), ], - x: { - scale: scaleLinear().domain([0, month.consumption]), - axis: false, - }, - y: { - scale: scaleBand().domain(['mix']), - axis: false, + scales: { + x: { + scale: scaleLinear().domain([0, month.consumption]), + axis: false, + }, + y: { + scale: scaleBand().domain(['mix']), + axis: false, + }, }, + margin: 0, }, { svgAnimation: false, keyboard: false, tooltip: false }, diff --git a/benchmarks/conformance/cases/85-scrollable-resource-lanes/example.tsx b/benchmarks/conformance/cases/85-scrollable-resource-lanes/example.tsx index 1af2d999..072df0eb 100644 --- a/benchmarks/conformance/cases/85-scrollable-resource-lanes/example.tsx +++ b/benchmarks/conformance/cases/85-scrollable-resource-lanes/example.tsx @@ -91,19 +91,22 @@ export const resourceTimelineDefinition = (input: ExampleChartInput) => { strokeWidth: 1, }), ], - x: { - scale: scaleUtc().domain(resourceTimelineDomain), - grid: true, - axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } }, - }, - y: { - scale: scaleBand() - .domain(resourceLanes) - .paddingInner(0.08) - .paddingOuter(0.04), - grid: false, - axis: false, + scales: { + x: { + scale: scaleUtc().domain(resourceTimelineDomain), + grid: true, + axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } }, + }, + y: { + scale: scaleBand() + .domain(resourceLanes) + .paddingInner(0.08) + .paddingOuter(0.04), + grid: false, + axis: false, + }, }, + color: { domain: timelineStatuses, range: timelineStatuses.map((status) => timelineStatusColors[status]), diff --git a/benchmarks/conformance/cases/86-streaming-window-preservation/example.tsx b/benchmarks/conformance/cases/86-streaming-window-preservation/example.tsx index 526025d6..aedb180a 100644 --- a/benchmarks/conformance/cases/86-streaming-window-preservation/example.tsx +++ b/benchmarks/conformance/cases/86-streaming-window-preservation/example.tsx @@ -46,25 +46,28 @@ export function streamingWindowDefinition( strokeWidth: 1, }), ], - x: { - scale: scaleUtc().domain(viewport), - axis: { - ticks: { - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + scales: { + x: { + scale: scaleUtc().domain(viewport), + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, + label: streamingViewportLabel(viewportMode), }, - label: streamingViewportLabel(viewportMode), + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Downloads' }, }, }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Downloads' }, - }, + margin: { top: 18, right: 24, bottom: 44, left: 58 }, svgAnimation: false, keyboard: true, diff --git a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/example.tsx b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/example.tsx index fe7e1833..bf78045c 100644 --- a/benchmarks/conformance/cases/87-echarts-synchronized-cursors/example.tsx +++ b/benchmarks/conformance/cases/87-echarts-synchronized-cursors/example.tsx @@ -202,22 +202,25 @@ function synchronizedCursorViewDefinition( }, }), ], - x: { - scale: scaleUtc, - axis: preview - ? false - : { ticks: { format: (value) => month.format(value) } }, - }, - y: { - scale: scaleLinear().domain(synchronizedCursorYDomains[view]), - grid: !preview, - axis: preview - ? false - : { - ticks: { count: 4, format: travelerCountFormat.format }, - label: view === 'current' ? '2020 travelers' : '2019 travelers', - }, + scales: { + x: { + scale: scaleUtc, + axis: preview + ? false + : { ticks: { format: (value) => month.format(value) } }, + }, + y: { + scale: scaleLinear().domain(synchronizedCursorYDomains[view]), + grid: !preview, + axis: preview + ? false + : { + ticks: { count: 4, format: travelerCountFormat.format }, + label: view === 'current' ? '2020 travelers' : '2019 travelers', + }, + }, }, + margin: preview ? 0 : viewMargin, }) } diff --git a/benchmarks/conformance/cases/88-echarts-free-cursor/example.tsx b/benchmarks/conformance/cases/88-echarts-free-cursor/example.tsx index fec9b242..73c132f0 100644 --- a/benchmarks/conformance/cases/88-echarts-free-cursor/example.tsx +++ b/benchmarks/conformance/cases/88-echarts-free-cursor/example.tsx @@ -186,15 +186,18 @@ export function freeCursorDefinition( strokeWidth: 1, }), ], - x: { - scale: scaleLinear().domain(freeCursorXDomain), - axis: { label: 'Horsepower' }, - }, - y: { - scale: scaleLinear().domain(freeCursorYDomain), - grid: true, - axis: { ticks: { count: 7 }, label: 'Fuel economy (mpg)' }, + scales: { + x: { + scale: scaleLinear().domain(freeCursorXDomain), + axis: { label: 'Horsepower' }, + }, + y: { + scale: scaleLinear().domain(freeCursorYDomain), + grid: true, + axis: { ticks: { count: 7 }, label: 'Fuel economy (mpg)' }, + }, }, + controls: [ continuousCursor({ position: controlledSignal< diff --git a/benchmarks/conformance/cases/89-brush-range-selection/example.tsx b/benchmarks/conformance/cases/89-brush-range-selection/example.tsx index fad10159..318dabb7 100644 --- a/benchmarks/conformance/cases/89-brush-range-selection/example.tsx +++ b/benchmarks/conformance/cases/89-brush-range-selection/example.tsx @@ -73,18 +73,21 @@ export function brushRangeDefinition( strokeWidth: 1, }), ], - x: { - scale: scaleUtc().domain(fullDomain), - axis: { - ticks: { format: (value) => brushMonthFormatter.format(value) }, - label: 'Month', + scales: { + x: { + scale: scaleUtc().domain(fullDomain), + axis: { + ticks: { format: (value) => brushMonthFormatter.format(value) }, + label: 'Month', + }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, }, }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, - }, + controls: [ brushX({ id: 'monthly-range', diff --git a/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx b/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx index 0540b1cf..02400384 100644 --- a/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx +++ b/benchmarks/conformance/cases/90-zoomable-time-window/example.tsx @@ -71,18 +71,21 @@ export function zoomTimeWindowDefinition( strokeWidth: 1, }), ], - x: { - scale: scaleUtc().domain([window.start, window.end]), - axis: { - ticks: { format: (value) => zoomDateFormatter.format(value) }, - label: 'Date', + scales: { + x: { + scale: scaleUtc().domain([window.start, window.end]), + axis: { + ticks: { format: (value) => zoomDateFormatter.format(value) }, + label: 'Date', + }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, }, }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, - }, + controls: [ zoomX({ id: 'time-window', diff --git a/benchmarks/conformance/cases/91-timeline-playback-scrubber/example.tsx b/benchmarks/conformance/cases/91-timeline-playback-scrubber/example.tsx index 56b9ece8..a06e8c06 100644 --- a/benchmarks/conformance/cases/91-timeline-playback-scrubber/example.tsx +++ b/benchmarks/conformance/cases/91-timeline-playback-scrubber/example.tsx @@ -65,24 +65,27 @@ export function playbackDefinition( strokeWidth: 1, }), ], - x: { - scale: scaleUtc, - axis: { - ticks: { - format: (value) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + scales: { + x: { + scale: scaleUtc, + axis: { + ticks: { + format: (value) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, }, }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, + }, }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 4 }, label: 'AAPL close ($)' }, - }, + controls: [ handleX({ id: 'playback-frame', diff --git a/benchmarks/conformance/cases/92-editable-event-range/example.tsx b/benchmarks/conformance/cases/92-editable-event-range/example.tsx index 3267fbfb..baaf36d3 100644 --- a/benchmarks/conformance/cases/92-editable-event-range/example.tsx +++ b/benchmarks/conformance/cases/92-editable-event-range/example.tsx @@ -100,27 +100,30 @@ export function editableEventDefinition( }), ]), ], - x: { - scale: scaleUtc().domain(editableDomain), - grid: true, - axis: { - ticks: { - format: (value: Date) => - value.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), + scales: { + x: { + scale: scaleUtc().domain(editableDomain), + grid: true, + axis: { + ticks: { + format: (value: Date) => + value.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + }, }, }, + y: { + scale: scaleBand() + .domain(editableLanes) + .paddingInner(0.38) + .paddingOuter(0.19), + grid: false, + }, }, - y: { - scale: scaleBand() - .domain(editableLanes) - .paddingInner(0.38) - .paddingOuter(0.19), - grid: false, - }, + color: { domain: ['discovery', 'design', 'campaign', 'release'], range: [ diff --git a/benchmarks/conformance/cases/93-labeled-pie/example.tsx b/benchmarks/conformance/cases/93-labeled-pie/example.tsx index 76be7d2f..b1291946 100644 --- a/benchmarks/conformance/cases/93-labeled-pie/example.tsx +++ b/benchmarks/conformance/cases/93-labeled-pie/example.tsx @@ -28,8 +28,11 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio, - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(arcs, { id: 'letter-slices', @@ -61,6 +64,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/94-center-donut/example.tsx b/benchmarks/conformance/cases/94-center-donut/example.tsx index b78eb5eb..78008546 100644 --- a/benchmarks/conformance/cases/94-center-donut/example.tsx +++ b/benchmarks/conformance/cases/94-center-donut/example.tsx @@ -27,8 +27,11 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio: 0.8, - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(arcs, { id: 'letter-slices', @@ -49,6 +52,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/95-rounded-donut/example.tsx b/benchmarks/conformance/cases/95-rounded-donut/example.tsx index 5cdf076d..fafc210a 100644 --- a/benchmarks/conformance/cases/95-rounded-donut/example.tsx +++ b/benchmarks/conformance/cases/95-rounded-donut/example.tsx @@ -33,8 +33,16 @@ export const createExampleChart = (input: ChartOptions) => { color: 'letter', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/96-nested-donut/example.tsx b/benchmarks/conformance/cases/96-nested-donut/example.tsx index 92f6e624..0ff7a58d 100644 --- a/benchmarks/conformance/cases/96-nested-donut/example.tsx +++ b/benchmarks/conformance/cases/96-nested-donut/example.tsx @@ -52,8 +52,16 @@ export const createExampleChart = (input: ChartOptions) => { color: 'name', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { domain: names, range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/97-rose/example.tsx b/benchmarks/conformance/cases/97-rose/example.tsx index 7a15e383..05f66433 100644 --- a/benchmarks/conformance/cases/97-rose/example.tsx +++ b/benchmarks/conformance/cases/97-rose/example.tsx @@ -25,11 +25,14 @@ export const createExampleChart = (input: ChartOptions) => { marks: [ polar({ radiusRatio: 0.8, - angle: { scale: () => scaleBand() }, - radius: { - scale: scaleLinear().domain([0, maximumFrequency]), - range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + scales: { + angle: { scale: () => scaleBand() }, + radius: { + scale: scaleLinear().domain([0, maximumFrequency]), + range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + }, }, + marks: [ radialBarRadius(data, { id: 'letter-bars', @@ -43,6 +46,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { range: colors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/98-needle-gauge/example.tsx b/benchmarks/conformance/cases/98-needle-gauge/example.tsx index d561fbd6..6c6aedd7 100644 --- a/benchmarks/conformance/cases/98-needle-gauge/example.tsx +++ b/benchmarks/conformance/cases/98-needle-gauge/example.tsx @@ -41,8 +41,11 @@ export const createExampleChart = (input: ChartOptions) => { { marks: [ polar({ - angle: { scale: angleScale }, - radius: { scale: radiusScale }, + scales: { + angle: { scale: angleScale }, + radius: { scale: radiusScale }, + }, + startAngle, endAngle, radiusRatio: 0.82, @@ -96,6 +99,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: bandIds, range: bandColors }, margin: 0, }, diff --git a/benchmarks/conformance/cases/99-comparative-radar/example.tsx b/benchmarks/conformance/cases/99-comparative-radar/example.tsx index 7367617b..c1dbff70 100644 --- a/benchmarks/conformance/cases/99-comparative-radar/example.tsx +++ b/benchmarks/conformance/cases/99-comparative-radar/example.tsx @@ -59,8 +59,11 @@ export const createExampleChart = (input: ChartOptions) => { { marks: [ polar({ - angle: { scale: angleScale, wrap: true }, - radius: { scale: radiusScale }, + scales: { + angle: { scale: angleScale, wrap: true }, + radius: { scale: radiusScale }, + }, + inset: 0, radiusRatio: input.preview === true ? 0.94 : 0.78, guides: [ @@ -101,6 +104,10 @@ export const createExampleChart = (input: ChartOptions) => { ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: radarCountries, range: colors, diff --git a/benchmarks/conformance/cases/bar-grouped/example.tsx b/benchmarks/conformance/cases/bar-grouped/example.tsx index 9f678386..63095fbd 100644 --- a/benchmarks/conformance/cases/bar-grouped/example.tsx +++ b/benchmarks/conformance/cases/bar-grouped/example.tsx @@ -41,16 +41,19 @@ export const createExampleChart = (input: ChartOptions) => inset: 1, }), ], - x: { - scale: () => - scaleBand().paddingInner(0.14).paddingOuter(0.06), - axis: { tickLabels: { rotate: width < 640 ? -32 : 0 } }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Penguins' }, + scales: { + x: { + scale: () => + scaleBand().paddingInner(0.14).paddingOuter(0.06), + axis: { tickLabels: { rotate: width < 640 ? -32 : 0 } }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Penguins' }, + }, }, + color: { range: sexColors, legend: colorLegend({ diff --git a/benchmarks/conformance/cases/bar-horizontal-ranking/example.tsx b/benchmarks/conformance/cases/bar-horizontal-ranking/example.tsx index 680a75b6..cac9d0f0 100644 --- a/benchmarks/conformance/cases/bar-horizontal-ranking/example.tsx +++ b/benchmarks/conformance/cases/bar-horizontal-ranking/example.tsx @@ -21,16 +21,18 @@ export const createExampleChart = (input: ChartOptions) => { }), ruleX([0]), ], - x: { - scale: scaleLinear, - grid: input.preview !== true, - axis: - input.preview === true - ? false - : { ticks: { count: 5 }, label: '2015 population' }, - }, - y: { - scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + scales: { + x: { + scale: scaleLinear, + grid: input.preview !== true, + axis: + input.preview === true + ? false + : { ticks: { count: 5 }, label: '2015 population' }, + }, + y: { + scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + }, }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/bar-stacked/example.tsx b/benchmarks/conformance/cases/bar-stacked/example.tsx index 94df51f3..08c5e563 100644 --- a/benchmarks/conformance/cases/bar-stacked/example.tsx +++ b/benchmarks/conformance/cases/bar-stacked/example.tsx @@ -27,15 +27,18 @@ export const createExampleChart = (input: ChartOptions) => { }), ruleY([0]), ], - x: { - scale: scaleUtc, - axis: { ticks: { count: 6, format: (value) => month.format(value) } }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Deaths' }, + scales: { + x: { + scale: scaleUtc, + axis: { ticks: { count: 6, format: (value) => month.format(value) } }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Deaths' }, + }, }, + color: { domain: causes, range: causeColors }, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/bar-vertical-sorted/example.tsx b/benchmarks/conformance/cases/bar-vertical-sorted/example.tsx index 709b4725..4b4c6382 100644 --- a/benchmarks/conformance/cases/bar-vertical-sorted/example.tsx +++ b/benchmarks/conformance/cases/bar-vertical-sorted/example.tsx @@ -20,19 +20,21 @@ export const createExampleChart = () => inset: 1, }), ], - x: { - scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), - axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { - ticks: { - count: 5, - format: (value: number) => percent.format(value), + scales: { + x: { + scale: () => scaleBand().paddingInner(0.1).paddingOuter(0.05), + axis: { tickLabels: { rotate: width < 560 ? -32 : 0 } }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { + ticks: { + count: 5, + format: (value: number) => percent.format(value), + }, + label: 'Frequency', }, - label: 'Frequency', }, }, }), diff --git a/benchmarks/conformance/cases/facets-anscombe/example.tsx b/benchmarks/conformance/cases/facets-anscombe/example.tsx index d5f05d4a..df5326c9 100644 --- a/benchmarks/conformance/cases/facets-anscombe/example.tsx +++ b/benchmarks/conformance/cases/facets-anscombe/example.tsx @@ -23,19 +23,25 @@ export const createExampleChart = (input: ChartOptions) => fill: '#2563eb', }), ], - x: { - scale: scaleLinear().domain([3, 20]), - grid: input.preview !== true, - axis: input.preview === true ? false : { ticks: { count: 5 } }, - }, - y: { - scale: scaleLinear().domain([2, 14]), - grid: input.preview !== true, - axis: input.preview === true ? false : { ticks: { count: 4 } }, + scales: { + x: { + scale: scaleLinear().domain([3, 20]), + grid: input.preview !== true, + axis: input.preview === true ? false : { ticks: { count: 5 } }, + }, + y: { + scale: scaleLinear().domain([2, 14]), + grid: input.preview !== true, + axis: input.preview === true ? false : { ticks: { count: 4 } }, + }, }, }), }), ], + scales: { + x: null, + y: null, + }, margin: 0, }, { keyboard: true, tooltip: exampleTooltip }, diff --git a/benchmarks/conformance/cases/heatmap-labeled/example.tsx b/benchmarks/conformance/cases/heatmap-labeled/example.tsx index d994d4d1..f11e38f1 100644 --- a/benchmarks/conformance/cases/heatmap-labeled/example.tsx +++ b/benchmarks/conformance/cases/heatmap-labeled/example.tsx @@ -50,20 +50,23 @@ export const createExampleChart = (input: ChartOptions) => { fontWeight: 600, }), ], - x: { - scale: scaleBand() - .domain(episodeDomain) - .paddingInner(0.04) - .paddingOuter(0.02), - axis: { label: 'Episode' }, - }, - y: { - scale: scaleBand() - .domain(seasonDomain) - .paddingInner(0.04) - .paddingOuter(0.02), - axis: { label: 'Season' }, + scales: { + x: { + scale: scaleBand() + .domain(episodeDomain) + .paddingInner(0.04) + .paddingOuter(0.02), + axis: { label: 'Episode' }, + }, + y: { + scale: scaleBand() + .domain(seasonDomain) + .paddingInner(0.04) + .paddingOuter(0.02), + axis: { label: 'Season' }, + }, }, + color: { scale: () => scaleLinear().range(ratingColors), legend: colorGradientLegend({ label: 'IMDb rating', steps: 6 }), diff --git a/benchmarks/conformance/cases/histogram/example.tsx b/benchmarks/conformance/cases/histogram/example.tsx index badabaf1..c1c645aa 100644 --- a/benchmarks/conformance/cases/histogram/example.tsx +++ b/benchmarks/conformance/cases/histogram/example.tsx @@ -31,12 +31,14 @@ export const createExampleChart = (input: ChartOptions) => { inset: 1, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Fuel economy (mpg)' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Fuel economy (mpg)' }, + }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Count' } }, }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Count' } }, }, { keyboard: true, diff --git a/benchmarks/conformance/cases/scatter-bubble/example.tsx b/benchmarks/conformance/cases/scatter-bubble/example.tsx index db1bc47f..d08c9e03 100644 --- a/benchmarks/conformance/cases/scatter-bubble/example.tsx +++ b/benchmarks/conformance/cases/scatter-bubble/example.tsx @@ -29,12 +29,19 @@ export const createExampleChart = (input: ChartOptions) => { strokeWidth: 0.75, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Bill length (mm)' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill length (mm)' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill depth (mm)' }, + }, }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Bill depth (mm)' } }, + color: { range: groupRange, legend: colorLegend({ label: 'Species' }), diff --git a/benchmarks/conformance/previews/115-definition-motion.svg b/benchmarks/conformance/previews/115-definition-motion.svg index 1a551900..df184ba6 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/80-echarts-axis-pointer.svg b/benchmarks/conformance/previews/80-echarts-axis-pointer.svg index 62a8cc11..7b7eab29 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/bar-horizontal-ranking.svg b/benchmarks/conformance/previews/bar-horizontal-ranking.svg index 7b146ea8..a3f2b90a 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/manifest.json b/benchmarks/conformance/previews/manifest.json index 7d1e4d4b..5175b615 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "136ba5c0be5fdc8fc8c7e18e0663f3ddecfc851acaf585a175f2031ad1468d32", + "sourceHash": "99fa46439aa2b513dc5c09e9bc5e9f4d7dacb18ed9e5f5d289df9d16082bb7fa", "assets": [ { "id": "01-line-gaps", @@ -31,7 +31,7 @@ }, { "id": "bar-horizontal-ranking", - "sha256": "58f73c04a239ef117ebf39ea868f1e1701d72fb52c39e83d14f9d8d8aa98c64a", + "sha256": "062085685118d5154e0b5ab6eee1d695347e63871a5f82e2302e86121ccacc8c", "bytes": 6734 }, { @@ -351,7 +351,7 @@ }, { "id": "80-echarts-axis-pointer", - "sha256": "d14d04621a0b9dd5d9a50eef68258e18ea30123e86fb6aa28eeaafdfe5e320d9", + "sha256": "ed410449ee25240faf6640bfe12f4968357c68d68bd9b47bd12ffcf79d06ba71", "bytes": 11445 }, { @@ -531,7 +531,7 @@ }, { "id": "115-definition-motion", - "sha256": "5f73247b5a696ee66068a2d8fd532e5e629c44edff62681de4251b14bca8ab15", + "sha256": "79c5a21713f55e561938157f728be9a29943f6b495a617c34dcda50e899a075b", "bytes": 6708 }, { diff --git a/benchmarks/conformance/shared/mount.test.ts b/benchmarks/conformance/shared/mount.test.ts index 8cb625b8..025bc6c5 100644 --- a/benchmarks/conformance/shared/mount.test.ts +++ b/benchmarks/conformance/shared/mount.test.ts @@ -20,8 +20,10 @@ const definition = (_input: ConformanceInput) => key: 'id', }), ], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) describe('tanstackMount', () => { diff --git a/benchmarks/entries/charts-area-x-svg.ts b/benchmarks/entries/charts-area-x-svg.ts index 2e30b1a0..b80b00b2 100644 --- a/benchmarks/entries/charts-area-x-svg.ts +++ b/benchmarks/entries/charts-area-x-svg.ts @@ -19,8 +19,10 @@ const definition = defineChart({ curve: d3AreaXCurve(curveBasis), }), ], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-arrow-svg.ts b/benchmarks/entries/charts-arrow-svg.ts index eb598d58..6b14ff4c 100644 --- a/benchmarks/entries/charts-arrow-svg.ts +++ b/benchmarks/entries/charts-arrow-svg.ts @@ -17,8 +17,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([0, 6]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 6]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-box-svg.ts b/benchmarks/entries/charts-box-svg.ts index 90ca07c7..136d0b4b 100644 --- a/benchmarks/entries/charts-box-svg.ts +++ b/benchmarks/entries/charts-box-svg.ts @@ -21,8 +21,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleBand }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-compact-linear-scene.ts b/benchmarks/entries/charts-compact-linear-scene.ts index 031a5a10..8ad440b2 100644 --- a/benchmarks/entries/charts-compact-linear-scene.ts +++ b/benchmarks/entries/charts-compact-linear-scene.ts @@ -3,8 +3,10 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export const scene = createChartScene(definition, { diff --git a/benchmarks/entries/charts-composite-mark.ts b/benchmarks/entries/charts-composite-mark.ts index bd83e810..d70c6209 100644 --- a/benchmarks/entries/charts-composite-mark.ts +++ b/benchmarks/entries/charts-composite-mark.ts @@ -51,8 +51,10 @@ const definition = defineChart({ ), ], guides: false, - x: { scale: scaleBand }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-core.ts b/benchmarks/entries/charts-core.ts index 1fa3437b..14320956 100644 --- a/benchmarks/entries/charts-core.ts +++ b/benchmarks/entries/charts-core.ts @@ -16,8 +16,10 @@ export const scene = createChartScene( key: 'id', }), ], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }), { width: 640, height: 320 }, ) diff --git a/benchmarks/entries/charts-curve-svg.ts b/benchmarks/entries/charts-curve-svg.ts index eefda7d5..1a2b14a7 100644 --- a/benchmarks/entries/charts-curve-svg.ts +++ b/benchmarks/entries/charts-curve-svg.ts @@ -14,8 +14,10 @@ export function render( ): string { const definition = defineChart({ marks: [lineY([4, 9, 7, 12, 10], { curve })], - x: { scale: scaleLinear().domain([0, 4]) }, - y: { scale: scaleLinear().domain([0, 12]) }, + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 12]) }, + }, }) return renderChartSvg(createChartScene(definition, { width, height }), { ariaLabel: 'Curved values', diff --git a/benchmarks/entries/charts-custom-scale-scene.ts b/benchmarks/entries/charts-custom-scale-scene.ts index 89f53c91..149a6b3b 100644 --- a/benchmarks/entries/charts-custom-scale-scene.ts +++ b/benchmarks/entries/charts-custom-scale-scene.ts @@ -35,8 +35,10 @@ function fixedLinearScale( const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: fixedLinearScale([0, 2], [0, 1, 2]) }, - y: { scale: fixedLinearScale([0, 10], [0, 5, 10]) }, + scales: { + x: { scale: fixedLinearScale([0, 2], [0, 1, 2]) }, + y: { scale: fixedLinearScale([0, 10], [0, 5, 10]) }, + }, }) export const scene = createChartScene(definition, { diff --git a/benchmarks/entries/charts-d3-curved-line-scene.ts b/benchmarks/entries/charts-d3-curved-line-scene.ts index 239df224..ac80903d 100644 --- a/benchmarks/entries/charts-d3-curved-line-scene.ts +++ b/benchmarks/entries/charts-d3-curved-line-scene.ts @@ -5,8 +5,10 @@ import { curveMonotoneX } from 'd3-shape' const definition = defineChart({ marks: [lineY([4, 9, 7], { curve: d3Curve(curveMonotoneX) })], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export const scene = createChartScene(definition, { diff --git a/benchmarks/entries/charts-d3-delaunay-dom.ts b/benchmarks/entries/charts-d3-delaunay-dom.ts index 18bdc14e..48893d3c 100644 --- a/benchmarks/entries/charts-d3-delaunay-dom.ts +++ b/benchmarks/entries/charts-d3-delaunay-dom.ts @@ -32,8 +32,11 @@ const createDelaunayPointIndex: ChartSpatialIndexFactory< const definition = defineChart({ marks: [dot([{ x: 0, y: 0 }], { x: 'x', y: 'y' })], - x: { scale: scaleLinear().domain([-1, 1]) }, - y: { scale: scaleLinear().domain([-1, 1]) }, + scales: { + x: { scale: scaleLinear().domain([-1, 1]) }, + y: { scale: scaleLinear().domain([-1, 1]) }, + }, + spatialIndex: createDelaunayPointIndex, }) diff --git a/benchmarks/entries/charts-d3-linear-scene.ts b/benchmarks/entries/charts-d3-linear-scene.ts index a137b869..090c31ef 100644 --- a/benchmarks/entries/charts-d3-linear-scene.ts +++ b/benchmarks/entries/charts-d3-linear-scene.ts @@ -3,8 +3,10 @@ import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export const scene = createChartScene(definition, { diff --git a/benchmarks/entries/charts-d3-quadtree-dom.ts b/benchmarks/entries/charts-d3-quadtree-dom.ts index 38d25553..37c73e29 100644 --- a/benchmarks/entries/charts-d3-quadtree-dom.ts +++ b/benchmarks/entries/charts-d3-quadtree-dom.ts @@ -25,8 +25,11 @@ const createQuadtreePointIndex: ChartSpatialIndexFactory< const definition = defineChart({ marks: [dot([{ x: 0, y: 0 }], { x: 'x', y: 'y' })], - x: { scale: scaleLinear().domain([-1, 1]) }, - y: { scale: scaleLinear().domain([-1, 1]) }, + scales: { + x: { scale: scaleLinear().domain([-1, 1]) }, + y: { scale: scaleLinear().domain([-1, 1]) }, + }, + spatialIndex: createQuadtreePointIndex, }) diff --git a/benchmarks/entries/charts-d3-time-scene.ts b/benchmarks/entries/charts-d3-time-scene.ts index 308a35b4..f1a072eb 100644 --- a/benchmarks/entries/charts-d3-time-scene.ts +++ b/benchmarks/entries/charts-d3-time-scene.ts @@ -7,8 +7,10 @@ const data = [ ] const definition = defineChart({ marks: [lineY(data, { x: 'date', y: 'value' })], - x: { scale: scaleUtc().domain(data.map((point) => point.date)) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleUtc().domain(data.map((point) => point.date)) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export const scene = createChartScene(definition, { diff --git a/benchmarks/entries/charts-d3-time-svg.ts b/benchmarks/entries/charts-d3-time-svg.ts index 271c038c..d27f6354 100644 --- a/benchmarks/entries/charts-d3-time-svg.ts +++ b/benchmarks/entries/charts-d3-time-svg.ts @@ -20,8 +20,10 @@ const definition = defineChart({ { x: 'date', y: 'value' }, ), ], - x: { scale: scaleUtc().domain(dates) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleUtc().domain(dates) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-d3-transform-histogram.ts b/benchmarks/entries/charts-d3-transform-histogram.ts index 7a0560ef..4ccd78b1 100644 --- a/benchmarks/entries/charts-d3-transform-histogram.ts +++ b/benchmarks/entries/charts-d3-transform-histogram.ts @@ -22,8 +22,11 @@ const definition = defineChart({ z: (entry) => entry.length, }), ], - x: { scale: scaleLinear().domain([0, 25]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 25]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + color: { scale: scaleLinear().domain([0, 4]).range(['#eff6ff', '#2563eb']), legend: colorGradientLegend(), diff --git a/benchmarks/entries/charts-decorative-line-svg.ts b/benchmarks/entries/charts-decorative-line-svg.ts index 8311b275..ffafe2fe 100644 --- a/benchmarks/entries/charts-decorative-line-svg.ts +++ b/benchmarks/entries/charts-decorative-line-svg.ts @@ -6,8 +6,10 @@ import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [decorative(lineY([4, 9, 7]))], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-difference-svg.ts b/benchmarks/entries/charts-difference-svg.ts index 3a2c7b36..862ce27f 100644 --- a/benchmarks/entries/charts-difference-svg.ts +++ b/benchmarks/entries/charts-difference-svg.ts @@ -20,8 +20,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-dodge-svg.ts b/benchmarks/entries/charts-dodge-svg.ts index ece25132..88ab6a6c 100644 --- a/benchmarks/entries/charts-dodge-svg.ts +++ b/benchmarks/entries/charts-dodge-svg.ts @@ -18,7 +18,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleLinear().domain([0, 20]) }, + scales: { + x: { scale: scaleLinear().domain([0, 20]) }, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-dot-svg.ts b/benchmarks/entries/charts-dot-svg.ts index 949542be..f424d6c3 100644 --- a/benchmarks/entries/charts-dot-svg.ts +++ b/benchmarks/entries/charts-dot-svg.ts @@ -11,8 +11,10 @@ const rows = [ const definition = defineChart({ marks: [dot(rows, { x: 'x', y: 'y', key: 'id', r: 4 })], guides: false, - x: { scale: scaleLinear().domain([0, 4]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-facet-svg.ts b/benchmarks/entries/charts-facet-svg.ts index 8a17f27e..52790c6c 100644 --- a/benchmarks/entries/charts-facet-svg.ts +++ b/benchmarks/entries/charts-facet-svg.ts @@ -16,8 +16,10 @@ const definition = facetChart(data, { by: 'group', chart: (group) => ({ marks: [lineY(group, { x: 'x', y: 'y' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 6]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 6]) }, + }, }), }) diff --git a/benchmarks/entries/charts-focus-guide.ts b/benchmarks/entries/charts-focus-guide.ts index f79e6174..73099250 100644 --- a/benchmarks/entries/charts-focus-guide.ts +++ b/benchmarks/entries/charts-focus-guide.ts @@ -24,8 +24,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleLinear().domain([0, 4]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-frame-svg.ts b/benchmarks/entries/charts-frame-svg.ts index 62cc4261..43a17155 100644 --- a/benchmarks/entries/charts-frame-svg.ts +++ b/benchmarks/entries/charts-frame-svg.ts @@ -11,8 +11,10 @@ const definition = defineChart({ }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-geo-svg.ts b/benchmarks/entries/charts-geo-svg.ts index 53e9ba00..8f13c48e 100644 --- a/benchmarks/entries/charts-geo-svg.ts +++ b/benchmarks/entries/charts-geo-svg.ts @@ -62,8 +62,10 @@ const definition = defineChart({ ], guides: false, margin: 8, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-hexagon-svg.ts b/benchmarks/entries/charts-hexagon-svg.ts index e44e0192..a1b55f4c 100644 --- a/benchmarks/entries/charts-hexagon-svg.ts +++ b/benchmarks/entries/charts-hexagon-svg.ts @@ -19,8 +19,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([-1, 6]) }, - y: { scale: scaleLinear().domain([-1, 4]) }, + scales: { + x: { scale: scaleLinear().domain([-1, 6]) }, + y: { scale: scaleLinear().domain([-1, 4]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-histogram-svg.ts b/benchmarks/entries/charts-histogram-svg.ts index 6707ab9f..06faa5ba 100644 --- a/benchmarks/entries/charts-histogram-svg.ts +++ b/benchmarks/entries/charts-histogram-svg.ts @@ -23,8 +23,11 @@ const definition = defineChart({ z: 'value', }), ], - x: { scale: scaleLinear().domain([0, 25]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 25]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + color: { scale: scaleLinear().domain([0, 4]).range(['#eff6ff', '#2563eb']), legend: colorGradientLegend(), diff --git a/benchmarks/entries/charts-line-x-svg.ts b/benchmarks/entries/charts-line-x-svg.ts index 3ef0f58a..ade9864d 100644 --- a/benchmarks/entries/charts-line-x-svg.ts +++ b/benchmarks/entries/charts-line-x-svg.ts @@ -5,8 +5,10 @@ import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [lineX([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 10]) }, - y: { scale: scaleLinear().domain([0, 2]) }, + scales: { + x: { scale: scaleLinear().domain([0, 10]) }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-link-svg.ts b/benchmarks/entries/charts-link-svg.ts index 60a59770..89d62bd2 100644 --- a/benchmarks/entries/charts-link-svg.ts +++ b/benchmarks/entries/charts-link-svg.ts @@ -17,8 +17,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([0, 6]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 6]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-polar-arc-svg.ts b/benchmarks/entries/charts-polar-arc-svg.ts index 9f3a0b3a..f4b552a1 100644 --- a/benchmarks/entries/charts-polar-arc-svg.ts +++ b/benchmarks/entries/charts-polar-arc-svg.ts @@ -28,11 +28,17 @@ const definition = defineChart({ fill: (row) => row.fill, }), ], + scales: { + angle: null, + radius: null, + }, }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-polar-gauge-svg.ts b/benchmarks/entries/charts-polar-gauge-svg.ts index fd815da7..31371c55 100644 --- a/benchmarks/entries/charts-polar-gauge-svg.ts +++ b/benchmarks/entries/charts-polar-gauge-svg.ts @@ -38,8 +38,11 @@ const definition = defineChart({ polar({ startAngle, endAngle, - angle: { scale: scaleLinear().domain([0, 100]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 100]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(bands, { key: 'id', @@ -71,8 +74,10 @@ const definition = defineChart({ }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-polar-line-scatter-svg.ts b/benchmarks/entries/charts-polar-line-scatter-svg.ts index 20e8e40f..d614a79d 100644 --- a/benchmarks/entries/charts-polar-line-scatter-svg.ts +++ b/benchmarks/entries/charts-polar-line-scatter-svg.ts @@ -21,8 +21,11 @@ const samples = [ const definition = defineChart({ marks: [ polar({ - angle: { scale: scalePoint().domain(metrics), wrap: true }, - radius: { scale: scaleLinear().domain([0, 100]) }, + scales: { + angle: { scale: scalePoint().domain(metrics), wrap: true }, + radius: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: [ radialGrid({ values: [25, 50, 75, 100] }), angleGrid({ values: metrics }), @@ -46,8 +49,10 @@ const definition = defineChart({ }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-polar-pie-svg.ts b/benchmarks/entries/charts-polar-pie-svg.ts index 9f251b12..ffb1303d 100644 --- a/benchmarks/entries/charts-polar-pie-svg.ts +++ b/benchmarks/entries/charts-polar-pie-svg.ts @@ -20,11 +20,17 @@ const definition = defineChart({ fill: 'fill', }), ], + scales: { + angle: null, + radius: null, + }, }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-radial-bar-svg.ts b/benchmarks/entries/charts-radial-bar-svg.ts index 74ae17ff..4919fd5d 100644 --- a/benchmarks/entries/charts-radial-bar-svg.ts +++ b/benchmarks/entries/charts-radial-bar-svg.ts @@ -11,11 +11,14 @@ const rows = [ const radiusDefinition = defineChart({ marks: [ polar({ - angle: { scale: () => scaleBand().padding(0.08) }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + scales: { + angle: { scale: () => scaleBand().padding(0.08) }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + }, }, + marks: [ radialBarRadius(rows, { angle: 'id', @@ -26,16 +29,23 @@ const radiusDefinition = defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) const angleDefinition = defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 8]) }, - radius: { - scale: () => scaleBand().paddingInner(0.3).paddingOuter(0.15), - range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + scales: { + angle: { scale: scaleLinear().domain([0, 8]) }, + radius: { + scale: () => scaleBand().paddingInner(0.3).paddingOuter(0.15), + range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + }, }, + marks: [ radialBarAngle(rows, { angle: 'value', @@ -47,6 +57,10 @@ const angleDefinition = defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-radial-label-svg.ts b/benchmarks/entries/charts-radial-label-svg.ts index 0c2ed2d6..c371a413 100644 --- a/benchmarks/entries/charts-radial-label-svg.ts +++ b/benchmarks/entries/charts-radial-label-svg.ts @@ -15,8 +15,11 @@ const definition = defineChart({ marks: [ polar({ radiusRatio: 0.72, - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialRule(labels, { angle: 'angle', @@ -36,6 +39,10 @@ const definition = defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, margin: 0, }) diff --git a/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts b/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts index 4b9b30b8..67c2da04 100644 --- a/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts +++ b/benchmarks/entries/charts-react-compact-line-tooltip-portal.ts @@ -7,8 +7,11 @@ export { Chart } from '@tanstack/charts/react' export const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + tooltip: { use: tooltip, portal, diff --git a/benchmarks/entries/charts-react-compact-line-tooltip.ts b/benchmarks/entries/charts-react-compact-line-tooltip.ts index 81024e93..db7ad548 100644 --- a/benchmarks/entries/charts-react-compact-line-tooltip.ts +++ b/benchmarks/entries/charts-react-compact-line-tooltip.ts @@ -6,7 +6,10 @@ export { Chart } from '@tanstack/charts/react' export const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + tooltip, }) diff --git a/benchmarks/entries/charts-react-compact-line.ts b/benchmarks/entries/charts-react-compact-line.ts index 17dbe7e9..98f6a190 100644 --- a/benchmarks/entries/charts-react-compact-line.ts +++ b/benchmarks/entries/charts-react-compact-line.ts @@ -5,6 +5,8 @@ export { Chart } from '@tanstack/charts/react' export const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) diff --git a/benchmarks/entries/charts-react-line-mark-canvas.ts b/benchmarks/entries/charts-react-line-mark-canvas.ts new file mode 100644 index 00000000..28a24a90 --- /dev/null +++ b/benchmarks/entries/charts-react-line-mark-canvas.ts @@ -0,0 +1,13 @@ +import { defineChart, lineY } from '@tanstack/charts' +import { canvasChartRenderer } from '@tanstack/charts/canvas' +import { scaleLinear } from 'd3-scale' + +export { Chart } from '@tanstack/charts/react' + +export const definition = defineChart({ + marks: [lineY([4, 9, 7], { renderer: canvasChartRenderer })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, +}) diff --git a/benchmarks/entries/charts-react-line.ts b/benchmarks/entries/charts-react-line.ts index 43ddc088..775d3889 100644 --- a/benchmarks/entries/charts-react-line.ts +++ b/benchmarks/entries/charts-react-line.ts @@ -5,6 +5,8 @@ export { Chart } from '@tanstack/charts/react' export const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) diff --git a/benchmarks/entries/charts-react-native-line.ts b/benchmarks/entries/charts-react-native-line.ts index d894f6ab..ce107683 100644 --- a/benchmarks/entries/charts-react-native-line.ts +++ b/benchmarks/entries/charts-react-native-line.ts @@ -6,6 +6,8 @@ export { Chart } from '@tanstack/charts/react-native' export const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) diff --git a/benchmarks/entries/charts-regression-svg.ts b/benchmarks/entries/charts-regression-svg.ts index e9409951..c492d888 100644 --- a/benchmarks/entries/charts-regression-svg.ts +++ b/benchmarks/entries/charts-regression-svg.ts @@ -20,8 +20,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-representative-mark-canvas.ts b/benchmarks/entries/charts-representative-mark-canvas.ts new file mode 100644 index 00000000..45aa1d38 --- /dev/null +++ b/benchmarks/entries/charts-representative-mark-canvas.ts @@ -0,0 +1,51 @@ +import { + areaY, + barY, + createChartScene, + defineChart, + dot, + lineY, + renderChartSvg, + ruleX, + ruleY, + text, +} from '@tanstack/charts' +import { canvasChartRenderer } from '@tanstack/charts/canvas' +import { scaleBand, scaleLinear } from 'd3-scale' + +const data = [ + { id: 'a', category: 'Alpha', x: 0, y: 4, label: 'A' }, + { id: 'b', category: 'Beta', x: 1, y: 8, label: 'B' }, +] + +const definition = defineChart({ + marks: [ + areaY(data, { x: 'category', y: 'y' }), + barY(data, { x: 'category', y: 'y', key: 'id' }), + lineY(data, { x: 'category', y: 'y', key: 'id' }), + dot(data, { + x: 'category', + y: 'y', + key: 'id', + renderer: canvasChartRenderer, + }), + ruleX(['Alpha']), + ruleY([0]), + text(data, { x: 'category', y: 'y', text: 'label' }), + ], + scales: { + x: { + scale: scaleBand() + .domain(data.map((point) => point.category)) + .paddingInner(0.1) + .paddingOuter(0.05), + }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Representative chart', + }) +} diff --git a/benchmarks/entries/charts-representative.ts b/benchmarks/entries/charts-representative.ts index a7ea347b..e3f2bc69 100644 --- a/benchmarks/entries/charts-representative.ts +++ b/benchmarks/entries/charts-representative.ts @@ -27,13 +27,15 @@ const definition = defineChart({ ruleY([0]), text(data, { x: 'category', y: 'y', text: 'label' }), ], - x: { - scale: scaleBand() - .domain(data.map((point) => point.category)) - .paddingInner(0.1) - .paddingOuter(0.05), + scales: { + x: { + scale: scaleBand() + .domain(data.map((point) => point.category)) + .paddingInner(0.1) + .paddingOuter(0.05), + }, + y: { scale: scaleLinear().domain([0, 8]) }, }, - y: { scale: scaleLinear().domain([0, 8]) }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-ridgeline-svg.ts b/benchmarks/entries/charts-ridgeline-svg.ts index a56ba413..155b8a8d 100644 --- a/benchmarks/entries/charts-ridgeline-svg.ts +++ b/benchmarks/entries/charts-ridgeline-svg.ts @@ -43,8 +43,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: categoryScale }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: categoryScale }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-selected-overlay-svg.ts b/benchmarks/entries/charts-selected-overlay-svg.ts index 5695f3d1..6e59483e 100644 --- a/benchmarks/entries/charts-selected-overlay-svg.ts +++ b/benchmarks/entries/charts-selected-overlay-svg.ts @@ -45,8 +45,11 @@ const definition = defineChart({ ), ], guides: false, - x: { scale: scaleLinear().domain([0, 4]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + selection, }) diff --git a/benchmarks/entries/charts-spatial-contour-svg.ts b/benchmarks/entries/charts-spatial-contour-svg.ts index 07d33976..86caf955 100644 --- a/benchmarks/entries/charts-spatial-contour-svg.ts +++ b/benchmarks/entries/charts-spatial-contour-svg.ts @@ -16,6 +16,10 @@ const definition = defineChart({ strokeWidth: 0.75, }), ], + scales: { + x: null, + y: null, + }, guides: false, }) diff --git a/benchmarks/entries/charts-spatial-delaunay-svg.ts b/benchmarks/entries/charts-spatial-delaunay-svg.ts index 992a547e..627ff39c 100644 --- a/benchmarks/entries/charts-spatial-delaunay-svg.ts +++ b/benchmarks/entries/charts-spatial-delaunay-svg.ts @@ -10,8 +10,10 @@ const rows = Array.from({ length: 24 }, (_, index) => ({ })) const definition = defineChart({ marks: [delaunayLink(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 100]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-spatial-density-svg.ts b/benchmarks/entries/charts-spatial-density-svg.ts index 58009f59..840883e1 100644 --- a/benchmarks/entries/charts-spatial-density-svg.ts +++ b/benchmarks/entries/charts-spatial-density-svg.ts @@ -19,8 +19,10 @@ const definition = defineChart({ stroke: '#1e3a8a', }), ], - x: { scale: scaleLinear().domain([0, 100]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-spatial-hexbin-svg.ts b/benchmarks/entries/charts-spatial-hexbin-svg.ts index db60b4c9..ce4522cc 100644 --- a/benchmarks/entries/charts-spatial-hexbin-svg.ts +++ b/benchmarks/entries/charts-spatial-hexbin-svg.ts @@ -17,8 +17,11 @@ const definition = defineChart({ binWidth: 20, }), ], - x: { scale: scaleLinear().domain([0, 100]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + color: { scale: scaleLinear, range: ['#dbeafe', '#1d4ed8'], diff --git a/benchmarks/entries/charts-spatial-voronoi-svg.ts b/benchmarks/entries/charts-spatial-voronoi-svg.ts index 75a6e89c..c7385c0c 100644 --- a/benchmarks/entries/charts-spatial-voronoi-svg.ts +++ b/benchmarks/entries/charts-spatial-voronoi-svg.ts @@ -21,8 +21,10 @@ const definition = defineChart({ strokeWidth: 1, }), ], - x: { scale: scaleLinear().domain([0, 100]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-svg.ts b/benchmarks/entries/charts-svg.ts index f10085fd..998a2b93 100644 --- a/benchmarks/entries/charts-svg.ts +++ b/benchmarks/entries/charts-svg.ts @@ -8,8 +8,10 @@ import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-tick-label-accessors.ts b/benchmarks/entries/charts-tick-label-accessors.ts index 1e0bb898..e108fc21 100644 --- a/benchmarks/entries/charts-tick-label-accessors.ts +++ b/benchmarks/entries/charts-tick-label-accessors.ts @@ -8,22 +8,24 @@ import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { - scale: scaleLinear().domain([0, 2]), - axis: { - ticks: { values: [0, 1, 2] }, - tickLabels: { - fontSize: ({ index }) => (index === 0 ? 13 : undefined), - fontWeight: ({ index }) => (index === 0 ? 600 : undefined), - opacity: ({ index }) => (index === 0 ? 0.62 : undefined), - anchor: ({ index }) => (index === 0 ? 'start' : undefined), - dx: ({ index, bandwidth }) => - index === 0 ? -bandwidth / 2 : undefined, - dy: ({ index }) => (index === 0 ? 1 : undefined), + scales: { + x: { + scale: scaleLinear().domain([0, 2]), + axis: { + ticks: { values: [0, 1, 2] }, + tickLabels: { + fontSize: ({ index }) => (index === 0 ? 13 : undefined), + fontWeight: ({ index }) => (index === 0 ? 600 : undefined), + opacity: ({ index }) => (index === 0 ? 0.62 : undefined), + anchor: ({ index }) => (index === 0 ? 'start' : undefined), + dx: ({ index, bandwidth }) => + index === 0 ? -bandwidth / 2 : undefined, + dy: ({ index }) => (index === 0 ? 1 : undefined), + }, }, }, + y: { scale: scaleLinear().domain([0, 10]) }, }, - y: { scale: scaleLinear().domain([0, 10]) }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-tick-svg.ts b/benchmarks/entries/charts-tick-svg.ts index 5b527eaf..1bfc703e 100644 --- a/benchmarks/entries/charts-tick-svg.ts +++ b/benchmarks/entries/charts-tick-svg.ts @@ -13,8 +13,10 @@ const definition = defineChart({ tickY(data, { x: 'category', y: 'high' }), tickX(data, { x: 'category', y: 'value' }), ], - x: { scale: scaleBand().domain(['A', 'B']).padding(0.2) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleBand().domain(['A', 'B']).padding(0.2) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-time-svg.ts b/benchmarks/entries/charts-time-svg.ts index 271c038c..d27f6354 100644 --- a/benchmarks/entries/charts-time-svg.ts +++ b/benchmarks/entries/charts-time-svg.ts @@ -20,8 +20,10 @@ const definition = defineChart({ { x: 'date', y: 'value' }, ), ], - x: { scale: scaleUtc().domain(dates) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleUtc().domain(dates) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-vector-svg.ts b/benchmarks/entries/charts-vector-svg.ts index 1c4dd60b..5274f88d 100644 --- a/benchmarks/entries/charts-vector-svg.ts +++ b/benchmarks/entries/charts-vector-svg.ts @@ -21,8 +21,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([-1, 6]) }, - y: { scale: scaleLinear().domain([-1, 4]) }, + scales: { + x: { scale: scaleLinear().domain([-1, 6]) }, + y: { scale: scaleLinear().domain([-1, 4]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-view-composition.ts b/benchmarks/entries/charts-view-composition.ts index a5968193..57cbf1a1 100644 --- a/benchmarks/entries/charts-view-composition.ts +++ b/benchmarks/entries/charts-view-composition.ts @@ -13,14 +13,19 @@ const definition = composeViews({ views: { overview: defineChart({ marks: [dot(rows, { x: 'x', y: 'y', r: 2 })], - x: { scale: x }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: x }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + guides: false, }), main: defineChart({ marks: [dot(rows, { x: 'x', y: 'y' })], - x: { scale: x }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: x }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }), }, layout: grid({ diff --git a/benchmarks/entries/charts-violin-svg.ts b/benchmarks/entries/charts-violin-svg.ts index fd1f1a13..905ad8d1 100644 --- a/benchmarks/entries/charts-violin-svg.ts +++ b/benchmarks/entries/charts-violin-svg.ts @@ -43,8 +43,10 @@ const definition = defineChart({ }), ], guides: false, - x: { scale: categoryScale }, - y: { scale: scaleLinear().domain([0, 2]) }, + scales: { + x: { scale: categoryScale }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/entries/charts-waffle-svg.ts b/benchmarks/entries/charts-waffle-svg.ts index 54889e70..5050ee6d 100644 --- a/benchmarks/entries/charts-waffle-svg.ts +++ b/benchmarks/entries/charts-waffle-svg.ts @@ -19,8 +19,10 @@ const definition = defineChart({ }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, }) export function render(width: number, height: number) { diff --git a/benchmarks/motion/poc.ts b/benchmarks/motion/poc.ts index ef08036f..ebca8e2b 100644 --- a/benchmarks/motion/poc.ts +++ b/benchmarks/motion/poc.ts @@ -69,8 +69,11 @@ const baseDefinition = defineChart({ points: true, }), ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: { top: 20, right: 20, bottom: 20, left: 20 }, }) diff --git a/benchmarks/motion/update-poc.ts b/benchmarks/motion/update-poc.ts index 61e50786..74b7f4a1 100644 --- a/benchmarks/motion/update-poc.ts +++ b/benchmarks/motion/update-poc.ts @@ -214,8 +214,11 @@ function chartDefinition( points: true, }), ], - x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleBand().domain(rows.map((row) => row.period)) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: { top: 24, right: 24, bottom: 24, left: 24 }, }, diff --git a/benchmarks/rendering.ts b/benchmarks/rendering.ts index 98e908e7..b31dad29 100644 --- a/benchmarks/rendering.ts +++ b/benchmarks/rendering.ts @@ -120,15 +120,17 @@ const nativeDownloads = defineChart({ y: 'downloads', }), ], - x: { - scale: scaleUtc().domain(downloadData.map((point) => point.date)), - axis: { ticks: { count: 6 } }, - }, - y: { - scale: scaleLinear() - .domain([0, max(downloadData, (point) => point.downloads) ?? 1]) - .nice(5), - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: scaleUtc().domain(downloadData.map((point) => point.date)), + axis: { ticks: { count: 6 } }, + }, + y: { + scale: scaleLinear() + .domain([0, max(downloadData, (point) => point.downloads) ?? 1]) + .nice(5), + axis: { ticks: { count: 5 } }, + }, }, }) const largeData = Array.from({ length: 10_000 }, (_, index) => ({ @@ -148,8 +150,10 @@ const nativeLarge = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([0, largeData.length - 1]) }, - y: { scale: scaleLinear().domain([largeMinimum, largeMaximum]) }, + scales: { + x: { scale: scaleLinear().domain([0, largeData.length - 1]) }, + y: { scale: scaleLinear().domain([largeMinimum, largeMaximum]) }, + }, }) const d3Downloads = defineD3Chart({ marks: [ @@ -295,13 +299,15 @@ const createNativeDynamicDefinition = (input: { y: 'downloads', }), ], - x: { - scale: scaleUtc().domain(input.points.map((point) => point.date)), - axis: { ticks: { count: 6 } }, - }, - y: { - scale: scaleLinear().domain([0, maximum]).nice(5), - axis: { ticks: { count: 5 } }, + scales: { + x: { + scale: scaleUtc().domain(input.points.map((point) => point.date)), + axis: { ticks: { count: 6 } }, + }, + y: { + scale: scaleLinear().domain([0, maximum]).nice(5), + axis: { ticks: { count: 5 } }, + }, }, } }) @@ -324,8 +330,10 @@ const d3DynamicDefinition = defineD3Chart<{ y: 'downloads', }), ], - x: { type: d3ScaleUtc(), ticks: 6 }, - y: { ticks: 5 }, + scales: { + x: { type: d3ScaleUtc(), ticks: 6 }, + y: { ticks: 5 }, + }, })) const d3UpdateSamples = measureD3HostUpdates( d3DynamicDefinition, diff --git a/docs/comparison.md b/docs/comparison.md index 61dfd381..e5538f5c 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -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 | 37.60–43.56 KiB | — | Controlled suite | +| TanStack Charts | 38.90–44.88 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/concepts/chart-definitions.md b/docs/concepts/chart-definitions.md index 065e8121..a993f318 100644 --- a/docs/concepts/chart-definitions.md +++ b/docs/concepts/chart-definitions.md @@ -33,14 +33,16 @@ const alphabet: readonly AlphabetRow[] = [ const letterFrequencies = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], - x: { - scale: () => scaleBand().padding(0.12), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Frequency' }, + scales: { + x: { + scale: () => scaleBand().padding(0.12), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Frequency' }, + }, }, }) ``` @@ -57,13 +59,15 @@ const productRanking = defineChart({ tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) @@ -108,13 +112,15 @@ function ProductRanking({ rows, metric }: Props) { tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) diff --git a/docs/concepts/data-and-channels.md b/docs/concepts/data-and-channels.md index 8863f2ca..ab87e0fa 100644 --- a/docs/concepts/data-and-channels.md +++ b/docs/concepts/data-and-channels.md @@ -51,7 +51,7 @@ initializes; keep expensive cross-row transforms in application code. ## Positional channels -The x and y channels feed the chart’s positional scale factories or instances: +The x and y channels feed the reserved scales with the same names: ```ts barX(rows, { @@ -60,6 +60,10 @@ barX(rows, { }) ``` +Use `xScale` or `yScale` when a mark should feed another named entry in the +chart's `scales` registry. Channel names continue to describe geometry; scale +IDs select the mapping. + Positional values are also retained in each interaction `ChartPoint`: ```ts @@ -125,8 +129,11 @@ const chart = defineChart({ z: 'segment', }), ], - x: { scale: revenueScale }, - y: { scale: retentionScale }, + scales: { + x: { scale: revenueScale }, + y: { scale: retentionScale }, + }, + color: { scale: segmentColor, legend: colorLegend({ label: 'Segment' }), @@ -317,16 +324,19 @@ export default defineChart({ strokeWidth: 0.75, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Bill length (mm)' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Bill depth (mm)' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill length (mm)' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill depth (mm)' }, + }, }, + color: { scale: scaleOrdinal(species, ['#2563eb', '#f97316', '#10b981']), legend: colorLegend({ label: 'Species' }), diff --git a/docs/concepts/grammar-of-graphics.md b/docs/concepts/grammar-of-graphics.md index 42f4e4a1..afa72e9f 100644 --- a/docs/concepts/grammar-of-graphics.md +++ b/docs/concepts/grammar-of-graphics.md @@ -45,8 +45,10 @@ const alphabet: readonly LetterFrequency[] = [ const chart = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], - x: { scale: scaleBand }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -170,7 +172,7 @@ const y = { label: 'Monthly revenue', ticks: { count: 5, - format: (value: number) => `$${Math.round(value / 1_000)}k`, + format: (value: number) => `${Math.round(value / 1_000)}k`, }, }, } @@ -219,14 +221,16 @@ export default defineChart({ fill: '#2563eb', }), ], - x: { - scale: () => scaleBand().padding(0.12), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Value' }, + scales: { + x: { + scale: () => scaleBand().padding(0.12), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Value' }, + }, }, }) ``` diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index af9d82b9..9aa67858 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -61,16 +61,18 @@ Responsive definitions receive the current `width` and `height`, so presentation ```ts const chart = defineChart(({ width }) => ({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { - scale: xScale, - axis: { - ticks: { count: width < 420 ? 4 : 8 }, - tickLabels: { rotate: width < 520 ? -30 : undefined }, + scales: { + x: { + scale: xScale, + axis: { + ticks: { count: width < 420 ? 4 : 8 }, + tickLabels: { rotate: width < 520 ? -30 : undefined }, + }, + }, + y: { + scale: yScale, + axis: { label: width < 480 ? undefined : 'Weekly downloads' }, }, - }, - y: { - scale: yScale, - axis: { label: width < 480 ? undefined : 'Weekly downloads' }, }, })) ``` @@ -96,8 +98,11 @@ Explicit margins lock only the sides you provide: ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + margin: { left: 80 }, }) ``` @@ -110,8 +115,11 @@ Here the left margin is exactly `80`; top, right, and bottom remain automatic. const sparkline = defineChart({ marks: [lineY(values)], guides: false, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + margin: 0, }) ``` @@ -201,14 +209,48 @@ Hide every axis and grid while keeping scales for marks: const chart = defineChart({ marks, guides: false, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, +}) +``` + +Set a reserved scale to `null` only when no mark uses that dimension. For +example, a `ruleY`-only chart uses `scales: { x: null, y: { scale: yScale } }`. + +## Multiple axes + +Add a named scale when one coordinate system needs an independent mapping. +Declare whether it maps x or y, choose its axis side, then bind the relevant +mark to its ID: + +```ts +const chart = defineChart({ + marks: [ + lineY(revenue, { x: 'date', y: 'value' }), + lineY(margin, { + x: 'date', + y: 'percent', + yScale: 'margin', + }), + ], + scales: { + x: { scale: dateScale }, + y: { scale: revenueScale, axis: { label: 'Revenue' } }, + margin: { + channel: 'y', + scale: marginScale, + side: 'right', + axis: { label: 'Margin' }, + }, + }, }) ``` -Marks encode whether they materialize each positional dimension. Omit an unused -axis; for example, a `ruleY`-only chart needs `y` but no `x`. Explicit `null` -is accepted only for an unused dimension. +`xScale` and `yScale` bind marks to scale IDs, not axis IDs. Axes visualize the +scale registry entries. Multiple axes on one side stack outward and take part +in automatic margin measurement. ## Scale ranges and coordinate direction @@ -300,16 +342,16 @@ import { polar, radialArc } from '@tanstack/charts/polar' import { geoShape } from '@tanstack/charts/geo' ``` -`polar` copies configured angle and radius scales, assigns responsive angular -and radial ranges, and renders guide backgrounds, child marks, then guide -foregrounds around one resolved center. `geoShape` calls an +`polar` copies entries from its own `scales` registry, assigns responsive +angular and radial ranges, and renders guide backgrounds, child marks, then +guide foregrounds around one resolved center. `geoShape` calls an application-supplied D3 projection callback or fits a projection descriptor to data, a sphere, or explicit geometry. Both paths emit the same keyed scene nodes and interaction points as ordinary marks. SVG rendering, DOM reconciliation, focus, export, and adapters do not -need a coordinate-system branch. Their outer chart omits `x` and `y`; no -Cartesian guides are created. +need a coordinate-system branch. Their outer chart uses +`scales: { x: null, y: null }`; no Cartesian guides are created. These capabilities stay behind separate package subpaths so their D3 geometry does not enter a Cartesian consumer. See @@ -372,8 +414,11 @@ gesture can use the resolved scale's optional `invert` operation. ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + clip: true, }) ``` @@ -408,17 +453,19 @@ export default defineChart({ radius: 3, }), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: '2015 population', - ticks: { format: (value) => compact.format(value) }, + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: '2015 population', + ticks: { format: (value) => compact.format(value) }, + }, + }, + y: { + scale: () => scaleBand().paddingInner(0.12).paddingOuter(0.06), }, - }, - y: { - scale: () => scaleBand().paddingInner(0.12).paddingOuter(0.06), }, }) ``` diff --git a/docs/concepts/marks-and-layering.md b/docs/concepts/marks-and-layering.md index 144d70cd..b8f2f427 100644 --- a/docs/concepts/marks-and-layering.md +++ b/docs/concepts/marks-and-layering.md @@ -55,6 +55,43 @@ A useful default order is: There is no separate overlay subsystem. An annotation is another mark with its own data, channels, and stable identity. +## Opt individual marks into Canvas + +Import the Canvas renderer and attach it only to paint-heavy marks: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +const marks = [ + areaY(denseRange, { + x: 'date', + y1: 'low', + y2: 'high', + renderer: canvasChartRenderer, + }), + lineY(summary, { x: 'date', y: 'value' }), + dot(highlights, { x: 'date', y: 'value' }), + text(labels, { x: 'date', y: 'value', text: 'label' }), +] +``` + +The host keeps axes, guides, and marks without `renderer` in SVG. It creates +ordered SVG and Canvas layers from the mark declaration order, so a Canvas +mark can sit behind, between, or in front of SVG marks. Focus, tooltips, +keyboard navigation, responsive updates, SSR shell adoption, and export still +use the shared chart host. + +Cartesian and radial mark option objects accept `renderer`. A +`compositeMark` can select a renderer for its complete output, or its children +can select their own renderers when the parent does not. The same nested +selection works inside facets and `polar`. + +Importing `@tanstack/charts/canvas` is the opt-in boundary that adds the Canvas +painter to the bundle. Reuse a stable renderer instance across updates. The +exported `canvasChartRenderer` singleton already has stable identity. Creating +a new renderer or changing the renderer sequence replaces the affected layer +composition. + ## Decorative layers When two layered marks describe the same observations, choose one interaction @@ -207,8 +244,11 @@ Set `clip: true` on the chart definition when marks must not paint outside the r ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + clip: true, }) ``` @@ -248,15 +288,17 @@ export default defineChart({ strokeWidth: 1.5, }), ], - x: { - scale: scaleUtc, - axis: { label: 'Day' }, - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Temperature (°F)' }, + scales: { + x: { + scale: scaleUtc, + axis: { label: 'Day' }, + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Temperature (°F)' }, + }, }, }) ``` diff --git a/docs/concepts/scales-and-d3.md b/docs/concepts/scales-and-d3.md index f3061674..e6a1145a 100644 --- a/docs/concepts/scales-and-d3.md +++ b/docs/concepts/scales-and-d3.md @@ -85,8 +85,10 @@ import { scaleUtc } from 'd3-scale' const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: scaleUtc, nice: true }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleUtc, nice: true }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -164,24 +166,31 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const spec = { marks, - x: { scale: scaleBand }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear, nice: true }, + }, } ``` -Positionless marks omit both axes: +Positionless marks use explicit null entries: ```ts import { defineChart, frame } from '@tanstack/charts' const borderOnlyChart = defineChart({ marks: [frame()], + scales: { + x: null, + y: null, + }, }) ``` -A mark with x values requires an x scale. A mark with y values requires a y -scale. One-dimensional marks omit only the unused axis. The scale factory -chooses the mapping; materialized mark channels supply its domain. +A mark with x values requires a non-null x scale. A mark with y values requires +a non-null y scale. One-dimensional charts use `null` only for the unused +entry. The scale factory chooses the mapping; materialized mark channels +supply its domain. ## Factory domains come from marks @@ -193,8 +202,10 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const chart = defineChart({ marks: [lineY(rows, { x: 'month', y: 'value' })], - x: { scale: scalePoint }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scalePoint }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -296,8 +307,10 @@ Omitting `color.scale` uses the chart theme’s ordinal palette for categorical ```ts const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', z: 'region' })], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, }) ``` @@ -313,8 +326,11 @@ const regionColor = scaleOrdinal( const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', z: 'region' })], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + color: { scale: regionColor, legend: colorLegend({ label: 'Region' }), @@ -478,15 +494,17 @@ export default defineChart({ fill: '#2563eb', }), ], - x: { - scale: scaleLog().domain([200, 30_000]), - grid: true, - axis: { label: 'Class size' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Hierarchy depth' }, + scales: { + x: { + scale: scaleLog().domain([200, 30_000]), + grid: true, + axis: { label: 'Class size' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Hierarchy depth' }, + }, }, }) ``` diff --git a/docs/examples/bars-and-rankings.md b/docs/examples/bars-and-rankings.md index 392f918d..2881c964 100644 --- a/docs/examples/bars-and-rankings.md +++ b/docs/examples/bars-and-rankings.md @@ -44,17 +44,19 @@ const ranked = [...rows].sort((a, b) => b.value - a.value) const chart = defineChart({ marks: [barY(ranked, { x: 'category', y: 'value', inset: 2 })], - x: { - scale: () => - scaleBand() - .domain(ranked.map((row) => row.category)) - .padding(0.16), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Weekly signups' }, + scales: { + x: { + scale: () => + scaleBand() + .domain(ranked.map((row) => row.category)) + .padding(0.16), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Weekly signups' }, + }, }, }) diff --git a/docs/examples/distributions.md b/docs/examples/distributions.md index d51d2e37..9e98c4b4 100644 --- a/docs/examples/distributions.md +++ b/docs/examples/distributions.md @@ -76,8 +76,10 @@ const chart = defineChart({ fill: '#2563eb', }), ], - x: { scale: scaleLinear, axis: { label: 'Response time (ms)' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Requests' } }, + scales: { + x: { scale: scaleLinear, axis: { label: 'Response time (ms)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Requests' } }, + }, }) export default chart diff --git a/docs/examples/heatmaps-and-densities.md b/docs/examples/heatmaps-and-densities.md index d4e9b141..d82c8776 100644 --- a/docs/examples/heatmaps-and-densities.md +++ b/docs/examples/heatmaps-and-densities.md @@ -53,14 +53,17 @@ export default defineChart({ fontWeight: 650, }), ], - x: { - scale: () => scaleBand().domain(quarters).padding(0.04), - axis: { label: 'Quarter' }, - }, - y: { - scale: () => scaleBand().domain(teams).padding(0.04), - axis: { label: 'Team' }, + scales: { + x: { + scale: () => scaleBand().domain(quarters).padding(0.04), + axis: { label: 'Quarter' }, + }, + y: { + scale: () => scaleBand().domain(teams).padding(0.04), + axis: { label: 'Team' }, + }, }, + color: { scale: () => scaleLinear().domain([60, 100]).range(['#eff6ff', '#60a5fa']), diff --git a/docs/examples/intervals-and-financial.md b/docs/examples/intervals-and-financial.md index cf7f1f45..baae028f 100644 --- a/docs/examples/intervals-and-financial.md +++ b/docs/examples/intervals-and-financial.md @@ -78,11 +78,13 @@ export default defineChart({ r: 4, }), ], - x: { scale: () => scaleBand().padding(0.3) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Mean response (95% confidence interval)' }, + scales: { + x: { scale: () => scaleBand().padding(0.3) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Mean response (95% confidence interval)' }, + }, }, }) ``` diff --git a/docs/examples/lines-and-areas.md b/docs/examples/lines-and-areas.md index 2826fc1d..03e8bd35 100644 --- a/docs/examples/lines-and-areas.md +++ b/docs/examples/lines-and-areas.md @@ -50,12 +50,14 @@ const chart = defineChart({ stroke: '#2563eb', }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Downloads (thousands)' }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Downloads (thousands)' }, + }, }, }) diff --git a/docs/examples/maps-and-spatial.md b/docs/examples/maps-and-spatial.md index ee568e35..ede6a4a9 100644 --- a/docs/examples/maps-and-spatial.md +++ b/docs/examples/maps-and-spatial.md @@ -106,6 +106,10 @@ export default defineChart({ strokeWidth: 1.5, }), ], + scales: { + x: null, + y: null, + }, margin: 12, }) ``` diff --git a/docs/examples/networks-and-hierarchies.md b/docs/examples/networks-and-hierarchies.md index d2c62200..b81ea65e 100644 --- a/docs/examples/networks-and-hierarchies.md +++ b/docs/examples/networks-and-hierarchies.md @@ -93,6 +93,10 @@ export default defineChart({ ] as const, }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) @@ -185,8 +189,11 @@ export default defineChart({ dx: (node) => (node.internal ? -7 : 7), }), ], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, margin: { top: 24, right: 110, bottom: 24, left: 64 }, }) @@ -254,6 +261,10 @@ const chart = defineChart({ stroke: '#fff', }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) @@ -353,8 +364,10 @@ const chart = defineChart({ dot(graph.nodes, { x: 'x', y: 'y', color: 'group', key: 'id' }), text(graph.nodes, { x: 'x', y: 'y', text: 'id', key: 'id' }), ], - x: { scale: scaleLinear().domain(graph.xDomain) }, - y: { scale: scaleLinear().domain(graph.yDomain) }, + scales: { + x: { scale: scaleLinear().domain(graph.xDomain) }, + y: { scale: scaleLinear().domain(graph.yDomain) }, + }, }) ``` diff --git a/docs/examples/polar-and-radar.md b/docs/examples/polar-and-radar.md index 8ae8ac09..90bdde32 100644 --- a/docs/examples/polar-and-radar.md +++ b/docs/examples/polar-and-radar.md @@ -52,8 +52,16 @@ export default defineChart({ key: 'letter', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { domain: letters, range: ['#0ea5e9', '#6366f1', '#a855f7', '#ec4899', '#f97316', '#94a3b8'], @@ -120,8 +128,11 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.84, - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(slices, { innerRadius: ({ radius }) => radius * 0.72, @@ -141,6 +152,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: ['complete', 'remaining'], range: ['#ef4444', '#e2e8f0'], @@ -180,8 +195,11 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.72, - angle: { scale: scalePoint().domain(events), wrap: true }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scalePoint().domain(events), wrap: true }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + guides: [ radialGrid({ values: [0.25, 0.5, 0.75, 1], @@ -207,6 +225,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -310,8 +332,11 @@ import { dayOfYearAngle, seattle2012 } from './weather' export default defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 360]) }, - radius: { scale: scaleLinear().domain([-10, 40]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([-10, 40]) }, + }, + guides: [ radialGrid({ values: [0, 10, 20, 30, 40] }), angleGrid({ values: [0, 90, 180, 270], labels: false }), @@ -325,6 +350,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -373,8 +402,11 @@ import { latitudeBand, windDirection, windSpeed } from './wind' export default defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 360]) }, - radius: { scale: scaleLinear().domain([0, 13]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([0, 13]) }, + }, + guides: [ radialGrid({ values: [3, 6, 9, 12] }), angleGrid({ values: [0, 90, 180, 270], labels: false }), @@ -389,6 +421,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -441,11 +477,14 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.8, - angle: { scale: () => scaleBand().padding(0.12) }, - radius: { - scale: scaleLinear().domain([0, maximum]), - range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + scales: { + angle: { scale: () => scaleBand().padding(0.12) }, + radius: { + scale: scaleLinear().domain([0, maximum]), + range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + }, }, + marks: [ radialBarRadius(frequencies, { angle: 'letter', @@ -456,6 +495,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: letters, range: ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#16a34a'], @@ -514,8 +557,16 @@ export default defineChart({ stroke: '#fff', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: ['#7c3aed', '#0ea5e9', '#14b8a6'] }, }) ``` diff --git a/docs/examples/scatterplots-and-relationships.md b/docs/examples/scatterplots-and-relationships.md index 9a0fe6dd..af042e29 100644 --- a/docs/examples/scatterplots-and-relationships.md +++ b/docs/examples/scatterplots-and-relationships.md @@ -48,15 +48,17 @@ const chart = defineChart({ fill: '#2563eb', }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Temperature (°C)' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Daily sales' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Temperature (°C)' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Daily sales' }, + }, }, }) diff --git a/docs/examples/stacked-and-composition.md b/docs/examples/stacked-and-composition.md index 8b5e17ed..01353ea2 100644 --- a/docs/examples/stacked-and-composition.md +++ b/docs/examples/stacked-and-composition.md @@ -52,12 +52,15 @@ export default defineChart({ }), ruleY([0]), ], - x: { scale: () => scalePoint().padding(0.15) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Revenue (USD thousands)' }, + scales: { + x: { scale: () => scalePoint().padding(0.15) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD thousands)' }, + }, }, + color: { domain: ['Core', 'Services'], range: ['#2563eb', '#14b8a6'], diff --git a/docs/framework/alpine/adapter.md b/docs/framework/alpine/adapter.md index 406ade27..67394783 100644 --- a/docs/framework/alpine/adapter.md +++ b/docs/framework/alpine/adapter.md @@ -39,8 +39,9 @@ registering `Alpine.plugin(charts)`. The directive element itself becomes `.ts-chart-host`; normal HTML class and style attributes own its presentation. The `className` chart option applies -to the rendered SVG surface. The package exposes the SVG directive only; use -`renderSvg` to replace SVG serialization without replacing the shared host. +to the rendered chart surface. The directive starts with SVG and can compose +marks that use `canvasChartRenderer`. Use `renderSvg` to replace SVG +serialization without replacing the shared host. Exports: `charts`, `ChartOptions`, `ChartTooltipBodyRenderContext`, `AlpineChartTooltipBody`, `ChartDefinition`, and `ChartPoint`. diff --git a/docs/framework/alpine/reference/chart.md b/docs/framework/alpine/reference/chart.md index 976ce315..923bda18 100644 --- a/docs/framework/alpine/reference/chart.md +++ b/docs/framework/alpine/reference/chart.md @@ -15,25 +15,25 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## Directive options -| Option | Type | Default | Meaning | -| -------------------- | -------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | Existing height or `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Existing width or `100%` | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `className` | `string` | None | Extra class on the rendered SVG surface | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => AlpineChartTooltipBody` | None | Returns DOM content for the built-in tooltip body | +| Option | Type | Default | Meaning | +| -------------------- | -------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | Existing height or `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Existing width or `100%` | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `className` | `string` | None | Extra class on the rendered chart surface | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => AlpineChartTooltipBody` | None | Returns DOM content for the built-in tooltip body | The directive element becomes `.ts-chart-host`; use its normal HTML class and style attributes for outer presentation. Directive cleanup removes the chart diff --git a/docs/framework/angular/adapter.md b/docs/framework/angular/adapter.md index 8e3919d3..6d0ed00d 100644 --- a/docs/framework/angular/adapter.md +++ b/docs/framework/angular/adapter.md @@ -47,9 +47,10 @@ Angular hydration is not yet part of the adapter's tested public contract. ## Presentation and rendering `options.class` and the string `options.style` apply to the inner -`.ts-chart-host`; `options.className` applies to the rendered SVG surface. The -package exposes the SVG component only. Use `renderSvg` to replace SVG -serialization without replacing the shared host. +`.ts-chart-host`; `options.className` applies to the rendered chart surface. +The component starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `ChartCommonOptions`, `ChartOptions`, `ChartPresentationOptions`, `ChartTooltipBodyDirective`, diff --git a/docs/framework/angular/reference/chart.md b/docs/framework/angular/reference/chart.md index e19f4085..510c0b68 100644 --- a/docs/framework/angular/reference/chart.md +++ b/docs/framework/angular/reference/chart.md @@ -15,26 +15,26 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## `options` -| Option | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------ | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | -| `style` | `string` | None | Inner host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Option | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | +| `style` | `string` | None | Inner host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | Callbacks are functions inside `options`, not Angular outputs. Replace the complete `options` value when chart state changes so `OnPush` change detection diff --git a/docs/framework/lit/adapter.md b/docs/framework/lit/adapter.md index c7dadce2..989fa3ac 100644 --- a/docs/framework/lit/adapter.md +++ b/docs/framework/lit/adapter.md @@ -50,9 +50,10 @@ the adapter's tested public contract. Call `defineChartElement` only where ## Presentation and rendering `options.class` and the string `options.style` apply to the inner -`.ts-chart-host`; `options.className` applies to the rendered SVG surface. The -package exposes the SVG custom element only. Use `renderSvg` to replace SVG -serialization without replacing the shared host. +`.ts-chart-host`; `options.className` applies to the rendered chart surface. +The custom element starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `defineChartElement`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodyRenderContext`, diff --git a/docs/framework/lit/reference/chart.md b/docs/framework/lit/reference/chart.md index bced0a42..3e30d3d2 100644 --- a/docs/framework/lit/reference/chart.md +++ b/docs/framework/lit/reference/chart.md @@ -15,27 +15,27 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## `options` -| Option | Type | Default | Meaning | -| -------------------- | ----------------------------------------------------- | --------------------- | ----------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => unknown` | None | Composes Lit content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | -| `style` | `string` | None | Inner host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Option | Type | Default | Meaning | +| -------------------- | ----------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => unknown` | None | Composes Lit content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | +| `style` | `string` | None | Inner host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | ```ts const options = { diff --git a/docs/framework/octane/adapter.md b/docs/framework/octane/adapter.md index 619e2d26..d7c48cfe 100644 --- a/docs/framework/octane/adapter.md +++ b/docs/framework/octane/adapter.md @@ -30,7 +30,9 @@ import { Chart as RendererChart } from '@tanstack/charts/octane/core' ``` The default `Chart` remains SVG-based. `CanvasChart` selects the optional -built-in renderer; `RendererChart` requires a `renderer` prop. +built-in renderer; `RendererChart` requires a `renderer` prop. A definition +can still import `canvasChartRenderer` and assign it to selected marks, which +makes the default component render an ordered mixed surface. The package export map supplies a browser build for browser bundlers and a separate Node build for the `node` condition. @@ -64,6 +66,11 @@ layers. It paints no server pixels. The browser adopts the elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. +A default chart with selected Canvas marks emits one mixed root containing +ordered SVG markup and Canvas shells. SVG marks are visible in the server +response, Canvas pixels appear after mount, and the browser adopts every child +surface. + Keep data, definitions, scale domains, custom renderers, and dimensions deterministic between server and browser. The adapter generates a sanitized resource prefix from Octane's `useId()` when `idPrefix` is absent. @@ -82,7 +89,7 @@ The rendered structure is: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart | div.ts-chart-canvas + svg.ts-chart | div.ts-chart-canvas | div.ts-chart-layers ``` The outer host uses `position: relative`. diff --git a/docs/framework/octane/quick-start.md b/docs/framework/octane/quick-start.md index 24dc34b6..d423cb9c 100644 --- a/docs/framework/octane/quick-start.md +++ b/docs/framework/octane/quick-start.md @@ -36,18 +36,21 @@ const letterFrequencyChart = defineChart({ y: 'frequency', }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: 'Frequency', - ticks: { format: (value) => percent.format(value) }, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, }, + tooltip, }) @@ -127,13 +130,16 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { fill: accent, }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + }, }, + svgAnimation: true, tooltip, }) diff --git a/docs/framework/octane/reference/chart.md b/docs/framework/octane/reference/chart.md index e185e697..a493c58d 100644 --- a/docs/framework/octane/reference/chart.md +++ b/docs/framework/octane/reference/chart.md @@ -73,13 +73,13 @@ See [Sizing and layout](../adapter.md#sizing-and-layout). ## Focus, tooltip, and callbacks -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------------------- | ------- | -------------------------------------------------------- | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Inner surface, live SVG, and scene after reconciliation | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => OctaneNode` | None | Composes Octane content inside the built-in tooltip body | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------------------- | ------- | -------------------------------------------------------------------- | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Inner host, default SVG, complete surface, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => OctaneNode` | None | Composes Octane content inside the built-in tooltip body | ```tsx ` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ComponentChildren` | None | Composes Preact content inside the built-in tooltip body | -| `className` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | +| Prop | Type | Default | Meaning | +| -------------------- | --------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ComponentChildren` | None | Composes Preact content inside the built-in tooltip body | +| `className` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | Custom outer styles can override the adapter's computed layout. A fixed `width` prop still controls scene measurement, so do not give `style.width` a diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 3bf246f6..c465d680 100644 --- a/docs/framework/react/adapter.md +++ b/docs/framework/react/adapter.md @@ -30,7 +30,9 @@ import { Chart as RendererChart } from '@tanstack/charts/react/core' `CanvasChart` selects the optional built-in renderer. `RendererChart` requires a `renderer` prop. The default `Chart` remains SVG-based, so importing the -default adapter does not pull Canvas into its module graph. +default adapter does not pull Canvas into its module graph. A definition can +still import `canvasChartRenderer` and assign it to selected marks, which makes +the default component render an ordered mixed surface. The base entries render the built-in tooltip without the React tooltip-body bridge. Import from the optional tooltip entry when passing @@ -90,6 +92,11 @@ layers. It does not paint pixels on the server. The client adopts those elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. +A default chart with selected Canvas marks emits one mixed root containing +ordered SVG markup and Canvas shells. SVG marks are visible in the server +response, Canvas pixels appear after mount, and the client adopts every child +surface. + Use deterministic data, scale domains, definitions, dimensions, and custom renderers on server and client. The adapter generates a sanitized `idPrefix` from `React.useId()` when one is not supplied, keeping document resources @@ -109,7 +116,7 @@ The adapter renders two nested containers: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart | div.ts-chart-canvas + svg.ts-chart | div.ts-chart-canvas | div.ts-chart-layers ``` The outer host has `position: relative`. @@ -183,6 +190,10 @@ import { defineChart } from '@tanstack/charts' const definition = defineChart({ marks: [], + scales: { + x: null, + y: null, + }, }) ``` diff --git a/docs/framework/react/quick-start.md b/docs/framework/react/quick-start.md index 702d9200..fb49776c 100644 --- a/docs/framework/react/quick-start.md +++ b/docs/framework/react/quick-start.md @@ -37,18 +37,21 @@ const letterFrequencyChart = defineChart({ y: 'frequency', }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: 'Frequency', - ticks: { format: (value) => percent.format(value) }, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, }, + tooltip, }) @@ -131,13 +134,16 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { fill: accent, }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + }, }, + svgAnimation: true, tooltip, }) diff --git a/docs/framework/react/reference/chart.md b/docs/framework/react/reference/chart.md index 106e32d4..c876d5ed 100644 --- a/docs/framework/react/reference/chart.md +++ b/docs/framework/react/reference/chart.md @@ -81,12 +81,12 @@ See [Sizing and layout](../adapter.md#sizing-and-layout). ## Callbacks -| Prop | Type | Default | Meaning | -| -------------------- | ----------------------------------------- | ------- | ------------------------------------------------------- | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Inner surface, live SVG, and scene after reconciliation | +| Prop | Type | Default | Meaning | +| -------------------- | ----------------------------------------- | ------- | -------------------------------------------------------------------- | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Inner host, default SVG, complete surface, and scene after rendering | See [Focus and interaction](../../../reference/focus-and-interaction.md) for the behavior and complete callback values. diff --git a/docs/framework/solid/adapter.md b/docs/framework/solid/adapter.md index 1fab3a6c..76e83f63 100644 --- a/docs/framework/solid/adapter.md +++ b/docs/framework/solid/adapter.md @@ -44,9 +44,10 @@ browser definitions, formatters, and dimensions deterministic. ## Presentation and rendering `class` and `style: JSX.CSSProperties` apply to the outer host. `className` -applies to the rendered SVG surface. Custom outer styles are spread after -adapter sizing. The package exposes the SVG component only; use `renderSvg` to -replace SVG serialization without replacing the shared host. +applies to the rendered chart surface. Custom outer styles are spread after +adapter sizing. The component starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodyRenderContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/docs/framework/solid/reference/chart.md b/docs/framework/solid/reference/chart.md index 9f3006ec..d0fc6250 100644 --- a/docs/framework/solid/reference/chart.md +++ b/docs/framework/solid/reference/chart.md @@ -14,27 +14,27 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => JSX.Element` | None | Composes Solid content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => JSX.Element` | None | Composes Solid content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | `renderTooltipBody` receives `points`, `content`, `defaultBody`, `pinned`, and `dismiss`. Include `defaultBody` to keep the native title, rows, formatting, diff --git a/docs/framework/svelte/adapter.md b/docs/framework/svelte/adapter.md index 79d5f7ee..ee8f84f2 100644 --- a/docs/framework/svelte/adapter.md +++ b/docs/framework/svelte/adapter.md @@ -46,10 +46,10 @@ dimensions deterministic. ## Presentation and rendering `class` and the string `style` prop apply to the outer host. `className` -applies to the rendered SVG surface. Interaction hooks such as +applies to the rendered chart surface. Interaction hooks such as `onFocusChange` are callback props, not dispatched Svelte events. The package -exposes the SVG component only; use `renderSvg` to replace SVG serialization -without replacing the shared host. +starts with SVG and can compose marks that use `canvasChartRenderer`. Use +`renderSvg` to replace SVG serialization without replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodySnippetContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/docs/framework/svelte/reference/chart.md b/docs/framework/svelte/reference/chart.md index 7e7decfb..8e232c30 100644 --- a/docs/framework/svelte/reference/chart.md +++ b/docs/framework/svelte/reference/chart.md @@ -16,27 +16,27 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `tooltipBody` | `Snippet<[ChartTooltipBodySnippetContext]>` | None | Composes Svelte content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `string` | None | Outer host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `tooltipBody` | `Snippet<[ChartTooltipBodySnippetContext]>` | None | Composes Svelte content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `string` | None | Outer host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | Interaction hooks are Svelte 5 callback props. They are not component events. diff --git a/docs/framework/vue/adapter.md b/docs/framework/vue/adapter.md index e73abaea..4a946697 100644 --- a/docs/framework/vue/adapter.md +++ b/docs/framework/vue/adapter.md @@ -50,10 +50,10 @@ deterministic. ## Presentation and rendering `class` and `style: StyleValue` apply to the outer host. `className` applies to -the rendered SVG surface. The component disables general attribute -inheritance; unrelated attributes are not forwarded. The package exposes the -SVG component only; use `renderSvg` to replace SVG serialization without -replacing the shared host. +the rendered chart surface. The component disables general attribute +inheritance; unrelated attributes are not forwarded. It starts with SVG and +can compose marks that use `canvasChartRenderer`. Use `renderSvg` to replace +SVG serialization without replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodySlotContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/docs/framework/vue/reference/chart.md b/docs/framework/vue/reference/chart.md index 067e300f..91de795e 100644 --- a/docs/framework/vue/reference/chart.md +++ b/docs/framework/vue/reference/chart.md @@ -14,26 +14,26 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------ | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `StyleValue` | None | Outer host style | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `StyleValue` | None | Outer host style | +| `className` | `string` | None | Extra class on the rendered chart surface | In templates, camel-cased props may use kebab case: `aria-label`, `initial-width`, and `on-focus-change` map to `ariaLabel`, `initialWidth`, and diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md index 88fb3dca..7c3a2e21 100644 --- a/docs/guides/accessibility.md +++ b/docs/guides/accessibility.md @@ -276,8 +276,11 @@ export function createDefinition( selection, ), ], - x: { scale: scaleLinear, axis: { label: 'Setup time (minutes)' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Satisfaction' } }, + scales: { + x: { scale: scaleLinear, axis: { label: 'Setup time (minutes)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Satisfaction' } }, + }, + selection, }) } diff --git a/docs/guides/ai-authoring.md b/docs/guides/ai-authoring.md index 77590fdc..b25f3110 100644 --- a/docs/guides/ai-authoring.md +++ b/docs/guides/ai-authoring.md @@ -22,6 +22,17 @@ path is explicit and repeatable. Start at [Choosing a Chart](./choosing-a-chart.md), then use the relevant [example family](../examples/index.md). +Put Cartesian scale and axis options under `scales.x` and `scales.y`. Never +generate the deprecated root `x` or `y` form. Inside `polar()`, put position +scales under `scales.angle` and `scales.radius`. Use named registry entries and +mark-level `xScale`, `yScale`, `angleScale`, or `radiusScale` selectors when a +chart needs more than one mapping on the same channel. + +Keep the default SVG renderer unless a measured paint-heavy mark benefits from +Canvas. To mix surfaces, import `canvasChartRenderer` from the exact +`@tanstack/charts/canvas` subpath and set that mark's `renderer` option. Do not +move the complete chart to Canvas when only one dense mark needs raster paint. + ## Canonical sources Use one documentation owner for each decision: diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 176333f0..8fcbaa14 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -64,6 +64,24 @@ SVG-based. Canvas enters the module graph only through `@tanstack/charts/octane/canvas`. The React and Octane `/core` entries accept an application-supplied renderer without importing Canvas. +The Canvas renderer can also be attached to only the dense marks in an +otherwise SVG chart: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +lineY(rows, { + x: 'time', + y: 'value', + renderer: canvasChartRenderer, +}) +``` + +The shared host includes only the small renderer-selection and layer metadata +contract. It does not include the Canvas painter. A consumer that never +imports the Canvas subpath cannot retain that painter. Measure mixed and +SVG-only entries separately when reviewing a bundle change. + Non-cartesian geometry is subpath-only: ```ts diff --git a/docs/guides/custom-marks-and-renderers.md b/docs/guides/custom-marks-and-renderers.md index bf7d3e4e..fef39796 100644 --- a/docs/guides/custom-marks-and-renderers.md +++ b/docs/guides/custom-marks-and-renderers.md @@ -126,6 +126,28 @@ const threshold = createMark(({ markIndex }) => { `initialize` materializes channels for one scene build. `render` receives the required full `surface` bounds, inner `chart` plot bounds, scales, theme, color resolver, and text layout tools. + +Pass a mark renderer as the third argument when the custom mark should select +its own surface: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +const denseThresholds = createMark( + initializeThresholds, + undefined, + canvasChartRenderer, +) +``` + +The second argument remains the optional mark motion definition. A custom mark +can pass both motion and a renderer, and neither changes the other's meaning. +The selected renderer still decides whether it consumes that motion policy. +Built-in marks expose the same renderer choice in their option object. A DOM +renderer used this way must implement `ChartLayerRenderer`, including +`compose(defaultRenderer)`, so one compositor can own the ordered child +surfaces. `canvasChartRenderer` provides that composition. + When a custom mark emits data labels, an optional `layoutLabels(context)` can return those positioned `SceneLabel` nodes before render so unlocked margins contain them. Keep that method pure because responsive layout may call it more @@ -336,6 +358,13 @@ returns `null` when that optional capability is absent. The host retains sizing, runtime, keyboard, tooltip, selection, and focus-strategy behavior. Keep `prerender` deterministic and make `mount` adopt compatible server markup. +A composed surface exposes its child surfaces from back to front through +`ChartSurface.layers`. Its `element` remains the single accessible, +interactive root. `defaultElement` identifies the topmost element owned by the +host's default renderer. SVG-oriented `onRender` callbacks keep `svg` for +compatibility and also receive the complete `surface`, so application code can +inspect a mixed chart without treating the composition root as an SVG. + If `paintFocus` resolves and paints inline mark-state geometry, return that destination `ChartScene`. The host will use it for subsequent pointer hits; returning nothing preserves base-scene interaction for simpler renderers. diff --git a/docs/guides/dynamic-data-and-animation.md b/docs/guides/dynamic-data-and-animation.md index 6d84776c..09d96934 100644 --- a/docs/guides/dynamic-data-and-animation.md +++ b/docs/guides/dynamic-data-and-animation.md @@ -29,13 +29,15 @@ function RankingChart({ rows, metric, accent }: Props) { fill: accent, }), ], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 420 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 420 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) @@ -131,8 +133,10 @@ const definition = defineChart({ motion: { transition: { type: 'spring', mass: 1.25 } }, }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, }) const host = mountChartRenderer(container, { @@ -231,6 +235,10 @@ const definition = defineChart({ transition: { type: 'tween', duration: sampleInterval, easing: 'linear' }, }, marks, + scales: { + x: null, + y: null, + }, }) ``` diff --git a/docs/guides/exporting.md b/docs/guides/exporting.md index 20ca052f..043ec1e9 100644 --- a/docs/guides/exporting.md +++ b/docs/guides/exporting.md @@ -30,6 +30,10 @@ const svg = renderChartSvg(scene, { }) ``` +This direct serializer paints the complete renderer-neutral scene as SVG. It +does not run mark-level surface composition, so it can provide a vector +snapshot of a definition that selects Canvas when mounted. + For a responsive definition, create a runtime with its datum, x-value, and y-value generics, then call `render(...)` with the definition and explicit size. See [Runtime and Scene](../reference/runtime-and-scene.md#createchartruntime) @@ -106,6 +110,18 @@ scene but no transient focus. `backgroundCanvas`, `focusUnderCanvas`, SVG serialization, Canvas export does not retain vector geometry, accessible markup, or independently styleable nodes. +## Export a mixed SVG and Canvas chart + +Pass the mixed chart root or its host to `renderChartImage` or +`downloadChartImage`. The exporter composites every SVG and Canvas child +surface in visual order. Set `includeFocus: true` to include the live focus +layers in the same positions used by the mounted chart. + +`serializeChartSvg` and `downloadChartSvg` reject a mixed root. A mixed chart +contains raster pixels, so a pure vector SVG would either omit those marks or +embed a raster image. Use the raster export path when any mounted mark selects +Canvas. + ## Theme and resource policy Export the theme intended for the artifact. A chart following application dark @@ -135,7 +151,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. -- A Canvas export intentionally includes or excludes focus layers. +- A Canvas or mixed export intentionally includes or excludes focus layers. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/docs/guides/faceting-and-composition.md b/docs/guides/faceting-and-composition.md index 92b44413..94f0e1be 100644 --- a/docs/guides/faceting-and-composition.md +++ b/docs/guides/faceting-and-composition.md @@ -22,8 +22,10 @@ const definition = defineChart({ dot(highlights, { x: 'date', y: 'median' }), text(labels, { x: 'date', y: 'median', text: 'label' }), ], - x, - y, + scales: { + x: x, + y: y, + }, }) ``` @@ -56,11 +58,13 @@ export default facetChart(rows, { lineY(data, { x: 'week', y: 'orders', strokeWidth: 2 }), dot(data, { x: 'week', y: 'orders', r: 3.5 }), ], - x: { scale: scaleLinear().domain([1, 4]) }, - y: { - scale: scaleLinear().domain([0, 80]), - grid: true, - axis: { label: 'Orders' }, + scales: { + x: { scale: scaleLinear().domain([1, 4]) }, + y: { + scale: scaleLinear().domain([0, 80]), + grid: true, + axis: { label: 'Orders' }, + }, }, } }, diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index 9548add1..0d3b2ab1 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -58,8 +58,11 @@ const definition = defineChart({ lineY(rows, { x: 'date', y: 'value' }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', maxFocusDistance: Number.POSITIVE_INFINITY, }) @@ -124,8 +127,11 @@ const definition = defineChart({ marker: true, }), ], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + cursor: { use: cursorHost, controller: freeCursor, @@ -231,6 +237,10 @@ const definition = defineChart({ selection, ), ], + scales: { + x: null, + y: null, + }, selection, }) ``` @@ -270,8 +280,11 @@ type Position = ContinuousCursorPosition const definition = defineChart({ marks: [dot(rows, { x: 'horsepower', y: 'economy' })], - x: { scale: horsepowerScale }, - y: { scale: economyScale }, + scales: { + x: { scale: horsepowerScale }, + y: { scale: economyScale }, + }, + controls: [ continuousCursor({ position: controlledSignal< @@ -333,8 +346,11 @@ let interaction: ChartInteractionController | undefined const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', key: 'id' })], - x: { scale: scaleUtc() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleUtc() }, + y: { scale: scaleLinear() }, + }, + focus: 'nearest-x', pointer: false, tooltip, @@ -479,8 +495,11 @@ export default function App() { strokeWidth: 2.5, }), ], - x: { scale: scaleLinear().domain([1, 8]) }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + scales: { + x: { scale: scaleLinear().domain([1, 8]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ brushX({ range: controlledSignal, BrushXChange>( @@ -573,7 +592,10 @@ import { controlledSignal } from '@tanstack/charts/interaction/signal' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: utcScale }, + scales: { + x: { scale: utcScale }, + y: null, + }, controls: [ handleX({ value: controlledSignal>(currentDate, (next) => @@ -623,7 +645,10 @@ import { controlledSignal } from '@tanstack/charts/interaction/signal' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: utcScale.copy().domain([window.start, window.end]) }, + scales: { + x: { scale: utcScale.copy().domain([window.start, window.end]) }, + y: null, + }, controls: [ zoomX({ window: controlledSignal, ZoomXChange>( diff --git a/docs/guides/legends-and-color.md b/docs/guides/legends-and-color.md index e749fd50..0ca069bc 100644 --- a/docs/guides/legends-and-color.md +++ b/docs/guides/legends-and-color.md @@ -34,8 +34,11 @@ const definition = defineChart({ z: 'series', }), ], - x, - y, + scales: { + x: x, + y: y, + }, + color: { scale: color, legend: colorLegend({ label: 'Package' }), @@ -125,15 +128,18 @@ export default defineChart({ strokeWidth: 2.5, }), ], - x: { - scale: () => scalePoint().padding(0.2), - axis: { label: 'Week' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Downloads' }, + scales: { + x: { + scale: () => scalePoint().padding(0.2), + axis: { label: 'Week' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Downloads' }, + }, }, + color: { legend: colorLegend({ label: 'Package' }) }, }) ``` diff --git a/docs/guides/migrating.md b/docs/guides/migrating.md index 0d1be992..4b2f535b 100644 --- a/docs/guides/migrating.md +++ b/docs/guides/migrating.md @@ -7,6 +7,52 @@ Migration is a semantic exercise, not a component-name translation. First describe what the existing chart means and how users operate it. Then express that behavior with data preparation, scales, marks, and host options. +## Move root scales into the registry + +Current definitions put Cartesian scale and axis options under `scales`: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale }, + y: { scale: yScale, grid: true }, + }, +}) +``` + +The earlier root properties still work temporarily: + +```ts +const legacyDefinition = defineChart({ + marks, + x: { scale: xScale }, + y: { scale: yScale, grid: true }, +}) +``` + +Development builds warn with the exact replacement. Move `x` to `scales.x` +and `y` to `scales.y`. Root `x` and `y` will be removed when TanStack Charts +enters Alpha. New code and generated code should only use the `scales` form. + +Polar definitions follow the same migration. Move `polar({ angle, radius })` +to `polar({ scales: { angle, radius } })`. If neither positional scale is +used, write `scales: { angle: null, radius: null }`. The old polar properties +also work temporarily, warn in development, and will be removed when TanStack +Charts enters Alpha. + +During this compatibility window, `ChartSpec` is a union of the canonical and +legacy shapes. If application code extended the old interface, replace that +extension with an intersection: + +```ts +import type { ChartSpec } from '@tanstack/charts' + +type ApplicationChartSpec = ChartSpec & { + applicationTag: string +} +``` + ## Inventory the current contract Record: diff --git a/docs/guides/responsive-charts.md b/docs/guides/responsive-charts.md index f0954281..679d0737 100644 --- a/docs/guides/responsive-charts.md +++ b/docs/guides/responsive-charts.md @@ -79,14 +79,16 @@ export default defineChart({ inset: 1, }), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { ticks: { count: 5 }, label: 'Weekly requests' }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 5 }, label: 'Weekly requests' }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }) ``` diff --git a/docs/guides/ssr-and-hydration.md b/docs/guides/ssr-and-hydration.md index 1e88a9d2..1bf27318 100644 --- a/docs/guides/ssr-and-hydration.md +++ b/docs/guides/ssr-and-hydration.md @@ -11,12 +11,12 @@ runtime and renderer on the server and in the browser. | Adapter | Server output | Browser contract | | ------------------------------------------ | ----------------------------------- | --------------------------------------------------- | -| [React](../framework/react/adapter.md) | Complete SVG; Canvas shell | Hydrates and adopts the existing surface | -| [Preact](../framework/preact/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Vue](../framework/vue/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Solid](../framework/solid/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Svelte](../framework/svelte/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Octane](../framework/octane/adapter.md) | Complete SVG; Canvas shell | Hydrates and adopts the existing surface | +| [React](../framework/react/adapter.md) | SVG, Canvas, or mixed shell | Hydrates and adopts the existing surface | +| [Preact](../framework/preact/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Vue](../framework/vue/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Solid](../framework/solid/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Svelte](../framework/svelte/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Octane](../framework/octane/adapter.md) | SVG, Canvas, or mixed shell | Hydrates and adopts the existing surface | | [Angular](../framework/angular/adapter.md) | Not yet a verified adapter contract | Browser mount, immutable update, and teardown | | [Lit](../framework/lit/adapter.md) | Not yet a verified adapter contract | Browser registration, update, disconnect, reconnect | | [Alpine](../framework/alpine/adapter.md) | None | Browser-only directive | @@ -94,6 +94,11 @@ their backing stores for the device-pixel ratio, paints the scene, and attaches the shared interaction host. The first image appears after client mount; use the default SVG adapter when visible server-rendered geometry is required. +When selected marks use `canvasChartRenderer`, verified server adapters emit +one accessible mixed root with ordered SVG markup and Canvas shells. The +browser adopts each child surface. SVG marks remain visible in the server +response, while Canvas marks receive pixels after mount. + ## Fonts and text measurement Automatic guide margins depend on text metrics. The server uses deterministic diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index ad55b0e8..cbea3392 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -51,8 +51,11 @@ Use `theme` when a chart needs explicit scene colors: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + theme: { foreground: '#e5e7eb', muted: '#94a3b8', @@ -126,12 +129,15 @@ export default defineChart({ strokeWidth: 2, }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Revenue (USD)' }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, }, + gradients: [ { id: 'revenue-fill', diff --git a/docs/guides/tooltips-and-focus.md b/docs/guides/tooltips-and-focus.md index 15f9ef4c..4c8bba66 100644 --- a/docs/guides/tooltips-and-focus.md +++ b/docs/guides/tooltips-and-focus.md @@ -112,8 +112,11 @@ export default defineChart({ }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Active users' } }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Active users' } }, + }, + focus: 'nearest-x', maxFocusDistance: Number.POSITIVE_INFINITY, tooltip, @@ -163,8 +166,11 @@ point: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + tooltip: { use: tooltip, items: [ @@ -272,7 +278,14 @@ const renderer = motion({ }, }) -const definition = defineChart({ marks, tooltip }) +const definition = defineChart({ + marks, + scales: { + x: null, + y: null, + }, + tooltip, +}) mountChartRenderer(container, { definition, @@ -288,6 +301,10 @@ A static chart-level transition can refine the renderer fallback: ```ts const definition = defineChart({ marks, + scales: { + x: null, + y: null, + }, motion: { transition: { type: 'spring', stiffness: 170, damping: 18, mass: 1 }, }, @@ -351,8 +368,11 @@ group's bounding-box center: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -407,8 +427,11 @@ import { portal } from '@tanstack/charts/tooltip/portal' const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -542,11 +565,18 @@ function SeriesPie({ points }: { points: readonly RevenuePoint[] }) { fill: (slice) => slice.data.color ?? 'CanvasText', }), ], + scales: { + angle: null, + radius: null, + }, }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, + keyboard: false, }) }, [points]) diff --git a/docs/guides/typescript.md b/docs/guides/typescript.md index 73aa3c0c..c48a299a 100644 --- a/docs/guides/typescript.md +++ b/docs/guides/typescript.md @@ -31,8 +31,10 @@ const definition = defineChart({ y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, }) ``` @@ -51,8 +53,10 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, }) } ``` @@ -78,8 +82,11 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, + margin: width < 480 ? 24 : 40, })) } @@ -98,10 +105,10 @@ Avoid annotating an intermediate object as broad `ChartSpec` before passing it to `defineChart`. That discards the literal mark tuple used for axis and callback inference. -The mark tuple also determines which positional scales are required. Cartesian -marks require every dimension they materialize. Positionless geo, polar, and -facet marks omit both axes; one-dimensional marks such as `ruleY` require only -`y`. +The mark tuple determines the value type accepted by each reserved positional +scale. `scales.x` and `scales.y` are always present in canonical definitions. +Use `null` for a dimension that no mark materializes. Named scale selectors on +marks keep those values out of the reserved entry's inferred type. ## Callback types @@ -148,10 +155,13 @@ union manually. The exact utility contracts are listed in ## Custom marks -`createMark` keeps interaction points and scale -values aligned for the common case. Use the advanced scale-value factory when -the materialized axis domain differs from the point anchor or when a custom -mark is positionless and declares both scale value types as `never`. +`createMark` keeps interaction +points and scale values aligned for the common case. The scale ID parameters +default to `x` and `y`. Provide them when a custom mark selects named scales so +its values do not widen the reserved scale types. Use the advanced scale-value +factory when the materialized axis domain differs from the point anchor or +when a custom mark is positionless and declares both scale value types as +`never`. See [Custom Marks and Renderers](./custom-marks-and-renderers.md). A custom extension that requires `as unknown as`, a private import, or suppressed type diff --git a/docs/installation.md b/docs/installation.md index dc509cba..90b7dc0b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -296,8 +296,10 @@ const values = [4, 9, 7] const chart = defineChart({ marks: [lineY(values)], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) ``` @@ -326,8 +328,10 @@ import { createChartScene, defineChart, lineY } from '@tanstack/charts' const chart = defineChart({ marks: [lineY([2, 5, 3])], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) const scene = createChartScene(chart, { width: 640, height: 320 }) diff --git a/docs/overview.md b/docs/overview.md index c69a476b..58cd40a4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -62,14 +62,16 @@ export default defineChart({ r: 4, }), ], - x: { - scale: () => scaleBand().padding(0.2), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Signups' }, + scales: { + x: { + scale: () => scaleBand().padding(0.2), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Signups' }, + }, }, }) ``` diff --git a/docs/quick-start.md b/docs/quick-start.md index 634ca85a..36151834 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -54,16 +54,19 @@ const monthlyRevenueChart = defineChart({ stroke: '#2563eb', }), ], - x: { - scale: () => scalePoint().padding(0.2), - axis: { label: 'Month' }, - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Revenue (USD)' }, + scales: { + x: { + scale: () => scalePoint().padding(0.2), + axis: { label: 'Month' }, + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, }, + tooltip, }) ``` diff --git a/docs/reference/adapter-controller.md b/docs/reference/adapter-controller.md index 530ba474..7dc572d3 100644 --- a/docs/reference/adapter-controller.md +++ b/docs/reference/adapter-controller.md @@ -84,6 +84,11 @@ interface ChartAdapter< Keep one controller per framework component instance. +Both controllers resolve mark-level renderers from the compiled scene before +prerender and mount. A default SVG adapter therefore emits and adopts a mixed +shell when selected marks use `canvasChartRenderer`; framework adapters do not +need their own layer-selection logic. + ## Prerender and mount ```ts diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index c3c62878..74996619 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -43,8 +43,11 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: scaleUtc }, - y: { scale: scaleLinear, nice: true, grid: true }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear, nice: true, grid: true }, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -68,12 +71,14 @@ const definition = defineChart({ svgAnimation: true, chart: ({ width }) => ({ marks: [barY(rows, { x: 'category', y: 'value' })], - x: { scale: scaleBand }, - y: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - grid: true, + scales: { + x: { scale: scaleBand }, + y: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + grid: true, + }, }, }), }) @@ -141,13 +146,15 @@ const definition = useMemo(() => { return defineChart(({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'label' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, })) }, [rows, metric]) diff --git a/docs/reference/chart-spec.md b/docs/reference/chart-spec.md index 98b99768..a8bf4c20 100644 --- a/docs/reference/chart-spec.md +++ b/docs/reference/chart-spec.md @@ -10,33 +10,35 @@ presentation. ```ts type ChartSpec = { marks: TMarks + scales: ChartScales guides?: boolean color?: ChartColorOptions gradients?: readonly ChartLinearGradient[] clip?: boolean margin?: number | Partial theme?: Partial -} & ([ChartMarkScaleX] extends [never] - ? { x?: null } - : { x: ChartAxisOptions }) & - ([ChartMarkScaleY] extends [never] - ? { y?: null } - : { y: ChartAxisOptions }) +} + +type ChartScales = Readonly< + Record +> & { + x: ChartPositionScaleOptions | null + y: ChartPositionScaleOptions | null +} ``` ## Properties -| Property | Required | Meaning | -| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | -| `marks` | Yes | Ordered mark layers. Later scene nodes paint after earlier ones. | -| `x` | Conditional | Required when a mark materializes x; omitted otherwise. | -| `y` | Conditional | Required when a mark materializes y; omitted otherwise. | -| `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | -| `color` | No | Shared categorical or quantitative color scale and optional legend. | -| `gradients` | No | Linear-gradient resources consumed by the default SVG and Canvas renderers. | -| `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | -| `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | -| `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | +| Property | Required | Meaning | +| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------ | +| `marks` | Yes | Ordered mark layers. Later scene nodes paint after earlier ones. | +| `scales` | Yes | Cartesian scale registry. Reserved `x` and `y` entries are required; additional named scales are optional. | +| `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | +| `color` | No | Shared categorical or quantitative color scale and optional legend. | +| `gradients` | No | Linear-gradient resources consumed by the default SVG and Canvas renderers. | +| `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | +| `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | +| `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | The detailed option contracts live in [Scales, guides, and color](./scales-guides-and-color.md). Mark-specific @@ -61,8 +63,10 @@ const definition = defineChart({ }), lineY(rows, { x: 'date', y: 'value', points: true }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear, grid: true }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear, grid: true }, + }, }) ``` @@ -80,35 +84,49 @@ Built-in marks infer stable keys from a unique primitive top-level `id`, nested unique. Mark IDs default from layer order; set `id` explicitly when a mark must retain identity while its order changes. -## Conditional positional axes +Built-in Cartesian, radial, and composite marks accept an optional +`renderer`. Passing `canvasChartRenderer` opts that mark into Canvas while +marks without the option, including ordinary axes and guides, keep the host +renderer. The host groups adjacent runs without changing declaration order. +See [Mark-level renderers](./rendering-and-export.md#mark-level-renderers). + +## Required positional scales -Each axis used by the marks is required. Supply a compatible factory for an inferred -domain or a configured instance for a fixed domain: +`scales.x` and `scales.y` are required. Supply a compatible factory for an +inferred domain or a configured instance for a fixed domain: ```ts -const axes = { +const scales = { x: { scale: scaleUtc }, y: { scale: scaleLinear }, } ``` -Omit an unused dimension: +Use `null` for an unused dimension: ```ts const horizontalThresholds = defineChart({ marks: [ruleY([25, 50, 75])], - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: null, + y: { scale: scaleLinear().domain([0, 100]) }, + }, }) ``` -`axis: false` hides an axis but does not remove its scale. Scene compilation -still guards untyped consumers that omit or null an axis used by a mark. +`axis: false` hides an axis but does not remove its scale. A `null` entry says +that the scale does not exist. Scene compilation rejects a mark bound to a +missing scale. + +Additional entries name independent mappings. Each named entry declares its +`channel`, and marks opt into it with `xScale` or `yScale`. See +[Named scales and multiple axes](./scales-guides-and-color.md#named-scales-and-multiple-axes). ## Guides and margins Guide visibility and geometry are separate: -- `x.axis: false` or `y.axis: false` hides one axis. +- `scales.x.axis: false` or `scales.y.axis: false` hides one axis. - `guides: false` hides all guides and removes their implicit margin. - Omitted `margin` sides are measured from ticks, rotation, titles, edge overhang, color legends, and Cartesian `text` marks. @@ -128,8 +146,7 @@ renderers: ```ts const definition = defineChart({ marks, - x, - y, + scales: { x, y }, clip: true, gradients: [ { diff --git a/docs/reference/custom-extensions.md b/docs/reference/custom-extensions.md index 70f68ccc..9bee922b 100644 --- a/docs/reference/custom-extensions.md +++ b/docs/reference/custom-extensions.md @@ -29,8 +29,8 @@ function compositeMark< > ``` -`CompositeMarkOptions` contains optional `id` and `motion` fields. The result -preserves the union of child datum and positional types. Initialization merges +`CompositeMarkOptions` contains optional `id`, `motion`, and `renderer` fields. +The result preserves the union of child datum and positional types. Initialization merges the children's semantic channels under parent and child namespaces. Rendering keeps child order, namespaces scene keys and mark IDs, and retains each child point as a separate interaction target. Parent and child motion definitions @@ -53,12 +53,15 @@ function createMark< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark + renderer?: ChartMarkRenderer, +): ChartMark ``` Initialization runs once per scene compilation and receives the mark's layer @@ -97,6 +100,11 @@ initializer may return its own `motion` when a composite or resolved layout needs a scene-local policy; that value takes precedence over the factory fallback. +The optional third `renderer` argument is copied onto the nodes returned by +both direct `render` and resolved-layout render paths. It does not replace or +shift the motion argument. Use a stable `ChartMarkRenderer` instance so an +update can reuse its existing surface composition. + Materialized channels declare semantic values before scale resolution: ```ts @@ -107,9 +115,13 @@ interface MaterializedChannel { } ``` -Use `scale: 'x'`, `scale: 'y'`, or `scale: 'color'` for shared chart scales. -`includeZero` is a hint available to a custom scale resolver. Filter invalid -values before materializing them. +Use a `ChartSpec.scales` ID for a positional channel. The reserved `x` and `y` +IDs are the defaults; additional IDs select named scales. Use `scale: 'color'` +for the shared color scale, and do not reuse `color` as a positional scale ID. +Pass named position IDs through the `createMark` scale ID type parameters so +their values do not widen the reserved scale types. `includeZero` is a hint +available to a custom scale resolver. Filter invalid values before +materializing them. `viewport` overrides presentation ownership independently for x and y. Without an override, a mark is viewport content on an axis when one of its materialized @@ -256,17 +268,29 @@ function createMarkWithScaleValues< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark + renderer?: ChartMarkRenderer, +): ChartMark< + TDatum, + TXPointValue, + TYPointValue, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> ``` The subpath also exports `ChartMarkPointX`, `ChartMarkPointY`, `ChartMarkScaleX`, and `ChartMarkScaleY`. Use it only when the distinction is -real; ordinary custom marks should use `createMark`. +real; ordinary custom marks should use `createMark`. Pass named scale IDs in +the last two type parameters when the mark does not use reserved `x` or `y`. ## Curves @@ -372,6 +396,18 @@ Use `@tanstack/charts/renderer` directly or the framework `/core` entries. The optional built-in implementation at `@tanstack/charts/canvas` demonstrates the boundary without changing the default SVG imports. +`ChartMarkRenderer` is the small renderer-selection token stored in universal +mark and scene types. A DOM implementation uses `ChartLayerRenderer`, which +extends `ChartRenderer` and `ChartMarkRenderer` with +`compose(defaultRenderer)`. `UniversalChartLayerRenderer` provides the same +contract for a definition-agnostic renderer. The returned compositor owns the +ordered child surfaces and exposes them through `ChartSurface.layers`. + +Built-in marks accept a `ChartMarkRenderer` through their shared +`ChartMarkOptions`. Custom marks pass it as the third factory argument, after +the optional motion definition. See +[Mark-level renderers](./rendering-and-export.md#mark-level-renderers). + For an SVG-only serialization change, pass a `ChartSvgRenderer` as `renderSvg` to the compatibility host or adapt it with `createSvgChartRenderer` from `@tanstack/charts/svg/renderer`. Preserve the SVG root, stable DOM keys, diff --git a/docs/reference/dom-host.md b/docs/reference/dom-host.md index 36041802..85633106 100644 --- a/docs/reference/dom-host.md +++ b/docs/reference/dom-host.md @@ -91,24 +91,24 @@ For the built-in Canvas renderer, `mountCanvasChart` from The default SVG host requires `definition` and `ariaLabel`. -| Option | Default | Meaning | -| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------ | -| `definition` | Required | [Chart definition](./chart-definitions.md). Its identity is the application update boundary. | -| `ariaLabel` | Required | Accessible chart name placed on the SVG. | -| `ariaDescription` | None | Optional SVG description. | -| `height` | `320` | Fixed scene height in CSS pixels. | -| `aspectRatio` | None | Computes height as `width / aspectRatio` when `height` is absent and the ratio is positive and finite. | -| `width` | Container width | Fixed scene width. Supplying it disables resize observation. | -| `initialWidth` | `640` | Width used when a responsive container has not produced a positive measurement. | -| `className` | None | Extra class on the rendered SVG, not the container. | -| `idPrefix` | Empty | Prefix for renderer-owned resource IDs. Use a unique value for resource-aware charts. | -| `tabIndex` | `0` | SVG tab index while keyboard behavior is enabled. | -| `onFocusChange` | None | Receives the primary focused point or `null`. | -| `onFocusGroupChange` | None | Receives all points selected by the current focus strategy. | -| `onSelect` | None | Receives the clicked or keyboard-activated point, or `null` for an empty click. | -| `onRender` | None | Runs after reconciliation with the container, live SVG, scene, and interaction controller. | -| `renderSvg` | `renderChartSvg` | Replaces the scene-to-SVG renderer. | -| `measureText` | DOM font measurer | Replaces guide text measurement. | +| Option | Default | Meaning | +| -------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------- | +| `definition` | Required | [Chart definition](./chart-definitions.md). Its identity is the application update boundary. | +| `ariaLabel` | Required | Accessible chart name placed on the SVG. | +| `ariaDescription` | None | Optional SVG description. | +| `height` | `320` | Fixed scene height in CSS pixels. | +| `aspectRatio` | None | Computes height as `width / aspectRatio` when `height` is absent and the ratio is positive and finite. | +| `width` | Container width | Fixed scene width. Supplying it disables resize observation. | +| `initialWidth` | `640` | Width used when a responsive container has not produced a positive measurement. | +| `className` | None | Extra class on the rendered chart surface, not the container. | +| `idPrefix` | Empty | Prefix for renderer-owned resource IDs. Use a unique value for resource-aware charts. | +| `tabIndex` | `0` | SVG tab index while keyboard behavior is enabled. | +| `onFocusChange` | None | Receives the primary focused point or `null`. | +| `onFocusGroupChange` | None | Receives all points selected by the current focus strategy. | +| `onSelect` | None | Receives the clicked or keyboard-activated point, or `null` for an empty click. | +| `onRender` | None | Runs after reconciliation with the container, default SVG, complete surface, scene, and interaction controller. | +| `renderSvg` | `renderChartSvg` | Replaces the scene-to-SVG renderer. | +| `measureText` | DOM font measurer | Replaces guide text measurement. | The definition owns these chart controls: diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 373430bf..301d6cc7 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -162,8 +162,11 @@ const definition = defineChart({ strokeDasharray: '4 4', }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', maxFocusDistance: Number.POSITIVE_INFINITY, }) @@ -322,8 +325,11 @@ const definition = defineChart({ lineY(rows, { x: 'date', y: 'value' }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', cursor: { use: cursorHost, @@ -959,10 +965,10 @@ resolution. Dragging, scrolling, custom crosshair overlays, and freeform range or lasso selections can listen on a wrapper or use `onRender` to attach application -behavior to the live SVG. Use `handleX` for one ordered scale value, `brushX` -for a normal horizontal semantic range, and `zoomX` for a normal controlled x -window. For custom gestures, keep semantic state outside the scene and update a -responsive definition by replacing its identity. +behavior to the default SVG or complete surface. Use `handleX` for one ordered +scale value, `brushX` for a normal horizontal semantic range, and `zoomX` for a +normal controlled x window. For custom gestures, keep semantic state outside +the scene and update a responsive definition by replacing its identity. Use a definition cursor binding for snapped or free crosshairs. Keep other semantic state outside the scene. diff --git a/docs/reference/index.md b/docs/reference/index.md index af31ef73..04cdcf2d 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -28,6 +28,11 @@ and application-owned interaction. ## Mark reference +Built-in Cartesian, radial, and composite marks accept +`renderer?: ChartMarkRenderer`. Pass `canvasChartRenderer` from +`@tanstack/charts/canvas` to opt that mark into Canvas while the rest of the +chart keeps its host renderer. + | Marks | Reference | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `lineY`, `lineX`, `areaY`, and `areaX` | [Line and area](./marks/line-and-area.md) | @@ -70,9 +75,9 @@ and application-owned interaction. | Alpine | — | [Adapter](../framework/alpine/adapter.md) | [`charts`](../framework/alpine/reference/chart.md) | | Octane | [Quick start](../framework/octane/quick-start.md) | [Adapter](../framework/octane/adapter.md) | [`Chart`](../framework/octane/reference/chart.md) | -React and Octane keep the default `Chart` SVG-based. Their `/canvas` entries -select the optional Canvas renderer; their `/core` entries require an explicit -`ChartRenderer`. The other adapters currently expose the default SVG surface. +Every default `Chart` starts with SVG and can opt selected marks into Canvas. +React and Octane also provide `/canvas` entries for a completely Canvas chart +and `/core` entries that require an explicit `ChartRenderer`. ## Surface tiers @@ -164,7 +169,7 @@ aggregate `/scales` export. | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | | `@tanstack/charts/rect` | `rect`, `cell` | -| `@tanstack/charts/renderer` | `mountChartRenderer` | +| `@tanstack/charts/renderer` | `mountChartRenderer` and `resolveChartRenderer` | | `@tanstack/charts/ridgeline` | `ridgelineY`, `ridgelineX`, `RidgelineYOptions`, `RidgelineXOptions`, `RidgelinePosition`, `RidgelineCurve`, and `RidgelineStateStyle` | | `@tanstack/charts/rule` | `ruleX`, `ruleY` | | `@tanstack/charts/runtime` | `createChartRuntime`, `isResponsiveChartDefinition` | diff --git a/docs/reference/marks/dodge.md b/docs/reference/marks/dodge.md index a9ad7f93..5f03388f 100644 --- a/docs/reference/marks/dodge.md +++ b/docs/reference/marks/dodge.md @@ -90,7 +90,10 @@ defineChart({ layout: dodgeY({ anchor: 'middle' }), }), ], - x: { scale: scaleLinear().domain([5, 50]) }, + scales: { + x: { scale: scaleLinear().domain([5, 50]) }, + y: null, + }, }) ``` diff --git a/docs/reference/marks/line-and-area.md b/docs/reference/marks/line-and-area.md index 7e539e4d..2f776f67 100644 --- a/docs/reference/marks/line-and-area.md +++ b/docs/reference/marks/line-and-area.md @@ -255,8 +255,10 @@ const definition = defineChart({ z: 'series', }), ], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, }) ``` diff --git a/docs/reference/marks/polar.md b/docs/reference/marks/polar.md index 53f09798..1516d9c3 100644 --- a/docs/reference/marks/polar.md +++ b/docs/reference/marks/polar.md @@ -36,28 +36,29 @@ foregrounds paint last. function polar(options: PolarOptions): ChartMark ``` -| Option | Type | Default | Meaning | -| ------------- | ----------------------- | ------------- | ------------------------------------------------ | -| `id` | `string` | Layer-derived | Stable container ID | -| `className` | `string` | None | Class added beside `ts-chart__polar` | -| `marks` | `readonly PolarMark[]` | Required | Radial marks rendered in order | -| `guides` | `readonly PolarGuide[]` | `[]` | Background/foreground guide layers around marks | -| `angle` | `PolarAngleOptions` | None | Angle factory or instance and optional wrapping | -| `radius` | `PolarRadiusOptions` | None | Radius scale and optional responsive pixel range | -| `startAngle` | `number` | `0` | Start of the available angular range in radians | -| `endAngle` | `number` | `2π` | End of the available angular range in radians | -| `inset` | `number` | `0` | Pixels removed from the maximum centered radius | -| `radiusRatio` | `number` | `1` | Multiplier applied to the radius after inset | +| Option | Type | Default | Meaning | +| ------------- | ----------------------- | ------------- | -------------------------------------------------------------- | +| `id` | `string` | Layer-derived | Stable container ID | +| `className` | `string` | None | Class added beside `ts-chart__polar` | +| `marks` | `readonly PolarMark[]` | Required | Radial marks rendered in order | +| `guides` | `readonly PolarGuide[]` | `[]` | Background/foreground guide layers around marks | +| `scales` | `PolarScales` | Required | Reserved `angle` and `radius` entries plus optional named ones | +| `startAngle` | `number` | `0` | Start of the available angular range in radians | +| `endAngle` | `number` | `2π` | End of the available angular range in radians | +| `inset` | `number` | `0` | Pixels removed from the maximum centered radius | +| `radiusRatio` | `number` | `1` | Multiplier applied to the radius after inset | +| `renderer` | `ChartMarkRenderer` | Host renderer | Renderer for the complete polar container | The default angular range is a complete circle. Angles use D3's radial convention: zero is at twelve o'clock and positive values move clockwise. -`PolarAngleOptions` and `PolarRadiusOptions` accept compatible factories with -mark-inferred domains or configured instances with fixed domains. `nice` -applies after inference. TanStack supplies responsive ranges without mutating -an instance. An omitted `wrap` closes a complete revolution without adding a -duplicate semantic category, but preserves both endpoints of a partial range. -Set it explicitly to override that behavior. +`scales.angle` and `scales.radius` accept compatible factories with +mark-inferred domains or configured instances with fixed domains. Use `null` +when no child mark uses that channel. `nice` applies after inference. TanStack +supplies responsive ranges without mutating an instance. An omitted `wrap` +closes a complete revolution without adding a duplicate semantic category, +but preserves both endpoints of a partial range. Set it explicitly to override +that behavior. `PolarRadiusOptions.range` overrides the default `[0, radius]` pixel range on the copied radius scale. Each endpoint is a nonnegative pixel length or a @@ -74,14 +75,49 @@ const radiusOptions = { The range is re-resolved on resize and never mutates the authored D3 scale. `PolarLayoutContext` contains `chart`, `centerX`, `centerY`, `radius`, -`startAngle`, `endAngle`, and optional resolved angle/radius scales. Each -`PolarResolvedScale` exposes its semantic `domain`, responsive `map`, `ticks`, -and `bandwidth`. A `PolarLength` is either a pixel length or a callback of the -layout context. Use a callback for radii that must remain proportional during -resize. +`startAngle`, `endAngle`, and resolved position scales under `scales`. Each +`PolarResolvedScale` exposes its `id`, `channel`, semantic `domain`, responsive +`map`, `ticks`, and `bandwidth`. A `PolarLength` is either a pixel length or a +callback of the layout context. Use a callback for radii that must remain +proportional during resize. -The outer chart omits `x` and `y`. Cartesian axes do not participate in the -internal polar scales. +Additional entries support independent angle or radius mappings. Declare the +entry's channel, then bind a radial mark to its ID: + +```ts +polar({ + scales: { + angle: { scale: categoryScale }, + radius: { scale: percentScale }, + target: { + channel: 'radius', + scale: targetScale, + }, + }, + marks: [ + radialLine(actual, { angle: 'metric', radius: 'value' }), + radialLine(target, { + angle: 'metric', + radius: 'value', + radiusScale: 'target', + }), + ], +}) +``` + +`radialLine`, `radialArea`, `radialDot`, `radialText`, `radialRule`, and both +radial bar marks use `angleScale` and `radiusScale`. Omitted bindings use the +reserved `angle` and `radius` entries. `radialArc` uses authored angular and +radial geometry instead of positional scale bindings. + +All radial mark option objects also accept `renderer?: ChartMarkRenderer`. +Use `canvasChartRenderer` to paint one radial mark with Canvas while sibling +marks and guide labels stay in SVG. Setting `renderer` on the outer `polar` +options moves the complete polar container, including its guides, to that +renderer. + +The outer chart uses `scales: { x: null, y: null }`. Cartesian axes do not +participate in the internal polar scales. ## `focusGroupAngle` @@ -227,11 +263,12 @@ Use the implicit physical-center radius baseline for nonnegative magnitudes. For signed values or true radial intervals, set `radius1: 0` (or another semantic baseline) so both endpoints map through the configured scale. -Both marks accept `id`, `className`, `key`, `z`, `color`, `fill`, fill opacity, -stroke styling, opacity, and motion. `cornerRadius` accepts a `PolarLength` or -`"full"`; the latter resolves to half the bar's radial thickness. Each valid -bar emits one geometry-backed interaction point at its quantitative endpoint -and preserves its interval endpoints for focus and tooltip formatting. +Both marks accept `id`, `className`, `angleScale`, `radiusScale`, `key`, `z`, +`color`, `fill`, fill opacity, stroke styling, opacity, and motion. +`cornerRadius` accepts a `PolarLength` or `"full"`; the latter resolves to half +the bar's radial thickness. Each valid bar emits one geometry-backed +interaction point at its quantitative endpoint and preserves its interval +endpoints for focus and tooltip formatting. ## `radialLine` and `radialArea` @@ -248,13 +285,14 @@ function radialArea( ``` Both marks use `angle` and `radius` channels and accept `id`, `className`, -`key`, `z`, `color`, and a D3 curve factory. The channels default to row index -and a numeric datum. `color` contributes to the chart color scale and defaults -to `z`. When `z` is omitted, an authored `color` also partitions the paths. -When both are present, `z` remains the explicit geometry and interaction -group. `radialLine` accepts final stroke, dash, opacity, and optional `points` -styling. `radialArea` accepts final fill and stroke styling plus `radius1` for -an explicit inner scale value; `radius1` defaults to zero. +`angleScale`, `radiusScale`, `key`, `z`, `color`, and a D3 curve factory. The +channels default to row index and a numeric datum. `color` contributes to the +chart color scale and defaults to `z`. When `z` is omitted, an authored +`color` also partitions the paths. When both are present, `z` remains the +explicit geometry and interaction group. `radialLine` accepts final stroke, +dash, opacity, and optional `points` styling. `radialArea` accepts final fill +and stroke styling plus `radius1` for an explicit inner scale value; `radius1` +defaults to zero. Their datum key defaults to a unique top-level or nested `data.id`, then a unique angle within each effective path group, then row index. @@ -274,10 +312,11 @@ function radialDot( ``` `radialDot` uses the same angle/radius channel defaults. It also accepts `id`, -`className`, `key`, `z`, `color`, `r`, `rScale`, fill, stroke, and opacity styling. -Radius defaults to 3.5 pixels. Each valid datum emits one interaction point -with its original angle/radius values and projected screen position. Its key -defaults to a unique top-level or nested `data.id`, then row index. +`className`, `angleScale`, `radiusScale`, `key`, `z`, `color`, `r`, `rScale`, +fill, stroke, and opacity styling. Radius defaults to 3.5 pixels. Each valid +datum emits one interaction point with its original angle/radius values and +projected screen position. Its key defaults to a unique top-level or nested +`data.id`, then row index. ## `radialText` @@ -290,8 +329,9 @@ function radialText( `radialText` maps `angle` and `radius` channels through the container's copied polar scales, then positions labels with D3's radial point projection. It -accepts `text`, `key`, `z`, `color`, fill, font size and weight, anchor, baseline, -rotation, and pixel `dx`/`dy`. `radiusOffset` is a signed constant or per-datum +accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, `color`, fill, font +size and weight, anchor, baseline, rotation, and pixel `dx`/`dy`. +`radiusOffset` is a signed constant or per-datum visual channel applied in pixels after the semantic radius is mapped. It does not contribute to the radius domain. Set `anchor: "outside"` to resolve `start`, `middle`, or `end` from the final mapped angle. Exact and near-exact @@ -317,12 +357,12 @@ function radialRule( `radius1Offset` and `radius2Offset` as signed constant or per-datum pixel visual channels applied after the corresponding semantic radius is mapped. Offsets never contribute to radius-domain inference. A nonfinite resolved -endpoint offset omits that segment. The mark also accepts `key`, `z`, `color`, -stroke, opacity, width, and dash styling. It covers gauge needles, ticks, and -pie-label leaders without expanding one logical segment into two path rows. -Rules remain decorative and emit no interaction points. Its key defaults to a -unique top-level or nested `data.id`, then a unique angle within each `z` -group, then row index. +endpoint offset omits that segment. The mark also accepts `angleScale`, +`radiusScale`, `key`, `z`, `color`, stroke, opacity, width, and dash styling. +It covers gauge needles, ticks, and pie-label leaders without expanding one +logical segment into two path rows. Rules remain decorative and emit no +interaction points. Its key defaults to a unique top-level or nested +`data.id`, then a unique angle within each `z` group, then row index. Pixel offsets do not reserve space outside the polar radius. Use `radiusRatio`, `inset`, or chart margins when labels or leaders must remain @@ -335,15 +375,17 @@ function radialGrid(options?: RadialGridOptions): PolarGuide function angleGrid(options?: AngleGridOptions): PolarGuide ``` -`radialGrid` draws radius values as circles or polygons. Supply explicit -`values`, or let `ticks` request values from the configured radius scale. -Labels are off by default. Label angle, offset, rotation, format, fill, and -font size are configurable. Ring `fill` and `fillOpacity` can layer filled -circle or polygon grids behind the chart marks. - -`angleGrid` draws spokes for explicit `values` or the configured angle domain. -It can show labels around the circumference with `format` and `labelOffset`. -Labels are on by default and use the same outside-anchor rule as +`radialGrid` draws radius values as circles or polygons. Its `scale` option +selects a named radius scale, and `angleScale` selects the angle scale used for +polygon rings. Supply explicit `values`, or let `ticks` request values from the +selected radius scale. Labels are off by default. Label angle, offset, +rotation, format, fill, and font size are configurable. Ring `fill` and +`fillOpacity` can layer filled circle or polygon grids behind the chart marks. + +`angleGrid` draws spokes for explicit `values` or the selected angle domain. +Its `scale` option selects a named angle scale. It can show labels around the +circumference with `format` and `labelOffset`. Labels are on by default and +use the same outside-anchor rule as `radialText({ anchor: "outside" })` unless `labelAnchor` is supplied. Both guides accept ID, class, stroke, opacity, width, and dash styling. @@ -369,7 +411,8 @@ built-in grids put rings and spokes in `background` and labels in `foreground`, keeping labels legible without painting grid geometry over the data. -The exported option contracts are `PolarOptions`, `RadialArcOptions`, +The exported option contracts are `PolarOptions`, `PolarScales`, +`PolarPositionChannel`, `PolarPositionScaleOptions`, `RadialArcOptions`, `RadialBarRadiusOptions`, `RadialBarAngleOptions`, `RadialLineOptions`, `RadialAreaOptions`, `RadialDotOptions`, `RadialTextOptions`, `RadialRuleOptions`, `RadialGridOptions`, and `AngleGridOptions`. The coordinate contracts are `PolarAngleOptions`, diff --git a/docs/reference/marks/ridgeline.md b/docs/reference/marks/ridgeline.md index ffd40f48..403ec5c5 100644 --- a/docs/reference/marks/ridgeline.md +++ b/docs/reference/marks/ridgeline.md @@ -52,10 +52,12 @@ defineChart({ color: 'season', }), ], - x: { scale: scaleLinear().domain([4, 10]) }, - y: { - scale: scalePoint().domain(seasons).padding(0.78), - reverse: true, + scales: { + x: { scale: scaleLinear().domain([4, 10]) }, + y: { + scale: scalePoint().domain(seasons).padding(0.78), + reverse: true, + }, }, }) ``` diff --git a/docs/reference/marks/sankey.md b/docs/reference/marks/sankey.md index 43a897ca..420e3457 100644 --- a/docs/reference/marks/sankey.md +++ b/docs/reference/marks/sankey.md @@ -53,6 +53,10 @@ const chart = defineChart({ ] as const, }), ], + scales: { + x: null, + y: null, + }, guides: false, }) ``` diff --git a/docs/reference/marks/sunburst.md b/docs/reference/marks/sunburst.md index bea5841d..73e857ee 100644 --- a/docs/reference/marks/sunburst.md +++ b/docs/reference/marks/sunburst.md @@ -30,8 +30,16 @@ const chart = defineChart({ stroke: '#fff', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -140,8 +148,16 @@ const definition = (rootId: string) => visibleDepth: 2, }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, motion: { transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, }, diff --git a/docs/reference/marks/text-frame-and-facet.md b/docs/reference/marks/text-frame-and-facet.md index fcef73db..010a1bb0 100644 --- a/docs/reference/marks/text-frame-and-facet.md +++ b/docs/reference/marks/text-frame-and-facet.md @@ -104,8 +104,10 @@ facet(rows, { chart(groupRows) { return { marks: [lineY(groupRows, { x: 'date', y: 'value' })], - x: { scale: makeXScale(groupRows) }, - y: { scale: makeYScale(groupRows), grid: true }, + scales: { + x: { scale: makeXScale(groupRows) }, + y: { scale: makeYScale(groupRows), grid: true }, + }, } }, }) diff --git a/docs/reference/marks/violin.md b/docs/reference/marks/violin.md index 54b882f4..30aa5ab6 100644 --- a/docs/reference/marks/violin.md +++ b/docs/reference/marks/violin.md @@ -63,10 +63,12 @@ defineChart({ }), dot(summaries, { x: 'species', y: 'median' }), ], - x: { - scale: scalePoint().domain(species).padding(0.5), + scales: { + x: { + scale: scalePoint().domain(species).padding(0.5), + }, + y: { scale: scaleLinear }, }, - y: { scale: scaleLinear }, }) ``` diff --git a/docs/reference/motion.md b/docs/reference/motion.md index 76586f3a..3a32ac60 100644 --- a/docs/reference/motion.md +++ b/docs/reference/motion.md @@ -81,7 +81,8 @@ const host = mountChartRenderer(container, { ``` React and Octane applications use their `/core` component entry and pass the -same renderer. Other adapters currently expose their default SVG surface. +same renderer. Other adapters start with their default SVG renderer, and a +definition may still select Canvas for individual marks. ## Definition-local motion @@ -89,6 +90,11 @@ same renderer. Other adapters currently expose their default SVG surface. or axis label is inert policy. The optional renderer consumes it. Definitions remain valid for static SVG and Canvas, which paint the final state. +This stays true in a mixed chart. A mark that selects `canvasChartRenderer` +keeps its authored motion option, but the Canvas layer paints the final scene +instead of running the tween or spring policy. The optional `motion()` renderer +still animates the default-renderer layers it owns. + ```ts import { scaleBand } from '@tanstack/charts/scales/band' import { scaleLinear } from '@tanstack/charts/scales/linear' @@ -116,14 +122,16 @@ const definition = defineChart({ }, }), ], - x: { - scale: scaleBand, - axis: { - ticks: { motion: { transition: { type: 'tween', duration: 180 } } }, - tickLabels: { motion: { delay: 40 } }, + scales: { + x: { + scale: scaleBand, + axis: { + ticks: { motion: { transition: { type: 'tween', duration: 180 } } }, + tickLabels: { motion: { delay: 40 } }, + }, }, + y: { scale: scaleLinear }, }, - y: { scale: scaleLinear }, }) ``` @@ -169,6 +177,10 @@ const definition = defineChart({ ...stagger({ each: 35, by: 'series', roles: ['arc', 'bar'] }), }, marks, + scales: { + x: null, + y: null, + }, }) ``` @@ -271,8 +283,11 @@ const definition = defineChart({ transition: { type: 'tween', duration: 800, easing: 'linear' }, }, marks: [lineY(rows, { x: 'time', y: 'value', key: 'id' })], - x: { scale: scaleUtc().domain([visibleStart, visibleEnd]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleUtc().domain([visibleStart, visibleEnd]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + clip: true, }) ``` diff --git a/docs/reference/rendering-and-export.md b/docs/reference/rendering-and-export.md index 01bd612d..e0cb4e0b 100644 --- a/docs/reference/rendering-and-export.md +++ b/docs/reference/rendering-and-export.md @@ -41,6 +41,11 @@ for it only when they import a Canvas entry. Motion is likewise isolated behind `@tanstack/charts/motion`. The `/core` adapter entries accept an explicit `renderer` without choosing one for the application. +A default SVG host can still compose selected Canvas marks. Import +`canvasChartRenderer` from `@tanstack/charts/canvas` and attach it to those +marks. The default adapter remains the host, and the Canvas painter enters the +module graph through that explicit import. + ## React Native adapter The React Native entry selects its native build through the package export @@ -162,7 +167,7 @@ interface CanvasChartRenderer< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> extends ChartRenderer { +> extends ChartLayerRenderer { mount: ( container: HTMLElement, requestRender: (force?: boolean) => void, @@ -456,12 +461,99 @@ The promise rejects when any requirement fails or Canvas encoding returns no blob. `downloadChartImage` defaults to `chart.png`; keep the filename extension consistent with the selected MIME type. -The raster helpers accept a mounted SVG or Canvas chart root, or an ancestor -containing one. SVG is serialized, decoded, and drawn into the export canvas. -Canvas uses the stable `canvas` base bitmap directly when focus is excluded. -With `includeFocus`, it composites `backgroundCanvas`, `focusUnderCanvas`, -`sceneCanvas`, and `focusCanvas` in that order. `serializeChartSvg` and -`downloadChartSvg` remain SVG-only. +The raster helpers accept a mounted SVG, Canvas, or mixed chart root, or an +ancestor containing one. SVG is serialized, decoded, and drawn into the export +canvas. Canvas uses the stable `canvas` base bitmap directly when focus is +excluded. With `includeFocus`, it composites `backgroundCanvas`, +`focusUnderCanvas`, `sceneCanvas`, and `focusCanvas` in that order. Mixed roots +draw every child surface in visual order. `serializeChartSvg` and +`downloadChartSvg` reject mixed roots and remain SVG-only. + +## Mark-level renderers + +Built-in Cartesian, radial, and composite marks accept a `renderer` option. +Passing `canvasChartRenderer` opts that mark into Canvas while axes, guides, +and marks without the option keep the host renderer: + +```ts +import { areaY, defineChart, lineY, text } from '@tanstack/charts' +import { canvasChartRenderer } from '@tanstack/charts/canvas' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { scaleUtc } from 'd3-scale' + +const definition = defineChart({ + marks: [ + areaY(denseRows, { + x: 'time', + y: 'value', + renderer: canvasChartRenderer, + }), + lineY(summaryRows, { x: 'time', y: 'value' }), + text(labels, { x: 'time', y: 'value', text: 'label' }), + ], + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, +}) +``` + +Adjacent nodes with the same renderer share a layer. Alternating renderer runs +remain in declaration order, including nested runs inside facets, composites, +and `polar`. Server output contains one accessible mixed root with +deterministic child shells, and the browser adopts those shells. Direct +`renderChartSvg(scene, options)` calls remain an explicit SVG serialization of +the complete renderer-neutral scene and do not run surface composition. + +The universal mark contract stays small: + +```ts +interface ChartMarkRenderer { + readonly kind: 'chart-layer-renderer' + readonly id: string +} + +interface ChartMarkOptions { + renderer?: ChartMarkRenderer +} +``` + +A renderer that mounts one DOM layer implements the complete composition +contract: + +```ts +interface ChartLayerRenderer< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> + extends ChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} + +interface UniversalChartLayerRenderer + extends UniversalChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} +``` + +The first non-default mark renderer supplies the compositor. The built-in +Canvas renderer can compose normal `ChartRenderer` surfaces and exposes the +result as one `ChartSurface`. + +Mark motion and renderer selection are independent in the authoring API. The +built-in factories preserve both options, and `createMark` keeps motion as its +second argument and accepts the renderer as its third. The optional +`motion()` renderer consumes tween and spring policy only on layers it owns. +`canvasChartRenderer` paints the final mark scene and does not interpret that +policy. A host `svgAnimation` can still crossfade complete Canvas frames while +SVG layers reconcile keyed elements. Changing the layer renderer sequence +remounts the composition, so that structural update does not animate between +the old and new surface layout. ## Custom renderers @@ -480,6 +572,8 @@ interface ChartSurface< > { readonly renderer: ChartRenderer readonly element: Element + readonly layers?: readonly ChartSurface[] + readonly defaultElement?: Element render: ( scene: ChartScene, options: ChartSurfaceRenderOptions, @@ -531,6 +625,27 @@ interface ChartRendererRenderContext< } ``` +`element` is the one accessible, interactive root. `layers`, when present, +lists child surfaces from back to front. `defaultElement` is the topmost +surface element owned by the host's default renderer. SVG-oriented +`ChartRenderContext` callbacks expose that element as `svg` and also include +the complete renderer-neutral `surface`. `ChartRendererRenderContext` exposes +the complete surface directly. + +```ts +interface ChartRenderContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + container: HTMLElement + svg: SVGSVGElement + scene: ChartScene + surface: ChartSurface + interaction: ChartInteractionController +} +``` + | Member | Responsibility | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ChartRenderer.id` | Stable renderer identifier | @@ -539,6 +654,8 @@ interface ChartRendererRenderContext< | `mount()` | Adopt or create a surface in the container and connect renderer-owned environment observers | | `ChartSurface.renderer` | Refer to the renderer that created the surface; a different renderer object on update replaces the surface | | `ChartSurface.element` | Expose the accessible, focusable root used by shared keyboard and focus handling | +| `ChartSurface.layers` | Expose ordered child surfaces when this surface composes more than one renderer | +| `ChartSurface.defaultElement` | Expose the topmost element owned by the host's default renderer | | `render()` | Paint the complete scene and apply accessible name, class, tab index, ID prefix, and optional animation | | `clientToScene()` | Optionally convert viewport client coordinates to scene coordinates; the controller returns `null` when omitted or unavailable | | `getPresentationPoints()` | Expose renderer-owned point geometry while a scene transition is active | @@ -602,6 +719,14 @@ Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. +Import `resolveChartRenderer` from `@tanstack/charts/renderer` when implementing +a host outside the shared adapters. The function returns the effective renderer +for prerendering or mounting a scene. It returns the default when every node +uses that renderer. Otherwise it asks the first non-default +`ChartLayerRenderer` to compose the ordered layers with the default renderer. +The shared DOM host and `createChartRendererAdapter` perform this resolution +automatically. + SVG remains available as a renderer implementation: ```ts diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index 3ffb9db4..1e3f5126 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -9,6 +9,16 @@ Charts copies the resolved scale, assigns its responsive pixel range, and uses that copy for marks, ticks, and interaction. A supplied instance is never mutated. +Put positional mappings in the chart's `scales` registry. The reserved `x` +and `y` entries are the default bindings for Cartesian marks: + +```ts +const scales = { + x: { scale: scaleBand, axis: { label: 'Product' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Revenue' } }, +} +``` + Start with the exact compact scale entry for numeric linear, band, point, or ordinal mappings. Upgrade one mapping to D3 only when it needs temporal, nonlinear, radial, interpolated, or statistical scale semantics. The @@ -147,6 +157,13 @@ Scale copies make one configured scale safe to reuse across responsive scenes. ## Axis options ```ts +interface ChartPositionScaleOptions< + TValue extends ChartValue, +> extends ChartAxisOptions { + channel?: 'x' | 'y' + side?: 'top' | 'right' | 'bottom' | 'left' +} + interface ChartAxisOptions { scale: ChartScale | ChartScaleInput nice?: boolean | number @@ -216,14 +233,16 @@ interface ChartAxisOptions { } ``` -| Option | Default | Meaning | -| ---------- | --------------------------- | ------------------------------------------------------------------------ | -| `scale` | Required | Compact or D3 factory, configured instance, or custom `ChartScale`. | -| `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | -| `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | -| `viewport` | None | Commits a continuous semantic window and optional transient translation. | -| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | -| `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | +| Option | Default | Meaning | +| ---------- | ---------------------------- | ------------------------------------------------------------------------ | +| `scale` | Required | Compact or D3 factory, configured instance, or custom `ChartScale`. | +| `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | +| `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | +| `viewport` | None | Commits a continuous semantic window and optional transient translation. | +| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | +| `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | +| `channel` | Inferred for `x` and `y` | Required on named scales; selects the Cartesian channel and range. | +| `side` | `bottom` for x; `left` for y | Places an x axis on top/bottom or a y axis on left/right. | ```ts type ChartContinuousValue = number | Date @@ -257,9 +276,9 @@ domain order or `reverse`. Guides remain fixed. Viewport-dependent marks are clipped and translated per mark and per owned axis; see [Continuous viewports](../concepts/layout-axes-and-coordinates.md#continuous-viewports). -Set an axis to `null` only when no mark uses that positional scale. To keep the -scale while hiding its axis, use `axis: false`. Grid visibility remains -independent. +Set `scales.x` or `scales.y` to `null` only when no mark uses that positional +scale. To keep the scale while hiding its axis, use `axis: false`. Grid +visibility remains independent. Without an explicit `axis.ticks` policy, the responsive target is `clamp(2, floor(chart.width / 92), 8)` for x and @@ -330,6 +349,51 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +## Named scales and multiple axes + +The reserved `x` and `y` entries are the default bindings for Cartesian marks. +Add another entry when a mark needs an independent mapping, then bind that mark +with `xScale` or `yScale`: + +```ts +const chart = defineChart({ + marks: [ + lineY(revenue, { x: 'date', y: 'value' }), + lineY(conversion, { + x: 'date', + y: 'rate', + yScale: 'conversion', + }), + ], + scales: { + x: { scale: scaleUtc }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue' }, + }, + conversion: { + channel: 'y', + scale: scaleLinear, + side: 'right', + axis: { + label: 'Conversion', + ticks: { format: (value: number) => `${Math.round(value * 100)}%` }, + }, + }, + }, +}) +``` + +A named scale must declare `channel: 'x'` or `channel: 'y'`. Its mark binding +must use the same channel. The ID `color` is reserved for the shared visual +color scale and cannot name a Cartesian position scale. + +Every non-null scale renders an axis by default. Set `axis: false` when a +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. + ## Automatic guide layout When a margin side is omitted, the scene compiler measures: diff --git a/docs/reference/types.md b/docs/reference/types.md index e579ba1e..4fd27c07 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -81,8 +81,8 @@ not produce an unusably narrow scale or callback type. source datum → mark channel outputs → ChartMark point and scale value types - → ChartSpec axis requirements and definition datum/x/y unions - → axis scale and formatter types + → ChartSpec scale registry and definition datum/x/y unions + → scale and formatter types → host and adapter callback types ``` @@ -90,10 +90,10 @@ Marks in one chart may have different datum types. The definition exposes their union. TypeScript narrowing is therefore required when a callback handles heterogeneous layers. -`ChartMarkScaleX` and `ChartMarkScaleY` also control the chart shape. A -materialized scale value makes that axis required. `never` makes it optional -and null-only, so positionless and one-dimensional charts do not carry phantom -scale configuration. +`ChartMarkScaleX` and `ChartMarkScaleY` control the value types accepted by the +reserved scale entries. Canonical definitions always include `scales.x` and +`scales.y`; use `null` when a dimension is unused. A mark bound to another +scale ID does not widen the reserved entry's value type. Rect and custom interval marks can distinguish materialized scale values from interaction point values. The exported extractors are: @@ -129,7 +129,7 @@ style vocabulary; `ChartDotStateStyle`, `ChartBarStateStyle`, | Type | Purpose | | ------------------------------------- | ----------------------------------------------------------------------------- | -| `ChartSpec` | Marks plus conditionally required axes, guides, color, resources, and layout | +| `ChartSpec` | Marks plus a positional scale registry, guides, color, resources, and layout | | `StaticChartDefinition` | A directly compilable spec with inferred datum and semantic x/y phantom types | | `ResponsiveChartDefinition` | Responsive chart builder | | `ChartDefinition` | Static or responsive union | @@ -145,34 +145,38 @@ The complete overloads and runtime rules are in ## Marks and scenes -| Type | Purpose | -| ---------------------------------- | ----------------------------------------------------------------------------- | -| `ChartMark` | Public initialized-mark factory plus inferred point and scale types | -| `MarkInitializeContext` | Mark layer index | -| `InitializedMark` | Stable ID, channels, viewport ownership, render, and optional resolved layout | -| `MarkInitialization` | Direct-render or resolved-layout initializer result | -| `ResolvedLayoutMarkInitialization` | Layout initializer before `createMark` normalization | -| `MaterializedChannel` | Values contributed to an optional named scale | -| `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | -| `MarkResolvedLayoutContext` | Final positional scales and bounds for pure mark-local layout | -| `ResolvedMarkLayout` | Final channels, labels, states, and render closure from resolved layout | -| `MarkScene` | Mark-owned nodes plus optional interaction points, focus anchors, and guides | -| `MarkFocusGuide` | Mark-emitted focus guide with optional placement | -| `ChartScene` | Complete renderer-neutral output | -| `ChartPoint` | Typed interaction target | -| `ChartFocusAnchor` | Focus-filter identity that does not participate in hit testing | -| `SceneFocusGuide` | Data-less guide descriptor resolved against focus or cursor state | -| `SceneFocusGuideAxis` | One crosshair axis rule or categorical band, plus an optional label | -| `SceneFocusGuideBand` | Resolved categorical bandwidth, inset, radius, and paint | -| `SceneFocusGuideLabel` | Focus-guide label formatter, spacing, font, and paint | -| `SceneFocusGuideMarker` | Focus-guide intersection marker geometry and paint | -| `SceneFocusGuideResolveContext` | Scene, guide, local focus, pointer, and cursor passed to a guide resolver | -| `SceneFocusGuideResolver` | Optional-guide policy that returns one transient scene node | -| `ChartFocusPresentation` | Transient renderer-neutral underlay and overlay nodes | -| `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | -| `ChartTick` | Semantic value, formatted label, and pixel position | -| `ResolvedScale` | Final positional scale | -| `ResolvedColorScale` | Final color scale | +| Type | Purpose | +| ---------------------------------- | ------------------------------------------------------------------------------------ | +| `ChartMark` | Public initialized-mark factory plus inferred point, scale value, and scale ID types | +| `ChartMarkOptions` | Shared optional mark renderer contract | +| `ChartMarkRenderer` | Universal renderer-selection token stored on mark-owned scene nodes | +| `CartesianChartMark` | Cartesian mark alias that derives selected x and y scale IDs from its options | +| `OptionScaleId` | Resolves an optional named-scale selector to its fallback scale ID | +| `MarkInitializeContext` | Mark layer index | +| `InitializedMark` | Stable ID, channels, viewport ownership, render, and optional resolved layout | +| `MarkInitialization` | Direct-render or resolved-layout initializer result | +| `ResolvedLayoutMarkInitialization` | Layout initializer before `createMark` normalization | +| `MaterializedChannel` | Values contributed to an optional named scale | +| `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | +| `MarkResolvedLayoutContext` | Final positional scales and bounds for pure mark-local layout | +| `ResolvedMarkLayout` | Final channels, labels, states, and render closure from resolved layout | +| `MarkScene` | Mark-owned nodes plus optional interaction points, focus anchors, and guides | +| `MarkFocusGuide` | Mark-emitted focus guide with optional placement | +| `ChartScene` | Complete renderer-neutral output | +| `ChartPoint` | Typed interaction target | +| `ChartFocusAnchor` | Focus-filter identity that does not participate in hit testing | +| `SceneFocusGuide` | Data-less guide descriptor resolved against focus or cursor state | +| `SceneFocusGuideAxis` | One crosshair axis rule or categorical band, plus an optional label | +| `SceneFocusGuideBand` | Resolved categorical bandwidth, inset, radius, and paint | +| `SceneFocusGuideLabel` | Focus-guide label formatter, spacing, font, and paint | +| `SceneFocusGuideMarker` | Focus-guide intersection marker geometry and paint | +| `SceneFocusGuideResolveContext` | Scene, guide, local focus, pointer, and cursor passed to a guide resolver | +| `SceneFocusGuideResolver` | Optional-guide policy that returns one transient scene node | +| `ChartFocusPresentation` | Transient renderer-neutral underlay and overlay nodes | +| `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | +| `ChartTick` | Semantic value, formatted label, and pixel position | +| `ResolvedScale` | Final positional scale | +| `ResolvedColorScale` | Final color scale | Scene geometry and interaction point fields are documented in [Runtime and scene](./runtime-and-scene.md). @@ -203,7 +207,12 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | Type | Purpose | | ------------------------------- | --------------------------------------------------------------------- | -| `ChartAxisOptions` | Required positional scale and optional guide behavior | +| `ChartScales` | Reserved x/y entries plus optional named positional scales | +| `ChartPositionScaleOptions` | One registry entry, its Cartesian channel, side, and axis behavior | +| `ChartAxisOptions` | Positional scale and optional axis behavior | +| `ChartPositionChannel` | Cartesian registry channel, `x` or `y` | +| `ChartAxisSide` | Cartesian axis side | +| `CartesianScaleBindings` | Optional mark bindings to named `xScale` and `yScale` entries | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | | `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | @@ -255,18 +264,18 @@ scale bandwidth; zero-bandwidth axes emit no band. ## Host and runtime types -| Type | Purpose | -| -------------------------------- | ------------------------------------------------------------------- | -| `ChartHostCommonOptions` | Accessibility, sizing, callbacks, and SVG renderer options | -| `ChartHostOptions` | Common options plus a chart definition | -| `ChartHost` | SVG host `interaction`, `update`, `getScene`, and `destroy` | -| `ChartRendererHostCommonOptions` | Renderer-neutral common options plus required renderer | -| `ChartRendererHostOptions` | Renderer-neutral options plus a chart definition | -| `ChartRendererHost` | Renderer-neutral `interaction`, `update`, `getScene`, and `destroy` | -| `ChartRuntime` | Repeated static or responsive scene rendering | -| `ChartRuntimeOptions` | Platform theme shared by responsive building and scene compilation | -| `ChartRenderContext` | Container, live SVG, scene, and interaction controller | -| `ChartRendererRenderContext` | Container, live surface, scene, and interaction controller | +| Type | Purpose | +| -------------------------------- | -------------------------------------------------------------------------------- | +| `ChartHostCommonOptions` | Accessibility, sizing, callbacks, and SVG renderer options | +| `ChartHostOptions` | Common options plus a chart definition | +| `ChartHost` | SVG host `interaction`, `update`, `getScene`, and `destroy` | +| `ChartRendererHostCommonOptions` | Renderer-neutral common options plus required renderer | +| `ChartRendererHostOptions` | Renderer-neutral options plus a chart definition | +| `ChartRendererHost` | Renderer-neutral `interaction`, `update`, `getScene`, and `destroy` | +| `ChartRuntime` | Repeated static or responsive scene rendering | +| `ChartRuntimeOptions` | Platform theme shared by responsive building and scene compilation | +| `ChartRenderContext` | Container, live default SVG, complete surface, scene, and interaction controller | +| `ChartRendererRenderContext` | Container, live surface, scene, and interaction controller | See [DOM host](./dom-host.md) and [Runtime and scene](./runtime-and-scene.md). @@ -373,8 +382,10 @@ See [Focus and interaction](./focus-and-interaction.md). | `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | | `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | | `ChartSurfaceRenderOptions` | Render options plus optional animation | -| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | +| `ChartSurface` | Mounted root, optional child layers, painting, coordinates, focus, and cleanup | | `ChartRenderer` | Server shell and browser-surface renderer contract | +| `ChartLayerRenderer` | DOM renderer that can compose itself with the host renderer | +| `UniversalChartLayerRenderer` | Definition-agnostic form of `ChartLayerRenderer` | | `ChartRendererCapabilities` | Optional structural services supplied by a renderer | | `ChartRendererTooltipMotionCapability` | Versioned factory for an injected tooltip motion controller | | `ChartTooltipMotionController` | Tooltip paint, hide, and cleanup motion lifecycle | @@ -451,8 +462,9 @@ their behavior: - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). -- `@tanstack/charts/polar`: `PolarOptions`, `PolarMark`, `PolarGuide`, - `PolarGuideScene`, `PolarAngleOptions`, `PolarRadiusOptions`, +- `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, + `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, + `PolarRadiusOptions`, `PolarPositionChannel`, `PolarPositionScaleOptions`, `PolarResolvedScale`, `PolarLayoutContext`, `PolarLength`, `PolarGuideLabelContext`, `PolarGuideLabelOption`, `RadialArcOptions`, `RadialBarRadiusOptions`, `RadialBarAngleOptions`, `RadialLineOptions`, @@ -463,7 +475,8 @@ their behavior: ## Mark option types Every built-in mark exports its options type from the root and its granular -subpath: +subpath. Cartesian, radial, and composite option types include the shared +`ChartMarkOptions.renderer` selection: - `LineYOptions`, `AreaYOptions`, `AreaXOptions`, `AreaXCurve` - `BarYOptions`, `BarXOptions` diff --git a/docs/reference/view-composition.md b/docs/reference/view-composition.md index 718e2aec..634e1ced 100644 --- a/docs/reference/view-composition.md +++ b/docs/reference/view-composition.md @@ -59,8 +59,16 @@ const summaryDefinition = defineChart({ key: 'status', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) diff --git a/examples/charts-expo/App.tsx b/examples/charts-expo/App.tsx index dc859db3..df2e7924 100644 --- a/examples/charts-expo/App.tsx +++ b/examples/charts-expo/App.tsx @@ -23,8 +23,11 @@ const definition = defineChart({ points: true, }), ], - x: { label: 'Month', scale: scaleLinear().domain([1, 5]) }, - y: { label: 'Revenue', scale: scaleLinear().domain([0, 35]) }, + scales: { + x: { label: 'Month', scale: scaleLinear().domain([1, 5]) }, + y: { label: 'Revenue', scale: scaleLinear().domain([0, 35]) }, + }, + focus: 'nearest-x', tooltip: { use: tooltip, sticky: true }, }) diff --git a/examples/charts-octane/src/charts.ts b/examples/charts-octane/src/charts.ts index 84a79fc3..4ebbf2b2 100644 --- a/examples/charts-octane/src/charts.ts +++ b/examples/charts-octane/src/charts.ts @@ -103,29 +103,32 @@ export function createIndustryHistoryChart( curve: d3Curve(curveMonotoneX), }), ], - x: { - scale: - zoomed && zoomStart && zoomEnd - ? scaleUtc().domain([zoomStart, zoomEnd]) - : scaleUtc, - axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Month' }, - }, - y: { - scale: - mode === 'share' ? scaleLinear().domain([0, 1]).nice(5) : scaleLinear, - nice: 5, - grid: true, - axis: { - ticks: { - count: 5, - format: mode === 'share' ? formatPercent : formatNumber, + scales: { + x: { + scale: + zoomed && zoomStart && zoomEnd + ? scaleUtc().domain([zoomStart, zoomEnd]) + : scaleUtc, + axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Month' }, + }, + y: { + scale: + mode === 'share' ? scaleLinear().domain([0, 1]).nice(5) : scaleLinear, + nice: 5, + grid: true, + axis: { + ticks: { + count: 5, + format: mode === 'share' ? formatPercent : formatNumber, + }, + label: + mode === 'share' + ? 'Share of selected industries' + : 'Unemployed (thousands)', }, - label: - mode === 'share' - ? 'Share of selected industries' - : 'Unemployed (thousands)', }, }, + color: { scale: scaleOrdinal() .domain([...trackedIndustries]) @@ -163,21 +166,24 @@ export function createRankingChart(metric: RankingMetric, accent: string) { inset: width < 420 ? 2 : 3, }), ], - x: { - scale: scaleLinear, - nice: ticks, - grid: true, - axis: { - label: - width < 420 - ? undefined - : metric === 'economy (mpg)' - ? 'Fuel economy (mpg)' - : 'Power (hp)', + scales: { + x: { + scale: scaleLinear, + nice: ticks, + grid: true, + axis: { + label: + width < 420 + ? undefined + : metric === 'economy (mpg)' + ? 'Fuel economy (mpg)' + : 'Power (hp)', + }, + }, + y: { + scale: () => + scaleBand().paddingInner(0.24).paddingOuter(0.12), }, - }, - y: { - scale: () => scaleBand().paddingInner(0.24).paddingOuter(0.12), }, } }) diff --git a/examples/charts-react-native/RevenueChart.tsx b/examples/charts-react-native/RevenueChart.tsx index f3c85743..ec32bfd3 100644 --- a/examples/charts-react-native/RevenueChart.tsx +++ b/examples/charts-react-native/RevenueChart.tsx @@ -31,15 +31,18 @@ export function createRevenueDefinition( points: true, }), ], - x: { - label: 'Month', - scale: scaleLinear().domain([1, 5]), - }, - y: { - label: 'Revenue', - grid: true, - scale: scaleLinear().domain([0, 35]), + scales: { + x: { + label: 'Month', + scale: scaleLinear().domain([1, 5]), + }, + y: { + label: 'Revenue', + grid: true, + scale: scaleLinear().domain([0, 35]), + }, }, + focus: 'nearest-x', tooltip: { use: tooltip, sticky: true }, }) diff --git a/examples/charts-react/src/LiveCharts.tsx b/examples/charts-react/src/LiveCharts.tsx index 97bd84bf..4011c371 100644 --- a/examples/charts-react/src/LiveCharts.tsx +++ b/examples/charts-react/src/LiveCharts.tsx @@ -262,29 +262,32 @@ function createLiveDefinition(rows: readonly LiveSample[], metric: LiveMetric) { strokeWidth: 2, }), ], - x: { - scale: - first && latest ? scaleUtc().domain([first.at, latest.at]) : scaleUtc, - axis: { - line: false, - ticks: { count: 3, format: formatTickTime, size: 0, padding: 8 }, - tickLabels: { thin: { priority: 'ends', minGap: 20 } }, + scales: { + x: { + scale: + first && latest ? scaleUtc().domain([first.at, latest.at]) : scaleUtc, + axis: { + line: false, + ticks: { count: 3, format: formatTickTime, size: 0, padding: 8 }, + tickLabels: { thin: { priority: 'ends', minGap: 20 } }, + }, }, - }, - y: { - scale: scaleLinear().domain([floor, ceiling]), - grid: true, - axis: { - line: false, - ticks: { - count: 4, - format: (value) => compactNumber.format(value), - size: 0, - padding: 8, + y: { + scale: scaleLinear().domain([floor, ceiling]), + grid: true, + axis: { + line: false, + ticks: { + count: 4, + format: (value) => compactNumber.format(value), + size: 0, + padding: 8, + }, + tickLabels: { thin: { priority: 'ends', minGap: 14 } }, }, - tickLabels: { thin: { priority: 'ends', minGap: 14 } }, }, }, + margin: { top: 4, right: 8, bottom: 25, left: 38 }, clip: true, }) diff --git a/examples/charts-react/src/PagedHistoryChart.tsx b/examples/charts-react/src/PagedHistoryChart.tsx index bed2a3ad..8b3f564a 100644 --- a/examples/charts-react/src/PagedHistoryChart.tsx +++ b/examples/charts-react/src/PagedHistoryChart.tsx @@ -405,33 +405,36 @@ function createHistoryDefinition(page: HistoryPage, translate: number) { { match: 'x' }, ), ], - x: { - scale: scaleUtc().domain([contentFirst.at, contentLast.at]), - viewport: { - domain: [first.at, last.at], - translate, - }, - grid: false, - axis: { - line: false, - ticks: { count: 4, format: formatAxisDate, size: 0, padding: 9 }, - tickLabels: { thin: { priority: 'ends', minGap: 28 } }, + scales: { + x: { + scale: scaleUtc().domain([contentFirst.at, contentLast.at]), + viewport: { + domain: [first.at, last.at], + translate, + }, + grid: false, + axis: { + line: false, + ticks: { count: 4, format: formatAxisDate, size: 0, padding: 9 }, + tickLabels: { thin: { priority: 'ends', minGap: 28 } }, + }, }, - }, - y: { - scale: scaleLinear().domain(historyDomain), - grid: true, - axis: { - line: false, - ticks: { - count: 4, - format: (value: number) => `$${Math.round(value / 1_000)}k`, - size: 0, - padding: 8, + y: { + scale: scaleLinear().domain(historyDomain), + grid: true, + axis: { + line: false, + ticks: { + count: 4, + format: (value: number) => `$${Math.round(value / 1_000)}k`, + size: 0, + padding: 8, + }, + tickLabels: { thin: { priority: 'ends', minGap: 16 } }, }, - tickLabels: { thin: { priority: 'ends', minGap: 16 } }, }, }, + margin: chartMargin, clip: true, }) diff --git a/examples/charts-react/src/charts.ts b/examples/charts-react/src/charts.ts index 79c7e04f..71bb2a66 100644 --- a/examples/charts-react/src/charts.ts +++ b/examples/charts-react/src/charts.ts @@ -126,29 +126,32 @@ export function createIndustryHistoryChart( curve: d3Curve(curveMonotoneX), }), ], - x: { - scale: - zoomed && zoomStart && zoomEnd - ? scaleUtc().domain([zoomStart, zoomEnd]) - : scaleUtc, - axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Month' }, - }, - y: { - scale: - mode === 'share' ? scaleLinear().domain([0, 1]).nice(5) : scaleLinear, - nice: 5, - grid: true, - axis: { - ticks: { - count: 5, - format: mode === 'share' ? formatPercent : formatNumber, + scales: { + x: { + scale: + zoomed && zoomStart && zoomEnd + ? scaleUtc().domain([zoomStart, zoomEnd]) + : scaleUtc, + axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Month' }, + }, + y: { + scale: + mode === 'share' ? scaleLinear().domain([0, 1]).nice(5) : scaleLinear, + nice: 5, + grid: true, + axis: { + ticks: { + count: 5, + format: mode === 'share' ? formatPercent : formatNumber, + }, + label: + mode === 'share' + ? 'Share of selected industries' + : 'Unemployed (thousands)', }, - label: - mode === 'share' - ? 'Share of selected industries' - : 'Unemployed (thousands)', }, }, + color: { scale: scaleOrdinal() .domain([...trackedIndustries]) @@ -201,27 +204,30 @@ export function createPenguinChart( radius: stacked ? 0 : 2, }), ], - x: vertical - ? { - scale: categoryScale, - axis: { tickLabels: { rotate: width < 620 ? -28 : 0 } }, - } - : { - scale: scaleLinear, - nice: 5, - grid: true, - axis: { ticks: { count: 5 }, label: 'Penguins' }, - }, - y: vertical - ? { - scale: scaleLinear, - nice: 5, - grid: true, - axis: { ticks: { count: 5 }, label: 'Penguins' }, - } - : { - scale: categoryScale, - }, + scales: { + x: vertical + ? { + scale: categoryScale, + axis: { tickLabels: { rotate: width < 620 ? -28 : 0 } }, + } + : { + scale: scaleLinear, + nice: 5, + grid: true, + axis: { ticks: { count: 5 }, label: 'Penguins' }, + }, + y: vertical + ? { + scale: scaleLinear, + nice: 5, + grid: true, + axis: { ticks: { count: 5 }, label: 'Penguins' }, + } + : { + scale: categoryScale, + }, + }, + color: { scale: scaleOrdinal() .domain([...penguinSexes]) @@ -241,12 +247,14 @@ export const downloadsChart = defineChart({ stroke: 'var(--ts-chart-1, #2563eb)', }), ], - x: { scale: scaleUtc, axis: { ticks: { count: 6 } } }, - y: { - scale: scaleLinear, - nice: 5, - grid: true, - axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + scales: { + x: { scale: scaleUtc, axis: { ticks: { count: 6 } } }, + y: { + scale: scaleLinear, + nice: 5, + grid: true, + axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + }, }, }) @@ -269,11 +277,13 @@ export const downloadAreaChart = defineChart({ stroke: 'var(--ts-chart-4, #8b5cf6)', }), ], - x: { scale: scaleUtc, nice: 7, axis: { ticks: { count: 7 } } }, - y: { - scale: scaleLinear, - nice: 5, - axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + scales: { + x: { scale: scaleUtc, nice: 7, axis: { ticks: { count: 7 } } }, + y: { + scale: scaleLinear, + nice: 5, + axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + }, }, }) @@ -297,12 +307,15 @@ export const horsepowerChart = defineChart({ radius: 2, }), ], - x: { scale: scaleLinear, nice: 7, axis: { label: 'Power (hp)' } }, - y: { - scale: scaleLinear, - nice: 5, - axis: { ticks: { count: 5 }, label: 'Cars' }, + scales: { + x: { scale: scaleLinear, nice: 7, axis: { label: 'Power (hp)' } }, + y: { + scale: scaleLinear, + nice: 5, + axis: { ticks: { count: 5 }, label: 'Cars' }, + }, }, + color: { scale: () => scaleLinear() @@ -343,21 +356,24 @@ export function createRankingChart(metric: RankingMetric, accent: string) { inset: width < 420 ? 2 : 3, }), ], - x: { - scale: scaleLinear, - nice: ticks, - grid: true, - axis: { - label: - width < 420 - ? undefined - : metric === 'economy (mpg)' - ? 'Fuel economy (mpg)' - : 'Power (hp)', + scales: { + x: { + scale: scaleLinear, + nice: ticks, + grid: true, + axis: { + label: + width < 420 + ? undefined + : metric === 'economy (mpg)' + ? 'Fuel economy (mpg)' + : 'Power (hp)', + }, + }, + y: { + scale: () => + scaleBand().paddingInner(0.24).paddingOuter(0.12), }, - }, - y: { - scale: () => scaleBand().paddingInner(0.24).paddingOuter(0.12), }, } }) diff --git a/examples/sandbox/src/InteractionGeometryLab.tsx b/examples/sandbox/src/InteractionGeometryLab.tsx index 03674cea..c9daa9df 100644 --- a/examples/sandbox/src/InteractionGeometryLab.tsx +++ b/examples/sandbox/src/InteractionGeometryLab.tsx @@ -200,8 +200,11 @@ function facetedBarsDefinition(syncAxis?: FacetSyncAxis) { inset: 5, }), ], - x: { scale: scaleBand().domain([0, 1, 2, 3, 4, 5]) }, - y: { scale: scaleLinear().domain([0, 260]) }, + scales: { + x: { scale: scaleBand().domain([0, 1, 2, 3, 4, 5]) }, + y: { scale: scaleLinear().domain([0, 260]) }, + }, + guides: false, margin: 0, clip: true, @@ -1713,8 +1716,11 @@ function createProofCases(): ProofCase[] { inset: 2, }), ], - x: { scale: scaleBand().domain([0, 1, 2]).padding(0.12) }, - y: { scale: scaleLinear().domain([0, 250]) }, + scales: { + x: { scale: scaleBand().domain([0, 1, 2]).padding(0.12) }, + y: { scale: scaleLinear().domain([0, 250]) }, + }, + guides: false, margin: 0, clip: true, @@ -1773,8 +1779,11 @@ function createProofCases(): ProofCase[] { inset: 6, }), ], - x: { scale: scaleBand().domain([0, 1, 2]).padding(0.12) }, - y: { scale: scaleLinear().domain([0, 250]) }, + scales: { + x: { scale: scaleBand().domain([0, 1, 2]).padding(0.12) }, + y: { scale: scaleLinear().domain([0, 250]) }, + }, + guides: false, margin: 0, clip: true, @@ -1858,8 +1867,11 @@ function createProofCases(): ProofCase[] { strokeWidth: 1.5, }), ], - x: { scale: scaleLinear().domain([0, 100]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleLinear().domain([0, 100]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + guides: false, margin: 0, clip: true, @@ -2562,8 +2574,11 @@ function baseDefinition( ): StaticChartDefinition { return { marks, - x: { scale: xScale, grid: true }, - y: { scale: yScale, grid: true }, + scales: { + x: { scale: xScale, grid: true }, + y: { scale: yScale, grid: true }, + }, + guides: false, margin: { top: 18, right: 20, bottom: 18, left: 20 }, theme: { diff --git a/examples/sandbox/src/plots.ts b/examples/sandbox/src/plots.ts index 44954599..b0ca61fb 100644 --- a/examples/sandbox/src/plots.ts +++ b/examples/sandbox/src/plots.ts @@ -79,24 +79,27 @@ export const createIndustryChart = (input: IndustryChartInput) => curve: smooth, }), ], - x: { - scale: scaleUtc, - axis: { - ticks: { - count: width < 680 ? 4 : 7, - format: input.compactTime ? formatMonth : formatYear, + scales: { + x: { + scale: scaleUtc, + axis: { + ticks: { + count: width < 680 ? 4 : 7, + format: input.compactTime ? formatMonth : formatYear, + }, }, }, - }, - y: { - scale: scaleLinear, - nice: 4, - grid: true, - axis: { - ticks: { count: 4, format: compactNumber }, - label: 'Unemployed (thousands)', + y: { + scale: scaleLinear, + nice: 4, + grid: true, + axis: { + ticks: { count: 4, format: compactNumber }, + label: 'Unemployed (thousands)', + }, }, }, + color: { domain: industryNames, range: industryNames.map((industry) => industryColors[industry]), @@ -154,8 +157,11 @@ export const createSparklineChart = (input: SparkInput) => { r: 3.25, }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + guides: false, gradients: [ { @@ -178,6 +184,10 @@ export const createSparklineChart = (input: SparkInput) => { export const createAgreementChart = (input: { value: number }) => defineChart(() => ({ marks: [radialAgreementMark(input.value)], + scales: { + x: null, + y: null, + }, margin: 4, theme: chartTheme, })) @@ -194,24 +204,27 @@ export const createRatingsHeatmap = (input: { rows: readonly SimpsonsRow[] }) => radius: 3, }), ], - x: { - scale: () => scaleBand().paddingInner(0.03), - axis: { - ticks: { - format: (value: number) => (value % 5 === 0 ? `${value}` : ''), + scales: { + x: { + scale: () => scaleBand().paddingInner(0.03), + axis: { + ticks: { + format: (value: number) => (value % 5 === 0 ? `${value}` : ''), + }, + label: 'Episode', }, - label: 'Episode', }, - }, - y: { - scale: () => scaleBand().paddingInner(0.03), - axis: { - ticks: { - format: (value: number) => (value % 5 === 0 ? `${value}` : ''), + y: { + scale: () => scaleBand().paddingInner(0.03), + axis: { + ticks: { + format: (value: number) => (value % 5 === 0 ? `${value}` : ''), + }, + label: 'Season', }, - label: 'Season', }, }, + color: { scale: scaleSequential, range: ['#1b181e', '#ff5b56'], @@ -253,20 +266,23 @@ export const createCarEconomyChart = (input: { r: 3, }), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - ticks: { count: 3, format: (value: number) => `${value} mpg` }, + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + ticks: { count: 3, format: (value: number) => `${value} mpg` }, + }, }, - }, - y: { - scale: () => scaleBand().paddingInner(0.2), - axis: { - ticks: { format: (value: number) => `${value} cyl` }, + y: { + scale: () => scaleBand().paddingInner(0.2), + axis: { + ticks: { format: (value: number) => `${value} cyl` }, + }, }, }, + color: { scale: scaleSequential, range: ['#7f76e8', '#45d49c'], @@ -332,18 +348,21 @@ export const createPenguinChart = (input: PenguinChartInput) => ] : []), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { ticks: { count: 3 }, label: 'Bill length (mm)' }, - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { ticks: { count: 3 }, label: 'Bill depth (mm)' }, + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 3 }, label: 'Bill length (mm)' }, + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 3 }, label: 'Bill depth (mm)' }, + }, }, + color: { range: ['#ff625a', '#8579ff', '#45d49c'], }, @@ -368,14 +387,17 @@ export const createSurveyStackChart = (input: { radius: 3, }), ], - x: { - scale: () => scaleBand().paddingInner(0.08), - }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 3 }, label: 'Responses' }, + scales: { + x: { + scale: () => scaleBand().paddingInner(0.08), + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 3 }, label: 'Responses' }, + }, }, + color: { domain: surveyResponses, range: surveyResponses.map((response) => responseColors[response]), @@ -398,14 +420,17 @@ export const createSurveyWaffleChart = (input: { radius: 2, }), ], - x: { - scale: scaleBand().domain( - Array.from({ length: 21 }, (_, index) => index), - ), - }, - y: { - scale: scaleBand().domain([4, 3, 2, 1, 0]), + scales: { + x: { + scale: scaleBand().domain( + Array.from({ length: 21 }, (_, index) => index), + ), + }, + y: { + scale: scaleBand().domain([4, 3, 2, 1, 0]), + }, }, + guides: false, color: { domain: surveyResponses, diff --git a/llms.txt b/llms.txt index cb2b658c..10b9a3bf 100644 --- a/llms.txt +++ b/llms.txt @@ -112,10 +112,13 @@ Authoring rules: - Start with exact @tanstack/charts/scales/linear, @tanstack/charts/scales/band, @tanstack/charts/scales/point, or @tanstack/charts/scales/ordinal entries. Upgrade only the mapping that needs temporal, nonlinear, radial, interpolated, statistical, or other complete D3 semantics; never import the d3 umbrella. - Treat Date values on compact band or point scales as equally spaced categories. Use d3-scale scaleTime or scaleUtc when elapsed-time spacing or calendar-aware ticks matter. - Let TanStack Charts own responsive pixel ranges. Scale factories infer domains from mark channels; configured instances preserve application-owned domains. +- Put Cartesian scale and guide options under scales.x and scales.y; never generate deprecated root x or y properties. +- Put polar position scales under scales.angle and scales.radius inside polar(); never generate deprecated direct angle or radius properties. - Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model. - Memoize the complete definition against captured application values; definition identity is the application update boundary. - Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. - Prefer built-in marks, then composition, then a custom mark or application-owned overlay. +- Keep the default SVG renderer unless a measured paint-heavy mark benefits from Canvas. Import canvasChartRenderer from @tanstack/charts/canvas and set only that mark's renderer option when mixing surfaces. - Treat docs/concepts/scales-and-d3.md as the sole scale-selection and D3 integration contract and follow its official D3 links for D3 API details. - Reference a canonical catalog case with ``. The documentation site resolves its source-backed workspace; never author a catalog iframe or duplicate the case source in a docs page. - Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript. diff --git a/packages/alpine-charts/src/index.test.ts b/packages/alpine-charts/src/index.test.ts index f8a4f3a1..05e37aad 100644 --- a/packages/alpine-charts/src/index.test.ts +++ b/packages/alpine-charts/src/index.test.ts @@ -12,8 +12,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) if (false) { diff --git a/packages/alpine-charts/src/index.ts b/packages/alpine-charts/src/index.ts index 067ec41d..f1245300 100644 --- a/packages/alpine-charts/src/index.ts +++ b/packages/alpine-charts/src/index.ts @@ -115,7 +115,8 @@ export function charts(Alpine: AlpineLike) { onSelect: options.onSelect, onRender: options.onRender ? (context) => { - const svg = context.surface.element + const svg = + context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -124,6 +125,7 @@ export function charts(Alpine: AlpineLike) { options.onRender?.({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/angular-charts/src/Chart.ssr.test.ts b/packages/angular-charts/src/Chart.ssr.test.ts index 30aa476b..35513138 100644 --- a/packages/angular-charts/src/Chart.ssr.test.ts +++ b/packages/angular-charts/src/Chart.ssr.test.ts @@ -14,8 +14,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) @Component({ diff --git a/packages/angular-charts/src/Chart.test.ts b/packages/angular-charts/src/Chart.test.ts index d5ea738a..295555a3 100644 --- a/packages/angular-charts/src/Chart.test.ts +++ b/packages/angular-charts/src/Chart.test.ts @@ -31,8 +31,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) const tooltipDefinition = defineChart(definition, { diff --git a/packages/angular-charts/src/Chart.ts b/packages/angular-charts/src/Chart.ts index 4327bb8e..2b388b28 100644 --- a/packages/angular-charts/src/Chart.ts +++ b/packages/angular-charts/src/Chart.ts @@ -340,7 +340,7 @@ function adaptOnRender< return ( context: ChartRendererRenderContext, ): void => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -349,6 +349,7 @@ function adaptOnRender< onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 61dfd381..e5538f5c 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -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 | 37.60–43.56 KiB | — | Controlled suite | +| TanStack Charts | 38.90–44.88 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/concepts/chart-definitions.md b/packages/charts-core/docs/concepts/chart-definitions.md index 065e8121..a993f318 100644 --- a/packages/charts-core/docs/concepts/chart-definitions.md +++ b/packages/charts-core/docs/concepts/chart-definitions.md @@ -33,14 +33,16 @@ const alphabet: readonly AlphabetRow[] = [ const letterFrequencies = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], - x: { - scale: () => scaleBand().padding(0.12), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Frequency' }, + scales: { + x: { + scale: () => scaleBand().padding(0.12), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Frequency' }, + }, }, }) ``` @@ -57,13 +59,15 @@ const productRanking = defineChart({ tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) @@ -108,13 +112,15 @@ function ProductRanking({ rows, metric }: Props) { tooltip, chart: ({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'product' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) diff --git a/packages/charts-core/docs/concepts/data-and-channels.md b/packages/charts-core/docs/concepts/data-and-channels.md index 8863f2ca..ab87e0fa 100644 --- a/packages/charts-core/docs/concepts/data-and-channels.md +++ b/packages/charts-core/docs/concepts/data-and-channels.md @@ -51,7 +51,7 @@ initializes; keep expensive cross-row transforms in application code. ## Positional channels -The x and y channels feed the chart’s positional scale factories or instances: +The x and y channels feed the reserved scales with the same names: ```ts barX(rows, { @@ -60,6 +60,10 @@ barX(rows, { }) ``` +Use `xScale` or `yScale` when a mark should feed another named entry in the +chart's `scales` registry. Channel names continue to describe geometry; scale +IDs select the mapping. + Positional values are also retained in each interaction `ChartPoint`: ```ts @@ -125,8 +129,11 @@ const chart = defineChart({ z: 'segment', }), ], - x: { scale: revenueScale }, - y: { scale: retentionScale }, + scales: { + x: { scale: revenueScale }, + y: { scale: retentionScale }, + }, + color: { scale: segmentColor, legend: colorLegend({ label: 'Segment' }), @@ -317,16 +324,19 @@ export default defineChart({ strokeWidth: 0.75, }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Bill length (mm)' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Bill depth (mm)' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill length (mm)' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Bill depth (mm)' }, + }, }, + color: { scale: scaleOrdinal(species, ['#2563eb', '#f97316', '#10b981']), legend: colorLegend({ label: 'Species' }), diff --git a/packages/charts-core/docs/concepts/grammar-of-graphics.md b/packages/charts-core/docs/concepts/grammar-of-graphics.md index 42f4e4a1..afa72e9f 100644 --- a/packages/charts-core/docs/concepts/grammar-of-graphics.md +++ b/packages/charts-core/docs/concepts/grammar-of-graphics.md @@ -45,8 +45,10 @@ const alphabet: readonly LetterFrequency[] = [ const chart = defineChart({ marks: [barY(alphabet, { x: 'letter', y: 'frequency' })], - x: { scale: scaleBand }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -170,7 +172,7 @@ const y = { label: 'Monthly revenue', ticks: { count: 5, - format: (value: number) => `$${Math.round(value / 1_000)}k`, + format: (value: number) => `${Math.round(value / 1_000)}k`, }, }, } @@ -219,14 +221,16 @@ export default defineChart({ fill: '#2563eb', }), ], - x: { - scale: () => scaleBand().padding(0.12), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Value' }, + scales: { + x: { + scale: () => scaleBand().padding(0.12), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Value' }, + }, }, }) ``` 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 af9d82b9..9aa67858 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -61,16 +61,18 @@ Responsive definitions receive the current `width` and `height`, so presentation ```ts const chart = defineChart(({ width }) => ({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { - scale: xScale, - axis: { - ticks: { count: width < 420 ? 4 : 8 }, - tickLabels: { rotate: width < 520 ? -30 : undefined }, + scales: { + x: { + scale: xScale, + axis: { + ticks: { count: width < 420 ? 4 : 8 }, + tickLabels: { rotate: width < 520 ? -30 : undefined }, + }, + }, + y: { + scale: yScale, + axis: { label: width < 480 ? undefined : 'Weekly downloads' }, }, - }, - y: { - scale: yScale, - axis: { label: width < 480 ? undefined : 'Weekly downloads' }, }, })) ``` @@ -96,8 +98,11 @@ Explicit margins lock only the sides you provide: ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + margin: { left: 80 }, }) ``` @@ -110,8 +115,11 @@ Here the left margin is exactly `80`; top, right, and bottom remain automatic. const sparkline = defineChart({ marks: [lineY(values)], guides: false, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + margin: 0, }) ``` @@ -201,14 +209,48 @@ Hide every axis and grid while keeping scales for marks: const chart = defineChart({ marks, guides: false, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, +}) +``` + +Set a reserved scale to `null` only when no mark uses that dimension. For +example, a `ruleY`-only chart uses `scales: { x: null, y: { scale: yScale } }`. + +## Multiple axes + +Add a named scale when one coordinate system needs an independent mapping. +Declare whether it maps x or y, choose its axis side, then bind the relevant +mark to its ID: + +```ts +const chart = defineChart({ + marks: [ + lineY(revenue, { x: 'date', y: 'value' }), + lineY(margin, { + x: 'date', + y: 'percent', + yScale: 'margin', + }), + ], + scales: { + x: { scale: dateScale }, + y: { scale: revenueScale, axis: { label: 'Revenue' } }, + margin: { + channel: 'y', + scale: marginScale, + side: 'right', + axis: { label: 'Margin' }, + }, + }, }) ``` -Marks encode whether they materialize each positional dimension. Omit an unused -axis; for example, a `ruleY`-only chart needs `y` but no `x`. Explicit `null` -is accepted only for an unused dimension. +`xScale` and `yScale` bind marks to scale IDs, not axis IDs. Axes visualize the +scale registry entries. Multiple axes on one side stack outward and take part +in automatic margin measurement. ## Scale ranges and coordinate direction @@ -300,16 +342,16 @@ import { polar, radialArc } from '@tanstack/charts/polar' import { geoShape } from '@tanstack/charts/geo' ``` -`polar` copies configured angle and radius scales, assigns responsive angular -and radial ranges, and renders guide backgrounds, child marks, then guide -foregrounds around one resolved center. `geoShape` calls an +`polar` copies entries from its own `scales` registry, assigns responsive +angular and radial ranges, and renders guide backgrounds, child marks, then +guide foregrounds around one resolved center. `geoShape` calls an application-supplied D3 projection callback or fits a projection descriptor to data, a sphere, or explicit geometry. Both paths emit the same keyed scene nodes and interaction points as ordinary marks. SVG rendering, DOM reconciliation, focus, export, and adapters do not -need a coordinate-system branch. Their outer chart omits `x` and `y`; no -Cartesian guides are created. +need a coordinate-system branch. Their outer chart uses +`scales: { x: null, y: null }`; no Cartesian guides are created. These capabilities stay behind separate package subpaths so their D3 geometry does not enter a Cartesian consumer. See @@ -372,8 +414,11 @@ gesture can use the resolved scale's optional `invert` operation. ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + clip: true, }) ``` @@ -408,17 +453,19 @@ export default defineChart({ radius: 3, }), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: '2015 population', - ticks: { format: (value) => compact.format(value) }, + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: '2015 population', + ticks: { format: (value) => compact.format(value) }, + }, + }, + y: { + scale: () => scaleBand().paddingInner(0.12).paddingOuter(0.06), }, - }, - y: { - scale: () => scaleBand().paddingInner(0.12).paddingOuter(0.06), }, }) ``` diff --git a/packages/charts-core/docs/concepts/marks-and-layering.md b/packages/charts-core/docs/concepts/marks-and-layering.md index 144d70cd..b8f2f427 100644 --- a/packages/charts-core/docs/concepts/marks-and-layering.md +++ b/packages/charts-core/docs/concepts/marks-and-layering.md @@ -55,6 +55,43 @@ A useful default order is: There is no separate overlay subsystem. An annotation is another mark with its own data, channels, and stable identity. +## Opt individual marks into Canvas + +Import the Canvas renderer and attach it only to paint-heavy marks: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +const marks = [ + areaY(denseRange, { + x: 'date', + y1: 'low', + y2: 'high', + renderer: canvasChartRenderer, + }), + lineY(summary, { x: 'date', y: 'value' }), + dot(highlights, { x: 'date', y: 'value' }), + text(labels, { x: 'date', y: 'value', text: 'label' }), +] +``` + +The host keeps axes, guides, and marks without `renderer` in SVG. It creates +ordered SVG and Canvas layers from the mark declaration order, so a Canvas +mark can sit behind, between, or in front of SVG marks. Focus, tooltips, +keyboard navigation, responsive updates, SSR shell adoption, and export still +use the shared chart host. + +Cartesian and radial mark option objects accept `renderer`. A +`compositeMark` can select a renderer for its complete output, or its children +can select their own renderers when the parent does not. The same nested +selection works inside facets and `polar`. + +Importing `@tanstack/charts/canvas` is the opt-in boundary that adds the Canvas +painter to the bundle. Reuse a stable renderer instance across updates. The +exported `canvasChartRenderer` singleton already has stable identity. Creating +a new renderer or changing the renderer sequence replaces the affected layer +composition. + ## Decorative layers When two layered marks describe the same observations, choose one interaction @@ -207,8 +244,11 @@ Set `clip: true` on the chart definition when marks must not paint outside the r ```ts const chart = defineChart({ marks, - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + clip: true, }) ``` @@ -248,15 +288,17 @@ export default defineChart({ strokeWidth: 1.5, }), ], - x: { - scale: scaleUtc, - axis: { label: 'Day' }, - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Temperature (°F)' }, + scales: { + x: { + scale: scaleUtc, + axis: { label: 'Day' }, + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Temperature (°F)' }, + }, }, }) ``` diff --git a/packages/charts-core/docs/concepts/scales-and-d3.md b/packages/charts-core/docs/concepts/scales-and-d3.md index f3061674..e6a1145a 100644 --- a/packages/charts-core/docs/concepts/scales-and-d3.md +++ b/packages/charts-core/docs/concepts/scales-and-d3.md @@ -85,8 +85,10 @@ import { scaleUtc } from 'd3-scale' const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: scaleUtc, nice: true }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleUtc, nice: true }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -164,24 +166,31 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const spec = { marks, - x: { scale: scaleBand }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear, nice: true }, + }, } ``` -Positionless marks omit both axes: +Positionless marks use explicit null entries: ```ts import { defineChart, frame } from '@tanstack/charts' const borderOnlyChart = defineChart({ marks: [frame()], + scales: { + x: null, + y: null, + }, }) ``` -A mark with x values requires an x scale. A mark with y values requires a y -scale. One-dimensional marks omit only the unused axis. The scale factory -chooses the mapping; materialized mark channels supply its domain. +A mark with x values requires a non-null x scale. A mark with y values requires +a non-null y scale. One-dimensional charts use `null` only for the unused +entry. The scale factory chooses the mapping; materialized mark channels +supply its domain. ## Factory domains come from marks @@ -193,8 +202,10 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const chart = defineChart({ marks: [lineY(rows, { x: 'month', y: 'value' })], - x: { scale: scalePoint }, - y: { scale: scaleLinear, nice: true }, + scales: { + x: { scale: scalePoint }, + y: { scale: scaleLinear, nice: true }, + }, }) ``` @@ -296,8 +307,10 @@ Omitting `color.scale` uses the chart theme’s ordinal palette for categorical ```ts const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', z: 'region' })], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, }) ``` @@ -313,8 +326,11 @@ const regionColor = scaleOrdinal( const chart = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', z: 'region' })], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + color: { scale: regionColor, legend: colorLegend({ label: 'Region' }), @@ -478,15 +494,17 @@ export default defineChart({ fill: '#2563eb', }), ], - x: { - scale: scaleLog().domain([200, 30_000]), - grid: true, - axis: { label: 'Class size' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Hierarchy depth' }, + scales: { + x: { + scale: scaleLog().domain([200, 30_000]), + grid: true, + axis: { label: 'Class size' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Hierarchy depth' }, + }, }, }) ``` diff --git a/packages/charts-core/docs/examples/bars-and-rankings.md b/packages/charts-core/docs/examples/bars-and-rankings.md index 392f918d..2881c964 100644 --- a/packages/charts-core/docs/examples/bars-and-rankings.md +++ b/packages/charts-core/docs/examples/bars-and-rankings.md @@ -44,17 +44,19 @@ const ranked = [...rows].sort((a, b) => b.value - a.value) const chart = defineChart({ marks: [barY(ranked, { x: 'category', y: 'value', inset: 2 })], - x: { - scale: () => - scaleBand() - .domain(ranked.map((row) => row.category)) - .padding(0.16), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Weekly signups' }, + scales: { + x: { + scale: () => + scaleBand() + .domain(ranked.map((row) => row.category)) + .padding(0.16), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Weekly signups' }, + }, }, }) diff --git a/packages/charts-core/docs/examples/distributions.md b/packages/charts-core/docs/examples/distributions.md index d51d2e37..9e98c4b4 100644 --- a/packages/charts-core/docs/examples/distributions.md +++ b/packages/charts-core/docs/examples/distributions.md @@ -76,8 +76,10 @@ const chart = defineChart({ fill: '#2563eb', }), ], - x: { scale: scaleLinear, axis: { label: 'Response time (ms)' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Requests' } }, + scales: { + x: { scale: scaleLinear, axis: { label: 'Response time (ms)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Requests' } }, + }, }) export default chart diff --git a/packages/charts-core/docs/examples/heatmaps-and-densities.md b/packages/charts-core/docs/examples/heatmaps-and-densities.md index d4e9b141..d82c8776 100644 --- a/packages/charts-core/docs/examples/heatmaps-and-densities.md +++ b/packages/charts-core/docs/examples/heatmaps-and-densities.md @@ -53,14 +53,17 @@ export default defineChart({ fontWeight: 650, }), ], - x: { - scale: () => scaleBand().domain(quarters).padding(0.04), - axis: { label: 'Quarter' }, - }, - y: { - scale: () => scaleBand().domain(teams).padding(0.04), - axis: { label: 'Team' }, + scales: { + x: { + scale: () => scaleBand().domain(quarters).padding(0.04), + axis: { label: 'Quarter' }, + }, + y: { + scale: () => scaleBand().domain(teams).padding(0.04), + axis: { label: 'Team' }, + }, }, + color: { scale: () => scaleLinear().domain([60, 100]).range(['#eff6ff', '#60a5fa']), diff --git a/packages/charts-core/docs/examples/intervals-and-financial.md b/packages/charts-core/docs/examples/intervals-and-financial.md index cf7f1f45..baae028f 100644 --- a/packages/charts-core/docs/examples/intervals-and-financial.md +++ b/packages/charts-core/docs/examples/intervals-and-financial.md @@ -78,11 +78,13 @@ export default defineChart({ r: 4, }), ], - x: { scale: () => scaleBand().padding(0.3) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Mean response (95% confidence interval)' }, + scales: { + x: { scale: () => scaleBand().padding(0.3) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Mean response (95% confidence interval)' }, + }, }, }) ``` diff --git a/packages/charts-core/docs/examples/lines-and-areas.md b/packages/charts-core/docs/examples/lines-and-areas.md index 2826fc1d..03e8bd35 100644 --- a/packages/charts-core/docs/examples/lines-and-areas.md +++ b/packages/charts-core/docs/examples/lines-and-areas.md @@ -50,12 +50,14 @@ const chart = defineChart({ stroke: '#2563eb', }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Downloads (thousands)' }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Downloads (thousands)' }, + }, }, }) diff --git a/packages/charts-core/docs/examples/maps-and-spatial.md b/packages/charts-core/docs/examples/maps-and-spatial.md index ee568e35..ede6a4a9 100644 --- a/packages/charts-core/docs/examples/maps-and-spatial.md +++ b/packages/charts-core/docs/examples/maps-and-spatial.md @@ -106,6 +106,10 @@ export default defineChart({ strokeWidth: 1.5, }), ], + scales: { + x: null, + y: null, + }, margin: 12, }) ``` diff --git a/packages/charts-core/docs/examples/networks-and-hierarchies.md b/packages/charts-core/docs/examples/networks-and-hierarchies.md index d2c62200..b81ea65e 100644 --- a/packages/charts-core/docs/examples/networks-and-hierarchies.md +++ b/packages/charts-core/docs/examples/networks-and-hierarchies.md @@ -93,6 +93,10 @@ export default defineChart({ ] as const, }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) @@ -185,8 +189,11 @@ export default defineChart({ dx: (node) => (node.internal ? -7 : 7), }), ], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, + guides: false, margin: { top: 24, right: 110, bottom: 24, left: 64 }, }) @@ -254,6 +261,10 @@ const chart = defineChart({ stroke: '#fff', }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) @@ -353,8 +364,10 @@ const chart = defineChart({ dot(graph.nodes, { x: 'x', y: 'y', color: 'group', key: 'id' }), text(graph.nodes, { x: 'x', y: 'y', text: 'id', key: 'id' }), ], - x: { scale: scaleLinear().domain(graph.xDomain) }, - y: { scale: scaleLinear().domain(graph.yDomain) }, + scales: { + x: { scale: scaleLinear().domain(graph.xDomain) }, + y: { scale: scaleLinear().domain(graph.yDomain) }, + }, }) ``` diff --git a/packages/charts-core/docs/examples/polar-and-radar.md b/packages/charts-core/docs/examples/polar-and-radar.md index 8ae8ac09..90bdde32 100644 --- a/packages/charts-core/docs/examples/polar-and-radar.md +++ b/packages/charts-core/docs/examples/polar-and-radar.md @@ -52,8 +52,16 @@ export default defineChart({ key: 'letter', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { domain: letters, range: ['#0ea5e9', '#6366f1', '#a855f7', '#ec4899', '#f97316', '#94a3b8'], @@ -120,8 +128,11 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.84, - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [ radialArc(slices, { innerRadius: ({ radius }) => radius * 0.72, @@ -141,6 +152,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: ['complete', 'remaining'], range: ['#ef4444', '#e2e8f0'], @@ -180,8 +195,11 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.72, - angle: { scale: scalePoint().domain(events), wrap: true }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scalePoint().domain(events), wrap: true }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + guides: [ radialGrid({ values: [0.25, 0.5, 0.75, 1], @@ -207,6 +225,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -310,8 +332,11 @@ import { dayOfYearAngle, seattle2012 } from './weather' export default defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 360]) }, - radius: { scale: scaleLinear().domain([-10, 40]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([-10, 40]) }, + }, + guides: [ radialGrid({ values: [0, 10, 20, 30, 40] }), angleGrid({ values: [0, 90, 180, 270], labels: false }), @@ -325,6 +350,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -373,8 +402,11 @@ import { latitudeBand, windDirection, windSpeed } from './wind' export default defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 360]) }, - radius: { scale: scaleLinear().domain([0, 13]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 360]) }, + radius: { scale: scaleLinear().domain([0, 13]) }, + }, + guides: [ radialGrid({ values: [3, 6, 9, 12] }), angleGrid({ values: [0, 90, 180, 270], labels: false }), @@ -389,6 +421,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -441,11 +477,14 @@ export default defineChart({ marks: [ polar({ radiusRatio: 0.8, - angle: { scale: () => scaleBand().padding(0.12) }, - radius: { - scale: scaleLinear().domain([0, maximum]), - range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + scales: { + angle: { scale: () => scaleBand().padding(0.12) }, + radius: { + scale: scaleLinear().domain([0, maximum]), + range: [({ radius }) => radius * 0.3, ({ radius }) => radius], + }, }, + marks: [ radialBarRadius(frequencies, { angle: 'letter', @@ -456,6 +495,10 @@ export default defineChart({ ], }), ], + scales: { + x: null, + y: null, + }, color: { domain: letters, range: ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#16a34a'], @@ -514,8 +557,16 @@ export default defineChart({ stroke: '#fff', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, color: { range: ['#7c3aed', '#0ea5e9', '#14b8a6'] }, }) ``` diff --git a/packages/charts-core/docs/examples/scatterplots-and-relationships.md b/packages/charts-core/docs/examples/scatterplots-and-relationships.md index 9a0fe6dd..af042e29 100644 --- a/packages/charts-core/docs/examples/scatterplots-and-relationships.md +++ b/packages/charts-core/docs/examples/scatterplots-and-relationships.md @@ -48,15 +48,17 @@ const chart = defineChart({ fill: '#2563eb', }), ], - x: { - scale: scaleLinear, - grid: true, - axis: { label: 'Temperature (°C)' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Daily sales' }, + scales: { + x: { + scale: scaleLinear, + grid: true, + axis: { label: 'Temperature (°C)' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Daily sales' }, + }, }, }) diff --git a/packages/charts-core/docs/examples/stacked-and-composition.md b/packages/charts-core/docs/examples/stacked-and-composition.md index 8b5e17ed..01353ea2 100644 --- a/packages/charts-core/docs/examples/stacked-and-composition.md +++ b/packages/charts-core/docs/examples/stacked-and-composition.md @@ -52,12 +52,15 @@ export default defineChart({ }), ruleY([0]), ], - x: { scale: () => scalePoint().padding(0.15) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Revenue (USD thousands)' }, + scales: { + x: { scale: () => scalePoint().padding(0.15) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD thousands)' }, + }, }, + color: { domain: ['Core', 'Services'], range: ['#2563eb', '#14b8a6'], diff --git a/packages/charts-core/docs/framework/alpine/adapter.md b/packages/charts-core/docs/framework/alpine/adapter.md index 406ade27..67394783 100644 --- a/packages/charts-core/docs/framework/alpine/adapter.md +++ b/packages/charts-core/docs/framework/alpine/adapter.md @@ -39,8 +39,9 @@ registering `Alpine.plugin(charts)`. The directive element itself becomes `.ts-chart-host`; normal HTML class and style attributes own its presentation. The `className` chart option applies -to the rendered SVG surface. The package exposes the SVG directive only; use -`renderSvg` to replace SVG serialization without replacing the shared host. +to the rendered chart surface. The directive starts with SVG and can compose +marks that use `canvasChartRenderer`. Use `renderSvg` to replace SVG +serialization without replacing the shared host. Exports: `charts`, `ChartOptions`, `ChartTooltipBodyRenderContext`, `AlpineChartTooltipBody`, `ChartDefinition`, and `ChartPoint`. diff --git a/packages/charts-core/docs/framework/alpine/reference/chart.md b/packages/charts-core/docs/framework/alpine/reference/chart.md index 976ce315..923bda18 100644 --- a/packages/charts-core/docs/framework/alpine/reference/chart.md +++ b/packages/charts-core/docs/framework/alpine/reference/chart.md @@ -15,25 +15,25 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## Directive options -| Option | Type | Default | Meaning | -| -------------------- | -------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | Existing height or `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Existing width or `100%` | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `className` | `string` | None | Extra class on the rendered SVG surface | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => AlpineChartTooltipBody` | None | Returns DOM content for the built-in tooltip body | +| Option | Type | Default | Meaning | +| -------------------- | -------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | Existing height or `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Existing width or `100%` | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `className` | `string` | None | Extra class on the rendered chart surface | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => AlpineChartTooltipBody` | None | Returns DOM content for the built-in tooltip body | The directive element becomes `.ts-chart-host`; use its normal HTML class and style attributes for outer presentation. Directive cleanup removes the chart diff --git a/packages/charts-core/docs/framework/angular/adapter.md b/packages/charts-core/docs/framework/angular/adapter.md index 8e3919d3..6d0ed00d 100644 --- a/packages/charts-core/docs/framework/angular/adapter.md +++ b/packages/charts-core/docs/framework/angular/adapter.md @@ -47,9 +47,10 @@ Angular hydration is not yet part of the adapter's tested public contract. ## Presentation and rendering `options.class` and the string `options.style` apply to the inner -`.ts-chart-host`; `options.className` applies to the rendered SVG surface. The -package exposes the SVG component only. Use `renderSvg` to replace SVG -serialization without replacing the shared host. +`.ts-chart-host`; `options.className` applies to the rendered chart surface. +The component starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `ChartCommonOptions`, `ChartOptions`, `ChartPresentationOptions`, `ChartTooltipBodyDirective`, diff --git a/packages/charts-core/docs/framework/angular/reference/chart.md b/packages/charts-core/docs/framework/angular/reference/chart.md index e19f4085..510c0b68 100644 --- a/packages/charts-core/docs/framework/angular/reference/chart.md +++ b/packages/charts-core/docs/framework/angular/reference/chart.md @@ -15,26 +15,26 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## `options` -| Option | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------ | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | -| `style` | `string` | None | Inner host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Option | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | +| `style` | `string` | None | Inner host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | Callbacks are functions inside `options`, not Angular outputs. Replace the complete `options` value when chart state changes so `OnPush` change detection diff --git a/packages/charts-core/docs/framework/lit/adapter.md b/packages/charts-core/docs/framework/lit/adapter.md index c7dadce2..989fa3ac 100644 --- a/packages/charts-core/docs/framework/lit/adapter.md +++ b/packages/charts-core/docs/framework/lit/adapter.md @@ -50,9 +50,10 @@ the adapter's tested public contract. Call `defineChartElement` only where ## Presentation and rendering `options.class` and the string `options.style` apply to the inner -`.ts-chart-host`; `options.className` applies to the rendered SVG surface. The -package exposes the SVG custom element only. Use `renderSvg` to replace SVG -serialization without replacing the shared host. +`.ts-chart-host`; `options.className` applies to the rendered chart surface. +The custom element starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `defineChartElement`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodyRenderContext`, diff --git a/packages/charts-core/docs/framework/lit/reference/chart.md b/packages/charts-core/docs/framework/lit/reference/chart.md index bced0a42..3e30d3d2 100644 --- a/packages/charts-core/docs/framework/lit/reference/chart.md +++ b/packages/charts-core/docs/framework/lit/reference/chart.md @@ -15,27 +15,27 @@ The definition also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimatio ## `options` -| Option | Type | Default | Meaning | -| -------------------- | ----------------------------------------------------- | --------------------- | ----------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => unknown` | None | Composes Lit content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | -| `style` | `string` | None | Inner host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Option | Type | Default | Meaning | +| -------------------- | ----------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => unknown` | None | Composes Lit content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the inner `.ts-chart-host` | +| `style` | `string` | None | Inner host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | ```ts const options = { diff --git a/packages/charts-core/docs/framework/octane/adapter.md b/packages/charts-core/docs/framework/octane/adapter.md index 619e2d26..d7c48cfe 100644 --- a/packages/charts-core/docs/framework/octane/adapter.md +++ b/packages/charts-core/docs/framework/octane/adapter.md @@ -30,7 +30,9 @@ import { Chart as RendererChart } from '@tanstack/charts/octane/core' ``` The default `Chart` remains SVG-based. `CanvasChart` selects the optional -built-in renderer; `RendererChart` requires a `renderer` prop. +built-in renderer; `RendererChart` requires a `renderer` prop. A definition +can still import `canvasChartRenderer` and assign it to selected marks, which +makes the default component render an ordered mixed surface. The package export map supplies a browser build for browser bundlers and a separate Node build for the `node` condition. @@ -64,6 +66,11 @@ layers. It paints no server pixels. The browser adopts the elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. +A default chart with selected Canvas marks emits one mixed root containing +ordered SVG markup and Canvas shells. SVG marks are visible in the server +response, Canvas pixels appear after mount, and the browser adopts every child +surface. + Keep data, definitions, scale domains, custom renderers, and dimensions deterministic between server and browser. The adapter generates a sanitized resource prefix from Octane's `useId()` when `idPrefix` is absent. @@ -82,7 +89,7 @@ The rendered structure is: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart | div.ts-chart-canvas + svg.ts-chart | div.ts-chart-canvas | div.ts-chart-layers ``` The outer host uses `position: relative`. diff --git a/packages/charts-core/docs/framework/octane/quick-start.md b/packages/charts-core/docs/framework/octane/quick-start.md index 24dc34b6..d423cb9c 100644 --- a/packages/charts-core/docs/framework/octane/quick-start.md +++ b/packages/charts-core/docs/framework/octane/quick-start.md @@ -36,18 +36,21 @@ const letterFrequencyChart = defineChart({ y: 'frequency', }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: 'Frequency', - ticks: { format: (value) => percent.format(value) }, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, }, + tooltip, }) @@ -127,13 +130,16 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { fill: accent, }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + }, }, + svgAnimation: true, tooltip, }) diff --git a/packages/charts-core/docs/framework/octane/reference/chart.md b/packages/charts-core/docs/framework/octane/reference/chart.md index e185e697..a493c58d 100644 --- a/packages/charts-core/docs/framework/octane/reference/chart.md +++ b/packages/charts-core/docs/framework/octane/reference/chart.md @@ -73,13 +73,13 @@ See [Sizing and layout](../adapter.md#sizing-and-layout). ## Focus, tooltip, and callbacks -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------------------- | ------- | -------------------------------------------------------- | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Inner surface, live SVG, and scene after reconciliation | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => OctaneNode` | None | Composes Octane content inside the built-in tooltip body | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------------------- | ------- | -------------------------------------------------------------------- | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Inner host, default SVG, complete surface, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => OctaneNode` | None | Composes Octane content inside the built-in tooltip body | ```tsx ` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ComponentChildren` | None | Composes Preact content inside the built-in tooltip body | -| `className` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | +| Prop | Type | Default | Meaning | +| -------------------- | --------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => ComponentChildren` | None | Composes Preact content inside the built-in tooltip body | +| `className` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | Custom outer styles can override the adapter's computed layout. A fixed `width` prop still controls scene measurement, so do not give `style.width` a diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 3bf246f6..c465d680 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -30,7 +30,9 @@ import { Chart as RendererChart } from '@tanstack/charts/react/core' `CanvasChart` selects the optional built-in renderer. `RendererChart` requires a `renderer` prop. The default `Chart` remains SVG-based, so importing the -default adapter does not pull Canvas into its module graph. +default adapter does not pull Canvas into its module graph. A definition can +still import `canvasChartRenderer` and assign it to selected marks, which makes +the default component render an ordered mixed surface. The base entries render the built-in tooltip without the React tooltip-body bridge. Import from the optional tooltip entry when passing @@ -90,6 +92,11 @@ layers. It does not paint pixels on the server. The client adopts those elements, paints after mount, and attaches the same focus, keyboard, tooltip, and selection host. +A default chart with selected Canvas marks emits one mixed root containing +ordered SVG markup and Canvas shells. SVG marks are visible in the server +response, Canvas pixels appear after mount, and the client adopts every child +surface. + Use deterministic data, scale domains, definitions, dimensions, and custom renderers on server and client. The adapter generates a sanitized `idPrefix` from `React.useId()` when one is not supplied, keeping document resources @@ -109,7 +116,7 @@ The adapter renders two nested containers: ```text .ts-chart-host .ts-chart-surface - svg.ts-chart | div.ts-chart-canvas + svg.ts-chart | div.ts-chart-canvas | div.ts-chart-layers ``` The outer host has `position: relative`. @@ -183,6 +190,10 @@ import { defineChart } from '@tanstack/charts' const definition = defineChart({ marks: [], + scales: { + x: null, + y: null, + }, }) ``` diff --git a/packages/charts-core/docs/framework/react/quick-start.md b/packages/charts-core/docs/framework/react/quick-start.md index 702d9200..fb49776c 100644 --- a/packages/charts-core/docs/framework/react/quick-start.md +++ b/packages/charts-core/docs/framework/react/quick-start.md @@ -37,18 +37,21 @@ const letterFrequencyChart = defineChart({ y: 'frequency', }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { - label: 'Frequency', - ticks: { format: (value) => percent.format(value) }, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { + label: 'Frequency', + ticks: { format: (value) => percent.format(value) }, + }, }, }, + tooltip, }) @@ -131,13 +134,16 @@ export function LiveLetterFrequency({ rows, accent }: LetterFrequencyInput) { fill: accent, }), ], - x: { - scale: () => scaleBand().padding(0.18), - }, - y: { - scale: scaleLinear, - nice: true, + scales: { + x: { + scale: () => scaleBand().padding(0.18), + }, + y: { + scale: scaleLinear, + nice: true, + }, }, + svgAnimation: true, tooltip, }) diff --git a/packages/charts-core/docs/framework/react/reference/chart.md b/packages/charts-core/docs/framework/react/reference/chart.md index 106e32d4..c876d5ed 100644 --- a/packages/charts-core/docs/framework/react/reference/chart.md +++ b/packages/charts-core/docs/framework/react/reference/chart.md @@ -81,12 +81,12 @@ See [Sizing and layout](../adapter.md#sizing-and-layout). ## Callbacks -| Prop | Type | Default | Meaning | -| -------------------- | ----------------------------------------- | ------- | ------------------------------------------------------- | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Inner surface, live SVG, and scene after reconciliation | +| Prop | Type | Default | Meaning | +| -------------------- | ----------------------------------------- | ------- | -------------------------------------------------------------------- | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Click and keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Inner host, default SVG, complete surface, and scene after rendering | See [Focus and interaction](../../../reference/focus-and-interaction.md) for the behavior and complete callback values. diff --git a/packages/charts-core/docs/framework/solid/adapter.md b/packages/charts-core/docs/framework/solid/adapter.md index 1fab3a6c..76e83f63 100644 --- a/packages/charts-core/docs/framework/solid/adapter.md +++ b/packages/charts-core/docs/framework/solid/adapter.md @@ -44,9 +44,10 @@ browser definitions, formatters, and dimensions deterministic. ## Presentation and rendering `class` and `style: JSX.CSSProperties` apply to the outer host. `className` -applies to the rendered SVG surface. Custom outer styles are spread after -adapter sizing. The package exposes the SVG component only; use `renderSvg` to -replace SVG serialization without replacing the shared host. +applies to the rendered chart surface. Custom outer styles are spread after +adapter sizing. The component starts with SVG and can compose marks that use +`canvasChartRenderer`. Use `renderSvg` to replace SVG serialization without +replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodyRenderContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/packages/charts-core/docs/framework/solid/reference/chart.md b/packages/charts-core/docs/framework/solid/reference/chart.md index 9f3006ec..d0fc6250 100644 --- a/packages/charts-core/docs/framework/solid/reference/chart.md +++ b/packages/charts-core/docs/framework/solid/reference/chart.md @@ -14,27 +14,27 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => JSX.Element` | None | Composes Solid content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `renderTooltipBody` | `(context: ChartTooltipBodyRenderContext) => JSX.Element` | None | Composes Solid content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `JSX.CSSProperties` | None | Outer host styles applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | `renderTooltipBody` receives `points`, `content`, `defaultBody`, `pinned`, and `dismiss`. Include `defaultBody` to keep the native title, rows, formatting, diff --git a/packages/charts-core/docs/framework/svelte/adapter.md b/packages/charts-core/docs/framework/svelte/adapter.md index 79d5f7ee..ee8f84f2 100644 --- a/packages/charts-core/docs/framework/svelte/adapter.md +++ b/packages/charts-core/docs/framework/svelte/adapter.md @@ -46,10 +46,10 @@ dimensions deterministic. ## Presentation and rendering `class` and the string `style` prop apply to the outer host. `className` -applies to the rendered SVG surface. Interaction hooks such as +applies to the rendered chart surface. Interaction hooks such as `onFocusChange` are callback props, not dispatched Svelte events. The package -exposes the SVG component only; use `renderSvg` to replace SVG serialization -without replacing the shared host. +starts with SVG and can compose marks that use `canvasChartRenderer`. Use +`renderSvg` to replace SVG serialization without replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodySnippetContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/packages/charts-core/docs/framework/svelte/reference/chart.md b/packages/charts-core/docs/framework/svelte/reference/chart.md index 7e7decfb..8e232c30 100644 --- a/packages/charts-core/docs/framework/svelte/reference/chart.md +++ b/packages/charts-core/docs/framework/svelte/reference/chart.md @@ -16,27 +16,27 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | -------------------------------------------------------- | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `tooltipBody` | `Snippet<[ChartTooltipBodySnippetContext]>` | None | Composes Svelte content inside the built-in tooltip body | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `string` | None | Outer host declarations applied after adapter sizing | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `tooltipBody` | `Snippet<[ChartTooltipBodySnippetContext]>` | None | Composes Svelte content inside the built-in tooltip body | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `string` | None | Outer host declarations applied after adapter sizing | +| `className` | `string` | None | Extra class on the rendered chart surface | Interaction hooks are Svelte 5 callback props. They are not component events. diff --git a/packages/charts-core/docs/framework/vue/adapter.md b/packages/charts-core/docs/framework/vue/adapter.md index e73abaea..4a946697 100644 --- a/packages/charts-core/docs/framework/vue/adapter.md +++ b/packages/charts-core/docs/framework/vue/adapter.md @@ -50,10 +50,10 @@ deterministic. ## Presentation and rendering `class` and `style: StyleValue` apply to the outer host. `className` applies to -the rendered SVG surface. The component disables general attribute -inheritance; unrelated attributes are not forwarded. The package exposes the -SVG component only; use `renderSvg` to replace SVG serialization without -replacing the shared host. +the rendered chart surface. The component disables general attribute +inheritance; unrelated attributes are not forwarded. It starts with SVG and +can compose marks that use `canvasChartRenderer`. Use `renderSvg` to replace +SVG serialization without replacing the shared host. Exports: `Chart`, `ChartCommonProps`, `ChartPresentationProps`, `ChartProps`, `ChartTooltipBodySlotContext`, `ChartDefinition`, and `ChartPoint`. diff --git a/packages/charts-core/docs/framework/vue/reference/chart.md b/packages/charts-core/docs/framework/vue/reference/chart.md index 067e300f..91de795e 100644 --- a/packages/charts-core/docs/framework/vue/reference/chart.md +++ b/packages/charts-core/docs/framework/vue/reference/chart.md @@ -14,26 +14,26 @@ It also owns `focus`, `focusRing`, `cursor`, `tooltip`, `svgAnimation`, `keyboar ## Props -| Prop | Type | Default | Meaning | -| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------ | -| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | -| `ariaLabel` | `string` | Required | Accessible chart name | -| `ariaDescription` | `string` | None | Optional accessible description | -| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | -| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | -| `width` | `number` | Responsive | Fixed CSS and scene width | -| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | -| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | -| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | -| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | -| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | -| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | -| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | -| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | -| `onRender` | `(context: ChartRenderContext) => void` | None | Live SVG, container, and scene after rendering | -| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | -| `style` | `StyleValue` | None | Outer host style | -| `className` | `string` | None | Extra class on the rendered SVG surface | +| Prop | Type | Default | Meaning | +| -------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------- | +| `definition` | `ChartDefinition` | Required | Framework-neutral chart definition | +| `ariaLabel` | `string` | Required | Accessible chart name | +| `ariaDescription` | `string` | None | Optional accessible description | +| `height` | `number` | `320` without a ratio | Fixed CSS and scene height | +| `aspectRatio` | `number` | None | Positive width-to-height ratio when height is absent | +| `width` | `number` | Responsive | Fixed CSS and scene width | +| `initialWidth` | `number` | `640` | Initial and server width before responsive measurement | +| `tabIndex` | `number` | `0` | Surface tab index; `keyboard: false` forces `-1` | +| `idPrefix` | `string` | Generated | Prefix for renderer-owned document IDs | +| `renderSvg` | `ChartSvgRenderer` | `renderChartSvg` | Scene-to-SVG renderer | +| `measureText` | `ChartTextMeasurer` | Host measurer | Guide text measurement | +| `onFocusChange` | `(point: ChartPoint \| null) => void` | None | Primary focus callback | +| `onFocusGroupChange` | `(points: readonly ChartPoint[]) => void` | None | Grouped focus callback | +| `onSelect` | `(point: ChartPoint \| null) => void` | None | Pointer or keyboard activation callback | +| `onRender` | `(context: ChartRenderContext) => void` | None | Default SVG, complete surface, container, and scene after rendering | +| `class` | `string` | None | Extra class on the outer `.ts-chart-host` | +| `style` | `StyleValue` | None | Outer host style | +| `className` | `string` | None | Extra class on the rendered chart surface | In templates, camel-cased props may use kebab case: `aria-label`, `initial-width`, and `on-focus-change` map to `ariaLabel`, `initialWidth`, and diff --git a/packages/charts-core/docs/guides/accessibility.md b/packages/charts-core/docs/guides/accessibility.md index 88fb3dca..7c3a2e21 100644 --- a/packages/charts-core/docs/guides/accessibility.md +++ b/packages/charts-core/docs/guides/accessibility.md @@ -276,8 +276,11 @@ export function createDefinition( selection, ), ], - x: { scale: scaleLinear, axis: { label: 'Setup time (minutes)' } }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Satisfaction' } }, + scales: { + x: { scale: scaleLinear, axis: { label: 'Setup time (minutes)' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Satisfaction' } }, + }, + selection, }) } diff --git a/packages/charts-core/docs/guides/ai-authoring.md b/packages/charts-core/docs/guides/ai-authoring.md index 77590fdc..b25f3110 100644 --- a/packages/charts-core/docs/guides/ai-authoring.md +++ b/packages/charts-core/docs/guides/ai-authoring.md @@ -22,6 +22,17 @@ path is explicit and repeatable. Start at [Choosing a Chart](./choosing-a-chart.md), then use the relevant [example family](../examples/index.md). +Put Cartesian scale and axis options under `scales.x` and `scales.y`. Never +generate the deprecated root `x` or `y` form. Inside `polar()`, put position +scales under `scales.angle` and `scales.radius`. Use named registry entries and +mark-level `xScale`, `yScale`, `angleScale`, or `radiusScale` selectors when a +chart needs more than one mapping on the same channel. + +Keep the default SVG renderer unless a measured paint-heavy mark benefits from +Canvas. To mix surfaces, import `canvasChartRenderer` from the exact +`@tanstack/charts/canvas` subpath and set that mark's `renderer` option. Do not +move the complete chart to Canvas when only one dense mark needs raster paint. + ## Canonical sources Use one documentation owner for each decision: diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 176333f0..8fcbaa14 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -64,6 +64,24 @@ SVG-based. Canvas enters the module graph only through `@tanstack/charts/octane/canvas`. The React and Octane `/core` entries accept an application-supplied renderer without importing Canvas. +The Canvas renderer can also be attached to only the dense marks in an +otherwise SVG chart: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +lineY(rows, { + x: 'time', + y: 'value', + renderer: canvasChartRenderer, +}) +``` + +The shared host includes only the small renderer-selection and layer metadata +contract. It does not include the Canvas painter. A consumer that never +imports the Canvas subpath cannot retain that painter. Measure mixed and +SVG-only entries separately when reviewing a bundle change. + Non-cartesian geometry is subpath-only: ```ts diff --git a/packages/charts-core/docs/guides/custom-marks-and-renderers.md b/packages/charts-core/docs/guides/custom-marks-and-renderers.md index bf7d3e4e..fef39796 100644 --- a/packages/charts-core/docs/guides/custom-marks-and-renderers.md +++ b/packages/charts-core/docs/guides/custom-marks-and-renderers.md @@ -126,6 +126,28 @@ const threshold = createMark(({ markIndex }) => { `initialize` materializes channels for one scene build. `render` receives the required full `surface` bounds, inner `chart` plot bounds, scales, theme, color resolver, and text layout tools. + +Pass a mark renderer as the third argument when the custom mark should select +its own surface: + +```ts +import { canvasChartRenderer } from '@tanstack/charts/canvas' + +const denseThresholds = createMark( + initializeThresholds, + undefined, + canvasChartRenderer, +) +``` + +The second argument remains the optional mark motion definition. A custom mark +can pass both motion and a renderer, and neither changes the other's meaning. +The selected renderer still decides whether it consumes that motion policy. +Built-in marks expose the same renderer choice in their option object. A DOM +renderer used this way must implement `ChartLayerRenderer`, including +`compose(defaultRenderer)`, so one compositor can own the ordered child +surfaces. `canvasChartRenderer` provides that composition. + When a custom mark emits data labels, an optional `layoutLabels(context)` can return those positioned `SceneLabel` nodes before render so unlocked margins contain them. Keep that method pure because responsive layout may call it more @@ -336,6 +358,13 @@ returns `null` when that optional capability is absent. The host retains sizing, runtime, keyboard, tooltip, selection, and focus-strategy behavior. Keep `prerender` deterministic and make `mount` adopt compatible server markup. +A composed surface exposes its child surfaces from back to front through +`ChartSurface.layers`. Its `element` remains the single accessible, +interactive root. `defaultElement` identifies the topmost element owned by the +host's default renderer. SVG-oriented `onRender` callbacks keep `svg` for +compatibility and also receive the complete `surface`, so application code can +inspect a mixed chart without treating the composition root as an SVG. + If `paintFocus` resolves and paints inline mark-state geometry, return that destination `ChartScene`. The host will use it for subsequent pointer hits; returning nothing preserves base-scene interaction for simpler renderers. diff --git a/packages/charts-core/docs/guides/dynamic-data-and-animation.md b/packages/charts-core/docs/guides/dynamic-data-and-animation.md index 6d84776c..09d96934 100644 --- a/packages/charts-core/docs/guides/dynamic-data-and-animation.md +++ b/packages/charts-core/docs/guides/dynamic-data-and-animation.md @@ -29,13 +29,15 @@ function RankingChart({ rows, metric, accent }: Props) { fill: accent, }), ], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 420 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 420 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }), }) @@ -131,8 +133,10 @@ const definition = defineChart({ motion: { transition: { type: 'spring', mass: 1.25 } }, }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, }) const host = mountChartRenderer(container, { @@ -231,6 +235,10 @@ const definition = defineChart({ transition: { type: 'tween', duration: sampleInterval, easing: 'linear' }, }, marks, + scales: { + x: null, + y: null, + }, }) ``` diff --git a/packages/charts-core/docs/guides/exporting.md b/packages/charts-core/docs/guides/exporting.md index 20ca052f..043ec1e9 100644 --- a/packages/charts-core/docs/guides/exporting.md +++ b/packages/charts-core/docs/guides/exporting.md @@ -30,6 +30,10 @@ const svg = renderChartSvg(scene, { }) ``` +This direct serializer paints the complete renderer-neutral scene as SVG. It +does not run mark-level surface composition, so it can provide a vector +snapshot of a definition that selects Canvas when mounted. + For a responsive definition, create a runtime with its datum, x-value, and y-value generics, then call `render(...)` with the definition and explicit size. See [Runtime and Scene](../reference/runtime-and-scene.md#createchartruntime) @@ -106,6 +110,18 @@ scene but no transient focus. `backgroundCanvas`, `focusUnderCanvas`, SVG serialization, Canvas export does not retain vector geometry, accessible markup, or independently styleable nodes. +## Export a mixed SVG and Canvas chart + +Pass the mixed chart root or its host to `renderChartImage` or +`downloadChartImage`. The exporter composites every SVG and Canvas child +surface in visual order. Set `includeFocus: true` to include the live focus +layers in the same positions used by the mounted chart. + +`serializeChartSvg` and `downloadChartSvg` reject a mixed root. A mixed chart +contains raster pixels, so a pure vector SVG would either omit those marks or +embed a raster image. Use the raster export path when any mounted mark selects +Canvas. + ## Theme and resource policy Export the theme intended for the artifact. A chart following application dark @@ -135,7 +151,7 @@ needs embedded or inlined assets. - Fonts and external resources are portable. - Focus decoration is included only when meaningful. - Raster scale is chosen for the target medium. -- A Canvas export intentionally includes or excludes focus layers. +- A Canvas or mixed export intentionally includes or excludes focus layers. See [Rendering and Export](../reference/rendering-and-export.md) for every function and option. diff --git a/packages/charts-core/docs/guides/faceting-and-composition.md b/packages/charts-core/docs/guides/faceting-and-composition.md index 92b44413..94f0e1be 100644 --- a/packages/charts-core/docs/guides/faceting-and-composition.md +++ b/packages/charts-core/docs/guides/faceting-and-composition.md @@ -22,8 +22,10 @@ const definition = defineChart({ dot(highlights, { x: 'date', y: 'median' }), text(labels, { x: 'date', y: 'median', text: 'label' }), ], - x, - y, + scales: { + x: x, + y: y, + }, }) ``` @@ -56,11 +58,13 @@ export default facetChart(rows, { lineY(data, { x: 'week', y: 'orders', strokeWidth: 2 }), dot(data, { x: 'week', y: 'orders', r: 3.5 }), ], - x: { scale: scaleLinear().domain([1, 4]) }, - y: { - scale: scaleLinear().domain([0, 80]), - grid: true, - axis: { label: 'Orders' }, + scales: { + x: { scale: scaleLinear().domain([1, 4]) }, + y: { + scale: scaleLinear().domain([0, 80]), + grid: true, + axis: { label: 'Orders' }, + }, }, } }, diff --git a/packages/charts-core/docs/guides/interactions-and-selections.md b/packages/charts-core/docs/guides/interactions-and-selections.md index 9548add1..0d3b2ab1 100644 --- a/packages/charts-core/docs/guides/interactions-and-selections.md +++ b/packages/charts-core/docs/guides/interactions-and-selections.md @@ -58,8 +58,11 @@ const definition = defineChart({ lineY(rows, { x: 'date', y: 'value' }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', maxFocusDistance: Number.POSITIVE_INFINITY, }) @@ -124,8 +127,11 @@ const definition = defineChart({ marker: true, }), ], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, + cursor: { use: cursorHost, controller: freeCursor, @@ -231,6 +237,10 @@ const definition = defineChart({ selection, ), ], + scales: { + x: null, + y: null, + }, selection, }) ``` @@ -270,8 +280,11 @@ type Position = ContinuousCursorPosition const definition = defineChart({ marks: [dot(rows, { x: 'horsepower', y: 'economy' })], - x: { scale: horsepowerScale }, - y: { scale: economyScale }, + scales: { + x: { scale: horsepowerScale }, + y: { scale: economyScale }, + }, + controls: [ continuousCursor({ position: controlledSignal< @@ -333,8 +346,11 @@ let interaction: ChartInteractionController | undefined const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value', key: 'id' })], - x: { scale: scaleUtc() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleUtc() }, + y: { scale: scaleLinear() }, + }, + focus: 'nearest-x', pointer: false, tooltip, @@ -479,8 +495,11 @@ export default function App() { strokeWidth: 2.5, }), ], - x: { scale: scaleLinear().domain([1, 8]) }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + scales: { + x: { scale: scaleLinear().domain([1, 8]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ brushX({ range: controlledSignal, BrushXChange>( @@ -573,7 +592,10 @@ import { controlledSignal } from '@tanstack/charts/interaction/signal' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: utcScale }, + scales: { + x: { scale: utcScale }, + y: null, + }, controls: [ handleX({ value: controlledSignal>(currentDate, (next) => @@ -623,7 +645,10 @@ import { controlledSignal } from '@tanstack/charts/interaction/signal' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: utcScale.copy().domain([window.start, window.end]) }, + scales: { + x: { scale: utcScale.copy().domain([window.start, window.end]) }, + y: null, + }, controls: [ zoomX({ window: controlledSignal, ZoomXChange>( diff --git a/packages/charts-core/docs/guides/legends-and-color.md b/packages/charts-core/docs/guides/legends-and-color.md index e749fd50..0ca069bc 100644 --- a/packages/charts-core/docs/guides/legends-and-color.md +++ b/packages/charts-core/docs/guides/legends-and-color.md @@ -34,8 +34,11 @@ const definition = defineChart({ z: 'series', }), ], - x, - y, + scales: { + x: x, + y: y, + }, + color: { scale: color, legend: colorLegend({ label: 'Package' }), @@ -125,15 +128,18 @@ export default defineChart({ strokeWidth: 2.5, }), ], - x: { - scale: () => scalePoint().padding(0.2), - axis: { label: 'Week' }, - }, - y: { - scale: scaleLinear, - grid: true, - axis: { ticks: { count: 5 }, label: 'Downloads' }, + scales: { + x: { + scale: () => scalePoint().padding(0.2), + axis: { label: 'Week' }, + }, + y: { + scale: scaleLinear, + grid: true, + axis: { ticks: { count: 5 }, label: 'Downloads' }, + }, }, + color: { legend: colorLegend({ label: 'Package' }) }, }) ``` diff --git a/packages/charts-core/docs/guides/migrating.md b/packages/charts-core/docs/guides/migrating.md index 0d1be992..4b2f535b 100644 --- a/packages/charts-core/docs/guides/migrating.md +++ b/packages/charts-core/docs/guides/migrating.md @@ -7,6 +7,52 @@ Migration is a semantic exercise, not a component-name translation. First describe what the existing chart means and how users operate it. Then express that behavior with data preparation, scales, marks, and host options. +## Move root scales into the registry + +Current definitions put Cartesian scale and axis options under `scales`: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale }, + y: { scale: yScale, grid: true }, + }, +}) +``` + +The earlier root properties still work temporarily: + +```ts +const legacyDefinition = defineChart({ + marks, + x: { scale: xScale }, + y: { scale: yScale, grid: true }, +}) +``` + +Development builds warn with the exact replacement. Move `x` to `scales.x` +and `y` to `scales.y`. Root `x` and `y` will be removed when TanStack Charts +enters Alpha. New code and generated code should only use the `scales` form. + +Polar definitions follow the same migration. Move `polar({ angle, radius })` +to `polar({ scales: { angle, radius } })`. If neither positional scale is +used, write `scales: { angle: null, radius: null }`. The old polar properties +also work temporarily, warn in development, and will be removed when TanStack +Charts enters Alpha. + +During this compatibility window, `ChartSpec` is a union of the canonical and +legacy shapes. If application code extended the old interface, replace that +extension with an intersection: + +```ts +import type { ChartSpec } from '@tanstack/charts' + +type ApplicationChartSpec = ChartSpec & { + applicationTag: string +} +``` + ## Inventory the current contract Record: diff --git a/packages/charts-core/docs/guides/responsive-charts.md b/packages/charts-core/docs/guides/responsive-charts.md index f0954281..679d0737 100644 --- a/packages/charts-core/docs/guides/responsive-charts.md +++ b/packages/charts-core/docs/guides/responsive-charts.md @@ -79,14 +79,16 @@ export default defineChart({ inset: 1, }), ], - x: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { ticks: { count: 5 }, label: 'Weekly requests' }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { ticks: { count: 5 }, label: 'Weekly requests' }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, }) ``` diff --git a/packages/charts-core/docs/guides/ssr-and-hydration.md b/packages/charts-core/docs/guides/ssr-and-hydration.md index 1e88a9d2..1bf27318 100644 --- a/packages/charts-core/docs/guides/ssr-and-hydration.md +++ b/packages/charts-core/docs/guides/ssr-and-hydration.md @@ -11,12 +11,12 @@ runtime and renderer on the server and in the browser. | Adapter | Server output | Browser contract | | ------------------------------------------ | ----------------------------------- | --------------------------------------------------- | -| [React](../framework/react/adapter.md) | Complete SVG; Canvas shell | Hydrates and adopts the existing surface | -| [Preact](../framework/preact/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Vue](../framework/vue/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Solid](../framework/solid/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Svelte](../framework/svelte/adapter.md) | Complete SVG | Hydrates before the shared host mounts | -| [Octane](../framework/octane/adapter.md) | Complete SVG; Canvas shell | Hydrates and adopts the existing surface | +| [React](../framework/react/adapter.md) | SVG, Canvas, or mixed shell | Hydrates and adopts the existing surface | +| [Preact](../framework/preact/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Vue](../framework/vue/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Solid](../framework/solid/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Svelte](../framework/svelte/adapter.md) | SVG or mixed shell | Hydrates before the shared host mounts | +| [Octane](../framework/octane/adapter.md) | SVG, Canvas, or mixed shell | Hydrates and adopts the existing surface | | [Angular](../framework/angular/adapter.md) | Not yet a verified adapter contract | Browser mount, immutable update, and teardown | | [Lit](../framework/lit/adapter.md) | Not yet a verified adapter contract | Browser registration, update, disconnect, reconnect | | [Alpine](../framework/alpine/adapter.md) | None | Browser-only directive | @@ -94,6 +94,11 @@ their backing stores for the device-pixel ratio, paints the scene, and attaches the shared interaction host. The first image appears after client mount; use the default SVG adapter when visible server-rendered geometry is required. +When selected marks use `canvasChartRenderer`, verified server adapters emit +one accessible mixed root with ordered SVG markup and Canvas shells. The +browser adopts each child surface. SVG marks remain visible in the server +response, while Canvas marks receive pixels after mount. + ## Fonts and text measurement Automatic guide margins depend on text metrics. The server uses deterministic diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index ad55b0e8..cbea3392 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -51,8 +51,11 @@ Use `theme` when a chart needs explicit scene colors: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + theme: { foreground: '#e5e7eb', muted: '#94a3b8', @@ -126,12 +129,15 @@ export default defineChart({ strokeWidth: 2, }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { - scale: scaleLinear, - grid: true, - axis: { label: 'Revenue (USD)' }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, }, + gradients: [ { id: 'revenue-fill', diff --git a/packages/charts-core/docs/guides/tooltips-and-focus.md b/packages/charts-core/docs/guides/tooltips-and-focus.md index 15f9ef4c..4c8bba66 100644 --- a/packages/charts-core/docs/guides/tooltips-and-focus.md +++ b/packages/charts-core/docs/guides/tooltips-and-focus.md @@ -112,8 +112,11 @@ export default defineChart({ }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: () => scalePoint().padding(0.2) }, - y: { scale: scaleLinear, grid: true, axis: { label: 'Active users' } }, + scales: { + x: { scale: () => scalePoint().padding(0.2) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Active users' } }, + }, + focus: 'nearest-x', maxFocusDistance: Number.POSITIVE_INFINITY, tooltip, @@ -163,8 +166,11 @@ point: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + tooltip: { use: tooltip, items: [ @@ -272,7 +278,14 @@ const renderer = motion({ }, }) -const definition = defineChart({ marks, tooltip }) +const definition = defineChart({ + marks, + scales: { + x: null, + y: null, + }, + tooltip, +}) mountChartRenderer(container, { definition, @@ -288,6 +301,10 @@ A static chart-level transition can refine the renderer fallback: ```ts const definition = defineChart({ marks, + scales: { + x: null, + y: null, + }, motion: { transition: { type: 'spring', stiffness: 170, damping: 18, mass: 1 }, }, @@ -351,8 +368,11 @@ group's bounding-box center: ```ts const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -407,8 +427,11 @@ import { portal } from '@tanstack/charts/tooltip/portal' const definition = defineChart({ marks, - x, - y, + scales: { + x: x, + y: y, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -542,11 +565,18 @@ function SeriesPie({ points }: { points: readonly RevenuePoint[] }) { fill: (slice) => slice.data.color ?? 'CanvasText', }), ], + scales: { + angle: null, + radius: null, + }, }), ], guides: false, - x: null, - y: null, + scales: { + x: null, + y: null, + }, + keyboard: false, }) }, [points]) diff --git a/packages/charts-core/docs/guides/typescript.md b/packages/charts-core/docs/guides/typescript.md index 73aa3c0c..c48a299a 100644 --- a/packages/charts-core/docs/guides/typescript.md +++ b/packages/charts-core/docs/guides/typescript.md @@ -31,8 +31,10 @@ const definition = defineChart({ y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, }) ``` @@ -51,8 +53,10 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, }) } ``` @@ -78,8 +82,11 @@ function createTrafficDefinition(rows: readonly Reading[]) { y: 'temperature', }), ], - x: { scale: scaleTime }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleTime }, + y: { scale: scaleLinear }, + }, + margin: width < 480 ? 24 : 40, })) } @@ -98,10 +105,10 @@ Avoid annotating an intermediate object as broad `ChartSpec` before passing it to `defineChart`. That discards the literal mark tuple used for axis and callback inference. -The mark tuple also determines which positional scales are required. Cartesian -marks require every dimension they materialize. Positionless geo, polar, and -facet marks omit both axes; one-dimensional marks such as `ruleY` require only -`y`. +The mark tuple determines the value type accepted by each reserved positional +scale. `scales.x` and `scales.y` are always present in canonical definitions. +Use `null` for a dimension that no mark materializes. Named scale selectors on +marks keep those values out of the reserved entry's inferred type. ## Callback types @@ -148,10 +155,13 @@ union manually. The exact utility contracts are listed in ## Custom marks -`createMark` keeps interaction points and scale -values aligned for the common case. Use the advanced scale-value factory when -the materialized axis domain differs from the point anchor or when a custom -mark is positionless and declares both scale value types as `never`. +`createMark` keeps interaction +points and scale values aligned for the common case. The scale ID parameters +default to `x` and `y`. Provide them when a custom mark selects named scales so +its values do not widen the reserved scale types. Use the advanced scale-value +factory when the materialized axis domain differs from the point anchor or +when a custom mark is positionless and declares both scale value types as +`never`. See [Custom Marks and Renderers](./custom-marks-and-renderers.md). A custom extension that requires `as unknown as`, a private import, or suppressed type diff --git a/packages/charts-core/docs/installation.md b/packages/charts-core/docs/installation.md index dc509cba..90b7dc0b 100644 --- a/packages/charts-core/docs/installation.md +++ b/packages/charts-core/docs/installation.md @@ -296,8 +296,10 @@ const values = [4, 9, 7] const chart = defineChart({ marks: [lineY(values)], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) ``` @@ -326,8 +328,10 @@ import { createChartScene, defineChart, lineY } from '@tanstack/charts' const chart = defineChart({ marks: [lineY([2, 5, 3])], - x: { scale: scaleLinear }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleLinear }, + y: { scale: scaleLinear }, + }, }) const scene = createChartScene(chart, { width: 640, height: 320 }) diff --git a/packages/charts-core/docs/overview.md b/packages/charts-core/docs/overview.md index c69a476b..58cd40a4 100644 --- a/packages/charts-core/docs/overview.md +++ b/packages/charts-core/docs/overview.md @@ -62,14 +62,16 @@ export default defineChart({ r: 4, }), ], - x: { - scale: () => scaleBand().padding(0.2), - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Signups' }, + scales: { + x: { + scale: () => scaleBand().padding(0.2), + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Signups' }, + }, }, }) ``` diff --git a/packages/charts-core/docs/quick-start.md b/packages/charts-core/docs/quick-start.md index 634ca85a..36151834 100644 --- a/packages/charts-core/docs/quick-start.md +++ b/packages/charts-core/docs/quick-start.md @@ -54,16 +54,19 @@ const monthlyRevenueChart = defineChart({ stroke: '#2563eb', }), ], - x: { - scale: () => scalePoint().padding(0.2), - axis: { label: 'Month' }, - }, - y: { - scale: scaleLinear, - nice: true, - grid: true, - axis: { label: 'Revenue (USD)' }, + scales: { + x: { + scale: () => scalePoint().padding(0.2), + axis: { label: 'Month' }, + }, + y: { + scale: scaleLinear, + nice: true, + grid: true, + axis: { label: 'Revenue (USD)' }, + }, }, + tooltip, }) ``` diff --git a/packages/charts-core/docs/reference/adapter-controller.md b/packages/charts-core/docs/reference/adapter-controller.md index 530ba474..7dc572d3 100644 --- a/packages/charts-core/docs/reference/adapter-controller.md +++ b/packages/charts-core/docs/reference/adapter-controller.md @@ -84,6 +84,11 @@ interface ChartAdapter< Keep one controller per framework component instance. +Both controllers resolve mark-level renderers from the compiled scene before +prerender and mount. A default SVG adapter therefore emits and adopts a mixed +shell when selected marks use `canvasChartRenderer`; framework adapters do not +need their own layer-selection logic. + ## Prerender and mount ```ts diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index c3c62878..74996619 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -43,8 +43,11 @@ import { scaleLinear } from '@tanstack/charts/scales/linear' const definition = defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { scale: scaleUtc }, - y: { scale: scaleLinear, nice: true, grid: true }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear, nice: true, grid: true }, + }, + focus: 'group-x', tooltip: { use: tooltip, @@ -68,12 +71,14 @@ const definition = defineChart({ svgAnimation: true, chart: ({ width }) => ({ marks: [barY(rows, { x: 'category', y: 'value' })], - x: { scale: scaleBand }, - y: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - grid: true, + scales: { + x: { scale: scaleBand }, + y: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + grid: true, + }, }, }), }) @@ -141,13 +146,15 @@ const definition = useMemo(() => { return defineChart(({ width }) => ({ marks: [barX(ranked, { x: 'value', y: 'label' })], - x: { - scale: scaleLinear, - nice: true, - axis: { ticks: { count: width < 480 ? 4 : 7 } }, - }, - y: { - scale: () => scaleBand().padding(0.1), + scales: { + x: { + scale: scaleLinear, + nice: true, + axis: { ticks: { count: width < 480 ? 4 : 7 } }, + }, + y: { + scale: () => scaleBand().padding(0.1), + }, }, })) }, [rows, metric]) diff --git a/packages/charts-core/docs/reference/chart-spec.md b/packages/charts-core/docs/reference/chart-spec.md index 98b99768..a8bf4c20 100644 --- a/packages/charts-core/docs/reference/chart-spec.md +++ b/packages/charts-core/docs/reference/chart-spec.md @@ -10,33 +10,35 @@ presentation. ```ts type ChartSpec = { marks: TMarks + scales: ChartScales guides?: boolean color?: ChartColorOptions gradients?: readonly ChartLinearGradient[] clip?: boolean margin?: number | Partial theme?: Partial -} & ([ChartMarkScaleX] extends [never] - ? { x?: null } - : { x: ChartAxisOptions }) & - ([ChartMarkScaleY] extends [never] - ? { y?: null } - : { y: ChartAxisOptions }) +} + +type ChartScales = Readonly< + Record +> & { + x: ChartPositionScaleOptions | null + y: ChartPositionScaleOptions | null +} ``` ## Properties -| Property | Required | Meaning | -| ----------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | -| `marks` | Yes | Ordered mark layers. Later scene nodes paint after earlier ones. | -| `x` | Conditional | Required when a mark materializes x; omitted otherwise. | -| `y` | Conditional | Required when a mark materializes y; omitted otherwise. | -| `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | -| `color` | No | Shared categorical or quantitative color scale and optional legend. | -| `gradients` | No | Linear-gradient resources consumed by the default SVG and Canvas renderers. | -| `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | -| `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | -| `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | +| Property | Required | Meaning | +| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------ | +| `marks` | Yes | Ordered mark layers. Later scene nodes paint after earlier ones. | +| `scales` | Yes | Cartesian scale registry. Reserved `x` and `y` entries are required; additional named scales are optional. | +| `guides` | No | Set to `false` to suppress both axes, grid lines, titles, and their implicit margins. | +| `color` | No | Shared categorical or quantitative color scale and optional legend. | +| `gradients` | No | Linear-gradient resources consumed by the default SVG and Canvas renderers. | +| `clip` | No | Clips the marks group to the resolved inner chart bounds in the default SVG and Canvas renderers. | +| `margin` | No | Locks all margins with a number or selected sides with a partial object. Omitted sides are measured automatically. | +| `theme` | No | Overrides default foreground, muted, grid, background, or palette tokens. | The detailed option contracts live in [Scales, guides, and color](./scales-guides-and-color.md). Mark-specific @@ -61,8 +63,10 @@ const definition = defineChart({ }), lineY(rows, { x: 'date', y: 'value', points: true }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear, grid: true }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear, grid: true }, + }, }) ``` @@ -80,35 +84,49 @@ Built-in marks infer stable keys from a unique primitive top-level `id`, nested unique. Mark IDs default from layer order; set `id` explicitly when a mark must retain identity while its order changes. -## Conditional positional axes +Built-in Cartesian, radial, and composite marks accept an optional +`renderer`. Passing `canvasChartRenderer` opts that mark into Canvas while +marks without the option, including ordinary axes and guides, keep the host +renderer. The host groups adjacent runs without changing declaration order. +See [Mark-level renderers](./rendering-and-export.md#mark-level-renderers). + +## Required positional scales -Each axis used by the marks is required. Supply a compatible factory for an inferred -domain or a configured instance for a fixed domain: +`scales.x` and `scales.y` are required. Supply a compatible factory for an +inferred domain or a configured instance for a fixed domain: ```ts -const axes = { +const scales = { x: { scale: scaleUtc }, y: { scale: scaleLinear }, } ``` -Omit an unused dimension: +Use `null` for an unused dimension: ```ts const horizontalThresholds = defineChart({ marks: [ruleY([25, 50, 75])], - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: null, + y: { scale: scaleLinear().domain([0, 100]) }, + }, }) ``` -`axis: false` hides an axis but does not remove its scale. Scene compilation -still guards untyped consumers that omit or null an axis used by a mark. +`axis: false` hides an axis but does not remove its scale. A `null` entry says +that the scale does not exist. Scene compilation rejects a mark bound to a +missing scale. + +Additional entries name independent mappings. Each named entry declares its +`channel`, and marks opt into it with `xScale` or `yScale`. See +[Named scales and multiple axes](./scales-guides-and-color.md#named-scales-and-multiple-axes). ## Guides and margins Guide visibility and geometry are separate: -- `x.axis: false` or `y.axis: false` hides one axis. +- `scales.x.axis: false` or `scales.y.axis: false` hides one axis. - `guides: false` hides all guides and removes their implicit margin. - Omitted `margin` sides are measured from ticks, rotation, titles, edge overhang, color legends, and Cartesian `text` marks. @@ -128,8 +146,7 @@ renderers: ```ts const definition = defineChart({ marks, - x, - y, + scales: { x, y }, clip: true, gradients: [ { diff --git a/packages/charts-core/docs/reference/custom-extensions.md b/packages/charts-core/docs/reference/custom-extensions.md index 70f68ccc..9bee922b 100644 --- a/packages/charts-core/docs/reference/custom-extensions.md +++ b/packages/charts-core/docs/reference/custom-extensions.md @@ -29,8 +29,8 @@ function compositeMark< > ``` -`CompositeMarkOptions` contains optional `id` and `motion` fields. The result -preserves the union of child datum and positional types. Initialization merges +`CompositeMarkOptions` contains optional `id`, `motion`, and `renderer` fields. +The result preserves the union of child datum and positional types. Initialization merges the children's semantic channels under parent and child namespaces. Rendering keeps child order, namespaces scene keys and mark IDs, and retains each child point as a separate interaction target. Parent and child motion definitions @@ -53,12 +53,15 @@ function createMark< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark + renderer?: ChartMarkRenderer, +): ChartMark ``` Initialization runs once per scene compilation and receives the mark's layer @@ -97,6 +100,11 @@ initializer may return its own `motion` when a composite or resolved layout needs a scene-local policy; that value takes precedence over the factory fallback. +The optional third `renderer` argument is copied onto the nodes returned by +both direct `render` and resolved-layout render paths. It does not replace or +shift the motion argument. Use a stable `ChartMarkRenderer` instance so an +update can reuse its existing surface composition. + Materialized channels declare semantic values before scale resolution: ```ts @@ -107,9 +115,13 @@ interface MaterializedChannel { } ``` -Use `scale: 'x'`, `scale: 'y'`, or `scale: 'color'` for shared chart scales. -`includeZero` is a hint available to a custom scale resolver. Filter invalid -values before materializing them. +Use a `ChartSpec.scales` ID for a positional channel. The reserved `x` and `y` +IDs are the defaults; additional IDs select named scales. Use `scale: 'color'` +for the shared color scale, and do not reuse `color` as a positional scale ID. +Pass named position IDs through the `createMark` scale ID type parameters so +their values do not widen the reserved scale types. `includeZero` is a hint +available to a custom scale resolver. Filter invalid values before +materializing them. `viewport` overrides presentation ownership independently for x and y. Without an override, a mark is viewport content on an axis when one of its materialized @@ -256,17 +268,29 @@ function createMarkWithScaleValues< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark + renderer?: ChartMarkRenderer, +): ChartMark< + TDatum, + TXPointValue, + TYPointValue, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> ``` The subpath also exports `ChartMarkPointX`, `ChartMarkPointY`, `ChartMarkScaleX`, and `ChartMarkScaleY`. Use it only when the distinction is -real; ordinary custom marks should use `createMark`. +real; ordinary custom marks should use `createMark`. Pass named scale IDs in +the last two type parameters when the mark does not use reserved `x` or `y`. ## Curves @@ -372,6 +396,18 @@ Use `@tanstack/charts/renderer` directly or the framework `/core` entries. The optional built-in implementation at `@tanstack/charts/canvas` demonstrates the boundary without changing the default SVG imports. +`ChartMarkRenderer` is the small renderer-selection token stored in universal +mark and scene types. A DOM implementation uses `ChartLayerRenderer`, which +extends `ChartRenderer` and `ChartMarkRenderer` with +`compose(defaultRenderer)`. `UniversalChartLayerRenderer` provides the same +contract for a definition-agnostic renderer. The returned compositor owns the +ordered child surfaces and exposes them through `ChartSurface.layers`. + +Built-in marks accept a `ChartMarkRenderer` through their shared +`ChartMarkOptions`. Custom marks pass it as the third factory argument, after +the optional motion definition. See +[Mark-level renderers](./rendering-and-export.md#mark-level-renderers). + For an SVG-only serialization change, pass a `ChartSvgRenderer` as `renderSvg` to the compatibility host or adapt it with `createSvgChartRenderer` from `@tanstack/charts/svg/renderer`. Preserve the SVG root, stable DOM keys, diff --git a/packages/charts-core/docs/reference/dom-host.md b/packages/charts-core/docs/reference/dom-host.md index 36041802..85633106 100644 --- a/packages/charts-core/docs/reference/dom-host.md +++ b/packages/charts-core/docs/reference/dom-host.md @@ -91,24 +91,24 @@ For the built-in Canvas renderer, `mountCanvasChart` from The default SVG host requires `definition` and `ariaLabel`. -| Option | Default | Meaning | -| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------ | -| `definition` | Required | [Chart definition](./chart-definitions.md). Its identity is the application update boundary. | -| `ariaLabel` | Required | Accessible chart name placed on the SVG. | -| `ariaDescription` | None | Optional SVG description. | -| `height` | `320` | Fixed scene height in CSS pixels. | -| `aspectRatio` | None | Computes height as `width / aspectRatio` when `height` is absent and the ratio is positive and finite. | -| `width` | Container width | Fixed scene width. Supplying it disables resize observation. | -| `initialWidth` | `640` | Width used when a responsive container has not produced a positive measurement. | -| `className` | None | Extra class on the rendered SVG, not the container. | -| `idPrefix` | Empty | Prefix for renderer-owned resource IDs. Use a unique value for resource-aware charts. | -| `tabIndex` | `0` | SVG tab index while keyboard behavior is enabled. | -| `onFocusChange` | None | Receives the primary focused point or `null`. | -| `onFocusGroupChange` | None | Receives all points selected by the current focus strategy. | -| `onSelect` | None | Receives the clicked or keyboard-activated point, or `null` for an empty click. | -| `onRender` | None | Runs after reconciliation with the container, live SVG, scene, and interaction controller. | -| `renderSvg` | `renderChartSvg` | Replaces the scene-to-SVG renderer. | -| `measureText` | DOM font measurer | Replaces guide text measurement. | +| Option | Default | Meaning | +| -------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------- | +| `definition` | Required | [Chart definition](./chart-definitions.md). Its identity is the application update boundary. | +| `ariaLabel` | Required | Accessible chart name placed on the SVG. | +| `ariaDescription` | None | Optional SVG description. | +| `height` | `320` | Fixed scene height in CSS pixels. | +| `aspectRatio` | None | Computes height as `width / aspectRatio` when `height` is absent and the ratio is positive and finite. | +| `width` | Container width | Fixed scene width. Supplying it disables resize observation. | +| `initialWidth` | `640` | Width used when a responsive container has not produced a positive measurement. | +| `className` | None | Extra class on the rendered chart surface, not the container. | +| `idPrefix` | Empty | Prefix for renderer-owned resource IDs. Use a unique value for resource-aware charts. | +| `tabIndex` | `0` | SVG tab index while keyboard behavior is enabled. | +| `onFocusChange` | None | Receives the primary focused point or `null`. | +| `onFocusGroupChange` | None | Receives all points selected by the current focus strategy. | +| `onSelect` | None | Receives the clicked or keyboard-activated point, or `null` for an empty click. | +| `onRender` | None | Runs after reconciliation with the container, default SVG, complete surface, scene, and interaction controller. | +| `renderSvg` | `renderChartSvg` | Replaces the scene-to-SVG renderer. | +| `measureText` | DOM font measurer | Replaces guide text measurement. | The definition owns these chart controls: diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 373430bf..301d6cc7 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -162,8 +162,11 @@ const definition = defineChart({ strokeDasharray: '4 4', }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', maxFocusDistance: Number.POSITIVE_INFINITY, }) @@ -322,8 +325,11 @@ const definition = defineChart({ lineY(rows, { x: 'date', y: 'value' }), crosshair({ x: { label: true }, y: false }), ], - x: { scale: scaleUtc }, - y: { scale: scaleLinear }, + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, + focus: 'group-x', cursor: { use: cursorHost, @@ -959,10 +965,10 @@ resolution. Dragging, scrolling, custom crosshair overlays, and freeform range or lasso selections can listen on a wrapper or use `onRender` to attach application -behavior to the live SVG. Use `handleX` for one ordered scale value, `brushX` -for a normal horizontal semantic range, and `zoomX` for a normal controlled x -window. For custom gestures, keep semantic state outside the scene and update a -responsive definition by replacing its identity. +behavior to the default SVG or complete surface. Use `handleX` for one ordered +scale value, `brushX` for a normal horizontal semantic range, and `zoomX` for a +normal controlled x window. For custom gestures, keep semantic state outside +the scene and update a responsive definition by replacing its identity. Use a definition cursor binding for snapped or free crosshairs. Keep other semantic state outside the scene. diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index af31ef73..04cdcf2d 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -28,6 +28,11 @@ and application-owned interaction. ## Mark reference +Built-in Cartesian, radial, and composite marks accept +`renderer?: ChartMarkRenderer`. Pass `canvasChartRenderer` from +`@tanstack/charts/canvas` to opt that mark into Canvas while the rest of the +chart keeps its host renderer. + | Marks | Reference | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `lineY`, `lineX`, `areaY`, and `areaX` | [Line and area](./marks/line-and-area.md) | @@ -70,9 +75,9 @@ and application-owned interaction. | Alpine | — | [Adapter](../framework/alpine/adapter.md) | [`charts`](../framework/alpine/reference/chart.md) | | Octane | [Quick start](../framework/octane/quick-start.md) | [Adapter](../framework/octane/adapter.md) | [`Chart`](../framework/octane/reference/chart.md) | -React and Octane keep the default `Chart` SVG-based. Their `/canvas` entries -select the optional Canvas renderer; their `/core` entries require an explicit -`ChartRenderer`. The other adapters currently expose the default SVG surface. +Every default `Chart` starts with SVG and can opt selected marks into Canvas. +React and Octane also provide `/canvas` entries for a completely Canvas chart +and `/core` entries that require an explicit `ChartRenderer`. ## Surface tiers @@ -164,7 +169,7 @@ aggregate `/scales` export. | `@tanstack/charts/universal` | Common root authoring, runtime, scene, and static SVG values without browser hosts or adapters | | `@tanstack/charts/reconcile` | `reconcileChartSvg`, `reconcileChartSvgFragment` | | `@tanstack/charts/rect` | `rect`, `cell` | -| `@tanstack/charts/renderer` | `mountChartRenderer` | +| `@tanstack/charts/renderer` | `mountChartRenderer` and `resolveChartRenderer` | | `@tanstack/charts/ridgeline` | `ridgelineY`, `ridgelineX`, `RidgelineYOptions`, `RidgelineXOptions`, `RidgelinePosition`, `RidgelineCurve`, and `RidgelineStateStyle` | | `@tanstack/charts/rule` | `ruleX`, `ruleY` | | `@tanstack/charts/runtime` | `createChartRuntime`, `isResponsiveChartDefinition` | diff --git a/packages/charts-core/docs/reference/marks/dodge.md b/packages/charts-core/docs/reference/marks/dodge.md index a9ad7f93..5f03388f 100644 --- a/packages/charts-core/docs/reference/marks/dodge.md +++ b/packages/charts-core/docs/reference/marks/dodge.md @@ -90,7 +90,10 @@ defineChart({ layout: dodgeY({ anchor: 'middle' }), }), ], - x: { scale: scaleLinear().domain([5, 50]) }, + scales: { + x: { scale: scaleLinear().domain([5, 50]) }, + y: null, + }, }) ``` 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 7e539e4d..2f776f67 100644 --- a/packages/charts-core/docs/reference/marks/line-and-area.md +++ b/packages/charts-core/docs/reference/marks/line-and-area.md @@ -255,8 +255,10 @@ const definition = defineChart({ z: 'series', }), ], - x: { scale: xScale }, - y: { scale: yScale }, + scales: { + x: { scale: xScale }, + y: { scale: yScale }, + }, }) ``` diff --git a/packages/charts-core/docs/reference/marks/polar.md b/packages/charts-core/docs/reference/marks/polar.md index 53f09798..1516d9c3 100644 --- a/packages/charts-core/docs/reference/marks/polar.md +++ b/packages/charts-core/docs/reference/marks/polar.md @@ -36,28 +36,29 @@ foregrounds paint last. function polar(options: PolarOptions): ChartMark ``` -| Option | Type | Default | Meaning | -| ------------- | ----------------------- | ------------- | ------------------------------------------------ | -| `id` | `string` | Layer-derived | Stable container ID | -| `className` | `string` | None | Class added beside `ts-chart__polar` | -| `marks` | `readonly PolarMark[]` | Required | Radial marks rendered in order | -| `guides` | `readonly PolarGuide[]` | `[]` | Background/foreground guide layers around marks | -| `angle` | `PolarAngleOptions` | None | Angle factory or instance and optional wrapping | -| `radius` | `PolarRadiusOptions` | None | Radius scale and optional responsive pixel range | -| `startAngle` | `number` | `0` | Start of the available angular range in radians | -| `endAngle` | `number` | `2π` | End of the available angular range in radians | -| `inset` | `number` | `0` | Pixels removed from the maximum centered radius | -| `radiusRatio` | `number` | `1` | Multiplier applied to the radius after inset | +| Option | Type | Default | Meaning | +| ------------- | ----------------------- | ------------- | -------------------------------------------------------------- | +| `id` | `string` | Layer-derived | Stable container ID | +| `className` | `string` | None | Class added beside `ts-chart__polar` | +| `marks` | `readonly PolarMark[]` | Required | Radial marks rendered in order | +| `guides` | `readonly PolarGuide[]` | `[]` | Background/foreground guide layers around marks | +| `scales` | `PolarScales` | Required | Reserved `angle` and `radius` entries plus optional named ones | +| `startAngle` | `number` | `0` | Start of the available angular range in radians | +| `endAngle` | `number` | `2π` | End of the available angular range in radians | +| `inset` | `number` | `0` | Pixels removed from the maximum centered radius | +| `radiusRatio` | `number` | `1` | Multiplier applied to the radius after inset | +| `renderer` | `ChartMarkRenderer` | Host renderer | Renderer for the complete polar container | The default angular range is a complete circle. Angles use D3's radial convention: zero is at twelve o'clock and positive values move clockwise. -`PolarAngleOptions` and `PolarRadiusOptions` accept compatible factories with -mark-inferred domains or configured instances with fixed domains. `nice` -applies after inference. TanStack supplies responsive ranges without mutating -an instance. An omitted `wrap` closes a complete revolution without adding a -duplicate semantic category, but preserves both endpoints of a partial range. -Set it explicitly to override that behavior. +`scales.angle` and `scales.radius` accept compatible factories with +mark-inferred domains or configured instances with fixed domains. Use `null` +when no child mark uses that channel. `nice` applies after inference. TanStack +supplies responsive ranges without mutating an instance. An omitted `wrap` +closes a complete revolution without adding a duplicate semantic category, +but preserves both endpoints of a partial range. Set it explicitly to override +that behavior. `PolarRadiusOptions.range` overrides the default `[0, radius]` pixel range on the copied radius scale. Each endpoint is a nonnegative pixel length or a @@ -74,14 +75,49 @@ const radiusOptions = { The range is re-resolved on resize and never mutates the authored D3 scale. `PolarLayoutContext` contains `chart`, `centerX`, `centerY`, `radius`, -`startAngle`, `endAngle`, and optional resolved angle/radius scales. Each -`PolarResolvedScale` exposes its semantic `domain`, responsive `map`, `ticks`, -and `bandwidth`. A `PolarLength` is either a pixel length or a callback of the -layout context. Use a callback for radii that must remain proportional during -resize. +`startAngle`, `endAngle`, and resolved position scales under `scales`. Each +`PolarResolvedScale` exposes its `id`, `channel`, semantic `domain`, responsive +`map`, `ticks`, and `bandwidth`. A `PolarLength` is either a pixel length or a +callback of the layout context. Use a callback for radii that must remain +proportional during resize. -The outer chart omits `x` and `y`. Cartesian axes do not participate in the -internal polar scales. +Additional entries support independent angle or radius mappings. Declare the +entry's channel, then bind a radial mark to its ID: + +```ts +polar({ + scales: { + angle: { scale: categoryScale }, + radius: { scale: percentScale }, + target: { + channel: 'radius', + scale: targetScale, + }, + }, + marks: [ + radialLine(actual, { angle: 'metric', radius: 'value' }), + radialLine(target, { + angle: 'metric', + radius: 'value', + radiusScale: 'target', + }), + ], +}) +``` + +`radialLine`, `radialArea`, `radialDot`, `radialText`, `radialRule`, and both +radial bar marks use `angleScale` and `radiusScale`. Omitted bindings use the +reserved `angle` and `radius` entries. `radialArc` uses authored angular and +radial geometry instead of positional scale bindings. + +All radial mark option objects also accept `renderer?: ChartMarkRenderer`. +Use `canvasChartRenderer` to paint one radial mark with Canvas while sibling +marks and guide labels stay in SVG. Setting `renderer` on the outer `polar` +options moves the complete polar container, including its guides, to that +renderer. + +The outer chart uses `scales: { x: null, y: null }`. Cartesian axes do not +participate in the internal polar scales. ## `focusGroupAngle` @@ -227,11 +263,12 @@ Use the implicit physical-center radius baseline for nonnegative magnitudes. For signed values or true radial intervals, set `radius1: 0` (or another semantic baseline) so both endpoints map through the configured scale. -Both marks accept `id`, `className`, `key`, `z`, `color`, `fill`, fill opacity, -stroke styling, opacity, and motion. `cornerRadius` accepts a `PolarLength` or -`"full"`; the latter resolves to half the bar's radial thickness. Each valid -bar emits one geometry-backed interaction point at its quantitative endpoint -and preserves its interval endpoints for focus and tooltip formatting. +Both marks accept `id`, `className`, `angleScale`, `radiusScale`, `key`, `z`, +`color`, `fill`, fill opacity, stroke styling, opacity, and motion. +`cornerRadius` accepts a `PolarLength` or `"full"`; the latter resolves to half +the bar's radial thickness. Each valid bar emits one geometry-backed +interaction point at its quantitative endpoint and preserves its interval +endpoints for focus and tooltip formatting. ## `radialLine` and `radialArea` @@ -248,13 +285,14 @@ function radialArea( ``` Both marks use `angle` and `radius` channels and accept `id`, `className`, -`key`, `z`, `color`, and a D3 curve factory. The channels default to row index -and a numeric datum. `color` contributes to the chart color scale and defaults -to `z`. When `z` is omitted, an authored `color` also partitions the paths. -When both are present, `z` remains the explicit geometry and interaction -group. `radialLine` accepts final stroke, dash, opacity, and optional `points` -styling. `radialArea` accepts final fill and stroke styling plus `radius1` for -an explicit inner scale value; `radius1` defaults to zero. +`angleScale`, `radiusScale`, `key`, `z`, `color`, and a D3 curve factory. The +channels default to row index and a numeric datum. `color` contributes to the +chart color scale and defaults to `z`. When `z` is omitted, an authored +`color` also partitions the paths. When both are present, `z` remains the +explicit geometry and interaction group. `radialLine` accepts final stroke, +dash, opacity, and optional `points` styling. `radialArea` accepts final fill +and stroke styling plus `radius1` for an explicit inner scale value; `radius1` +defaults to zero. Their datum key defaults to a unique top-level or nested `data.id`, then a unique angle within each effective path group, then row index. @@ -274,10 +312,11 @@ function radialDot( ``` `radialDot` uses the same angle/radius channel defaults. It also accepts `id`, -`className`, `key`, `z`, `color`, `r`, `rScale`, fill, stroke, and opacity styling. -Radius defaults to 3.5 pixels. Each valid datum emits one interaction point -with its original angle/radius values and projected screen position. Its key -defaults to a unique top-level or nested `data.id`, then row index. +`className`, `angleScale`, `radiusScale`, `key`, `z`, `color`, `r`, `rScale`, +fill, stroke, and opacity styling. Radius defaults to 3.5 pixels. Each valid +datum emits one interaction point with its original angle/radius values and +projected screen position. Its key defaults to a unique top-level or nested +`data.id`, then row index. ## `radialText` @@ -290,8 +329,9 @@ function radialText( `radialText` maps `angle` and `radius` channels through the container's copied polar scales, then positions labels with D3's radial point projection. It -accepts `text`, `key`, `z`, `color`, fill, font size and weight, anchor, baseline, -rotation, and pixel `dx`/`dy`. `radiusOffset` is a signed constant or per-datum +accepts `angleScale`, `radiusScale`, `text`, `key`, `z`, `color`, fill, font +size and weight, anchor, baseline, rotation, and pixel `dx`/`dy`. +`radiusOffset` is a signed constant or per-datum visual channel applied in pixels after the semantic radius is mapped. It does not contribute to the radius domain. Set `anchor: "outside"` to resolve `start`, `middle`, or `end` from the final mapped angle. Exact and near-exact @@ -317,12 +357,12 @@ function radialRule( `radius1Offset` and `radius2Offset` as signed constant or per-datum pixel visual channels applied after the corresponding semantic radius is mapped. Offsets never contribute to radius-domain inference. A nonfinite resolved -endpoint offset omits that segment. The mark also accepts `key`, `z`, `color`, -stroke, opacity, width, and dash styling. It covers gauge needles, ticks, and -pie-label leaders without expanding one logical segment into two path rows. -Rules remain decorative and emit no interaction points. Its key defaults to a -unique top-level or nested `data.id`, then a unique angle within each `z` -group, then row index. +endpoint offset omits that segment. The mark also accepts `angleScale`, +`radiusScale`, `key`, `z`, `color`, stroke, opacity, width, and dash styling. +It covers gauge needles, ticks, and pie-label leaders without expanding one +logical segment into two path rows. Rules remain decorative and emit no +interaction points. Its key defaults to a unique top-level or nested +`data.id`, then a unique angle within each `z` group, then row index. Pixel offsets do not reserve space outside the polar radius. Use `radiusRatio`, `inset`, or chart margins when labels or leaders must remain @@ -335,15 +375,17 @@ function radialGrid(options?: RadialGridOptions): PolarGuide function angleGrid(options?: AngleGridOptions): PolarGuide ``` -`radialGrid` draws radius values as circles or polygons. Supply explicit -`values`, or let `ticks` request values from the configured radius scale. -Labels are off by default. Label angle, offset, rotation, format, fill, and -font size are configurable. Ring `fill` and `fillOpacity` can layer filled -circle or polygon grids behind the chart marks. - -`angleGrid` draws spokes for explicit `values` or the configured angle domain. -It can show labels around the circumference with `format` and `labelOffset`. -Labels are on by default and use the same outside-anchor rule as +`radialGrid` draws radius values as circles or polygons. Its `scale` option +selects a named radius scale, and `angleScale` selects the angle scale used for +polygon rings. Supply explicit `values`, or let `ticks` request values from the +selected radius scale. Labels are off by default. Label angle, offset, +rotation, format, fill, and font size are configurable. Ring `fill` and +`fillOpacity` can layer filled circle or polygon grids behind the chart marks. + +`angleGrid` draws spokes for explicit `values` or the selected angle domain. +Its `scale` option selects a named angle scale. It can show labels around the +circumference with `format` and `labelOffset`. Labels are on by default and +use the same outside-anchor rule as `radialText({ anchor: "outside" })` unless `labelAnchor` is supplied. Both guides accept ID, class, stroke, opacity, width, and dash styling. @@ -369,7 +411,8 @@ built-in grids put rings and spokes in `background` and labels in `foreground`, keeping labels legible without painting grid geometry over the data. -The exported option contracts are `PolarOptions`, `RadialArcOptions`, +The exported option contracts are `PolarOptions`, `PolarScales`, +`PolarPositionChannel`, `PolarPositionScaleOptions`, `RadialArcOptions`, `RadialBarRadiusOptions`, `RadialBarAngleOptions`, `RadialLineOptions`, `RadialAreaOptions`, `RadialDotOptions`, `RadialTextOptions`, `RadialRuleOptions`, `RadialGridOptions`, and `AngleGridOptions`. The coordinate contracts are `PolarAngleOptions`, diff --git a/packages/charts-core/docs/reference/marks/ridgeline.md b/packages/charts-core/docs/reference/marks/ridgeline.md index ffd40f48..403ec5c5 100644 --- a/packages/charts-core/docs/reference/marks/ridgeline.md +++ b/packages/charts-core/docs/reference/marks/ridgeline.md @@ -52,10 +52,12 @@ defineChart({ color: 'season', }), ], - x: { scale: scaleLinear().domain([4, 10]) }, - y: { - scale: scalePoint().domain(seasons).padding(0.78), - reverse: true, + scales: { + x: { scale: scaleLinear().domain([4, 10]) }, + y: { + scale: scalePoint().domain(seasons).padding(0.78), + reverse: true, + }, }, }) ``` diff --git a/packages/charts-core/docs/reference/marks/sankey.md b/packages/charts-core/docs/reference/marks/sankey.md index 43a897ca..420e3457 100644 --- a/packages/charts-core/docs/reference/marks/sankey.md +++ b/packages/charts-core/docs/reference/marks/sankey.md @@ -53,6 +53,10 @@ const chart = defineChart({ ] as const, }), ], + scales: { + x: null, + y: null, + }, guides: false, }) ``` diff --git a/packages/charts-core/docs/reference/marks/sunburst.md b/packages/charts-core/docs/reference/marks/sunburst.md index bea5841d..73e857ee 100644 --- a/packages/charts-core/docs/reference/marks/sunburst.md +++ b/packages/charts-core/docs/reference/marks/sunburst.md @@ -30,8 +30,16 @@ const chart = defineChart({ stroke: '#fff', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, }) ``` @@ -140,8 +148,16 @@ const definition = (rootId: string) => visibleDepth: 2, }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, motion: { transition: { type: 'tween', duration: 720, easing: 'ease-in-out' }, }, diff --git a/packages/charts-core/docs/reference/marks/text-frame-and-facet.md b/packages/charts-core/docs/reference/marks/text-frame-and-facet.md index fcef73db..010a1bb0 100644 --- a/packages/charts-core/docs/reference/marks/text-frame-and-facet.md +++ b/packages/charts-core/docs/reference/marks/text-frame-and-facet.md @@ -104,8 +104,10 @@ facet(rows, { chart(groupRows) { return { marks: [lineY(groupRows, { x: 'date', y: 'value' })], - x: { scale: makeXScale(groupRows) }, - y: { scale: makeYScale(groupRows), grid: true }, + scales: { + x: { scale: makeXScale(groupRows) }, + y: { scale: makeYScale(groupRows), grid: true }, + }, } }, }) diff --git a/packages/charts-core/docs/reference/marks/violin.md b/packages/charts-core/docs/reference/marks/violin.md index 54b882f4..30aa5ab6 100644 --- a/packages/charts-core/docs/reference/marks/violin.md +++ b/packages/charts-core/docs/reference/marks/violin.md @@ -63,10 +63,12 @@ defineChart({ }), dot(summaries, { x: 'species', y: 'median' }), ], - x: { - scale: scalePoint().domain(species).padding(0.5), + scales: { + x: { + scale: scalePoint().domain(species).padding(0.5), + }, + y: { scale: scaleLinear }, }, - y: { scale: scaleLinear }, }) ``` diff --git a/packages/charts-core/docs/reference/motion.md b/packages/charts-core/docs/reference/motion.md index 76586f3a..3a32ac60 100644 --- a/packages/charts-core/docs/reference/motion.md +++ b/packages/charts-core/docs/reference/motion.md @@ -81,7 +81,8 @@ const host = mountChartRenderer(container, { ``` React and Octane applications use their `/core` component entry and pass the -same renderer. Other adapters currently expose their default SVG surface. +same renderer. Other adapters start with their default SVG renderer, and a +definition may still select Canvas for individual marks. ## Definition-local motion @@ -89,6 +90,11 @@ same renderer. Other adapters currently expose their default SVG surface. or axis label is inert policy. The optional renderer consumes it. Definitions remain valid for static SVG and Canvas, which paint the final state. +This stays true in a mixed chart. A mark that selects `canvasChartRenderer` +keeps its authored motion option, but the Canvas layer paints the final scene +instead of running the tween or spring policy. The optional `motion()` renderer +still animates the default-renderer layers it owns. + ```ts import { scaleBand } from '@tanstack/charts/scales/band' import { scaleLinear } from '@tanstack/charts/scales/linear' @@ -116,14 +122,16 @@ const definition = defineChart({ }, }), ], - x: { - scale: scaleBand, - axis: { - ticks: { motion: { transition: { type: 'tween', duration: 180 } } }, - tickLabels: { motion: { delay: 40 } }, + scales: { + x: { + scale: scaleBand, + axis: { + ticks: { motion: { transition: { type: 'tween', duration: 180 } } }, + tickLabels: { motion: { delay: 40 } }, + }, }, + y: { scale: scaleLinear }, }, - y: { scale: scaleLinear }, }) ``` @@ -169,6 +177,10 @@ const definition = defineChart({ ...stagger({ each: 35, by: 'series', roles: ['arc', 'bar'] }), }, marks, + scales: { + x: null, + y: null, + }, }) ``` @@ -271,8 +283,11 @@ const definition = defineChart({ transition: { type: 'tween', duration: 800, easing: 'linear' }, }, marks: [lineY(rows, { x: 'time', y: 'value', key: 'id' })], - x: { scale: scaleUtc().domain([visibleStart, visibleEnd]) }, - y: { scale: scaleLinear().domain([0, 100]) }, + scales: { + x: { scale: scaleUtc().domain([visibleStart, visibleEnd]) }, + y: { scale: scaleLinear().domain([0, 100]) }, + }, + clip: true, }) ``` diff --git a/packages/charts-core/docs/reference/rendering-and-export.md b/packages/charts-core/docs/reference/rendering-and-export.md index 01bd612d..e0cb4e0b 100644 --- a/packages/charts-core/docs/reference/rendering-and-export.md +++ b/packages/charts-core/docs/reference/rendering-and-export.md @@ -41,6 +41,11 @@ for it only when they import a Canvas entry. Motion is likewise isolated behind `@tanstack/charts/motion`. The `/core` adapter entries accept an explicit `renderer` without choosing one for the application. +A default SVG host can still compose selected Canvas marks. Import +`canvasChartRenderer` from `@tanstack/charts/canvas` and attach it to those +marks. The default adapter remains the host, and the Canvas painter enters the +module graph through that explicit import. + ## React Native adapter The React Native entry selects its native build through the package export @@ -162,7 +167,7 @@ interface CanvasChartRenderer< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> extends ChartRenderer { +> extends ChartLayerRenderer { mount: ( container: HTMLElement, requestRender: (force?: boolean) => void, @@ -456,12 +461,99 @@ The promise rejects when any requirement fails or Canvas encoding returns no blob. `downloadChartImage` defaults to `chart.png`; keep the filename extension consistent with the selected MIME type. -The raster helpers accept a mounted SVG or Canvas chart root, or an ancestor -containing one. SVG is serialized, decoded, and drawn into the export canvas. -Canvas uses the stable `canvas` base bitmap directly when focus is excluded. -With `includeFocus`, it composites `backgroundCanvas`, `focusUnderCanvas`, -`sceneCanvas`, and `focusCanvas` in that order. `serializeChartSvg` and -`downloadChartSvg` remain SVG-only. +The raster helpers accept a mounted SVG, Canvas, or mixed chart root, or an +ancestor containing one. SVG is serialized, decoded, and drawn into the export +canvas. Canvas uses the stable `canvas` base bitmap directly when focus is +excluded. With `includeFocus`, it composites `backgroundCanvas`, +`focusUnderCanvas`, `sceneCanvas`, and `focusCanvas` in that order. Mixed roots +draw every child surface in visual order. `serializeChartSvg` and +`downloadChartSvg` reject mixed roots and remain SVG-only. + +## Mark-level renderers + +Built-in Cartesian, radial, and composite marks accept a `renderer` option. +Passing `canvasChartRenderer` opts that mark into Canvas while axes, guides, +and marks without the option keep the host renderer: + +```ts +import { areaY, defineChart, lineY, text } from '@tanstack/charts' +import { canvasChartRenderer } from '@tanstack/charts/canvas' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { scaleUtc } from 'd3-scale' + +const definition = defineChart({ + marks: [ + areaY(denseRows, { + x: 'time', + y: 'value', + renderer: canvasChartRenderer, + }), + lineY(summaryRows, { x: 'time', y: 'value' }), + text(labels, { x: 'time', y: 'value', text: 'label' }), + ], + scales: { + x: { scale: scaleUtc }, + y: { scale: scaleLinear }, + }, +}) +``` + +Adjacent nodes with the same renderer share a layer. Alternating renderer runs +remain in declaration order, including nested runs inside facets, composites, +and `polar`. Server output contains one accessible mixed root with +deterministic child shells, and the browser adopts those shells. Direct +`renderChartSvg(scene, options)` calls remain an explicit SVG serialization of +the complete renderer-neutral scene and do not run surface composition. + +The universal mark contract stays small: + +```ts +interface ChartMarkRenderer { + readonly kind: 'chart-layer-renderer' + readonly id: string +} + +interface ChartMarkOptions { + renderer?: ChartMarkRenderer +} +``` + +A renderer that mounts one DOM layer implements the complete composition +contract: + +```ts +interface ChartLayerRenderer< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> + extends ChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} + +interface UniversalChartLayerRenderer + extends UniversalChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} +``` + +The first non-default mark renderer supplies the compositor. The built-in +Canvas renderer can compose normal `ChartRenderer` surfaces and exposes the +result as one `ChartSurface`. + +Mark motion and renderer selection are independent in the authoring API. The +built-in factories preserve both options, and `createMark` keeps motion as its +second argument and accepts the renderer as its third. The optional +`motion()` renderer consumes tween and spring policy only on layers it owns. +`canvasChartRenderer` paints the final mark scene and does not interpret that +policy. A host `svgAnimation` can still crossfade complete Canvas frames while +SVG layers reconcile keyed elements. Changing the layer renderer sequence +remounts the composition, so that structural update does not animate between +the old and new surface layout. ## Custom renderers @@ -480,6 +572,8 @@ interface ChartSurface< > { readonly renderer: ChartRenderer readonly element: Element + readonly layers?: readonly ChartSurface[] + readonly defaultElement?: Element render: ( scene: ChartScene, options: ChartSurfaceRenderOptions, @@ -531,6 +625,27 @@ interface ChartRendererRenderContext< } ``` +`element` is the one accessible, interactive root. `layers`, when present, +lists child surfaces from back to front. `defaultElement` is the topmost +surface element owned by the host's default renderer. SVG-oriented +`ChartRenderContext` callbacks expose that element as `svg` and also include +the complete renderer-neutral `surface`. `ChartRendererRenderContext` exposes +the complete surface directly. + +```ts +interface ChartRenderContext< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> { + container: HTMLElement + svg: SVGSVGElement + scene: ChartScene + surface: ChartSurface + interaction: ChartInteractionController +} +``` + | Member | Responsibility | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `ChartRenderer.id` | Stable renderer identifier | @@ -539,6 +654,8 @@ interface ChartRendererRenderContext< | `mount()` | Adopt or create a surface in the container and connect renderer-owned environment observers | | `ChartSurface.renderer` | Refer to the renderer that created the surface; a different renderer object on update replaces the surface | | `ChartSurface.element` | Expose the accessible, focusable root used by shared keyboard and focus handling | +| `ChartSurface.layers` | Expose ordered child surfaces when this surface composes more than one renderer | +| `ChartSurface.defaultElement` | Expose the topmost element owned by the host's default renderer | | `render()` | Paint the complete scene and apply accessible name, class, tab index, ID prefix, and optional animation | | `clientToScene()` | Optionally convert viewport client coordinates to scene coordinates; the controller returns `null` when omitted or unavailable | | `getPresentationPoints()` | Expose renderer-owned point geometry while a scene transition is active | @@ -602,6 +719,14 @@ Octane `/core` entries, to mount a custom renderer. `RenderChartOptions`, `ChartRendererHostOptions`, and `ChartRendererHost` describe the complete boundary. +Import `resolveChartRenderer` from `@tanstack/charts/renderer` when implementing +a host outside the shared adapters. The function returns the effective renderer +for prerendering or mounting a scene. It returns the default when every node +uses that renderer. Otherwise it asks the first non-default +`ChartLayerRenderer` to compose the ordered layers with the default renderer. +The shared DOM host and `createChartRendererAdapter` perform this resolution +automatically. + SVG remains available as a renderer implementation: ```ts 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 3ffb9db4..1e3f5126 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -9,6 +9,16 @@ Charts copies the resolved scale, assigns its responsive pixel range, and uses that copy for marks, ticks, and interaction. A supplied instance is never mutated. +Put positional mappings in the chart's `scales` registry. The reserved `x` +and `y` entries are the default bindings for Cartesian marks: + +```ts +const scales = { + x: { scale: scaleBand, axis: { label: 'Product' } }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Revenue' } }, +} +``` + Start with the exact compact scale entry for numeric linear, band, point, or ordinal mappings. Upgrade one mapping to D3 only when it needs temporal, nonlinear, radial, interpolated, or statistical scale semantics. The @@ -147,6 +157,13 @@ Scale copies make one configured scale safe to reuse across responsive scenes. ## Axis options ```ts +interface ChartPositionScaleOptions< + TValue extends ChartValue, +> extends ChartAxisOptions { + channel?: 'x' | 'y' + side?: 'top' | 'right' | 'bottom' | 'left' +} + interface ChartAxisOptions { scale: ChartScale | ChartScaleInput nice?: boolean | number @@ -216,14 +233,16 @@ interface ChartAxisOptions { } ``` -| Option | Default | Meaning | -| ---------- | --------------------------- | ------------------------------------------------------------------------ | -| `scale` | Required | Compact or D3 factory, configured instance, or custom `ChartScale`. | -| `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | -| `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | -| `viewport` | None | Commits a continuous semantic window and optional transient translation. | -| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | -| `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | +| Option | Default | Meaning | +| ---------- | ---------------------------- | ------------------------------------------------------------------------ | +| `scale` | Required | Compact or D3 factory, configured instance, or custom `ChartScale`. | +| `nice` | `false` | Nice the resolved domain using the responsive or supplied tick count. | +| `reverse` | `false` | Reverses the responsive pixel range without changing the caller's scale. | +| `viewport` | None | Commits a continuous semantic window and optional transient translation. | +| `grid` | `false` for x; `true` for y | Draws grid rules from semantic tick candidates. | +| `axis` | Inferred axis | Axis line, tick candidates, labels, and title; `false` hides the axis. | +| `channel` | Inferred for `x` and `y` | Required on named scales; selects the Cartesian channel and range. | +| `side` | `bottom` for x; `left` for y | Places an x axis on top/bottom or a y axis on left/right. | ```ts type ChartContinuousValue = number | Date @@ -257,9 +276,9 @@ domain order or `reverse`. Guides remain fixed. Viewport-dependent marks are clipped and translated per mark and per owned axis; see [Continuous viewports](../concepts/layout-axes-and-coordinates.md#continuous-viewports). -Set an axis to `null` only when no mark uses that positional scale. To keep the -scale while hiding its axis, use `axis: false`. Grid visibility remains -independent. +Set `scales.x` or `scales.y` to `null` only when no mark uses that positional +scale. To keep the scale while hiding its axis, use `axis: false`. Grid +visibility remains independent. Without an explicit `axis.ticks` policy, the responsive target is `clamp(2, floor(chart.width / 92), 8)` for x and @@ -330,6 +349,51 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +## Named scales and multiple axes + +The reserved `x` and `y` entries are the default bindings for Cartesian marks. +Add another entry when a mark needs an independent mapping, then bind that mark +with `xScale` or `yScale`: + +```ts +const chart = defineChart({ + marks: [ + lineY(revenue, { x: 'date', y: 'value' }), + lineY(conversion, { + x: 'date', + y: 'rate', + yScale: 'conversion', + }), + ], + scales: { + x: { scale: scaleUtc }, + y: { + scale: scaleLinear, + grid: true, + axis: { label: 'Revenue' }, + }, + conversion: { + channel: 'y', + scale: scaleLinear, + side: 'right', + axis: { + label: 'Conversion', + ticks: { format: (value: number) => `${Math.round(value * 100)}%` }, + }, + }, + }, +}) +``` + +A named scale must declare `channel: 'x'` or `channel: 'y'`. Its mark binding +must use the same channel. The ID `color` is reserved for the shared visual +color scale and cannot name a Cartesian position scale. + +Every non-null scale renders an axis by default. Set `axis: false` when a +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. + ## Automatic guide layout When a margin side is omitted, the scene compiler measures: diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index e579ba1e..4fd27c07 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -81,8 +81,8 @@ not produce an unusably narrow scale or callback type. source datum → mark channel outputs → ChartMark point and scale value types - → ChartSpec axis requirements and definition datum/x/y unions - → axis scale and formatter types + → ChartSpec scale registry and definition datum/x/y unions + → scale and formatter types → host and adapter callback types ``` @@ -90,10 +90,10 @@ Marks in one chart may have different datum types. The definition exposes their union. TypeScript narrowing is therefore required when a callback handles heterogeneous layers. -`ChartMarkScaleX` and `ChartMarkScaleY` also control the chart shape. A -materialized scale value makes that axis required. `never` makes it optional -and null-only, so positionless and one-dimensional charts do not carry phantom -scale configuration. +`ChartMarkScaleX` and `ChartMarkScaleY` control the value types accepted by the +reserved scale entries. Canonical definitions always include `scales.x` and +`scales.y`; use `null` when a dimension is unused. A mark bound to another +scale ID does not widen the reserved entry's value type. Rect and custom interval marks can distinguish materialized scale values from interaction point values. The exported extractors are: @@ -129,7 +129,7 @@ style vocabulary; `ChartDotStateStyle`, `ChartBarStateStyle`, | Type | Purpose | | ------------------------------------- | ----------------------------------------------------------------------------- | -| `ChartSpec` | Marks plus conditionally required axes, guides, color, resources, and layout | +| `ChartSpec` | Marks plus a positional scale registry, guides, color, resources, and layout | | `StaticChartDefinition` | A directly compilable spec with inferred datum and semantic x/y phantom types | | `ResponsiveChartDefinition` | Responsive chart builder | | `ChartDefinition` | Static or responsive union | @@ -145,34 +145,38 @@ The complete overloads and runtime rules are in ## Marks and scenes -| Type | Purpose | -| ---------------------------------- | ----------------------------------------------------------------------------- | -| `ChartMark` | Public initialized-mark factory plus inferred point and scale types | -| `MarkInitializeContext` | Mark layer index | -| `InitializedMark` | Stable ID, channels, viewport ownership, render, and optional resolved layout | -| `MarkInitialization` | Direct-render or resolved-layout initializer result | -| `ResolvedLayoutMarkInitialization` | Layout initializer before `createMark` normalization | -| `MaterializedChannel` | Values contributed to an optional named scale | -| `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | -| `MarkResolvedLayoutContext` | Final positional scales and bounds for pure mark-local layout | -| `ResolvedMarkLayout` | Final channels, labels, states, and render closure from resolved layout | -| `MarkScene` | Mark-owned nodes plus optional interaction points, focus anchors, and guides | -| `MarkFocusGuide` | Mark-emitted focus guide with optional placement | -| `ChartScene` | Complete renderer-neutral output | -| `ChartPoint` | Typed interaction target | -| `ChartFocusAnchor` | Focus-filter identity that does not participate in hit testing | -| `SceneFocusGuide` | Data-less guide descriptor resolved against focus or cursor state | -| `SceneFocusGuideAxis` | One crosshair axis rule or categorical band, plus an optional label | -| `SceneFocusGuideBand` | Resolved categorical bandwidth, inset, radius, and paint | -| `SceneFocusGuideLabel` | Focus-guide label formatter, spacing, font, and paint | -| `SceneFocusGuideMarker` | Focus-guide intersection marker geometry and paint | -| `SceneFocusGuideResolveContext` | Scene, guide, local focus, pointer, and cursor passed to a guide resolver | -| `SceneFocusGuideResolver` | Optional-guide policy that returns one transient scene node | -| `ChartFocusPresentation` | Transient renderer-neutral underlay and overlay nodes | -| `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | -| `ChartTick` | Semantic value, formatted label, and pixel position | -| `ResolvedScale` | Final positional scale | -| `ResolvedColorScale` | Final color scale | +| Type | Purpose | +| ---------------------------------- | ------------------------------------------------------------------------------------ | +| `ChartMark` | Public initialized-mark factory plus inferred point, scale value, and scale ID types | +| `ChartMarkOptions` | Shared optional mark renderer contract | +| `ChartMarkRenderer` | Universal renderer-selection token stored on mark-owned scene nodes | +| `CartesianChartMark` | Cartesian mark alias that derives selected x and y scale IDs from its options | +| `OptionScaleId` | Resolves an optional named-scale selector to its fallback scale ID | +| `MarkInitializeContext` | Mark layer index | +| `InitializedMark` | Stable ID, channels, viewport ownership, render, and optional resolved layout | +| `MarkInitialization` | Direct-render or resolved-layout initializer result | +| `ResolvedLayoutMarkInitialization` | Layout initializer before `createMark` normalization | +| `MaterializedChannel` | Values contributed to an optional named scale | +| `MarkRenderContext` | Final chart bounds, scales, theme, color resolver, and layout | +| `MarkResolvedLayoutContext` | Final positional scales and bounds for pure mark-local layout | +| `ResolvedMarkLayout` | Final channels, labels, states, and render closure from resolved layout | +| `MarkScene` | Mark-owned nodes plus optional interaction points, focus anchors, and guides | +| `MarkFocusGuide` | Mark-emitted focus guide with optional placement | +| `ChartScene` | Complete renderer-neutral output | +| `ChartPoint` | Typed interaction target | +| `ChartFocusAnchor` | Focus-filter identity that does not participate in hit testing | +| `SceneFocusGuide` | Data-less guide descriptor resolved against focus or cursor state | +| `SceneFocusGuideAxis` | One crosshair axis rule or categorical band, plus an optional label | +| `SceneFocusGuideBand` | Resolved categorical bandwidth, inset, radius, and paint | +| `SceneFocusGuideLabel` | Focus-guide label formatter, spacing, font, and paint | +| `SceneFocusGuideMarker` | Focus-guide intersection marker geometry and paint | +| `SceneFocusGuideResolveContext` | Scene, guide, local focus, pointer, and cursor passed to a guide resolver | +| `SceneFocusGuideResolver` | Optional-guide policy that returns one transient scene node | +| `ChartFocusPresentation` | Transient renderer-neutral underlay and overlay nodes | +| `SceneInteraction` | Semantic point or points attached to a rendered scene primitive | +| `ChartTick` | Semantic value, formatted label, and pixel position | +| `ResolvedScale` | Final positional scale | +| `ResolvedColorScale` | Final color scale | Scene geometry and interaction point fields are documented in [Runtime and scene](./runtime-and-scene.md). @@ -203,7 +207,12 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | Type | Purpose | | ------------------------------- | --------------------------------------------------------------------- | -| `ChartAxisOptions` | Required positional scale and optional guide behavior | +| `ChartScales` | Reserved x/y entries plus optional named positional scales | +| `ChartPositionScaleOptions` | One registry entry, its Cartesian channel, side, and axis behavior | +| `ChartAxisOptions` | Positional scale and optional axis behavior | +| `ChartPositionChannel` | Cartesian registry channel, `x` or `y` | +| `ChartAxisSide` | Cartesian axis side | +| `CartesianScaleBindings` | Optional mark bindings to named `xScale` and `yScale` entries | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | | `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | @@ -255,18 +264,18 @@ scale bandwidth; zero-bandwidth axes emit no band. ## Host and runtime types -| Type | Purpose | -| -------------------------------- | ------------------------------------------------------------------- | -| `ChartHostCommonOptions` | Accessibility, sizing, callbacks, and SVG renderer options | -| `ChartHostOptions` | Common options plus a chart definition | -| `ChartHost` | SVG host `interaction`, `update`, `getScene`, and `destroy` | -| `ChartRendererHostCommonOptions` | Renderer-neutral common options plus required renderer | -| `ChartRendererHostOptions` | Renderer-neutral options plus a chart definition | -| `ChartRendererHost` | Renderer-neutral `interaction`, `update`, `getScene`, and `destroy` | -| `ChartRuntime` | Repeated static or responsive scene rendering | -| `ChartRuntimeOptions` | Platform theme shared by responsive building and scene compilation | -| `ChartRenderContext` | Container, live SVG, scene, and interaction controller | -| `ChartRendererRenderContext` | Container, live surface, scene, and interaction controller | +| Type | Purpose | +| -------------------------------- | -------------------------------------------------------------------------------- | +| `ChartHostCommonOptions` | Accessibility, sizing, callbacks, and SVG renderer options | +| `ChartHostOptions` | Common options plus a chart definition | +| `ChartHost` | SVG host `interaction`, `update`, `getScene`, and `destroy` | +| `ChartRendererHostCommonOptions` | Renderer-neutral common options plus required renderer | +| `ChartRendererHostOptions` | Renderer-neutral options plus a chart definition | +| `ChartRendererHost` | Renderer-neutral `interaction`, `update`, `getScene`, and `destroy` | +| `ChartRuntime` | Repeated static or responsive scene rendering | +| `ChartRuntimeOptions` | Platform theme shared by responsive building and scene compilation | +| `ChartRenderContext` | Container, live default SVG, complete surface, scene, and interaction controller | +| `ChartRendererRenderContext` | Container, live surface, scene, and interaction controller | See [DOM host](./dom-host.md) and [Runtime and scene](./runtime-and-scene.md). @@ -373,8 +382,10 @@ See [Focus and interaction](./focus-and-interaction.md). | `RenderChartOptions` | Renderer-neutral accessible name, description, class, tab index, and ID prefix | | `RenderChartSvgOptions` | SVG specialization of `RenderChartOptions` | | `ChartSurfaceRenderOptions` | Render options plus optional animation | -| `ChartSurface` | Mounted element, painting, coordinates, focus, and cleanup | +| `ChartSurface` | Mounted root, optional child layers, painting, coordinates, focus, and cleanup | | `ChartRenderer` | Server shell and browser-surface renderer contract | +| `ChartLayerRenderer` | DOM renderer that can compose itself with the host renderer | +| `UniversalChartLayerRenderer` | Definition-agnostic form of `ChartLayerRenderer` | | `ChartRendererCapabilities` | Optional structural services supplied by a renderer | | `ChartRendererTooltipMotionCapability` | Versioned factory for an injected tooltip motion controller | | `ChartTooltipMotionController` | Tooltip paint, hide, and cleanup motion lifecycle | @@ -451,8 +462,9 @@ their behavior: - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). -- `@tanstack/charts/polar`: `PolarOptions`, `PolarMark`, `PolarGuide`, - `PolarGuideScene`, `PolarAngleOptions`, `PolarRadiusOptions`, +- `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, + `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, + `PolarRadiusOptions`, `PolarPositionChannel`, `PolarPositionScaleOptions`, `PolarResolvedScale`, `PolarLayoutContext`, `PolarLength`, `PolarGuideLabelContext`, `PolarGuideLabelOption`, `RadialArcOptions`, `RadialBarRadiusOptions`, `RadialBarAngleOptions`, `RadialLineOptions`, @@ -463,7 +475,8 @@ their behavior: ## Mark option types Every built-in mark exports its options type from the root and its granular -subpath: +subpath. Cartesian, radial, and composite option types include the shared +`ChartMarkOptions.renderer` selection: - `LineYOptions`, `AreaYOptions`, `AreaXOptions`, `AreaXCurve` - `BarYOptions`, `BarXOptions` diff --git a/packages/charts-core/docs/reference/view-composition.md b/packages/charts-core/docs/reference/view-composition.md index 718e2aec..634e1ced 100644 --- a/packages/charts-core/docs/reference/view-composition.md +++ b/packages/charts-core/docs/reference/view-composition.md @@ -59,8 +59,16 @@ const summaryDefinition = defineChart({ key: 'status', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, guides: false, margin: 0, }) diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index cb2b658c..10b9a3bf 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -112,10 +112,13 @@ Authoring rules: - Start with exact @tanstack/charts/scales/linear, @tanstack/charts/scales/band, @tanstack/charts/scales/point, or @tanstack/charts/scales/ordinal entries. Upgrade only the mapping that needs temporal, nonlinear, radial, interpolated, statistical, or other complete D3 semantics; never import the d3 umbrella. - Treat Date values on compact band or point scales as equally spaced categories. Use d3-scale scaleTime or scaleUtc when elapsed-time spacing or calendar-aware ticks matter. - Let TanStack Charts own responsive pixel ranges. Scale factories infer domains from mark channels; configured instances preserve application-owned domains. +- Put Cartesian scale and guide options under scales.x and scales.y; never generate deprecated root x or y properties. +- Put polar position scales under scales.angle and scales.radius inside polar(); never generate deprecated direct angle or radius properties. - Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model. - Memoize the complete definition against captured application values; definition identity is the application update boundary. - Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable. - Prefer built-in marks, then composition, then a custom mark or application-owned overlay. +- Keep the default SVG renderer unless a measured paint-heavy mark benefits from Canvas. Import canvasChartRenderer from @tanstack/charts/canvas and set only that mark's renderer option when mixing surfaces. - Treat docs/concepts/scales-and-d3.md as the sole scale-selection and D3 integration contract and follow its official D3 links for D3 API details. - Reference a canonical catalog case with ``. The documentation site resolves its source-backed workspace; never author a catalog iframe or duplicate the case source in a docs page. - Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript. diff --git a/packages/charts-core/src/adapter-renderer.ts b/packages/charts-core/src/adapter-renderer.ts index b736cd9f..8652d0d8 100644 --- a/packages/charts-core/src/adapter-renderer.ts +++ b/packages/charts-core/src/adapter-renderer.ts @@ -1,4 +1,4 @@ -import { mountChartRenderer } from './renderer' +import { mountChartRenderer, resolveChartRenderer } from './renderer' import { createChartRuntime } from './runtime' import { resolveChartAdapterLayout, @@ -38,7 +38,7 @@ export function createChartRendererAdapter< }, { measureText: options.measureText }, ) - return options.renderer.prerender(scene, { + return resolveChartRenderer(scene, options.renderer).prerender(scene, { ariaLabel: options.ariaLabel, ariaDescription: options.ariaDescription, className: options.className, diff --git a/packages/charts-core/src/adapter.ts b/packages/charts-core/src/adapter.ts index 622091d7..28968d7e 100644 --- a/packages/charts-core/src/adapter.ts +++ b/packages/charts-core/src/adapter.ts @@ -2,6 +2,7 @@ import { mountChart } from './dom' import { createChartRuntime } from './runtime' import { renderChartSvg } from './svg' import { createSvgChartRenderer } from './svg-surface' +import { resolveChartRenderer } from './renderer' import { resolveChartAdapterLayout, resolveChartHostTabIndex, @@ -50,7 +51,7 @@ export function createChartAdapter< const renderer = createSvgChartRenderer( options.renderSvg ?? renderChartSvg, ) - return renderer.prerender(scene, { + return resolveChartRenderer(scene, renderer).prerender(scene, { ariaLabel: options.ariaLabel, ariaDescription: options.ariaDescription, className: options.className, diff --git a/packages/charts-core/src/area-x.ts b/packages/charts-core/src/area-x.ts index cd3bcfd7..c0f50750 100644 --- a/packages/charts-core/src/area-x.ts +++ b/packages/charts-core/src/area-x.ts @@ -12,6 +12,8 @@ import { valueKey } from './scales' import { stackValues } from './stack-internal' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -32,7 +34,8 @@ export interface AreaXCurve { ) => string } -export interface AreaXOptions extends ChartMarkMotionOptions { +export interface AreaXOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel x1?: number | Channel @@ -59,190 +62,208 @@ export function areaX< >( source: Iterable, options: TOptions, -): ChartMark> +): CartesianChartMark< + TDatum, + number, + OptionChannelOutput, + number, + OptionChannelOutput, + TOptions +> export function areaX( source: Iterable, options: AreaXOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `area-x-${markIndex}` - const rawX = options.x ?? options.x2 - const rawXValues = - typeof rawX === 'number' - ? data.map(() => rawX) - : channelValues(data, rawX, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const yValues = channelValues(data, options.y, (_datum, { index }) => index) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const groupValues = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const explicitExtent = options.x1 !== undefined || options.x2 !== undefined - if (explicitExtent && options.layout) { - throw new TypeError( - 'An area with explicit x1 or x2 endpoints cannot also configure a stack layout', + return createMark( + ({ markIndex }) => { + const id = options.id ?? `area-x-${markIndex}` + const rawX = options.x ?? options.x2 + const rawXValues = + typeof rawX === 'number' + ? data.map(() => rawX) + : channelValues(data, rawX, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const yValues = channelValues( + data, + options.y, + (_datum, { index }) => index, ) - } - const stacked = explicitExtent - ? undefined - : stackValues(yValues, rawXValues, groupValues, options.layout) - const x1Values = explicitExtent - ? typeof options.x1 === 'number' - ? data.map(() => options.x1 as number) - : channelValues(data, options.x1, () => 0) - : stacked!.starts - const x2Values = explicitExtent - ? typeof options.x2 === 'number' - ? data.map(() => options.x2 as number) - : channelValues(data, options.x2 ?? options.x, () => undefined) - : stacked!.ends - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [yValues], - markId: id, - warningIdentity: options, - }) - const groups = new Map() - groupValues.forEach((value, index) => { - const key = valueKey(value ?? null) - const group = groups.get(key) - if (group) group.push(index) - else groups.set(key, [index]) - }) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const groupValues = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const explicitExtent = + options.x1 !== undefined || options.x2 !== undefined + if (explicitExtent && options.layout) { + throw new TypeError( + 'An area with explicit x1 or x2 endpoints cannot also configure a stack layout', + ) + } + const stacked = explicitExtent + ? undefined + : stackValues(yValues, rawXValues, groupValues, options.layout) + const x1Values = explicitExtent + ? typeof options.x1 === 'number' + ? data.map(() => options.x1 as number) + : channelValues(data, options.x1, () => 0) + : stacked!.starts + const x2Values = explicitExtent + ? typeof options.x2 === 'number' + ? data.map(() => options.x2 as number) + : channelValues(data, options.x2 ?? options.x, () => undefined) + : stacked!.ends + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [yValues], + markId: id, + warningIdentity: options, + }) + const groups = new Map() + groupValues.forEach((value, index) => { + const key = valueKey(value ?? null) + const group = groups.get(key) + if (group) group.push(index) + else groups.set(key, [index]) + }) - return { - id, - states: markStates(data, options.states), - seriesFromColor: options.z === undefined && options.color !== undefined, - channels: { - x: { - scale: 'x', - values: [ - ...x2Values.filter(isFiniteNumber), - ...x1Values.filter(isFiniteNumber), - ], - includeZero: options.x1 === undefined, - }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + states: markStates(data, options.states), + seriesFromColor: options.z === undefined && options.color !== undefined, + channels: { + x: { + scale: xScale, + values: [ + ...x2Values.filter(isFiniteNumber), + ...x1Values.filter(isFiniteNumber), + ], + includeZero: options.x1 === undefined, + }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - - for (const [groupKey, indices] of groups) { - const firstIndex = indices[0] - if (firstIndex === undefined) continue - const group = groupValues[firstIndex] ?? null - const datum = data[firstIndex] - const resolvedColor = resolveColor(colorValues[firstIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - firstIndex, - data, - resolvedColor, - ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue( - options.stroke, - datum, - firstIndex, - data, - resolvedColor, - ) - let right: (readonly [number, number])[] = [] - let left: (readonly [number, number])[] = [] - let segmentPoints: ChartPoint[] = [] - let segmentIndex = 0 + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] - const flush = () => { - if (!right.length) return - const lower = [...left].reverse() - const path = options.curve?.areaX(right, left) - nodes.push({ - kind: 'area', - key: `${id}:${groupKey}:segment:${segmentIndex}`, - points: [...right, ...lower], - path, - interaction: { points: segmentPoints, affinity: 'y' }, - style: { - fill, - fillOpacity: options.fillOpacity ?? 0.2, - stroke, - strokeWidth: options.strokeWidth, - }, - }) - right = [] - left = [] - segmentPoints = [] - segmentIndex += 1 - } + for (const [groupKey, indices] of groups) { + const firstIndex = indices[0] + if (firstIndex === undefined) continue + const group = groupValues[firstIndex] ?? null + const datum = data[firstIndex] + const resolvedColor = resolveColor(colorValues[firstIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + firstIndex, + data, + resolvedColor, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue( + options.stroke, + datum, + firstIndex, + data, + resolvedColor, + ) + let right: (readonly [number, number])[] = [] + let left: (readonly [number, number])[] = [] + let segmentPoints: ChartPoint[] = [] + let segmentIndex = 0 - for (const datumIndex of indices) { - const xValue = rawXValues[datumIndex] - const x1Value = x1Values[datumIndex] - const x2Value = x2Values[datumIndex] - const yValue = yValues[datumIndex] - if ( - !isFiniteNumber(xValue) || - !isFiniteNumber(x1Value) || - !isFiniteNumber(x2Value) || - !isChartValue(yValue) - ) { - flush() - continue + const flush = () => { + if (!right.length) return + const lower = [...left].reverse() + const path = options.curve?.areaX(right, left) + nodes.push({ + kind: 'area', + key: `${id}:${groupKey}:segment:${segmentIndex}`, + points: [...right, ...lower], + path, + interaction: { points: segmentPoints, affinity: 'y' }, + style: { + fill, + fillOpacity: options.fillOpacity ?? 0.2, + stroke, + strokeWidth: options.strokeWidth, + }, + }) + right = [] + left = [] + segmentPoints = [] + segmentIndex += 1 } - const x = scales.x.map(x2Value) - const y = scales.y.map(yValue) - right.push([x, y]) - left.push([scales.x.map(x1Value), y]) - const key = `${id}:${groupKey}:${valueKey(keys[datumIndex])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[datumIndex], - datumIndex, - xValue, - yValue, - x1Value, - x2Value, - xInterval: 'difference', - x, - y, - color: fill, + + for (const datumIndex of indices) { + const xValue = rawXValues[datumIndex] + const x1Value = x1Values[datumIndex] + const x2Value = x2Values[datumIndex] + const yValue = yValues[datumIndex] + if ( + !isFiniteNumber(xValue) || + !isFiniteNumber(x1Value) || + !isFiniteNumber(x2Value) || + !isChartValue(yValue) + ) { + flush() + continue + } + const x = scales[xScale]!.map(x2Value) + const y = scales[yScale]!.map(yValue) + right.push([x, y]) + left.push([scales[xScale]!.map(x1Value), y]) + const key = `${id}:${groupKey}:${valueKey(keys[datumIndex])}` + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[datumIndex], + datumIndex, + xValue, + yValue, + x1Value, + x2Value, + xInterval: 'difference', + x, + y, + color: fill, + } + segmentPoints.push(point) } - segmentPoints.push(point) + flush() } - flush() - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__area', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__area', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/area.ts b/packages/charts-core/src/area.ts index 8f638722..4b9273d2 100644 --- a/packages/charts-core/src/area.ts +++ b/packages/charts-core/src/area.ts @@ -12,6 +12,8 @@ import { valueKey } from './scales' import { stackValues } from './stack-internal' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -26,7 +28,8 @@ import type { } from './types' import type { StackLayout } from './stack' -export interface AreaYOptions extends ChartMarkMotionOptions { +export interface AreaYOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel y?: Channel @@ -53,190 +56,208 @@ export function areaY< >( source: Iterable, options: TOptions, -): ChartMark, number> +): CartesianChartMark< + TDatum, + OptionChannelOutput, + number, + OptionChannelOutput, + number, + TOptions +> export function areaY( source: Iterable, options: AreaYOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `area-y-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, { index }) => index) - const rawY = options.y ?? options.y2 - const rawYValues = - typeof rawY === 'number' - ? data.map(() => rawY) - : channelValues(data, rawY, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const groupValues = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const explicitExtent = options.y1 !== undefined || options.y2 !== undefined - if (explicitExtent && options.layout) { - throw new TypeError( - 'An area with explicit y1 or y2 endpoints cannot also configure a stack layout', + return createMark( + ({ markIndex }) => { + const id = options.id ?? `area-y-${markIndex}` + const xValues = channelValues( + data, + options.x, + (_datum, { index }) => index, ) - } - const stacked = explicitExtent - ? undefined - : stackValues(xValues, rawYValues, groupValues, options.layout) - const y1Values = explicitExtent - ? typeof options.y1 === 'number' - ? data.map(() => options.y1 as number) - : channelValues(data, options.y1, () => 0) - : stacked!.starts - const y2Values = explicitExtent - ? typeof options.y2 === 'number' - ? data.map(() => options.y2 as number) - : channelValues(data, options.y2 ?? options.y, () => undefined) - : stacked!.ends - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [xValues], - markId: id, - warningIdentity: options, - }) - const groups = new Map() - groupValues.forEach((value, index) => { - const key = valueKey(value ?? null) - const group = groups.get(key) - if (group) group.push(index) - else groups.set(key, [index]) - }) + const rawY = options.y ?? options.y2 + const rawYValues = + typeof rawY === 'number' + ? data.map(() => rawY) + : channelValues(data, rawY, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const groupValues = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const explicitExtent = + options.y1 !== undefined || options.y2 !== undefined + if (explicitExtent && options.layout) { + throw new TypeError( + 'An area with explicit y1 or y2 endpoints cannot also configure a stack layout', + ) + } + const stacked = explicitExtent + ? undefined + : stackValues(xValues, rawYValues, groupValues, options.layout) + const y1Values = explicitExtent + ? typeof options.y1 === 'number' + ? data.map(() => options.y1 as number) + : channelValues(data, options.y1, () => 0) + : stacked!.starts + const y2Values = explicitExtent + ? typeof options.y2 === 'number' + ? data.map(() => options.y2 as number) + : channelValues(data, options.y2 ?? options.y, () => undefined) + : stacked!.ends + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [xValues], + markId: id, + warningIdentity: options, + }) + const groups = new Map() + groupValues.forEach((value, index) => { + const key = valueKey(value ?? null) + const group = groups.get(key) + if (group) group.push(index) + else groups.set(key, [index]) + }) - return { - id, - states: markStates(data, options.states), - seriesFromColor: options.z === undefined && options.color !== undefined, - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { - scale: 'y', - values: [ - ...y2Values.filter(isFiniteNumber), - ...y1Values.filter(isFiniteNumber), - ], - includeZero: options.y1 === undefined, - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + states: markStates(data, options.states), + seriesFromColor: options.z === undefined && options.color !== undefined, + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { + scale: yScale, + values: [ + ...y2Values.filter(isFiniteNumber), + ...y1Values.filter(isFiniteNumber), + ], + includeZero: options.y1 === undefined, + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - - for (const [groupKey, indices] of groups) { - const firstIndex = indices[0] - if (firstIndex === undefined) continue - const group = groupValues[firstIndex] ?? null - const datum = data[firstIndex] - const resolvedColor = resolveColor(colorValues[firstIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - firstIndex, - data, - resolvedColor, - ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue( - options.stroke, - datum, - firstIndex, - data, - resolvedColor, - ) - let top: (readonly [number, number])[] = [] - let bottom: (readonly [number, number])[] = [] - let segmentPoints: ChartPoint[] = [] - let segmentIndex = 0 + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] - const flush = () => { - if (!top.length) return - const lower = [...bottom].reverse() - const path = options.curve?.area(top, bottom) - nodes.push({ - kind: 'area', - key: `${id}:${groupKey}:segment:${segmentIndex}`, - points: [...top, ...lower], - path, - interaction: { points: segmentPoints, affinity: 'x' }, - style: { - fill, - fillOpacity: options.fillOpacity ?? 0.2, - stroke, - strokeWidth: options.strokeWidth, - }, - }) - top = [] - bottom = [] - segmentPoints = [] - segmentIndex += 1 - } + for (const [groupKey, indices] of groups) { + const firstIndex = indices[0] + if (firstIndex === undefined) continue + const group = groupValues[firstIndex] ?? null + const datum = data[firstIndex] + const resolvedColor = resolveColor(colorValues[firstIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + firstIndex, + data, + resolvedColor, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue( + options.stroke, + datum, + firstIndex, + data, + resolvedColor, + ) + let top: (readonly [number, number])[] = [] + let bottom: (readonly [number, number])[] = [] + let segmentPoints: ChartPoint[] = [] + let segmentIndex = 0 - for (const datumIndex of indices) { - const xValue = xValues[datumIndex] - const yValue = rawYValues[datumIndex] - const y1Value = y1Values[datumIndex] - const y2Value = y2Values[datumIndex] - if ( - !isChartValue(xValue) || - !isFiniteNumber(yValue) || - !isFiniteNumber(y1Value) || - !isFiniteNumber(y2Value) - ) { - flush() - continue + const flush = () => { + if (!top.length) return + const lower = [...bottom].reverse() + const path = options.curve?.area(top, bottom) + nodes.push({ + kind: 'area', + key: `${id}:${groupKey}:segment:${segmentIndex}`, + points: [...top, ...lower], + path, + interaction: { points: segmentPoints, affinity: 'x' }, + style: { + fill, + fillOpacity: options.fillOpacity ?? 0.2, + stroke, + strokeWidth: options.strokeWidth, + }, + }) + top = [] + bottom = [] + segmentPoints = [] + segmentIndex += 1 } - const x = scales.x.map(xValue) - const y = scales.y.map(y2Value) - top.push([x, y]) - bottom.push([x, scales.y.map(y1Value)]) - const key = `${id}:${groupKey}:${valueKey(keys[datumIndex])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[datumIndex], - datumIndex, - xValue, - yValue, - y1Value, - y2Value, - yInterval: 'difference', - x, - y, - color: fill, + + for (const datumIndex of indices) { + const xValue = xValues[datumIndex] + const yValue = rawYValues[datumIndex] + const y1Value = y1Values[datumIndex] + const y2Value = y2Values[datumIndex] + if ( + !isChartValue(xValue) || + !isFiniteNumber(yValue) || + !isFiniteNumber(y1Value) || + !isFiniteNumber(y2Value) + ) { + flush() + continue + } + const x = scales[xScale]!.map(xValue) + const y = scales[yScale]!.map(y2Value) + top.push([x, y]) + bottom.push([x, scales[yScale]!.map(y1Value)]) + const key = `${id}:${groupKey}:${valueKey(keys[datumIndex])}` + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[datumIndex], + datumIndex, + xValue, + yValue, + y1Value, + y2Value, + yInterval: 'difference', + x, + y, + color: fill, + } + segmentPoints.push(point) } - segmentPoints.push(point) + flush() } - flush() - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__area', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__area', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/arrow.ts b/packages/charts-core/src/arrow.ts index a36718e9..6321e547 100644 --- a/packages/charts-core/src/arrow.ts +++ b/packages/charts-core/src/arrow.ts @@ -11,6 +11,8 @@ import { valueKey } from './scales' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -20,7 +22,8 @@ import type { VisualChannel, } from './types' -export interface ArrowOptions extends ChartMarkMotionOptions { +export interface ArrowOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x1: Channel y1: Channel @@ -51,132 +54,141 @@ export function arrow< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, ArrowXOutput, - ArrowYOutput + ArrowYOutput, + ArrowXOutput, + ArrowYOutput, + TOptions > export function arrow( source: Iterable, options: ArrowOptions>, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `arrow-${markIndex}` - const x1Values = channelValues(data, options.x1, () => undefined) - const y1Values = channelValues(data, options.y1, () => undefined) - const x2Values = channelValues(data, options.x2, () => undefined) - const y2Values = channelValues(data, options.y2, () => undefined) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups: zValues }) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `arrow-${markIndex}` + const x1Values = channelValues(data, options.x1, () => undefined) + const y1Values = channelValues(data, options.y1, () => undefined) + const x2Values = channelValues(data, options.x2, () => undefined) + const y2Values = channelValues(data, options.y2, () => undefined) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups: zValues }) - return { - id, - channels: { - x: { - scale: 'x', - values: [...x1Values, ...x2Values].filter(isChartValue), - }, - y: { - scale: 'y', - values: [...y1Values, ...y2Values].filter(isChartValue), + return { + id, + channels: { + x: { + scale: xScale, + values: [...x1Values, ...x2Values].filter(isChartValue), + }, + y: { + scale: yScale, + values: [...y1Values, ...y2Values].filter(isChartValue), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - const headLength = Math.max(0, options.headLength ?? 8) - const headAngle = ((options.headAngle ?? 30) * Math.PI) / 180 + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + const headLength = Math.max(0, options.headLength ?? 8) + const headAngle = ((options.headAngle ?? 30) * Math.PI) / 180 - data.forEach((datum, datumIndex) => { - const x1Value = x1Values[datumIndex] - const y1Value = y1Values[datumIndex] - const x2Value = x2Values[datumIndex] - const y2Value = y2Values[datumIndex] - if ( - !isChartValue(x1Value) || - !isChartValue(y1Value) || - !isChartValue(x2Value) || - !isChartValue(y2Value) - ) { - return - } + data.forEach((datum, datumIndex) => { + const x1Value = x1Values[datumIndex] + const y1Value = y1Values[datumIndex] + const x2Value = x2Values[datumIndex] + const y2Value = y2Values[datumIndex] + if ( + !isChartValue(x1Value) || + !isChartValue(y1Value) || + !isChartValue(x2Value) || + !isChartValue(y2Value) + ) { + return + } - const x1 = scales.x.map(x1Value) - const y1 = scales.y.map(y1Value) - const x2 = scales.x.map(x2Value) - const y2 = scales.y.map(y2Value) - const group = zValues[datumIndex] ?? null - const color = visualValue( - options.stroke, - datum, - datumIndex, - data, - resolveColor(colorValues[datumIndex] ?? null), - ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const style = { - stroke: color, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - lineCap: 'round' as const, - lineJoin: 'round' as const, - } - nodes.push( - arrowGeometry({ + const x1 = scales[xScale]!.map(x1Value) + const y1 = scales[yScale]!.map(y1Value) + const x2 = scales[xScale]!.map(x2Value) + const y2 = scales[yScale]!.map(y2Value) + const group = zValues[datumIndex] ?? null + const color = visualValue( + options.stroke, + datum, + datumIndex, + data, + resolveColor(colorValues[datumIndex] ?? null), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const style = { + stroke: color, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + lineCap: 'round' as const, + lineJoin: 'round' as const, + } + nodes.push( + arrowGeometry({ + key, + x1, + y1, + x2, + y2, + headLength, + headAngle, + style, + }), + ) + points.push({ key, - x1, - y1, - x2, - y2, - headLength, - headAngle, - style, - }), - ) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: x2Value, - yValue: y2Value, - x1Value, - x2Value, - y1Value, - y2Value, - xInterval: 'range', - yInterval: 'range', - x: x2, - y: y2, - color, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: x2Value, + yValue: y2Value, + x1Value, + x2Value, + y1Value, + y2Value, + xInterval: 'range', + yInterval: 'range', + x: x2, + y: y2, + color, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__arrow', - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__arrow', + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/band.ts b/packages/charts-core/src/band.ts index 33076ce5..69730b8e 100644 --- a/packages/charts-core/src/band.ts +++ b/packages/charts-core/src/band.ts @@ -11,6 +11,7 @@ import { minimumMappedSpacing } from './mapped-spacing-internal' import { valueKey } from './scales' import type { Channel, + CartesianChartMark, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -33,6 +34,7 @@ export interface BandXOptions extends ChartMarkMotionOptions { width?: number inset?: number radius?: number + xScale?: string } export interface BandYOptions extends ChartMarkMotionOptions { @@ -46,6 +48,7 @@ export interface BandYOptions extends ChartMarkMotionOptions { height?: number inset?: number radius?: number + yScale?: string } export function bandX< @@ -54,103 +57,114 @@ export function bandX< >( source: Iterable, options?: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, number, OptionChannelOutput, - never + never, + TOptions > { const data = Array.isArray(source) ? source : Array.from(source) const resolved = options ?? ({} as BandXOptions>) + const xScale = resolved.xScale ?? 'x' - return createMarkWithScaleValues(({ markIndex }) => { - const id = resolved.id ?? `band-x-${markIndex}` - const values = channelValues(data, resolved.x, (_datum, { index }) => index) - const zValues = channelValues(data, resolved.z, () => null) - const colorValues = - resolved.color === undefined - ? zValues - : channelValues(data, resolved.color, () => null) - const keys = inferredKeyValues(data, resolved.key, { - groups: zValues, - candidates: [values], - markId: id, - warningIdentity: resolved, - }) + return createMarkWithScaleValues( + ({ markIndex }) => { + const id = resolved.id ?? `band-x-${markIndex}` + const values = channelValues( + data, + resolved.x, + (_datum, { index }) => index, + ) + const zValues = channelValues(data, resolved.z, () => null) + const colorValues = + resolved.color === undefined + ? zValues + : channelValues(data, resolved.color, () => null) + const keys = inferredKeyValues(data, resolved.key, { + groups: zValues, + candidates: [values], + markId: id, + warningIdentity: resolved, + }) - return { - id, - channels: { - x: { scale: 'x', values: values.filter(isChartValue) }, - color: { scale: 'color', values: colorValues.filter(isChartKey) }, - }, - render: ({ chart, scales, color }) => { - const width = Number.isFinite(resolved.width) - ? Math.max(0, resolved.width!) - : scales.x.bandwidth || - inferBandwidth(scales.x, values, chart.width, data.length) - const inset = Number.isFinite(resolved.inset) ? resolved.inset! : 0 - const nodes: SceneNode[] = [] - data.forEach((datum, index) => { - const xValue = values[index] - if (!isChartValue(xValue)) return - const x = scales.x.map(xValue) - const fill = visualValue( - resolved.fill, - datum, - index, - data, - color(colorValues[index]), - ) - const group = zValues[index] ?? null - const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` - const left = x - width / 2 + inset - const paintedWidth = Math.max(0, width - inset * 2) - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex: index, - xValue, - yValue: 0, - x, - y: chart.y + chart.height / 2, - color: fill, - } - nodes.push({ - kind: 'rect', - key, - x: left, - y: chart.y, - width: paintedWidth, - height: chart.height, - radius: resolved.radius, - interaction: { point, affinity: 'x' }, - style: { fill, fillOpacity: resolved.fillOpacity }, + return { + id, + channels: { + x: { scale: xScale, values: values.filter(isChartValue) }, + color: { scale: 'color', values: colorValues.filter(isChartKey) }, + }, + render: ({ chart, scales, color }) => { + const width = Number.isFinite(resolved.width) + ? Math.max(0, resolved.width!) + : scales[xScale]!.bandwidth || + inferBandwidth(scales[xScale]!, values, chart.width, data.length) + const inset = Number.isFinite(resolved.inset) ? resolved.inset! : 0 + const nodes: SceneNode[] = [] + data.forEach((datum, index) => { + const xValue = values[index] + if (!isChartValue(xValue)) return + const x = scales[xScale]!.map(xValue) + const fill = visualValue( + resolved.fill, + datum, + index, + data, + color(colorValues[index]), + ) + const group = zValues[index] ?? null + const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const left = x - width / 2 + inset + const paintedWidth = Math.max(0, width - inset * 2) + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex: index, + xValue, + yValue: 0, + x, + y: chart.y + chart.height / 2, + color: fill, + } + nodes.push({ + kind: 'rect', + key, + x: left, + y: chart.y, + width: paintedWidth, + height: chart.height, + radius: resolved.radius, + interaction: { point, affinity: 'x' }, + style: { fill, fillOpacity: resolved.fillOpacity }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__band ts-chart__band-x', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, resolved.motion) as ChartMark< + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__band ts-chart__band-x', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + resolved.motion, + resolved.renderer, + ) as CartesianChartMark< TDatum, OptionChannelOutput, number, OptionChannelOutput, - never + never, + TOptions > } @@ -160,103 +174,114 @@ export function bandY< >( source: Iterable, options?: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, number, OptionChannelOutput, never, - OptionChannelOutput + OptionChannelOutput, + TOptions > { const data = Array.isArray(source) ? source : Array.from(source) const resolved = options ?? ({} as BandYOptions>) + const yScale = resolved.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = resolved.id ?? `band-y-${markIndex}` - const values = channelValues(data, resolved.y, (_datum, { index }) => index) - const zValues = channelValues(data, resolved.z, () => null) - const colorValues = - resolved.color === undefined - ? zValues - : channelValues(data, resolved.color, () => null) - const keys = inferredKeyValues(data, resolved.key, { - groups: zValues, - candidates: [values], - markId: id, - warningIdentity: resolved, - }) + return createMark( + ({ markIndex }) => { + const id = resolved.id ?? `band-y-${markIndex}` + const values = channelValues( + data, + resolved.y, + (_datum, { index }) => index, + ) + const zValues = channelValues(data, resolved.z, () => null) + const colorValues = + resolved.color === undefined + ? zValues + : channelValues(data, resolved.color, () => null) + const keys = inferredKeyValues(data, resolved.key, { + groups: zValues, + candidates: [values], + markId: id, + warningIdentity: resolved, + }) - return { - id, - channels: { - y: { scale: 'y', values: values.filter(isChartValue) }, - color: { scale: 'color', values: colorValues.filter(isChartKey) }, - }, - render: ({ chart, scales, color }) => { - const height = Number.isFinite(resolved.height) - ? Math.max(0, resolved.height!) - : scales.y.bandwidth || - inferBandwidth(scales.y, values, chart.height, data.length) - const inset = Number.isFinite(resolved.inset) ? resolved.inset! : 0 - const nodes: SceneNode[] = [] - data.forEach((datum, index) => { - const yValue = values[index] - if (!isChartValue(yValue)) return - const y = scales.y.map(yValue) - const fill = visualValue( - resolved.fill, - datum, - index, - data, - color(colorValues[index]), - ) - const group = zValues[index] ?? null - const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` - const top = y - height / 2 + inset - const paintedHeight = Math.max(0, height - inset * 2) - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex: index, - xValue: 0, - yValue, - x: chart.x + chart.width / 2, - y, - color: fill, - } - nodes.push({ - kind: 'rect', - key, - x: chart.x, - y: top, - width: chart.width, - height: paintedHeight, - radius: resolved.radius, - interaction: { point, affinity: 'y' }, - style: { fill, fillOpacity: resolved.fillOpacity }, + return { + id, + channels: { + y: { scale: yScale, values: values.filter(isChartValue) }, + color: { scale: 'color', values: colorValues.filter(isChartKey) }, + }, + render: ({ chart, scales, color }) => { + const height = Number.isFinite(resolved.height) + ? Math.max(0, resolved.height!) + : scales[yScale]!.bandwidth || + inferBandwidth(scales[yScale]!, values, chart.height, data.length) + const inset = Number.isFinite(resolved.inset) ? resolved.inset! : 0 + const nodes: SceneNode[] = [] + data.forEach((datum, index) => { + const yValue = values[index] + if (!isChartValue(yValue)) return + const y = scales[yScale]!.map(yValue) + const fill = visualValue( + resolved.fill, + datum, + index, + data, + color(colorValues[index]), + ) + const group = zValues[index] ?? null + const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const top = y - height / 2 + inset + const paintedHeight = Math.max(0, height - inset * 2) + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex: index, + xValue: 0, + yValue, + x: chart.x + chart.width / 2, + y, + color: fill, + } + nodes.push({ + kind: 'rect', + key, + x: chart.x, + y: top, + width: chart.width, + height: paintedHeight, + radius: resolved.radius, + interaction: { point, affinity: 'y' }, + style: { fill, fillOpacity: resolved.fillOpacity }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__band ts-chart__band-y', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, resolved.motion) as ChartMark< + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__band ts-chart__band-y', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + resolved.motion, + resolved.renderer, + ) as CartesianChartMark< TDatum, number, OptionChannelOutput, never, - OptionChannelOutput + OptionChannelOutput, + TOptions > } diff --git a/packages/charts-core/src/bar.ts b/packages/charts-core/src/bar.ts index e9ca90c5..1d93a476 100644 --- a/packages/charts-core/src/bar.ts +++ b/packages/charts-core/src/bar.ts @@ -15,6 +15,8 @@ import { stackValues } from './stack-internal' import type { Channel, ChannelAccessor, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -32,7 +34,8 @@ import type { StackLayout } from './stack' type BarLayout = GroupLayout | StackLayout -export interface BarYOptions extends ChartMarkMotionOptions { +export interface BarYOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel y?: Channel @@ -56,7 +59,8 @@ export interface BarYOptions extends ChartMarkMotionOptions { states?: readonly ChartMarkState>[] } -export interface BarXOptions extends ChartMarkMotionOptions { +export interface BarXOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel x1?: number | Channel @@ -89,200 +93,219 @@ export function barY< >( source: Iterable, options: TOptions, -): ChartMark, number> +): CartesianChartMark< + TDatum, + OptionChannelOutput, + number, + OptionChannelOutput, + number, + TOptions +> export function barY( source: Iterable, options: BarYOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `bar-y-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, { index }) => index) - const rawYValues = numericChannelValues( - data, - options.y ?? options.y2, - (datum) => (typeof datum === 'number' ? datum : undefined), - ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const seriesValues = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const explicitExtent = options.y1 !== undefined || options.y2 !== undefined - if (explicitExtent && options.layout?.type === 'stack') { - throw new TypeError( - 'A bar with explicit y1 or y2 endpoints cannot also configure a stack layout', + return createMark( + ({ markIndex }) => { + const id = options.id ?? `bar-y-${markIndex}` + const xValues = channelValues( + data, + options.x, + (_datum, { index }) => index, ) - } - const grouped = options.layout?.type === 'group' - const stackLayout = options.layout?.type === 'stack' ? options.layout : {} - const stacked = - !explicitExtent && !grouped - ? stackValues(xValues, rawYValues, seriesValues, stackLayout, 'index') - : undefined - const y1Values = explicitExtent - ? numericChannelValues(data, options.y1, () => 0) - : (stacked?.starts ?? data.map(() => 0)) - const y2Values = explicitExtent - ? numericChannelValues(data, options.y2 ?? options.y, () => undefined) - : grouped - ? rawYValues - : stacked!.ends - const duplicatePositions = hasDuplicateValues(xValues) - const groupValues = - grouped || (!explicitExtent && duplicatePositions) - ? seriesValues - : zValues - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [xValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - states: markStates(data, options.states), - seriesFromColor: - options.z === undefined && - options.color !== undefined && - (grouped || duplicatePositions), - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { - scale: 'y', - values: [ - ...y2Values.filter(isFiniteNumber), - ...y1Values.filter(isFiniteNumber), - ], - includeZero: options.y1 === undefined, - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ scales, chart, color: resolveColor }) => { - const totalBandwidth = - scales.x.bandwidth || - inferBandwidth(scales.x, xValues, chart.width, data.length) - const groupScale = resolveGroupScale( - options.layout?.type === 'group' ? options.layout : undefined, - groupValues, - totalBandwidth, - ) - const groupBandwidth = groupScale?.bandwidth ?? totalBandwidth - const thickness = resolveBarThickness( - groupBandwidth, - options.inset, - options.maxThickness, + const rawYValues = numericChannelValues( + data, + options.y ?? options.y2, + (datum) => (typeof datum === 'number' ? datum : undefined), + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const seriesValues = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const explicitExtent = + options.y1 !== undefined || options.y2 !== undefined + if (explicitExtent && options.layout?.type === 'stack') { + throw new TypeError( + 'A bar with explicit y1 or y2 endpoints cannot also configure a stack layout', ) - const nodes: SceneNode[] = [] + } + const grouped = options.layout?.type === 'group' + const stackLayout = options.layout?.type === 'stack' ? options.layout : {} + const stacked = + !explicitExtent && !grouped + ? stackValues(xValues, rawYValues, seriesValues, stackLayout, 'index') + : undefined + const y1Values = explicitExtent + ? numericChannelValues(data, options.y1, () => 0) + : (stacked?.starts ?? data.map(() => 0)) + const y2Values = explicitExtent + ? numericChannelValues(data, options.y2 ?? options.y, () => undefined) + : grouped + ? rawYValues + : stacked!.ends + const duplicatePositions = hasDuplicateValues(xValues) + const groupValues = + grouped || (!explicitExtent && duplicatePositions) + ? seriesValues + : zValues + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [xValues], + markId: id, + warningIdentity: options, + }) - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const yValue = rawYValues[datumIndex] - const y1Value = y1Values[datumIndex] - const y2Value = y2Values[datumIndex] - if ( - !isChartValue(xValue) || - !isFiniteNumber(yValue) || - !isFiniteNumber(y1Value) || - !isFiniteNumber(y2Value) - ) - return - const group = groupValues[datumIndex] ?? null - const groupOffset = groupScale?.map(group) ?? 0 - const resolvedColor = resolveColor(colorValues[datumIndex]) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - resolvedColor, - ) - const stroke = visualValue( - options.stroke, - datum, - datumIndex, - data, - 'none', + return { + id, + states: markStates(data, options.states), + seriesFromColor: + options.z === undefined && + options.color !== undefined && + (grouped || duplicatePositions), + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { + scale: yScale, + values: [ + ...y2Values.filter(isFiniteNumber), + ...y1Values.filter(isFiniteNumber), + ], + includeZero: options.y1 === undefined, + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, + }, + render: ({ scales, chart, color: resolveColor }) => { + const totalBandwidth = + scales[xScale]!.bandwidth || + inferBandwidth(scales[xScale]!, xValues, chart.width, data.length) + const groupScale = resolveGroupScale( + options.layout?.type === 'group' ? options.layout : undefined, + groupValues, + totalBandwidth, ) - const strokeDasharray = visualValue( - options.strokeDasharray, - datum, - datumIndex, - data, - 'none', + const groupBandwidth = groupScale?.bandwidth ?? totalBandwidth + const thickness = resolveBarThickness( + groupBandwidth, + options.inset, + options.maxThickness, ) - const center = scales.x.map(xValue) - const baselinePosition = scales.y.map(y1Value) - const valuePosition = scales.y.map(y2Value) - const x = center - totalBandwidth / 2 + groupOffset + thickness.inset - const y = Math.min(baselinePosition, valuePosition) - const width = thickness.size - const height = Math.abs(baselinePosition - valuePosition) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - y1Value, - y2Value, - yInterval: 'difference', - x: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, - y: valuePosition, - color: fill, - } - nodes.push({ - kind: 'rect', - key, - x, - y, - width, - height, - radius: options.radius, - inset: thickness.inset, - insetAxis: 'x', - ...(thickness.maximum === undefined - ? {} - : { maxThickness: thickness.maximum }), - interaction: { point, affinity: 'x' }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray, - }, + const nodes: SceneNode[] = [] + + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const yValue = rawYValues[datumIndex] + const y1Value = y1Values[datumIndex] + const y2Value = y2Values[datumIndex] + if ( + !isChartValue(xValue) || + !isFiniteNumber(yValue) || + !isFiniteNumber(y1Value) || + !isFiniteNumber(y2Value) + ) + return + const group = groupValues[datumIndex] ?? null + const groupOffset = groupScale?.map(group) ?? 0 + const resolvedColor = resolveColor(colorValues[datumIndex]) + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + resolvedColor, + ) + const stroke = visualValue( + options.stroke, + datum, + datumIndex, + data, + 'none', + ) + const strokeDasharray = visualValue( + options.strokeDasharray, + datum, + datumIndex, + data, + 'none', + ) + const center = scales[xScale]!.map(xValue) + const baselinePosition = scales[yScale]!.map(y1Value) + const valuePosition = scales[yScale]!.map(y2Value) + const x = + center - totalBandwidth / 2 + groupOffset + thickness.inset + const y = Math.min(baselinePosition, valuePosition) + const width = thickness.size + const height = Math.abs(baselinePosition - valuePosition) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + y1Value, + y2Value, + yInterval: 'difference', + x: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, + y: valuePosition, + color: fill, + } + nodes.push({ + kind: 'rect', + key, + x, + y, + width, + height, + radius: options.radius, + inset: thickness.inset, + insetAxis: 'x', + ...(thickness.maximum === undefined + ? {} + : { maxThickness: thickness.maximum }), + interaction: { point, affinity: 'x' }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray, + }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__bar ts-chart__bar-y', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__bar ts-chart__bar-y', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } export function barX( @@ -294,200 +317,219 @@ export function barX< >( source: Iterable, options: TOptions, -): ChartMark> +): CartesianChartMark< + TDatum, + number, + OptionChannelOutput, + number, + OptionChannelOutput, + TOptions +> export function barX( source: Iterable, options: BarXOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `bar-x-${markIndex}` - const rawXValues = numericChannelValues( - data, - options.x ?? options.x2, - (datum) => (typeof datum === 'number' ? datum : undefined), - ) - const yValues = channelValues(data, options.y, (_datum, { index }) => index) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const seriesValues = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const explicitExtent = options.x1 !== undefined || options.x2 !== undefined - if (explicitExtent && options.layout?.type === 'stack') { - throw new TypeError( - 'A bar with explicit x1 or x2 endpoints cannot also configure a stack layout', + return createMark( + ({ markIndex }) => { + const id = options.id ?? `bar-x-${markIndex}` + const rawXValues = numericChannelValues( + data, + options.x ?? options.x2, + (datum) => (typeof datum === 'number' ? datum : undefined), ) - } - const grouped = options.layout?.type === 'group' - const stackLayout = options.layout?.type === 'stack' ? options.layout : {} - const stacked = - !explicitExtent && !grouped - ? stackValues(yValues, rawXValues, seriesValues, stackLayout, 'index') - : undefined - const x1Values = explicitExtent - ? numericChannelValues(data, options.x1, () => 0) - : (stacked?.starts ?? data.map(() => 0)) - const x2Values = explicitExtent - ? numericChannelValues(data, options.x2 ?? options.x, () => undefined) - : grouped - ? rawXValues - : stacked!.ends - const duplicatePositions = hasDuplicateValues(yValues) - const groupValues = - grouped || (!explicitExtent && duplicatePositions) - ? seriesValues - : zValues - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [yValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - states: markStates(data, options.states), - seriesFromColor: - options.z === undefined && - options.color !== undefined && - (grouped || duplicatePositions), - channels: { - x: { - scale: 'x', - values: [ - ...x2Values.filter(isFiniteNumber), - ...x1Values.filter(isFiniteNumber), - ], - includeZero: options.x1 === undefined, - }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ scales, chart, color: resolveColor }) => { - const totalBandwidth = - scales.y.bandwidth || - inferBandwidth(scales.y, yValues, chart.height, data.length) - const groupScale = resolveGroupScale( - options.layout?.type === 'group' ? options.layout : undefined, - groupValues, - totalBandwidth, - ) - const groupBandwidth = groupScale?.bandwidth ?? totalBandwidth - const thickness = resolveBarThickness( - groupBandwidth, - options.inset, - options.maxThickness, + const yValues = channelValues( + data, + options.y, + (_datum, { index }) => index, + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const seriesValues = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const explicitExtent = + options.x1 !== undefined || options.x2 !== undefined + if (explicitExtent && options.layout?.type === 'stack') { + throw new TypeError( + 'A bar with explicit x1 or x2 endpoints cannot also configure a stack layout', ) - const nodes: SceneNode[] = [] + } + const grouped = options.layout?.type === 'group' + const stackLayout = options.layout?.type === 'stack' ? options.layout : {} + const stacked = + !explicitExtent && !grouped + ? stackValues(yValues, rawXValues, seriesValues, stackLayout, 'index') + : undefined + const x1Values = explicitExtent + ? numericChannelValues(data, options.x1, () => 0) + : (stacked?.starts ?? data.map(() => 0)) + const x2Values = explicitExtent + ? numericChannelValues(data, options.x2 ?? options.x, () => undefined) + : grouped + ? rawXValues + : stacked!.ends + const duplicatePositions = hasDuplicateValues(yValues) + const groupValues = + grouped || (!explicitExtent && duplicatePositions) + ? seriesValues + : zValues + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [yValues], + markId: id, + warningIdentity: options, + }) - data.forEach((datum, datumIndex) => { - const xValue = rawXValues[datumIndex] - const x1Value = x1Values[datumIndex] - const x2Value = x2Values[datumIndex] - const yValue = yValues[datumIndex] - if ( - !isFiniteNumber(xValue) || - !isFiniteNumber(x1Value) || - !isFiniteNumber(x2Value) || - !isChartValue(yValue) - ) - return - const group = groupValues[datumIndex] ?? null - const groupOffset = groupScale?.map(group) ?? 0 - const resolvedColor = resolveColor(colorValues[datumIndex]) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - resolvedColor, - ) - const stroke = visualValue( - options.stroke, - datum, - datumIndex, - data, - 'none', + return { + id, + states: markStates(data, options.states), + seriesFromColor: + options.z === undefined && + options.color !== undefined && + (grouped || duplicatePositions), + channels: { + x: { + scale: xScale, + values: [ + ...x2Values.filter(isFiniteNumber), + ...x1Values.filter(isFiniteNumber), + ], + includeZero: options.x1 === undefined, + }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, + }, + render: ({ scales, chart, color: resolveColor }) => { + const totalBandwidth = + scales[yScale]!.bandwidth || + inferBandwidth(scales[yScale]!, yValues, chart.height, data.length) + const groupScale = resolveGroupScale( + options.layout?.type === 'group' ? options.layout : undefined, + groupValues, + totalBandwidth, ) - const strokeDasharray = visualValue( - options.strokeDasharray, - datum, - datumIndex, - data, - 'none', + const groupBandwidth = groupScale?.bandwidth ?? totalBandwidth + const thickness = resolveBarThickness( + groupBandwidth, + options.inset, + options.maxThickness, ) - const baselinePosition = scales.x.map(x1Value) - const valuePosition = scales.x.map(x2Value) - const center = scales.y.map(yValue) - const y = center - totalBandwidth / 2 + groupOffset + thickness.inset - const x = Math.min(baselinePosition, valuePosition) - const width = Math.abs(baselinePosition - valuePosition) - const height = thickness.size - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - x1Value, - x2Value, - xInterval: 'difference', - x: valuePosition, - y: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, - color: fill, - } - nodes.push({ - kind: 'rect', - key, - x, - y, - width, - height, - radius: options.radius, - inset: thickness.inset, - insetAxis: 'y', - ...(thickness.maximum === undefined - ? {} - : { maxThickness: thickness.maximum }), - interaction: { point, affinity: 'y' }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray, - }, + const nodes: SceneNode[] = [] + + data.forEach((datum, datumIndex) => { + const xValue = rawXValues[datumIndex] + const x1Value = x1Values[datumIndex] + const x2Value = x2Values[datumIndex] + const yValue = yValues[datumIndex] + if ( + !isFiniteNumber(xValue) || + !isFiniteNumber(x1Value) || + !isFiniteNumber(x2Value) || + !isChartValue(yValue) + ) + return + const group = groupValues[datumIndex] ?? null + const groupOffset = groupScale?.map(group) ?? 0 + const resolvedColor = resolveColor(colorValues[datumIndex]) + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + resolvedColor, + ) + const stroke = visualValue( + options.stroke, + datum, + datumIndex, + data, + 'none', + ) + const strokeDasharray = visualValue( + options.strokeDasharray, + datum, + datumIndex, + data, + 'none', + ) + const baselinePosition = scales[xScale]!.map(x1Value) + const valuePosition = scales[xScale]!.map(x2Value) + const center = scales[yScale]!.map(yValue) + const y = + center - totalBandwidth / 2 + groupOffset + thickness.inset + const x = Math.min(baselinePosition, valuePosition) + const width = Math.abs(baselinePosition - valuePosition) + const height = thickness.size + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + x1Value, + x2Value, + xInterval: 'difference', + x: valuePosition, + y: center - totalBandwidth / 2 + groupOffset + groupBandwidth / 2, + color: fill, + } + nodes.push({ + kind: 'rect', + key, + x, + y, + width, + height, + radius: options.radius, + inset: thickness.inset, + insetAxis: 'y', + ...(thickness.maximum === undefined + ? {} + : { maxThickness: thickness.maximum }), + interaction: { point, affinity: 'y' }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray, + }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__bar ts-chart__bar-x', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__bar ts-chart__bar-x', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } function resolveBarThickness( diff --git a/packages/charts-core/src/box.ts b/packages/charts-core/src/box.ts index 29750f51..b28956d1 100644 --- a/packages/charts-core/src/box.ts +++ b/packages/charts-core/src/box.ts @@ -19,6 +19,8 @@ import type { } from './transform' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMotionDefinition, @@ -84,6 +86,8 @@ interface BoxOptions< inset?: number /** Outlier radius in pixels. Defaults to 3. */ r?: number + xScale?: CartesianScaleBindings['xScale'] + yScale?: CartesianScaleBindings['yScale'] } export interface BoxYOptions< @@ -159,18 +163,27 @@ export function boxY< NoInfer, ChartValue | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: BoxYCallOptions, + options: BoxYCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< BoxYDatum, OptionChannelOutput, - number + number, + OptionChannelOutput, + number, + TXScaleId, + TYScaleId > export function boxY( source: Iterable, options: BoxYOptions>, -): ChartMark { +): CartesianChartMark> { return box(source, options, options.x, options.y, 'y') } @@ -181,18 +194,27 @@ export function boxX< NoInfer, ChartValue | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: BoxXCallOptions, + options: BoxXCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< BoxXDatum, number, - OptionChannelOutput + OptionChannelOutput, + number, + OptionChannelOutput, + TXScaleId, + TYScaleId > export function boxX( source: Iterable, options: BoxXOptions>, -): ChartMark { +): CartesianChartMark> { return box(source, options, options.y, options.x, 'x') } @@ -211,140 +233,162 @@ function box( category: Channel, numeric: Channel, orientation: 'x' | 'y', -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `box-${orientation}-${markIndex}` - const categoryValues = channelValues(data, category, () => undefined) - const numericValues = channelValues(data, numeric, () => undefined) - const keys = inferredKeyValues(data, options.key, { - groups: categoryValues, - markId: id, - warningIdentity: options, - }) - const rows = boxRows(data, { - category: (_datum, { index }) => categoryValues[index], - value: (_datum, { index }) => numericValues[index], - }) - const summaries: BoxSummaryMarkDatum[] = rows.flatMap((row) => - row.kind === 'summary' - ? [{ ...row, markKey: `box:${valueKey(row.category)}` }] - : [], - ) - const outliers: BoxOutlierMarkDatum[] = rows.flatMap((row) => { - if (row.kind !== 'outlier') return [] - const sourceIndex = row.sourceIndexes[0] - return [ - { - ...row, - markKey: `box:${valueKey(row.category)}:outlier:${valueKey( - keys[sourceIndex], - )}`, - }, - ] - }) - const stroke = options.stroke ?? 'currentColor' - const children = - orientation === 'y' - ? [ - link(summaries, { - id: 'whisker', - x1: 'category', - y1: 'whiskerLow', - x2: 'category', - y2: 'whiskerHigh', - key: 'markKey', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1, - lineCap: 'butt', - }), - barY(summaries, { - id: 'box', - x: 'category', - y: 'median', - y1: 'q1', - y2: 'q3', - key: 'markKey', - fill: options.fill ?? '#ccc', - fillOpacity: options.fillOpacity, - inset: options.inset, - }), - tickY(summaries, { - id: 'median', - x: 'category', - y: 'median', - key: 'markKey', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 2, - inset: options.inset, - }), - dot(outliers, { - id: 'outlier', - x: 'category', - y: 'value', - key: 'markKey', - r: options.r ?? 3, - fill: 'none', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - }), - ] - : [ - link(summaries, { - id: 'whisker', - x1: 'whiskerLow', - y1: 'category', - x2: 'whiskerHigh', - y2: 'category', - key: 'markKey', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1, - lineCap: 'butt', - }), - barX(summaries, { - id: 'box', - x: 'median', - x1: 'q1', - x2: 'q3', - y: 'category', - key: 'markKey', - fill: options.fill ?? '#ccc', - fillOpacity: options.fillOpacity, - inset: options.inset, - }), - tickX(summaries, { - id: 'median', - x: 'median', - y: 'category', - key: 'markKey', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 2, - inset: options.inset, - }), - dot(outliers, { - id: 'outlier', - x: 'value', - y: 'category', - key: 'markKey', - r: options.r ?? 3, - fill: 'none', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - }), - ] + return createMark( + ({ markIndex }) => { + const id = options.id ?? `box-${orientation}-${markIndex}` + const categoryValues = channelValues(data, category, () => undefined) + const numericValues = channelValues(data, numeric, () => undefined) + const keys = inferredKeyValues(data, options.key, { + groups: categoryValues, + markId: id, + warningIdentity: options, + }) + const rows = boxRows(data, { + category: (_datum, { index }) => categoryValues[index], + value: (_datum, { index }) => numericValues[index], + }) + const summaries: BoxSummaryMarkDatum[] = rows.flatMap((row) => + row.kind === 'summary' + ? [{ ...row, markKey: `box:${valueKey(row.category)}` }] + : [], + ) + const outliers: BoxOutlierMarkDatum[] = rows.flatMap((row) => { + if (row.kind !== 'outlier') return [] + const sourceIndex = row.sourceIndexes[0] + return [ + { + ...row, + markKey: `box:${valueKey(row.category)}:outlier:${valueKey( + keys[sourceIndex], + )}`, + }, + ] + }) + const stroke = options.stroke ?? 'currentColor' + const children = + orientation === 'y' + ? [ + link(summaries, { + id: 'whisker', + x1: 'category', + y1: 'whiskerLow', + x2: 'category', + y2: 'whiskerHigh', + key: 'markKey', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1, + lineCap: 'butt', + xScale, + yScale, + }), + barY(summaries, { + id: 'box', + x: 'category', + y: 'median', + y1: 'q1', + y2: 'q3', + key: 'markKey', + fill: options.fill ?? '#ccc', + fillOpacity: options.fillOpacity, + inset: options.inset, + xScale, + yScale, + }), + tickY(summaries, { + id: 'median', + x: 'category', + y: 'median', + key: 'markKey', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 2, + inset: options.inset, + xScale, + yScale, + }), + dot(outliers, { + id: 'outlier', + x: 'category', + y: 'value', + key: 'markKey', + r: options.r ?? 3, + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + xScale, + yScale, + }), + ] + : [ + link(summaries, { + id: 'whisker', + x1: 'whiskerLow', + y1: 'category', + x2: 'whiskerHigh', + y2: 'category', + key: 'markKey', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1, + lineCap: 'butt', + xScale, + yScale, + }), + barX(summaries, { + id: 'box', + x: 'median', + x1: 'q1', + x2: 'q3', + y: 'category', + key: 'markKey', + fill: options.fill ?? '#ccc', + fillOpacity: options.fillOpacity, + inset: options.inset, + xScale, + yScale, + }), + tickX(summaries, { + id: 'median', + x: 'median', + y: 'category', + key: 'markKey', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 2, + inset: options.inset, + xScale, + yScale, + }), + dot(outliers, { + id: 'outlier', + x: 'value', + y: 'category', + key: 'markKey', + r: options.r ?? 3, + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + xScale, + yScale, + }), + ] - return initializeCompositeMark(id, children, { - motion: options.motion, - interactiveChildren: interactiveBoxChildren, - }) - }) + return initializeCompositeMark(id, children, { + motion: options.motion, + interactiveChildren: interactiveBoxChildren, + }) + }, + options.motion, + options.renderer, + ) } function summarizeBoxes( diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 29c61700..f3c482bc 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { scaleLinear } from 'd3-scale' +import { scaleBand, scaleLinear } from 'd3-scale' +import { areaY } from './area' +import { createChartRendererAdapter } from './adapter-renderer' +import { barY } from './bar' import { canvasChartRenderer, createCanvasChartRenderer, mountCanvasChart, type CanvasChartHost, } from './canvas' +import { crosshair } from './crosshair' import { controlledSignal } from './interaction-signal' import { continuousCursor, @@ -16,10 +20,15 @@ import { handleX, type HandleXChange } from './interaction-handle' import { zoomX, type ZoomXChange, type ZoomXWindow } from './interaction-zoom' import { interactiveColorLegend } from './interactive-legend' import { dot } from './dot' +import { facet } from './facet' import { lineX, lineY } from './line' -import { pie, polar, radialArc } from './polar' +import { mountChart } from './dom' +import { pie, polar, radialArc, radialDot, radialLine } from './polar' import { resolveCrosshairGuide } from './crosshair-resolver' import { createChartScene, defineChart } from './scene' +import { createSvgChartRenderer } from './svg-surface' +import { renderChartSvgWithResources } from './svg-resources' +import { text } from './text' import { tooltip } from './tooltip' import { composeViews, fill, inset, layer } from './view' import type { ChartSurfaceRenderOptions } from './dom-types' @@ -68,6 +77,507 @@ afterEach(() => { }) describe('Canvas renderer', () => { + it('composes renderer-tagged marks in source order', () => { + const data = [ + { id: 'a', category: 'A', value: 3 }, + { id: 'b', category: 'B', value: 7 }, + { id: 'c', category: 'C', value: 5 }, + ] + const container = document.createElement('div') + const host = mountChart(container, { + definition: defineChart({ + marks: [ + areaY(data, { + id: 'canvas-area', + x: 'category', + y: 'value', + renderer: canvasChartRenderer, + }), + barY(data, { + id: 'svg-bars', + x: 'category', + y: 'value', + key: 'id', + }), + lineY(data, { + id: 'canvas-line', + x: 'category', + y: 'value', + key: 'id', + renderer: canvasChartRenderer, + }), + dot(data, { + id: 'svg-dots', + x: 'category', + y: 'value', + key: 'id', + }), + text(data, { + id: 'svg-labels', + x: 'category', + y: 'value', + text: 'id', + }), + ], + scales: { + x: { scale: scaleBand().domain(['A', 'B', 'C']) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, + }), + width: 400, + height: 240, + ariaLabel: 'Mixed renderer chart', + }) + + const root = container.querySelector('.ts-chart-layers') + expect(root?.getAttribute('aria-label')).toBe('Mixed renderer chart') + const layers = [ + ...container.querySelectorAll('.ts-chart-layer'), + ] + expect(layers).toHaveLength(5) + expect( + layers.map((layer) => layer.querySelector('canvas') !== null), + ).toEqual([false, true, false, true, false]) + expect(container.querySelectorAll('canvas')).toHaveLength(10) + expect(container.querySelectorAll('svg')).toHaveLength(3) + expect(container.querySelector('[data-ts-key="svg-bars"]')).not.toBeNull() + expect(container.querySelector('[data-ts-key="svg-dots"]')).not.toBeNull() + expect(container.querySelector('[data-ts-key="svg-labels"]')).not.toBeNull() + expect(container.querySelector('[data-ts-key="canvas-area"]')).toBeNull() + expect(container.querySelector('[data-ts-key="canvas-line"]')).toBeNull() + expect( + [...contexts.values()].flatMap((context) => context.operations), + ).toEqual(expect.arrayContaining(['fill:current', 'stroke:current'])) + + host.destroy() + }) + + it('preserves mixed-layer geometry after painting focus', () => { + const arcs = pie( + [ + { id: 'first', value: 1 }, + { id: 'second', value: 1 }, + ], + { value: 'value' }, + ) + const container = document.createElement('div') + const host = mountChart(container, { + definition: defineChart({ + marks: [ + polar({ + radiusRatio: 0.9, + scales: { angle: null, radius: null }, + marks: [ + radialArc(arcs, { + key: 'id', + innerRadius: ({ radius }) => radius * 0.4, + renderer: canvasChartRenderer, + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + margin: 0, + maxFocusDistance: 1, + }), + width: 400, + height: 400, + ariaLabel: 'Mixed renderer geometry', + }) + const surface = container.querySelector('.ts-chart-layers') + if (!surface) throw new Error('Expected a mixed renderer surface') + vi.spyOn(surface, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 400, + bottom: 400, + left: 0, + width: 400, + height: 400, + toJSON: () => ({}), + }) + + const angle = arcs[0]!.startAngle + 0.1 + const radius = 162 + const clientX = 200 + Math.sin(angle) * radius + const clientY = 200 - Math.cos(angle) * radius + const first = host.interaction.resolvePointer(clientX, clientY) + if (!first) throw new Error('Expected the first arc to resolve') + expect(first.point.datum).toBe(arcs[0]) + + host.interaction.setControlledFocus(first) + + expect(host.interaction.resolvePointer(clientX, clientY)?.point.datum).toBe( + arcs[0], + ) + host.destroy() + }) + + it('finds renderer-tagged marks inside facet and polar groups', () => { + const rows = [ + { id: 'a', panel: 'First', metric: 'A', value: 3 }, + { id: 'b', panel: 'First', metric: 'B', value: 7 }, + { id: 'c', panel: 'Second', metric: 'A', value: 5 }, + { id: 'd', panel: 'Second', metric: 'B', value: 6 }, + ] + const definitions = [ + defineChart({ + marks: [ + facet(rows, { + id: 'facets', + by: 'panel', + axes: 'cell', + chart: (data) => ({ + marks: [ + lineY(data, { + id: 'facet-lines', + x: 'metric', + y: 'value', + renderer: canvasChartRenderer, + }), + dot(data, { id: 'facet-dots', x: 'metric', y: 'value' }), + ], + scales: { + x: { scale: scaleBand().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, + }), + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + defineChart({ + marks: [ + polar({ + scales: { + angle: { scale: scaleBand().domain(['A', 'B']) }, + radius: { scale: scaleLinear().domain([0, 8]) }, + }, + marks: [ + radialLine(rows.slice(0, 2), { + id: 'radial-line', + angle: 'metric', + radius: 'value', + renderer: canvasChartRenderer, + }), + radialDot(rows.slice(0, 2), { + id: 'radial-dots', + angle: 'metric', + radius: 'value', + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + ] + + for (const [index, definition] of definitions.entries()) { + const container = document.createElement('div') + const host = mountChart(container, { + definition, + width: 400, + height: 240, + ariaLabel: index === 0 ? 'Mixed facets' : 'Mixed polar', + }) + + expect(container.querySelector('.ts-chart-layers')).not.toBeNull() + expect(container.querySelector('.ts-chart-canvas')).not.toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + expect( + [...contexts.values()].flatMap((context) => context.operations), + ).toContain(index === 0 ? 'stroke:current' : 'stroke:path') + expect( + container.querySelector( + index === 0 + ? '[data-ts-key*="facet-dots"]' + : '[data-ts-key="radial-dots"]', + ), + ).not.toBeNull() + host.destroy() + } + }) + + it('prerenders and adopts the same mixed layer shell', () => { + const definition = defineChart({ + marks: [ + lineY([2, 6, 4], { + id: 'canvas-line', + renderer: canvasChartRenderer, + }), + dot([2, 6, 4], { id: 'svg-dots' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 6]) }, + }, + }) + const adapter = createChartRendererAdapter({ + definition, + renderer: createSvgChartRenderer(), + initialWidth: 320, + height: 180, + ariaLabel: 'Mixed server chart', + }) + + const markup = adapter.prerender() + + expect(markup).toContain('class="ts-chart ts-chart-layers"') + expect(markup).toContain('aria-label="Mixed server chart"') + expect(markup).toContain('ts-chart-canvas__scene') + expect(markup).toContain('data-ts-key="svg-dots"') + expect(markup).not.toContain('data-ts-key="canvas-line"') + expect(markup.match(/class="ts-chart-layer"/g)).toHaveLength(3) + const container = document.createElement('div') + container.innerHTML = markup + const roots = [...container.querySelectorAll('.ts-chart-layer')] + adapter.mount(container) + expect([...container.querySelectorAll('.ts-chart-layer')]).toEqual(roots) + expect(container.querySelectorAll('canvas')).toHaveLength(5) + expect(container.querySelectorAll('svg')).toHaveLength(2) + adapter.destroy() + }) + + it('keeps the default SVG callback and exposes every composed surface', () => { + const onRender = vi.fn() + const definition = defineChart({ + marks: [ + lineY([2, 6, 4], { + id: 'canvas-line', + renderer: canvasChartRenderer, + }), + dot([2, 6, 4], { id: 'svg-dots' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 6]) }, + }, + }) + const container = document.createElement('div') + const host = mountChart(container, { + definition, + width: 320, + height: 180, + ariaLabel: 'Mixed callback chart', + onRender, + }) + + const context = onRender.mock.calls[0]?.[0] + expect(context.surface.element).toBe( + container.querySelector('.ts-chart-layers'), + ) + expect(context.surface.layers).toHaveLength(3) + expect(context.svg).toBe(container.querySelectorAll('svg').item(1)) + expect(context.svg.querySelector('[data-ts-key="svg-dots"]')).not.toBeNull() + + host.destroy() + }) + + it('scopes SVG resources independently in each composed layer', () => { + const container = document.createElement('div') + const host = mountChart(container, { + definition: defineChart({ + marks: [ + areaY([2, 6, 4], { id: 'svg-area', fill: 'url(#fill)' }), + lineY([2, 6, 4], { + id: 'canvas-line', + renderer: canvasChartRenderer, + }), + dot([2, 6, 4], { id: 'svg-dots', fill: 'url(#fill)' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 6]) }, + }, + gradients: [ + { + id: 'fill', + stops: [ + { offset: 0, color: '#2563eb' }, + { offset: 1, color: '#60a5fa' }, + ], + }, + ], + }), + renderSvg: renderChartSvgWithResources, + width: 320, + height: 180, + idPrefix: 'mixed-resources', + ariaLabel: 'Mixed resources', + }) + const ids = [...container.querySelectorAll('linearGradient')].map( + (gradient) => gradient.id, + ) + + expect(ids).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + expect(ids.every((id) => id.startsWith('mixed-resources-layer-'))).toBe( + true, + ) + for (const svg of container.querySelectorAll('svg')) { + const gradient = svg.querySelector('linearGradient') + expect(gradient).not.toBeNull() + expect(svg.querySelector(`[fill="url(#${gradient!.id})"]`)).not.toBeNull() + } + + host.destroy() + }) + + it('reuses mixed layers across updates and removes them when marks return to SVG', () => { + const mixedDefinition = (values: readonly number[]) => + defineChart({ + marks: [ + lineY(values, { + id: 'canvas-line', + renderer: canvasChartRenderer, + }), + dot(values, { id: 'svg-dots' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, + }) + const options = { + definition: mixedDefinition([2, 6, 4]), + width: 320, + height: 180, + ariaLabel: 'Updated mixed chart', + } + const container = document.createElement('div') + const host = mountChart(container, options) + const root = container.querySelector('.ts-chart-layers') + const layers = [...container.querySelectorAll('.ts-chart-layer')] + const canvas = container.querySelector('.ts-chart-canvas') + + host.update({ ...options, definition: mixedDefinition([3, 7, 5]) }) + + expect(container.querySelector('.ts-chart-layers')).toBe(root) + expect([...container.querySelectorAll('.ts-chart-layer')]).toEqual(layers) + expect(container.querySelector('.ts-chart-canvas')).toBe(canvas) + + host.update({ + ...options, + definition: defineChart({ + marks: [dot([4, 8, 6], { id: 'svg-only' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, + }), + }) + + expect(container.querySelector('.ts-chart-layers')).toBeNull() + expect(container.querySelector('.ts-chart-canvas')).toBeNull() + expect(container.querySelector('svg')).not.toBeNull() + host.destroy() + }) + + it('does not request another render for unchanged layered root attributes', async () => { + const container = document.createElement('div') + const requestRender = vi.fn() + const surface = canvasChartRenderer + .compose(createSvgChartRenderer()) + .mount(container, requestRender) + const chart = scene([ + { + kind: 'dot', + key: 'canvas-dot', + x: 30, + y: 30, + radius: 5, + renderer: canvasChartRenderer, + style: { fill: '#2563eb' }, + }, + { + kind: 'dot', + key: 'svg-dot', + x: 70, + y: 30, + radius: 5, + style: { fill: '#111111' }, + }, + ]) + + surface.render(chart, renderOptions()) + await new Promise((resolve) => setTimeout(resolve, 0)) + requestRender.mockClear() + + surface.render(chart, renderOptions()) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(requestRender).not.toHaveBeenCalled() + surface.destroy() + }) + + it('paints a renderer-tagged crosshair in its own composed focus layer', () => { + const chart = createChartScene( + defineChart({ + marks: [ + dot([{ id: 'a', x: 2, y: 7 }], { + id: 'svg-dot', + x: 'x', + y: 'y', + key: 'id', + }), + crosshair({ + id: 'canvas-crosshair', + renderer: canvasChartRenderer, + x: true, + y: false, + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + guides: false, + margin: 20, + }), + { width: 320, height: 180 }, + ) + const point = chart.points[0] + if (!point) throw new Error('Expected one focus point') + const container = document.createElement('div') + const surface = canvasChartRenderer + .compose(createSvgChartRenderer()) + .mount(container, () => {}) + + surface.render(chart, renderOptions()) + + const layers = [ + ...container.querySelectorAll('.ts-chart-layer'), + ] + expect( + layers.map((layer) => layer.querySelector('canvas') !== null), + ).toEqual([false, true, false]) + const focusCanvas = layers[1]?.querySelector( + '.ts-chart-canvas__focus', + ) + const focusContext = focusCanvas ? contexts.get(focusCanvas) : undefined + if (!focusContext) throw new Error('Expected a Canvas focus layer') + const paintStart = focusContext.operations.length + + surface.paintFocus({ + primary: point, + group: [point], + source: 'pointer', + pinned: false, + }) + + expect(focusContext.operations.slice(paintStart)).toEqual( + expect.arrayContaining([ + `moveTo:${point.x},${chart.chart.y}`, + `lineTo:${point.x},${chart.chart.y + chart.chart.height}`, + ]), + ) + surface.destroy() + }) + it('prerenders an accessible shell without accessing Canvas APIs', () => { const markup = canvasChartRenderer.prerender(scene([]), { ariaLabel: 'Dense scatter', diff --git a/packages/charts-core/src/canvas.ts b/packages/charts-core/src/canvas.ts index 4353bf1e..9c0c06c3 100644 --- a/packages/charts-core/src/canvas.ts +++ b/packages/charts-core/src/canvas.ts @@ -6,11 +6,13 @@ import { resolveMarkStateScene } from './mark-state' import { resolveMarkStateTransition } from './mark-state-transition' import type { ChartInteractionController, + ChartLayerRenderer, ChartRenderer, ChartRendererHost, ChartRendererHostOptions, ChartSurface, ChartSurfaceRenderOptions, + UniversalChartLayerRenderer, } from './dom-types' import type { ChartAnimationOptions, @@ -22,6 +24,7 @@ import type { ChartValue, RenderChartOptions, SceneNode, + SceneFocusGuide, ScenePolygon, SceneStyle, } from './types' @@ -50,15 +53,14 @@ export interface CanvasChartRenderer< TDatum = unknown, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, -> extends ChartRenderer { +> extends ChartLayerRenderer { mount: ( container: HTMLElement, requestRender: (force?: boolean) => void, ) => CanvasChartSurface } -interface UniversalCanvasChartRenderer { - readonly id: string +interface UniversalCanvasChartRenderer extends UniversalChartLayerRenderer { prerender: < TDatum = unknown, TXValue extends ChartValue = ChartValue, @@ -156,8 +158,21 @@ export function createCanvasChartRenderer< function createUniversalCanvasChartRenderer( rendererOptions: CanvasChartRendererOptions = {}, ): UniversalCanvasChartRenderer { + const compositions = new WeakMap< + ChartRenderer, + ChartRenderer + >() const renderer: UniversalCanvasChartRenderer = { + kind: 'chart-layer-renderer', id: 'canvas', + compose(defaultRenderer) { + let composition = compositions.get(defaultRenderer) + if (!composition) { + composition = createLayeredChartRenderer(defaultRenderer) + compositions.set(defaultRenderer, composition) + } + return composition + }, prerender(scene, options) { return renderCanvasShell(scene, options) }, @@ -383,6 +398,388 @@ function createUniversalCanvasChartRenderer( return renderer } +interface ChartRenderLayer { + renderer: ChartRenderer + nodes: readonly SceneNode[] + focusGuides?: readonly SceneFocusGuide[] +} + +interface MountedLayer { + renderer: ChartRenderer + container: HTMLDivElement + surface: ChartSurface +} + +function createLayeredChartRenderer( + defaultRenderer: ChartRenderer, +): ChartRenderer { + const renderer: ChartRenderer = { + id: `layers:${defaultRenderer.id}`, + capabilities: defaultRenderer.capabilities, + prerender(scene, options) { + const layers = chartRenderLayers(scene, defaultRenderer) + const className = options.className + ? `ts-chart ts-chart-layers ${options.className}` + : 'ts-chart ts-chart-layers' + const description = options.ariaDescription + ? ` aria-description="${escapeAttribute(options.ariaDescription)}"` + : '' + return `` + }, + mount(container, requestRender) { + const root = findOrCreateLayerRoot(container) + const presentationListeners = new Set< + (points: readonly ChartPoint[]) => void + >() + let mounted: MountedLayer[] = [] + let renderedLayers: ChartRenderLayer[] = [] + let unsubscribePresentation: (() => void) | undefined + let scene: ChartScene | undefined + let destroyed = false + + const defaultSurface = () => { + for (let index = mounted.length - 1; index >= 0; index -= 1) { + const layer = mounted[index] + if (layer?.renderer === defaultRenderer) return layer.surface + } + return undefined + } + + const subscribeToPresentation = () => { + unsubscribePresentation?.() + unsubscribePresentation = + defaultSurface()?.subscribePresentationPoints?.((points) => { + for (const listener of presentationListeners) listener(points) + }) + } + + const surface: ChartSurface = { + renderer, + element: root, + get layers() { + return mounted.map((layer) => layer.surface) + }, + get defaultElement() { + return defaultSurface()?.defaultElement ?? defaultSurface()?.element + }, + render(nextScene, options) { + if (destroyed) return + configureLayerRoot(root, nextScene, options) + const nextLayers = chartRenderLayers(nextScene, defaultRenderer) + const reusedLayers = sameLayerRenderers(mounted, nextLayers) + if (!reusedLayers) { + const adoptPrerenderedLayers = mounted.length === 0 + unsubscribePresentation?.() + unsubscribePresentation = undefined + for (const layer of mounted) layer.surface.destroy() + mounted = mountLayers( + root, + nextLayers, + requestRender, + adoptPrerenderedLayers, + ) + subscribeToPresentation() + } + for (let index = 0; index < mounted.length; index += 1) { + const layer = nextLayers[index]! + mounted[index]!.surface.render( + layerScene(nextScene, layer, index), + layerOptions(options, index, reusedLayers), + ) + } + renderedLayers = nextLayers + scene = nextScene + }, + clientToScene(currentScene, clientX, clientY) { + const bounds = root.getBoundingClientRect() + if (!bounds.width || !bounds.height) return null + return { + x: ((clientX - bounds.left) / bounds.width) * currentScene.width, + y: ((clientY - bounds.top) / bounds.height) * currentScene.height, + } + }, + getPresentationPoints() { + return defaultSurface()?.getPresentationPoints?.() + }, + subscribePresentationPoints(listener) { + presentationListeners.add(listener) + return () => presentationListeners.delete(listener) + }, + paintFocus(focus, pointer, cursor) { + if (!scene || destroyed) return + const presentedNodes: SceneNode[] = [] + let hasPresentedScene = false + for (let index = 0; index < mounted.length; index += 1) { + const result = mounted[index]!.surface.paintFocus( + focus, + pointer, + cursor, + ) + hasPresentedScene ||= result !== undefined + presentedNodes.push( + ...(result?.nodes ?? renderedLayers[index]?.nodes ?? []), + ) + } + return hasPresentedScene + ? { ...scene, nodes: presentedNodes } + : undefined + }, + destroy() { + if (destroyed) return + destroyed = true + unsubscribePresentation?.() + unsubscribePresentation = undefined + presentationListeners.clear() + for (const layer of mounted) layer.surface.destroy() + mounted = [] + }, + } + + return surface + }, + } + return renderer +} + +function chartRenderLayers( + scene: ChartScene, + defaultRenderer: ChartRenderer, +): ChartRenderLayer[] { + const layers = [...splitRenderLayers(scene.nodes, defaultRenderer).layers] + const defaultGuides: SceneFocusGuide[] = [] + const customUnder: SceneFocusGuide[] = [] + const customOver: SceneFocusGuide[] = [] + for (const guide of scene.focusGuides ?? []) { + if ( + guide.renderer === undefined || + guide.renderer === (defaultRenderer as unknown) + ) { + defaultGuides.push(guide) + } else { + ;(guide.placement === 'under' ? customUnder : customOver).push(guide) + } + } + + if (customUnder.length > 0 || layers[0]?.renderer !== defaultRenderer) { + layers.unshift({ renderer: defaultRenderer, nodes: [] }) + } + if (customUnder.length > 0) { + layers.splice(1, 0, ...focusGuideLayers(customUnder)) + } + if (customOver.length > 0) { + layers.push(...focusGuideLayers(customOver)) + } + if (layers.at(-1)?.renderer !== defaultRenderer) { + layers.push({ renderer: defaultRenderer, nodes: [] }) + } + const top = layers.at(-1)! + if (defaultGuides.length > 0) top.focusGuides = defaultGuides + return layers +} + +function focusGuideLayers( + guides: readonly SceneFocusGuide[], +): ChartRenderLayer[] { + const layers: ChartRenderLayer[] = [] + for (const guide of guides) { + const renderer = requiredLayerRenderer(guide.renderer!) + const previous = layers.at(-1) + if (previous?.renderer === renderer) { + previous.focusGuides = [...(previous.focusGuides ?? []), guide] + } else { + layers.push({ renderer, nodes: [], focusGuides: [guide] }) + } + } + return layers +} + +function splitRenderLayers( + nodes: readonly SceneNode[], + defaultRenderer: ChartRenderer, +): { layers: ChartRenderLayer[]; hasCustomRenderer: boolean } { + const layers: ChartRenderLayer[] = [] + let hasCustomRenderer = false + const append = (layer: ChartRenderLayer) => { + const previous = layers.at(-1) + if (previous?.renderer === layer.renderer) { + previous.nodes = [...previous.nodes, ...layer.nodes] + } else { + layers.push(layer) + } + } + for (const node of nodes) { + if (node.renderer) { + const renderer = requiredLayerRenderer(node.renderer) + hasCustomRenderer ||= renderer !== defaultRenderer + append({ + renderer, + nodes: [{ ...node, renderer: undefined }], + }) + continue + } + if (node.kind !== 'group') { + append({ renderer: defaultRenderer, nodes: [node] }) + continue + } + const children = splitRenderLayers(node.children, defaultRenderer) + if (!children.hasCustomRenderer) { + append({ renderer: defaultRenderer, nodes: [node] }) + continue + } + hasCustomRenderer = true + for (const layer of children.layers) { + append({ + renderer: layer.renderer, + nodes: [{ ...node, children: layer.nodes }], + }) + } + } + return { layers, hasCustomRenderer } +} + +function requiredLayerRenderer( + renderer: NonNullable, +): ChartRenderer { + const candidate = renderer as unknown as Partial + if ( + typeof candidate.prerender !== 'function' || + typeof candidate.mount !== 'function' + ) { + throw new TypeError( + `Mark renderer "${renderer.id}" does not implement the DOM chart renderer contract`, + ) + } + return renderer as unknown as ChartRenderer +} + +function layerScene( + scene: ChartScene, + layer: ChartRenderLayer, + index: number, +): ChartScene { + return { + ...scene, + nodes: layer.nodes, + focusGuides: layer.focusGuides, + theme: + index === 0 + ? scene.theme + : { ...scene.theme, background: 'transparent' as const }, + } +} + +function layerOptions( + options: ChartSurfaceRenderOptions, + index: number, + animate = true, +): ChartSurfaceRenderOptions { + return { + ...options, + className: undefined, + tabIndex: -1, + idPrefix: `${options.idPrefix ?? 'ts-chart'}-layer-${index}`, + animation: animate ? options.animation : undefined, + } +} + +function findOrCreateLayerRoot(container: HTMLElement): HTMLDivElement { + const existing = container.querySelector( + ':scope > .ts-chart-layers', + ) + if (existing) return existing + const root = container.ownerDocument.createElement('div') + container.replaceChildren(root) + return root +} + +function configureLayerRoot( + root: HTMLDivElement, + scene: ChartScene, + options: ChartSurfaceRenderOptions, +) { + const className = options.className + ? `ts-chart ts-chart-layers ${options.className}` + : 'ts-chart ts-chart-layers' + if (root.className !== className) root.className = className + setAttributeIfChanged(root, 'role', 'img') + setAttributeIfChanged(root, 'aria-roledescription', 'chart') + setAttributeIfChanged(root, 'aria-label', options.ariaLabel) + if (options.ariaDescription) { + setAttributeIfChanged(root, 'aria-description', options.ariaDescription) + } else if (root.hasAttribute('aria-description')) { + root.removeAttribute('aria-description') + } + const tabIndex = options.tabIndex ?? 0 + if (root.tabIndex !== tabIndex) root.tabIndex = tabIndex + const width = String(scene.width) + const height = String(scene.height) + if (root.dataset.tsChartWidth !== width) root.dataset.tsChartWidth = width + if (root.dataset.tsChartHeight !== height) root.dataset.tsChartHeight = height + if (root.style.display !== 'block') root.style.display = 'block' + if (root.style.position !== 'relative') root.style.position = 'relative' + if (root.style.width !== '100%') root.style.width = '100%' + if (root.style.height !== '100%') root.style.height = '100%' + if (root.style.overflow !== 'visible') root.style.overflow = 'visible' +} + +function setAttributeIfChanged(element: Element, name: string, value: string) { + if (element.getAttribute(name) !== value) element.setAttribute(name, value) +} + +function sameLayerRenderers( + mounted: readonly MountedLayer[], + layers: readonly ChartRenderLayer[], +) { + return ( + mounted.length === layers.length && + mounted.every((layer, index) => layer.renderer === layers[index]?.renderer) + ) +} + +function mountLayers( + root: HTMLDivElement, + layers: readonly ChartRenderLayer[], + requestRender: (force?: boolean) => void, + adoptExisting: boolean, +): MountedLayer[] { + const existing = [ + ...root.querySelectorAll(':scope > .ts-chart-layer'), + ] + const canAdopt = adoptExisting && existing.length === layers.length + if (!canAdopt) root.replaceChildren() + return layers.map((layer, index) => { + const container = canAdopt + ? existing[index]! + : createLayerContainer(root, index) + return { + renderer: layer.renderer, + container, + surface: layer.renderer.mount(container, requestRender), + } + }) +} + +function createLayerContainer(root: HTMLDivElement, index: number) { + const container = root.ownerDocument.createElement('div') + container.className = 'ts-chart-layer' + container.dataset.tsChartLayer = String(index) + container.setAttribute('aria-hidden', 'true') + Object.assign(container.style, { + position: 'absolute', + inset: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }) + root.append(container) + return container +} + export const canvasChartRenderer = createUniversalCanvasChartRenderer() export function mountCanvasChart< diff --git a/packages/charts-core/src/cartesian-scale-types.test.ts b/packages/charts-core/src/cartesian-scale-types.test.ts new file mode 100644 index 00000000..9b7b986e --- /dev/null +++ b/packages/charts-core/src/cartesian-scale-types.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import { areaY } from './area' +import { areaX } from './area-x' +import { arrow } from './arrow' +import { bandX, bandY } from './band' +import { barX, barY } from './bar' +import { boxX, boxY } from './box' +import { crosshair } from './crosshair' +import { differenceX, differenceY } from './difference' +import { dot } from './dot' +import { focusGuideX } from './focus-guide' +import { whenFocused } from './focus-mark' +import { hexagon } from './hexagon' +import { lineX, lineY } from './line' +import { link } from './link' +import { compositeMark } from './mark-composite' +import { decorative } from './mark-decorative' +import { createMark } from './mark' +import { createMarkWithScaleValues } from './mark-with-scale-values' +import { rect } from './rect' +import { linearRegressionX, linearRegressionY } from './regression' +import { ridgelineX, ridgelineY } from './ridgeline' +import { ruleX, ruleY } from './rule' +import { whenSelected } from './selection' +import type { KeyedSelection } from './selection' +import { delaunayLink } from './spatial-delaunay' +import { densityContour } from './spatial-density' +import { hexbin } from './spatial-hexbin' +import { voronoi } from './spatial-voronoi' +import { text } from './text' +import { tickX, tickY } from './tick' +import { vector } from './vector' +import { violinX, violinY } from './violin' +import type { ChartMark } from './types' + +type ScaleIds = + TMark extends ChartMark< + any, + any, + any, + any, + any, + infer TXScaleId, + infer TYScaleId + > + ? readonly [TXScaleId, TYScaleId] + : never + +interface Row { + id: string + category: string + x: number + y: number + x2: number + y2: number + height: number + width: number +} + +const rows: readonly Row[] = [] + +if (false) { + const twoAxisMarks = [ + areaY(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + areaX(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + arrow(rows, { + x1: 'x', + y1: 'y', + x2: 'x2', + y2: 'y2', + xScale: 'namedX', + yScale: 'namedY', + }), + barY(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + barX(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + boxY(rows, { + x: 'category', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + boxX(rows, { + x: 'x', + y: 'category', + xScale: 'namedX', + yScale: 'namedY', + }), + crosshair({ xScale: 'namedX', yScale: 'namedY' }), + differenceY(rows, { + x: 'x', + y1: 'y', + y2: 'y2', + xScale: 'namedX', + yScale: 'namedY', + }), + differenceX(rows, { + x1: 'x', + x2: 'x2', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + dot(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + focusGuideX(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + hexagon(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + lineY(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + lineX(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + link(rows, { + x1: 'x', + y1: 'y', + x2: 'x2', + y2: 'y2', + xScale: 'namedX', + yScale: 'namedY', + }), + rect(rows, { + x1: 'x', + y1: 'y', + x2: 'x2', + y2: 'y2', + xScale: 'namedX', + yScale: 'namedY', + }), + linearRegressionY(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + linearRegressionX(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + ridgelineY(rows, { + x: 'x', + y: 'category', + height: 'height', + xScale: 'namedX', + yScale: 'namedY', + }), + ridgelineX(rows, { + x: 'category', + y: 'y', + height: 'height', + xScale: 'namedX', + yScale: 'namedY', + }), + delaunayLink(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + densityContour(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + hexbin(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + voronoi(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }), + text(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + tickX(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + tickY(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + vector(rows, { x: 'x', y: 'y', xScale: 'namedX', yScale: 'namedY' }), + violinY(rows, { + x: 'category', + y: 'y', + width: 'width', + xScale: 'namedX', + yScale: 'namedY', + }), + violinX(rows, { + x: 'x', + y: 'category', + width: 'width', + xScale: 'namedX', + yScale: 'namedY', + }), + ] as const + + twoAxisMarks.forEach((mark) => { + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + }) + + const namedBandX = bandX(rows, { x: 'x', xScale: 'namedX' }) + const namedBandY = bandY(rows, { y: 'y', yScale: 'namedY' }) + const namedRuleX = ruleX(rows, { x: 'x', xScale: 'namedX' }) + const namedRuleY = ruleY(rows, { y: 'y', yScale: 'namedY' }) + + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'y'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'y'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['x', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['x', 'namedY'] + >() + + const custom = createMark(() => ({ + id: 'custom', + channels: { + x: { scale: 'namedX', values: [] }, + y: { scale: 'namedY', values: [] }, + }, + render: () => ({ nodes: [] }), + })) + const customWithScaleValues = createMarkWithScaleValues< + Row, + number, + number, + number, + number, + 'namedX', + 'namedY' + >(() => ({ + id: 'custom-with-scale-values', + channels: { + x: { scale: 'namedX', values: [] }, + y: { scale: 'namedY', values: [] }, + }, + render: () => ({ nodes: [] }), + })) + const namedLine = lineY(rows, { + x: 'x', + y: 'y', + xScale: 'namedX', + yScale: 'namedY', + }) + const decorated = decorative(namedLine) + const focused = whenFocused(namedLine) + const selected = whenSelected( + namedLine, + null as unknown as KeyedSelection, + ) + const composite = compositeMark([namedLine] as const) + + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + expectTypeOf>().toEqualTypeOf< + readonly ['namedX', 'namedY'] + >() + + const explicitUndefined = lineY(rows, { + x: 'x', + y: 'y', + xScale: undefined, + yScale: undefined, + }) + expectTypeOf>().toEqualTypeOf< + readonly ['x', 'y'] + >() +} + +describe('Cartesian scale ID types', () => { + it('typechecks every positional built-in and custom factory', () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/charts-core/src/cartesian-scales.test.ts b/packages/charts-core/src/cartesian-scales.test.ts new file mode 100644 index 00000000..ed798c5e --- /dev/null +++ b/packages/charts-core/src/cartesian-scales.test.ts @@ -0,0 +1,379 @@ +import { scaleBand, scaleLinear } from 'd3-scale' +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { crosshair } from './crosshair' +import { lineY } from './line' +import { createChartScene, defineChart } from './scene' +import type { SceneNode, SceneRule } from './types' + +const legacyScaleWarning = + '[TanStack Charts] Root `x` and `y` options are deprecated. Move them to `scales.x` and `scales.y`. When neither Cartesian scale is used, set `scales` to `{ x: null, y: null }`. This compatibility will be removed when TanStack Charts enters Alpha.' + +describe('Cartesian scale registry', () => { + it('renders legacy root scales and warns once with migration guidance', async () => { + vi.resetModules() + const freshScene = await import('./scene') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const definition = freshScene.defineChart({ + marks: [lineY([2, 4, 6])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 6]) }, + }) + + const first = freshScene.createChartScene(definition, { + width: 480, + height: 260, + }) + freshScene.createChartScene(definition, { width: 640, height: 320 }) + + expect(first.points).toHaveLength(3) + expect(first.scales.x?.domain).toEqual([0, 2]) + expect(first.scales.y?.domain).toEqual([0, 6]) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(legacyScaleWarning) + }) + + it('warns when a scale-free legacy definition omits the registry', async () => { + vi.resetModules() + const freshScene = await import('./scene') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + freshScene.createChartScene(freshScene.defineChart({ marks: [] }), { + width: 320, + height: 180, + }) + + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(legacyScaleWarning) + }) + + it('warns in browsers without a process global', async () => { + vi.resetModules() + const freshScene = await import('./scene') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const nodeProcess = globalThis.process + vi.stubGlobal('process', undefined) + + try { + freshScene.createChartScene( + freshScene.defineChart({ + marks: [lineY([1, 2, 3])], + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + }), + { width: 480, height: 260 }, + ) + } finally { + vi.stubGlobal('process', nodeProcess) + } + + expect(warn).toHaveBeenCalledWith(legacyScaleWarning) + }) + + it('removes the full legacy warning from production bundles', async () => { + const output = execFileSync( + resolve('node_modules/.bin/esbuild'), + [ + resolve('packages/charts-core/src/scene.ts'), + '--bundle', + '--platform=browser', + '--format=esm', + '--minify', + '--define:process.env.NODE_ENV="production"', + ], + { encoding: 'utf8' }, + ) + + expect(output).not.toContain(legacyScaleWarning) + }) + + it('binds marks to named y scales and stacks axes sharing the right side', () => { + const scene = createNamedScaleScene() + const percentPoint = scene.points.find( + (point) => point.markId === 'percent', + ) + const rules = flatten(scene.nodes).filter( + (node): node is SceneRule => node.kind === 'rule', + ) + const percentAxis = rules.find((node) => node.key === 'percent-axis') + const temperatureAxis = rules.find( + (node) => node.key === 'temperature-axis', + ) + if (!percentAxis || !temperatureAxis) { + throw new Error('Expected both named right-side axes') + } + + expect(scene.scales.percent?.domain).toEqual([0, 1]) + expect(percentPoint?.y).toBe(scene.scales.percent?.map(0.5)) + expect(percentPoint?.y).not.toBe(scene.scales.y?.map(0.5)) + expect(percentAxis.x1).toBe(scene.chart.x + scene.chart.width) + expect(temperatureAxis.x1).toBeGreaterThan(percentAxis.x1) + }) + + it('supports top, right, bottom, and left axis sides', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2])], + scales: { + x: { scale: scaleLinear().domain([0, 1]), side: 'bottom' }, + y: { scale: scaleLinear().domain([0, 2]), side: 'left' }, + top: { + channel: 'x', + side: 'top', + scale: scaleLinear().domain([0, 1]), + }, + right: { + channel: 'y', + side: 'right', + scale: scaleLinear().domain([0, 2]), + }, + }, + }), + { width: 480, height: 260 }, + ) + const rules = flatten(scene.nodes).filter( + (node): node is SceneRule => node.kind === 'rule', + ) + + expect(rules.find((node) => node.key === 'top-axis')?.y1).toBe( + scene.chart.y, + ) + expect(rules.find((node) => node.key === 'right-axis')?.x1).toBe( + scene.chart.x + scene.chart.width, + ) + expect(rules.find((node) => node.key === 'x-axis')?.y1).toBe( + scene.chart.y + scene.chart.height, + ) + expect(rules.find((node) => node.key === 'y-axis')?.x1).toBe(scene.chart.x) + }) + + it('keeps named grids off by default', () => { + const keys = flatten(createNamedScaleScene().nodes).map((node) => node.key) + + expect(keys.some((key) => key.startsWith('y-grid:'))).toBe(true) + expect(keys.some((key) => key.startsWith('percent-grid:'))).toBe(false) + expect(keys.some((key) => key.startsWith('temperature-grid:'))).toBe(false) + }) + + it('renders an enabled grid when its axis is disabled', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: false, + grid: true, + }, + }, + }), + { width: 480, height: 260 }, + ) + const keys = flatten(scene.nodes).map((node) => node.key) + + expect(keys.some((key) => key.startsWith('y-grid:'))).toBe(true) + expect(keys).not.toContain('y-axis') + }) + + it('reports missing, reserved, and mismatched scale bindings', () => { + expect(() => + createChartScene( + defineChart({ + marks: [lineY([1, 2, 3], { yScale: 'missing' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + }, + }), + { width: 480, height: 260 }, + ), + ).toThrowError( + 'Chart scale "missing" is used by a mark but is not configured', + ) + + expect(() => + createChartScene( + defineChart({ + marks: [lineY([1, 2, 3], { yScale: 'color' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + }, + }), + { width: 480, height: 260 }, + ), + ).toThrowError('Position scales cannot use reserved ID "color"') + + expect(() => + createChartScene( + defineChart({ + marks: [lineY([1, 2, 3], { yScale: 'alternate' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + alternate: { + channel: 'x', + scale: scaleLinear().domain([0, 3]), + }, + }, + }), + { width: 480, height: 260 }, + ), + ).toThrowError( + 'Chart scale "alternate" is configured for x but is used as y', + ) + }) + + it('validates named channels, reserved channels, and axis sides', () => { + expect(() => + createChartScene( + defineChart({ + marks: [], + scales: { + x: null, + y: null, + alternate: { scale: scaleLinear() }, + }, + }), + { width: 200, height: 120 }, + ), + ).toThrowError( + 'Named chart scale "alternate" requires channel: "x" or channel: "y"', + ) + + expect(() => + createChartScene( + defineChart({ + marks: [], + scales: { + x: { channel: 'y', scale: scaleLinear() }, + y: null, + }, + }), + { width: 200, height: 120 }, + ), + ).toThrowError('Chart scale "x" is configured for x but is used as y') + + expect(() => + createChartScene( + defineChart({ + marks: [], + scales: { + x: null, + y: null, + alternate: { + channel: 'x', + side: 'left', + scale: scaleLinear(), + }, + }, + }), + { width: 200, height: 120 }, + ), + ).toThrowError( + 'Chart scale "alternate" uses x and cannot render an axis on the left side', + ) + }) + + it('does not replace canonical null scales with legacy root values', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const scene = createChartScene( + defineChart({ + marks: [], + guides: false, + scales: { x: null, y: null }, + x: { scale: scaleLinear().domain([0, 10]) }, + }), + { width: 480, height: 260 }, + ) + + expect(scene.scales.x?.type).toBe('none') + expect(scene.scales.y?.type).toBe('none') + }) + + it('allows focus-guide-only marks to bind null scales', () => { + const scene = createChartScene( + defineChart({ + marks: [crosshair({ x: true, y: true })], + guides: false, + scales: { x: null, y: null }, + }), + { width: 480, height: 260 }, + ) + + expect(scene.scales.x?.type).toBe('none') + expect(scene.scales.y?.type).toBe('none') + expect(scene.focusGuides).toHaveLength(1) + }) + + it('keeps stacked empty-domain axes inside the scene', () => { + const scene = createChartScene( + defineChart({ + marks: [], + scales: { + x: null, + y: { scale: scaleBand().domain([]) }, + alternate: { + channel: 'y', + side: 'left', + scale: scaleBand().domain([]), + }, + }, + }), + { width: 200, height: 120 }, + ) + const axes = flatten(scene.nodes).filter( + (node): node is SceneRule => + node.kind === 'rule' && + (node.key === 'y-axis' || node.key === 'alternate-axis'), + ) + + expect(axes).toHaveLength(2) + expect(axes.every((axis) => axis.x1 >= 0 && axis.x2 >= 0)).toBe(true) + }) +}) + +function createNamedScaleScene() { + return createChartScene( + defineChart({ + marks: [ + lineY([{ x: 0, y: 25 }], { id: 'primary', x: 'x', y: 'y' }), + lineY([{ x: 0, y: 0.5 }], { + id: 'percent', + x: 'x', + y: 'y', + yScale: 'percent', + }), + lineY([{ x: 0, y: 20 }], { + id: 'temperature', + x: 'x', + y: 'y', + yScale: 'temperature', + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 100]), grid: true }, + percent: { + channel: 'y', + side: 'right', + scale: scaleLinear().domain([0, 1]), + }, + temperature: { + channel: 'y', + side: 'right', + scale: scaleLinear().domain([-40, 40]), + }, + }, + }), + { width: 640, height: 320 }, + ) +} + +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/configured-scale.test.ts b/packages/charts-core/src/configured-scale.test.ts index 1dae2ed1..d40354cd 100644 --- a/packages/charts-core/src/configured-scale.test.ts +++ b/packages/charts-core/src/configured-scale.test.ts @@ -13,7 +13,7 @@ import { lineY } from './line' import { ruleY } from './rule' import { resolveScaleInput } from './scale-input' import type { - ChartAxisOptions, + ChartPositionScaleOptions, ChartScale, ChartSpec, ConfiguredScaleLike, @@ -224,7 +224,7 @@ describe('configured scales', () => { x: { scale: scaleBand, viewport: { domain: ['A', 'B'] }, - } as unknown as ChartAxisOptions, + } as unknown as ChartPositionScaleOptions, y: { scale: scaleLinear().domain([0, 1]) }, }), { width: 480, height: 260 }, diff --git a/packages/charts-core/src/configured-scale.ts b/packages/charts-core/src/configured-scale.ts index 72da8a31..8d4b99bb 100644 --- a/packages/charts-core/src/configured-scale.ts +++ b/packages/charts-core/src/configured-scale.ts @@ -23,7 +23,7 @@ export function resolveConfiguredScale( const viewport = resolveViewport(scale, context, contentDomain) const categorical = scale.bandwidth !== undefined const naturalRange = - categorical && context.id === 'y' + categorical && context.channel === 'y' ? ([Math.min(...context.range), Math.max(...context.range)] as const) : context.range const range = context.options?.reverse diff --git a/packages/charts-core/src/crosshair.ts b/packages/charts-core/src/crosshair.ts index 59e31642..5f1e9969 100644 --- a/packages/charts-core/src/crosshair.ts +++ b/packages/charts-core/src/crosshair.ts @@ -2,6 +2,7 @@ import { resolveCrosshairGuide } from './crosshair-resolver' import { createMark } from './mark' import { valueKey } from './scales' import type { + CartesianScaleBindings, ChartMark, ChartMarkMotionOptions, ChartValue, @@ -70,7 +71,10 @@ export interface CrosshairOptions< TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, > - extends ChartMarkMotionOptions, CrosshairRuleOptions { + extends + ChartMarkMotionOptions, + CrosshairRuleOptions, + CartesianScaleBindings { id?: string /** Draws the vertical guide at the focused x position. Defaults to true. */ x?: boolean | CrosshairAxisOptions @@ -84,37 +88,62 @@ export interface CrosshairOptions< export function crosshair< TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( - options: CrosshairOptions = {}, -): ChartMark { - return createMark(({ markIndex }) => { - const id = options.id ?? `crosshair-${markIndex}` - - return { - id, - focusGuideOnly: true, - channels: {}, - render: ({ chart, surface, scales, theme, layout }) => ({ - nodes: [], - focusGuides: [ - { - key: id, - markId: id, - chart, - surface, - x: resolveAxis(options.x, options, theme.foreground, scales.x), - y: resolveAxis(options.y, options, theme.foreground, scales.y), - marker: resolveMarker(options.marker), - projectX: projector(scales.x), - projectY: projector(scales.y), - motion: options.motion, - measureText: layout.measureText, - resolve: resolveCrosshairGuide, - }, - ], - }), - } - }, options.motion) + options: CrosshairOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + } = {}, +): ChartMark { + const xScale = (options.xScale ?? 'x') as TXScaleId + const yScale = (options.yScale ?? 'y') as TYScaleId + + return createMark( + ({ markIndex }) => { + const id = options.id ?? `crosshair-${markIndex}` + + return { + id, + focusGuideOnly: true, + channels: { + x: { scale: xScale, values: [] }, + y: { scale: yScale, values: [] }, + }, + render: ({ chart, surface, scales, theme, layout }) => ({ + nodes: [], + focusGuides: [ + { + key: id, + markId: id, + chart, + surface, + x: resolveAxis( + options.x, + options, + theme.foreground, + scales[xScale], + ), + y: resolveAxis( + options.y, + options, + theme.foreground, + scales[yScale], + ), + marker: resolveMarker(options.marker), + projectX: projector(scales[xScale]), + projectY: projector(scales[yScale]), + motion: options.motion, + measureText: layout.measureText, + resolve: resolveCrosshairGuide, + }, + ], + }), + } + }, + options.motion, + options.renderer, + ) } function resolveAxis( diff --git a/packages/charts-core/src/difference.test.ts b/packages/charts-core/src/difference.test.ts index 186a9179..de0e0266 100644 --- a/packages/charts-core/src/difference.test.ts +++ b/packages/charts-core/src/difference.test.ts @@ -496,7 +496,7 @@ describe('difference marks', () => { scene.scales.y.map(new Date('2026-01-02T00:00:00Z')), ] - expectTypeOf(mark).toEqualTypeOf< + expectTypeOf(mark).toMatchTypeOf< ChartMark, number, Date> >() expect(nodes.filter(isArea)).toHaveLength(2) diff --git a/packages/charts-core/src/difference.ts b/packages/charts-core/src/difference.ts index a0db3888..6ce28a7c 100644 --- a/packages/charts-core/src/difference.ts +++ b/packages/charts-core/src/difference.ts @@ -16,11 +16,13 @@ import { groupedIndexes } from './transform-internal' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartLineStateStyle, ChartMark, + ChartMarkMotionOptions, ChartMarkState, - ChartMotionDefinition, ResolvedScale, VisualChannel, } from './types' @@ -50,10 +52,10 @@ export type DifferenceDatum< TIndependent extends DifferenceIndependent = DifferenceIndependent, > = TDatum | DifferenceAreaDatum -interface DifferenceOptions< - TDatum, - TIndependent extends DifferenceIndependent, -> { +interface DifferenceOptions + extends + CartesianScaleBindings, + ChartMarkMotionOptions> { id?: string z?: Channel key?: Channel @@ -84,7 +86,6 @@ interface DifferenceOptions< TDatum, ChartLineStateStyle >[] - motion?: ChartMotionDefinition> } export interface DifferenceYOptions< @@ -143,18 +144,27 @@ export function differenceY< NoInfer, DifferenceIndependent | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: DifferenceYCallOptions, + options: DifferenceYCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< DifferenceDatum>, IndependentOutput, - number + number, + IndependentOutput, + number, + TXScaleId, + TYScaleId > export function differenceY( source: Iterable, options: DifferenceYOptions>, -): ChartMark { +): CartesianChartMark> { return difference(source, options, options.x, options.y1, options.y2, 'y') } @@ -165,18 +175,27 @@ export function differenceX< NoInfer, DifferenceIndependent | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: DifferenceXCallOptions, + options: DifferenceXCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< DifferenceDatum>, number, - IndependentOutput + IndependentOutput, + number, + IndependentOutput, + TXScaleId, + TYScaleId > export function differenceX( source: Iterable, options: DifferenceXOptions>, -): ChartMark { +): CartesianChartMark> { return difference(source, options, options.y, options.x1, options.x2, 'x') } @@ -217,97 +236,119 @@ function difference( comparison: number | Channel, primary: number | Channel, orientation: 'x' | 'y', -): ChartMark { +): CartesianChartMark< + any, + any, + any, + any, + any, + DifferenceOptions +> { const data = Array.isArray(source) ? source : Array.from(source) - - return createMark(({ markIndex }) => { - const id = options.id ?? `difference-${orientation}-${markIndex}` - const independentValues = channelValues(data, independent, () => undefined) - const comparisonValues = numericValues(data, comparison) - const primaryValues = numericValues(data, primary) - const groupValues = channelValues(data, options.z, () => null) - validateIndependentValues( - independentValues, - comparisonValues, - primaryValues, - orientation, - ) - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [independentValues], - markId: id, - warningIdentity: options, - }) - const semanticValid = data.map( - (_datum, index) => - isIndependent(independentValues[index]) && - isFiniteNumber(comparisonValues[index]) && - isFiniteNumber(primaryValues[index]), - ) - const domainIndependent = independentValues.filter( - (_value, index): _value is TIndependent => semanticValid[index]!, - ) - const domainDependent = [ - ...comparisonValues.filter( - (_value, index): _value is number => semanticValid[index]!, - ), - ...primaryValues.filter( - (_value, index): _value is number => semanticValid[index]!, - ), - ] - - return { - id, - channels: - orientation === 'y' - ? { - x: { scale: 'x', values: domainIndependent }, - y: { scale: 'y', values: domainDependent }, - } - : { - x: { scale: 'x', values: domainDependent }, - y: { scale: 'y', values: domainIndependent }, - }, - resolveLayout: ({ scales }) => { - const projection = resolveDifferenceScales(scales, orientation) - const positions = projectDifferenceValues( - independentValues, - comparisonValues, - primaryValues, - projection, - ) - const lobes = differenceLobes( - data, - independentValues, - comparisonValues, - primaryValues, - positions.independent, - positions.comparison, - positions.primary, - groupValues, - keys, - projection, - ) - const children = differenceChildren( - data, - options, - orientation, - materializeAreaRows(lobes), - independentValues, - comparisonValues, - primaryValues, - groupValues, - keys, - positions.valid, - ) - return adoptResolvedChildMark( - initializeCompositeMark(id, children, { - interactiveChildren: interactiveDifferenceChildren, - }), - ) - }, - } - }, options.motion) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' + + return createMark( + ({ markIndex }) => { + const id = options.id ?? `difference-${orientation}-${markIndex}` + const independentValues = channelValues( + data, + independent, + () => undefined, + ) + const comparisonValues = numericValues(data, comparison) + const primaryValues = numericValues(data, primary) + const groupValues = channelValues(data, options.z, () => null) + validateIndependentValues( + independentValues, + comparisonValues, + primaryValues, + orientation, + ) + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [independentValues], + markId: id, + warningIdentity: options, + }) + const semanticValid = data.map( + (_datum, index) => + isIndependent(independentValues[index]) && + isFiniteNumber(comparisonValues[index]) && + isFiniteNumber(primaryValues[index]), + ) + const domainIndependent = independentValues.filter( + (_value, index): _value is TIndependent => semanticValid[index]!, + ) + const domainDependent = [ + ...comparisonValues.filter( + (_value, index): _value is number => semanticValid[index]!, + ), + ...primaryValues.filter( + (_value, index): _value is number => semanticValid[index]!, + ), + ] + + return { + id, + channels: + orientation === 'y' + ? { + x: { scale: xScale, values: domainIndependent }, + y: { scale: yScale, values: domainDependent }, + } + : { + x: { scale: xScale, values: domainDependent }, + y: { scale: yScale, values: domainIndependent }, + }, + resolveLayout: ({ scales }) => { + const projection = resolveDifferenceScales( + scales, + orientation, + xScale, + yScale, + ) + const positions = projectDifferenceValues( + independentValues, + comparisonValues, + primaryValues, + projection, + ) + const lobes = differenceLobes( + data, + independentValues, + comparisonValues, + primaryValues, + positions.independent, + positions.comparison, + positions.primary, + groupValues, + keys, + projection, + ) + const children = differenceChildren( + data, + options, + orientation, + materializeAreaRows(lobes), + independentValues, + comparisonValues, + primaryValues, + groupValues, + keys, + positions.valid, + ) + return adoptResolvedChildMark( + initializeCompositeMark(id, children, { + interactiveChildren: interactiveDifferenceChildren, + }), + ) + }, + } + }, + options.motion, + options.renderer, + ) } function differenceChildren( @@ -324,7 +365,7 @@ function differenceChildren( groupValues: readonly (ChartKey | null | undefined)[], keys: readonly ChartKey[], sharedValid: readonly boolean[], -): ChartMark[] { +): ChartMark[] { const lineIndependent = (_datum: TDatum, { index }: { index: number }) => sharedValid[index] ? independentValues[index] : undefined const comparisonValue = (_datum: TDatum, { index }: { index: number }) => @@ -336,7 +377,7 @@ function differenceChildren( const lineKey = (_datum: TDatum, { index }: { index: number }) => keys[index] ?? index const noColor = () => null - const children: ChartMark[] = [] + const children: ChartMark[] = [] if (options.positiveFill !== null) { children.push( @@ -352,6 +393,8 @@ function differenceChildren( fill: options.positiveFill ?? '#3ca951', fillOpacity: options.positiveFillOpacity ?? options.fillOpacity ?? 0.2, + xScale: options.xScale, + yScale: options.yScale, }) : areaX(areas.positive, { id: 'positive', @@ -364,6 +407,8 @@ function differenceChildren( fill: options.positiveFill ?? '#3ca951', fillOpacity: options.positiveFillOpacity ?? options.fillOpacity ?? 0.2, + xScale: options.xScale, + yScale: options.yScale, }), ) } @@ -381,6 +426,8 @@ function differenceChildren( fill: options.negativeFill ?? '#4269d0', fillOpacity: options.negativeFillOpacity ?? options.fillOpacity ?? 0.2, + xScale: options.xScale, + yScale: options.yScale, }) : areaX(areas.negative, { id: 'negative', @@ -393,6 +440,8 @@ function differenceChildren( fill: options.negativeFill ?? '#4269d0', fillOpacity: options.negativeFillOpacity ?? options.fillOpacity ?? 0.2, + xScale: options.xScale, + yScale: options.yScale, }), ) } @@ -414,6 +463,8 @@ function differenceChildren( strokeDasharray: options.comparisonStrokeDasharray, points: options.points, states: options.comparisonStates, + xScale: options.xScale, + yScale: options.yScale, }) : lineX(data, { id: 'comparison', @@ -430,6 +481,8 @@ function differenceChildren( strokeDasharray: options.comparisonStrokeDasharray, points: options.points, states: options.comparisonStates, + xScale: options.xScale, + yScale: options.yScale, }), orientation === 'y' ? lineY(data, { @@ -445,6 +498,8 @@ function differenceChildren( strokeDasharray: options.strokeDasharray, points: options.points, states: options.states, + xScale: options.xScale, + yScale: options.yScale, }) : lineX(data, { id: 'primary', @@ -459,6 +514,8 @@ function differenceChildren( strokeDasharray: options.strokeDasharray, points: options.points, states: options.states, + xScale: options.xScale, + yScale: options.yScale, }), ) @@ -477,10 +534,12 @@ function numericValues( function resolveDifferenceScales( scales: Readonly>, orientation: 'x' | 'y', + xScale: string, + yScale: string, ): DifferenceScales { const owner = orientation === 'y' ? 'differenceY' : 'differenceX' - const x = scales.x - const y = scales.y + const x = scales[xScale] + const y = scales[yScale] if (!x || !y) { throw new TypeError(`${owner}: x and y scales are required`) } diff --git a/packages/charts-core/src/dom-types.ts b/packages/charts-core/src/dom-types.ts index b18a0462..b572a78d 100644 --- a/packages/charts-core/src/dom-types.ts +++ b/packages/charts-core/src/dom-types.ts @@ -6,6 +6,7 @@ import type { ChartHostControl, ChartHostControlExtensionToken, ChartMotionTransition, + ChartMarkRenderer, ChartPoint, ChartScene, ChartSvgRenderer, @@ -95,6 +96,10 @@ export interface ChartSurface< > { readonly renderer: ChartRenderer readonly element: Element + /** Ordered child surfaces when this surface composes multiple renderers. */ + readonly layers?: readonly ChartSurface[] + /** Topmost surface element owned by the chart's default renderer. */ + readonly defaultElement?: Element render: ( scene: ChartScene, options: ChartSurfaceRenderOptions, @@ -180,6 +185,17 @@ export interface ChartRenderer< ) => ChartSurface } +export interface ChartLayerRenderer< + TDatum = unknown, + TXValue extends ChartValue = ChartValue, + TYValue extends ChartValue = ChartValue, +> + extends ChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} + /** A definition-agnostic renderer that acquires chart types from its host. */ export interface UniversalChartRenderer { readonly id: string @@ -202,6 +218,13 @@ export interface UniversalChartRenderer { ) => ChartSurface } +export interface UniversalChartLayerRenderer + extends UniversalChartRenderer, ChartMarkRenderer { + compose: ( + defaultRenderer: ChartRenderer, + ) => ChartRenderer +} + export interface ChartRendererRenderContext< TDatum = unknown, TXValue extends ChartValue = ChartValue, @@ -304,6 +327,7 @@ export interface ChartRenderContext< container: HTMLElement svg: SVGSVGElement scene: ChartScene + surface: ChartSurface interaction: ChartInteractionController } diff --git a/packages/charts-core/src/dom.ts b/packages/charts-core/src/dom.ts index d28877ef..aaa47c67 100644 --- a/packages/charts-core/src/dom.ts +++ b/packages/charts-core/src/dom.ts @@ -40,13 +40,19 @@ export function mountChart< renderer, onRender: onRender ? ({ container: hostContainer, scene, surface, interaction }) => { - const svg = surface.element + const svg = surface.defaultElement ?? surface.element const SvgElement = container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { throw new TypeError('Expected the SVG chart surface.') } - onRender({ container: hostContainer, scene, svg, interaction }) + onRender({ + container: hostContainer, + scene, + surface, + svg, + interaction, + }) } : undefined, } diff --git a/packages/charts-core/src/dot.ts b/packages/charts-core/src/dot.ts index f8225f83..1c38c922 100644 --- a/packages/charts-core/src/dot.ts +++ b/packages/charts-core/src/dot.ts @@ -20,6 +20,8 @@ import type { } from './resolved-layout-position' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartBounds, ChartKey, ChartMark, @@ -34,7 +36,8 @@ import type { SceneNode, } from './types' -export interface DotOptions extends ChartMarkMotionOptions { +export interface DotOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel y?: Channel @@ -91,20 +94,23 @@ export function dot< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, DotPointX, DotPointY, DotScaleX, - DotScaleY + DotScaleY, + TOptions > export function dot( source: Iterable, options: DotOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMarkWithScaleValues( + return createMarkWithScaleValues( ({ markIndex }) => { const id = options.id ?? `dot-${markIndex}` const layout = options.layout @@ -237,10 +243,10 @@ export function dot( const channels = { ...(layout?.axis !== 'x' - ? { x: { scale: 'x', values: xValues.filter(isChartValue) } } + ? { x: { scale: xScale, values: xValues.filter(isChartValue) } } : {}), ...(layout?.axis !== 'y' - ? { y: { scale: 'y', values: yValues.filter(isChartValue) } } + ? { y: { scale: yScale, values: yValues.filter(isChartValue) } } : {}), color: { scale: 'color', @@ -258,12 +264,12 @@ export function dot( return { ...initialized, render: ({ scales, color: resolveColor }) => { - const xScale = requiredScale(scales.x, 'x') - const yScale = requiredScale(scales.y, 'y') + const resolvedXScale = requiredScale(scales[xScale], xScale) + const resolvedYScale = requiredScale(scales[yScale], yScale) const positions = projectLayoutY( - projectLayoutX(sourceRows, xValues, xScale), + projectLayoutX(sourceRows, xValues, resolvedXScale), yValues, - yScale, + resolvedYScale, ) return renderPositions(positions, resolveColor) }, @@ -277,7 +283,7 @@ export function dot( const measured = projectLayoutX( sourceRows, xValues, - requiredScale(scales.x, 'x'), + requiredScale(scales[xScale], xScale), ).filter((row) => isNonnegativeFiniteNumber(radii[row.sourceIndex])) const crossPositions = resolveCrossPositions( layout, @@ -301,7 +307,7 @@ export function dot( const measured = projectLayoutY( sourceRows, yValues, - requiredScale(scales.y, 'y'), + requiredScale(scales[yScale], yScale), ).filter((row) => isNonnegativeFiniteNumber(radii[row.sourceIndex])) const crossPositions = resolveCrossPositions( layout, @@ -325,14 +331,15 @@ export function dot( } }, options.motion, + options.renderer, ) } function requiredScale( scale: ResolvedScale | undefined, - axis: 'x' | 'y', + id: string, ): ResolvedScale { - if (!scale) throw new TypeError(`dot: missing ${axis} scale`) + if (!scale) throw new TypeError(`dot: missing ${id} scale`) return scale } diff --git a/packages/charts-core/src/export.test.ts b/packages/charts-core/src/export.test.ts index 81cad253..a728069e 100644 --- a/packages/charts-core/src/export.test.ts +++ b/packages/charts-core/src/export.test.ts @@ -175,4 +175,105 @@ describe('optional export', () => { getContext.mockRestore() toBlob.mockRestore() }) + + it('rasterizes mixed SVG and Canvas layers in their visual order', async () => { + const container = document.createElement('div') + container.innerHTML = ` +
+
+ +
+
+
+ + + + + +
+
+
+ +
+
` + const drawImage = vi.fn() + const getContext = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue({ + scale: vi.fn(), + fillRect: vi.fn(), + drawImage, + } as unknown as CanvasRenderingContext2D) + const toBlob = vi + .spyOn(HTMLCanvasElement.prototype, 'toBlob') + .mockImplementation((callback) => { + callback(new Blob(['layers'], { type: 'image/png' })) + }) + const urlDescriptor = { + create: Object.getOwnPropertyDescriptor(window.URL, 'createObjectURL'), + revoke: Object.getOwnPropertyDescriptor(window.URL, 'revokeObjectURL'), + } + Object.defineProperties(window.URL, { + createObjectURL: { + configurable: true, + value: vi.fn(() => 'blob:chart-layer'), + }, + revokeObjectURL: { configurable: true, value: vi.fn() }, + }) + const sourceDescriptor = Object.getOwnPropertyDescriptor( + HTMLImageElement.prototype, + 'src', + ) + Object.defineProperty(HTMLImageElement.prototype, 'src', { + configurable: true, + get() { + return 'blob:chart-layer' + }, + set() { + this.onload?.call(this, new Event('load')) + }, + }) + + try { + await expect( + renderChartImage(container, { scale: 1, includeFocus: true }), + ).resolves.toMatchObject({ type: 'image/png' }) + + const background = container.querySelector('.ts-chart-canvas__background') + const focusUnder = container.querySelector( + '.ts-chart-canvas__focus-under', + ) + const scene = container.querySelector('.ts-chart-canvas__scene') + const focus = container.querySelector('.ts-chart-canvas__focus') + expect(drawImage).toHaveBeenCalledTimes(6) + expect(drawImage.mock.calls.map((call) => call[0])).toEqual([ + expect.any(HTMLImageElement), + background, + focusUnder, + scene, + focus, + expect.any(HTMLImageElement), + ]) + expect(() => serializeChartSvg(container)).toThrow( + 'Mixed-renderer charts require raster export', + ) + } finally { + if (sourceDescriptor) { + Object.defineProperty( + HTMLImageElement.prototype, + 'src', + sourceDescriptor, + ) + } + for (const [key, descriptor] of Object.entries({ + createObjectURL: urlDescriptor.create, + revokeObjectURL: urlDescriptor.revoke, + })) { + if (descriptor) Object.defineProperty(window.URL, key, descriptor) + else delete (window.URL as unknown as Record)[key] + } + getContext.mockRestore() + toBlob.mockRestore() + } + }) }) diff --git a/packages/charts-core/src/export.ts b/packages/charts-core/src/export.ts index f3eca13c..09a8b16f 100644 --- a/packages/charts-core/src/export.ts +++ b/packages/charts-core/src/export.ts @@ -68,18 +68,21 @@ export async function renderChartImage( target: Element, options: RenderChartImageOptions = {}, ): Promise { - const svg = findSvg(target) - const canvasSurface = svg ? null : findCanvasSurface(target) - if (!svg && !canvasSurface) { + const layeredSurface = findLayeredSurface(target) + const svg = layeredSurface ? null : findSvg(target) + const canvasSurface = layeredSurface || svg ? null : findCanvasSurface(target) + if (!layeredSurface && !svg && !canvasSurface) { throw new Error('Expected a TanStack Chart SVG or Canvas surface') } - const source = svg ?? canvasSurface! + const source = layeredSurface ?? svg ?? canvasSurface! const document = source.ownerDocument const view = document.defaultView if (!view) throw new Error('Chart image export requires a browser document') - const dimensions = svg - ? svgDimensions(svg) - : canvasSurfaceDimensions(canvasSurface!) + const dimensions = layeredSurface + ? htmlSurfaceDimensions(layeredSurface) + : svg + ? svgDimensions(svg) + : canvasSurfaceDimensions(canvasSurface!) const width = options.width ?? dimensions.width const height = options.height ?? dimensions.height if (!(width > 0 && height > 0)) { @@ -97,52 +100,19 @@ export async function renderChartImage( context.fillRect(0, 0, width, height) } - if (svg) { - const serialized = serializeChartSvg(svg, { - ...options, + if (layeredSurface) { + await drawLayeredSurface( + context, + layeredSurface, width, height, - }) - const url = view.URL.createObjectURL( - new view.Blob([serialized], { type: 'image/svg+xml;charset=utf-8' }), + options, + view, ) - try { - const image = await loadImage(view, url) - context.drawImage(image, 0, 0, width, height) - } finally { - view.URL.revokeObjectURL(url) - } + } else if (svg) { + await drawSvg(context, svg, width, height, options, view) } else { - const base = canvasSurface!.querySelector( - '.ts-chart-canvas__base', - ) - const background = canvasSurface!.querySelector( - '.ts-chart-canvas__background', - ) - const focusUnder = canvasSurface!.querySelector( - '.ts-chart-canvas__focus-under', - ) - const scene = canvasSurface!.querySelector( - '.ts-chart-canvas__scene', - ) - if (!scene) throw new Error('Expected a Canvas chart scene layer') - if (!options.includeFocus && base) { - context.drawImage(base, 0, 0, width, height) - } else { - if (background) { - context.drawImage(background, 0, 0, width, height) - } - if (options.includeFocus && focusUnder) { - context.drawImage(focusUnder, 0, 0, width, height) - } - context.drawImage(scene, 0, 0, width, height) - const focus = canvasSurface!.querySelector( - '.ts-chart-canvas__focus', - ) - if (options.includeFocus && focus) { - context.drawImage(focus, 0, 0, width, height) - } - } + drawCanvasSurface(context, canvasSurface!, width, height, options) } return new Promise((resolve, reject) => { @@ -168,6 +138,11 @@ export async function downloadChartImage( } function resolveSvg(target: Element): SVGSVGElement { + if (findLayeredSurface(target)) { + throw new Error( + 'Mixed-renderer charts require raster export through renderChartImage', + ) + } const svg = findSvg(target) if (!svg) throw new Error('Expected a TanStack Chart SVG') return svg @@ -185,6 +160,12 @@ function findCanvasSurface(target: Element): HTMLElement | null { : target.querySelector('.ts-chart-canvas') } +function findLayeredSurface(target: Element): HTMLElement | null { + return target.classList.contains('ts-chart-layers') + ? (target as HTMLElement) + : target.querySelector('.ts-chart-layers') +} + function svgDimensions(svg: SVGSVGElement) { const values = (svg.getAttribute('viewBox') ?? '') .trim() @@ -214,6 +195,101 @@ function canvasSurfaceDimensions(surface: HTMLElement) { } } +function htmlSurfaceDimensions(surface: HTMLElement) { + const bounds = surface.getBoundingClientRect() + return { + width: positiveNumber(surface.dataset.tsChartWidth) || bounds.width, + height: positiveNumber(surface.dataset.tsChartHeight) || bounds.height, + } +} + +async function drawLayeredSurface( + context: CanvasRenderingContext2D, + surface: HTMLElement, + width: number, + height: number, + options: RenderChartImageOptions, + view: Window, +) { + const constructors = view as Window & typeof globalThis + const layers = [...surface.children].filter( + (element): element is HTMLElement => + element instanceof constructors.HTMLElement && + element.classList.contains('ts-chart-layer'), + ) + for (const layer of layers) { + const svg = layer.querySelector('svg.ts-chart') + const canvas = svg ? null : findCanvasSurface(layer) + if (svg) { + await drawSvg(context, svg, width, height, options, view) + } else if (canvas) { + drawCanvasSurface(context, canvas, width, height, options) + } else { + throw new Error('Layered chart export found an unsupported renderer') + } + } +} + +async function drawSvg( + context: CanvasRenderingContext2D, + svg: SVGSVGElement, + width: number, + height: number, + options: RenderChartImageOptions, + view: Window, +) { + const constructors = view as Window & typeof globalThis + const serialized = serializeChartSvg(svg, { ...options, width, height }) + const url = constructors.URL.createObjectURL( + new constructors.Blob([serialized], { + type: 'image/svg+xml;charset=utf-8', + }), + ) + try { + const image = await loadImage(view, url) + context.drawImage(image, 0, 0, width, height) + } finally { + constructors.URL.revokeObjectURL(url) + } +} + +function drawCanvasSurface( + context: CanvasRenderingContext2D, + surface: HTMLElement, + width: number, + height: number, + options: RenderChartImageOptions, +) { + const base = surface.querySelector( + '.ts-chart-canvas__base', + ) + const background = surface.querySelector( + '.ts-chart-canvas__background', + ) + const focusUnder = surface.querySelector( + '.ts-chart-canvas__focus-under', + ) + const scene = surface.querySelector( + '.ts-chart-canvas__scene', + ) + if (!scene) throw new Error('Expected a Canvas chart scene layer') + if (!options.includeFocus && base) { + context.drawImage(base, 0, 0, width, height) + return + } + if (background) context.drawImage(background, 0, 0, width, height) + if (options.includeFocus && focusUnder) { + context.drawImage(focusUnder, 0, 0, width, height) + } + context.drawImage(scene, 0, 0, width, height) + const focus = surface.querySelector( + '.ts-chart-canvas__focus', + ) + if (options.includeFocus && focus) { + context.drawImage(focus, 0, 0, width, height) + } +} + function positiveNumber(value: string | undefined): number { const number = Number(value) return Number.isFinite(number) && number > 0 ? number : 0 diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index e9b51e61..adb724f8 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -332,6 +332,40 @@ describe('facets', () => { ).toBeLessThanOrEqual(320) }) + it('rejects named guides that outer-axis composition cannot place safely', () => { + const data = [ + { group: 'A', x: 0, y: 1 }, + { group: 'B', x: 0, y: 2 }, + ] + const definition = facetChart(data, { + by: 'group', + chart: (group) => ({ + marks: [ + lineY(group, { + x: 'x', + y: 'y', + yScale: 'alternate', + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 2]) }, + alternate: { + channel: 'y' as const, + side: 'right' as const, + scale: scaleLinear().domain([0, 2]), + }, + }, + }), + }) + + expect(() => + createChartScene(definition, { width: 480, height: 260 }), + ).toThrowError( + 'cannot share outer axes because its cell scales or guide options differ', + ) + }) + it('composes nested facets with unique points and responsive outer flow', () => { const data = ['North', 'South'].flatMap((region) => ['A', 'B'].flatMap((series, seriesIndex) => [ diff --git a/packages/charts-core/src/facet.ts b/packages/charts-core/src/facet.ts index 37bd209d..74db5558 100644 --- a/packages/charts-core/src/facet.ts +++ b/packages/charts-core/src/facet.ts @@ -13,6 +13,7 @@ import type { ChartMargin, ChartMotionDefinition, ChartPoint, + ChartPositionScaleOptions, ChartScene, ChartSpec, ChartSpecDatum, @@ -97,114 +98,118 @@ export function facet( ChartValue, never, never - >(({ markIndex }) => { - const id = options.id ?? `facet-${markIndex}` - const keys = channelValues(data, options.by, () => '') - const groups = new Map>() - data.forEach((datum, index) => { - const key = keys[index] - if (!isKey(key)) return - const identity = valueKey(key) - const group = groups.get(identity) - if (group) group.data.push(datum) - else groups.set(identity, { key, data: [datum] }) - }) + >( + ({ markIndex }) => { + const id = options.id ?? `facet-${markIndex}` + const keys = channelValues(data, options.by, () => '') + const groups = new Map>() + data.forEach((datum, index) => { + const key = keys[index] + if (!isKey(key)) return + const identity = valueKey(key) + const group = groups.get(identity) + if (group) group.data.push(datum) + else groups.set(identity, { key, data: [datum] }) + }) + + return { + id, + channels: {}, + render: ({ chart, theme, layout }) => { + const entries = [...groups.values()] + const gap = Math.max(0, options.gap ?? 16) + const automaticColumns = Math.max( + 1, + Math.floor((chart.width + gap) / ((options.minWidth ?? 220) + gap)), + ) + const columns = Math.max( + 1, + Math.min( + entries.length || 1, + Math.floor(options.columns ?? automaticColumns), + ), + ) + const rows = Math.max(1, Math.ceil(entries.length / columns)) + const cellWidth = cellSize(chart.width, gap, columns) + 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), + ) - return { - id, - channels: {}, - render: ({ chart, theme, layout }) => { - const entries = [...groups.values()] - const gap = Math.max(0, options.gap ?? 16) - const automaticColumns = Math.max( - 1, - Math.floor((chart.width + gap) / ((options.minWidth ?? 220) + gap)), - ) - const columns = Math.max( - 1, - Math.min( - entries.length || 1, - Math.floor(options.columns ?? automaticColumns), - ), - ) - const rows = Math.max(1, Math.ceil(entries.length / columns)) - const cellWidth = cellSize(chart.width, gap, columns) - 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), - ) - - if ( - options.axes === 'cell' || - entries.length <= 1 || - definitions.every((definition) => definition.guides === false) - ) { - return renderCellAxes({ + if ( + options.axes === 'cell' || + entries.length <= 1 || + definitions.every((definition) => definition.guides === false) + ) { + return renderCellAxes({ + id, + entries, + definitions, + chart, + columns, + cellWidth, + cellHeight, + gap, + labelHeight, + showLabel, + label: options.label, + theme, + layout, + }) + } + + const guideScenes = entries.map((entry, index) => + createFacetScene( + id, + entry, + { + ...definitions[index]!, + marks: definitions[index]!.marks.map(withoutMarkRendering), + }, + { + width: cellWidth, + height: Math.max(1, cellHeight - labelHeight), + }, + layout, + ), + ) + assertOuterAxes(id, definitions, guideScenes) + const margin = resolveOuterMargin({ id, entries, definitions, chart, columns, - cellWidth, - cellHeight, + rows, gap, labelHeight, - showLabel, - label: options.label, - theme, + initial: maxSceneMargins(guideScenes), layout, }) - } - const guideScenes = entries.map((entry, index) => - createFacetScene( + return renderOuterAxes({ id, - entry, - { - ...definitions[index]!, - marks: definitions[index]!.marks.map(withoutMarkRendering), - }, - { - width: cellWidth, - height: Math.max(1, cellHeight - labelHeight), - }, + entries, + definitions, + chart, + columns, + rows, + gap, + labelHeight, + showLabel, + label: options.label, + theme, + margin, layout, - ), - ) - assertOuterAxes(id, definitions, guideScenes) - const margin = resolveOuterMargin({ - id, - entries, - definitions, - chart, - columns, - rows, - gap, - labelHeight, - initial: maxSceneMargins(guideScenes), - layout, - }) - - return renderOuterAxes({ - id, - entries, - definitions, - chart, - columns, - rows, - gap, - labelHeight, - showLabel, - label: options.label, - theme, - margin, - layout, - }) - }, - } - }, options.motion) + }) + }, + } + }, + options.motion, + options.renderer, + ) return mark } @@ -637,7 +642,9 @@ function createFacetScene( } } -function withoutMarkRendering(mark: ChartMark): ChartMark { +function withoutMarkRendering( + mark: ChartMark, +): ChartMark { return { initialize: (context) => { const initialized = mark.initialize(context) @@ -664,20 +671,26 @@ function assertOuterAxes( ): void { const firstDefinition = definitions[0] const firstScene = scenes[0] + const firstScales = firstDefinition + ? positionScaleOptions(firstDefinition) + : undefined const incompatible = !firstDefinition || !firstScene || + !firstScales || definitions.some( (definition) => definition.guides === false || definition.margin !== undefined || - definition.color?.legend !== undefined, + definition.color?.legend !== undefined || + hasUnsupportedOuterAxis(definition), ) || scenes.slice(1).some((scene, index) => { const definition = definitions[index + 1]! + const scales = positionScaleOptions(definition) return ( - !sameAxis(firstDefinition.x, definition.x) || - !sameAxis(firstDefinition.y, definition.y) || + !sameAxis(firstScales.x, scales.x) || + !sameAxis(firstScales.y, scales.y) || !sameScale(firstScene.scales.x!, scene.scales.x!) || !sameScale(firstScene.scales.y!, scene.scales.y!) || !sameMaterializedAxes(firstScene, scene) || @@ -694,8 +707,8 @@ function assertOuterAxes( } function sameAxis( - left: StaticChartDefinition['x'], - right: StaticChartDefinition['x'], + left: ChartPositionScaleOptions | null | undefined, + right: ChartPositionScaleOptions | null | undefined, ): boolean { if (left === null || right === null) return left === right const leftAxis = left?.axis @@ -718,6 +731,23 @@ function sameAxis( ) } +function positionScaleOptions( + definition: StaticChartDefinition, +): Readonly> { + return definition.scales ?? { x: definition.x, y: definition.y } +} + +function hasUnsupportedOuterAxis(definition: StaticChartDefinition): boolean { + return Object.entries(positionScaleOptions(definition)).some( + ([id, options]) => { + if (options === null || options?.axis === false) return false + if (id !== 'x' && id !== 'y') return true + const side = options?.side ?? (id === 'x' ? 'bottom' : 'left') + return side !== (id === 'x' ? 'bottom' : 'left') + }, + ) +} + function sameAxisTicks( left: Exclude< NonNullable['axis']>, @@ -882,8 +912,7 @@ export function facetChart( marks: [facet(source, options)], guides: false, margin: 0, - x: null, - y: null, + scales: { x: null, y: null }, } } diff --git a/packages/charts-core/src/focus-coordinate-internal.ts b/packages/charts-core/src/focus-coordinate-internal.ts new file mode 100644 index 00000000..828042a8 --- /dev/null +++ b/packages/charts-core/src/focus-coordinate-internal.ts @@ -0,0 +1,25 @@ +import type { ChartPoint } from './types' + +type FocusCoordinates = Partial> + +const mappedCoordinates = new WeakMap() + +export function setMappedFocusCoordinate( + point: ChartPoint, + axis: 'x' | 'y', + coordinate: number, +): void { + const current = mappedCoordinates.get(point) + if (current) { + current[axis] = coordinate + } else { + mappedCoordinates.set(point, { [axis]: coordinate }) + } +} + +export function mappedFocusCoordinate( + point: ChartPoint, + axis: 'x' | 'y', +): number { + return mappedCoordinates.get(point)?.[axis] ?? point[axis] +} diff --git a/packages/charts-core/src/focus-guide.ts b/packages/charts-core/src/focus-guide.ts index 49a4afdd..88ae03b8 100644 --- a/packages/charts-core/src/focus-guide.ts +++ b/packages/charts-core/src/focus-guide.ts @@ -12,6 +12,7 @@ import { valueKey } from './scales' import type { Channel, ChannelOutput, + CartesianScaleBindings, ChartFocusMatch, ChartKey, ChartMark, @@ -78,7 +79,8 @@ export interface FocusGuideOptions< TDatum, TXChannel extends FocusGuideChannel, TYChannel extends FocusGuideChannel, -> extends ChartMarkMotionOptions { +> + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: TXChannel y: TYChannel @@ -110,47 +112,84 @@ export function focusGuideX< TDatum, const TXChannel extends FocusGuideChannel>, const TYChannel extends FocusGuideChannel>, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: FocusGuideOptions, TXChannel, TYChannel>, + options: FocusGuideOptions, TXChannel, TYChannel> & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, ChannelOutput, - ChannelOutput + ChannelOutput, + ChannelOutput, + ChannelOutput, + TXScaleId, + TYScaleId > { - return focusGuide(source, options, 'x') + return focusGuide( + source, + options, + 'x', + ) } export function focusGuideY< TDatum, const TXChannel extends FocusGuideChannel>, const TYChannel extends FocusGuideChannel>, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: FocusGuideOptions, TXChannel, TYChannel>, + options: FocusGuideOptions, TXChannel, TYChannel> & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, ChannelOutput, - ChannelOutput + ChannelOutput, + ChannelOutput, + ChannelOutput, + TXScaleId, + TYScaleId > { - return focusGuide(source, options, 'y') + return focusGuide( + source, + options, + 'y', + ) } function focusGuide< TDatum, TXChannel extends FocusGuideChannel, TYChannel extends FocusGuideChannel, + TXScaleId extends string, + TYScaleId extends string, >( source: Iterable, - options: FocusGuideOptions, + options: FocusGuideOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, orientation: 'x' | 'y', ): ChartMark< TDatum, ChannelOutput, - ChannelOutput + ChannelOutput, + ChannelOutput, + ChannelOutput, + TXScaleId, + TYScaleId > { type TXValue = ChannelOutput type TYValue = ChannelOutput + const xScale = (options.xScale ?? 'x') as TXScaleId + const yScale = (options.yScale ?? 'y') as TYScaleId const data = Array.isArray(source) ? source : Array.from(source) const xRule = @@ -166,129 +205,133 @@ function focusGuide< : false : options.yRule - const mark = createMark(({ markIndex }) => { - const id = options.id ?? `focus-guide-${orientation}-${markIndex}` - const xValues = channelValues(data, options.x, () => undefined) - const yValues = channelValues(data, options.y, () => undefined) - const zValues = channelValues(data, options.z, () => null) - const keys = inferredKeyValues(data, options.key, { - groups: zValues, - candidates: [xValues, yValues], - markId: id, - warningIdentity: options, - }) + const mark = createMark( + ({ markIndex }) => { + const id = options.id ?? `focus-guide-${orientation}-${markIndex}` + const xValues = channelValues(data, options.x, () => undefined) + const yValues = channelValues(data, options.y, () => undefined) + const zValues = channelValues(data, options.z, () => null) + const keys = inferredKeyValues(data, options.key, { + groups: zValues, + candidates: [xValues, yValues], + markId: id, + warningIdentity: options, + }) - const renderCandidates = (context: MarkRenderContext) => { - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - const labels: SceneLabel[] = [] + const renderCandidates = (context: MarkRenderContext) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + const labels: SceneLabel[] = [] - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const yValue = yValues[datumIndex] - if (!isChartValue(xValue) || !isChartValue(yValue)) return + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const yValue = yValues[datumIndex] + if (!isChartValue(xValue) || !isChartValue(yValue)) return - const group = zValues[datumIndex] - const normalizedGroup = isChartKey(group) ? group : null - const candidateKey = `${id}:${valueKey(normalizedGroup)}:${valueKey(keys[datumIndex])}` - const color = context.theme.foreground - const x = context.scales.x.map(xValue) - const y = context.scales.y.map(yValue) - if (!Number.isFinite(x) || !Number.isFinite(y)) return - const point: ChartPoint = { - key: `${candidateKey}:point`, - markId: id, - group: normalizedGroup, - groupLabel: normalizedGroup === null ? id : String(normalizedGroup), - datum, - datumIndex, - xValue: xValue as TXValue, - yValue: yValue as TYValue, - x, - y, - color, - } - const guide = createGuideNodes({ - id, - classPrefix: 'ts-chart__focus-guide', - chart: context.chart, - x: point.x, - y: point.y, - xRule: - xRule === false - ? false - : { style: ruleStyle(xRule, datum, datumIndex, data, context) }, - yRule: - yRule === false - ? false - : { style: ruleStyle(yRule, datum, datumIndex, data, context) }, - marker: resolveMarker( - options.marker, - datum, - datumIndex, - data, - context, - ), - xLabel: resolveLabel( - options.xLabel, - point.xValue, - point, + const group = zValues[datumIndex] + const normalizedGroup = isChartKey(group) ? group : null + const candidateKey = `${id}:${valueKey(normalizedGroup)}:${valueKey(keys[datumIndex])}` + const color = context.theme.foreground + const x = context.scales[xScale]!.map(xValue) + const y = context.scales[yScale]!.map(yValue) + if (!Number.isFinite(x) || !Number.isFinite(y)) return + const point: ChartPoint = { + key: `${candidateKey}:point`, + markId: id, + group: normalizedGroup, + groupLabel: normalizedGroup === null ? id : String(normalizedGroup), datum, datumIndex, - data, - context, - ), - yLabel: resolveLabel( - options.yLabel, - point.yValue, - point, - datum, - datumIndex, - data, - context, - ), - measureText: context.layout.measureText, - }) - labels.push(...guide.labels) + xValue: xValue as TXValue, + yValue: yValue as TYValue, + x, + y, + color, + } + const guide = createGuideNodes({ + id, + classPrefix: 'ts-chart__focus-guide', + chart: context.chart, + x: point.x, + y: point.y, + xRule: + xRule === false + ? false + : { style: ruleStyle(xRule, datum, datumIndex, data, context) }, + yRule: + yRule === false + ? false + : { style: ruleStyle(yRule, datum, datumIndex, data, context) }, + marker: resolveMarker( + options.marker, + datum, + datumIndex, + data, + context, + ), + xLabel: resolveLabel( + options.xLabel, + point.xValue, + point, + datum, + datumIndex, + data, + context, + ), + yLabel: resolveLabel( + options.yLabel, + point.yValue, + point, + datum, + datumIndex, + data, + context, + ), + measureText: context.layout.measureText, + }) + labels.push(...guide.labels) - nodes.push({ - kind: 'group', - key: candidateKey, - className: 'ts-chart__focus-guide-candidate', - ariaHidden: true, - focusCandidateIndex: points.length, - children: guide.nodes, + nodes.push({ + kind: 'group', + key: candidateKey, + className: 'ts-chart__focus-guide-candidate', + ariaHidden: true, + focusCandidateIndex: points.length, + children: guide.nodes, + }) + points.push(point) }) - points.push(point) - }) - return { nodes, points, labels } - } + return { nodes, points, labels } + } - return { - id, - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - }, - layoutLabels: (context) => renderCandidates(context).labels, - render: (context) => { - const rendered = renderCandidates(context) - return { - nodes: [ - { - kind: 'group', - key: id, - className: `ts-chart__focus-guide ts-chart__focus-guide-${orientation}`, - ariaHidden: true, - children: rendered.nodes, - }, - ], - points: rendered.points, - } - }, - } - }, options.motion) + return { + id, + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + }, + layoutLabels: (context) => renderCandidates(context).labels, + render: (context) => { + const rendered = renderCandidates(context) + return { + nodes: [ + { + kind: 'group', + key: id, + className: `ts-chart__focus-guide ts-chart__focus-guide-${orientation}`, + ariaHidden: true, + children: rendered.nodes, + }, + ], + points: rendered.points, + } + }, + } + }, + options.motion, + options.renderer, + ) return whenFocused(mark, { match: options.match ?? 'primary', diff --git a/packages/charts-core/src/focus-mark.ts b/packages/charts-core/src/focus-mark.ts index 3e48c5a2..0aa4b7fa 100644 --- a/packages/charts-core/src/focus-mark.ts +++ b/packages/charts-core/src/focus-mark.ts @@ -6,16 +6,28 @@ export function whenFocused< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string, + TYScaleId extends string, >( mark: ChartMark< TDatum, TXPointValue, TYPointValue, TXScaleValue, - TYScaleValue + TYScaleValue, + TXScaleId, + TYScaleId >, options: ChartFocusFilter = {}, -): ChartMark { +): ChartMark< + TDatum, + TXPointValue, + TYPointValue, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> { return { ...mark, initialize(context) { diff --git a/packages/charts-core/src/focus.test.ts b/packages/charts-core/src/focus.test.ts index ba76a48b..29c3b42a 100644 --- a/packages/charts-core/src/focus.test.ts +++ b/packages/charts-core/src/focus.test.ts @@ -61,6 +61,23 @@ describe('axis focus strategies', () => { ).toEqual(['a-focused', 'b']) }) + it('groups axis focus by mapped pixels across named scales', () => { + const mappedPoints = [ + point('primary', 10, 20, 'A', 1), + point('different-pixel', 30, 40, 'B', 1), + point('same-pixel', 10, 60, 'C', 99), + ] + + expect( + focusGroupX + .group(mappedPoints, { point: mappedPoints[0]! }) + .map((candidate) => candidate.key), + ).toEqual(['primary', 'same-pixel']) + expect( + focusGroupX.navigation(mappedPoints).map((candidate) => candidate.key), + ).toEqual(['primary', 'different-pixel']) + }) + it('can disable native datum focus without a case-local strategy', () => { expect( focusDisabled.resolve(points, { x: 10, y: 20, maxDistance: 100 }), diff --git a/packages/charts-core/src/focus.ts b/packages/charts-core/src/focus.ts index 08310497..ace88faa 100644 --- a/packages/charts-core/src/focus.ts +++ b/packages/charts-core/src/focus.ts @@ -1,4 +1,5 @@ import { valueKey } from './scales' +import { mappedFocusCoordinate } from './focus-coordinate-internal' import type { ChartFocusGroupContext, ChartFocusResolveContext, @@ -12,9 +13,7 @@ export const focusNearestX = axisFocus('x', false) export const focusNearestY = axisFocus('y', false) function axisFocus(axis: 'x' | 'y', grouped: boolean) { - const coordinate = (point: ChartPoint) => (axis === 'x' ? point.x : point.y) - const value = (point: ChartPoint) => - axis === 'x' ? point.xValue : point.yValue + const coordinate = (point: ChartPoint) => mappedFocusCoordinate(point, axis) const secondary = (point: ChartPoint) => (axis === 'x' ? point.y : point.x) return { @@ -33,7 +32,7 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { distance = nextDistance } if (!nearest) return [] - const candidates = groupPoints(points, nearest, value) + const candidates = groupPoints(points, nearest, coordinate) const secondaryTarget = axis === 'x' ? y : x const primary = candidates.reduce( (closest, candidate) => @@ -52,7 +51,7 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { context: ChartFocusGroupContext, ) { const { point } = context - return grouped ? groupPoints(points, point, value) : [point] + return grouped ? groupPoints(points, point, coordinate) : [point] }, navigation( points: readonly ChartPoint[], @@ -63,7 +62,7 @@ function axisFocus(axis: 'x' | 'y', grouped: boolean) { if (!grouped) return sorted const unique = new Map() for (const point of sorted) { - const key = valueKey(value(point)) + const key = valueKey(coordinate(point)) if (!unique.has(key)) unique.set(key, point) } return [...unique.values()] @@ -92,13 +91,13 @@ function groupPoints< >( points: readonly ChartPoint[], point: ChartPoint, - value: (point: ChartPoint) => ChartValue, + coordinate: (point: ChartPoint) => number, ) { - const key = valueKey(value(point)) + const key = valueKey(coordinate(point)) const unique = new Map>() unique.set(valueKey(point.group), point) for (const candidate of points) { - if (valueKey(value(candidate)) !== key) continue + if (valueKey(coordinate(candidate)) !== key) continue const group = valueKey(candidate.group) if (!unique.has(group)) unique.set(group, candidate) } diff --git a/packages/charts-core/src/frame.ts b/packages/charts-core/src/frame.ts index 03acba9b..5ef5926b 100644 --- a/packages/charts-core/src/frame.ts +++ b/packages/charts-core/src/frame.ts @@ -16,42 +16,46 @@ export interface FrameOptions extends ChartMarkMotionOptions { export function frame( options: FrameOptions = {}, ): ChartMark { - return createMark(({ markIndex }) => { - const id = options.id ?? `frame-${markIndex}` - return { - id, - channels: {}, - render: ({ chart, theme }) => { - const inset = Math.max(0, options.inset ?? 0) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__frame', - ariaHidden: true, - children: [ - { - kind: 'rect', - key: `${id}:rect`, - x: chart.x + inset, - y: chart.y + inset, - width: Math.max(0, chart.width - inset * 2), - height: Math.max(0, chart.height - inset * 2), - radius: options.radius, - style: { - fill: options.fill ?? 'none', - fillOpacity: options.fillOpacity, - stroke: options.stroke ?? theme.foreground, - strokeOpacity: options.strokeOpacity ?? 0.35, - strokeWidth: options.strokeWidth ?? 1, + return createMark( + ({ markIndex }) => { + const id = options.id ?? `frame-${markIndex}` + return { + id, + channels: {}, + render: ({ chart, theme }) => { + const inset = Math.max(0, options.inset ?? 0) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__frame', + ariaHidden: true, + children: [ + { + kind: 'rect', + key: `${id}:rect`, + x: chart.x + inset, + y: chart.y + inset, + width: Math.max(0, chart.width - inset * 2), + height: Math.max(0, chart.height - inset * 2), + radius: options.radius, + style: { + fill: options.fill ?? 'none', + fillOpacity: options.fillOpacity, + stroke: options.stroke ?? theme.foreground, + strokeOpacity: options.strokeOpacity ?? 0.35, + strokeWidth: options.strokeWidth ?? 1, + }, }, - }, - ], - }, - ], - } - }, - } - }, options.motion) + ], + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/geo.ts b/packages/charts-core/src/geo.ts index 61c1a013..874addd2 100644 --- a/packages/charts-core/src/geo.ts +++ b/packages/charts-core/src/geo.ts @@ -212,6 +212,7 @@ export function geoShape( } }, options.motion, + options.renderer, ) } diff --git a/packages/charts-core/src/hexagon.ts b/packages/charts-core/src/hexagon.ts index fc7948d7..adbdc525 100644 --- a/packages/charts-core/src/hexagon.ts +++ b/packages/charts-core/src/hexagon.ts @@ -11,6 +11,8 @@ import { resolveNumericScale } from './scale-input' import { valueKey } from './scales' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -22,7 +24,8 @@ import type { VisualChannel, } from './types' -export interface HexagonOptions extends ChartMarkMotionOptions { +export interface HexagonOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel y?: Channel @@ -48,126 +51,141 @@ export function hexagon< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionChannelOutput, + OptionChannelOutput, + TOptions > export function hexagon( source: Iterable, options: HexagonOptions = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `hexagon-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, { index }) => index) - const yValues = channelValues(data, options.y, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups: zValues }) - const radiusOption = options.r - const rawRadii = - typeof radiusOption === 'number' - ? data.map(() => radiusOption) - : channelValues(data, radiusOption, () => 6) - const radiusMapper = resolveNumericScale(options.rScale, rawRadii) - const radii = radiusMapper - ? rawRadii.map((radius) => - isNonnegativeFiniteNumber(radius) ? radiusMapper(radius) : Number.NaN, - ) - : rawRadii + return createMark( + ({ markIndex }) => { + const id = options.id ?? `hexagon-${markIndex}` + const xValues = channelValues( + data, + options.x, + (_datum, { index }) => index, + ) + const yValues = channelValues(data, options.y, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups: zValues }) + const radiusOption = options.r + const rawRadii = + typeof radiusOption === 'number' + ? data.map(() => radiusOption) + : channelValues(data, radiusOption, () => 6) + const radiusMapper = resolveNumericScale(options.rScale, rawRadii) + const radii = radiusMapper + ? rawRadii.map((radius) => + isNonnegativeFiniteNumber(radius) + ? radiusMapper(radius) + : Number.NaN, + ) + : rawRadii - return { - id, - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { scale: 'color', values: colorValues.filter(isChartKey) }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] + return { + id, + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { scale: 'color', values: colorValues.filter(isChartKey) }, + }, + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const yValue = yValues[datumIndex] - const radius = radii[datumIndex] - if ( - !isChartValue(xValue) || - !isChartValue(yValue) || - !isNonnegativeFiniteNumber(radius) - ) { - return - } - const x = scales.x.map(xValue) - const y = scales.y.map(yValue) - const group = zValues[datumIndex] ?? null - const fallback = resolveColor(colorValues[datumIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - fallback, - ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue(options.stroke, datum, datumIndex, data, fallback) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const vertices = Array.from({ length: 6 }, (_, vertex) => { - const angle = ((vertex * 60 - 90) * Math.PI) / 180 - return [ - x + Math.cos(angle) * radius, - y + Math.sin(angle) * radius, - ] as const - }) - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - x, - y, - color: fill, - } + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const yValue = yValues[datumIndex] + const radius = radii[datumIndex] + if ( + !isChartValue(xValue) || + !isChartValue(yValue) || + !isNonnegativeFiniteNumber(radius) + ) { + return + } + const x = scales[xScale]!.map(xValue) + const y = scales[yScale]!.map(yValue) + const group = zValues[datumIndex] ?? null + const fallback = resolveColor(colorValues[datumIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + fallback, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue(options.stroke, datum, datumIndex, data, fallback) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const vertices = Array.from({ length: 6 }, (_, vertex) => { + const angle = ((vertex * 60 - 90) * Math.PI) / 180 + return [ + x + Math.cos(angle) * radius, + y + Math.sin(angle) * radius, + ] as const + }) + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + x, + y, + color: fill, + } - nodes.push({ - kind: 'area', - key, - points: vertices, - interaction: { point }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - }, + nodes.push({ + kind: 'area', + key, + points: vertices, + interaction: { point }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__hexagon', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__hexagon', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/hierarchy-sunburst.ts b/packages/charts-core/src/hierarchy-sunburst.ts index 332a735a..e9cda6f5 100644 --- a/packages/charts-core/src/hierarchy-sunburst.ts +++ b/packages/charts-core/src/hierarchy-sunburst.ts @@ -335,6 +335,7 @@ export function sunburst( } }, options.motion, + options.renderer, ) } diff --git a/packages/charts-core/src/hierarchy-treemap.ts b/packages/charts-core/src/hierarchy-treemap.ts index ae6c4986..944012d8 100644 --- a/packages/charts-core/src/hierarchy-treemap.ts +++ b/packages/charts-core/src/hierarchy-treemap.ts @@ -218,145 +218,151 @@ export function treemap( number, never, never - >(({ markIndex }) => { - const id = options.id ?? `treemap-${markIndex}` - return { - id, - channels: {}, - seriesFromColor: options.color !== undefined, - resolveLayout: ({ chart, layout }) => { - const root = hierarchy.root.copy() - const laidOut = configureLayout( - createTreemapLayout>(), - chart.width, - chart.height, - method, - ratio, - options.round ?? false, - paddingInner, - paddingOuter, - )(root) - const leaves = laidOut.leaves() - assertLayoutCoordinates(leaves, chart.width, chart.height) - const cells = leaves - .map((node) => materializeCell(node, chart.x, chart.y)) - .filter((cell) => cell.x1 > cell.x0 && cell.y1 > cell.y0) - const nodes = cells.map((cell) => cell.node) - const colorValues = channelValues(nodes, options.color, () => null) - const labels = materializeLabels( - cells, - options.label, - options.labelFontSize ?? 11, - options.labelFontWeight, - inset, - labelPadding, - layout.measureText, - id, - ) + >( + ({ markIndex }) => { + const id = options.id ?? `treemap-${markIndex}` + return { + id, + channels: {}, + seriesFromColor: options.color !== undefined, + resolveLayout: ({ chart, layout }) => { + const root = hierarchy.root.copy() + const laidOut = configureLayout( + createTreemapLayout>(), + chart.width, + chart.height, + method, + ratio, + options.round ?? false, + paddingInner, + paddingOuter, + )(root) + const leaves = laidOut.leaves() + assertLayoutCoordinates(leaves, chart.width, chart.height) + const cells = leaves + .map((node) => materializeCell(node, chart.x, chart.y)) + .filter((cell) => cell.x1 > cell.x0 && cell.y1 > cell.y0) + const nodes = cells.map((cell) => cell.node) + const colorValues = channelValues(nodes, options.color, () => null) + const labels = materializeLabels( + cells, + options.label, + options.labelFontSize ?? 11, + options.labelFontWeight, + inset, + labelPadding, + layout.measureText, + id, + ) - return { - channels: { - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + channels: { + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - states: markStates(nodes, options.states), - render: ({ color: resolveColor, theme }) => { - const points: ChartPoint, string, number>[] = [] - const children: SceneNode[] = [] - cells.forEach((cell, nodeIndex) => { - const node = cell.node - const colorValue = colorValues[nodeIndex] - const fallback = resolveColor( - isChartKey(colorValue) ? colorValue : null, - ) - const fill = visualValue( - options.fill, - node, - nodeIndex, - nodes, - fallback, - ) - const key = `${id}:node:${valueKey(node.id)}` - const group = isChartKey(colorValue) ? colorValue : null - const point: ChartPoint, string, number> = { - key, - markId: id, - group, - groupLabel: group === null ? id : String(group), - datum: node, - datumIndex: nodeIndex, - xValue: node.id, - yValue: node.value, - x: cell.x, - y: cell.y, - color: fill, - } - points.push(point) - children.push({ - kind: 'rect', - key, - x: cell.x0 + inset, - y: cell.y0 + inset, - width: Math.max(0, cell.x1 - cell.x0 - inset * 2), - height: Math.max(0, cell.y1 - cell.y0 - inset * 2), - radius: options.radius, - inset, - insetAxis: 'xy', - interaction: { point }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke: - options.stroke === undefined - ? undefined - : visualValue( - options.stroke, - node, - nodeIndex, - nodes, - fallback, - ), - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - }, - }) - const label = labels.get(node.id) - if (label) { + states: markStates(nodes, options.states), + render: ({ color: resolveColor, theme }) => { + const points: ChartPoint, string, number>[] = + [] + const children: SceneNode[] = [] + cells.forEach((cell, nodeIndex) => { + const node = cell.node + const colorValue = colorValues[nodeIndex] + const fallback = resolveColor( + isChartKey(colorValue) ? colorValue : null, + ) + const fill = visualValue( + options.fill, + node, + nodeIndex, + nodes, + fallback, + ) + const key = `${id}:node:${valueKey(node.id)}` + const group = isChartKey(colorValue) ? colorValue : null + const point: ChartPoint, string, number> = { + key, + markId: id, + group, + groupLabel: group === null ? id : String(group), + datum: node, + datumIndex: nodeIndex, + xValue: node.id, + yValue: node.value, + x: cell.x, + y: cell.y, + color: fill, + } + points.push(point) children.push({ - ...label, - key: `${key}:label`, - pointOwner: point, + kind: 'rect', + key, + x: cell.x0 + inset, + y: cell.y0 + inset, + width: Math.max(0, cell.x1 - cell.x0 - inset * 2), + height: Math.max(0, cell.y1 - cell.y0 - inset * 2), + radius: options.radius, + inset, + insetAxis: 'xy', + interaction: { point }, style: { - fill: visualValue( - options.labelFill, - node, - nodeIndex, - nodes, - theme.foreground, - ), + fill, + fillOpacity: options.fillOpacity, + stroke: + options.stroke === undefined + ? undefined + : visualValue( + options.stroke, + node, + nodeIndex, + nodes, + fallback, + ), + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, }, }) + const label = labels.get(node.id) + if (label) { + children.push({ + ...label, + key: `${key}:label`, + pointOwner: point, + style: { + fill: visualValue( + options.labelFill, + node, + nodeIndex, + nodes, + theme.foreground, + ), + }, + }) + } + }) + return { + nodes: [ + { + kind: 'group', + key: id, + className: + 'ts-chart__treemap ts-chart__rect ts-chart__text', + ariaHidden: true, + children, + }, + ], + points, } - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__treemap ts-chart__rect ts-chart__text', - ariaHidden: true, - children, - }, - ], - points, - } - }, - } - }, - } - }, options.motion) + }, + } + }, + } + }, + options.motion, + options.renderer, + ) } function configureLayout( diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index bf0d7178..cc25888d 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -245,6 +245,7 @@ export type { ChartPointerResolution, ChartRenderContext, ChartRenderer, + ChartLayerRenderer, ChartRendererCapabilities, ChartRendererHost, ChartRendererHostCommonOptions, @@ -252,6 +253,7 @@ export type { ChartRendererRenderContext, ChartRendererTooltipMotionCapability, UniversalChartRenderer, + UniversalChartLayerRenderer, ChartSurface, ChartSurfaceRenderOptions, ChartTooltipBodyTarget, @@ -283,6 +285,9 @@ export type { MaterializedChannel, ResolvedLayoutMarkInitialization, ResolvedMarkLayout, + CartesianChartMark, + CartesianScaleBindings, + ChartAxisSide, ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, @@ -350,6 +355,8 @@ export type { ChartNumericScaleOptions, ChartLinearGradient, ChartMark, + ChartMarkOptions, + ChartMarkRenderer, ChartMarkMotionOptions, ChartMarkDatum, ChartMarkState, @@ -377,6 +384,8 @@ export type { ChartMotionTransition, ChartMotionTweenTransition, ChartPoint, + ChartPositionChannel, + ChartPositionScaleOptions, ChartRuntime, ChartRuntimeOptions, ChartScene, @@ -388,6 +397,7 @@ export type { ConfiguredScaleLike, InferableScaleLike, OptionChannelOutput, + OptionScaleId, ChartSize, ChartSpatialIndex, ChartSpatialIndexFactory, @@ -396,6 +406,7 @@ export type { ChartSpecXValue, ChartSpecYValue, ChartSpec, + ChartScales, ChartSvgRenderer, ChartTheme, ChartTextMeasurer, diff --git a/packages/charts-core/src/line.ts b/packages/charts-core/src/line.ts index 344de46f..af569466 100644 --- a/packages/charts-core/src/line.ts +++ b/packages/charts-core/src/line.ts @@ -11,6 +11,8 @@ import { import { valueKey } from './scales' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -24,7 +26,8 @@ import type { OptionChannelOutput, } from './types' -interface LineOptions extends ChartMarkMotionOptions { +interface LineOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string z?: Channel color?: Channel @@ -75,11 +78,25 @@ export function lineY< >( source: Iterable, options: TOptions, -): ChartMark, number> +): CartesianChartMark< + TDatum, + OptionChannelOutput, + number, + OptionChannelOutput, + number, + TOptions +> export function lineY( source: Iterable, options: LineYOptions> = {}, -): ChartMark { +): CartesianChartMark< + TDatum, + ChartValue, + ChartValue, + ChartValue, + ChartValue, + LineYOptions +> { const data = Array.isArray(source) ? source : Array.from(source) return createLineMark(data, options, 'line', () => { @@ -108,11 +125,25 @@ export function lineX< >( source: Iterable, options: TOptions, -): ChartMark> +): CartesianChartMark< + TDatum, + number, + OptionChannelOutput, + number, + OptionChannelOutput, + TOptions +> export function lineX( source: Iterable, options: LineXOptions> = {}, -): ChartMark { +): CartesianChartMark< + TDatum, + ChartValue, + ChartValue, + ChartValue, + ChartValue, + LineXOptions +> { const data = Array.isArray(source) ? source : Array.from(source) return createLineMark(data, options, 'line-x', () => { @@ -137,149 +168,163 @@ function createLineMark( options: LineOptions, idPrefix: string, channels: () => LineChannels, -): ChartMark { - return createMark(({ markIndex }) => { - const id = options.id ?? `${idPrefix}-${markIndex}` - const { xValues, yValues, isValidX, isValidY, keyValues, affinity } = - channels() - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const groupValues = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const keys = inferredKeyValues(data, options.key, { - groups: groupValues, - candidates: [keyValues], - markId: id, - warningIdentity: options, - }) - const rows = data.map((datum, datumIndex): LineRow => ({ - datum, - datumIndex, - xValue: xValues[datumIndex], - yValue: yValues[datumIndex], - groupValue: groupValues[datumIndex], - datumKey: keys[datumIndex], - })) +): CartesianChartMark< + TDatum, + ChartValue, + ChartValue, + ChartValue, + ChartValue, + LineOptions +> { + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' + return createMark( + ({ markIndex }) => { + const id = options.id ?? `${idPrefix}-${markIndex}` + const { xValues, yValues, isValidX, isValidY, keyValues, affinity } = + channels() + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const groupValues = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const keys = inferredKeyValues(data, options.key, { + groups: groupValues, + candidates: [keyValues], + markId: id, + warningIdentity: options, + }) + const rows = data.map((datum, datumIndex): LineRow => ({ + datum, + datumIndex, + xValue: xValues[datumIndex], + yValue: yValues[datumIndex], + groupValue: groupValues[datumIndex], + datumKey: keys[datumIndex], + })) - return { - id, - states: markStates(data, options.states), - seriesFromColor: options.z === undefined && options.color !== undefined, - channels: { - x: { - scale: 'x', - values: xValues.filter(isValidX), - }, - y: { - scale: 'y', - values: yValues.filter(isValidY), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + states: markStates(data, options.states), + seriesFromColor: options.z === undefined && options.color !== undefined, + channels: { + x: { + scale: xScale, + values: xValues.filter(isValidX), + }, + y: { + scale: yScale, + values: yValues.filter(isValidY), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, color: resolveColor }) => { - const groups = groupRows(rows) - const nodes: SceneNode[] = [] + render: ({ scales, color: resolveColor }) => { + const groups = groupRows(rows) + const nodes: SceneNode[] = [] - for (const [groupKey, groupRows] of groups) { - const firstRow = groupRows[0] - if (!firstRow) continue - const color = visualValue( - options.stroke, - firstRow.datum, - firstRow.datumIndex, - data, - resolveColor(colorValues[firstRow.datumIndex] ?? null), - ) - const children: SceneNode[] = [] - let segment: (readonly [number, number])[] = [] - let segmentPoints: ChartPoint[] = [] - let segmentIndex = 0 + for (const [groupKey, groupRows] of groups) { + const firstRow = groupRows[0] + if (!firstRow) continue + const color = visualValue( + options.stroke, + firstRow.datum, + firstRow.datumIndex, + data, + resolveColor(colorValues[firstRow.datumIndex] ?? null), + ) + const children: SceneNode[] = [] + let segment: (readonly [number, number])[] = [] + let segmentPoints: ChartPoint[] = [] + let segmentIndex = 0 - const flushSegment = () => { - if (!segment.length) return - children.push({ - kind: 'polyline', - key: `${id}:${groupKey}:segment:${segmentIndex}`, - points: segment, - path: options.curve?.line(segment), - interaction: { - points: segmentPoints, - affinity, - }, - style: { - fill: 'none', - stroke: color, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 2.25, - strokeDasharray: options.strokeDasharray, - lineCap: 'round', - lineJoin: 'round', - }, - }) - segment = [] - segmentPoints = [] - segmentIndex += 1 - } - - for (const row of groupRows) { - if (!isValidX(row.xValue) || !isValidY(row.yValue)) { - flushSegment() - continue + const flushSegment = () => { + if (!segment.length) return + children.push({ + kind: 'polyline', + key: `${id}:${groupKey}:segment:${segmentIndex}`, + points: segment, + path: options.curve?.line(segment), + interaction: { + points: segmentPoints, + affinity, + }, + style: { + fill: 'none', + stroke: color, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 2.25, + strokeDasharray: options.strokeDasharray, + lineCap: 'round', + lineJoin: 'round', + }, + }) + segment = [] + segmentPoints = [] + segmentIndex += 1 } - const x = scales.x.map(row.xValue) - const y = scales.y.map(row.yValue) - const point: ChartPoint = { - key: `${id}:${groupKey}:${valueKey(row.datumKey)}`, - markId: id, - group: row.groupValue ?? null, - groupLabel: row.groupValue == null ? id : String(row.groupValue), - datum: row.datum, - datumIndex: row.datumIndex, - xValue: row.xValue, - yValue: row.yValue, - x, - y, - color, - } - segmentPoints.push(point) - segment.push([x, y]) + for (const row of groupRows) { + if (!isValidX(row.xValue) || !isValidY(row.yValue)) { + flushSegment() + continue + } - if (options.points) { - children.push({ - kind: 'dot', - key: `${point.key}:dot`, + const x = scales[xScale]!.map(row.xValue) + const y = scales[yScale]!.map(row.yValue) + const point: ChartPoint = { + key: `${id}:${groupKey}:${valueKey(row.datumKey)}`, + markId: id, + group: row.groupValue ?? null, + groupLabel: + row.groupValue == null ? id : String(row.groupValue), + datum: row.datum, + datumIndex: row.datumIndex, + xValue: row.xValue, + yValue: row.yValue, x, y, - radius: 2.5, - pointOwner: point, - style: { fill: color }, - }) + color, + } + segmentPoints.push(point) + segment.push([x, y]) + + if (options.points) { + children.push({ + kind: 'dot', + key: `${point.key}:dot`, + x, + y, + radius: 2.5, + pointOwner: point, + style: { fill: color }, + }) + } } - } - flushSegment() - nodes.push({ - kind: 'group', - key: `${id}:${groupKey}`, - className: 'ts-chart__line', - ariaHidden: true, - children, - }) - } + flushSegment() + nodes.push({ + kind: 'group', + key: `${id}:${groupKey}`, + className: 'ts-chart__line', + ariaHidden: true, + children, + }) + } - return { nodes } - }, - } - }, options.motion) + return { nodes } + }, + } + }, + options.motion, + options.renderer, + ) } function groupRows( diff --git a/packages/charts-core/src/link.ts b/packages/charts-core/src/link.ts index 4443a3a9..3a1d45f1 100644 --- a/packages/charts-core/src/link.ts +++ b/packages/charts-core/src/link.ts @@ -10,6 +10,8 @@ import { valueKey } from './scales' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartCurve, ChartKey, ChartMark, @@ -20,7 +22,8 @@ import type { VisualChannel, } from './types' -export interface LinkOptions extends ChartMarkMotionOptions { +export interface LinkOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x1: Channel y1: Channel @@ -57,161 +60,170 @@ export function link< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, LinkXOutput, - LinkYOutput + LinkYOutput, + LinkXOutput, + LinkYOutput, + TOptions > export function link( source: Iterable, options: LinkOptions>, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `link-${markIndex}` - const x1Values = channelValues(data, options.x1, () => undefined) - const y1Values = channelValues(data, options.y1, () => undefined) - const x2Values = channelValues(data, options.x2, () => undefined) - const y2Values = channelValues(data, options.y2, () => undefined) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups: zValues }) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `link-${markIndex}` + const x1Values = channelValues(data, options.x1, () => undefined) + const y1Values = channelValues(data, options.y1, () => undefined) + const x2Values = channelValues(data, options.x2, () => undefined) + const y2Values = channelValues(data, options.y2, () => undefined) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups: zValues }) - return { - id, - channels: { - x: { - scale: 'x', - values: [...x1Values, ...x2Values].filter(isChartValue), - }, - y: { - scale: 'y', - values: [...y1Values, ...y2Values].filter(isChartValue), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + channels: { + x: { + scale: xScale, + values: [...x1Values, ...x2Values].filter(isChartValue), + }, + y: { + scale: yScale, + values: [...y1Values, ...y2Values].filter(isChartValue), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const x1Value = x1Values[datumIndex] - const y1Value = y1Values[datumIndex] - const x2Value = x2Values[datumIndex] - const y2Value = y2Values[datumIndex] - if ( - !isChartValue(x1Value) || - !isChartValue(y1Value) || - !isChartValue(x2Value) || - !isChartValue(y2Value) - ) { - return - } + data.forEach((datum, datumIndex) => { + const x1Value = x1Values[datumIndex] + const y1Value = y1Values[datumIndex] + const x2Value = x2Values[datumIndex] + const y2Value = y2Values[datumIndex] + if ( + !isChartValue(x1Value) || + !isChartValue(y1Value) || + !isChartValue(x2Value) || + !isChartValue(y2Value) + ) { + return + } - const x1 = scales.x.map(x1Value) - const y1 = scales.y.map(y1Value) - const x2 = scales.x.map(x2Value) - const y2 = scales.y.map(y2Value) - const group = zValues[datumIndex] ?? null - const color = visualValue( - options.stroke, - datum, - datumIndex, - data, - resolveColor(colorValues[datumIndex] ?? null), - ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const style = { - fill: 'none', - stroke: color, - strokeOpacity: - options.strokeOpacity === undefined - ? undefined - : visualValue( - options.strokeOpacity, - datum, - datumIndex, - data, - 1, - ), - strokeWidth: visualValue( - options.strokeWidth, + const x1 = scales[xScale]!.map(x1Value) + const y1 = scales[yScale]!.map(y1Value) + const x2 = scales[xScale]!.map(x2Value) + const y2 = scales[yScale]!.map(y2Value) + const group = zValues[datumIndex] ?? null + const color = visualValue( + options.stroke, datum, datumIndex, data, - 1.5, - ), - strokeDasharray: options.strokeDasharray, - lineCap: options.lineCap ?? ('round' as const), - lineJoin: 'round' as const, - } + resolveColor(colorValues[datumIndex] ?? null), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const style = { + fill: 'none', + stroke: color, + strokeOpacity: + options.strokeOpacity === undefined + ? undefined + : visualValue( + options.strokeOpacity, + datum, + datumIndex, + data, + 1, + ), + strokeWidth: visualValue( + options.strokeWidth, + datum, + datumIndex, + data, + 1.5, + ), + strokeDasharray: options.strokeDasharray, + lineCap: options.lineCap ?? ('round' as const), + lineJoin: 'round' as const, + } - nodes.push( - options.curve - ? { - kind: 'polyline', - key, - points: [ - [x1, y1], - [x2, y2], - ], - path: options.curve.line([ - [x1, y1], - [x2, y2], - ]), - style, - } - : { - kind: 'rule', - key, - x1, - y1, - x2, - y2, - style, - }, - ) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: x2Value, - yValue: y2Value, - x1Value, - x2Value, - y1Value, - y2Value, - xInterval: 'range', - yInterval: 'range', - x: (x1 + x2) / 2, - y: (y1 + y2) / 2, - color, + nodes.push( + options.curve + ? { + kind: 'polyline', + key, + points: [ + [x1, y1], + [x2, y2], + ], + path: options.curve.line([ + [x1, y1], + [x2, y2], + ]), + style, + } + : { + kind: 'rule', + key, + x1, + y1, + x2, + y2, + style, + }, + ) + points.push({ + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: x2Value, + yValue: y2Value, + x1Value, + x2Value, + y1Value, + y2Value, + xInterval: 'range', + yInterval: 'range', + x: (x1 + x2) / 2, + y: (y1 + y2) / 2, + color, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__link', - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__link', + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/mark-composite-internal.ts b/packages/charts-core/src/mark-composite-internal.ts index 8fc0f574..d17517cf 100644 --- a/packages/charts-core/src/mark-composite-internal.ts +++ b/packages/charts-core/src/mark-composite-internal.ts @@ -1,4 +1,5 @@ import { resolveCompositeChildMotion } from './composite-motion-internal' +import { preserveMaterializedPositionChannel } from './materialized-channel-internal' import { sceneChildId } from './scene-child-id-internal' import { createScenePointLookup, @@ -172,7 +173,7 @@ export function composeInitializedMarks< } } -type AnyChartMark = ChartMark +type AnyChartMark = ChartMark interface InitializeCompositeMarkOptions { motion?: ChartMotionDefinition @@ -273,7 +274,8 @@ function mergeChildChannels( continue } - merged[`${compositeChildMarkId(parentId, child.id)}:${name}`] = channel + merged[`${compositeChildMarkId(parentId, child.id)}:${name}`] = + preserveMaterializedPositionChannel(name, channel) } } diff --git a/packages/charts-core/src/mark-composite.test.ts b/packages/charts-core/src/mark-composite.test.ts index 801474a0..529577b6 100644 --- a/packages/charts-core/src/mark-composite.test.ts +++ b/packages/charts-core/src/mark-composite.test.ts @@ -1,9 +1,12 @@ import { scaleBand, scaleLinear } from 'd3-scale' import { describe, expect, expectTypeOf, it } from 'vitest' import { barY } from './bar' +import { boxY } from './box' import { dot } from './dot' +import { lineY } from './line' import { createMark } from './mark' import { compositeMark } from './mark-composite' +import { linearRegressionY } from './regression' import { createChartScene, defineChart } from './scene' import { tickY } from './tick' import type { ChartDefinition, ChartMotionContext, SceneNode } from './types' @@ -119,6 +122,66 @@ describe('compositeMark', () => { ]) }) + it('preserves positional channel identity under child namespaces', () => { + const mark = compositeMark([ + lineY(rows, { + id: 'line', + x: 'category', + y: 'value', + yScale: 'alternate', + }), + ]) + + expect(() => + createChartScene( + defineChart({ + marks: [mark], + scales: { + x: { scale: scaleBand() }, + y: null, + alternate: { channel: 'x', scale: scaleLinear() }, + }, + }), + { width: 480, height: 280 }, + ), + ).toThrow('Chart scale "alternate" is configured for x but is used as y') + }) + + it.each([ + [ + 'boxY', + boxY(rows, { + x: 'category', + y: 'value', + yScale: 'alternate', + }), + ], + [ + 'linearRegressionY', + linearRegressionY(rows, { + x: 'value', + y: 'value', + ci: 0, + samples: 2, + yScale: 'alternate', + }), + ], + ])('validates the named positional channel used by %s', (_name, mark) => { + expect(() => + createChartScene( + defineChart({ + marks: [mark], + scales: { + x: { scale: scaleLinear() }, + y: null, + alternate: { channel: 'x', scale: scaleLinear() }, + }, + }), + { width: 480, height: 280 }, + ), + ).toThrow('Chart scale "alternate" is configured for x but is used as y') + }) + it('merges parent and child motion under the resolved child namespace', () => { const mark = compositeMark( [ diff --git a/packages/charts-core/src/mark-composite.ts b/packages/charts-core/src/mark-composite.ts index b4dd4f3c..f551d7d4 100644 --- a/packages/charts-core/src/mark-composite.ts +++ b/packages/charts-core/src/mark-composite.ts @@ -6,11 +6,29 @@ import type { ChartMarkMotionOptions, ChartMarkPointX, ChartMarkPointY, - ChartMarkScaleX, - ChartMarkScaleY, } from './types' -type AnyChartMark = ChartMark +type AnyChartMark = ChartMark + +type ChartMarkScaleIdX = + TMark extends ChartMark + ? TScaleId + : never + +type ChartMarkAnyScaleX = + TMark extends ChartMark + ? TValue + : never + +type ChartMarkAnyScaleY = + TMark extends ChartMark + ? TValue + : never + +type ChartMarkScaleIdY = + TMark extends ChartMark + ? TScaleId + : never export interface CompositeMarkOptions< TDatum = unknown, @@ -26,15 +44,21 @@ export function compositeMark( ChartMarkDatum, ChartMarkPointX, ChartMarkPointY, - ChartMarkScaleX, - ChartMarkScaleY + ChartMarkAnyScaleX, + ChartMarkAnyScaleY, + ChartMarkScaleIdX, + ChartMarkScaleIdY > export function compositeMark( marks: readonly AnyChartMark[], options: CompositeMarkOptions = {}, -): ChartMark { - return createMark(({ markIndex }) => { - const id = options.id ?? `composite-${markIndex}` - return initializeCompositeMark(id, marks, { motion: options.motion }) - }) +): ChartMark { + return createMark( + ({ markIndex }) => { + const id = options.id ?? `composite-${markIndex}` + return initializeCompositeMark(id, marks, { motion: options.motion }) + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/mark-decorative-internal.ts b/packages/charts-core/src/mark-decorative-internal.ts index d1d8f7d7..825cfa12 100644 --- a/packages/charts-core/src/mark-decorative-internal.ts +++ b/packages/charts-core/src/mark-decorative-internal.ts @@ -18,21 +18,34 @@ export function createDecorativeMark< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string, + TYScaleId extends string, >( mark: ChartMark< TDatum, TXPointValue, TYPointValue, TXScaleValue, - TYScaleValue + TYScaleValue, + TXScaleId, + TYScaleId >, transform: ( scene: MarkScene, ) => MarkScene, options: DecorativeMarkLifecycleOptions, -): ChartMark { +): ChartMark< + TDatum, + never, + never, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> { return { motion: mark.motion, + renderer: mark.renderer, initialize(context) { const initialized = mark.initialize(context) assertConditionalMetadata(initialized, initialized.id, options) diff --git a/packages/charts-core/src/mark-decorative.ts b/packages/charts-core/src/mark-decorative.ts index aa899a3b..852a54de 100644 --- a/packages/charts-core/src/mark-decorative.ts +++ b/packages/charts-core/src/mark-decorative.ts @@ -4,7 +4,7 @@ import type { ChartMark, DecorativeChartMark } from './types' /** Keeps one mark's scale and painted geometry while removing interaction ownership. */ export function decorative< - const TMark extends ChartMark, + const TMark extends ChartMark, >(mark: TMark): DecorativeChartMark { return createDecorativeMark( mark, diff --git a/packages/charts-core/src/mark-with-scale-values.ts b/packages/charts-core/src/mark-with-scale-values.ts index bbc62600..17a7a488 100644 --- a/packages/charts-core/src/mark-with-scale-values.ts +++ b/packages/charts-core/src/mark-with-scale-values.ts @@ -1,11 +1,12 @@ import type { ChartMark, + ChartMarkRenderer, ChartMotionDefinition, ChartValue, MarkInitialization, MarkInitializeContext, } from './types' -import { normalizeMarkInitialization } from './mark' +import { applyMarkRenderer, normalizeMarkInitialization } from './mark' export type { ChartMarkPointX, @@ -24,19 +25,36 @@ export function createMarkWithScaleValues< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark { + renderer?: ChartMarkRenderer, +): ChartMark< + TDatum, + TXPointValue, + TYPointValue, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> { const normalizedInitialize = (context: MarkInitializeContext) => { const initialized = normalizeMarkInitialization(initialize(context)) - return motion === undefined || initialized.motion !== undefined - ? initialized - : { ...initialized, motion } + const withMotion = + motion === undefined || initialized.motion !== undefined + ? initialized + : { ...initialized, motion } + return renderer === undefined + ? withMotion + : applyMarkRenderer(withMotion, renderer) + } + return { + initialize: normalizedInitialize, + ...(motion === undefined ? {} : { motion }), + ...(renderer === undefined ? {} : { renderer }), } - return motion === undefined - ? { initialize: normalizedInitialize } - : { initialize: normalizedInitialize, motion } } diff --git a/packages/charts-core/src/mark.ts b/packages/charts-core/src/mark.ts index 4d4ede5b..d7b0e220 100644 --- a/packages/charts-core/src/mark.ts +++ b/packages/charts-core/src/mark.ts @@ -3,6 +3,7 @@ import type { Channel, ChannelAccessor, ChartKey, + ChartMarkRenderer, ChartValue, InitializedMark, MarkInitialization, @@ -40,21 +41,83 @@ export function createMark< TDatum, TXValue extends ChartValue = ChartValue, TYValue extends ChartValue = ChartValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', >( initialize: ( context: MarkInitializeContext, ) => MarkInitialization, motion?: ChartMotionDefinition, -): ChartMark { + renderer?: ChartMarkRenderer, +): ChartMark { const normalizedInitialize = (context: MarkInitializeContext) => { const initialized = normalizeMarkInitialization(initialize(context)) - return motion === undefined || initialized.motion !== undefined - ? initialized - : { ...initialized, motion } + const withMotion = + motion === undefined || initialized.motion !== undefined + ? initialized + : { ...initialized, motion } + return renderer === undefined + ? withMotion + : applyMarkRenderer(withMotion, renderer) + } + return { + initialize: normalizedInitialize, + ...(motion === undefined ? {} : { motion }), + ...(renderer === undefined ? {} : { renderer }), + } +} + +export function applyMarkRenderer< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + initialized: InitializedMark, + renderer: ChartMarkRenderer, +): InitializedMark { + const render = initialized.render + const resolveLayout = initialized.resolveLayout + return { + ...initialized, + render: (context) => applyMarkRendererToScene(render(context), renderer), + ...(resolveLayout + ? { + resolveLayout(context) { + const resolved = resolveLayout(context) + return { + ...resolved, + render: (renderContext) => + applyMarkRendererToScene( + resolved.render(renderContext), + renderer, + ), + } + }, + } + : {}), + } +} + +export function applyMarkRendererToScene< + TDatum, + TXValue extends ChartValue, + TYValue extends ChartValue, +>( + scene: import('./types').MarkScene, + renderer: ChartMarkRenderer, +): import('./types').MarkScene { + return { + ...scene, + nodes: scene.nodes.map((node) => ({ ...node, renderer })), + ...(scene.focusGuides + ? { + focusGuides: scene.focusGuides.map((guide) => ({ + ...guide, + renderer, + })), + } + : {}), } - return motion === undefined - ? { initialize: normalizedInitialize } - : { initialize: normalizedInitialize, motion } } export function normalizeMarkInitialization< diff --git a/packages/charts-core/src/materialized-channel-internal.ts b/packages/charts-core/src/materialized-channel-internal.ts new file mode 100644 index 00000000..a16d1a1d --- /dev/null +++ b/packages/charts-core/src/materialized-channel-internal.ts @@ -0,0 +1,35 @@ +import type { ChartPositionChannel, MaterializedChannel } from './types' + +const positionChannel = Symbol() + +type PositionedMaterializedChannel = MaterializedChannel & { + readonly [positionChannel]?: ChartPositionChannel +} + +/** Reads positional identity without coupling it to one channel record key. */ +export function readMaterializedPositionChannel( + name: string, + channel: MaterializedChannel, +): ChartPositionChannel | undefined { + if (name === 'x' || name === 'y') return name + return (channel as PositionedMaterializedChannel)[positionChannel] +} + +/** Retains positional identity when a composed mark namespaces channel keys. */ +export function preserveMaterializedPositionChannel( + name: string, + channel: MaterializedChannel, +): MaterializedChannel { + const position = readMaterializedPositionChannel(name, channel) + if (position === undefined) return channel + if ( + (channel as PositionedMaterializedChannel)[positionChannel] === position + ) { + return channel + } + const positioned: PositionedMaterializedChannel = { + ...channel, + [positionChannel]: position, + } + return positioned +} diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index dbc2af25..375cbe94 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -1217,6 +1217,61 @@ describe('SVG motion', () => { frames.restore() }) + it('scopes named-axis motion by scale ID while retaining its channel', () => { + const contexts: ChartMotionContext[] = [] + const definition = defineChart({ + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { scale: scaleLinear().domain([0, 1]), axis: false }, + alternate: { + channel: 'y', + side: 'right', + scale: scaleLinear().domain([0, 1]), + axis: { + motion(context) { + contexts.push(context) + return { + transition: { type: 'tween', duration: 100, easing: 'linear' }, + } + }, + }, + }, + }, + }) + const first = createChartScene(definition, { width: 300, height: 200 }) + const next = createChartScene(definition, { width: 400, height: 200 }) + const container = document.createElement('div') + const surface = motion({ initial: false, resize: true }).mount( + container, + () => {}, + ) + surface.render(first, { ariaLabel: 'Named guide motion' }) + const frames = installManagedFrames() + surface.render(next, { ariaLabel: 'Named guide motion' }) + + expect( + container + .querySelector('[data-ts-key="alternate-axis"]') + ?.getAttribute('data-ts-motion-role'), + ).toBe('axis') + expect(contexts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + axis: 'y', + scaleId: 'alternate', + seriesKey: 'axis:alternate', + }), + ]), + ) + + frames.run(0) + frames.run(100) + surface.destroy() + frames.restore() + }) + it('cascades definition motion through marks, axes, ticks, and labels', () => { const chartRoles: string[] = [] const markRoles: string[] = [] diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index b3b60c8f..82ab9980 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -1745,12 +1745,13 @@ function scenePathSnapshot( const points = interaction && 'points' in interaction ? (interaction.points ?? []) : [] if (!points.length) return undefined + const yScaleId = motionPointScaleId(scene, points[0], 'y') ?? 'y' return { kind: node.kind, points, geometry: node.points, chart: scene.chart, - yScale: scene.scales.y!, + yScale: scene.scales[yScaleId]!, viewportTranslate: { x: context.translateX, y: context.translateY, @@ -1760,6 +1761,23 @@ function scenePathSnapshot( } } +function motionPointScaleId( + scene: ChartScene, + point: ChartPoint | undefined, + channel: 'x' | 'y', +): string | undefined { + if (!point) return undefined + const initialized = motionSceneSource(scene)?.[1] + const mark = initialized + ?.filter( + (candidate) => + point.markId === candidate.id || + point.markId.startsWith(`${candidate.id}:`), + ) + .sort((left, right) => right.id.length - left.id.length)[0] + return mark?.channels[channel]?.scale +} + function findSceneNodeContext( nodes: readonly SceneNode[], key: string, @@ -2640,31 +2658,28 @@ function guideOrMarkTimingContext( const axes = element.closest('g.ts-chart__axes') const grid = element.closest('g.ts-chart__grid') - const axis = key.startsWith('x-') - ? 'x' - : key.startsWith('y-') - ? 'y' - : undefined - if (axis && (axes || grid)) { + const scaleId = guideScaleId(scene, key) + const axis = scaleId ? guideScaleChannel(scene, scaleId) : undefined + if (scaleId && axis && (axes || grid)) { const role: ChartMotionRole = grid ? 'grid' - : key === `${axis}-axis` + : key === `${scaleId}-axis` ? 'axis' - : key.startsWith(`${axis}-tick-rule:`) + : key.startsWith(`${scaleId}-tick-rule:`) ? 'tick' - : key.startsWith(`${axis}-tick-label:`) + : key.startsWith(`${scaleId}-tick-label:`) ? 'tick-label' - : key === `${axis}-label` + : key === `${scaleId}-label` ? 'axis-label' : 'axis' const parent = grid ?? axes const prefix = role === 'grid' - ? `${axis}-grid:` + ? `${scaleId}-grid:` : role === 'tick' - ? `${axis}-tick-rule:` + ? `${scaleId}-tick-rule:` : role === 'tick-label' - ? `${axis}-tick-label:` + ? `${scaleId}-tick-label:` : key const peers = parent ? [...parent.querySelectorAll('[data-ts-key]')].filter( @@ -2677,8 +2692,9 @@ function guideOrMarkTimingContext( role, key, axis, - seriesKey: `${role}:${axis}`, - seriesIndex: axis === 'x' ? 0 : 1, + scaleId, + seriesKey: `${role}:${scaleId}`, + seriesIndex: Math.max(0, Object.keys(scene.scales).indexOf(scaleId)), datumIndex: Math.max(0, peers.indexOf(element)), datumCount: Math.max(1, peers.length), datum: undefined, @@ -2726,6 +2742,28 @@ function guideOrMarkTimingContext( } } +function guideScaleId(scene: ChartScene, key: string): string | undefined { + return Object.keys(scene.scales) + .filter( + (id) => + key === `${id}-axis` || + key === `${id}-label` || + key.startsWith(`${id}-tick-rule:`) || + key.startsWith(`${id}-tick-label:`) || + key.startsWith(`${id}-grid:`), + ) + .sort((left, right) => right.length - left.length)[0] +} + +function guideScaleChannel( + scene: ChartScene, + scaleId: string, +): 'x' | 'y' | undefined { + if (scaleId === 'x' || scaleId === 'y') return scaleId + const definition = motionSceneSource(scene)?.[0] + return definition?.scales?.[scaleId]?.channel +} + function retargetFocusContext( element: Element, focusLayer: SVGGElement, @@ -3373,10 +3411,11 @@ function resolveTiming( .sort((left, right) => right.length - left.length)[0] if (markId) apply(definitions.marks[markId]) } - if (context.axis) { - apply(definitions?.guides?.[`axis:${context.axis}`]) + const guideId = context.scaleId ?? context.axis + if (guideId) { + apply(definitions?.guides?.[`axis:${guideId}`]) if (context.role !== 'axis') { - apply(definitions?.guides?.[`${context.role}:${context.axis}`]) + apply(definitions?.guides?.[`${context.role}:${guideId}`]) } } apply(overrides?.default) @@ -3437,29 +3476,32 @@ function motionDefinitions( const guides: Record> = {} if (source) { const [definition] = source - for (const axis of ['x', 'y'] as const) { - const configured = definition[axis] + const configuredScales = definition.scales ?? { + x: definition.x, + y: definition.y, + } + for (const [scaleId, configured] of Object.entries(configuredScales)) { const presentation = !configured || configured.axis === false ? undefined : (configured.axis ?? {}) if (presentation?.motion !== undefined) { - guides[`axis:${axis}`] = presentation.motion + guides[`axis:${scaleId}`] = presentation.motion } if (presentation?.ticks && presentation.ticks.motion !== undefined) { - guides[`tick:${axis}`] = presentation.ticks.motion + guides[`tick:${scaleId}`] = presentation.ticks.motion } if ( presentation?.tickLabels && presentation.tickLabels.motion !== undefined ) { - guides[`tick-label:${axis}`] = presentation.tickLabels.motion + guides[`tick-label:${scaleId}`] = presentation.tickLabels.motion } if ( typeof presentation?.label === 'object' && presentation.label.motion !== undefined ) { - guides[`axis-label:${axis}`] = presentation.label.motion + guides[`axis-label:${scaleId}`] = presentation.label.motion } } } diff --git a/packages/charts-core/src/network-sankey.ts b/packages/charts-core/src/network-sankey.ts index d56b92ba..140ca487 100644 --- a/packages/charts-core/src/network-sankey.ts +++ b/packages/charts-core/src/network-sankey.ts @@ -28,6 +28,7 @@ import type { ChartMarkPointY, ChartMotionContext, ChartMotionDefinition, + ChartMarkMotionOptions, } from './types' type AnyChartMark = ChartMark @@ -195,7 +196,7 @@ export interface SankeyDiagramOptions< TTarget extends TransformValue, TValue extends TransformValue, TMarks extends SankeyMarks, -> { +> extends ChartMarkMotionOptions> { readonly id?: string readonly nodes: Iterable readonly links: Iterable @@ -231,7 +232,6 @@ export interface SankeyDiagramOptions< readonly marks: ( context: SankeyDiagramContext>, ) => TMarks - readonly motion?: ChartMotionDefinition> } /** Resolves a proportional flow layout, then composes ordinary pixel marks. */ @@ -297,113 +297,117 @@ export function sankeyDiagram< ChartMarkPointY, never, never - >(({ markIndex }) => { - const id = options.id ?? `sankey-${markIndex}` - let childMotions = new Map>() - const motion = ( - context: ChartMotionContext>, - ) => resolveCompositeChildMotion(options.motion, childMotions, context) - return { - id, - channels: {}, - motion, - resolveLayout: ({ chart }) => { - const nodeWidth = resolveLayoutNumber( - options.nodeWidth, - chart, - 24, - 'nodeWidth', - true, - ) - const nodePadding = resolveLayoutNumber( - options.nodePadding, - chart, - 8, - 'nodePadding', - false, - ) - const inset = resolveInset(options.inset, chart) - const extent = { - x0: chart.x + inset.left, - y0: chart.y + inset.top, - x1: chart.x + chart.width - inset.right, - y1: chart.y + chart.height - inset.bottom, - } - if (extent.x1 - extent.x0 < nodeWidth) { - throw new TypeError( - 'sankeyDiagram: inset leaves less horizontal space than nodeWidth', + >( + ({ markIndex }) => { + const id = options.id ?? `sankey-${markIndex}` + let childMotions = new Map>() + const motion = ( + context: ChartMotionContext>, + ) => resolveCompositeChildMotion(options.motion, childMotions, context) + return { + id, + channels: {}, + motion, + resolveLayout: ({ chart }) => { + const nodeWidth = resolveLayoutNumber( + options.nodeWidth, + chart, + 24, + 'nodeWidth', + true, ) - } - if (extent.y1 <= extent.y0) { - throw new TypeError( - 'sankeyDiagram: inset leaves no vertical layout space', + const nodePadding = resolveLayoutNumber( + options.nodePadding, + chart, + 8, + 'nodePadding', + false, ) - } - - const laidOut = - graph.nodes.length === 0 - ? { nodes: [], links: [] } - : createSankey, WorkingLink>() - .nodeId((node) => node.key) - .nodeAlign((node, columnCount) => - aligner( - node as unknown as SankeyAlignmentNode< - TNode, - TLink, - TResolvedNodeKey - >, - columnCount, - ), - ) - .nodeWidth(nodeWidth) - .nodePadding(nodePadding) - .extent([ - [extent.x0, extent.y0], - [extent.x1, extent.y1], - ]) - .iterations(iterations) - .nodeSort(resolveNodeSort(options.nodeSort)) - .linkSort(resolveLinkSort(options.linkSort))({ - nodes: graph.nodes.map((data, index) => ({ - data, - key: graph.nodeKeys[index] as TResolvedNodeKey, - sourceIndex: index, - })), - links: graph.links.map((data, index) => ({ - data, - key: linkKeys[index] as ChartKey, - source: graph.sourceKeys[index] as TResolvedNodeKey, - target: graph.targetKeys[index] as TResolvedNodeKey, - value: values[index] as number, - sourceIndex: index, - })), - }) - const output = materializeSankey( - laidOut.nodes, - laidOut.links, - graph.nodeIndexes, - ) - const marks = options.marks({ id, chart, ...output }) - if (!Array.isArray(marks) || marks.length === 0) { - throw new TypeError( - 'sankeyDiagram: marks must return at least one chart mark', + const inset = resolveInset(options.inset, chart) + const extent = { + x0: chart.x + inset.left, + y0: chart.y + inset.top, + x1: chart.x + chart.width - inset.right, + y1: chart.y + chart.height - inset.bottom, + } + if (extent.x1 - extent.x0 < nodeWidth) { + throw new TypeError( + 'sankeyDiagram: inset leaves less horizontal space than nodeWidth', + ) + } + if (extent.y1 <= extent.y0) { + throw new TypeError( + 'sankeyDiagram: inset leaves no vertical layout space', + ) + } + + const laidOut = + graph.nodes.length === 0 + ? { nodes: [], links: [] } + : createSankey, WorkingLink>() + .nodeId((node) => node.key) + .nodeAlign((node, columnCount) => + aligner( + node as unknown as SankeyAlignmentNode< + TNode, + TLink, + TResolvedNodeKey + >, + columnCount, + ), + ) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [extent.x0, extent.y0], + [extent.x1, extent.y1], + ]) + .iterations(iterations) + .nodeSort(resolveNodeSort(options.nodeSort)) + .linkSort(resolveLinkSort(options.linkSort))({ + nodes: graph.nodes.map((data, index) => ({ + data, + key: graph.nodeKeys[index] as TResolvedNodeKey, + sourceIndex: index, + })), + links: graph.links.map((data, index) => ({ + data, + key: linkKeys[index] as ChartKey, + source: graph.sourceKeys[index] as TResolvedNodeKey, + target: graph.targetKeys[index] as TResolvedNodeKey, + value: values[index] as number, + sourceIndex: index, + })), + }) + const output = materializeSankey( + laidOut.nodes, + laidOut.links, + graph.nodeIndexes, ) - } - const children = marks.map((mark, childIndex) => - mark.initialize({ markIndex: childIndex }), - ) - const composition = composeResolvedChildMarks(id, children) - childMotions = new Map( - children.flatMap((child, childIndex) => { - const childMotion = child.motion ?? marks[childIndex]?.motion - if (childMotion === undefined) return [] - return [[resolvedChildMarkId(id, child.id), childMotion] as const] - }), - ) - return composition - }, - } - }, options.motion) + const marks = options.marks({ id, chart, ...output }) + if (!Array.isArray(marks) || marks.length === 0) { + throw new TypeError( + 'sankeyDiagram: marks must return at least one chart mark', + ) + } + const children = marks.map((mark, childIndex) => + mark.initialize({ markIndex: childIndex }), + ) + const composition = composeResolvedChildMarks(id, children) + childMotions = new Map( + children.flatMap((child, childIndex) => { + const childMotion = child.motion ?? marks[childIndex]?.motion + if (childMotion === undefined) return [] + return [[resolvedChildMarkId(id, child.id), childMotion] as const] + }), + ) + return composition + }, + } + }, + options.motion, + options.renderer, + ) } interface WorkingNode { diff --git a/packages/charts-core/src/polar-mark-internal.ts b/packages/charts-core/src/polar-mark-internal.ts index 0f2abbcb..26158d91 100644 --- a/packages/charts-core/src/polar-mark-internal.ts +++ b/packages/charts-core/src/polar-mark-internal.ts @@ -1,13 +1,17 @@ import type { ChartBounds, ChartKey, + ChartMarkRenderer, ChartMotionDefinition, ChartTheme, ChartValue, MarkScene, } from './types' +import { applyMarkRendererToScene } from './mark' export interface PolarResolvedScale { + id: string + channel: 'angle' | 'radius' domain: readonly TValue[] map: (value: TValue) => number ticks: (count: number) => readonly TValue[] @@ -21,6 +25,7 @@ export interface PolarLayoutContext { radius: number startAngle: number endAngle: number + scales: Readonly> angle?: PolarResolvedScale radiusScale?: PolarResolvedScale } @@ -63,6 +68,7 @@ export interface PolarMark< context: PolarMarkInitializeContext, ) => InitializedPolarMark motion?: ChartMotionDefinition + renderer?: ChartMarkRenderer readonly __datum?: TDatum readonly __angle?: TAngle readonly __radius?: TRadius @@ -78,12 +84,24 @@ export function createPolarMark< context: PolarMarkInitializeContext, ) => InitializedPolarMark, motion?: ChartMotionDefinition, + renderer?: ChartMarkRenderer, ): PolarMark { - if (motion === undefined) return { initialize } return { - motion, + ...(motion === undefined ? {} : { motion }), + ...(renderer === undefined ? {} : { renderer }), initialize(context) { - return { ...initialize(context), motion } + const initialized = initialize(context) + const withMotion = + motion === undefined || initialized.motion !== undefined + ? initialized + : { ...initialized, motion } + if (renderer === undefined) return withMotion + const render = withMotion.render + return { + ...withMotion, + render: (renderContext) => + applyMarkRendererToScene(render(renderContext), renderer), + } }, } } diff --git a/packages/charts-core/src/polar-radial-bar.test.ts b/packages/charts-core/src/polar-radial-bar.test.ts index bc8770a8..9881d270 100644 --- a/packages/charts-core/src/polar-radial-bar.test.ts +++ b/packages/charts-core/src/polar-radial-bar.test.ts @@ -33,13 +33,16 @@ describe('polar radial bars', () => { .range([7, 9]) const sourceRadius = scaleLinear().domain([0, 8]).range([7, 9]) const definition = defineChart({ + scales: { x: null, y: null }, marks: [ polar({ radiusRatio: 0.8, - angle: { scale: sourceAngle }, - radius: { - scale: sourceRadius, - range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + scales: { + angle: { scale: sourceAngle }, + radius: { + scale: sourceRadius, + range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + }, }, marks: [ radialBarRadius(rows, { @@ -97,12 +100,15 @@ describe('polar radial bars', () => { it('maps explicit radial baselines through the configured radius range', () => { const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleBand().domain(['A', 'B']) }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + scales: { + angle: { scale: scaleBand().domain(['A', 'B']) }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + }, }, marks: [ radialBarRadius(rows, { @@ -137,12 +143,15 @@ describe('polar radial bars', () => { const datum = { id: 'negative', category: 'A', value: -4 } const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleBand().domain(['A']) }, - radius: { - scale: scaleLinear().domain([-8, 8]), - range: [20, 100], + scales: { + angle: { scale: scaleBand().domain(['A']) }, + radius: { + scale: scaleLinear().domain([-8, 8]), + range: [20, 100], + }, }, marks: [ radialBarRadius([datum], { @@ -182,12 +191,15 @@ describe('polar radial bars', () => { .paddingInner(0.38) .paddingOuter(0.19) const definition = defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 8]) }, - radius: { - scale: radiusScale, - range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + scales: { + angle: { scale: scaleLinear().domain([0, 8]) }, + radius: { + scale: radiusScale, + range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + }, }, marks: [ radialBarAngle(rows, { @@ -254,12 +266,15 @@ describe('polar radial bars', () => { ] const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 8]) }, - radius: { - scale: scaleBand().domain(['A', 'B']), - range: [20, 100], + scales: { + angle: { scale: scaleLinear().domain([0, 8]) }, + radius: { + scale: scaleBand().domain(['A', 'B']), + range: [20, 100], + }, }, marks: [ radialBarAngle(data, { @@ -293,12 +308,15 @@ describe('polar radial bars', () => { const datum = { id: 'rounded', ring: 'A', value: 1 } const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 4]) }, - radius: { - scale: scaleBand().domain(['A']), - range: [20, 40], + scales: { + angle: { scale: scaleLinear().domain([0, 4]) }, + radius: { + scale: scaleBand().domain(['A']), + range: [20, 40], + }, }, marks: [ radialBarAngle([datum], { @@ -325,12 +343,15 @@ describe('polar radial bars', () => { const datum = { id: 'reversed-angle', ring: 'A', start: 3, end: 1 } const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 4]) }, - radius: { - scale: scaleBand().domain(['A']), - range: [20, 40], + scales: { + angle: { scale: scaleLinear().domain([0, 4]) }, + radius: { + scale: scaleBand().domain(['A']), + range: [20, 40], + }, }, marks: [ radialBarAngle([datum], { @@ -363,10 +384,13 @@ describe('polar radial bars', () => { expect(() => createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scalePoint().domain(['A', 'B']) }, - radius: { scale: scaleLinear().domain([0, 8]) }, + scales: { + angle: { scale: scalePoint().domain(['A', 'B']) }, + radius: { scale: scaleLinear().domain([0, 8]) }, + }, marks: [ radialBarRadius(rows, { angle: 'category', @@ -383,10 +407,13 @@ describe('polar radial bars', () => { expect(() => createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 8]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 8]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, marks: [ radialBarAngle( rows.map((row, index) => ({ ...row, ring: index })), @@ -403,12 +430,15 @@ describe('polar radial bars', () => { expect(() => createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleBand().domain(['A', 'B']) }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [Number.NaN, 100], + scales: { + angle: { scale: scaleBand().domain(['A', 'B']) }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [Number.NaN, 100], + }, }, marks: [ radialBarRadius(rows, { @@ -426,12 +456,15 @@ describe('polar radial bars', () => { expect(() => createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleBand().domain(['A', 'B']) }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [] as unknown as [number, number], + scales: { + angle: { scale: scaleBand().domain(['A', 'B']) }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [] as unknown as [number, number], + }, }, marks: [ radialBarRadius(rows, { @@ -450,16 +483,19 @@ describe('polar radial bars', () => { it('re-resolves responsive ranges without mutating the source scale', () => { const radius = scaleLinear().domain([0, 8]).range([11, 13]) const definition = defineChart({ + scales: { x: null, y: null }, marks: [ polar({ radiusRatio: 0.8, - angle: { scale: scaleBand().domain(['A', 'B']) }, - radius: { - scale: radius, - range: [ - ({ radius: resolved }) => resolved * 0.25, - ({ radius: resolved }) => resolved, - ], + scales: { + angle: { scale: scaleBand().domain(['A', 'B']) }, + radius: { + scale: radius, + range: [ + ({ radius: resolved }) => resolved * 0.25, + ({ radius: resolved }) => resolved, + ], + }, }, marks: [ radialBarRadius(rows, { @@ -483,12 +519,15 @@ describe('polar radial bars', () => { const datum = { id: 'reversed', category: 'A', value: 6, low: 2 } const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleBand().domain(['A']) }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [100, 20], + scales: { + angle: { scale: scaleBand().domain(['A']) }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [100, 20], + }, }, marks: [ radialBarRadius([datum], { diff --git a/packages/charts-core/src/polar-scales.test.ts b/packages/charts-core/src/polar-scales.test.ts new file mode 100644 index 00000000..0d08ac8a --- /dev/null +++ b/packages/charts-core/src/polar-scales.test.ts @@ -0,0 +1,417 @@ +import { scaleBand, scaleLinear } from 'd3-scale' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { + angleGrid, + polar, + radialArc, + radialArea, + radialBarAngle, + radialBarRadius, + radialDot, + radialGrid, + radialLine, + radialRule, + radialText, +} from './polar' +import { createChartScene, defineChart } from './scene' +import type { + PolarLayoutContext, + PolarLength, + PolarMark, + PolarOptions, +} from './polar' +import type { SceneNode } from './types' + +interface Row { + id: string + angle: number + radius: number +} + +interface ExtendedPolarOptions extends PolarOptions { + applicationTag?: string +} + +describe('polar scale registry', () => { + it('exposes named scales to responsive length callbacks', () => { + let observedScaleId: string | undefined + const outerRadius: PolarLength = (layout) => { + expectTypeOf(layout).toEqualTypeOf() + const namedRadius = layout.scales.namedRadius + observedScaleId = namedRadius?.id + return namedRadius?.map(1) ?? layout.radius + } + + createChartScene( + defineChart({ + marks: [ + polar({ + scales: { + angle: null, + radius: null, + namedRadius: { + channel: 'radius', + scale: scaleLinear().domain([0, 1]), + }, + }, + marks: [ + radialArc([{ startAngle: 0, endAngle: Math.PI }], { + outerRadius, + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + { width: 200, height: 200 }, + ) + + expect(observedScaleId).toBe('namedRadius') + }) + + it('binds every scale-backed mark and both guides to named scales', () => { + const angleLinear = scaleLinear().domain([0, 1]) + const angleBand = scaleBand().domain(['A']) + const radiusLinear = scaleLinear().domain([0, 1]) + const radiusBand = scaleBand().domain(['ring']) + const rows: readonly Row[] = [{ id: 'point', angle: 0.25, radius: 0.5 }] + const definition = defineChart({ + marks: [ + polar({ + scales: { + angle: null, + radius: null, + angleLinear: { channel: 'angle', scale: angleLinear }, + angleBand: { channel: 'angle', scale: angleBand }, + radiusLinear: { channel: 'radius', scale: radiusLinear }, + radiusBand: { channel: 'radius', scale: radiusBand }, + }, + guides: [ + angleGrid({ + scale: 'angleLinear', + values: [0.25], + labels: false, + }), + radialGrid({ + scale: 'radiusLinear', + values: [0.5], + labels: true, + }), + radialGrid({ + scale: 'radiusLinear', + angleScale: 'angleBand', + values: [0.75], + shape: 'polygon', + }), + ], + marks: [ + radialDot(rows, { + id: 'named-dot', + angle: 'angle', + radius: 'radius', + angleScale: 'angleLinear', + radiusScale: 'radiusLinear', + }), + radialLine(rows, { + angle: 'angle', + radius: 'radius', + angleScale: 'angleLinear', + radiusScale: 'radiusLinear', + }), + radialArea(rows, { + angle: 'angle', + radius: 'radius', + angleScale: 'angleLinear', + radiusScale: 'radiusLinear', + }), + radialText(rows, { + angle: 'angle', + radius: 'radius', + text: 'id', + angleScale: 'angleLinear', + radiusScale: 'radiusLinear', + }), + radialRule(rows, { + angle: 'angle', + radius1: 0, + radius2: 'radius', + angleScale: 'angleLinear', + radiusScale: 'radiusLinear', + }), + radialBarRadius([{ id: 'radius-bar', angle: 'A', radius: 0.8 }], { + angle: 'angle', + radius: 'radius', + angleScale: 'angleBand', + radiusScale: 'radiusLinear', + }), + radialBarAngle([{ id: 'angle-bar', angle: 0.8, radius: 'ring' }], { + angle: 'angle', + radius: 'radius', + angleScale: 'angleLinear', + radiusScale: 'radiusBand', + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + }) + + const scene = createChartScene(definition, { width: 200, height: 200 }) + const nodes = flatten(scene.nodes) + + expect(scene.points).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + markId: 'named-dot', + x: expect.closeTo(150, 6), + y: expect.closeTo(100, 6), + }), + ]), + ) + expect(nodes.some((node) => node.kind === 'rule')).toBe(true) + expect( + nodes.some((node) => node.kind === 'label' && node.text === '0.5'), + ).toBe(true) + expect(nodes.filter((node) => node.kind === 'area').length).toBeGreaterThan( + 2, + ) + expect(angleLinear.range()).toEqual([0, 1]) + expect(angleBand.range()).toEqual([0, 1]) + expect(radiusLinear.range()).toEqual([0, 1]) + expect(radiusBand.range()).toEqual([0, 1]) + }) + + it('validates reserved, missing, and cross-channel scale bindings', () => { + const render = (options: PolarOptions) => + createChartScene( + defineChart({ + marks: [polar(options)], + scales: { x: null, y: null }, + guides: false, + }), + { width: 200, height: 200 }, + ) + const mark = radialDot([{ angle: 0.5, radius: 0.5 }], { + id: 'selected-scale', + angle: 'angle', + radius: 'radius', + angleScale: 'selected', + radiusScale: 'radius', + }) + + expect(() => + render({ + scales: { + angle: null, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [mark], + }), + ).toThrow( + /Polar mark "selected-scale" requires a configured angle scale "selected" in polar\.scales/, + ) + + expect(() => + render({ + scales: { + angle: null, + radius: { scale: scaleLinear().domain([0, 1]) }, + selected: { + channel: 'radius', + scale: scaleLinear().domain([0, 1]), + }, + }, + marks: [mark], + }), + ).toThrow(/uses scale "selected" as angle, but it is configured for radius/) + + expect(() => + render({ + scales: { + angle: null, + radius: null, + selected: { scale: scaleLinear().domain([0, 1]) }, + } as any, + marks: [], + }), + ).toThrow(/Named polar scale "selected" requires channel/) + + expect(() => + render({ + scales: { + angle: { + channel: 'radius', + scale: scaleLinear().domain([0, 1]), + }, + radius: null, + } as any, + marks: [], + }), + ).toThrow(/reserved for angle but declares channel: "radius"/) + + expect(() => + render({ + scales: { angle: null } as any, + marks: [], + }), + ).toThrow(/must define reserved `angle` and `radius` entries/) + }) + + it('keeps raw arcs scale-free with explicit reserved nulls', () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mark = radialArc([{ startAngle: 0, endAngle: Math.PI }]) + const scene = createChartScene( + defineChart({ + marks: [ + polar({ + scales: { angle: null, radius: null }, + marks: [mark], + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + { width: 200, height: 200 }, + ) + + expect(scene.points).toHaveLength(1) + expect(warning).not.toHaveBeenCalled() + expectTypeOf(mark).toMatchTypeOf< + PolarMark + >() + warning.mockRestore() + }) + + it('adapts legacy root scales with one actionable warning', async () => { + vi.resetModules() + const freshPolar = await import('./polar') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const angle = scaleLinear().domain([0, 1]) + const radius = scaleLinear().domain([0, 1]) + const mark = freshPolar.radialDot([{ angle: 0.5, radius: 0.5 }], { + angle: 'angle', + radius: 'radius', + }) + const legacy = defineChart({ + marks: [ + freshPolar.polar({ + angle: { scale: angle }, + radius: { scale: radius }, + marks: [mark], + }), + ], + scales: { x: null, y: null }, + guides: false, + }) + + createChartScene(legacy, { width: 200, height: 200 }) + createChartScene(legacy, { width: 220, height: 220 }) + + const messages = warning.mock.calls + .map(([message]) => String(message)) + .filter((message) => + message.includes('`polar()` scale options have moved'), + ) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain( + 'Move `angle` and `radius` to `scales.angle` and `scales.radius`', + ) + expect(messages[0]).toContain('set both entries to `null`') + expect(messages[0]).toContain('enters Alpha') + warning.mockRestore() + }) + + it('warns for legacy polar scales without a process global', async () => { + vi.resetModules() + const freshPolar = await import('./polar') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const nodeProcess = globalThis.process + vi.stubGlobal('process', undefined) + + try { + createChartScene( + defineChart({ + marks: [ + freshPolar.polar({ + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + marks: [ + freshPolar.radialDot([{ angle: 0.5, radius: 0.5 }], { + angle: 'angle', + radius: 'radius', + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + { width: 200, height: 200 }, + ) + } finally { + vi.stubGlobal('process', nodeProcess) + } + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('`polar()` scale options have moved'), + ) + warning.mockRestore() + }) +}) + +if (false) { + const namedDot = radialDot([{ angle: 1, radius: 0.5 }], { + angle: 'angle', + radius: 'radius', + angleScale: 'quadrant', + radiusScale: 'percent', + }) + const defaultDot = radialDot([{ angle: 'A', radius: 0.5 }], { + angle: 'angle', + radius: 'radius', + }) + const explicitUndefined = radialDot([{ angle: 1, radius: 0.5 }], { + angle: 'angle', + radius: 'radius', + angleScale: undefined, + radiusScale: undefined, + }) + + expectTypeOf(namedDot).toMatchTypeOf< + PolarMark + >() + expectTypeOf(explicitUndefined).toMatchTypeOf< + PolarMark + >() + + const extended: ExtendedPolarOptions = { + applicationTag: 'extended', + scales: { + angle: { scale: scaleBand().domain(['A']) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, + marks: [defaultDot], + } + polar(extended) + + polar({ + scales: { + angle: { scale: scaleBand().domain(['A']) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + quadrant: { channel: 'angle', scale: scaleLinear().domain([0, 4]) }, + percent: { channel: 'radius', scale: scaleLinear().domain([0, 1]) }, + }, + marks: [defaultDot, namedDot], + }) +} + +function flatten(nodes: readonly SceneNode[]): SceneNode[] { + const result: SceneNode[] = [] + for (const node of nodes) { + result.push(node) + if (node.kind === 'group') result.push(...flatten(node.children)) + } + return result +} diff --git a/packages/charts-core/src/polar.test.ts b/packages/charts-core/src/polar.test.ts index b5ab099f..c21863b9 100644 --- a/packages/charts-core/src/polar.test.ts +++ b/packages/charts-core/src/polar.test.ts @@ -50,6 +50,7 @@ describe('polar marks', () => { marks: [ polar({ radiusRatio: 0.8, + scales: { angle: null, radius: null }, marks: [ radialArc(arcs, { key: 'id', @@ -61,8 +62,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) const small = createChartScene(definition, { @@ -121,8 +121,10 @@ describe('polar marks', () => { const arcs = pie(slices, { value: 'value', gapAngle }) const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ + scales: { angle: null, radius: null }, marks: [radialArc(arcs, { id: 'gapped-arcs', key: 'id' })], }), ], @@ -168,8 +170,10 @@ describe('polar marks', () => { defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 2]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 2]) }, + }, marks: [ radialLine(data, { angle: 'angle', @@ -181,8 +185,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, color: { scale: colors }, }), @@ -223,8 +226,10 @@ describe('polar marks', () => { defineChart({ marks: [ polar({ - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 2]) }, + scales: { + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 2]) }, + }, marks: [ radialLine(data, { angle: 'angle', @@ -239,8 +244,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, color: { scale: scaleOrdinal() @@ -290,8 +294,10 @@ describe('polar marks', () => { marks: [ polar({ radiusRatio: 0.8, - angle: { scale: angle }, - radius: { scale: radius }, + scales: { + angle: { scale: angle }, + radius: { scale: radius }, + }, guides: [ radialGrid({ values: [50, 100], @@ -323,8 +329,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) const scene = createChartScene(definition, { @@ -430,8 +435,10 @@ describe('polar marks', () => { polar({ startAngle: -Math.PI / 2, endAngle: Math.PI / 2, - angle: { scale: angle }, - radius: { scale: radius }, + scales: { + angle: { scale: angle }, + radius: { scale: radius }, + }, marks: [ radialRule(ticks, { angle: 'value', @@ -455,8 +462,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) const scene = createChartScene(definition, { @@ -568,10 +574,13 @@ describe('polar marks', () => { key: 'id', }) const definition = defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, marks: [ruleMark, textMark], }), ], @@ -680,10 +689,13 @@ describe('polar marks', () => { })) const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, tau]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, tau]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, guides: [ angleGrid({ values }), angleGrid({ @@ -846,10 +858,13 @@ describe('polar marks', () => { }) const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleLinear().domain([0, Math.PI * 2]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, marks: [ruleMark, textMark], }), ], @@ -887,11 +902,11 @@ describe('polar marks', () => { const arcOnly = defineChart({ marks: [ polar({ + scales: { angle: null, radius: null }, marks: [radialArc([{ startAngle: 0, endAngle: Math.PI }])], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) expect(() => @@ -899,9 +914,13 @@ describe('polar marks', () => { ).not.toThrow() const missingScale = defineChart({ - marks: [polar({ marks: [radialLine([1, 2, 3])] })], - x: null, - y: null, + marks: [ + polar({ + scales: { angle: null, radius: null }, + marks: [radialLine([1, 2, 3])], + }), + ], + scales: { x: null, y: null }, guides: false, }) expect(() => @@ -911,13 +930,15 @@ describe('polar marks', () => { const missingPolygonAngle = defineChart({ marks: [ polar({ - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: null, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, guides: [radialGrid({ values: [1], shape: 'polygon' })], marks: [], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) expect(() => @@ -948,6 +969,7 @@ describe('polar marks', () => { const definition = defineChart({ marks: [ polar({ + scales: { angle: null, radius: null }, marks: [ radialArc(rings, { key: 'id', @@ -961,8 +983,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) const scene = createChartScene(definition, { @@ -988,8 +1009,10 @@ describe('polar marks', () => { const definition = defineChart({ marks: [ polar({ - angle: { scale: angle }, - radius: { scale: radius }, + scales: { + angle: { scale: angle }, + radius: { scale: radius }, + }, marks: [ radialDot(data, { angle: 'direction', @@ -998,8 +1021,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }) const scene = createChartScene(definition, { @@ -1026,17 +1048,20 @@ describe('polar marks', () => { ] const scene = createChartScene( defineChart({ + scales: { x: null, y: null }, marks: [ polar({ - angle: { - scale: scalePoint().domain([ - 'north', - 'east', - 'south', - 'west', - ]), + scales: { + angle: { + scale: scalePoint().domain([ + 'north', + 'east', + 'south', + 'west', + ]), + }, + radius: { scale: scaleLinear().domain([0, 1]) }, }, - radius: { scale: scaleLinear().domain([0, 1]) }, marks: [ radialLine(rows, { angle: 'direction', @@ -1075,11 +1100,13 @@ describe('polar marks', () => { polar({ startAngle: -Math.PI / 2, endAngle: Math.PI / 2, - angle: { - scale: sourceAngle, - ...(wrap === undefined ? {} : { wrap }), + scales: { + angle: { + scale: sourceAngle, + ...(wrap === undefined ? {} : { wrap }), + }, + radius: { scale: sourceRadius }, }, - radius: { scale: sourceRadius }, marks: [ radialDot(data, { angle: 'direction', @@ -1088,8 +1115,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }), { width: 200, height: 200 }, @@ -1128,8 +1154,10 @@ describe('polar marks', () => { defineChart({ marks: [ polar({ - angle: { scale: scalePoint }, - radius: { scale: scaleLinear, nice: true }, + scales: { + angle: { scale: scalePoint }, + radius: { scale: scaleLinear, nice: true }, + }, marks: [ radialDot(data, { angle: 'direction', @@ -1138,8 +1166,7 @@ describe('polar marks', () => { ], }), ], - x: null, - y: null, + scales: { x: null, y: null }, guides: false, }), { width: 200, height: 200 }, @@ -1162,6 +1189,10 @@ describe('polar marks', () => { it('snapshots child motion for each reused polar initialization', () => { const mark = polar({ + scales: { + angle: { scale: scaleLinear }, + radius: { scale: scaleLinear }, + }, motion: { delay: 5, transition: { type: 'tween', duration: 100 }, @@ -1241,15 +1272,19 @@ if (false) { }) polar({ - angle: { scale: scaleBand().domain(['A']) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + angle: { scale: scaleBand().domain(['A']) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, marks: [categoricalDots], }) polar({ - // @ts-expect-error A numeric scale cannot materialize categorical angles. - angle: { scale: scaleLinear().domain([0, 1]) }, - radius: { scale: scaleLinear().domain([0, 1]) }, + scales: { + // @ts-expect-error A numeric scale cannot materialize categorical angles. + angle: { scale: scaleLinear().domain([0, 1]) }, + radius: { scale: scaleLinear().domain([0, 1]) }, + }, marks: [categoricalDots], }) diff --git a/packages/charts-core/src/polar.ts b/packages/charts-core/src/polar.ts index 80ddb960..ead17066 100644 --- a/packages/charts-core/src/polar.ts +++ b/packages/charts-core/src/polar.ts @@ -16,7 +16,7 @@ import { } from './mark' import { resolveCompositeChildMotion } from './composite-motion-internal' import { createMarkWithScaleValues } from './mark-with-scale-values' -import { createPolarMark } from './polar-mark-internal' +import { createPolarMark as createInternalPolarMark } from './polar-mark-internal' import { focusGroupAngle, withPolarFocusGeometry } from './polar-focus-internal' import { resolvePolarSector, @@ -32,40 +32,95 @@ import type { ChartBounds, ChartKey, ChartMark, + ChartMarkRenderer, ChartMarkMotionOptions, ChartMotionContext, ChartMotionDefinition, ChartNumericScale, ChartPoint, + MarkScene, ChartTheme, ChartValue, ChartScaleInput, OptionChannelOutput, + OptionScaleId, SceneNode, VisualChannel, } from './types' import type { - InitializedPolarMark, - PolarLayoutContext, - PolarMark, - PolarResolvedScale, + InitializedPolarMark as InternalInitializedPolarMark, + PolarLayoutContext as InternalPolarLayoutContext, + PolarMarkInitializeContext, + PolarMarkRenderContext, + PolarResolvedScale as InternalPolarResolvedScale, } from './polar-mark-internal' export { pie } from './polar-pie' export { focusGroupAngle } export type { PieDatum, PieOptions } from './polar-pie' -export type { - PolarLayoutContext, - PolarMark, - PolarResolvedScale, -} from './polar-mark-internal' const tau = Math.PI * 2 +declare const process: { readonly env: { readonly NODE_ENV?: string } } + +export type PolarPositionChannel = 'angle' | 'radius' + +export interface PolarResolvedScale< + TValue extends ChartValue = ChartValue, +> extends InternalPolarResolvedScale { + id: string + channel: PolarPositionChannel +} + +export interface PolarLayoutContext extends InternalPolarLayoutContext { + scales: Readonly> + /** @deprecated Read the reserved `angle` entry from `scales` instead. */ + angle?: PolarResolvedScale + /** @deprecated Read the reserved `radius` entry from `scales` instead. */ + radiusScale?: PolarResolvedScale +} + +interface InitializedPolarMark< + TDatum = unknown, + TAngle extends ChartValue = ChartValue, + TRadius extends ChartValue = ChartValue, +> extends Omit< + InternalInitializedPolarMark, + 'render' +> { + angleScale?: string + radiusScale?: string + render: ( + context: Omit & { + layout: PolarLayoutContext + }, + ) => MarkScene +} + +export interface PolarMark< + TDatum = unknown, + TAngle extends ChartValue = ChartValue, + TRadius extends ChartValue = ChartValue, + TAngleScaleId extends string = 'angle', + TRadiusScaleId extends string = 'radius', +> { + initialize: ( + context: PolarMarkInitializeContext, + ) => InitializedPolarMark + motion?: ChartMotionDefinition + renderer?: ChartMarkRenderer + readonly __datum?: TDatum + readonly __angle?: TAngle + readonly __radius?: TRadius + readonly __angleScaleId?: TAngleScaleId + readonly __radiusScaleId?: TRadiusScaleId +} + export type PolarLength = number | ((context: PolarLayoutContext) => number) export interface PolarAngleOptions { scale: ChartScaleInput + channel?: 'angle' nice?: boolean | number /** * Avoids placing the first and last points at the same angle for point @@ -77,11 +132,15 @@ export interface PolarAngleOptions { export interface PolarRadiusOptions { scale: ChartScaleInput + channel?: 'radius' nice?: boolean | number /** Responsive pixel range for the copied radius scale. Defaults to [0, radius]. */ range?: readonly [PolarLength, PolarLength] } +export type PolarPositionScaleOptions = + PolarAngleOptions | PolarRadiusOptions + interface PolarGuideRenderContext { layout: PolarLayoutContext theme: ChartTheme @@ -98,16 +157,41 @@ export interface PolarGuideScene { foreground?: readonly SceneNode[] } -type AnyPolarMark = PolarMark +type AnyPolarMark = PolarMark type PolarMarkDatum = - TMark extends PolarMark ? TDatum : never + TMark extends PolarMark ? TDatum : never type PolarMarkAngle = - TMark extends PolarMark ? TAngle : never + TMark extends PolarMark ? TAngle : never type PolarMarkRadius = - TMark extends PolarMark ? TRadius : never + TMark extends PolarMark ? TRadius : never + +type PolarMarkScaleAngle = + TMark extends PolarMark + ? 'angle' extends TScaleId + ? TAngle + : never + : never + +type PolarMarkScaleRadius = + TMark extends PolarMark + ? 'radius' extends TScaleId + ? TRadius + : never + : never + +export type PolarScales< + TMarks extends readonly AnyPolarMark[] = readonly AnyPolarMark[], +> = Readonly> & { + angle: PolarAngleOptions< + ChartAxisValue> + > | null + radius: PolarRadiusOptions< + ChartAxisValue> + > | null +} export interface PolarOptions< TMarks extends readonly AnyPolarMark[] = readonly AnyPolarMark[], @@ -116,8 +200,13 @@ export interface PolarOptions< className?: string marks: TMarks guides?: readonly PolarGuide[] - angle?: PolarAngleOptions>> - radius?: PolarRadiusOptions>> + scales?: PolarScales + /** @deprecated Move this value to `scales.angle`. */ + angle?: PolarAngleOptions>> + /** @deprecated Move this value to `scales.radius`. */ + radius?: PolarRadiusOptions< + ChartAxisValue> + > startAngle?: number endAngle?: number /** Pixel inset applied before radiusRatio. */ @@ -141,9 +230,21 @@ export function polar( return createMarkWithScaleValues( ({ markIndex }) => { const id = options.id ?? `polar-${markIndex}` - const marks = options.marks.map((mark, polarMarkIndex) => - mark.initialize({ markIndex: polarMarkIndex, parentId: id }), - ) + const marks = options.marks.map((mark, polarMarkIndex) => { + const initialized = mark.initialize({ + markIndex: polarMarkIndex, + parentId: id, + }) + return { + ...initialized, + angleScale: + initialized.angleScale ?? + (initialized.requiresAngleScale ? 'angle' : undefined), + radiusScale: + initialized.radiusScale ?? + (initialized.requiresRadiusScale ? 'radius' : undefined), + } + }) const childMotions = new Map( marks.flatMap((mark, markIndex) => { const childMotion = mark.motion ?? options.marks[markIndex]?.motion @@ -167,18 +268,23 @@ export function polar( }, render: ({ chart, color, theme }) => { const layout = resolvePolarLayout(options, chart, marks) - if (marks.some((mark) => mark.requiresAngleScale) && !layout.angle) { - throw new TypeError( - `Polar mark in "${id}" requires a configured angle scale`, - ) - } - if ( - marks.some((mark) => mark.requiresRadiusScale) && - !layout.radiusScale - ) { - throw new TypeError( - `Polar mark in "${id}" requires a configured radius scale`, - ) + for (const mark of marks) { + if (mark.angleScale) { + requiredPolarScale( + layout, + mark.angleScale, + 'angle', + `Polar mark "${mark.id}"`, + ) + } + if (mark.radiusScale) { + requiredPolarScale( + layout, + mark.radiusScale, + 'radius', + `Polar mark "${mark.id}"`, + ) + } } const nodes: SceneNode[] = [] @@ -221,6 +327,7 @@ export function polar( } }, options.motion, + options.renderer, ) } @@ -257,161 +364,173 @@ export interface RadialArcOptions< export function radialArc( source: Iterable, options: RadialArcOptions> = {}, -): PolarMark { +): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:arc-${markIndex}` - const startAngles = channelValues(data, options.startAngle, (datum) => - numberProperty(datum, 'startAngle'), - ) - const endAngles = channelValues(data, options.endAngle, (datum) => - numberProperty(datum, 'endAngle'), - ) - const padAngles = channelValues( - data, - options.padAngle, - (datum) => numberProperty(datum, 'padAngle') ?? 0, - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: [], - radiusValues: [], - includeZeroRadius: false, - requiresAngleScale: false, - requiresRadiusScale: false, - render: ({ layout, color: resolveColor }) => { - const innerRadius = resolveLength(options.innerRadius, layout, 0) - const outerRadius = resolveLength( - options.outerRadius, - layout, - layout.radius, - ) - const generator = - options.generator?.(layout) ?? - createArc() - .startAngle((_datum, index: number) => startAngles[index] ?? 0) - .endAngle((_datum, index: number) => endAngles[index] ?? 0) - .padAngle((_datum, index: number) => padAngles[index] ?? 0) - .innerRadius(innerRadius) - .outerRadius(outerRadius) - .cornerRadius(resolveLength(options.cornerRadius, layout, 0)) - if (options.padRadius !== undefined && !options.generator) { - generator.padRadius(resolveLength(options.padRadius, layout, 0)) - } + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:arc-${markIndex}` + const startAngles = channelValues(data, options.startAngle, (datum) => + numberProperty(datum, 'startAngle'), + ) + const endAngles = channelValues(data, options.endAngle, (datum) => + numberProperty(datum, 'endAngle'), + ) + const padAngles = channelValues( + data, + options.padAngle, + (datum) => numberProperty(datum, 'padAngle') ?? 0, + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups }) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const startAngle = startAngles[datumIndex] - const endAngle = endAngles[datumIndex] - const padAngle = padAngles[datumIndex] - if ( - !options.generator && - (!isFiniteNumber(startAngle) || - !isFiniteNumber(endAngle) || - !isFiniteNumber(padAngle)) - ) { - return - } - const path = generator(datum, datumIndex, data) - if (typeof path !== 'string' || !path) return - const group = groups[datumIndex] ?? null - const fallback = resolveColor(colorValues[datumIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - fallback, - ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue(options.stroke, datum, datumIndex, data, fallback) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const generatedStart = generator.startAngle()(datum, datumIndex, data) - const generatedEnd = generator.endAngle()(datum, datumIndex, data) - const generatedInner = generator.innerRadius()( - datum, - datumIndex, - data, - ) - const generatedOuter = generator.outerRadius()( - datum, - datumIndex, - data, + return { + id, + colorValues: colorValues.filter(isChartKey), + angleValues: [], + radiusValues: [], + includeZeroRadius: false, + requiresAngleScale: false, + requiresRadiusScale: false, + render: ({ layout, color: resolveColor }) => { + const innerRadius = resolveLength(options.innerRadius, layout, 0) + const outerRadius = resolveLength( + options.outerRadius, + layout, + layout.radius, ) - const centroid = generator.centroid(datum, datumIndex, data) - const angleValue = (generatedStart + generatedEnd) / 2 - const radiusValue = (generatedInner + generatedOuter) / 2 - const point = withPolarFocusGeometry( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), + const generator = + options.generator?.(layout) ?? + createArc() + .startAngle((_datum, index: number) => startAngles[index] ?? 0) + .endAngle((_datum, index: number) => endAngles[index] ?? 0) + .padAngle((_datum, index: number) => padAngles[index] ?? 0) + .innerRadius(innerRadius) + .outerRadius(outerRadius) + .cornerRadius(resolveLength(options.cornerRadius, layout, 0)) + if (options.padRadius !== undefined && !options.generator) { + generator.padRadius(resolveLength(options.padRadius, layout, 0)) + } + + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const startAngle = startAngles[datumIndex] + const endAngle = endAngles[datumIndex] + const padAngle = padAngles[datumIndex] + if ( + !options.generator && + (!isFiniteNumber(startAngle) || + !isFiniteNumber(endAngle) || + !isFiniteNumber(padAngle)) + ) { + return + } + const path = generator(datum, datumIndex, data) + if (typeof path !== 'string' || !path) return + const group = groups[datumIndex] ?? null + const fallback = resolveColor(colorValues[datumIndex] ?? null) + const fill = visualValue( + options.fill, datum, datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + centroid[0], - y: layout.centerY + centroid[1], - color: fill, - }, - layout, - angleValue, - radiusValue, - centroid[0], - centroid[1], - ) - nodes.push({ - kind: 'area', - key, - points: tracePolarArcBoundary(generator, datum, datumIndex, data), - path, - interaction: { point, affinity: 'geometry' }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineJoin: 'round', - }, + data, + fallback, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue(options.stroke, datum, datumIndex, data, fallback) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const generatedStart = generator.startAngle()( + datum, + datumIndex, + data, + ) + const generatedEnd = generator.endAngle()(datum, datumIndex, data) + const generatedInner = generator.innerRadius()( + datum, + datumIndex, + data, + ) + const generatedOuter = generator.outerRadius()( + datum, + datumIndex, + data, + ) + const centroid = generator.centroid(datum, datumIndex, data) + const angleValue = (generatedStart + generatedEnd) / 2 + const radiusValue = (generatedInner + generatedOuter) / 2 + const point = withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + centroid[0], + y: layout.centerY + centroid[1], + color: fill, + }, + layout, + angleValue, + radiusValue, + centroid[0], + centroid[1], + ) + nodes.push({ + kind: 'area', + key, + points: tracePolarArcBoundary(generator, datum, datumIndex, data), + path, + interaction: { point, affinity: 'geometry' }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineJoin: 'round', + }, + }) + points.push(point) }) - points.push(point) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes('ts-chart__arc', options.className), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes('ts-chart__arc', options.className), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } interface RadialBarBaseOptions extends ChartMarkMotionOptions { id?: string className?: string + /** Named angle scale. Omit to use the reserved `angle` scale. */ + angleScale?: string + /** Named radius scale. Omit to use the reserved `radius` scale. */ + radiusScale?: string key?: Channel z?: Channel color?: Channel @@ -446,180 +565,209 @@ export function radialBarRadius< ): PolarMark< TDatum, OptionChannelOutput, - number + number, + OptionScaleId, + OptionScaleId > export function radialBarRadius( source: Iterable, options: RadialBarRadiusOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-bar-radius-${markIndex}` - const angleValues = channelValues( - data, - options.angle, - (_datum, { index }) => index, - ) - const rawRadiusValues = numericPolarChannelValues( - data, - options.radius ?? options.radius2, - (datum) => (typeof datum === 'number' ? datum : undefined), - ) - const radius1Values = numericPolarChannelValues( - data, - options.radius1, - () => 0, - ) - const radius2Values = numericPolarChannelValues( - data, - options.radius2 ?? options.radius, - (_datum, { index }) => rawRadiusValues[index], - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups, - candidates: [angleValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: [ - ...radius1Values.filter(isFiniteNumber), - ...radius2Values.filter(isFiniteNumber), - ], - includeZeroRadius: options.radius1 === undefined, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor }) => { - const angle = requiredBandScale(layout.angle, 'angle', id) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const angleValue = angleValues[datumIndex] - const radiusValue = rawRadiusValues[datumIndex] - const radius1Value = radius1Values[datumIndex] - const radius2Value = radius2Values[datumIndex] - if ( - !isChartValue(angleValue) || - !isFiniteNumber(radiusValue) || - !isFiniteNumber(radius1Value) || - !isFiniteNumber(radius2Value) - ) { - return - } - const angleBand = resolvePolarBand(angle, angleValue) - const mappedRadius1 = - options.radius1 === undefined - ? 0 - : mapPolarScale(radius, radius1Value) - const mappedRadius2 = mapPolarScale(radius, radius2Value) - if ( - !angleBand || - !isNonnegativeFiniteNumber(mappedRadius1) || - !isNonnegativeFiniteNumber(mappedRadius2) - ) { - return - } - const cornerRadius = resolveBarCornerRadius( - options.cornerRadius, - layout, - mappedRadius1, - mappedRadius2, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-bar-radius-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = channelValues( + data, + options.angle, + (_datum, { index }) => index, + ) + const rawRadiusValues = numericPolarChannelValues( + data, + options.radius ?? options.radius2, + (datum) => (typeof datum === 'number' ? datum : undefined), + ) + const radius1Values = numericPolarChannelValues( + data, + options.radius1, + () => 0, + ) + const radius2Values = numericPolarChannelValues( + data, + options.radius2 ?? options.radius, + (_datum, { index }) => rawRadiusValues[index], + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups, + candidates: [angleValues], + markId: id, + warningIdentity: options, + }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: [ + ...radius1Values.filter(isFiniteNumber), + ...radius2Values.filter(isFiniteNumber), + ], + includeZeroRadius: options.radius1 === undefined, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor }) => { + const angle = requiredBandScale( + requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, + ), + 'angle', + id, ) - const sector = resolvePolarSector({ - startAngle: angleBand.start, - endAngle: angleBand.end, - innerRadius: mappedRadius1, - outerRadius: mappedRadius2, - cornerRadius, - }) - if (!sector) return - - const group = groups[datumIndex] ?? null - const fallback = resolveColor(colorValues[datumIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - fallback, + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue(options.stroke, datum, datumIndex, data, fallback) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const [pointX, pointY] = pointRadial(angleBand.center, mappedRadius2) - const point = withPolarFocusGeometry>( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const angleValue = angleValues[datumIndex] + const radiusValue = rawRadiusValues[datumIndex] + const radius1Value = radius1Values[datumIndex] + const radius2Value = radius2Values[datumIndex] + if ( + !isChartValue(angleValue) || + !isFiniteNumber(radiusValue) || + !isFiniteNumber(radius1Value) || + !isFiniteNumber(radius2Value) + ) { + return + } + const angleBand = resolvePolarBand(angle, angleValue) + const mappedRadius1 = + options.radius1 === undefined + ? 0 + : mapPolarScale(radius, radius1Value) + const mappedRadius2 = mapPolarScale(radius, radius2Value) + if ( + !angleBand || + !isNonnegativeFiniteNumber(mappedRadius1) || + !isNonnegativeFiniteNumber(mappedRadius2) + ) { + return + } + const cornerRadius = resolveBarCornerRadius( + options.cornerRadius, + layout, + mappedRadius1, + mappedRadius2, + ) + const sector = resolvePolarSector({ + startAngle: angleBand.start, + endAngle: angleBand.end, + innerRadius: mappedRadius1, + outerRadius: mappedRadius2, + cornerRadius, + }) + if (!sector) return + + const group = groups[datumIndex] ?? null + const fallback = resolveColor(colorValues[datumIndex] ?? null) + const fill = visualValue( + options.fill, datum, datumIndex, - xValue: angleValue, - yValue: radiusValue, - y1Value: radius1Value, - y2Value: radius2Value, - yInterval: 'difference', - x: layout.centerX + pointX, - y: layout.centerY + pointY, - color: fill, - }, - layout, - angleBand.center, - mappedRadius2, - pointX, - pointY, - ) - nodes.push({ - kind: 'area', - key, - points: sector.points, - path: sector.path, - interaction: { point, affinity: 'geometry' }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineJoin: 'round', - }, + data, + fallback, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue(options.stroke, datum, datumIndex, data, fallback) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const [pointX, pointY] = pointRadial( + angleBand.center, + mappedRadius2, + ) + const point = withPolarFocusGeometry< + ChartPoint + >( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + y1Value: radius1Value, + y2Value: radius2Value, + yInterval: 'difference', + x: layout.centerX + pointX, + y: layout.centerY + pointY, + color: fill, + }, + layout, + angleBand.center, + mappedRadius2, + pointX, + pointY, + ) + nodes.push({ + kind: 'area', + key, + points: sector.points, + path: sector.path, + interaction: { point, affinity: 'geometry' }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineJoin: 'round', + }, + }) + points.push(point) }) - points.push(point) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-radius', - options.className, - ), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-radius', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } export interface RadialBarAngleOptions< @@ -643,182 +791,215 @@ export function radialBarAngle< ): PolarMark< TDatum, number, - OptionChannelOutput + OptionChannelOutput, + OptionScaleId, + OptionScaleId > export function radialBarAngle( source: Iterable, options: RadialBarAngleOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-bar-angle-${markIndex}` - const rawAngleValues = numericPolarChannelValues( - data, - options.angle ?? options.angle2, - (datum) => (typeof datum === 'number' ? datum : undefined), - ) - const angle1Values = numericPolarChannelValues( - data, - options.angle1, - () => 0, - ) - const angle2Values = numericPolarChannelValues( - data, - options.angle2 ?? options.angle, - (_datum, { index }) => rawAngleValues[index], - ) - const radiusValues = channelValues( - data, - options.radius, - (_datum, { index }) => index, - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups, - candidates: [radiusValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: [ - ...angle1Values.filter(isFiniteNumber), - ...angle2Values.filter(isFiniteNumber), - ], - radiusValues: radiusValues.filter(isChartValue), - includeZeroRadius: false, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor }) => { - const angle = requiredScale(layout.angle) - const radius = requiredBandScale(layout.radiusScale, 'radius', id) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const angleValue = rawAngleValues[datumIndex] - const angle1Value = angle1Values[datumIndex] - const angle2Value = angle2Values[datumIndex] - const radiusValue = radiusValues[datumIndex] - if ( - !isFiniteNumber(angleValue) || - !isFiniteNumber(angle1Value) || - !isFiniteNumber(angle2Value) || - !isChartValue(radiusValue) - ) { - return - } - const mappedAngle1 = mapPolarScale(angle, angle1Value) - const mappedAngle2 = mapPolarScale(angle, angle2Value) - const radiusBand = resolvePolarBand(radius, radiusValue) - if ( - !isFiniteNumber(mappedAngle1) || - !isFiniteNumber(mappedAngle2) || - !radiusBand - ) { - return - } - const cornerRadius = resolveBarCornerRadius( - options.cornerRadius, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-bar-angle-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const rawAngleValues = numericPolarChannelValues( + data, + options.angle ?? options.angle2, + (datum) => (typeof datum === 'number' ? datum : undefined), + ) + const angle1Values = numericPolarChannelValues( + data, + options.angle1, + () => 0, + ) + const angle2Values = numericPolarChannelValues( + data, + options.angle2 ?? options.angle, + (_datum, { index }) => rawAngleValues[index], + ) + const radiusValues = channelValues( + data, + options.radius, + (_datum, { index }) => index, + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups, + candidates: [radiusValues], + markId: id, + warningIdentity: options, + }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: [ + ...angle1Values.filter(isFiniteNumber), + ...angle2Values.filter(isFiniteNumber), + ], + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor }) => { + const angle = requiredPolarScale( layout, - radiusBand.start, - radiusBand.end, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const sector = resolvePolarSector({ - startAngle: mappedAngle1, - endAngle: mappedAngle2, - innerRadius: radiusBand.start, - outerRadius: radiusBand.end, - cornerRadius, - }) - if (!sector) return - - const group = groups[datumIndex] ?? null - const fallback = resolveColor(colorValues[datumIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - fallback, + const radius = requiredBandScale( + requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, + ), + 'radius', + id, ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue(options.stroke, datum, datumIndex, data, fallback) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const [pointX, pointY] = pointRadial(mappedAngle2, radiusBand.center) - const point = withPolarFocusGeometry>( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const angleValue = rawAngleValues[datumIndex] + const angle1Value = angle1Values[datumIndex] + const angle2Value = angle2Values[datumIndex] + const radiusValue = radiusValues[datumIndex] + if ( + !isFiniteNumber(angleValue) || + !isFiniteNumber(angle1Value) || + !isFiniteNumber(angle2Value) || + !isChartValue(radiusValue) + ) { + return + } + const mappedAngle1 = mapPolarScale(angle, angle1Value) + const mappedAngle2 = mapPolarScale(angle, angle2Value) + const radiusBand = resolvePolarBand(radius, radiusValue) + if ( + !isFiniteNumber(mappedAngle1) || + !isFiniteNumber(mappedAngle2) || + !radiusBand + ) { + return + } + const cornerRadius = resolveBarCornerRadius( + options.cornerRadius, + layout, + radiusBand.start, + radiusBand.end, + ) + const sector = resolvePolarSector({ + startAngle: mappedAngle1, + endAngle: mappedAngle2, + innerRadius: radiusBand.start, + outerRadius: radiusBand.end, + cornerRadius, + }) + if (!sector) return + + const group = groups[datumIndex] ?? null + const fallback = resolveColor(colorValues[datumIndex] ?? null) + const fill = visualValue( + options.fill, datum, datumIndex, - xValue: angleValue, - yValue: radiusValue, - x1Value: angle1Value, - x2Value: angle2Value, - xInterval: 'difference', - x: layout.centerX + pointX, - y: layout.centerY + pointY, - color: fill, - }, - layout, - mappedAngle2, - radiusBand.center, - pointX, - pointY, - ) - nodes.push({ - kind: 'area', - key, - points: sector.points, - path: sector.path, - interaction: { point, affinity: 'geometry' }, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineJoin: 'round', - }, + data, + fallback, + ) + const stroke = + options.stroke === undefined + ? undefined + : visualValue(options.stroke, datum, datumIndex, data, fallback) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const [pointX, pointY] = pointRadial( + mappedAngle2, + radiusBand.center, + ) + const point = withPolarFocusGeometry< + ChartPoint + >( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x1Value: angle1Value, + x2Value: angle2Value, + xInterval: 'difference', + x: layout.centerX + pointX, + y: layout.centerY + pointY, + color: fill, + }, + layout, + mappedAngle2, + radiusBand.center, + pointX, + pointY, + ) + nodes.push({ + kind: 'area', + key, + points: sector.points, + path: sector.path, + interaction: { point, affinity: 'geometry' }, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineJoin: 'round', + }, + }) + points.push(point) }) - points.push(point) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-angle', - options.className, - ), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-angle', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } interface RadialPathOptions extends ChartMarkMotionOptions { id?: string className?: string + /** Named angle scale. Omit to use the reserved `angle` scale. */ + angleScale?: string + /** Named radius scale. Omit to use the reserved `radius` scale. */ + radiusScale?: string angle?: number | Channel radius?: number | Channel key?: Channel @@ -848,161 +1029,182 @@ export function radialLine< ): PolarMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionScaleId, + OptionScaleId > export function radialLine( source: Iterable, options: RadialLineOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-line-${markIndex}` - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, { index }) => index) - const radiusValues = - typeof options.radius === 'number' - ? data.map(() => options.radius as number) - : channelValues(data, options.radius, (datum) => - typeof datum === 'number' ? datum : undefined, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-line-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (_datum, { index }) => index) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const groups = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const keys = inferredKeyValues(data, options.key, { + groups, + candidates: [angleValues], + markId: id, + warningIdentity: options, + }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const groups = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const keys = inferredKeyValues(data, options.key, { - groups, - candidates: [angleValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: radiusValues.filter(isChartValue), - includeZeroRadius: false, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor }) => { - const angle = requiredScale(layout.angle) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - for (const [groupKey, indices] of groupIndices(groups)) { - const firstIndex = indices[0] - if (firstIndex === undefined) continue - const group = groups[firstIndex] ?? null - const stroke = visualValue( - options.stroke, - data[firstIndex], - firstIndex, - data, - resolveColor(colorValues[firstIndex] ?? null), + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, ) - const rows = indices.map((datumIndex) => ({ - datumIndex, - angleValue: angleValues[datumIndex], - radiusValue: radiusValues[datumIndex], - angle: mapPolarScale(angle, angleValues[datumIndex]), - radius: mapPolarScale(radius, radiusValues[datumIndex]), - })) - const generator = createLineRadial<(typeof rows)[number]>() - .defined( - (row) => isFiniteNumber(row.angle) && isFiniteNumber(row.radius), - ) - .angle((row) => row.angle) - .radius((row) => row.radius) - if (options.curve) generator.curve(options.curve) - const path = generator(rows) - if (typeof path === 'string' && path) { - nodes.push({ - kind: 'polyline', - key: `${id}:${groupKey}`, - points: [], - path, - style: { - fill: 'none', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 2.25, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineCap: 'round', - lineJoin: 'round', - }, - }) - } - for (const row of rows) { - if ( - !isChartValue(row.angleValue) || - !isChartValue(row.radiusValue) || - !isFiniteNumber(row.angle) || - !isFiniteNumber(row.radius) - ) { - continue - } - const [x, y] = pointRadial(row.angle, row.radius) - const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` - const point = withPolarFocusGeometry>( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[row.datumIndex], - datumIndex: row.datumIndex, - xValue: row.angleValue, - yValue: row.radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: stroke, - }, - layout, - row.angle, - row.radius, - x, - y, + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + for (const [groupKey, indices] of groupIndices(groups)) { + const firstIndex = indices[0] + if (firstIndex === undefined) continue + const group = groups[firstIndex] ?? null + const stroke = visualValue( + options.stroke, + data[firstIndex], + firstIndex, + data, + resolveColor(colorValues[firstIndex] ?? null), ) - points.push(point) - if (options.points) { + const rows = indices.map((datumIndex) => ({ + datumIndex, + angleValue: angleValues[datumIndex], + radiusValue: radiusValues[datumIndex], + angle: mapPolarScale(angle, angleValues[datumIndex]), + radius: mapPolarScale(radius, radiusValues[datumIndex]), + })) + const generator = createLineRadial<(typeof rows)[number]>() + .defined( + (row) => + isFiniteNumber(row.angle) && isFiniteNumber(row.radius), + ) + .angle((row) => row.angle) + .radius((row) => row.radius) + if (options.curve) generator.curve(options.curve) + const path = generator(rows) + if (typeof path === 'string' && path) { nodes.push({ - kind: 'dot', - key: `${key}:dot`, + kind: 'polyline', + key: `${id}:${groupKey}`, + points: [], + path, + style: { + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 2.25, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineCap: 'round', + lineJoin: 'round', + }, + }) + } + for (const row of rows) { + if ( + !isChartValue(row.angleValue) || + !isChartValue(row.radiusValue) || + !isFiniteNumber(row.angle) || + !isFiniteNumber(row.radius) + ) { + continue + } + const [x, y] = pointRadial(row.angle, row.radius) + const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` + const point = withPolarFocusGeometry>( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[row.datumIndex], + datumIndex: row.datumIndex, + xValue: row.angleValue, + yValue: row.radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: stroke, + }, + layout, + row.angle, + row.radius, x, y, - radius: 2.5, - pointOwner: point, - style: { fill: stroke }, - }) + ) + points.push(point) + if (options.points) { + nodes.push({ + kind: 'dot', + key: `${key}:dot`, + x, + y, + radius: 2.5, + pointOwner: point, + style: { fill: stroke }, + }) + } } } - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__radial-line ts-chart__line', - options.className, - ), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__radial-line ts-chart__line', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } export interface RadialAreaOptions extends RadialPathOptions { @@ -1029,166 +1231,186 @@ export function radialArea< ): PolarMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionScaleId, + OptionScaleId > export function radialArea( source: Iterable, options: RadialAreaOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-area-${markIndex}` - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, { index }) => index) - const radiusValues = - typeof options.radius === 'number' - ? data.map(() => options.radius as number) - : channelValues(data, options.radius, (datum) => - typeof datum === 'number' ? datum : undefined, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-area-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (_datum, { index }) => index) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const radius1Values = + typeof options.radius1 === 'number' + ? data.map(() => options.radius1 as number) + : channelValues(data, options.radius1, () => 0) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const groups = + options.z === undefined && options.color !== undefined + ? colorValues + : zValues + const keys = inferredKeyValues(data, options.key, { + groups, + candidates: [angleValues], + markId: id, + warningIdentity: options, + }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: [ + ...radiusValues.filter(isChartValue), + ...radius1Values.filter(isChartValue), + ], + includeZeroRadius: options.radius1 === undefined, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const radius1Values = - typeof options.radius1 === 'number' - ? data.map(() => options.radius1 as number) - : channelValues(data, options.radius1, () => 0) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const groups = - options.z === undefined && options.color !== undefined - ? colorValues - : zValues - const keys = inferredKeyValues(data, options.key, { - groups, - candidates: [angleValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: [ - ...radiusValues.filter(isChartValue), - ...radius1Values.filter(isChartValue), - ], - includeZeroRadius: options.radius1 === undefined, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor }) => { - const angle = requiredScale(layout.angle) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - for (const [groupKey, indices] of groupIndices(groups)) { - const firstIndex = indices[0] - if (firstIndex === undefined) continue - const datum = data[firstIndex] - const group = groups[firstIndex] ?? null - const fallback = resolveColor(colorValues[firstIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - firstIndex, - data, - fallback, + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, ) - const stroke = - options.stroke === undefined - ? undefined - : visualValue(options.stroke, datum, firstIndex, data, fallback) - const rows = indices.map((datumIndex) => ({ - datumIndex, - angleValue: angleValues[datumIndex], - radiusValue: radiusValues[datumIndex], - angle: mapPolarScale(angle, angleValues[datumIndex]), - radius: mapPolarScale(radius, radiusValues[datumIndex]), - radius1: mapPolarScale(radius, radius1Values[datumIndex]), - })) - const generator = createAreaRadial<(typeof rows)[number]>() - .defined( - (row) => - isFiniteNumber(row.angle) && - isFiniteNumber(row.radius) && - isFiniteNumber(row.radius1), + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + for (const [groupKey, indices] of groupIndices(groups)) { + const firstIndex = indices[0] + if (firstIndex === undefined) continue + const datum = data[firstIndex] + const group = groups[firstIndex] ?? null + const fallback = resolveColor(colorValues[firstIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + firstIndex, + data, + fallback, ) - .angle((row) => row.angle) - .innerRadius((row) => row.radius1) - .outerRadius((row) => row.radius) - if (options.curve) generator.curve(options.curve) - const path = generator(rows) - if (typeof path === 'string' && path) { - nodes.push({ - kind: 'area', - key: `${id}:${groupKey}`, - points: [], - path, - style: { - fill, - fillOpacity: options.fillOpacity ?? 0.2, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineJoin: 'round', - }, - }) - } - for (const row of rows) { - if ( - !isChartValue(row.angleValue) || - !isChartValue(row.radiusValue) || - !isFiniteNumber(row.angle) || - !isFiniteNumber(row.radius) - ) { - continue - } - const [x, y] = pointRadial(row.angle, row.radius) - const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` - points.push( - withPolarFocusGeometry( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum: data[row.datumIndex], - datumIndex: row.datumIndex, - xValue: row.angleValue, - yValue: row.radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, + const stroke = + options.stroke === undefined + ? undefined + : visualValue(options.stroke, datum, firstIndex, data, fallback) + const rows = indices.map((datumIndex) => ({ + datumIndex, + angleValue: angleValues[datumIndex], + radiusValue: radiusValues[datumIndex], + angle: mapPolarScale(angle, angleValues[datumIndex]), + radius: mapPolarScale(radius, radiusValues[datumIndex]), + radius1: mapPolarScale(radius, radius1Values[datumIndex]), + })) + const generator = createAreaRadial<(typeof rows)[number]>() + .defined( + (row) => + isFiniteNumber(row.angle) && + isFiniteNumber(row.radius) && + isFiniteNumber(row.radius1), + ) + .angle((row) => row.angle) + .innerRadius((row) => row.radius1) + .outerRadius((row) => row.radius) + if (options.curve) generator.curve(options.curve) + const path = generator(rows) + if (typeof path === 'string' && path) { + nodes.push({ + kind: 'area', + key: `${id}:${groupKey}`, + points: [], + path, + style: { + fill, + fillOpacity: options.fillOpacity ?? 0.2, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineJoin: 'round', }, - layout, - row.angle, - row.radius, - x, - y, - ), - ) + }) + } + for (const row of rows) { + if ( + !isChartValue(row.angleValue) || + !isChartValue(row.radiusValue) || + !isFiniteNumber(row.angle) || + !isFiniteNumber(row.radius) + ) { + continue + } + const [x, y] = pointRadial(row.angle, row.radius) + const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}` + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum: data[row.datumIndex], + datumIndex: row.datumIndex, + xValue: row.angleValue, + yValue: row.radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + row.angle, + row.radius, + x, + y, + ), + ) + } } - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes('ts-chart__radial-area', options.className), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes('ts-chart__radial-area', options.className), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } export interface RadialDotOptions extends RadialPathOptions { @@ -1228,163 +1450,185 @@ export function radialText< ): PolarMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionScaleId, + OptionScaleId > export function radialText( source: Iterable, options: RadialTextOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-text-${markIndex}` - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, { index }) => index) - const radiusValues = - typeof options.radius === 'number' - ? data.map(() => options.radius as number) - : channelValues(data, options.radius, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const textValues = channelValues(data, options.text, (datum) => - datum == null ? '' : String(datum), - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: radiusValues.filter(isChartValue), - includeZeroRadius: false, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor, theme }) => { - const angle = requiredScale(layout.angle) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const angleValue = angleValues[datumIndex] - const radiusValue = radiusValues[datumIndex] - const textValue = textValues[datumIndex] - const anglePosition = mapPolarScale(angle, angleValue) - const radiusPosition = mapPolarScale(radius, radiusValue) - if ( - !isChartValue(angleValue) || - !isChartValue(radiusValue) || - textValue == null || - !isFiniteNumber(anglePosition) || - !isFiniteNumber(radiusPosition) - ) { - return - } - const radiusOffset = visualValue( - options.radiusOffset, - datum, - datumIndex, - data, - 0, - ) - const projectedRadius = radiusPosition + radiusOffset - if ( - !isFiniteNumber(radiusOffset) || - !isFiniteNumber(projectedRadius) - ) { - return - } - const [baseX, baseY] = pointRadial(anglePosition, projectedRadius) - const x = baseX + visualValue(options.dx, datum, datumIndex, data, 0) - const y = baseY + visualValue(options.dy, datum, datumIndex, data, 0) - const group = groups[datumIndex] ?? null - const colorValue = colorValues[datumIndex] ?? null - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - colorValue == null ? theme.foreground : resolveColor(colorValue), + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-text-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (_datum, { index }) => index) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const textValues = channelValues(data, options.text, (datum) => + datum == null ? '' : String(datum), + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor, theme }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const authoredAnchor = visualValue( - options.anchor, - datum, - datumIndex, - data, - 'middle', + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, ) - nodes.push({ - kind: 'label', - key, - x, - y, - text: String(textValue), - anchor: - authoredAnchor === 'outside' - ? outsideRadialAnchor(anglePosition) - : authoredAnchor, - baseline: visualValue( - options.baseline, + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const angleValue = angleValues[datumIndex] + const radiusValue = radiusValues[datumIndex] + const textValue = textValues[datumIndex] + const anglePosition = mapPolarScale(angle, angleValue) + const radiusPosition = mapPolarScale(radius, radiusValue) + if ( + !isChartValue(angleValue) || + !isChartValue(radiusValue) || + textValue == null || + !isFiniteNumber(anglePosition) || + !isFiniteNumber(radiusPosition) + ) { + return + } + const radiusOffset = visualValue( + options.radiusOffset, datum, datumIndex, data, - 'middle', - ), - rotate: - options.rotate === undefined - ? undefined - : visualValue(options.rotate, datum, datumIndex, data, 0), - fontSize: options.fontSize, - fontWeight: options.fontWeight, - style: { fill }, - }) - points.push( - withPolarFocusGeometry( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, - }, - layout, - anglePosition, - projectedRadius, + 0, + ) + const projectedRadius = radiusPosition + radiusOffset + if ( + !isFiniteNumber(radiusOffset) || + !isFiniteNumber(projectedRadius) + ) { + return + } + const [baseX, baseY] = pointRadial(anglePosition, projectedRadius) + const x = + baseX + visualValue(options.dx, datum, datumIndex, data, 0) + const y = + baseY + visualValue(options.dy, datum, datumIndex, data, 0) + const group = groups[datumIndex] ?? null + const colorValue = colorValues[datumIndex] ?? null + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + colorValue == null ? theme.foreground : resolveColor(colorValue), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const authoredAnchor = visualValue( + options.anchor, + datum, + datumIndex, + data, + 'middle', + ) + nodes.push({ + kind: 'label', + key, x, y, - ), - ) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__radial-text ts-chart__text', - options.className, + text: String(textValue), + anchor: + authoredAnchor === 'outside' + ? outsideRadialAnchor(anglePosition) + : authoredAnchor, + baseline: visualValue( + options.baseline, + datum, + datumIndex, + data, + 'middle', ), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + rotate: + options.rotate === undefined + ? undefined + : visualValue(options.rotate, datum, datumIndex, data, 0), + fontSize: options.fontSize, + fontWeight: options.fontWeight, + style: { fill }, + }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + anglePosition, + projectedRadius, + x, + y, + ), + ) + }) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__radial-text ts-chart__text', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } export interface RadialRuleOptions< @@ -1392,6 +1636,10 @@ export interface RadialRuleOptions< > extends ChartMarkMotionOptions { id?: string className?: string + /** Named angle scale. Omit to use the reserved `angle` scale. */ + angleScale?: string + /** Named radius scale. Omit to use the reserved `radius` scale. */ + radiusScale?: string angle?: number | Channel radius1?: number | Channel radius2?: number | Channel @@ -1421,144 +1669,164 @@ export function radialRule< ): PolarMark< never, OptionChannelOutput, - number + number, + OptionScaleId, + OptionScaleId > export function radialRule( source: Iterable, options: RadialRuleOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-rule-${markIndex}` - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, { index }) => index) - const radius1Values = - typeof options.radius1 === 'number' - ? data.map(() => options.radius1 as number) - : channelValues(data, options.radius1, () => 0) - const radius2Values = - typeof options.radius2 === 'number' - ? data.map(() => options.radius2 as number) - : channelValues(data, options.radius2, (datum) => - typeof datum === 'number' - ? datum - : numberProperty(datum, 'radius2'), - ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups, - candidates: [angleValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: [ - ...radius1Values.filter(isChartValue), - ...radius2Values.filter(isChartValue), - ], - includeZeroRadius: options.radius1 === undefined, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor, theme }) => { - const angle = requiredScale(layout.angle) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - data.forEach((datum, datumIndex) => { - const anglePosition = mapPolarScale(angle, angleValues[datumIndex]) - const radius1Position = mapPolarScale( - radius, - radius1Values[datumIndex], - ) - const radius2Position = mapPolarScale( - radius, - radius2Values[datumIndex], - ) - if ( - !isFiniteNumber(anglePosition) || - !isFiniteNumber(radius1Position) || - !isFiniteNumber(radius2Position) - ) { - return - } - const radius1Offset = visualValue( - options.radius1Offset, - datum, - datumIndex, - data, - 0, - ) - const radius2Offset = visualValue( - options.radius2Offset, - datum, - datumIndex, - data, - 0, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-rule-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (_datum, { index }) => index) + const radius1Values = + typeof options.radius1 === 'number' + ? data.map(() => options.radius1 as number) + : channelValues(data, options.radius1, () => 0) + const radius2Values = + typeof options.radius2 === 'number' + ? data.map(() => options.radius2 as number) + : channelValues(data, options.radius2, (datum) => + typeof datum === 'number' + ? datum + : numberProperty(datum, 'radius2'), + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups, + candidates: [angleValues], + markId: id, + warningIdentity: options, + }) + + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: [ + ...radius1Values.filter(isChartValue), + ...radius2Values.filter(isChartValue), + ], + includeZeroRadius: options.radius1 === undefined, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor, theme }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const projectedRadius1 = radius1Position + radius1Offset - const projectedRadius2 = radius2Position + radius2Offset - if ( - !isFiniteNumber(radius1Offset) || - !isFiniteNumber(radius2Offset) || - !isFiniteNumber(projectedRadius1) || - !isFiniteNumber(projectedRadius2) - ) { - return - } - const [x1, y1] = pointRadial(anglePosition, projectedRadius1) - const [x2, y2] = pointRadial(anglePosition, projectedRadius2) - const group = groups[datumIndex] ?? null - const colorValue = colorValues[datumIndex] ?? null - const stroke = visualValue( - options.stroke, - datum, - datumIndex, - data, - colorValue == null ? theme.foreground : resolveColor(colorValue), + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, ) - nodes.push({ - kind: 'rule', - key: `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`, - x1, - y1, - x2, - y2, - style: { - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - lineCap: 'round', - }, + const nodes: SceneNode[] = [] + data.forEach((datum, datumIndex) => { + const anglePosition = mapPolarScale(angle, angleValues[datumIndex]) + const radius1Position = mapPolarScale( + radius, + radius1Values[datumIndex], + ) + const radius2Position = mapPolarScale( + radius, + radius2Values[datumIndex], + ) + if ( + !isFiniteNumber(anglePosition) || + !isFiniteNumber(radius1Position) || + !isFiniteNumber(radius2Position) + ) { + return + } + const radius1Offset = visualValue( + options.radius1Offset, + datum, + datumIndex, + data, + 0, + ) + const radius2Offset = visualValue( + options.radius2Offset, + datum, + datumIndex, + data, + 0, + ) + const projectedRadius1 = radius1Position + radius1Offset + const projectedRadius2 = radius2Position + radius2Offset + if ( + !isFiniteNumber(radius1Offset) || + !isFiniteNumber(radius2Offset) || + !isFiniteNumber(projectedRadius1) || + !isFiniteNumber(projectedRadius2) + ) { + return + } + const [x1, y1] = pointRadial(anglePosition, projectedRadius1) + const [x2, y2] = pointRadial(anglePosition, projectedRadius2) + const group = groups[datumIndex] ?? null + const colorValue = colorValues[datumIndex] ?? null + const stroke = visualValue( + options.stroke, + datum, + datumIndex, + data, + colorValue == null ? theme.foreground : resolveColor(colorValue), + ) + nodes.push({ + kind: 'rule', + key: `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`, + x1, + y1, + x2, + y2, + style: { + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + lineCap: 'round', + }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__radial-rule ts-chart__rule', - options.className, - ), - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__radial-rule ts-chart__rule', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } export function radialDot( @@ -1573,136 +1841,156 @@ export function radialDot< ): PolarMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionScaleId, + OptionScaleId > export function radialDot( source: Iterable, options: RadialDotOptions> = {}, ): PolarMark { const data = asArray(source) - return createPolarMark(({ markIndex, parentId }) => { - const id = options.id ?? `${parentId}:radial-dot-${markIndex}` - const angleValues = - typeof options.angle === 'number' - ? data.map(() => options.angle as number) - : channelValues(data, options.angle, (_datum, { index }) => index) - const radiusValues = - typeof options.radius === 'number' - ? data.map(() => options.radius as number) - : channelValues(data, options.radius, (datum) => - typeof datum === 'number' ? datum : undefined, + return createPolarMark( + ({ markIndex, parentId }) => { + const id = options.id ?? `${parentId}:radial-dot-${markIndex}` + const angleScale = options.angleScale ?? 'angle' + const radiusScale = options.radiusScale ?? 'radius' + const angleValues = + typeof options.angle === 'number' + ? data.map(() => options.angle as number) + : channelValues(data, options.angle, (_datum, { index }) => index) + const radiusValues = + typeof options.radius === 'number' + ? data.map(() => options.radius as number) + : channelValues(data, options.radius, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const groups = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? groups + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups }) + const rawRadii = + typeof options.r === 'number' + ? data.map(() => options.r as number) + : channelValues(data, options.r, () => 3.5) + const radiusMapper = resolveNumericScale(options.rScale, rawRadii) + const radii = radiusMapper + ? rawRadii.map((value) => + isNonnegativeFiniteNumber(value) ? radiusMapper(value) : Number.NaN, ) - const groups = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? groups - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups }) - const rawRadii = - typeof options.r === 'number' - ? data.map(() => options.r as number) - : channelValues(data, options.r, () => 3.5) - const radiusMapper = resolveNumericScale(options.rScale, rawRadii) - const radii = radiusMapper - ? rawRadii.map((value) => - isNonnegativeFiniteNumber(value) ? radiusMapper(value) : Number.NaN, - ) - : rawRadii + : rawRadii - return { - id, - colorValues: colorValues.filter(isChartKey), - angleValues: angleValues.filter(isChartValue), - radiusValues: radiusValues.filter(isChartValue), - includeZeroRadius: false, - requiresAngleScale: true, - requiresRadiusScale: true, - render: ({ layout, color: resolveColor }) => { - const angle = requiredScale(layout.angle) - const radius = requiredScale(layout.radiusScale) - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const angleValue = angleValues[datumIndex] - const radiusValue = radiusValues[datumIndex] - const anglePosition = mapPolarScale(angle, angleValue) - const radiusPosition = mapPolarScale(radius, radiusValue) - const dotRadius = radii[datumIndex] - if ( - !isChartValue(angleValue) || - !isChartValue(radiusValue) || - !isFiniteNumber(anglePosition) || - !isFiniteNumber(radiusPosition) || - !isNonnegativeFiniteNumber(dotRadius) - ) { - return - } - const [x, y] = pointRadial(anglePosition, radiusPosition) - const group = groups[datumIndex] ?? null - const fill = visualValue( - options.fill, - datum, - datumIndex, - data, - resolveColor(colorValues[datumIndex] ?? null), + return { + id, + angleScale, + radiusScale, + colorValues: colorValues.filter(isChartKey), + angleValues: angleValues.filter(isChartValue), + radiusValues: radiusValues.filter(isChartValue), + includeZeroRadius: false, + requiresAngleScale: true, + requiresRadiusScale: true, + render: ({ layout, color: resolveColor }) => { + const angle = requiredPolarScale( + layout, + angleScale, + 'angle', + `Polar mark "${id}"`, ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - nodes.push({ - kind: 'dot', - key, - x, - y, - radius: dotRadius, - style: { - fill, - fillOpacity: options.fillOpacity, - stroke: options.stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - opacity: options.opacity, - }, - }) - points.push( - withPolarFocusGeometry( - { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: angleValue, - yValue: radiusValue, - x: layout.centerX + x, - y: layout.centerY + y, - color: fill, - }, - layout, - anglePosition, - radiusPosition, + const radius = requiredPolarScale( + layout, + radiusScale, + 'radius', + `Polar mark "${id}"`, + ) + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const angleValue = angleValues[datumIndex] + const radiusValue = radiusValues[datumIndex] + const anglePosition = mapPolarScale(angle, angleValue) + const radiusPosition = mapPolarScale(radius, radiusValue) + const dotRadius = radii[datumIndex] + if ( + !isChartValue(angleValue) || + !isChartValue(radiusValue) || + !isFiniteNumber(anglePosition) || + !isFiniteNumber(radiusPosition) || + !isNonnegativeFiniteNumber(dotRadius) + ) { + return + } + const [x, y] = pointRadial(anglePosition, radiusPosition) + const group = groups[datumIndex] ?? null + const fill = visualValue( + options.fill, + datum, + datumIndex, + data, + resolveColor(colorValues[datumIndex] ?? null), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + nodes.push({ + kind: 'dot', + key, x, y, - ), - ) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: classes( - 'ts-chart__radial-dot ts-chart__dot', - options.className, + radius: dotRadius, + style: { + fill, + fillOpacity: options.fillOpacity, + stroke: options.stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + opacity: options.opacity, + }, + }) + points.push( + withPolarFocusGeometry( + { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: angleValue, + yValue: radiusValue, + x: layout.centerX + x, + y: layout.centerY + y, + color: fill, + }, + layout, + anglePosition, + radiusPosition, + x, + y, ), - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + ) + }) + return { + nodes: [ + { + kind: 'group', + key: id, + className: classes( + 'ts-chart__radial-dot ts-chart__dot', + options.className, + ), + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } export interface PolarGuideLabelContext { @@ -1739,6 +2027,10 @@ interface PolarGuideStyle { } export interface RadialGridOptions extends PolarGuideStyle { + /** Named radius scale. Omit to use the reserved `radius` scale. */ + scale?: string + /** Angle scale used for polygon rings. Omit to use reserved `angle`. */ + angleScale?: string values?: readonly ChartValue[] ticks?: number shape?: 'circle' | 'polygon' @@ -1750,7 +2042,12 @@ export interface RadialGridOptions extends PolarGuideStyle { export function radialGrid(options: RadialGridOptions = {}): PolarGuide { return { render: ({ layout, theme, guideIndex, parentId }) => { - const radial = requiredScale(layout.radiusScale) + const radial = requiredPolarScale( + layout, + options.scale ?? 'radius', + 'radius', + 'Radial grid', + ) const values = options.values ?? radial.ticks(options.ticks ?? 5) const stroke = options.stroke ?? theme.grid const rings: SceneNode[] = [] @@ -1760,12 +2057,13 @@ export function radialGrid(options: RadialGridOptions = {}): PolarGuide { if (!isFiniteNumber(radius)) continue let path: string | null | void if (options.shape === 'polygon') { - if (!layout.angle) { - throw new TypeError( - 'Polygon radial grid requires a configured angle scale', - ) - } - path = polygonRingPath(layout.angle, radius) + const angle = requiredPolarScale( + layout, + options.angleScale ?? 'angle', + 'angle', + 'Polygon radial grid', + ) + path = polygonRingPath(angle, radius) } else { path = createArc() .innerRadius(0) @@ -1851,6 +2149,8 @@ export function radialGrid(options: RadialGridOptions = {}): PolarGuide { } export interface AngleGridOptions extends PolarGuideStyle { + /** Named angle scale. Omit to use the reserved `angle` scale. */ + scale?: string values?: readonly ChartValue[] format?: (value: ChartValue) => string labelOffset?: number @@ -1859,7 +2159,12 @@ export interface AngleGridOptions extends PolarGuideStyle { export function angleGrid(options: AngleGridOptions = {}): PolarGuide { return { render: ({ layout, theme, guideIndex, parentId }) => { - const angle = requiredScale(layout.angle) + const angle = requiredPolarScale( + layout, + options.scale ?? 'angle', + 'angle', + 'Angle grid', + ) const values = options.values ?? angle.domain const spokes: SceneNode[] = [] const labels: SceneNode[] = [] @@ -1944,6 +2249,8 @@ export function angleGrid(options: AngleGridOptions = {}): PolarGuide { } } +let warnedLegacyPolarScales = false + function resolvePolarLayout( options: PolarOptions, chart: ChartBounds, @@ -1955,6 +2262,8 @@ function resolvePolarLayout( const radiusRatio = Math.max(0, finite(options.radiusRatio, 1)) const radius = Math.max(0, Math.min(chart.width, chart.height) / 2 - inset) * radiusRatio + const sourceScales = resolvePolarScaleOptions(options) + const scales: Record = {} const layout: PolarLayoutContext = { chart, centerX: chart.x + chart.width / 2, @@ -1962,39 +2271,107 @@ function resolvePolarLayout( radius, startAngle, endAngle, + scales, } - if (options.angle) { - const wrapPointScale = - options.angle.wrap ?? isCompleteRevolution(startAngle, endAngle) - layout.angle = resolvePolarScale( - options.angle.scale, - collectPolarValues(marks, 'angleValues'), - startAngle, - endAngle, - wrapPointScale, - false, - options.angle.nice, - ) - } - if (options.radius) { - const [rangeStart, rangeEnd] = resolvePolarRadiusRange( - options.radius.range, - layout, - ) - layout.radiusScale = resolvePolarScale( - options.radius.scale, - collectPolarValues(marks, 'radiusValues'), + + for (const [id, scaleOptions] of Object.entries(sourceScales)) { + if (!scaleOptions) continue + const reservedChannel = id === 'angle' || id === 'radius' ? id : undefined + const channel = reservedChannel ?? scaleOptions.channel + if (!channel) { + throw new TypeError( + `Named polar scale "${id}" requires channel: "angle" or channel: "radius"`, + ) + } + if (scaleOptions.channel && scaleOptions.channel !== channel) { + throw new TypeError( + `Polar scale "${id}" is reserved for ${channel} but declares channel: "${scaleOptions.channel}"`, + ) + } + + const valuesKey = channel === 'angle' ? 'angleValues' : 'radiusValues' + const values = collectPolarValues(marks, valuesKey, id) + const includeZero = + channel === 'radius' && + marks.some((mark) => mark.radiusScale === id && mark.includeZeroRadius) + let rangeStart: number + let rangeEnd: number + let wrapPointScale = false + if (channel === 'angle') { + rangeStart = startAngle + rangeEnd = endAngle + wrapPointScale = + (scaleOptions as PolarAngleOptions).wrap ?? + isCompleteRevolution(startAngle, endAngle) + } else { + const radiusRange = resolvePolarRadiusRange( + (scaleOptions as PolarRadiusOptions).range, + layout, + ) + rangeStart = radiusRange[0] + rangeEnd = radiusRange[1] + } + + scales[id] = resolvePolarScale( + id, + channel, + scaleOptions.scale, + values, rangeStart, rangeEnd, - false, - marks.some((mark) => mark.includeZeroRadius), - options.radius.nice, + wrapPointScale, + includeZero, + scaleOptions.nice, ) } + + layout.angle = scales.angle + layout.radiusScale = scales.radius return layout } +function resolvePolarScaleOptions( + options: PolarOptions, +): Readonly> { + if ( + (!options.scales || + options.angle !== undefined || + options.radius !== undefined) && + !warnedLegacyPolarScales + ) { + try { + if (process.env.NODE_ENV !== 'production') { + warnedLegacyPolarScales = true + console.warn( + '[TanStack Charts] `polar()` scale options have moved to `scales`. Move `angle` and `radius` to `scales.angle` and `scales.radius`. When neither scale is used, set both entries to `null`. This compatibility will be removed when TanStack Charts enters Alpha.', + ) + } + } catch { + warnedLegacyPolarScales = true + // Keep raw-browser migration help removable by production minifiers. + /* @__PURE__ */ console.warn( + '[TanStack Charts] `polar()` scale options have moved to `scales`. Move `angle` and `radius` to `scales.angle` and `scales.radius`. When neither scale is used, set both entries to `null`. This compatibility will be removed when TanStack Charts enters Alpha.', + ) + } + } + + if (!options.scales) { + return { angle: options.angle, radius: options.radius } + } + if ( + !Object.hasOwn(options.scales, 'angle') || + !Object.hasOwn(options.scales, 'radius') + ) { + throw new TypeError( + 'Polar scales must define reserved `angle` and `radius` entries', + ) + } + return options.scales +} + function resolvePolarScale( + id: string, + channel: PolarPositionChannel, source: ChartScaleInput, values: readonly unknown[], rangeStart: number, @@ -2029,6 +2406,8 @@ function resolvePolarScale( : Number.NaN } return { + id, + channel, domain, map, ticks: (count) => (scale.ticks?.(count) ?? domain).filter(isChartValue), @@ -2039,9 +2418,15 @@ function resolvePolarScale( function collectPolarValues( marks: readonly InitializedPolarMark[], key: 'angleValues' | 'radiusValues', + scaleId: string, ): unknown[] { const values: unknown[] = [] for (const mark of marks) { + if ( + (key === 'angleValues' ? mark.angleScale : mark.radiusScale) !== scaleId + ) { + continue + } for (const value of mark[key]) values.push(value) } return values @@ -2060,6 +2445,34 @@ function polygonRingPath(angle: PolarResolvedScale, radius: number): string { ) } +function createPolarMark< + TDatum, + TAngle extends ChartValue, + TRadius extends ChartValue, + TAngleScaleId extends string = 'angle', + TRadiusScaleId extends string = 'radius', +>( + initialize: ( + context: PolarMarkInitializeContext, + ) => InitializedPolarMark, + motion?: ChartMotionDefinition, + renderer?: ChartMarkRenderer, +): PolarMark { + return createInternalPolarMark( + initialize as unknown as ( + context: PolarMarkInitializeContext, + ) => InternalInitializedPolarMark, + motion, + renderer, + ) as unknown as PolarMark< + TDatum, + TAngle, + TRadius, + TAngleScaleId, + TRadiusScaleId + > +} + function groupIndices( groups: readonly (ChartKey | null | undefined)[], ): Map { @@ -2073,25 +2486,37 @@ function groupIndices( return result } -function requiredScale( - scale: PolarResolvedScale | undefined, +function requiredPolarScale( + layout: PolarLayoutContext, + id: string, + channel: PolarPositionChannel, + owner = 'Polar mark', ): PolarResolvedScale { - if (!scale) throw new TypeError('Missing configured polar scale') + const scale = layout.scales[id] + if (!scale) { + throw new TypeError( + `${owner} requires a configured ${channel} scale "${id}" in polar.scales`, + ) + } + if (scale.channel !== channel) { + throw new TypeError( + `${owner} uses scale "${id}" as ${channel}, but it is configured for ${scale.channel}`, + ) + } return scale } function requiredBandScale( - scale: PolarResolvedScale | undefined, + scale: PolarResolvedScale, axis: 'angle' | 'radius', markId: string, ): PolarResolvedScale { - const resolved = requiredScale(scale) - if (!isFiniteNumber(resolved.bandwidth) || resolved.bandwidth <= 0) { + if (!isFiniteNumber(scale.bandwidth) || scale.bandwidth <= 0) { throw new TypeError( `Radial bar "${markId}" requires positive ${axis}-scale bandwidth`, ) } - return resolved + return scale } function mapPolarScale(scale: PolarResolvedScale, value: unknown): number { diff --git a/packages/charts-core/src/rect.ts b/packages/charts-core/src/rect.ts index f1b6c70b..5a4956ed 100644 --- a/packages/charts-core/src/rect.ts +++ b/packages/charts-core/src/rect.ts @@ -11,6 +11,8 @@ import { valueKey } from './scales' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -21,7 +23,8 @@ import type { SceneNode, } from './types' -export interface RectOptions extends ChartMarkMotionOptions { +export interface RectOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel x1?: Channel @@ -87,169 +90,188 @@ export function rect< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, RectPointXOutput, RectPointYOutput, RectXOutput, - RectYOutput + RectYOutput, + TOptions > export function rect( source: Iterable, options: RectOptions>, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `rect-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, { index }) => index) - const x1Values = channelValues(data, options.x1, (_datum, { index }) => - options.x === undefined ? index : xValues[index], - ) - const x2Values = channelValues( - data, - options.x2, - (_datum, { index }) => xValues[index], - ) - const yValues = channelValues(data, options.y, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const y1Values = channelValues( - data, - options.y1, - (_datum, { index }) => yValues[index], - ) - const y2Values = channelValues( - data, - options.y2, - (_datum, { index }) => yValues[index], - ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups: zValues, - candidates: [compositeKeyValues(x1Values, x2Values, y1Values, y2Values)], - markId: id, - warningIdentity: options, - }) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `rect-${markIndex}` + const xValues = channelValues( + data, + options.x, + (_datum, { index }) => index, + ) + const x1Values = channelValues(data, options.x1, (_datum, { index }) => + options.x === undefined ? index : xValues[index], + ) + const x2Values = channelValues( + data, + options.x2, + (_datum, { index }) => xValues[index], + ) + const yValues = channelValues(data, options.y, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const y1Values = channelValues( + data, + options.y1, + (_datum, { index }) => yValues[index], + ) + const y2Values = channelValues( + data, + options.y2, + (_datum, { index }) => yValues[index], + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups: zValues, + candidates: [ + compositeKeyValues(x1Values, x2Values, y1Values, y2Values), + ], + markId: id, + warningIdentity: options, + }) - return { - id, - states: markStates(data, options.states), - channels: { - x: { - scale: 'x', - values: [...x1Values, ...x2Values].filter(isChartValue), + return { + id, + states: markStates(data, options.states), + channels: { + x: { + scale: xScale, + values: [...x1Values, ...x2Values].filter(isChartValue), + }, + y: { + scale: yScale, + values: [...y1Values, ...y2Values].filter(isChartValue), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - y: { - scale: 'y', - values: [...y1Values, ...y2Values].filter(isChartValue), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - const inset = Math.max(0, options.inset ?? 0.75) + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const inset = Math.max(0, options.inset ?? 0.75) - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const x1Value = x1Values[datumIndex] - const x2Value = x2Values[datumIndex] - const yValue = yValues[datumIndex] - const y1Value = y1Values[datumIndex] - const y2Value = y2Values[datumIndex] - if ( - !isChartValue(x1Value) || - !isChartValue(x2Value) || - !isChartValue(y1Value) || - !isChartValue(y2Value) - ) - return + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const x1Value = x1Values[datumIndex] + const x2Value = x2Values[datumIndex] + const yValue = yValues[datumIndex] + const y1Value = y1Values[datumIndex] + const y2Value = y2Values[datumIndex] + if ( + !isChartValue(x1Value) || + !isChartValue(x2Value) || + !isChartValue(y1Value) || + !isChartValue(y2Value) + ) + return - const x1 = scales.x.map(x1Value) - const x2 = scales.x.map(x2Value) - const y1 = scales.y.map(y1Value) - const y2 = scales.y.map(y2Value) - const categoricalWidth = - valueKey(x1Value) === valueKey(x2Value) ? scales.x.bandwidth : 0 - const categoricalHeight = - valueKey(y1Value) === valueKey(y2Value) ? scales.y.bandwidth : 0 - const left = - categoricalWidth > 0 ? x1 - categoricalWidth / 2 : Math.min(x1, x2) - const top = - categoricalHeight > 0 - ? y1 - categoricalHeight / 2 - : Math.min(y1, y2) - const width = categoricalWidth || Math.max(0, Math.abs(x2 - x1)) - const height = categoricalHeight || Math.max(0, Math.abs(y2 - y1)) - const group = zValues[datumIndex] ?? null - const color = - options.fill ?? resolveColor(colorValues[datumIndex] ?? null) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const paintedX = left + inset - const paintedY = top + inset - const paintedWidth = Math.max(0, width - inset * 2) - const paintedHeight = Math.max(0, height - inset * 2) - const pointXValue = isChartValue(xValue) ? xValue : x2Value - const pointYValue = isChartValue(yValue) ? yValue : y2Value - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue: pointXValue, - yValue: pointYValue, - x1Value, - x2Value, - y1Value, - y2Value, - xInterval: 'range', - yInterval: 'range', - x: left + width / 2, - y: top + height / 2, - color, - } - nodes.push({ - kind: 'rect', - key, - x: paintedX, - y: paintedY, - width: paintedWidth, - height: paintedHeight, - radius: options.radius, - inset, - interaction: { point }, - style: { - fill: color, - fillOpacity: options.fillOpacity, - stroke: options.stroke, - strokeWidth: options.strokeWidth, - }, + const x1 = scales[xScale]!.map(x1Value) + const x2 = scales[xScale]!.map(x2Value) + const y1 = scales[yScale]!.map(y1Value) + const y2 = scales[yScale]!.map(y2Value) + const categoricalWidth = + valueKey(x1Value) === valueKey(x2Value) + ? scales[xScale]!.bandwidth + : 0 + const categoricalHeight = + valueKey(y1Value) === valueKey(y2Value) + ? scales[yScale]!.bandwidth + : 0 + const left = + categoricalWidth > 0 + ? x1 - categoricalWidth / 2 + : Math.min(x1, x2) + const top = + categoricalHeight > 0 + ? y1 - categoricalHeight / 2 + : Math.min(y1, y2) + const width = categoricalWidth || Math.max(0, Math.abs(x2 - x1)) + const height = categoricalHeight || Math.max(0, Math.abs(y2 - y1)) + const group = zValues[datumIndex] ?? null + const color = + options.fill ?? resolveColor(colorValues[datumIndex] ?? null) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const paintedX = left + inset + const paintedY = top + inset + const paintedWidth = Math.max(0, width - inset * 2) + const paintedHeight = Math.max(0, height - inset * 2) + const pointXValue = isChartValue(xValue) ? xValue : x2Value + const pointYValue = isChartValue(yValue) ? yValue : y2Value + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue: pointXValue, + yValue: pointYValue, + x1Value, + x2Value, + y1Value, + y2Value, + xInterval: 'range', + yInterval: 'range', + x: left + width / 2, + y: top + height / 2, + color, + } + nodes.push({ + kind: 'rect', + key, + x: paintedX, + y: paintedY, + width: paintedWidth, + height: paintedHeight, + radius: options.radius, + inset, + interaction: { point }, + style: { + fill: color, + fillOpacity: options.fillOpacity, + stroke: options.stroke, + strokeWidth: options.strokeWidth, + }, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__rect', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__rect', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } export function cell< @@ -258,16 +280,17 @@ export function cell< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, RectPointXOutput, RectPointYOutput, RectXOutput, - RectYOutput + RectYOutput, + TOptions > export function cell( source: Iterable, options: CellOptions>, -): ChartMark { +): CartesianChartMark> { return rect(source, options) } diff --git a/packages/charts-core/src/regression.ts b/packages/charts-core/src/regression.ts index 6594e36a..a1f24da0 100644 --- a/packages/charts-core/src/regression.ts +++ b/packages/charts-core/src/regression.ts @@ -14,6 +14,8 @@ import type { import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -22,10 +24,8 @@ import type { type RegressionIndependentValue = number | Date -interface LinearRegressionOptions< - TDatum, - TRegressionDatum, -> extends ChartMarkMotionOptions { +interface LinearRegressionOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string /** Fits one independent regression for each series value. */ z?: Channel @@ -277,72 +277,104 @@ export function linearRegressionY< NoInfer, RegressionIndependentValue | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: LinearRegressionYCallOptions, + options: LinearRegressionYCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< LinearRegressionYDatum>, IndependentOutput, - number + number, + IndependentOutput, + number, + TXScaleId, + TYScaleId > export function linearRegressionY( source: Iterable, options: LinearRegressionYOptions>, -): ChartMark { +): CartesianChartMark< + any, + any, + any, + any, + any, + LinearRegressionYOptions +> { const data = Array.isArray(source) ? source : Array.from(source) - - return createMark(({ markIndex }) => { - const id = options.id ?? `linear-regression-y-${markIndex}` - const normalized = normalizeRegressionOptions(options, 'linearRegressionY') - const independentValues = channelValues(data, options.x, () => undefined) - const dependentValues = channelValues(data, options.y, () => undefined) - const groups = channelValues(data, options.z, () => null) - const semanticRows = linearRegressionRowsY< - TDatum, - TransformAccessor, - TransformAccessor, - TransformAccessor - >(data, { - x: (_datum, { index }) => independentValues[index], - y: (_datum, { index }) => dependentValues[index], - z: (_datum, { index }) => groups[index], - ...normalized, - }) - const rows = withRegressionMarkKeys(semanticRows) - const children = [ - ...(normalized.ci === 0 - ? [] - : [ - areaY(rows, { - id: 'band', - x: 'x', - y: 'y', - y1: 'y1', - y2: 'y2', - z: 'group', - key: 'markKey', - fill: options.fill ?? options.stroke, - fillOpacity: options.fillOpacity ?? 0.1, - }), - ]), - lineY(rows, { - id: 'line', - x: 'x', - y: 'y', - z: 'group', - key: 'markKey', - stroke: options.stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - strokeDasharray: options.strokeDasharray, - }), - ] - - return initializeCompositeMark(id, children, { - motion: options.motion, - interactiveChildren: interactiveRegressionChildren, - }) - }) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' + + return createMark( + ({ markIndex }) => { + const id = options.id ?? `linear-regression-y-${markIndex}` + const normalized = normalizeRegressionOptions( + options, + 'linearRegressionY', + ) + const independentValues = channelValues(data, options.x, () => undefined) + const dependentValues = channelValues(data, options.y, () => undefined) + const groups = channelValues(data, options.z, () => null) + const semanticRows = linearRegressionRowsY< + TDatum, + TransformAccessor< + TDatum, + RegressionIndependentValue | null | undefined + >, + TransformAccessor, + TransformAccessor + >(data, { + x: (_datum, { index }) => independentValues[index], + y: (_datum, { index }) => dependentValues[index], + z: (_datum, { index }) => groups[index], + ...normalized, + }) + const rows = withRegressionMarkKeys(semanticRows) + const children = [ + ...(normalized.ci === 0 + ? [] + : [ + areaY(rows, { + id: 'band', + x: 'x', + y: 'y', + y1: 'y1', + y2: 'y2', + z: 'group', + key: 'markKey', + fill: options.fill ?? options.stroke, + fillOpacity: options.fillOpacity ?? 0.1, + xScale, + yScale, + }), + ]), + lineY(rows, { + id: 'line', + x: 'x', + y: 'y', + z: 'group', + key: 'markKey', + stroke: options.stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + strokeDasharray: options.strokeDasharray, + xScale, + yScale, + }), + ] + + return initializeCompositeMark(id, children, { + motion: options.motion, + interactiveChildren: interactiveRegressionChildren, + }) + }, + options.motion, + options.renderer, + ) } /** Fits least-squares x-values from raw observations. */ @@ -352,72 +384,104 @@ export function linearRegressionX< NoInfer, RegressionIndependentValue | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: LinearRegressionXCallOptions, + options: LinearRegressionXCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< LinearRegressionXDatum>, number, - IndependentOutput + IndependentOutput, + number, + IndependentOutput, + TXScaleId, + TYScaleId > export function linearRegressionX( source: Iterable, options: LinearRegressionXOptions>, -): ChartMark { +): CartesianChartMark< + any, + any, + any, + any, + any, + LinearRegressionXOptions +> { const data = Array.isArray(source) ? source : Array.from(source) - - return createMark(({ markIndex }) => { - const id = options.id ?? `linear-regression-x-${markIndex}` - const normalized = normalizeRegressionOptions(options, 'linearRegressionX') - const independentValues = channelValues(data, options.y, () => undefined) - const dependentValues = channelValues(data, options.x, () => undefined) - const groups = channelValues(data, options.z, () => null) - const semanticRows = linearRegressionRowsX< - TDatum, - TransformAccessor, - TransformAccessor, - TransformAccessor - >(data, { - x: (_datum, { index }) => dependentValues[index], - y: (_datum, { index }) => independentValues[index], - z: (_datum, { index }) => groups[index], - ...normalized, - }) - const rows = withRegressionMarkKeys(semanticRows) - const children = [ - ...(normalized.ci === 0 - ? [] - : [ - areaX(rows, { - id: 'band', - x: 'x', - x1: 'x1', - x2: 'x2', - y: 'y', - z: 'group', - key: 'markKey', - fill: options.fill ?? options.stroke, - fillOpacity: options.fillOpacity ?? 0.1, - }), - ]), - lineX(rows, { - id: 'line', - x: 'x', - y: 'y', - z: 'group', - key: 'markKey', - stroke: options.stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - strokeDasharray: options.strokeDasharray, - }), - ] - - return initializeCompositeMark(id, children, { - motion: options.motion, - interactiveChildren: interactiveRegressionChildren, - }) - }) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' + + return createMark( + ({ markIndex }) => { + const id = options.id ?? `linear-regression-x-${markIndex}` + const normalized = normalizeRegressionOptions( + options, + 'linearRegressionX', + ) + const independentValues = channelValues(data, options.y, () => undefined) + const dependentValues = channelValues(data, options.x, () => undefined) + const groups = channelValues(data, options.z, () => null) + const semanticRows = linearRegressionRowsX< + TDatum, + TransformAccessor, + TransformAccessor< + TDatum, + RegressionIndependentValue | null | undefined + >, + TransformAccessor + >(data, { + x: (_datum, { index }) => dependentValues[index], + y: (_datum, { index }) => independentValues[index], + z: (_datum, { index }) => groups[index], + ...normalized, + }) + const rows = withRegressionMarkKeys(semanticRows) + const children = [ + ...(normalized.ci === 0 + ? [] + : [ + areaX(rows, { + id: 'band', + x: 'x', + x1: 'x1', + x2: 'x2', + y: 'y', + z: 'group', + key: 'markKey', + fill: options.fill ?? options.stroke, + fillOpacity: options.fillOpacity ?? 0.1, + xScale, + yScale, + }), + ]), + lineX(rows, { + id: 'line', + x: 'x', + y: 'y', + z: 'group', + key: 'markKey', + stroke: options.stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + strokeDasharray: options.strokeDasharray, + xScale, + yScale, + }), + ] + + return initializeCompositeMark(id, children, { + motion: options.motion, + interactiveChildren: interactiveRegressionChildren, + }) + }, + options.motion, + options.renderer, + ) } type LinearRegressionOwner = diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index 68322c91..55ea745c 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -18,7 +18,9 @@ import { } from './cursor-host-contract' import type { ChartInteractionController, + ChartLayerRenderer, ChartPointerResolution, + ChartRenderer, ChartRendererHost, ChartRendererHostOptions, ChartHostControlExtension, @@ -44,8 +46,66 @@ import type { ChartTooltipPosition, ChartMotionTransition, ChartValue, + SceneNode, + ChartMarkRenderer, } from './types' +export function resolveChartRenderer( + scene: ChartScene, + defaultRenderer: ChartRenderer, +): ChartRenderer { + const renderer = + findLayerRenderer(scene.nodes, defaultRenderer) ?? + findGuideLayerRenderer(scene.focusGuides, defaultRenderer) + return renderer?.compose(defaultRenderer) ?? defaultRenderer +} + +function findGuideLayerRenderer( + guides: ChartScene['focusGuides'], + defaultRenderer: ChartRenderer, +): ChartLayerRenderer | undefined { + for (const guide of guides ?? []) { + if ( + guide.renderer !== undefined && + guide.renderer !== (defaultRenderer as unknown) + ) { + return requiredCompositor(guide.renderer) + } + } + return undefined +} + +function findLayerRenderer( + nodes: readonly SceneNode[], + defaultRenderer: ChartRenderer, +): ChartLayerRenderer | undefined { + for (const node of nodes) { + if ( + node.renderer !== undefined && + node.renderer !== (defaultRenderer as unknown) + ) { + return requiredCompositor(node.renderer) + } + if (node.kind === 'group') { + const renderer = findLayerRenderer(node.children, defaultRenderer) + if (renderer) return renderer + } + } + return undefined +} + +function requiredCompositor( + renderer: ChartMarkRenderer, +): ChartLayerRenderer { + const candidate = renderer as unknown as Partial + if (typeof candidate.compose !== 'function') { + throw new TypeError( + `Mark renderer "${renderer.id}" cannot compose chart layers`, + ) + } + return renderer as unknown as ChartLayerRenderer +} + type HostRenderReason = 'update' | 'resize' | 'layout' type FocusOwner = 'pointer' | 'keyboard' | 'controlled' @@ -125,17 +185,18 @@ export function mountChartRenderer< const previousCursorBinding = renderedCursorBinding scene = createHostedScene(createScene()) interactionScene = scene + const renderer = resolveChartRenderer(scene, options.renderer) if (!surface) { - surface = options.renderer.mount(container, scheduleRender) + surface = renderer.mount(container, scheduleRender) subscribeToPresentation() - } else if (surface.renderer !== options.renderer) { + } else if (surface.renderer !== renderer) { unsubscribePresentation?.() unsubscribePresentation = undefined destroyTooltip() destroyHostControls() surface.destroy() container.replaceChildren() - surface = options.renderer.mount(container, scheduleRender) + surface = renderer.mount(container, scheduleRender) subscribeToPresentation() hasRendered = false } diff --git a/packages/charts-core/src/ridgeline.ts b/packages/charts-core/src/ridgeline.ts index 1bb6d6fa..ebdef9c9 100644 --- a/packages/charts-core/src/ridgeline.ts +++ b/packages/charts-core/src/ridgeline.ts @@ -16,6 +16,8 @@ import { groupedIndexes } from './transform-internal' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartCurve, ChartKey, ChartMark, @@ -35,7 +37,8 @@ export type RidgelineStateStyle = Pick< 'fillOpacity' | 'strokeOpacity' | 'opacity' > -interface RidgelineOptions extends ChartMarkMotionOptions { +interface RidgelineOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string /** Normalized profile height in the inclusive range [0, 1]. */ height: Channel @@ -128,18 +131,27 @@ export function ridgelineY< RidgelinePosition | null | undefined >, const TYChannel extends Channel, ChartKey | null | undefined>, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: RidgelineYCallOptions, + options: RidgelineYCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, PositionOutput, - CategoryOutput + CategoryOutput, + PositionOutput, + CategoryOutput, + TXScaleId, + TYScaleId > export function ridgelineY( source: Iterable, options: RidgelineYOptions>, -): ChartMark { +): CartesianChartMark> { return ridgeline(source, options, options.x, options.y, 'y') } @@ -151,18 +163,27 @@ export function ridgelineX< NoInfer, RidgelinePosition | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: RidgelineXCallOptions, + options: RidgelineXCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, CategoryOutput, - PositionOutput + PositionOutput, + CategoryOutput, + PositionOutput, + TXScaleId, + TYScaleId > export function ridgelineX( source: Iterable, options: RidgelineXOptions>, -): ChartMark { +): CartesianChartMark> { return ridgeline(source, options, options.y, options.x, 'x') } @@ -172,243 +193,251 @@ function ridgeline( position: Channel, category: Channel, orientation: 'x' | 'y', -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' const overlap = options.overlap ?? 1 if (!isFiniteNumber(overlap) || overlap <= 0) { throw new TypeError('ridgeline: overlap must be a positive finite number') } - return createMark(({ markIndex }) => { - const id = options.id ?? `ridgeline-${orientation}-${markIndex}` - const positionValues = channelValues(data, position, () => undefined) - const categoryValues = channelValues(data, category, () => undefined) - const heights = channelValues(data, options.height, () => undefined) - heights.forEach((height, index) => { - if (!isFiniteNumber(height)) return - if (height < 0 || height > 1) { - throw new TypeError( - `ridgeline: height must be between 0 and 1; received ${height} at index ${index}`, - ) - } - }) - const categoryKeys = categoryValues.map((value) => - isChartKey(value) ? value : null, - ) - const colorValues = - options.color === undefined - ? categoryKeys - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups: categoryKeys, - candidates: [positionValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - states: markStates(data, options.states), - channels: - orientation === 'y' - ? { - x: { - scale: 'x', - values: positionValues.filter(isRidgelinePosition), - }, - y: { - scale: 'y', - values: categoryValues.filter(isChartKey), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - } - : { - x: { - scale: 'x', - values: categoryValues.filter(isChartKey), - }, - y: { - scale: 'y', - values: positionValues.filter(isRidgelinePosition), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ chart, scales, color: resolveColor }) => { - const categoryScale = orientation === 'y' ? scales.y : scales.x - if (!isResolvedCategoryScale(categoryScale)) { + return createMark( + ({ markIndex }) => { + const id = options.id ?? `ridgeline-${orientation}-${markIndex}` + const positionValues = channelValues(data, position, () => undefined) + const categoryValues = channelValues(data, category, () => undefined) + const heights = channelValues(data, options.height, () => undefined) + heights.forEach((height, index) => { + if (!isFiniteNumber(height)) return + if (height < 0 || height > 1) { throw new TypeError( - `ridgeline${orientation.toUpperCase()}: the category axis requires a band or point scale`, + `ridgeline: height must be between 0 and 1; received ${height} at index ${index}`, ) } - const positionScale = orientation === 'y' ? scales.x : scales.y - if (!positionScale) { - throw new TypeError( - `ridgeline${orientation.toUpperCase()}: the profile axis scale is required`, - ) - } - const span = orientation === 'y' ? chart.height : chart.width - const step = resolvedCategoryStep(categoryScale, span, overlap * 2) - const areas: SceneArea[] = [] - const lines: ScenePolyline[] = [] - const points: ChartPoint[] = [] - - for (const { key: group, indexes } of groupedIndexes(categoryKeys)) { - const firstIndex = indexes.find((index) => - isChartKey(categoryValues[index]), - ) - if (firstIndex === undefined) continue - const categoryValue = categoryValues[firstIndex]! - const baseline = categoryScale.map(categoryValue) - if (!Number.isFinite(baseline)) continue - const datum = data[firstIndex]! - const fallback = resolveColor(colorValues[firstIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - firstIndex, - data, - fallback, - ) - const stroke = - options.stroke === null - ? undefined - : visualValue(options.stroke, datum, firstIndex, data, fallback) - let profile: (readonly [number, number])[] = [] - let baselinePoints: (readonly [number, number])[] = [] - let segmentPoints: ChartPoint[] = [] - let segmentIndex = 0 + }) + const categoryKeys = categoryValues.map((value) => + isChartKey(value) ? value : null, + ) + const colorValues = + options.color === undefined + ? categoryKeys + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups: categoryKeys, + candidates: [positionValues], + markId: id, + warningIdentity: options, + }) - const flush = () => { - if (!profile.length) return - const groupKey = valueKey(group) - const interaction = { - points: segmentPoints, - affinity: orientation === 'y' ? ('x' as const) : ('y' as const), - } - const profilePath = options.curve?.line(profile) - areas.push({ - kind: 'area', - key: `${id}:${groupKey}:segment:${segmentIndex}:area`, - points: [...profile, ...[...baselinePoints].reverse()], - ...(profilePath - ? { path: closeProfilePath(profilePath, baselinePoints) } - : {}), - interaction, - style: { - fill, - fillOpacity: options.fillOpacity ?? 0.5, + return { + id, + states: markStates(data, options.states), + channels: + orientation === 'y' + ? { + x: { + scale: xScale, + values: positionValues.filter(isRidgelinePosition), + }, + y: { + scale: yScale, + values: categoryValues.filter(isChartKey), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, + } + : { + x: { + scale: xScale, + values: categoryValues.filter(isChartKey), + }, + y: { + scale: yScale, + values: positionValues.filter(isRidgelinePosition), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }) - if (stroke !== undefined) { - lines.push({ - kind: 'polyline', - key: `${id}:${groupKey}:segment:${segmentIndex}:line`, - points: profile, - ...(profilePath ? { path: profilePath } : {}), + render: ({ chart, scales, color: resolveColor }) => { + const categoryScale = + orientation === 'y' ? scales[yScale]! : scales[xScale]! + if (!isResolvedCategoryScale(categoryScale)) { + throw new TypeError( + `ridgeline${orientation.toUpperCase()}: the category axis requires a band or point scale`, + ) + } + const positionScale = + orientation === 'y' ? scales[xScale] : scales[yScale] + if (!positionScale) { + throw new TypeError( + `ridgeline${orientation.toUpperCase()}: the profile axis scale is required`, + ) + } + const span = orientation === 'y' ? chart.height : chart.width + const step = resolvedCategoryStep(categoryScale, span, overlap * 2) + const areas: SceneArea[] = [] + const lines: ScenePolyline[] = [] + const points: ChartPoint[] = [] + + for (const { key: group, indexes } of groupedIndexes(categoryKeys)) { + const firstIndex = indexes.find((index) => + isChartKey(categoryValues[index]), + ) + if (firstIndex === undefined) continue + const categoryValue = categoryValues[firstIndex]! + const baseline = categoryScale.map(categoryValue) + if (!Number.isFinite(baseline)) continue + const datum = data[firstIndex]! + const fallback = resolveColor(colorValues[firstIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + firstIndex, + data, + fallback, + ) + const stroke = + options.stroke === null + ? undefined + : visualValue(options.stroke, datum, firstIndex, data, fallback) + let profile: (readonly [number, number])[] = [] + let baselinePoints: (readonly [number, number])[] = [] + let segmentPoints: ChartPoint[] = [] + let segmentIndex = 0 + + const flush = () => { + if (!profile.length) return + const groupKey = valueKey(group) + const interaction = { + points: segmentPoints, + affinity: orientation === 'y' ? ('x' as const) : ('y' as const), + } + const profilePath = options.curve?.line(profile) + areas.push({ + kind: 'area', + key: `${id}:${groupKey}:segment:${segmentIndex}:area`, + points: [...profile, ...[...baselinePoints].reverse()], + ...(profilePath + ? { path: closeProfilePath(profilePath, baselinePoints) } + : {}), interaction, style: { - fill: 'none', - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - strokeDasharray: options.strokeDasharray, + fill, + fillOpacity: options.fillOpacity ?? 0.5, }, }) + if (stroke !== undefined) { + lines.push({ + kind: 'polyline', + key: `${id}:${groupKey}:segment:${segmentIndex}:line`, + points: profile, + ...(profilePath ? { path: profilePath } : {}), + interaction, + style: { + fill: 'none', + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + strokeDasharray: options.strokeDasharray, + }, + }) + } + points.push(...segmentPoints) + profile = [] + baselinePoints = [] + segmentPoints = [] + segmentIndex += 1 } - points.push(...segmentPoints) - profile = [] - baselinePoints = [] - segmentPoints = [] - segmentIndex += 1 - } - for (const index of indexes) { - const positionValue = positionValues[index] - const nextCategory = categoryValues[index] - const height = heights[index] - if ( - !isRidgelinePosition(positionValue) || - !isChartKey(nextCategory) || - !isFiniteNumber(height) - ) { - flush() - continue + for (const index of indexes) { + const positionValue = positionValues[index] + const nextCategory = categoryValues[index] + const height = heights[index] + if ( + !isRidgelinePosition(positionValue) || + !isChartKey(nextCategory) || + !isFiniteNumber(height) + ) { + flush() + continue + } + const positionPixel = positionScale.map(positionValue) + const nextBaseline = categoryScale.map(nextCategory) + if ( + !Number.isFinite(positionPixel) || + !Number.isFinite(nextBaseline) + ) { + flush() + continue + } + const profilePixel = + nextBaseline + + (orientation === 'y' ? -1 : 1) * height * overlap * step + const x = orientation === 'y' ? positionPixel : profilePixel + const y = orientation === 'y' ? profilePixel : positionPixel + const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: String(nextCategory), + datum: data[index]!, + datumIndex: index, + xValue: orientation === 'y' ? positionValue : nextCategory, + yValue: orientation === 'y' ? nextCategory : positionValue, + x, + y, + color: fill, + } + profile.push([x, y]) + baselinePoints.push( + orientation === 'y' + ? [positionPixel, nextBaseline] + : [nextBaseline, positionPixel], + ) + segmentPoints.push(point) } - const positionPixel = positionScale.map(positionValue) - const nextBaseline = categoryScale.map(nextCategory) - if ( - !Number.isFinite(positionPixel) || - !Number.isFinite(nextBaseline) - ) { - flush() - continue - } - const profilePixel = - nextBaseline + - (orientation === 'y' ? -1 : 1) * height * overlap * step - const x = orientation === 'y' ? positionPixel : profilePixel - const y = orientation === 'y' ? profilePixel : positionPixel - const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: String(nextCategory), - datum: data[index]!, - datumIndex: index, - xValue: orientation === 'y' ? positionValue : nextCategory, - yValue: orientation === 'y' ? nextCategory : positionValue, - x, - y, - color: fill, - } - profile.push([x, y]) - baselinePoints.push( - orientation === 'y' - ? [positionPixel, nextBaseline] - : [nextBaseline, positionPixel], - ) - segmentPoints.push(point) + flush() } - flush() - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: `ts-chart__ridgeline ts-chart__ridgeline-${orientation}`, - ariaHidden: true, - children: [ - { - kind: 'group', - key: `${id}:areas`, - className: 'ts-chart__area', - ariaHidden: true, - children: areas, - }, - { - kind: 'group', - key: `${id}:lines`, - className: 'ts-chart__line', - ariaHidden: true, - children: lines, - }, - ], - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: `ts-chart__ridgeline ts-chart__ridgeline-${orientation}`, + ariaHidden: true, + children: [ + { + kind: 'group', + key: `${id}:areas`, + className: 'ts-chart__area', + ariaHidden: true, + children: areas, + }, + { + kind: 'group', + key: `${id}:lines`, + className: 'ts-chart__line', + ariaHidden: true, + children: lines, + }, + ], + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } function isRidgelinePosition(value: unknown): value is RidgelinePosition { diff --git a/packages/charts-core/src/rule.ts b/packages/charts-core/src/rule.ts index c07bbfdb..829c73d4 100644 --- a/packages/charts-core/src/rule.ts +++ b/packages/charts-core/src/rule.ts @@ -8,6 +8,7 @@ import { import { valueKey } from './scales' import type { Channel, + CartesianChartMark, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -27,6 +28,7 @@ export interface RuleYOptions extends ChartMarkMotionOptions { strokeOpacity?: number strokeWidth?: number strokeDasharray?: string + yScale?: string } export interface RuleXOptions extends ChartMarkMotionOptions { @@ -37,6 +39,7 @@ export interface RuleXOptions extends ChartMarkMotionOptions { strokeOpacity?: number strokeWidth?: number strokeDasharray?: string + xScale?: string } type RuleFallback = [ @@ -54,85 +57,93 @@ export function ruleY< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< never, never, - OptionChannelOutput> + OptionChannelOutput>, + never, + OptionChannelOutput>, + TOptions > export function ruleY( source: Iterable, options: RuleYOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `rule-y-${markIndex}` - const values = channelValues(data, options.y, (datum) => - isChartValue(datum) ? datum : undefined, - ) - const colorValues = channelValues(data, options.color, () => null) - return { - id, - channels: { - y: { scale: 'y', values: values.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return createMark( + ({ markIndex }) => { + const id = options.id ?? `rule-y-${markIndex}` + const values = channelValues(data, options.y, (datum) => + isChartValue(datum) ? datum : undefined, + ) + const colorValues = channelValues(data, options.color, () => null) + return { + id, + channels: { + y: { scale: yScale, values: values.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, chart, theme, color: resolveColor }) => { - const children: SceneNode[] = [] - const focusAnchors: ChartFocusAnchor[] = [] - data.forEach((datum, index) => { - const yValue = values[index] - if (!isChartValue(yValue)) return - const key = `${id}:${valueKey(yValue)}:${index}` - children.push({ - kind: 'rule', - key, - x1: chart.x, - x2: chart.x + chart.width, - y1: scales.y.map(yValue), - y2: scales.y.map(yValue), - style: { - stroke: visualValue( - options.stroke, - datum, - index, - data, - colorValues[index] == null - ? theme.foreground - : resolveColor(colorValues[index]), - ), - strokeOpacity: options.strokeOpacity ?? 0.5, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - }, - }) - focusAnchors.push({ - key, - markId: id, - group: isChartKey(colorValues[index]) ? colorValues[index] : null, - datum, - datumIndex: index, - yValue, + render: ({ scales, chart, theme, color: resolveColor }) => { + const children: SceneNode[] = [] + const focusAnchors: ChartFocusAnchor[] = [] + data.forEach((datum, index) => { + const yValue = values[index] + if (!isChartValue(yValue)) return + const key = `${id}:${valueKey(yValue)}:${index}` + children.push({ + kind: 'rule', + key, + x1: chart.x, + x2: chart.x + chart.width, + y1: scales[yScale]!.map(yValue), + y2: scales[yScale]!.map(yValue), + style: { + stroke: visualValue( + options.stroke, + datum, + index, + data, + colorValues[index] == null + ? theme.foreground + : resolveColor(colorValues[index]), + ), + strokeOpacity: options.strokeOpacity ?? 0.5, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + }, + }) + focusAnchors.push({ + key, + markId: id, + group: isChartKey(colorValues[index]) ? colorValues[index] : null, + datum, + datumIndex: index, + yValue, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__rule ts-chart__rule-y', - ariaHidden: true, - children, - }, - ], - focusAnchors, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__rule ts-chart__rule-y', + ariaHidden: true, + children, + }, + ], + focusAnchors, + } + }, + } + }, + options.motion, + options.renderer, + ) } export function ruleX( @@ -144,83 +155,91 @@ export function ruleX< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< + never, + OptionChannelOutput>, never, OptionChannelOutput>, - never + never, + TOptions > export function ruleX( source: Iterable, options: RuleXOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' - return createMark(({ markIndex }) => { - const id = options.id ?? `rule-x-${markIndex}` - const values = channelValues(data, options.x, (datum) => - isChartValue(datum) ? datum : undefined, - ) - const colorValues = channelValues(data, options.color, () => null) - return { - id, - channels: { - x: { scale: 'x', values: values.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return createMark( + ({ markIndex }) => { + const id = options.id ?? `rule-x-${markIndex}` + const values = channelValues(data, options.x, (datum) => + isChartValue(datum) ? datum : undefined, + ) + const colorValues = channelValues(data, options.color, () => null) + return { + id, + channels: { + x: { scale: xScale, values: values.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ scales, chart, theme, color: resolveColor }) => { - const children: SceneNode[] = [] - const focusAnchors: ChartFocusAnchor[] = [] - data.forEach((datum, index) => { - const xValue = values[index] - if (!isChartValue(xValue)) return - const key = `${id}:${valueKey(xValue)}:${index}` - children.push({ - kind: 'rule', - key, - x1: scales.x.map(xValue), - x2: scales.x.map(xValue), - y1: chart.y, - y2: chart.y + chart.height, - style: { - stroke: visualValue( - options.stroke, - datum, - index, - data, - colorValues[index] == null - ? theme.foreground - : resolveColor(colorValues[index]), - ), - strokeOpacity: options.strokeOpacity ?? 0.5, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - }, + render: ({ scales, chart, theme, color: resolveColor }) => { + const children: SceneNode[] = [] + const focusAnchors: ChartFocusAnchor[] = [] + data.forEach((datum, index) => { + const xValue = values[index] + if (!isChartValue(xValue)) return + const key = `${id}:${valueKey(xValue)}:${index}` + children.push({ + kind: 'rule', + key, + x1: scales[xScale]!.map(xValue), + x2: scales[xScale]!.map(xValue), + y1: chart.y, + y2: chart.y + chart.height, + style: { + stroke: visualValue( + options.stroke, + datum, + index, + data, + colorValues[index] == null + ? theme.foreground + : resolveColor(colorValues[index]), + ), + strokeOpacity: options.strokeOpacity ?? 0.5, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + }, + }) + focusAnchors.push({ + key, + markId: id, + group: isChartKey(colorValues[index]) ? colorValues[index] : null, + datum, + datumIndex: index, + xValue, + }) }) - focusAnchors.push({ - key, - markId: id, - group: isChartKey(colorValues[index]) ? colorValues[index] : null, - datum, - datumIndex: index, - xValue, - }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__rule ts-chart__rule-x', - ariaHidden: true, - children, - }, - ], - focusAnchors, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__rule ts-chart__rule-x', + ariaHidden: true, + children, + }, + ], + focusAnchors, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 9bfad712..c30663bc 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -5,13 +5,15 @@ import { withChartTextTypography, } from './guide-layout' import { nearestScenePoint } from './nearest' +import { setMappedFocusCoordinate } from './focus-coordinate-internal' +import { readMaterializedPositionChannel } from './materialized-channel-internal' import { mapScenePointReferences } from './scene-point-map' import { chartSceneSource } from './scene-source' import type { ResponsiveChartDefinition, InitializedMark, MaterializedChannel, - ChartAxisOptions, + ChartAxisSide, ChartAxisPresentationOptions, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, @@ -33,6 +35,8 @@ import type { ChartMarkPointY, ChartMarkState, ChartPoint, + ChartPositionChannel, + ChartPositionScaleOptions, SceneFocusGuide, ResolvedColorScale, ChartScene, @@ -55,6 +59,8 @@ import type { SceneNode, } from './types' +declare const process: { readonly env: { readonly NODE_ENV?: string } } + export const defaultChartTheme: ChartTheme = { foreground: 'currentColor', muted: 'currentColor', @@ -71,7 +77,7 @@ export const defaultChartTheme: ChartTheme = { } type DefinedStaticChart< - TMarks extends readonly ChartMark[], + TMarks extends readonly ChartMark[], TSpec extends ChartSpec, > = Omit< StaticChartDefinition< @@ -109,9 +115,10 @@ type ChartDefinitionWithOptions = Omit< type DefinedResponsiveChart< TSpec extends { - marks: readonly ChartMark[] - x?: ChartAxisOptions | null - y?: ChartAxisOptions | null + marks: readonly ChartMark[] + scales?: Readonly> + x?: ChartPositionScaleOptions | null + y?: ChartPositionScaleOptions | null }, > = Omit< ResponsiveChartDefinition< @@ -125,7 +132,7 @@ type DefinedResponsiveChart< } export function defineChart< - const TMarks extends readonly ChartMark[], + const TMarks extends readonly ChartMark[], const TSpec extends ChartSpec, >( spec: TSpec & { marks: TMarks } & ChartDefinitionOptions< @@ -136,9 +143,10 @@ export function defineChart< ): DefinedStaticChart export function defineChart< const TSpec extends { - marks: readonly ChartMark[] - x?: ChartAxisOptions | null - y?: ChartAxisOptions | null + marks: readonly ChartMark[] + scales?: Readonly> + x?: ChartPositionScaleOptions | null + y?: ChartPositionScaleOptions | null }, TTooltipHost extends string = never, >( @@ -154,15 +162,16 @@ export function defineChart< ResponsiveChartConfig export function defineChart< const TSpec extends { - marks: readonly ChartMark[] - x?: ChartAxisOptions | null - y?: ChartAxisOptions | null + marks: readonly ChartMark[] + scales?: Readonly> + x?: ChartPositionScaleOptions | null + y?: ChartPositionScaleOptions | null }, >( chart: (context: ChartBuildContext) => CheckedChartSpec, ): DefinedResponsiveChart export function defineChart< - const TMarks extends readonly ChartMark[], + const TMarks extends readonly ChartMark[], const TSpec extends ChartSpec, const TOptions extends ChartDefinitionOptions< ChartMarkDatum, @@ -179,9 +188,10 @@ export function defineChart< ): ChartDefinitionWithOptions, TOptions> export function defineChart< const TSpec extends { - marks: readonly ChartMark[] - x?: ChartAxisOptions | null - y?: ChartAxisOptions | null + marks: readonly ChartMark[] + scales?: Readonly> + x?: ChartPositionScaleOptions | null + y?: ChartPositionScaleOptions | null }, const TOptions extends ChartDefinitionOptions< ChartSpecDatum, @@ -193,7 +203,7 @@ export function defineChart< options: TOptions, ): ChartDefinitionWithOptions, TOptions> export function defineChart< - const TMarks extends readonly ChartMark[], + const TMarks extends readonly ChartMark[], const TSpec extends ChartSpec, const TOptions extends ChartDefinitionOptions< ChartMarkDatum, @@ -267,7 +277,7 @@ export function createChartScene< } function resolveSuppliedScale( - scale: NonNullable, + scale: NonNullable, context: Parameters[0], ) { if (typeof scale === 'function') return resolveConfiguredScale(scale, context) @@ -308,22 +318,15 @@ function createChartSceneWithScaleResolver< const initialized = definition.marks.map((mark, markIndex) => mark.initialize({ markIndex }), ) - const xChannels = collectScaleChannels(initialized, 'x') - const yChannels = collectScaleChannels(initialized, 'y') - const axes = - definition.guides === false - ? 0 - : +(definition.x != null && definition.x.axis !== false) | - (+(definition.y != null && definition.y.axis !== false) << 1) + const scaleChannels = collectPositionScaleChannels(initialized) + const scaleDefinitions = resolveScaleDefinitions(definition, scaleChannels) const resolvedLayout = resolveSceneLayout( definition, initialized, width, height, theme, - xChannels, - yChannels, - axes, + scaleDefinitions, resolveScale, layoutOptions, ) @@ -336,24 +339,33 @@ function createChartSceneWithScaleResolver< colors, legend, legendBounds, + positionScales, + scaleGuides, + gridScales, } = resolvedLayout const markEntries: ViewportMarkEntry[] = [] const defaultFocusEntries: DefaultFocusEntry[] = [] const points: ChartPoint[] = [] - const translateX = scales.x.viewport?.translate ?? 0 - const translateY = scales.y.viewport?.translate ?? 0 const focusGuides: SceneFocusGuide[] = [] const firstBaseMarkIndex = marks.findIndex( (mark) => !mark.focus && !mark.focusGuideOnly, ) marks.forEach((mark, markIndex) => { - const viewportX = Boolean( - scales.x.viewport && markUsesViewportAxis(mark, 'x'), + const translateX = markViewportTranslation( + mark, + 'x', + positionScales, + scales, ) - const viewportY = Boolean( - scales.y.viewport && markUsesViewportAxis(mark, 'y'), + const translateY = markViewportTranslation( + mark, + 'y', + positionScales, + scales, ) + const viewportX = translateX !== undefined + const viewportY = translateY !== undefined const pointMap = new Map>() const presentPoint = ( point: ChartPoint, @@ -364,11 +376,18 @@ function createChartSceneWithScaleResolver< viewportX || viewportY ? { ...point, - x: point.x + (viewportX ? translateX : 0), - y: point.y + (viewportY ? translateY : 0), + x: point.x + (translateX ?? 0), + y: point.y + (translateY ?? 0), } : point ) as ChartPoint + registerMappedFocusCoordinates( + presented, + mark, + scales, + translateX, + translateY, + ) pointMap.set(point, presented) return presented } @@ -446,14 +465,14 @@ function createChartSceneWithScaleResolver< }) } } - markEntries.push({ key: mark.id, nodes: entryNodes, viewportX, viewportY }) + markEntries.push({ + key: mark.id, + nodes: entryNodes, + translateX, + translateY, + }) }) - const markNodes = arrangeViewportMarkNodes( - markEntries, - translateX, - translateY, - chart, - ) + const markNodes = arrangeViewportMarkNodes(markEntries, chart) const nodes: SceneNode[] = [ { kind: 'group', @@ -463,13 +482,10 @@ function createChartSceneWithScaleResolver< children: markNodes, }, ] - if ( - definition.guides !== false && - (definition.x?.grid || definition.y?.grid) - ) { - nodes.unshift(createGrid(chart, scales, definition, theme)) + if (gridScales.length) { + nodes.unshift(createGrid(chart, gridScales, theme)) } - if (axes) { + if (scaleGuides.length) { nodes.push(axisNodes) } const controls: ChartHostControl[] = [] @@ -569,11 +585,37 @@ function createChartSceneWithScaleResolver< } } +function registerMappedFocusCoordinates( + point: ChartPoint, + mark: Pick, + scales: ChartScene['scales'], + translateX: number | undefined, + translateY: number | undefined, +): void { + register('x', point.xValue, point.x, translateX) + register('y', point.yValue, point.y, translateY) + + function register( + axis: ChartPositionChannel, + value: ChartValue, + coordinate: number, + translate: number | undefined, + ) { + const scaleId = mark.channels[axis]?.scale + const scale = scaleId === undefined ? undefined : scales[scaleId] + if (!scale || scale.type === 'none') return + const mapped = scale.map(value) + (translate ?? 0) + if (Number.isFinite(mapped) && mapped !== coordinate) { + setMappedFocusCoordinate(point, axis, mapped) + } + } +} + interface ViewportMarkEntry { key: string nodes: readonly SceneNode[] - viewportX: boolean - viewportY: boolean + translateX?: number + translateY?: number } interface DefaultFocusEntry< @@ -586,23 +628,56 @@ interface DefaultFocusEntry< clipped: boolean } -function markUsesViewportAxis( +function markViewportTranslation( mark: Pick, - axis: 'x' | 'y', -) { - const ownership = mark.viewport?.[axis] - if (ownership) return ownership === 'content' - return Object.values(mark.channels).some((channel) => channel.scale === axis) + channel: ChartPositionChannel, + positionScales: readonly ResolvedPositionScale[], + scales: ChartScene['scales'], +): number | undefined { + const ownership = mark.viewport?.[channel] + if (ownership === 'fixed') return undefined + for (const positionScale of positionScales) { + if ( + positionScale.channel === channel && + positionScale.scale.viewport && + Object.values(mark.channels).some( + (materialized) => materialized.scale === positionScale.id, + ) + ) { + return positionScale.scale.viewport.translate + } + } + return ownership === 'content' + ? scales[channel]?.viewport?.translate + : undefined +} + +function markUsesAnyViewport( + mark: Pick, + positionScales: readonly ResolvedPositionScale[], +): boolean { + return (['x', 'y'] as const).some((channel) => + positionScales.some( + (positionScale) => + positionScale.channel === channel && + positionScale.scale.viewport && + mark.viewport?.[channel] !== 'fixed' && + (mark.viewport?.[channel] === 'content' || + Object.values(mark.channels).some( + (materialized) => materialized.scale === positionScale.id, + )), + ), + ) } function arrangeViewportMarkNodes( entries: readonly ViewportMarkEntry[], - translateX: number, - translateY: number, chart: ChartBounds, ): SceneNode[] { return entries.flatMap((entry): SceneNode[] => { - if (!entry.viewportX && !entry.viewportY) return [...entry.nodes] + if (entry.translateX === undefined && entry.translateY === undefined) { + return [...entry.nodes] + } return [ { kind: 'group', @@ -614,8 +689,12 @@ function arrangeViewportMarkNodes( kind: 'group', key: `viewport-content:${entry.key}`, className: 'ts-chart__viewport-content', - ...(entry.viewportX ? { translateX } : {}), - ...(entry.viewportY ? { translateY } : {}), + ...(entry.translateX === undefined + ? {} + : { translateX: entry.translateX }), + ...(entry.translateY === undefined + ? {} + : { translateY: entry.translateY }), children: entry.nodes, }, ], @@ -647,7 +726,8 @@ export function viewportInteractionPoints< scene: ChartScene, points: readonly ChartPoint[] = scene.points, ): readonly ChartPoint[] { - if (!scene.scales.x?.viewport && !scene.scales.y?.viewport) return points + if (!Object.values(scene.scales).some((scale) => scale.viewport)) + return points const { x, y, width, height } = scene.chart const right = x + width const bottom = y + height @@ -670,10 +750,17 @@ function pointUsesViewportClip(scene: ChartScene, point: ChartPoint) { )[chartSceneSource] const mark = source?.[1].find((candidate) => candidate.id === point.markId) if (!mark) return true - return Boolean( - (scene.scales.x?.viewport && markUsesViewportAxis(mark, 'x')) || - (scene.scales.y?.viewport && markUsesViewportAxis(mark, 'y')), - ) + return (['x', 'y'] as const).some((axis) => { + const ownership = mark.viewport?.[axis] + if (ownership === 'fixed') return false + if (ownership === 'content' && scene.scales[axis]?.viewport) return true + return Object.entries(mark.channels).some( + ([channelName, channel]) => + channelName === axis && + channel.scale !== undefined && + scene.scales[channel.scale]?.viewport !== undefined, + ) + }) } function collectRenderedPoints( @@ -728,17 +815,162 @@ function collectScaleChannels( return { values, includeZero, materialized } } +function collectPositionScaleChannels( + marks: readonly InitializedMark[], +): ReadonlyMap { + const collected = new Map() + for (const mark of marks) { + for (const [channelName, channel] of Object.entries(mark.channels)) { + const scaleId = channel.scale + if (scaleId === undefined) continue + const positionChannel = readMaterializedPositionChannel( + channelName, + channel, + ) + if (scaleId === 'color') { + if (positionChannel) { + throw new TypeError('Position scales cannot use reserved ID "color"') + } + continue + } + const current = collected.get(scaleId) ?? { + values: [], + includeZero: false, + materialized: false, + } + if ( + positionChannel && + current.channel && + current.channel !== positionChannel + ) { + throw new TypeError( + `Chart scale "${scaleId}" cannot materialize both x and y channels`, + ) + } + current.channel ??= positionChannel + current.materialized ||= !mark.focusGuideOnly + current.includeZero ||= channel.includeZero ?? false + for (const value of channel.values) current.values.push(value) + collected.set(scaleId, current) + } + } + return collected +} + +let warnedLegacyScaleOptions = false + +function resolveScaleDefinitions( + definition: StaticChartDefinition, + collected: ReadonlyMap, +): readonly PositionScaleDefinition[] { + if ( + (definition.scales === undefined || + definition.x !== undefined || + definition.y !== undefined) && + !warnedLegacyScaleOptions + ) { + try { + if (process.env.NODE_ENV !== 'production') { + warnedLegacyScaleOptions = true + console.warn( + '[TanStack Charts] Root `x` and `y` options are deprecated. Move them to `scales.x` and `scales.y`. When neither Cartesian scale is used, set `scales` to `{ x: null, y: null }`. This compatibility will be removed when TanStack Charts enters Alpha.', + ) + } + } catch { + warnedLegacyScaleOptions = true + // Keep raw-browser migration help removable by production minifiers. + /* @__PURE__ */ console.warn( + '[TanStack Charts] Root `x` and `y` options are deprecated. Move them to `scales.x` and `scales.y`. When neither Cartesian scale is used, set `scales` to `{ x: null, y: null }`. This compatibility will be removed when TanStack Charts enters Alpha.', + ) + } + } + + const scales = definition.scales ?? { + x: definition.x, + y: definition.y, + } + + if (!Object.hasOwn(scales, 'x') || !Object.hasOwn(scales, 'y')) { + throw new TypeError('Chart scales must define reserved `x` and `y` entries') + } + + for (const scaleId of collected.keys()) { + if (!Object.hasOwn(scales, scaleId)) { + throw new TypeError( + `Chart scale "${scaleId}" is used by a mark but is not configured`, + ) + } + } + + return Object.entries(scales).map(([id, options]) => { + if (id === 'color') { + throw new TypeError('Position scales cannot use reserved ID "color"') + } + const channels = collected.get(id) ?? { + values: [], + includeZero: false, + materialized: false, + } + const reservedChannel = id === 'x' || id === 'y' ? id : undefined + const configuredChannel = options?.channel + if (!reservedChannel && options !== null && !configuredChannel) { + throw new TypeError( + `Named chart scale "${id}" requires channel: "x" or channel: "y"`, + ) + } + const channel = + reservedChannel ?? configuredChannel ?? channels.channel ?? 'x' + if ( + (configuredChannel && configuredChannel !== channel) || + (channels.channel && channels.channel !== channel) + ) { + throw new TypeError( + `Chart scale "${id}" is configured for ${channel} but is used as ${channels.channel ?? configuredChannel}`, + ) + } + const side = options?.side ?? (channel === 'x' ? 'bottom' : 'left') + if ( + (channel === 'x' && side !== 'top' && side !== 'bottom') || + (channel === 'y' && side !== 'left' && side !== 'right') + ) { + throw new TypeError( + `Chart scale "${id}" uses ${channel} and cannot render an axis on the ${side} side`, + ) + } + return { id, channel, side, options, channels } + }) +} + interface CollectedScaleChannels { values: unknown[] includeZero: boolean materialized: boolean } +interface CollectedPositionScaleChannels extends CollectedScaleChannels { + channel?: ChartPositionChannel +} + +interface PositionScaleDefinition { + id: string + channel: ChartPositionChannel + side: ChartAxisSide + options: ChartPositionScaleOptions | null | undefined + channels: CollectedPositionScaleChannels +} + +interface ResolvedPositionScale extends PositionScaleDefinition { + scale: ChartScene['scales'][string] +} + interface ResolvedSceneLayout { margin: ChartMargin chart: ChartBounds scales: ChartScene['scales'] axes: SceneGroup + positionScales: readonly ResolvedPositionScale[] + scaleGuides: readonly ResolvedPositionScale[] + gridScales: readonly ResolvedPositionScale[] guideMargin: ChartMargin marks: readonly ResolvedSceneMark[] colors: ResolvedColorScale @@ -777,14 +1009,14 @@ function resolveSceneLayout( width: number, height: number, theme: ChartTheme, - xChannels: CollectedScaleChannels, - yChannels: CollectedScaleChannels, - axes: number, + scaleDefinitions: readonly PositionScaleDefinition[], resolveScale: ChartScaleResolver, layout: ChartLayoutOptions, ): ResolvedSceneLayout { const locks = resolveMarginLocks(definition.margin) - const inset = axes ? automaticGuideInset : 0 + const hasGuides = + definition.guides !== false && scaleDefinitions.some(hasScaleGuide) + const inset = hasGuides ? automaticGuideInset : 0 let margin = mergeMarginLocks(uniformMargin(inset), locks) let safeMargin = margin @@ -814,31 +1046,35 @@ function resolveSceneLayout( width: Math.max(1, width - margin.left - margin.right), height: Math.max(1, height - margin.top - margin.bottom), } - const xTickCount = resolveTickCount(definition.x, chart.width, 92, 8) - const yTickCount = resolveTickCount(definition.y, chart.height, 48, 7) - const scales = { - x: - definition.x == null - ? createUnusedScale('x', xChannels.materialized, definition.x) - : resolveScale({ - id: 'x', - values: xChannels.values, - range: [chart.x, chart.x + chart.width], - options: definition.x, - tickCount: xTickCount, - includeZero: xChannels.includeZero, - }), - y: - definition.y == null - ? createUnusedScale('y', yChannels.materialized, definition.y) + const scales: Record = {} + const resolvedScales: ResolvedPositionScale[] = [] + for (const scaleDefinition of scaleDefinitions) { + const { id, channel, options, channels } = scaleDefinition + const length = channel === 'x' ? chart.width : chart.height + const tickCount = resolveTickCount( + options, + length, + channel === 'x' ? 92 : 48, + channel === 'x' ? 8 : 7, + ) + const range: readonly [number, number] = + channel === 'x' + ? [chart.x, chart.x + chart.width] + : [chart.y + chart.height, chart.y] + const scale = + options == null + ? createUnusedScale(id, channels.materialized, options) : resolveScale({ - id: 'y', - values: yChannels.values, - range: [chart.y + chart.height, chart.y], - options: definition.y, - tickCount: yTickCount, - includeZero: yChannels.includeZero, - }), + id, + channel, + values: channels.values, + range, + options, + tickCount, + includeZero: channels.includeZero, + }) + scales[id] = scale + resolvedScales.push({ ...scaleDefinition, scale }) } const marks = resolveMarkLayouts(initialized, { chart, @@ -883,13 +1119,15 @@ function resolveSceneLayout( height: legendHeight, } : undefined + const scaleGuides = + definition.guides === false ? [] : resolvedScales.filter(hasScaleGuide) + const gridScales = + definition.guides === false ? [] : resolvedScales.filter(hasScaleGrid) const resolvedAxes = createAxes( chart, - scales, - definition, + scaleGuides, theme, width, - axes, layout.measureText, ) return { @@ -897,6 +1135,9 @@ function resolveSceneLayout( chart, scales, axes: resolvedAxes.axes, + positionScales: resolvedScales, + scaleGuides, + gridScales, guideMargin: resolvedAxes.margin, marks, colors, @@ -933,8 +1174,7 @@ function resolveSceneLayout( if (!definition.clip) { resolved.marks.forEach((mark, markIndex) => { const autoClipped = Boolean( - (resolved.scales.x.viewport && markUsesViewportAxis(mark, 'x')) || - (resolved.scales.y.viewport && markUsesViewportAxis(mark, 'y')), + markUsesAnyViewport(mark, resolved.positionScales), ) if (autoClipped) return const labels = mark.layoutLabels?.({ @@ -961,6 +1201,18 @@ function resolveSceneLayout( } } +function hasScaleGuide( + scale: PositionScaleDefinition | ResolvedPositionScale, +): boolean { + return scale.options != null && scale.options.axis !== false +} + +function hasScaleGrid( + scale: PositionScaleDefinition | ResolvedPositionScale, +): boolean { + return scale.options != null && scale.options.grid === true +} + function resolveMarkLayouts( marks: readonly InitializedMark[], context: Omit, @@ -993,6 +1245,15 @@ function includeLabelMargin( ) { const bounds = measureSceneLabelBounds(label, measureText) if (!label.text) return bounds + includeBoundsMargin(margin, chart, bounds) + return bounds +} + +function includeBoundsMargin( + margin: ChartMargin, + chart: ChartBounds, + bounds: ChartBounds, +) { margin.top = Math.max(margin.top, chart.y - bounds.y + automaticGuideInset) margin.right = Math.max( margin.right, @@ -1003,7 +1264,6 @@ function includeLabelMargin( bounds.y + bounds.height - chart.y - chart.height + automaticGuideInset, ) margin.left = Math.max(margin.left, chart.x - bounds.x + automaticGuideInset) - return bounds } function resolveMarginLocks( @@ -1076,35 +1336,34 @@ function createUnusedScale( function createGrid( chart: ChartBounds, - scales: ChartScene['scales'], - definition: StaticChartDefinition, + guides: readonly ResolvedPositionScale[], theme: ChartTheme, ): SceneGroup { const children: SceneNode[] = [] - if (definition.y?.grid) { - for (const tick of scales.y.ticks) { - children.push({ - kind: 'rule', - key: `y-grid:${valueKey(tick.value)}`, - x1: chart.x, - x2: chart.x + chart.width, - y1: tick.position, - y2: tick.position, - }) - } - } - - if (definition.x?.grid) { - for (const tick of scales.x.ticks) { - children.push({ - kind: 'rule', - key: `x-grid:${valueKey(tick.value)}`, - x1: tick.position, - x2: tick.position, - y1: chart.y, - y2: chart.y + chart.height, - }) + for (const guide of guides) { + if (!guide.options?.grid) continue + for (const tick of guide.scale.ticks) { + const key = `${guide.id}-grid:${valueKey(tick.value)}` + children.push( + guide.channel === 'x' + ? { + kind: 'rule', + key, + x1: tick.position, + x2: tick.position, + y1: chart.y, + y2: chart.y + chart.height, + } + : { + kind: 'rule', + key, + x1: chart.x, + x2: chart.x + chart.width, + y1: tick.position, + y2: tick.position, + }, + ) } } @@ -1124,228 +1383,287 @@ function createGrid( function createAxes( chart: ChartBounds, - scales: ChartScene['scales'], - definition: StaticChartDefinition, + guides: readonly ResolvedPositionScale[], theme: ChartTheme, width: number, - axes: number, measureText?: ChartTextMeasurer, ): ResolvedAxes { - const showX = axes & 1 - const showY = axes & 2 - const xAxis = axisPresentation(definition.x) - const yAxis = axisPresentation(definition.y) - const children: SceneNode[] = - !showX || xAxis?.line === false - ? [] - : [ - { - kind: 'rule', - key: 'x-axis', - x1: chart.x, - x2: chart.x + chart.width, - y1: chart.y + chart.height, - y2: chart.y + chart.height, - style: { - stroke: theme.foreground, - strokeOpacity: 0.28, - }, - }, - ] - if (showY && yAxis?.line !== false) { - children.push({ - kind: 'rule', - key: 'y-axis', - x1: chart.x, - x2: chart.x, - y1: chart.y, - y2: chart.y + chart.height, - style: { - stroke: theme.foreground, - strokeOpacity: 0.28, - }, - }) - } - const xTickLabels = tickLabelPresentation(xAxis) - const yTickLabels = tickLabelPresentation(yAxis) - let xTickBottom = chart.y + chart.height - let yTickLeft = chart.x - const inset = axes ? automaticGuideInset : 0 + const children: SceneNode[] = [] + const inset = guides.length ? automaticGuideInset : 0 const margin = uniformMargin(inset) + const offsets: Record = { + top: 0, + right: 0, + bottom: 0, + left: 0, + } + const chartRight = chart.x + chart.width + const chartBottom = chart.y + chart.height + + for (const guide of guides) { + const offset = offsets[guide.side] + margin[guide.side] = Math.max( + margin[guide.side], + offset + automaticGuideInset, + ) + const axisPosition = + guide.side === 'top' + ? chart.y - offset + : guide.side === 'right' + ? chartRight + offset + : guide.side === 'bottom' + ? chartBottom + offset + : chart.x - offset + let outward = axisPosition + const includeOutward = (bounds: ChartBounds) => { + includeBoundsMargin(margin, chart, bounds) + if (guide.side === 'top') outward = Math.min(outward, bounds.y) + else if (guide.side === 'right') { + outward = Math.max(outward, bounds.x + bounds.width) + } else if (guide.side === 'bottom') { + outward = Math.max(outward, bounds.y + bounds.height) + } else outward = Math.min(outward, bounds.x) + } + const includeCoordinate = (coordinate: number) => { + if (guide.side === 'top' || guide.side === 'left') { + outward = Math.min(outward, coordinate) + } else { + outward = Math.max(outward, coordinate) + } + } - const addLabel = (label: SceneLabel) => - includeLabelMargin(margin, chart, label, measureText) - - const xTicks = xAxis?.ticks === false ? [] : scales.x.ticks - const yTicks = yAxis?.ticks === false ? [] : scales.y.ticks - const xTickSize = finiteMargin( - xAxis?.ticks === false ? 0 : (xAxis?.ticks?.size ?? 4), - ) - const yTickSize = finiteMargin( - yAxis?.ticks === false ? 0 : (yAxis?.ticks?.size ?? 4), - ) - const xTickPadding = finiteMargin( - xAxis?.ticks === false ? 0 : (xAxis?.ticks?.padding ?? 4), - ) - const yTickPadding = finiteMargin( - yAxis?.ticks === false ? 0 : (yAxis?.ticks?.padding ?? 4), - ) - const xLabelCandidates = - xTickLabels === false - ? [] - : createTickLabelCandidates( - 'x', - withKeptTicks(scales.x, definition.x, xTickLabels), - chart, - xTickSize, - xTickPadding, - xTickLabels, - scales.x.bandwidth, - width, - theme, - measureText, - ) - const yLabelCandidates = - yTickLabels === false - ? [] - : createTickLabelCandidates( - 'y', - withKeptTicks(scales.y, definition.y, yTickLabels), - chart, - yTickSize, - yTickPadding, - yTickLabels, - scales.y.bandwidth, - width, - theme, - measureText, - ) - const visibleXLabels = - xTickLabels === false - ? [] - : thinTickLabels(xLabelCandidates, xTickLabels, scales.x.type === 'band') - const visibleYLabels = - yTickLabels === false - ? [] - : thinTickLabels(yLabelCandidates, yTickLabels, false) - - for (const tick of showX ? xTicks : []) { - const key = valueKey(tick.value) - if (xTickSize > 0) { - children.push({ - kind: 'rule', - key: `x-tick-rule:${key}`, - x1: tick.position, - x2: tick.position, - y1: chart.y + chart.height, - y2: chart.y + chart.height + xTickSize, - style: { - stroke: theme.foreground, - strokeOpacity: 0.28, - }, - }) + if (guide.channel === 'x') { + renderXAxis(guide, axisPosition, includeOutward, includeCoordinate) + } else { + renderYAxis(guide, axisPosition, includeOutward, includeCoordinate) } + + const distance = + guide.side === 'top' + ? chart.y - outward + : guide.side === 'right' + ? outward - chartRight + : guide.side === 'bottom' + ? outward - chartBottom + : chart.x - outward + offsets[guide.side] = Math.max(offset, distance) + 8 } - for (const candidate of showX ? visibleXLabels : []) { - const bounds = addLabel(candidate.label) - if (axisLabelText(xAxis) && axisLabelOffset(xAxis) === 'auto') { - xTickBottom = Math.max(xTickBottom, bounds.y + bounds.height) - } - children.push(candidate.label) + return { + axes: { + kind: 'group', + key: 'axes', + className: 'ts-chart__axes', + ariaHidden: true, + children, + }, + margin, } - for (const tick of showY ? yTicks : []) { - const key = valueKey(tick.value) - if (yTickSize > 0) { + function renderXAxis( + guide: ResolvedPositionScale, + axisY: number, + includeOutward: (bounds: ChartBounds) => void, + includeCoordinate: (coordinate: number) => void, + ) { + const presentation = axisPresentation(guide.options) + const bottom = guide.side === 'bottom' + const direction = bottom ? 1 : -1 + if (presentation?.line !== false) { children.push({ kind: 'rule', - key: `y-tick-rule:${key}`, - x1: chart.x - yTickSize, - x2: chart.x, - y1: tick.position, - y2: tick.position, - style: { - stroke: theme.foreground, - strokeOpacity: 0.28, - }, + key: `${guide.id}-axis`, + x1: chart.x, + x2: chartRight, + y1: axisY, + y2: axisY, + style: axisStyle(), }) } - } - - for (const candidate of showY ? visibleYLabels : []) { - const bounds = addLabel(candidate.label) - if (axisLabelText(yAxis) && axisLabelOffset(yAxis) === 'auto') { - yTickLeft = Math.min(yTickLeft, bounds.x) + const ticks = presentation?.ticks === false ? [] : guide.scale.ticks + const tickSize = finiteMargin( + presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), + ) + const tickPadding = finiteMargin( + presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), + ) + const tickLabels = tickLabelPresentation(presentation) + const candidates = + tickLabels === false + ? [] + : createTickLabelCandidates( + guide, + withKeptTicks(guide.scale, guide.options, tickLabels), + axisY, + tickSize, + tickPadding, + tickLabels, + width, + theme, + measureText, + ) + const visibleLabels = + tickLabels === false + ? [] + : thinTickLabels(candidates, tickLabels, guide.scale.type === 'band') + let tickOuter = axisY + + for (const tick of ticks) { + if (tickSize <= 0) continue + const tickEnd = axisY + direction * tickSize + includeCoordinate(tickEnd) + tickOuter = bottom + ? Math.max(tickOuter, tickEnd) + : Math.min(tickOuter, tickEnd) + children.push({ + kind: 'rule', + key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, + x1: tick.position, + x2: tick.position, + y1: axisY, + y2: tickEnd, + style: axisStyle(), + }) + } + for (const candidate of visibleLabels) { + includeOutward(candidate.bounds) + tickOuter = bottom + ? Math.max(tickOuter, candidate.bounds.y + candidate.bounds.height) + : Math.min(tickOuter, candidate.bounds.y) + children.push(candidate.label) } - children.push(candidate.label) - } - const xAxisLabel = axisLabelText(xAxis) - if (showX && xAxisLabel) { - const offset = axisLabelOffset(xAxis) - const hasOffset = offset !== 'auto' + const labelText = axisLabelText(presentation) + if (!labelText) return + const labelOffset = axisLabelOffset(presentation) + const explicitOffset = labelOffset !== 'auto' const label: SceneLabel = { kind: 'label', - key: 'x-label', + key: `${guide.id}-label`, x: chart.x + chart.width / 2, - y: hasOffset - ? chart.y + chart.height + Math.max(0, finiteMargin(offset)) - : xTickBottom + 8, - text: xAxisLabel, + y: explicitOffset + ? axisY + direction * Math.max(0, finiteMargin(labelOffset)) + : tickOuter + direction * 8, + text: labelText, anchor: 'middle', - baseline: hasOffset ? 'auto' : 'hanging', + baseline: bottom && !explicitOffset ? 'hanging' : 'auto', fontSize: width < 360 ? 10 : 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, } - addLabel(label) + includeOutward(measureSceneLabelBounds(label, measureText)) children.push(label) } - const yAxisLabel = axisLabelText(yAxis) - if (showY && yAxisLabel) { - const yLabel: SceneLabel = { + function renderYAxis( + guide: ResolvedPositionScale, + axisX: number, + includeOutward: (bounds: ChartBounds) => void, + includeCoordinate: (coordinate: number) => void, + ) { + const presentation = axisPresentation(guide.options) + const right = guide.side === 'right' + const direction = right ? 1 : -1 + if (presentation?.line !== false) { + children.push({ + kind: 'rule', + key: `${guide.id}-axis`, + x1: axisX, + x2: axisX, + y1: chart.y, + y2: chartBottom, + style: axisStyle(), + }) + } + const ticks = presentation?.ticks === false ? [] : guide.scale.ticks + const tickSize = finiteMargin( + presentation?.ticks === false ? 0 : (presentation?.ticks?.size ?? 4), + ) + const tickPadding = finiteMargin( + presentation?.ticks === false ? 0 : (presentation?.ticks?.padding ?? 4), + ) + const tickLabels = tickLabelPresentation(presentation) + const candidates = + tickLabels === false + ? [] + : createTickLabelCandidates( + guide, + withKeptTicks(guide.scale, guide.options, tickLabels), + axisX, + tickSize, + tickPadding, + tickLabels, + width, + theme, + measureText, + ) + const visibleLabels = + tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false) + let tickOuter = axisX + + for (const tick of ticks) { + if (tickSize <= 0) continue + const tickEnd = axisX + direction * tickSize + includeCoordinate(tickEnd) + tickOuter = right + ? Math.max(tickOuter, tickEnd) + : Math.min(tickOuter, tickEnd) + children.push({ + kind: 'rule', + key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, + x1: axisX, + x2: tickEnd, + y1: tick.position, + y2: tick.position, + style: axisStyle(), + }) + } + for (const candidate of visibleLabels) { + includeOutward(candidate.bounds) + tickOuter = right + ? Math.max(tickOuter, candidate.bounds.x + candidate.bounds.width) + : Math.min(tickOuter, candidate.bounds.x) + children.push(candidate.label) + } + + const labelText = axisLabelText(presentation) + if (!labelText) return + const label: SceneLabel = { kind: 'label', - key: 'y-label', - x: chart.x, + key: `${guide.id}-label`, + x: axisX, y: chart.y + chart.height / 2, - text: yAxisLabel, + text: labelText, anchor: 'middle', baseline: 'middle', - rotate: -90, + rotate: right ? 90 : -90, fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, } - const offset = axisLabelOffset(yAxis) - if (offset !== 'auto') { - yLabel.x = chart.x - Math.max(0, finiteMargin(offset)) + const labelOffset = axisLabelOffset(presentation) + if (labelOffset !== 'auto') { + label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)) } else { const localBounds = measureSceneLabelBounds( - { ...yLabel, x: 0, y: 0 }, + { ...label, x: 0, y: 0 }, measureText, ) - yLabel.x = yTickLeft - 8 - (localBounds.x + localBounds.width) + label.x = right + ? tickOuter + 8 - localBounds.x + : tickOuter - 8 - (localBounds.x + localBounds.width) } - addLabel(yLabel) - children.push(yLabel) + includeOutward(measureSceneLabelBounds(label, measureText)) + children.push(label) } - return { - axes: { - kind: 'group', - key: 'axes', - className: 'ts-chart__axes', - ariaHidden: true, - children, - }, - margin, + function axisStyle() { + return { stroke: theme.foreground, strokeOpacity: 0.28 } } } function resolveTickCount( - axis: ChartAxisOptions | null | undefined, + axis: ChartPositionScaleOptions | null | undefined, length: number, defaultSpacing: number, maximum: number, @@ -1377,7 +1695,7 @@ function resolveTickCount( } function axisPresentation( - axis: ChartAxisOptions | null | undefined, + axis: ChartPositionScaleOptions | null | undefined, ): ChartAxisPresentationOptions | undefined { if (!axis || axis.axis === false) return undefined return axis.axis ?? {} @@ -1413,7 +1731,7 @@ interface TickLabelCandidate { function withKeptTicks( scale: ChartScene['scales'][string], - axis: ChartAxisOptions | null | undefined, + axis: ChartPositionScaleOptions | null | undefined, labels: ChartAxisTickLabelOptions, ): readonly (ChartTick & { hard?: boolean })[] { const thin = typeof labels.thin === 'object' ? labels.thin : undefined @@ -1442,24 +1760,25 @@ function withKeptTicks( } function createTickLabelCandidates( - axis: 'x' | 'y', + guide: ResolvedPositionScale, ticks: readonly (ChartTick & { hard?: boolean })[], - chart: ChartBounds, + axisPosition: number, size: number, padding: number, options: ChartAxisTickLabelOptions, - bandwidth: number, width: number, theme: ChartTheme, measureText: ChartTextMeasurer | undefined, ): TickLabelCandidate[] { const defaultFontSize = width < 360 ? 10 : 11 + const positiveSide = guide.side === 'bottom' || guide.side === 'right' + const direction = positiveSide ? 1 : -1 return ticks.map((tick, index) => { const context: ChartAxisTickLabelContext = { value: tick.value, index, position: tick.position, - bandwidth, + bandwidth: guide.scale.bandwidth, } const rotate = options.rotate const fontSize = @@ -1469,8 +1788,10 @@ function createTickLabelCandidates( const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 const defaultAnchor = - axis === 'y' - ? 'end' + guide.channel === 'y' + ? positiveSide + ? 'start' + : 'end' : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 @@ -1479,12 +1800,13 @@ function createTickLabelCandidates( const anchor = resolveTickLabelValue(options.anchor, context) ?? defaultAnchor const label: SceneLabel = - axis === 'x' + guide.channel === 'x' ? { kind: 'label', - key: `x-tick-label:${valueKey(tick.value)}`, + key: `${guide.id}-tick-label:${valueKey(tick.value)}`, x: tick.position + dx, - y: chart.y + chart.height + size + padding + fontSize * 0.8 + dy, + y: + axisPosition + direction * (size + padding + fontSize * 0.8) + dy, text: tick.label, anchor, rotate, @@ -1497,8 +1819,8 @@ function createTickLabelCandidates( } : { kind: 'label', - key: `y-tick-label:${valueKey(tick.value)}`, - x: chart.x - size - padding + dx, + key: `${guide.id}-tick-label:${valueKey(tick.value)}`, + x: axisPosition + direction * (size + padding) + dx, y: tick.position + dy, text: tick.label, anchor, diff --git a/packages/charts-core/src/selection.ts b/packages/charts-core/src/selection.ts index cb1a7ed5..58a8e77e 100644 --- a/packages/charts-core/src/selection.ts +++ b/packages/charts-core/src/selection.ts @@ -130,16 +130,28 @@ export function whenSelected< TYPointValue extends ChartValue, TXScaleValue extends ChartValue, TYScaleValue extends ChartValue, + TXScaleId extends string, + TYScaleId extends string, >( mark: ChartMark< TDatum, TXPointValue, TYPointValue, TXScaleValue, - TYScaleValue + TYScaleValue, + TXScaleId, + TYScaleId >, selection: KeyedSelection, -): ChartMark { +): ChartMark< + TDatum, + never, + never, + TXScaleValue, + TYScaleValue, + TXScaleId, + TYScaleId +> { const filter = ( scene: MarkScene, ): MarkScene => diff --git a/packages/charts-core/src/spatial-contour.ts b/packages/charts-core/src/spatial-contour.ts index ca017574..31df4305 100644 --- a/packages/charts-core/src/spatial-contour.ts +++ b/packages/charts-core/src/spatial-contour.ts @@ -128,68 +128,72 @@ export function contour( (datum) => datum.value, ) - return createMark(({ markIndex }) => { - const id = options.id ?? `contour-${markIndex}` - return { - id, - channels: { - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ chart, color: resolveColor }) => ({ - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__area ts-chart__contour', - ariaHidden: true, - translateX: chart.x, - translateY: chart.y, - clip: { x: 0, y: 0, width: chart.width, height: chart.height }, - children: prepared.map(({ datum, geometry, identity }, index) => { - const colorValue = colorValues[index] - const fallback = resolveColor( - isChartKey(colorValue) ? colorValue : null, - ) - return { - kind: 'area', - key: JSON.stringify([id, identity]), - points: [], - polygons: mapContourPolygons(geometry.coordinates, (x, y) => [ - (x / width) * chart.width, - chart.height - (y / height) * chart.height, - ]), - style: { - fill: visualValue( - options.fill, - datum, - index, - derivedData, - fallback, - ), - fillOpacity: options.fillOpacity, - stroke: - options.stroke === undefined - ? undefined - : visualValue( - options.stroke, - datum, - index, - derivedData, - fallback, - ), - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth, - strokeDasharray: options.strokeDasharray, - opacity: options.opacity, - }, - } satisfies SceneNode - }), + return createMark( + ({ markIndex }) => { + const id = options.id ?? `contour-${markIndex}` + return { + id, + channels: { + color: { + scale: 'color', + values: colorValues.filter(isChartKey), }, - ], - }), - } - }, options.motion) + }, + render: ({ chart, color: resolveColor }) => ({ + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__area ts-chart__contour', + ariaHidden: true, + translateX: chart.x, + translateY: chart.y, + clip: { x: 0, y: 0, width: chart.width, height: chart.height }, + children: prepared.map(({ datum, geometry, identity }, index) => { + const colorValue = colorValues[index] + const fallback = resolveColor( + isChartKey(colorValue) ? colorValue : null, + ) + return { + kind: 'area', + key: JSON.stringify([id, identity]), + points: [], + polygons: mapContourPolygons(geometry.coordinates, (x, y) => [ + (x / width) * chart.width, + chart.height - (y / height) * chart.height, + ]), + style: { + fill: visualValue( + options.fill, + datum, + index, + derivedData, + fallback, + ), + fillOpacity: options.fillOpacity, + stroke: + options.stroke === undefined + ? undefined + : visualValue( + options.stroke, + datum, + index, + derivedData, + fallback, + ), + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth, + strokeDasharray: options.strokeDasharray, + opacity: options.opacity, + }, + } satisfies SceneNode + }), + }, + ], + }), + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/spatial-delaunay.ts b/packages/charts-core/src/spatial-delaunay.ts index c134ffd1..a6d527ea 100644 --- a/packages/charts-core/src/spatial-delaunay.ts +++ b/packages/charts-core/src/spatial-delaunay.ts @@ -23,6 +23,7 @@ import { materializeLayoutXYRows } from './resolved-layout-position' import type { Channel, ChannelOutput, + CartesianChartMark, ChartKey, ChartMark, ChartValue, @@ -91,80 +92,103 @@ export function delaunayLink< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< DelaunayLinkDatum< TDatum, DelaunayXOutput, DelaunayYOutput >, DelaunayXOutput, - DelaunayYOutput + DelaunayYOutput, + DelaunayXOutput, + DelaunayYOutput, + TOptions > export function delaunayLink( source: Iterable, options: DelaunayLinkOptions>, -): ChartMark> { +): CartesianChartMark< + DelaunayLinkDatum, + any, + any, + any, + any, + DelaunayLinkOptions +> { const data = Array.isArray(source) ? source : Array.from(source) const xValues = channelValues(data, options.x, () => undefined) const yValues = channelValues(data, options.y, () => undefined) const zValues = channelValues(data, options.z, () => null) const completeRows = materializeLayoutXYRows(data, xValues, yValues) - const { x: _x, y: _y, z: _z, key: _key, motion, ...presentation } = options + const { + x: _x, + y: _y, + z: _z, + key: _key, + motion, + renderer, + ...presentation + } = options + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark>(({ markIndex }) => { - const id = options.id ?? `delaunay-link-${markIndex}` - const groups = data.map((_datum, index) => { - const group = zValues[index] - return isChartKey(group) ? group : null - }) - const keys = inferredKeyValues(data, options.key, { - groups, - markId: id, - warningIdentity: options, - }) - const sourceRows: readonly PreparedDelaunayRow[] = completeRows.map( - (row) => ({ - ...row, - group: groups[row.sourceIndex] ?? null, - key: keys[row.sourceIndex] ?? row.sourceIndex, - }), - ) + return createMark>( + ({ markIndex }) => { + const id = options.id ?? `delaunay-link-${markIndex}` + const groups = data.map((_datum, index) => { + const group = zValues[index] + return isChartKey(group) ? group : null + }) + const keys = inferredKeyValues(data, options.key, { + groups, + markId: id, + warningIdentity: options, + }) + const sourceRows: readonly PreparedDelaunayRow[] = + completeRows.map((row) => ({ + ...row, + group: groups[row.sourceIndex] ?? null, + key: keys[row.sourceIndex] ?? row.sourceIndex, + })) - return { - id, - channels: { - x: { scale: 'x', values: completeRows.map((row) => row.xValue) }, - y: { scale: 'y', values: completeRows.map((row) => row.yValue) }, - }, - resolveLayout: ({ scales }) => { - const xScale = scales.x - const yScale = scales.y - if (!xScale || !yScale) { - throw new TypeError('delaunayLink: x and y scales are required') - } - const rows = projectLayoutY( - projectLayoutX(sourceRows, xValues, xScale), - yValues, - yScale, - ) - const edges = groupRowsByChartKey(rows) - .flatMap(({ rows: groupRows }) => createEdges(groupRows)) - .sort((left, right) => compareText(left.edgeKey, right.edgeKey)) - const child = link(edges, { - ...presentation, - id, - x1: 'x1', - y1: 'y1', - x2: 'x2', - y2: 'y2', - z: 'group', - key: 'edgeKey', - }) + return { + id, + channels: { + x: { scale: xScale, values: completeRows.map((row) => row.xValue) }, + y: { scale: yScale, values: completeRows.map((row) => row.yValue) }, + }, + resolveLayout: ({ scales }) => { + const resolvedXScale = scales[xScale] + const resolvedYScale = scales[yScale] + if (!resolvedXScale || !resolvedYScale) { + throw new TypeError('delaunayLink: x and y scales are required') + } + const rows = projectLayoutY( + projectLayoutX(sourceRows, xValues, resolvedXScale), + yValues, + resolvedYScale, + ) + const edges = groupRowsByChartKey(rows) + .flatMap(({ rows: groupRows }) => createEdges(groupRows)) + .sort((left, right) => compareText(left.edgeKey, right.edgeKey)) + const child = link(edges, { + ...presentation, + id, + x1: 'x1', + y1: 'y1', + x2: 'x2', + y2: 'y2', + z: 'group', + key: 'edgeKey', + }) - return adoptResolvedChildMark(child.initialize({ markIndex })) - }, - } - }, motion) + return adoptResolvedChildMark(child.initialize({ markIndex })) + }, + } + }, + motion, + renderer, + ) } function createEdges( diff --git a/packages/charts-core/src/spatial-density.ts b/packages/charts-core/src/spatial-density.ts index e22add5a..cb21dc0b 100644 --- a/packages/charts-core/src/spatial-density.ts +++ b/packages/charts-core/src/spatial-density.ts @@ -32,6 +32,8 @@ import type { import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -48,9 +50,8 @@ export interface DensityContourDatum extends TransformLineage { readonly group: ChartKey | null } -export interface DensityContourOptions< - TDatum, -> extends ChartMarkMotionOptions { +export interface DensityContourOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: Channel y: Channel @@ -119,17 +120,25 @@ export function densityContour< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< never, never, never, DensityXOutput, - DensityYOutput + DensityYOutput, + TOptions > export function densityContour( source: Iterable, options: DensityContourOptions>, -): ChartMark { +): CartesianChartMark< + never, + never, + never, + any, + any, + DensityContourOptions +> { const data = Array.isArray(source) ? source : Array.from(source) const bandwidth = options.bandwidth ?? 20 const cellSize = options.cellSize ?? 4 @@ -138,6 +147,8 @@ export function densityContour( 20, 'densityContour', ) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' if (!isFiniteNumber(bandwidth) || bandwidth < 0) { throw new TypeError( 'densityContour: bandwidth must be a nonnegative finite number', @@ -167,101 +178,110 @@ export function densityContour( ] }) - return createMark(({ markIndex }) => { - const id = options.id ?? `density-contour-${markIndex}` - return { - id, - channels: { - x: { scale: 'x', values: sourceRows.map((row) => row.xValue) }, - y: { scale: 'y', values: sourceRows.map((row) => row.yValue) }, - }, - resolveLayout: ({ chart, scales }) => { - const xScale = scales.x - const yScale = scales.y - if (!xScale || !yScale) { - throw new TypeError('densityContour: x and y scales are required') - } - const rows = projectLayoutY( - projectLayoutX(sourceRows, xValues, xScale), - yValues, - yScale, - ) - const groups = groupRowsByChartKey(rows) - const contourFunctions = groups.map(({ rows: groupRows }) => ({ - rows: groupRows, - contour: createDensityEstimator( - chart.width, - chart.height, - chart.x, - chart.y, - bandwidth, - cellSize, - ).contours(groupRows), - })) - const levels = - typeof thresholds === 'number' - ? sharedThresholds( - contourFunctions.map(({ contour }) => contour.max), - thresholds, - ) - : thresholds - const identifiedLevels = identifyContourLevels( - levels, - typeof thresholds === 'number' - ? { kind: 'generated', count: thresholds } - : { kind: 'explicit' }, - ) - const contours = contourFunctions.flatMap( - ({ rows: groupRows, contour }) => - materializeContours(data, groupRows, contour, identifiedLevels), - ) - const derivedData = contours.map(({ datum }) => datum) - const colorValues = channelValues( - derivedData, - options.color, - (datum) => datum.group, - ) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `density-contour-${markIndex}` + return { + id, + channels: { + x: { scale: xScale, values: sourceRows.map((row) => row.xValue) }, + y: { scale: yScale, values: sourceRows.map((row) => row.yValue) }, + }, + resolveLayout: ({ chart, scales }) => { + const resolvedXScale = scales[xScale] + const resolvedYScale = scales[yScale] + if (!resolvedXScale || !resolvedYScale) { + throw new TypeError('densityContour: x and y scales are required') + } + const rows = projectLayoutY( + projectLayoutX(sourceRows, xValues, resolvedXScale), + yValues, + resolvedYScale, + ) + const groups = groupRowsByChartKey(rows) + const contourFunctions = groups.map(({ rows: groupRows }) => ({ + rows: groupRows, + contour: createDensityEstimator( + chart.width, + chart.height, + chart.x, + chart.y, + bandwidth, + cellSize, + ).contours(groupRows), + })) + const levels = + typeof thresholds === 'number' + ? sharedThresholds( + contourFunctions.map(({ contour }) => contour.max), + thresholds, + ) + : thresholds + const identifiedLevels = identifyContourLevels( + levels, + typeof thresholds === 'number' + ? { kind: 'generated', count: thresholds } + : { kind: 'explicit' }, + ) + const contours = contourFunctions.flatMap( + ({ rows: groupRows, contour }) => + materializeContours(data, groupRows, contour, identifiedLevels), + ) + const derivedData = contours.map(({ datum }) => datum) + const colorValues = channelValues( + derivedData, + options.color, + (datum) => datum.group, + ) - return { - channels: { - x: { scale: 'x', values: sourceRows.map((row) => row.xValue) }, - y: { scale: 'y', values: sourceRows.map((row) => row.yValue) }, - color: { - scale: 'color', - values: - options.color === 'density' - ? [0, ...colorValues.filter(isChartKey)] - : colorValues.filter(isChartKey), + return { + channels: { + x: { scale: xScale, values: sourceRows.map((row) => row.xValue) }, + y: { scale: yScale, values: sourceRows.map((row) => row.yValue) }, + color: { + scale: 'color', + values: + options.color === 'density' + ? [0, ...colorValues.filter(isChartKey)] + : colorValues.filter(isChartKey), + }, }, - }, - render: ({ color: resolveColor }) => ({ - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__area ts-chart__density-contour', - ariaHidden: true, - translateX: chart.x, - translateY: chart.y, - clip: { x: 0, y: 0, width: chart.width, height: chart.height }, - children: contours.map((contour, index) => - contourNode( - id, - contour, - index, - derivedData, - colorValues, - resolveColor, - options, + render: ({ color: resolveColor }) => ({ + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__area ts-chart__density-contour', + ariaHidden: true, + translateX: chart.x, + translateY: chart.y, + clip: { + x: 0, + y: 0, + width: chart.width, + height: chart.height, + }, + children: contours.map((contour, index) => + contourNode( + id, + contour, + index, + derivedData, + colorValues, + resolveColor, + options, + ), ), - ), - }, - ], - }), - } - }, - } - }, options.motion) + }, + ], + }), + } + }, + } + }, + options.motion, + options.renderer, + ) } function createDensityEstimator( diff --git a/packages/charts-core/src/spatial-hexbin.ts b/packages/charts-core/src/spatial-hexbin.ts index dbe7a800..2afc9c1b 100644 --- a/packages/charts-core/src/spatial-hexbin.ts +++ b/packages/charts-core/src/spatial-hexbin.ts @@ -56,10 +56,23 @@ interface HexbinSourceRow extends LayoutSourceRow { export function hexbin< TDatum, const TOutputs extends TransformOutputs = DefaultHexbinOutputs, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: HexbinOptions, -): ChartMark, number, number> { + options: HexbinOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, +): ChartMark< + HexbinDatum, + number, + number, + number, + number, + TXScaleId, + TYScaleId +> { const data = toArray(source) const binWidth = options.binWidth ?? 20 if (!Number.isFinite(binWidth) || binWidth <= 0) { @@ -93,32 +106,40 @@ export function hexbin< ...presentation } = options const layoutRadius = binWidth / Math.sqrt(3) + const xScale = (options.xScale ?? 'x') as TXScaleId + const yScale = (options.yScale ?? 'y') as TYScaleId - return createMark, number, number>( + return createMark< + HexbinDatum, + number, + number, + TXScaleId, + TYScaleId + >( ({ markIndex }) => { const id = options.id ?? `hexbin-${markIndex}` return { id, channels: { x: { - scale: 'x', + scale: xScale, values: sourceRows.map((row) => row.xValue), }, y: { - scale: 'y', + scale: yScale, values: sourceRows.map((row) => row.yValue), }, }, resolveLayout: ({ chart, scales }) => { - const xScale = scales.x - const yScale = scales.y - if (!xScale?.invert || !yScale?.invert) { + const resolvedXScale = scales[xScale] + const resolvedYScale = scales[yScale] + if (!resolvedXScale?.invert || !resolvedYScale?.invert) { throw new TypeError('hexbin: x and y scales must support inversion') } const rows = projectLayoutY( - projectLayoutX(sourceRows, xValues, xScale), + projectLayoutX(sourceRows, xValues, resolvedXScale), yValues, - yScale, + resolvedYScale, ) const layout = createHexbinLayout>() .x((row) => row.x) @@ -129,8 +150,8 @@ export function hexbin< [chart.x + chart.width, chart.y + chart.height], ]) const bins = layout([...rows]).map((bin) => { - const x = xScale.invert!(bin.x) - const y = yScale.invert!(bin.y) + const x = resolvedXScale.invert!(bin.x) + const y = resolvedYScale.invert!(bin.y) if (!isFiniteNumber(x) || !isFiniteNumber(y)) { throw new TypeError( 'hexbin: x and y scales must invert to finite numbers', @@ -164,6 +185,7 @@ export function hexbin< } }, options.motion, + options.renderer, ) } diff --git a/packages/charts-core/src/spatial-voronoi.ts b/packages/charts-core/src/spatial-voronoi.ts index 6515c79b..7e71561c 100644 --- a/packages/charts-core/src/spatial-voronoi.ts +++ b/packages/charts-core/src/spatial-voronoi.ts @@ -22,6 +22,8 @@ import type { import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -30,7 +32,8 @@ import type { VisualChannel, } from './types' -export interface VoronoiOptions extends ChartMarkMotionOptions { +export interface VoronoiOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: Channel y: Channel @@ -79,90 +82,97 @@ export function voronoi< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< never, never, never, VoronoiXOutput, - VoronoiYOutput + VoronoiYOutput, + TOptions > export function voronoi( source: Iterable, options: VoronoiOptions>, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) const xValues = channelValues(data, options.x, () => undefined) const yValues = channelValues(data, options.y, () => undefined) const zValues = channelValues(data, options.z, () => null) const colorValues = channelValues(data, options.color, () => null) const completeRows = materializeLayoutXYRows(data, xValues, yValues) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `voronoi-${markIndex}` - const groups = data.map((_datum, index) => { - const group = zValues[index] - return isChartKey(group) ? group : null - }) - const keys = inferredKeyValues(data, options.key, { - groups, - markId: id, - warningIdentity: options, - }) - const sourceRows: readonly PreparedVoronoiRow[] = completeRows.map( - (row) => ({ - ...row, - group: groups[row.sourceIndex] ?? null, - key: keys[row.sourceIndex] ?? row.sourceIndex, - }), - ) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `voronoi-${markIndex}` + const groups = data.map((_datum, index) => { + const group = zValues[index] + return isChartKey(group) ? group : null + }) + const keys = inferredKeyValues(data, options.key, { + groups, + markId: id, + warningIdentity: options, + }) + const sourceRows: readonly PreparedVoronoiRow[] = + completeRows.map((row) => ({ + ...row, + group: groups[row.sourceIndex] ?? null, + key: keys[row.sourceIndex] ?? row.sourceIndex, + })) - return { - id, - channels: { - x: { scale: 'x', values: completeRows.map((row) => row.xValue) }, - y: { scale: 'y', values: completeRows.map((row) => row.yValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + channels: { + x: { scale: xScale, values: completeRows.map((row) => row.xValue) }, + y: { scale: yScale, values: completeRows.map((row) => row.yValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ chart, scales, color: resolveColor }) => { - const xScale = scales.x - const yScale = scales.y - if (!xScale || !yScale) { - throw new TypeError('voronoi: x and y scales are required') - } - const rows = projectLayoutY( - projectLayoutX(sourceRows, xValues, xScale), - yValues, - yScale, - ) - const nodes = groupRowsByChartKey(rows).flatMap(({ rows: groupRows }) => - createCellNodes( - id, - data, - canonicalDelaunayPoints(groupRows), - chart, - colorValues, - resolveColor, - options, - ), - ) + render: ({ chart, scales, color: resolveColor }) => { + const resolvedXScale = scales[xScale] + const resolvedYScale = scales[yScale] + if (!resolvedXScale || !resolvedYScale) { + throw new TypeError('voronoi: x and y scales are required') + } + const rows = projectLayoutY( + projectLayoutX(sourceRows, xValues, resolvedXScale), + yValues, + resolvedYScale, + ) + const nodes = groupRowsByChartKey(rows).flatMap( + ({ rows: groupRows }) => + createCellNodes( + id, + data, + canonicalDelaunayPoints(groupRows), + chart, + colorValues, + resolveColor, + options, + ), + ) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__voronoi', - ariaHidden: true, - children: nodes, - }, - ], - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__voronoi', + ariaHidden: true, + children: nodes, + }, + ], + } + }, + } + }, + options.motion, + options.renderer, + ) } function createCellNodes( diff --git a/packages/charts-core/src/text.ts b/packages/charts-core/src/text.ts index fce4f750..8d44e3f0 100644 --- a/packages/charts-core/src/text.ts +++ b/packages/charts-core/src/text.ts @@ -11,6 +11,8 @@ import { import { valueKey } from './scales' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -27,7 +29,8 @@ import type { export type TextAnchor = 'start' | 'middle' | 'end' -export interface TextOptions extends ChartMarkMotionOptions { +export interface TextOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x?: Channel y?: Channel @@ -54,139 +57,156 @@ export function text< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionChannelOutput, + OptionChannelOutput, + TOptions > export function text( source: Iterable, options: TextOptions> = {}, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `text-${markIndex}` - const xValues = channelValues(data, options.x, (_datum, { index }) => index) - const yValues = channelValues(data, options.y, (datum) => - typeof datum === 'number' ? datum : undefined, - ) - const textValues = channelValues(data, options.text, (datum) => - datum == null ? '' : String(datum), - ) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups: zValues, - candidates: [xValues, yValues, compositeKeyValues(xValues, yValues)], - markId: id, - warningIdentity: options, - }) - const anchors = data.map((datum, datumIndex) => - visualValue(options.anchor, datum, datumIndex, data, 'middle'), - ) - const dxValues = data.map((datum, datumIndex) => - visualValue(options.dx, datum, datumIndex, data, 0), - ) - const dyValues = data.map((datum, datumIndex) => - visualValue(options.dy, datum, datumIndex, data, 0), - ) - const rotateValues = - options.rotate === undefined - ? undefined - : data.map((datum, datumIndex) => - visualValue(options.rotate, datum, datumIndex, data, 0), - ) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `text-${markIndex}` + const xValues = channelValues( + data, + options.x, + (_datum, { index }) => index, + ) + const yValues = channelValues(data, options.y, (datum) => + typeof datum === 'number' ? datum : undefined, + ) + const textValues = channelValues(data, options.text, (datum) => + datum == null ? '' : String(datum), + ) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups: zValues, + candidates: [xValues, yValues, compositeKeyValues(xValues, yValues)], + markId: id, + warningIdentity: options, + }) + const anchors = data.map((datum, datumIndex) => + visualValue(options.anchor, datum, datumIndex, data, 'middle'), + ) + const dxValues = data.map((datum, datumIndex) => + visualValue(options.dx, datum, datumIndex, data, 0), + ) + const dyValues = data.map((datum, datumIndex) => + visualValue(options.dy, datum, datumIndex, data, 0), + ) + const rotateValues = + options.rotate === undefined + ? undefined + : data.map((datum, datumIndex) => + visualValue(options.rotate, datum, datumIndex, data, 0), + ) - const resolveLabel = (context: MarkRenderContext, datumIndex: number) => { - const xValue = xValues[datumIndex] - const yValue = yValues[datumIndex] - const textValue = textValues[datumIndex] - if (!isChartValue(xValue) || !isChartValue(yValue) || textValue == null) { - return undefined - } - const group = zValues[datumIndex] ?? null - const colorValue = colorValues[datumIndex] ?? null - const node: SceneLabel = { - kind: 'label', - key: `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`, - x: context.scales.x.map(xValue) + (dxValues[datumIndex] ?? 0), - y: context.scales.y.map(yValue) + (dyValues[datumIndex] ?? 0), - text: String(textValue), - anchor: anchors[datumIndex], - baseline: 'middle', - rotate: rotateValues?.[datumIndex], - fontSize: options.fontSize, - fontWeight: options.fontWeight, + const resolveLabel = (context: MarkRenderContext, datumIndex: number) => { + const xValue = xValues[datumIndex] + const yValue = yValues[datumIndex] + const textValue = textValues[datumIndex] + if ( + !isChartValue(xValue) || + !isChartValue(yValue) || + textValue == null + ) { + return undefined + } + const group = zValues[datumIndex] ?? null + const colorValue = colorValues[datumIndex] ?? null + const node: SceneLabel = { + kind: 'label', + key: `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`, + x: context.scales[xScale]!.map(xValue) + (dxValues[datumIndex] ?? 0), + y: context.scales[yScale]!.map(yValue) + (dyValues[datumIndex] ?? 0), + text: String(textValue), + anchor: anchors[datumIndex], + baseline: 'middle', + rotate: rotateValues?.[datumIndex], + fontSize: options.fontSize, + fontWeight: options.fontWeight, + } + return { node, xValue, yValue, group, colorValue } } - return { node, xValue, yValue, group, colorValue } - } - return { - id, - states: markStates(data, options.states), - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + states: markStates(data, options.states), + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - layoutLabels: (context) => - data.flatMap((_datum, datumIndex) => { - const resolved = resolveLabel(context, datumIndex) - return resolved ? [resolved.node] : [] - }), - render: (context) => { - const { theme, color: resolveColor } = context - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - data.forEach((datum, datumIndex) => { - const resolved = resolveLabel(context, datumIndex) - if (!resolved) return - const { node, xValue, yValue, group, colorValue } = resolved - const color = visualValue( - options.fill, - datum, - datumIndex, - data, - colorValue == null ? theme.foreground : resolveColor(colorValue), - ) - nodes.push({ - ...node, - style: { fill: color }, + layoutLabels: (context) => + data.flatMap((_datum, datumIndex) => { + const resolved = resolveLabel(context, datumIndex) + return resolved ? [resolved.node] : [] + }), + render: (context) => { + const { theme, color: resolveColor } = context + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + data.forEach((datum, datumIndex) => { + const resolved = resolveLabel(context, datumIndex) + if (!resolved) return + const { node, xValue, yValue, group, colorValue } = resolved + const color = visualValue( + options.fill, + datum, + datumIndex, + data, + colorValue == null ? theme.foreground : resolveColor(colorValue), + ) + nodes.push({ + ...node, + style: { fill: color }, + }) + points.push({ + key: node.key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + x: node.x, + y: node.y, + color, + }) }) - points.push({ - key: node.key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - x: node.x, - y: node.y, - color, - }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__text', - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__text', + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/tick.ts b/packages/charts-core/src/tick.ts index 246d6a01..14dd74ba 100644 --- a/packages/charts-core/src/tick.ts +++ b/packages/charts-core/src/tick.ts @@ -14,6 +14,8 @@ import { import { valueKey } from './scales' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -24,7 +26,8 @@ import type { VisualChannel, } from './types' -export interface TickXOptions extends ChartMarkMotionOptions { +export interface TickXOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: Channel y: Channel @@ -40,7 +43,8 @@ export interface TickXOptions extends ChartMarkMotionOptions { inset?: number } -export interface TickYOptions extends ChartMarkMotionOptions { +export interface TickYOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: Channel y: Channel @@ -62,15 +66,18 @@ export function tickX< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionChannelOutput, + OptionChannelOutput, + TOptions > export function tickX( source: Iterable, options: TickXOptions>, -): ChartMark { +): CartesianChartMark> { return tick(source, options, 'x') } @@ -80,15 +87,18 @@ export function tickY< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionChannelOutput, + OptionChannelOutput, + TOptions > export function tickY( source: Iterable, options: TickYOptions>, -): ChartMark { +): CartesianChartMark> { return tick(source, options, 'y') } @@ -96,8 +106,17 @@ function tick( source: Iterable, options: TickXOptions> | TickYOptions>, orientation: 'x' | 'y', -): ChartMark { +): CartesianChartMark< + TDatum, + any, + any, + any, + any, + TickXOptions | TickYOptions +> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' if (options.length !== undefined && options.span !== undefined) { throw new TypeError('tick: length and span are mutually exclusive') } @@ -108,112 +127,117 @@ function tick( throw new TypeError('tick: span must be a positive finite number') } - return createMark(({ markIndex }) => { - const id = options.id ?? `tick-${orientation}-${markIndex}` - const xValues = channelValues(data, options.x, () => undefined) - const yValues = channelValues(data, options.y, () => undefined) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups: zValues }) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `tick-${orientation}-${markIndex}` + const xValues = channelValues(data, options.x, () => undefined) + const yValues = channelValues(data, options.y, () => undefined) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups: zValues }) - return { - id, - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), + return { + id, + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }, - render: ({ chart, scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - const orthogonalScale = orientation === 'x' ? scales.y : scales.x - const bandwidth = orthogonalScale.bandwidth - if ( - options.span !== undefined && - !isResolvedCategoryScale(orthogonalScale) - ) { - throw new TypeError( - `tick${orientation.toUpperCase()}: span requires a point or band scale on the orthogonal axis`, + render: ({ chart, scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + const orthogonalScale = + orientation === 'x' ? scales[yScale]! : scales[xScale]! + const bandwidth = orthogonalScale.bandwidth + if ( + options.span !== undefined && + !isResolvedCategoryScale(orthogonalScale) + ) { + throw new TypeError( + `tick${orientation.toUpperCase()}: span requires a point or band scale on the orthogonal axis`, + ) + } + const spanLength = + options.span === undefined + ? undefined + : resolvedCategoryStep( + orthogonalScale, + orientation === 'x' ? chart.height : chart.width, + options.span, + ) * options.span + const availableLength = Math.max( + 0, + (spanLength ?? options.length ?? (bandwidth || 6)) - + (options.inset ?? 0) * 2, ) - } - const spanLength = - options.span === undefined - ? undefined - : resolvedCategoryStep( - orthogonalScale, - orientation === 'x' ? chart.height : chart.width, - options.span, - ) * options.span - const availableLength = Math.max( - 0, - (spanLength ?? options.length ?? (bandwidth || 6)) - - (options.inset ?? 0) * 2, - ) - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const yValue = yValues[datumIndex] - if (!isChartValue(xValue) || !isChartValue(yValue)) return + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const yValue = yValues[datumIndex] + if (!isChartValue(xValue) || !isChartValue(yValue)) return - const x = scales.x.map(xValue) - const y = scales.y.map(yValue) - const group = zValues[datumIndex] ?? null - const color = visualValue( - options.stroke, - datum, - datumIndex, - data, - resolveColor(colorValues[datumIndex] ?? null), - ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - nodes.push({ - kind: 'rule', - key, - x1: orientation === 'x' ? x : x - availableLength / 2, - x2: orientation === 'x' ? x : x + availableLength / 2, - y1: orientation === 'x' ? y - availableLength / 2 : y, - y2: orientation === 'x' ? y + availableLength / 2 : y, - style: { - stroke: color, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - lineCap: 'butt', - }, - }) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - x, - y, - color, + const x = scales[xScale]!.map(xValue) + const y = scales[yScale]!.map(yValue) + const group = zValues[datumIndex] ?? null + const color = visualValue( + options.stroke, + datum, + datumIndex, + data, + resolveColor(colorValues[datumIndex] ?? null), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + nodes.push({ + kind: 'rule', + key, + x1: orientation === 'x' ? x : x - availableLength / 2, + x2: orientation === 'x' ? x : x + availableLength / 2, + y1: orientation === 'x' ? y - availableLength / 2 : y, + y2: orientation === 'x' ? y + availableLength / 2 : y, + style: { + stroke: color, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + lineCap: 'butt', + }, + }) + points.push({ + key, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + x, + y, + color, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: `ts-chart__tick ts-chart__tick-${orientation}`, - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: `ts-chart__tick ts-chart__tick-${orientation}`, + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/type-contract.test.ts b/packages/charts-core/src/type-contract.test.ts index ce83feae..e2f8c0c0 100644 --- a/packages/charts-core/src/type-contract.test.ts +++ b/packages/charts-core/src/type-contract.test.ts @@ -1046,9 +1046,12 @@ describe('public type contracts', () => { type InferredDatum = NonNullable expectTypeOf().toEqualTypeOf() - expectTypeOf(optionalOptionsMark).toMatchTypeOf< - ChartMark - >() + expectTypeOf< + ChartMarkPointX + >().toEqualTypeOf() + expectTypeOf< + ChartMarkPointY + >().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() expectTypeOf>().toEqualTypeOf() diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index c9db3040..3344f344 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -11,9 +11,10 @@ export interface ChartCurve { export interface ChartScaleResolveContext { id: string + channel: ChartPositionChannel values: readonly unknown[] range: readonly [number, number] - options: ChartAxisOptions | undefined + options: ChartPositionScaleOptions | undefined tickCount: number includeZero: boolean } @@ -402,6 +403,25 @@ export interface ChartAxisOptions { axis?: false | ChartAxisPresentationOptions } +export type ChartPositionChannel = 'x' | 'y' +export type ChartAxisSide = 'top' | 'right' | 'bottom' | 'left' + +export interface ChartPositionScaleOptions< + TValue extends ChartValue = any, +> extends ChartAxisOptions { + /** Required for named scales other than the reserved `x` and `y` defaults. */ + channel?: ChartPositionChannel + /** Defaults to `bottom` for x scales and `left` for y scales. */ + side?: ChartAxisSide +} + +export interface CartesianScaleBindings { + /** Named x scale. Omit to use the reserved `x` scale. */ + xScale?: string + /** Named y scale. Omit to use the reserved `y` scale. */ + yScale?: string +} + export interface ChartColorOptions { /** * A D3 color-scale factory infers its domain from color channels. @@ -553,34 +573,62 @@ export interface ChartLinearGradient { /** A scale-contributing mark that does not own interactive chart points. */ export type DecorativeChartMark< - TMark extends ChartMark, + TMark extends ChartMark, > = TMark & { readonly __decorativeMark: TMark } interface DecorativeChartMarkBrand { - readonly __decorativeMark: ChartMark + readonly __decorativeMark: ChartMark } export type ChartMarkScaleX = TMark extends { readonly __decorativeMark: infer TSource } ? ChartMarkScaleX - : TMark extends ChartMark - ? TValue + : TMark extends ChartMark< + any, + any, + any, + infer TValue, + any, + infer TScaleId, + any + > + ? 'x' extends TScaleId + ? TValue + : never : never export type ChartMarkScaleY = TMark extends { readonly __decorativeMark: infer TSource } ? ChartMarkScaleY - : TMark extends ChartMark - ? TValue + : TMark extends ChartMark< + any, + any, + any, + any, + infer TValue, + any, + infer TScaleId + > + ? 'y' extends TScaleId + ? TValue + : never : never export type ChartMarkPointX = TMark extends DecorativeChartMarkBrand ? never - : TMark extends ChartMark + : TMark extends ChartMark< + infer TDatum, + infer TXValue, + any, + any, + any, + any, + any + > ? [TDatum] extends [never] ? never : TXValue @@ -588,7 +636,15 @@ export type ChartMarkPointX = TMark extends DecorativeChartMarkBrand export type ChartMarkPointY = TMark extends DecorativeChartMarkBrand ? never - : TMark extends ChartMark + : TMark extends ChartMark< + infer TDatum, + any, + infer TYValue, + any, + any, + any, + any + > ? [TDatum] extends [never] ? never : TYValue @@ -611,7 +667,7 @@ export type ChartAxisValue = ? any : WidenChartValue -type AnyChartMarks = readonly ChartMark[] +type AnyChartMarks = readonly ChartMark[] type IsUnion = TValue extends TWhole ? [TWhole] extends [TValue] @@ -621,16 +677,20 @@ type IsUnion = TValue extends TWhole type ChartXOptionsForMarks = IsUnion extends false - ? ChartAxisOptions>> + ? ChartPositionScaleOptions>> : TMarks extends AnyChartMarks - ? ChartAxisOptions>> + ? ChartPositionScaleOptions< + ChartAxisValue> + > : never type ChartYOptionsForMarks = IsUnion extends false - ? ChartAxisOptions>> + ? ChartPositionScaleOptions>> : TMarks extends AnyChartMarks - ? ChartAxisOptions>> + ? ChartPositionScaleOptions< + ChartAxisValue> + > : never interface ChartSpecBase { @@ -681,6 +741,8 @@ export interface ChartMotionContext { datum: TDatum | undefined point: ChartPoint | undefined axis?: 'x' | 'y' + /** Positional scale that owns an axis or grid element. */ + scaleId?: string } export interface ChartMotionTweenTransition { @@ -724,14 +786,21 @@ export type ChartMotionDefinition = context: ChartMotionContext, ) => false | ChartMotionTiming | undefined) -export interface ChartMarkMotionOptions { - motion?: ChartMotionDefinition +/** Renderer capability a mark can select without importing a DOM contract. */ +export interface ChartMarkRenderer { + readonly kind: 'chart-layer-renderer' + readonly id: string } -interface StoredChartSpec extends ChartSpecBase { - marks: readonly ChartMark[] - x?: ChartAxisOptions | null - y?: ChartAxisOptions | null +export interface ChartMarkOptions { + /** Paints this mark through a renderer that can compose with the chart host. */ + renderer?: ChartMarkRenderer +} + +export interface ChartMarkMotionOptions< + TDatum = unknown, +> extends ChartMarkOptions { + motion?: ChartMotionDefinition } type ChartXSpec = @@ -748,11 +817,38 @@ type ChartYSpec = ? { y?: null } : { y: ChartYOptionsForMarks } +export type ChartScales = + Readonly> & { + x: ChartXOptionsForMarks | null + y: ChartYOptionsForMarks | null + } + +interface CanonicalChartScaleSpec { + scales: ChartScales + /** @deprecated Move this value to `scales.x`. */ + x?: ChartXOptionsForMarks | null + /** @deprecated Move this value to `scales.y`. */ + y?: ChartYOptionsForMarks | null +} + +type LegacyChartScaleSpec = { + scales?: undefined +} & ChartXSpec & + ChartYSpec + type ChartSpecForMarks = { marks: TMarks } & ChartSpecBase & - ChartXSpec & - ChartYSpec + (CanonicalChartScaleSpec | LegacyChartScaleSpec) + +interface StoredChartSpec extends ChartSpecBase { + marks: AnyChartMarks + scales?: Readonly> + /** @deprecated Move this value to `scales.x`. */ + x?: ChartXOptionsForMarks | null + /** @deprecated Move this value to `scales.y`. */ + y?: ChartYOptionsForMarks | null +} export type ChartSpec = [ TMarks, @@ -835,7 +931,7 @@ export interface StaticChartDefinition< TTooltipHost extends string = string, > extends StoredChartSpec, StoredChartDefinitionOptions { - marks: readonly ChartMark[] + marks: AnyChartMarks readonly __datum?: TDatum readonly __xValue?: TXValue readonly __yValue?: TYValue @@ -913,7 +1009,7 @@ export type DomChartDefinition< export type ChartMarkDatum = TMark extends DecorativeChartMarkBrand ? never - : TMark extends ChartMark + : TMark extends ChartMark ? TDatum : never @@ -1018,17 +1114,53 @@ export interface ChartMark< TYPointValue extends ChartValue = ChartValue, TXScaleValue extends ChartValue = TXPointValue, TYScaleValue extends ChartValue = TYPointValue, + TXScaleId extends string = 'x', + TYScaleId extends string = 'y', > { initialize: ( context: MarkInitializeContext, ) => InitializedMark motion?: ChartMotionDefinition + renderer?: ChartMarkRenderer readonly __xValue?: TXPointValue readonly __yValue?: TYPointValue readonly __xScaleValue?: TXScaleValue readonly __yScaleValue?: TYScaleValue + readonly __xScaleId?: TXScaleId + readonly __yScaleId?: TYScaleId } +export type OptionScaleId< + TOptions, + TKey extends PropertyKey, + TFallback extends string, +> = TOptions extends unknown + ? TKey extends keyof TOptions + ? [NonNullable] extends [never] + ? TFallback + : NonNullable extends string + ? NonNullable + : TFallback + : TFallback + : never + +export type CartesianChartMark< + TDatum, + TXPointValue extends ChartValue, + TYPointValue extends ChartValue, + TXScaleValue extends ChartValue, + TYScaleValue extends ChartValue, + TOptions, +> = ChartMark< + TDatum, + TXPointValue, + TYPointValue, + TXScaleValue, + TYScaleValue, + OptionScaleId, + OptionScaleId +> + interface InitializedMarkBase< TDatum = unknown, TXValue extends ChartValue = ChartValue, @@ -1240,6 +1372,8 @@ export interface SceneFocusGuide { chart: ChartBounds surface: ChartBounds placement: 'under' | 'over' + /** Renderer selected by the guide's owning mark. */ + renderer?: ChartMarkRenderer x?: SceneFocusGuideAxis y?: SceneFocusGuideAxis marker?: SceneFocusGuideMarker @@ -1262,6 +1396,8 @@ export interface ChartFocusPresentation { interface SceneNodeBase { key: string + /** Renderer selected by the mark that owns this scene subtree. */ + renderer?: ChartMarkRenderer className?: string style?: SceneStyle ariaHidden?: boolean diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index b4a1e9fa..7daadd63 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -175,6 +175,9 @@ export type { MaterializedChannel, ResolvedLayoutMarkInitialization, ResolvedMarkLayout, + CartesianChartMark, + CartesianScaleBindings, + ChartAxisSide, ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, @@ -244,6 +247,8 @@ export type { ChartSelectionController, ChartSelectionSource, ChartMark, + ChartMarkOptions, + ChartMarkRenderer, ChartMarkMotionOptions, ChartMarkDatum, ChartMarkState, @@ -271,6 +276,8 @@ export type { ChartMotionTransition, ChartMotionTweenTransition, ChartPoint, + ChartPositionChannel, + ChartPositionScaleOptions, ChartRuntime, ChartRuntimeOptions, ChartScene, @@ -282,6 +289,7 @@ export type { ConfiguredScaleLike, InferableScaleLike, OptionChannelOutput, + OptionScaleId, ChartSize, ChartSpatialIndex, ChartSpatialIndexFactory, @@ -290,6 +298,7 @@ export type { ChartSpecXValue, ChartSpecYValue, ChartSpec, + ChartScales, ChartSvgRenderer, ChartTheme, ChartTextMeasurer, diff --git a/packages/charts-core/src/vector.ts b/packages/charts-core/src/vector.ts index 52ada010..310b11ca 100644 --- a/packages/charts-core/src/vector.ts +++ b/packages/charts-core/src/vector.ts @@ -11,6 +11,8 @@ import { import { valueKey } from './scales' import type { Channel, + CartesianChartMark, + CartesianScaleBindings, ChartKey, ChartMark, ChartMarkMotionOptions, @@ -23,7 +25,8 @@ import type { export type VectorAnchor = 'start' | 'middle' | 'end' -export interface VectorOptions extends ChartMarkMotionOptions { +export interface VectorOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string x: Channel y: Channel @@ -50,135 +53,144 @@ export function vector< >( source: Iterable, options: TOptions, -): ChartMark< +): CartesianChartMark< TDatum, OptionChannelOutput, - OptionChannelOutput + OptionChannelOutput, + OptionChannelOutput, + OptionChannelOutput, + TOptions > export function vector( source: Iterable, options: VectorOptions>, -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' - return createMark(({ markIndex }) => { - const id = options.id ?? `vector-${markIndex}` - const xValues = channelValues(data, options.x, () => undefined) - const yValues = channelValues(data, options.y, () => undefined) - const lengthOption = options.length - const lengthValues = - typeof lengthOption === 'number' - ? data.map(() => lengthOption) - : channelValues(data, lengthOption, () => 12) - const rotateOption = options.rotate - const rotateValues = - typeof rotateOption === 'number' - ? data.map(() => rotateOption) - : channelValues(data, rotateOption, () => 0) - const zValues = channelValues(data, options.z, () => null) - const colorValues = - options.color === undefined - ? zValues - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { groups: zValues }) + return createMark( + ({ markIndex }) => { + const id = options.id ?? `vector-${markIndex}` + const xValues = channelValues(data, options.x, () => undefined) + const yValues = channelValues(data, options.y, () => undefined) + const lengthOption = options.length + const lengthValues = + typeof lengthOption === 'number' + ? data.map(() => lengthOption) + : channelValues(data, lengthOption, () => 12) + const rotateOption = options.rotate + const rotateValues = + typeof rotateOption === 'number' + ? data.map(() => rotateOption) + : channelValues(data, rotateOption, () => 0) + const zValues = channelValues(data, options.z, () => null) + const colorValues = + options.color === undefined + ? zValues + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { groups: zValues }) - return { - id, - channels: { - x: { scale: 'x', values: xValues.filter(isChartValue) }, - y: { scale: 'y', values: yValues.filter(isChartValue) }, - color: { scale: 'color', values: colorValues.filter(isChartKey) }, - }, - render: ({ scales, color: resolveColor }) => { - const nodes: SceneNode[] = [] - const points: ChartPoint[] = [] - const anchor = options.anchor ?? 'middle' - const headLength = Math.max(0, options.headLength ?? 5) - const headAngle = ((options.headAngle ?? 30) * Math.PI) / 180 + return { + id, + channels: { + x: { scale: xScale, values: xValues.filter(isChartValue) }, + y: { scale: yScale, values: yValues.filter(isChartValue) }, + color: { scale: 'color', values: colorValues.filter(isChartKey) }, + }, + render: ({ scales, color: resolveColor }) => { + const nodes: SceneNode[] = [] + const points: ChartPoint[] = [] + const anchor = options.anchor ?? 'middle' + const headLength = Math.max(0, options.headLength ?? 5) + const headAngle = ((options.headAngle ?? 30) * Math.PI) / 180 - data.forEach((datum, datumIndex) => { - const xValue = xValues[datumIndex] - const yValue = yValues[datumIndex] - const length = lengthValues[datumIndex] - const rotate = rotateValues[datumIndex] - if ( - !isChartValue(xValue) || - !isChartValue(yValue) || - !isFiniteNumber(length) || - !isFiniteNumber(rotate) - ) { - return - } + data.forEach((datum, datumIndex) => { + const xValue = xValues[datumIndex] + const yValue = yValues[datumIndex] + const length = lengthValues[datumIndex] + const rotate = rotateValues[datumIndex] + if ( + !isChartValue(xValue) || + !isChartValue(yValue) || + !isFiniteNumber(length) || + !isFiniteNumber(rotate) + ) { + return + } - const x = scales.x.map(xValue) - const y = scales.y.map(yValue) - const radians = (rotate * Math.PI) / 180 - const dx = Math.sin(radians) * length - const dy = -Math.cos(radians) * length - const [x1, y1, x2, y2] = - anchor === 'start' - ? [x, y, x + dx, y + dy] - : anchor === 'end' - ? [x - dx, y - dy, x, y] - : [x - dx / 2, y - dy / 2, x + dx / 2, y + dy / 2] - const group = zValues[datumIndex] ?? null - const color = visualValue( - options.stroke, - datum, - datumIndex, - data, - resolveColor(colorValues[datumIndex] ?? null), - ) - const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` - const style = { - stroke: color, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - lineCap: 'round' as const, - lineJoin: 'round' as const, - } + const x = scales[xScale]!.map(xValue) + const y = scales[yScale]!.map(yValue) + const radians = (rotate * Math.PI) / 180 + const dx = Math.sin(radians) * length + const dy = -Math.cos(radians) * length + const [x1, y1, x2, y2] = + anchor === 'start' + ? [x, y, x + dx, y + dy] + : anchor === 'end' + ? [x - dx, y - dy, x, y] + : [x - dx / 2, y - dy / 2, x + dx / 2, y + dy / 2] + const group = zValues[datumIndex] ?? null + const color = visualValue( + options.stroke, + datum, + datumIndex, + data, + resolveColor(colorValues[datumIndex] ?? null), + ) + const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}` + const style = { + stroke: color, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + lineCap: 'round' as const, + lineJoin: 'round' as const, + } - nodes.push( - arrowGeometry({ + nodes.push( + arrowGeometry({ + key, + x1, + y1, + x2, + y2, + headLength, + headAngle, + style, + className: 'ts-chart__vector-item', + }), + ) + points.push({ key, - x1, - y1, - x2, - y2, - headLength, - headAngle, - style, - className: 'ts-chart__vector-item', - }), - ) - points.push({ - key, - markId: id, - group, - groupLabel: group == null ? id : String(group), - datum, - datumIndex, - xValue, - yValue, - x, - y, - color, + markId: id, + group, + groupLabel: group == null ? id : String(group), + datum, + datumIndex, + xValue, + yValue, + x, + y, + color, + }) }) - }) - return { - nodes: [ - { - kind: 'group', - key: id, - className: 'ts-chart__vector', - ariaHidden: true, - children: nodes, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: 'ts-chart__vector', + ariaHidden: true, + children: nodes, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } diff --git a/packages/charts-core/src/violin.ts b/packages/charts-core/src/violin.ts index 21266b3c..55c27fb5 100644 --- a/packages/charts-core/src/violin.ts +++ b/packages/charts-core/src/violin.ts @@ -17,6 +17,8 @@ import type { AreaXCurve } from './area-x' import type { Channel, ChannelOutput, + CartesianChartMark, + CartesianScaleBindings, ChartAreaStateStyle, ChartCurve, ChartKey, @@ -32,7 +34,8 @@ export type ViolinPosition = number | Date export type ViolinYCurve = AreaXCurve export type ViolinXCurve = Pick -interface ViolinOptions extends ChartMarkMotionOptions { +interface ViolinOptions + extends ChartMarkMotionOptions, CartesianScaleBindings { id?: string /** Normalized half-envelope width in the inclusive range [0, 1]. */ width: Channel @@ -120,18 +123,27 @@ export function violinY< NoInfer, ViolinPosition | null | undefined >, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: ViolinYCallOptions, + options: ViolinYCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, CategoryOutput, - PositionOutput + PositionOutput, + CategoryOutput, + PositionOutput, + TXScaleId, + TYScaleId > export function violinY( source: Iterable, options: ViolinYOptions>, -): ChartMark { +): CartesianChartMark> { return violin(source, options, options.y, options.x, options.curve, 'y') } @@ -143,18 +155,27 @@ export function violinX< ViolinPosition | null | undefined >, const TYChannel extends Channel, ChartKey | null | undefined>, + const TXScaleId extends string = 'x', + const TYScaleId extends string = 'y', >( source: Iterable, - options: ViolinXCallOptions, + options: ViolinXCallOptions & { + xScale?: TXScaleId + yScale?: TYScaleId + }, ): ChartMark< TDatum, PositionOutput, - CategoryOutput + CategoryOutput, + PositionOutput, + CategoryOutput, + TXScaleId, + TYScaleId > export function violinX( source: Iterable, options: ViolinXOptions>, -): ChartMark { +): CartesianChartMark> { return violin(source, options, options.x, options.y, options.curve, 'x') } @@ -165,214 +186,222 @@ function violin( category: Channel, curve: ViolinXCurve | ViolinYCurve | undefined, orientation: 'x' | 'y', -): ChartMark { +): CartesianChartMark> { const data = Array.isArray(source) ? source : Array.from(source) + const xScale = options.xScale ?? 'x' + const yScale = options.yScale ?? 'y' const span = options.span ?? 0.8 if (!isFiniteNumber(span) || span <= 0) { throw new TypeError('violin: span must be a positive finite number') } - return createMark(({ markIndex }) => { - const id = options.id ?? `violin-${orientation}-${markIndex}` - const positionValues = channelValues(data, position, () => undefined) - const categoryValues = channelValues(data, category, () => undefined) - const widths = channelValues(data, options.width, () => undefined) - widths.forEach((width, index) => { - if (!isFiniteNumber(width)) return - if (width < 0 || width > 1) { - throw new TypeError( - `violin: width must be between 0 and 1; received ${width} at index ${index}`, - ) - } - }) - const categoryKeys = categoryValues.map((value) => - isChartKey(value) ? value : null, - ) - const colorValues = - options.color === undefined - ? categoryKeys - : channelValues(data, options.color, () => null) - const keys = inferredKeyValues(data, options.key, { - groups: categoryKeys, - candidates: [positionValues], - markId: id, - warningIdentity: options, - }) - - return { - id, - states: markStates(data, options.states), - channels: - orientation === 'y' - ? { - x: { - scale: 'x', - values: categoryValues.filter(isChartKey), - }, - y: { - scale: 'y', - values: positionValues.filter(isViolinPosition), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - } - : { - x: { - scale: 'x', - values: positionValues.filter(isViolinPosition), - }, - y: { - scale: 'y', - values: categoryValues.filter(isChartKey), - }, - color: { - scale: 'color', - values: colorValues.filter(isChartKey), - }, - }, - render: ({ chart, scales, color: resolveColor }) => { - const categoryScale = orientation === 'y' ? scales.x : scales.y - if (!isResolvedCategoryScale(categoryScale)) { - throw new TypeError( - `violin${orientation.toUpperCase()}: the category axis requires a band or point scale`, - ) - } - const positionScale = orientation === 'y' ? scales.y : scales.x - if (!positionScale) { + return createMark( + ({ markIndex }) => { + const id = options.id ?? `violin-${orientation}-${markIndex}` + const positionValues = channelValues(data, position, () => undefined) + const categoryValues = channelValues(data, category, () => undefined) + const widths = channelValues(data, options.width, () => undefined) + widths.forEach((width, index) => { + if (!isFiniteNumber(width)) return + if (width < 0 || width > 1) { throw new TypeError( - `violin${orientation.toUpperCase()}: the profile axis scale is required`, + `violin: width must be between 0 and 1; received ${width} at index ${index}`, ) } - const plotSpan = orientation === 'y' ? chart.width : chart.height - const step = resolvedCategoryStep(categoryScale, plotSpan, span) - const areas: SceneArea[] = [] - const points: ChartPoint[] = [] + }) + const categoryKeys = categoryValues.map((value) => + isChartKey(value) ? value : null, + ) + const colorValues = + options.color === undefined + ? categoryKeys + : channelValues(data, options.color, () => null) + const keys = inferredKeyValues(data, options.key, { + groups: categoryKeys, + candidates: [positionValues], + markId: id, + warningIdentity: options, + }) - for (const { key: group, indexes } of groupedIndexes(categoryKeys)) { - const firstIndex = indexes.find((index) => - isChartKey(categoryValues[index]), - ) - if (firstIndex === undefined) continue - const categoryValue = categoryValues[firstIndex]! - const baseline = categoryScale.map(categoryValue) - if (!Number.isFinite(baseline)) continue - const datum = data[firstIndex]! - const fallback = resolveColor(colorValues[firstIndex] ?? null) - const fill = visualValue( - options.fill, - datum, - firstIndex, - data, - fallback, - ) - const stroke = - options.stroke === null - ? undefined - : visualValue(options.stroke, datum, firstIndex, data, fallback) - let positive: (readonly [number, number])[] = [] - let negative: (readonly [number, number])[] = [] - let segmentPoints: ChartPoint[] = [] - let segmentIndex = 0 - - const flush = () => { - if (!positive.length) return - const reversedNegative = [...negative].reverse() - const path = violinPath(curve, orientation, positive, negative) - areas.push({ - kind: 'area', - key: `${id}:${valueKey(group)}:segment:${segmentIndex}`, - points: [...positive, ...reversedNegative], - ...(path ? { path } : {}), - interaction: { - points: segmentPoints, - affinity: orientation, - }, - style: { - fill, - fillOpacity: options.fillOpacity ?? 0.5, - stroke, - strokeOpacity: options.strokeOpacity, - strokeWidth: options.strokeWidth ?? 1.5, - strokeDasharray: options.strokeDasharray, + return { + id, + states: markStates(data, options.states), + channels: + orientation === 'y' + ? { + x: { + scale: xScale, + values: categoryValues.filter(isChartKey), + }, + y: { + scale: yScale, + values: positionValues.filter(isViolinPosition), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, + } + : { + x: { + scale: xScale, + values: positionValues.filter(isViolinPosition), + }, + y: { + scale: yScale, + values: categoryValues.filter(isChartKey), + }, + color: { + scale: 'color', + values: colorValues.filter(isChartKey), + }, }, - }) - points.push(...segmentPoints) - positive = [] - negative = [] - segmentPoints = [] - segmentIndex += 1 + render: ({ chart, scales, color: resolveColor }) => { + const categoryScale = + orientation === 'y' ? scales[xScale]! : scales[yScale]! + if (!isResolvedCategoryScale(categoryScale)) { + throw new TypeError( + `violin${orientation.toUpperCase()}: the category axis requires a band or point scale`, + ) + } + const positionScale = + orientation === 'y' ? scales[yScale] : scales[xScale] + if (!positionScale) { + throw new TypeError( + `violin${orientation.toUpperCase()}: the profile axis scale is required`, + ) } + const plotSpan = orientation === 'y' ? chart.width : chart.height + const step = resolvedCategoryStep(categoryScale, plotSpan, span) + const areas: SceneArea[] = [] + const points: ChartPoint[] = [] - for (const index of indexes) { - const positionValue = positionValues[index] - const nextCategory = categoryValues[index] - const width = widths[index] - if ( - !isViolinPosition(positionValue) || - !isChartKey(nextCategory) || - !isFiniteNumber(width) - ) { - flush() - continue - } - const positionPixel = positionScale.map(positionValue) - const nextBaseline = categoryScale.map(nextCategory) - if ( - !Number.isFinite(positionPixel) || - !Number.isFinite(nextBaseline) - ) { - flush() - continue - } - const halfWidth = (width * span * step) / 2 - const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` - const x = orientation === 'y' ? nextBaseline : positionPixel - const y = orientation === 'y' ? positionPixel : nextBaseline - const point: ChartPoint = { - key, - markId: id, - group, - groupLabel: String(nextCategory), - datum: data[index]!, - datumIndex: index, - xValue: orientation === 'y' ? nextCategory : positionValue, - yValue: orientation === 'y' ? positionValue : nextCategory, - x, - y, - color: fill, - } - positive.push( - orientation === 'y' - ? [nextBaseline + halfWidth, positionPixel] - : [positionPixel, nextBaseline - halfWidth], + for (const { key: group, indexes } of groupedIndexes(categoryKeys)) { + const firstIndex = indexes.find((index) => + isChartKey(categoryValues[index]), ) - negative.push( - orientation === 'y' - ? [nextBaseline - halfWidth, positionPixel] - : [positionPixel, nextBaseline + halfWidth], + if (firstIndex === undefined) continue + const categoryValue = categoryValues[firstIndex]! + const baseline = categoryScale.map(categoryValue) + if (!Number.isFinite(baseline)) continue + const datum = data[firstIndex]! + const fallback = resolveColor(colorValues[firstIndex] ?? null) + const fill = visualValue( + options.fill, + datum, + firstIndex, + data, + fallback, ) - segmentPoints.push(point) + const stroke = + options.stroke === null + ? undefined + : visualValue(options.stroke, datum, firstIndex, data, fallback) + let positive: (readonly [number, number])[] = [] + let negative: (readonly [number, number])[] = [] + let segmentPoints: ChartPoint[] = [] + let segmentIndex = 0 + + const flush = () => { + if (!positive.length) return + const reversedNegative = [...negative].reverse() + const path = violinPath(curve, orientation, positive, negative) + areas.push({ + kind: 'area', + key: `${id}:${valueKey(group)}:segment:${segmentIndex}`, + points: [...positive, ...reversedNegative], + ...(path ? { path } : {}), + interaction: { + points: segmentPoints, + affinity: orientation, + }, + style: { + fill, + fillOpacity: options.fillOpacity ?? 0.5, + stroke, + strokeOpacity: options.strokeOpacity, + strokeWidth: options.strokeWidth ?? 1.5, + strokeDasharray: options.strokeDasharray, + }, + }) + points.push(...segmentPoints) + positive = [] + negative = [] + segmentPoints = [] + segmentIndex += 1 + } + + for (const index of indexes) { + const positionValue = positionValues[index] + const nextCategory = categoryValues[index] + const width = widths[index] + if ( + !isViolinPosition(positionValue) || + !isChartKey(nextCategory) || + !isFiniteNumber(width) + ) { + flush() + continue + } + const positionPixel = positionScale.map(positionValue) + const nextBaseline = categoryScale.map(nextCategory) + if ( + !Number.isFinite(positionPixel) || + !Number.isFinite(nextBaseline) + ) { + flush() + continue + } + const halfWidth = (width * span * step) / 2 + const key = `${id}:${valueKey(group)}:${valueKey(keys[index])}` + const x = orientation === 'y' ? nextBaseline : positionPixel + const y = orientation === 'y' ? positionPixel : nextBaseline + const point: ChartPoint = { + key, + markId: id, + group, + groupLabel: String(nextCategory), + datum: data[index]!, + datumIndex: index, + xValue: orientation === 'y' ? nextCategory : positionValue, + yValue: orientation === 'y' ? positionValue : nextCategory, + x, + y, + color: fill, + } + positive.push( + orientation === 'y' + ? [nextBaseline + halfWidth, positionPixel] + : [positionPixel, nextBaseline - halfWidth], + ) + negative.push( + orientation === 'y' + ? [nextBaseline - halfWidth, positionPixel] + : [positionPixel, nextBaseline + halfWidth], + ) + segmentPoints.push(point) + } + flush() } - flush() - } - return { - nodes: [ - { - kind: 'group', - key: id, - className: `ts-chart__area ts-chart__violin ts-chart__violin-${orientation}`, - ariaHidden: true, - children: areas, - }, - ], - points, - } - }, - } - }, options.motion) + return { + nodes: [ + { + kind: 'group', + key: id, + className: `ts-chart__area ts-chart__violin ts-chart__violin-${orientation}`, + ariaHidden: true, + children: areas, + }, + ], + points, + } + }, + } + }, + options.motion, + options.renderer, + ) } function isViolinPosition(value: unknown): value is ViolinPosition { diff --git a/packages/charts-core/src/waffle.ts b/packages/charts-core/src/waffle.ts index 46c59614..0a502690 100644 --- a/packages/charts-core/src/waffle.ts +++ b/packages/charts-core/src/waffle.ts @@ -265,6 +265,7 @@ function waffle( } }, options.motion, + options.renderer, ) } diff --git a/packages/charts-fixtures/src/index.ts b/packages/charts-fixtures/src/index.ts index 43ee7d53..dbc7ba68 100644 --- a/packages/charts-fixtures/src/index.ts +++ b/packages/charts-fixtures/src/index.ts @@ -50,12 +50,14 @@ export const downloadsChart = defineChart({ stroke: 'var(--ts-chart-1, #2563eb)', }), ], - x: { scale: scaleUtc, axis: { ticks: { count: 6 } } }, - y: { - scale: scaleLinear, - nice: 5, - grid: true, - axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + scales: { + x: { scale: scaleUtc, axis: { ticks: { count: 6 } } }, + y: { + scale: scaleLinear, + nice: 5, + grid: true, + axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + }, }, }) @@ -78,11 +80,13 @@ export const downloadAreaChart = defineChart({ stroke: 'var(--ts-chart-4, #8b5cf6)', }), ], - x: { scale: scaleUtc, nice: 7, axis: { ticks: { count: 7 } } }, - y: { - scale: scaleLinear, - nice: 5, - axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + scales: { + x: { scale: scaleUtc, nice: 7, axis: { ticks: { count: 7 } } }, + y: { + scale: scaleLinear, + nice: 5, + axis: { ticks: { count: 5 }, label: 'Daily downloads' }, + }, }, }) @@ -106,17 +110,20 @@ export const horsepowerChart = defineChart({ radius: 2, }), ], - x: { - scale: scaleLinear, - nice: 7, - grid: false, - axis: { label: 'Power (hp)' }, - }, - y: { - scale: scaleLinear, - nice: 5, - axis: { ticks: { count: 5 }, label: 'Cars' }, + scales: { + x: { + scale: scaleLinear, + nice: 7, + grid: false, + axis: { label: 'Power (hp)' }, + }, + y: { + scale: scaleLinear, + nice: 5, + axis: { ticks: { count: 5 }, label: 'Cars' }, + }, }, + color: { scale: () => scaleLinear() @@ -168,24 +175,28 @@ export const createRankingChart = (input: RankingInput) => inset: width < 420 ? 2 : 3, }), ], - x: { - scale: scaleLinear, - nice: xTicks, - grid: true, - axis: { - ticks: { count: xTicks }, - label: - width < 420 - ? undefined - : input.metric === 'economy (mpg)' - ? 'Fuel economy (mpg)' - : 'Power (hp)', + scales: { + x: { + scale: scaleLinear, + nice: xTicks, + grid: true, + axis: { + ticks: { count: xTicks }, + label: + width < 420 + ? undefined + : input.metric === 'economy (mpg)' + ? 'Fuel economy (mpg)' + : 'Power (hp)', + }, + }, + y: { + scale: () => + scaleBand().paddingInner(0.24).paddingOuter(0.12), + grid: false, }, }, - y: { - scale: () => scaleBand().paddingInner(0.24).paddingOuter(0.12), - grid: false, - }, + color: { scale: () => scaleOrdinal().range([input.accent]), }, diff --git a/packages/charts-fixtures/src/stats-parity.ts b/packages/charts-fixtures/src/stats-parity.ts index 4cd983a1..47f109a3 100644 --- a/packages/charts-fixtures/src/stats-parity.ts +++ b/packages/charts-fixtures/src/stats-parity.ts @@ -163,27 +163,30 @@ export const createStatsHistoryChart = (input: StatsHistoryInput) => curve: d3Curve(curveMonotoneX), }), ], - x: { - scale: xScale, - axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Date' }, - }, - y: { - scale: scaleLinear, - nice: 5, - grid: true, - axis: { - ticks: { - count: 5, - format: input.mode === 'share' ? formatPercent : formatCompact, + scales: { + x: { + scale: xScale, + axis: { ticks: { count: width < 520 ? 4 : 7 }, label: 'Date' }, + }, + y: { + scale: scaleLinear, + nice: 5, + grid: true, + axis: { + ticks: { + count: 5, + format: input.mode === 'share' ? formatPercent : formatCompact, + }, + label: + input.mode === 'share' + ? 'Download Share' + : input.mode === 'stream' + ? 'Downloads (stream)' + : 'Downloads', }, - label: - input.mode === 'share' - ? 'Download Share' - : input.mode === 'stream' - ? 'Downloads (stream)' - : 'Downloads', }, }, + color: { scale: scaleOrdinal() .domain(series.map((entry) => entry.name)) @@ -269,35 +272,38 @@ export const createStatsLatestChart = (input: StatsLatestInput) => return { marks, - x: vertical - ? { - scale: categoricalScale, - grid: false, - axis: { tickLabels: { rotate: width < 680 ? -28 : 0 } }, - } - : { - scale: scaleLinear, - nice: 7, - grid: true, - axis: { - ticks: { format: formatCompact }, - label: 'Downloads', + scales: { + x: vertical + ? { + scale: categoricalScale, + grid: false, + axis: { tickLabels: { rotate: width < 680 ? -28 : 0 } }, + } + : { + scale: scaleLinear, + nice: 7, + grid: true, + axis: { + ticks: { format: formatCompact }, + label: 'Downloads', + }, }, - }, - y: vertical - ? { - scale: scaleLinear, - nice: 7, - grid: true, - axis: { - ticks: { format: formatCompact }, - label: 'Downloads', + y: vertical + ? { + scale: scaleLinear, + nice: 7, + grid: true, + axis: { + ticks: { format: formatCompact }, + label: 'Downloads', + }, + } + : { + scale: categoricalScale, + grid: false, }, - } - : { - scale: categoricalScale, - grid: false, - }, + }, + color: { scale: scaleOrdinal() .domain(colorDomain) diff --git a/packages/lit-charts/src/Chart.test.ts b/packages/lit-charts/src/Chart.test.ts index 7d33fb04..9b16dc7d 100644 --- a/packages/lit-charts/src/Chart.test.ts +++ b/packages/lit-charts/src/Chart.test.ts @@ -15,8 +15,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) if (false) { diff --git a/packages/lit-charts/src/Chart.ts b/packages/lit-charts/src/Chart.ts index c4d0ef93..5e182004 100644 --- a/packages/lit-charts/src/Chart.ts +++ b/packages/lit-charts/src/Chart.ts @@ -233,7 +233,7 @@ function adaptOnRender< return ( context: ChartRendererRenderContext, ): void => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -242,6 +242,7 @@ function adaptOnRender< onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/octane-charts/src/CanvasChart.client.test.tsrx b/packages/octane-charts/src/CanvasChart.client.test.tsrx index f7585442..6afd7a2d 100644 --- a/packages/octane-charts/src/CanvasChart.client.test.tsrx +++ b/packages/octane-charts/src/CanvasChart.client.test.tsrx @@ -28,8 +28,11 @@ const definition = defineChart({ stroke: '#2563eb', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, }) const interactiveDefinition = defineChart(definition, { @@ -51,57 +54,53 @@ afterEach(() => { }) describe('Octane Canvas adapter client', () => { - it( - 'hydrates the server-rendered Canvas shell in place', - async () => { - const target = document.createElement('div') - target.innerHTML = await renderServerHydrationFixture() - const host = target.querySelector('.ts-chart-host') - const surface = target.querySelector('.ts-chart-canvas') - const backgroundCanvas = target.querySelector( - '.ts-chart-canvas__background', - ) - const focusUnderCanvas = target.querySelector( - '.ts-chart-canvas__focus-under', - ) - const sceneCanvas = target.querySelector('.ts-chart-canvas__scene') - const focusCanvas = target.querySelector('.ts-chart-canvas__focus') - const baseCanvas = target.querySelector('.ts-chart-canvas__base') - if ( - !host || - !surface || - !backgroundCanvas || - !focusUnderCanvas || - !sceneCanvas || - !focusCanvas || - !baseCanvas - ) { - throw new Error('Expected the server-rendered Canvas chart shell') - } + it('hydrates the server-rendered Canvas shell in place', async () => { + const target = document.createElement('div') + target.innerHTML = await renderServerHydrationFixture() + const host = target.querySelector('.ts-chart-host') + const surface = target.querySelector('.ts-chart-canvas') + const backgroundCanvas = target.querySelector( + '.ts-chart-canvas__background', + ) + const focusUnderCanvas = target.querySelector( + '.ts-chart-canvas__focus-under', + ) + const sceneCanvas = target.querySelector('.ts-chart-canvas__scene') + const focusCanvas = target.querySelector('.ts-chart-canvas__focus') + const baseCanvas = target.querySelector('.ts-chart-canvas__base') + if ( + !host || + !surface || + !backgroundCanvas || + !focusUnderCanvas || + !sceneCanvas || + !focusCanvas || + !baseCanvas + ) { + throw new Error('Expected the server-rendered Canvas chart shell') + } - let root: ReturnType | undefined - await act(() => { - root = hydrateRoot(target, HydrationCanvasChart) - }) + let root: ReturnType | undefined + await act(() => { + root = hydrateRoot(target, HydrationCanvasChart) + }) - expect(target.querySelector('.ts-chart-host')).toBe(host) - expect(target.querySelector('.ts-chart-canvas')).toBe(surface) - expect(target.querySelector('.ts-chart-canvas__background')).toBe( - backgroundCanvas, - ) - expect(target.querySelector('.ts-chart-canvas__focus-under')).toBe( - focusUnderCanvas, - ) - expect(target.querySelector('.ts-chart-canvas__scene')).toBe(sceneCanvas) - expect(target.querySelector('.ts-chart-canvas__focus')).toBe(focusCanvas) - expect(target.querySelector('.ts-chart-canvas__base')).toBe(baseCanvas) - expect(target.querySelector('svg')).toBeNull() + expect(target.querySelector('.ts-chart-host')).toBe(host) + expect(target.querySelector('.ts-chart-canvas')).toBe(surface) + expect(target.querySelector('.ts-chart-canvas__background')).toBe( + backgroundCanvas, + ) + expect(target.querySelector('.ts-chart-canvas__focus-under')).toBe( + focusUnderCanvas, + ) + expect(target.querySelector('.ts-chart-canvas__scene')).toBe(sceneCanvas) + expect(target.querySelector('.ts-chart-canvas__focus')).toBe(focusCanvas) + expect(target.querySelector('.ts-chart-canvas__base')).toBe(baseCanvas) + expect(target.querySelector('svg')).toBeNull() - await act(() => root?.unmount()) - expect(target.childElementCount).toBe(0) - }, - 15_000, - ) + await act(() => root?.unmount()) + expect(target.childElementCount).toBe(0) + }, 15_000) it('adopts its rendered Canvas shell and preserves interaction callbacks', async () => { const target = document.createElement('div') diff --git a/packages/octane-charts/src/CanvasChart.hydration.fixture.tsrx b/packages/octane-charts/src/CanvasChart.hydration.fixture.tsrx index 4704913e..faca8881 100644 --- a/packages/octane-charts/src/CanvasChart.hydration.fixture.tsrx +++ b/packages/octane-charts/src/CanvasChart.hydration.fixture.tsrx @@ -15,8 +15,11 @@ const definition = defineChart({ stroke: '#2563eb', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, }) diff --git a/packages/octane-charts/src/CanvasChart.test.tsrx b/packages/octane-charts/src/CanvasChart.test.tsrx index a748a0f9..579cbfe1 100644 --- a/packages/octane-charts/src/CanvasChart.test.tsrx +++ b/packages/octane-charts/src/CanvasChart.test.tsrx @@ -17,8 +17,11 @@ const definition = defineChart({ stroke: '#2563eb', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, }) diff --git a/packages/octane-charts/src/Chart.client.test.tsrx b/packages/octane-charts/src/Chart.client.test.tsrx index 4ce8e321..19366722 100644 --- a/packages/octane-charts/src/Chart.client.test.tsrx +++ b/packages/octane-charts/src/Chart.client.test.tsrx @@ -26,8 +26,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, }); const broadFocusDefinition = defineChart(definition, { maxFocusDistance: 1_000, @@ -44,8 +46,10 @@ describe('Octane Chart adapter client', () => { key: 'id', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, }), ); const target = document.createElement('div'); diff --git a/packages/octane-charts/src/Chart.test.tsrx b/packages/octane-charts/src/Chart.test.tsrx index 6a021e16..26184795 100644 --- a/packages/octane-charts/src/Chart.test.tsrx +++ b/packages/octane-charts/src/Chart.test.tsrx @@ -18,8 +18,10 @@ const definition = defineChart({ key: 'id', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, }); describe('Octane Chart adapter', () => { @@ -81,8 +83,10 @@ describe('Octane Chart adapter', () => { it('server-renders scoped SVG resource IDs', () => { const gradientDefinition = defineChart({ marks: [areaY([1, 3, 2], { fill: 'url(#fill)' })], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 3]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + }, gradients: [{ id: 'fill', stops: [ diff --git a/packages/octane-charts/src/Chart.tsrx b/packages/octane-charts/src/Chart.tsrx index 9625f5d3..283a439b 100644 --- a/packages/octane-charts/src/Chart.tsrx +++ b/packages/octane-charts/src/Chart.tsrx @@ -17,7 +17,7 @@ export function Chart< ) const onRender = props.onRender ? (context: ChartRendererRenderContext) => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -26,6 +26,7 @@ export function Chart< props.onRender?.({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/octane-charts/src/types.type-test.ts b/packages/octane-charts/src/types.type-test.ts index 2e5af84f..ce83af01 100644 --- a/packages/octane-charts/src/types.type-test.ts +++ b/packages/octane-charts/src/types.type-test.ts @@ -11,8 +11,10 @@ const rows = [ const staticDefinition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'value', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 8]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, }) const dynamicDefinition = defineChart(() => ({ @@ -24,8 +26,10 @@ const dynamicDefinition = defineChart(() => ({ stroke: 'red', }), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 8]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, })) const widenedDefinition: ChartDefinition< (typeof rows)[number], diff --git a/packages/preact-charts/src/Chart.test.tsx b/packages/preact-charts/src/Chart.test.tsx index 9f4ba01d..d5353bba 100644 --- a/packages/preact-charts/src/Chart.test.tsx +++ b/packages/preact-charts/src/Chart.test.tsx @@ -17,8 +17,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) if (false) { diff --git a/packages/preact-charts/src/Chart.tsx b/packages/preact-charts/src/Chart.tsx index 8e2ef949..cdd5cd18 100644 --- a/packages/preact-charts/src/Chart.tsx +++ b/packages/preact-charts/src/Chart.tsx @@ -143,7 +143,8 @@ function toHostOptions< } = props const handleRender = onRender ? (context: ChartRendererRenderContext): void => { - const element = context.surface.element + const element = + context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(element instanceof SvgElement)) { @@ -152,6 +153,7 @@ function toHostOptions< onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg: element, interaction: context.interaction, }) diff --git a/packages/react-charts/src/CanvasChart.test.tsx b/packages/react-charts/src/CanvasChart.test.tsx index bfb069ab..276947e0 100644 --- a/packages/react-charts/src/CanvasChart.test.tsx +++ b/packages/react-charts/src/CanvasChart.test.tsx @@ -22,8 +22,11 @@ const definition = defineChart({ stroke: '#2563eb', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, }) const interactiveDefinition = defineChart(definition, { @@ -40,8 +43,11 @@ const dynamicDefinition = defineChart(() => ({ stroke: '#2563eb', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, })) diff --git a/packages/react-charts/src/Chart.test.tsx b/packages/react-charts/src/Chart.test.tsx index af740f84..56873c85 100644 --- a/packages/react-charts/src/Chart.test.tsx +++ b/packages/react-charts/src/Chart.test.tsx @@ -3,12 +3,13 @@ import { act } from 'react' import { createRoot, hydrateRoot } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' -import { areaY, defineChart, lineY } from '@tanstack/charts' +import { areaY, defineChart, dot, lineY } from '@tanstack/charts' import type { ChartDefinition, ChartInteractionController, } from '@tanstack/charts' import { renderChartSvgWithResources } from '@tanstack/charts/svg/resources' +import { canvasChartRenderer } from '@tanstack/charts/canvas' import { tooltip } from '@tanstack/charts/tooltip' import { portal as tooltipPortal } from '@tanstack/charts/tooltip/portal' import { motion } from '@tanstack/charts/motion' @@ -33,8 +34,25 @@ const definition = defineChart({ points: true, }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, +}) +const mixedDefinition = defineChart({ + marks: [ + lineY(data, { + id: 'canvas-revenue', + x: 'month', + y: 'value', + renderer: canvasChartRenderer, + }), + dot(data, { id: 'svg-points', x: 'month', y: 'value' }), + ], + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, }) const typedDynamicDefinition = defineChart(() => ({ @@ -46,8 +64,10 @@ const typedDynamicDefinition = defineChart(() => ({ stroke: 'red', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, })) const widenedDefinition: ChartDefinition< (typeof data)[number], @@ -216,6 +236,22 @@ describe('React adapter', () => { expect(html).toContain(' { + const html = renderToString( + , + ) + + expect(html).toContain('class="ts-chart ts-chart-layers"') + expect(html).toContain('ts-chart-canvas__scene') + expect(html).toContain('data-ts-key="svg-points"') + expect(html).not.toContain('data-ts-key="canvas-revenue"') + }) + it('server-renders a deterministic proportional size', () => { const html = renderToString( { it('server-renders unique scoped resource IDs for sibling charts', () => { const gradientDefinition = defineChart({ marks: [areaY([1, 3, 2], { fill: 'url(#fill)' })], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 3]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]) }, + }, + gradients: [ { id: 'fill', @@ -733,8 +772,10 @@ describe('React adapter', () => { stroke: 'red', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, })) const target = document.createElement('div') const root = createRoot(target) @@ -766,6 +807,80 @@ describe('React adapter', () => { await act(async () => root.unmount()) }) + it('mounts mixed marks without breaking the SVG render callback', async () => { + const getContext = mockCanvasContexts() + const target = document.createElement('div') + const root = createRoot(target) + const onRender = vi.fn() + + try { + await act(async () => { + root.render( + , + ) + }) + + const context = onRender.mock.calls.at(-1)?.[0] + expect(context.surface.element).toBe( + target.querySelector('.ts-chart-layers'), + ) + expect(context.surface.layers).toHaveLength(3) + expect(context.svg).toBe(target.querySelectorAll('svg').item(1)) + expect( + context.svg.querySelector('[data-ts-key="svg-points"]'), + ).not.toBeNull() + } finally { + await act(async () => root.unmount()) + getContext.mockRestore() + } + }) + + it('hydrates mixed server markup without replacing its layers', async () => { + const getContext = mockCanvasContexts() + const target = document.createElement('div') + target.innerHTML = renderToString( + , + ) + const serverRoot = target.querySelector('.ts-chart-layers') + const serverLayers = [...target.querySelectorAll('.ts-chart-layer')] + const serverCanvases = [...target.querySelectorAll('canvas')] + let root!: ReturnType + + try { + await act(async () => { + root = hydrateRoot( + target, + , + ) + }) + + expect(target.querySelector('.ts-chart-layers')).toBe(serverRoot) + expect([...target.querySelectorAll('.ts-chart-layer')]).toEqual( + serverLayers, + ) + expect([...target.querySelectorAll('canvas')]).toEqual(serverCanvases) + } finally { + await act(async () => root.unmount()) + getContext.mockRestore() + } + }) + it('hydrates complete server markup without replacing the SVG', async () => { const target = document.createElement('div') target.innerHTML = renderToString( @@ -795,3 +910,49 @@ describe('React adapter', () => { await act(async () => root.unmount()) }) }) + +function mockCanvasContexts() { + return vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation(() => fakeCanvasContext()) +} + +function fakeCanvasContext(): CanvasRenderingContext2D { + const gradient = { addColorStop() {} } as CanvasGradient + return { + save() {}, + restore() {}, + setTransform() {}, + clearRect() {}, + fillRect() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + rect() {}, + arc() {}, + arcTo() {}, + translate() {}, + rotate() {}, + clip() {}, + fill() {}, + stroke() {}, + fillText() {}, + strokeText() {}, + setLineDash() {}, + createLinearGradient: () => gradient, + drawImage() {}, + globalAlpha: 1, + fillStyle: '#000000', + strokeStyle: '#000000', + lineWidth: 1, + lineCap: 'butt', + lineJoin: 'miter', + font: '', + fontStretch: 'normal', + letterSpacing: '0px', + direction: 'inherit', + textAlign: 'left', + textBaseline: 'alphabetic', + } as unknown as CanvasRenderingContext2D +} diff --git a/packages/react-charts/src/Chart.tsx b/packages/react-charts/src/Chart.tsx index 21e6a76e..4a3f0757 100644 --- a/packages/react-charts/src/Chart.tsx +++ b/packages/react-charts/src/Chart.tsx @@ -84,7 +84,7 @@ export function ChartImplementation< const onRender = React.useMemo(() => { if (!props.onRender) return undefined return (context: ChartRendererRenderContext) => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -93,6 +93,7 @@ export function ChartImplementation< props.onRender?.({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx index d28bfd20..4c62d636 100644 --- a/packages/react-native-charts/src/Chart.test.tsx +++ b/packages/react-native-charts/src/Chart.test.tsx @@ -181,8 +181,11 @@ const definition = defineChart({ points: true, }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + tooltip: { use: tooltip, sticky: true }, }) @@ -265,8 +268,11 @@ describe('React Native Chart', () => { { id: 'observations', x: 'x', y: 'y', key: 'id' }, ), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + guides: false, margin: 0, }), @@ -280,10 +286,17 @@ describe('React Native Chart', () => { innerRadius: ({ radius }) => radius * 0.55, }), ], + scales: { + angle: null, + radius: null, + }, }), ], - x: null, - y: null, + scales: { + x: null, + y: null, + }, + guides: false, margin: 0, }), @@ -397,8 +410,11 @@ describe('React Native Chart', () => { selection, ), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + maxFocusDistance: 8, selection, }) @@ -517,6 +533,10 @@ describe('React Native Chart', () => { const controller = createChartCursor() const pointerDisabledDefinition = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, pointer: false, cursor: { use: cursorHost, controller, mode: 'free' }, @@ -545,8 +565,11 @@ describe('React Native Chart', () => { } const foreignDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + tooltip: foreignTooltip, }) @@ -588,8 +611,11 @@ describe('React Native Chart', () => { } const customDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + tooltip: { use: customTooltip, sticky: true }, }) @@ -665,8 +691,11 @@ describe('React Native Chart', () => { } const trackingDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + tooltip: { use: trackingTooltip }, }) const container = document.createElement('div') @@ -719,8 +748,10 @@ describe('React Native Chart', () => { const [, rerender] = React.useReducer((value) => value + 1, 0) const inlineDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, }) return ( { ), barY(rows, { x: 'category', y: 'value', fill: '#2563eb' }), ], - x: { scale: scaleBand().domain(['A', 'B']) }, - y: { scale: scaleLinear().domain([0, 12]) }, + scales: { + x: { scale: scaleBand().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 12]) }, + }, + guides: false, focusRing: false, }) @@ -809,8 +843,11 @@ describe('React Native Chart', () => { it('honors focusRing false instead of painting a native-only indicator', async () => { const noRingDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, focusRing: false, }) @@ -853,8 +890,11 @@ describe('React Native Chart', () => { strokeDasharray: '3 2', }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, focusRing: false, }) @@ -914,11 +954,14 @@ describe('React Native Chart', () => { lineY(rows, { x: 'x', y: 'y', key: 'id' }), crosshair({ x: true, y: false }), ], - x: { - scale: scaleLinear().domain([0, 3]), - viewport: { domain: [1, 2] }, + scales: { + x: { + scale: scaleLinear().domain([0, 3]), + viewport: { domain: [1, 2] }, + }, + y: { scale: scaleLinear().domain([0, 3]) }, }, - y: { scale: scaleLinear().domain([0, 3]) }, + guides: false, cursor: { use: cursorHost, controller, mode: 'focus', match: 'x' }, }) @@ -999,8 +1042,11 @@ describe('React Native Chart', () => { }, }), ], - x: { scale: scaleBand().domain(['A', 'B']).padding(0.18) }, - y: { scale: scaleLinear().domain([0, 12]) }, + scales: { + x: { scale: scaleBand().domain(['A', 'B']).padding(0.18) }, + y: { scale: scaleLinear().domain([0, 12]) }, + }, + guides: false, focusRing: false, }) @@ -1077,8 +1123,11 @@ describe('React Native Chart', () => { marker: true, }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([0, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([0, 12]) }, + }, + guides: false, focus: 'group-x', focusRing: false, @@ -1202,8 +1251,11 @@ describe('React Native Chart', () => { lineY(rows, { x: 'x', y: 'y', key: 'id' }), crosshair({ id: 'disabled-focus-cursor', y: false }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + guides: false, focus: false, cursor: { @@ -1295,8 +1347,11 @@ describe('React Native Chart', () => { }), crosshair({ x: true, y: false, marker: true }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([0, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([0, 12]) }, + }, + guides: false, focus: 'group-x', focusRing: false, @@ -1421,8 +1476,11 @@ describe('React Native Chart', () => { lineY(data, { x: 'month', y: 'value' }), crosshair({ x: true, y: true }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, focusRing: false, maxFocusDistance: 1_000, @@ -1498,6 +1556,10 @@ describe('React Native Chart', () => { marker: true, }), ], + scales: { + x: null, + y: null, + }, guides: false, cursor: { use: cursorHost, @@ -1607,6 +1669,10 @@ describe('React Native Chart', () => { const controller = createChartCursor() const sharedDefinition = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, cursor: { use: cursorHost, @@ -1720,8 +1786,11 @@ describe('React Native Chart', () => { lineY(data, { x: 'month', y: 'value' }), crosshair({ x: true, y: false }), ], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, focusRing: false, focus: 'group-x', @@ -1809,11 +1878,19 @@ describe('React Native Chart', () => { const second = trackedCursorController() const unboundDefinition = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, }) const definitionFor = (controller: ChartCursorController) => defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, cursor: { use: cursorHost, @@ -1906,6 +1983,10 @@ describe('React Native Chart', () => { const controller = new MethodCursorController() const methodDefinition = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, cursor: { use: cursorHost, @@ -1944,6 +2025,10 @@ describe('React Native Chart', () => { const controller = createChartCursor() const bound = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, cursor: { use: cursorHost, @@ -1955,6 +2040,10 @@ describe('React Native Chart', () => { }) const unbound = defineChart({ marks: [crosshair({ x: true, y: true })], + scales: { + x: null, + y: null, + }, guides: false, }) const container = document.createElement('div') @@ -1995,8 +2084,11 @@ describe('React Native Chart', () => { it('rejects browser tooltip portal extensions', () => { const portalDefinition = defineChart({ marks: [lineY(data, { x: 'month', y: 'value' })], - x: { scale: scaleLinear().domain([1, 2]) }, - y: { scale: scaleLinear().domain([8, 12]) }, + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + tooltip: { use: tooltip, portal: { diff --git a/packages/react-native-charts/src/interaction.test.ts b/packages/react-native-charts/src/interaction.test.ts index 5f6e5982..97a079b6 100644 --- a/packages/react-native-charts/src/interaction.test.ts +++ b/packages/react-native-charts/src/interaction.test.ts @@ -110,11 +110,14 @@ describe('native focus model', () => { const chartDefinition = (translate: number) => defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { - scale: scaleLinear().domain([0, 3]), - viewport: { domain: [1, 2], translate }, + scales: { + x: { + scale: scaleLinear().domain([0, 3]), + viewport: { domain: [1, 2], translate }, + }, + y: { scale: scaleLinear().domain([0, 3]) }, }, - y: { scale: scaleLinear().domain([0, 3]) }, + guides: false, }) const settled = createChartScene(chartDefinition(0), { diff --git a/packages/solid-charts/src/Chart.ssr.test.tsx b/packages/solid-charts/src/Chart.ssr.test.tsx index de2dd430..b47eceb1 100644 --- a/packages/solid-charts/src/Chart.ssr.test.tsx +++ b/packages/solid-charts/src/Chart.ssr.test.tsx @@ -11,8 +11,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) describe('Solid adapter SSR', () => { diff --git a/packages/solid-charts/src/Chart.test.tsx b/packages/solid-charts/src/Chart.test.tsx index a45f0562..87a6103f 100644 --- a/packages/solid-charts/src/Chart.test.tsx +++ b/packages/solid-charts/src/Chart.test.tsx @@ -16,8 +16,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) if (false) { diff --git a/packages/solid-charts/src/Chart.tsx b/packages/solid-charts/src/Chart.tsx index 27151223..bc8d0ee5 100644 --- a/packages/solid-charts/src/Chart.tsx +++ b/packages/solid-charts/src/Chart.tsx @@ -119,7 +119,8 @@ function toHostOptions< } = props const handleRender = onRender ? (context: ChartRendererRenderContext): void => { - const element = context.surface.element + const element = + context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(element instanceof SvgElement)) { @@ -128,6 +129,7 @@ function toHostOptions< onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg: element, interaction: context.interaction, }) diff --git a/packages/svelte-charts/src/Chart.svelte b/packages/svelte-charts/src/Chart.svelte index a8d53313..16582585 100644 --- a/packages/svelte-charts/src/Chart.svelte +++ b/packages/svelte-charts/src/Chart.svelte @@ -112,7 +112,7 @@ return ( context: ChartRendererRenderContext, ): void => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -121,6 +121,7 @@ onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/packages/svelte-charts/tests/Chart.ssr.test.ts b/packages/svelte-charts/tests/Chart.ssr.test.ts index f3bc1161..20c3a27b 100644 --- a/packages/svelte-charts/tests/Chart.ssr.test.ts +++ b/packages/svelte-charts/tests/Chart.ssr.test.ts @@ -10,8 +10,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) describe('Svelte adapter SSR', () => { diff --git a/packages/svelte-charts/tests/Chart.test.ts b/packages/svelte-charts/tests/Chart.test.ts index f366570f..22e50003 100644 --- a/packages/svelte-charts/tests/Chart.test.ts +++ b/packages/svelte-charts/tests/Chart.test.ts @@ -18,8 +18,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, diff --git a/packages/vue-charts/src/Chart.test.ts b/packages/vue-charts/src/Chart.test.ts index 85c23148..346adceb 100644 --- a/packages/vue-charts/src/Chart.test.ts +++ b/packages/vue-charts/src/Chart.test.ts @@ -15,8 +15,10 @@ const rows = [ ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 4]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, }) const tooltipDefinition = defineChart(definition, { maxFocusDistance: 1_000, diff --git a/packages/vue-charts/src/Chart.ts b/packages/vue-charts/src/Chart.ts index 9695c3e6..ac2323e3 100644 --- a/packages/vue-charts/src/Chart.ts +++ b/packages/vue-charts/src/Chart.ts @@ -229,7 +229,7 @@ function adaptOnRender< return ( context: ChartRendererRenderContext, ): void => { - const svg = context.surface.element + const svg = context.surface.defaultElement ?? context.surface.element const SvgElement = context.container.ownerDocument.defaultView?.SVGSVGElement if (!SvgElement || !(svg instanceof SvgElement)) { @@ -238,6 +238,7 @@ function adaptOnRender< onRender({ container: context.container, scene: context.scene, + surface: context.surface, svg, interaction: context.interaction, }) diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs index 349f9103..319683ac 100644 --- a/scripts/catalog-definition-shapes.test.mjs +++ b/scripts/catalog-definition-shapes.test.mjs @@ -93,10 +93,20 @@ describe('catalog definition shapes', () => { classifyDefinitions( 'two-argument.ts', ` - defineChart({ marks: [] }, { focus: 'nearest' }) - defineChart(defineChart({ marks: [] }), { keyboard: true }) defineChart( - defineChart(({ width }) => ({ marks: [], width })), + { marks: [], scales: { x: null, y: null } }, + { focus: 'nearest' }, + ) + defineChart( + defineChart({ marks: [], scales: { x: null, y: null } }), + { keyboard: true }, + ) + defineChart( + defineChart(({ width }) => ({ + marks: [], + scales: { x: null, y: null }, + width, + })), { keyboard: true }, ) `, diff --git a/scripts/check-canvas-renderer.mjs b/scripts/check-canvas-renderer.mjs index cdd8eb87..5c692d2c 100644 --- a/scripts/check-canvas-renderer.mjs +++ b/scripts/check-canvas-renderer.mjs @@ -12,14 +12,28 @@ const bundle = await build({ write: false, stdin: { contents: ` - import { scaleLinear } from 'd3-scale' + import { scaleBand, scaleLinear } from 'd3-scale' import { createCanvasChartRenderer, mountCanvasChart, } from '@tanstack/charts/canvas' + import { areaY } from '@tanstack/charts/area' + import { areaX } from '@tanstack/charts/area-x' + import { arrow } from '@tanstack/charts/arrow' + import { barY } from '@tanstack/charts/bar' import { dot } from '@tanstack/charts/dot' import { renderChartImage } from '@tanstack/charts/export' + import { facet } from '@tanstack/charts/facet' + import { hexagon } from '@tanstack/charts/hexagon' + import { lineY } from '@tanstack/charts/line' + import { link } from '@tanstack/charts/link' + import { mountChart } from '@tanstack/charts/dom' + import { polar, radialDot, radialLine } from '@tanstack/charts/polar' + import { rect } from '@tanstack/charts/rect' import { defineChart } from '@tanstack/charts/scene' + import { text } from '@tanstack/charts/text' + import { tickY } from '@tanstack/charts/tick' + import { vector } from '@tanstack/charts/vector' import { tooltip } from '@tanstack/charts/tooltip' window.runCanvasRendererCheck = async () => { @@ -205,8 +219,11 @@ const bundle = await build({ const host = mountCanvasChart(interactionContainer, { definition: defineChart({ marks: [dot(data, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 2]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, + tooltip, maxFocusDistance: 1000, }), @@ -232,6 +249,454 @@ const bundle = await build({ !interactionContainer.querySelector('.ts-chart-tooltip')?.hidden host.destroy() + const rows = [ + { id: 'a', category: 'A', x: 1, y: 3, low: 2, high: 4 }, + { id: 'b', category: 'B', x: 2, y: 7, low: 5, high: 8 }, + { id: 'c', category: 'C', x: 3, y: 5, low: 4, high: 6 }, + ] + const cartesianScales = () => ({ + x: { scale: scaleLinear().domain([0, 4]), grid: true }, + y: { scale: scaleLinear().domain([0, 10]), grid: true }, + }) + const mixedDefinitions = [ + { + id: 'composed', + svgKey: 'bars', + definition: defineChart(() => ({ + marks: [ + areaY(rows, { + id: 'area', + x: 'category', + y: 'y', + renderer, + }), + barY(rows, { + id: 'bars', + x: 'category', + y: 'y', + key: 'id', + }), + lineY(rows, { + id: 'line', + x: 'category', + y: 'high', + renderer, + }), + dot(rows, { + id: 'dots', + x: 'category', + y: 'y', + key: 'id', + }), + ], + scales: { + x: { scale: scaleBand().domain(['A', 'B', 'C']) }, + y: { scale: scaleLinear().domain([0, 10]), grid: true }, + }, + })), + }, + { + id: 'error-bars', + svgKey: 'ticks', + definition: defineChart({ + marks: [ + link(rows, { + id: 'intervals', + x1: 'x', + x2: 'x', + y1: 'low', + y2: 'high', + renderer, + }), + tickY(rows, { id: 'ticks', x: 'x', y: 'low' }), + dot(rows, { id: 'estimates', x: 'x', y: 'y' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'network', + svgKey: 'nodes', + definition: defineChart({ + marks: [ + link( + [ + { x1: 1, y1: 3, x2: 2, y2: 7 }, + { x1: 2, y1: 7, x2: 3, y2: 5 }, + ], + { + id: 'edges', + x1: 'x1', + y1: 'y1', + x2: 'x2', + y2: 'y2', + renderer, + }, + ), + dot(rows, { id: 'nodes', x: 'x', y: 'y' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'ribbon', + svgKey: 'ribbon-points', + definition: defineChart({ + marks: [ + areaX(rows, { + id: 'range', + y: 'x', + x1: 'low', + x2: 'high', + renderer, + }), + lineY(rows, { id: 'median', x: 'x', y: 'y' }), + dot(rows, { id: 'ribbon-points', x: 'x', y: 'y' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'heatmap', + svgKey: 'labels', + definition: defineChart({ + marks: [ + rect(rows, { + id: 'cells', + x1: (row) => row.x - 0.4, + x2: (row) => row.x + 0.4, + y1: (row) => row.y - 0.8, + y2: (row) => row.y + 0.8, + renderer, + }), + text(rows, { id: 'labels', x: 'x', y: 'y', text: 'id' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'vectors', + svgKey: 'vector-points', + definition: defineChart({ + marks: [ + vector(rows, { + id: 'vectors', + x: 'x', + y: 'y', + length: 18, + rotate: 35, + renderer, + }), + dot(rows, { id: 'vector-points', x: 'x', y: 'y' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'arrows', + svgKey: 'arrow-points', + definition: defineChart({ + marks: [ + arrow(rows, { + id: 'arrows', + x1: 'x', + y1: 'low', + x2: 'x', + y2: 'high', + renderer, + }), + dot(rows, { id: 'arrow-points', x: 'x', y: 'high' }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'hexagons', + svgKey: 'hex-labels', + definition: defineChart({ + marks: [ + hexagon(rows, { + id: 'hexes', + x: 'x', + y: 'y', + r: 10, + renderer, + }), + text(rows, { + id: 'hex-labels', + x: 'x', + y: 'y', + text: 'id', + dy: -14, + }), + ], + scales: cartesianScales(), + }), + }, + { + id: 'facets', + svgKey: 'facet-dots', + definition: defineChart({ + marks: [ + facet( + rows.map((row, index) => ({ + ...row, + panel: index < 2 ? 'First' : 'Second', + })), + { + id: 'panels', + by: 'panel', + axes: 'cell', + chart: (data) => ({ + marks: [ + lineY(data, { + id: 'facet-lines', + x: 'category', + y: 'y', + renderer, + }), + dot(data, { + id: 'facet-dots', + x: 'category', + y: 'y', + }), + ], + scales: { + x: { + scale: scaleBand().domain(['A', 'B', 'C']), + }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + }), + }, + ), + ], + scales: { x: null, y: null }, + guides: false, + }), + }, + { + id: 'polar', + svgKey: 'radial-dots', + definition: defineChart({ + marks: [ + polar({ + scales: { + angle: { + scale: scaleBand().domain(['A', 'B', 'C']), + }, + radius: { scale: scaleLinear().domain([0, 10]) }, + }, + marks: [ + radialLine(rows, { + id: 'radial-line', + angle: 'category', + radius: 'y', + renderer, + }), + radialDot(rows, { + id: 'radial-dots', + angle: 'category', + radius: 'y', + }), + ], + }), + ], + scales: { x: null, y: null }, + guides: false, + }), + }, + ] + const mixedRoot = document.querySelector('#mixed') + const mixedResults = [] + let mixedExportSize = 0 + for (const fixture of mixedDefinitions) { + const fixtureContainer = document.createElement('div') + fixtureContainer.style.cssText = 'width:360px;height:220px' + mixedRoot.append(fixtureContainer) + const focusedKeys = [] + let renderContext + const options = { + definition: fixture.definition, + width: 360, + height: 220, + ariaLabel: fixture.id, + maxFocusDistance: 1_000, + onFocusChange: (point) => focusedKeys.push(point?.key ?? null), + onRender: (context) => { + renderContext = context + }, + } + const fixtureHost = mountChart(fixtureContainer, options) + const layerRoot = fixtureContainer.querySelector('.ts-chart-layers') + layerRoot.focus() + layerRoot.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'ArrowRight', + }), + ) + const canvases = [ + ...fixtureContainer.querySelectorAll( + '.ts-chart-canvas__scene', + ), + ] + const canvasInk = canvases.reduce((total, canvas) => { + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data + let ink = 0 + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] > 0) ink += 1 + } + return total + ink + }, 0) + const originalRoot = layerRoot + if (fixture.id === 'composed') { + mixedExportSize = ( + await renderChartImage(layerRoot, { scale: 1 }) + ).size + } + fixtureHost.update({ ...options, width: 380 }) + mixedResults.push({ + id: fixture.id, + layers: fixtureContainer.querySelectorAll('.ts-chart-layer') + .length, + layerTypes: [ + ...fixtureContainer.querySelectorAll('.ts-chart-layer'), + ].map((layer) => + layer.querySelector('.ts-chart-canvas') ? 'canvas' : 'svg', + ), + canvases: canvases.length, + svgs: fixtureContainer.querySelectorAll('svg').length, + canvasInk, + svgMark: + fixtureContainer.querySelector( + '[data-ts-key*="' + fixture.svgKey + '"]', + ) !== null, + rootPreserved: + fixtureContainer.querySelector('.ts-chart-layers') === + originalRoot, + focused: focusedKeys.length > 0, + callbackSurface: + renderContext?.surface.element === originalRoot && + renderContext?.surface.layers?.length === + fixtureContainer.querySelectorAll('.ts-chart-layer').length && + renderContext?.svg instanceof SVGSVGElement, + }) + fixtureHost.destroy() + } + + const updateContainer = document.createElement('div') + updateContainer.style.cssText = 'width:360px;height:220px' + mixedRoot.append(updateContainer) + const updateDefinition = (values) => { + const data = values.map((value, index) => ({ + id: String(index), + category: String.fromCharCode(65 + index), + value, + })) + return defineChart({ + marks: [ + areaY(data, { + id: 'updated-area', + x: 'category', + y: 'value', + renderer, + }), + dot(data, { + id: 'updated-dots', + x: 'category', + y: 'value', + key: 'id', + }), + ], + scales: { + x: { scale: scaleBand().domain(['A', 'B', 'C']) }, + y: { scale: scaleLinear().domain([0, 10]), grid: true }, + }, + tooltip, + maxFocusDistance: 1_000, + svgAnimation: { + duration: 100, + respectReducedMotion: false, + }, + }) + } + const updateOptions = { + width: 360, + height: 220, + ariaLabel: 'Mixed renderer update', + } + const updateHost = mountChart(updateContainer, { + ...updateOptions, + definition: updateDefinition([2, 5, 7]), + }) + const updateRoot = updateContainer.querySelector('.ts-chart-layers') + const canvasHash = () => { + const canvas = updateContainer.querySelector( + '.ts-chart-canvas__scene', + ) + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data + let hash = 2166136261 + for (const value of pixels) { + hash ^= value + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 + } + const dotSelector = '[data-ts-key="updated-dots"] circle' + const initialDot = updateContainer.querySelector(dotSelector) + const initialCy = Number(initialDot.getAttribute('cy')) + const initialCanvasHash = canvasHash() + + updateHost.update({ + ...updateOptions, + definition: updateDefinition([8, 3, 4]), + }) + await new Promise((resolve) => setTimeout(resolve, 220)) + + const updatedDot = updateContainer.querySelector(dotSelector) + const updatedCx = Number(updatedDot.getAttribute('cx')) + const updatedCy = Number(updatedDot.getAttribute('cy')) + const updatedCanvasHash = canvasHash() + const rootBounds = updateRoot.getBoundingClientRect() + updateRoot.dispatchEvent( + new MouseEvent('pointermove', { + bubbles: true, + clientX: rootBounds.left + updatedCx, + clientY: rootBounds.top + updatedCy, + }), + ) + const updatedPoint = updateHost + .getScene() + .points.find((point) => point.markId === 'updated-dots') + if (!updatedPoint) throw new Error('Expected updated SVG focus point') + updateHost.interaction.setControlledFocus(updatedPoint, { + source: 'pointer', + }) + const focus = updateContainer.querySelector( + '[data-ts-focus-layer="over"]:not([data-ts-focus-guide-layer]) circle[visibility="visible"]', + ) + const tooltipElement = updateContainer.querySelector( + '.ts-chart-tooltip', + ) + const focusCx = Number(focus?.getAttribute('cx')) + const focusCy = Number(focus?.getAttribute('cy')) + const updateResult = { + rootPreserved: + updateContainer.querySelector('.ts-chart-layers') === updateRoot, + canvasChanged: initialCanvasHash !== updatedCanvasHash, + svgChanged: initialCy !== updatedCy, + focusAligned: + Math.abs(focusCx - updatedCx) < 0.01 && + Math.abs(focusCy - updatedCy) < 0.01, + tooltipVisible: tooltipElement ? !tooltipElement.hidden : false, + } + updateHost.destroy() + return { surface: surfaceResult, export: { type: exported.type, size: exported.size }, @@ -241,6 +706,9 @@ const bundle = await build({ tooltipVisible, destroyed: interactionContainer.childElementCount === 0, }, + mixed: mixedResults, + mixedExportSize, + update: updateResult, } } `, @@ -259,7 +727,7 @@ try { }) const page = await context.newPage() await page.setContent( - '
', + '
', ) await page.addScriptTag({ content: bundle.outputFiles[0].text }) const result = await page.evaluate(() => window.runCanvasRendererCheck()) @@ -282,6 +750,38 @@ try { assert.deepEqual(result.interaction.selected, ['b']) assert.equal(result.interaction.tooltipVisible, true) assert.equal(result.interaction.destroyed, true) + assert.equal(result.mixed.length, 10) + assert.ok(result.mixedExportSize > 100) + assert.equal(result.update.rootPreserved, true) + assert.equal(result.update.canvasChanged, true) + assert.equal(result.update.svgChanged, true) + assert.equal(result.update.focusAligned, true, JSON.stringify(result.update)) + assert.equal(result.update.tooltipVisible, true) + for (const fixture of result.mixed) { + assert.ok(fixture.layers >= 3, fixture.id + ' omitted mixed layers') + assert.ok(fixture.canvases >= 1, fixture.id + ' omitted Canvas') + assert.ok(fixture.svgs >= 2, fixture.id + ' omitted SVG framing layers') + assert.ok(fixture.canvasInk > 20, fixture.id + ' produced no Canvas ink') + assert.equal(fixture.svgMark, true, fixture.id + ' omitted its SVG mark') + assert.equal( + fixture.rootPreserved, + true, + fixture.id + ' replaced its root on update', + ) + assert.equal(fixture.focused, true, fixture.id + ' lost keyboard focus') + assert.equal( + fixture.callbackSurface, + true, + fixture.id + ' exposed an invalid render callback surface', + ) + } + assert.deepEqual(result.mixed[0].layerTypes, [ + 'svg', + 'canvas', + 'svg', + 'canvas', + 'svg', + ]) await context.close() console.log('Canvas renderer browser check passed.') } catch (error) { diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index e012a789..fa94d189 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -835,19 +835,38 @@ async function verifyEsmRuntime() { ] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y', key: 'id' })], - x: { - scale: scaleLinear().domain([0, 1]), - axis: { - ticks: { values: [0, 1] }, - tickLabels: { - fontSize: 13, - opacity: 0.62, - anchor: ({ index }) => index === 0 ? 'start' : undefined, - dx: ({ index, bandwidth }) => index === 0 ? -bandwidth / 2 : undefined, + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: { values: [0, 1] }, + tickLabels: { + fontSize: 13, + opacity: 0.62, + anchor: ({ index }) => (index === 0 ? 'start' : undefined), + dx: ({ index, bandwidth }) => + index === 0 ? -bandwidth / 2 : undefined, + }, }, }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + }) + const mixedDefinition = defineChart({ + marks: [ + lineY(rows, { + id: 'canvas-line', + x: 'x', + y: 'y', + key: 'id', + renderer: canvasChartRenderer, + }), + lineY(rows, { id: 'svg-line', x: 'x', y: 'y', key: 'id' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, }, - y: { scale: scaleLinear().domain([0, 5]) }, }) const scene = createChartScene(definition, { width: 320, height: 180 }) const coreSvg = renderChartSvg(scene, { ariaLabel: 'Packed core chart' }) @@ -858,8 +877,10 @@ async function verifyEsmRuntime() { const horizontalDefinition = defineChart({ marks: [lineX(rows, { x: 'y', y: 'id', key: 'id' })], - x: { scale: scaleLinear().domain([0, 5]) }, - y: { scale: scaleBand().domain(['a', 'b']) }, + scales: { + x: { scale: scaleLinear().domain([0, 5]) }, + y: { scale: scaleBand().domain(['a', 'b']) }, + }, }) const horizontalScene = createChartScene(horizontalDefinition, { width: 320, @@ -880,15 +901,19 @@ async function verifyEsmRuntime() { { id: 'b:1', category: 'B', x: 1, height: 0.75 }, ] const ridgeScene = createChartScene(defineChart({ - marks: [ridgelineY(ridgeRows, { - x: 'x', - y: 'category', - height: 'height', - key: 'id', - })], + marks: [ + ridgelineY(ridgeRows, { + x: 'x', + y: 'category', + height: 'height', + key: 'id', + }), + ], guides: false, - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scalePoint().domain(['A', 'B']) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scalePoint().domain(['A', 'B']) }, + }, }), { width: 320, height: 180 }) const ridgeSvg = renderChartSvg(ridgeScene, { ariaLabel: 'Packed ridgeline chart', @@ -906,15 +931,19 @@ async function verifyEsmRuntime() { { id: 'b:1', category: 'B', value: 1, width: 0.75 }, ] const violinScene = createChartScene(defineChart({ - marks: [violinY(violinRows, { - x: 'category', - y: 'value', - width: 'width', - key: 'id', - })], + marks: [ + violinY(violinRows, { + x: 'category', + y: 'value', + width: 'width', + key: 'id', + }), + ], guides: false, - x: { scale: scalePoint().domain(['A', 'B']) }, - y: { scale: scaleLinear().domain([0, 1]) }, + scales: { + x: { scale: scalePoint().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, }), { width: 320, height: 180 }) const violinSvg = renderChartSvg(violinScene, { ariaLabel: 'Packed violin chart', @@ -934,8 +963,10 @@ async function verifyEsmRuntime() { samples: 3, }), ], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) const regressionScene = createChartScene(regressionDefinition, { width: 320, @@ -960,8 +991,10 @@ async function verifyEsmRuntime() { y2: 'y', }), ], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) const differenceScene = createChartScene(differenceDefinition, { width: 320, @@ -995,8 +1028,10 @@ async function verifyEsmRuntime() { layout: stack({ order: 'inside-out', offset: 'wiggle' }), }), ], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) const packedStreamScene = createChartScene(packedStreamDefinition, { width: 320, @@ -1028,8 +1063,10 @@ async function verifyEsmRuntime() { }), }), ], - x: { scale: scaleLinear() }, - y: { scale: scaleBand() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleBand() }, + }, }) const packedLikertScene = createChartScene(packedLikertDefinition, { width: 320, @@ -1109,15 +1146,18 @@ async function verifyEsmRuntime() { ] const packedCompositeDefinition = defineChart({ marks: [ - compositeMark([ - barY(packedSummaryRows, { - id: 'body', - x: 'category', - y: 'median', - y1: 'q1', - y2: 'q3', - }), - ], { id: 'packed-composite' }), + compositeMark( + [ + barY(packedSummaryRows, { + id: 'body', + x: 'category', + y: 'median', + y1: 'q1', + y2: 'q3', + }), + ], + { id: 'packed-composite' }, + ), boxY(packedBoxRows, { id: 'packed-box', x: 'category', @@ -1125,8 +1165,10 @@ async function verifyEsmRuntime() { key: 'id', }), ], - x: { scale: scaleBand().domain(['A', 'B']) }, - y: { scale: scaleLinear().domain([0, 10]) }, + scales: { + x: { scale: scaleBand().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, }) const packedCompositeScene = createChartScene(packedCompositeDefinition, { width: 320, @@ -1170,8 +1212,11 @@ async function verifyEsmRuntime() { yLabel: {}, }), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + focusRing: false, }) const focusGuideScene = createChartScene(focusGuideDefinition, { @@ -1214,8 +1259,11 @@ async function verifyEsmRuntime() { packedSelection, ), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + selection: packedSelection, }) const packedSelectionScene = createChartScene( @@ -1244,17 +1292,25 @@ async function verifyEsmRuntime() { let packedVisibleChange const packedInteractiveLegendDefinition = defineChart({ - marks: [lineY([ - { id: 'a', x: 0, y: 2, series: 'one' }, - { id: 'b', x: 1, y: 5, series: 'two' }, - ], { - x: 'x', - y: 'y', - color: 'series', - key: 'id', - })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + marks: [ + lineY( + [ + { id: 'a', x: 0, y: 2, series: 'one' }, + { id: 'b', x: 1, y: 5, series: 'two' }, + ], + { + x: 'x', + y: 'y', + color: 'series', + key: 'id', + }, + ), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + color: { domain: ['one', 'two'], legend: interactiveColorLegend({ @@ -1287,11 +1343,14 @@ async function verifyEsmRuntime() { const radialDefinition = defineChart({ marks: [ polar({ - angle: { scale: () => scaleBand() }, - radius: { - scale: scaleLinear().domain([0, 8]), - range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + scales: { + angle: { scale: () => scaleBand() }, + radius: { + scale: scaleLinear().domain([0, 8]), + range: [({ radius }) => radius * 0.25, ({ radius }) => radius], + }, }, + marks: [ radialBarRadius(radialRows, { angle: 'id', @@ -1302,11 +1361,14 @@ async function verifyEsmRuntime() { ], }), polar({ - angle: { scale: scaleLinear().domain([0, 8]) }, - radius: { - scale: () => scaleBand(), - range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + scales: { + angle: { scale: scaleLinear().domain([0, 8]) }, + radius: { + scale: () => scaleBand(), + range: [({ radius }) => radius * 0.2, ({ radius }) => radius], + }, }, + marks: [ radialBarAngle(radialRows, { angle: 'value', @@ -1318,6 +1380,10 @@ async function verifyEsmRuntime() { ], }), ], + scales: { + x: null, + y: null, + }, }) const radialSvg = renderChartSvg( createChartScene(radialDefinition, { width: 320, height: 180 }), @@ -1347,8 +1413,16 @@ async function verifyEsmRuntime() { color: 'branchId', }), ], + scales: { + angle: null, + radius: null, + }, }), ], + scales: { + x: null, + y: null, + }, }) const hierarchyScene = createChartScene(hierarchyDefinition, { width: 320, @@ -1411,6 +1485,10 @@ async function verifyEsmRuntime() { ], }), ], + scales: { + x: null, + y: null, + }, guides: false, }) const sankeyScene = createChartScene(sankeyDefinition, { @@ -1442,6 +1520,18 @@ async function verifyEsmRuntime() { assert.match(reactHtml, /Packed React chart/) assert.match(reactHtml, / + OctaneChart({ + definition: mixedDefinition, + ariaLabel: 'Packed mixed Octane chart', + width: 320, + height: 180, + }), + ) + assert.match(octaneMixedHtml, /ts-chart-layers/) + assert.match(octaneMixedHtml, / OctaneRendererChart({ renderer: canvasChartRenderer, @@ -1525,6 +1627,7 @@ async function verifyDeclarations() { type ChartHost, type ChartHostCommonOptions, type ChartHostOptions, + type ChartLayerRenderer, type ChartAxisTickLabelContext, type ChartAxisTickLabelValue, type ChartSpec, @@ -2083,8 +2186,10 @@ async function verifyDeclarations() { const packedViewLink: ViewLink = { x: 'main' } const packedViewChart = defineChart({ marks: [lineY(lineRows, lineYOptions)], - x: { scale: compactScaleLinear().domain([0, 5]) }, - y: { scale: compactScaleLinear().domain([0, 5]) }, + scales: { + x: { scale: compactScaleLinear().domain([0, 5]) }, + y: { scale: compactScaleLinear().domain([0, 5]) }, + }, }) const packedViewItem: ViewGridItem< typeof packedViewChart, @@ -2380,19 +2485,29 @@ async function verifyDeclarations() { const radialBarDefinition = defineChart({ marks: [ polar({ - angle: { scale: () => scaleBand() }, - radius: polarRadiusOptions, + scales: { + angle: { scale: () => scaleBand() }, + radius: polarRadiusOptions, + }, + marks: [radiusBars], }), polar({ - angle: { scale: scaleLinear().domain([0, 2]) }, - radius: { - scale: () => scaleBand(), - range: [0, ({ radius }) => radius], + scales: { + angle: { scale: scaleLinear().domain([0, 2]) }, + radius: { + scale: () => scaleBand(), + range: [0, ({ radius }) => radius], + }, }, + marks: [angleBars], }), ], + scales: { + x: null, + y: null, + }, }) if (false) { // @ts-expect-error Pie values must be numeric or nullish. @@ -2603,6 +2718,10 @@ async function verifyDeclarations() { }) const sankeyDefinition = defineChart({ marks: [sankeyMark], + scales: { + x: null, + y: null, + }, guides: false, }) type PackedSankeyNode = SankeyNode< @@ -2658,8 +2777,10 @@ async function verifyDeclarations() { }) const packedBoxDefinition = defineChart({ marks: [packedBoxMark], - x: { scale: scaleBand() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleBand() }, + y: { scale: scaleLinear() }, + }, }) type PackedBoxDatumIsDerived = Expect< Equal, BoxDatum> @@ -2908,16 +3029,19 @@ async function verifyDeclarations() { ] const definition = defineChart({ marks: [lineY(rows, { x: 'category', y: 'value', key: 'id' })], - x: { - scale: scaleBand().domain(rows.map((row) => row.category)), - axis: { - tickLabels: { - fontSize: packedTickFontSize, - anchor: packedTickAnchor, + scales: { + x: { + scale: scaleBand().domain(rows.map((row) => row.category)), + axis: { + tickLabels: { + fontSize: packedTickFontSize, + anchor: packedTickAnchor, + }, }, }, + y: { scale: scaleLinear().domain([0, 8]) }, }, - y: { scale: scaleLinear().domain([0, 8]) }, + focus: { resolve(points) { const point = points[0] @@ -2925,11 +3049,7 @@ async function verifyDeclarations() { type DatumIsRow = Expect> type XIsString = Expect> type YIsNumber = Expect> - const checks: [DatumIsRow, XIsString, YIsNumber] = [ - true, - true, - true, - ] + const checks: [DatumIsRow, XIsString, YIsNumber] = [true, true, true] void checks } return points @@ -2945,14 +3065,35 @@ async function verifyDeclarations() { }, }, }) + const mixedDefinition = defineChart({ + marks: [ + lineY(rows, { + x: 'category', + y: 'value', + key: 'id', + renderer: canvasChartRenderer, + }), + lineY(rows, { x: 'category', y: 'value', key: 'id' }), + ], + scales: { + x: { + scale: scaleBand().domain(rows.map((row) => row.category)), + }, + y: { scale: scaleLinear().domain([0, 8]) }, + }, + }) + const packedLayerRenderer: ChartLayerRenderer = + canvasChartRenderer + void packedLayerRenderer const compactDefinition = defineChart({ marks: [lineY(rows, { x: 'category', y: 'value', key: 'id' })], - x: { - scale: compactScaleBand().domain( - rows.map((row) => row.category), - ), + scales: { + x: { + scale: compactScaleBand().domain(rows.map((row) => row.category)), + }, + y: { scale: compactScaleLinear().domain([0, 8]) }, }, - y: { scale: compactScaleLinear().domain([0, 8]) }, + tooltip: { use: tooltip, portal, @@ -2980,11 +3121,13 @@ async function verifyDeclarations() { void compactDefinition const responsiveDefinition = defineChart(({ width }) => ({ marks: [lineY(rows, { x: 'category', y: 'value', key: 'id' })], - x: { - scale: scaleBand().domain(rows.map((row) => row.category)), - axis: { ticks: { count: width < 480 ? 3 : 5 } }, + scales: { + x: { + scale: scaleBand().domain(rows.map((row) => row.category)), + axis: { ticks: { count: width < 480 ? 3 : 5 } }, + }, + y: { scale: scaleLinear().domain([0, 8]) }, }, - y: { scale: scaleLinear().domain([0, 8]) }, })) const endpointMark = createMarkWithScaleValues< Row, @@ -3017,12 +3160,12 @@ async function verifyDeclarations() { })) const endpointDefinition = defineChart({ marks: [endpointMark], - x: { - scale: scaleBand().domain( - rows.map((row) => row.category), - ), + scales: { + x: { + scale: scaleBand().domain(rows.map((row) => row.category)), + }, + y: { scale: scaleLinear().domain([0, 8]) }, }, - y: { scale: scaleLinear().domain([0, 8]) }, }) type EndpointXIsNumber = Expect< Equal, number> @@ -3058,9 +3201,11 @@ async function verifyDeclarations() { ] const invalidEndpointSpec: ChartSpec = { marks: [endpointMark], - // @ts-expect-error The packed custom mark declares categorical x scale values. - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + // @ts-expect-error The packed custom mark declares categorical x scale values. + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, } void invalidEndpointSpec const container = document.createElement('div') @@ -3199,10 +3344,28 @@ async function verifyDeclarations() { definition: responsiveDefinition, ariaLabel: 'Responsive React chart', }) + ReactChart({ + definition: mixedDefinition, + ariaLabel: 'Mixed React chart', + onRender({ scene, surface, svg }) { + const point = scene.points[0] + point?.xValue.toUpperCase() + surface.layers?.forEach((layer) => layer.element) + svg.getAttribute('viewBox') + }, + }) OctaneChart({ definition: responsiveDefinition, ariaLabel: 'Responsive Octane chart', }) + OctaneChart({ + definition: mixedDefinition, + ariaLabel: 'Mixed Octane chart', + onRender({ surface, svg }) { + surface.element.getAttribute('role') + svg.getAttribute('viewBox') + }, + }) ReactRendererChart({ renderer: canvasChartRenderer, definition: responsiveDefinition, @@ -3340,8 +3503,11 @@ async function verifyDeclarations() { selection, ), ], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, + selection, }) const runtime = createChartRuntime() @@ -3651,8 +3817,10 @@ async function verifyProductionBundles() { const rows = [{ x: 0, y: 2 }, { x: 1, y: 5 }] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -3930,8 +4098,11 @@ async function verifyProductionBundles() { selection, ), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + selection, }) export const svg = renderChartSvg( @@ -3969,8 +4140,10 @@ async function verifyProductionBundles() { import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [decorative(lineY([2, 5, 3]))], - x: { scale: scaleLinear().domain([0, 2]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4035,8 +4208,10 @@ async function verifyProductionBundles() { ] const definition = defineChart({ marks: [lineX(rows, { x: 'value', y: 'category', key: 'id' })], - x: { scale: scaleLinear().domain([0, 5]) }, - y: { scale: scaleBand().domain(['A', 'B']) }, + scales: { + x: { scale: scaleLinear().domain([0, 5]) }, + y: { scale: scaleBand().domain(['A', 'B']) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4073,8 +4248,10 @@ async function verifyProductionBundles() { ] const definition = defineChart({ marks: [linearRegressionY(rows, { x: 'x', y: 'y', samples: 8 })], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4110,13 +4287,17 @@ async function verifyProductionBundles() { { x: 3, comparison: 2, primary: 1 }, ] const definition = defineChart({ - marks: [differenceY(rows, { - x: 'x', - y1: 'comparison', - y2: 'primary', - })], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + marks: [ + differenceY(rows, { + x: 'x', + y1: 'comparison', + y2: 'primary', + }), + ], + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4156,14 +4337,19 @@ async function verifyProductionBundles() { views: { overview: defineChart({ marks: [dot(rows, { x: 'x', y: 'y' })], - x: { scale: x }, - y: { scale: scaleLinear().domain([0, 2]) }, + scales: { + x: { scale: x }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, + guides: false, }), main: defineChart({ marks: [dot(rows, { x: 'x', y: 'y' })], - x: { scale: x }, - y: { scale: scaleLinear().domain([0, 2]) }, + scales: { + x: { scale: x }, + y: { scale: scaleLinear().domain([0, 2]) }, + }, }), }, layout: grid({ @@ -4316,16 +4502,23 @@ async function verifyProductionBundles() { import { scaleBand, scaleLinear } from 'd3-scale' const rows = [{ id: 'a', category: 'A', value: 4 }] const definition = defineChart({ - marks: [compositeMark([ - barY(rows, { - id: 'body', - x: 'category', - y: 'value', - key: 'id', - }), - ], { id: 'packed-composite' })], - x: { scale: scaleBand() }, - y: { scale: scaleLinear() }, + marks: [ + compositeMark( + [ + barY(rows, { + id: 'body', + x: 'category', + y: 'value', + key: 'id', + }), + ], + { id: 'packed-composite' }, + ), + ], + scales: { + x: { scale: scaleBand() }, + y: { scale: scaleLinear() }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4362,13 +4555,17 @@ async function verifyProductionBundles() { { id: 'e', category: 'A', value: 10 }, ] const definition = defineChart({ - marks: [boxY(rows, { - x: 'category', - y: 'value', - key: 'id', - })], - x: { scale: scaleBand() }, - y: { scale: scaleLinear() }, + marks: [ + boxY(rows, { + x: 'category', + y: 'value', + key: 'id', + }), + ], + scales: { + x: { scale: scaleBand() }, + y: { scale: scaleLinear() }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4403,15 +4600,19 @@ async function verifyProductionBundles() { { id: 'b:1', category: 'B', x: 1, height: 0.75 }, ] const definition = defineChart({ - marks: [ridgelineY(rows, { - x: 'x', - y: 'category', - height: 'height', - key: 'id', - })], + marks: [ + ridgelineY(rows, { + x: 'x', + y: 'category', + height: 'height', + key: 'id', + }), + ], guides: false, - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scalePoint().domain(['A', 'B']) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scalePoint().domain(['A', 'B']) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4448,15 +4649,19 @@ async function verifyProductionBundles() { { id: 'b:1', category: 'B', value: 1, width: 0.75 }, ] const definition = defineChart({ - marks: [violinY(rows, { - x: 'category', - y: 'value', - width: 'width', - key: 'id', - })], + marks: [ + violinY(rows, { + x: 'category', + y: 'value', + width: 'width', + key: 'id', + }), + ], guides: false, - x: { scale: scalePoint().domain(['A', 'B']) }, - y: { scale: scaleLinear().domain([0, 1]) }, + scales: { + x: { scale: scalePoint().domain(['A', 'B']) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4557,32 +4762,38 @@ async function verifyProductionBundles() { { id: 'middle-target', source: 'middle', target: 'target', value: 4 }, ] const definition = defineChart({ - marks: [sankeyDiagram({ - nodes, - links, - nodeKey: 'id', - source: 'source', - target: 'target', - value: 'value', - align: sankeyLeft, - marks: ({ nodes: laidOutNodes, links: laidOutLinks }) => [ - link(laidOutLinks, { - x1: 'x1', - y1: 'y1', - x2: 'x2', - y2: 'y2', - key: 'key', - strokeWidth: (flow) => flow.width, - }), - rect(laidOutNodes, { - x1: 'x0', - x2: 'x1', - y1: 'y0', - y2: 'y1', - key: 'key', - }), - ], - })], + marks: [ + sankeyDiagram({ + nodes, + links, + nodeKey: 'id', + source: 'source', + target: 'target', + value: 'value', + align: sankeyLeft, + marks: ({ nodes: laidOutNodes, links: laidOutLinks }) => [ + link(laidOutLinks, { + x1: 'x1', + y1: 'y1', + x2: 'x2', + y2: 'y2', + key: 'key', + strokeWidth: (flow) => flow.width, + }), + rect(laidOutNodes, { + x1: 'x0', + x2: 'x1', + y1: 'y0', + y2: 'y1', + key: 'key', + }), + ], + }), + ], + scales: { + x: null, + y: null, + }, guides: false, }) export const svg = renderChartSvg( @@ -4614,19 +4825,22 @@ async function verifyProductionBundles() { import { scaleLinear } from 'd3-scale' const definition = defineChart({ marks: [lineY([4, 9, 7])], - x: { - scale: scaleLinear().domain([0, 2]), - axis: { - ticks: { values: [0, 1, 2] }, - tickLabels: { - fontSize: 13, - opacity: 0.62, - anchor: ({ index }) => index === 0 ? 'start' : undefined, - dx: ({ index, bandwidth }) => index === 0 ? -bandwidth / 2 : undefined, + scales: { + x: { + scale: scaleLinear().domain([0, 2]), + axis: { + ticks: { values: [0, 1, 2] }, + tickLabels: { + fontSize: 13, + opacity: 0.62, + anchor: ({ index }) => (index === 0 ? 'start' : undefined), + dx: ({ index, bandwidth }) => + index === 0 ? -bandwidth / 2 : undefined, + }, }, }, + y: { scale: scaleLinear().domain([0, 10]) }, }, - y: { scale: scaleLinear().domain([0, 10]) }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4674,8 +4888,10 @@ async function verifyProductionBundles() { ], guides: false, focusRing: false, - x: { scale: scaleLinear().domain([0, 3]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 3]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4800,13 +5016,17 @@ async function verifyProductionBundles() { { x: 1, y: 5 }, ] const definition = defineChart({ - marks: [densityContour(rows, { - x: 'x', - y: 'y', - thresholds: [0.0001], - })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + marks: [ + densityContour(rows, { + x: 'x', + y: 'y', + thresholds: [0.0001], + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4832,11 +5052,17 @@ async function verifyProductionBundles() { import { contour } from '@tanstack/charts/spatial/contour' const values = [0, 0, 0, 0, 8, 0, 0, 0, 0] const definition = defineChart({ - marks: [contour(values, { - width: 3, - height: 3, - thresholds: [4], - })], + marks: [ + contour(values, { + width: 3, + height: 3, + thresholds: [4], + }), + ], + scales: { + x: null, + y: null, + }, guides: false, }) export const svg = renderChartSvg( @@ -4866,8 +5092,10 @@ async function verifyProductionBundles() { ] const definition = defineChart({ marks: [hexbin(rows, { x: 'x', y: 'y', binWidth: 20 })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4898,8 +5126,10 @@ async function verifyProductionBundles() { ] const definition = defineChart({ marks: [delaunayLink(rows, { x: 'x', y: 'y', key: 'id' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 1]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4944,8 +5174,10 @@ async function verifyProductionBundles() { stroke: '#fff', }), ], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 1]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 1]) }, + }, }) export const svg = renderChartSvg( createChartScene(definition, { width: 320, height: 180 }), @@ -4990,8 +5222,10 @@ async function verifyProductionBundles() { const rows = [{ x: 0, y: 2 }, { x: 1, y: 5 }] const definition = defineChart({ marks: [lineY(rows, { x: 'x', y: 'y' })], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, }) export const chart = createElement(Chart, { definition, @@ -5156,8 +5390,11 @@ async function verifyProductionBundles() { import { Chart } from '@tanstack/react-charts/tooltip' const definition = defineChart({ marks: [lineY([2, 5])], - x: { scale: scaleLinear().domain([0, 1]) }, - y: { scale: scaleLinear().domain([0, 5]) }, + scales: { + x: { scale: scaleLinear().domain([0, 1]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + tooltip, }) export const chart = createElement(Chart, { @@ -5218,6 +5455,7 @@ async function verifyProductionBundles() { absWorkingDir: fixtureDirectory, bundle: true, conditions: entry.conditions ?? ['browser', 'import', 'default'], + define: { 'process.env.NODE_ENV': '"production"' }, external: entry.external, format: 'esm', legalComments: 'none', @@ -5233,6 +5471,11 @@ async function verifyProductionBundles() { contents.byteLength > (entry.minimumBytes ?? 100), `${entry.label} bundle is empty`, ) + assert.equal( + contents.includes('enters Alpha'), + false, + `${entry.label} retained a development-only migration warning`, + ) assert.ok( contents.byteLength < 500_000, `${entry.label} bundle unexpectedly exceeds 500 kB`, diff --git a/scripts/compare-plot-catalog.mjs b/scripts/compare-plot-catalog.mjs index 71f7955a..3bd3b27c 100644 --- a/scripts/compare-plot-catalog.mjs +++ b/scripts/compare-plot-catalog.mjs @@ -834,8 +834,10 @@ function typeProtectionBaselines() { const rows: readonly Row[] = [{ x: 1, y: 2 }] defineChart({ marks: [lineY(rows, { x: 'x', y: 'y' })], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) `, } @@ -998,8 +1000,10 @@ function typeProtectionProbes() { const rows: readonly Row[] = [{ category: 'Alpha', value: 2 }] defineChart({ marks: [dot(rows, { x: 'category', y: 'value' })], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) `, }, @@ -1040,11 +1044,13 @@ function typeProtectionProbes() { const rows: readonly Row[] = [{ date: new Date(0), value: 2 }] defineChart({ marks: [lineY(rows, { x: 'date', y: 'value' })], - x: { - scale: scaleUtc(), - format: (value) => value.toFixed(2), + scales: { + x: { + scale: scaleUtc(), + format: (value) => value.toFixed(2), + }, + y: { scale: scaleLinear() }, }, - y: { scale: scaleLinear() }, }) `, }, @@ -1097,8 +1103,10 @@ function typeProtectionProbes() { y2: 'high', }), ], - x: { scale: scaleLinear() }, - y: { scale: scaleLinear() }, + scales: { + x: { scale: scaleLinear() }, + y: { scale: scaleLinear() }, + }, }) `, }, diff --git a/scripts/docs-contract.mjs b/scripts/docs-contract.mjs index ac6dfe72..55dae187 100644 --- a/scripts/docs-contract.mjs +++ b/scripts/docs-contract.mjs @@ -648,13 +648,15 @@ async function validateComparisonEvidence( bundles.map((bundle) => bundle.incrementalGzipBytes), ) const expectedIncremental = - fullRange === incrementalRange ? '—' : incrementalRange + fullRange === incrementalRange ? null : incrementalRange if ( !rows.some( (row) => row[0] === library.label && row[1] === fullRange && - row[2] === expectedIncremental, + (expectedIncremental === null + ? row[2] === '\u2014' || row[2] === 'Not applicable' + : row[2] === expectedIncremental), ) ) { failures.push( diff --git a/scripts/evaluate-chart-authoring.mjs b/scripts/evaluate-chart-authoring.mjs index c758b3c2..1c75502a 100644 --- a/scripts/evaluate-chart-authoring.mjs +++ b/scripts/evaluate-chart-authoring.mjs @@ -384,7 +384,7 @@ recipes shipped with the local package. - Marks consume materialized rows; keep TanStack data transforms beside the definition and memoize them through application reactivity. -- \`defineChart({ marks, x, y, svgAnimation: false, keyboard: false })\` creates a +- \`defineChart({ marks, scales: { x, y }, svgAnimation: false, keyboard: false })\` creates a static definition. Chart behavior belongs to the definition. - Each materialized positional dimension requires a D3 scale factory or configured instance. A factory infers its domain from mark channels; an diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index f9e2e220..e1310cc3 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -264,7 +264,7 @@ const retainedInputGroups = { ], polarPie: [/(?:^|\/)packages\/charts-core\/src\/polar-pie\.ts$/u], markInfrastructure: [ - /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|scales)\.ts$/u, + /(?:^|\/)packages\/charts-core\/src\/(?:guide-layout|mark|mark-with-scale-values|materialized-channel-internal|scales)\.ts$/u, ], rectMark: [/(?:^|\/)packages\/charts-core\/src\/rect\.ts$/u], nativeTooltip: [ @@ -495,7 +495,7 @@ const entries = [ 'Network Sankey mark', 'benchmarks/entries/charts-network-sankey.ts', 'D3 Sankey kernel', - 6.1, + 6.24, { inputBoundary: { require: [ @@ -552,7 +552,7 @@ const entries = [ 'Hierarchy treemap mark', 'benchmarks/entries/charts-hierarchy-treemap.ts', 'D3 hierarchy treemap kernel', - 3.65, + 3.74, { inputBoundary: { require: ['hierarchyFlat', 'hierarchyTreemap', 'd3Hierarchy'], @@ -578,7 +578,7 @@ const entries = [ 'Hierarchy sunburst mark', 'benchmarks/entries/charts-hierarchy-sunburst.ts', 'D3 hierarchy partition kernel', - 5.4, + 5.45, { inputBoundary: { require: [ @@ -671,37 +671,37 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 19.15, + 20.24, ), budgeted( 'D3-scale UTC line + static SVG', 'benchmarks/entries/charts-time-svg.ts', - 23.75, + 24.9, ), budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 21.6, + 22.78, ), budgeted( 'D3-scale facets + static SVG', 'benchmarks/entries/charts-facet-svg.ts', - 22.65, + 23.94, ), budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 19.05, + 20.2, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 23.05, + 24.23, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 19.5, + 20.67, { inputBoundary: { forbid: [ @@ -764,7 +764,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 10.5, + 11.63, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -903,7 +903,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 26.5, + 27.71, { inputBoundary: { require: [ @@ -943,7 +943,7 @@ const entries = [ 'Linear regression + static SVG', 'benchmarks/entries/charts-regression-svg.ts', 'D3-scale line + static SVG', - 6.5, + 6.54, { inputBoundary: { require: [ @@ -977,7 +977,7 @@ const entries = [ 'Difference mark + static SVG', 'benchmarks/entries/charts-difference-svg.ts', 'D3-scale line + static SVG', - 6.95, + 6.98, { inputBoundary: { require: [ @@ -1049,7 +1049,7 @@ const entries = [ budgeted( 'Custom mark scale-value factory', 'benchmarks/entries/charts-mark-scale-values.ts', - 0.27, + 0.39, ), measured( 'Crosshair mark extension', @@ -1072,7 +1072,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 19, + 20.12, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1102,7 +1102,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 19, + 20.1, { inputBoundary: { forbid: [ @@ -1142,22 +1142,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 19.95, + 21.13, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 19.2, + 20.31, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 16.45, + 17.58, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 15.1, + 16.56, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1176,18 +1176,18 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 24.1, + 25.69, ), budgeted( 'Radial labels + static SVG', 'benchmarks/entries/charts-radial-label-svg.ts', - 20.7, + 22.2, { inputBoundary: { forbid: ['polarPie'] } }, ), budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 24.2, + 25.7, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1198,12 +1198,36 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 25.32, + 26.8, ), locked( 'Representative marks', 'benchmarks/entries/charts-representative.ts', { + rendererBoundary: 'svg', + inputBoundary: { + forbid: [ + 'dodgeLayout', + 'networkForce', + 'd3Force', + ...optionalHierarchyInputGroups, + 'spatialDensity', + 'spatialContour', + 'spatialGrouping', + 'd3Contour', + 'spatialDelaunay', + 'spatialVoronoi', + 'd3Delaunay', + ], + }, + }, + ), + budgeted( + 'Representative marks + mark Canvas renderer', + 'benchmarks/entries/charts-representative-mark-canvas.ts', + 35.8, + { + rendererBoundary: 'mixed', inputBoundary: { forbid: [ 'dodgeLayout', @@ -1532,8 +1556,19 @@ const entries = [ }), locked('React line consumer', 'benchmarks/entries/charts-react-line.ts', { external: ['react', 'react/jsx-runtime', 'react-dom'], + rendererBoundary: 'svg', inputBoundary: { forbid: ['d3GeometryRuntime', 'spatialVoronoi'] }, }), + budgeted( + 'React line consumer + mark Canvas renderer', + 'benchmarks/entries/charts-react-line-mark-canvas.ts', + 41.8, + { + external: ['react', 'react/jsx-runtime', 'react-dom'], + rendererBoundary: 'mixed', + inputBoundary: { forbid: ['d3GeometryRuntime', 'spatialVoronoi'] }, + }, + ), measured( 'React Native SVG host', 'benchmarks/entries/charts-react-native.ts', @@ -1607,7 +1642,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 10.55, + 11.68, { inputBoundary: { require: ['compactLinear'], @@ -1626,7 +1661,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 27.2, + 28.41, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1688,7 +1723,7 @@ const entries = [ budgeted( 'Motion SVG renderer', 'benchmarks/entries/charts-motion-svg-renderer.ts', - 20.75, + 20.91, { rendererBoundary: 'svg', inputBoundary: { @@ -1794,7 +1829,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 51.4, + 52.72, ), locked( 'Custom-scale line scene', @@ -1820,37 +1855,37 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 19.7, + 20.9, ), budgeted( 'D3 time-scale line scene', 'benchmarks/entries/charts-d3-time-scene.ts', - 22.05, + 23.21, ), budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 21.45, + 22.61, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 20.35, + 21.5, ), budgeted( 'Direct D3 time + TanStack UTC line', 'benchmarks/entries/charts-d3-time-svg.ts', - 23.75, + 24.9, ), budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 35.5, + 36.79, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 40.75, + 42.01, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2054,7 +2089,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 52.3, + 53.61, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'), @@ -2106,6 +2141,7 @@ await runWithConcurrency( platform: platform ?? 'browser', format: 'esm', target: 'es2022', + define: { 'process.env.NODE_ENV': '"production"' }, legalComments: 'none', logLevel: 'silent', external, @@ -2361,6 +2397,9 @@ function assertRendererBoundary(label, inputs, boundary) { } else if (boundary === 'svg') { if (!svg.length) failures.push('did not include the SVG renderer') if (canvas.length) failures.push(`included Canvas: ${canvas.join(', ')}`) + } else if (boundary === 'mixed') { + if (!canvas.length) failures.push('did not include the Canvas renderer') + if (!svg.length) failures.push('did not include the SVG renderer') } else if (boundary === 'native') { if (!native.length) failures.push('did not include the native SVG host') if (browser.length) { diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 8ef428d2..3d47bf56 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -57,7 +57,7 @@ const callbackInventory = { ['@tanstack/charts:src/box.ts:BoxYOptions', 'x y'], [ '@tanstack/charts:src/difference.ts:DifferenceOptions', - 'comparisonStroke key motion negativeFill positiveFill stroke z', + 'comparisonStroke key negativeFill positiveFill stroke z', ], ['@tanstack/charts:src/difference.ts:DifferenceXOptions', 'x1 x2 y'], ['@tanstack/charts:src/difference.ts:DifferenceYOptions', 'x y1 y2'], @@ -197,7 +197,7 @@ const callbackInventory = { ['@tanstack/charts:src/network-force.ts:ForceFactoryDescriptor', 'create'], [ '@tanstack/charts:src/network-sankey.ts:SankeyDiagramOptions', - 'inset linkKey marks motion nodePadding nodeWidth', + 'inset linkKey marks nodePadding nodeWidth', ], ['@tanstack/charts:src/network-sankey.ts:SankeyLayoutValue', '$call'], ['@tanstack/charts:src/polar.ts:AngleGridOptions', 'format'], @@ -208,13 +208,11 @@ const callbackInventory = { 'labelAnchor labelBaseline labelDx labelDy labelRotate', ], ['@tanstack/charts:src/polar.ts:PolarLength', '$call'], - [ - '@tanstack/charts:src/polar-mark-internal.ts:PolarMark', - 'initialize motion', - ], + ['@tanstack/charts:src/polar.ts:PolarMark', 'initialize motion'], + ['@tanstack/charts:src/polar.ts:InitializedPolarMark', 'render'], [ '@tanstack/charts:src/polar-mark-internal.ts:InitializedPolarMark', - 'motion render', + 'motion', ], ['@tanstack/charts:src/polar-pie.ts:PieOptions', 'value'], [ @@ -610,6 +608,7 @@ const callbackInventory = { 'clear clearOwnedTransient createFocusState createFreeState destroy getState owns publish resolveFocus resolvePresentation subscribe subscribe.$return', ], ['@tanstack/charts:src/dom-types.ts:ChartHost', 'destroy getScene update'], + ['@tanstack/charts:src/dom-types.ts:ChartLayerRenderer', 'compose'], ['@tanstack/charts:src/dom-types.ts:ChartRenderer', 'mount prerender'], [ '@tanstack/charts:src/dom-types.ts:ChartRendererTooltipMotionCapability', @@ -623,6 +622,10 @@ const callbackInventory = { '@tanstack/charts:src/dom-types.ts:UniversalChartRenderer', 'mount prerender', ], + [ + '@tanstack/charts:src/dom-types.ts:UniversalChartLayerRenderer', + 'compose', + ], [ '@tanstack/charts:src/dom-types.ts:ChartRendererHost', 'destroy getScene update', diff --git a/scripts/sync-package-docs.mjs b/scripts/sync-package-docs.mjs index d1a52a45..a167eda0 100644 --- a/scripts/sync-package-docs.mjs +++ b/scripts/sync-package-docs.mjs @@ -88,10 +88,13 @@ export async function createLlmsIndex(root) { '- Start with exact @tanstack/charts/scales/linear, @tanstack/charts/scales/band, @tanstack/charts/scales/point, or @tanstack/charts/scales/ordinal entries. Upgrade only the mapping that needs temporal, nonlinear, radial, interpolated, statistical, or other complete D3 semantics; never import the d3 umbrella.', '- Treat Date values on compact band or point scales as equally spaced categories. Use d3-scale scaleTime or scaleUtc when elapsed-time spacing or calendar-aware ticks matter.', '- Let TanStack Charts own responsive pixel ranges. Scale factories infer domains from mark channels; configured instances preserve application-owned domains.', + '- Put Cartesian scale and guide options under scales.x and scales.y; never generate deprecated root x or y properties.', + '- Put polar position scales under scales.angle and scales.radius inside polar(); never generate deprecated direct angle or radius properties.', '- Keep data in its application shape. Map fields or accessors into marks instead of creating a library-owned series model.', '- Memoize the complete definition against captured application values; definition identity is the application update boundary.', '- Preserve inferable datum identity across updates; add explicit keys only when IDs or unique positions are unavailable.', '- Prefer built-in marks, then composition, then a custom mark or application-owned overlay.', + "- Keep the default SVG renderer unless a measured paint-heavy mark benefits from Canvas. Import canvasChartRenderer from @tanstack/charts/canvas and set only that mark's renderer option when mixing surfaces.", '- Treat docs/concepts/scales-and-d3.md as the sole scale-selection and D3 integration contract and follow its official D3 links for D3 API details.', '- Reference a canonical catalog case with ``. The documentation site resolves its source-backed workspace; never author a catalog iframe or duplicate the case source in a docs page.', '- Do not use casts, suppression comments, private imports, or adapter generics to force a chart through TypeScript.',