diff --git a/.changeset/bright-axis-titles.md b/.changeset/bright-axis-titles.md new file mode 100644 index 00000000..2274bf4b --- /dev/null +++ b/.changeset/bright-axis-titles.md @@ -0,0 +1,6 @@ +--- +'@tanstack/charts': minor +--- + +Allow Cartesian axis titles to configure font size, font weight, fill, and +opacity while retaining automatic guide layout. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..7361ebf1 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 | Axis titles could not carry authored typography or paint | API | resolved | ## Findings @@ -8505,3 +8506,28 @@ 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 - Axis titles could not carry authored typography or paint + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: migrating Rewardo dashboard charts to TanStack Charts on the + `experimental-tanstack-charts` branch and issue #93 +- Friction: tick labels accepted font size, weight, and opacity, but + `axis.label` accepted only text, offset, and motion. Rewardo's 14-pixel chart + typography and foreground treatment therefore required a generated-node CSS + override that Canvas and React Native could not honor. +- Decision: extend the existing axis-label object with `fontSize`, + `fontWeight`, `fill`, and `opacity`. Keep the string form and every omitted + object field on the existing defaults. Resolve the options into the shared + scene label so automatic guide measurement and every renderer use one + contract. +- Verification: scene layout covers configured typography, paint, default + compatibility, automatic margins, and shared facet titles. SVG, Canvas, + React Native, and motion tests exercise the same public definition. The + composed weather catalog case uses independently styled titles on three y + axes without renderer callbacks or CSS selectors. The reviewed shared-path + delta is 169 minified bytes and 29 gzip bytes for the locked line plus static + SVG consumer. A dedicated styled-title fixture adds 0.06 KiB gzip and no + retained modules over that consumer. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index bb9c0e54..0b9230aa 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -37,6 +37,10 @@ gzip ceiling. The locked shared-host entries record the reviewed integration cost, while noninteractive consumers retain only the small scene-compiler portion of that contract. +Cartesian axis-title styling is also part of the shared scene contract. Its +static SVG fixture adds no retained modules over the ordinary line consumer +and keeps authored title options under a 0.25 KiB incremental gzip ceiling. + Continuous viewports and the controlled interaction controller are also part of the default scene and host contracts. Default static SVG consumes scene clips and gradients. Their reviewed shared-path cost is recorded in the locked diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..7c6584b7 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -3,44 +3,44 @@ "policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.", "bundles": { "D3-scale line scene": { - "bytes": 50813, - "gzip": 19070 + "bytes": 50982, + "gzip": 19097 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55713, + "gzip": 20805 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75788, + "gzip": 27619 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 75118, + "gzip": 26064 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77301, + "gzip": 26854 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 100937, + "gzip": 36262 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33436, + "gzip": 11953 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83437, + "gzip": 29177 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31618, + "gzip": 11219 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50914, + "gzip": 19060 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..71f88136 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:42:45.654Z", "packageVersions": { - "tanstack": "0.15.0", + "tanstack": "0.16.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e" + "inputDigest": "sha256:f6e6651fe8f30068b56208e243a35ea634593f85c7364b8403dfebfcb1e0d0f8" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110431, - "gzipBytes": 39891, - "brotliBytes": 35325, - "incrementalGzipBytes": 39891, - "incrementalBrotliBytes": 35325 + "minifiedBytes": 110602, + "gzipBytes": 39906, + "brotliBytes": 35329, + "incrementalGzipBytes": 39906, + "incrementalBrotliBytes": 35329 }, "tanstack-line-interactive": { - "minifiedBytes": 115872, - "gzipBytes": 41648, - "brotliBytes": 36746, - "incrementalGzipBytes": 41648, - "incrementalBrotliBytes": 36746 + "minifiedBytes": 116043, + "gzipBytes": 41675, + "brotliBytes": 36794, + "incrementalGzipBytes": 41675, + "incrementalBrotliBytes": 36794 }, "tanstack-line-advanced": { - "minifiedBytes": 123062, - "gzipBytes": 43974, - "brotliBytes": 38764, - "incrementalGzipBytes": 43974, - "incrementalBrotliBytes": 38764 + "minifiedBytes": 123233, + "gzipBytes": 44002, + "brotliBytes": 38748, + "incrementalGzipBytes": 44002, + "incrementalBrotliBytes": 38748 }, "tanstack-bar-basic": { - "minifiedBytes": 119225, - "gzipBytes": 43210, - "brotliBytes": 38168, - "incrementalGzipBytes": 43210, - "incrementalBrotliBytes": 38168 + "minifiedBytes": 119396, + "gzipBytes": 43218, + "brotliBytes": 38198, + "incrementalGzipBytes": 43218, + "incrementalBrotliBytes": 38198 }, "tanstack-bar-interactive": { - "minifiedBytes": 123521, - "gzipBytes": 44585, - "brotliBytes": 39223, - "incrementalGzipBytes": 44585, - "incrementalBrotliBytes": 39223 + "minifiedBytes": 123692, + "gzipBytes": 44605, + "brotliBytes": 39267, + "incrementalGzipBytes": 44605, + "incrementalBrotliBytes": 39267 }, "tanstack-bar-advanced": { - "minifiedBytes": 123860, - "gzipBytes": 44735, - "brotliBytes": 39349, - "incrementalGzipBytes": 44735, - "incrementalBrotliBytes": 39349 + "minifiedBytes": 124031, + "gzipBytes": 44749, + "brotliBytes": 39341, + "incrementalGzipBytes": 44749, + "incrementalBrotliBytes": 39341 }, "tanstack-area-basic": { - "minifiedBytes": 115640, - "gzipBytes": 41867, - "brotliBytes": 37073, - "incrementalGzipBytes": 41867, - "incrementalBrotliBytes": 37073 + "minifiedBytes": 115811, + "gzipBytes": 41866, + "brotliBytes": 37072, + "incrementalGzipBytes": 41866, + "incrementalBrotliBytes": 37072 }, "tanstack-area-interactive": { - "minifiedBytes": 121085, - "gzipBytes": 43594, - "brotliBytes": 38493, - "incrementalGzipBytes": 43594, - "incrementalBrotliBytes": 38493 + "minifiedBytes": 121256, + "gzipBytes": 43620, + "brotliBytes": 38585, + "incrementalGzipBytes": 43620, + "incrementalBrotliBytes": 38585 }, "tanstack-area-advanced": { - "minifiedBytes": 128457, - "gzipBytes": 46057, - "brotliBytes": 40527, - "incrementalGzipBytes": 46057, - "incrementalBrotliBytes": 40527 + "minifiedBytes": 128628, + "gzipBytes": 46064, + "brotliBytes": 40628, + "incrementalGzipBytes": 46064, + "incrementalBrotliBytes": 40628 }, "tanstack-scatter-basic": { - "minifiedBytes": 111460, - "gzipBytes": 40266, - "brotliBytes": 35596, - "incrementalGzipBytes": 40266, - "incrementalBrotliBytes": 35596 + "minifiedBytes": 111631, + "gzipBytes": 40284, + "brotliBytes": 35628, + "incrementalGzipBytes": 40284, + "incrementalBrotliBytes": 35628 }, "tanstack-scatter-interactive": { - "minifiedBytes": 116901, - "gzipBytes": 42015, - "brotliBytes": 36976, - "incrementalGzipBytes": 42015, - "incrementalBrotliBytes": 36976 + "minifiedBytes": 117072, + "gzipBytes": 42044, + "brotliBytes": 37051, + "incrementalGzipBytes": 42044, + "incrementalBrotliBytes": 37051 }, "tanstack-scatter-advanced": { - "minifiedBytes": 116917, - "gzipBytes": 42020, - "brotliBytes": 37012, - "incrementalGzipBytes": 42020, - "incrementalBrotliBytes": 37012 + "minifiedBytes": 117088, + "gzipBytes": 42050, + "brotliBytes": 37043, + "incrementalGzipBytes": 42050, + "incrementalBrotliBytes": 37043 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/70-composed-chart/case.json b/benchmarks/conformance/cases/70-composed-chart/case.json index 62dac152..bb1368ee 100644 --- a/benchmarks/conformance/cases/70-composed-chart/case.json +++ b/benchmarks/conformance/cases/70-composed-chart/case.json @@ -11,6 +11,7 @@ "mixed marks", "independent y scales", "left axis and stacked right axes", + "styled axis titles", "daily observations", "monotone curves", "resolved maximum bar thickness" diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx index 02187acb..78b372ad 100644 --- a/benchmarks/conformance/cases/70-composed-chart/example.tsx +++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx @@ -63,7 +63,13 @@ export const createExampleChart = (input: ChartOptions) => { scale: scaleLinear, grid: true, axis: { - label: 'Temperature (°C)', + label: { + text: 'Temperature (°C)', + fontSize: 12, + fontWeight: 700, + fill: '#8884d8', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, @@ -72,7 +78,13 @@ export const createExampleChart = (input: ChartOptions) => { side: 'right', scale: scaleLinear, axis: { - label: 'Precipitation (mm)', + label: { + text: 'Precipitation (mm)', + fontSize: 12, + fontWeight: 700, + fill: '#413ea0', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, @@ -81,7 +93,13 @@ export const createExampleChart = (input: ChartOptions) => { side: 'right', scale: scaleLinear, axis: { - label: 'Wind (m/s)', + label: { + text: 'Wind (m/s)', + fontSize: 12, + fontWeight: 700, + fill: '#ef4444', + opacity: 0.8, + }, ticks: { count: 5 }, }, }, diff --git a/benchmarks/conformance/cases/70-composed-chart/recharts.ts b/benchmarks/conformance/cases/70-composed-chart/recharts.ts index 24a7d097..71443f44 100644 --- a/benchmarks/conformance/cases/70-composed-chart/recharts.ts +++ b/benchmarks/conformance/cases/70-composed-chart/recharts.ts @@ -55,6 +55,10 @@ function chart(input: ConformanceInput) { value: 'Temperature (°C)', angle: -90, position: 'insideLeft', + fill: '#8884d8', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(YAxis, { @@ -67,6 +71,10 @@ function chart(input: ConformanceInput) { value: 'Precipitation (mm)', angle: 90, position: 'insideRight', + fill: '#413ea0', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(YAxis, { @@ -79,6 +87,10 @@ function chart(input: ConformanceInput) { value: 'Wind (m/s)', angle: 90, position: 'insideRight', + fill: '#ef4444', + fontSize: 12, + fontWeight: 700, + opacity: 0.8, }, }), createElement(Area, { diff --git a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts index ec59bc74..7df727ab 100644 --- a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts +++ b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts @@ -2,7 +2,7 @@ import { createChartRuntime } from '@tanstack/charts' import { describe, expect, it } from 'vitest' import { loadTanStackSources } from '../../native-catalog' import { createExampleChart } from './tanstack' -import type { SceneNode, SceneRule } from '@tanstack/charts' +import type { SceneLabel, SceneNode, SceneRule } from '@tanstack/charts' import type { ConformanceInput } from '../../types' const input = { @@ -67,6 +67,36 @@ describe('native composed-chart bar sizing', () => { expect(windAxis?.x1).toBeGreaterThan(precipitationAxis?.x1 ?? Infinity) }) + it('styles each axis title through the chart definition', () => { + const titles = flatten(render(input).nodes).filter( + (node): node is SceneLabel => + node.kind === 'label' && node.key.endsWith('-label'), + ) + + expect(titles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Temperature (°C)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#8884d8', opacity: 0.8 }, + }), + expect.objectContaining({ + text: 'Precipitation (mm)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#413ea0', opacity: 0.8 }, + }), + expect.objectContaining({ + text: 'Wind (m/s)', + fontSize: 12, + fontWeight: 700, + style: { fill: '#ef4444', opacity: 0.8 }, + }), + ]), + ) + }) + it('does not hide responsive bar geometry outside the definition', async () => { const closure = await loadTanStackSources('70-composed-chart') const source = closure.files.map((file) => file.source).join('\n') diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 9d3c84f0..56c01564 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -2392,6 +2392,7 @@ "mixed marks", "independent y scales", "left axis and stacked right axes", + "styled axis titles", "daily observations", "monotone curves", "resolved maximum bar thickness" diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg index 5de7df71..3bb5b3d1 100644 --- a/benchmarks/conformance/previews/70-composed-chart.svg +++ b/benchmarks/conformance/previews/70-composed-chart.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..487e8ed8 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293", + "sourceHash": "e8fb24cd96d6fbfbb8327eb97ab506f7f60fd4870baa986a725da75d736c87b9", "assets": [ { "id": "01-line-gaps", @@ -306,8 +306,8 @@ }, { "id": "70-composed-chart", - "sha256": "642139631cae72ef76ff81e148e6d40e2ff83225a11b582a32878d0f166beae7", - "bytes": 14436 + "sha256": "9d79b12f0fbcf14f8bfa4115ea0ee75147fe0d9fa27ca2ec20a0f29a50857fc0", + "bytes": 14480 }, { "id": "71-recharts-population-pyramid", diff --git a/benchmarks/entries/charts-axis-label-styles.ts b/benchmarks/entries/charts-axis-label-styles.ts new file mode 100644 index 00000000..a0c82ad6 --- /dev/null +++ b/benchmarks/entries/charts-axis-label-styles.ts @@ -0,0 +1,32 @@ +import { + createChartScene, + defineChart, + lineY, + renderChartSvg, +} from '@tanstack/charts' +import { scaleLinear } from 'd3-scale' + +const definition = defineChart({ + marks: [lineY([4, 9, 7])], + scales: { + x: { + scale: scaleLinear().domain([0, 2]), + axis: { + label: { + text: 'Quarter', + fontSize: 13, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.8, + }, + }, + }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, +}) + +export function render(width: number, height: number) { + return renderChartSvg(createChartScene(definition, { width, height }), { + ariaLabel: 'Styled axis title', + }) +} diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..83f66b70 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index 9aa67858..ae21a276 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -152,20 +152,27 @@ const x = { format: (date: Date) => monthFormatter.format(date), }, tickLabels: { rotate: -30 }, - label: { text: 'Month', offset: 12 }, + label: { + text: 'Month', + offset: 12, + fontSize: 13, + fontWeight: 600, + fill: '#334155', + opacity: 0.9, + }, }, } ``` -| Option | Purpose | -| ----------------- | ---------------------------------------------------- | -| `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | -| `axis.ticks` | Configure candidates, stubs, padding, and formatting | -| `axis.tickLabels` | Configure label rotation and collision thinning | -| `axis.label` | Configure the axis title and offset | -| `grid` | Draw grid lines at semantic candidates | -| `reverse` | Reverse the responsive range | +| Option | Purpose | +| ----------------- | -------------------------------------------------------- | +| `axis` | Configure the axis or hide it with `false` | +| `axis.line` | Show or hide the baseline | +| `axis.ticks` | Configure candidates, stubs, padding, and formatting | +| `axis.tickLabels` | Configure label rotation and collision thinning | +| `axis.label` | Configure axis title text, typography, paint, and offset | +| `grid` | Draw grid lines at semantic candidates | +| `reverse` | Reverse the responsive range | The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md index cbea3392..1cd79e40 100644 --- a/docs/guides/themes-and-styling.md +++ b/docs/guides/themes-and-styling.md @@ -93,6 +93,37 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Axis title styling + +String axis titles use the chart foreground and built-in title typography. +Use the object form when one title needs an explicit text color, opacity, size, +weight, or offset: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale, axis: { label: 'Month' } }, + y: { + scale: yScale, + axis: { + label: { + text: 'Revenue (USD)', + fontSize: 14, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.85, + }, + }, + }, + }, +}) +``` + +`fill` is the title's text color. The layout measures the configured font size +and weight before resolving automatic guide margins, so larger titles do not +need matching manual margins. + ## Canvas styling The Canvas renderer resolves scene paints such as `currentColor` and CSS diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md index 69af1cab..b8913e52 100644 --- a/docs/reference/scales-guides-and-color.md +++ b/docs/reference/scales-guides-and-color.md @@ -228,7 +228,17 @@ interface ChartAxisOptions { keep?: readonly TValue[] } } - label?: string | { text: string; offset?: number | 'auto' } + label?: + | string + | { + text: string + offset?: number | 'auto' + fontSize?: number + fontWeight?: number + fill?: string + opacity?: number + motion?: ChartMotionDefinition + } } } ``` @@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +Axis titles keep the compact string form when only text is needed. Use the +object form for title typography, paint, offset, or motion: + +```ts +const y = { + scale: scaleLinear, + axis: { + label: { + text: 'Average order value (PLN)', + fontSize: 14, + fontWeight: 500, + fill: '#363636', + opacity: 0.9, + }, + }, +} +``` + +`fill` sets the text color. Omitted presentation fields retain the existing +title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight +600, theme foreground fill, and 0.76 fill opacity. Configured font size and +weight participate in text measurement, automatic offsets, and automatic +margins for SVG, Canvas, and native rendering. + ## Named scales and multiple axes The reserved `x` and `y` entries are the default bindings for Cartesian marks. diff --git a/docs/reference/types.md b/docs/reference/types.md index f219b23b..34536583 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | | `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property | | `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept | -| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset | +| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset | | `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain | | `ChartScaleInput` | Factory or configured positional scale instance | | `InferableScaleLike` | Domain-configurable scale returned by a factory | diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..83f66b70 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- | -| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | | Chart.js | 44.70–58.21 KiB | — | Controlled suite | | Apache ECharts | 153.10–173.18 KiB | — | Controlled suite | diff --git a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md index 9aa67858..ae21a276 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -152,20 +152,27 @@ const x = { format: (date: Date) => monthFormatter.format(date), }, tickLabels: { rotate: -30 }, - label: { text: 'Month', offset: 12 }, + label: { + text: 'Month', + offset: 12, + fontSize: 13, + fontWeight: 600, + fill: '#334155', + opacity: 0.9, + }, }, } ``` -| Option | Purpose | -| ----------------- | ---------------------------------------------------- | -| `axis` | Configure the axis or hide it with `false` | -| `axis.line` | Show or hide the baseline | -| `axis.ticks` | Configure candidates, stubs, padding, and formatting | -| `axis.tickLabels` | Configure label rotation and collision thinning | -| `axis.label` | Configure the axis title and offset | -| `grid` | Draw grid lines at semantic candidates | -| `reverse` | Reverse the responsive range | +| Option | Purpose | +| ----------------- | -------------------------------------------------------- | +| `axis` | Configure the axis or hide it with `false` | +| `axis.line` | Show or hide the baseline | +| `axis.ticks` | Configure candidates, stubs, padding, and formatting | +| `axis.tickLabels` | Configure label rotation and collision thinning | +| `axis.label` | Configure axis title text, typography, paint, and offset | +| `grid` | Draw grid lines at semantic candidates | +| `reverse` | Reverse the responsive range | The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted. diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md index cbea3392..1cd79e40 100644 --- a/packages/charts-core/docs/guides/themes-and-styling.md +++ b/packages/charts-core/docs/guides/themes-and-styling.md @@ -93,6 +93,37 @@ Keep these responsibilities separate: For categorical or quantitative color mapping, use the canonical [Legends and Color](./legends-and-color.md) guide. +## Axis title styling + +String axis titles use the chart foreground and built-in title typography. +Use the object form when one title needs an explicit text color, opacity, size, +weight, or offset: + +```ts +const definition = defineChart({ + marks, + scales: { + x: { scale: xScale, axis: { label: 'Month' } }, + y: { + scale: yScale, + axis: { + label: { + text: 'Revenue (USD)', + fontSize: 14, + fontWeight: 700, + fill: '#2563eb', + opacity: 0.85, + }, + }, + }, + }, +}) +``` + +`fill` is the title's text color. The layout measures the configured font size +and weight before resolving automatic guide margins, so larger titles do not +need matching manual margins. + ## Canvas styling The Canvas renderer resolves scene paints such as `currentColor` and CSS diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md index 69af1cab..b8913e52 100644 --- a/packages/charts-core/docs/reference/scales-guides-and-color.md +++ b/packages/charts-core/docs/reference/scales-guides-and-color.md @@ -228,7 +228,17 @@ interface ChartAxisOptions { keep?: readonly TValue[] } } - label?: string | { text: string; offset?: number | 'auto' } + label?: + | string + | { + text: string + offset?: number | 'auto' + fontSize?: number + fontWeight?: number + fill?: string + opacity?: number + motion?: ChartMotionDefinition + } } } ``` @@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision thinning and automatic margins. Numeric typography follows tick-label motion; anchor changes snap. +Axis titles keep the compact string form when only text is needed. Use the +object form for title typography, paint, offset, or motion: + +```ts +const y = { + scale: scaleLinear, + axis: { + label: { + text: 'Average order value (PLN)', + fontSize: 14, + fontWeight: 500, + fill: '#363636', + opacity: 0.9, + }, + }, +} +``` + +`fill` sets the text color. Omitted presentation fields retain the existing +title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight +600, theme foreground fill, and 0.76 fill opacity. Configured font size and +weight participate in text measurement, automatic offsets, and automatic +margins for SVG, Canvas, and native rendering. + ## Named scales and multiple axes The reserved `x` and `y` entries are the default bindings for Cartesian marks. diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index f219b23b..34536583 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | | `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property | | `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept | -| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset | +| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset | | `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain | | `ChartScaleInput` | Factory or configured positional scale instance | | `InferableScaleLike` | Domain-configurable scale returned by a factory | diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 5ce7d73c..8cc59ba8 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -37,6 +37,11 @@ import type { ChartScene, SceneNode } from './types' interface FakeCanvasContext { operations: string[] gradientStops: Array<[number, string]> + textPaints: Array<{ + text: string + globalAlpha: number + font: string + }> context: CanvasRenderingContext2D } @@ -988,6 +993,49 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints Cartesian axis-title typography and paint', () => { + const definition = defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }) + const container = document.createElement('div') + const surface = canvasChartRenderer.mount(container, () => {}) + const generated = createChartScene(definition, { + width: 480, + height: 260, + }) + const title = generated.nodes + .flatMap((node) => (node.kind === 'group' ? node.children : [node])) + .find((node) => node.key === 'y-label') + + expect(title?.style?.fill).toBe('#0f766e') + surface.render(generated, { ariaLabel: 'Revenue chart' }) + + const painted = contexts + .get(surface.sceneCanvas) + ?.textPaints.find(({ text }) => text === 'Revenue') + expect(painted).toMatchObject({ + globalAlpha: 0.6, + }) + expect(painted?.font).toMatch(/650 17px/) + surface.destroy() + }) + it('snaps retargeting focus candidates without adding interaction points', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -2432,6 +2480,7 @@ function renderOptions(): ChartSurfaceRenderOptions { function fakeContext(): FakeCanvasContext { const operations: string[] = [] const gradientStops: Array<[number, string]> = [] + const textPaints: FakeCanvasContext['textPaints'] = [] let fillStyle: string | CanvasGradient = '#000000' let strokeStyle: string | CanvasGradient = '#000000' let lineWidth = 1 @@ -2480,8 +2529,14 @@ function fakeContext(): FakeCanvasContext { `stroke:${String(strokeStyle)}:${lineWidth}:${globalAlpha}`, ) }, - fillText: (text: string, x: number, y: number) => - operations.push(`fillText:${text},${x},${y}`), + fillText: (text: string, x: number, y: number) => { + operations.push(`fillText:${text},${x},${y}`) + textPaints.push({ + text, + globalAlpha, + font: context.font, + }) + }, strokeText: (text: string, x: number, y: number) => operations.push(`strokeText:${text},${x},${y}`), setLineDash: (values: number[]) => @@ -2537,5 +2592,5 @@ function fakeContext(): FakeCanvasContext { textAlign: 'left', textBaseline: 'alphabetic', } as unknown as CanvasRenderingContext2D - return { operations, gradientStops, context } + return { operations, gradientStops, textPaints, context } } diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts index e1d0fd43..0089eb40 100644 --- a/packages/charts-core/src/facet.test.ts +++ b/packages/charts-core/src/facet.test.ts @@ -178,7 +178,15 @@ describe('facets', () => { }, y: { scale: scaleLinear().domain([0, 6]), - axis: { label: 'Vertical' }, + axis: { + label: { + text: 'Vertical', + fontSize: 18, + fontWeight: 700, + fill: '#7c3aed', + opacity: 0.5, + }, + }, }, }, }), @@ -201,6 +209,10 @@ describe('facets', () => { ) const aPoints = scene.points.filter((point) => point.datum.group === 'A') const bPoints = scene.points.filter((point) => point.datum.group === 'B') + const yTitle = nodes.find( + (node): node is Extract => + node.kind === 'label' && node.key === 'facet-0:y-label', + ) expect(yAxes).toHaveLength(1) expect(xAxes).toHaveLength(2) @@ -210,6 +222,11 @@ describe('facets', () => { expect(nodes.filter((node) => node.key === 'facet-0:y-label')).toHaveLength( 1, ) + expect(yTitle).toMatchObject({ + fontSize: 18, + fontWeight: 700, + style: { fill: '#7c3aed', opacity: 0.5 }, + }) expect(yLabels).toHaveLength( new Set(yLabels.map((node) => (node.kind === 'label' ? node.text : ''))) .size, diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index a8f24f91..e5cb2300 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -2106,6 +2106,72 @@ describe('SVG motion', () => { frames.restore() }) + it('applies axis-title motion to configured typography and opacity', () => { + const definition = ( + fontSize: number, + fontWeight: number, + opacity: number, + ) => + defineChart({ + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize, + fontWeight, + opacity, + motion: { + transition: { + type: 'tween', + duration: 100, + easing: 'linear', + }, + }, + }, + }, + }, + }, + }) + const first = createChartScene(definition(10, 400, 0.4), { + width: 300, + height: 200, + }) + const next = createChartScene(definition(20, 700, 0.8), { + width: 300, + height: 200, + }) + const container = document.createElement('div') + const surface = motion({ initial: false }).mount(container, () => {}) + surface.render(first, { ariaLabel: 'Axis title typography' }) + const frames = installManagedFrames() + surface.render(next, { ariaLabel: 'Axis title typography' }) + const label = container.querySelector( + '[data-ts-key="y-label"]', + ) + + expect(label?.getAttribute('font-size')).toBe('10') + expect(label?.getAttribute('font-weight')).toBe('400') + expect(label?.getAttribute('opacity')).toBe('0.4') + frames.run(0) + frames.run(50) + expect(Number(label?.getAttribute('font-size'))).toBeCloseTo(15) + expect(Number(label?.getAttribute('font-weight'))).toBeCloseTo(550) + expect(Number(label?.getAttribute('opacity'))).toBeCloseTo(0.6) + frames.run(100) + expect(label?.getAttribute('font-size')).toBe('20') + expect(label?.getAttribute('font-weight')).toBe('700') + expect(label?.getAttribute('opacity')).toBe('0.8') + + surface.destroy() + frames.restore() + }) + it('reports radial-bar paths with the semantic bar motion role', () => { const roles: string[] = [] const definition = (value: number) => diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts index 8d2d8492..9b3b7aeb 100644 --- a/packages/charts-core/src/scene-layout.test.ts +++ b/packages/charts-core/src/scene-layout.test.ts @@ -6,6 +6,7 @@ import { createMark } from './mark' import { createChartScene, defineChart } from './scene' import { text } from './text' import type { + ChartAxisLabelOptions, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -240,6 +241,97 @@ describe('automatic scene guide layout', () => { expect(yLabels.every(({ anchor }) => anchor === 'end')).toBe(true) }) + it('applies axis-title typography and paint while preserving string-label defaults', () => { + const xLabel = { + text: 'Styled horizontal title', + fontSize: 18, + fontWeight: 700, + fill: '#7c3aed', + opacity: 0.45, + } satisfies ChartAxisLabelOptions + const scene = createChartScene( + defineChart({ + marks: [lineY([0, 1])], + margin: 0, + scales: { + x: { + scale: scaleLinear().domain([0, 1]), + axis: { label: xLabel }, + }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { label: 'Default vertical title' }, + }, + }, + }), + { width: 480, height: 240 }, + { measureText }, + ) + const labels = flatten(scene.nodes).filter( + (node): node is SceneLabel => node.kind === 'label', + ) + const styled = labels.find(({ key }) => key === 'x-label') + const defaulted = labels.find(({ key }) => key === 'y-label') + + expect(styled).toMatchObject({ + text: xLabel.text, + fontSize: 18, + fontWeight: 700, + style: { fill: '#7c3aed', opacity: 0.45 }, + }) + expect(defaulted).toMatchObject({ + text: 'Default vertical title', + fontSize: 11, + fontWeight: 600, + style: { fill: 'currentColor', fillOpacity: 0.76 }, + }) + }) + + it('measures configured axis-title typography into automatic margins', () => { + const definition = (fontSize: number, fontWeight: number) => + defineChart({ + marks: [lineY([0, 1])], + scales: { + x: { scale: scaleLinear().domain([0, 1]), axis: false }, + y: { + scale: scaleLinear().domain([0, 1]), + axis: { + ticks: false, + label: { + text: 'Average order value', + fontSize, + fontWeight, + }, + }, + }, + }, + }) + const measured = vi.fn(measureText) + const regular = createChartScene( + definition(11, 600), + { width: 480, height: 240 }, + { measureText: measured }, + ) + const enlarged = createChartScene( + definition(24, 750), + { width: 480, height: 240 }, + { measureText: measured }, + ) + + expect(enlarged.margin.left).toBeGreaterThan(regular.margin.left) + expect(measured).toHaveBeenCalledWith( + 'Average order value', + expect.objectContaining({ fontSize: 24, fontWeight: 750 }), + ) + const title = flatten(enlarged.nodes).find( + (node): node is SceneLabel => + node.kind === 'label' && node.key === 'y-label', + ) + if (!title) throw new Error('Expected a y-axis title') + const bounds = measureSceneLabelBounds(title, measureText) + expect(bounds.x).toBeGreaterThanOrEqual(3.99) + }) + it('measures accessor-resolved tick-label bounds into automatic margins', () => { const definition = (fontSize: number, dx: number) => defineChart({ diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..267bfef4 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1501,9 +1501,12 @@ function createAxes( children.push(candidate.label) } - const labelText = axisLabelText(presentation) + const axisLabel = presentation?.label + const labelText = + typeof axisLabel === 'string' ? axisLabel : axisLabel?.text if (!labelText) return - const labelOffset = axisLabelOffset(presentation) + const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined + const labelOffset = labelOptions?.offset ?? 'auto' const explicitOffset = labelOffset !== 'auto' const label: SceneLabel = { kind: 'label', @@ -1515,9 +1518,13 @@ function createAxes( text: labelText, anchor: 'middle', baseline: bottom && !explicitOffset ? 'hanging' : 'auto', - fontSize: width < 360 ? 10 : 11, - fontWeight: 600, - style: { fill: theme.foreground, fillOpacity: 0.76 }, + fontSize: labelOptions?.fontSize ?? (width < 360 ? 10 : 11), + fontWeight: labelOptions?.fontWeight ?? 600, + style: { + fill: labelOptions?.fill ?? theme.foreground, + fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, + opacity: labelOptions?.opacity, + }, } includeOutward(measureSceneLabelBounds(label, measureText)) children.push(label) @@ -1594,8 +1601,11 @@ function createAxes( children.push(candidate.label) } - const labelText = axisLabelText(presentation) + const axisLabel = presentation?.label + const labelText = + typeof axisLabel === 'string' ? axisLabel : axisLabel?.text if (!labelText) return + const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined const label: SceneLabel = { kind: 'label', key: `${guide.id}-label`, @@ -1605,11 +1615,15 @@ function createAxes( anchor: 'middle', baseline: 'middle', rotate: right ? 90 : -90, - fontSize: 11, - fontWeight: 600, - style: { fill: theme.foreground, fillOpacity: 0.76 }, + fontSize: labelOptions?.fontSize ?? 11, + fontWeight: labelOptions?.fontWeight ?? 600, + style: { + fill: labelOptions?.fill ?? theme.foreground, + fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined, + opacity: labelOptions?.opacity, + }, } - const labelOffset = axisLabelOffset(presentation) + const labelOffset = labelOptions?.offset ?? 'auto' if (labelOffset !== 'auto') { label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)) } else { @@ -1676,20 +1690,6 @@ function tickLabelPresentation( return axis?.tickLabels ?? {} } -function axisLabelText( - axis: ChartAxisPresentationOptions | undefined, -): string | undefined { - return typeof axis?.label === 'string' ? axis.label : axis?.label?.text -} - -function axisLabelOffset( - axis: ChartAxisPresentationOptions | undefined, -): number | 'auto' { - return typeof axis?.label === 'object' - ? (axis.label.offset ?? 'auto') - : 'auto' -} - interface TickLabelCandidate { value: ChartValue label: SceneLabel diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts index af0d7c39..a3ecbb2d 100644 --- a/packages/charts-core/src/svg-renderer.test.ts +++ b/packages/charts-core/src/svg-renderer.test.ts @@ -1,4 +1,7 @@ +import { scaleLinear } from 'd3-scale' import { describe, expect, it } from 'vitest' +import { lineY } from './line' +import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' import type { ChartScene } from './types' @@ -13,6 +16,36 @@ describe('SVG scene renderer', () => { ) expect(svg).not.toContain('M99,99Z') }) + + it('serializes Cartesian axis-title typography and paint', () => { + const scene = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }), + { width: 480, height: 260 }, + ) + const svg = renderChartSvg(scene, { ariaLabel: 'Revenue chart' }) + + expect(svg).toMatch( + /]* fill="#0f766e" opacity="0\.6"[^>]* font-size="17" font-weight="650"/, + ) + }) }) function testScene(): ChartScene { diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 3ed32bb9..276d5a17 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -381,6 +381,11 @@ export interface ChartAxisTickLabelOptions { export interface ChartAxisLabelOptions { text: string offset?: number | 'auto' + fontSize?: number + fontWeight?: number + /** Text fill color. Defaults to the theme foreground. */ + fill?: string + opacity?: number motion?: ChartMotionDefinition } diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx index 272eab7a..b50a67bb 100644 --- a/packages/react-native-charts/src/SvgScene.test.tsx +++ b/packages/react-native-charts/src/SvgScene.test.tsx @@ -1,6 +1,8 @@ import * as React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' +import { createChartScene, defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' import type { ChartScene, SceneNode } from '@tanstack/charts/types' import { NativeChartFocusOverlay } from './FocusOverlay' import { resolveNativePaint } from './paint' @@ -70,6 +72,43 @@ describe('React Native SVG scene renderer', () => { expect(markup).toContain('font-size="24"') }) + it('renders Cartesian axis-title typography and paint', () => { + const generated = createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]), axis: false }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { + ticks: false, + label: { + text: 'Revenue', + fontSize: 17, + fontWeight: 650, + fill: '#0f766e', + opacity: 0.6, + }, + }, + }, + }, + }), + { width: 480, height: 260 }, + ) + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toMatch( + /]*fill="#0f766e"[^>]*opacity="0\.6"[^>]*font-size="17" font-weight="650"[^>]*>Revenue<\/text>/, + ) + }) + it('makes the two unsupported SVG joins an explicit lossy mapping', () => { expect(resolveNativeLineJoin('arcs')).toBe('round') expect(resolveNativeLineJoin('miter-clip')).toBe('miter') diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..f51c5ca9 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -696,7 +696,7 @@ const entries = [ budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 24.25, + 24.28, ), budgeted( 'D3-scale dots + static SVG', @@ -761,6 +761,27 @@ const entries = [ }, }, ), + incrementalBudgeted( + 'Axis title styles + static SVG', + 'benchmarks/entries/charts-axis-label-styles.ts', + 'D3-scale line + static SVG', + 0.25, + { + inputBoundary: { + forbid: [ + 'focusGuide', + 'focusMark', + 'motionRuntime', + 'springRuntime', + 'tooltipRuntime', + 'tooltipPortal', + 'd3GeometryRuntime', + ], + addedFrom: 'D3-scale line + static SVG', + allowAdded: [], + }, + }, + ), budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', @@ -903,7 +924,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 27.75, + 27.77, { inputBoundary: { require: [ @@ -1072,7 +1093,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 20.11, + 20.13, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1102,7 +1123,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.1, + 20.11, { inputBoundary: { forbid: [ @@ -1198,7 +1219,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 26.8, + 26.81, ), locked( 'Representative marks', @@ -1661,7 +1682,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 28.47, + 28.5, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1829,7 +1850,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 52.82, + 52.84, ), locked( 'Custom-scale line scene', @@ -1855,7 +1876,7 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 20.9, + 20.91, ), budgeted( 'D3 time-scale line scene', @@ -1865,12 +1886,12 @@ const entries = [ budgeted( 'Direct D3 monotone + TanStack SVG', 'benchmarks/entries/charts-d3-curve-svg.ts', - 22.61, + 22.63, ), budgeted( 'Direct D3 transforms + TanStack histogram', 'benchmarks/entries/charts-d3-transform-histogram.ts', - 21.5, + 21.53, ), budgeted( 'Direct D3 time + TanStack UTC line', @@ -1880,12 +1901,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 36.84, + 36.87, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 42.07, + 42.11, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2089,7 +2110,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 53.72, + 53.74, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'),