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/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/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 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: [