Skip to content

Commit f939855

Browse files
authored
Merge pull request Expensify#81049 from software-mansion-labs/add-search-line-chart-component
feat: Add `SearchLineChart` component
2 parents 654ef3c + 2d11275 commit f939855

43 files changed

Lines changed: 943 additions & 460 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@
272272
"@types/base-64": "^1.0.2",
273273
"@types/canvas-size": "^1.2.2",
274274
"@types/concurrently": "^7.0.0",
275+
"@types/d3-scale": "^4.0.9",
275276
"@types/howler": "^2.2.12",
276277
"@types/jest": "^29.5.14",
277278
"@types/jest-when": "^3.5.2",

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7180,6 +7180,7 @@ const CONST = {
71807180
VIEW: {
71817181
TABLE: 'table',
71827182
BAR: 'bar',
7183+
LINE: 'line',
71837184
},
71847185
SYNTAX_FILTER_KEYS: {
71857186
TYPE: 'type',

src/components/Charts/BarChart/BarChartContent.tsx

Lines changed: 36 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,35 @@ import React, {useCallback, useMemo, useState} from 'react';
33
import type {LayoutChangeEvent} from 'react-native';
44
import {View} from 'react-native';
55
import Animated, {useSharedValue} from 'react-native-reanimated';
6-
import type {ChartBounds, PointsArray} from 'victory-native';
6+
import type {ChartBounds, PointsArray, Scale} from 'victory-native';
77
import {Bar, CartesianChart} from 'victory-native';
88
import ActivityIndicator from '@components/ActivityIndicator';
9-
import {getChartColor} from '@components/Charts/chartColors';
10-
import ChartHeader from '@components/Charts/ChartHeader';
11-
import ChartTooltip from '@components/Charts/ChartTooltip';
12-
import {
13-
BAR_INNER_PADDING,
14-
BAR_ROUNDED_CORNERS,
15-
CHART_COLORS,
16-
CHART_CONTENT_MIN_HEIGHT,
17-
CHART_PADDING,
18-
DEFAULT_SINGLE_BAR_COLOR_INDEX,
19-
DOMAIN_PADDING,
20-
DOMAIN_PADDING_SAFETY_BUFFER,
21-
FRAME_LINE_WIDTH,
22-
X_AXIS_LINE_WIDTH,
23-
Y_AXIS_LABEL_OFFSET,
24-
Y_AXIS_LINE_WIDTH,
25-
Y_AXIS_TICK_COUNT,
26-
} from '@components/Charts/constants';
9+
import ChartHeader from '@components/Charts/components/ChartHeader';
10+
import ChartTooltip from '@components/Charts/components/ChartTooltip';
11+
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';
2712
import fontSource from '@components/Charts/font';
2813
import type {HitTestArgs} from '@components/Charts/hooks';
29-
import {useChartInteractions, useChartLabelFormats, useChartLabelLayout} from '@components/Charts/hooks';
30-
import type {BarChartProps} from '@components/Charts/types';
14+
import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks';
15+
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
16+
import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
3117
import useResponsiveLayout from '@hooks/useResponsiveLayout';
3218
import useTheme from '@hooks/useTheme';
3319
import useThemeStyles from '@hooks/useThemeStyles';
3420
import variables from '@styles/variables';
3521

36-
/**
37-
* Calculate minimum domainPadding required to prevent bars from overflowing chart edges.
38-
*
39-
* The issue: victory-native calculates bar width as (1 - innerPadding) * chartWidth / barCount,
40-
* but positions bars at indices [0, 1, ..., n-1] scaled to the chart width with domainPadding.
41-
* For small bar counts, the default padding is insufficient and bars overflow.
42-
*/
43-
function calculateMinDomainPadding(chartWidth: number, barCount: number, innerPadding: number): number {
44-
if (barCount <= 0) {
45-
return 0;
46-
}
47-
const minPaddingRatio = (1 - innerPadding) / (2 * (barCount - 1 + innerPadding));
48-
return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER);
49-
}
22+
/** Inner padding between bars (0.3 = 30% of bar width) */
23+
const BAR_INNER_PADDING = 0.3;
24+
25+
/** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) */
26+
const BASE_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0};
27+
28+
type BarChartProps = CartesianChartProps & {
29+
/** Callback when a bar is pressed */
30+
onBarPress?: (dataPoint: ChartDataPoint, index: number) => void;
31+
32+
/** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */
33+
useSingleColor?: boolean;
34+
};
5035

5136
function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) {
5237
const theme = useTheme();
@@ -55,9 +40,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
5540
const font = useFont(fontSource, variables.iconSizeExtraSmall);
5641
const [chartWidth, setChartWidth] = useState(0);
5742
const [barAreaWidth, setBarAreaWidth] = useState(0);
58-
const [containerHeight, setContainerHeight] = useState(0);
59-
60-
const defaultBarColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX);
43+
const defaultBarColor = DEFAULT_CHART_COLOR;
6144

6245
// prepare data for display
6346
const chartData = useMemo(() => {
@@ -67,9 +50,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
6750
}));
6851
}, [data]);
6952

70-
// Anchor Y-axis at zero so the baseline is always visible.
71-
// When negative values are present, let victory-native auto-calculate the domain to avoid clipping.
72-
const yAxisDomain = useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]);
53+
const yAxisDomain = useDynamicYDomain(data);
7354

7455
// Handle bar press callback
7556
const handleBarPress = useCallback(
@@ -86,25 +67,22 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
8667
);
8768

8869
const handleLayout = useCallback((event: LayoutChangeEvent) => {
89-
const {width, height} = event.nativeEvent.layout;
90-
setChartWidth(width);
91-
setContainerHeight(height);
70+
setChartWidth(event.nativeEvent.layout.width);
9271
}, []);
9372

94-
const {labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength} = useChartLabelLayout({
73+
const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({
9574
data,
9675
font,
97-
chartWidth,
98-
barAreaWidth,
99-
containerHeight,
76+
tickSpacing: barAreaWidth > 0 ? barAreaWidth / data.length : 0,
77+
labelAreaWidth: barAreaWidth,
10078
});
10179

10280
const domainPadding = useMemo(() => {
10381
if (chartWidth === 0) {
104-
return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom};
82+
return BASE_DOMAIN_PADDING;
10583
}
10684
const horizontalPadding = calculateMinDomainPadding(chartWidth, data.length, BAR_INNER_PADDING);
107-
return {left: horizontalPadding, right: horizontalPadding + DOMAIN_PADDING.right, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom};
85+
return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding};
10886
}, [chartWidth, data.length]);
10987

11088
const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({
@@ -134,7 +112,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
134112
);
135113

136114
const handleScaleChange = useCallback(
137-
(_xScale: unknown, yScale: (value: number) => number) => {
115+
(_xScale: Scale, yScale: Scale) => {
138116
barGeometry.set({
139117
...barGeometry.get(),
140118
yZero: yScale(0),
@@ -169,29 +147,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
169147
barGeometry,
170148
});
171149

172-
const tooltipData = useMemo(() => {
173-
if (activeDataIndex < 0 || activeDataIndex >= data.length) {
174-
return null;
175-
}
176-
const dataPoint = data.at(activeDataIndex);
177-
if (!dataPoint) {
178-
return null;
179-
}
180-
const formatted = dataPoint.total.toLocaleString();
181-
let formattedAmount = formatted;
182-
if (yAxisUnit) {
183-
// Add space for multi-character codes (e.g., "PLN 100") but not for symbols (e.g., "$100")
184-
const separator = yAxisUnit.length > 1 ? ' ' : '';
185-
formattedAmount = yAxisUnitPosition === 'left' ? `${yAxisUnit}${separator}${formatted}` : `${formatted}${separator}${yAxisUnit}`;
186-
}
187-
const totalSum = data.reduce((sum, point) => sum + Math.abs(point.total), 0);
188-
const percent = totalSum > 0 ? Math.round((Math.abs(dataPoint.total) / totalSum) * 100) : 0;
189-
return {
190-
label: dataPoint.label,
191-
amount: formattedAmount,
192-
percentage: percent < 1 ? '<1%' : `${percent}%`,
193-
};
194-
}, [activeDataIndex, data, yAxisUnit, yAxisUnitPosition]);
150+
const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition);
195151

196152
const renderBar = useCallback(
197153
(point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => {
@@ -207,7 +163,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
207163
color={barColor}
208164
barCount={barCount}
209165
innerPadding={BAR_INNER_PADDING}
210-
roundedCorners={BAR_ROUNDED_CORNERS}
166+
roundedCorners={{topLeft: 8, topRight: 8, bottomLeft: 8, bottomRight: 8}}
211167
/>
212168
);
213169
},
@@ -218,9 +174,9 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
218174
// This keeps bar area at ~250px while giving labels their needed vertical space
219175
const dynamicChartStyle = useMemo(
220176
() => ({
221-
height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0),
177+
height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0),
222178
}),
223-
[maxLabelLength],
179+
[xAxisLabelHeight],
224180
);
225181

226182
if (isLoading || !font) {
@@ -242,7 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
242198
titleIcon={titleIcon}
243199
/>
244200
<View
245-
style={[styles.barChartChartContainer, labelRotation === -90 ? dynamicChartStyle : undefined]}
201+
style={[styles.barChartChartContainer, dynamicChartStyle]}
246202
onLayout={handleLayout}
247203
>
248204
{chartWidth > 0 && (
@@ -276,7 +232,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
276232
domain: yAxisDomain,
277233
},
278234
]}
279-
frame={{lineWidth: FRAME_LINE_WIDTH}}
235+
frame={{lineWidth: 0}}
280236
data={chartData}
281237
>
282238
{({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}</>}
@@ -297,3 +253,4 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
297253
}
298254

299255
export default BarChartContent;
256+
export type {BarChartProps};

src/components/Charts/BarChart/index.native.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import type {BarChartProps} from '@components/Charts/types';
2+
import type {BarChartProps} from './BarChartContent';
33
import BarChartContent from './BarChartContent';
44

55
function BarChart(props: BarChartProps) {

src/components/Charts/BarChart/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web';
22
import React from 'react';
33
import {View} from 'react-native';
44
import ActivityIndicator from '@components/ActivityIndicator';
5-
import type {BarChartProps} from '@components/Charts/types';
65
import useThemeStyles from '@hooks/useThemeStyles';
6+
import type {BarChartProps} from './BarChartContent';
77

88
function BarChart(props: BarChartProps) {
99
const styles = useThemeStyles();

0 commit comments

Comments
 (0)