From 37cfc9f780df87fbc569a9e7b9b348f446dfc7bc Mon Sep 17 00:00:00 2001 From: 3li7alaki Date: Thu, 27 Aug 2026 12:29:12 +0300 Subject: [PATCH 1/2] Reserve the axis gutter in a right-to-left container A y scale with `side: 'right'` took its tick-label anchor from the physical side alone. That anchor reaches the DOM as SVG `text-anchor`, which resolves against inline base direction, so an RTL container painted the labels leftward into the plot. The margin pass then read those bounds correctly and reserved nothing on the right, widening the plot until it ran under its own labels. Anchor the far side with `end` once the container reads right to left, and mirror the same relation in the text estimator so a host without a DOM measurer resolves the identical layout. --- .changeset/rtl-axis-side-gutter.md | 9 ++++ .../charts-core/src/cartesian-scales.test.ts | 45 ++++++++++++++++++- packages/charts-core/src/guide-layout.test.ts | 18 ++++++++ packages/charts-core/src/guide-layout.ts | 10 ++++- packages/charts-core/src/scene.ts | 14 ++++-- 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 .changeset/rtl-axis-side-gutter.md diff --git a/.changeset/rtl-axis-side-gutter.md b/.changeset/rtl-axis-side-gutter.md new file mode 100644 index 00000000..ad5758d6 --- /dev/null +++ b/.changeset/rtl-axis-side-gutter.md @@ -0,0 +1,9 @@ +--- +'@tanstack/charts': patch +--- + +Reserve the correct gutter for a positioned axis in a right-to-left container. +A y scale with `side: 'right'` anchored its tick labels for a left-to-right +inline direction, so an RTL host painted them across the plot instead of beside +it. Text estimation now mirrors with the same direction the DOM measurer +already reports, keeping a host without one on the same layout. diff --git a/packages/charts-core/src/cartesian-scales.test.ts b/packages/charts-core/src/cartesian-scales.test.ts index 94f657cb..860aa0de 100644 --- a/packages/charts-core/src/cartesian-scales.test.ts +++ b/packages/charts-core/src/cartesian-scales.test.ts @@ -4,7 +4,7 @@ import { crosshair } from './crosshair' import { lineY } from './line' import { ruleY } from './rule' import { createChartScene, defineChart } from './scene' -import type { SceneNode, SceneRule } from './types' +import type { ChartTextMeasurer, SceneNode, SceneRule } from './types' describe('Cartesian scale registry', () => { it('binds marks to named y scales and stacks axes sharing the right side', () => { @@ -67,6 +67,16 @@ describe('Cartesian scale registry', () => { expect(rules.find((node) => node.key === 'y-axis')?.x1).toBe(scene.chart.x) }) + it('reserves the gutter for a right-side axis reading right to left', () => { + const leftToRight = createRightAxisScene('ltr') + const rightToLeft = createRightAxisScene('rtl') + + expect(rightToLeft.chart.width).toBeCloseTo(leftToRight.chart.width) + expect( + sceneWidth - rightToLeft.chart.x - rightToLeft.chart.width, + ).toBeGreaterThanOrEqual(labelWidth) + }) + it('keeps named grids off by default', () => { const keys = flatten(createNamedScaleScene().nodes).map((node) => node.key) @@ -275,6 +285,39 @@ function createNamedScaleScene() { ) } +const sceneWidth = 480 +const labelWidth = 40 + +/** + * Reports the painted box the way a DOM text measurer does: `anchor` resolves + * against inline base direction, so the box sits on the opposite side of the + * origin once the container reads right to left. + */ +const mirroredText: ChartTextMeasurer = (_text, options) => { + const leftwardAnchor = options.direction === 'rtl' ? 'start' : 'end' + const x = + options.anchor === 'middle' + ? -labelWidth / 2 + : options.anchor === leftwardAnchor + ? -labelWidth + : 0 + return { x, y: -8, width: labelWidth, height: 10 } +} + +function createRightAxisScene(direction: 'ltr' | 'rtl') { + return createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 3]), side: 'right' }, + }, + }), + { width: sceneWidth, height: 260 }, + { measureText: mirroredText, typography: { direction } }, + ) +} + function flatten(nodes: readonly SceneNode[]): SceneNode[] { return nodes.flatMap((node) => node.kind === 'group' ? [node, ...flatten(node.children)] : [node], diff --git a/packages/charts-core/src/guide-layout.test.ts b/packages/charts-core/src/guide-layout.test.ts index dcb9c997..345181b5 100644 --- a/packages/charts-core/src/guide-layout.test.ts +++ b/packages/charts-core/src/guide-layout.test.ts @@ -106,6 +106,24 @@ describe('guide layout', () => { }) }) + it('mirrors the estimated painted box in a right-to-left direction', () => { + const options = { + ...typography, + direction: 'rtl' as const, + fontSize: 10, + fontWeight: 400, + baseline: 'auto' as const, + } + const start = estimateSceneText('Margin 100', { + ...options, + anchor: 'start', + }) + const end = estimateSceneText('Margin 100', { ...options, anchor: 'end' }) + + expect(start.x).toBeCloseTo(-start.width) + expect(end.x).toBe(0) + }) + it('rotates the measured rectangle around the label position', () => { const bounds = measureSceneLabelBounds( label({ diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index ac1f6b6b..2fb936d0 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -55,8 +55,16 @@ export function estimateSceneText( Math.max(0, Array.from(text).length - 1) * letterSpacing, ) const height = fontSize + // `anchor` resolves against inline base direction, so which side of the + // origin the painted box occupies mirrors with that direction. The DOM + // measurer already reports the mirrored origin; match it here so a host + // without one lays out the same chart. const x = - style.anchor === 'middle' ? -width / 2 : style.anchor === 'end' ? -width : 0 + style.anchor === 'middle' + ? -width / 2 + : (style.anchor === 'end') === (style.direction !== 'rtl') + ? -width + : 0 const y = style.baseline === 'middle' ? -height / 2 diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..8d646950 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1097,6 +1097,7 @@ function resolveSceneLayout( theme, width, layout.measureText, + layout.typography?.direction === 'rtl', ) return { margin, @@ -1355,6 +1356,7 @@ function createAxes( theme: ChartTheme, width: number, measureText?: ChartTextMeasurer, + rightToLeft = false, ): ResolvedAxes { const children: SceneNode[] = [] const inset = guides.length ? automaticGuideInset : 0 @@ -1469,6 +1471,7 @@ function createAxes( width, theme, measureText, + rightToLeft, ) const visibleLabels = tickLabels === false @@ -1564,6 +1567,7 @@ function createAxes( width, theme, measureText, + rightToLeft, ) const visibleLabels = tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false) @@ -1737,6 +1741,7 @@ function createTickLabelCandidates( width: number, theme: ChartTheme, measureText: ChartTextMeasurer | undefined, + rightToLeft = false, ): TickLabelCandidate[] { const defaultFontSize = width < 360 ? 10 : 11 const positiveSide = guide.side === 'bottom' || guide.side === 'right' @@ -1755,11 +1760,14 @@ function createTickLabelCandidates( const opacity = resolveTickLabelValue(options.opacity, context) const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 + // A y axis anchors its labels away from the plot, a physical relation, + // while `text-anchor` resolves against inline base direction. The two + // agree only left to right, so the far side takes `end` once they differ. const defaultAnchor = guide.channel === 'y' - ? positiveSide - ? 'start' - : 'end' + ? positiveSide === rightToLeft + ? 'end' + : 'start' : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 From 71ec03581e4b4f40b9ef5df7e2a3f8213466ebea Mon Sep 17 00:00:00 2001 From: 3li7alaki Date: Thu, 27 Aug 2026 12:29:12 +0300 Subject: [PATCH 2/2] Relock the difference-mark bundle increment The layout fix costs 73 B raw in shared code, which puts the difference-mark increment 0.02 kB over its ceiling. Gzip falls on most entries. --- .../bundle-size/universal-baseline.json | 40 +++++++++---------- scripts/measure-bundles.mjs | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..ff0ee159 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": 50886, + "gzip": 18949 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55617, + "gzip": 20642 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75684, + "gzip": 27457 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 75020, + "gzip": 26057 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77203, + "gzip": 26880 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 100837, + "gzip": 36082 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33338, + "gzip": 11954 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83339, + "gzip": 29131 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31520, + "gzip": 11203 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50818, + "gzip": 18914 } } } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..93cefafd 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -977,7 +977,7 @@ const entries = [ 'Difference mark + static SVG', 'benchmarks/entries/charts-difference-svg.ts', 'D3-scale line + static SVG', - 7.03, + 7.06, { inputBoundary: { require: [