diff --git a/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx b/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx index 577a87043..5f6e0380b 100644 --- a/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx @@ -343,6 +343,42 @@ function CustomGridLineExample() { } ``` +On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. + +Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. + +```jsx +function BandGridPlacement() { + const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState('edges'); + + return ( + + + + + ); +} +``` + ### Line You can show the axis line using the `showLine` prop. @@ -536,6 +572,41 @@ function XAxisTickMarksExample() { } ``` +On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. + +Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. + +```jsx +function BandTickMarkPlacement() { + const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState('middle'); + + return ( + + + + + ); +} +``` + ### Tick Labels You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. diff --git a/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx b/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx index f068580ea..961821961 100644 --- a/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx @@ -342,6 +342,62 @@ function CustomGridLineExample() { } ``` +On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. + +Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. + +```jsx live +function BandGridPlacement() { + const bandGridLinePlacements = [ + { id: 'edges', label: 'Edges' }, + { id: 'start', label: 'Start' }, + { id: 'middle', label: 'Middle' }, + { id: 'end', label: 'End' }, + ]; + const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState( + bandGridLinePlacements[0], + ); + + return ( + + + + Band Grid Placement + + + + + + + + + ); +} +``` + ### Line You can show the axis line using the `showLine` prop. @@ -534,6 +590,61 @@ function XAxisTickMarksExample() { } ``` +On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. + +Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. + +```jsx live +function BandTickMarkPlacement() { + const bandTickMarkPlacements = [ + { id: 'middle', label: 'Middle' }, + { id: 'edges', label: 'Edges' }, + { id: 'start', label: 'Start' }, + { id: 'end', label: 'End' }, + ]; + const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState( + bandTickMarkPlacements[0], + ); + + return ( + + + + Band Tick Mark Placement + + + + + + + + + ); +} +``` + ### Tick Labels You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. diff --git a/packages/mobile-visualization/CHANGELOG.md b/packages/mobile-visualization/CHANGELOG.md index 6978b4600..c0ff38b5f 100644 --- a/packages/mobile-visualization/CHANGELOG.md +++ b/packages/mobile-visualization/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 3.4.0-beta.11 (1/7/2026 PST) + +#### 🐞 Fixes + +- Allow customization of axis tick mark and grid line alignment in band scale. [[#291](https://github.com/coinbase/cds/pull/291)] + ## 3.4.0-beta.10 (1/6/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile-visualization/package.json b/packages/mobile-visualization/package.json index 788973098..ecd02ac5d 100644 --- a/packages/mobile-visualization/package.json +++ b/packages/mobile-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile-visualization", - "version": "3.4.0-beta.10", + "version": "3.4.0-beta.11", "description": "Coinbase Design System - Mobile Visualization Native", "repository": { "type": "git", diff --git a/packages/mobile-visualization/src/chart/axis/Axis.tsx b/packages/mobile-visualization/src/chart/axis/Axis.tsx index 1d0dfe339..520c8a047 100644 --- a/packages/mobile-visualization/src/chart/axis/Axis.tsx +++ b/packages/mobile-visualization/src/chart/axis/Axis.tsx @@ -2,51 +2,14 @@ import type React from 'react'; import { type LineComponent } from '../line'; import type { ChartTextChildren, ChartTextProps } from '../text/ChartText'; +import { accessoryFadeTransitionDuration, type AxisBandPlacement } from '../utils'; /** - * Animation variants for grouped axis tick labels - initial mount - * Note: Mobile currently doesn't use these variants. Axes render immediately without animation. - * Web uses similar variants with delay to match path enter animation timing. + * Animation transition for axis elements (grid lines, tick marks, tick labels). + * Matches web's axisUpdateAnimationTransition timing. */ -export const axisTickLabelsInitialAnimationVariants = { - initial: { - opacity: 0, - }, - animate: { - opacity: 1, - transition: { - duration: 0, - delay: 0, - }, - }, - exit: { - opacity: 0, - transition: { - duration: 0.15, - }, - }, -}; - -/** - * Animation variants for axis elements - updates (used for both grid lines and tick labels) - */ -export const axisUpdateAnimationVariants = { - initial: { - opacity: 0, - }, - animate: { - opacity: 1, - transition: { - duration: 0.15, - delay: 0.15, // For updates: fade out 150ms, then fade in 150ms - }, - }, - exit: { - opacity: 0, - transition: { - duration: 0.15, - }, - }, +export const axisUpdateAnimationTransition = { + duration: accessoryFadeTransitionDuration, }; export type AxisTickLabelComponentProps = Pick< @@ -70,6 +33,20 @@ export type AxisTickLabelComponentProps = Pick< export type AxisTickLabelComponent = React.FC; export type AxisBaseProps = { + /** + * Placement of grid lines relative to each band. + * Options: 'start', 'middle', 'end', 'edges' + * @note This property only applies to band scales. + * @default 'edges' + */ + bandGridLinePlacement?: AxisBandPlacement; + /** + * Placement of tick marks relative to each band. + * Options: 'start', 'middle', 'end', 'edges' + * @note This property only applies to band scales. + * @default 'middle' + */ + bandTickMarkPlacement?: AxisBandPlacement; /** * Label text to display for the axis. */ diff --git a/packages/mobile-visualization/src/chart/axis/XAxis.tsx b/packages/mobile-visualization/src/chart/axis/XAxis.tsx index 9ea9135d9..66601f469 100644 --- a/packages/mobile-visualization/src/chart/axis/XAxis.tsx +++ b/packages/mobile-visualization/src/chart/axis/XAxis.tsx @@ -4,11 +4,17 @@ import { Group } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; -import { ReferenceLine } from '../line/ReferenceLine'; import { SolidLine } from '../line/SolidLine'; import { ChartText } from '../text/ChartText'; import { ChartTextGroup, type TextLabelData } from '../text/ChartTextGroup'; -import { getAxisTicksData, isCategoricalScale, lineToPath } from '../utils'; +import { + type CategoricalScale, + getAxisTicksData, + getPointOnScale, + isCategoricalScale, + lineToPath, + toPointAnchor, +} from '../utils'; import { type AxisBaseProps, type AxisProps } from './Axis'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; @@ -53,20 +59,26 @@ export const XAxis = memo( label, labelGap = 4, height = label ? AXIS_HEIGHT + LABEL_SIZE : AXIS_HEIGHT, + bandGridLinePlacement = 'edges', + bandTickMarkPlacement = 'middle', ...props }) => { const theme = useTheme(); const registrationId = useId(); - const { animate, getXScale, getXAxis, registerAxis, unregisterAxis, getAxisBounds } = - useCartesianChartContext(); + const { + animate, + drawingArea, + getXScale, + getXAxis, + registerAxis, + unregisterAxis, + getAxisBounds, + } = useCartesianChartContext(); const xScale = getXScale(); const xAxis = getXAxis(); const axisBounds = getAxisBounds(registrationId); - // Note: gridOpacity not currently used in Skia version - // const gridOpacity = useSharedValue(1); - useEffect(() => { registerAxis(registrationId, position, height); @@ -138,6 +150,63 @@ export const XAxis = memo( }); }, [ticks, xScale, requestedTickCount, tickInterval, tickMinStep, tickMaxStep, xAxis?.data]); + const isBandScale = useMemo(() => { + if (!xScale) return false; + return isCategoricalScale(xScale); + }, [xScale]); + + // Compute grid line positions (including bounds closing line for band scales) + const gridLinePositions = useMemo((): Array<{ x: number; key: string }> => { + if (!xScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ x: tick.position, key: `grid-${tick.tick}-${index}` }]; + } + + const bandScale = xScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandGridLinePlacement === 'edges'; + + const startX = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandGridLinePlacement)); + const positions = [{ x: startX, key: `grid-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing line at stepEnd + if (isLastTick && isEdges) { + const endX = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ x: endX, key: `grid-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, xScale, isBandScale, bandGridLinePlacement]); + + // Compute tick mark positions (including bounds closing tick for band scales) + const tickMarkPositions = useMemo((): Array<{ x: number; key: string }> => { + if (!xScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ x: tick.position, key: `tick-mark-${tick.tick}-${index}` }]; + } + + const bandScale = xScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandTickMarkPlacement === 'edges'; + + const startX = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandTickMarkPlacement)); + const positions = [{ x: startX, key: `tick-mark-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing tick mark at stepEnd + if (isLastTick && isEdges) { + const endX = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ x: endX, key: `tick-mark-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, xScale, isBandScale, bandTickMarkPlacement]); + const chartTextData: TextLabelData[] | null = useMemo(() => { if (!axisBounds) return null; @@ -149,12 +218,10 @@ export const XAxis = memo( const availableSpace = AXIS_HEIGHT - tickOffset; const labelOffset = availableSpace / 2; - // For bottom position: start at axisBounds.y - // For top position with label: start at axisBounds.y + LABEL_SIZE - const baseY = position === 'top' && label ? axisBounds.y + LABEL_SIZE : axisBounds.y; - const labelY = - position === 'top' ? baseY + labelOffset - tickOffset : baseY + labelOffset + tickOffset; + position === 'top' + ? axisBounds.y + axisBounds.height - tickOffset - labelOffset + : axisBounds.y + labelOffset + tickOffset; return { x: tick.position, @@ -176,7 +243,6 @@ export const XAxis = memo( tickMarkSize, position, formatTick, - label, ]); if (!xScale || !axisBounds) return; @@ -187,17 +253,28 @@ export const XAxis = memo( ? axisBounds.y + axisBounds.height - LABEL_SIZE / 2 : axisBounds.y + LABEL_SIZE / 2; + // Pre-compute tick mark Y coordinates + const tickYTop = axisBounds.y; + const tickYBottom = axisBounds.y + axisBounds.height; + const tickYStart = position === 'bottom' ? tickYTop : tickYBottom; + const tickYEnd = position === 'bottom' ? tickYTop + tickMarkSize : tickYBottom - tickMarkSize; + + // Note: Unlike web, mobile renders grid lines and tick marks immediately without fade animation. + // This is because Skia can measure text dimensions synchronously, so there's no need to hide + // elements while waiting for measurements (web uses async ResizeObserver). return ( {showGrid && ( - {ticksData.map((tick, index) => { - const verticalLine = ( - - ); - - return {verticalLine}; - })} + {gridLinePositions.map(({ x, key }) => ( + + ))} )} {chartTextData && ( @@ -210,25 +287,17 @@ export const XAxis = memo( )} {axisBounds && showTickMarks && ( - {ticksData.map((tick, index) => { - const tickY = position === 'bottom' ? axisBounds.y : axisBounds.y + axisBounds.height; - const tickY2 = - position === 'bottom' - ? axisBounds.y + tickMarkSize - : axisBounds.y + axisBounds.height - tickMarkSize; - - return ( - - ); - })} + {tickMarkPositions.map(({ x, key }) => ( + + ))} )} {showLine && ( diff --git a/packages/mobile-visualization/src/chart/axis/YAxis.tsx b/packages/mobile-visualization/src/chart/axis/YAxis.tsx index cbf6f2641..9aa1c4174 100644 --- a/packages/mobile-visualization/src/chart/axis/YAxis.tsx +++ b/packages/mobile-visualization/src/chart/axis/YAxis.tsx @@ -4,11 +4,17 @@ import { Group, vec } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; -import { ReferenceLine } from '../line/ReferenceLine'; import { SolidLine } from '../line/SolidLine'; import { ChartText } from '../text/ChartText'; import { ChartTextGroup, type TextLabelData } from '../text/ChartTextGroup'; -import { getAxisTicksData, isCategoricalScale, lineToPath } from '../utils'; +import { + type CategoricalScale, + getAxisTicksData, + getPointOnScale, + isCategoricalScale, + lineToPath, + toPointAnchor, +} from '../utils'; import { type AxisBaseProps, type AxisProps } from './Axis'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; @@ -57,21 +63,27 @@ export const YAxis = memo( label, labelGap = 4, width = label ? AXIS_WIDTH + LABEL_SIZE : AXIS_WIDTH, + bandGridLinePlacement = 'edges', + bandTickMarkPlacement = 'middle', ...props }) => { const theme = useTheme(); const registrationId = useId(); - const { animate, getYScale, getYAxis, registerAxis, unregisterAxis, getAxisBounds } = - useCartesianChartContext(); + const { + animate, + drawingArea, + getYScale, + getYAxis, + registerAxis, + unregisterAxis, + getAxisBounds, + } = useCartesianChartContext(); const yScale = getYScale(axisId); const yAxis = getYAxis(axisId); const axisBounds = getAxisBounds(registrationId); - // Note: gridOpacity not currently used in Skia version - // const gridOpacity = useSharedValue(1); - useEffect(() => { registerAxis(registrationId, position, width); @@ -131,6 +143,63 @@ export const YAxis = memo( }); }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data]); + const isBandScale = useMemo(() => { + if (!yScale) return false; + return isCategoricalScale(yScale); + }, [yScale]); + + // Compute grid line positions (including bounds closing line for band scales) + const gridLinePositions = useMemo((): Array<{ y: number; key: string }> => { + if (!yScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ y: tick.position, key: `grid-${tick.tick}-${index}` }]; + } + + const bandScale = yScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandGridLinePlacement === 'edges'; + + const startY = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandGridLinePlacement)); + const positions = [{ y: startY, key: `grid-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing line at stepEnd + if (isLastTick && isEdges) { + const endY = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ y: endY, key: `grid-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, yScale, isBandScale, bandGridLinePlacement]); + + // Compute tick mark positions (including bounds closing tick for band scales) + const tickMarkPositions = useMemo((): Array<{ y: number; key: string }> => { + if (!yScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ y: tick.position, key: `tick-mark-${tick.tick}-${index}` }]; + } + + const bandScale = yScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandTickMarkPlacement === 'edges'; + + const startY = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandTickMarkPlacement)); + const positions = [{ y: startY, key: `tick-mark-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing tick mark at stepEnd + if (isLastTick && isEdges) { + const endY = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ y: endY, key: `tick-mark-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, yScale, isBandScale, bandTickMarkPlacement]); + const chartTextData: TextLabelData[] | null = useMemo(() => { if (!axisBounds) return null; @@ -172,21 +241,28 @@ export const YAxis = memo( : axisBounds.x + axisBounds.width - LABEL_SIZE / 2; const labelY = axisBounds.y + axisBounds.height / 2; + // Pre-compute tick mark X coordinates + const tickXLeft = axisBounds.x; + const tickXRight = axisBounds.x + axisBounds.width; + const tickXStart = position === 'left' ? tickXRight : tickXLeft; + const tickXEnd = position === 'left' ? tickXRight - tickMarkSize : tickXLeft + tickMarkSize; + + // Note: Unlike web, mobile renders grid lines and tick marks immediately without fade animation. + // This is because Skia can measure text dimensions synchronously, so there's no need to hide + // elements while waiting for measurements (web uses async ResizeObserver). return ( {showGrid && ( - {ticksData.map((tick, index) => { - const horizontalLine = ( - - ); - - return {horizontalLine}; - })} + {gridLinePositions.map(({ y, key }) => ( + + ))} )} {chartTextData && ( @@ -199,26 +275,17 @@ export const YAxis = memo( )} {axisBounds && showTickMarks && ( - {ticksData.map((tick, index) => { - const tickX = position === 'left' ? axisBounds.x + axisBounds.width : axisBounds.x; - const tickMarkSizePixels = tickMarkSize; - const tickX2 = - position === 'left' - ? axisBounds.x + axisBounds.width - tickMarkSizePixels - : axisBounds.x + tickMarkSizePixels; - - return ( - - ); - })} + {tickMarkPositions.map(({ y, key }) => ( + + ))} )} {showLine && ( diff --git a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx b/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx index 83fb9d0c9..be759fa95 100644 --- a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx +++ b/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx @@ -1,7 +1,8 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { BarChart, BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { LineChart, SolidLine, type SolidLineProps } from '../../line'; import { Line } from '../../line/Line'; @@ -226,6 +227,100 @@ const MultipleYAxesExample = () => ( ); +const AxesOnAllSides = () => { + const theme = useTheme(); + const data = [30, 45, 60, 80, 55, 40, 65]; + const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + return ( + + index)} + /> + index)} + /> + + + + + ); +}; + +const CustomTickMarkSizes = () => { + const theme = useTheme(); + const data = [25, 50, 75, 60, 45, 80, 35]; + + return ( + + + + + + + + ); +}; + const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { const exponentialData = [ 1, 2, 4, 8, 15, 30, 65, 140, 280, 580, 1200, 2400, 4800, 9500, 19000, 38000, 75000, 150000, @@ -286,6 +381,86 @@ const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { ); }; +// Band scale with tick filtering - show every other tick +const BandScaleTickFiltering = () => ( + + i % 2 === 0} + /> + + +); + +// Band scale with explicit ticks array +const BandScaleExplicitTicks = () => ( + + + + +); + +// Line chart on band scale - comparing grid placements +const LineChartOnBandScale = ({ + bandGridLinePlacement, +}: { + bandGridLinePlacement: 'start' | 'middle' | 'end' | 'edges'; +}) => { + const theme = useTheme(); + return ( + + + + + + ); +}; + const AxisStories = () => { return ( @@ -304,6 +479,50 @@ const AxisStories = () => { + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx index a1cfbfb17..ba10f6d9d 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -595,6 +595,23 @@ const ColorMapWithOpacity = () => { ); }; +const BandGridPositionExample = ({ + position, +}: { + position: 'start' | 'middle' | 'end' | 'edges'; +}) => ( + + + + +); + const BarChartStories = () => { return ( @@ -637,6 +654,12 @@ const BarChartStories = () => { + + + + + + ); }; diff --git a/packages/mobile-visualization/src/chart/line/Line.tsx b/packages/mobile-visualization/src/chart/line/Line.tsx index 746941bba..4b2304eb0 100644 --- a/packages/mobile-visualization/src/chart/line/Line.tsx +++ b/packages/mobile-visualization/src/chart/line/Line.tsx @@ -5,6 +5,7 @@ import { type AnimatedProp, Group } from '@shopify/react-native-skia'; import { Area, type AreaComponent } from '../area/Area'; import { useCartesianChartContext } from '../ChartProvider'; +import { type PathProps } from '../Path'; import { Point, type PointBaseProps, type PointProps } from '../point'; import { accessoryFadeTransitionDelay, @@ -119,17 +120,18 @@ export type LineProps = LineBaseProps & { export type LineComponentProps = Pick< LineProps, 'stroke' | 'strokeOpacity' | 'strokeWidth' | 'gradient' | 'animate' | 'transition' -> & { - /** - * Path of the line - */ - d: AnimatedProp; - /** - * ID of the y-axis to use. - * If not provided, defaults to the default y-axis. - */ - yAxisId?: string; -}; +> & + Pick & { + /** + * Path of the line + */ + d: AnimatedProp; + /** + * ID of the y-axis to use. + * If not provided, defaults to the default y-axis. + */ + yAxisId?: string; + }; export type LineComponent = React.FC; diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts index 0a2a0b4e0..f2e612a4d 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts @@ -237,6 +237,88 @@ describe('getAxisTicksData', () => { expect(result.length).toBe(3); expect(result.map((r) => r.tick)).toEqual([0, 1, 2]); }); + + it('should use middle anchor by default', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + }); + + const bandwidth = bandScale.bandwidth(); + expect(result[0].position).toBe(bandScale(0)! + bandwidth / 2); + }); + + it('should respect anchor option for band scale positioning', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + + // Test stepStart anchor - should be at the start of the step (before band padding) + const stepStartResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'stepStart' }, + }); + const expectedStepStart = bandScale(0)! - paddingOffset; + expect(stepStartResult[0].position).toBeCloseTo(expectedStepStart, 5); + + // Test middle anchor (explicit) + const middleResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'middle' }, + }); + expect(middleResult[0].position).toBe(bandScale(0)! + bandwidth / 2); + + // Test stepEnd anchor - should be at the end of the step + const stepEndResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'stepEnd' }, + }); + const expectedStepEnd = bandScale(0)! - paddingOffset + step; + expect(stepEndResult[0].position).toBeCloseTo(expectedStepEnd, 5); + }); + + it('should apply anchor option with tick filter function', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + const expectedStepStart = bandScale(0)! - paddingOffset; + + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: (index) => index === 0, + options: { anchor: 'stepStart' }, + }); + + expect(result.length).toBe(1); + expect(result[0].position).toBeCloseTo(expectedStepStart, 5); + }); + + it('should apply anchor option when showing all categories', () => { + const categories = ['Jan', 'Feb']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + options: { anchor: 'stepStart' }, + }); + + expect(result[0].position).toBeCloseTo(bandScale(0)! - paddingOffset, 5); + expect(result[1].position).toBeCloseTo(bandScale(1)! - paddingOffset, 5); + }); }); describe('tick generation options', () => { diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts index 476153c9d..4cb6c93fe 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts @@ -124,6 +124,69 @@ describe('getPointOnScale', () => { expect(typeof result).toBe('number'); }); }); + + describe('with categorical scale and anchor parameter', () => { + it('should use middle anchor by default', () => { + const result = getPointOnScale(0, categoricalScale); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth / 2); + }); + + it('should position at stepStart when anchor is stepStart', () => { + const result = getPointOnScale(0, categoricalScale, 'stepStart'); + const bandStart = categoricalScale(0) ?? 0; + const step = categoricalScale.step(); + const bandwidth = categoricalScale.bandwidth(); + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + expect(result).toBeCloseTo(stepStart, 5); + }); + + it('should position at bandStart when anchor is bandStart', () => { + const result = getPointOnScale(0, categoricalScale, 'bandStart'); + const bandStart = categoricalScale(0) ?? 0; + expect(result).toBe(bandStart); + }); + + it('should position at middle when anchor is middle', () => { + const result = getPointOnScale(0, categoricalScale, 'middle'); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth / 2); + }); + + it('should position at bandEnd when anchor is bandEnd', () => { + const result = getPointOnScale(0, categoricalScale, 'bandEnd'); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth); + }); + + it('should position at stepEnd when anchor is stepEnd', () => { + const result = getPointOnScale(0, categoricalScale, 'stepEnd'); + const bandStart = categoricalScale(0) ?? 0; + const step = categoricalScale.step(); + const bandwidth = categoricalScale.bandwidth(); + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + expect(result).toBeCloseTo(stepStart + step, 5); + }); + + it('should maintain consistent spacing between anchor positions', () => { + const stepStart = getPointOnScale(0, categoricalScale, 'stepStart'); + const bandStart = getPointOnScale(0, categoricalScale, 'bandStart'); + const middle = getPointOnScale(0, categoricalScale, 'middle'); + const bandEnd = getPointOnScale(0, categoricalScale, 'bandEnd'); + const stepEnd = getPointOnScale(0, categoricalScale, 'stepEnd'); + + // Positions should be in order + expect(stepStart).toBeLessThanOrEqual(bandStart); + expect(bandStart).toBeLessThan(middle); + expect(middle).toBeLessThan(bandEnd); + expect(bandEnd).toBeLessThanOrEqual(stepEnd); + }); + }); }); describe('getPointOnSerializableScale', () => { @@ -279,6 +342,30 @@ describe('getPointOnSerializableScale', () => { expect(serializableResult).toBeCloseTo(d3Result, 5); }); }); + + it('should match getPointOnScale with anchor parameter', () => { + const serializableScale = convertToSerializableScale(categoricalScale); + expect(serializableScale?.type).toBe('band'); + + if (serializableScale?.type !== 'band') return; + + const anchors: Array<'stepStart' | 'bandStart' | 'middle' | 'bandEnd' | 'stepEnd'> = [ + 'stepStart', + 'bandStart', + 'middle', + 'bandEnd', + 'stepEnd', + ]; + + anchors.forEach((anchor) => { + for (let i = 0; i <= 4; i++) { + const d3Result = getPointOnScale(i, categoricalScale, anchor); + const serializableResult = getPointOnSerializableScale(i, serializableScale, anchor); + + expect(serializableResult).toBeCloseTo(d3Result, 5); + } + }); + }); }); describe('edge cases', () => { diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index 0731e67ab..1abdf96a5 100644 --- a/packages/mobile-visualization/src/chart/utils/axis.ts +++ b/packages/mobile-visualization/src/chart/utils/axis.ts @@ -9,7 +9,9 @@ import { isValidBounds, type Series, } from './chart'; +import { getPointOnScale } from './point'; import { + type CategoricalScale, type ChartAxisScaleType, type ChartScaleFunction, getCategoricalScale, @@ -17,11 +19,43 @@ import { isCategoricalScale, isNumericScale, type NumericScale, + type PointAnchor, } from './scale'; export const defaultAxisId = 'DEFAULT_AXIS_ID'; export const defaultAxisScaleType = 'linear'; +/** + * Position options for band scale axis elements (grid lines, tick marks, labels). + * + * - `'start'` - At the start of each step (before bar padding) + * - `'middle'` - At the center of each band + * - `'end'` - At the end of each step (after bar padding) + * - `'edges'` - At start of each tick, plus end for the last tick (encloses the chart) + * + * @note These properties only apply when using a band scale (`scaleType: 'band'`). + */ +export type AxisBandPlacement = 'start' | 'middle' | 'end' | 'edges'; + +/** + * Converts an AxisBandPlacement to the corresponding PointAnchor for use with getPointOnScale. + * + * @param placement - The axis placement value + * @returns The corresponding PointAnchor for scale calculations + */ +export const toPointAnchor = (placement: AxisBandPlacement): PointAnchor => { + switch (placement) { + case 'edges': // edges uses stepStart for each tick, stepEnd handled separately + case 'start': + return 'stepStart'; + case 'end': + return 'stepEnd'; + case 'middle': + default: + return 'middle'; + } +}; + /** * Axis configuration with computed bounds */ @@ -324,6 +358,12 @@ type TickGenerationOptions = { * @default 4 */ minTickCount?: number; + /** + * Anchor position for band/categorical scales. + * Controls where tick labels are positioned within each band. + * @default 'middle' + */ + anchor?: PointAnchor; }; export type GetAxisTicksDataProps = { @@ -623,23 +663,20 @@ export const getAxisTicksData = ({ tickInterval, options, }: GetAxisTicksDataProps): Array<{ tick: number; position: number }> => { + const anchor = options?.anchor ?? 'middle'; + // Handle band scales if (isCategoricalScale(scaleFunction)) { + const bandScale = scaleFunction; + // If explicit ticks are provided as array, use them if (Array.isArray(ticks)) { return ticks .filter((index) => index >= 0 && index < categories.length) - .map((index) => { - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - - return { - tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, - }; - }) - .filter(Boolean) as Array<{ tick: number; position: number }>; + .map((index) => ({ + tick: index, + position: getPointOnScale(index, bandScale, anchor), + })); } // If a tick function is provided, use it to filter @@ -648,36 +685,20 @@ export const getAxisTicksData = ({ .map((category, index) => { if (!ticks(index)) return null; - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - return { tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, + position: getPointOnScale(index, bandScale, anchor), }; }) .filter(Boolean) as Array<{ tick: number; position: number }>; } - if (typeof ticks === 'boolean' && !ticks) { - return []; - } - // For band scales without explicit ticks, show all categories // requestedTickCount is ignored for categorical scales - use ticks parameter to control visibility - return categories - .map((category, index) => { - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - - return { - tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, - }; - }) - .filter(Boolean) as Array<{ tick: number; position: number }>; + return categories.map((_, index) => ({ + tick: index, + position: getPointOnScale(index, bandScale, anchor), + })); } // Handle numeric scales diff --git a/packages/mobile-visualization/src/chart/utils/point.ts b/packages/mobile-visualization/src/chart/utils/point.ts index 3ae906817..22dc0720a 100644 --- a/packages/mobile-visualization/src/chart/utils/point.ts +++ b/packages/mobile-visualization/src/chart/utils/point.ts @@ -1,11 +1,15 @@ import type { TextHorizontalAlignment, TextVerticalAlignment } from '../text'; import { + applyBandScale, applySerializableScale, + type CategoricalScale, type ChartScaleFunction, isCategoricalScale, isLogScale, isNumericScale, + type PointAnchor, + type SerializableBandScale, type SerializableScale, } from './scale'; @@ -20,17 +24,39 @@ export type PointLabelPosition = 'top' | 'bottom' | 'left' | 'right' | 'center'; /** * Get a point from a data value and a scale. - * @note for categorical scales, the point will be centered within the band. - * @note for log scales, zero and negative values are clamped to a small positive value. - * @param data - the data value. - * @param scale - the scale function. - * @returns the pixel value (defaulting to 0 if data value is not defined in scale). + * + * @param dataValue - The data value to convert to a pixel position. + * @param scale - The scale function. + * @param anchor (@default 'middle') - For band scales, where to anchor the point within the band. + * @returns The pixel value (@default 0 if data value is not defined in scale). */ -export const getPointOnScale = (dataValue: number, scale: ChartScaleFunction): number => { +export const getPointOnScale = ( + dataValue: number, + scale: ChartScaleFunction, + anchor: PointAnchor = 'middle', +): number => { if (isCategoricalScale(scale)) { - const bandStart = scale(dataValue) ?? 0; - const bandwidth = scale.bandwidth() ?? 0; - return bandStart + bandwidth / 2; + const bandScale = scale as CategoricalScale; + const bandStart = bandScale(dataValue); + if (bandStart === undefined) return 0; + + const bandwidth = bandScale.bandwidth?.() ?? 0; + const step = bandScale.step?.() ?? bandwidth; + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + + switch (anchor) { + case 'stepStart': + return stepStart; + case 'bandStart': + return bandStart; + case 'middle': + return bandStart + bandwidth / 2; + case 'bandEnd': + return bandStart + bandwidth; + case 'stepEnd': + return stepStart + step; + } } // For log scales, ensure the value is positive @@ -44,18 +70,47 @@ export const getPointOnScale = (dataValue: number, scale: ChartScaleFunction): n /** * Get a point from a data value and a serializable scale (worklet-compatible). - * @note for categorical scales, the point will be centered within the band. - * @note for log scales, zero and negative values are clamped to a small positive value. - * @param dataValue - the data value. - * @param scale - the serializable scale object. - * @returns the pixel value (defaulting to 0 if data value is not defined in scale). + * + * @param dataValue - The data value to convert to a pixel position. + * @param scale - The serializable scale function. + * @param anchor (@default 'middle') - For band scales, where to anchor the point within the band. + * @returns The pixel value (@default 0 if data value is not defined in scale). */ -export function getPointOnSerializableScale(dataValue: number, scale: SerializableScale): number { +export function getPointOnSerializableScale( + dataValue: number, + scale: SerializableScale, + anchor: PointAnchor = 'middle', +): number { 'worklet'; + // Handle band scales with the specified position if (scale.type === 'band') { - const bandStart = applySerializableScale(dataValue, scale); - return bandStart + scale.bandwidth / 2; + const bandScale = scale as SerializableBandScale; + const [domainMin, domainMax] = bandScale.domain; + const index = dataValue - domainMin; + const n = domainMax - domainMin + 1; + + if (index < 0 || index >= n) { + return 0; + } + + const bandStart = applyBandScale(dataValue, bandScale); + + const paddingOffset = (bandScale.step - bandScale.bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + + switch (anchor) { + case 'stepStart': + return stepStart; + case 'bandStart': + return bandStart; + case 'middle': + return bandStart + bandScale.bandwidth / 2; + case 'bandEnd': + return bandStart + bandScale.bandwidth; + case 'stepEnd': + return stepStart + bandScale.step; + } } // For log scales, ensure the value is positive diff --git a/packages/mobile-visualization/src/chart/utils/scale.ts b/packages/mobile-visualization/src/chart/utils/scale.ts index e73d0b3ae..a3f70313c 100644 --- a/packages/mobile-visualization/src/chart/utils/scale.ts +++ b/packages/mobile-visualization/src/chart/utils/scale.ts @@ -82,10 +82,23 @@ export const getCategoricalScale = ({ const scale = scaleBand() .domain(domainArray) .range([range.min, range.max]) - .padding(padding); + .paddingInner(padding) + .paddingOuter(padding / 2); return scale; }; +/** + * Anchor position for points on a scale. Currently used only for band scales. + * + * For band scales, this determines where within the band to position a point: + * - `'stepStart'` - At the start of the step + * - `'bandStart'` - At the start of the band + * - `'middle'` - At the center of the band + * - `'bandEnd'` - At the end of the band + * - `'stepEnd'` - At the end of the step + */ +export type PointAnchor = 'stepStart' | 'bandStart' | 'middle' | 'bandEnd' | 'stepEnd'; + /** * Convert a D3 scale to a serializable scale configuration that can be used in worklets */ @@ -254,7 +267,7 @@ export function applyBandScale(value: number, scale: SerializableBandScale): num return r0; } - const paddingOffset = step - scale.bandwidth; + const paddingOffset = (step - scale.bandwidth) / 2; const bandStart = r0 + step * index + paddingOffset; return bandStart; diff --git a/packages/web-visualization/CHANGELOG.md b/packages/web-visualization/CHANGELOG.md index 66cae41eb..d04c24a44 100644 --- a/packages/web-visualization/CHANGELOG.md +++ b/packages/web-visualization/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 3.4.0-beta.11 (1/7/2026 PST) + +#### 🐞 Fixes + +- Allow customization of axis tick mark and grid line alignment in band scale. [[#291](https://github.com/coinbase/cds/pull/291)] + ## 3.4.0-beta.10 (1/6/2026 PST) #### 🐞 Fixes diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index 99a699d20..0e39052a2 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web-visualization", - "version": "3.4.0-beta.10", + "version": "3.4.0-beta.11", "description": "Coinbase Design System - Web Sparkline", "repository": { "type": "git", diff --git a/packages/web-visualization/src/chart/axis/Axis.tsx b/packages/web-visualization/src/chart/axis/Axis.tsx index b03b7b624..b6a353eba 100644 --- a/packages/web-visualization/src/chart/axis/Axis.tsx +++ b/packages/web-visualization/src/chart/axis/Axis.tsx @@ -1,9 +1,10 @@ import type React from 'react'; import type { SharedProps } from '@coinbase/cds-common/types'; +import type { Transition } from 'framer-motion'; import { type LineComponent } from '../line'; import type { ChartTextChildren, ChartTextProps } from '../text/ChartText'; -import { accessoryFadeTransitionDuration } from '../utils'; +import { accessoryFadeTransitionDuration, type AxisBandPlacement } from '../utils'; export const axisLineStyles = ` stroke: var(--color-fg); @@ -20,23 +21,9 @@ export const axisTickMarkStyles = ` /** * Animation variants for axis elements - updates (used for both grid lines and tick labels) */ -export const axisUpdateAnimationVariants = { - initial: { - opacity: 0, - }, - animate: { - opacity: 1, - transition: { - duration: accessoryFadeTransitionDuration, - delay: accessoryFadeTransitionDuration, - }, - }, - exit: { - opacity: 0, - transition: { - duration: accessoryFadeTransitionDuration, - }, - }, +export const axisUpdateAnimationTransition: Transition = { + duration: accessoryFadeTransitionDuration, + ease: 'easeOut', }; export type AxisTickLabelComponentProps = Pick< @@ -69,6 +56,20 @@ export type AxisTickLabelComponentProps = Pick< export type AxisTickLabelComponent = React.FC; export type AxisBaseProps = SharedProps & { + /** + * Placement of grid lines relative to each band. + * Options: 'start', 'middle', 'end', 'edges' + * @note This property only applies to band scales. + * @default 'edges' + */ + bandGridLinePlacement?: AxisBandPlacement; + /** + * Placement of tick marks relative to each band. + * Options: 'start', 'middle', 'end', 'edges' + * @note This property only applies to band scales. + * @default 'middle' + */ + bandTickMarkPlacement?: AxisBandPlacement; /** * Label text to display for the axis. */ diff --git a/packages/web-visualization/src/chart/axis/XAxis.tsx b/packages/web-visualization/src/chart/axis/XAxis.tsx index a81afc0d0..1e690ffa0 100644 --- a/packages/web-visualization/src/chart/axis/XAxis.tsx +++ b/packages/web-visualization/src/chart/axis/XAxis.tsx @@ -1,22 +1,28 @@ import { memo, useCallback, useEffect, useId, useMemo } from 'react'; import { cx } from '@coinbase/cds-web'; import { css } from '@linaria/core'; -import { AnimatePresence, m as motion } from 'framer-motion'; +import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; -import { ReferenceLine } from '../line/ReferenceLine'; import { SolidLine } from '../line/SolidLine'; import { ChartText } from '../text/ChartText'; import { ChartTextGroup, type TextLabelData } from '../text/ChartTextGroup'; -import { getAxisTicksData, isCategoricalScale, lineToPath } from '../utils'; +import { + type CategoricalScale, + getAxisTicksData, + getPointOnScale, + isCategoricalScale, + lineToPath, + toPointAnchor, +} from '../utils'; import { type AxisBaseProps, axisLineStyles, type AxisProps, axisTickMarkStyles, - axisUpdateAnimationVariants, + axisUpdateAnimationTransition, } from './Axis'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; @@ -72,11 +78,20 @@ export const XAxis = memo( labelGap = 4, height = label ? AXIS_HEIGHT + LABEL_SIZE : AXIS_HEIGHT, testID = 'x-axis', + bandGridLinePlacement = 'edges', + bandTickMarkPlacement = 'middle', ...props }) => { const registrationId = useId(); - const { animate, getXScale, getXAxis, registerAxis, unregisterAxis, getAxisBounds } = - useCartesianChartContext(); + const { + animate, + getXScale, + getXAxis, + registerAxis, + unregisterAxis, + getAxisBounds, + drawingArea, + } = useCartesianChartContext(); const xScale = getXScale(); const xAxis = getXAxis(); @@ -128,17 +143,6 @@ export const XAxis = memo( categories = domain.map(String); } - let possibleTickValues: number[]; - - // If we have discrete data, we can use the indices as possible tick values - if ( - axisData && - Array.isArray(axisData) && - (typeof axisData[0] === 'string' || - (typeof axisData[0] === 'number' && isCategoricalScale(xScale))) - ) - possibleTickValues = Array.from({ length: axisData.length }, (_, i) => i); - return getAxisTicksData({ scaleFunction: xScale, ticks, @@ -156,6 +160,63 @@ export const XAxis = memo( }); }, [ticks, xScale, requestedTickCount, tickInterval, tickMinStep, tickMaxStep, xAxis?.data]); + const isBandScale = useMemo(() => { + if (!xScale) return false; + return isCategoricalScale(xScale); + }, [xScale]); + + // Compute grid line positions (including bounds closing line for band scales) + const gridLinePositions = useMemo((): Array<{ x: number; key: string }> => { + if (!xScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ x: tick.position, key: `grid-${tick.tick}-${index}` }]; + } + + const bandScale = xScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandGridLinePlacement === 'edges'; + + const startX = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandGridLinePlacement)); + const positions = [{ x: startX, key: `grid-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing line at stepEnd + if (isLastTick && isEdges) { + const endX = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ x: endX, key: `grid-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, xScale, isBandScale, bandGridLinePlacement]); + + // Compute tick mark positions (including bounds closing tick mark for band scales) + const tickMarkPositions = useMemo((): Array<{ x: number; key: string }> => { + if (!xScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ x: tick.position, key: `tick-mark-${tick.tick}-${index}` }]; + } + + const bandScale = xScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandTickMarkPlacement === 'edges'; + + const startX = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandTickMarkPlacement)); + const positions = [{ x: startX, key: `tick-mark-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing tick mark at stepEnd + if (isLastTick && isEdges) { + const endX = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ x: endX, key: `tick-mark-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, xScale, isBandScale, bandTickMarkPlacement]); + const chartTextData: TextLabelData[] | null = useMemo(() => { if (!axisBounds) return null; @@ -165,9 +226,10 @@ export const XAxis = memo( const availableSpace = AXIS_HEIGHT - tickOffset; const labelOffset = availableSpace / 2; - const baseY = position === 'top' && label ? axisBounds.y + LABEL_SIZE : axisBounds.y; const labelY = - position === 'top' ? baseY + labelOffset - tickOffset : baseY + labelOffset + tickOffset; + position === 'top' + ? axisBounds.y + axisBounds.height - tickOffset - labelOffset + : axisBounds.y + labelOffset + tickOffset; return { x: tick.position, @@ -192,10 +254,9 @@ export const XAxis = memo( formatTick, classNames?.tickLabel, styles?.tickLabel, - label, ]); - if (!xScale || !axisBounds) return; + if (!xScale || !axisBounds || !drawingArea) return; const labelX = axisBounds.x + axisBounds.width / 2; const labelY = @@ -203,6 +264,11 @@ export const XAxis = memo( ? axisBounds.y + axisBounds.height - LABEL_SIZE / 2 : axisBounds.y + LABEL_SIZE / 2; + const tickYTop = axisBounds.y; + const tickYBottom = axisBounds.y + axisBounds.height; + const tickYStart = position === 'bottom' ? tickYTop : tickYBottom; + const tickYEnd = position === 'bottom' ? tickYTop + tickMarkSize : tickYBottom - tickMarkSize; + return ( ( > {showGrid && ( - - {ticksData.map((tick, index) => { - const verticalLine = ( - - ); - - return animate ? ( - - {verticalLine} - - ) : ( - {verticalLine} - ); - })} - + {gridLinePositions.map(({ x, key }) => + animate ? ( + + + + ) : ( + + ), + )} )} {chartTextData && ( @@ -246,24 +320,39 @@ export const XAxis = memo( )} {axisBounds && showTickMarks && ( - {ticksData.map((tick, index) => { - const tickY = position === 'bottom' ? axisBounds.y : axisBounds.y + axisBounds.height; - const tickY2 = position === 'bottom' ? tickY + tickMarkSize : tickY - tickMarkSize; - - return ( + {tickMarkPositions.map(({ x, key }) => + animate ? ( + + + + ) : ( - ); - })} + ), + )} )} {showLine && ( diff --git a/packages/web-visualization/src/chart/axis/YAxis.tsx b/packages/web-visualization/src/chart/axis/YAxis.tsx index e90101fb0..db72b725c 100644 --- a/packages/web-visualization/src/chart/axis/YAxis.tsx +++ b/packages/web-visualization/src/chart/axis/YAxis.tsx @@ -1,18 +1,29 @@ import { memo, useCallback, useEffect, useId, useMemo } from 'react'; import { cx } from '@coinbase/cds-web'; import { css } from '@linaria/core'; -import { AnimatePresence, m as motion } from 'framer-motion'; +import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; -import { ReferenceLine } from '../line/ReferenceLine'; import { SolidLine } from '../line/SolidLine'; import { ChartText } from '../text/ChartText'; import { ChartTextGroup, type TextLabelData } from '../text/ChartTextGroup'; -import { getAxisTicksData, isCategoricalScale, lineToPath } from '../utils'; +import { + type CategoricalScale, + getAxisTicksData, + getPointOnScale, + isCategoricalScale, + lineToPath, + toPointAnchor, +} from '../utils'; -import type { AxisBaseProps, AxisProps } from './Axis'; -import { axisLineStyles, axisTickMarkStyles, axisUpdateAnimationVariants } from './Axis'; +import { + type AxisBaseProps, + axisLineStyles, + type AxisProps, + axisTickMarkStyles, + axisUpdateAnimationTransition, +} from './Axis'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; const AXIS_WIDTH = 44; @@ -71,11 +82,20 @@ export const YAxis = memo( labelGap = 4, width = label ? AXIS_WIDTH + LABEL_SIZE : AXIS_WIDTH, testID = 'y-axis', + bandGridLinePlacement = 'edges', + bandTickMarkPlacement = 'middle', ...props }) => { const registrationId = useId(); - const { animate, getYScale, getYAxis, registerAxis, unregisterAxis, getAxisBounds } = - useCartesianChartContext(); + const { + animate, + getYScale, + getYAxis, + registerAxis, + unregisterAxis, + getAxisBounds, + drawingArea, + } = useCartesianChartContext(); const yScale = getYScale(axisId); const yAxis = getYAxis(axisId); @@ -137,6 +157,63 @@ export const YAxis = memo( }); }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data]); + const isBandScale = useMemo(() => { + if (!yScale) return false; + return isCategoricalScale(yScale); + }, [yScale]); + + // Compute grid line positions (including bounds closing line for band scales) + const gridLinePositions = useMemo((): Array<{ y: number; key: string }> => { + if (!yScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ y: tick.position, key: `grid-${tick.tick}-${index}` }]; + } + + const bandScale = yScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandGridLinePlacement === 'edges'; + + const startY = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandGridLinePlacement)); + const positions = [{ y: startY, key: `grid-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing line at stepEnd + if (isLastTick && isEdges) { + const endY = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ y: endY, key: `grid-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, yScale, isBandScale, bandGridLinePlacement]); + + // Compute tick mark positions (including bounds closing tick mark for band scales) + const tickMarkPositions = useMemo((): Array<{ y: number; key: string }> => { + if (!yScale) return []; + + return ticksData.flatMap((tick, index) => { + if (!isBandScale) { + return [{ y: tick.position, key: `tick-mark-${tick.tick}-${index}` }]; + } + + const bandScale = yScale as CategoricalScale; + const isLastTick = index === ticksData.length - 1; + const isEdges = bandTickMarkPlacement === 'edges'; + + const startY = getPointOnScale(tick.tick, bandScale, toPointAnchor(bandTickMarkPlacement)); + const positions = [{ y: startY, key: `tick-mark-${tick.tick}-${index}` }]; + + // For edges on last tick, add the closing tick mark at stepEnd + if (isLastTick && isEdges) { + const endY = getPointOnScale(tick.tick, bandScale, 'stepEnd'); + positions.push({ y: endY, key: `tick-mark-${tick.tick}-${index}-end` }); + } + + return positions; + }); + }, [ticksData, yScale, isBandScale, bandTickMarkPlacement]); + const chartTextData: TextLabelData[] | null = useMemo(() => { if (!axisBounds) return null; @@ -173,7 +250,7 @@ export const YAxis = memo( styles?.tickLabel, ]); - if (!yScale || !axisBounds) return; + if (!yScale || !axisBounds || !drawingArea) return; const labelX = position === 'left' @@ -181,6 +258,11 @@ export const YAxis = memo( : axisBounds.x + axisBounds.width - LABEL_SIZE / 2; const labelY = axisBounds.y + axisBounds.height / 2; + const tickXLeft = axisBounds.x; + const tickXRight = axisBounds.x + axisBounds.width; + const tickXStart = position === 'left' ? tickXRight : tickXLeft; + const tickXEnd = position === 'left' ? tickXRight - tickMarkSize : tickXLeft + tickMarkSize; + return ( ( > {showGrid && ( - - {ticksData.map((tick, index) => { - const horizontalLine = ( - + animate ? ( + + - ); - - return animate ? ( - - {horizontalLine} - - ) : ( - {horizontalLine} - ); - })} - + + ) : ( + + ), + )} )} {chartTextData && ( @@ -228,28 +314,39 @@ export const YAxis = memo( )} {showTickMarks && ( - {ticksData.map((tick, index) => { - const tickX = position === 'left' ? axisBounds.x + axisBounds.width : axisBounds.x; - const tickMarkSizePixels = tickMarkSize; - const tickX2 = - position === 'left' - ? axisBounds.x + axisBounds.width - tickMarkSizePixels - : axisBounds.x + tickMarkSizePixels; - - return ( + {tickMarkPositions.map(({ y, key }) => + animate ? ( + + + + ) : ( - ); - })} + ), + )} )} {showLine && ( diff --git a/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx b/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx index e6b3d1d5f..8505da1b2 100644 --- a/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx +++ b/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx @@ -1,7 +1,9 @@ import React, { memo, useCallback, useMemo } from 'react'; -import { VStack } from '@coinbase/cds-web/layout'; +import { Examples } from '@coinbase/cds-web/dates/__stories__/Calendar.stories'; +import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; +import { BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { LineChart, SolidLine, type SolidLineProps } from '../../line'; import { Line } from '../../line/Line'; @@ -240,6 +242,203 @@ const MultipleYAxesExample = () => ( ); +const BandAxisGridAlignment = () => ( + + + + + + + + +); + +// Band scale with tick filtering - show every other tick +const BandScaleTickFiltering = () => ( + + i % 2 === 0} + /> + + +); + +// Line chart on band scale - comparing grid placements +const LineChartOnBandScale = ({ + bandGridLinePlacement, +}: { + bandGridLinePlacement: 'start' | 'middle' | 'end' | 'edges'; +}) => ( + + + + + +); + +// Band scale with explicit ticks array +const BandScaleExplicitTicks = () => ( + + + + +); + +const AxesOnAllSides = () => { + const data = [30, 45, 60, 80, 55, 40, 65]; + const labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + return ( + + index)} + /> + index)} + /> + + + + + ); +}; + +const CustomTickMarkSizes = () => { + const data = [25, 50, 75, 60, 45, 80, 35]; + + return ( + + + + + + + + ); +}; + const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { const exponentialData = [ 1, 2, 4, 8, 15, 30, 65, 140, 280, 580, 1200, 2400, 4800, 9500, 19000, 38000, 75000, 150000, @@ -318,6 +517,36 @@ export const All = () => { + + + + + Using a function to filter which ticks are shown on a band scale. + + } + title="Band Scale - Tick Filtering" + > + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx index b05f9d036..893e453cb 100644 --- a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,6 +1,6 @@ import React, { memo, useId } from 'react'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; -import { VStack } from '@coinbase/cds-web/layout'; +import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import { CartesianChart } from '../..'; @@ -204,6 +204,24 @@ const ScrubberRect = memo(() => { ); }); +const BandGridPositionExample = ({ + position, +}: { + position: 'start' | 'middle' | 'end' | 'edges'; +}) => ( + + + + +); + const Candlesticks = () => { const infoTextRef = React.useRef(null); const selectedIndexRef = React.useRef(undefined); @@ -751,6 +769,14 @@ export const All = () => { }} /> + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/line/Line.tsx b/packages/web-visualization/src/chart/line/Line.tsx index a70b48598..a58c5b835 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -5,6 +5,7 @@ import { m as motion, type Transition } from 'framer-motion'; import { Area, type AreaComponent } from '../area/Area'; import { useCartesianChartContext } from '../ChartProvider'; +import type { PathProps } from '../Path'; import { Point, type PointBaseProps, type PointProps } from '../point'; import { accessoryFadeTransitionDelay, @@ -118,23 +119,39 @@ export type LineProps = LineBaseProps & { * Passed through to Point components rendered via points. */ onPointClick?: PointProps['onClick']; -}; - -export type LineComponentProps = Pick< - LineProps, - 'stroke' | 'strokeOpacity' | 'strokeWidth' | 'gradient' | 'animate' | 'transition' -> & { /** - * Path of the line + * Custom style for the line. */ - d: SVGProps['d']; + style?: React.CSSProperties; /** - * ID of the y-axis to use. - * If not provided, defaults to the default y-axis. + * Custom className for the line. */ - yAxisId?: string; + className?: string; }; +export type LineComponentProps = Pick< + LineProps, + | 'stroke' + | 'strokeOpacity' + | 'strokeWidth' + | 'gradient' + | 'animate' + | 'transition' + | 'style' + | 'className' +> & + Pick & { + /** + * Path of the line. + */ + d: SVGProps['d']; + /** + * ID of the y-axis to use. + * If not provided, defaults to the default y-axis. + */ + yAxisId?: string; + }; + export type LineComponent = React.FC; export const Line = memo( diff --git a/packages/web-visualization/src/chart/text/ChartText.tsx b/packages/web-visualization/src/chart/text/ChartText.tsx index ad29b9df5..e7fbcb092 100644 --- a/packages/web-visualization/src/chart/text/ChartText.tsx +++ b/packages/web-visualization/src/chart/text/ChartText.tsx @@ -7,6 +7,7 @@ import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import { type ChartInset, getChartInset } from '../utils'; +import { accessoryFadeTransitionDuration } from '../utils/transition'; type ValidChartTextChildElements = | React.ReactElement, 'tspan'> @@ -320,7 +321,9 @@ export const ChartText = memo( > { expect(result.length).toBe(3); expect(result.map((r) => r.tick)).toEqual([0, 1, 2]); }); + + it('should use middle anchor by default', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + }); + + const bandwidth = bandScale.bandwidth(); + expect(result[0].position).toBe(bandScale(0)! + bandwidth / 2); + }); + + it('should respect anchor option for band scale positioning', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + + // Test stepStart anchor - should be at the start of the step (before band padding) + const stepStartResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'stepStart' }, + }); + const expectedStepStart = bandScale(0)! - paddingOffset; + expect(stepStartResult[0].position).toBeCloseTo(expectedStepStart, 5); + + // Test middle anchor (explicit) + const middleResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'middle' }, + }); + expect(middleResult[0].position).toBe(bandScale(0)! + bandwidth / 2); + + // Test stepEnd anchor - should be at the end of the step + const stepEndResult = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: [0], + options: { anchor: 'stepEnd' }, + }); + const expectedStepEnd = bandScale(0)! - paddingOffset + step; + expect(stepEndResult[0].position).toBeCloseTo(expectedStepEnd, 5); + }); + + it('should apply anchor option with tick filter function', () => { + const categories = ['Jan', 'Feb', 'Mar', 'Apr', 'May']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + const expectedStepStart = bandScale(0)! - paddingOffset; + + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + ticks: (index) => index === 0, + options: { anchor: 'stepStart' }, + }); + + expect(result.length).toBe(1); + expect(result[0].position).toBeCloseTo(expectedStepStart, 5); + }); + + it('should apply anchor option when showing all categories', () => { + const categories = ['Jan', 'Feb']; + const bandwidth = bandScale.bandwidth(); + const step = bandScale.step(); + const paddingOffset = (step - bandwidth) / 2; + + const result = getAxisTicksData({ + scaleFunction: bandScale, + categories, + options: { anchor: 'stepStart' }, + }); + + expect(result[0].position).toBeCloseTo(bandScale(0)! - paddingOffset, 5); + expect(result[1].position).toBeCloseTo(bandScale(1)! - paddingOffset, 5); + }); }); describe('tick generation options', () => { diff --git a/packages/web-visualization/src/chart/utils/__tests__/point.test.ts b/packages/web-visualization/src/chart/utils/__tests__/point.test.ts index ba23a3163..a11d405ce 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/point.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/point.test.ts @@ -123,6 +123,69 @@ describe('getPointOnScale', () => { expect(typeof result).toBe('number'); }); }); + + describe('with categorical scale and anchor parameter', () => { + it('should use middle anchor by default', () => { + const result = getPointOnScale(0, categoricalScale); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth / 2); + }); + + it('should position at stepStart when anchor is stepStart', () => { + const result = getPointOnScale(0, categoricalScale, 'stepStart'); + const bandStart = categoricalScale(0) ?? 0; + const step = categoricalScale.step(); + const bandwidth = categoricalScale.bandwidth(); + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + expect(result).toBeCloseTo(stepStart, 5); + }); + + it('should position at bandStart when anchor is bandStart', () => { + const result = getPointOnScale(0, categoricalScale, 'bandStart'); + const bandStart = categoricalScale(0) ?? 0; + expect(result).toBe(bandStart); + }); + + it('should position at middle when anchor is middle', () => { + const result = getPointOnScale(0, categoricalScale, 'middle'); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth / 2); + }); + + it('should position at bandEnd when anchor is bandEnd', () => { + const result = getPointOnScale(0, categoricalScale, 'bandEnd'); + const bandStart = categoricalScale(0) ?? 0; + const bandwidth = categoricalScale.bandwidth(); + expect(result).toBe(bandStart + bandwidth); + }); + + it('should position at stepEnd when anchor is stepEnd', () => { + const result = getPointOnScale(0, categoricalScale, 'stepEnd'); + const bandStart = categoricalScale(0) ?? 0; + const step = categoricalScale.step(); + const bandwidth = categoricalScale.bandwidth(); + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + expect(result).toBeCloseTo(stepStart + step, 5); + }); + + it('should maintain consistent spacing between anchor positions', () => { + const stepStart = getPointOnScale(0, categoricalScale, 'stepStart'); + const bandStart = getPointOnScale(0, categoricalScale, 'bandStart'); + const middle = getPointOnScale(0, categoricalScale, 'middle'); + const bandEnd = getPointOnScale(0, categoricalScale, 'bandEnd'); + const stepEnd = getPointOnScale(0, categoricalScale, 'stepEnd'); + + // Positions should be in order + expect(stepStart).toBeLessThanOrEqual(bandStart); + expect(bandStart).toBeLessThan(middle); + expect(middle).toBeLessThan(bandEnd); + expect(bandEnd).toBeLessThanOrEqual(stepEnd); + }); + }); }); describe('projectPoint', () => { diff --git a/packages/web-visualization/src/chart/utils/axis.ts b/packages/web-visualization/src/chart/utils/axis.ts index 0731e67ab..73fac3a3d 100644 --- a/packages/web-visualization/src/chart/utils/axis.ts +++ b/packages/web-visualization/src/chart/utils/axis.ts @@ -9,7 +9,9 @@ import { isValidBounds, type Series, } from './chart'; +import { getPointOnScale } from './point'; import { + type CategoricalScale, type ChartAxisScaleType, type ChartScaleFunction, getCategoricalScale, @@ -17,11 +19,43 @@ import { isCategoricalScale, isNumericScale, type NumericScale, + type PointAnchor, } from './scale'; export const defaultAxisId = 'DEFAULT_AXIS_ID'; export const defaultAxisScaleType = 'linear'; +/** + * Position options for band scale axis elements. + * + * - `'start'` - At the start of each step (before bar padding) + * - `'middle'` - At the center of each band + * - `'end'` - At the end of each step (after bar padding) + * - `'edges'` - At start of each tick, plus end for the last tick (encloses the chart) + * + * @note These properties only apply when using a band scale (`scaleType: 'band'`). + */ +export type AxisBandPlacement = 'start' | 'middle' | 'end' | 'edges'; + +/** + * Converts an AxisBandPlacement to the corresponding PointAnchor for use with getPointOnScale. + * + * @param placement - The axis placement value + * @returns The corresponding PointAnchor for scale calculations + */ +export const toPointAnchor = (placement: AxisBandPlacement): PointAnchor => { + switch (placement) { + case 'edges': // edges uses stepStart for each tick, stepEnd handled separately + case 'start': + return 'stepStart'; + case 'end': + return 'stepEnd'; + case 'middle': + default: + return 'middle'; + } +}; + /** * Axis configuration with computed bounds */ @@ -324,6 +358,12 @@ type TickGenerationOptions = { * @default 4 */ minTickCount?: number; + /** + * Anchor position for band/categorical scales. + * Controls where tick labels are positioned within each band. + * @default 'middle' + */ + anchor?: PointAnchor; }; export type GetAxisTicksDataProps = { @@ -623,23 +663,20 @@ export const getAxisTicksData = ({ tickInterval, options, }: GetAxisTicksDataProps): Array<{ tick: number; position: number }> => { + const anchor = options?.anchor ?? 'middle'; + // Handle band scales if (isCategoricalScale(scaleFunction)) { + const bandScale = scaleFunction; + // If explicit ticks are provided as array, use them if (Array.isArray(ticks)) { return ticks .filter((index) => index >= 0 && index < categories.length) - .map((index) => { - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - - return { - tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, - }; - }) - .filter(Boolean) as Array<{ tick: number; position: number }>; + .map((index) => ({ + tick: index, + position: getPointOnScale(index, bandScale, anchor), + })); } // If a tick function is provided, use it to filter @@ -648,36 +685,20 @@ export const getAxisTicksData = ({ .map((category, index) => { if (!ticks(index)) return null; - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - return { tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, + position: getPointOnScale(index, bandScale, anchor), }; }) .filter(Boolean) as Array<{ tick: number; position: number }>; } - if (typeof ticks === 'boolean' && !ticks) { - return []; - } - // For band scales without explicit ticks, show all categories // requestedTickCount is ignored for categorical scales - use ticks parameter to control visibility - return categories - .map((category, index) => { - // Band scales expect numeric indices, not category strings - const position = scaleFunction(index); - if (position === undefined) return null; - - return { - tick: index, - position: position + ((scaleFunction as any).bandwidth?.() ?? 0) / 2, - }; - }) - .filter(Boolean) as Array<{ tick: number; position: number }>; + return categories.map((_, index) => ({ + tick: index, + position: getPointOnScale(index, bandScale, anchor), + })); } // Handle numeric scales diff --git a/packages/web-visualization/src/chart/utils/point.ts b/packages/web-visualization/src/chart/utils/point.ts index af4a62fce..d64c6e9f0 100644 --- a/packages/web-visualization/src/chart/utils/point.ts +++ b/packages/web-visualization/src/chart/utils/point.ts @@ -1,6 +1,13 @@ import type { TextHorizontalAlignment, TextVerticalAlignment } from '../text'; -import { type ChartScaleFunction, isCategoricalScale, isLogScale, isNumericScale } from './scale'; +import { + type CategoricalScale, + type ChartScaleFunction, + isCategoricalScale, + isLogScale, + isNumericScale, + type PointAnchor, +} from './scale'; /** * Position a label should be placed relative to the point @@ -13,17 +20,40 @@ export type PointLabelPosition = 'top' | 'bottom' | 'left' | 'right' | 'center'; /** * Get a point from a data value and a scale. - * @note for categorical scales, the point will be centered within the band. - * @note for log scales, zero and negative values are clamped to a small positive value. - * @param data - the data value. - * @param scale - the scale function. - * @returns the pixel value (defaulting to 0 if data value is not defined in scale). + * + * @param dataValue - The data value to convert to a pixel position. + * @param scale - The scale function. + * @param anchor (@default 'middle') - For band scales, where to anchor the point within the band. + * @returns The pixel value (@default 0 if data value is not defined in scale). */ -export const getPointOnScale = (dataValue: number, scale: ChartScaleFunction): number => { +export const getPointOnScale = ( + dataValue: number, + scale: ChartScaleFunction, + anchor: PointAnchor = 'middle', +): number => { if (isCategoricalScale(scale)) { - const bandStart = scale(dataValue) ?? 0; - const bandwidth = scale.bandwidth() ?? 0; - return bandStart + bandwidth / 2; + const bandScale = scale; + const bandStart = bandScale(dataValue); + if (bandStart === undefined) return 0; + + const bandwidth = bandScale.bandwidth?.() ?? 0; + const step = bandScale.step?.() ?? bandwidth; + const paddingOffset = (step - bandwidth) / 2; + const stepStart = bandStart - paddingOffset; + + switch (anchor) { + case 'stepStart': + return stepStart; + case 'bandStart': + return bandStart; + case 'bandEnd': + return bandStart + bandwidth; + case 'stepEnd': + return stepStart + step; + case 'middle': + default: + return bandStart + bandwidth / 2; + } } // For log scales, ensure the value is positive diff --git a/packages/web-visualization/src/chart/utils/scale.ts b/packages/web-visualization/src/chart/utils/scale.ts index 21c2a33ef..ebc0fb496 100644 --- a/packages/web-visualization/src/chart/utils/scale.ts +++ b/packages/web-visualization/src/chart/utils/scale.ts @@ -65,6 +65,19 @@ export const getCategoricalScale = ({ const scale = scaleBand() .domain(domainArray) .range([range.min, range.max]) - .padding(padding); + .paddingInner(padding) + .paddingOuter(padding / 2); return scale; }; + +/** + * Anchor position for points on a scale. Currently used only for band scales. + * + * For band scales, this determines where within the band to position a point: + * - `'stepStart'` - At the start of the step + * - `'bandStart'` - At the start of the band + * - `'middle'` - At the center of the band + * - `'bandEnd'` - At the end of the band + * - `'stepEnd'` - At the end of the step + */ +export type PointAnchor = 'stepStart' | 'bandStart' | 'middle' | 'bandEnd' | 'stepEnd';