From e4c9fa1ff16d73341601232269f664a9225c6bc3 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 27 Aug 2026 23:50:28 +0200 Subject: [PATCH 1/5] Add configurable categorical legend items --- .changeset/soft-legends-flow.md | 7 + API-FRICTION.md | 23 +- benchmarks/bundle-size/README.md | 6 + .../cases/168-shadcn-pie-legend/example.tsx | 44 +-- .../cases/168-shadcn-pie-legend/styles.css | 19 -- .../cases/70-composed-chart/case.json | 3 +- .../cases/70-composed-chart/example.tsx | 50 ++- .../cases/70-composed-chart/tanstack.test.ts | 12 +- benchmarks/conformance/catalog-index.json | 3 +- .../previews/70-composed-chart.svg | 2 +- benchmarks/conformance/previews/manifest.json | 6 +- .../charts-categorical-legend-items.ts | 20 ++ docs/guides/legends-and-color.md | 32 ++ docs/reference/scales-guides-and-color.md | 91 ++++- docs/reference/types.md | 5 +- .../docs/guides/legends-and-color.md | 32 ++ .../docs/reference/scales-guides-and-color.md | 91 ++++- packages/charts-core/docs/reference/types.md | 5 +- .../src/legend-layout-internal.test.ts | 30 ++ .../charts-core/src/legend-layout-internal.ts | 57 ++++ .../charts-core/src/legend-static.test.ts | 243 ++++++++++++++ packages/charts-core/src/legend-static.ts | 313 ++++++++++++++++-- packages/charts-core/src/legend.ts | 7 + .../charts-core/src/text-estimate-internal.ts | 29 ++ scripts/measure-bundles.mjs | 20 ++ scripts/public-callback-contract.mjs | 9 + 26 files changed, 1067 insertions(+), 92 deletions(-) create mode 100644 .changeset/soft-legends-flow.md create mode 100644 benchmarks/entries/charts-categorical-legend-items.ts create mode 100644 packages/charts-core/src/legend-layout-internal.test.ts create mode 100644 packages/charts-core/src/legend-static.test.ts create mode 100644 packages/charts-core/src/text-estimate-internal.ts diff --git a/.changeset/soft-legends-flow.md b/.changeset/soft-legends-flow.md new file mode 100644 index 00000000..d144e295 --- /dev/null +++ b/.changeset/soft-legends-flow.md @@ -0,0 +1,7 @@ +--- +'@tanstack/charts': minor +--- + +Add 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..13af0344 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,23 @@ 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()` configure categorical item labels and + indicators while retaining chart-owned measurement and wrapping. Keep the + equal-column layout as the default. Compact start and center layouts measure + formatted labels from their configured typography. 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..96603e79 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -92,6 +92,12 @@ 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 item fixture measures centered flow layout, +custom label paint, and mixed square and line-point indicators through the +exact `@tanstack/charts/legend` subpath. Its retained-input gate rejects the +interactive legend, host renderers, interactions, and D3 geometry. +The complete feature fixture has a 2.7 KiB gzip ceiling. + Update the universal baseline only after reviewing why a shared path changed: ```sh diff --git a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx index cb193587..f25b19b5 100644 --- a/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx +++ b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx @@ -1,4 +1,4 @@ -import { defineChart, type ChartPoint } from '@tanstack/charts' +import { colorLegend, 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' @@ -40,7 +40,29 @@ export function createExampleChart() { x: null, y: null, }, - color: { domain: browserNames, range: shadcnColors }, + color: { + domain: browserNames, + range: shadcnColors, + legend: colorLegend({ + placement: 'bottom', + items: { + 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 +143,8 @@ 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..fd9b4aa7 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -2,11 +2,26 @@ 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, + 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 +38,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 +48,7 @@ export const createExampleChart = (input: ChartOptions) => { id: 'precipitation-bars', x: 'date', y: 'precipitation', + color: () => weatherSeries.precipitation, yScale: 'precipitation', fill: '#413ea0', maxThickness: 20, @@ -39,6 +56,7 @@ export const createExampleChart = (input: ChartOptions) => { lineY(rows, { x: 'date', y: 'temp_min', + color: () => weatherSeries.low, stroke: '#ff7300', strokeWidth: 2, curve: monotone, @@ -47,6 +65,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 +106,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: { + 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..085a7255 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": "fb69bf169cc2896fd718726445ec22bff39a632bb7e41c8176e29eb09d7015da", "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..3a1753ac --- /dev/null +++ b/benchmarks/entries/charts-categorical-legend-items.ts @@ -0,0 +1,20 @@ +import { colorLegend } from '@tanstack/charts/legend' + +export const legend = colorLegend<'Revenue' | 'Orders'>({ + placement: 'bottom', + items: { + 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/docs/guides/legends-and-color.md b/docs/guides/legends-and-color.md index 0ca069bc..a2cdcfcc 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 layout, indicator, and label presentation; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -113,6 +114,37 @@ 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. Use +`items.justify: 'start'` or `'center'` for compact rows measured from the +formatted labels. The built-in legend keeps responsive wrapping when you +change typography, spacing, label paint, or indicator shape: + +```ts +colorLegend<'Revenue' | 'Orders'>({ + placement: 'bottom', + items: { + 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 `items.indicator.render`; 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..ad951842 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -590,6 +590,12 @@ import { colorLegend } from '@tanstack/charts/legend' colorLegend({ label: 'Package', itemWidth: 120, + items: { + justify: 'center', + gap: 16, + indicator: { shape: 'square' }, + label: { fontSize: 12 }, + }, width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -597,20 +603,95 @@ colorLegend({ ``` ```ts -interface ColorLegendOptions { +interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItemOptions width?: number format?: (value: number) => string placement?: 'top' | 'bottom' } + +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 from its configured font size and weight, 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: { + 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..090577d2 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -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/guides/legends-and-color.md b/packages/charts-core/docs/guides/legends-and-color.md index 0ca069bc..a2cdcfcc 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 layout, indicator, and label presentation; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -113,6 +114,37 @@ 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. Use +`items.justify: 'start'` or `'center'` for compact rows measured from the +formatted labels. The built-in legend keeps responsive wrapping when you +change typography, spacing, label paint, or indicator shape: + +```ts +colorLegend<'Revenue' | 'Orders'>({ + placement: 'bottom', + items: { + 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 `items.indicator.render`; 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..ad951842 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -590,6 +590,12 @@ import { colorLegend } from '@tanstack/charts/legend' colorLegend({ label: 'Package', itemWidth: 120, + items: { + justify: 'center', + gap: 16, + indicator: { shape: 'square' }, + label: { fontSize: 12 }, + }, width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -597,20 +603,95 @@ colorLegend({ ``` ```ts -interface ColorLegendOptions { +interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItemOptions width?: number format?: (value: number) => string placement?: 'top' | 'bottom' } + +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 from its configured font size and weight, 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: { + 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..090577d2 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -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/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..c19de7e4 --- /dev/null +++ b/packages/charts-core/src/legend-static.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { scaleBand, scaleLinear } from 'd3-scale' +import { barY } from './bar' +import { colorLegend } 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, + 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: { + 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('renders per-series symbols and label colors from resolved items', () => { + const legend = colorLegend<'Alpha' | 'Beta' | 'Gamma'>({ + items: { + 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: { 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('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: { + 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: { + 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: { + 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..d083d076 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -1,17 +1,68 @@ import { + layoutCategoricalLegendFlow, layoutCategoricalLegendItems, resolveCategoricalLegendItems, } from './legend-layout-internal' +import { valueKey } from './scales' +import { estimateTextWidth } from './text-estimate-internal' 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 +} + +export interface ColorLegendOptions { label?: string itemWidth?: number + items?: ColorLegendItemOptions width?: number format?: (value: number) => string placement?: ChartLegendPlacement @@ -25,8 +76,8 @@ export interface ColorGradientLegendOptions { placement?: ChartLegendPlacement } -export function colorLegend( - options: ColorLegendOptions = {}, +export function colorLegend( + options: ColorLegendOptions = {}, ): ChartColorLegend { const gradient = colorGradientLegend({ label: options.label, @@ -42,12 +93,12 @@ export function colorLegend( if (isQuantitativeLegend(context.colors.kind)) { return gradient.height(itemCount, context) } - const layout = layoutCategoricalLegendItems( - itemCount, - context.chart.width, + const presentation = resolveCategoricalLegendPresentation( + options, + context, minimumItemWidth, ) - return 18 + labelOffset + layout.rows * 19 + return 18 + labelOffset + presentation.rows * presentation.rowHeight }, render(context) { if (isContinuousLegend(context.colors.kind)) { @@ -56,11 +107,10 @@ 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, + const { bounds, theme } = context + const presentation = resolveCategoricalLegendPresentation( + options, + context, minimumItemWidth, ) const children: SceneNode[] = [] @@ -76,30 +126,46 @@ 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 + presentation.items.forEach(({ item, row, x }) => { + const y = bounds.y + 10 + labelOffset + row * presentation.rowHeight + const indicatorBounds = { + x: bounds.x + x, + y: y - presentation.indicatorHeight / 2, + width: presentation.indicatorWidth, + height: presentation.indicatorHeight, + } children.push( - { - kind: 'dot', - key: `legend-dot:${item.key}`, - x: x + 4, - y, - radius: 4, - style: { fill: item.color }, - }, + ...renderCategoricalLegendIndicator( + options.items?.indicator, + item.value, + item.context, + indicatorBounds, + theme.background, + ), { kind: 'label', key: `legend-label:${item.key}`, - x: x + 13, + x: + indicatorBounds.x + + indicatorBounds.width + + presentation.indicatorGap, y, text: item.label, baseline: 'middle', - fontSize: 11, - style: { fill: theme.foreground, fillOpacity: 0.76 }, - }, + fontSize: presentation.fontSize, + fontWeight: presentation.fontWeight, + style: { + fill: resolveItemValue( + options.items?.label?.fill, + item.value, + item.context, + theme.foreground, + ), + fillOpacity: + options.items?.label?.fillOpacity ?? + (options.items?.label?.fill === undefined ? 0.76 : 1), + }, + } satisfies SceneLabel, ) }) @@ -114,6 +180,193 @@ export function colorLegend( } } +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: ColorLegendOptions, + context: Parameters[1], + minimumItemWidth: number, +): CategoricalLegendPresentation { + const itemOptions = options.items + const labelOptions = itemOptions?.label + const indicatorOptions = itemOptions?.indicator + const fontSize = finiteNonnegative(labelOptions?.fontSize, 11) + const fontWeight = labelOptions?.fontWeight + const indicatorWidth = finiteNonnegative(indicatorOptions?.width, 8) + const indicatorHeight = finiteNonnegative(indicatorOptions?.height, 8) + const indicatorGap = finiteNonnegative(indicatorOptions?.gap, 5) + const rowGap = finiteNonnegative(itemOptions?.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 + + estimateTextWidth(item.label, fontSize, fontWeight ?? 400), + } + }) + const rowHeight = Math.max(fontSize, indicatorHeight) + rowGap + const justify = itemOptions?.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(itemOptions?.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 +382,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..64911a42 100644 --- a/packages/charts-core/src/legend.ts +++ b/packages/charts-core/src/legend.ts @@ -2,6 +2,13 @@ export { colorGradientLegend, colorLegend, type ColorGradientLegendOptions, + type ColorLegendIndicatorOptions, + type ColorLegendIndicatorRenderContext, + type ColorLegendIndicatorShape, + type ColorLegendItemContext, + type ColorLegendItemOptions, + type ColorLegendItemValue, + type ColorLegendLabelOptions, type ColorLegendOptions, } from './legend-static' export { interactiveColorLegend } from './interactive-legend' diff --git a/packages/charts-core/src/text-estimate-internal.ts b/packages/charts-core/src/text-estimate-internal.ts new file mode 100644 index 00000000..aec5660e --- /dev/null +++ b/packages/charts-core/src/text-estimate-internal.ts @@ -0,0 +1,29 @@ +export function estimateTextWidth( + text: string, + fontSize: number, + fontWeight: number, + letterSpacing = 0, +): number { + let emWidth = 0 + for (const character of text) { + emWidth += estimateCharacterWidth(character) + } + const clampedWeight = Math.min(900, Math.max(100, fontWeight)) + const weightFactor = 1 + (clampedWeight - 400) / 12_500 + return Math.max( + 0, + emWidth * fontSize * weightFactor + + Math.max(0, Array.from(text).length - 1) * letterSpacing, + ) +} + +function estimateCharacterWidth(character: string): number { + if (/\s/u.test(character)) return 0.33 + if (/[\u0300-\u036f]/u.test(character)) return 0 + if (/[ilI1|!.,:;'`]/u.test(character)) return 0.28 + if (/[mwMW@#%&]/u.test(character)) return 0.9 + if (/[A-Z]/u.test(character)) return 0.64 + if (/[0-9]/u.test(character)) return 0.56 + if (character.codePointAt(0)! > 0x7f) return 1 + return 0.54 +} diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..83e76b6e 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,25 @@ const entries = [ }, }, ), + budgeted( + 'Categorical legend item presentation', + 'benchmarks/entries/charts-categorical-legend-items.ts', + 2.7, + { + rendererBoundary: 'neutral', + inputBoundary: { + require: ['staticLegend', 'categoricalLegendLayout'], + forbid: [ + 'interactiveLegend', + 'interactionSignal', + 'markSceneFilter', + 'keyedSelection', + 'platformRendererRuntime', + 'd3GeometryRuntime', + ], + }, + }, + ), 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'], From 5f9ff1e57eb671f2772d75f8f5d750efef51cd9b Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Thu, 27 Aug 2026 23:53:45 +0200 Subject: [PATCH 2/5] Update chart comparison bundle baseline --- benchmarks/comparison/bundle-baseline.json | 88 +++++++++++----------- docs/comparison.md | 8 +- packages/charts-core/docs/comparison.md | 8 +- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..7f67992f 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-26T21:51:12.832Z", + "generatedAt": "2026-08-27T21:51:31.433Z", "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": "e4c9fa1ff16d73341601232269f664a9225c6bc3", + "inputDigest": "sha256:b045a5fb010b03b947d8c23f5684e0c948870fa6b68239231d4f19f54b2653e5" }, "chartjs": { "kind": "package", @@ -52,18 +52,18 @@ "incrementalBrotliBytes": 35325 }, "tanstack-line-interactive": { - "minifiedBytes": 115872, - "gzipBytes": 41648, - "brotliBytes": 36746, - "incrementalGzipBytes": 41648, - "incrementalBrotliBytes": 36746 + "minifiedBytes": 118587, + "gzipBytes": 42639, + "brotliBytes": 37470, + "incrementalGzipBytes": 42639, + "incrementalBrotliBytes": 37470 }, "tanstack-line-advanced": { - "minifiedBytes": 123062, - "gzipBytes": 43974, - "brotliBytes": 38764, - "incrementalGzipBytes": 43974, - "incrementalBrotliBytes": 38764 + "minifiedBytes": 125777, + "gzipBytes": 44993, + "brotliBytes": 39504, + "incrementalGzipBytes": 44993, + "incrementalBrotliBytes": 39504 }, "tanstack-bar-basic": { "minifiedBytes": 119225, @@ -73,18 +73,18 @@ "incrementalBrotliBytes": 38168 }, "tanstack-bar-interactive": { - "minifiedBytes": 123521, - "gzipBytes": 44585, - "brotliBytes": 39223, - "incrementalGzipBytes": 44585, - "incrementalBrotliBytes": 39223 + "minifiedBytes": 126236, + "gzipBytes": 45563, + "brotliBytes": 39970, + "incrementalGzipBytes": 45563, + "incrementalBrotliBytes": 39970 }, "tanstack-bar-advanced": { - "minifiedBytes": 123860, - "gzipBytes": 44735, - "brotliBytes": 39349, - "incrementalGzipBytes": 44735, - "incrementalBrotliBytes": 39349 + "minifiedBytes": 126575, + "gzipBytes": 45699, + "brotliBytes": 40080, + "incrementalGzipBytes": 45699, + "incrementalBrotliBytes": 40080 }, "tanstack-area-basic": { "minifiedBytes": 115640, @@ -94,18 +94,18 @@ "incrementalBrotliBytes": 37073 }, "tanstack-area-interactive": { - "minifiedBytes": 121085, - "gzipBytes": 43594, - "brotliBytes": 38493, - "incrementalGzipBytes": 43594, - "incrementalBrotliBytes": 38493 + "minifiedBytes": 123800, + "gzipBytes": 44669, + "brotliBytes": 39253, + "incrementalGzipBytes": 44669, + "incrementalBrotliBytes": 39253 }, "tanstack-area-advanced": { - "minifiedBytes": 128457, - "gzipBytes": 46057, - "brotliBytes": 40527, - "incrementalGzipBytes": 46057, - "incrementalBrotliBytes": 40527 + "minifiedBytes": 131172, + "gzipBytes": 47071, + "brotliBytes": 41339, + "incrementalGzipBytes": 47071, + "incrementalBrotliBytes": 41339 }, "tanstack-scatter-basic": { "minifiedBytes": 111460, @@ -115,18 +115,18 @@ "incrementalBrotliBytes": 35596 }, "tanstack-scatter-interactive": { - "minifiedBytes": 116901, - "gzipBytes": 42015, - "brotliBytes": 36976, - "incrementalGzipBytes": 42015, - "incrementalBrotliBytes": 36976 + "minifiedBytes": 119616, + "gzipBytes": 42989, + "brotliBytes": 37829, + "incrementalGzipBytes": 42989, + "incrementalBrotliBytes": 37829 }, "tanstack-scatter-advanced": { - "minifiedBytes": 116917, - "gzipBytes": 42020, - "brotliBytes": 37012, - "incrementalGzipBytes": 42020, - "incrementalBrotliBytes": 37012 + "minifiedBytes": 119632, + "gzipBytes": 42995, + "brotliBytes": 37763, + "incrementalGzipBytes": 42995, + "incrementalBrotliBytes": 37763 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..a3bc35d4 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 `e4c9fa1` | | [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 `e4c9fa1`. ## 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.97 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..a3bc35d4 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 `e4c9fa1` | | [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 `e4c9fa1`. ## 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.97 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 | From 2ddcf661897ba305e81b83f80dd9c802bea2307d Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Fri, 28 Aug 2026 00:41:27 +0200 Subject: [PATCH 3/5] Make categorical legend items tree-shakeable --- .changeset/soft-legends-flow.md | 6 +- API-FRICTION.md | 11 +- benchmarks/bundle-size/README.md | 12 +- .../bundle-size/universal-baseline.json | 40 ++-- .../cases/168-shadcn-pie-legend/example.tsx | 28 ++- .../cases/70-composed-chart/example.tsx | 5 +- .../charts-categorical-legend-items.ts | 6 +- .../entries/charts-categorical-legend.ts | 3 + docs/guides/legends-and-color.md | 25 +- docs/reference/scales-guides-and-color.md | 24 +- docs/reference/types.md | 2 +- .../docs/guides/legends-and-color.md | 25 +- .../docs/reference/scales-guides-and-color.md | 24 +- packages/charts-core/docs/reference/types.md | 2 +- packages/charts-core/src/index.ts | 14 +- .../charts-core/src/legend-static.test.ts | 58 ++++- packages/charts-core/src/legend-static.ts | 223 +++++++++++++----- packages/charts-core/src/legend.ts | 2 + packages/charts-core/src/scene.ts | 3 + .../charts-core/src/text-estimate-internal.ts | 29 --- packages/charts-core/src/types.ts | 1 + packages/charts-core/src/universal-types.ts | 8 + packages/charts-core/src/universal.ts | 6 +- scripts/measure-bundles.mjs | 24 +- 24 files changed, 390 insertions(+), 191 deletions(-) create mode 100644 benchmarks/entries/charts-categorical-legend.ts delete mode 100644 packages/charts-core/src/text-estimate-internal.ts diff --git a/.changeset/soft-legends-flow.md b/.changeset/soft-legends-flow.md index d144e295..9485729f 100644 --- a/.changeset/soft-legends-flow.md +++ b/.changeset/soft-legends-flow.md @@ -2,6 +2,6 @@ '@tanstack/charts': minor --- -Add measured categorical legend item layouts with configurable spacing, -typography, label paint, built-in indicator shapes, and custom indicator scene -renderers. +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 13af0344..e6a07172 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -8517,11 +8517,12 @@ Each entry records: 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()` configure categorical item labels and - indicators while retaining chart-owned measurement and wrapping. Keep the - equal-column layout as the default. Compact start and center layouts measure - formatted labels from their configured typography. Indicator callbacks receive - resolved color-scale items and a measured bounds box. +- 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 diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index 96603e79..8134244b 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -92,11 +92,13 @@ 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 item fixture measures centered flow layout, -custom label paint, and mixed square and line-point indicators through the -exact `@tanstack/charts/legend` subpath. Its retained-input gate rejects the -interactive legend, host renderers, interactions, and D3 geometry. -The complete feature fixture has a 2.7 KiB gzip ceiling. +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: 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/conformance/cases/168-shadcn-pie-legend/example.tsx b/benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx index f25b19b5..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 { colorLegend, 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', @@ -45,7 +62,7 @@ export function createExampleChart() { range: shadcnColors, legend: colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems({ justify: 'center', gap: 12, rowGap: 8, @@ -60,7 +77,7 @@ export function createExampleChart() { fontSize: 12, fill: (_browser, { color }) => color, }, - }, + }), }), }, margin: 0, @@ -143,6 +160,11 @@ export default function Example({ width = 640, height = 600 }: ExampleProps) { ariaLabel="Pie Chart - Legend" /> +
    + {browserNames.map((browser) => ( +
  • {titleCase(browser)}
  • + ))} +
diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index fd9b4aa7..8386e3d2 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -6,6 +6,7 @@ import { areaY, barY, colorLegend, + colorLegendItems, d3Curve, defineChart, dot, @@ -111,7 +112,7 @@ export const createExampleChart = (input: ChartOptions) => { range: ['#8884d8', '#413ea0', '#ff7300', '#ef4444'], legend: colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems({ justify: 'center', gap: 18, rowGap: 8, @@ -129,7 +130,7 @@ export const createExampleChart = (input: ChartOptions) => { fontSize: 12, fill: (_series, { color }) => color, }, - }, + }), }), }, diff --git a/benchmarks/entries/charts-categorical-legend-items.ts b/benchmarks/entries/charts-categorical-legend-items.ts index 3a1753ac..23751d87 100644 --- a/benchmarks/entries/charts-categorical-legend-items.ts +++ b/benchmarks/entries/charts-categorical-legend-items.ts @@ -1,8 +1,8 @@ -import { colorLegend } from '@tanstack/charts/legend' +import { colorLegend, colorLegendItems } from '@tanstack/charts/legend' export const legend = colorLegend<'Revenue' | 'Orders'>({ placement: 'bottom', - items: { + items: colorLegendItems({ justify: 'center', gap: 20, rowGap: 10, @@ -16,5 +16,5 @@ export const legend = colorLegend<'Revenue' | 'Orders'>({ 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/guides/legends-and-color.md b/docs/guides/legends-and-color.md index a2cdcfcc..b176191a 100644 --- a/docs/guides/legends-and-color.md +++ b/docs/guides/legends-and-color.md @@ -105,7 +105,7 @@ Options: - `label`: optional legend title; - `itemWidth`: minimum categorical item width; -- `items`: categorical item layout, indicator, and label presentation; +- `items`: categorical item presentation from `colorLegendItems()`; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -114,15 +114,18 @@ 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. Use -`items.justify: 'start'` or `'center'` for compact rows measured from the -formatted labels. The built-in legend keeps responsive wrapping when you -change typography, spacing, label paint, or indicator shape: +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 -colorLegend<'Revenue' | 'Orders'>({ +import { colorLegend, colorLegendItems } from '@tanstack/charts' + +colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems<'Revenue' | 'Orders'>({ justify: 'center', gap: 20, rowGap: 10, @@ -136,14 +139,14 @@ colorLegend<'Revenue' | 'Orders'>({ fontSize: 14, fill: (_series, { color }) => color, }, - }, + }), }) ``` `dot`, `square`, `line`, and `line-dot` are renderer-neutral scene shapes. -For another symbol, provide `items.indicator.render`; its context contains the -resolved item color and the indicator bounds. The legend still owns row -measurement and wrapping. +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' diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index ad951842..065f5ba9 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -585,17 +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: { + items: colorLegendItems({ justify: 'center', gap: 16, indicator: { shape: 'square' }, label: { fontSize: 12 }, - }, + }), width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -606,12 +606,16 @@ colorLegend({ interface ColorLegendOptions { label?: string itemWidth?: number - items?: ColorLegendItemOptions + 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 @@ -651,9 +655,9 @@ interface ColorLegendLabelOptions { `itemWidth` defaults to `110` and is clamped to a minimum of `64`. Items wrap to responsive columns for categorical scales. The default `stretch` justification retains those equal-width columns. `start` and `center` measure -each formatted label from its configured font size and weight, wrap compact -rows, and use `gap` between items. `rowGap` adds vertical space to every item -row. +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 @@ -673,9 +677,9 @@ paint: ```ts type Series = 'Revenue' | 'Orders' -colorLegend({ +colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems({ justify: 'center', gap: 20, rowGap: 10, @@ -689,7 +693,7 @@ colorLegend({ fontSize: 14, fill: (_series, { color }) => color, }, - }, + }), }) ``` diff --git a/docs/reference/types.md b/docs/reference/types.md index 090577d2..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 | diff --git a/packages/charts-core/docs/guides/legends-and-color.md b/packages/charts-core/docs/guides/legends-and-color.md index a2cdcfcc..b176191a 100644 --- a/packages/charts-core/docs/guides/legends-and-color.md +++ b/packages/charts-core/docs/guides/legends-and-color.md @@ -105,7 +105,7 @@ Options: - `label`: optional legend title; - `itemWidth`: minimum categorical item width; -- `items`: categorical item layout, indicator, and label presentation; +- `items`: categorical item presentation from `colorLegendItems()`; - `width`: preferred quantitative legend width; - `format`: numeric boundary formatter; - `placement`: `top` by default or `bottom`. @@ -114,15 +114,18 @@ 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. Use -`items.justify: 'start'` or `'center'` for compact rows measured from the -formatted labels. The built-in legend keeps responsive wrapping when you -change typography, spacing, label paint, or indicator shape: +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 -colorLegend<'Revenue' | 'Orders'>({ +import { colorLegend, colorLegendItems } from '@tanstack/charts' + +colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems<'Revenue' | 'Orders'>({ justify: 'center', gap: 20, rowGap: 10, @@ -136,14 +139,14 @@ colorLegend<'Revenue' | 'Orders'>({ fontSize: 14, fill: (_series, { color }) => color, }, - }, + }), }) ``` `dot`, `square`, `line`, and `line-dot` are renderer-neutral scene shapes. -For another symbol, provide `items.indicator.render`; its context contains the -resolved item color and the indicator bounds. The legend still owns row -measurement and wrapping. +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' 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 ad951842..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,17 +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: { + items: colorLegendItems({ justify: 'center', gap: 16, indicator: { shape: 'square' }, label: { fontSize: 12 }, - }, + }), width: 240, format: (value) => value.toFixed(0), placement: 'bottom', @@ -606,12 +606,16 @@ colorLegend({ interface ColorLegendOptions { label?: string itemWidth?: number - items?: ColorLegendItemOptions + 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 @@ -651,9 +655,9 @@ interface ColorLegendLabelOptions { `itemWidth` defaults to `110` and is clamped to a minimum of `64`. Items wrap to responsive columns for categorical scales. The default `stretch` justification retains those equal-width columns. `start` and `center` measure -each formatted label from its configured font size and weight, wrap compact -rows, and use `gap` between items. `rowGap` adds vertical space to every item -row. +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 @@ -673,9 +677,9 @@ paint: ```ts type Series = 'Revenue' | 'Orders' -colorLegend({ +colorLegend({ placement: 'bottom', - items: { + items: colorLegendItems({ justify: 'center', gap: 20, rowGap: 10, @@ -689,7 +693,7 @@ colorLegend({ fontSize: 14, fill: (_series, { color }) => color, }, - }, + }), }) ``` diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index 090577d2..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 | 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-static.test.ts b/packages/charts-core/src/legend-static.test.ts index c19de7e4..9628e80b 100644 --- a/packages/charts-core/src/legend-static.test.ts +++ b/packages/charts-core/src/legend-static.test.ts @@ -1,7 +1,8 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { scaleBand, scaleLinear } from 'd3-scale' import { barY } from './bar' -import { colorLegend } from './legend-static' +import { estimateSceneText } from './guide-layout' +import { colorLegend, colorLegendItems } from './legend-static' import { lineY } from './line' import { createChartScene, defaultChartTheme, defineChart } from './scene' import type { @@ -29,6 +30,7 @@ function legendContext( 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, @@ -63,13 +65,13 @@ describe('categorical color legend presentation', () => { it('measures labels to center and wrap compact rows', () => { const legend = colorLegend({ - items: { + items: colorLegendItems({ justify: 'center', gap: 20, rowGap: 10, indicator: { width: 20, height: 14, gap: 6 }, label: { fontSize: 14 }, - }, + }), }) const context = legendContext() @@ -82,9 +84,39 @@ describe('categorical color legend presentation', () => { 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: { + items: colorLegendItems<'Alpha' | 'Beta' | 'Gamma'>({ justify: 'center', indicator: { width: 20, @@ -95,7 +127,7 @@ describe('categorical color legend presentation', () => { fontSize: 14, fill: (_value, { color }) => color, }, - }, + }), }) const children = renderLegend(legend).children @@ -141,7 +173,9 @@ describe('categorical color legend presentation', () => { style: { stroke: context.color }, })) const legend = colorLegend({ - items: { indicator: { width: 18, height: 12, render } }, + items: colorLegendItems({ + indicator: { width: 18, height: 12, render }, + }), }) renderLegend(legend) @@ -186,13 +220,13 @@ describe('categorical color legend presentation', () => { range: ['#2563eb', '#f97316'], legend: colorLegend<'Revenue' | 'Orders'>({ placement: 'bottom', - items: { + items: colorLegendItems<'Revenue' | 'Orders'>({ indicator: { width: 20, height: 12, shape: (value) => (value === 'Revenue' ? 'line-dot' : 'square'), }, - }, + }), }), }, }) @@ -216,7 +250,7 @@ describe('categorical color legend presentation', () => { }) colorLegend<'Revenue' | 'Orders'>({ - items: { + items: colorLegendItems<'Revenue' | 'Orders'>({ indicator: { shape: (value, context) => { expectTypeOf(value).toEqualTypeOf<'Revenue' | 'Orders'>() @@ -230,14 +264,14 @@ colorLegend<'Revenue' | 'Orders'>({ return value }, }, - }, + }), }) colorLegend({ - items: { + 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 d083d076..7ddf9ea2 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -4,7 +4,6 @@ import { resolveCategoricalLegendItems, } from './legend-layout-internal' import { valueKey } from './scales' -import { estimateTextWidth } from './text-estimate-internal' import type { ChartBounds, ChartColorLegend, @@ -59,10 +58,16 @@ export interface ColorLegendItemOptions { label?: ColorLegendLabelOptions } +declare const colorLegendItemsBrand: unique symbol + +export interface ColorLegendItems { + readonly [colorLegendItemsBrand]: TValue +} + export interface ColorLegendOptions { label?: string itemWidth?: number - items?: ColorLegendItemOptions + items?: ColorLegendItems width?: number format?: (value: number) => string placement?: ChartLegendPlacement @@ -76,6 +81,77 @@ export interface ColorGradientLegendOptions { placement?: ChartLegendPlacement } +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 y = bounds.y + 10 + 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 { @@ -85,6 +161,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 { @@ -93,12 +170,15 @@ export function colorLegend( if (isQuantitativeLegend(context.colors.kind)) { return gradient.height(itemCount, context) } - const presentation = resolveCategoricalLegendPresentation( - options, - context, + if (items) { + return 18 + labelOffset + (items(context, minimumItemWidth) as number) + } + const layout = layoutCategoricalLegendItems( + itemCount, + context.chart.width, minimumItemWidth, ) - return 18 + labelOffset + presentation.rows * presentation.rowHeight + return 18 + labelOffset + layout.rows * 19 }, render(context) { if (isContinuousLegend(context.colors.kind)) { @@ -108,11 +188,6 @@ export function colorLegend( return renderSteppedLegend(options, context) } const { bounds, theme } = context - const presentation = resolveCategoricalLegendPresentation( - options, - context, - minimumItemWidth, - ) const children: SceneNode[] = [] if (options.label) { children.push({ @@ -126,48 +201,19 @@ export function colorLegend( style: { fill: theme.foreground, fillOpacity: 0.78 }, }) } - presentation.items.forEach(({ item, row, x }) => { - const y = bounds.y + 10 + 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.items?.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.items?.label?.fill, - item.value, - item.context, - theme.foreground, - ), - fillOpacity: - options.items?.label?.fillOpacity ?? - (options.items?.label?.fill === undefined ? 0.76 : 1), - }, - } satisfies SceneLabel, - ) - }) + children.push( + ...(items + ? (items( + context, + minimumItemWidth, + labelOffset, + ) as readonly SceneNode[]) + : renderDefaultCategoricalLegend( + context, + labelOffset, + minimumItemWidth, + )), + ) return { kind: 'group', @@ -180,6 +226,47 @@ 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 @@ -206,19 +293,20 @@ interface CategoricalLegendPresentation { } function resolveCategoricalLegendPresentation( - options: ColorLegendOptions, + options: ColorLegendItemOptions, context: Parameters[1], minimumItemWidth: number, ): CategoricalLegendPresentation { - const itemOptions = options.items - const labelOptions = itemOptions?.label - const indicatorOptions = itemOptions?.indicator + const labelOptions = options.label + const indicatorOptions = options.indicator const fontSize = finiteNonnegative(labelOptions?.fontSize, 11) - const fontWeight = labelOptions?.fontWeight + 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(itemOptions?.rowGap, 8) + const rowGap = finiteNonnegative(options.rowGap, 8) const resolvedItems = resolveCategoricalLegendItems( context.colors, labelOptions?.format, @@ -231,11 +319,22 @@ function resolveCategoricalLegendPresentation( width: indicatorWidth + indicatorGap + - estimateTextWidth(item.label, fontSize, fontWeight ?? 400), + (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 = itemOptions?.justify ?? 'stretch' + const justify = options.justify ?? 'stretch' if (justify === 'stretch') { const layout = layoutCategoricalLegendItems( items.length, @@ -261,7 +360,7 @@ function resolveCategoricalLegendPresentation( const layout = layoutCategoricalLegendFlow( items.map((item) => item.width), context.bounds.width, - finiteNonnegative(itemOptions?.gap, 16), + finiteNonnegative(options.gap, 16), justify, ) return { diff --git a/packages/charts-core/src/legend.ts b/packages/charts-core/src/legend.ts index 64911a42..d341b13c 100644 --- a/packages/charts-core/src/legend.ts +++ b/packages/charts-core/src/legend.ts @@ -1,12 +1,14 @@ export { colorGradientLegend, colorLegend, + colorLegendItems, type ColorGradientLegendOptions, type ColorLegendIndicatorOptions, type ColorLegendIndicatorRenderContext, type ColorLegendIndicatorShape, type ColorLegendItemContext, type ColorLegendItemOptions, + type ColorLegendItems, type ColorLegendItemValue, type ColorLegendLabelOptions, type ColorLegendOptions, 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/text-estimate-internal.ts b/packages/charts-core/src/text-estimate-internal.ts deleted file mode 100644 index aec5660e..00000000 --- a/packages/charts-core/src/text-estimate-internal.ts +++ /dev/null @@ -1,29 +0,0 @@ -export function estimateTextWidth( - text: string, - fontSize: number, - fontWeight: number, - letterSpacing = 0, -): number { - let emWidth = 0 - for (const character of text) { - emWidth += estimateCharacterWidth(character) - } - const clampedWeight = Math.min(900, Math.max(100, fontWeight)) - const weightFactor = 1 + (clampedWeight - 400) / 12_500 - return Math.max( - 0, - emWidth * fontSize * weightFactor + - Math.max(0, Array.from(text).length - 1) * letterSpacing, - ) -} - -function estimateCharacterWidth(character: string): number { - if (/\s/u.test(character)) return 0.33 - if (/[\u0300-\u036f]/u.test(character)) return 0 - if (/[ilI1|!.,:;'`]/u.test(character)) return 0.28 - if (/[mwMW@#%&]/u.test(character)) return 0.9 - if (/[A-Z]/u.test(character)) return 0.64 - if (/[0-9]/u.test(character)) return 0.56 - if (character.codePointAt(0)! > 0x7f) return 1 - return 0.54 -} 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 83e76b6e..68e0f2aa 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1369,9 +1369,29 @@ 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', - 2.7, + 'Categorical legend', + 1.15, { rendererBoundary: 'neutral', inputBoundary: { @@ -1384,6 +1404,8 @@ const entries = [ 'platformRendererRuntime', 'd3GeometryRuntime', ], + addedFrom: 'Categorical legend', + allowAdded: [], }, }, ), From 8a2966585f59b7186a21a46db8bc8cf8e163acac Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Fri, 28 Aug 2026 00:45:04 +0200 Subject: [PATCH 4/5] Update chart comparison bundle baseline --- benchmarks/comparison/bundle-baseline.json | 126 +++++++++--------- benchmarks/conformance/previews/manifest.json | 2 +- docs/comparison.md | 6 +- packages/charts-core/docs/comparison.md | 6 +- 4 files changed, 70 insertions(+), 70 deletions(-) diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 7f67992f..920c6bf3 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-27T21:51:31.433Z", + "generatedAt": "2026-08-27T22:42:28.689Z", "packageVersions": { "tanstack": "0.16.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "e4c9fa1ff16d73341601232269f664a9225c6bc3", - "inputDigest": "sha256:b045a5fb010b03b947d8c23f5684e0c948870fa6b68239231d4f19f54b2653e5" + "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": 118587, - "gzipBytes": 42639, - "brotliBytes": 37470, - "incrementalGzipBytes": 42639, - "incrementalBrotliBytes": 37470 + "minifiedBytes": 116019, + "gzipBytes": 41709, + "brotliBytes": 36809, + "incrementalGzipBytes": 41709, + "incrementalBrotliBytes": 36809 }, "tanstack-line-advanced": { - "minifiedBytes": 125777, - "gzipBytes": 44993, - "brotliBytes": 39504, - "incrementalGzipBytes": 44993, - "incrementalBrotliBytes": 39504 + "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": 126236, - "gzipBytes": 45563, - "brotliBytes": 39970, - "incrementalGzipBytes": 45563, - "incrementalBrotliBytes": 39970 + "minifiedBytes": 123668, + "gzipBytes": 44643, + "brotliBytes": 39213, + "incrementalGzipBytes": 44643, + "incrementalBrotliBytes": 39213 }, "tanstack-bar-advanced": { - "minifiedBytes": 126575, - "gzipBytes": 45699, - "brotliBytes": 40080, - "incrementalGzipBytes": 45699, - "incrementalBrotliBytes": 40080 + "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": 123800, - "gzipBytes": 44669, - "brotliBytes": 39253, - "incrementalGzipBytes": 44669, - "incrementalBrotliBytes": 39253 + "minifiedBytes": 121232, + "gzipBytes": 43648, + "brotliBytes": 38510, + "incrementalGzipBytes": 43648, + "incrementalBrotliBytes": 38510 }, "tanstack-area-advanced": { - "minifiedBytes": 131172, - "gzipBytes": 47071, - "brotliBytes": 41339, - "incrementalGzipBytes": 47071, - "incrementalBrotliBytes": 41339 + "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": 119616, - "gzipBytes": 42989, - "brotliBytes": 37829, - "incrementalGzipBytes": 42989, - "incrementalBrotliBytes": 37829 + "minifiedBytes": 117044, + "gzipBytes": 42075, + "brotliBytes": 37041, + "incrementalGzipBytes": 42075, + "incrementalBrotliBytes": 37041 }, "tanstack-scatter-advanced": { - "minifiedBytes": 119632, - "gzipBytes": 42995, - "brotliBytes": 37763, - "incrementalGzipBytes": 42995, - "incrementalBrotliBytes": 37763 + "minifiedBytes": 117060, + "gzipBytes": 42081, + "brotliBytes": 37123, + "incrementalGzipBytes": 42081, + "incrementalBrotliBytes": 37123 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 085a7255..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": "fb69bf169cc2896fd718726445ec22bff39a632bb7e41c8176e29eb09d7015da", + "sourceHash": "8ca5210cc2b914ddf13f5dc5c389df299842160e168e6006dce08553da5a06b9", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index a3bc35d4..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 `e4c9fa1` | +| [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 `e4c9fa1`. +at page render time. The measured TanStack workspace revision is `2ddcf66`. ## Capability matrix @@ -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–45.97 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/comparison.md b/packages/charts-core/docs/comparison.md index a3bc35d4..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 `e4c9fa1` | +| [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 `e4c9fa1`. +at page render time. The measured TanStack workspace revision is `2ddcf66`. ## Capability matrix @@ -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–45.97 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 | From 7114305366b74d6a11523fca8a391516e2e2c72d Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Fri, 28 Aug 2026 07:48:16 +0200 Subject: [PATCH 5/5] Keep oversized legend indicators within bounds --- packages/charts-core/src/legend-static.test.ts | 17 +++++++++++++++++ packages/charts-core/src/legend-static.ts | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/charts-core/src/legend-static.test.ts b/packages/charts-core/src/legend-static.test.ts index 9628e80b..3031f8ed 100644 --- a/packages/charts-core/src/legend-static.test.ts +++ b/packages/charts-core/src/legend-static.test.ts @@ -191,6 +191,23 @@ describe('categorical color legend presentation', () => { ) }) + 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 }, diff --git a/packages/charts-core/src/legend-static.ts b/packages/charts-core/src/legend-static.ts index 7ddf9ea2..c8d4f62d 100644 --- a/packages/charts-core/src/legend-static.ts +++ b/packages/charts-core/src/legend-static.ts @@ -106,7 +106,12 @@ export function colorLegendItems( const { bounds, theme } = context const children: SceneNode[] = [] presentation.items.forEach(({ item, row, x }) => { - const y = bounds.y + 10 + labelOffset + row * presentation.rowHeight + 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,