diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md deleted file mode 100644 index d0fd67d8efb2e..0000000000000 --- a/patches/victory-native/details.md +++ /dev/null @@ -1,43 +0,0 @@ -# victory-native patches - -## 001+fix-rotated-label-bounds-check - -- **Patch:** [victory-native+41.20.2+001+fix-rotated-label-bounds-check.patch](victory-native+41.20.2+001+fix-rotated-label-bounds-check.patch) -- **Issue:** https://github.com/Expensify/App/issues/80970 -- **PR:** https://github.com/Expensify/App/pull/80967 - -**Problem:** Victory Native's XAxis component calculates label bounds using the unrotated text width, even when `labelRotate` is specified. This causes labels near the chart edges to be incorrectly hidden when rotated. - -For example, at 90° rotation: -- Actual horizontal extent = font height (~14px) -- Victory's bounds check uses = text width (could be 50-100px+) - -This results in labels being hidden even though they would visually fit. - -**Fix:** Calculate the actual horizontal extent of rotated labels using the formula: -``` -rotatedWidth = textWidth * |cos(angle)| + fontSize * |sin(angle)| -``` - -Use this `rotatedLabelWidth` for the bounds check (`canFitLabelContent`) while preserving the original `labelWidth` for positioning and rotation origin calculations. - -## 002+add-label-overflow-prop - -- **Patch:** [victory-native+41.20.2+002+add-label-overflow-prop.patch](victory-native+41.20.2+002+add-label-overflow-prop.patch) - -**Problem:** Victory Native's XAxis component applies a `canFitLabelContent` bounds check that hides labels near chart edges. When consumers already control label visibility via `formatXLabel` (returning `''` for skipped labels), this creates double-filtering — labels are hidden both by the consumer's skip logic and by Victory's bounds check. This causes non-uniform gaps and missing end labels. - -**Fix:** Add a `labelOverflow` prop to `XAxisInputProps`: -- `"hidden"` (default) — current behavior, bounds check active -- `"visible"` — skip the `canFitLabelContent` check, render all labels with non-empty text - -When `labelOverflow` is `"visible"`, the rendering condition changes from: -```typescript -font && labelWidth && canFitLabelContent -``` -to: -```typescript -font && labelWidth && (labelOverflow === "visible" || canFitLabelContent) -``` - -Labels with empty text (`formatXLabel` returning `''`) still get hidden naturally because `labelWidth` evaluates to `0` (falsy). This means the consumer's skip logic remains the sole visibility filter, eliminating double-filtering. diff --git a/patches/victory-native/victory-native+41.20.2+001+fix-rotated-label-bounds-check.patch b/patches/victory-native/victory-native+41.20.2+001+fix-rotated-label-bounds-check.patch deleted file mode 100644 index 35093d2ec3edc..0000000000000 --- a/patches/victory-native/victory-native+41.20.2+001+fix-rotated-label-bounds-check.patch +++ /dev/null @@ -1,29 +0,0 @@ -diff --git a/node_modules/victory-native/src/cartesian/components/XAxis.tsx b/node_modules/victory-native/src/cartesian/components/XAxis.tsx -index 6d83472..a6e2ed0 100644 ---- a/node_modules/victory-native/src/cartesian/components/XAxis.tsx -+++ b/node_modules/victory-native/src/cartesian/components/XAxis.tsx -@@ -63,13 +63,22 @@ export const XAxis = < - font - ?.getGlyphWidths?.(font.getGlyphIDs(contentX)) - .reduce((sum, value) => sum + value, 0) ?? 0; -+ -+ // Calculate actual horizontal extent accounting for rotation for bounds checking -+ // For a rotated rectangle: width * |cos(angle)| + height * |sin(angle)| -+ const rotateRad = (Math.PI / 180) * (labelRotate ?? 0); -+ const cosAngle = Math.abs(Math.cos(rotateRad)); -+ const sinAngle = Math.abs(Math.sin(rotateRad)); -+ const rotatedLabelWidth = labelWidth * cosAngle + fontSize * sinAngle; -+ - const labelX = xScale(tick) - (labelWidth ?? 0) / 2; -+ const rotatedLabelX = xScale(tick) - rotatedLabelWidth / 2; - const canFitLabelContent = - xScale(tick) >= chartBounds.left && - xScale(tick) <= chartBounds.right && - (yAxisSide === "left" -- ? labelX + labelWidth < chartBounds.right -- : chartBounds.left < labelX); -+ ? rotatedLabelX + rotatedLabelWidth < chartBounds.right -+ : chartBounds.left < rotatedLabelX); - - const labelY = (() => { - // bottom, outset diff --git a/patches/victory-native/victory-native+41.20.2+002+add-label-overflow-prop.patch b/patches/victory-native/victory-native+41.20.2+002+add-label-overflow-prop.patch deleted file mode 100644 index 232ed798110fb..0000000000000 --- a/patches/victory-native/victory-native+41.20.2+002+add-label-overflow-prop.patch +++ /dev/null @@ -1,65 +0,0 @@ -diff --git a/node_modules/victory-native/dist/types.d.ts b/node_modules/victory-native/dist/types.d.ts -index efb5207..df7bbba 100644 ---- a/node_modules/victory-native/dist/types.d.ts -+++ b/node_modules/victory-native/dist/types.d.ts -@@ -177,8 +177,9 @@ export type XAxisInputProps, XK extends - yAxisSide?: YAxisSide; - linePathEffect?: DashPathEffectComponent; - enableRescaling?: boolean; -+ labelOverflow?: "hidden" | "visible"; - }; --export type XAxisPropsWithDefaults, XK extends keyof InputFields> = Required, "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate">> & Partial, "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate">>; -+export type XAxisPropsWithDefaults, XK extends keyof InputFields> = Required, "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate" | "labelOverflow">> & Partial, "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate" | "labelOverflow">>; - export type XAxisProps, XK extends keyof InputFields> = XAxisPropsWithDefaults & { - xScale: Scale; - yScale: Scale; -diff --git a/node_modules/victory-native/src/cartesian/components/XAxis.tsx b/node_modules/victory-native/src/cartesian/components/XAxis.tsx -index a6e2ed0..628abab 100644 ---- a/node_modules/victory-native/src/cartesian/components/XAxis.tsx -+++ b/node_modules/victory-native/src/cartesian/components/XAxis.tsx -@@ -41,6 +41,7 @@ export const XAxis = < - linePathEffect, - chartBounds, - enableRescaling, -+ labelOverflow, - zoom, - }: XAxisProps) => { - const xScale = zoom ? zoom.rescaleX(xScaleProp) : xScaleProp; -@@ -146,7 +147,7 @@ export const XAxis = < - - - ) : null} -- {font && labelWidth && canFitLabelContent ? ( -+ {font && labelWidth && (labelOverflow === "visible" || canFitLabelContent) ? ( - - = Required< - Omit< - XAxisInputProps, -- "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate" -+ "font" | "tickValues" | "linePathEffect" | "enableRescaling" | "labelRotate" | "labelOverflow" - > - > & - Partial< -@@ -220,6 +221,7 @@ export type XAxisPropsWithDefaults< - | "linePathEffect" - | "enableRescaling" - | "labelRotate" -+ | "labelOverflow" - > - >; - diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index de7de768e707f..d563e4bae0463 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,13 +1,14 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {useSharedValue} from 'react-native-reanimated'; -import type {ChartBounds, PointsArray, Scale} from 'victory-native'; +import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; @@ -42,105 +43,92 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); const [barAreaWidth, setBarAreaWidth] = useState(0); + const [boundsLeft, setBoundsLeft] = useState(0); + const [boundsRight, setBoundsRight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; - // prepare data for display - const chartData = useMemo(() => { - return data.map((point, index) => ({ - x: index, - y: point.total, - })); - }, [data]); + const chartData = data.map((point, index) => ({ + x: index, + y: point.total, + })); const yAxisDomain = useDynamicYDomain(data); - // Handle bar press callback - const handleBarPress = useCallback( - (index: number) => { - if (index < 0 || index >= data.length) { - return; - } - const dataPoint = data.at(index); - if (dataPoint && onBarPress) { - onBarPress(dataPoint, index); - } - }, - [data, onBarPress], - ); + const handleBarPress = (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onBarPress) { + onBarPress(dataPoint, index); + } + }; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { setChartWidth(event.nativeEvent.layout.width); - }, []); + }; + + const domainPadding = (() => { + if (chartWidth === 0) { + return BASE_DOMAIN_PADDING; + } + const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); + return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; + })(); + + const totalDomainPadding = domainPadding.left + domainPadding.right; + const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0; const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ data, font, tickSpacing: barAreaWidth > 0 ? barAreaWidth / data.length : 0, labelAreaWidth: barAreaWidth, + firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, + lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, }); - const domainPadding = useMemo(() => { - if (chartWidth === 0) { - return BASE_DOMAIN_PADDING; - } - const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; - }, [chartWidth, data.length]); - - const {formatLabel, formatValue} = useChartLabelFormats({ + const {formatValue} = useChartLabelFormats({ data, font, unit: yAxisUnit, unitPosition: yAxisUnitPosition, - labelSkipInterval, - labelRotation, - truncatedLabels, }); - // Store bar geometry for hit-testing (only constants, no arrays) const barWidth = useSharedValue(0); const chartBottom = useSharedValue(0); const yZero = useSharedValue(0); - const handleChartBoundsChange = useCallback( - (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; - barWidth.set(calculatedBarWidth); - chartBottom.set(bounds.bottom); - yZero.set(0); - setBarAreaWidth(domainWidth); - }, - [data.length, barWidth, chartBottom, yZero], - ); - - const handleScaleChange = useCallback( - (_xScale: Scale, yScale: Scale) => { - yZero.set(yScale(0)); - }, - [yZero], - ); - - const checkIsOverBar = useCallback( - (args: HitTestArgs) => { - 'worklet'; - - const currentBarWidth = barWidth.get(); - const currentYZero = yZero.get(); - if (currentBarWidth === 0) { - return false; - } - const barLeft = args.targetX - currentBarWidth / 2; - const barRight = args.targetX + currentBarWidth / 2; - // For positive bars: targetY < yZero, bar goes from targetY (top) to yZero (bottom) - // For negative bars: targetY > yZero, bar goes from yZero (top) to targetY (bottom) - const barTop = Math.min(args.targetY, currentYZero); - const barBottom = Math.max(args.targetY, currentYZero); + const handleChartBoundsChange = (bounds: ChartBounds) => { + const domainWidth = bounds.right - bounds.left; + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; + barWidth.set(calculatedBarWidth); + chartBottom.set(bounds.bottom); + yZero.set(0); + setBarAreaWidth(domainWidth); + setBoundsLeft(bounds.left); + setBoundsRight(bounds.right); + }; + + const handleScaleChange = (_xScale: Scale, yScale: Scale) => { + yZero.set(yScale(0)); + }; + + const checkIsOverBar = (args: HitTestArgs) => { + 'worklet'; + + const currentBarWidth = barWidth.get(); + const currentYZero = yZero.get(); + if (currentBarWidth === 0) { + return false; + } + const barLeft = args.targetX - currentBarWidth / 2; + const barRight = args.targetX + currentBarWidth / 2; + const barTop = Math.min(args.targetY, currentYZero); + const barBottom = Math.max(args.targetY, currentYZero); - return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; - }, - [barWidth, yZero], - ); + return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; + }; const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, @@ -151,35 +139,45 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const tooltipData = useTooltipData(activeDataIndex, data, formatValue); - const renderBar = useCallback( - (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { - const dataIndex = point.xValue as number; - const dataPoint = data.at(dataIndex); - const barColor = useSingleColor ? defaultBarColor : getChartColor(dataIndex); + const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { + const dataIndex = point.xValue as number; + const dataPoint = data.at(dataIndex); + const barColor = useSingleColor ? defaultBarColor : getChartColor(dataIndex); - return ( - - ); - }, - [data, useSingleColor, defaultBarColor], - ); + return ( + + ); + }; - // When labels are rotated 90°, add measured label height to container - // This keeps bar area at ~250px while giving labels their needed vertical space - const dynamicChartStyle = useMemo( - () => ({ - height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0), - }), - [xAxisLabelHeight], - ); + const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + if (!font || xAxisLabelHeight === undefined) { + return null; + } + return ( + + ); + }; + + const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { return ( @@ -206,24 +204,17 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni {chartWidth > 0 && ( { - return data.map((point, index) => ({ - x: index, - y: point.total, - })); - }, [data]); - - const handlePointPress = useCallback( - (index: number) => { - if (index < 0 || index >= data.length) { - return; - } - const dataPoint = data.at(index); - if (dataPoint && onPointPress) { - onPointPress(dataPoint, index); - } - }, - [data, onPointPress], - ); + const chartData = data.map((point, index) => ({ + x: index, + y: point.total, + })); + + const handlePointPress = (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onPointPress) { + onPointPress(dataPoint, index); + } + }; - const handleLayout = useCallback((event: LayoutChangeEvent) => { + const handleLayout = (event: LayoutChangeEvent) => { setChartWidth(event.nativeEvent.layout.width); - }, []); + }; - const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { + const handleChartBoundsChange = (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); - }, []); + setBoundsLeft(bounds.left); + setBoundsRight(bounds.right); + }; - // Calculate dynamic domain padding for centered labels - // Optimize by reducing wasted space when edge labels are shorter than tick spacing - const domainPadding = useMemo(() => { + const domainPadding = (() => { if (chartWidth === 0 || data.length === 0) { return BASE_DOMAIN_PADDING; } const geometricPadding = calculateMinDomainPadding(chartWidth, data.length); - // Without font, use geometric padding (safe fallback) if (!font) { return {...BASE_DOMAIN_PADDING, left: geometricPadding, right: geometricPadding}; } - // Measure edge labels to see if we can reduce padding const firstLabelWidth = measureTextWidth(data.at(0)?.label ?? '', font); const lastLabelWidth = measureTextWidth(data.at(-1)?.label ?? '', font); - // At 0° rotation, centered labels extend by half their width const firstLabelNeeds = firstLabelWidth / 2; const lastLabelNeeds = lastLabelWidth / 2; - // How much space is wasted on each side const wastedLeft = geometricPadding - firstLabelNeeds; const wastedRight = geometricPadding - lastLabelNeeds; const reclaimablePadding = Math.min(wastedLeft, wastedRight); - // Only reduce if both sides have excess space (labels short enough for 0°) - // If reclaimablePadding <= 0, labels are too long and hook will use rotation/truncation - const shouldUseExtraPadding = reclaimablePadding > 0; - const horizontalPadding = Math.max(shouldUseExtraPadding ? geometricPadding - reclaimablePadding : geometricPadding, MIN_SAFE_PADDING); + if (reclaimablePadding <= 0) { + return {...BASE_DOMAIN_PADDING, left: geometricPadding, right: geometricPadding}; + } - // If shouldUseExtraPadding is true then we have to add the extra padding to the right so the label is not clipped - return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding + (shouldUseExtraPadding ? MIN_SAFE_PADDING : 0)}; - }, [chartWidth, data, font]); + const horizontalPadding = Math.max(geometricPadding - reclaimablePadding, MIN_SAFE_PADDING); + return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; + })(); - // For centered labels, tick spacing is evenly distributed across the plot area (same as BarChart) const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; + const totalDomainPadding = domainPadding.left + domainPadding.right; + const paddingScale = plotAreaWidth > 0 ? plotAreaWidth / (plotAreaWidth + totalDomainPadding) : 0; + const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ data, font, tickSpacing, labelAreaWidth: plotAreaWidth, + firstTickLeftSpace: boundsLeft + domainPadding.left * paddingScale, + lastTickRightSpace: chartWidth > 0 ? chartWidth - boundsRight + domainPadding.right * paddingScale : 0, allowTightDiagonalPacking: true, }); - // Measure label widths for custom positioning in `renderOutside` - const labelWidths = useMemo(() => { - if (!font) { - return [] as number[]; - } - return truncatedLabels.map((label) => measureTextWidth(label, font)); - }, [font, truncatedLabels]); - - // Convert hook's degree rotation to radians for Skia rendering - const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; - const {formatValue} = useChartLabelFormats({ data, font, @@ -139,13 +125,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn unitPosition: yAxisUnitPosition, }); - const checkIsOverDot = useCallback((args: HitTestArgs) => { + const checkIsOverDot = (args: HitTestArgs) => { 'worklet'; const dx = args.cursorX - args.targetX; const dy = args.cursorY - args.targetY; return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; - }, []); + }; const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, @@ -154,91 +140,38 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const tooltipData = useTooltipData(activeDataIndex, data, formatValue); - // Custom x-axis labels with hybrid positioning: - // - At 0° (horizontal): center label under the point (like bar chart) - // - At 45° (rotated): right-align so the last character is under the point - const renderOutsideComponents = useCallback( - (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { - const fontMetrics = font?.getMetrics(); - const ascent = fontMetrics ? Math.abs(fontMetrics.ascent) : 0; - const descent = fontMetrics ? Math.abs(fontMetrics.descent) : 0; - const labelY = fontMetrics ? args.chartBounds.bottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) : 0; - - const xLabels = font - ? truncatedLabels.map((label, i) => { - if (i % labelSkipInterval !== 0) { - return null; - } - - const tickX = args.xScale(i); - const labelWidth = labelWidths.at(i) ?? 0; - - // At 0°: center the label under the point (like bar chart) - // At 45°: right-align so the last character is under the point - if (angleRad === 0) { - return ( - - ); - } - - const textX = tickX - labelWidth; // right-aligned for rotated labels - const origin = vec(tickX, labelY); - - // Rotate around the anchor, then translate to correct for ascent/descent - // asymmetry (ascent > descent shifts the visual center left of the anchor). - const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); - - return ( - - - - ); - }) - : null; - - return ( - <> - - ) => { + return ( + <> + + + {!!font && xAxisLabelHeight !== undefined && ( + - {xLabels} - - ); - }, - [font, truncatedLabels, labelSkipInterval, labelWidths, angleRad, theme.textSupporting, theme.border], - ); + )} + + ); + }; - const dynamicChartStyle = useMemo( - () => ({ - height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0), - }), - [xAxisLabelHeight], - ); + const labelSpace = AXIS_LABEL_GAP + (xAxisLabelHeight ?? 0); + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const chartPadding = {...CHART_PADDING, bottom: labelSpace + CHART_PADDING.bottom}; if (isLoading || !font) { return ( @@ -265,13 +198,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn {chartWidth > 0 && ( number; + + /** Y-pixel coordinate of the bottom edge of the chart plot area. */ + chartBoundsBottom: number; + + /** When true, rotated labels are centered on the tick. When false, they are right-aligned (end of text at tick). */ + centerRotatedLabels?: boolean; +}; + +function ChartXAxisLabels({labels, labelRotation, labelSkipInterval, font, labelColor, xScale, chartBoundsBottom, centerRotatedLabels = false}: ChartXAxisLabelsProps) { + const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + + const fontMetrics = font.getMetrics(); + const ascent = Math.abs(fontMetrics.ascent); + const descent = Math.abs(fontMetrics.descent); + const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); + + const labelWidths = useMemo(() => { + return labels.map((label) => measureTextWidth(label, font)); + }, [labels, font]); + + // Centered labels extend upward by (maxWidth/2)*sin(angle) from the anchor; + // push the anchor down so the top of the bounding box clears chartBoundsBottom. + const centeredUpwardOffset = centerRotatedLabels && angleRad > 0 ? (Math.max(...labelWidths) / 2) * Math.sin(angleRad) : 0; + const labelY = chartBoundsBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset; + + return labels.map((label, i) => { + if (i % labelSkipInterval !== 0 || label.length === 0) { + return null; + } + + const tickX = xScale(i); + const labelWidth = labelWidths.at(i) ?? 0; + + if (angleRad === 0) { + return ( + + ); + } + + const textX = centerRotatedLabels ? tickX - labelWidth / 2 : tickX - labelWidth; + const origin = vec(tickX, labelY); + + return ( + + + + ); + }); +} + +export default ChartXAxisLabels; +export type {ChartXAxisLabelsProps}; diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 13a780a35c217..8a952af70b641 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -4,8 +4,8 @@ const Y_AXIS_TICK_COUNT = 5; /** Desired visual gap (px) between axis labels and the chart edge, used for both axes */ const AXIS_LABEL_GAP = 12; -/** Chart padding */ -const CHART_PADDING = 5; +/** Base chart padding applied to all sides */ +const CHART_PADDING = {top: 5, left: 5, right: 5, bottom: 5}; /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; @@ -19,4 +19,34 @@ const Y_AXIS_LINE_WIDTH = 1; /** Starting angle for pie chart (0 = 3 o'clock, -90 = 12 o'clock) */ const PIE_CHART_START_ANGLE = -90; -export {Y_AXIS_TICK_COUNT, AXIS_LABEL_GAP, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, PIE_CHART_START_ANGLE}; +/** Supported label rotation angles in degrees */ +const LABEL_ROTATIONS = { + HORIZONTAL: 0, + DIAGONAL: 45, + VERTICAL: 90, +} as const; + +const SIN_45 = Math.sin(Math.PI / 4); + +/** Minimum gap between adjacent labels (px) */ +const LABEL_PADDING = 4; + +const ELLIPSIS = '...'; + +/** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ +const MIN_TRUNCATED_CHARS = 10; + +export { + Y_AXIS_TICK_COUNT, + AXIS_LABEL_GAP, + CHART_PADDING, + CHART_CONTENT_MIN_HEIGHT, + X_AXIS_LINE_WIDTH, + Y_AXIS_LINE_WIDTH, + PIE_CHART_START_ANGLE, + LABEL_ROTATIONS, + SIN_45, + LABEL_PADDING, + ELLIPSIS, + MIN_TRUNCATED_CHARS, +}; diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 3c6ab6f71cca7..e9c2d84b2c037 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,7 +1,7 @@ import type {SkFont} from '@shopify/react-native-skia'; -import type {ChartDataPoint, UnitPosition, UnitWithFallback} from '@components/Charts/types'; +import {LABEL_ROTATIONS} from '@components/Charts/constants'; +import type {ChartDataPoint, LabelRotation, UnitPosition, UnitWithFallback} from '@components/Charts/types'; import useLocalize from '@hooks/useLocalize'; -import {LABEL_ROTATIONS} from './useChartLabelLayout'; type UseChartLabelFormatsProps = { data: ChartDataPoint[]; @@ -9,7 +9,7 @@ type UseChartLabelFormatsProps = { unit?: UnitWithFallback | string; unitPosition?: UnitPosition; labelSkipInterval?: number; - labelRotation?: number; + labelRotation?: LabelRotation; truncatedLabels?: string[]; }; @@ -60,7 +60,7 @@ export default function useChartLabelFormats({data, font, unit, unitPosition = ' return ''; } - const sourceToUse = labelRotation === -LABEL_ROTATIONS.VERTICAL || !truncatedLabels ? data.map((p) => p.label) : truncatedLabels; + const sourceToUse = labelRotation === LABEL_ROTATIONS.VERTICAL || !truncatedLabels ? data.map((p) => p.label) : truncatedLabels; return sourceToUse.at(index) ?? ''; }; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 78564dfc131cc..bd3fe4e383fd2 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,159 +1,119 @@ import type {SkFont} from '@shopify/react-native-skia'; -import {useMemo} from 'react'; -import type {ChartDataPoint} from '@components/Charts/types'; -import {measureTextWidth} from '@components/Charts/utils'; - -/** Supported label rotation angles in degrees */ -const LABEL_ROTATIONS = { - HORIZONTAL: 0, - DIAGONAL: 45, - VERTICAL: 90, -} as const; - -const SIN_45 = Math.sin(Math.PI / 4); - -/** Minimum gap between adjacent labels (px) */ -const LABEL_PADDING = 4; - -const ELLIPSIS = '...'; - -/** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ -const MIN_TRUNCATED_CHARS = 10; +import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, MIN_TRUNCATED_CHARS, SIN_45} from '@components/Charts/constants'; +import type {ChartDataPoint, LabelRotation} from '@components/Charts/types'; +import {edgeLabelsFit, edgeMaxLabelWidth, effectiveHeight, effectiveWidth, maxVisibleCount, measureTextWidth, truncateLabel} from '@components/Charts/utils'; type LabelLayoutConfig = { + /** Chart data points whose labels will be laid out. */ data: ChartDataPoint[]; + + /** Skia font used for measuring label text widths. */ font: SkFont | null; + + /** Distance in pixels between adjacent tick marks. */ tickSpacing: number; + + /** Total width in pixels of the plot area where labels are rendered. */ labelAreaWidth: number; - /** When true, allows tighter label packing at 45° by accounting for vertical offset. Useful for line charts. */ - allowTightDiagonalPacking?: boolean; -}; -/** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ -function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string { - if (labelWidth <= maxWidth) { - return label; - } - const available = maxWidth - ellipsisWidth; - if (available <= 0) { - return ELLIPSIS; - } - const maxChars = Math.max(1, Math.floor(label.length * (available / labelWidth))); - return label.slice(0, maxChars) + ELLIPSIS; -} + /** Pixels from first tick to left edge of canvas. Defaults to Infinity (no constraint). */ + firstTickLeftSpace?: number; -/** Horizontal footprint of a label at a given rotation angle. */ -function effectiveWidth(labelWidth: number, lineHeight: number, rotation: number): number { - if (rotation === LABEL_ROTATIONS.VERTICAL) { - return lineHeight; - } - if (rotation === LABEL_ROTATIONS.DIAGONAL) { - return labelWidth * SIN_45; - } - return labelWidth; -} + /** Pixels from last tick to right edge of canvas. Defaults to Infinity (no constraint). */ + lastTickRightSpace?: number; -/** Vertical footprint of a label at a given rotation angle. */ -function effectiveHeight(labelWidth: number, lineHeight: number, rotation: number): number { - if (rotation === LABEL_ROTATIONS.VERTICAL) { - return labelWidth; - } - if (rotation === LABEL_ROTATIONS.DIAGONAL) { - return labelWidth * SIN_45 + lineHeight * SIN_45; - } - return lineHeight; -} - -/** How many labels fit side-by-side in `areaWidth` given each takes `itemWidth`. */ -function maxVisibleCount(areaWidth: number, itemWidth: number): number { - return Math.floor(areaWidth / (itemWidth + LABEL_PADDING)); -} + /** When true, allows tighter label packing at 45° by accounting for vertical offset between right-aligned labels. */ + allowTightDiagonalPacking?: boolean; +}; -/** - * Pick the smallest rotation (0 → 45 → 90) where labels don't overlap, - * preferring rotation over skip interval. - */ -function pickRotation( - maxLabelWidth: number, - lineHeight: number, - tickSpacing: number, - labelArea: number, - dataCount: number, - minTruncatedWidth: number, - allowTightDiagonalPacking: boolean, -): number { - // 0°: labels fit horizontally without truncation - const horizontalWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); - if (horizontalWidth + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, horizontalWidth) >= dataCount) { - return LABEL_ROTATIONS.HORIZONTAL; +function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTickLeftSpace = Infinity, lastTickRightSpace = Infinity, allowTightDiagonalPacking = false}: LabelLayoutConfig) { + if (!font || data.length === 0 || tickSpacing <= 0 || labelAreaWidth <= 0) { + return {labelRotation: LABEL_ROTATIONS.HORIZONTAL, labelSkipInterval: 1, truncatedLabels: [] as string[]}; } - // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks - // With tight packing, labels can overlap horizontally by lineHeight * sin(45°) due to vertical offset - const diagonalOverlap = allowTightDiagonalPacking ? lineHeight * SIN_45 : 0; - const minDiagonalWidth = minTruncatedWidth * SIN_45 - diagonalOverlap; - if (minDiagonalWidth + LABEL_PADDING <= tickSpacing) { - return LABEL_ROTATIONS.DIAGONAL; + const fontMetrics = font.getMetrics(); + const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); + const ellipsisWidth = measureTextWidth(ELLIPSIS, font); + const labelWidths = data.map((point) => measureTextWidth(point.label, font)); + const maxLabelWidth = Math.max(...labelWidths); + const firstLabelWidth = labelWidths.at(0) ?? 0; + const lastLabelWidth = labelWidths.at(-1) ?? 0; + const minTruncatedWidth = Math.max( + ...data.map((point, index) => { + if (point.label.length <= MIN_TRUNCATED_CHARS) { + return labelWidths.at(index) ?? 0; + } + return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + }), + ); + + const firstLabel = data.at(0)?.label ?? ''; + const lastLabel = data.at(-1)?.label ?? ''; + const firstMinTrunc = firstLabel.length <= MIN_TRUNCATED_CHARS ? firstLabelWidth : measureTextWidth(firstLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + const lastMinTrunc = lastLabel.length <= MIN_TRUNCATED_CHARS ? lastLabelWidth : measureTextWidth(lastLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + + // Pick rotation (prefer 0° → 45° → 90°) + let rotation: LabelRotation = LABEL_ROTATIONS.VERTICAL; + + const hWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); + const hFitsInTicks = hWidth + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelAreaWidth, hWidth) >= data.length; + const hEdgeFits = edgeLabelsFit({firstLabelWidth, lastLabelWidth, lineHeight, rotation: LABEL_ROTATIONS.HORIZONTAL, firstTickLeftSpace, lastTickRightSpace, rightAligned: false}); + + if (hFitsInTicks && hEdgeFits) { + rotation = LABEL_ROTATIONS.HORIZONTAL; + } else { + const diagonalOverlap = allowTightDiagonalPacking ? lineHeight * SIN_45 : 0; + const minDiagWidth = minTruncatedWidth * SIN_45 - diagonalOverlap; + const dFitsInTicks = minDiagWidth + LABEL_PADDING <= tickSpacing; + + const firstEdgeMax = edgeMaxLabelWidth(firstTickLeftSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'first'); + const lastEdgeMax = edgeMaxLabelWidth(lastTickRightSpace, lineHeight, LABEL_ROTATIONS.DIAGONAL, allowTightDiagonalPacking, 'last'); + const dEdgeFits = firstEdgeMax >= firstMinTrunc && lastEdgeMax >= lastMinTrunc; + + if (dFitsInTicks && dEdgeFits) { + rotation = LABEL_ROTATIONS.DIAGONAL; + } } - // 90°: fallback - return LABEL_ROTATIONS.VERTICAL; -} + // Truncate labels + const truncDiagonalOverlap = allowTightDiagonalPacking ? lineHeight : 0; + const tickMaxWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + truncDiagonalOverlap : Infinity; -function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, allowTightDiagonalPacking = false}: LabelLayoutConfig) { - return useMemo(() => { - if (!font || data.length === 0 || tickSpacing <= 0 || labelAreaWidth <= 0) { - return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: []}; - } + const finalLabels = data.map((point, index) => { + let maxWidth = tickMaxWidth; - const fontMetrics = font.getMetrics(); - const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); - const ellipsisWidth = measureTextWidth(ELLIPSIS, font); - const labelWidths = data.map((point) => measureTextWidth(point.label, font)); - const maxLabelLength = Math.max(...labelWidths); - - // Maximum width of labels after truncation to MIN_TRUNCATED_CHARS characters. - // Labels shorter than the threshold keep their full width (can't be truncated further). - const minTruncatedWidth = Math.max( - ...data.map((point, index) => { - if (point.label.length <= MIN_TRUNCATED_CHARS) { - return labelWidths.at(index) ?? 0; - } - return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); - }), - ); - - // 1. Pick rotation - const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, allowTightDiagonalPacking); - - // 2. Truncate labels (only at 45°) - // With tight packing, labels can be longer due to allowed horizontal overlap - const diagonalOverlap = allowTightDiagonalPacking ? lineHeight : 0; - const maxLabelWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + diagonalOverlap : Infinity; - const finalLabels = data.map((point, index) => { - return truncateLabel(point.label, labelWidths.at(index) ?? 0, maxLabelWidth, ellipsisWidth); - }); - - // 3. Compute skip interval (only at 90°) - const finalMaxWidth = Math.max(...finalLabels.map((label) => measureTextWidth(label, font))); - let skipInterval = 1; - if (rotation === LABEL_ROTATIONS.VERTICAL) { - const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation); - const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth); - skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount)); + if (index === 0) { + const edgeMax = edgeMaxLabelWidth(firstTickLeftSpace, lineHeight, rotation, allowTightDiagonalPacking, 'first'); + maxWidth = Math.min(maxWidth, edgeMax); + } + if (index === data.length - 1) { + const edgeMax = edgeMaxLabelWidth(lastTickRightSpace, lineHeight, rotation, allowTightDiagonalPacking, 'last'); + maxWidth = Math.min(maxWidth, edgeMax); } - // 4. Compute vertical space needed for x-axis labels - const xAxisLabelHeight = effectiveHeight(finalMaxWidth, lineHeight, rotation); + return truncateLabel(point.label, labelWidths.at(index) ?? 0, maxWidth, ellipsisWidth); + }); + + // Compute skip interval (only at 90°) + const finalWidths = finalLabels.map((label) => measureTextWidth(label, font)); + const finalMaxWidth = Math.max(...finalWidths); + let skipInterval = 1; + if (rotation === LABEL_ROTATIONS.VERTICAL) { + const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation); + const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth); + skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount)); + } + + // Compute vertical space needed for x-axis labels + const xAxisLabelHeight = effectiveHeight(finalMaxWidth, lineHeight, rotation); - return { - labelRotation: -rotation, - labelSkipInterval: skipInterval, - truncatedLabels: finalLabels, - xAxisLabelHeight, - }; - }, [font, tickSpacing, labelAreaWidth, data, allowTightDiagonalPacking]); + return { + labelRotation: rotation, + labelSkipInterval: skipInterval, + truncatedLabels: finalLabels, + xAxisLabelHeight, + }; } -export {LABEL_ROTATIONS, useChartLabelLayout}; +export {useChartLabelLayout}; export type {LabelLayoutConfig}; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 92b6bf0f41f41..72c7f008c49fd 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,4 +1,6 @@ +import type {ValueOf} from 'type-fest'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {LABEL_ROTATIONS} from './constants'; type ChartDataPoint = { /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ @@ -65,4 +67,6 @@ type PieSlice = { originalIndex: number; }; -export type {ChartDataPoint, ChartProps, CartesianChartProps, PieSlice, UnitPosition, UnitWithFallback}; +type LabelRotation = ValueOf; + +export type {ChartDataPoint, ChartProps, CartesianChartProps, LabelRotation, PieSlice, UnitPosition, UnitWithFallback}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index ac7bb158d6c33..07a2de4d7589b 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -1,6 +1,7 @@ import type {SkFont} from '@shopify/react-native-skia'; import colors from '@styles/theme/colors'; -import type {ChartDataPoint, PieSlice} from './types'; +import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from './constants'; +import type {ChartDataPoint, LabelRotation, PieSlice} from './types'; /** * Expensify Chart Color Palette. @@ -180,6 +181,109 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieS ).slices; } +/** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ +function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string { + if (labelWidth <= maxWidth) { + return label; + } + const available = maxWidth - ellipsisWidth; + if (available <= 0) { + return ELLIPSIS; + } + const maxChars = Math.max(1, Math.floor(label.length * (available / labelWidth))); + return label.slice(0, maxChars) + ELLIPSIS; +} + +/** Horizontal footprint of a label at a given rotation angle (for inter-tick overlap checks). */ +function effectiveWidth(labelWidth: number, lineHeight: number, rotation: LabelRotation): number { + if (rotation === LABEL_ROTATIONS.VERTICAL) { + return lineHeight; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + return labelWidth * SIN_45; + } + return labelWidth; +} + +/** Vertical footprint of a label at a given rotation angle. */ +function effectiveHeight(labelWidth: number, lineHeight: number, rotation: LabelRotation): number { + if (rotation === LABEL_ROTATIONS.VERTICAL) { + return labelWidth; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + return labelWidth * SIN_45 + lineHeight * SIN_45; + } + return lineHeight; +} + +/** How many labels fit side-by-side in `areaWidth` given each takes `itemWidth`. */ +function maxVisibleCount(areaWidth: number, itemWidth: number): number { + return Math.floor(areaWidth / (itemWidth + LABEL_PADDING)); +} + +/** + * How far a label extends beyond its tick position after rotation. + * Accounts for the rotatedLabelCenterCorrection translateX applied during rendering. + */ +function labelOverhang(labelWidth: number, lineHeight: number, rotation: LabelRotation, rightAligned: boolean): {left: number; right: number} { + if (rotation === LABEL_ROTATIONS.HORIZONTAL) { + return {left: labelWidth / 2, right: labelWidth / 2}; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + const halfLH = lineHeight / 2; + if (rightAligned) { + return { + left: (labelWidth + halfLH) * SIN_45, + right: halfLH * SIN_45, + }; + } + const overhang = (labelWidth / 2 + halfLH) * SIN_45; + return {left: overhang, right: overhang}; + } + return {left: lineHeight / 2, right: lineHeight / 2}; +} + +/** Check if first and last labels fit within the available canvas edge space. */ +function edgeLabelsFit({ + firstLabelWidth, + lastLabelWidth, + lineHeight, + rotation, + firstTickLeftSpace, + lastTickRightSpace, + rightAligned, +}: { + firstLabelWidth: number; + lastLabelWidth: number; + lineHeight: number; + rotation: LabelRotation; + firstTickLeftSpace: number; + lastTickRightSpace: number; + rightAligned: boolean; +}): boolean { + const first = labelOverhang(firstLabelWidth, lineHeight, rotation, rightAligned); + const last = labelOverhang(lastLabelWidth, lineHeight, rotation, rightAligned); + return first.left <= firstTickLeftSpace && last.right <= lastTickRightSpace; +} + +/** + * Maximum label width that fits within the available edge space at a given rotation. + * Returns Infinity when the overhang at that edge doesn't depend on label width. + */ +function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: LabelRotation, rightAligned: boolean, edge: 'first' | 'last'): number { + const halfLH = lineHeight / 2; + if (rotation === LABEL_ROTATIONS.HORIZONTAL) { + return 2 * edgeSpace; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + if (rightAligned) { + return edge === 'first' ? Math.max(0, edgeSpace / SIN_45 - halfLH) : Infinity; + } + return Math.max(0, 2 * (edgeSpace / SIN_45 - halfLH)); + } + return Infinity; +} + export { getChartColor, DEFAULT_CHART_COLOR, @@ -191,4 +295,11 @@ export { isAngleInSlice, findSliceAtPosition, processDataIntoSlices, + truncateLabel, + effectiveWidth, + effectiveHeight, + maxVisibleCount, + labelOverhang, + edgeLabelsFit, + edgeMaxLabelWidth, }; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 37bff622fc72f..9a75008d706fa 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -154,6 +154,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll, o const {preferredLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Users', 'CreditCard', 'Send', 'Folder', 'Basket', 'Tag', 'Calendar']); + const {titleIconName, getLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; const titleIcon = icons[titleIconName]; const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; diff --git a/tests/unit/Charts/useChartLabelFormats.test.ts b/tests/unit/components/Charts/useChartLabelFormats.test.ts similarity index 98% rename from tests/unit/Charts/useChartLabelFormats.test.ts rename to tests/unit/components/Charts/useChartLabelFormats.test.ts index f07cfbc9e1979..418f53671ef26 100644 --- a/tests/unit/Charts/useChartLabelFormats.test.ts +++ b/tests/unit/components/Charts/useChartLabelFormats.test.ts @@ -84,7 +84,7 @@ describe('formatLabel', () => { }); it('ignores truncatedLabels when rotation is vertical', () => { - const {result} = renderHook(() => useChartLabelFormats({data: SAMPLE_DATA, truncatedLabels: ['J', 'F'], labelRotation: -90})); + const {result} = renderHook(() => useChartLabelFormats({data: SAMPLE_DATA, truncatedLabels: ['J', 'F'], labelRotation: 90})); expect(result.current.formatLabel(0)).toBe('Jan'); expect(result.current.formatLabel(1)).toBe('Feb'); diff --git a/tests/unit/components/Charts/useChartLabelLayout.test.ts b/tests/unit/components/Charts/useChartLabelLayout.test.ts new file mode 100644 index 0000000000000..596a8c4978b67 --- /dev/null +++ b/tests/unit/components/Charts/useChartLabelLayout.test.ts @@ -0,0 +1,252 @@ +import type {SkFont} from '@shopify/react-native-skia'; +import {renderHook} from '@testing-library/react-native'; +import {SIN_45} from '@components/Charts/constants'; +import {useChartLabelLayout} from '@components/Charts/hooks/useChartLabelLayout'; +import type {ChartDataPoint} from '@components/Charts/types'; + +/** + * Each glyph = PX_PER_CHAR wide. This gives deterministic widths: + * "AAA" = 21px, "AAAAAA" = 42px, "A".repeat(16) = 112px, "..." = 21px + */ +const PX_PER_CHAR = 7; + +function createMockFont(): SkFont { + return { + getMetrics: () => ({ascent: -12, descent: 4, leading: 0}), + getGlyphIDs: (text: string) => [...text].map((_, i) => i + 1), + getGlyphWidths: (glyphIDs: number[]) => glyphIDs.map(() => PX_PER_CHAR), + getSize: () => 12, + } as unknown as SkFont; +} + +function makeData(...labels: string[]): ChartDataPoint[] { + return labels.map((label, i) => ({label, total: (i + 1) * 100})); +} + +const LINE_HEIGHT = 16; // |ascent(12)| + |descent(4)| + +describe('useChartLabelLayout', () => { + const font = createMockFont(); + + describe('early returns', () => { + const defaults = {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: []}; + + it('returns defaults when font is null', () => { + const {result} = renderHook(() => useChartLabelLayout({data: makeData('A', 'B'), font: null, tickSpacing: 50, labelAreaWidth: 100})); + expect(result.current).toEqual(defaults); + }); + + it('returns defaults when data is empty', () => { + const {result} = renderHook(() => useChartLabelLayout({data: [], font, tickSpacing: 50, labelAreaWidth: 100})); + expect(result.current).toEqual(defaults); + }); + + it('returns defaults when tickSpacing is 0', () => { + const {result} = renderHook(() => useChartLabelLayout({data: makeData('A', 'B'), font, tickSpacing: 0, labelAreaWidth: 100})); + expect(result.current).toEqual(defaults); + }); + + it('returns defaults when labelAreaWidth is 0', () => { + const {result} = renderHook(() => useChartLabelLayout({data: makeData('A', 'B'), font, tickSpacing: 50, labelAreaWidth: 0})); + expect(result.current).toEqual(defaults); + }); + }); + + describe('rotation selection without edge constraints', () => { + it('picks 0° when labels fit horizontally', () => { + // "AAA" = 21px. 21+4=25 ≤ tickSpacing(30). maxVisibleCount(90,21)=3 ≥ 3 + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAA', 'BBB', 'CCC'), font, tickSpacing: 30, labelAreaWidth: 90})); + expect(result.current.labelRotation).toBe(0); + expect(result.current.truncatedLabels).toEqual(['AAA', 'BBB', 'CCC']); + expect(result.current.xAxisLabelHeight).toBe(LINE_HEIGHT); + expect(result.current.labelSkipInterval).toBe(1); + }); + + it('picks 45° when labels overflow horizontally but fit diagonally', () => { + // "AAAAAA" = 42px. 42+4=46 > tickSpacing(40) → 0° fails. + // At 45°: 42*SIN_45 ≈ 29.7, 29.7+4 ≤ 40 ✓ + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 40, labelAreaWidth: 400})); + expect(result.current.labelRotation).toBe(45); + expect(result.current.truncatedLabels).toEqual(['AAAAAA', 'BBBBBB']); + expect(result.current.xAxisLabelHeight).toBeCloseTo((42 + LINE_HEIGHT) * SIN_45, 5); + expect(result.current.labelSkipInterval).toBe(1); + }); + + it('picks 45° when labelAreaWidth is too narrow for 0° despite sufficient tickSpacing', () => { + // "AAA" = 21px. tickSpacing=30: 21+4=25 ≤ 30 ✓ (tick check passes). + // BUT labelAreaWidth=40: maxVisibleCount(40,21) = floor(40/25) = 1 < 3 → 0° fails. + // At 45°: 21*SIN_45 ≈ 14.85, 14.85+4=18.85 ≤ 30 ✓ → 45° selected. + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAA', 'BBB', 'CCC'), font, tickSpacing: 30, labelAreaWidth: 40})); + expect(result.current.labelRotation).toBe(45); + }); + + it('picks 90° when labels overflow at all rotations', () => { + // tickSpacing=20: 0° fails (46>20), 45° fails (29.7+4=33.7>20) + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 20, labelAreaWidth: 400})); + expect(result.current.labelRotation).toBe(90); + expect(result.current.truncatedLabels).toEqual(['AAAAAA', 'BBBBBB']); + }); + }); + + describe('backward compatibility', () => { + it('produces identical result whether edge params are omitted or set to Infinity', () => { + const config = {data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 40, labelAreaWidth: 400}; + const {result: withoutEdge} = renderHook(() => useChartLabelLayout(config)); + const {result: withEdge} = renderHook(() => useChartLabelLayout({...config, firstTickLeftSpace: Infinity, lastTickRightSpace: Infinity})); + expect(withoutEdge.current).toEqual(withEdge.current); + }); + + it('Infinity edge space never constrains rotation', () => { + const {result} = renderHook(() => + useChartLabelLayout({ + data: makeData('AAAAAA', 'BBBBBB', 'CCCCCC'), + font, + tickSpacing: 50, + labelAreaWidth: 150, + firstTickLeftSpace: Infinity, + lastTickRightSpace: Infinity, + }), + ); + expect(result.current.labelRotation).toBe(0); + }); + }); + + describe('edge-constrained rotation', () => { + it('same data picks 0° without edge constraint but 45° with edge constraint', () => { + // "A".repeat(16) = 112px. At 0°: overhang = 56px. + const config = {data: makeData('A'.repeat(16), 'BB', 'CC'), font, tickSpacing: 120, labelAreaWidth: 360}; + + const {result: noEdge} = renderHook(() => useChartLabelLayout(config)); + expect(noEdge.current.labelRotation).toBe(0); + + // firstTickLeftSpace=40 < 56 → 0° edge fails → escalates to 45° + const {result: withEdge} = renderHook(() => useChartLabelLayout({...config, firstTickLeftSpace: 40, lastTickRightSpace: 200})); + expect(withEdge.current.labelRotation).toBe(45); + }); + + it('escalates to 90° when edge space is too small for both 0° and 45°', () => { + // firstTickLeftSpace=5: at 45° centered edgeMax = max(0, 2*(5/SIN_45-8)) ≈ 0 → fails + const {result} = renderHook(() => + useChartLabelLayout({data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 50, labelAreaWidth: 200, firstTickLeftSpace: 5, lastTickRightSpace: 5}), + ); + expect(result.current.labelRotation).toBe(90); + }); + + it('allowTightDiagonalPacking enables 45° at tighter tick spacing', () => { + // "AAAAAA" = 42px. tickSpacing=30. + // Without packing: minDiagWidth = 42*SIN_45 ≈ 29.7, 29.7+4=33.7 > 30 → 45° fails + // With packing: diagonalOverlap = 16*SIN_45 ≈ 11.3, minDiagWidth = 29.7-11.3=18.4, 18.4+4=22.4 ≤ 30 ✓ + const base = {data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 30, labelAreaWidth: 400, firstTickLeftSpace: 100, lastTickRightSpace: 100}; + + const {result: noPacking} = renderHook(() => useChartLabelLayout({...base, allowTightDiagonalPacking: false})); + expect(noPacking.current.labelRotation).toBe(90); + + const {result: withPacking} = renderHook(() => useChartLabelLayout({...base, allowTightDiagonalPacking: true})); + expect(withPacking.current.labelRotation).toBe(45); + }); + }); + + describe('edge-aware truncation', () => { + it('truncates first label due to edge constraint while middle labels remain full (centered)', () => { + // First label: 16 chars = 112px. tickMaxWidth ≈ 164. edgeMax ≈ 97 (stricter). + // Truncated to 10 chars + "..." + const {result} = renderHook(() => + useChartLabelLayout({ + data: makeData('A'.repeat(16), 'BB', 'CC'), + font, + tickSpacing: 120, + labelAreaWidth: 360, + firstTickLeftSpace: 40, + lastTickRightSpace: 200, + }), + ); + expect(result.current.labelRotation).toBe(45); + expect(result.current.truncatedLabels.at(0)).toBe(`${'A'.repeat(10)}...`); + expect(result.current.truncatedLabels.at(1)).toBe('BB'); + expect(result.current.truncatedLabels.at(2)).toBe('CC'); + }); + + it('truncates first label due to edge constraint (right-aligned)', () => { + // Right-aligned first label: edgeMax = 72/SIN_45 - 8 ≈ 93.8. tickMax ≈ 95.2. + // Edge is stricter → first label truncated to 10 chars + "..." + const {result} = renderHook(() => + useChartLabelLayout({ + data: makeData('A'.repeat(16), 'BB', 'CC'), + font, + tickSpacing: 60, + labelAreaWidth: 360, + firstTickLeftSpace: 72, + lastTickRightSpace: 200, + allowTightDiagonalPacking: true, + }), + ); + expect(result.current.labelRotation).toBe(45); + expect(result.current.truncatedLabels.at(0)).toBe(`${'A'.repeat(10)}...`); + expect(result.current.truncatedLabels.at(1)).toBe('BB'); + }); + + it('truncates last label when centered due to symmetric overhang', () => { + // Centered: last label right overhang = (W/2+halfLH)*SIN_45 ≈ 45.6 for W=112 + // lastTickRightSpace=40: edgeMax = 2*(40/SIN_45-8) ≈ 97.1 < 112 → truncated + const {result} = renderHook(() => + useChartLabelLayout({ + data: makeData('AA', 'BB', 'A'.repeat(16)), + font, + tickSpacing: 200, + labelAreaWidth: 600, + firstTickLeftSpace: 200, + lastTickRightSpace: 40, + }), + ); + expect(result.current.labelRotation).toBe(45); + expect(result.current.truncatedLabels.at(2)).toBe(`${'A'.repeat(10)}...`); + }); + + it('does NOT truncate last label when right-aligned despite tight right edge', () => { + // Right-aligned: last label right overhang = halfLH*SIN_45 ≈ 5.6 (constant, tiny). + // lastTickRightSpace=40 >> 5.6 → edgeMax = Infinity → no edge truncation. + // tickMaxWidth = (200-4)/SIN_45+16 ≈ 293 > 112 → no tick truncation either. + const {result} = renderHook(() => + useChartLabelLayout({ + data: makeData('AA', 'BB', 'A'.repeat(16)), + font, + tickSpacing: 200, + labelAreaWidth: 600, + firstTickLeftSpace: 200, + lastTickRightSpace: 40, + allowTightDiagonalPacking: true, + }), + ); + expect(result.current.labelRotation).toBe(45); + expect(result.current.truncatedLabels.at(2)).toBe('A'.repeat(16)); + }); + }); + + describe('skip interval', () => { + it('computes skip interval > 1 at 90° when too many labels for the area', () => { + // 10 labels, forced to 90°. At 90°, effectiveWidth = lineHeight = 16. + // maxVisibleCount(100, 16) = floor(100/20) = 5 < 10 → skip = ceil(10/5) = 2 + const labels = Array.from({length: 10}, (_, i) => `L${String(i).padStart(4, '0')}`); + const {result} = renderHook(() => useChartLabelLayout({data: makeData(...labels), font, tickSpacing: 10, labelAreaWidth: 100})); + expect(result.current.labelRotation).toBe(90); + expect(result.current.labelSkipInterval).toBe(2); + }); + + it('returns skip interval 1 at 90° when labels fit', () => { + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAAAAA', 'BBBBBB'), font, tickSpacing: 10, labelAreaWidth: 400})); + expect(result.current.labelRotation).toBe(90); + expect(result.current.labelSkipInterval).toBe(1); + expect(result.current.truncatedLabels).toEqual(['AAAAAA', 'BBBBBB']); + expect(result.current.xAxisLabelHeight).toBe(42); + }); + }); + + describe('edge cases', () => { + it('handles single data point', () => { + const {result} = renderHook(() => useChartLabelLayout({data: makeData('AAA'), font, tickSpacing: 50, labelAreaWidth: 50})); + expect(result.current.labelRotation).toBe(0); + expect(result.current.truncatedLabels).toEqual(['AAA']); + expect(result.current.labelSkipInterval).toBe(1); + }); + }); +}); diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index f7f613ac5df7b..2e2987c8b19be 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -1,5 +1,170 @@ +import {LABEL_ROTATIONS, SIN_45} from '@components/Charts/constants'; import type {ChartDataPoint, PieSlice} from '@components/Charts/types'; -import {findSliceAtPosition, isAngleInSlice, normalizeAngle, processDataIntoSlices} from '@components/Charts/utils'; +import { + edgeLabelsFit, + edgeMaxLabelWidth, + effectiveHeight, + effectiveWidth, + findSliceAtPosition, + isAngleInSlice, + labelOverhang, + maxVisibleCount, + normalizeAngle, + processDataIntoSlices, + truncateLabel, +} from '@components/Charts/utils'; + +const LINE_HEIGHT = 16; + +describe('truncateLabel', () => { + const ellipsisWidth = 21; + + it('returns label unchanged when it fits within maxWidth', () => { + expect(truncateLabel('Hello', 35, 50, ellipsisWidth)).toBe('Hello'); + }); + + it('truncates and adds ellipsis when label exceeds maxWidth', () => { + // available = 40 - 21 = 19, maxChars = floor(9 * 19/63) = 2 + expect(truncateLabel('LongLabel', 63, 40, ellipsisWidth)).toBe('Lo...'); + }); + + it('returns label unchanged when labelWidth exactly equals maxWidth', () => { + expect(truncateLabel('Hello', 50, 50, ellipsisWidth)).toBe('Hello'); + }); + + it('returns only ellipsis when available space is zero or negative', () => { + expect(truncateLabel('Text', 28, 20, ellipsisWidth)).toBe('...'); + }); + + it('keeps at least 1 character before ellipsis even when space is extremely tight', () => { + // available = 25 - 21 = 4, maxChars = max(1, floor(6 * 4/42)) = 1 + // Note: the hook enforces MIN_TRUNCATED_CHARS (10) so this extreme case + // only tests the pure function's floor behavior. + expect(truncateLabel('ABCDEF', 42, 25, ellipsisWidth)).toBe('A...'); + }); +}); + +describe('effectiveWidth', () => { + it('returns labelWidth at 0°', () => { + expect(effectiveWidth(100, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL)).toBe(100); + }); + + it('returns labelWidth * sin(45°) at 45°', () => { + expect(effectiveWidth(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL)).toBeCloseTo(100 * SIN_45); + }); + + it('returns lineHeight at 90°', () => { + expect(effectiveWidth(100, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL)).toBe(LINE_HEIGHT); + }); +}); + +describe('effectiveHeight', () => { + it('returns lineHeight at 0°', () => { + expect(effectiveHeight(100, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL)).toBe(LINE_HEIGHT); + }); + + it('returns (labelWidth + lineHeight) * sin(45°) at 45°', () => { + expect(effectiveHeight(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL)).toBeCloseTo((100 + LINE_HEIGHT) * SIN_45); + }); + + it('returns labelWidth at 90°', () => { + expect(effectiveHeight(100, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL)).toBe(100); + }); +}); + +describe('maxVisibleCount', () => { + it('returns correct count for simple case', () => { + // Each label takes 20px + 4px padding = 24px. 100 / 24 = 4.16 → floor = 4 + expect(maxVisibleCount(100, 20)).toBe(4); + }); + + it('returns 0 when area is smaller than one label', () => { + expect(maxVisibleCount(10, 20)).toBe(0); + }); + + it('returns 0 when area is zero', () => { + expect(maxVisibleCount(0, 20)).toBe(0); + }); +}); + +describe('labelOverhang', () => { + it('returns symmetric halves at 0° (horizontal)', () => { + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL, false); + expect(result.left).toBe(50); + expect(result.right).toBe(50); + }); + + it('returns symmetric overhang at 45° when centered', () => { + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false); + expect(result.left).toBeCloseTo(result.right); + }); + + it('returns asymmetric overhang at 45° when right-aligned', () => { + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true); + expect(result.left).toBeGreaterThan(result.right); + }); + + it('returns lineHeight/2 on both sides at 90°', () => { + const result = labelOverhang(100, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL, false); + expect(result.left).toBe(LINE_HEIGHT / 2); + expect(result.right).toBe(LINE_HEIGHT / 2); + }); +}); + +describe('edgeLabelsFit', () => { + const base = { + firstLabelWidth: 40, + lastLabelWidth: 40, + lineHeight: LINE_HEIGHT, + rotation: LABEL_ROTATIONS.HORIZONTAL, + firstTickLeftSpace: 30, + lastTickRightSpace: 30, + rightAligned: false, + }; + + it('returns true when both edges have enough space', () => { + expect(edgeLabelsFit(base)).toBe(true); + }); + + it('returns false when first label overflows left', () => { + expect(edgeLabelsFit({...base, firstLabelWidth: 100})).toBe(false); + }); + + it('returns false when last label overflows right', () => { + expect(edgeLabelsFit({...base, lastLabelWidth: 100})).toBe(false); + }); +}); + +describe('edgeMaxLabelWidth', () => { + it('returns 2 * edgeSpace at 0°', () => { + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.HORIZONTAL, false, 'first')).toBe(100); + }); + + it('returns Infinity at 90° (overhang is constant)', () => { + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.VERTICAL, false, 'first')).toBe(Infinity); + }); + + it('returns finite value at 45° for first label when centered', () => { + const result = edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false, 'first'); + expect(result).toBeGreaterThan(0); + expect(result).not.toBe(Infinity); + }); + + it('returns Infinity at 45° for last label when right-aligned', () => { + expect(edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true, 'last')).toBe(Infinity); + }); + + it('returns finite value at 45° for first label when right-aligned', () => { + const result = edgeMaxLabelWidth(50, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, true, 'first'); + expect(result).toBeGreaterThan(0); + expect(result).not.toBe(Infinity); + }); + + it('returns 0 when edgeSpace is too small at 45° centered', () => { + // edgeSpace/SIN_45 - halfLH ≤ 0 → Math.max(0, ...) = 0 + expect(edgeMaxLabelWidth(1, LINE_HEIGHT, LABEL_ROTATIONS.DIAGONAL, false, 'first')).toBe(0); + }); +}); describe('normalizeAngle', () => { it('returns angle unchanged when within 0-360', () => {