Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7182,6 +7182,7 @@ const CONST = {
VIEW: {
TABLE: 'table',
BAR: 'bar',
LINE: 'line',
},
SYNTAX_FILTER_KEYS: {
TYPE: 'type',
Expand Down
87 changes: 36 additions & 51 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,41 @@ 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 {
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,
Y_AXIS_TICK_COUNT,
} from '@components/Charts/constants';
import ChartHeader from '@components/Charts/components/ChartHeader';
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} 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 {DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
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;

/** 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;

/**
* Calculate minimum domainPadding required to prevent bars from overflowing chart edges.
*
Expand All @@ -57,7 +65,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(() => {
Expand All @@ -67,9 +75,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(
Expand Down Expand Up @@ -101,10 +107,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, right: horizontalPadding + DOMAIN_PADDING.right, left: horizontalPadding};
}, [chartWidth, data.length]);

const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({
Expand Down Expand Up @@ -169,29 +175,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) => {
Expand All @@ -207,7 +191,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}}
/>
);
},
Expand Down Expand Up @@ -276,7 +260,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))}</>}
Expand All @@ -297,3 +281,4 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
}

export default BarChartContent;
export type {BarChartProps};
2 changes: 1 addition & 1 deletion src/components/Charts/BarChart/index.native.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Charts/BarChart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '@components/Charts/types';
import useThemeStyles from '@hooks/useThemeStyles';
import type {BarChartProps} from './BarChartContent';

function BarChart(props: BarChartProps) {
const styles = useThemeStyles();
Expand Down
190 changes: 190 additions & 0 deletions src/components/Charts/LineChart/LineChartContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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 ChartHeader from '@components/Charts/components/ChartHeader';
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 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';
import variables from '@styles/variables';

/** 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) {
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 yAxisDomain = useDynamicYDomain(data);

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 = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition);

const dynamicChartStyle = useMemo(
() => ({
height: CHART_CONTENT_MIN_HEIGHT + (maxLabelLength ?? 0),
}),
[maxLabelLength],
);

if (isLoading || !font) {
return (
<View style={[styles.lineChartContainer, styles.highlightBG, shouldUseNarrowLayout ? styles.p5 : styles.p8, styles.justifyContentCenter, styles.alignItemsCenter]}>
<ActivityIndicator size="large" />
</View>
);
}

if (data.length === 0) {
return null;
}

return (
<View style={[styles.lineChartContainer, styles.highlightBG, shouldUseNarrowLayout ? styles.p5 : styles.p8]}>
<ChartHeader
title={title}
titleIcon={titleIcon}
/>
<View
style={[styles.lineChartChartContainer, labelRotation === -90 ? dynamicChartStyle : undefined]}
onLayout={handleLayout}
>
{chartWidth > 0 && (
<CartesianChart
xKey="x"
padding={CHART_PADDING}
yKeys={['y']}
domainPadding={{left: 20, right: 20, top: 20, bottom: 20}}
actionsRef={actionsRef}
customGestures={customGestures}
xAxis={{
font,
tickCount: data.length,
labelColor: theme.textSupporting,
lineWidth: X_AXIS_LINE_WIDTH,
formatXLabel: formatXAxisLabel,
labelRotate: labelRotation,
labelOverflow: 'visible',
}}
yAxis={[
{
font,
labelColor: theme.textSupporting,
formatYLabel: formatYAxisLabel,
tickCount: Y_AXIS_TICK_COUNT,
lineWidth: Y_AXIS_LINE_WIDTH,
lineColor: theme.border,
labelOffset: Y_AXIS_LABEL_OFFSET,
domain: yAxisDomain,
},
]}
frame={{lineWidth: {left: 1, bottom: 1, top: 0, right: 0}}}
data={chartData}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The hardcoded color index 5 is a magic number without documentation or a named constant.

Suggested fix:

Define a named constant at the top of the file:

/** Default color index for line charts */
const DEFAULT_LINE_COLOR_INDEX = 5;

Then use it:

color={getChartColor(DEFAULT_LINE_COLOR_INDEX)}

And similarly on line 197 for the Scatter component.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

{({points}) => (
<>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The hardcoded strokeWidth={2} is a magic number. For consistency with other chart constants (X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, etc.), this should be defined as a named constant.

Suggested fix:

Add to src/components/Charts/constants.ts:

/** Stroke width for line chart lines */
const LINE_STROKE_WIDTH = 2;

Then import and use it:

strokeWidth={LINE_STROKE_WIDTH}

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

<Line
points={points.y}
color={DEFAULT_CHART_COLOR}
strokeWidth={2}
curveType="linear"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

Same magic number issue as line 190 - the hardcoded color index 5 should be a named constant (e.g., DEFAULT_LINE_COLOR_INDEX).


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

/>
<Scatter
points={points.y}
radius={DOT_INNER_RADIUS}
color={DEFAULT_CHART_COLOR}
/>
</>
)}
</CartesianChart>
)}
{isTooltipActive && !!tooltipData && (
<Animated.View style={tooltipStyle}>
<ChartTooltip
label={tooltipData.label}
amount={tooltipData.amount}
percentage={tooltipData.percentage}
/>
</Animated.View>
)}
</View>
</View>
);
}

export default LineChartContent;
export type {LineChartProps};
12 changes: 12 additions & 0 deletions src/components/Charts/LineChart/index.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import type {LineChartProps} from './LineChartContent';
import LineChartContent from './LineChartContent';

function LineChart(props: LineChartProps) {
// eslint-disable-next-line react/jsx-props-no-spreading
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-5 (docs)

The ESLint rule disable lacks an accompanying comment explaining why the rule is being disabled.

Suggested fix:

function LineChart(props: LineChartProps) {
    // eslint-disable-next-line react/jsx-props-no-spreading -- Spreading props is necessary here to pass all LineChartProps to the platform-specific content component
    return <LineChartContent {...props} />;
}

This ensures team members understand why spreading props is acceptable in this platform wrapper component.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

return <LineChartContent {...props} />;
}

LineChart.displayName = 'LineChart';

export default LineChart;
Loading
Loading