diff --git a/package-lock.json b/package-lock.json index ec061e3a51d24..c1a8a42e3aa10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,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", @@ -16349,6 +16350,23 @@ "@types/node": "*" } }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", diff --git a/package.json b/package.json index a081cb938276b..a7e022a083fe9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e23570f40a7ed..c45ae63c589f2 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7169,6 +7169,7 @@ const CONST = { VIEW: { TABLE: 'table', BAR: 'bar', + LINE: 'line', }, SYNTAX_FILTER_KEYS: { TYPE: 'type', diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index c1d60f962b6b3..19dc0a723db5e 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -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(); @@ -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(() => { @@ -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( @@ -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}; }, [chartWidth, data.length]); const {formatXAxisLabel, formatYAxisLabel} = useChartLabelFormats({ @@ -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), @@ -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) => { @@ -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}} /> ); }, @@ -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) { @@ -242,7 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni titleIcon={titleIcon} /> {chartWidth > 0 && ( @@ -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))}} @@ -297,3 +253,4 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } export default BarChartContent; +export type {BarChartProps}; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 82396525a5b00..54f47ea8bb1af 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -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) { diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index c82a92ecbf23e..90e4bee47edb4 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -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(); diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx new file mode 100644 index 0000000000000..94e16c7936997 --- /dev/null +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -0,0 +1,308 @@ +import {Group, Text as SkiaText, useFont, vec} 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 type {CartesianChartRenderArg, ChartBounds} from 'victory-native'; +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 {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, rotatedLabelYOffset} 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_RADIUS = 6; + +/** Extra hover area beyond the dot radius for easier touch targeting */ +const DOT_HOVER_EXTRA_RADIUS = 2; + +/** Base domain padding applied to all sides */ +const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; + +/** Consistent gap between x-axis and closest point of label (regardless of rotation) */ +const LABEL_GAP = 8; + +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 [plotAreaWidth, setPlotAreaWidth] = 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) => { + setChartWidth(event.nativeEvent.layout.width); + }, []); + + const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { + setPlotAreaWidth(bounds.right - bounds.left); + }, []); + + // Calculate dynamic domain padding for centered labels + // Optimize by reducing wasted space when edge labels are shorter than tick spacing + const domainPadding = useMemo(() => { + if (chartWidth === 0 || data.length === 0) { + return BASE_DOMAIN_PADDING; + } + + const geometricPadding = calculateMinDomainPadding(chartWidth, data.length); + + // Without font, use geometric padding (safe fallback) + if (!font) { + return {...BASE_DOMAIN_PADDING, left: geometricPadding, right: geometricPadding}; + } + + // Measure edge labels to see if we can reduce padding + const firstLabelWidth = measureTextWidth(data.at(0)?.label ?? '', font); + const lastLabelWidth = measureTextWidth(data.at(-1)?.label ?? '', font); + + // At 0° rotation, centered labels extend by half their width + const firstLabelNeeds = firstLabelWidth / 2; + const lastLabelNeeds = lastLabelWidth / 2; + + // How much space is wasted on each side + const wastedLeft = geometricPadding - firstLabelNeeds; + const wastedRight = geometricPadding - lastLabelNeeds; + const canReduce = Math.min(wastedLeft, wastedRight); + + // Only reduce if both sides have excess space (labels short enough for 0°) + // If canReduce <= 0, labels are too long and hook will use rotation/truncation + const horizontalPadding = canReduce > 0 ? geometricPadding - canReduce : geometricPadding; + + return {...BASE_DOMAIN_PADDING, left: horizontalPadding, right: horizontalPadding}; + }, [chartWidth, data, font]); + + // For centered labels, tick spacing is evenly distributed across the plot area (same as BarChart) + const tickSpacing = plotAreaWidth > 0 && data.length > 0 ? plotAreaWidth / data.length : 0; + + const {labelRotation, labelSkipInterval, truncatedLabels, xAxisLabelHeight} = useChartLabelLayout({ + data, + font, + tickSpacing, + labelAreaWidth: plotAreaWidth, + allowTightDiagonalPacking: true, + }); + + // Measure label widths for custom positioning in `renderOutside` + const labelWidths = useMemo(() => { + if (!font) { + return [] as number[]; + } + return truncatedLabels.map((label) => measureTextWidth(label, font)); + }, [font, truncatedLabels]); + + // Convert hook's degree rotation to radians for Skia rendering + const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + + const {formatYAxisLabel} = useChartLabelFormats({ + data, + yAxisUnit, + yAxisUnitPosition, + }); + + 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_RADIUS + DOT_HOVER_EXTRA_RADIUS; + }, []); + + const {actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle} = useChartInteractions({ + handlePress: handlePointPress, + checkIsOver: checkIsOverDot, + }); + + const tooltipData = useTooltipData(activeDataIndex, data, yAxisUnit, yAxisUnitPosition); + + // Custom x-axis labels with hybrid positioning: + // - At 0° (horizontal): center label under the point (like bar chart) + // - At 45° (rotated): right-align so the last character is under the point + const renderCustomXLabels = useCallback( + (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + if (!font) { + return null; + } + + const fontMetrics = font.getMetrics(); + const ascent = Math.abs(fontMetrics.ascent); + const descent = Math.abs(fontMetrics.descent); + const labelY = args.chartBounds.bottom + LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad); + + return truncatedLabels.map((label, i) => { + if (i % labelSkipInterval !== 0) { + return null; + } + + const tickX = args.xScale(i); + const labelWidth = labelWidths.at(i) ?? 0; + + // At 0°: center the label under the point (like bar chart) + // At 45°: right-align so the last character is under the point + if (angleRad === 0) { + return ( + + ); + } + + const textX = tickX - labelWidth; // right-aligned for rotated labels + const origin = vec(tickX, labelY); + + // Rotate around the anchor, then translate to correct for ascent/descent + // asymmetry (ascent > descent shifts the visual center left of the anchor). + const correction = rotatedLabelCenterCorrection(ascent, descent, angleRad); + + return ( + + + + ); + }); + }, + [font, truncatedLabels, labelSkipInterval, labelWidths, angleRad, theme.textSupporting], + ); + + const dynamicChartStyle = useMemo( + () => ({ + height: CHART_CONTENT_MIN_HEIGHT + (xAxisLabelHeight ?? 0), + }), + [xAxisLabelHeight], + ); + + if (isLoading || !font) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + + {chartWidth > 0 && ( + + {({points}) => ( + <> + + + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + + + )} + + + ); +} + +export default LineChartContent; +export type {LineChartProps}; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx new file mode 100644 index 0000000000000..db7c218db9aba --- /dev/null +++ b/src/components/Charts/LineChart/index.native.tsx @@ -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 + return ; +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/LineChart/index.tsx b/src/components/Charts/LineChart/index.tsx new file mode 100644 index 0000000000000..907d385722f8e --- /dev/null +++ b/src/components/Charts/LineChart/index.tsx @@ -0,0 +1,27 @@ +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 useThemeStyles from '@hooks/useThemeStyles'; +import type {LineChartProps} from './LineChartContent'; + +function LineChart(props: LineChartProps) { + const styles = useThemeStyles(); + + return ( + `/${file}`}} + getComponent={() => import('./LineChartContent')} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/chartColors.ts b/src/components/Charts/chartColors.ts deleted file mode 100644 index c34ea720c0409..0000000000000 --- a/src/components/Charts/chartColors.ts +++ /dev/null @@ -1,39 +0,0 @@ -import colors from '@styles/theme/colors'; - -/** - * Expensify Chart Color Palette. - * Sequence logic: - * 1. Row Sequence: 400, 600, 300, 500, 700 - * 2. Hue Order: Yellow, Tangerine, Pink, Green, Ice, Blue - */ -const CHART_PALETTE: string[] = (() => { - const rows = [400, 600, 300, 500, 700] as const; - const hues = ['yellow', 'tangerine', 'pink', 'green', 'ice', 'blue'] as const; - - const palette: string[] = []; - - // Generate the 30 unique combinations (5 rows × 6 hues) - for (const row of rows) { - for (const hue of hues) { - const colorKey = `${hue}${row}`; - if (colors[colorKey]) { - palette.push(colors[colorKey]); - } - } - } - - return palette; -})(); - -/** - * Gets a color from the chart palette based on index. - * Automatically loops back to the start if the index exceeds 29. - */ -function getChartColor(index: number): string { - if (CHART_PALETTE.length === 0) { - return colors.black; // Fallback - } - return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; -} - -export {CHART_PALETTE, getChartColor}; diff --git a/src/components/Charts/ChartHeader.tsx b/src/components/Charts/components/ChartHeader.tsx similarity index 100% rename from src/components/Charts/ChartHeader.tsx rename to src/components/Charts/components/ChartHeader.tsx diff --git a/src/components/Charts/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx similarity index 90% rename from src/components/Charts/ChartTooltip.tsx rename to src/components/Charts/components/ChartTooltip.tsx index 5c61feef80f70..be005042afb50 100644 --- a/src/components/Charts/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -3,7 +3,12 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from './constants'; + +/** The height of the chart tooltip pointer */ +const TOOLTIP_POINTER_HEIGHT = 4; + +/** The width of the chart tooltip pointer */ +const TOOLTIP_POINTER_WIDTH = 12; type ChartTooltipProps = { /** Label text (e.g., "Airfare", "Amazon") */ diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index b0ed40872622d..b8b987b9fe19a 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -1,113 +1,19 @@ -import type {Color} from '@shopify/react-native-skia'; -import type {RoundedCorners} from 'victory-native'; -import colors from '@styles/theme/colors'; - -/** - * Chart color palette from Figma design. - * Colors cycle when there are more data points than colors. - */ -const CHART_COLORS: Color[] = [colors.yellow400, colors.tangerine400, colors.pink400, colors.green400, colors.ice400]; - /** Number of Y-axis ticks (including zero) */ const Y_AXIS_TICK_COUNT = 5; -/** Inner padding between bars (0.3 = 30% of bar width) */ -const BAR_INNER_PADDING = 0.3; - -/** Domain padding configuration for the chart */ -const DOMAIN_PADDING = { - left: 0, - right: 16, - top: 30, - bottom: 10, -}; - /** Distance between Y-axis labels and the chart */ const Y_AXIS_LABEL_OFFSET = 16; -/** Rounded corners radius for bars */ -const BAR_CORNER_RADIUS = 8; - -/** Rounded corners configuration for bars */ -const BAR_ROUNDED_CORNERS: RoundedCorners = { - topLeft: BAR_CORNER_RADIUS, - topRight: BAR_CORNER_RADIUS, - bottomLeft: BAR_CORNER_RADIUS, - bottomRight: BAR_CORNER_RADIUS, -}; - /** Chart padding */ const CHART_PADDING = 5; /** Minimum height for the chart content area (bars, Y-axis, grid lines) */ const CHART_CONTENT_MIN_HEIGHT = 250; -/** Default bar color index when useSingleColor is true (ice blue) */ -const DEFAULT_SINGLE_BAR_COLOR_INDEX = 4; - -/** Safety buffer multiplier for domain padding calculation */ -const DOMAIN_PADDING_SAFETY_BUFFER = 1.1; - /** Line width for X-axis (hidden) */ const X_AXIS_LINE_WIDTH = 0; /** Line width for Y-axis grid lines */ const Y_AXIS_LINE_WIDTH = 1; -/** Line width for frame (hidden) */ -const FRAME_LINE_WIDTH = 0; - -/** The height of the chart tooltip pointer */ -const TOOLTIP_POINTER_HEIGHT = 4; - -/** The width of the chart tooltip pointer */ -const TOOLTIP_POINTER_WIDTH = 12; - -/** Gap between bar top and tooltip bottom */ -const TOOLTIP_BAR_GAP = 8; - -/** Rotation angle for X-axis labels - 45 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_45 = -45; - -/** Rotation angle for X-axis labels - 90 degrees (in degrees) */ -const X_AXIS_LABEL_ROTATION_90 = -90; - -/** Sin of 45 degrees - used to calculate effective width of rotated labels */ -const SIN_45_DEGREES = Math.sin(Math.PI / 4); // ≈ 0.707 - -/** Minimum padding between labels (in pixels) */ -const LABEL_PADDING = 4; - -/** Maximum ratio of container height that X-axis labels can occupy. - * Victory allocates: fontHeight + yLabelOffset * 2 + rotateOffset. - * With fontHeight ~12px and yLabelOffset = 16, base is ~44px. - * This ratio limits total label area to prevent labels from taking too much space. */ -const X_AXIS_LABEL_MAX_HEIGHT_RATIO = 0.35; - -/** Ellipsis character for truncated labels */ -const LABEL_ELLIPSIS = '...'; - -export { - CHART_COLORS, - Y_AXIS_TICK_COUNT, - BAR_INNER_PADDING, - DOMAIN_PADDING, - Y_AXIS_LABEL_OFFSET, - BAR_ROUNDED_CORNERS, - CHART_PADDING, - CHART_CONTENT_MIN_HEIGHT, - DEFAULT_SINGLE_BAR_COLOR_INDEX, - DOMAIN_PADDING_SAFETY_BUFFER, - X_AXIS_LINE_WIDTH, - Y_AXIS_LINE_WIDTH, - FRAME_LINE_WIDTH, - TOOLTIP_POINTER_HEIGHT, - TOOLTIP_POINTER_WIDTH, - TOOLTIP_BAR_GAP, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - SIN_45_DEGREES, - LABEL_PADDING, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - LABEL_ELLIPSIS, -}; +export {Y_AXIS_TICK_COUNT, Y_AXIS_LABEL_OFFSET, CHART_PADDING, CHART_CONTENT_MIN_HEIGHT, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH}; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index fc5119b0a729b..ac5272aa5b566 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -4,3 +4,5 @@ export {useChartInteractions} from './useChartInteractions'; export type {HitTestArgs} from './useChartInteractions'; export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; export {default as useChartLabelFormats} from './useChartLabelFormats'; +export {default as useDynamicYDomain} from './useDynamicYDomain'; +export {useTooltipData} from './useTooltipData'; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts index 344832485fe1f..a2d15a86e4d6f 100644 --- a/src/components/Charts/hooks/useChartInteractionState.ts +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -22,13 +22,16 @@ type ChartInteractionStateInit = { type ChartInteractionState = { /** Whether interaction (hover/press) is currently active */ isActive: SharedValue; + /** Index of the matched data point (-1 if none) */ matchedIndex: SharedValue; + /** X-axis value and position */ x: { value: SharedValue; position: SharedValue; }; + /** Y-axis values and positions for each y key */ y: Record< keyof Init['y'], @@ -37,8 +40,10 @@ type ChartInteractionState = { position: SharedValue; } >; + /** Y index for stacked bar charts */ yIndex: SharedValue; + /** Raw cursor position */ cursor: { x: SharedValue; @@ -68,27 +73,6 @@ function useIsInteractionActive(state: C /** * Creates shared state for chart interactions (hover, tap, press). * Compatible with Victory Native's handleTouch function exposed via actionsRef. - * - * @param initialValues - Initial x and y values matching your chart data structure - * @returns Object containing the interaction state and a boolean indicating if interaction is active - * - * @example - * ```tsx - * const { state, isActive } = useChartInteractionState({ - * x: '', - * y: { value: 0 } - * }); - * - * // Use with customGestures and actionsRef - * const hoverGesture = Gesture.Hover() - * .onUpdate((e) => { - * state.isActive.set(true); - * actionsRef.current?.handleTouch(state, e.x, e.y); - * }) - * .onEnd(() => { - * state.isActive.set(false); - * }); - * ``` */ function useChartInteractionState( initialValues: Init, diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index e7a5422f58808..3cb17d796b71a 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -3,9 +3,11 @@ import {Gesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; import {useAnimatedReaction, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; -import {TOOLTIP_BAR_GAP} from '@components/Charts/constants'; import {useChartInteractionState} from './useChartInteractionState'; +/** Gap between bar top and tooltip bottom */ +const TOOLTIP_BAR_GAP = 8; + /** * Arguments passed to the checkIsOver callback for hit-testing */ diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts index 3f6136c5d262a..7c32baa2c1abc 100644 --- a/src/components/Charts/hooks/useChartLabelFormats.ts +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -1,19 +1,17 @@ import {useCallback} from 'react'; - -type ChartDataPoint = { - label: string; -}; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; +import {LABEL_ROTATIONS} from './useChartLabelLayout'; type UseChartLabelFormatsProps = { data: ChartDataPoint[]; yAxisUnit?: string; - yAxisUnitPosition?: 'left' | 'right'; - labelSkipInterval: number; - labelRotation: number; - truncatedLabels: string[]; + yAxisUnitPosition?: YAxisUnitPosition; + labelSkipInterval?: number; + labelRotation?: number; + truncatedLabels?: string[]; }; -export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval, labelRotation, truncatedLabels}: UseChartLabelFormatsProps) { +export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition = 'left', labelSkipInterval = 1, labelRotation = 0, truncatedLabels}: UseChartLabelFormatsProps) { const formatYAxisLabel = useCallback( (value: number) => { const formatted = value.toLocaleString(); @@ -39,7 +37,7 @@ export default function useChartLabelFormats({data, yAxisUnit, yAxisUnitPosition // Use pre-truncated labels // If rotation is vertical (-90), we usually want full labels // because they have more space vertically. - const sourceToUse = labelRotation === -90 ? data.map((p) => p.label) : truncatedLabels; + const sourceToUse = labelRotation === -LABEL_ROTATIONS.VERTICAL || !truncatedLabels ? data.map((p) => p.label) : truncatedLabels; return sourceToUse.at(index) ?? ''; }, diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts index 215459488f8f6..78564dfc131cc 100644 --- a/src/components/Charts/hooks/useChartLabelLayout.ts +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -1,148 +1,159 @@ import type {SkFont} from '@shopify/react-native-skia'; import {useMemo} from 'react'; -import { - LABEL_ELLIPSIS, - LABEL_PADDING, - SIN_45_DEGREES, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, - Y_AXIS_LABEL_OFFSET, -} from '@components/Charts/constants'; - -type ChartDataPoint = { - label: string; - [key: string]: unknown; -}; +import type {ChartDataPoint} from '@components/Charts/types'; +import {measureTextWidth} from '@components/Charts/utils'; + +/** Supported label rotation angles in degrees */ +const LABEL_ROTATIONS = { + HORIZONTAL: 0, + DIAGONAL: 45, + VERTICAL: 90, +} as const; + +const SIN_45 = Math.sin(Math.PI / 4); + +/** Minimum gap between adjacent labels (px) */ +const LABEL_PADDING = 4; + +const ELLIPSIS = '...'; + +/** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ +const MIN_TRUNCATED_CHARS = 10; type LabelLayoutConfig = { data: ChartDataPoint[]; font: SkFont | null; - chartWidth: number; - barAreaWidth: number; - containerHeight: number; + tickSpacing: number; + labelAreaWidth: number; + /** When true, allows tighter label packing at 45° by accounting for vertical offset. Useful for line charts. */ + allowTightDiagonalPacking?: boolean; }; +/** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ +function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string { + if (labelWidth <= maxWidth) { + return label; + } + const available = maxWidth - ellipsisWidth; + if (available <= 0) { + return ELLIPSIS; + } + const maxChars = Math.max(1, Math.floor(label.length * (available / labelWidth))); + return label.slice(0, maxChars) + ELLIPSIS; +} + +/** Horizontal footprint of a label at a given rotation angle. */ +function effectiveWidth(labelWidth: number, lineHeight: number, rotation: number): number { + if (rotation === LABEL_ROTATIONS.VERTICAL) { + return lineHeight; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + return labelWidth * SIN_45; + } + return labelWidth; +} + +/** Vertical footprint of a label at a given rotation angle. */ +function effectiveHeight(labelWidth: number, lineHeight: number, rotation: number): number { + if (rotation === LABEL_ROTATIONS.VERTICAL) { + return labelWidth; + } + if (rotation === LABEL_ROTATIONS.DIAGONAL) { + return labelWidth * SIN_45 + lineHeight * SIN_45; + } + return lineHeight; +} + +/** How many labels fit side-by-side in `areaWidth` given each takes `itemWidth`. */ +function maxVisibleCount(areaWidth: number, itemWidth: number): number { + return Math.floor(areaWidth / (itemWidth + LABEL_PADDING)); +} + /** - * Measure the width of a text string using the font's glyph widths. - * Uses getGlyphWidths as measureText is not implemented on React Native Web. + * Pick the smallest rotation (0 → 45 → 90) where labels don't overlap, + * preferring rotation over skip interval. */ -function measureTextWidth(text: string, font: SkFont): number { - const glyphIDs = font.getGlyphIDs(text); - const glyphWidths = font.getGlyphWidths(glyphIDs); - return glyphWidths.reduce((sum, w) => sum + w, 0); +function pickRotation( + maxLabelWidth: number, + lineHeight: number, + tickSpacing: number, + labelArea: number, + dataCount: number, + minTruncatedWidth: number, + allowTightDiagonalPacking: boolean, +): number { + // 0°: labels fit horizontally without truncation + const horizontalWidth = effectiveWidth(maxLabelWidth, lineHeight, LABEL_ROTATIONS.HORIZONTAL); + if (horizontalWidth + LABEL_PADDING <= tickSpacing && maxVisibleCount(labelArea, horizontalWidth) >= dataCount) { + return LABEL_ROTATIONS.HORIZONTAL; + } + + // 45°: viable if MIN_TRUNCATED_CHARS + ellipsis fits between ticks + // With tight packing, labels can overlap horizontally by lineHeight * sin(45°) due to vertical offset + const diagonalOverlap = allowTightDiagonalPacking ? lineHeight * SIN_45 : 0; + const minDiagonalWidth = minTruncatedWidth * SIN_45 - diagonalOverlap; + if (minDiagonalWidth + LABEL_PADDING <= tickSpacing) { + return LABEL_ROTATIONS.DIAGONAL; + } + + // 90°: fallback + return LABEL_ROTATIONS.VERTICAL; } -function useChartLabelLayout({data, font, chartWidth, barAreaWidth, containerHeight}: LabelLayoutConfig) { +function useChartLabelLayout({data, font, tickSpacing, labelAreaWidth, allowTightDiagonalPacking = false}: LabelLayoutConfig) { return useMemo(() => { - if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { - return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; + if (!font || data.length === 0 || tickSpacing <= 0 || labelAreaWidth <= 0) { + return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: []}; } - // Get font metrics const fontMetrics = font.getMetrics(); const lineHeight = Math.abs(fontMetrics.descent) + Math.abs(fontMetrics.ascent); - const ellipsisWidth = measureTextWidth(LABEL_ELLIPSIS, font); - - // Calculate available dimensions - const availableWidthPerBar = chartWidth / data.length - LABEL_PADDING; - - // Measure original labels - const labelWidths = data.map((p) => measureTextWidth(p.label, font)); + const ellipsisWidth = measureTextWidth(ELLIPSIS, font); + const labelWidths = data.map((point) => measureTextWidth(point.label, font)); const maxLabelLength = Math.max(...labelWidths); - // Helper to truncate a label to fit a max pixel width - const truncateToWidth = (label: string, labelWidth: number, maxWidth: number): string => { - if (labelWidth <= maxWidth) { - return label; - } - const availableWidth = maxWidth - ellipsisWidth; - if (availableWidth <= 0) { - return LABEL_ELLIPSIS; - } - const ratio = availableWidth / labelWidth; - const maxChars = Math.max(1, Math.floor(label.length * ratio)); - return label.slice(0, maxChars) + LABEL_ELLIPSIS; - }; - - // === DETERMINE ROTATION (based on WIDTH constraint, monotonic: 0° → 45° → 90°) === - let rotation = 0; - if (maxLabelLength > availableWidthPerBar) { - // Labels don't fit at 0°, try 45° - const effectiveWidthAt45 = maxLabelLength * SIN_45_DEGREES; - if (effectiveWidthAt45 <= availableWidthPerBar) { - rotation = 45; - } else { - // 45° doesn't fit either, use 90° - rotation = 90; - } + // Maximum width of labels after truncation to MIN_TRUNCATED_CHARS characters. + // Labels shorter than the threshold keep their full width (can't be truncated further). + const minTruncatedWidth = Math.max( + ...data.map((point, index) => { + if (point.label.length <= MIN_TRUNCATED_CHARS) { + return labelWidths.at(index) ?? 0; + } + return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, font); + }), + ); + + // 1. Pick rotation + const rotation = pickRotation(maxLabelLength, lineHeight, tickSpacing, labelAreaWidth, data.length, minTruncatedWidth, allowTightDiagonalPacking); + + // 2. Truncate labels (only at 45°) + // With tight packing, labels can be longer due to allowed horizontal overlap + const diagonalOverlap = allowTightDiagonalPacking ? lineHeight : 0; + const maxLabelWidth = rotation === LABEL_ROTATIONS.DIAGONAL ? (tickSpacing - LABEL_PADDING) / SIN_45 + diagonalOverlap : Infinity; + const finalLabels = data.map((point, index) => { + return truncateLabel(point.label, labelWidths.at(index) ?? 0, maxLabelWidth, ellipsisWidth); + }); + + // 3. Compute skip interval (only at 90°) + const finalMaxWidth = Math.max(...finalLabels.map((label) => measureTextWidth(label, font))); + let skipInterval = 1; + if (rotation === LABEL_ROTATIONS.VERTICAL) { + const verticalWidth = effectiveWidth(finalMaxWidth, lineHeight, rotation); + const visibleCount = maxVisibleCount(labelAreaWidth, verticalWidth); + skipInterval = visibleCount >= data.length ? 1 : Math.ceil(data.length / Math.max(1, visibleCount)); } - // === DETERMINE TRUNCATION === - // Limit label area to X_AXIS_LABEL_MAX_HEIGHT_RATIO of container height. - // - // IMPLEMENTATION NOTE: We assume Victory allocates space for X-axis labels using: - // totalHeight = fontHeight + yAxis.labelOffset * 2 + labelWidth * sin(angle) - // This formula was found in: victory-native-xl/src/cartesian/utils/transformInputData.ts - // If Victory changes this formula, these calculations will need adjustment. - // - // We calculate max labelWidth so total allocation stays within our limit. - const maxLabelHeight = containerHeight * X_AXIS_LABEL_MAX_HEIGHT_RATIO; - const victoryBaseAllocation = lineHeight + Y_AXIS_LABEL_OFFSET * 2; - const availableForRotation = Math.max(0, maxLabelHeight - victoryBaseAllocation); - - let maxAllowedLabelWidth: number; - - if (rotation === 0) { - // At 0°: no truncation, use skip interval instead (like Google Sheets) - maxAllowedLabelWidth = Infinity; - } else if (rotation === 45) { - // At 45°: labelWidth * sin(45°) <= availableForRotation - // labelWidth <= availableForRotation / sin(45°) - maxAllowedLabelWidth = availableForRotation / SIN_45_DEGREES; - } else { - // At 90°: no truncation, container expands to accommodate labels - maxAllowedLabelWidth = Infinity; - } - - // Generate truncated labels - const finalLabels = data.map((p, i) => truncateToWidth(p.label, labelWidths.at(i) ?? 0, maxAllowedLabelWidth)); - - // === CALCULATE SKIP INTERVAL === - // Calculate effective width based on rotation angle - const finalMaxWidth = Math.max(...finalLabels.map((l) => measureTextWidth(l, font))); - let effectiveWidth: number; - if (rotation === 0) { - effectiveWidth = finalMaxWidth; - } else if (rotation === 45) { - effectiveWidth = finalMaxWidth * SIN_45_DEGREES; - } else { - effectiveWidth = lineHeight; // At 90°, width is the line height - } + // 4. Compute vertical space needed for x-axis labels + const xAxisLabelHeight = effectiveHeight(finalMaxWidth, lineHeight, rotation); - // Calculate skip interval using spec formula: - // maxVisibleLabels = floor(barAreaWidth / (effectiveWidth + MIN_LABEL_GAP)) - // skipInterval = ceil(barCount / maxVisibleLabels) - // Use barAreaWidth (actual plotting area from chartBounds) rather than chartWidth - // (full container) so Y-axis labels and padding don't inflate the count. - const labelAreaWidth = barAreaWidth || chartWidth; - const maxVisibleLabels = Math.floor(labelAreaWidth / (effectiveWidth + LABEL_PADDING)); - // When maxVisibleLabels is 0 (area too narrow for even one label) or less than - // data.length, compute the interval. data.length is the safe upper bound — show - // at most the first label. - const skipInterval = maxVisibleLabels >= data.length ? 1 : Math.ceil(data.length / Math.max(1, maxVisibleLabels)); - - // Convert rotation to negative degrees for Victory chart - let rotationValue = 0; - if (rotation === 45) { - rotationValue = X_AXIS_LABEL_ROTATION_45; - } else if (rotation === 90) { - rotationValue = X_AXIS_LABEL_ROTATION_90; - } - - return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels, maxLabelLength}; - }, [font, chartWidth, barAreaWidth, containerHeight, data]); + return { + labelRotation: -rotation, + labelSkipInterval: skipInterval, + truncatedLabels: finalLabels, + xAxisLabelHeight, + }; + }, [font, tickSpacing, labelAreaWidth, data, allowTightDiagonalPacking]); } -export {useChartLabelLayout}; +export {LABEL_ROTATIONS, useChartLabelLayout}; export type {LabelLayoutConfig}; diff --git a/src/components/Charts/hooks/useDynamicYDomain.ts b/src/components/Charts/hooks/useDynamicYDomain.ts new file mode 100644 index 0000000000000..2868a317b5404 --- /dev/null +++ b/src/components/Charts/hooks/useDynamicYDomain.ts @@ -0,0 +1,12 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint} from '@components/Charts/types'; + +/** + * 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. + */ +function useDynamicYDomain(data: ChartDataPoint[]): [number] | undefined { + return useMemo((): [number] | undefined => (data.some((point) => point.total < 0) ? undefined : [0]), [data]); +} + +export default useDynamicYDomain; diff --git a/src/components/Charts/hooks/useTooltipData.ts b/src/components/Charts/hooks/useTooltipData.ts new file mode 100644 index 0000000000000..2ec687681de92 --- /dev/null +++ b/src/components/Charts/hooks/useTooltipData.ts @@ -0,0 +1,42 @@ +import {useMemo} from 'react'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts/types'; + +type TooltipData = { + label: string; + amount: string; + percentage: string; +}; + +/** + * Formats tooltip content for the active chart data point. + * Computes the display amount (with optional currency unit) and the percentage relative to all data points. + */ +function useTooltipData(activeDataIndex: number, data: ChartDataPoint[], yAxisUnit?: string, yAxisUnitPosition?: YAxisUnitPosition): TooltipData | null { + const totalSum = useMemo(() => data.reduce((sum, point) => sum + Math.abs(point.total), 0), [data]); + + return 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 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, totalSum, yAxisUnit, yAxisUnitPosition]); +} + +export {useTooltipData}; +export type {TooltipData}; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index aabb568439238..f86738fa140a5 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,6 +1,9 @@ import BarChart from './BarChart'; -import ChartHeader from './ChartHeader'; -import ChartTooltip from './ChartTooltip'; +import ChartHeader from './components/ChartHeader'; +import ChartTooltip from './components/ChartTooltip'; +import LineChart from './LineChart'; -export {BarChart, ChartHeader, ChartTooltip}; -export type {BarChartDataPoint, BarChartProps} from './types'; +export {BarChart, ChartHeader, ChartTooltip, LineChart}; +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition} from './types'; +export type {BarChartProps} from './BarChart/BarChartContent'; +export type {LineChartProps} from './LineChart/LineChartContent'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index e2ec4fe540726..da769fd2aeac8 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,24 +1,21 @@ import type IconAsset from '@src/types/utils/IconAsset'; -type BarChartDataPoint = { - /** Label displayed under the bar (e.g., "Amazon", "Travel", "Nov 2025") */ +type ChartDataPoint = { + /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ label: string; /** Total amount (pre-formatted, e.g., dollars not cents) */ total: number; - /** Currency code for formatting */ - currency: string; - - /** Query string for navigation when bar is clicked (optional) */ + /** Query string for navigation when data point is clicked (optional) */ onClickQuery?: string; }; -type BarChartProps = { +type CartesianChartProps = { /** Data points to display */ - data: BarChartDataPoint[]; + data: ChartDataPoint[]; - /** Chart title (e.g., "Top Categories", "Spend by Merchant") */ + /** Chart title (e.g., "Top Categories", "Spend over time") */ title?: string; /** Icon displayed next to the title */ @@ -27,17 +24,13 @@ type BarChartProps = { /** Whether data is loading */ isLoading?: boolean; - /** Callback when a bar is pressed */ - onBarPress?: (dataPoint: BarChartDataPoint, index: number) => void; - /** Symbol/unit for Y-axis labels (e.g., '$', '€', 'zł'). Empty string or undefined shows raw numbers. */ yAxisUnit?: string; /** Position of the unit symbol relative to the value. Defaults to 'left'. */ - yAxisUnitPosition?: 'left' | 'right'; - - /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ - useSingleColor?: boolean; + yAxisUnitPosition?: YAxisUnitPosition; }; -export type {BarChartDataPoint, BarChartProps}; +type YAxisUnitPosition = 'left' | 'right'; + +export type {ChartDataPoint, CartesianChartProps, YAxisUnitPosition}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts new file mode 100644 index 0000000000000..8918d6fa2d5b9 --- /dev/null +++ b/src/components/Charts/utils.ts @@ -0,0 +1,99 @@ +import type {SkFont} from '@shopify/react-native-skia'; +import colors from '@styles/theme/colors'; + +/** + * Expensify Chart Color Palette. + * + * Shades are ordered (400, 600, 300, 500, 700) so that sequential colors have + * maximum contrast, making adjacent chart segments easy to distinguish. + * + * Within each shade, hues cycle: Yellow, Tangerine, Pink, Green, Ice, Blue. + */ +const CHART_PALETTE: string[] = (() => { + const shades = [400, 600, 300, 500, 700] as const; + const hues = ['yellow', 'tangerine', 'pink', 'green', 'ice', 'blue'] as const; + + const palette: string[] = []; + + // Generate the 30 unique combinations (5 shades × 6 hues) + for (const shade of shades) { + for (const hue of hues) { + const colorKey = `${hue}${shade}`; + if (colors[colorKey]) { + palette.push(colors[colorKey]); + } + } + } + + return palette; +})(); + +/** + * Gets a color from the chart palette based on index. + * Automatically loops back to the start if the index exceeds 29. + */ +function getChartColor(index: number): string { + if (CHART_PALETTE.length === 0) { + return colors.black; // Fallback + } + return CHART_PALETTE.at(index % CHART_PALETTE.length) ?? colors.black; +} + +/** Default color used for single-color charts (e.g., line chart, single-color bar chart) */ +const DEFAULT_CHART_COLOR = getChartColor(5); + +/** + * Measure pixel width of a string via glyph widths. + * (measureText is not implemented on React Native Web) + */ +function measureTextWidth(text: string, font: SkFont): number { + const glyphIDs = font.getGlyphIDs(text); + return font.getGlyphWidths(glyphIDs).reduce((sum, w) => sum + w, 0); +} + +/** + * Post-rotation horizontal translation to center a rotated label on its tick mark. + * + * Text baselines sit closer to glyph tops (ascent > descent), so rotating around + * the baseline end creates asymmetric horizontal extent. This returns the correction + * to apply as a translateX AFTER rotation. + */ +function rotatedLabelCenterCorrection(ascent: number, descent: number, angleRad: number): number { + return ((ascent - descent) * Math.sin(angleRad)) / 2; +} + +/** + * Calculate the vertical offset from axis to text baseline that maintains + * a consistent visual gap regardless of label rotation. + * + * At 0°: gap to top of text (ascent above baseline) + * At 45°: gap to top-right corner after rotation (ascent projects as ascent * cos(45°)) + * At 90°: gap to closest point of vertical text (descent from baseline) + */ +function rotatedLabelYOffset(ascent: number, descent: number, angleRad: number): number { + if (angleRad === 0) { + return ascent; + } + if (angleRad >= Math.PI / 2) { + return descent; + } + return ascent * Math.cos(angleRad); +} + +/** + * Calculate minimum horizontal domainPadding so that edge data points + * (and their centered labels) aren't clipped by the chart boundary. + * + * @param chartWidth - Total chart width in pixels + * @param pointCount - Number of data points + * @param innerPadding - Padding ratio between points (0 for line charts, ~0.3 for bar charts) + */ +function calculateMinDomainPadding(chartWidth: number, pointCount: number, innerPadding = 0): number { + if (pointCount <= 1) { + return 0; + } + const minPaddingRatio = (1 - innerPadding) / (2 * (pointCount - 1 + innerPadding)); + return Math.ceil(chartWidth * minPaddingRatio); +} + +export {getChartColor, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelCenterCorrection, rotatedLabelYOffset, calculateMinDomainPadding}; diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 68d1e2f89e1a3..18b4beb3045f9 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -1,21 +1,13 @@ import React, {useCallback, useMemo} from 'react'; import {BarChart} from '@components/Charts'; -import type {BarChartDataPoint} from '@components/Charts'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionGroupListItemType, - TransactionMemberGroupListItemType, - TransactionWithdrawalIDGroupListItemType, -} from '@components/SelectionListWithSections/types'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; import type IconAsset from '@src/types/utils/IconAsset'; - -type GroupedItem = TransactionMemberGroupListItemType | TransactionCardGroupListItemType | TransactionWithdrawalIDGroupListItemType | TransactionCategoryGroupListItemType; +import type {GroupedItem} from './types'; type SearchBarChartProps = { /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Chart title */ title: string; @@ -29,8 +21,8 @@ type SearchBarChartProps = { /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; - /** Callback when a bar is pressed - receives the filter query to apply */ - onBarPress?: (filterQuery: string) => void; + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; /** Whether data is loading */ isLoading?: boolean; @@ -39,28 +31,26 @@ type SearchBarChartProps = { yAxisUnit?: string; /** Position of currency symbol relative to value */ - yAxisUnitPosition?: 'left' | 'right'; + yAxisUnitPosition?: YAxisUnitPosition; }; -function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBarPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { +function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchBarChartProps) { // Transform grouped transaction data to BarChart format - const chartData: BarChartDataPoint[] = useMemo(() => { + const chartData: ChartDataPoint[] = useMemo(() => { return data.map((item) => { - const groupedItem = item as GroupedItem; - const currency = groupedItem.currency ?? 'USD'; - const totalInDisplayUnits = convertToFrontendAmountAsInteger(groupedItem.total ?? 0, currency); + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); return { - label: getLabel(groupedItem), + label: getLabel(item), total: totalInDisplayUnits, - currency, }; }); }, [data, getLabel]); const handleBarPress = useCallback( - (dataPoint: BarChartDataPoint, index: number) => { - if (!onBarPress) { + (dataPoint: ChartDataPoint, index: number) => { + if (!onItemPress) { return; } @@ -69,10 +59,10 @@ function SearchBarChart({data, title, titleIcon, getLabel, getFilterQuery, onBar return; } - const filterQuery = getFilterQuery(item as GroupedItem); - onBarPress(filterQuery); + const filterQuery = getFilterQuery(item); + onItemPress(filterQuery); }, - [data, getFilterQuery, onBarPress], + [data, getFilterQuery, onItemPress], ); return ( diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 1979dad7fb6cb..4f34f3e67a525 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -5,7 +5,6 @@ import Animated from 'react-native-reanimated'; import type { TransactionCardGroupListItemType, TransactionCategoryGroupListItemType, - TransactionGroupListItemType, TransactionMemberGroupListItemType, TransactionMerchantGroupListItemType, TransactionMonthGroupListItemType, @@ -27,19 +26,8 @@ import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUti import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchBarChart from './SearchBarChart'; -import type {ChartView, SearchGroupBy, SearchQueryJSON} from './types'; - -type GroupedItem = - | TransactionMemberGroupListItemType - | TransactionCardGroupListItemType - | TransactionWithdrawalIDGroupListItemType - | TransactionCategoryGroupListItemType - | TransactionMerchantGroupListItemType - | TransactionTagGroupListItemType - | TransactionMonthGroupListItemType - | TransactionWeekGroupListItemType - | TransactionYearGroupListItemType - | TransactionQuarterGroupListItemType; +import SearchLineChart from './SearchLineChart'; +import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './types'; type ChartGroupByConfig = { titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; @@ -125,14 +113,14 @@ type SearchChartViewProps = { /** The current search query JSON */ queryJSON: SearchQueryJSON; - /** The view type (bar, etc.) */ - view: Exclude; + /** The view type (bar, line, etc.) */ + view: Exclude; /** The groupBy parameter */ groupBy: SearchGroupBy; /** Grouped transaction data from search results */ - data: TransactionGroupListItemType[]; + data: GroupedItem[]; /** Whether data is loading */ isLoading?: boolean; @@ -144,8 +132,9 @@ type SearchChartViewProps = { /** * Map of chart view types to their corresponding chart components. */ -const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart> = { +const CHART_VIEW_TO_COMPONENT: Record, typeof SearchBarChart | typeof SearchLineChart> = { [CONST.SEARCH.VIEW.BAR]: SearchBarChart, + [CONST.SEARCH.VIEW.LINE]: SearchLineChart, }; /** @@ -162,9 +151,8 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: const title = translate(`search.chartTitles.${groupBy}`); const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; - const handleBarPress = useCallback( + const handleItemPress = useCallback( (filterQuery: string) => { - // Build new query string from current query + filter, then parse it const currentQueryString = buildSearchQueryString(queryJSON); const newQueryJSON = buildSearchQueryJSON(`${currentQueryString} ${filterQuery}`); @@ -173,20 +161,17 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: return; } - // Modify the query object directly: remove groupBy and view to show table newQueryJSON.groupBy = undefined; newQueryJSON.view = CONST.SEARCH.VIEW.TABLE; - // Build the final query string and navigate const newQueryString = buildSearchQueryString(newQueryJSON); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: newQueryString})); }, [queryJSON], ); - // Get currency symbol and position from first data item const {yAxisUnit, yAxisUnitPosition} = useMemo((): {yAxisUnit: string; yAxisUnitPosition: 'left' | 'right'} => { - const firstItem = data.at(0) as GroupedItem | undefined; + const firstItem = data.at(0); const currency = firstItem?.currency ?? 'USD'; const {symbol, position} = getCurrencyDisplayInfoForCharts(currency); @@ -210,7 +195,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading, onScroll}: titleIcon={titleIcon} getLabel={getLabel} getFilterQuery={getFilterQuery} - onBarPress={handleBarPress} + onItemPress={handleItemPress} isLoading={isLoading} yAxisUnit={yAxisUnit} yAxisUnitPosition={yAxisUnitPosition} diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx new file mode 100644 index 0000000000000..b066990219337 --- /dev/null +++ b/src/components/Search/SearchLineChart.tsx @@ -0,0 +1,82 @@ +import React, {useCallback, useMemo} from 'react'; +import {LineChart} from '@components/Charts'; +import type {ChartDataPoint, YAxisUnitPosition} from '@components/Charts'; +import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {GroupedItem} from './types'; + +type SearchLineChartProps = { + /** Grouped transaction data from search results */ + data: GroupedItem[]; + + /** Chart title */ + title: string; + + /** Chart title icon */ + titleIcon: IconAsset; + + /** Function to extract label from grouped item */ + getLabel: (item: GroupedItem) => string; + + /** Function to build filter query from grouped item */ + getFilterQuery: (item: GroupedItem) => string; + + /** Callback when a chart item is pressed - receives the filter query to apply */ + onItemPress?: (filterQuery: string) => void; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Currency symbol for Y-axis labels */ + yAxisUnit?: string; + + /** Position of currency symbol relative to value */ + yAxisUnitPosition?: YAxisUnitPosition; +}; + +function SearchLineChart({data, title, titleIcon, getLabel, getFilterQuery, onItemPress, isLoading, yAxisUnit, yAxisUnitPosition}: SearchLineChartProps) { + const chartData: ChartDataPoint[] = useMemo(() => { + return data.map((item) => { + const currency = item.currency ?? 'USD'; + const totalInDisplayUnits = convertToFrontendAmountAsInteger(item.total ?? 0, currency); + + return { + label: getLabel(item), + total: totalInDisplayUnits, + }; + }); + }, [data, getLabel]); + + const handlePointPress = useCallback( + (dataPoint: ChartDataPoint, index: number) => { + if (!onItemPress) { + return; + } + + const item = data.at(index); + if (!item) { + return; + } + + const filterQuery = getFilterQuery(item); + onItemPress(filterQuery); + }, + [data, getFilterQuery, onItemPress], + ); + + return ( + + ); +} + +SearchLineChart.displayName = 'SearchLineChart'; + +export default SearchLineChart; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 59e59a9363449..8750ddb30642e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -49,6 +49,7 @@ import { getSortedSections, getSuggestedSearches, getWideAmountIndicators, + isGroupedItemArray, isReportActionListItemType, isSearchDataLoaded, isSearchResultsEmpty as isSearchResultsEmptyUtil, @@ -1305,17 +1306,16 @@ function Search({ const shouldShowTableHeader = isLargeScreenWidth && !isChat; const tableHeaderVisible = canSelectMultiple || shouldShowTableHeader; - // Other charts are not implemented yet - const shouldShowChartView = view === CONST.SEARCH.VIEW.BAR && !!validGroupBy; + const shouldShowChartView = (view === CONST.SEARCH.VIEW.BAR || view === CONST.SEARCH.VIEW.LINE) && !!validGroupBy; - if (shouldShowChartView) { + if (shouldShowChartView && isGroupedItemArray(sortedData)) { return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 7faab6847ef1e..a232c0463ec8a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,6 +1,21 @@ import type {ValueOf} from 'type-fest'; import type {PaymentMethod} from '@components/KYCWall/types'; -import type {ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import type { + ReportActionListItemType, + TaskListItemType, + TransactionCardGroupListItemType, + TransactionCategoryGroupListItemType, + TransactionGroupListItemType, + TransactionListItemType, + TransactionMemberGroupListItemType, + TransactionMerchantGroupListItemType, + TransactionMonthGroupListItemType, + TransactionQuarterGroupListItemType, + TransactionTagGroupListItemType, + TransactionWeekGroupListItemType, + TransactionWithdrawalIDGroupListItemType, + TransactionYearGroupListItemType, +} from '@components/SelectionListWithSections/types'; import type {SearchKey} from '@libs/SearchUIUtils'; import type CONST from '@src/CONST'; import type {Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; @@ -115,8 +130,8 @@ type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | In type SearchStatus = SingularSearchStatus | SingularSearchStatus[]; type SearchGroupBy = ValueOf; type SearchView = ValueOf; -// LineChart and PieChart are not implemented so we exclude them here to prevent TypeScript errors in `SearchChartView.tsx`. -type ChartView = Exclude; +// PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`. +type ChartView = Exclude; type TableColumnSize = ValueOf; type SearchDatePreset = ValueOf; type SearchWithdrawalType = ValueOf; @@ -307,6 +322,19 @@ type BankAccountMenuItem = { value: PaymentMethod; }; +/** Union type representing all possible grouped transaction item types used in chart views */ +type GroupedItem = + | TransactionMemberGroupListItemType + | TransactionCardGroupListItemType + | TransactionWithdrawalIDGroupListItemType + | TransactionCategoryGroupListItemType + | TransactionMerchantGroupListItemType + | TransactionTagGroupListItemType + | TransactionMonthGroupListItemType + | TransactionWeekGroupListItemType + | TransactionYearGroupListItemType + | TransactionQuarterGroupListItemType; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -353,4 +381,5 @@ export type { SearchTextFilterKeys, BankAccountMenuItem, SearchCustomColumnIds, + GroupedItem, }; diff --git a/src/languages/de.ts b/src/languages/de.ts index 95ce823e52e89..ce0b0d8be57c9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7081,6 +7081,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und label: 'Anzeigen', table: 'Tabelle', bar: 'Leiste', + line: 'Linie', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Von', diff --git a/src/languages/en.ts b/src/languages/en.ts index 058ae9ea21bb9..eeddf1a02eabc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7004,6 +7004,7 @@ const translations = { label: 'View', table: 'Table', bar: 'Bar', + line: 'Line', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'From', diff --git a/src/languages/es.ts b/src/languages/es.ts index aa9a9f802e57c..7620d5638b99c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6661,7 +6661,7 @@ ${amount} para ${merchant} - ${date}`, unapprovedCard: 'Tarjeta no aprobada', reconciliation: 'Conciliación', topSpenders: 'Mayores gastadores', - view: {label: 'Ver', table: 'Tabla', bar: 'Barra'}, + view: {label: 'Ver', table: 'Tabla', bar: 'Barra', line: 'Línea'}, saveSearch: 'Guardar búsqueda', savedSearchesMenuItemTitle: 'Guardadas', topCategories: 'Categorías principales', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ae288dfd8238a..b3fecdf1af97c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7097,6 +7097,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip label: 'Afficher', table: 'Table', bar: 'Bar', + line: 'Ligne', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', diff --git a/src/languages/it.ts b/src/languages/it.ts index ae040c048fa32..7dcb9a5160065 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7064,6 +7064,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo label: 'Visualizza', table: 'Tabella', bar: 'Bar', + line: 'Linea', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Da', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f169d12a662bf..6f9be529521b0 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7005,6 +7005,7 @@ ${reportName} label: '表示', table: 'テーブル', bar: 'バー', + line: '折れ線', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '差出人', @@ -7039,7 +7040,7 @@ ${reportName} exportedTo: 'エクスポート先', exportAll: { selectAllMatchingItems: '一致する項目をすべて選択', - allMatchingItemsSelected: '一致するすべての項目を選択済み', + allMatchingItemsSelected: '一致する項目をすべて選択済み', }, }, genericErrorPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f1b4424ee4511..f45535b277320 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7052,6 +7052,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar label: 'Bekijken', table: 'Tabel', bar: 'Balk', + line: 'Lijn', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Van', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d3dbb5696f5b9..78bfc1249f55f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7035,6 +7035,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i label: 'Pokaż', table: 'Tabela', bar: 'Bar', + line: 'Linia', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'Od', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ad95bf3a24f06..bdc057d96b21e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7034,6 +7034,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e label: 'Visualizar', table: 'Tabela', bar: 'Bar', + line: 'Linha', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e8d83be9ce052..6e50d9bb49893 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6901,6 +6901,7 @@ ${reportName} label: '查看', table: '表', bar: '栏', + line: '折线', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: '来自', diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 79d9b6320aeca..13c9524948c86 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -410,6 +410,34 @@ function isFilterSupported(filter: SearchAdvancedFiltersKey, type: SearchDataTyp }); } +/** + * Returns default values for chart views (non-table views). + * - Sets default groupBy based on view type (month for line charts, category for others) + * - Sets default sortOrder to ASC for line charts (time-based graphs show chronologically oldest to newest) + */ +function getChartViewDefaults(queryJSON: SearchQueryJSON): Partial { + if (queryJSON.view === CONST.SEARCH.VIEW.TABLE) { + return {}; + } + + const defaults: Partial = {}; + + // Default groupBy when not explicitly set + if (!queryJSON.groupBy) { + defaults.groupBy = queryJSON.view === CONST.SEARCH.VIEW.LINE ? CONST.SEARCH.GROUP_BY.MONTH : CONST.SEARCH.GROUP_BY.CATEGORY; + } + + // Default sortOrder to ASC for LINE view, only if not explicitly set by the user + if (queryJSON.view === CONST.SEARCH.VIEW.LINE) { + const wasSortOrderExplicitlySet = queryJSON.rawFilterList?.some((filter) => filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER) ?? false; + if (!wasSortOrderExplicitlySet) { + defaults.sortOrder = CONST.SEARCH.SORT_ORDER.ASC; + } + } + + return defaults; +} + /** * Parses a given search query string into a structured `SearchQueryJSON` format. * This format of query is most commonly shared between components and also sent to backend to retrieve search results. @@ -442,10 +470,7 @@ function buildSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQuerySt result.policyID = [result.policyID]; } - // Default groupBy to category when a chart view is specified without an explicit groupBy - if (result.view !== CONST.SEARCH.VIEW.TABLE && !result.groupBy) { - result.groupBy = CONST.SEARCH.GROUP_BY.CATEGORY; - } + Object.assign(result, getChartViewDefaults(result)); // Normalize limit before computing hashes to ensure invalid values don't affect hash if (result.limit !== undefined) { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 87026e8681d68..62f3edfe9dc85 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -11,6 +11,7 @@ import type {MenuItemWithLink} from '@components/MenuItemList'; import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; import type {SingleSelectItem} from '@components/Search/FilterDropdowns/SingleSelectPopup'; import type { + GroupedItem, QueryFilters, SearchAction, SearchColumnType, @@ -1036,6 +1037,15 @@ function isTransactionQuarterGroupListItemType(item: ListItem): item is Transact return isTransactionGroupListItemType(item) && 'groupedBy' in item && item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER; } +/** + * Type guard that checks if a list of search items contains grouped transaction data. + * When a search has a groupBy parameter, all items share the same shape, so checking the first element is sufficient. + */ +function isGroupedItemArray(data: ListItem[]): data is GroupedItem[] { + const first = data.at(0); + return data.length === 0 || (first !== undefined && isTransactionGroupListItemType(first) && 'groupedBy' in first); +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -4496,6 +4506,7 @@ export { isTransactionWeekGroupListItemType, isTransactionYearGroupListItemType, isTransactionQuarterGroupListItemType, + isGroupedItemArray, isSearchResultsEmpty, isTransactionListItemType, isReportActionListItemType, diff --git a/src/styles/index.ts b/src/styles/index.ts index bc1bb2ce1217f..d79b2ba517a06 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5789,6 +5789,14 @@ const staticStyles = (theme: ThemeColors) => barChartChartContainer: { minHeight: 250, }, + lineChartContainer: { + borderRadius: variables.componentBorderRadiusLarge, + paddingTop: variables.qrShareHorizontalPadding, + paddingHorizontal: variables.qrShareHorizontalPadding, + }, + lineChartChartContainer: { + minHeight: 250, + }, discoverSectionImage: { width: '100%', height: undefined, diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index bd010f5054261..778c228c43298 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -108,7 +108,8 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual(`${defaultQuery} view:line groupBy:category category:travel`); + // LINE view defaults to sortOrder:asc (chronological) and groupBy:month + expect(result).toEqual('type:expense sortBy:date sortOrder:asc view:line groupBy:month category:travel'); }); test('returns query with view:pie', () => { diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 0dc7c41788c2a..e9bab9ee4b3a3 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -6242,11 +6242,12 @@ describe('SearchUIUtils', () => { }); describe('view autocomplete values', () => { - test('should include all view values (table, bar)', () => { + test('should include all view values (table, bar, line)', () => { const viewValues = Object.values(CONST.SEARCH.VIEW); expect(viewValues).toContain('table'); expect(viewValues).toContain('bar'); - expect(viewValues).toHaveLength(2); + expect(viewValues).toContain('line'); + expect(viewValues).toHaveLength(3); }); test('should correctly map view values to user-friendly values', () => { @@ -6254,7 +6255,7 @@ describe('SearchUIUtils', () => { const userFriendlyValues = viewValues.map((value) => getUserFriendlyValue(value)); // All view values should be mapped (they may be the same or different) - expect(userFriendlyValues).toHaveLength(2); + expect(userFriendlyValues).toHaveLength(3); expect(userFriendlyValues.every((value) => typeof value === 'string')).toBe(true); }); });