Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/rtl-axis-side-gutter.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 20 additions & 20 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
45 changes: 44 additions & 1 deletion packages/charts-core/src/cartesian-scales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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],
Expand Down
18 changes: 18 additions & 0 deletions packages/charts-core/src/guide-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
10 changes: 9 additions & 1 deletion packages/charts-core/src/guide-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions packages/charts-core/src/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@ function resolveSceneLayout(
theme,
width,
layout.measureText,
layout.typography?.direction === 'rtl',
)
return {
margin,
Expand Down Expand Up @@ -1355,6 +1356,7 @@ function createAxes(
theme: ChartTheme,
width: number,
measureText?: ChartTextMeasurer,
rightToLeft = false,
): ResolvedAxes {
const children: SceneNode[] = []
const inset = guides.length ? automaticGuideInset : 0
Expand Down Expand Up @@ -1469,6 +1471,7 @@ function createAxes(
width,
theme,
measureText,
rightToLeft,
)
const visibleLabels =
tickLabels === false
Expand Down Expand Up @@ -1564,6 +1567,7 @@ function createAxes(
width,
theme,
measureText,
rightToLeft,
)
const visibleLabels =
tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false)
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/measure-bundles.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down