diff --git a/.claude/skills/deprecate-cds-api/SKILL.md b/.claude/skills/deprecate-cds-api/SKILL.md index 790e017302..f7b55de354 100644 --- a/.claude/skills/deprecate-cds-api/SKILL.md +++ b/.claude/skills/deprecate-cds-api/SKILL.md @@ -9,7 +9,7 @@ description: | Also use when replacing a component or hook and sunsetting the old one. Always finish by running `yarn nx run :lint` on modified packages so `internal/deprecated-jsdoc-has-removal-version` passes. allowed-tools: Read, Grep, Glob, StrReplace, Bash(yarn nx run:*) -argument-hint: ' — replacement — [@deprecationExpectedRemoval major e.g. v10] — [optional notes]' +argument-hint: ' — replacement — [@deprecationExpectedRemoval major e.g. v11] — [optional notes]' --- # Deprecate CDS public API @@ -20,7 +20,7 @@ Automate the standard CDS deprecation workflow for symbols exported from `packag 1. **What is being deprecated?** Component name, hook, prop, or other exported symbol. 2. **What should consumers use instead?** The replacement must be named in JSDoc and in docs `warning` text. -3. **Which major should `@deprecationExpectedRemoval` use?** (e.g. `v9`, `v10`.) **Ask the user to confirm** if they have not already stated it. If they want a default, **suggest** the next major from the relevant `package.json` (see Step 2) and confirm they accept it before editing. +3. **Which major should `@deprecationExpectedRemoval` use?** (e.g. `v11`.) **Ask the user to confirm** if they have not already stated it. If they want a default, **suggest** the earliest allowed removal major from Step 2 (current major + 2) and confirm they accept it before editing. --- @@ -49,14 +49,15 @@ Use the **standard JSDoc tag `@deprecated`** (not `@deprecate`). * …existing description if any… * * @deprecated . This will be removed in a future major release. - * @deprecationExpectedRemoval v + * @deprecationExpectedRemoval v */ ``` Rules: - The `@deprecated` line must end with exactly: `This will be removed in a future major release.` (same sentence as the rest of the deprecation message, as in existing CDS examples). -- `@deprecationExpectedRemoval` must match `v` + version (e.g. `v9` or `v9.0.0`; full semver is allowed by ESLint). +- `@deprecationExpectedRemoval` must match `v` + version (e.g. `v11` or `v11.0.0`; full semver is allowed by ESLint). +- **`v`** is the earliest allowed removal major per Step 2 (full major undisturbed). Never use `v` for a new deprecation. The repo’s ESLint rule **`internal/deprecated-jsdoc-has-removal-version`** (`libs/eslint-plugin-internal`) enforces the prose ending and the presence of `@deprecationExpectedRemoval`; **lint must pass** after edits (see **Step 6**). @@ -64,14 +65,34 @@ The repo’s ESLint rule **`internal/deprecated-jsdoc-has-removal-version`** (`l ## Step 2 — Removal version for `@deprecationExpectedRemoval` -The tag must satisfy `@deprecationExpectedRemoval v…` as enforced by ESLint (e.g. `v10` or `v10.0.0`). +The tag must satisfy `@deprecationExpectedRemoval v…` as enforced by ESLint (e.g. `v11` or `v11.0.0`). -1. **Confirm with the user** which major **`N`** to use, unless they already specified it in **Inputs** (e.g. “remove in v10” → use `v10`). -2. **Default suggestion** when the user wants a recommendation: read the **`version`** field from the relevant `package.json` and set **`N = current major + 1`**. - - **`packages/web`**, **`packages/mobile`**, and **`packages/common`** always share the same semver — read **`version`** from any one of them (e.g. `8.60.0` → suggest **`v9`**). -3. After agreeing on **`N`**, use **`@deprecationExpectedRemoval v`** everywhere for this deprecation (same **Step 3**). +### Policy — full major undisturbed (required) -Do **not** assume the default without checking—either the user names **`N`**, or they accept the suggested next-major after you show the current **`version`**. +Deprecated APIs must remain available for **one full major version undisturbed** before they may be removed. + +That means: if you deprecate while shipping major **`M`**, the deprecation must still be present throughout **all of major `M+1`**, and the **earliest** allowed removal is major **`M+2`**. + +Do **not** set `@deprecationExpectedRemoval` to the next major (`M+1`). Removal in `M+1` would give consumers **zero** undisturbed major in which the API is only deprecated (not yet removed). + +Examples (read `version` from `packages/web`, `packages/mobile`, or `packages/common` — they share semver): + +| Current package version | Current major `M` | Earliest `@deprecationExpectedRemoval` | +| ----------------------- | ----------------- | -------------------------------------- | +| `9.14.0` | `9` | **`v11`** (must survive all of v10) | +| `10.0.0` | `10` | **`v12`** (must survive all of v11) | + +Never suggest or apply `v(M+1)` as the removal target for a newly introduced deprecation. + +### Choosing `N` + +1. **Confirm with the user** which major **`N`** to use, unless they already specified it in **Inputs** (e.g. “remove in v11” → use `v11`). +2. **Default suggestion** when the user wants a recommendation: read the **`version`** field from the relevant `package.json` and set **`N = current major + 2`** (the earliest allowed under the policy above). + - Example: `9.14.0` → suggest **`v11`**, not `v10`. +3. If the user asks for an earlier major than `M+2`, **refuse that default**, restate the full-major-undisturbed policy, and only proceed with a lower `N` if they explicitly override after that warning. +4. After agreeing on **`N`**, use **`@deprecationExpectedRemoval v`** everywhere for this deprecation (same **Step 3**). + +Do **not** assume the default without checking—either the user names **`N`**, or they accept the suggested **`M+2`** major after you show the current **`version`**. --- @@ -123,7 +144,7 @@ Use the same `{replacement}` phrasing as in JSDoc. If the replacement is not a s - [ ] Every **public export path** across packages that expose the symbol has been found (Step 0) and carries deprecation (implementation and re-exports as needed). - [ ] `@deprecated` includes replacement guidance and the exact closing sentence about future major removal. -- [ ] **`@deprecationExpectedRemoval v`** matches the **confirmed** removal major (Step 2), not an unverified default. +- [ ] **`@deprecationExpectedRemoval v`** matches the **confirmed** removal major (Step 2), and **`N` is at least current major + 2** unless the user explicitly overrode the full-major-undisturbed policy after a warning. - [ ] **Web + mobile** implementations and metadata (when applicable) are updated; nothing skipped because the symbol was “only” defined in common or another package. - [ ] `warning` in metadata matches the replacement story: **this component is deprecated** for component docs, **this hook is deprecated** for hook docs (`apps/docs/docs/hooks/`). - [ ] **`yarn nx run :lint`** has been run for every touched project (**Step 6**) and passes. diff --git a/.claude/skills/research.deprecation-usage/SKILL.md b/.claude/skills/research.deprecation-usage/SKILL.md index 6cc7921262..8ced6cd225 100644 --- a/.claude/skills/research.deprecation-usage/SKILL.md +++ b/.claude/skills/research.deprecation-usage/SKILL.md @@ -14,6 +14,8 @@ description: | Your objective is to provide information to user about the extent to which deprecated members of CDS are used in customer repositories. This information should be as accurate as possible as it will be used to inform decisions on whether or not it is safe to drop certain exports in a release or hold them for the next major version. +**Removal readiness policy:** a deprecated API must remain for **one full major version undisturbed** before removal. If it was deprecated during major `M`, it must survive all of `M+1`; the earliest removal major is `M+2` (see `@deprecationExpectedRemoval` and `.claude/skills/deprecate-cds-api/SKILL.md`). + Follow the ## 1 - Determining Research Scope diff --git a/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx b/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx index e18f00c94c..e6acefeafd 100644 --- a/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx +++ b/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx @@ -542,11 +542,11 @@ function DraggablePriceTarget() { y={yPixel} /> setTextDimensions(dimensions)} + repositionAxes="none" verticalAlignment="middle" x={drawingArea.x + padding + dragIconSize + iconGap + trendArrowIconSize} y={yPixel + 1} diff --git a/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx b/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx index 1bba914cd2..a05a478320 100644 --- a/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx +++ b/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx @@ -792,12 +792,12 @@ function MatchupBeaconLabels() { return ( ``` +### Tick Label Overflow + +Use `tickLabelOverflow` to control how tick labels behave at the chart edges. + +```jsx + + + + +``` + ### Label You can add a label to the axis using the `label` prop. diff --git a/apps/docs/docs/components/charts/XAxis/_webExamples.mdx b/apps/docs/docs/components/charts/XAxis/_webExamples.mdx index 4106d8b28b..77d2133b4d 100644 --- a/apps/docs/docs/components/charts/XAxis/_webExamples.mdx +++ b/apps/docs/docs/components/charts/XAxis/_webExamples.mdx @@ -1,4 +1,4 @@ -## Basic Example +## Basics The XAxis component provides a horizontal axis for charts with automatic tick generation and labeling. @@ -235,7 +235,7 @@ For band scales, you can set the category padding to adjust the spacing between ## Axis Props -Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. +Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, `tickLabelOverflow`, and `tickMarkSize`. ### Position @@ -742,6 +742,58 @@ If no data is set for the axis, it will receive the regular number value of the ``` +### Tick Label Overflow + +Use `tickLabelOverflow` to control how tick labels behave at the chart edges. + +```jsx live +function TickLabelOverflowExample() { + const tickLabelOverflowModes = [ + { id: 'reposition', label: 'Reposition' }, + { id: 'fade', label: 'Fade' }, + { id: 'visible', label: 'Visible' }, + ]; + const [selectedTickLabelOverflow, setSelectedTickLabelOverflow] = useState( + tickLabelOverflowModes[0], + ); + + return ( + + + + Tick Label Overflow + + + + + + + + + + + ); +} +``` + ### Label You can add a label to the axis using the `label` prop. diff --git a/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx b/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx index 153c0fc898..a18a7d208b 100644 --- a/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx @@ -1,4 +1,4 @@ -## Basic Example +## Basics The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. @@ -113,7 +113,7 @@ You can pass in either an object (AxisBounds) with `min` and `max` properties (b ## Axis Props -Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. +Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, `tickLabelOverflow`, and `tickMarkSize`. ### Position @@ -444,6 +444,30 @@ You can customize the tick labels using the `tickLabelFormatter` prop. ``` +### Tick Label Overflow + +Use `tickLabelOverflow` to control how tick labels behave at the chart edges. + +```jsx + + + + +``` + ### Label You can add a label to the axis using the `label` prop. diff --git a/apps/docs/docs/components/charts/YAxis/_webExamples.mdx b/apps/docs/docs/components/charts/YAxis/_webExamples.mdx index 559ff4a900..9945289a4b 100644 --- a/apps/docs/docs/components/charts/YAxis/_webExamples.mdx +++ b/apps/docs/docs/components/charts/YAxis/_webExamples.mdx @@ -1,4 +1,4 @@ -## Basic Example +## Basics The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. @@ -107,7 +107,7 @@ You can pass in either an object (AxisBounds) with `min` and `max` properties (b ## Axis Props -Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. +Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, `tickLabelOverflow`, and `tickMarkSize`. ### Position @@ -430,6 +430,63 @@ You can customize the tick labels using the `tickLabelFormatter` prop. ``` +### Tick Label Overflow + +Use `tickLabelOverflow` to control how tick labels behave at the chart edges. + +```jsx live +function TickLabelOverflowExample() { + const tickLabelOverflowModes = [ + { id: 'reposition', label: 'Reposition' }, + { id: 'fade', label: 'Fade' }, + { id: 'visible', label: 'Visible' }, + ]; + const [selectedTickLabelOverflow, setSelectedTickLabelOverflow] = useState( + tickLabelOverflowModes[0], + ); + + return ( + + + + Tick Label Overflow + + + + + + + + + + + ); +} +``` + ### Label You can add a label to the axis using the `label` prop. diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index ae0ef08baa..61dd374f94 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.14.0 ((8/11/2026, 11:48 AM PST)) + +This is an artificial version bump with no new change. + ## 9.13.0 ((8/11/2026, 09:52 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index b26cbf22a1..b810b7e1de 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "9.13.0", + "version": "9.14.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 8a419e93e6..2f3296e5d7 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 9.14.0 ((8/11/2026, 11:48 AM PST)) + +This is an artificial version bump with no new change. + ## 9.13.0 ((8/11/2026, 09:52 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 6d159dea5d..3ba4b89908 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "9.13.0", + "version": "9.14.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 391b8861a5..a43f9acf91 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 9.14.0 (8/11/2026 PST) + +#### 🚀 Updates + +- Feat: support different tick label overflow- #835. [[#835](https://github.com/coinbase/cds/pull/835)] + ## 9.13.0 (8/11/2026 PST) #### 🚀 Updates diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b721ba58ff..66f0f4b861 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "9.13.0", + "version": "9.14.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/visualizations/chart/axis/Axis.tsx b/packages/mobile/src/visualizations/chart/axis/Axis.tsx index 05ec9c6774..01a75512bb 100644 --- a/packages/mobile/src/visualizations/chart/axis/Axis.tsx +++ b/packages/mobile/src/visualizations/chart/axis/Axis.tsx @@ -29,10 +29,14 @@ export type AxisTickLabelComponentProps = Pick< | 'horizontalAlignment' | 'verticalAlignment' | 'opacity' + | 'repositionAxes' + | 'disableRepositioning' >; export type AxisTickLabelComponent = React.FC; +export type AxisTickLabelOverflow = 'reposition' | 'fade' | 'visible'; + export type AxisBaseProps = { /** * Placement of grid lines relative to each band. @@ -132,6 +136,15 @@ export type AxisBaseProps = { * Prevents the step from being larger than this value. */ tickMaxStep?: number; + /** + * How tick labels behave at the chart edges. + * - `reposition` — nudge labels to stay in bounds on both axes. + * - `fade` — add a fade effect to the labels at the edges (vertical for y-axis, horizontal for x-axis), + * reposition on opposite axis (horizontal for y-axis, vertical for x-axis). + * - `visible` — leave labels at their natural positions with no fading. + * @default 'reposition' + */ + tickLabelOverflow?: AxisTickLabelOverflow; }; export type AxisProps = AxisBaseProps & { diff --git a/packages/mobile/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx b/packages/mobile/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx new file mode 100644 index 0000000000..4eb3fce8bf --- /dev/null +++ b/packages/mobile/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx @@ -0,0 +1,52 @@ +import { memo, type PropsWithChildren, useMemo } from 'react'; +import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; +import { Group, LinearGradient, Rect, vec } from '@shopify/react-native-skia'; + +import { useCartesianChartContext } from '../ChartProvider'; + +const axisTickLabelOverflowFadeWidth = 30; +const maskOpaque = 'rgba(255, 255, 255, 1)'; +const maskClear = 'rgba(255, 255, 255, 0)'; + +export type AxisTickLabelOverflowMaskProps = SharedProps & + PropsWithChildren<{ + /** + * Fade along the chart edges for this axis (`x`: left/right, `y`: top/bottom). + */ + axis: 'x' | 'y'; + }>; + +export const AxisTickLabelOverflowMask = memo( + ({ axis, children }) => { + const { width: chartWidth, height: chartHeight } = useCartesianChartContext(); + const fadeWidth = axisTickLabelOverflowFadeWidth; + const isXAxis = axis === 'x'; + + const fadeX = Math.min(fadeWidth, chartWidth / 2) / Math.max(chartWidth, 1); + const fadeY = Math.min(fadeWidth, chartHeight / 2) / Math.max(chartHeight, 1); + + const colors = useMemo(() => [maskClear, maskOpaque, maskOpaque, maskClear], []); + const positions = useMemo( + () => (isXAxis ? [0, fadeX, 1 - fadeX, 1] : [0, fadeY, 1 - fadeY, 1]), + [fadeX, fadeY, isXAxis], + ); + + if (chartWidth <= 0 || chartHeight <= 0) { + return {children}; + } + + return ( + + {children} + + + + + ); + }, +); diff --git a/packages/mobile/src/visualizations/chart/axis/XAxis.tsx b/packages/mobile/src/visualizations/chart/axis/XAxis.tsx index bb8ce04a10..eec11adb40 100644 --- a/packages/mobile/src/visualizations/chart/axis/XAxis.tsx +++ b/packages/mobile/src/visualizations/chart/axis/XAxis.tsx @@ -13,6 +13,7 @@ import { getPointOnScale } from '../utils/point'; import { type CategoricalScale, isCategoricalScale } from '../utils/scale'; import { type AxisBaseProps, type AxisProps } from './Axis'; +import { AxisTickLabelOverflowMask } from './AxisTickLabelOverflowMask'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; const AXIS_HEIGHT = 32; @@ -64,6 +65,7 @@ export const XAxis = memo( height = label ? AXIS_HEIGHT + LABEL_SIZE : AXIS_HEIGHT, bandGridLinePlacement = 'edges', bandTickMarkPlacement = 'middle', + tickLabelOverflow = 'reposition', ...props }) => { const theme = useTheme(); @@ -78,6 +80,7 @@ export const XAxis = memo( unregisterAxis, getAxisBounds, } = useCartesianChartContext(); + const fadeTickLabels = tickLabelOverflow === 'fade'; const xScale = getXScale(axisId); const xAxis = getXAxis(axisId); @@ -232,6 +235,8 @@ export const XAxis = memo( color: theme.color.fgMuted, verticalAlignment: 'middle', horizontalAlignment: 'center', + repositionAxes: + tickLabelOverflow === 'visible' ? 'none' : fadeTickLabels ? 'y' : 'both', }, }; }); @@ -244,6 +249,8 @@ export const XAxis = memo( tickMarkSize, position, formatTick, + fadeTickLabels, + tickLabelOverflow, ]); if (!xScale || !axisBounds) return; @@ -278,14 +285,6 @@ export const XAxis = memo( ))} )} - {chartTextData && ( - - )} {axisBounds && showTickMarks && ( {tickMarkPositions.map(({ x, key }) => ( @@ -316,8 +315,32 @@ export const XAxis = memo( strokeWidth={1} /> )} + {chartTextData && + (fadeTickLabels ? ( + + + + ) : ( + + ))} {label && ( - + {label} )} diff --git a/packages/mobile/src/visualizations/chart/axis/YAxis.tsx b/packages/mobile/src/visualizations/chart/axis/YAxis.tsx index 0b470737eb..6ca8dd6ae0 100644 --- a/packages/mobile/src/visualizations/chart/axis/YAxis.tsx +++ b/packages/mobile/src/visualizations/chart/axis/YAxis.tsx @@ -13,6 +13,7 @@ import { getPointOnScale } from '../utils/point'; import { type CategoricalScale, isCategoricalScale } from '../utils/scale'; import { type AxisBaseProps, type AxisProps } from './Axis'; +import { AxisTickLabelOverflowMask } from './AxisTickLabelOverflowMask'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; const AXIS_WIDTH = 44; @@ -62,6 +63,7 @@ export const YAxis = memo( width = label ? AXIS_WIDTH + LABEL_SIZE : AXIS_WIDTH, bandGridLinePlacement = 'edges', bandTickMarkPlacement = 'middle', + tickLabelOverflow = 'reposition', ...props }) => { const theme = useTheme(); @@ -76,6 +78,7 @@ export const YAxis = memo( unregisterAxis, getAxisBounds, } = useCartesianChartContext(); + const fadeTickLabels = tickLabelOverflow === 'fade'; const yScale = getYScale(axisId); const yAxis = getYAxis(axisId); @@ -220,6 +223,8 @@ export const YAxis = memo( color: theme.color.fgMuted, verticalAlignment: 'middle', horizontalAlignment: position === 'left' ? 'right' : 'left', + repositionAxes: + tickLabelOverflow === 'visible' ? 'none' : fadeTickLabels ? 'x' : 'both', }, }; }); @@ -232,6 +237,8 @@ export const YAxis = memo( position, formatTick, theme.color.fgMuted, + fadeTickLabels, + tickLabelOverflow, ]); if (!yScale || !axisBounds) return; @@ -266,14 +273,6 @@ export const YAxis = memo( ))} )} - {chartTextData && ( - - )} {axisBounds && showTickMarks && ( {tickMarkPositions.map(({ y, key }) => ( @@ -304,6 +303,24 @@ export const YAxis = memo( strokeWidth={1} /> )} + {chartTextData && + (fadeTickLabels ? ( + + + + ) : ( + + ))} {label && ( ( > ); -const Simple = () => { - const data = [ - { - name: 'Page A', - uv: 4000, - pv: 2400, - amt: 2400, - }, - { - name: 'Page B', - uv: 3000, - pv: 1398, - amt: 2210, - }, - { - name: 'Page C', - uv: 2000, - pv: 9800, - amt: 2290, - }, - { - name: 'Page D', - uv: 2780, - pv: 3908, - amt: 2000, - }, - { - name: 'Page E', - uv: 1890, - pv: 4800, - amt: 2181, - }, - { - name: 'Page F', - uv: 2390, - pv: 3800, - amt: 2500, - }, - { - name: 'Page G', - uv: 3490, - pv: 4300, - amt: 2100, - }, - ]; +const data = [ + { + name: 'Page A', + uv: 4000, + pv: 2400, + amt: 2400, + }, + { + name: 'Page B', + uv: 3000, + pv: 1398, + amt: 2210, + }, + { + name: 'Page C', + uv: 2000, + pv: 9800, + amt: 2290, + }, + { + name: 'Page D', + uv: 2780, + pv: 3908, + amt: 2000, + }, + { + name: 'Page E', + uv: 1890, + pv: 4800, + amt: 2181, + }, + { + name: 'Page F', + uv: 2390, + pv: 3800, + amt: 2500, + }, + { + name: 'Page G', + uv: 3490, + pv: 4300, + amt: 2100, + }, +]; +const Simple = () => { const pageViews = data.map((d) => d.pv); const pageNames = data.map((d) => d.name); const pageUniqueVisitors = data.map((d) => d.uv); @@ -500,70 +503,219 @@ const LineChartOnBandScale = ({ ); }; -const AxisStories = () => { +const tickLabelOverflowModes: AxisTickLabelOverflow[] = ['reposition', 'fade', 'visible']; +const ordinalLabels = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']; +const overflowSeriesData = [10, 22, 29, 45, 98, 45, 22]; + +const TickLabelOverflowYAxes = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + + + ); +}; + +const TickLabelOverflowXAxes = () => { + return ( + + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + + ); }; -export default AxisStories; +const TickLabelOverflowAllSides = () => { + const theme = useTheme(); + + return ( + + + + + + ); +}; + +type ExampleItem = { + title: string; + component: ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { title: 'Basic', component: }, + { title: 'Time of Day', component: }, + { title: 'Multiple Axes on Same Side', component: }, + { title: 'Strict Domain Limit', component: }, + { title: 'Nice Domain Limit', component: }, + { + title: 'Band Axis Grid Alignment', + component: ( + + + + + + + + + ), + }, + { title: 'Band Scale - Tick Filtering', component: }, + { title: 'Band Scale - Explicit Ticks', component: }, + { + title: 'Line Chart on Band Scale - Grid Positions', + component: ( + <> + + + + + + ), + }, + { title: 'Axes on All Sides', component: }, + { title: 'Custom Tick Mark Sizes', component: }, + { title: 'Y Axis Label Repositioning', component: }, + { title: 'X Axis Label Repositioning', component: }, + { + title: 'Ineffective Repositioning', + component: , + }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev + 1) % examples.length); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + {currentExample.component} + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile/src/visualizations/chart/axis/index.ts b/packages/mobile/src/visualizations/chart/axis/index.ts index 0cfeeddf32..49d7ada7e2 100644 --- a/packages/mobile/src/visualizations/chart/axis/index.ts +++ b/packages/mobile/src/visualizations/chart/axis/index.ts @@ -1,5 +1,6 @@ // codegen:start {preset: barrel, include: ./*.tsx, exclude: ./__stories__/*.tsx} export * from './Axis'; +export * from './AxisTickLabelOverflowMask'; export * from './DefaultAxisTickLabel'; export * from './XAxis'; export * from './YAxis'; diff --git a/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx index 2e02c3fb72..170f976be5 100644 --- a/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx +++ b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx @@ -28,6 +28,7 @@ export type ReferenceLineLabelComponentProps = Pick< | 'background' | 'borderRadius' | 'disableRepositioning' + | 'repositionAxes' | 'horizontalAlignment' | 'verticalAlignment' | 'font' diff --git a/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 4c6572e760..cbfe5034be 100644 --- a/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -37,13 +37,13 @@ export const DefaultScrubberBeaconLabel = memo( const theme = useTheme(); return ( diff --git a/packages/mobile/src/visualizations/chart/text/ChartText.tsx b/packages/mobile/src/visualizations/chart/text/ChartText.tsx index 4477722b3a..51b4b52121 100644 --- a/packages/mobile/src/visualizations/chart/text/ChartText.tsx +++ b/packages/mobile/src/visualizations/chart/text/ChartText.tsx @@ -63,6 +63,12 @@ export type TextHorizontalAlignment = 'left' | 'center' | 'right'; */ export type TextVerticalAlignment = 'top' | 'middle' | 'bottom'; +/** + * Which axes ChartText may nudge labels along to stay in bounds. + * @default 'both' + */ +export type ChartTextRepositionAxes = 'x' | 'y' | 'both' | 'none'; + export type ChartTextBaseProps = { /** * The text color. @@ -79,7 +85,19 @@ export type ChartTextBaseProps = { */ elevated?: boolean; /** - * When true, disables automatic repositioning to fit within bounds. + * Which axes to reposition labels on when they would overflow bounds. + * @default 'both' + */ + repositionAxes?: ChartTextRepositionAxes; + /** + * Disables automatic repositioning to fit within bounds. + * + * @note `true` is equivalent to `repositionAxes='none'`, `false`/omit equals `repositionAxes='both'`. + * + * @deprecated Use `repositionAxes` instead + * + * This will be removed in a future major release. + * @deprecationExpectedRemoval v11 */ disableRepositioning?: boolean; /** @@ -131,12 +149,12 @@ export type ChartTextProps = ChartTextBaseProps & { children: ChartTextChildren; /** * The desired x position in pixels. - * @note Text will be automatically positioned to fit within bounds unless `disableRepositioning` is true. + * @note Text will be automatically positioned to fit within bounds based on `repositionAxes`. */ x: AnimatedProp; /** * The desired y position in pixels. - * @note Text will be automatically positioned to fit within bounds unless `disableRepositioning` is true. + * @note Text will be automatically positioned to fit within bounds based on `repositionAxes`. */ y: AnimatedProp; /** @@ -215,6 +233,7 @@ export const ChartText = memo( verticalAlignment = 'middle', paragraphAlignment = TextAlign.Left, disableRepositioning = false, + repositionAxes = disableRepositioning ? 'none' : 'both', bounds, color, background: backgroundProp, @@ -357,9 +376,8 @@ export const ChartText = memo( ); const overflowAmount = useDerivedValue(() => { - if (disableRepositioning) { - return { x: 0, y: 0 }; - } + const repositionX = repositionAxes === 'both' || repositionAxes === 'x'; + const repositionY = repositionAxes === 'both' || repositionAxes === 'y'; const parentBounds = bounds ?? fullChartBounds; if (!parentBounds || parentBounds.width <= 0 || parentBounds.height <= 0) { @@ -369,34 +387,36 @@ export const ChartText = memo( let offsetX = 0; let offsetY = 0; - // X-axis overflow - if (backgroundRect.value.x < parentBounds.x) { - offsetX = parentBounds.x - backgroundRect.value.x; - } else if ( - backgroundRect.value.x + backgroundRect.value.width > - parentBounds.x + parentBounds.width - ) { - offsetX = - parentBounds.x + - parentBounds.width - - (backgroundRect.value.x + backgroundRect.value.width); + if (repositionX) { + if (backgroundRect.value.x < parentBounds.x) { + offsetX = parentBounds.x - backgroundRect.value.x; + } else if ( + backgroundRect.value.x + backgroundRect.value.width > + parentBounds.x + parentBounds.width + ) { + offsetX = + parentBounds.x + + parentBounds.width - + (backgroundRect.value.x + backgroundRect.value.width); + } } - // Y-axis overflow - if (backgroundRect.value.y < parentBounds.y) { - offsetY = parentBounds.y - backgroundRect.value.y; - } else if ( - backgroundRect.value.y + backgroundRect.value.height > - parentBounds.y + parentBounds.height - ) { - offsetY = - parentBounds.y + - parentBounds.height - - (backgroundRect.value.y + backgroundRect.value.height); + if (repositionY) { + if (backgroundRect.value.y < parentBounds.y) { + offsetY = parentBounds.y - backgroundRect.value.y; + } else if ( + backgroundRect.value.y + backgroundRect.value.height > + parentBounds.y + parentBounds.height + ) { + offsetY = + parentBounds.y + + parentBounds.height - + (backgroundRect.value.y + backgroundRect.value.height); + } } return { x: offsetX, y: offsetY }; - }, [backgroundRect, fullChartBounds, bounds, disableRepositioning]); + }, [backgroundRect, fullChartBounds, bounds, repositionAxes]); // Final adjusted positions const backgroundRectWithOffset = useDerivedValue(() => { diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 5fbc8da6c8..734a33d5df 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 9.14.0 (8/11/2026 PST) + +#### 🚀 Updates + +- Feat: support different tick label overflow- #835. [[#835](https://github.com/coinbase/cds/pull/835)] + ## 9.13.0 ((8/11/2026, 09:52 AM PST)) This is an artificial version bump with no new change. diff --git a/packages/web/package.json b/packages/web/package.json index e73760e279..377864f2c8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "9.13.0", + "version": "9.14.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/visualizations/chart/axis/Axis.tsx b/packages/web/src/visualizations/chart/axis/Axis.tsx index ba6bb114b2..0e76e49741 100644 --- a/packages/web/src/visualizations/chart/axis/Axis.tsx +++ b/packages/web/src/visualizations/chart/axis/Axis.tsx @@ -44,6 +44,7 @@ export type AxisTickLabelComponentProps = Pick< | 'inset' | 'background' | 'borderRadius' + | 'repositionAxes' | 'disableRepositioning' | 'bounds' | 'styles' @@ -56,6 +57,8 @@ export type AxisTickLabelComponentProps = Pick< export type AxisTickLabelComponent = React.FC; +export type AxisTickLabelOverflow = 'reposition' | 'fade' | 'visible'; + export type AxisBaseProps = SharedProps & { /** * Placement of grid lines relative to each band. @@ -155,6 +158,15 @@ export type AxisBaseProps = SharedProps & { * Prevents the step from being larger than this value. */ tickMaxStep?: number; + /** + * How tick labels behave at the chart edges. + * - `reposition` — nudge labels to stay in bounds on both axes. + * - `fade` — add a fade effect to the labels at the edges (vertical for y-axis, horizontal for x-axis), + * reposition on opposite axis (horizontal for y-axis, vertical for x-axis). + * - `visible` — leave labels at their natural positions with no fading. + * @default 'reposition' + */ + tickLabelOverflow?: AxisTickLabelOverflow; }; export type AxisProps = AxisBaseProps & { diff --git a/packages/web/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx b/packages/web/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx new file mode 100644 index 0000000000..06e20baa37 --- /dev/null +++ b/packages/web/src/visualizations/chart/axis/AxisTickLabelOverflowMask.tsx @@ -0,0 +1,88 @@ +import { memo, useId, type PropsWithChildren } from 'react'; +import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; + +import { useCartesianChartContext } from '../ChartProvider'; + +const axisTickLabelOverflowFadeWidth = 30; + +export type AxisTickLabelOverflowMaskProps = SharedProps & + PropsWithChildren<{ + /** + * Fade along the chart edges for this axis (`x`: left/right, `y`: top/bottom). + */ + axis: 'x' | 'y'; + }>; + +/** + * Masks tick-label children so they fade at the chart/SVG outer edges + * without painting over other axes. + */ +export const AxisTickLabelOverflowMask = memo( + ({ axis, children, testID }) => { + const { width: chartWidth, height: chartHeight } = useCartesianChartContext(); + const reactId = useId(); + const maskId = `${reactId}-mask`; + const fadeWidth = axisTickLabelOverflowFadeWidth; + const isXAxis = axis === 'x'; + + if (chartWidth <= 0 || chartHeight <= 0) { + return children; + } + + const fadeX = Math.min(fadeWidth, chartWidth / 2) / chartWidth; + const fadeY = Math.min(fadeWidth, chartHeight / 2) / chartHeight; + + return ( + + + {isXAxis ? ( + + + + + + + ) : ( + + + + + + + )} + + + + + {children} + + ); + }, +); diff --git a/packages/web/src/visualizations/chart/axis/XAxis.tsx b/packages/web/src/visualizations/chart/axis/XAxis.tsx index 3386886c04..817902e32b 100644 --- a/packages/web/src/visualizations/chart/axis/XAxis.tsx +++ b/packages/web/src/visualizations/chart/axis/XAxis.tsx @@ -20,6 +20,7 @@ import { axisTickMarkStyles, axisUpdateAnimationTransition, } from './Axis'; +import { AxisTickLabelOverflowMask } from './AxisTickLabelOverflowMask'; import { DefaultAxisTickLabel } from './DefaultAxisTickLabel'; const AXIS_HEIGHT = 32; @@ -83,6 +84,7 @@ export const XAxis = memo( testID = 'x-axis', bandGridLinePlacement = 'edges', bandTickMarkPlacement = 'middle', + tickLabelOverflow = 'reposition', ...props }) => { const registrationId = useId(); @@ -96,6 +98,7 @@ export const XAxis = memo( getAxisBounds, drawingArea, } = useCartesianChartContext(); + const fadeTickLabels = tickLabelOverflow === 'fade'; const xScale = getXScale(axisId); const xAxis = getXAxis(axisId); @@ -251,6 +254,8 @@ export const XAxis = memo( verticalAlignment: 'middle', style: styles?.tickLabel, horizontalAlignment: 'center', + repositionAxes: + tickLabelOverflow === 'visible' ? 'none' : fadeTickLabels ? 'y' : 'both', }, }; }); @@ -264,6 +269,8 @@ export const XAxis = memo( formatTick, classNames?.tickLabel, styles?.tickLabel, + fadeTickLabels, + tickLabelOverflow, ]); if (!xScale || !axisBounds || !drawingArea) return; @@ -320,14 +327,24 @@ export const XAxis = memo( )} )} - {chartTextData && ( - - )} + {chartTextData && + (fadeTickLabels ? ( + + + + ) : ( + + ))} {axisBounds && showTickMarks && ( {tickMarkPositions.map(({ x, key }) => @@ -385,9 +402,9 @@ export const XAxis = memo( )} {label && ( ( testID = 'y-axis', bandGridLinePlacement = 'edges', bandTickMarkPlacement = 'middle', + tickLabelOverflow = 'reposition', ...props }) => { const registrationId = useId(); @@ -94,6 +96,7 @@ export const YAxis = memo( getAxisBounds, drawingArea, } = useCartesianChartContext(); + const fadeTickLabels = tickLabelOverflow === 'fade'; const yScale = getYScale(axisId); const yAxis = getYAxis(axisId); @@ -235,6 +238,8 @@ export const YAxis = memo( verticalAlignment: 'middle', style: styles?.tickLabel, horizontalAlignment: position === 'left' ? 'right' : 'left', + repositionAxes: + tickLabelOverflow === 'visible' ? 'none' : fadeTickLabels ? 'x' : 'both', }, }; }); @@ -248,6 +253,8 @@ export const YAxis = memo( formatTick, classNames?.tickLabel, styles?.tickLabel, + fadeTickLabels, + tickLabelOverflow, ]); if (!yScale || !axisBounds || !drawingArea) return; @@ -304,14 +311,24 @@ export const YAxis = memo( )} )} - {chartTextData && ( - - )} + {chartTextData && + (fadeTickLabels ? ( + + + + ) : ( + + ))} {showTickMarks && ( {tickMarkPositions.map(({ y, key }) => @@ -376,9 +393,9 @@ export const YAxis = memo( }} > ); -const Simple = () => { - const data = [ - { - name: 'Page A', - uv: 4000, - pv: 2400, - amt: 2400, - }, - { - name: 'Page B', - uv: 3000, - pv: 1398, - amt: 2210, - }, - { - name: 'Page C', - uv: 2000, - pv: 9800, - amt: 2290, - }, - { - name: 'Page D', - uv: 2780, - pv: 3908, - amt: 2000, - }, - { - name: 'Page E', - uv: 1890, - pv: 4800, - amt: 2181, - }, - { - name: 'Page F', - uv: 2390, - pv: 3800, - amt: 2500, - }, - { - name: 'Page G', - uv: 3490, - pv: 4300, - amt: 2100, - }, - ]; +const data = [ + { + name: 'Page A', + uv: 4000, + pv: 2400, + amt: 2400, + }, + { + name: 'Page B', + uv: 3000, + pv: 1398, + amt: 2210, + }, + { + name: 'Page C', + uv: 2000, + pv: 9800, + amt: 2290, + }, + { + name: 'Page D', + uv: 2780, + pv: 3908, + amt: 2000, + }, + { + name: 'Page E', + uv: 1890, + pv: 4800, + amt: 2181, + }, + { + name: 'Page F', + uv: 2390, + pv: 3800, + amt: 2500, + }, + { + name: 'Page G', + uv: 3490, + pv: 4300, + amt: 2100, + }, +]; +const Simple = () => { const pageViews = data.map((d) => d.pv); const pageNames = data.map((d) => d.name); const pageUniqueVisitors = data.map((d) => d.uv); @@ -499,6 +499,115 @@ const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { ); }; +const tickLabelOverflowModes: AxisTickLabelOverflow[] = ['reposition', 'fade', 'visible']; +const ordinalLabels = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']; +const overflowSeriesData = [10, 22, 29, 45, 98, 45, 22]; + +const TickLabelOverflowYAxes = () => ( + + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + + +); + +const TickLabelOverflowXAxes = () => ( + + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + {tickLabelOverflowModes.map((tickLabelOverflow) => ( + + ))} + + +); + +const TickLabelOverflowAllSides = () => ( + + + + + +); + export const All = () => { return ( @@ -548,6 +657,15 @@ export const All = () => { + + + + + + + + + ); diff --git a/packages/web/src/visualizations/chart/axis/index.ts b/packages/web/src/visualizations/chart/axis/index.ts index 0cfeeddf32..49d7ada7e2 100644 --- a/packages/web/src/visualizations/chart/axis/index.ts +++ b/packages/web/src/visualizations/chart/axis/index.ts @@ -1,5 +1,6 @@ // codegen:start {preset: barrel, include: ./*.tsx, exclude: ./__stories__/*.tsx} export * from './Axis'; +export * from './AxisTickLabelOverflowMask'; export * from './DefaultAxisTickLabel'; export * from './XAxis'; export * from './YAxis'; diff --git a/packages/web/src/visualizations/chart/line/ReferenceLine.tsx b/packages/web/src/visualizations/chart/line/ReferenceLine.tsx index bbab465263..5ccf5560c0 100644 --- a/packages/web/src/visualizations/chart/line/ReferenceLine.tsx +++ b/packages/web/src/visualizations/chart/line/ReferenceLine.tsx @@ -33,6 +33,7 @@ export type ReferenceLineLabelComponentProps = Pick< | 'inset' | 'background' | 'borderRadius' + | 'repositionAxes' | 'disableRepositioning' | 'styles' | 'classNames' diff --git a/packages/web/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx b/packages/web/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx index 95f8e413a7..6df032675b 100644 --- a/packages/web/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx +++ b/packages/web/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx @@ -291,11 +291,11 @@ const DraggableReferenceLine = memo( y={yPixel} /> setTextDimensions(dimensions)} + repositionAxes="none" verticalAlignment="middle" x={drawingArea.x + padding + dragIconSize + iconGap + trendArrowIconSize} y={yPixel + 1} diff --git a/packages/web/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/web/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 9bec51b91f..891d34cad2 100644 --- a/packages/web/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/web/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -39,13 +39,13 @@ export const DefaultScrubberBeaconLabel = memo( return ( ( return ( ( {teamLabel} ( verticalAlignment = 'middle', dx, dy, - disableRepositioning, + disableRepositioning = false, + repositionAxes = disableRepositioning ? 'none' : 'both', bounds, opacity, testID, @@ -194,7 +211,7 @@ export const ChartText = memo( const textRef = useRef(null); const [textBBox, setTextBBox] = useState(null); - const isDimensionsReady = disableRepositioning || textRef.current !== null; + const isDimensionsReady = repositionAxes === 'none' || textRef.current !== null; const backgroundRectDimensions = useMemo(() => { if (!textBBox) { @@ -211,9 +228,8 @@ export const ChartText = memo( }, [textBBox, insetInput]); const overflowAmount = useMemo(() => { - if (disableRepositioning) { - return { x: 0, y: 0 }; - } + const repositionX = repositionAxes === 'both' || repositionAxes === 'x'; + const repositionY = repositionAxes === 'both' || repositionAxes === 'y'; const parentBounds = bounds ?? fullChartBounds; if ( @@ -225,35 +241,39 @@ export const ChartText = memo( return { x: 0, y: 0 }; } - let x = 0; - let y = 0; + let nextX = 0; + let nextY = 0; - if (backgroundRectDimensions.x < parentBounds.x) { - x = parentBounds.x - backgroundRectDimensions.x; // positive = shift right - } else if ( - backgroundRectDimensions.x + backgroundRectDimensions.width > - parentBounds.x + parentBounds.width - ) { - x = - parentBounds.x + - parentBounds.width - - (backgroundRectDimensions.x + backgroundRectDimensions.width); // negative = shift left + if (repositionX) { + if (backgroundRectDimensions.x < parentBounds.x) { + nextX = parentBounds.x - backgroundRectDimensions.x; + } else if ( + backgroundRectDimensions.x + backgroundRectDimensions.width > + parentBounds.x + parentBounds.width + ) { + nextX = + parentBounds.x + + parentBounds.width - + (backgroundRectDimensions.x + backgroundRectDimensions.width); + } } - if (backgroundRectDimensions.y < parentBounds.y) { - y = parentBounds.y - backgroundRectDimensions.y; // positive = shift down - } else if ( - backgroundRectDimensions.y + backgroundRectDimensions.height > - parentBounds.y + parentBounds.height - ) { - y = - parentBounds.y + - parentBounds.height - - (backgroundRectDimensions.y + backgroundRectDimensions.height); // negative = shift up + if (repositionY) { + if (backgroundRectDimensions.y < parentBounds.y) { + nextY = parentBounds.y - backgroundRectDimensions.y; + } else if ( + backgroundRectDimensions.y + backgroundRectDimensions.height > + parentBounds.y + parentBounds.height + ) { + nextY = + parentBounds.y + + parentBounds.height - + (backgroundRectDimensions.y + backgroundRectDimensions.height); + } } - return { x, y }; - }, [backgroundRectDimensions, fullChartBounds, bounds, disableRepositioning]); + return { x: nextX, y: nextY }; + }, [backgroundRectDimensions, fullChartBounds, bounds, repositionAxes]); // Compose the final reported rect including any overflow translation applied const reportedRect = useMemo(() => {