diff --git a/.changeset/opposite-axis-side.md b/.changeset/opposite-axis-side.md new file mode 100644 index 00000000..7fa60e50 --- /dev/null +++ b/.changeset/opposite-axis-side.md @@ -0,0 +1,8 @@ +--- +'@tanstack/charts': minor +--- + +Add `axis.side` so an axis can sit on the opposite plot edge, moving its stubs, +tick labels, title, crosshair value label, and automatic margin with it. A +right-to-left locale reads the value axis on the right through +`y.axis.side: 'end'`. diff --git a/API-FRICTION.md b/API-FRICTION.md index 51c8c1f7..54e3f600 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -329,6 +329,7 @@ Each entry records: | F-290 | Public examples imported a private workspace package | Tooling | resolved | | F-291 | Renderer capability injection depended on module identity | API/Tooling | resolved | | F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved | +| F-293 | A right-to-left locale could not move the value axis | API | resolved | ## Findings @@ -8429,3 +8430,32 @@ Each entry records: - Follow-up verification: the generator contract covers all four tokens in both palettes, and regenerated ShadCN donut, radial, radar, and authored-label previews render readable dark text and theme-matched separators. + +### F-293 — A right-to-left locale could not move the value axis + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: migrating a bilingual production dashboard from Recharts, whose + Arabic locale reads the value axis on the right +- Friction: `ChartAxisPresentationOptions` exposed the axis line, ticks, tick + labels, and title, but no placement, so the y axis always rendered against + the left plot edge. `x.reverse` already ordered categories right to left, + which left the axis as the only wrong element on the screen. Mirroring the + container with `transform: scaleX(-1)` reversed the marks and the tick text + and broke pointer hit-testing, and `direction: rtl` on the host changed + nothing because placement is resolved during layout. The migration shipped + with a knowingly misplaced axis in Arabic. +- Decision: add `axis.side` with `start` and `end` rather than physical `left` + and `right`, so one spelling serves both dimensions and composes with the + existing `reverse`. Resolve the placement once per axis into a plot edge and + an outward sign, then derive the axis line, tick stubs, tick labels and their + default anchor, the title and its rotation, and the crosshair value label + from that pair. Automatic margins already grow from measured label bounds, so + the reserved gutter follows the placement without new layout code. +- Verification: scene tests assert the end-side y axis line, stubs, labels, and + 90 degree title against the right plot edge with the reserved margin moving + from left to right, the end-side x axis against the top edge, and an explicit + `start` producing a node tree identical to an unset side. A crosshair test + asserts both value labels follow the placement, including the label anchor. + The existing 940 core tests pass unchanged. diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md index af9d82b9..1644ee29 100644 --- a/docs/concepts/layout-axes-and-coordinates.md +++ b/docs/concepts/layout-axes-and-coordinates.md @@ -156,11 +156,27 @@ const x = { | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure the axis title and offset | +| `axis.side` | Place the axis on the opposite plot edge | | `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. +`axis.side` defaults to `start`, which places the y axis left and the x axis +bottom. `end` places the y axis right and the x axis top, and moves that axis's +stubs, tick labels, title, and crosshair value label with it. Automatic margins +follow the placement, so the reserved gutter moves rather than being duplicated. + +A right-to-left locale reads the value axis on the right, which is `side` on the +y axis combined with `reverse` on the x axis: + +```ts +const chart = { + x: { scale: xScale, reverse: true }, + y: { scale: yScale, axis: { side: 'end' } }, +} +``` + Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines and tick stubs use the generated candidates; label thinning does not remove diff --git a/docs/reference/types.md b/docs/reference/types.md index e579ba1e..961a3b46 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -206,7 +206,8 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisOptions` | Required positional scale and optional guide behavior | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | -| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | +| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, title, and side presentation | +| `ChartAxisSide` | Plot edge that carries the axis | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | 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 af9d82b9..1644ee29 100644 --- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md +++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md @@ -156,11 +156,27 @@ const x = { | `axis.ticks` | Configure candidates, stubs, padding, and formatting | | `axis.tickLabels` | Configure label rotation and collision thinning | | `axis.label` | Configure the axis title and offset | +| `axis.side` | Place the axis on the opposite plot edge | | `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. +`axis.side` defaults to `start`, which places the y axis left and the x axis +bottom. `end` places the y axis right and the x axis top, and moves that axis's +stubs, tick labels, title, and crosshair value label with it. Automatic margins +follow the placement, so the reserved gutter moves rather than being duplicated. + +A right-to-left locale reads the value axis on the right, which is `side` on the +y axis combined with `reverse` on the x axis: + +```ts +const chart = { + x: { scale: xScale, reverse: true }, + y: { scale: yScale, axis: { side: 'end' } }, +} +``` + Candidate generation and label layout are separate. Choose at most one of `axis.ticks.count`, `axis.ticks.spacing`, and `axis.ticks.values`. Grid lines and tick stubs use the generated candidates; label thinning does not remove diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index e579ba1e..961a3b46 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -206,7 +206,8 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes). | `ChartAxisOptions` | Required positional scale and optional guide behavior | | `ChartAxisViewportOptions` | Continuous semantic window and transient pixel translation | | `ChartAxisGuideOptions` | Guide behavior without the scale field | -| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, and title presentation | +| `ChartAxisPresentationOptions` | Axis line, ticks, tick labels, title, and side presentation | +| `ChartAxisSide` | Plot edge that carries the axis | | `ChartAxisTickOptions` | Candidate values, density, formatting, size, and padding | | `ChartAxisTickLabelOptions` | Per-candidate typography, anchor, offset, rotation, and thinning | | `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth | diff --git a/packages/charts-core/src/crosshair-resolver.ts b/packages/charts-core/src/crosshair-resolver.ts index f65ce54a..6d6fc0a5 100644 --- a/packages/charts-core/src/crosshair-resolver.ts +++ b/packages/charts-core/src/crosshair-resolver.ts @@ -1,4 +1,4 @@ -import { measureSceneLabelBounds } from './guide-layout' +import { axisPlacement, measureSceneLabelBounds } from './guide-layout' import { valueKey } from './scales' import type { ChartBounds, @@ -219,6 +219,8 @@ function resolveFocusGuide( }) } if (guide.x.label && target.xValue !== undefined) { + const placement = axisPlacement('x', guide.chart, guide.x.side) + const baselineDrop = placement.sign > 0 ? guide.x.label.fontSize * 0.8 : 0 children.push( ...guideLabels( clampLabel( @@ -229,10 +231,9 @@ function resolveFocusGuide( 'ts-chart__crosshair-label ts-chart__crosshair-label--x', x: labelX, y: - guide.chart.y + - guide.chart.height + - guide.x.label.offset + - guide.x.label.fontSize * 0.8, + placement.edge + + placement.sign * guide.x.label.offset + + baselineDrop, text: formatGuideValue( scene, 'x', @@ -287,6 +288,7 @@ function resolveFocusGuide( }) } if (guide.y.label && target.yValue !== undefined) { + const placement = axisPlacement('y', guide.chart, guide.y.side) children.push( ...guideLabels( clampLabel( @@ -295,7 +297,7 @@ function resolveFocusGuide( key: `${guide.key}:y-label`, className: 'ts-chart__crosshair-label ts-chart__crosshair-label--y', - x: guide.chart.x - guide.y.label.offset, + x: placement.edge + placement.sign * guide.y.label.offset, y: labelY, text: formatGuideValue( scene, @@ -303,7 +305,7 @@ function resolveFocusGuide( target.yValue, guide.y.label.format, ), - anchor: 'end', + anchor: placement.sign > 0 ? 'start' : 'end', baseline: 'middle', fontSize: guide.y.label.fontSize, fontWeight: guide.y.label.fontWeight, diff --git a/packages/charts-core/src/crosshair.test.ts b/packages/charts-core/src/crosshair.test.ts index 1466c2e0..d5e49b26 100644 --- a/packages/charts-core/src/crosshair.test.ts +++ b/packages/charts-core/src/crosshair.test.ts @@ -10,6 +10,7 @@ import { createChartScene, defineChart } from './scene' import { stack } from './stack' import { linearAxes } from './test-scales' import type { + ChartAxisSide, ChartFocusState, ChartPoint, SceneGroup, @@ -612,6 +613,19 @@ describe('crosshair', () => { [...positioned.under, ...positioned.over].flatMap(crosshairNodes), ).toHaveLength(1) }) + + it('follows the axis side with its value labels', () => { + const start = crosshairLabels('start') + const end = crosshairLabels('end') + + expect(start.y.x).toBeLessThan(start.chart.x) + expect(start.y.anchor).toBe('end') + expect(start.x.y).toBeGreaterThan(start.chart.y + start.chart.height) + + expect(end.y.x).toBeGreaterThan(end.chart.x + end.chart.width) + expect(end.y.anchor).toBe('start') + expect(end.x.y).toBeLessThan(end.chart.y) + }) }) if (false) { @@ -636,6 +650,35 @@ function focus(point: ChartPoint): ChartFocusState { return { primary: point, group: [point], source: 'pointer', pinned: false } } +function crosshairLabels(side: ChartAxisSide) { + const rows = [ + { x: 0, y: 1 }, + { x: 2, y: 3 }, + ] + const scene = createChartScene( + defineChart({ + marks: [ + dot(rows, { x: 'x', y: 'y' }), + crosshair({ x: { label: true }, y: { label: true } }), + ], + x: { scale: scaleLinear().domain([0, 2]), axis: { side } }, + y: { scale: scaleLinear().domain([0, 4]), axis: { side } }, + }), + { width: 320, height: 180 }, + ) + const point = scene.points.find((candidate) => candidate.datum === rows[1])! + const guide = findNode( + resolveFocusPresentation(scene, focus(point)).over, + 'crosshair-1', + ) as SceneGroup + + return { + chart: scene.chart, + x: findNode(guide.children, 'crosshair-1:x-label:text') as SceneLabel, + y: findNode(guide.children, 'crosshair-1:y-label:text') as SceneLabel, + } +} + function findNode( nodes: readonly SceneNode[], key: string, diff --git a/packages/charts-core/src/guide-layout.ts b/packages/charts-core/src/guide-layout.ts index ac1f6b6b..93daf6d5 100644 --- a/packages/charts-core/src/guide-layout.ts +++ b/packages/charts-core/src/guide-layout.ts @@ -1,4 +1,5 @@ import type { + ChartAxisSide, ChartBounds, ChartMargin, ChartTextMeasurer, @@ -27,6 +28,23 @@ export interface GuideMarginOptions { measureText?: ChartTextMeasurer } +export interface AxisPlacement { + edge: number + sign: 1 | -1 +} + +export function axisPlacement( + axis: 'x' | 'y', + chart: ChartBounds, + side: ChartAxisSide | undefined, +): AxisPlacement { + const end = side === 'end' + if (axis === 'x') { + return { edge: end ? chart.y : chart.y + chart.height, sign: end ? -1 : 1 } + } + return { edge: end ? chart.x + chart.width : chart.x, sign: end ? 1 : -1 } +} + export function estimateSceneText( text: string, style: ChartTextMeasureOptions, diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index bf0d7178..390e58f0 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -286,6 +286,7 @@ export type { ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelThinOptions, diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts index c94758ef..347a72cd 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 { + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -803,8 +804,70 @@ describe('automatic scene guide layout', () => { true, ) }) + + it('places an end-side y axis against the right plot edge', () => { + const scene = sceneWithSides({ y: 'end' }) + const nodes = flatten(scene.nodes) + const right = scene.chart.x + scene.chart.width + const axis = nodes.find((node) => node.key === 'y-axis')! + const tick = nodes.find((node) => node.key.startsWith('y-tick-rule:'))! + const label = nodes.find( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('y-tick-label:'), + )! + + expect(axis).toMatchObject({ x1: right, x2: right }) + expect(tick).toMatchObject({ x1: right + 4, x2: right }) + expect(label).toMatchObject({ x: right + 8, anchor: 'start' }) + expect(nodes.find((node) => node.key === 'y-label')).toMatchObject({ + rotate: 90, + }) + expect(scene.margin.right).toBeGreaterThan(scene.margin.left) + }) + + it('places an end-side x axis against the top plot edge', () => { + const scene = sceneWithSides({ x: 'end' }) + const nodes = flatten(scene.nodes) + const top = scene.chart.y + const axis = nodes.find((node) => node.key === 'x-axis')! + const tick = nodes.find((node) => node.key.startsWith('x-tick-rule:'))! + const label = nodes.find( + (node): node is SceneLabel => + node.kind === 'label' && node.key.startsWith('x-tick-label:'), + )! + + expect(axis).toMatchObject({ y1: top, y2: top }) + expect(tick).toMatchObject({ y1: top, y2: top - 4 }) + expect(label).toMatchObject({ y: top - 8 }) + expect(scene.margin.top).toBeGreaterThan(scene.margin.bottom) + }) + + it('keeps start-side placement identical to an unset side', () => { + const explicit = flatten(sceneWithSides({ x: 'start', y: 'start' }).nodes) + const implicit = flatten(sceneWithSides({}).nodes) + + expect(explicit).toEqual(implicit) + }) }) +function sceneWithSides(sides: { x?: ChartAxisSide; y?: ChartAxisSide }) { + return createChartScene( + defineChart({ + marks: [lineY([1, 2, 3])], + x: { + scale: scaleLinear().domain([0, 2]), + axis: { side: sides.x, ticks: { values: [0, 2] }, label: 'Release' }, + }, + y: { + scale: scaleLinear().domain([0, 3]), + axis: { side: sides.y, ticks: { values: [0, 3] }, label: 'Downloads' }, + }, + }), + { width: 480, height: 240 }, + { measureText }, + ) +} + function sceneWithYFormat(format: (value: unknown) => string) { return createChartScene( defineChart({ diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 9bfad712..1fa50924 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -1,8 +1,10 @@ import { createColorScale, valueKey } from './scales' import { resolveConfiguredScale } from './configured-scale' import { + axisPlacement, measureSceneLabelBounds, withChartTextTypography, + type AxisPlacement, } from './guide-layout' import { nearestScenePoint } from './nearest' import { mapScenePointReferences } from './scene-point-map' @@ -13,6 +15,7 @@ import type { MaterializedChannel, ChartAxisOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelValue, @@ -34,6 +37,7 @@ import type { ChartMarkState, ChartPoint, SceneFocusGuide, + SceneFocusGuideAxis, ResolvedColorScale, ChartScene, ChartScaleResolver, @@ -343,6 +347,8 @@ function createChartSceneWithScaleResolver< const translateX = scales.x.viewport?.translate ?? 0 const translateY = scales.y.viewport?.translate ?? 0 const focusGuides: SceneFocusGuide[] = [] + const xAxisSide = axisPresentation(definition.x)?.side + const yAxisSide = axisPresentation(definition.y)?.side const firstBaseMarkIndex = marks.findIndex( (mark) => !mark.focus && !mark.focusGuideOnly, ) @@ -403,7 +409,12 @@ function createChartSceneWithScaleResolver< ? 'under' : 'over' for (const guide of rendered.focusGuides ?? []) { - focusGuides.push({ ...guide, placement: guide.placement ?? placement }) + focusGuides.push({ + ...guide, + placement: guide.placement ?? placement, + x: withGuideAxisSide(guide.x, xAxisSide), + y: withGuideAxisSide(guide.y, yAxisSide), + }) } if (mark.focus) { const retarget = mark.focus.retarget === true @@ -1135,6 +1146,8 @@ function createAxes( const showY = axes & 2 const xAxis = axisPresentation(definition.x) const yAxis = axisPresentation(definition.y) + const xPlacement = axisPlacement('x', chart, xAxis?.side) + const yPlacement = axisPlacement('y', chart, yAxis?.side) const children: SceneNode[] = !showX || xAxis?.line === false ? [] @@ -1144,8 +1157,8 @@ function createAxes( key: 'x-axis', x1: chart.x, x2: chart.x + chart.width, - y1: chart.y + chart.height, - y2: chart.y + chart.height, + y1: xPlacement.edge, + y2: xPlacement.edge, style: { stroke: theme.foreground, strokeOpacity: 0.28, @@ -1156,8 +1169,8 @@ function createAxes( children.push({ kind: 'rule', key: 'y-axis', - x1: chart.x, - x2: chart.x, + x1: yPlacement.edge, + x2: yPlacement.edge, y1: chart.y, y2: chart.y + chart.height, style: { @@ -1168,8 +1181,8 @@ function createAxes( } const xTickLabels = tickLabelPresentation(xAxis) const yTickLabels = tickLabelPresentation(yAxis) - let xTickBottom = chart.y + chart.height - let yTickLeft = chart.x + let xTickExtent = xPlacement.edge + let yTickExtent = yPlacement.edge const inset = axes ? automaticGuideInset : 0 const margin = uniformMargin(inset) @@ -1196,7 +1209,7 @@ function createAxes( : createTickLabelCandidates( 'x', withKeptTicks(scales.x, definition.x, xTickLabels), - chart, + xPlacement, xTickSize, xTickPadding, xTickLabels, @@ -1211,7 +1224,7 @@ function createAxes( : createTickLabelCandidates( 'y', withKeptTicks(scales.y, definition.y, yTickLabels), - chart, + yPlacement, yTickSize, yTickPadding, yTickLabels, @@ -1237,8 +1250,8 @@ function createAxes( key: `x-tick-rule:${key}`, x1: tick.position, x2: tick.position, - y1: chart.y + chart.height, - y2: chart.y + chart.height + xTickSize, + y1: xPlacement.edge, + y2: xPlacement.edge + xPlacement.sign * xTickSize, style: { stroke: theme.foreground, strokeOpacity: 0.28, @@ -1250,7 +1263,7 @@ function createAxes( for (const candidate of showX ? visibleXLabels : []) { const bounds = addLabel(candidate.label) if (axisLabelText(xAxis) && axisLabelOffset(xAxis) === 'auto') { - xTickBottom = Math.max(xTickBottom, bounds.y + bounds.height) + xTickExtent = extendTickExtent(xTickExtent, bounds, 'x', xPlacement.sign) } children.push(candidate.label) } @@ -1261,8 +1274,8 @@ function createAxes( children.push({ kind: 'rule', key: `y-tick-rule:${key}`, - x1: chart.x - yTickSize, - x2: chart.x, + x1: yPlacement.edge + yPlacement.sign * yTickSize, + x2: yPlacement.edge, y1: tick.position, y2: tick.position, style: { @@ -1276,7 +1289,7 @@ function createAxes( for (const candidate of showY ? visibleYLabels : []) { const bounds = addLabel(candidate.label) if (axisLabelText(yAxis) && axisLabelOffset(yAxis) === 'auto') { - yTickLeft = Math.min(yTickLeft, bounds.x) + yTickExtent = extendTickExtent(yTickExtent, bounds, 'y', yPlacement.sign) } children.push(candidate.label) } @@ -1285,16 +1298,17 @@ function createAxes( if (showX && xAxisLabel) { const offset = axisLabelOffset(xAxis) const hasOffset = offset !== 'auto' + const hangingBaseline = !hasOffset && xPlacement.sign > 0 const label: SceneLabel = { kind: 'label', key: 'x-label', x: chart.x + chart.width / 2, y: hasOffset - ? chart.y + chart.height + Math.max(0, finiteMargin(offset)) - : xTickBottom + 8, + ? xPlacement.edge + xPlacement.sign * Math.max(0, finiteMargin(offset)) + : xTickExtent + xPlacement.sign * 8, text: xAxisLabel, anchor: 'middle', - baseline: hasOffset ? 'auto' : 'hanging', + baseline: hangingBaseline ? 'hanging' : 'auto', fontSize: width < 360 ? 10 : 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, @@ -1308,25 +1322,28 @@ function createAxes( const yLabel: SceneLabel = { kind: 'label', key: 'y-label', - x: chart.x, + x: yPlacement.edge, y: chart.y + chart.height / 2, text: yAxisLabel, anchor: 'middle', baseline: 'middle', - rotate: -90, + rotate: yPlacement.sign > 0 ? 90 : -90, fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 }, } const offset = axisLabelOffset(yAxis) if (offset !== 'auto') { - yLabel.x = chart.x - Math.max(0, finiteMargin(offset)) + yLabel.x = + yPlacement.edge + yPlacement.sign * Math.max(0, finiteMargin(offset)) } else { const localBounds = measureSceneLabelBounds( { ...yLabel, x: 0, y: 0 }, measureText, ) - yLabel.x = yTickLeft - 8 - (localBounds.x + localBounds.width) + const localEdge = + yPlacement.sign > 0 ? localBounds.x : localBounds.x + localBounds.width + yLabel.x = yTickExtent + yPlacement.sign * 8 - localEdge } addLabel(yLabel) children.push(yLabel) @@ -1404,6 +1421,26 @@ function axisLabelOffset( : 'auto' } +function withGuideAxisSide( + axis: SceneFocusGuideAxis | undefined, + side: ChartAxisSide | undefined, +): SceneFocusGuideAxis | undefined { + if (!axis || side === undefined) return axis + return { ...axis, side } +} + +function extendTickExtent( + extent: number, + bounds: ChartBounds, + axis: 'x' | 'y', + sign: 1 | -1, +): number { + const near = axis === 'x' ? bounds.y : bounds.x + const far = near + (axis === 'x' ? bounds.height : bounds.width) + if (sign > 0) return Math.max(extent, far) + return Math.min(extent, near) +} + interface TickLabelCandidate { value: ChartValue label: SceneLabel @@ -1444,7 +1481,7 @@ function withKeptTicks( function createTickLabelCandidates( axis: 'x' | 'y', ticks: readonly (ChartTick & { hard?: boolean })[], - chart: ChartBounds, + placement: AxisPlacement, size: number, padding: number, options: ChartAxisTickLabelOptions, @@ -1468,9 +1505,11 @@ function createTickLabelCandidates( const opacity = resolveTickLabelValue(options.opacity, context) const dx = resolveTickLabelValue(options.dx, context) ?? 0 const dy = resolveTickLabelValue(options.dy, context) ?? 0 + const crossAnchor = placement.sign > 0 ? 'start' : 'end' + const baselineDrop = placement.sign > 0 ? fontSize * 0.8 : 0 const defaultAnchor = axis === 'y' - ? 'end' + ? crossAnchor : (rotate ?? 0) < 0 ? 'end' : (rotate ?? 0) > 0 @@ -1484,7 +1523,11 @@ function createTickLabelCandidates( kind: 'label', key: `x-tick-label:${valueKey(tick.value)}`, x: tick.position + dx, - y: chart.y + chart.height + size + padding + fontSize * 0.8 + dy, + y: + placement.edge + + placement.sign * (size + padding) + + baselineDrop + + dy, text: tick.label, anchor, rotate, @@ -1498,7 +1541,7 @@ function createTickLabelCandidates( : { kind: 'label', key: `y-tick-label:${valueKey(tick.value)}`, - x: chart.x - size - padding + dx, + x: placement.edge + placement.sign * (size + padding) + dx, y: tick.position + dy, text: tick.label, anchor, diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index c9db3040..f0e7fe2e 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -358,11 +358,18 @@ export interface ChartAxisLabelOptions { motion?: ChartMotionDefinition } +/** + * Axis placement. `y` places `start` left and `end` right; `x` places `start` + * bottom and `end` top. + */ +export type ChartAxisSide = 'start' | 'end' + export interface ChartAxisPresentationOptions { line?: boolean ticks?: false | ChartAxisTickOptions tickLabels?: false | ChartAxisTickLabelOptions label?: string | ChartAxisLabelOptions + side?: ChartAxisSide motion?: ChartMotionDefinition } @@ -1205,6 +1212,8 @@ export interface SceneFocusGuideAxis { label?: SceneFocusGuideLabel /** Categorical band geometry that replaces the axis rule when present. */ band?: SceneFocusGuideBand + /** Chart axis placement the guide label follows. */ + side?: ChartAxisSide } export interface SceneFocusGuideBand { diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index b4a1e9fa..abfa8923 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -178,6 +178,7 @@ export type { ChartAxisOptions, ChartAxisLabelOptions, ChartAxisPresentationOptions, + ChartAxisSide, ChartAxisTickLabelContext, ChartAxisTickLabelOptions, ChartAxisTickLabelThinOptions,