Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f0d8e81
add tooltip on hover of labels, add debounce on hover start
borys3kk Feb 27, 2026
ad89eec
fix hover when entering the label from the left
borys3kk Mar 3, 2026
61502d7
merge main
borys3kk Mar 3, 2026
0350a55
add tooltip on label hover in pie chart
borys3kk Mar 4, 2026
fbec98d
adjust label bounding box positioning
borys3kk Mar 4, 2026
7ab38fb
start implementing hover on label in bar chart
borys3kk Mar 4, 2026
234fc99
merge main
borys3kk Mar 4, 2026
3bdff09
fix hovering for line chart labels
borys3kk Mar 4, 2026
50748fe
Merge branch 'main' of github.com:Expensify/App into add-tooltip-for-…
borys3kk Mar 5, 2026
4bbc98b
add hover for barChart
borys3kk Mar 5, 2026
57c6707
fix prettier
borys3kk Mar 5, 2026
a9fcd5f
fix check for vertical labels in bar chart
borys3kk Mar 5, 2026
d0f65ff
fix tests typecheck, fix prettier
borys3kk Mar 5, 2026
5729b2e
fix bounding boxes for line and bar chart, memoize whats possible
borys3kk Mar 5, 2026
021808f
add tests for new function
borys3kk Mar 6, 2026
f21d491
make bar and line charts use the new function
borys3kk Mar 6, 2026
bc68d1d
remove manual memoization in lineChartContent
borys3kk Mar 6, 2026
a937fe6
remove manual memoization from bar chart
borys3kk Mar 6, 2026
f06e95a
add utils for label hit testins/ refactor and simplify code
borys3kk Mar 6, 2026
8d8a70a
add tests for common function in label hover
borys3kk Mar 6, 2026
4002dbb
fix prettier
borys3kk Mar 6, 2026
1187bf8
remove argument unpacking
borys3kk Mar 6, 2026
af9691c
remerge main
borys3kk Mar 9, 2026
ba84519
fix error on native devices
borys3kk Mar 9, 2026
8b43d2f
move redundant code to hook
borys3kk Mar 9, 2026
a051d7e
fix prettier
borys3kk Mar 9, 2026
79c5642
add review suggestins
borys3kk Mar 9, 2026
c32aa1c
fix tapping on mobile devices, make hovering more accuratee
borys3kk Mar 10, 2026
a443442
fix error on mobile about worklets
borys3kk Mar 10, 2026
9bb1932
fix spellcheck
borys3kk Mar 10, 2026
e955550
update tests
borys3kk Mar 10, 2026
ae6aed5
fix spelling
borys3kk Mar 10, 2026
9ba69f5
fix missing spaces in useChartInteractions
borys3kk Mar 11, 2026
29ca3fc
fix missing spaces
borys3kk Mar 11, 2026
39ecf08
add comments
borys3kk Mar 11, 2026
be86d9c
fxi puneets review comments
borys3kk Mar 11, 2026
8913c7f
fix prettier
borys3kk Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 111 additions & 56 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ import {useFont} from '@shopify/react-native-skia';
import React, {useState} from 'react';
import type {LayoutChangeEvent} from 'react-native';
import {View} from 'react-native';
import {GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native';
import {Bar, CartesianChart} from 'victory-native';
import ActivityIndicator from '@components/ActivityIndicator';
import ChartHeader from '@components/Charts/components/ChartHeader';
import ChartTooltip from '@components/Charts/components/ChartTooltip';
import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels';
import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants';
import {
AXIS_LABEL_GAP,
CHART_CONTENT_MIN_HEIGHT,
CHART_PADDING,
DIAGONAL_ANGLE_RADIAN_THRESHOLD,
X_AXIS_LINE_WIDTH,
Y_AXIS_LINE_WIDTH,
Y_AXIS_TICK_COUNT,
} from '@components/Charts/constants';
import fontSource from '@components/Charts/font';
import type {HitTestArgs} from '@components/Charts/hooks';
import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks';
import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks';
import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks';
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -28,6 +37,35 @@ const BAR_INNER_PADDING = 0.3;
*/
const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0};

/**
* Bar chart geometry for label hit-testing.
* Labels are center-anchored: the 45° parallelogram's upper-right corner is offset
* by (halfLabelWidth * sinA) right and up, so the box straddles the tick symmetrically.
*/
const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
const maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0;
const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0;
const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA - variables.iconSizeExtraSmall / 3);
const halfWidths = labelWidths.map((w) => w / 2);
let additionalOffset = 0;
if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) {
additionalOffset = variables.iconSizeExtraSmall / 1.5;
} else if (angleRad > 1) {
additionalOffset = variables.iconSizeExtraSmall / 3;
}
return {
// variables.iconSizeExtraSmall / 3 is the vertical offset of label from the axis line
labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset,
iconSin: variables.iconSizeExtraSmall * sinA,
labelSins: labelWidths.map((w) => w * sinA),
halfWidths,
cornerAnchorDX: halfLabelSins,
cornerAnchorDY: halfLabelSins.map((v) => -v),
yMin90Offsets: halfWidths.map((hw) => -hw + padding),
yMax90Offsets: halfWidths.map((hw) => hw + padding),
};
};

type BarChartProps = CartesianChartProps & {
/** Callback when a bar is pressed */
onBarPress?: (dataPoint: ChartDataPoint, index: number) => void;
Expand Down Expand Up @@ -99,6 +137,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
const chartBottom = useSharedValue(0);
const yZero = useSharedValue(0);

const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({
font,
truncatedLabels,
labelRotation,
labelSkipInterval,
chartBottom,
computeGeometry: computeBarLabelGeometry,
});

const handleChartBoundsChange = (bounds: ChartBounds) => {
const domainWidth = bounds.right - bounds.left;
const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length;
Expand All @@ -110,10 +157,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
setBoundsRight(bounds.right);
};

const handleScaleChange = (_xScale: Scale, yScale: Scale) => {
yZero.set(yScale(0));
};

const checkIsOverBar = (args: HitTestArgs) => {
'worklet';

Expand All @@ -124,19 +167,31 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
}
const barLeft = args.targetX - currentBarWidth / 2;
const barRight = args.targetX + currentBarWidth / 2;

const barTop = Math.min(args.targetY, currentYZero);
const barBottom = Math.max(args.targetY, currentYZero);

return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom;
};

const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
handlePress: handleBarPress,
checkIsOver: checkIsOverBar,
isCursorOverLabel,
resolveLabelTouchX: findLabelCursorX,
chartBottom,
yZero,
});

const handleScaleChange = (xScale: Scale, yScale: Scale) => {
yZero.set(yScale(0));
updateTickPositions(xScale, data.length);
setPointPositions(
chartData.map((point) => xScale(point.x)),
chartData.map((point) => yScale(point.y)),
);
};

const tooltipData = useTooltipData(activeDataIndex, data, formatValue);

const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => {
Expand Down Expand Up @@ -197,53 +252,53 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
title={title}
titleIcon={titleIcon}
/>
<View
style={[styles.barChartChartContainer, dynamicChartStyle]}
onLayout={handleLayout}
>
{chartWidth > 0 && (
<CartesianChart
xKey="x"
padding={chartPadding}
yKeys={['y']}
domainPadding={domainPadding}
actionsRef={actionsRef}
customGestures={customGestures}
onChartBoundsChange={handleChartBoundsChange}
onScaleChange={handleScaleChange}
renderOutside={renderOutside}
xAxis={{
tickCount: data.length,
lineWidth: X_AXIS_LINE_WIDTH,
}}
yAxis={[
{
font,
labelColor: theme.textSupporting,
formatYLabel: formatValue,
tickCount: Y_AXIS_TICK_COUNT,
lineWidth: Y_AXIS_LINE_WIDTH,
lineColor: theme.border,
labelOffset: AXIS_LABEL_GAP,
domain: yAxisDomain,
},
]}
frame={{lineWidth: 0}}
data={chartData}
>
{({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}</>}
</CartesianChart>
)}
{isTooltipActive && !!tooltipData && (
<ChartTooltip
label={tooltipData.label}
amount={tooltipData.amount}
percentage={tooltipData.percentage}
chartWidth={chartWidth}
initialTooltipPosition={initialTooltipPosition}
/>
)}
</View>
<GestureDetector gesture={customGestures}>
<View
style={[styles.barChartChartContainer, dynamicChartStyle]}
onLayout={handleLayout}
>
{chartWidth > 0 && (
<CartesianChart
xKey="x"
padding={chartPadding}
yKeys={['y']}
domainPadding={domainPadding}
onChartBoundsChange={handleChartBoundsChange}
onScaleChange={handleScaleChange}
renderOutside={renderOutside}
xAxis={{
tickCount: data.length,
lineWidth: X_AXIS_LINE_WIDTH,
}}
yAxis={[
{
font,
labelColor: theme.textSupporting,
formatYLabel: formatValue,
tickCount: Y_AXIS_TICK_COUNT,
lineWidth: Y_AXIS_LINE_WIDTH,
lineColor: theme.border,
labelOffset: AXIS_LABEL_GAP,
domain: yAxisDomain,
},
]}
frame={{lineWidth: 0}}
data={chartData}
>
{({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}</>}
</CartesianChart>
)}
{isTooltipActive && !!tooltipData && (
<ChartTooltip
label={tooltipData.label}
amount={tooltipData.amount}
percentage={tooltipData.percentage}
chartWidth={chartWidth}
initialTooltipPosition={initialTooltipPosition}
/>
)}
</View>
</GestureDetector>
</View>
);
}
Expand Down
Loading
Loading