Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9e179c2
Implement line chart
mateuuszzzzz Jan 30, 2026
a8c40b2
Add english translation
mateuuszzzzz Jan 30, 2026
6340c82
Refactor charts directory and make it more DRY
mateuuszzzzz Jan 30, 2026
2c42fde
Add remaining translations
mateuuszzzzz Jan 30, 2026
acec8e6
Fix default color
mateuuszzzzz Jan 30, 2026
03dc033
Move GroupedItem to common types
mateuuszzzzz Jan 30, 2026
e53afcd
Fix domain padding in BarChart
mateuuszzzzz Jan 30, 2026
d5966d6
Fix BarChart domain padding
mateuuszzzzz Jan 30, 2026
d3f8df9
Bring back removed type imports
mateuuszzzzz Jan 30, 2026
c643578
Address comments from previous PR
mateuuszzzzz Jan 30, 2026
513f1ac
Add missing import
mateuuszzzzz Jan 30, 2026
b7b1a17
Format code and add type guard for grouped data
mateuuszzzzz Jan 30, 2026
01ab33f
Fix label rendering issues
mateuuszzzzz Jan 30, 2026
d584243
Revert "Fix label rendering issues"
mateuuszzzzz Jan 30, 2026
f21f713
Add mising d3 types to devDependencies
mateuuszzzzz Feb 3, 2026
023a2a1
Fix label rendering in LineChart by introducing custom label component
mateuuszzzzz Feb 3, 2026
aafce99
Resolve conflicts
mateuuszzzzz Feb 3, 2026
b283a70
Fix spellcheck
mateuuszzzzz Feb 3, 2026
b631ccb
Refactor useTooltipData and fix tests
mateuuszzzzz Feb 3, 2026
c090cca
Improve label layout primitives for LineChart
mateuuszzzzz Feb 4, 2026
ef1a0ae
Address review comments
mateuuszzzzz Feb 4, 2026
2a70f4f
Fix LineChart layout issue of first left-most label
mateuuszzzzz Feb 4, 2026
993739d
Change default sorting order for line chart
mateuuszzzzz Feb 5, 2026
dd295b8
Center LineChart labels when rotation degree is 0
mateuuszzzzz Feb 5, 2026
fa5a338
Improve naming conventions
mateuuszzzzz Feb 5, 2026
d28141d
Maintain constant 8px gap between plot and labels
mateuuszzzzz Feb 5, 2026
2d11275
Move labelY utility function to utils
mateuuszzzzz Feb 5, 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
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,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",
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7169,6 +7169,7 @@ const CONST = {
VIEW: {
TABLE: 'table',
BAR: 'bar',
LINE: 'line',
},
SYNTAX_FILTER_KEYS: {
TYPE: 'type',
Expand Down
115 changes: 36 additions & 79 deletions src/components/Charts/BarChart/BarChartContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,35 @@ 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 {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 {calculateMinDomainPadding, 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';

/**
* Calculate minimum domainPadding required to prevent bars from overflowing 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.
*/
function calculateMinDomainPadding(chartWidth: number, barCount: number, innerPadding: number): number {
if (barCount <= 0) {
return 0;
}
const minPaddingRatio = (1 - innerPadding) / (2 * (barCount - 1 + innerPadding));
return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER);
}
/** 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 BASE_DOMAIN_PADDING = {top: 32, bottom: 0, left: 0, right: 0};

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();
Expand All @@ -55,9 +40,7 @@ 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 = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX);
const defaultBarColor = DEFAULT_CHART_COLOR;

// prepare data for display
const chartData = useMemo(() => {
Expand All @@ -67,9 +50,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 All @@ -86,25 +67,22 @@ 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(() => {
if (chartWidth === 0) {
return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom};
return BASE_DOMAIN_PADDING;
}
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 {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding};
Comment on lines 84 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's edge case where data point is still cut off.
Fixed later in #81669

}, [chartWidth, data.length]);

const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({
Expand Down Expand Up @@ -134,7 +112,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),
Expand Down Expand Up @@ -169,29 +147,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 +163,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}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nab: why are these no longer constants?

/>
);
},
Expand All @@ -218,9 +174,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) {
Expand All @@ -242,7 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
titleIcon={titleIcon}
/>
<View
style={[styles.barChartChartContainer, labelRotation === -90 ? dynamicChartStyle : undefined]}
style={[styles.barChartChartContainer, dynamicChartStyle]}
onLayout={handleLayout}
>
{chartWidth > 0 && (
Expand Down Expand Up @@ -276,7 +232,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 +253,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
Loading
Loading