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';