From 9e179c2db425ea8125adc824d85801e82cd9f775 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 11:21:43 -0800 Subject: [PATCH 01/26] Implement line chart --- src/CONST/index.ts | 2 + .../Charts/LineChart/LineChartContent.tsx | 222 ++++++++++++++++++ .../Charts/LineChart/index.native.tsx | 12 + src/components/Charts/LineChart/index.tsx | 27 +++ src/components/Charts/constants.ts | 16 ++ src/components/Charts/index.ts | 5 +- src/components/Charts/types.ts | 36 ++- src/components/Search/SearchChartView.tsx | 8 +- src/components/Search/SearchLineChart.tsx | 91 +++++++ src/components/Search/index.tsx | 3 +- src/components/Search/types.ts | 4 +- src/styles/index.ts | 8 + 12 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/components/Charts/LineChart/LineChartContent.tsx create mode 100644 src/components/Charts/LineChart/index.native.tsx create mode 100644 src/components/Charts/LineChart/index.tsx create mode 100644 src/components/Search/SearchLineChart.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 55eaf64d919f1..f7398158f3455 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7182,6 +7182,8 @@ const CONST = { VIEW: { TABLE: 'table', BAR: 'bar', + LINE: 'line', + PIE: 'pie', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx new file mode 100644 index 0000000000000..710fb24a47a8e --- /dev/null +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -0,0 +1,222 @@ +import {useFont} from '@shopify/react-native-skia'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; +import {CartesianChart, Line, Scatter} from 'victory-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import {getChartColor} from '@components/Charts/chartColors'; +import ChartHeader from '@components/Charts/ChartHeader'; +import ChartTooltip from '@components/Charts/ChartTooltip'; +import { + CHART_CONTENT_MIN_HEIGHT, + CHART_PADDING, + DOT_INNER_RADIUS, + DOT_OUTER_RADIUS, + LINE_CHART_FRAME, + X_AXIS_LINE_WIDTH, + Y_AXIS_DOMAIN, + Y_AXIS_LABEL_OFFSET, + 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'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; +import type {LineChartProps} from '@components/Charts/types'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + +/** Symmetric domain padding for line charts */ +const LINE_DOMAIN_PADDING = { + left: 20, + right: 20, + top: 20, + bottom: 20, +}; + +function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const font = useFont(fontSource, variables.iconSizeExtraSmall); + const [chartWidth, setChartWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + const chartData = useMemo(() => { + 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 handleLayout = useCallback((event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }, []); + + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ + data, + font, + chartWidth, + barAreaWidth: chartWidth, + containerHeight, + }); + + const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ + data, + yAxisUnit, + yAxisUnitPosition, + labelSkipInterval, + labelRotation, + truncatedLabels, + }); + + const checkIsOverDot = useCallback( + (args: HitTestArgs) => { + 'worklet'; + + const dx = args.cursorX - args.targetX; + const dy = args.cursorY - args.targetY; + return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; + }, + [], + ); + + const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ + handlePress: handlePointPress, + checkIsOver: checkIsOverDot, + }); + + const tooltipData = useMemo(() => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + const formatted = dataPoint.total.toLocaleString(); + let formattedAmount = formatted; + if (yAxisUnit) { + const separator = yAxisUnit.length > 1 ? ' ' : ''; + formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + } + const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; + return { + label: dataPoint.label, + amount: formattedAmount, + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; + }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + + const dynamicChartStyle = useMemo( + () => ({ + height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), + }), + [maxLabelLength], + ); + + if (isLoading || !font) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + + {chartWidth > 0 && ( + + {({points}) => ( + <> + + + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + + + )} + + + ); +} + +export default LineChartContent; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx new file mode 100644 index 0000000000000..f0e5af8da0122 --- /dev/null +++ b/src/components/Charts/LineChart/index.native.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type {LineChartProps} from '@components/Charts/types'; +import LineChartContent from './LineChartContent'; + +function LineChart(props: LineChartProps) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx new file mode 100644 index 0000000000000..2fefabd884cb6 --- /dev/null +++ b/src/components/Charts/LineChart/index.tsx @@ -0,0 +1,27 @@ +import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; +import React from 'react'; +import {View} from 'react-native'; +import ActivityIndicator from '@components/ActivityIndicator'; +import type {LineChartProps} from '@components/Charts/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function LineChart(props: LineChartProps) { + const styles = useThemeStyles(); + + return ( + `/${file}`}} + getComponent={() => import('./LineChartContent')} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index b0ed40872622d..7c597f66bb150 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -87,6 +87,18 @@ const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; /** Ellipsis character for truncated labels */ const LABEL_ELLIPSIS = '...'; +/** Inner dot radius for line chart data points */ +const DOT_INNER_RADIUS = 6; + +/** Outer dot radius for line chart data points (background ring) */ +const DOT_OUTER_RADIUS = 8; + +/** Y-axis domain anchored at zero for line charts */ +const Y_AXIS_DOMAIN: [number] = [0]; + +/** Frame configuration for line charts - only left and bottom borders */ +const LINE_CHART_FRAME = {lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}; + export { CHART_COLORS, Y_AXIS_TICK_COUNT, @@ -110,4 +122,8 @@ export { LABEL_PADDING, X_AXIS_LABEL_MAX_HEIGHT_RATIO, LABEL_ELLIPSIS, + DOT_INNER_RADIUS, + DOT_OUTER_RADIUS, + Y_AXIS_DOMAIN, + LINE_CHART_FRAME, }; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index aabb568439238..5804e553c8f40 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,6 +1,7 @@ import BarChart from './BarChart'; import ChartHeader from './ChartHeader'; import ChartTooltip from './ChartTooltip'; +import LineChart from './LineChart'; -export {BarChart, ChartHeader, ChartTooltip}; -export type {BarChartDataPoint, BarChartProps} from './types'; +export {BarChart, ChartHeader, ChartTooltip, LineChart}; +export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps} from './types'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index e2ec4fe540726..678a0dd58b982 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -40,4 +40,38 @@ type BarChartProps = { useSingleColor?: boolean; }; -export type {BarChartDataPoint, BarChartProps}; +type LineChartDataPoint = { + /** Label displayed under the data point (e.g., "Nov 2025", "Week 3") */ + label: string; + + /** Total amount (pre-formatted, e.g., dollars not cents) */ + total: number; + + /** Query string for navigation when point is clicked (optional) */ + onClickQuery?: string; +}; + +type LineChartProps = { + /** Data points to display */ + data: LineChartDataPoint[]; + + /** Chart title (e.g., "Spend over time") */ + title?: string; + + /** Icon displayed next to the title */ + titleIcon?: IconAsset; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Callback when a data point is pressed */ + onPointPress?: (dataPoint: LineChartDataPoint, index: number) => void; + + /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ + yAxisUnit?: string; + + /** Position of the unit symbol relative to the value. Defaults to 'left'. */ + yAxisUnitPosition?: 'left' | 'right'; +}; + +export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps}; diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 1979dad7fb6cb..0d5cde51e236d 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -27,6 +27,7 @@ import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUti import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchBarChart from './SearchBarChart'; +import SearchLineChart from './SearchLineChart'; import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; type GroupedItem = @@ -125,8 +126,8 @@ type SearchChartViewProps = { /** The current search query JSON */ queryJSON: SearchQueryJSON; - /** The view type (bar, etc.) */ - view: Exclude; + /** The view type (bar, line, etc.) */ + view: Exclude; /** The groupBy parameter */ groupBy: SearchGroupBy; @@ -144,8 +145,9 @@ type SearchChartViewProps = { /** * Map of chart view types to their corresponding chart components. */ -const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart> = { +const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart | typeof SearchLineChart> = { [CONST.SEARCH.VIEW.BAR]: SearchBarChart, + [CONST.SEARCH.VIEW.LINE]: SearchLineChart, }; /** diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx new file mode 100644 index 0000000000000..efe030eeaeb74 --- /dev/null +++ b/src/components/Search/SearchLineChart.tsx @@ -0,0 +1,91 @@ +import React, {useCallback, useMemo} from 'react'; +import {LineChart} from '@components/Charts'; +import type {LineChartDataPoint} from '@components/Charts'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionWithdrawalIDGroupListItemType, +} from '@components/SelectionListWithSections/types'; +import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; + +type SearchLineChartProps = { + /** Grouped transaction data from search results */ + data: TransactionGroupListItemType[]; + + /** Chart title */ + title: string; + + /** Chart title icon */ + titleIcon: IconAsset; + + /** Function to extract label from grouped item */ + getLabel: (item: GroupedItem) => string; + + /** Function to build filter query from grouped item */ + getFilterQuery: (item: GroupedItem) => string; + + /** Callback when a point is pressed - receives the filter query to apply */ + onBarPress?: (filterQuery: string) => void; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Currency symbol for Y-axis labels */ + yAxisUnit?: string; + + /** Position of currency symbol relative to value */ + yAxisUnitPosition?: 'left' | 'right'; +}; + +function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { + const chartData: LineChartDataPoint[] = useMemo(() => { + return data.map((item) => { + const groupedItem = item as GroupedItem; + const currency = groupedItem.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + + return { + label: getLabel(groupedItem), + total: totalInDisplayUnits, + }; + }); + }, [data, getLabel]); + + const handlePointPress = useCallback( + (dataPoint: LineChartDataPoint, index: number) => { + if (!onBarPress) { + return; + } + + const item = data.at(index); + if (!item) { + return; + } + + const filterQuery = getFilterQuery(item as GroupedItem); + onBarPress(filterQuery); + }, + [data, getFilterQuery, onBarPress], + ); + + return ( + + ); +} + +SearchLineChart.displayName = 'SearchLineChart'; + +export default SearchLineChart; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 59e59a9363449..eaa360727828b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1305,8 +1305,7 @@ function Search({ const shouldShowTableHeader = isLargeScreenWidth && !isChat; const tableHeaderVisible = canSelectMultiple || shouldShowTableHeader; - // Other charts are not implemented yet - const shouldShowChartView = view === CONST.SEARCH.VIEW.BAR && !!validGroupBy; + const shouldShowChartView = (view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE) && !!validGroupBy; if (shouldShowChartView) { return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 7faab6847ef1e..953aaca2848f6 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -115,8 +115,8 @@ type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | In type SearchStatus = SingularSearchStatus | SingularSearchStatus[]; type SearchGroupBy = ValueOf; type SearchView = ValueOf; -// LineChart and PieChart are not implemented so we exclude them here to prevent TypeScript errors in `SearchChartView.tsx`. -type ChartView = Exclude; +// PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`. +type ChartView = Exclude; type TableColumnSize = ValueOf; type SearchDatePreset = ValueOf; type SearchWithdrawalType = ValueOf; diff --git a/src/styles/index.ts b/src/styles/index.ts index 4ca6f677c1118..ccb2b64432820 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5805,6 +5805,14 @@ const staticStyles = (theme: ThemeColors) => barChartChartContainer: { minHeight: 250, }, + lineChartContainer: { + borderRadius: variables.componentBorderRadiusLarge, + paddingTop: variables.qrShareHorizontalPadding, + paddingHorizontal: variables.qrShareHorizontalPadding, + }, + lineChartChartContainer: { + minHeight: 250, + }, discoverSectionImage: { width: '100%', height: undefined, From a8c40b2c028e35f610daefc93bc696a80d9bebd4 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 11:21:56 -0800 Subject: [PATCH 02/26] Add english translation --- src/languages/en.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 62a48239b88bb..33ce71189d769 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6981,6 +6981,8 @@ const translations = { label: 'View', table: 'Table', bar: 'Bar', + line: 'Line', + pie: 'Pie', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'From', From 6340c82aef8ae789c7384f3719ff03269478b3dc Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:30:16 -0800 Subject: [PATCH 03/26] Refactor charts directory and make it more DRY --- .../Charts/BarChart/BarChartContent.tsx | 74 ++++++------ .../Charts/BarChart/index.native.tsx | 2 +- src/components/Charts/BarChart/index.tsx | 2 +- .../Charts/LineChart/LineChartContent.tsx | 62 ++++------ .../Charts/LineChart/index.native.tsx | 2 +- src/components/Charts/LineChart/index.tsx | 2 +- .../Charts/{ => components}/ChartHeader.tsx | 0 .../Charts/{ => components}/ChartTooltip.tsx | 6 +- src/components/Charts/constants.ts | 112 +----------------- src/components/Charts/hooks/index.ts | 2 + .../Charts/hooks/useChartInteractions.ts | 4 +- .../Charts/hooks/useChartLabelFormats.ts | 7 +- .../Charts/hooks/useChartLabelLayout.ts | 28 +++-- .../Charts/hooks/useDynamicYDomain.ts | 12 ++ src/components/Charts/hooks/useTooltipData.ts | 41 +++++++ src/components/Charts/index.ts | 8 +- src/components/Charts/types.ts | 59 ++------- .../Charts/{chartColors.ts => utils.ts} | 5 +- src/components/Search/SearchBarChart.tsx | 7 +- src/components/Search/SearchLineChart.tsx | 6 +- 20 files changed, 166 insertions(+), 275 deletions(-) rename src/components/Charts/{ => components}/ChartHeader.tsx (100%) rename src/components/Charts/{ => components}/ChartTooltip.tsx (90%) create mode 100644 src/components/Charts/hooks/useDynamicYDomain.ts create mode 100644 src/components/Charts/hooks/useTooltipData.ts rename src/components/Charts/{chartColors.ts => utils.ts} (85%) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index c1d60f962b6b3..08cffc68f0c66 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -6,19 +6,12 @@ import Animated, {useSharedValue} from 'react-native-reanimated'; import type {ChartBounds, PointsArray} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import {getChartColor} from '@components/Charts/chartColors'; -import ChartHeader from '@components/Charts/ChartHeader'; -import ChartTooltip from '@components/Charts/ChartTooltip'; +import ChartHeader from '@components/Charts/components/ChartHeader'; +import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import { - BAR_INNER_PADDING, - BAR_ROUNDED_CORNERS, - CHART_COLORS, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, - DEFAULT_SINGLE_BAR_COLOR_INDEX, - DOMAIN_PADDING, - DOMAIN_PADDING_SAFETY_BUFFER, - FRAME_LINE_WIDTH, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, @@ -26,13 +19,35 @@ import { } from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; -import type {BarChartProps} from '@components/Charts/types'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +type BarChartProps = CartesianChartProps & { + /** Callback when a bar is pressed */ + onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; + + /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ + useSingleColor?: boolean; +}; + +/** Inner padding between bars (0.3 = 30% of bar width) */ +const BAR_INNER_PADDING = 0.3; + +/** Domain padding configuration for the bar chart */ +const DOMAIN_PADDING = { + left: 0, + right: 16, + top: 30, + bottom: 10, +}; + +/** Safety buffer multiplier for domain padding calculation */ +const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; + /** * Calculate minimum domainPadding required to prevent bars from overflowing chart edges. * @@ -57,7 +72,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); - const defaultBarColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX); + const defaultBarColor = DEFAULT_CHART_COLOR; // prepare data for display const chartData = useMemo(() => { @@ -67,9 +82,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni })); }, [data]); - // Anchor Y-axis at zero so the baseline is always visible. - // When negative values are present, let victory-native auto-calculate the domain to avoid clipping. - const yAxisDomain = useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); + const yAxisDomain = useDynamicYDomain(data); // Handle bar press callback const handleBarPress = useCallback( @@ -169,29 +182,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni barGeometry, }); - const tooltipData = useMemo(() => { - if (activeDataIndex < 0 || activeDataIndex >= data.length) { - return null; - } - const dataPoint = data.at(activeDataIndex); - if (!dataPoint) { - return null; - } - const formatted = dataPoint.total.toLocaleString(); - let formattedAmount = formatted; - if (yAxisUnit) { - // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") - const separator = yAxisUnit.length > 1 ? ' ' : ''; - formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; - } - const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); - const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; - return { - label: dataPoint.label, - amount: formattedAmount, - percentage: percent < 1 ? '<1%' : `${percent}%`, - }; - }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); const renderBar = useCallback( (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { @@ -207,7 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni color={barColor} barCount={barCount} innerPadding={BAR_INNER_PADDING} - roundedCorners={BAR_ROUNDED_CORNERS} + roundedCorners={{topLeft: 8, topRight: 8, bottomLeft: 8, bottomRight: 8}} /> ); }, @@ -276,7 +267,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni domain: yAxisDomain, }, ]} - frame={{lineWidth: FRAME_LINE_WIDTH}} + frame={{lineWidth: 0}} data={chartData} > {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} @@ -297,3 +288,4 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } export default BarChartContent; +export type {BarChartProps}; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 82396525a5b00..54f47ea8bb1af 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {BarChartProps} from '@components/Charts/types'; +import type {BarChartProps} from './BarChartContent'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index c82a92ecbf23e..0d00fb501193c 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -2,7 +2,7 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {BarChartProps} from '@components/Charts/types'; +import type {BarChartProps} from './BarChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; function BarChart(props: BarChartProps) { diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 710fb24a47a8e..6e7e0e5fa13c5 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -5,36 +5,32 @@ import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import {getChartColor} from '@components/Charts/chartColors'; -import ChartHeader from '@components/Charts/ChartHeader'; -import ChartTooltip from '@components/Charts/ChartTooltip'; +import ChartHeader from '@components/Charts/components/ChartHeader'; +import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import { CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, - DOT_INNER_RADIUS, - DOT_OUTER_RADIUS, - LINE_CHART_FRAME, X_AXIS_LINE_WIDTH, - Y_AXIS_DOMAIN, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks'; -import type {LineChartProps} from '@components/Charts/types'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -/** Symmetric domain padding for line charts */ -const LINE_DOMAIN_PADDING = { - left: 20, - right: 20, - top: 20, - bottom: 20, +/** Inner dot radius for line chart data points */ +const DOT_INNER_RADIUS = 6; + +type LineChartProps = CartesianChartProps & { + /** Callback when a data point is pressed */ + onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; }; function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { @@ -45,6 +41,8 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const [chartWidth, setChartWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); + const yAxisDomain = useDynamicYDomain(data); + const chartData = useMemo(() => { return data.map((point, index) => ({ x: index, @@ -104,28 +102,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn checkIsOver: checkIsOverDot, }); - const tooltipData = useMemo(() => { - if (activeDataIndex < 0 || activeDataIndex >= data.length) { - return null; - } - const dataPoint = data.at(activeDataIndex); - if (!dataPoint) { - return null; - } - const formatted = dataPoint.total.toLocaleString(); - let formattedAmount = formatted; - if (yAxisUnit) { - const separator = yAxisUnit.length > 1 ? ' ' : ''; - formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; - } - const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); - const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; - return { - label: dataPoint.label, - amount: formattedAmount, - percentage: percent < 1 ? '<1%' : `${percent}%`, - }; - }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); const dynamicChartStyle = useMemo( () => ({ @@ -161,7 +138,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={LINE_DOMAIN_PADDING} + domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} xAxis={{ @@ -182,24 +159,24 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn lineWidth: Y_AXIS_LINE_WIDTH, lineColor: theme.border, labelOffset: Y_AXIS_LABEL_OFFSET, - domain: Y_AXIS_DOMAIN, + domain: yAxisDomain, }, ]} - frame={LINE_CHART_FRAME} + frame={{lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}} data={chartData} > {({points}) => ( <> )} @@ -220,3 +197,4 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn } export default LineChartContent; +export type {LineChartProps}; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx index f0e5af8da0122..db7c218db9aba 100644 --- a/src/components/Charts/LineChart/index.native.tsx +++ b/src/components/Charts/LineChart/index.native.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {LineChartProps} from '@components/Charts/types'; +import type {LineChartProps} from './LineChartContent'; import LineChartContent from './LineChartContent'; function LineChart(props: LineChartProps) { diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 2fefabd884cb6..492b278697b71 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -2,7 +2,7 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {LineChartProps} from '@components/Charts/types'; +import type {LineChartProps} from './LineChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; function LineChart(props: LineChartProps) { diff --git a/src/components/Charts/ChartHeader.tsx b/src/components/Charts/components/ChartHeader.tsx similarity index 100% rename from src/components/Charts/ChartHeader.tsx rename to src/components/Charts/components/ChartHeader.tsx diff --git a/src/components/Charts/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx similarity index 90% rename from src/components/Charts/ChartTooltip.tsx rename to src/components/Charts/components/ChartTooltip.tsx index 5c61feef80f70..83e6d4b2f9dbd 100644 --- a/src/components/Charts/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -3,7 +3,11 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from './constants'; +/** The height of the chart tooltip pointer */ +const TOOLTIP_POINTER_HEIGHT = 4; + +/** The width of the chart tooltip pointer */ +const TOOLTIP_POINTER_WIDTH = 12; type ChartTooltipProps = { /** Label text (e.g., "Airfare", "Amazon") */ diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 7c597f66bb150..b8b987b9fe19a 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -1,129 +1,19 @@ -import type {Color} from '@shopify/react-native-skia'; -import type {RoundedCorners} from 'victory-native'; -import colors from '@styles/theme/colors'; - -/** - * Chart color palette from Figma design. - * Colors cycle when there are more data points than colors. - */ -const CHART_COLORS: Color[] = [colors.yellow400, colors.tangerine400, colors.pink400, colors.green400, colors.ice400]; - /** Number of Y-axis ticks (including zero) */ const Y_AXIS_TICK_COUNT = 5; -/** Inner padding between bars (0.3 = 30% of bar width) */ -const BAR_INNER_PADDING = 0.3; - -/** Domain padding configuration for the chart */ -const DOMAIN_PADDING = { - left: 0, - right: 16, - top: 30, - bottom: 10, -}; - /** Distance between Y-axis labels and the chart */ const Y_AXIS_LABEL_OFFSET = 16; -/** Rounded corners radius for bars */ -const BAR_CORNER_RADIUS = 8; - -/** Rounded corners configuration for bars */ -const BAR_ROUNDED_CORNERS: RoundedCorners = { - topLeft: BAR_CORNER_RADIUS, - topRight: BAR_CORNER_RADIUS, - bottomLeft: BAR_CORNER_RADIUS, - bottomRight: BAR_CORNER_RADIUS, -}; - /** Chart padding */ const CHART_PADDING = 5; /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; -/** Default bar color index when useSingleColor is true (ice blue) */ -const DEFAULT_SINGLE_BAR_COLOR_INDEX = 4; - -/** Safety buffer multiplier for domain padding calculation */ -const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; - /** Line width for X-axis (hidden) */ const X_AXIS_LINE_WIDTH = 0; /** Line width for Y-axis grid lines */ const Y_AXIS_LINE_WIDTH = 1; -/** Line width for frame (hidden) */ -const FRAME_LINE_WIDTH = 0; - -/** The height of the chart tooltip pointer */ -const TOOLTIP_POINTER_HEIGHT = 4; - -/** The width of the chart tooltip pointer */ -const TOOLTIP_POINTER_WIDTH = 12; - -/** Gap between bar top and tooltip bottom */ -const TOOLTIP_BAR_GAP = 8; - -/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_45 = -45; - -/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_90 = -90; - -/** Sin of 45 degrees - used to calculate effective width of rotated labels */ -const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 - -/** Minimum padding between labels (in pixels) */ -const LABEL_PADDING = 4; - -/** Maximum ratio of container height that X-axis labels can occupy. - * Victory allocates: fontHeight + yLabelOffset * 2 + rotateOffset. - * With fontHeight ~12px and yLabelOffset = 16, base is ~44px. - * This ratio limits total label area to prevent labels from taking too much space. */ -const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; - -/** Ellipsis character for truncated labels */ -const LABEL_ELLIPSIS = '...'; - -/** Inner dot radius for line chart data points */ -const DOT_INNER_RADIUS = 6; - -/** Outer dot radius for line chart data points (background ring) */ -const DOT_OUTER_RADIUS = 8; - -/** Y-axis domain anchored at zero for line charts */ -const Y_AXIS_DOMAIN: [number] = [0]; - -/** Frame configuration for line charts - only left and bottom borders */ -const LINE_CHART_FRAME = {lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}; - -export { - CHART_COLORS, - Y_AXIS_TICK_COUNT, - BAR_INNER_PADDING, - DOMAIN_PADDING, - Y_AXIS_LABEL_OFFSET, - BAR_ROUNDED_CORNERS, - CHART_PADDING, - CHART_CONTENT_MIN_HEIGHT, - DEFAULT_SINGLE_BAR_COLOR_INDEX, - DOMAIN_PADDING_SAFETY_BUFFER, - X_AXIS_LINE_WIDTH, - Y_AXIS_LINE_WIDTH, - FRAME_LINE_WIDTH, - TOOLTIP_POINTER_HEIGHT, - TOOLTIP_POINTER_WIDTH, - TOOLTIP_BAR_GAP, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - SIN_45_DEGREES, - LABEL_PADDING, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - LABEL_ELLIPSIS, - DOT_INNER_RADIUS, - DOT_OUTER_RADIUS, - Y_AXIS_DOMAIN, - LINE_CHART_FRAME, -}; +export {Y_AXIS_TICK_COUNT, Y_AXIS_LABEL_OFFSET, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH}; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index fc5119b0a729b..ac5272aa5b566 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -4,3 +4,5 @@ export {useChartInteractions} from './useChartInteractions'; export type {HitTestArgs} from './useChartInteractions'; export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; export {default as useChartLabelFormats} from './useChartLabelFormats'; +export {default as useDynamicYDomain} from './useDynamicYDomain'; +export {useTooltipData} from './useTooltipData'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index e7a5422f58808..3cb17d796b71a 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -3,9 +3,11 @@ import {Gesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import {useAnimatedReaction, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; -import {TOOLTIP_BAR_GAP} from '@components/Charts/constants'; import {useChartInteractionState} from './useChartInteractionState'; +/** Gap between bar top and tooltip bottom */ +const TOOLTIP_BAR_GAP = 8; + /** * Arguments passed to the checkIsOver callback for hit-testing */ diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 3f6136c5d262a..31921462d8b8c 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,13 +1,10 @@ import {useCallback} from 'react'; - -type ChartDataPoint = { - label: string; -}; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; type UseChartLabelFormatsProps = { data: ChartDataPoint[]; yAxisUnit?: string; - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; labelSkipInterval: number; labelRotation: number; truncatedLabels: string[]; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 215459488f8f6..108aa027ed4fb 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,14 +1,24 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; -import { - LABEL_ELLIPSIS, - LABEL_PADDING, - SIN_45_DEGREES, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - Y_AXIS_LABEL_OFFSET, -} from '@components/Charts/constants'; +import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; + +/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_45 = -45; + +/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ +const X_AXIS_LABEL_ROTATION_90 = -90; + +/** Sin of 45 degrees - used to calculate effective width of rotated labels */ +const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 + +/** Minimum padding between labels (in pixels) */ +const LABEL_PADDING = 4; + +/** Maximum ratio of container height that X-axis labels can occupy. */ +const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; + +/** Ellipsis character for truncated labels */ +const LABEL_ELLIPSIS = '...'; type ChartDataPoint = { label: string; diff --git a/src/components/Charts/hooks/useDynamicYDomain.ts b/src/components/Charts/hooks/useDynamicYDomain.ts new file mode 100644 index 0000000000000..2868a317b5404 --- /dev/null +++ b/src/components/Charts/hooks/useDynamicYDomain.ts @@ -0,0 +1,12 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint} from '@components/Charts/types'; + +/** + * Anchor Y-axis at zero so the baseline is always visible. + * When negative values are present, let victory-native auto-calculate the domain to avoid clipping. + */ +function useDynamicYDomain(data: ChartDataPoint[]): [number] | undefined { + return useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); +} + +export default useDynamicYDomain; diff --git a/src/components/Charts/hooks/useTooltipData.ts b/src/components/Charts/hooks/useTooltipData.ts new file mode 100644 index 0000000000000..3a75db14a1068 --- /dev/null +++ b/src/components/Charts/hooks/useTooltipData.ts @@ -0,0 +1,41 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; + +type TooltipData = { + label: string; + amount: string; + percentage: string; +}; + +/** + * Formats tooltip content for the active chart data point. + * Computes the display amount (with optional currency unit) and the percentage relative to all data points. + */ +function useTooltipData(activeDataIndex: number, data: ChartDataPoint[], yAxisUnit?: string, yAxisUnitPosition?: YAxisUnitPosition): TooltipData | null { + return useMemo(() => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + const formatted = dataPoint.total.toLocaleString(); + let formattedAmount = formatted; + if (yAxisUnit) { + // Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100") + const separator = yAxisUnit.length > 1 ? ' ' : ''; + formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; + } + const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; + return { + label: dataPoint.label, + amount: formattedAmount, + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; + }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); +} + +export {useTooltipData}; +export type {TooltipData}; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index 5804e553c8f40..85629c33083bd 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,7 +1,9 @@ import BarChart from './BarChart'; -import ChartHeader from './ChartHeader'; -import ChartTooltip from './ChartTooltip'; +import ChartHeader from './components/ChartHeader'; +import ChartTooltip from './components/ChartTooltip'; import LineChart from './LineChart'; export {BarChart, ChartHeader, ChartTooltip, LineChart}; -export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps} from './types'; +export type {ChartDataPoint, CartesianChartProps} from './types'; +export type {BarChartProps} from './BarChart/BarChartContent'; +export type {LineChartProps} from './LineChart/LineChartContent'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 678a0dd58b982..da769fd2aeac8 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,24 +1,21 @@ import type IconAsset from '@src/types/utils/IconAsset'; -type BarChartDataPoint = { - /** Label displayed under the bar (e.g., "Amazon", "Travel", "Nov 2025") */ +type ChartDataPoint = { + /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ label: string; /** Total amount (pre-formatted, e.g., dollars not cents) */ total: number; - /** Currency code for formatting */ - currency: string; - - /** Query string for navigation when bar is clicked (optional) */ + /** Query string for navigation when data point is clicked (optional) */ onClickQuery?: string; }; -type BarChartProps = { +type CartesianChartProps = { /** Data points to display */ - data: BarChartDataPoint[]; + data: ChartDataPoint[]; - /** Chart title (e.g., "Top Categories", "Spend by Merchant") */ + /** Chart title (e.g., "Top Categories", "Spend over time") */ title?: string; /** Icon displayed next to the title */ @@ -27,51 +24,13 @@ type BarChartProps = { /** Whether data is loading */ isLoading?: boolean; - /** Callback when a bar is pressed */ - onBarPress?: (dataPoint: BarChartDataPoint, index: number) => void; - /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ yAxisUnit?: string; /** Position of the unit symbol relative to the value. Defaults to 'left'. */ - yAxisUnitPosition?: 'left' | 'right'; - - /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ - useSingleColor?: boolean; -}; - -type LineChartDataPoint = { - /** Label displayed under the data point (e.g., "Nov 2025", "Week 3") */ - label: string; - - /** Total amount (pre-formatted, e.g., dollars not cents) */ - total: number; - - /** Query string for navigation when point is clicked (optional) */ - onClickQuery?: string; + yAxisUnitPosition?: YAxisUnitPosition; }; -type LineChartProps = { - /** Data points to display */ - data: LineChartDataPoint[]; - - /** Chart title (e.g., "Spend over time") */ - title?: string; - - /** Icon displayed next to the title */ - titleIcon?: IconAsset; - - /** Whether data is loading */ - isLoading?: boolean; - - /** Callback when a data point is pressed */ - onPointPress?: (dataPoint: LineChartDataPoint, index: number) => void; - - /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ - yAxisUnit?: string; - - /** Position of the unit symbol relative to the value. Defaults to 'left'. */ - yAxisUnitPosition?: 'left' | 'right'; -}; +type YAxisUnitPosition = 'left' | 'right'; -export type {BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps}; +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition}; diff --git a/src/components/Charts/chartColors.ts b/src/components/Charts/utils.ts similarity index 85% rename from src/components/Charts/chartColors.ts rename to src/components/Charts/utils.ts index c34ea720c0409..f14e4d5efbd07 100644 --- a/src/components/Charts/chartColors.ts +++ b/src/components/Charts/utils.ts @@ -36,4 +36,7 @@ function getChartColor(index: number): string { return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; } -export {CHART_PALETTE, getChartColor}; +/** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ +const DEFAULT_CHART_COLOR = getChartColor(4); + +export {getChartColor, DEFAULT_CHART_COLOR}; diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 68d1e2f89e1a3..a8b012167ade2 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; -import type {BarChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint} from '@components/Charts'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -44,7 +44,7 @@ type SearchBarChartProps = { function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { // Transform grouped transaction data to BarChart format - const chartData: BarChartDataPoint[] = useMemo(() => { + const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; const currency = groupedItem.currency ?? 'USD'; @@ -53,13 +53,12 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar return { label: getLabel(groupedItem), total: totalInDisplayUnits, - currency, }; }); }, [data, getLabel]); const handleBarPress = useCallback( - (dataPoint: BarChartDataPoint, index: number) => { + (dataPoint: ChartDataPoint, index: number) => { if (!onBarPress) { return; } diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index efe030eeaeb74..a726dddf4641c 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; -import type {LineChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint} from '@components/Charts'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, @@ -43,7 +43,7 @@ type SearchLineChartProps = { }; function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { - const chartData: LineChartDataPoint[] = useMemo(() => { + const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; const currency = groupedItem.currency ?? 'USD'; @@ -57,7 +57,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa }, [data, getLabel]); const handlePointPress = useCallback( - (dataPoint: LineChartDataPoint, index: number) => { + (dataPoint: ChartDataPoint, index: number) => { if (!onBarPress) { return; } From 2c42fde7dce08b7863e1a471eba1015eef9d9320 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:38:14 -0800 Subject: [PATCH 04/26] Add remaining translations --- src/CONST/index.ts | 1 - src/languages/de.ts | 2 +- src/languages/en.ts | 1 - src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 11 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f7398158f3455..4dba9ffdc9bf2 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7183,7 +7183,6 @@ const CONST = { TABLE: 'table', BAR: 'bar', LINE: 'line', - PIE: 'pie', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', diff --git a/src/languages/de.ts b/src/languages/de.ts index 8da341287e34e..755ffefffc84b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7079,7 +7079,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', }, topSpenders: 'Top-Ausgaben', - view: {label: 'Ansehen', table: 'Tabelle', bar: 'Bar'}, + view: {label: 'Ansehen', table: 'Tabelle', bar: 'Bar', line: 'Linie'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Von', [CONST.SEARCH.GROUP_BY.CARD]: 'Karten', diff --git a/src/languages/en.ts b/src/languages/en.ts index 33ce71189d769..54690165917b8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6982,7 +6982,6 @@ const translations = { table: 'Table', bar: 'Bar', line: 'Line', - pie: 'Pie', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'From', diff --git a/src/languages/es.ts b/src/languages/es.ts index 550115b5b9021..fbf00adfaca24 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6639,7 +6639,7 @@ ${amount} para ${merchant} - ${date}`, unapprovedCard: 'Tarjeta no aprobada', reconciliation: 'Conciliación', topSpenders: 'Mayores gastadores', - view: {label: 'Ver', table: 'Tabla', bar: 'Barra'}, + view: {label: 'Ver', table: 'Tabla', bar: 'Barra', line: 'Línea'}, saveSearch: 'Guardar búsqueda', savedSearchesMenuItemTitle: 'Guardadas', topCategories: 'Categorías principales', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 726c88e995a49..ca9980563cc10 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7091,7 +7091,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', }, topSpenders: 'Plus gros dépensiers', - view: {label: 'Afficher', table: 'Tableau', bar: 'Barre'}, + view: {label: 'Afficher', table: 'Tableau', bar: 'Barre', line: 'Ligne'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartes', diff --git a/src/languages/it.ts b/src/languages/it.ts index 33c0428bfdf4e..64063d39b9fc9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7068,7 +7068,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori allMatchingItemsSelected: 'Tutti gli elementi corrispondenti selezionati', }, topSpenders: 'Maggiori spenditori', - view: {label: 'Visualizza', table: 'Tabella', bar: 'Bar'}, + view: {label: 'Visualizza', table: 'Tabella', bar: 'Bar', line: 'Linea'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Da', [CONST.SEARCH.GROUP_BY.CARD]: 'Carte', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 761da2729ec5d..85f5160d36d74 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7006,7 +7006,7 @@ ${reportName} allMatchingItemsSelected: '一致する項目をすべて選択済み', }, topSpenders: 'トップ支出者', - view: {label: '表示', table: 'テーブル', bar: 'バー'}, + view: {label: '表示', table: 'テーブル', bar: 'バー', line: '折れ線'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '差出人', [CONST.SEARCH.GROUP_BY.CARD]: 'カード', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f4f2720e38722..fb4d0a2dcd892 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7051,7 +7051,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', }, topSpenders: 'Grootste uitgaven', - view: {label: 'Bekijken', table: 'Tabel', bar: 'Bar'}, + view: {label: 'Bekijken', table: 'Tabel', bar: 'Bar', line: 'Lijn'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Van', [CONST.SEARCH.GROUP_BY.CARD]: 'Kaarten', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7c790e3888ced..697f0a768c83e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7039,7 +7039,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i allMatchingItemsSelected: 'Wybrano wszystkie pasujące elementy', }, topSpenders: 'Najwięksi wydający', - view: {label: 'Zobacz', table: 'Tabela', bar: 'Pasek'}, + view: {label: 'Zobacz', table: 'Tabela', bar: 'Pasek', line: 'Linia'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Od', [CONST.SEARCH.GROUP_BY.CARD]: 'Karty', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 84ec54156619e..8077b928f3d90 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7040,7 +7040,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', }, topSpenders: 'Maiores gastadores', - view: {label: 'Ver', table: 'Tabela', bar: 'Bar'}, + view: {label: 'Ver', table: 'Tabela', bar: 'Bar', line: 'Linha'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartões', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7630d11c09fb3..70aac591ce31a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6886,7 +6886,7 @@ ${reportName} allMatchingItemsSelected: '已选择所有匹配的项目', }, topSpenders: '最高支出者', - view: {label: '查看', table: '表格', bar: '栏'}, + view: {label: '查看', table: '表格', bar: '栏', line: '折线'}, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '来自', [CONST.SEARCH.GROUP_BY.CARD]: '卡片', From acec8e616d31a4d36b29df130fb3f8d366c87501 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:49:33 -0800 Subject: [PATCH 05/26] Fix default color --- src/components/Charts/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index f14e4d5efbd07..f56cd568b28c8 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -37,6 +37,6 @@ function getChartColor(index: number): string { } /** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ -const DEFAULT_CHART_COLOR = getChartColor(4); +const DEFAULT_CHART_COLOR = getChartColor(5); export {getChartColor, DEFAULT_CHART_COLOR}; From 03dc033b6bb9607a64042e50f2c543747f6beddf Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 13:57:25 -0800 Subject: [PATCH 06/26] Move GroupedItem to common types --- src/components/Search/SearchBarChart.tsx | 11 ++------ src/components/Search/SearchChartView.tsx | 28 ++------------------ src/components/Search/SearchLineChart.tsx | 11 ++------ src/components/Search/types.ts | 31 ++++++++++++++++++++++- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index a8b012167ade2..874ee22f6393f 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,17 +1,10 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; import type {ChartDataPoint} from '@components/Charts'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionWithdrawalIDGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; - -type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; +import type {GroupedItem} from './types'; type SearchBarChartProps = { /** Grouped transaction data from search results */ diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 0d5cde51e236d..f57b66cdaaf2c 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -2,19 +2,7 @@ import React, {useCallback, useMemo} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionMerchantGroupListItemType, - TransactionMonthGroupListItemType, - TransactionQuarterGroupListItemType, - TransactionTagGroupListItemType, - TransactionWeekGroupListItemType, - TransactionWithdrawalIDGroupListItemType, - TransactionYearGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -28,19 +16,7 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchBarChart from './SearchBarChart'; import SearchLineChart from './SearchLineChart'; -import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; - -type GroupedItem = - | TransactionMemberGroupListItemType - | TransactionCardGroupListItemType - | TransactionWithdrawalIDGroupListItemType - | TransactionCategoryGroupListItemType - | TransactionMerchantGroupListItemType - | TransactionTagGroupListItemType - | TransactionMonthGroupListItemType - | TransactionWeekGroupListItemType - | TransactionYearGroupListItemType - | TransactionQuarterGroupListItemType; +import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './types'; type ChartGroupByConfig = { titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index a726dddf4641c..d83607b7cc028 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,17 +1,10 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; import type {ChartDataPoint} from '@components/Charts'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionWithdrawalIDGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; - -type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; +import type {GroupedItem} from './types'; type SearchLineChartProps = { /** Grouped transaction data from search results */ diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 953aaca2848f6..a232c0463ec8a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,6 +1,21 @@ import type {ValueOf} from 'type-fest'; import type {PaymentMethod} from '@components/KYCWall/types'; -import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import type { + ReportActionListItemType, + TaskListItemType, + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; import type {SearchKey} from '@libs/SearchUIUtils'; import type CONST from '@src/CONST'; import type {Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; @@ -307,6 +322,19 @@ type BankAccountMenuItem = { value: PaymentMethod; }; +/** Union type representing all possible grouped transaction item types used in chart views */ +type GroupedItem = + | TransactionMemberGroupListItemType + | TransactionCardGroupListItemType + | TransactionWithdrawalIDGroupListItemType + | TransactionCategoryGroupListItemType + | TransactionMerchantGroupListItemType + | TransactionTagGroupListItemType + | TransactionMonthGroupListItemType + | TransactionWeekGroupListItemType + | TransactionYearGroupListItemType + | TransactionQuarterGroupListItemType; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -353,4 +381,5 @@ export type { SearchTextFilterKeys, BankAccountMenuItem, SearchCustomColumnIds, + GroupedItem, }; From e53afcd53e9cd6d1c544771027e7dbfb9f5e30c5 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:05:36 -0800 Subject: [PATCH 07/26] Fix domain padding in BarChart --- src/components/Charts/BarChart/BarChartContent.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 08cffc68f0c66..e3e6be5e81026 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -37,12 +37,9 @@ type BarChartProps = CartesianChartProps & { /** Inner padding between bars (0.3 = 30% of bar width) */ const BAR_INNER_PADDING = 0.3; -/** Domain padding configuration for the bar chart */ +/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ const DOMAIN_PADDING = { - left: 0, - right: 16, - top: 30, - bottom: 10, + top: 32, }; /** Safety buffer multiplier for domain padding calculation */ @@ -114,10 +111,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const domainPadding = useMemo(() => { if (chartWidth === 0) { - return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + return {...DOMAIN_PADDING, left: 0, right: 0}; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + return {...DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ From d5966d6d8cdf6c2662d5775a2f4813807a40833d Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:09:24 -0800 Subject: [PATCH 08/26] Fix BarChart domain padding --- src/components/Charts/BarChart/BarChartContent.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index e3e6be5e81026..5b52b9913f6bc 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -40,6 +40,9 @@ const BAR_INNER_PADDING = 0.3; /** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ const DOMAIN_PADDING = { top: 32, + bottom: 0, + left: 0, + right: 0, }; /** Safety buffer multiplier for domain padding calculation */ @@ -114,7 +117,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return {...DOMAIN_PADDING, left: 0, right: 0}; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {...DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right}; + return {...DOMAIN_PADDING, right: horizontalPadding + DOMAIN_PADDING.right, left: horizontalPadding}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ From d3f8df9ee279954c8ce9a206122ed44baa6fc1a4 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:12:33 -0800 Subject: [PATCH 09/26] Bring back removed type imports --- src/components/Search/SearchChartView.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index f57b66cdaaf2c..32cb4f9385f98 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -2,7 +2,19 @@ import React, {useCallback, useMemo} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; +import type { + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; From c6435780ad0ff665a80063d002d9579b5b015963 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:30:03 -0800 Subject: [PATCH 10/26] Address comments from previous PR --- .../Charts/components/ChartTooltip.tsx | 1 + .../Charts/hooks/useChartInteractionState.ts | 26 +++------------ .../Charts/hooks/useChartInteractions.ts | 32 +++++-------------- .../Charts/hooks/useChartLabelLayout.ts | 4 +-- src/components/Charts/utils.ts | 16 ++++++---- src/components/Search/SearchBarChart.tsx | 16 +++++----- src/components/Search/SearchChartView.tsx | 8 ++--- src/components/Search/SearchLineChart.tsx | 16 +++++----- 8 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index 83e6d4b2f9dbd..be005042afb50 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; + /** The height of the chart tooltip pointer */ const TOOLTIP_POINTER_HEIGHT = 4; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts index 344832485fe1f..a2d15a86e4d6f 100644 --- a/src/components/Charts/hooks/useChartInteractionState.ts +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -22,13 +22,16 @@ type ChartInteractionStateInit = { type ChartInteractionState = { /** Whether interaction (hover/press) is currently active */ isActive: SharedValue; + /** Index of the matched data point (-1 if none) */ matchedIndex: SharedValue; + /** X-axis value and position */ x: { value: SharedValue; position: SharedValue; }; + /** Y-axis values and positions for each y key */ y: Record< keyof Init['y'], @@ -37,8 +40,10 @@ type ChartInteractionState = { position: SharedValue; } >; + /** Y index for stacked bar charts */ yIndex: SharedValue; + /** Raw cursor position */ cursor: { x: SharedValue; @@ -68,27 +73,6 @@ function useIsInteractionActive(state: C /** * Creates shared state for chart interactions (hover, tap, press). * Compatible with Victory Native's handleTouch function exposed via actionsRef. - * - * @param initialValues - Initial x and y values matching your chart data structure - * @returns Object containing the interaction state and a boolean indicating if interaction is active - * - * @example - * ```tsx - * const { state, isActive } = useChartInteractionState({ - * x: '', - * y: { value: 0 } - * }); - * - * // Use with customGestures and actionsRef - * const hoverGesture = Gesture.Hover() - * .onUpdate((e) => { - * state.isActive.set(true); - * actionsRef.current?.handleTouch(state, e.x, e.y); - * }) - * .onEnd(() => { - * state.isActive.set(false); - * }); - * ``` */ function useChartInteractionState( initialValues: Init, diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 3cb17d796b71a..065eadcd3188e 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -14,12 +14,16 @@ const TOOLTIP_BAR_GAP = 8; type HitTestArgs = { /** Current raw X position of the cursor */ cursorX: number; + /** Current raw Y position of the cursor */ cursorY: number; + /** Calculated X position of the matched data point */ targetX: number; + /** Calculated Y position of the matched data point */ targetY: number; + /** The bottom boundary of the chart area */ chartBottom: number; }; @@ -30,11 +34,13 @@ type HitTestArgs = { type UseChartInteractionsProps = { /** Callback triggered when a valid data point is tapped/clicked */ handlePress: (index: number) => void; + /** * Worklet function to determine if the cursor is technically "hovering" * over a specific chart element (e.g., within a bar's width or a point's radius). */ checkIsOver: (args: HitTestArgs) => boolean; + /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ barGeometry?: SharedValue<{barWidth: number; chartBottom: number; yZero: number}>; }; @@ -48,33 +54,11 @@ type CartesianActionsHandle = { }; /** - * Hook to manage complex chart interactions including hover gestures (web), + * Hook to manage chart interactions including hover gestures (web), * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. * - * It synchronizes high-frequency interaction data from the UI thread to React state + * Synchronizes high-frequency interaction data from the UI thread to React state * for metadata display (like tooltips) and navigation. - * - * @param props - Configuration including press handlers and hit-test logic. - * @returns An object containing refs, gestures, and state for the chart component. - * - * @example - * ```tsx - * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ - * handlePress: (index) => console.log("Pressed index:", index), - * checkIsOver: ({ cursorX, targetX, barWidth }) => { - * 'worklet'; - * return Math.abs(cursorX - targetX) < barWidth / 2; - * }, - * barGeometry: myBarSharedValue, - * }); - * - * return ( - * - * - * {isTooltipActive && } - * - * ); - * ``` */ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 108aa027ed4fb..e41bd9ff21294 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -2,10 +2,10 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; -/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ +/** Rotation angle for X-axis labels - 45 degrees */ const X_AXIS_LABEL_ROTATION_45 = -45; -/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ +/** Rotation angle for X-axis labels - 90 degrees */ const X_AXIS_LABEL_ROTATION_90 = -90; /** Sin of 45 degrees - used to calculate effective width of rotated labels */ diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index f56cd568b28c8..3dca6dc372f65 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -2,20 +2,22 @@ import colors from '@styles/theme/colors'; /** * Expensify Chart Color Palette. - * Sequence logic: - * 1. Row Sequence: 400, 600, 300, 500, 700 - * 2. Hue Order: Yellow, Tangerine, Pink, Green, Ice, Blue + * + * Shades are ordered (400, 600, 300, 500, 700) so that sequential colors have + * maximum contrast, making adjacent chart segments easy to distinguish. + * + * Within each shade, hues cycle: Yellow, Tangerine, Pink, Green, Ice, Blue. */ const CHART_PALETTE: string[] = (() => { - const rows = [400, 600, 300, 500, 700] as const; + const shades = [400, 600, 300, 500, 700] as const; const hues = ['yellow', 'tangerine', 'pink', 'green', 'ice', 'blue'] as const; const palette: string[] = []; - // Generate the 30 unique combinations (5 rows × 6 hues) - for (const row of rows) { + // Generate the 30 unique combinations (5 shades × 6 hues) + for (const shade of shades) { for (const hue of hues) { - const colorKey = `${hue}${row}`; + const colorKey = `${hue}${shade}`; if (colors[colorKey]) { palette.push(colors[colorKey]); } diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 874ee22f6393f..684ed04ed8b9c 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; -import type {ChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -22,8 +22,8 @@ type SearchBarChartProps = { /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; - /** Callback when a bar is pressed - receives the filter query to apply */ - onBarPress?: (filterQuery: string) => void; + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; /** Whether data is loading */ isLoading?: boolean; @@ -32,10 +32,10 @@ type SearchBarChartProps = { yAxisUnit?: string; /** Position of currency symbol relative to value */ - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; }; -function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { +function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { // Transform grouped transaction data to BarChart format const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { @@ -52,7 +52,7 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar const handleBarPress = useCallback( (dataPoint: ChartDataPoint, index: number) => { - if (!onBarPress) { + if (!onItemPress) { return; } @@ -62,9 +62,9 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar } const filterQuery = getFilterQuery(item as GroupedItem); - onBarPress(filterQuery); + onItemPress(filterQuery); }, - [data, getFilterQuery, onBarPress], + [data, getFilterQuery, onItemPress], ); return ( diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 32cb4f9385f98..7b0b82b56b8b6 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -152,9 +152,8 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: const title = translate(`search.chartTitles.${groupBy}`); const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; - const handleBarPress = useCallback( + const handleItemPress = useCallback( (filterQuery: string) => { - // Build new query string from current query + filter, then parse it const currentQueryString = buildSearchQueryString(queryJSON); const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); @@ -163,18 +162,15 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: return; } - // Modify the query object directly: remove groupBy and view to show table newQueryJSON.groupBy = undefined; newQueryJSON.view = CONST.SEARCH.VIEW.TABLE; - // Build the final query string and navigate const newQueryString = buildSearchQueryString(newQueryJSON); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString})); }, [queryJSON], ); - // Get currency symbol and position from first data item const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { const firstItem = data.at(0) as GroupedItem | undefined; const currency = firstItem?.currency ?? 'USD'; @@ -200,7 +196,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: titleIcon={titleIcon} getLabel={getLabel} getFilterQuery={getFilterQuery} - onBarPress={handleBarPress} + onItemPress={handleItemPress} isLoading={isLoading} yAxisUnit={yAxisUnit} yAxisUnitPosition={yAxisUnitPosition} diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index d83607b7cc028..5505a2a647d32 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; -import type {ChartDataPoint} from '@components/Charts'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -22,8 +22,8 @@ type SearchLineChartProps = { /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; - /** Callback when a point is pressed - receives the filter query to apply */ - onBarPress?: (filterQuery: string) => void; + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; /** Whether data is loading */ isLoading?: boolean; @@ -32,10 +32,10 @@ type SearchLineChartProps = { yAxisUnit?: string; /** Position of currency symbol relative to value */ - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; }; -function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { +function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { const groupedItem = item as GroupedItem; @@ -51,7 +51,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa const handlePointPress = useCallback( (dataPoint: ChartDataPoint, index: number) => { - if (!onBarPress) { + if (!onItemPress) { return; } @@ -61,9 +61,9 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onBa } const filterQuery = getFilterQuery(item as GroupedItem); - onBarPress(filterQuery); + onItemPress(filterQuery); }, - [data, getFilterQuery, onBarPress], + [data, getFilterQuery, onItemPress], ); return ( From 513f1ac24ba4acfc39510e9c3f80e85193111891 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:31:26 -0800 Subject: [PATCH 11/26] Add missing import --- src/components/Charts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index 85629c33083bd..f86738fa140a5 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -4,6 +4,6 @@ import ChartTooltip from './components/ChartTooltip'; import LineChart from './LineChart'; export {BarChart, ChartHeader, ChartTooltip, LineChart}; -export type {ChartDataPoint, CartesianChartProps} from './types'; +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition} from './types'; export type {BarChartProps} from './BarChart/BarChartContent'; export type {LineChartProps} from './LineChart/LineChartContent'; From b7b1a172c66bc4163ac98ac32f9ea2fa825e436c Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 14:47:27 -0800 Subject: [PATCH 12/26] Format code and add type guard for grouped data --- .../Charts/BarChart/BarChartContent.tsx | 11 ++------ src/components/Charts/BarChart/index.tsx | 2 +- .../Charts/LineChart/LineChartContent.tsx | 26 ++++++------------- src/components/Charts/LineChart/index.tsx | 2 +- src/components/Search/SearchBarChart.tsx | 12 ++++----- src/components/Search/SearchChartView.tsx | 5 ++-- src/components/Search/SearchLineChart.tsx | 12 ++++----- src/components/Search/index.tsx | 5 ++-- src/libs/SearchUIUtils.ts | 11 ++++++++ 9 files changed, 38 insertions(+), 48 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 5b52b9913f6bc..af40d0e8c6d46 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -8,19 +8,12 @@ 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 {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; -import { - CHART_CONTENT_MIN_HEIGHT, - CHART_PADDING, - X_AXIS_LINE_WIDTH, - Y_AXIS_LABEL_OFFSET, - Y_AXIS_LINE_WIDTH, - Y_AXIS_TICK_COUNT, -} from '@components/Charts/constants'; +import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; +import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 0d00fb501193c..90e4bee47edb4 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -2,8 +2,8 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {BarChartProps} from './BarChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {BarChartProps} from './BarChartContent'; function BarChart(props: BarChartProps) { const styles = useThemeStyles(); diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 6e7e0e5fa13c5..140f704a9f0a6 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -7,19 +7,12 @@ import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; -import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; -import { - CHART_CONTENT_MIN_HEIGHT, - CHART_PADDING, - X_AXIS_LINE_WIDTH, - Y_AXIS_LABEL_OFFSET, - Y_AXIS_LINE_WIDTH, - Y_AXIS_TICK_COUNT, -} from '@components/Charts/constants'; +import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; +import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -86,16 +79,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn truncatedLabels, }); - const checkIsOverDot = useCallback( - (args: HitTestArgs) => { - 'worklet'; + const checkIsOverDot = useCallback((args: HitTestArgs) => { + 'worklet'; - const dx = args.cursorX - args.targetX; - const dy = args.cursorY - args.targetY; - return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; - }, - [], - ); + const dx = args.cursorX - args.targetX; + const dy = args.cursorY - args.targetY; + return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; + }, []); const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ handlePress: handlePointPress, diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx index 492b278697b71..907d385722f8e 100644 --- a/src/components/Charts/LineChart/index.tsx +++ b/src/components/Charts/LineChart/index.tsx @@ -2,8 +2,8 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; import React from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {LineChartProps} from './LineChartContent'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {LineChartProps} from './LineChartContent'; function LineChart(props: LineChartProps) { const styles = useThemeStyles(); diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 684ed04ed8b9c..18b4beb3045f9 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,14 +1,13 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; import type {GroupedItem} from './types'; type SearchBarChartProps = { /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Chart title */ title: string; @@ -39,12 +38,11 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onIte // Transform grouped transaction data to BarChart format const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { - const groupedItem = item as GroupedItem; - const currency = groupedItem.currency ?? 'USD'; - const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); return { - label: getLabel(groupedItem), + label: getLabel(item), total: totalInDisplayUnits, }; }); @@ -61,7 +59,7 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onIte return; } - const filterQuery = getFilterQuery(item as GroupedItem); + const filterQuery = getFilterQuery(item); onItemPress(filterQuery); }, [data, getFilterQuery, onItemPress], diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 7b0b82b56b8b6..4f34f3e67a525 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -5,7 +5,6 @@ import Animated from 'react-native-reanimated'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, - TransactionGroupListItemType, TransactionMemberGroupListItemType, TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, @@ -121,7 +120,7 @@ type SearchChartViewProps = { groupBy: SearchGroupBy; /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Whether data is loading */ isLoading?: boolean; @@ -172,7 +171,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: ); const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { - const firstItem = data.at(0) as GroupedItem | undefined; + const firstItem = data.at(0); const currency = firstItem?.currency ?? 'USD'; const {symbol, position} = getCurrencyDisplayInfoForCharts(currency); diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index 5505a2a647d32..b066990219337 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -1,14 +1,13 @@ import React, {useCallback, useMemo} from 'react'; import {LineChart} from '@components/Charts'; import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; -import type {TransactionGroupListItemType} from '@components/SelectionListWithSections/types'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; import type {GroupedItem} from './types'; type SearchLineChartProps = { /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Chart title */ title: string; @@ -38,12 +37,11 @@ type SearchLineChartProps = { function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { - const groupedItem = item as GroupedItem; - const currency = groupedItem.currency ?? 'USD'; - const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); return { - label: getLabel(groupedItem), + label: getLabel(item), total: totalInDisplayUnits, }; }); @@ -60,7 +58,7 @@ function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onIt return; } - const filterQuery = getFilterQuery(item as GroupedItem); + const filterQuery = getFilterQuery(item); onItemPress(filterQuery); }, [data, getFilterQuery, onItemPress], diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index eaa360727828b..2e1d087b315f4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -56,6 +56,7 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, + isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, @@ -1307,14 +1308,14 @@ function Search({ const shouldShowChartView = (view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE) && !!validGroupBy; - if (shouldShowChartView) { + if (shouldShowChartView && isGroupedItemArray(sortedData)) { return ( diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d6b32ac056454..387cfec489822 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -25,6 +25,7 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, + GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; @@ -1036,6 +1037,15 @@ function isTransactionQuarterGroupListItemType(item: ListItem): item is Transact return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER; } +/** + * Type guard that checks if a list of search items contains grouped transaction data. + * When a search has a groupBy parameter, all items share the same shape, so checking the first element is sufficient. + */ +function isGroupedItemArray(data: ListItem[]): data is GroupedItem[] { + const first = data.at(0); + return data.length === 0 || (first !== undefined && isTransactionGroupListItemType(first) && 'groupedBy' in first); +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -4496,6 +4506,7 @@ export { isTransactionWeekGroupListItemType, isTransactionYearGroupListItemType, isTransactionQuarterGroupListItemType, + isGroupedItemArray, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, From 01ab33f5ce088a5600af0c518dc661fe0c848d2f Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 15:11:00 -0800 Subject: [PATCH 13/26] Fix label rendering issues --- .../Charts/BarChart/BarChartContent.tsx | 39 +++++++++---------- .../Charts/LineChart/LineChartContent.tsx | 9 +++-- src/components/Charts/hooks/index.ts | 1 + .../Charts/hooks/useChartBoundsTracking.ts | 27 +++++++++++++ .../Charts/hooks/useChartLabelLayout.ts | 10 ++--- src/components/Search/index.tsx | 2 +- src/libs/SearchUIUtils.ts | 2 +- 7 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 src/components/Charts/hooks/useChartBoundsTracking.ts diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index af40d0e8c6d46..06672891b0722 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,7 +62,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); - const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; @@ -97,11 +96,28 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setContainerHeight(height); }, []); + // Store bar geometry for hit-testing (only constants, no arrays) + const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); + + const onBoundsChange = useCallback( + (bounds: ChartBounds, width: number) => { + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * width) / data.length; + barGeometry.set({ + ...barGeometry.get(), + barWidth: calculatedBarWidth, + chartBottom: bounds.bottom, + }); + }, + [data.length, barGeometry], + ); + + const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(onBoundsChange); + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - barAreaWidth, + plotAreaWidth, containerHeight, }); @@ -122,23 +138,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni truncatedLabels, }); - // Store bar geometry for hit-testing (only constants, no arrays) - const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); - - const handleChartBoundsChange = useCallback( - (bounds: ChartBounds) => { - const domainWidth = bounds.right - bounds.left; - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; - barGeometry.set({ - ...barGeometry.get(), - barWidth: calculatedBarWidth, - chartBottom: bounds.bottom, - }); - setBarAreaWidth(domainWidth); - }, - [data.length, barGeometry], - ); - const handleScaleChange = useCallback( (_xScale: unknown, yScale: (value: number) => number) => { barGeometry.set({ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 140f704a9f0a6..340bf66ebe101 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -10,7 +10,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,11 +62,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setContainerHeight(height); }, []); + const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(); + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - barAreaWidth: chartWidth, + plotAreaWidth, containerHeight, }); @@ -128,9 +130,10 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} + domainPadding={{left: 20, right: 40, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} + onChartBoundsChange={handleChartBoundsChange} xAxis={{ font, tickCount: data.length, diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index ac5272aa5b566..c1996fad8d443 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,3 +1,4 @@ +export {useChartBoundsTracking} from './useChartBoundsTracking'; export {useChartInteractionState} from './useChartInteractionState'; export {useChartLabelLayout} from './useChartLabelLayout'; export {useChartInteractions} from './useChartInteractions'; diff --git a/src/components/Charts/hooks/useChartBoundsTracking.ts b/src/components/Charts/hooks/useChartBoundsTracking.ts new file mode 100644 index 0000000000000..8b5188929617d --- /dev/null +++ b/src/components/Charts/hooks/useChartBoundsTracking.ts @@ -0,0 +1,27 @@ +import {useCallback, useState} from 'react'; +import type {ChartBounds} from 'victory-native'; + +type OnBoundsChange = (bounds: ChartBounds, plotAreaWidth: number) => void; + +/** + * Reusable hook that tracks the plot area width from CartesianChart's `onChartBoundsChange`. + * + * @param onBoundsChange - Optional callback for chart-specific logic (e.g. BarChart bar geometry). + */ +function useChartBoundsTracking(onBoundsChange?: OnBoundsChange) { + const [plotAreaWidth, setPlotAreaWidth] = useState(0); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + const width = bounds.right - bounds.left; + setPlotAreaWidth(width); + onBoundsChange?.(bounds, width); + }, + [onBoundsChange], + ); + + return {plotAreaWidth, handleChartBoundsChange}; +} + +export {useChartBoundsTracking}; +export type {OnBoundsChange}; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index e41bd9ff21294..18a3a4d1a45e5 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -29,7 +29,7 @@ type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; chartWidth: number; - barAreaWidth: number; + plotAreaWidth: number; containerHeight: number; }; @@ -43,7 +43,7 @@ function measureTextWidth(text: string, font: SkFont): number { return glyphWidths.reduce((sum, w) => sum + w, 0); } -function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHeight}: LabelLayoutConfig) { return useMemo(() => { if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; @@ -133,9 +133,9 @@ function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHei // Calculate skip interval using spec formula: // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) // skipInterval = ceil(barCount / maxVisibleLabels) - // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth + // Use plotAreaWidth (actual plotting area from chartBounds) rather than chartWidth // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = barAreaWidth || chartWidth; + const labelAreaWidth = plotAreaWidth || chartWidth; const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); // When maxVisibleLabels is 0 (area too narrow for even one label) or less than // data.length, compute the interval. data.length is the safe upper bound — show @@ -151,7 +151,7 @@ function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHei } return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; - }, [font, chartWidth, barAreaWidth, containerHeight, data]); + }, [font, chartWidth, plotAreaWidth, containerHeight, data]); } export {useChartLabelLayout}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2e1d087b315f4..8750ddb30642e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,6 +49,7 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, + isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -56,7 +57,6 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, - isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 387cfec489822..446cc1df6bb8a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,6 +11,7 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { + GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -25,7 +26,6 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, - GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; From d584243d9ac2d221581c44ea59b2fbb9c10dc31d Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Fri, 30 Jan 2026 15:18:17 -0800 Subject: [PATCH 14/26] Revert "Fix label rendering issues" This reverts commit 01ab33f5ce088a5600af0c518dc661fe0c848d2f. --- .../Charts/BarChart/BarChartContent.tsx | 39 ++++++++++--------- .../Charts/LineChart/LineChartContent.tsx | 9 ++--- src/components/Charts/hooks/index.ts | 1 - .../Charts/hooks/useChartBoundsTracking.ts | 27 ------------- .../Charts/hooks/useChartLabelLayout.ts | 10 ++--- src/components/Search/index.tsx | 2 +- src/libs/SearchUIUtils.ts | 2 +- 7 files changed, 30 insertions(+), 60 deletions(-) delete mode 100644 src/components/Charts/hooks/useChartBoundsTracking.ts diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 06672891b0722..af40d0e8c6d46 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,6 +62,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); + const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; @@ -96,28 +97,11 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setContainerHeight(height); }, []); - // Store bar geometry for hit-testing (only constants, no arrays) - const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); - - const onBoundsChange = useCallback( - (bounds: ChartBounds, width: number) => { - const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * width) / data.length; - barGeometry.set({ - ...barGeometry.get(), - barWidth: calculatedBarWidth, - chartBottom: bounds.bottom, - }); - }, - [data.length, barGeometry], - ); - - const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(onBoundsChange); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - plotAreaWidth, + barAreaWidth, containerHeight, }); @@ -138,6 +122,23 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni truncatedLabels, }); + // Store bar geometry for hit-testing (only constants, no arrays) + const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0, yZero: 0}); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + const domainWidth = bounds.right - bounds.left; + const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; + barGeometry.set({ + ...barGeometry.get(), + barWidth: calculatedBarWidth, + chartBottom: bounds.bottom, + }); + setBarAreaWidth(domainWidth); + }, + [data.length, barGeometry], + ); + const handleScaleChange = useCallback( (_xScale: unknown, yScale: (value: number) => number) => { barGeometry.set({ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 340bf66ebe101..140f704a9f0a6 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -10,7 +10,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartBoundsTracking, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -62,13 +62,11 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setContainerHeight(height); }, []); - const {plotAreaWidth, handleChartBoundsChange} = useChartBoundsTracking(); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - plotAreaWidth, + barAreaWidth: chartWidth, containerHeight, }); @@ -130,10 +128,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={{left: 20, right: 40, top: 20, bottom: 20}} + domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} actionsRef={actionsRef} customGestures={customGestures} - onChartBoundsChange={handleChartBoundsChange} xAxis={{ font, tickCount: data.length, diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index c1996fad8d443..ac5272aa5b566 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,4 +1,3 @@ -export {useChartBoundsTracking} from './useChartBoundsTracking'; export {useChartInteractionState} from './useChartInteractionState'; export {useChartLabelLayout} from './useChartLabelLayout'; export {useChartInteractions} from './useChartInteractions'; diff --git a/src/components/Charts/hooks/useChartBoundsTracking.ts b/src/components/Charts/hooks/useChartBoundsTracking.ts deleted file mode 100644 index 8b5188929617d..0000000000000 --- a/src/components/Charts/hooks/useChartBoundsTracking.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {useCallback, useState} from 'react'; -import type {ChartBounds} from 'victory-native'; - -type OnBoundsChange = (bounds: ChartBounds, plotAreaWidth: number) => void; - -/** - * Reusable hook that tracks the plot area width from CartesianChart's `onChartBoundsChange`. - * - * @param onBoundsChange - Optional callback for chart-specific logic (e.g. BarChart bar geometry). - */ -function useChartBoundsTracking(onBoundsChange?: OnBoundsChange) { - const [plotAreaWidth, setPlotAreaWidth] = useState(0); - - const handleChartBoundsChange = useCallback( - (bounds: ChartBounds) => { - const width = bounds.right - bounds.left; - setPlotAreaWidth(width); - onBoundsChange?.(bounds, width); - }, - [onBoundsChange], - ); - - return {plotAreaWidth, handleChartBoundsChange}; -} - -export {useChartBoundsTracking}; -export type {OnBoundsChange}; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 18a3a4d1a45e5..e41bd9ff21294 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -29,7 +29,7 @@ type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; chartWidth: number; - plotAreaWidth: number; + barAreaWidth: number; containerHeight: number; }; @@ -43,7 +43,7 @@ function measureTextWidth(text: string, font: SkFont): number { return glyphWidths.reduce((sum, w) => sum + w, 0); } -function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { return useMemo(() => { if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; @@ -133,9 +133,9 @@ function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHe // Calculate skip interval using spec formula: // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) // skipInterval = ceil(barCount / maxVisibleLabels) - // Use plotAreaWidth (actual plotting area from chartBounds) rather than chartWidth + // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = plotAreaWidth || chartWidth; + const labelAreaWidth = barAreaWidth || chartWidth; const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); // When maxVisibleLabels is 0 (area too narrow for even one label) or less than // data.length, compute the interval. data.length is the safe upper bound — show @@ -151,7 +151,7 @@ function useChartLabelLayout({data, font, chartWidth, plotAreaWidth, containerHe } return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; - }, [font, chartWidth, plotAreaWidth, containerHeight, data]); + }, [font, chartWidth, barAreaWidth, containerHeight, data]); } export {useChartLabelLayout}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8750ddb30642e..2e1d087b315f4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,7 +49,6 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, - isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -57,6 +56,7 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, + isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 446cc1df6bb8a..387cfec489822 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,7 +11,6 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { - GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -26,6 +25,7 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, + GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; From f21f713b29699530e3b74d70419175eb3ec61811 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Tue, 3 Feb 2026 13:59:01 +0100 Subject: [PATCH 15/26] Add mising d3 types to devDependencies --- package-lock.json | 18 ++++++++++++++++++ package.json | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index d29b3b0c6df49..930c4b00eb965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -204,6 +204,7 @@ "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", + "@types/d3-scale": "^4.0.9", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/jest-when": "^3.5.2", @@ -16350,6 +16351,23 @@ "@types/node": "*" } }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", diff --git a/package.json b/package.json index 0d7308e984762..d5a5888ad2c29 100644 --- a/package.json +++ b/package.json @@ -143,9 +143,9 @@ "expo-location": "^19.0.7", "expo-modules-core": "3.0.18", "expo-secure-store": "~14.2.4", - "expo-video": "^3.0.12", "expo-store-review": "~9.0.8", "expo-task-manager": "~14.0.9", + "expo-video": "^3.0.12", "fast-equals": "^5.2.2", "focus-trap-react": "^11.0.3", "group-ib-fp": "file:modules/group-ib-fp", @@ -273,6 +273,7 @@ "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", + "@types/d3-scale": "^4.0.9", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/jest-when": "^3.5.2", From 023a2a1beacacd534a57b5beea674f4abee2c607 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Tue, 3 Feb 2026 14:38:19 +0100 Subject: [PATCH 16/26] Fix label rendering in LineChart by introducing custom label component --- .../Charts/BarChart/BarChartContent.tsx | 54 +++-- .../Charts/LineChart/LineChartContent.tsx | 107 +++++++++- src/components/Charts/hooks/index.ts | 2 +- .../Charts/hooks/useChartLabelFormats.ts | 3 +- .../Charts/hooks/useChartLabelLayout.ts | 201 ++++++++---------- src/components/Charts/utils.ts | 12 +- src/components/Search/index.tsx | 2 +- src/libs/SearchUIUtils.ts | 2 +- 8 files changed, 225 insertions(+), 158 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index af40d0e8c6d46..71b4e329d681a 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import Animated, {useSharedValue} from 'react-native-reanimated'; -import type {ChartBounds, PointsArray} from 'victory-native'; +import type {ChartBounds, PointsArray, Scale} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {LABEL_ROTATIONS, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -19,43 +19,38 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -type BarChartProps = CartesianChartProps & { - /** Callback when a bar is pressed */ - onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; - - /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ - useSingleColor?: boolean; -}; - /** Inner padding between bars (0.3 = 30% of bar width) */ const BAR_INNER_PADDING = 0.3; -/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ -const DOMAIN_PADDING = { - top: 32, - bottom: 0, - left: 0, - right: 0, -}; - /** Safety buffer multiplier for domain padding calculation */ const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; +/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ +const BASE_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0}; + /** - * Calculate minimum domainPadding required to prevent bars from overflowing chart edges. + * Calculate minimum domainPadding required to prevent data points from crowding chart edges. * - * The issue: victory-native calculates bar width as (1 - innerPadding) * chartWidth / barCount, - * but positions bars at indices [0, 1, ..., n-1] scaled to the chart width with domainPadding. - * For small bar counts, the default padding is insufficient and bars overflow. + * For evenly-spaced data points, Victory maps indices [0..N-1] into the output range. + * Without enough padding the first/last points sit too close to the View boundary + * and their centered labels get clipped by overflow:hidden. */ -function calculateMinDomainPadding(chartWidth: number, barCount: number, innerPadding: number): number { - if (barCount <= 0) { +function calculateMinDomainPadding(chartWidth: number, pointCount: number, innerPadding: number): number { + if (pointCount <= 0) { return 0; } - const minPaddingRatio = (1 - innerPadding) / (2 * (barCount - 1 + innerPadding)); + const minPaddingRatio = (1 - innerPadding) / (2 * (pointCount - 1 + innerPadding)); return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER); } +type BarChartProps = CartesianChartProps & { + /** Callback when a bar is pressed */ + onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; + + /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ + useSingleColor?: boolean; +}; + function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -64,7 +59,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const [chartWidth, setChartWidth] = useState(0); const [barAreaWidth, setBarAreaWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); - const defaultBarColor = DEFAULT_CHART_COLOR; // prepare data for display @@ -107,10 +101,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const domainPadding = useMemo(() => { if (chartWidth === 0) { - return {...DOMAIN_PADDING, left: 0, right: 0}; + return BASE_DOMAIN_PADDING; } const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING); - return {...DOMAIN_PADDING, right: horizontalPadding + DOMAIN_PADDING.right, left: horizontalPadding}; + return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ @@ -140,7 +134,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni ); const handleScaleChange = useCallback( - (_xScale: unknown, yScale: (value: number) => number) => { + (_xScale: Scale, yScale: Scale) => { barGeometry.set({ ...barGeometry.get(), yZero: yScale(0), @@ -226,7 +220,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni titleIcon={titleIcon} /> {chartWidth > 0 && ( diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 140f704a9f0a6..1e5fcf96ef537 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -1,8 +1,9 @@ -import {useFont} from '@shopify/react-native-skia'; +import {Group, Text as SkiaText, useFont, vec} from '@shopify/react-native-skia'; import React, {useCallback, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import Animated from 'react-native-reanimated'; +import type {CartesianChartRenderArg, ChartBounds} from 'victory-native'; import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; @@ -10,16 +11,16 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {LABEL_ROTATIONS, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {DEFAULT_CHART_COLOR} from '@components/Charts/utils'; +import {DEFAULT_CHART_COLOR, measureTextWidth} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; /** Inner dot radius for line chart data points */ -const DOT_INNER_RADIUS = 6; +const DOT_RADIUS = 6; type LineChartProps = CartesianChartProps & { /** Callback when a data point is pressed */ @@ -33,6 +34,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); + const [plotAreaWidth, setPlotAreaWidth] = useState(0); const yAxisDomain = useDynamicYDomain(data); @@ -62,14 +64,29 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setContainerHeight(height); }, []); + const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { + setPlotAreaWidth(bounds.right - bounds.left); + }, []); + const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ data, font, chartWidth, - barAreaWidth: chartWidth, + barAreaWidth: plotAreaWidth, containerHeight, }); + // 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 {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ data, yAxisUnit, @@ -84,7 +101,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const dx = args.cursorX - args.targetX; const dy = args.cursorY - args.targetY; - return Math.sqrt(dx * dx + dy * dy) <= DOT_INNER_RADIUS; + return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS; }, []); const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ @@ -94,6 +111,72 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); + // Victory's built-in x-axis labels center each label under its tick mark, + // which works well for bar charts where bars have width and natural spacing. + // For line charts, data points sit at the edges of the plot area, so centered + // labels get clipped or overflow the chart bounds. We render labels manually + // via `renderOutside` so we can right-align each label's last character at its + // tick position and clamp edge labels within the canvas. + const renderCustomXLabels = useCallback( + (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + if (!font) { + return null; + } + + const fontMetrics = font.getMetrics(); + const lineHeight = Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent); + const fontSize = font.getSize(); + const labelY = args.chartBounds.bottom + 2 + fontSize; + + return truncatedLabels.map((label, i) => { + if (i % labelSkipInterval !== 0) { + return null; + } + + const tickX = args.xScale(i); + const labelWidth = labelWidths.at(i) ?? 0; + + // Last character anchored at tickX, clamped to canvas edges. + const idealX = tickX - labelWidth; + const clampedX = Math.max(0, Math.min(args.canvasSize.width - labelWidth, idealX)); + + if (angleRad === 0) { + return ( + + ); + } + + // At 90° the rotated label's horizontal footprint is lineHeight; + // shift by half to center it on the tick mark. + const centeringOffset = labelRotation === -LABEL_ROTATIONS.VERTICAL ? lineHeight / 2 : 0; + const origin = vec(clampedX + labelWidth + centeringOffset, labelY); + return ( + + + + ); + }); + }, + [font, truncatedLabels, labelSkipInterval, labelWidths, angleRad, theme.textSupporting], + ); + const dynamicChartStyle = useMemo( () => ({ height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), @@ -120,7 +203,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn titleIcon={titleIcon} /> {chartWidth > 0 && ( @@ -128,13 +211,15 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn xKey="x" padding={CHART_PADDING} yKeys={['y']} - domainPadding={{left: 20, right: 20, top: 20, bottom: 20}} + domainPadding={16} actionsRef={actionsRef} customGestures={customGestures} + onChartBoundsChange={handleChartBoundsChange} + renderOutside={renderCustomXLabels} xAxis={{ font, tickCount: data.length, - labelColor: theme.textSupporting, + labelColor: 'transparent', lineWidth: X_AXIS_LINE_WIDTH, formatXLabel: formatXAxisLabel, labelRotate: labelRotation, @@ -152,7 +237,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn domain: yAxisDomain, }, ]} - frame={{lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}} + frame={{lineWidth: {left: 1, bottom: 1, top: 0, right: 0}, lineColor: theme.border}} data={chartData} > {({points}) => ( @@ -165,7 +250,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn /> diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index ac5272aa5b566..6b90bfca65bf0 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,5 +1,5 @@ export {useChartInteractionState} from './useChartInteractionState'; -export {useChartLabelLayout} from './useChartLabelLayout'; +export {LABEL_ROTATIONS, useChartLabelLayout} from './useChartLabelLayout'; export {useChartInteractions} from './useChartInteractions'; export type {HitTestArgs} from './useChartInteractions'; export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 31921462d8b8c..c87a2c6177ba1 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,5 +1,6 @@ import {useCallback} from 'react'; import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; +import {LABEL_ROTATIONS} from './useChartLabelLayout'; type UseChartLabelFormatsProps = { data: ChartDataPoint[]; @@ -36,7 +37,7 @@ export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition // Use pre-truncated labels // If rotation is vertical (-90), we usually want full labels // because they have more space vertically. - const sourceToUse = labelRotation === -90 ? data.map((p) => p.label) : truncatedLabels; + const sourceToUse = labelRotation === -LABEL_ROTATIONS.VERTICAL ? 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 e41bd9ff21294..39fce259acad8 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,24 +1,24 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; +import {measureTextWidth} from '@components/Charts/utils'; -/** Rotation angle for X-axis labels - 45 degrees */ -const X_AXIS_LABEL_ROTATION_45 = -45; +/** Supported label rotation angles in degrees */ +const LABEL_ROTATIONS = { + HORIZONTAL: 0, + DIAGONAL: 45, + VERTICAL: 90, +} as const; -/** Rotation angle for X-axis labels - 90 degrees */ -const X_AXIS_LABEL_ROTATION_90 = -90; +const SIN_45 = Math.sin(Math.PI / 4); -/** Sin of 45 degrees - used to calculate effective width of rotated labels */ -const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 - -/** Minimum padding between labels (in pixels) */ +/** Minimum gap between adjacent labels (px) */ const LABEL_PADDING = 4; -/** Maximum ratio of container height that X-axis labels can occupy. */ -const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; +/** X-axis labels can occupy at most this fraction of container height */ +const MAX_LABEL_HEIGHT_RATIO = 0.35; -/** Ellipsis character for truncated labels */ -const LABEL_ELLIPSIS = '...'; +const ELLIPSIS = '...'; type ChartDataPoint = { label: string; @@ -33,14 +33,65 @@ type LabelLayoutConfig = { containerHeight: number; }; +/** 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. */ +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; +} + +/** 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)); +} + +/** + * Pick the smallest rotation (0 → 45 → 90) where labels don't overlap, + * preferring rotation over skip interval. + */ +function pickRotation(maxLabelWidth: number, lineHeight: number, availableWidthPerLabel: number, labelArea: number, dataCount: number): number { + const candidates = [LABEL_ROTATIONS.HORIZONTAL, LABEL_ROTATIONS.DIAGONAL, LABEL_ROTATIONS.VERTICAL] as const; + for (const angle of candidates) { + const ew = effectiveWidth(maxLabelWidth, lineHeight, angle); + if (ew <= availableWidthPerLabel && maxVisibleCount(labelArea, ew) >= dataCount) { + return angle; + } + } + return LABEL_ROTATIONS.VERTICAL; +} + /** - * Measure the width of a text string using the font's glyph widths. - * Uses getGlyphWidths as measureText is not implemented on React Native Web. + * Max label pixel width allowed at a given rotation so labels don't exceed + * `MAX_LABEL_HEIGHT_RATIO` of the container height. + * + * Based on Victory's internal allocation formula: + * totalHeight = lineHeight + Y_AXIS_LABEL_OFFSET * 2 + labelWidth * sin(angle) + * (from victory-native-xl/src/cartesian/utils/transformInputData.ts) */ -function measureTextWidth(text: string, font: SkFont): number { - const glyphIDs = font.getGlyphIDs(text); - const glyphWidths = font.getGlyphWidths(glyphIDs); - return glyphWidths.reduce((sum, w) => sum + w, 0); +function maxAllowedWidth(rotation: number, lineHeight: number, containerHeight: number): number { + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + const budget = containerHeight * MAX_LABEL_HEIGHT_RATIO - (lineHeight + Y_AXIS_LABEL_OFFSET * 2); + return Math.max(0, budget) / SIN_45; + } + // 0° and 90°: no truncation (0° uses skip, 90° expands container) + return Infinity; } function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { @@ -49,110 +100,36 @@ function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHei return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; } - // Get font metrics const fontMetrics = font.getMetrics(); const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); - const ellipsisWidth = measureTextWidth(LABEL_ELLIPSIS, font); + const ellipsisWidth = measureTextWidth(ELLIPSIS, font); - // Calculate available dimensions - const availableWidthPerBar = chartWidth / data.length - LABEL_PADDING; - - // Measure original labels + const availableWidthPerLabel = chartWidth / data.length - LABEL_PADDING; const labelWidths = data.map((p) => measureTextWidth(p.label, font)); const maxLabelLength = Math.max(...labelWidths); + const labelArea = barAreaWidth || chartWidth; - // Helper to truncate a label to fit a max pixel width - const truncateToWidth = (label: string, labelWidth: number, maxWidth: number): string => { - if (labelWidth <= maxWidth) { - return label; - } - const availableWidth = maxWidth - ellipsisWidth; - if (availableWidth <= 0) { - return LABEL_ELLIPSIS; - } - const ratio = availableWidth / labelWidth; - const maxChars = Math.max(1, Math.floor(label.length * ratio)); - return label.slice(0, maxChars) + LABEL_ELLIPSIS; - }; - - // === DETERMINE ROTATION (based on WIDTH constraint, monotonic: 0° → 45° → 90°) === - let rotation = 0; - if (maxLabelLength > availableWidthPerBar) { - // Labels don't fit at 0°, try 45° - const effectiveWidthAt45 = maxLabelLength * SIN_45_DEGREES; - if (effectiveWidthAt45 <= availableWidthPerBar) { - rotation = 45; - } else { - // 45° doesn't fit either, use 90° - rotation = 90; - } - } - - // === DETERMINE TRUNCATION === - // Limit label area to X_AXIS_LABEL_MAX_HEIGHT_RATIO of container height. - // - // IMPLEMENTATION NOTE: We assume Victory allocates space for X-axis labels using: - // totalHeight = fontHeight + yAxis.labelOffset * 2 + labelWidth * sin(angle) - // This formula was found in: victory-native-xl/src/cartesian/utils/transformInputData.ts - // If Victory changes this formula, these calculations will need adjustment. - // - // We calculate max labelWidth so total allocation stays within our limit. - const maxLabelHeight = containerHeight * X_AXIS_LABEL_MAX_HEIGHT_RATIO; - const victoryBaseAllocation = lineHeight + Y_AXIS_LABEL_OFFSET * 2; - const availableForRotation = Math.max(0, maxLabelHeight - victoryBaseAllocation); - - let maxAllowedLabelWidth: number; - - if (rotation === 0) { - // At 0°: no truncation, use skip interval instead (like Google Sheets) - maxAllowedLabelWidth = Infinity; - } else if (rotation === 45) { - // At 45°: labelWidth * sin(45°) <= availableForRotation - // labelWidth <= availableForRotation / sin(45°) - maxAllowedLabelWidth = availableForRotation / SIN_45_DEGREES; - } else { - // At 90°: no truncation, container expands to accommodate labels - maxAllowedLabelWidth = Infinity; - } + // 1. Pick rotation + const rotation = pickRotation(maxLabelLength, lineHeight, availableWidthPerLabel, labelArea, data.length); - // Generate truncated labels - const finalLabels = data.map((p, i) => truncateToWidth(p.label, labelWidths.at(i) ?? 0, maxAllowedLabelWidth)); + // 2. Truncate labels (only effective at 45°) + const maxWidth = maxAllowedWidth(rotation, lineHeight, containerHeight); + const finalLabels = data.map((p, i) => truncateLabel(p.label, labelWidths.at(i) ?? 0, maxWidth, ellipsisWidth)); - // === CALCULATE SKIP INTERVAL === - // Calculate effective width based on rotation angle + // 3. Compute skip interval const finalMaxWidth = Math.max(...finalLabels.map((l) => measureTextWidth(l, font))); - let effectiveWidth: number; - if (rotation === 0) { - effectiveWidth = finalMaxWidth; - } else if (rotation === 45) { - effectiveWidth = finalMaxWidth * SIN_45_DEGREES; - } else { - effectiveWidth = lineHeight; // At 90°, width is the line height - } - - // Calculate skip interval using spec formula: - // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) - // skipInterval = ceil(barCount / maxVisibleLabels) - // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth - // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = barAreaWidth || chartWidth; - const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); - // When maxVisibleLabels is 0 (area too narrow for even one label) or less than - // data.length, compute the interval. data.length is the safe upper bound — show - // at most the first label. - const skipInterval = maxVisibleLabels >= data.length ? 1 : Math.ceil(data.length / Math.max(1, maxVisibleLabels)); - - // Convert rotation to negative degrees for Victory chart - let rotationValue = 0; - if (rotation === 45) { - rotationValue = X_AXIS_LABEL_ROTATION_45; - } else if (rotation === 90) { - rotationValue = X_AXIS_LABEL_ROTATION_90; - } - - return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; + const ew = effectiveWidth(finalMaxWidth, lineHeight, rotation); + const visible = maxVisibleCount(labelArea, ew); + const skipInterval = visible >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visible)); + + return { + labelRotation: -rotation, + labelSkipInterval: skipInterval, + truncatedLabels: finalLabels, + maxLabelLength, + }; }, [font, chartWidth, barAreaWidth, containerHeight, data]); } -export {useChartLabelLayout}; +export {LABEL_ROTATIONS, useChartLabelLayout}; export type {LabelLayoutConfig}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 3dca6dc372f65..e63b87e197748 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -1,3 +1,4 @@ +import type {SkFont} from '@shopify/react-native-skia'; import colors from '@styles/theme/colors'; /** @@ -41,4 +42,13 @@ function getChartColor(index: number): string { /** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ const DEFAULT_CHART_COLOR = getChartColor(5); -export {getChartColor, DEFAULT_CHART_COLOR}; +/** + * Measure pixel width of a string via glyph widths. + * (measureText is not implemented on React Native Web) + */ +function measureTextWidth(text: string, font: SkFont): number { + const glyphIDs = font.getGlyphIDs(text); + return font.getGlyphWidths(glyphIDs).reduce((sum, w) => sum + w, 0); +} + +export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2e1d087b315f4..8750ddb30642e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,6 +49,7 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, + isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -56,7 +57,6 @@ import { isTransactionCardGroupListItemType, isTransactionCategoryGroupListItemType, isTransactionGroupListItemType, - isGroupedItemArray, isTransactionListItemType, isTransactionMemberGroupListItemType, isTransactionMerchantGroupListItemType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 387cfec489822..446cc1df6bb8a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,6 +11,7 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { + GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -25,7 +26,6 @@ import type { SelectedTransactionInfo, SingularSearchStatus, SortOrder, - GroupedItem, } from '@components/Search/types'; import ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import ExpenseReportListItem from '@components/SelectionListWithSections/Search/ExpenseReportListItem'; From b283a70e8f2527b121d6c97a4fd2038681b18f99 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Tue, 3 Feb 2026 15:05:47 +0100 Subject: [PATCH 17/26] Fix spellcheck --- src/components/Charts/LineChart/LineChartContent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 1e5fcf96ef537..29b4324f7419f 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -143,7 +143,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn if (angleRad === 0) { return ( @@ -174,7 +174,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn ); }); }, - [font, truncatedLabels, labelSkipInterval, labelWidths, angleRad, theme.textSupporting], + [font, truncatedLabels, labelSkipInterval, labelWidths, angleRad, labelRotation, theme.textSupporting], ); const dynamicChartStyle = useMemo( From b631ccb28b8ca9f5a1cdc4daba59e01855247868 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Tue, 3 Feb 2026 15:14:31 +0100 Subject: [PATCH 18/26] Refactor useTooltipData and fix tests --- src/components/Charts/hooks/useTooltipData.ts | 5 +++-- tests/unit/Search/SearchUIUtilsTest.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/Charts/hooks/useTooltipData.ts b/src/components/Charts/hooks/useTooltipData.ts index 3a75db14a1068..2ec687681de92 100644 --- a/src/components/Charts/hooks/useTooltipData.ts +++ b/src/components/Charts/hooks/useTooltipData.ts @@ -12,6 +12,8 @@ type TooltipData = { * Computes the display amount (with optional currency unit) and the percentage relative to all data points. */ function useTooltipData(activeDataIndex: number, data: ChartDataPoint[], yAxisUnit?: string, yAxisUnitPosition?: YAxisUnitPosition): TooltipData | null { + const totalSum = useMemo(() => data.reduce((sum, point) => sum + Math.abs(point.total), 0), [data]); + return useMemo(() => { if (activeDataIndex < 0 || activeDataIndex >= data.length) { return null; @@ -27,14 +29,13 @@ function useTooltipData(activeDataIndex: number, data: ChartDataPoint[], yAxisUn const separator = yAxisUnit.length > 1 ? ' ' : ''; formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`; } - const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0); const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0; return { label: dataPoint.label, amount: formattedAmount, percentage: percent < 1 ? '<1%' : `${percent}%`, }; - }, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]); + }, [activeDataIndex, data, totalSum, yAxisUnit, yAxisUnitPosition]); } export {useTooltipData}; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 0dc7c41788c2a..e9bab9ee4b3a3 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -6242,11 +6242,12 @@ describe('SearchUIUtils', () => { }); describe('view autocomplete values', () => { - test('should include all view values (table, bar)', () => { + test('should include all view values (table, bar, line)', () => { const viewValues = Object.values(CONST.SEARCH.VIEW); expect(viewValues).toContain('table'); expect(viewValues).toContain('bar'); - expect(viewValues).toHaveLength(2); + expect(viewValues).toContain('line'); + expect(viewValues).toHaveLength(3); }); test('should correctly map view values to user-friendly values', () => { @@ -6254,7 +6255,7 @@ describe('SearchUIUtils', () => { const userFriendlyValues = viewValues.map((value) => getUserFriendlyValue(value)); // All view values should be mapped (they may be the same or different) - expect(userFriendlyValues).toHaveLength(2); + expect(userFriendlyValues).toHaveLength(3); expect(userFriendlyValues.every((value) => typeof value === 'string')).toBe(true); }); }); From c090cca69cad21e4f3550115b24668e9bf73ef41 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Wed, 4 Feb 2026 15:29:59 +0100 Subject: [PATCH 19/26] Improve label layout primitives for LineChart --- .../Charts/BarChart/BarChartContent.tsx | 20 ++- .../Charts/LineChart/LineChartContent.tsx | 95 ++++++------- src/components/Charts/hooks/index.ts | 2 +- .../Charts/hooks/useChartInteractions.ts | 32 +++-- .../Charts/hooks/useChartLabelFormats.ts | 10 +- .../Charts/hooks/useChartLabelLayout.ts | 130 +++++++++++------- src/components/Charts/utils.ts | 13 +- 7 files changed, 178 insertions(+), 124 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 71b4e329d681a..55f79db985ec5 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,7 +11,7 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {LABEL_ROTATIONS, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -58,7 +58,6 @@ 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 [containerHeight, setContainerHeight] = useState(0); const defaultBarColor = DEFAULT_CHART_COLOR; // prepare data for display @@ -86,17 +85,14 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni ); const handleLayout = useCallback((event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - setChartWidth(width); - setContainerHeight(height); + setChartWidth(event.nativeEvent.layout.width); }, []); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ + const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ data, font, - chartWidth, - barAreaWidth, - containerHeight, + tickSpacing: barAreaWidth > 0 ? barAreaWidth / data.length : 0, + labelAreaWidth: barAreaWidth, }); const domainPadding = useMemo(() => { @@ -196,9 +192,9 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni // This keeps bar area at ~250px while giving labels their needed vertical space const dynamicChartStyle = useMemo( () => ({ - height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), + height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0), }), - [maxLabelLength], + [xAxisLabelHeight], ); if (isLoading || !font) { @@ -220,7 +216,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni titleIcon={titleIcon} /> {chartWidth > 0 && ( diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 29b4324f7419f..6bc439d801b19 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -11,9 +11,9 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LABEL_OFFSET, 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'; -import {LABEL_ROTATIONS, useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {DEFAULT_CHART_COLOR, measureTextWidth} from '@components/Charts/utils'; +import {DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -22,6 +22,12 @@ import variables from '@styles/variables'; /** Inner dot radius for line chart data points */ const DOT_RADIUS = 6; +/** Extra pixel spacing between the plot boundary and the first/last data point */ +const LINE_CHART_DOMAIN_PADDING = 16; + +/** Gap between the x-axis line and the top of label glyphs */ +const X_AXIS_LABEL_GAP = 2; + type LineChartProps = CartesianChartProps & { /** Callback when a data point is pressed */ onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; @@ -33,8 +39,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); - const [containerHeight, setContainerHeight] = useState(0); - const [plotAreaWidth, setPlotAreaWidth] = useState(0); + const [chartBoundsInfo, setChartBoundsInfo] = useState({plotAreaWidth: 0, firstTickOffset: 0}); const yAxisDomain = useDynamicYDomain(data); @@ -59,21 +64,31 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn ); const handleLayout = useCallback((event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - setChartWidth(width); - setContainerHeight(height); + setChartWidth(event.nativeEvent.layout.width); }, []); const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { - setPlotAreaWidth(bounds.right - bounds.left); + setChartBoundsInfo({ + plotAreaWidth: bounds.right - bounds.left, + firstTickOffset: bounds.left + LINE_CHART_DOMAIN_PADDING, + }); }, []); - const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({ + const {plotAreaWidth, firstTickOffset} = chartBoundsInfo; + + const tickSpacing = useMemo(() => { + if (plotAreaWidth === 0 || data.length <= 1) { + return 0; + } + return (plotAreaWidth - 2 * LINE_CHART_DOMAIN_PADDING) / (data.length - 1); + }, [plotAreaWidth, data.length]); + + const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ data, font, - chartWidth, - barAreaWidth: plotAreaWidth, - containerHeight, + tickSpacing, + labelAreaWidth: plotAreaWidth, + firstTickOffset, }); // Measure label widths for custom positioning in `renderOutside` @@ -87,13 +102,10 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn // Convert hook's degree rotation to radians for Skia rendering const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; - const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ + const {formatYAxisLabel} = useChartLabelFormats({ data, yAxisUnit, yAxisUnitPosition, - labelSkipInterval, - labelRotation, - truncatedLabels, }); const checkIsOverDot = useCallback((args: HitTestArgs) => { @@ -112,11 +124,8 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); // Victory's built-in x-axis labels center each label under its tick mark, - // which works well for bar charts where bars have width and natural spacing. - // For line charts, data points sit at the edges of the plot area, so centered - // labels get clipped or overflow the chart bounds. We render labels manually - // via `renderOutside` so we can right-align each label's last character at its - // tick position and clamp edge labels within the canvas. + // which works for bar charts but clips labels on line charts where data points + // sit at the edges. We render labels via `renderOutside` with custom positioning. const renderCustomXLabels = useCallback( (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { if (!font) { @@ -124,9 +133,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn } const fontMetrics = font.getMetrics(); - const lineHeight = Math.abs(fontMetrics.ascent) + Math.abs(fontMetrics.descent); - const fontSize = font.getSize(); - const labelY = args.chartBounds.bottom + 2 + fontSize; + const ascent = Math.abs(fontMetrics.ascent); + const descent = Math.abs(fontMetrics.descent); + const labelY = args.chartBounds.bottom + X_AXIS_LABEL_GAP + font.getSize(); return truncatedLabels.map((label, i) => { if (i % labelSkipInterval !== 0) { @@ -136,15 +145,11 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const tickX = args.xScale(i); const labelWidth = labelWidths.at(i) ?? 0; - // Last character anchored at tickX, clamped to canvas edges. - const idealX = tickX - labelWidth; - const clampedX = Math.max(0, Math.min(args.canvasSize.width - labelWidth, idealX)); - if (angleRad === 0) { return ( descent shifts the visual center left of the anchor). + const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); + return ( ({ - height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0), + height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0), }), - [maxLabelLength], + [xAxisLabelHeight], ); if (isLoading || !font) { @@ -203,27 +211,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn titleIcon={titleIcon} /> {chartWidth > 0 && ( void; - /** * Worklet function to determine if the cursor is technically "hovering" * over a specific chart element (e.g., within a bar's width or a point's radius). */ checkIsOver: (args: HitTestArgs) => boolean; - /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ barGeometry?: SharedValue<{barWidth: number; chartBottom: number; yZero: number}>; }; @@ -54,11 +48,33 @@ type CartesianActionsHandle = { }; /** - * Hook to manage chart interactions including hover gestures (web), + * Hook to manage complex chart interactions including hover gestures (web), * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. * - * Synchronizes high-frequency interaction data from the UI thread to React state + * It synchronizes high-frequency interaction data from the UI thread to React state * for metadata display (like tooltips) and navigation. + * + * @param props - Configuration including press handlers and hit-test logic. + * @returns An object containing refs, gestures, and state for the chart component. + * + * @example + * ```tsx + * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ + * handlePress: (index) => console.log("Pressed index:", index), + * checkIsOver: ({ cursorX, targetX, barWidth }) => { + * 'worklet'; + * return Math.abs(cursorX - targetX) < barWidth / 2; + * }, + * barGeometry: myBarSharedValue, + * }); + * + * return ( + * + * + * {isTooltipActive && } + * + * ); + * ``` */ function useChartInteractions({handlePress, checkIsOver, barGeometry}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index c87a2c6177ba1..7c32baa2c1abc 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -6,12 +6,12 @@ type UseChartLabelFormatsProps = { data: ChartDataPoint[]; yAxisUnit?: string; yAxisUnitPosition?: YAxisUnitPosition; - labelSkipInterval: number; - labelRotation: number; - truncatedLabels: string[]; + labelSkipInterval?: number; + labelRotation?: number; + truncatedLabels?: string[]; }; -export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval, labelRotation, truncatedLabels}: UseChartLabelFormatsProps) { +export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval = 1, labelRotation = 0, truncatedLabels}: UseChartLabelFormatsProps) { const formatYAxisLabel = useCallback( (value: number) => { const formatted = value.toLocaleString(); @@ -37,7 +37,7 @@ export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition // Use pre-truncated labels // If rotation is vertical (-90), we usually want full labels // because they have more space vertically. - const sourceToUse = labelRotation === -LABEL_ROTATIONS.VERTICAL ? 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 39fce259acad8..b11508758f7fc 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,6 +1,6 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; -import {Y_AXIS_LABEL_OFFSET} from '@components/Charts/constants'; +import type {ChartDataPoint} from '@components/Charts/types'; import {measureTextWidth} from '@components/Charts/utils'; /** Supported label rotation angles in degrees */ @@ -15,22 +15,17 @@ const SIN_45 = Math.sin(Math.PI / 4); /** Minimum gap between adjacent labels (px) */ const LABEL_PADDING = 4; -/** X-axis labels can occupy at most this fraction of container height */ -const MAX_LABEL_HEIGHT_RATIO = 0.35; - const ELLIPSIS = '...'; -type ChartDataPoint = { - label: string; - [key: string]: unknown; -}; +/** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ +const MIN_TRUNCATED_CHARS = 10; type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; - chartWidth: number; - barAreaWidth: number; - containerHeight: number; + tickSpacing: number; + labelAreaWidth: number; + firstTickOffset?: number; }; /** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ @@ -57,78 +52,111 @@ function effectiveWidth(labelWidth: number, lineHeight: number, rotation: number return labelWidth; } +/** 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)); } /** - * Pick the smallest rotation (0 → 45 → 90) where labels don't overlap, - * preferring rotation over skip interval. + * Left extent of a rotated label at 45° from its anchor point. + * At 45° cos and sin are equal (SIN_45), so extent = (labelWidth + lineHeight) * SIN_45. */ -function pickRotation(maxLabelWidth: number, lineHeight: number, availableWidthPerLabel: number, labelArea: number, dataCount: number): number { - const candidates = [LABEL_ROTATIONS.HORIZONTAL, LABEL_ROTATIONS.DIAGONAL, LABEL_ROTATIONS.VERTICAL] as const; - for (const angle of candidates) { - const ew = effectiveWidth(maxLabelWidth, lineHeight, angle); - if (ew <= availableWidthPerLabel && maxVisibleCount(labelArea, ew) >= dataCount) { - return angle; - } - } - return LABEL_ROTATIONS.VERTICAL; +function leftExtentAt45(labelWidth: number, lineHeight: number): number { + return (labelWidth + lineHeight) * SIN_45; } /** - * Max label pixel width allowed at a given rotation so labels don't exceed - * `MAX_LABEL_HEIGHT_RATIO` of the container height. - * - * Based on Victory's internal allocation formula: - * totalHeight = lineHeight + Y_AXIS_LABEL_OFFSET * 2 + labelWidth * sin(angle) - * (from victory-native-xl/src/cartesian/utils/transformInputData.ts) + * Pick the smallest rotation (0 → 45 → 90) where labels don't overlap, + * preferring rotation over skip interval. */ -function maxAllowedWidth(rotation: number, lineHeight: number, containerHeight: number): number { - if (rotation === LABEL_ROTATIONS.DIAGONAL) { - const budget = containerHeight * MAX_LABEL_HEIGHT_RATIO - (lineHeight + Y_AXIS_LABEL_OFFSET * 2); - return Math.max(0, budget) / SIN_45; +function pickRotation(maxLabelWidth: number, lineHeight: number, tickSpacing: number, labelArea: number, dataCount: number, minTruncatedWidth: number, firstTickOffset?: number): number { + // 0°: labels fit horizontally without truncation and first label won't be clipped + const ew0 = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); + const fits0 = ew0 + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, ew0) >= dataCount; + const firstLabelClipped = firstTickOffset !== undefined && maxLabelWidth > firstTickOffset; + if (fits0 && !firstLabelClipped) { + return LABEL_ROTATIONS.HORIZONTAL; } - // 0° and 90°: no truncation (0° uses skip, 90° expands container) - return Infinity; + + // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks + // and the minimum truncated label fits within firstTickOffset + const minEw45 = minTruncatedWidth * SIN_45; + const fitsBetweenTicks = minEw45 + LABEL_PADDING <= tickSpacing; + const fitsFirstTick = firstTickOffset === undefined || leftExtentAt45(minTruncatedWidth, lineHeight) <= firstTickOffset; + if (fitsBetweenTicks && fitsFirstTick) { + return LABEL_ROTATIONS.DIAGONAL; + } + + // 90°: fallback + return LABEL_ROTATIONS.VERTICAL; } -function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTickOffset}: LabelLayoutConfig) { return useMemo(() => { - if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { + if (!font || tickSpacing <= 0 || labelAreaWidth <= 0 || data.length === 0) { return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; } const fontMetrics = font.getMetrics(); const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); const ellipsisWidth = measureTextWidth(ELLIPSIS, font); - - const availableWidthPerLabel = chartWidth / data.length - LABEL_PADDING; const labelWidths = data.map((p) => measureTextWidth(p.label, font)); const maxLabelLength = Math.max(...labelWidths); - const labelArea = barAreaWidth || chartWidth; - - // 1. Pick rotation - const rotation = pickRotation(maxLabelLength, lineHeight, availableWidthPerLabel, labelArea, data.length); - // 2. Truncate labels (only effective at 45°) - const maxWidth = maxAllowedWidth(rotation, lineHeight, containerHeight); - const finalLabels = data.map((p, i) => truncateLabel(p.label, labelWidths.at(i) ?? 0, maxWidth, ellipsisWidth)); + // 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((p, i) => { + if (p.label.length <= MIN_TRUNCATED_CHARS) { + return labelWidths.at(i) ?? 0; + } + return measureTextWidth(p.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + }), + ); - // 3. Compute skip interval + // 1. Pick rotation + const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, firstTickOffset); + + // 2. Truncate labels (only at 45°) + // Tick-spacing constraint: labelWidth * sin(45°) + padding <= tickSpacing + // First-label constraint: (labelWidth + lineHeight) * sin(45°) <= firstTickOffset + const tickSpacingLimit = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 : Infinity; + const firstTickLimit = rotation === LABEL_ROTATIONS.DIAGONAL && firstTickOffset !== undefined ? firstTickOffset / SIN_45 - lineHeight : Infinity; + const finalLabels = data.map((p, i) => { + const limit = i === 0 ? Math.min(tickSpacingLimit, firstTickLimit) : tickSpacingLimit; + return truncateLabel(p.label, labelWidths.at(i) ?? 0, limit, ellipsisWidth); + }); + + // 3. Compute skip interval (only at 90°) const finalMaxWidth = Math.max(...finalLabels.map((l) => measureTextWidth(l, font))); - const ew = effectiveWidth(finalMaxWidth, lineHeight, rotation); - const visible = maxVisibleCount(labelArea, ew); - const skipInterval = visible >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visible)); + let skipInterval = 1; + if (rotation === LABEL_ROTATIONS.VERTICAL) { + const ew = effectiveWidth(finalMaxWidth, lineHeight, rotation); + const visible = maxVisibleCount(labelAreaWidth, ew); + skipInterval = visible >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visible)); + } + + // 4. Compute vertical space needed for x-axis labels + const xAxisLabelHeight = effectiveHeight(finalMaxWidth, lineHeight, rotation); return { labelRotation: -rotation, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, - maxLabelLength, + xAxisLabelHeight, }; - }, [font, chartWidth, barAreaWidth, containerHeight, data]); + }, [font, tickSpacing, labelAreaWidth, firstTickOffset, data]); } export {LABEL_ROTATIONS, useChartLabelLayout}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index e63b87e197748..6c920a4074576 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -51,4 +51,15 @@ function measureTextWidth(text: string, font: SkFont): number { return font.getGlyphWidths(glyphIDs).reduce((sum, w) => sum + w, 0); } -export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth}; +/** + * Post-rotation horizontal translation to center a rotated label on its tick mark. + * + * Text baselines sit closer to glyph tops (ascent > descent), so rotating around + * the baseline end creates asymmetric horizontal extent. This returns the correction + * to apply as a translateX AFTER rotation. + */ +function rotatedLabelCenterCorrection(ascent: number, descent: number, angleRad: number): number { + return ((ascent - descent) * Math.sin(angleRad)) / 2; +} + +export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection}; From ef1a0ae57bf21afb3f35746947cb4a4e1a0a757a Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Wed, 4 Feb 2026 16:55:26 +0100 Subject: [PATCH 20/26] Address review comments --- src/components/Charts/LineChart/LineChartContent.tsx | 5 ++++- src/libs/SearchQueryUtils.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 6bc439d801b19..3df2a48f0c890 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -22,6 +22,9 @@ import variables from '@styles/variables'; /** Inner dot radius for line chart data points */ const DOT_RADIUS = 6; +/** Extra hover area beyond the dot radius for easier touch targeting */ +const DOT_HOVER_EXTRA_RADIUS = 2; + /** Extra pixel spacing between the plot boundary and the first/last data point */ const LINE_CHART_DOMAIN_PADDING = 16; @@ -113,7 +116,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const dx = args.cursorX - args.targetX; const dy = args.cursorY - args.targetY; - return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS; + return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }, []); const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 79d9b6320aeca..531abde6b29b4 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -442,9 +442,9 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt result.policyID = [result.policyID]; } - // Default groupBy to category when a chart view is specified without an explicit groupBy + // Default groupBy when a chart view is specified without an explicit groupBy if (result.view !== CONST.SEARCH.VIEW.TABLE && !result.groupBy) { - result.groupBy = CONST.SEARCH.GROUP_BY.CATEGORY; + result.groupBy = result.view === CONST.SEARCH.VIEW.LINE ? CONST.SEARCH.GROUP_BY.MONTH : CONST.SEARCH.GROUP_BY.CATEGORY; } // Normalize limit before computing hashes to ensure invalid values don't affect hash From 2a70f4fe28f005875ae8635c9fcc5d06c0fe6c9a Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Wed, 4 Feb 2026 17:05:58 +0100 Subject: [PATCH 21/26] Fix LineChart layout issue of first left-most label --- .../Charts/hooks/useChartLabelLayout.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index b11508758f7fc..9013c07d27e57 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -80,11 +80,20 @@ function leftExtentAt45(labelWidth: number, lineHeight: number): number { * 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, firstTickOffset?: number): number { +function pickRotation( + maxLabelWidth: number, + firstLabelWidth: number, + lineHeight: number, + tickSpacing: number, + labelArea: number, + dataCount: number, + minTruncatedWidth: number, + firstTickOffset?: number, +): number { // 0°: labels fit horizontally without truncation and first label won't be clipped const ew0 = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); const fits0 = ew0 + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, ew0) >= dataCount; - const firstLabelClipped = firstTickOffset !== undefined && maxLabelWidth > firstTickOffset; + const firstLabelClipped = firstTickOffset !== undefined && firstLabelWidth > firstTickOffset; if (fits0 && !firstLabelClipped) { return LABEL_ROTATIONS.HORIZONTAL; } @@ -126,7 +135,8 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTick ); // 1. Pick rotation - const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, firstTickOffset); + const firstLabelWidth = labelWidths.at(0) ?? 0; + const rotation = pickRotation(maxLabelLength, firstLabelWidth, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, firstTickOffset); // 2. Truncate labels (only at 45°) // Tick-spacing constraint: labelWidth * sin(45°) + padding <= tickSpacing From 993739dbe8b6c20653e0f9d9d895239a5c8913f5 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Thu, 5 Feb 2026 09:37:51 +0100 Subject: [PATCH 22/26] Change default sorting order for line chart --- src/libs/SearchQueryUtils.ts | 33 ++++++++++++++++++++--- tests/unit/Search/SearchQueryUtilsTest.ts | 3 ++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 531abde6b29b4..13c9524948c86 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -410,6 +410,34 @@ function isFilterSupported(filter: SearchAdvancedFiltersKey, type: SearchDataTyp }); } +/** + * Returns default values for chart views (non-table views). + * - Sets default groupBy based on view type (month for line charts, category for others) + * - Sets default sortOrder to ASC for line charts (time-based graphs show chronologically oldest to newest) + */ +function getChartViewDefaults(queryJSON: SearchQueryJSON): Partial { + if (queryJSON.view === CONST.SEARCH.VIEW.TABLE) { + return {}; + } + + const defaults: Partial = {}; + + // Default groupBy when not explicitly set + if (!queryJSON.groupBy) { + defaults.groupBy = queryJSON.view === CONST.SEARCH.VIEW.LINE ? CONST.SEARCH.GROUP_BY.MONTH : CONST.SEARCH.GROUP_BY.CATEGORY; + } + + // Default sortOrder to ASC for LINE view, only if not explicitly set by the user + if (queryJSON.view === CONST.SEARCH.VIEW.LINE) { + const wasSortOrderExplicitlySet = queryJSON.rawFilterList?.some((filter) => filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER) ?? false; + if (!wasSortOrderExplicitlySet) { + defaults.sortOrder = CONST.SEARCH.SORT_ORDER.ASC; + } + } + + return defaults; +} + /** * Parses a given search query string into a structured `SearchQueryJSON` format. * This format of query is most commonly shared between components and also sent to backend to retrieve search results. @@ -442,10 +470,7 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt result.policyID = [result.policyID]; } - // Default groupBy when a chart view is specified without an explicit groupBy - if (result.view !== CONST.SEARCH.VIEW.TABLE && !result.groupBy) { - result.groupBy = result.view === CONST.SEARCH.VIEW.LINE ? CONST.SEARCH.GROUP_BY.MONTH : CONST.SEARCH.GROUP_BY.CATEGORY; - } + Object.assign(result, getChartViewDefaults(result)); // Normalize limit before computing hashes to ensure invalid values don't affect hash if (result.limit !== undefined) { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index bd010f5054261..778c228c43298 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -108,7 +108,8 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual(`${defaultQuery} view:line groupBy:category category:travel`); + // LINE view defaults to sortOrder:asc (chronological) and groupBy:month + expect(result).toEqual('type:expense sortBy:date sortOrder:asc view:line groupBy:month category:travel'); }); test('returns query with view:pie', () => { From dd295b8ec3bd3861a0257c26dce4144273c9d394 Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Thu, 5 Feb 2026 11:14:11 +0100 Subject: [PATCH 23/26] Center LineChart labels when rotation degree is 0 --- .../Charts/BarChart/BarChartContent.tsx | 20 +----- .../Charts/LineChart/LineChartContent.tsx | 68 +++++++++++++------ .../Charts/hooks/useChartLabelLayout.ts | 49 +++---------- src/components/Charts/utils.ts | 18 ++++- 4 files changed, 76 insertions(+), 79 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 55f79db985ec5..19dc0a723db5e 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -13,7 +13,7 @@ import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -22,27 +22,9 @@ import variables from '@styles/variables'; /** Inner padding between bars (0.3 = 30% of bar width) */ const BAR_INNER_PADDING = 0.3; -/** Safety buffer multiplier for domain padding calculation */ -const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; - /** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */ const BASE_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0}; -/** - * Calculate minimum domainPadding required to prevent data points from crowding chart edges. - * - * For evenly-spaced data points, Victory maps indices [0..N-1] into the output range. - * Without enough padding the first/last points sit too close to the View boundary - * and their centered labels get clipped by overflow:hidden. - */ -function calculateMinDomainPadding(chartWidth: number, pointCount: number, innerPadding: number): number { - if (pointCount <= 0) { - return 0; - } - const minPaddingRatio = (1 - innerPadding) / (2 * (pointCount - 1 + innerPadding)); - return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER); -} - type BarChartProps = CartesianChartProps & { /** Callback when a bar is pressed */ onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 3df2a48f0c890..6726675eea093 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -13,7 +13,7 @@ import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,8 +25,8 @@ const DOT_RADIUS = 6; /** Extra hover area beyond the dot radius for easier touch targeting */ const DOT_HOVER_EXTRA_RADIUS = 2; -/** Extra pixel spacing between the plot boundary and the first/last data point */ -const LINE_CHART_DOMAIN_PADDING = 16; +/** Base domain padding applied to all sides */ +const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; /** Gap between the x-axis line and the top of label glyphs */ const X_AXIS_LABEL_GAP = 2; @@ -42,7 +42,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const {shouldUseNarrowLayout} = useResponsiveLayout(); const font = useFont(fontSource, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); - const [chartBoundsInfo, setChartBoundsInfo] = useState({plotAreaWidth: 0, firstTickOffset: 0}); + const [plotAreaWidth, setPlotAreaWidth] = useState(0); const yAxisDomain = useDynamicYDomain(data); @@ -71,27 +71,51 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn }, []); const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { - setChartBoundsInfo({ - plotAreaWidth: bounds.right - bounds.left, - firstTickOffset: bounds.left + LINE_CHART_DOMAIN_PADDING, - }); + setPlotAreaWidth(bounds.right - bounds.left); }, []); - const {plotAreaWidth, firstTickOffset} = chartBoundsInfo; + // Calculate dynamic domain padding for centered labels + // Optimize by reducing wasted space when edge labels are shorter than tick spacing + const domainPadding = useMemo(() => { + if (chartWidth === 0 || data.length === 0) { + return BASE_DOMAIN_PADDING; + } + + const geometricPadding = calculateMinDomainPadding(chartWidth, data.length); - const tickSpacing = useMemo(() => { - if (plotAreaWidth === 0 || data.length <= 1) { - return 0; + // Without font, use geometric padding (safe fallback) + if (!font) { + return {...BASE_DOMAIN_PADDING, left: geometricPadding, right: geometricPadding}; } - return (plotAreaWidth - 2 * LINE_CHART_DOMAIN_PADDING) / (data.length - 1); - }, [plotAreaWidth, data.length]); + + // 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 canReduce = Math.min(wastedLeft, wastedRight); + + // Only reduce if both sides have excess space (labels short enough for 0°) + // If canReduce <= 0, labels are too long and hook will use rotation/truncation + const horizontalPadding = canReduce > 0 ? geometricPadding - canReduce : geometricPadding; + + return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; + }, [chartWidth, data, font]); + + // 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 {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ data, font, tickSpacing, labelAreaWidth: plotAreaWidth, - firstTickOffset, }); // Measure label widths for custom positioning in `renderOutside` @@ -126,9 +150,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); - // Victory's built-in x-axis labels center each label under its tick mark, - // which works for bar charts but clips labels on line charts where data points - // sit at the edges. We render labels via `renderOutside` with custom positioning. + // 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 renderCustomXLabels = useCallback( (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { if (!font) { @@ -148,11 +172,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn 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 ( = dataCount; - const firstLabelClipped = firstTickOffset !== undefined && firstLabelWidth > firstTickOffset; - if (fits0 && !firstLabelClipped) { + if (ew0 + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, ew0) >= dataCount) { return LABEL_ROTATIONS.HORIZONTAL; } // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks - // and the minimum truncated label fits within firstTickOffset const minEw45 = minTruncatedWidth * SIN_45; - const fitsBetweenTicks = minEw45 + LABEL_PADDING <= tickSpacing; - const fitsFirstTick = firstTickOffset === undefined || leftExtentAt45(minTruncatedWidth, lineHeight) <= firstTickOffset; - if (fitsBetweenTicks && fitsFirstTick) { + if (minEw45 + LABEL_PADDING <= tickSpacing) { return LABEL_ROTATIONS.DIAGONAL; } @@ -111,10 +88,10 @@ function pickRotation( return LABEL_ROTATIONS.VERTICAL; } -function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTickOffset}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLayoutConfig) { return useMemo(() => { - if (!font || tickSpacing <= 0 || labelAreaWidth <= 0 || data.length === 0) { - return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; + if (!font || data.length === 0 || tickSpacing <= 0 || labelAreaWidth <= 0) { + return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: []}; } const fontMetrics = font.getMetrics(); @@ -135,17 +112,13 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTick ); // 1. Pick rotation - const firstLabelWidth = labelWidths.at(0) ?? 0; - const rotation = pickRotation(maxLabelLength, firstLabelWidth, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, firstTickOffset); + const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth); // 2. Truncate labels (only at 45°) // Tick-spacing constraint: labelWidth * sin(45°) + padding <= tickSpacing - // First-label constraint: (labelWidth + lineHeight) * sin(45°) <= firstTickOffset - const tickSpacingLimit = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 : Infinity; - const firstTickLimit = rotation === LABEL_ROTATIONS.DIAGONAL && firstTickOffset !== undefined ? firstTickOffset / SIN_45 - lineHeight : Infinity; + const maxLabelWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 : Infinity; const finalLabels = data.map((p, i) => { - const limit = i === 0 ? Math.min(tickSpacingLimit, firstTickLimit) : tickSpacingLimit; - return truncateLabel(p.label, labelWidths.at(i) ?? 0, limit, ellipsisWidth); + return truncateLabel(p.label, labelWidths.at(i) ?? 0, maxLabelWidth, ellipsisWidth); }); // 3. Compute skip interval (only at 90°) @@ -166,7 +139,7 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, firstTick truncatedLabels: finalLabels, xAxisLabelHeight, }; - }, [font, tickSpacing, labelAreaWidth, firstTickOffset, data]); + }, [font, tickSpacing, labelAreaWidth, data]); } export {LABEL_ROTATIONS, useChartLabelLayout}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 6c920a4074576..af0c9e0fdafa8 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -62,4 +62,20 @@ function rotatedLabelCenterCorrection(ascent: number, descent: number, angleRad: return ((ascent - descent) * Math.sin(angleRad)) / 2; } -export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection}; +/** + * Calculate minimum horizontal domainPadding so that edge data points + * (and their centered labels) aren't clipped by the chart boundary. + * + * @param chartWidth - Total chart width in pixels + * @param pointCount - Number of data points + * @param innerPadding - Padding ratio between points (0 for line charts, ~0.3 for bar charts) + */ +function calculateMinDomainPadding(chartWidth: number, pointCount: number, innerPadding = 0): number { + if (pointCount <= 1) { + return 0; + } + const minPaddingRatio = (1 - innerPadding) / (2 * (pointCount - 1 + innerPadding)); + return Math.ceil(chartWidth * minPaddingRatio); +} + +export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, calculateMinDomainPadding}; From fa5a338bdf08d2d57cfeac572953adeeae29f08c Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Thu, 5 Feb 2026 11:18:18 +0100 Subject: [PATCH 24/26] Improve naming conventions --- .../Charts/hooks/useChartLabelLayout.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 9f6fec14f9c9b..857daa7e0b0a3 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -73,14 +73,14 @@ function maxVisibleCount(areaWidth: number, itemWidth: number): number { */ function pickRotation(maxLabelWidth: number, lineHeight: number, tickSpacing: number, labelArea: number, dataCount: number, minTruncatedWidth: number): number { // 0°: labels fit horizontally without truncation - const ew0 = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); - if (ew0 + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, ew0) >= dataCount) { + const horizontalWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); + if (horizontalWidth + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, horizontalWidth) >= dataCount) { return LABEL_ROTATIONS.HORIZONTAL; } // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks - const minEw45 = minTruncatedWidth * SIN_45; - if (minEw45 + LABEL_PADDING <= tickSpacing) { + const minDiagonalWidth = minTruncatedWidth * SIN_45; + if (minDiagonalWidth + LABEL_PADDING <= tickSpacing) { return LABEL_ROTATIONS.DIAGONAL; } @@ -97,17 +97,17 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLay const fontMetrics = font.getMetrics(); const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); const ellipsisWidth = measureTextWidth(ELLIPSIS, font); - const labelWidths = data.map((p) => measureTextWidth(p.label, 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((p, i) => { - if (p.label.length <= MIN_TRUNCATED_CHARS) { - return labelWidths.at(i) ?? 0; + ...data.map((point, index) => { + if (point.label.length <= MIN_TRUNCATED_CHARS) { + return labelWidths.at(index) ?? 0; } - return measureTextWidth(p.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); }), ); @@ -117,17 +117,17 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLay // 2. Truncate labels (only at 45°) // Tick-spacing constraint: labelWidth * sin(45°) + padding <= tickSpacing const maxLabelWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 : Infinity; - const finalLabels = data.map((p, i) => { - return truncateLabel(p.label, labelWidths.at(i) ?? 0, maxLabelWidth, ellipsisWidth); + 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((l) => measureTextWidth(l, font))); + const finalMaxWidth = Math.max(...finalLabels.map((label) => measureTextWidth(label, font))); let skipInterval = 1; if (rotation === LABEL_ROTATIONS.VERTICAL) { - const ew = effectiveWidth(finalMaxWidth, lineHeight, rotation); - const visible = maxVisibleCount(labelAreaWidth, ew); - skipInterval = visible >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visible)); + const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation); + const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth); + skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount)); } // 4. Compute vertical space needed for x-axis labels From d28141dd789cd5f623d6fe4771c6d60ebee991cb Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Thu, 5 Feb 2026 13:23:53 +0100 Subject: [PATCH 25/26] Maintain constant 8px gap between plot and labels --- .../Charts/LineChart/LineChartContent.tsx | 19 ++++++++++--- .../Charts/hooks/useChartLabelLayout.ts | 27 ++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 6726675eea093..508682c5ee1bf 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -28,8 +28,8 @@ const DOT_HOVER_EXTRA_RADIUS = 2; /** Base domain padding applied to all sides */ const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; -/** Gap between the x-axis line and the top of label glyphs */ -const X_AXIS_LABEL_GAP = 2; +/** Consistent gap between x-axis and closest point of label (regardless of rotation) */ +const LABEL_GAP = 8; type LineChartProps = CartesianChartProps & { /** Callback when a data point is pressed */ @@ -116,6 +116,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn font, tickSpacing, labelAreaWidth: plotAreaWidth, + allowTightDiagonalPacking: true, }); // Measure label widths for custom positioning in `renderOutside` @@ -162,7 +163,19 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const fontMetrics = font.getMetrics(); const ascent = Math.abs(fontMetrics.ascent); const descent = Math.abs(fontMetrics.descent); - const labelY = args.chartBounds.bottom + X_AXIS_LABEL_GAP + font.getSize(); + + // Calculate labelY to maintain consistent LABEL_GAP from axis to closest point of text + // At 0°: closest point is top of text (baseline - ascent) + // At 45°: closest point is top-right corner, ascent projects as ascent * cos(45°) + // At 90°: text is vertical, closest point is at descent from baseline + let labelY: number; + if (angleRad === 0) { + labelY = args.chartBounds.bottom + LABEL_GAP + ascent; + } else if (angleRad >= Math.PI / 2) { + labelY = args.chartBounds.bottom + LABEL_GAP + descent; + } else { + labelY = args.chartBounds.bottom + LABEL_GAP + ascent * Math.cos(angleRad); + } return truncatedLabels.map((label, i) => { if (i % labelSkipInterval !== 0) { diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 857daa7e0b0a3..78564dfc131cc 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -25,6 +25,8 @@ type LabelLayoutConfig = { font: SkFont | null; tickSpacing: number; 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. */ @@ -71,7 +73,15 @@ function maxVisibleCount(areaWidth: number, itemWidth: number): number { * 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): number { +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) { @@ -79,7 +89,9 @@ function pickRotation(maxLabelWidth: number, lineHeight: number, tickSpacing: nu } // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks - const minDiagonalWidth = minTruncatedWidth * SIN_45; + // 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; } @@ -88,7 +100,7 @@ function pickRotation(maxLabelWidth: number, lineHeight: number, tickSpacing: nu return LABEL_ROTATIONS.VERTICAL; } -function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLayoutConfig) { +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: []}; @@ -112,11 +124,12 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLay ); // 1. Pick rotation - const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth); + const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, allowTightDiagonalPacking); // 2. Truncate labels (only at 45°) - // Tick-spacing constraint: labelWidth * sin(45°) + padding <= tickSpacing - const maxLabelWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 : Infinity; + // 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); }); @@ -139,7 +152,7 @@ function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth}: LabelLay truncatedLabels: finalLabels, xAxisLabelHeight, }; - }, [font, tickSpacing, labelAreaWidth, data]); + }, [font, tickSpacing, labelAreaWidth, data, allowTightDiagonalPacking]); } export {LABEL_ROTATIONS, useChartLabelLayout}; From 2d11275ecf00d0c929308b8f385147623e795a1f Mon Sep 17 00:00:00 2001 From: Mateusz Rajski Date: Thu, 5 Feb 2026 13:28:10 +0100 Subject: [PATCH 26/26] Move labelY utility function to utils --- .../Charts/LineChart/LineChartContent.tsx | 16 ++------------- src/components/Charts/utils.ts | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 508682c5ee1bf..94e16c7936997 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -13,7 +13,7 @@ import fontSource from '@components/Charts/font'; import type {HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -163,19 +163,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const fontMetrics = font.getMetrics(); const ascent = Math.abs(fontMetrics.ascent); const descent = Math.abs(fontMetrics.descent); - - // Calculate labelY to maintain consistent LABEL_GAP from axis to closest point of text - // At 0°: closest point is top of text (baseline - ascent) - // At 45°: closest point is top-right corner, ascent projects as ascent * cos(45°) - // At 90°: text is vertical, closest point is at descent from baseline - let labelY: number; - if (angleRad === 0) { - labelY = args.chartBounds.bottom + LABEL_GAP + ascent; - } else if (angleRad >= Math.PI / 2) { - labelY = args.chartBounds.bottom + LABEL_GAP + descent; - } else { - labelY = args.chartBounds.bottom + LABEL_GAP + ascent * Math.cos(angleRad); - } + const labelY = args.chartBounds.bottom + LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad); return truncatedLabels.map((label, i) => { if (i % labelSkipInterval !== 0) { diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index af0c9e0fdafa8..8918d6fa2d5b9 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -62,6 +62,24 @@ function rotatedLabelCenterCorrection(ascent: number, descent: number, angleRad: return ((ascent - descent) * Math.sin(angleRad)) / 2; } +/** + * Calculate the vertical offset from axis to text baseline that maintains + * a consistent visual gap regardless of label rotation. + * + * At 0°: gap to top of text (ascent above baseline) + * At 45°: gap to top-right corner after rotation (ascent projects as ascent * cos(45°)) + * At 90°: gap to closest point of vertical text (descent from baseline) + */ +function rotatedLabelYOffset(ascent: number, descent: number, angleRad: number): number { + if (angleRad === 0) { + return ascent; + } + if (angleRad >= Math.PI / 2) { + return descent; + } + return ascent * Math.cos(angleRad); +} + /** * Calculate minimum horizontal domainPadding so that edge data points * (and their centered labels) aren't clipped by the chart boundary. @@ -78,4 +96,4 @@ function calculateMinDomainPadding(chartWidth: number, pointCount: number, inner return Math.ceil(chartWidth * minPaddingRatio); } -export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, calculateMinDomainPadding}; +export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, rotatedLabelYOffset, calculateMinDomainPadding};