Skip to content

Commit 7a6393d

Browse files
authored
Merge pull request Expensify#81000 from software-mansion-labs/add-search-pie-chart-component
Add search pie chart component
2 parents ac15423 + 7b4d824 commit 7a6393d

34 files changed

+828
-218
lines changed

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7413,6 +7413,7 @@ const CONST = {
74137413
TABLE: 'table',
74147414
BAR: 'bar',
74157415
LINE: 'line',
7416+
PIE: 'pie',
74167417
},
74177418
SYNTAX_FILTER_KEYS: {
74187419
TYPE: 'type',

src/components/Charts/BarChart/BarChartContent.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
8585
return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding};
8686
}, [chartWidth, data.length]);
8787

88-
const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({
88+
const {formatLabel, formatValue} = useChartLabelFormats({
8989
data,
9090
font,
91-
yAxisUnit,
92-
yAxisUnitPosition,
91+
unit: yAxisUnit,
92+
unitPosition: yAxisUnitPosition,
9393
labelSkipInterval,
9494
labelRotation,
9595
truncatedLabels,
@@ -147,7 +147,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
147147
yZero,
148148
});
149149

150-
const tooltipData = useTooltipData(activeDataIndex, data, formatYAxisLabel);
150+
const tooltipData = useTooltipData(activeDataIndex, data, formatValue);
151151

152152
const renderBar = useCallback(
153153
(point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => {
@@ -219,15 +219,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
219219
// Victory-native positions x-axis labels at: chartBounds.bottom + labelOffset + fontSize.
220220
// We subtract descent (fontSize - ascent) so the gap from chart to the ascent line equals AXIS_LABEL_GAP.
221221
labelOffset: AXIS_LABEL_GAP - Math.abs(font?.getMetrics().descent ?? 0),
222-
formatXLabel: formatXAxisLabel,
222+
formatXLabel: formatLabel,
223223
labelRotate: labelRotation,
224224
labelOverflow: 'visible',
225225
}}
226226
yAxis={[
227227
{
228228
font,
229229
labelColor: theme.textSupporting,
230-
formatYLabel: formatYAxisLabel,
230+
formatYLabel: formatValue,
231231
tickCount: Y_AXIS_TICK_COUNT,
232232
lineWidth: Y_AXIS_LINE_WIDTH,
233233
lineColor: theme.border,

src/components/Charts/BarChart/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import ActivityIndicator from '@components/ActivityIndicator';
55
import useThemeStyles from '@hooks/useThemeStyles';
66
import type {BarChartProps} from './BarChartContent';
77

8+
const getBarChartContent = () => import('./BarChartContent');
89
function BarChart(props: BarChartProps) {
910
const styles = useThemeStyles();
1011

1112
return (
1213
<WithSkiaWeb
1314
opts={{locateFile: (file: string) => `/${file}`}}
14-
getComponent={() => import('./BarChartContent')}
15+
getComponent={getBarChartContent}
1516
componentProps={props}
1617
fallback={
1718
<View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter, styles.highlightBG, styles.br4, styles.p5]}>

src/components/Charts/LineChart/LineChartContent.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
131131
// Convert hook's degree rotation to radians for Skia rendering
132132
const angleRad = (Math.abs(labelRotation) * Math.PI) / 180;
133133

134-
const {formatYAxisLabel} = useChartLabelFormats({
134+
const {formatValue} = useChartLabelFormats({
135135
data,
136136
font,
137-
yAxisUnit,
138-
yAxisUnitPosition,
137+
unit: yAxisUnit,
138+
unitPosition: yAxisUnitPosition,
139139
});
140140

141141
const checkIsOverDot = useCallback((args: HitTestArgs) => {
@@ -151,7 +151,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
151151
checkIsOver: checkIsOverDot,
152152
});
153153

154-
const tooltipData = useTooltipData(activeDataIndex, data, formatYAxisLabel);
154+
const tooltipData = useTooltipData(activeDataIndex, data, formatValue);
155155

156156
// Custom x-axis labels with hybrid positioning:
157157
// - At 0° (horizontal): center label under the point (like bar chart)
@@ -264,7 +264,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
264264
{
265265
font,
266266
labelColor: theme.textSupporting,
267-
formatYLabel: formatYAxisLabel,
267+
formatYLabel: formatValue,
268268
tickCount: Y_AXIS_TICK_COUNT,
269269
lineWidth: Y_AXIS_LINE_WIDTH,
270270
lineColor: theme.border,
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import React, {useState} from 'react';
2+
import type {LayoutChangeEvent} from 'react-native';
3+
import {View} from 'react-native';
4+
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
5+
import Animated, {useSharedValue} from 'react-native-reanimated';
6+
import {scheduleOnRN} from 'react-native-worklets';
7+
import {Pie, PolarChart} from 'victory-native';
8+
import ActivityIndicator from '@components/ActivityIndicator';
9+
import ChartHeader from '@components/Charts/components/ChartHeader';
10+
import ChartTooltip from '@components/Charts/components/ChartTooltip';
11+
import {PIE_CHART_START_ANGLE} from '@components/Charts/constants';
12+
import {TOOLTIP_BAR_GAP, useChartLabelFormats, useTooltipData} from '@components/Charts/hooks';
13+
import type {ChartDataPoint, ChartProps, PieSlice, UnitPosition} from '@components/Charts/types';
14+
import {findSliceAtPosition, processDataIntoSlices} from '@components/Charts/utils';
15+
import Text from '@components/Text';
16+
import useThemeStyles from '@hooks/useThemeStyles';
17+
18+
type PieChartProps = ChartProps & {
19+
/** Callback when a slice is pressed */
20+
onSlicePress?: (dataPoint: ChartDataPoint, index: number) => void;
21+
22+
/** Symbol/unit for value labels in tooltip (e.g., '$', '€'). */
23+
valueUnit?: string;
24+
25+
/** Position of the unit symbol relative to the value. Defaults to 'left'. */
26+
valueUnitPosition?: UnitPosition;
27+
};
28+
29+
function PieChartContent({data, title, titleIcon, isLoading, valueUnit, valueUnitPosition, onSlicePress}: PieChartProps) {
30+
const styles = useThemeStyles();
31+
const [canvasWidth, setCanvasWidth] = useState(0);
32+
const [canvasHeight, setCanvasHeight] = useState(0);
33+
const [activeSliceIndex, setActiveSliceIndex] = useState(-1);
34+
35+
// Shared values for hover state
36+
const isHovering = useSharedValue(false);
37+
const cursorX = useSharedValue(0);
38+
const cursorY = useSharedValue(0);
39+
const tooltipPosition = useSharedValue({x: 0, y: 0});
40+
41+
const handleLayout = (event: LayoutChangeEvent) => {
42+
setCanvasWidth(event.nativeEvent.layout.width);
43+
setCanvasHeight(event.nativeEvent.layout.height);
44+
};
45+
46+
// Slices are sorted by absolute value (largest first) for color assignment,
47+
// so slice indices don't match the original data array. We map back via
48+
// originalIndex so the tooltip can display the original (possibly negative) value.
49+
const processedSlices = processDataIntoSlices(data, PIE_CHART_START_ANGLE);
50+
const activeOriginalDataIndex = activeSliceIndex >= 0 ? (processedSlices.at(activeSliceIndex)?.originalIndex ?? -1) : -1;
51+
52+
const {formatValue} = useChartLabelFormats({data, unit: valueUnit, unitPosition: valueUnitPosition});
53+
const tooltipData = useTooltipData(activeOriginalDataIndex, data, formatValue);
54+
55+
// Calculate pie geometry
56+
const pieGeometry = {radius: Math.min(canvasWidth, canvasHeight) / 2, centerX: canvasWidth / 2, centerY: canvasHeight / 2};
57+
58+
// Handle hover state updates
59+
const updateActiveSlice = (x: number, y: number) => {
60+
const {radius, centerX, centerY} = pieGeometry;
61+
const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, 0, processedSlices);
62+
setActiveSliceIndex(sliceIndex);
63+
};
64+
65+
// Handle slice press callback
66+
const handleSlicePress = (sliceIndex: number) => {
67+
if (sliceIndex < 0 || sliceIndex >= processedSlices.length) {
68+
return;
69+
}
70+
const slice = processedSlices.at(sliceIndex);
71+
if (!slice) {
72+
return;
73+
}
74+
const originalDataPoint = data.at(slice.originalIndex);
75+
if (originalDataPoint && onSlicePress) {
76+
onSlicePress(originalDataPoint, slice.originalIndex);
77+
}
78+
};
79+
80+
// Hover gesture
81+
const hoverGesture = () =>
82+
Gesture.Hover()
83+
.onBegin((e) => {
84+
'worklet';
85+
86+
isHovering.set(true);
87+
cursorX.set(e.x);
88+
cursorY.set(e.y);
89+
tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP});
90+
scheduleOnRN(updateActiveSlice, e.x, e.y);
91+
})
92+
.onUpdate((e) => {
93+
'worklet';
94+
95+
cursorX.set(e.x);
96+
cursorY.set(e.y);
97+
tooltipPosition.set({x: e.x, y: e.y - TOOLTIP_BAR_GAP});
98+
scheduleOnRN(updateActiveSlice, e.x, e.y);
99+
})
100+
.onEnd(() => {
101+
'worklet';
102+
103+
isHovering.set(false);
104+
scheduleOnRN(setActiveSliceIndex, -1);
105+
});
106+
107+
// Tap gesture for click/tap navigation
108+
const tapGesture = () =>
109+
Gesture.Tap().onEnd((e) => {
110+
'worklet';
111+
112+
const {radius, centerX, centerY} = pieGeometry;
113+
const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, 0, processedSlices);
114+
115+
if (sliceIndex >= 0) {
116+
scheduleOnRN(handleSlicePress, sliceIndex);
117+
}
118+
});
119+
120+
// Combined gestures - Race allows both hover and tap to work independently
121+
const combinedGesture = Gesture.Race(hoverGesture(), tapGesture());
122+
123+
const renderLegendItem = (slice: PieSlice) => {
124+
return (
125+
<View
126+
key={`legend-${slice.label}`}
127+
style={[styles.flexRow, styles.alignItemsCenter, styles.mr4, styles.mb2]}
128+
>
129+
<View style={[styles.pieChartLegendDot, {backgroundColor: slice.color}]} />
130+
<Text style={[styles.textNormal, styles.ml2]}>{slice.label}</Text>
131+
</View>
132+
);
133+
};
134+
135+
if (isLoading) {
136+
return (
137+
<View style={[styles.pieChartContainer, styles.highlightBG, styles.justifyContentCenter, styles.alignItemsCenter]}>
138+
<ActivityIndicator size="large" />
139+
</View>
140+
);
141+
}
142+
143+
if (data.length === 0) {
144+
return null;
145+
}
146+
147+
return (
148+
<View style={[styles.pieChartContainer, styles.highlightBG]}>
149+
<ChartHeader
150+
title={title}
151+
titleIcon={titleIcon}
152+
/>
153+
154+
<GestureDetector gesture={combinedGesture}>
155+
<Animated.View
156+
style={styles.pieChartChartContainer}
157+
onLayout={handleLayout}
158+
>
159+
{processedSlices.length > 0 && (
160+
<PolarChart
161+
data={processedSlices}
162+
labelKey="label"
163+
valueKey="value"
164+
colorKey="color"
165+
>
166+
<Pie.Chart startAngle={PIE_CHART_START_ANGLE} />
167+
</PolarChart>
168+
)}
169+
170+
{/* Tooltip */}
171+
{activeSliceIndex >= 0 && !!tooltipData && (
172+
<ChartTooltip
173+
label={tooltipData.label}
174+
amount={tooltipData.amount}
175+
percentage={tooltipData.percentage}
176+
chartWidth={canvasWidth}
177+
initialTooltipPosition={tooltipPosition}
178+
/>
179+
)}
180+
</Animated.View>
181+
</GestureDetector>
182+
<View style={styles.pieChartLegendContainer}>{processedSlices.map((slice) => renderLegendItem(slice))}</View>
183+
</View>
184+
);
185+
}
186+
187+
export default PieChartContent;
188+
export type {PieChartProps};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React from 'react';
2+
import type {PieChartProps} from './PieChartContent';
3+
import PieChartContent from './PieChartContent';
4+
5+
function PieChart(props: PieChartProps) {
6+
// eslint-disable-next-line react/jsx-props-no-spreading
7+
return <PieChartContent {...props} />;
8+
}
9+
10+
PieChart.displayName = 'PieChart';
11+
12+
export default PieChart;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web';
2+
import React from 'react';
3+
import {View} from 'react-native';
4+
import ActivityIndicator from '@components/ActivityIndicator';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
import type {PieChartProps} from './PieChartContent';
7+
8+
const getPieChartContent = () => import('./PieChartContent');
9+
10+
function PieChart(props: PieChartProps) {
11+
const styles = useThemeStyles();
12+
13+
return (
14+
<WithSkiaWeb
15+
opts={{locateFile: (file: string) => `/${file}`}}
16+
getComponent={getPieChartContent}
17+
componentProps={props}
18+
fallback={
19+
<View style={[styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter, styles.highlightBG, styles.br4, styles.p5]}>
20+
<ActivityIndicator size="large" />
21+
</View>
22+
}
23+
/>
24+
);
25+
}
26+
27+
PieChart.displayName = 'PieChart';
28+
29+
export default PieChart;

src/components/Charts/constants.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@ const X_AXIS_LINE_WIDTH = 0;
1616
/** Line width for Y-axis grid lines */
1717
const Y_AXIS_LINE_WIDTH = 1;
1818

19-
export {Y_AXIS_TICK_COUNT, AXIS_LABEL_GAP, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH};
19+
/** Starting angle for pie chart (0 = 3 o'clock, -90 = 12 o'clock) */
20+
const PIE_CHART_START_ANGLE = -90;
21+
22+
export {Y_AXIS_TICK_COUNT, AXIS_LABEL_GAP, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, PIE_CHART_START_ANGLE};
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
export {useChartInteractionState} from './useChartInteractionState';
21
export {useChartLabelLayout} from './useChartLabelLayout';
3-
export {useChartInteractions} from './useChartInteractions';
2+
export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions';
43
export type {HitTestArgs} from './useChartInteractions';
5-
export type {ChartInteractionState} from './useChartInteractionState';
64
export {default as useChartLabelFormats} from './useChartLabelFormats';
75
export {default as useDynamicYDomain} from './useDynamicYDomain';
86
export {useTooltipData} from './useTooltipData';

src/components/Charts/hooks/useChartInteractions.ts

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,33 +49,8 @@ type CartesianActionsHandle = {
4949
};
5050

5151
/**
52-
* Hook to manage complex chart interactions including hover gestures (web),
53-
* tap gestures (mobile/web), hit-testing, and animated tooltip positioning.
54-
*
55-
* It synchronizes high-frequency interaction data from the UI thread to React state
56-
* for metadata display (like tooltips) and navigation.
57-
*
58-
* @param props - Configuration including press handlers and hit-test logic.
59-
* @returns An object containing refs, gestures, and state for the chart component.
60-
*
61-
* @example
62-
* ```tsx
63-
* const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({
64-
* handlePress: (index) => console.log("Pressed index:", index),
65-
* checkIsOver: ({ cursorX, targetX, barWidth }) => {
66-
* 'worklet';
67-
* return Math.abs(cursorX - targetX) < barWidth / 2;
68-
* },
69-
* barGeometry: myBarSharedValue,
70-
* });
71-
*
72-
* return (
73-
* <View>
74-
* <CartesianChart customGestures={customGestures} actionsRef={actionsRef} ... />
75-
* {isTooltipActive && <Animated.View style={tooltipStyle}><Tooltip index={activeDataIndex} /></Animated.View>}
76-
* </View>
77-
* );
78-
* ```
52+
* Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning.
53+
* Synchronizes high-frequency UI thread data to React state for tooltip display and navigation.
7954
*/
8055
function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: UseChartInteractionsProps) {
8156
/** Interaction state compatible with Victory Native's internal logic */
@@ -225,5 +200,5 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
225200
};
226201
}
227202

228-
export {useChartInteractions};
203+
export {useChartInteractions, TOOLTIP_BAR_GAP};
229204
export type {HitTestArgs};

0 commit comments

Comments
 (0)