diff --git a/.changeset/soft-legends-flow.md b/.changeset/soft-legends-flow.md new file mode 100644 index 00000000..9485729f --- /dev/null +++ b/.changeset/soft-legends-flow.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': minor +--- + +Add tree-shakeable, host-measured categorical legend item layouts with +configurable spacing, typography, label paint, built-in indicator shapes, and +custom indicator scene renderers. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..e6a07172 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-26 +Last updated: 2026-08-27 ## Triage rule @@ -331,6 +331,7 @@ Each entry records: | F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved | | F-293 | Root scale slots blocked named axes | API | resolved | | F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved | +| F-295 | Categorical legend styling required a replacement renderer | API | resolved | ## Findings @@ -8505,3 +8506,24 @@ Each entry records: React Native Metro gates, and framework package checks pass. Bundle boundary checks keep SVG-only entries free of Canvas and measure the opt-in mixed representative and React consumers at 35.68 KiB and 41.63 KiB gzip. + +### F-295 - Categorical legend styling required a replacement renderer + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: replacing Rewardo's line, bar, mixed, and pie chart legends +- Friction: changing label typography, series-colored labels, spacing, or + indicators required a complete `ChartColorLegend`. The application repeated + color-domain resolution, estimated text widths, wrapped rows, centered each + row, and reserved legend height itself. +- Decision: let `colorLegend()` accept a `colorLegendItems()` presentation for + categorical labels and indicators while retaining chart-owned measurement + and wrapping. The separate factory keeps its machinery out of default legend + bundles. Keep the equal-column layout as the default. Compact start and center + layouts use the chart host's configured text measurer. Indicator callbacks + receive resolved color-scale items and a measured bounds box. +- Verification: focused layout, measurement, renderer, type, and mixed-mark + tests cover compact wrapping, per-series symbols, resolved label paint, and + custom scene indicators. The composed and pie catalog examples use the + built-in legend instead of application-owned layout. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index bb9c0e54..8134244b 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -92,6 +92,14 @@ The interactive categorical legend fixture adds 2.53 KiB gzip over the DOM host and has a 2.6 KiB incremental cap. Both retain only their declared exact subpath modules. +The static categorical legend fixtures measure the default legend separately +from the tree-shakeable `colorLegendItems()` presentation. The configured +fixture covers centered flow layout, custom label paint, and mixed square and +line-dot indicators through the exact `@tanstack/charts/legend` subpath. Their +retained-input gates reject the interactive legend, host renderers, +interactions, and D3 geometry. The default fixture has a 1.8 KiB gzip ceiling; +item presentation may add at most 1.15 KiB gzip. + Update the universal baseline only after reviewing why a shared path changed: ```sh diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..f0a02b88 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": 50813, - "gzip": 19070 + "bytes": 50840, + "gzip": 19076 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55571, + "gzip": 20782 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75644, + "gzip": 27587 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 74974, + "gzip": 26058 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77157, + "gzip": 26837 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 100793, + "gzip": 36228 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33292, + "gzip": 11941 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83293, + "gzip": 29150 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31474, + "gzip": 11200 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50772, + "gzip": 19039 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..920c6bf3 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-26T21:51:12.832Z", + "generatedAt": "2026-08-27T22:42:28.689Z", "packageVersions": { - "tanstack": "0.15.0", + "tanstack": "0.16.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e" + "revision": "2ddcf661897ba305e81b83f80dd9c802bea2307d", + "inputDigest": "sha256:d4aaf3d3e5bf3586a218124ce822e6a0484f6ea9e041c806d47380be9b936db2" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110431, - "gzipBytes": 39891, - "brotliBytes": 35325, - "incrementalGzipBytes": 39891, - "incrementalBrotliBytes": 35325 + "minifiedBytes": 110458, + "gzipBytes": 39896, + "brotliBytes": 35340, + "incrementalGzipBytes": 39896, + "incrementalBrotliBytes": 35340 }, "tanstack-line-interactive": { - "minifiedBytes": 115872, - "gzipBytes": 41648, - "brotliBytes": 36746, - "incrementalGzipBytes": 41648, - "incrementalBrotliBytes": 36746 + "minifiedBytes": 116019, + "gzipBytes": 41709, + "brotliBytes": 36809, + "incrementalGzipBytes": 41709, + "incrementalBrotliBytes": 36809 }, "tanstack-line-advanced": { - "minifiedBytes": 123062, - "gzipBytes": 43974, - "brotliBytes": 38764, - "incrementalGzipBytes": 43974, - "incrementalBrotliBytes": 38764 + "minifiedBytes": 123209, + "gzipBytes": 44037, + "brotliBytes": 38748, + "incrementalGzipBytes": 44037, + "incrementalBrotliBytes": 38748 }, "tanstack-bar-basic": { - "minifiedBytes": 119225, - "gzipBytes": 43210, - "brotliBytes": 38168, - "incrementalGzipBytes": 43210, - "incrementalBrotliBytes": 38168 + "minifiedBytes": 119252, + "gzipBytes": 43216, + "brotliBytes": 38134, + "incrementalGzipBytes": 43216, + "incrementalBrotliBytes": 38134 }, "tanstack-bar-interactive": { - "minifiedBytes": 123521, - "gzipBytes": 44585, - "brotliBytes": 39223, - "incrementalGzipBytes": 44585, - "incrementalBrotliBytes": 39223 + "minifiedBytes": 123668, + "gzipBytes": 44643, + "brotliBytes": 39213, + "incrementalGzipBytes": 44643, + "incrementalBrotliBytes": 39213 }, "tanstack-bar-advanced": { - "minifiedBytes": 123860, - "gzipBytes": 44735, - "brotliBytes": 39349, - "incrementalGzipBytes": 44735, - "incrementalBrotliBytes": 39349 + "minifiedBytes": 124007, + "gzipBytes": 44794, + "brotliBytes": 39345, + "incrementalGzipBytes": 44794, + "incrementalBrotliBytes": 39345 }, "tanstack-area-basic": { - "minifiedBytes": 115640, - "gzipBytes": 41867, - "brotliBytes": 37073, - "incrementalGzipBytes": 41867, - "incrementalBrotliBytes": 37073 + "minifiedBytes": 115667, + "gzipBytes": 41871, + "brotliBytes": 37074, + "incrementalGzipBytes": 41871, + "incrementalBrotliBytes": 37074 }, "tanstack-area-interactive": { - "minifiedBytes": 121085, - "gzipBytes": 43594, - "brotliBytes": 38493, - "incrementalGzipBytes": 43594, - "incrementalBrotliBytes": 38493 + "minifiedBytes": 121232, + "gzipBytes": 43648, + "brotliBytes": 38510, + "incrementalGzipBytes": 43648, + "incrementalBrotliBytes": 38510 }, "tanstack-area-advanced": { - "minifiedBytes": 128457, - "gzipBytes": 46057, - "brotliBytes": 40527, - "incrementalGzipBytes": 46057, - "incrementalBrotliBytes": 40527 + "minifiedBytes": 128604, + "gzipBytes": 46114, + "brotliBytes": 40644, + "incrementalGzipBytes": 46114, + "incrementalBrotliBytes": 40644 }, "tanstack-scatter-basic": { - "minifiedBytes": 111460, - "gzipBytes": 40266, - "brotliBytes": 35596, - "incrementalGzipBytes": 40266, - "incrementalBrotliBytes": 35596 + "minifiedBytes": 111487, + "gzipBytes": 40271, + "brotliBytes": 35668, + "incrementalGzipBytes": 40271, + "incrementalBrotliBytes": 35668 }, "tanstack-scatter-interactive": { - "minifiedBytes": 116901, - "gzipBytes": 42015, - "brotliBytes": 36976, - "incrementalGzipBytes": 42015, - "incrementalBrotliBytes": 36976 + "minifiedBytes": 117044, + "gzipBytes": 42075, + "brotliBytes": 37041, + "incrementalGzipBytes": 42075, + "incrementalBrotliBytes": 37041 }, "tanstack-scatter-advanced": { - "minifiedBytes": 116917, - "gzipBytes": 42020, - "brotliBytes": 37012, - "incrementalGzipBytes": 42020, - "incrementalBrotliBytes": 37012 + "minifiedBytes": 117060, + "gzipBytes": 42081, + "brotliBytes": 37123, + "incrementalGzipBytes": 42081, + "incrementalBrotliBytes": 37123 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx index cb193587..1625dea2 100644 --- a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx +++ b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx @@ -1,4 +1,9 @@ -import { defineChart, type ChartPoint } from '@tanstack/charts' +import { + colorLegend, + colorLegendItems, + defineChart, + type ChartPoint, +} from '@tanstack/charts' import { focusGroupAngle, pie, polar, radialArc } from '@tanstack/charts/polar' import { RendererChart } from '@tanstack/charts/react/tooltip' import { tooltip } from '@tanstack/charts/tooltip' @@ -6,6 +11,18 @@ import { motion } from '@tanstack/charts/motion' import { shadcnBrowsers, shadcnColors } from '@tanstack/charts-data/shadcn' import './styles.css' const browserNames = shadcnBrowsers.map((row) => row.browser) +const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +} as const + export function createExampleChart() { const arcs = pie(shadcnBrowsers, { value: 'visitors', @@ -40,7 +57,29 @@ export function createExampleChart() { x: null, y: null, }, - color: { domain: browserNames, range: shadcnColors }, + color: { + domain: browserNames, + range: shadcnColors, + legend: colorLegend({ + placement: 'bottom', + items: colorLegendItems({ + justify: 'center', + gap: 12, + rowGap: 8, + indicator: { + shape: 'square', + width: 12, + height: 12, + gap: 6, + }, + label: { + format: titleCase, + fontSize: 12, + fill: (_browser, { color }) => color, + }, + }), + }), + }, margin: 0, }, { @@ -121,26 +160,13 @@ export default function Example({ width = 640, height = 600 }: ExampleProps) { ariaLabel="Pie Chart - Legend" /> -
- -
+ ) } -function Legend() { - return ( - <> - {['chrome', 'safari', 'firefox', 'edge', 'other'].map((label, index) => ( - - - {titleCase(label)} - - ))} - - ) -} diff --git a/benchmarks/conformance/cases/168-shadcn-pie-legend/styles.css b/benchmarks/conformance/cases/168-shadcn-pie-legend/styles.css index cc9b9905..82ad9dae 100644 --- a/benchmarks/conformance/cases/168-shadcn-pie-legend/styles.css +++ b/benchmarks/conformance/cases/168-shadcn-pie-legend/styles.css @@ -90,25 +90,6 @@ .sc-chart > * { display: block; } -.sc-chart-footer { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 12px; - margin-top: 12px; - color: var(--muted-foreground); - font-size: 12px; -} -.sc-legend-item { - display: inline-flex; - align-items: center; - gap: 6px; -} -.sc-legend-dot { - width: 8px; - height: 8px; - border-radius: 2px; -} .sc-centered { justify-content: center; align-items: center; diff --git a/benchmarks/conformance/cases/70-composed-chart/case.json b/benchmarks/conformance/cases/70-composed-chart/case.json index 62dac152..c7c6b6f4 100644 --- a/benchmarks/conformance/cases/70-composed-chart/case.json +++ b/benchmarks/conformance/cases/70-composed-chart/case.json @@ -13,7 +13,8 @@ "left axis and stacked right axes", "daily observations", "monotone curves", - "resolved maximum bar thickness" + "resolved maximum bar thickness", + "mixed-mark color legend" ], "geometry": [ { "role": "area", "count": 1 }, diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index 02187acb..8386e3d2 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -2,11 +2,27 @@ import { Chart } from '@tanstack/charts/react/tooltip' import { tooltip as exampleTooltip } from '@tanstack/charts/tooltip' import { weather } from '@tanstack/charts-data/weather' -import { areaY, barY, d3Curve, defineChart, dot, lineY } from '@tanstack/charts' +import { + areaY, + barY, + colorLegend, + colorLegendItems, + d3Curve, + defineChart, + dot, + lineY, +} from '@tanstack/charts' import { scaleBand, scaleLinear } from 'd3-scale' import { curveMonotoneX } from 'd3-shape' const monotone = d3Curve(curveMonotoneX) +const weatherSeries = { + high: 'High temperature', + precipitation: 'Precipitation', + low: 'Low temperature', + wind: 'Wind', +} as const +type WeatherSeries = (typeof weatherSeries)[keyof typeof weatherSeries] const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', @@ -23,6 +39,7 @@ export const createExampleChart = (input: ChartOptions) => { areaY(rows, { x: 'date', y: 'temp_max', + color: () => weatherSeries.high, fill: '#8884d8', fillOpacity: 0.2, stroke: '#8884d8', @@ -32,6 +49,7 @@ export const createExampleChart = (input: ChartOptions) => { id: 'precipitation-bars', x: 'date', y: 'precipitation', + color: () => weatherSeries.precipitation, yScale: 'precipitation', fill: '#413ea0', maxThickness: 20, @@ -39,6 +57,7 @@ export const createExampleChart = (input: ChartOptions) => { lineY(rows, { x: 'date', y: 'temp_min', + color: () => weatherSeries.low, stroke: '#ff7300', strokeWidth: 2, curve: monotone, @@ -47,6 +66,7 @@ export const createExampleChart = (input: ChartOptions) => { id: 'wind-points', x: 'date', y: 'wind', + color: () => weatherSeries.wind, yScale: 'wind', fill: '#ef4444', r: 4.5, @@ -87,7 +107,34 @@ export const createExampleChart = (input: ChartOptions) => { }, }, - margin: { top: 20, bottom: 50 }, + color: { + domain: Object.values(weatherSeries), + range: ['#8884d8', '#413ea0', '#ff7300', '#ef4444'], + legend: colorLegend({ + placement: 'bottom', + items: colorLegendItems({ + justify: 'center', + gap: 18, + rowGap: 8, + indicator: { + width: 18, + height: 12, + shape: (series) => + series === weatherSeries.low + ? 'line-dot' + : series === weatherSeries.wind + ? 'dot' + : 'square', + }, + label: { + fontSize: 12, + fill: (_series, { color }) => color, + }, + }), + }), + }, + + margin: { top: 20 }, }, { keyboard: true, tooltip: exampleTooltip }, ) diff --git a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts index ec59bc74..1177a15b 100644 --- a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts +++ b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts @@ -17,7 +17,11 @@ describe('native composed-chart bar sizing', () => { const points = scene.points.filter( ({ markId }) => markId === 'precipitation-bars', ) - const bars = flatten(scene.nodes).filter((node) => node.kind === 'rect') + const bars = flatten(scene.nodes).filter( + (node) => + node.kind === 'rect' && + node.interaction?.point?.markId === 'precipitation-bars', + ) expect(points).toHaveLength(6) expect(bars).toHaveLength(6) @@ -30,7 +34,11 @@ describe('native composed-chart bar sizing', () => { it('keeps narrower responsive bands instead of forcing 20 pixels', () => { const scene = render({ ...input, width: 180 }) - const bars = flatten(scene.nodes).filter((node) => node.kind === 'rect') + const bars = flatten(scene.nodes).filter( + (node) => + node.kind === 'rect' && + node.interaction?.point?.markId === 'precipitation-bars', + ) expect(scene.scales.x.bandwidth).toBeLessThan(20) expect(bars).toHaveLength(6) diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 9d3c84f0..f6171bd2 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -2394,7 +2394,8 @@ "left axis and stacked right axes", "daily observations", "monotone curves", - "resolved maximum bar thickness" + "resolved maximum bar thickness", + "mixed-mark color legend" ], "geometry": [ { diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg index 5de7df71..c706dcd9 100644 --- a/benchmarks/conformance/previews/70-composed-chart.svg +++ b/benchmarks/conformance/previews/70-composed-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..9fb7c61e 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293", + "sourceHash": "8ca5210cc2b914ddf13f5dc5c389df299842160e168e6006dce08553da5a06b9", "assets": [ { "id": "01-line-gaps", @@ -306,8 +306,8 @@ }, { "id": "70-composed-chart", - "sha256": "642139631cae72ef76ff81e148e6d40e2ff83225a11b582a32878d0f166beae7", - "bytes": 14436 + "sha256": "0b6ea9f8d2c3791e23d9aaa2f023626ca0d683286af952c4e9b930b2f73b63f4", + "bytes": 14675 }, { "id": "71-recharts-population-pyramid", diff --git a/benchmarks/entries/charts-categorical-legend-items.ts b/benchmarks/entries/charts-categorical-legend-items.ts new file mode 100644 index 00000000..23751d87 --- /dev/null +++ b/benchmarks/entries/charts-categorical-legend-items.ts @@ -0,0 +1,20 @@ +import { colorLegend, colorLegendItems } from '@tanstack/charts/legend' + +export const legend = colorLegend<'Revenue' | 'Orders'>({ + placement: 'bottom', + items: colorLegendItems({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { + width: 20, + height: 14, + gap: 6, + shape: (series) => (series === 'Revenue' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_series, { color }) => color, + }, + }), +}) diff --git a/benchmarks/entries/charts-categorical-legend.ts b/benchmarks/entries/charts-categorical-legend.ts new file mode 100644 index 00000000..cd25a89b --- /dev/null +++ b/benchmarks/entries/charts-categorical-legend.ts @@ -0,0 +1,3 @@ +import { colorLegend } from '@tanstack/charts/legend' + +export const legend = colorLegend() diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..83737425 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2ddcf66` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `3df87d7`. +at page render time. The measured TanStack workspace revision is `2ddcf66`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.96–45.03 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/guides/legends-and-color.md b/docs/guides/legends-and-color.md index 0ca069bc..b176191a 100644 --- a/docs/guides/legends-and-color.md +++ b/docs/guides/legends-and-color.md @@ -105,6 +105,7 @@ Options: - `label`: optional legend title; - `itemWidth`: minimum categorical item width; +- `items`: categorical item presentation from `colorLegendItems()`; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -113,6 +114,40 @@ The legend reserves its own layout height. It is visual guidance and is hidden from the SVG accessibility tree; essential category meaning should also be available through direct labels, surrounding HTML, or a table. +The default categorical legend stretches equal-width item columns. Pass +`colorLegendItems()` to configure compact rows, typography, spacing, label +paint, or indicator shape. `justify: 'start'` and `'center'` use the chart +host's text measurer, including configured typography, to wrap formatted +labels responsively: + +```ts +import { colorLegend, colorLegendItems } from '@tanstack/charts' + +colorLegend({ + placement: 'bottom', + items: colorLegendItems<'Revenue' | 'Orders'>({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { + width: 20, + height: 14, + gap: 6, + shape: (series) => (series === 'Revenue' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_series, { color }) => color, + }, + }), +}) +``` + +`dot`, `square`, `line`, and `line-dot` are renderer-neutral scene shapes. +For another symbol, provide `indicator.render` to `colorLegendItems()`; its +context contains the resolved item color and the indicator bounds. The legend +still owns row measurement and wrapping. + ```ts group=automatic-color-legend env=charts file=/src/chart.ts entry import { colorLegend, defineChart, lineY } from '@tanstack/charts' import { scaleLinear } from '@tanstack/charts/scales/linear' diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index 69af1cab..065f5ba9 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -585,11 +585,17 @@ automatically. ## Automatic color legend ```ts -import { colorLegend } from '@tanstack/charts/legend' +import { colorLegend, colorLegendItems } from '@tanstack/charts/legend' colorLegend({ label: 'Package', itemWidth: 120, + items: colorLegendItems({ + justify: 'center', + gap: 16, + indicator: { shape: 'square' }, + label: { fontSize: 12 }, + }), width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -597,20 +603,99 @@ colorLegend({ ``` ```ts -interface ColorLegendOptions { +interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItems width?: number format?: (value: number) => string placement?: 'top' | 'bottom' } + +declare function colorLegendItems( + options?: ColorLegendItemOptions, +): ColorLegendItems + +interface ColorLegendItemOptions { + justify?: 'start' | 'center' | 'stretch' + gap?: number + rowGap?: number + indicator?: ColorLegendIndicatorOptions + label?: ColorLegendLabelOptions +} + +interface ColorLegendIndicatorOptions { + shape?: + | 'dot' + | 'square' + | 'line' + | 'line-dot' + | (( + value: TValue, + context: ColorLegendItemContext, + ) => 'dot' | 'square' | 'line' | 'line-dot') + width?: number + height?: number + gap?: number + render?: ( + value: TValue, + context: ColorLegendIndicatorRenderContext, + ) => SceneNode | readonly SceneNode[] +} + +interface ColorLegendLabelOptions { + format?: (value: TValue) => string + fontSize?: number + fontWeight?: number + fill?: string | ((value: TValue, context: ColorLegendItemContext) => string) + fillOpacity?: number +} ``` `itemWidth` defaults to `110` and is clamped to a minimum of `64`. Items wrap -to responsive columns for categorical scales. Continuous scales render a -sampled ramp. Quantize, quantile, and threshold scales render exact range bins -at their resolved thresholds. `width` and `format` configure the quantitative -forms. `placement` defaults to `top`. +to responsive columns for categorical scales. The default `stretch` +justification retains those equal-width columns. `start` and `center` measure +each formatted label with the chart host's configured text measurer, wrap +compact rows, and use `gap` between items. `rowGap` adds vertical space to +every item row. + +`indicator.shape` selects a dot, square, line, or line with an outlined point. +`width`, `height`, and `gap` reserve the indicator box and its distance from +the label. A custom `render` callback receives the categorical value plus +`{ bounds, color, index, label }`; the built-in legend still measures, wraps, +and positions the item. Label `fill` callbacks receive the same resolved color +and formatted label. + +Continuous scales render a sampled ramp. Quantize, quantile, and threshold +scales render exact range bins at their resolved thresholds. `width` and the +top-level `format` configure the quantitative forms. `placement` defaults to +`top`. + +This mixed-series legend uses only the resolved color scale for labels and +paint: + +```ts +type Series = 'Revenue' | 'Orders' + +colorLegend({ + placement: 'bottom', + items: colorLegendItems({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { + width: 20, + height: 14, + gap: 6, + shape: (series) => (series === 'Revenue' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_series, { color }) => color, + }, + }), +}) +``` ## Gradient legend diff --git a/docs/reference/types.md b/docs/reference/types.md index f219b23b..8af1c1d4 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -240,7 +240,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ResolvedColorScale` | Resolved mapping and optional stepped legend boundaries | | `ResolvedColorScaleKind` | Categorical, continuous, quantile, quantize, or threshold | | `ChartColorLegend` | Legend layout and scene rendering | -| `ChartColorLegendContext` | Resolved colors, chart bounds, theme, and width | +| `ChartColorLegendContext` | Resolved colors, chart bounds, theme, layout, and viewport size | | `CrosshairOptions` | Data-less x/y guides, marker, style, and motion options | | `CrosshairRuleOptions` | Stroke shared by both crosshair axes or overridden per axis | | `CrosshairAxisOptions` | Per-axis rule or categorical band with an optional label | @@ -488,7 +488,10 @@ subpath. Cartesian, radial, and composite option types include the shared - `TextOptions`, `TextAnchor` - `FrameOptions` - `FacetOptions`, `FacetAxes`, `FacetChartContext` -- `ColorLegendOptions`, `ColorGradientLegendOptions` +- `ColorLegendOptions`, `ColorLegendItemOptions`, `ColorLegendItemValue`, + `ColorLegendIndicatorOptions`, `ColorLegendIndicatorShape`, + `ColorLegendLabelOptions`, + `ColorGradientLegendOptions` Their public fields and defaults are owned by the [mark reference](./index.md#mark-reference) and diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..83737425 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `2ddcf66` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `3df87d7`. +at page render time. The measured TanStack workspace revision is `2ddcf66`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.96–45.03 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/guides/legends-and-color.md b/packages/charts-core/docs/guides/legends-and-color.md index 0ca069bc..b176191a 100644 --- a/packages/charts-core/docs/guides/legends-and-color.md +++ b/packages/charts-core/docs/guides/legends-and-color.md @@ -105,6 +105,7 @@ Options: - `label`: optional legend title; - `itemWidth`: minimum categorical item width; +- `items`: categorical item presentation from `colorLegendItems()`; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -113,6 +114,40 @@ The legend reserves its own layout height. It is visual guidance and is hidden from the SVG accessibility tree; essential category meaning should also be available through direct labels, surrounding HTML, or a table. +The default categorical legend stretches equal-width item columns. Pass +`colorLegendItems()` to configure compact rows, typography, spacing, label +paint, or indicator shape. `justify: 'start'` and `'center'` use the chart +host's text measurer, including configured typography, to wrap formatted +labels responsively: + +```ts +import { colorLegend, colorLegendItems } from '@tanstack/charts' + +colorLegend({ + placement: 'bottom', + items: colorLegendItems<'Revenue' | 'Orders'>({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { + width: 20, + height: 14, + gap: 6, + shape: (series) => (series === 'Revenue' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_series, { color }) => color, + }, + }), +}) +``` + +`dot`, `square`, `line`, and `line-dot` are renderer-neutral scene shapes. +For another symbol, provide `indicator.render` to `colorLegendItems()`; its +context contains the resolved item color and the indicator bounds. The legend +still owns row measurement and wrapping. + ```ts group=automatic-color-legend env=charts file=/src/chart.ts entry import { colorLegend, defineChart, lineY } from '@tanstack/charts' import { scaleLinear } from '@tanstack/charts/scales/linear' diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index 69af1cab..065f5ba9 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -585,11 +585,17 @@ automatically. ## Automatic color legend ```ts -import { colorLegend } from '@tanstack/charts/legend' +import { colorLegend, colorLegendItems } from '@tanstack/charts/legend' colorLegend({ label: 'Package', itemWidth: 120, + items: colorLegendItems({ + justify: 'center', + gap: 16, + indicator: { shape: 'square' }, + label: { fontSize: 12 }, + }), width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -597,20 +603,99 @@ colorLegend({ ``` ```ts -interface ColorLegendOptions { +interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItems width?: number format?: (value: number) => string placement?: 'top' | 'bottom' } + +declare function colorLegendItems( + options?: ColorLegendItemOptions, +): ColorLegendItems + +interface ColorLegendItemOptions { + justify?: 'start' | 'center' | 'stretch' + gap?: number + rowGap?: number + indicator?: ColorLegendIndicatorOptions + label?: ColorLegendLabelOptions +} + +interface ColorLegendIndicatorOptions { + shape?: + | 'dot' + | 'square' + | 'line' + | 'line-dot' + | (( + value: TValue, + context: ColorLegendItemContext, + ) => 'dot' | 'square' | 'line' | 'line-dot') + width?: number + height?: number + gap?: number + render?: ( + value: TValue, + context: ColorLegendIndicatorRenderContext, + ) => SceneNode | readonly SceneNode[] +} + +interface ColorLegendLabelOptions { + format?: (value: TValue) => string + fontSize?: number + fontWeight?: number + fill?: string | ((value: TValue, context: ColorLegendItemContext) => string) + fillOpacity?: number +} ``` `itemWidth` defaults to `110` and is clamped to a minimum of `64`. Items wrap -to responsive columns for categorical scales. Continuous scales render a -sampled ramp. Quantize, quantile, and threshold scales render exact range bins -at their resolved thresholds. `width` and `format` configure the quantitative -forms. `placement` defaults to `top`. +to responsive columns for categorical scales. The default `stretch` +justification retains those equal-width columns. `start` and `center` measure +each formatted label with the chart host's configured text measurer, wrap +compact rows, and use `gap` between items. `rowGap` adds vertical space to +every item row. + +`indicator.shape` selects a dot, square, line, or line with an outlined point. +`width`, `height`, and `gap` reserve the indicator box and its distance from +the label. A custom `render` callback receives the categorical value plus +`{ bounds, color, index, label }`; the built-in legend still measures, wraps, +and positions the item. Label `fill` callbacks receive the same resolved color +and formatted label. + +Continuous scales render a sampled ramp. Quantize, quantile, and threshold +scales render exact range bins at their resolved thresholds. `width` and the +top-level `format` configure the quantitative forms. `placement` defaults to +`top`. + +This mixed-series legend uses only the resolved color scale for labels and +paint: + +```ts +type Series = 'Revenue' | 'Orders' + +colorLegend({ + placement: 'bottom', + items: colorLegendItems({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { + width: 20, + height: 14, + gap: 6, + shape: (series) => (series === 'Revenue' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_series, { color }) => color, + }, + }), +}) +``` ## Gradient legend diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index f219b23b..8af1c1d4 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -240,7 +240,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ResolvedColorScale` | Resolved mapping and optional stepped legend boundaries | | `ResolvedColorScaleKind` | Categorical, continuous, quantile, quantize, or threshold | | `ChartColorLegend` | Legend layout and scene rendering | -| `ChartColorLegendContext` | Resolved colors, chart bounds, theme, and width | +| `ChartColorLegendContext` | Resolved colors, chart bounds, theme, layout, and viewport size | | `CrosshairOptions` | Data-less x/y guides, marker, style, and motion options | | `CrosshairRuleOptions` | Stroke shared by both crosshair axes or overridden per axis | | `CrosshairAxisOptions` | Per-axis rule or categorical band with an optional label | @@ -488,7 +488,10 @@ subpath. Cartesian, radial, and composite option types include the shared - `TextOptions`, `TextAnchor` - `FrameOptions` - `FacetOptions`, `FacetAxes`, `FacetChartContext` -- `ColorLegendOptions`, `ColorGradientLegendOptions` +- `ColorLegendOptions`, `ColorLegendItemOptions`, `ColorLegendItemValue`, + `ColorLegendIndicatorOptions`, `ColorLegendIndicatorShape`, + `ColorLegendLabelOptions`, + `ColorGradientLegendOptions` Their public fields and defaults are owned by the [mark reference](./index.md#mark-reference) and diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 192eb680..a0c4e2d7 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -97,9 +97,21 @@ export type { } from './ridgeline' export { link } from './link' export type { LinkOptions } from './link' -export { colorGradientLegend, colorLegend } from './legend-static' +export { + colorGradientLegend, + colorLegend, + colorLegendItems, +} from './legend-static' export type { ColorGradientLegendOptions, + ColorLegendIndicatorOptions, + ColorLegendIndicatorRenderContext, + ColorLegendIndicatorShape, + ColorLegendItemContext, + ColorLegendItemOptions, + ColorLegendItems, + ColorLegendItemValue, + ColorLegendLabelOptions, ColorLegendOptions, } from './legend-static' export { createMark } from './mark' diff --git a/packages/charts-core/src/legend-layout-internal.test.ts b/packages/charts-core/src/legend-layout-internal.test.ts new file mode 100644 index 00000000..00772d65 --- /dev/null +++ b/packages/charts-core/src/legend-layout-internal.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { layoutCategoricalLegendFlow } from './legend-layout-internal' + +describe('categorical legend flow layout', () => { + it('wraps measured items without stretching them', () => { + expect(layoutCategoricalLegendFlow([66, 86, 66], 200, 20, 'start')).toEqual( + { + rows: 2, + items: [ + { index: 0, row: 0, x: 0, width: 66 }, + { index: 1, row: 0, x: 86, width: 86 }, + { index: 2, row: 1, x: 0, width: 66 }, + ], + }, + ) + }) + + it('centers each wrapped row', () => { + expect( + layoutCategoricalLegendFlow([66, 86, 66], 200, 20, 'center'), + ).toEqual({ + rows: 2, + items: [ + { index: 0, row: 0, x: 14, width: 66 }, + { index: 1, row: 0, x: 100, width: 86 }, + { index: 2, row: 1, x: 67, width: 66 }, + ], + }) + }) +}) diff --git a/packages/charts-core/src/legend-layout-internal.ts b/packages/charts-core/src/legend-layout-internal.ts index 6abd6ffb..8e9362d9 100644 --- a/packages/charts-core/src/legend-layout-internal.ts +++ b/packages/charts-core/src/legend-layout-internal.ts @@ -14,6 +14,18 @@ export interface CategoricalLegendLayout { itemWidth: number } +export interface CategoricalLegendFlowItem { + index: number + row: number + x: number + width: number +} + +export interface CategoricalLegendFlowLayout { + rows: number + items: readonly CategoricalLegendFlowItem[] +} + export function resolveCategoricalLegendItems< TValue extends ChartKey = ChartKey, >( @@ -43,3 +55,48 @@ export function layoutCategoricalLegendItems( itemWidth: width / columns, } } + +export function layoutCategoricalLegendFlow( + itemWidths: readonly number[], + width: number, + gap: number, + justify: 'start' | 'center', +): CategoricalLegendFlowLayout { + const availableWidth = Math.max(0, finiteNumber(width)) + const itemGap = Math.max(0, finiteNumber(gap)) + const rows: { indexes: number[]; width: number }[] = [] + + itemWidths.forEach((candidateWidth, index) => { + const itemWidth = Math.min( + availableWidth, + Math.max(0, finiteNumber(candidateWidth)), + ) + const row = rows.at(-1) + const nextWidth = row ? row.width + itemGap + itemWidth : itemWidth + if (!row || nextWidth > availableWidth) { + rows.push({ indexes: [index], width: itemWidth }) + return + } + row.indexes.push(index) + row.width = nextWidth + }) + + const items: CategoricalLegendFlowItem[] = [] + rows.forEach((row, rowIndex) => { + let x = justify === 'center' ? (availableWidth - row.width) / 2 : 0 + row.indexes.forEach((index) => { + const itemWidth = Math.min( + availableWidth, + Math.max(0, finiteNumber(itemWidths[index])), + ) + items.push({ index, row: rowIndex, x, width: itemWidth }) + x += itemWidth + itemGap + }) + }) + + return { rows: rows.length, items } +} + +function finiteNumber(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/packages/charts-core/src/legend-static.test.ts b/packages/charts-core/src/legend-static.test.ts new file mode 100644 index 00000000..3031f8ed --- /dev/null +++ b/packages/charts-core/src/legend-static.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { scaleBand, scaleLinear } from 'd3-scale' +import { barY } from './bar' +import { estimateSceneText } from './guide-layout' +import { colorLegend, colorLegendItems } from './legend-static' +import { lineY } from './line' +import { createChartScene, defaultChartTheme, defineChart } from './scene' +import type { + ChartColorLegendContext, + ResolvedColorScale, + SceneGroup, +} from './types' + +const colors: ResolvedColorScale = { + type: 'ordinal', + kind: 'categorical', + domain: ['Alpha', 'Beta', 'Gamma'], + range: ['#2563eb', '#f97316', '#16a34a'], + map(value) { + const index = this.domain.indexOf(value as string) + return this.range[index] ?? 'currentColor' + }, +} + +function legendContext( + overrides: Partial = {}, +): ChartColorLegendContext { + return { + colors, + chart: { x: 40, y: 60, width: 200, height: 200 }, + bounds: { x: 40, y: 0, width: 200, height: 100 }, + theme: defaultChartTheme, + layout: { measureText: estimateSceneText }, + width: 280, + height: 320, + ...overrides, + } +} + +function renderLegend( + legend: ReturnType, + context = legendContext(), +): SceneGroup { + const node = legend.render(context) + if (node.kind !== 'group') throw new Error('Expected a legend group') + return node +} + +describe('categorical color legend presentation', () => { + it('keeps the default stretched layout and measurement', () => { + const legend = colorLegend() + const context = legendContext() + + expect(legend.height(colors.domain.length, context)).toBe(75) + expect( + renderLegend(legend, context) + .children.filter((node) => node.kind === 'dot') + .map(({ x, y }) => [x, y]), + ).toEqual([ + [44, 10], + [44, 29], + [44, 48], + ]) + }) + + it('measures labels to center and wrap compact rows', () => { + const legend = colorLegend({ + items: colorLegendItems({ + justify: 'center', + gap: 20, + rowGap: 10, + indicator: { width: 20, height: 14, gap: 6 }, + label: { fontSize: 14 }, + }), + }) + const context = legendContext() + + expect(legend.height(colors.domain.length, context)).toBe(66) + const labels = renderLegend(legend, context).children.filter( + (node) => node.kind === 'label', + ) + expect(labels.map(({ y }) => y)).toEqual([10, 10, 34]) + expect(labels[0]!.x).toBeGreaterThan(context.bounds.x + 20) + expect(labels[2]!.x).toBeGreaterThan(labels[0]!.x) + }) + + it('uses host text measurement and ignores invalid font weights', () => { + const measureText = vi.fn((text: string) => ({ + x: 0, + y: 0, + width: text.length * 10, + height: 14, + })) + const legend = colorLegend({ + items: colorLegendItems({ + justify: 'center', + label: { fontSize: 14, fontWeight: Number.NaN }, + }), + }) + const context = legendContext({ layout: { measureText } }) + + const labels = renderLegend(legend, context).children.filter( + (node) => node.kind === 'label', + ) + + expect(measureText).toHaveBeenCalledWith( + 'Alpha', + expect.objectContaining({ fontSize: 14, fontWeight: undefined }), + ) + expect(labels.map((label) => label.fontWeight)).toEqual([ + undefined, + undefined, + undefined, + ]) + }) + + it('renders per-series symbols and label colors from resolved items', () => { + const legend = colorLegend<'Alpha' | 'Beta' | 'Gamma'>({ + items: colorLegendItems<'Alpha' | 'Beta' | 'Gamma'>({ + justify: 'center', + indicator: { + width: 20, + height: 14, + shape: (value) => (value === 'Alpha' ? 'line-dot' : 'square'), + }, + label: { + fontSize: 14, + fill: (_value, { color }) => color, + }, + }), + }) + const children = renderLegend(legend).children + + expect(children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'rule', + key: expect.stringMatching(/^legend-line:.*Alpha$/), + style: expect.objectContaining({ stroke: '#2563eb' }), + }), + expect.objectContaining({ + kind: 'dot', + key: expect.stringMatching(/^legend-line-dot:.*Alpha$/), + style: expect.objectContaining({ + fill: '#fff', + stroke: '#2563eb', + }), + }), + expect.objectContaining({ + kind: 'rect', + key: expect.stringMatching(/^legend-square:.*Beta$/), + style: { fill: '#f97316' }, + }), + expect.objectContaining({ + kind: 'label', + key: expect.stringMatching(/^legend-label:.*Alpha$/), + baseline: 'middle', + fontSize: 14, + style: { fill: '#2563eb', fillOpacity: 1 }, + }), + ]), + ) + }) + + it('passes resolved item data and measured bounds to custom indicators', () => { + const render = vi.fn((_value: string, context) => ({ + kind: 'rule' as const, + key: `custom:${context.index}`, + x1: context.bounds.x, + x2: context.bounds.x + context.bounds.width, + y1: context.bounds.y, + y2: context.bounds.y, + style: { stroke: context.color }, + })) + const legend = colorLegend({ + items: colorLegendItems({ + indicator: { width: 18, height: 12, render }, + }), + }) + + renderLegend(legend) + + expect(render).toHaveBeenCalledWith( + 'Alpha', + expect.objectContaining({ + color: '#2563eb', + index: 0, + label: 'Alpha', + bounds: { x: 40, y: 4, width: 18, height: 12 }, + }), + ) + }) + + it('keeps oversized indicators within the legend bounds', () => { + const legend = colorLegend({ + items: colorLegendItems({ + indicator: { width: 24, height: 24, shape: 'square' }, + }), + }) + const context = legendContext() + const children = renderLegend(legend, context).children + const indicators = children.filter((node) => node.kind === 'rect') + const labels = children.filter((node) => node.kind === 'label') + + expect(indicators[0]).toEqual( + expect.objectContaining({ y: context.bounds.y, height: 24 }), + ) + expect(labels[0]).toEqual(expect.objectContaining({ y: 12 })) + }) + + it('supports mixed marks with one resolved categorical legend', () => { + const rows = [ + { month: 'Jan', revenue: 10, orders: 4 }, + { month: 'Feb', revenue: 14, orders: 7 }, + ] + const definition = defineChart({ + marks: [ + barY(rows, { + id: 'orders', + x: 'month', + y: 'orders', + color: () => 'Orders', + }), + lineY(rows, { + id: 'revenue', + x: 'month', + y: 'revenue', + color: () => 'Revenue', + }), + ], + scales: { + x: { scale: scaleBand }, + y: { scale: scaleLinear }, + }, + color: { + domain: ['Revenue', 'Orders'], + range: ['#2563eb', '#f97316'], + legend: colorLegend<'Revenue' | 'Orders'>({ + placement: 'bottom', + items: colorLegendItems<'Revenue' | 'Orders'>({ + indicator: { + width: 20, + height: 12, + shape: (value) => (value === 'Revenue' ? 'line-dot' : 'square'), + }, + }), + }), + }, + }) + const scene = createChartScene(definition, { width: 480, height: 320 }) + const legend = scene.nodes.find((node) => node.key === 'legend') + + expect(legend).toEqual( + expect.objectContaining({ + kind: 'group', + children: expect.arrayContaining([ + expect.objectContaining({ + key: expect.stringMatching(/^legend-line:.*Revenue$/), + }), + expect.objectContaining({ + key: expect.stringMatching(/^legend-square:.*Orders$/), + }), + ]), + }), + ) + }) +}) + +colorLegend<'Revenue' | 'Orders'>({ + items: colorLegendItems<'Revenue' | 'Orders'>({ + indicator: { + shape: (value, context) => { + expectTypeOf(value).toEqualTypeOf<'Revenue' | 'Orders'>() + expectTypeOf(context.color).toEqualTypeOf() + return value === 'Revenue' ? 'line-dot' : 'square' + }, + }, + label: { + format: (value) => { + expectTypeOf(value).toEqualTypeOf<'Revenue' | 'Orders'>() + return value + }, + }, + }), +}) + +colorLegend({ + items: colorLegendItems({ + indicator: { + // @ts-expect-error Indicator shapes are a closed renderer-neutral set. + shape: 'triangle', + }, + }), +}) diff --git a/packages/charts-core/src/legend-static.ts b/packages/charts-core/src/legend-static.ts index cf7e2e6f..c8d4f62d 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -1,17 +1,73 @@ import { + layoutCategoricalLegendFlow, layoutCategoricalLegendItems, resolveCategoricalLegendItems, } from './legend-layout-internal' +import { valueKey } from './scales' import type { + ChartBounds, ChartColorLegend, + ChartKey, ChartLegendPlacement, ResolvedColorScaleKind, + SceneLabel, SceneNode, } from './types' -export interface ColorLegendOptions { +export interface ColorLegendItemContext { + color: string + index: number + label: string +} + +export type ColorLegendIndicatorShape = 'dot' | 'square' | 'line' | 'line-dot' + +export type ColorLegendItemValue = + TResult | ((value: TValue, context: ColorLegendItemContext) => TResult) + +export interface ColorLegendIndicatorRenderContext extends ColorLegendItemContext { + bounds: ChartBounds +} + +export interface ColorLegendIndicatorOptions< + TValue extends ChartKey = ChartKey, +> { + shape?: ColorLegendItemValue + width?: number + height?: number + gap?: number + render?: ( + value: TValue, + context: ColorLegendIndicatorRenderContext, + ) => SceneNode | readonly SceneNode[] +} + +export interface ColorLegendLabelOptions { + format?: (value: TValue) => string + fontSize?: number + fontWeight?: number + fill?: ColorLegendItemValue + fillOpacity?: number +} + +export interface ColorLegendItemOptions { + justify?: 'start' | 'center' | 'stretch' + gap?: number + rowGap?: number + indicator?: ColorLegendIndicatorOptions + label?: ColorLegendLabelOptions +} + +declare const colorLegendItemsBrand: unique symbol + +export interface ColorLegendItems { + readonly [colorLegendItemsBrand]: TValue +} + +export interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItems width?: number format?: (value: number) => string placement?: ChartLegendPlacement @@ -25,8 +81,84 @@ export interface ColorGradientLegendOptions { placement?: ChartLegendPlacement } -export function colorLegend( - options: ColorLegendOptions = {}, +type ResolvedColorLegendItems = ( + context: Parameters[1], + minimumItemWidth: number, + labelOffset?: number, +) => number | readonly SceneNode[] + +export function colorLegendItems( + options: ColorLegendItemOptions = {}, +): ColorLegendItems { + const items: ResolvedColorLegendItems = ( + context, + minimumItemWidth, + labelOffset, + ) => { + const presentation = resolveCategoricalLegendPresentation( + options, + context, + minimumItemWidth, + ) + if (labelOffset === undefined) { + return presentation.rows * presentation.rowHeight + } + const { bounds, theme } = context + const children: SceneNode[] = [] + presentation.items.forEach(({ item, row, x }) => { + const firstRowCenter = Math.max( + 10, + Math.max(presentation.fontSize, presentation.indicatorHeight) / 2, + ) + const y = + bounds.y + firstRowCenter + labelOffset + row * presentation.rowHeight + const indicatorBounds = { + x: bounds.x + x, + y: y - presentation.indicatorHeight / 2, + width: presentation.indicatorWidth, + height: presentation.indicatorHeight, + } + children.push( + ...renderCategoricalLegendIndicator( + options.indicator, + item.value, + item.context, + indicatorBounds, + theme.background, + ), + { + kind: 'label', + key: `legend-label:${item.key}`, + x: + indicatorBounds.x + + indicatorBounds.width + + presentation.indicatorGap, + y, + text: item.label, + baseline: 'middle', + fontSize: presentation.fontSize, + fontWeight: presentation.fontWeight, + style: { + fill: resolveItemValue( + options.label?.fill, + item.value, + item.context, + theme.foreground, + ), + fillOpacity: + options.label?.fillOpacity ?? + (options.label?.fill === undefined ? 0.76 : 1), + }, + } satisfies SceneLabel, + ) + }) + return children + } + return items as unknown as ColorLegendItems +} + +export function colorLegend( + options: ColorLegendOptions = {}, ): ChartColorLegend { const gradient = colorGradientLegend({ label: options.label, @@ -34,6 +166,7 @@ export function colorLegend( format: options.format, placement: options.placement, }) + const items = options.items as ResolvedColorLegendItems | undefined const minimumItemWidth = Math.max(64, options.itemWidth ?? 110) const labelOffset = options.label ? 13 : 0 return { @@ -42,6 +175,9 @@ export function colorLegend( if (isQuantitativeLegend(context.colors.kind)) { return gradient.height(itemCount, context) } + if (items) { + return 18 + labelOffset + (items(context, minimumItemWidth) as number) + } const layout = layoutCategoricalLegendItems( itemCount, context.chart.width, @@ -56,13 +192,7 @@ export function colorLegend( if (isSteppedLegend(context.colors.kind)) { return renderSteppedLegend(options, context) } - const { colors, bounds, theme } = context - const items = resolveCategoricalLegendItems(colors) - const layout = layoutCategoricalLegendItems( - items.length, - bounds.width, - minimumItemWidth, - ) + const { bounds, theme } = context const children: SceneNode[] = [] if (options.label) { children.push({ @@ -76,32 +206,19 @@ export function colorLegend( style: { fill: theme.foreground, fillOpacity: 0.78 }, }) } - items.forEach((item, index) => { - const column = index % layout.columns - const row = Math.floor(index / layout.columns) - const x = bounds.x + column * layout.itemWidth - const y = bounds.y + 10 + labelOffset + row * 19 - children.push( - { - kind: 'dot', - key: `legend-dot:${item.key}`, - x: x + 4, - y, - radius: 4, - style: { fill: item.color }, - }, - { - kind: 'label', - key: `legend-label:${item.key}`, - x: x + 13, - y, - text: item.label, - baseline: 'middle', - fontSize: 11, - style: { fill: theme.foreground, fillOpacity: 0.76 }, - }, - ) - }) + children.push( + ...(items + ? (items( + context, + minimumItemWidth, + labelOffset, + ) as readonly SceneNode[]) + : renderDefaultCategoricalLegend( + context, + labelOffset, + minimumItemWidth, + )), + ) return { kind: 'group', @@ -114,6 +231,246 @@ export function colorLegend( } } +function renderDefaultCategoricalLegend( + { colors, bounds, theme }: Parameters[0], + labelOffset: number, + minimumItemWidth: number, +): readonly SceneNode[] { + const items = resolveCategoricalLegendItems(colors) + const layout = layoutCategoricalLegendItems( + items.length, + bounds.width, + minimumItemWidth, + ) + const children: SceneNode[] = [] + items.forEach((item, index) => { + const column = index % layout.columns + const row = Math.floor(index / layout.columns) + const x = bounds.x + column * layout.itemWidth + const y = bounds.y + 10 + labelOffset + row * 19 + children.push( + { + kind: 'dot', + key: `legend-dot:${item.key}`, + x: x + 4, + y, + radius: 4, + style: { fill: item.color }, + }, + { + kind: 'label', + key: `legend-label:${item.key}`, + x: x + 13, + y, + text: item.label, + baseline: 'middle', + fontSize: 11, + style: { fill: theme.foreground, fillOpacity: 0.76 }, + }, + ) + }) + return children +} + +interface ResolvedCategoricalLegendItem { + key: string + value: TValue + label: string + context: ColorLegendItemContext + width: number +} + +interface PositionedCategoricalLegendItem { + item: ResolvedCategoricalLegendItem + row: number + x: number +} + +interface CategoricalLegendPresentation { + rows: number + rowHeight: number + indicatorWidth: number + indicatorHeight: number + indicatorGap: number + fontSize: number + fontWeight: number | undefined + items: readonly PositionedCategoricalLegendItem[] +} + +function resolveCategoricalLegendPresentation( + options: ColorLegendItemOptions, + context: Parameters[1], + minimumItemWidth: number, +): CategoricalLegendPresentation { + const labelOptions = options.label + const indicatorOptions = options.indicator + const fontSize = finiteNonnegative(labelOptions?.fontSize, 11) + const fontWeight = Number.isFinite(labelOptions?.fontWeight) + ? labelOptions?.fontWeight + : undefined + const indicatorWidth = finiteNonnegative(indicatorOptions?.width, 8) + const indicatorHeight = finiteNonnegative(indicatorOptions?.height, 8) + const indicatorGap = finiteNonnegative(indicatorOptions?.gap, 5) + const rowGap = finiteNonnegative(options.rowGap, 8) + const resolvedItems = resolveCategoricalLegendItems( + context.colors, + labelOptions?.format, + ) + const items = resolvedItems.map((item, index) => { + const itemContext = { color: item.color, index, label: item.label } + return { + ...item, + context: itemContext, + width: + indicatorWidth + + indicatorGap + + (context.layout?.measureText?.(item.label, { + fontSize, + fontWeight, + fontFamily: 'sans-serif', + fontStyle: 'normal', + fontStretch: 'normal', + letterSpacing: 0, + direction: 'inherit', + fontScale: 1, + anchor: 'start', + baseline: 'middle', + }).width ?? item.label.length * fontSize * 0.6), + } + }) + const rowHeight = Math.max(fontSize, indicatorHeight) + rowGap + const justify = options.justify ?? 'stretch' + if (justify === 'stretch') { + const layout = layoutCategoricalLegendItems( + items.length, + context.bounds.width, + minimumItemWidth, + ) + return { + rows: layout.rows, + rowHeight, + indicatorWidth, + indicatorHeight, + indicatorGap, + fontSize, + fontWeight, + items: items.map((item, index) => ({ + item, + row: Math.floor(index / layout.columns), + x: (index % layout.columns) * layout.itemWidth, + })), + } + } + + const layout = layoutCategoricalLegendFlow( + items.map((item) => item.width), + context.bounds.width, + finiteNonnegative(options.gap, 16), + justify, + ) + return { + rows: layout.rows, + rowHeight, + indicatorWidth, + indicatorHeight, + indicatorGap, + fontSize, + fontWeight, + items: layout.items.map(({ index, row, x }) => ({ + item: items[index]!, + row, + x, + })), + } +} + +function renderCategoricalLegendIndicator( + options: ColorLegendIndicatorOptions | undefined, + value: TValue, + context: ColorLegendItemContext, + bounds: ChartBounds, + background: string, +): readonly SceneNode[] { + if (options?.render) { + const rendered = options.render(value, { ...context, bounds }) + return 'kind' in rendered ? [rendered] : rendered + } + + const shape = resolveItemValue(options?.shape, value, context, 'dot') + const centerX = bounds.x + bounds.width / 2 + const centerY = bounds.y + bounds.height / 2 + const size = Math.min(bounds.width, bounds.height) + if (shape === 'square') { + return [ + { + kind: 'rect', + key: `legend-square:${valueKey(value)}`, + x: centerX - size / 2, + y: centerY - size / 2, + width: size, + height: size, + style: { fill: context.color }, + }, + ] + } + if (shape === 'line' || shape === 'line-dot') { + const nodes: SceneNode[] = [ + { + kind: 'rule', + key: `legend-line:${valueKey(value)}`, + x1: bounds.x, + x2: bounds.x + bounds.width, + y1: centerY, + y2: centerY, + style: { stroke: context.color, strokeWidth: 3 }, + }, + ] + if (shape === 'line-dot') { + const radius = Math.min(4, size / 2) + nodes.push({ + kind: 'dot', + key: `legend-line-dot:${valueKey(value)}`, + x: centerX, + y: centerY, + radius, + style: { + fill: background === 'transparent' ? '#fff' : background, + stroke: context.color, + strokeWidth: Math.min(2, radius), + }, + }) + } + return nodes + } + return [ + { + kind: 'dot', + key: `legend-dot:${valueKey(value)}`, + x: centerX, + y: centerY, + radius: size / 2, + style: { fill: context.color }, + }, + ] +} + +function resolveItemValue( + input: ColorLegendItemValue | undefined, + value: TValue, + context: ColorLegendItemContext, + fallback: TResult, +): TResult { + return typeof input === 'function' + ? input(value, context) + : (input ?? fallback) +} + +function finiteNonnegative(value: number | undefined, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, value) + : fallback +} + function isContinuousLegend(kind: ResolvedColorScaleKind | undefined): boolean { return kind === 'continuous' } @@ -129,7 +486,7 @@ function isQuantitativeLegend( } function renderSteppedLegend( - options: ColorLegendOptions, + options: Pick, { colors, bounds, theme }: Parameters[0], ): SceneNode { const width = Math.min(bounds.width, Math.max(80, options.width ?? 240)) diff --git a/packages/charts-core/src/legend.ts b/packages/charts-core/src/legend.ts index c7110dc7..d341b13c 100644 --- a/packages/charts-core/src/legend.ts +++ b/packages/charts-core/src/legend.ts @@ -1,7 +1,16 @@ export { colorGradientLegend, colorLegend, + colorLegendItems, type ColorGradientLegendOptions, + type ColorLegendIndicatorOptions, + type ColorLegendIndicatorRenderContext, + type ColorLegendIndicatorShape, + type ColorLegendItemContext, + type ColorLegendItemOptions, + type ColorLegendItems, + type ColorLegendItemValue, + type ColorLegendLabelOptions, type ColorLegendOptions, } from './legend-static' export { interactiveColorLegend } from './interactive-legend' diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..e388a2ab 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -510,6 +510,7 @@ function createChartSceneWithScaleResolver< chart, bounds: legendBounds, theme, + layout: layoutOptions, width, height, } @@ -1075,6 +1076,7 @@ function resolveSceneLayout( chart, bounds: { x: chart.x, y: 0, width: chart.width, height: 0 }, theme, + layout, width, height, }) @@ -1129,6 +1131,7 @@ function resolveSceneLayout( height: 0, }, theme, + layout, width, height, }, diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 3ed32bb9..847cd3c7 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -517,6 +517,7 @@ export interface ChartColorLegendContext { chart: ChartBounds bounds: ChartBounds theme: ChartTheme + layout?: ChartLayoutOptions width: number height: number } diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 3aef282c..c5477a9b 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -65,6 +65,14 @@ export type { export type { LinkOptions } from './link' export type { ColorGradientLegendOptions, + ColorLegendIndicatorOptions, + ColorLegendIndicatorRenderContext, + ColorLegendIndicatorShape, + ColorLegendItemContext, + ColorLegendItemOptions, + ColorLegendItems, + ColorLegendItemValue, + ColorLegendLabelOptions, ColorLegendOptions, } from './legend-static' export type { CompositeMarkOptions } from './mark-composite' diff --git a/packages/charts-core/src/universal.ts b/packages/charts-core/src/universal.ts index 92549cc0..8c7f5242 100644 --- a/packages/charts-core/src/universal.ts +++ b/packages/charts-core/src/universal.ts @@ -35,7 +35,11 @@ export { } from './regression' export { ridgelineX, ridgelineY } from './ridgeline' export { link } from './link' -export { colorGradientLegend, colorLegend } from './legend-static' +export { + colorGradientLegend, + colorLegend, + colorLegendItems, +} from './legend-static' export { createMark } from './mark' export { compositeMark } from './mark-composite' export { cell, rect } from './rect' diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..68e0f2aa 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -124,6 +124,7 @@ const retainedInputGroups = { interactiveLegend: [ /(?:^|\/)packages\/charts-core\/src\/interactive-legend\.ts$/u, ], + staticLegend: [/(?:^|\/)packages\/charts-core\/src\/legend-static\.ts$/u], keyedSelection: [/(?:^|\/)packages\/charts-core\/src\/selection\.ts$/u], decorativeMarkPublic: [ /(?:^|\/)packages\/charts-core\/src\/mark-decorative\.ts$/u, @@ -1367,6 +1368,47 @@ const entries = [ }, }, ), + budgeted( + 'Categorical legend', + 'benchmarks/entries/charts-categorical-legend.ts', + 1.8, + { + rendererBoundary: 'neutral', + inputBoundary: { + require: ['staticLegend', 'categoricalLegendLayout'], + forbid: [ + 'interactiveLegend', + 'interactionSignal', + 'markSceneFilter', + 'keyedSelection', + 'platformRendererRuntime', + 'd3GeometryRuntime', + ], + }, + }, + ), + incrementalBudgeted( + 'Categorical legend item presentation', + 'benchmarks/entries/charts-categorical-legend-items.ts', + 'Categorical legend', + 1.15, + { + rendererBoundary: 'neutral', + inputBoundary: { + require: ['staticLegend', 'categoricalLegendLayout'], + forbid: [ + 'interactiveLegend', + 'interactionSignal', + 'markSceneFilter', + 'keyedSelection', + 'platformRendererRuntime', + 'd3GeometryRuntime', + ], + addedFrom: 'Categorical legend', + allowAdded: [], + }, + }, + ), incrementalBudgeted( 'Continuous cursor + DOM host', 'benchmarks/entries/charts-continuous-cursor.ts', diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 3d47bf56..b4f49250 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -174,6 +174,15 @@ const callbackInventory = { '@tanstack/charts:src/legend-static.ts:ColorGradientLegendOptions', 'format', ], + [ + '@tanstack/charts:src/legend-static.ts:ColorLegendIndicatorOptions', + 'render shape', + ], + ['@tanstack/charts:src/legend-static.ts:ColorLegendItemValue', '$call'], + [ + '@tanstack/charts:src/legend-static.ts:ColorLegendLabelOptions', + 'fill format', + ], ['@tanstack/charts:src/legend-static.ts:ColorLegendOptions', 'format'], ['@tanstack/charts:src/line.ts:LineOptions', 'color key stroke z'], ['@tanstack/charts:src/line.ts:LineXOptions', 'x y'],