diff --git a/src/components/ChartKit/ChartContent.tsx b/src/components/ChartKit/ChartContent.tsx deleted file mode 100644 index 1cd64770e1ed..000000000000 --- a/src/components/ChartKit/ChartContent.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, {useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; -import {View} from 'react-native'; -import {CartesianChart, Line} from 'victory-native'; - -const SAMPLE_DATA = [ - {x: 1, y: 10}, - {x: 2, y: 25}, - {x: 3, y: 15}, - {x: 4, y: 32}, - {x: 5, y: 28}, - {x: 6, y: 45}, - {x: 7, y: 38}, -]; - -function ChartContent() { - const [dimensions, setDimensions] = useState<{width: number; height: number} | null>(null); - - const handleLayout = (event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - if (width > 0 && height > 0) { - setDimensions({width, height}); - } - }; - - return ( - - {dimensions && ( - - {({points}) => ( - - )} - - )} - - ); -} - -export default ChartContent; diff --git a/src/components/ChartKit/index.native.tsx b/src/components/ChartKit/index.native.tsx deleted file mode 100644 index 09b724d15c59..000000000000 --- a/src/components/ChartKit/index.native.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import ChartContent from './ChartContent'; - -function ChartKit() { - return ; -} - -export default ChartKit; diff --git a/src/components/ChartKit/index.tsx b/src/components/ChartKit/index.tsx deleted file mode 100644 index 8ae848d643c6..000000000000 --- a/src/components/ChartKit/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import ActivityIndicator from '@components/ActivityIndicator'; -import {View} from 'react-native'; -import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; - -function ChartKit() { - return ( - `/${file}`}} - getComponent={() => import('./ChartContent')} - fallback={ - - - - } - /> - ); -} - -export default ChartKit; diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 0bfa205d20cc..e8c418ade6e4 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,13 +1,12 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; -import {View} from 'react-native'; -import {Gesture} from 'react-native-gesture-handler'; -import Animated, {useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue} from 'react-native-reanimated'; -import {scheduleOnRN} from 'react-native-worklets'; -import type {ChartBounds, PointsArray} from 'victory-native'; -import {Bar, CartesianChart} from 'victory-native'; -import {useFont} from '@shopify/react-native-skia'; -import {useChartInteractionState} from '@components/Charts/hooks'; +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 { Bar, CartesianChart } from 'victory-native'; +import { useFont } from '@shopify/react-native-skia'; +import type { HitTestArgs } from '@components/Charts/hooks'; +import { useChartInteractions, useChartLabelLayout, useChartLabelFormats, useChartColors } from '@components/Charts/hooks'; import ChartTooltip from '@components/Charts/ChartTooltip'; import ActivityIndicator from '@components/ActivityIndicator'; import { @@ -20,25 +19,17 @@ import { DOMAIN_PADDING_SAFETY_BUFFER, EXPENSIFY_NEUE_FONT_URL, FRAME_LINE_WIDTH, - LABEL_ELLIPSIS, - LABEL_PADDING, - SIN_45_DEGREES, - TOOLTIP_BAR_GAP, - X_AXIS_LABEL_MAX_HEIGHT_RATIO, - X_AXIS_LABEL_ROTATION_45, - X_AXIS_LABEL_ROTATION_90, X_AXIS_LINE_WIDTH, Y_AXIS_DOMAIN, Y_AXIS_LABEL_OFFSET, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT, } from '@components/Charts/constants'; -import type {BarChartProps} from '@components/Charts/types'; -import Icon from '@components/Icon'; -import Text from '@components/Text'; +import type { BarChartProps } from '@components/Charts/types'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import ChartHeader from '@components/Charts/components/ChartHeader'; /** * Calculate minimum domainPadding required to prevent bars from overflowing chart edges. @@ -55,42 +46,16 @@ function calculateMinDomainPadding(chartWidth: number, barCount: number, innerPa return Math.ceil(chartWidth * minPaddingRatio * DOMAIN_PADDING_SAFETY_BUFFER); } -/** - * Measure the width of a text string using the font's glyph widths. - * Uses getGlyphWidths as measureText is not implemented on React Native Web. - */ -function measureTextWidth(text: string, fontInstance: ReturnType): number { - if (!fontInstance) { - return 0; - } - const glyphIDs = fontInstance.getGlyphIDs(text); - const glyphWidths = fontInstance.getGlyphWidths(glyphIDs); - return glyphWidths.reduce((sum, w) => sum + w, 0); -} - -/** Type for Victory's actionsRef handle - uses unknown since Victory accepts ChartPressState-compatible objects */ -type CartesianActionsHandle = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handleTouch: (state: any, x: number, y: number) => void; -}; - -function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingleColor = false, onBarPress}: BarChartProps) { +function BarChartContent({ data, title, titleIcon, isLoading, yAxisUnit, useSingleColor = false, onBarPress }: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const font = useFont(EXPENSIFY_NEUE_FONT_URL, variables.iconSizeExtraSmall); const [chartWidth, setChartWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); - const {state: chartInteractionState, isActive: isTooltipActive} = useChartInteractionState({x: 0, y: {y: 0}}); - const actionsRef = useRef(null); const defaultBarColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX); - const handleLayout = useCallback((event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - setChartWidth(width); - setContainerHeight(height); - }, []); - + // prepare data for display const chartData = useMemo(() => { return data.map((point, index) => ({ x: index, @@ -98,144 +63,51 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl })); }, [data]); - const domainPadding = useMemo(() => { - if (chartWidth === 0) { - return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; - } - 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}; - }, [chartWidth, data.length]); - - // Calculate rotation and truncation for X-axis labels - // Monotonic progression: 0° → 45° → 90° based on WIDTH constraint - // Truncation: use max width limit so Victory allocates appropriate space - const {labelRotation, labelSkipInterval, truncatedLabels} = useMemo(() => { - if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { - return {labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label)}; - } - - // 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 maxLabelWidth = 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; + // Handle bar press callback + const handleBarPress = useCallback( + (index: number) => { + if (index < 0 || index >= data.length) { + return; } - 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 (maxLabelWidth > availableWidthPerBar) { - // Labels don't fit at 0°, try 45° - const effectiveWidthAt45 = maxLabelWidth * SIN_45_DEGREES; - if (effectiveWidthAt45 <= availableWidthPerBar) { - rotation = 45; - } else { - // 45° doesn't fit either, use 90° - rotation = 90; + const dataPoint = data.at(index); + if (dataPoint && onBarPress) { + onBarPress(dataPoint, index); } - } - - // === 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°: labelWidth <= availableForRotation - maxAllowedLabelWidth = availableForRotation; - } - - // Generate truncated labels - const finalLabels = data.map((p, i) => truncateToWidth(p.label, labelWidths.at(i) ?? 0, maxAllowedLabelWidth)); + }, + [data, onBarPress], + ); - // === CALCULATE SKIP INTERVAL === - let skipInterval = 1; - 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 - } + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }, []); - if (effectiveWidth > availableWidthPerBar) { - skipInterval = Math.ceil(effectiveWidth / availableWidthPerBar); - } + const { labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength } = useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, + }); - // 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; + const domainPadding = useMemo(() => { + if (chartWidth === 0) { + return { left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom }; } + 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 }; + }, [chartWidth, data.length]); - return {labelRotation: rotationValue, labelSkipInterval: skipInterval, truncatedLabels: finalLabels}; - }, [font, chartWidth, containerHeight, data]); - - const formatYAxisLabel = useCallback( - (value: number) => { - const formatted = value.toLocaleString(); - return yAxisUnit ? `${yAxisUnit}${formatted}` : formatted; - }, - [yAxisUnit], - ); - - const formatXAxisLabel = useCallback( - (value: number) => { - const index = Math.round(value); - // Skip labels based on calculated interval - if (index % labelSkipInterval !== 0) { - return ''; - } - // Use pre-truncated labels - return truncatedLabels.at(index) ?? ''; - }, - [truncatedLabels, labelSkipInterval], - ); - - const [activeDataIndex, setActiveDataIndex] = useState(-1); - const [isOverBar, setIsOverBar] = useState(false); + const { formatXAxisLabel, formatYAxisLabel } = useChartLabelFormats({ + data, + yAxisUnit, + labelSkipInterval, + labelRotation, + truncatedLabels, + }); // Store bar geometry for hit-testing (only constants, no arrays) - const barGeometry = useSharedValue({barWidth: 0, chartBottom: 0}); + const barGeometry = useSharedValue({ barWidth: 0, chartBottom: 0 }); const handleChartBoundsChange = useCallback( (bounds: ChartBounds) => { @@ -249,41 +121,33 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl [data.length, barGeometry], ); - // Check if cursor is over the matched bar - // Uses chartInteractionState.x.position (bar center X) and chartInteractionState.y.y.position (bar top Y) - const isCursorOverBar = useDerivedValue(() => { - const {barWidth, chartBottom} = barGeometry.get(); - const cursorX = chartInteractionState.cursor.x.get(); - const cursorY = chartInteractionState.cursor.y.get(); + const checkIsOverBar = useCallback((args: HitTestArgs) => { + 'worklet'; - if (barWidth === 0) { + const width = barGeometry.get().barWidth; + if (width === 0) { return false; } - - // Bar bounds from the matched point's position (already computed by victory-native) - const barCenterX = chartInteractionState.x.position.get(); - const barTop = chartInteractionState.y.y.position.get(); - - const barLeft = barCenterX - barWidth / 2; - const barRight = barCenterX + barWidth / 2; - - return cursorX >= barLeft && cursorX <= barRight && cursorY >= barTop && cursorY <= chartBottom; + const barLeft = args.targetX - width / 2; + const barRight = args.targetX + width / 2; + const barTop = args.targetY; + const barBottom = args.chartBottom; + + return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; + }, [barGeometry]); + + const { + actionsRef, + customGestures, + activeDataIndex, + isTooltipActive, + tooltipStyle, + } = useChartInteractions({ + handlePress: handleBarPress, + checkIsOver: checkIsOverBar, + barGeometry, }); - useAnimatedReaction( - () => chartInteractionState.matchedIndex.get(), - (currentIndex) => { - scheduleOnRN(setActiveDataIndex, currentIndex); - }, - ); - - useAnimatedReaction( - () => isCursorOverBar.get(), - (isOver) => { - scheduleOnRN(setIsOverBar, isOver); - }, - ); - const tooltipData = useMemo(() => { if (activeDataIndex < 0 || activeDataIndex >= data.length) { return null; @@ -299,92 +163,13 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl }; }, [activeDataIndex, data, yAxisUnit]); - const tooltipStyle = useAnimatedStyle(() => { - return { - position: 'absolute', - left: chartInteractionState.x.position.get(), - top: chartInteractionState.y.y.position.get() - TOOLTIP_BAR_GAP, - transform: [{translateX: '-50%'}, {translateY: '-100%'}], - opacity: chartInteractionState.isActive.get() ? 1 : 0, - }; - }); - - // Handle bar press callback - const handleBarPress = useCallback( - (index: number) => { - if (index < 0 || index >= data.length) { - return; - } - const dataPoint = data.at(index); - if (dataPoint && onBarPress) { - onBarPress(dataPoint, index); - } - }, - [data, onBarPress], - ); - - // Hover gesture for web - shows tooltip on mouse hover - const hoverGesture = useMemo( - () => - Gesture.Hover() - .onBegin((e) => { - 'worklet'; - - chartInteractionState.isActive.value = true; - chartInteractionState.cursor.x.value = e.x; - chartInteractionState.cursor.y.value = e.y; - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - }) - .onUpdate((e) => { - 'worklet'; - - chartInteractionState.cursor.x.value = e.x; - chartInteractionState.cursor.y.value = e.y; - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - }) - .onEnd(() => { - 'worklet'; - - chartInteractionState.isActive.value = false; - }), - [chartInteractionState], - ); - - // Tap gesture for click/tap - triggers navigation - const tapGesture = useMemo( - () => - Gesture.Tap().onEnd((e) => { - 'worklet'; - - // Use handleTouch to find which bar was tapped - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - const matchedIndex = chartInteractionState.matchedIndex.value; - - // Check if tap is over the bar (not just nearest) - const {barWidth, chartBottom} = barGeometry.value; - const barCenterX = chartInteractionState.x.position.value; - const barTop = chartInteractionState.y.y.position.value; - const barLeft = barCenterX - barWidth / 2; - const barRight = barCenterX + barWidth / 2; - - const isTapOverBar = e.x >= barLeft && e.x <= barRight && e.y >= barTop && e.y <= chartBottom; - - if (isTapOverBar && matchedIndex >= 0) { - scheduleOnRN(handleBarPress, matchedIndex); - } - }), - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps, rulesdir/prefer-narrow-hook-dependencies -- shared values are stable references - [chartInteractionState, barGeometry, handleBarPress], - ); - - // Combined gestures for the chart - Race allows both hover and tap to work independently - const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + const { getChartColor } = useChartColors(); const renderBar = useCallback( (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { const dataIndex = point.xValue as number; const dataPoint = data.at(dataIndex); - const barColor = useSingleColor ? defaultBarColor : CHART_COLORS.at(dataIndex % CHART_COLORS.length); + const barColor = useSingleColor ? defaultBarColor : getChartColor(dataIndex); return ( ); }, - [data, useSingleColor, defaultBarColor], + [data, useSingleColor, defaultBarColor, getChartColor], ); + const dynamicChartStyle = useMemo(() => ({ + height: 250 + (maxLabelLength ?? 0) + 100, + }), [maxLabelLength]); + if (isLoading || !font) { return ( @@ -412,24 +201,14 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl if (data.length === 0) { return null; } - return ( - {!!title && ( - - {!!titleIcon && ( - - )} - {title} - - )} - + {chartWidth > 0 && ( @@ -464,15 +243,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl dataPointCount: data.length, }, ]} - frame={{lineWidth: FRAME_LINE_WIDTH}} + frame={{ lineWidth: FRAME_LINE_WIDTH }} data={chartData} > - {({points, chartBounds}) => ( + {({ points, chartBounds }) => ( <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))} )} )} - {isTooltipActive && isOverBar && !!tooltipData && ( + {isTooltipActive && !!tooltipData && ( `/${file}`}} + opts={{ locateFile: (file: string) => `/${file}` }} getComponent={() => import('./BarChartContent')} componentProps={props} fallback={ diff --git a/src/components/Charts/ChartTooltip.tsx b/src/components/Charts/ChartTooltip.tsx index 0402ea7df325..5c61feef80f7 100644 --- a/src/components/Charts/ChartTooltip.tsx +++ b/src/components/Charts/ChartTooltip.tsx @@ -1,9 +1,9 @@ import React from 'react'; import {View} from 'react-native'; import Text from '@components/Text'; -import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from '@components/Charts/constants'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {TOOLTIP_POINTER_HEIGHT, TOOLTIP_POINTER_WIDTH} from './constants'; type ChartTooltipProps = { /** Label text (e.g., "Airfare", "Amazon") */ diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx new file mode 100644 index 000000000000..29796a5dfd52 --- /dev/null +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -0,0 +1,205 @@ +import { useFont } from '@shopify/react-native-skia'; +import React, { useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { View } from 'react-native'; +import { CartesianChart, Line, Scatter } from 'victory-native'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import type { LineChartProps } from '@components/Charts/types'; +import type { HitTestArgs } from '@components/Charts/hooks'; +import { useChartInteractions, useChartLabelFormats, useChartLabelLayout } from '@components/Charts/hooks'; +import { CHART_PADDING, DEFAULT_SINGLE_BAR_COLOR_INDEX, CHART_COLORS, EXPENSIFY_NEUE_FONT_URL, Y_AXIS_DOMAIN, Y_AXIS_LABEL_OFFSET, Y_AXIS_TICK_COUNT, DOT_INNER_RADIUS, DOT_OUTER_RADIUS, LINE_CHART_FRAME } from '@components/Charts/constants'; +import Animated, { } from 'react-native-reanimated'; +import ChartTooltip from '@components/Charts/ChartTooltip'; +import ActivityIndicator from '@components/ActivityIndicator'; +import ChartHeader from '@components/Charts/components/ChartHeader'; + + +function LineChart({ data, title, titleIcon, isLoading, onPointPress, yAxisUnit }: LineChartProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const font = useFont(EXPENSIFY_NEUE_FONT_URL, variables.iconSizeExtraSmall); + const [chartWidth, setChartWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + + const defaultDotColor = CHART_COLORS.at(DEFAULT_SINGLE_BAR_COLOR_INDEX); + + // prepare data for display + const chartData = data.map((point, index) => ({ + x: index, + y: point.total, + })); + + const handlePointPress = (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onPointPress) { + onPointPress(dataPoint, index); + } + }; + + const handleLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }; + + const { labelRotation, labelSkipInterval, truncatedLabels, maxLabelLength } = useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, + }); + + const domainPadding = () => { + return { top: 20, bottom: 20, left: 20, right: (labelRotation === -90 ? 0 : (maxLabelLength ?? 0) / 2) + 20 }; + }; + + const { formatXAxisLabel, formatYAxisLabel } = useChartLabelFormats({ + data, + yAxisUnit, + labelSkipInterval, + labelRotation, + truncatedLabels, + }); + + const checkIsOverDot = (args: HitTestArgs) => { + 'worklet'; + + const targetX = args.targetX; + const targetY = args.targetY; + return (args.cursorX - targetX) ** 2 + (args.cursorY - targetY) ** 2 <= DOT_INNER_RADIUS ** 2; + }; + + const { + actionsRef, + customGestures, + activeDataIndex, + isTooltipActive, + tooltipStyle, + } = useChartInteractions({ + handlePress: handlePointPress, + checkIsOver: checkIsOverDot, + }); + + const tooltipData = () => { + if (activeDataIndex < 0 || activeDataIndex >= data.length) { + return null; + } + const dataPoint = data.at(activeDataIndex); + if (!dataPoint) { + return null; + } + return { + label: dataPoint.label, + amount: yAxisUnit ? `${yAxisUnit} ${dataPoint.total.toLocaleString()}` : dataPoint.total.toLocaleString(), + }; + }; + + const dynamicChartStyle = () => { + return { + height: 250 + (maxLabelLength ?? 0) + 100 + }; + }; + + if (isLoading || !font) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + + {chartWidth > 0 && ( + + {({ points }) => ( + <> + + {points.y.map((point) => { + return ( + <> + + + + ); + })} + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + + + )} + + + ); +} + +export default LineChart; diff --git a/src/components/Charts/LineChart/index.native.tsx b/src/components/Charts/LineChart/index.native.tsx new file mode 100644 index 000000000000..696f869ec7ec --- /dev/null +++ b/src/components/Charts/LineChart/index.native.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { LineChartProps } from '@components/Charts/types'; +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 000000000000..001e2ee3f118 --- /dev/null +++ b/src/components/Charts/LineChart/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import ActivityIndicator from '@components/ActivityIndicator'; +import { View } from 'react-native'; +import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web'; +import colors from '@styles/theme/colors'; +import type { LineChartProps } from '@components/Charts/types'; + +const getLineChartContent = () => import('./LineChartContent'); + +function LineChart(props: LineChartProps) { + return ( + `/${file}` }} + getComponent={getLineChartContent} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index a7e8350a66d1..5ab805169e12 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -1,13 +1,14 @@ -import React, {useCallback, useMemo, useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; -import {View} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; -import {scheduleOnRN} from 'react-native-worklets'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import {Pie, PolarChart} from 'victory-native'; -import type {Color} from '@shopify/react-native-skia'; +import React, { useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { Pie, PolarChart } from 'victory-native'; +import type { Color } from '@shopify/react-native-skia'; import ChartTooltip from '@components/Charts/ChartTooltip'; import ActivityIndicator from '@components/ActivityIndicator'; +import Text from '@components/Text'; import { CHART_COLORS, PIE_CHART_MAX_SLICES, @@ -16,11 +17,10 @@ import { PIE_CHART_START_ANGLE, TOOLTIP_BAR_GAP, } from '@components/Charts/constants'; -import type {PieChartDataPoint, PieChartProps} from '@components/Charts/types'; -import Icon from '@components/Icon'; -import Text from '@components/Text'; +import type { PieChartDataPoint, PieChartProps } from '@components/Charts/types'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import { useChartColors } from '@components/Charts/hooks'; +import ChartHeader from '@components/Charts/components/ChartHeader'; type ProcessedSlice = { label: string; @@ -38,7 +38,7 @@ type ProcessedSlice = { * - Slices below minPercentage are aggregated into "Other" * - If more than maxSlices, smallest are aggregated into "Other" */ -function processDataIntoSlices(data: PieChartDataPoint[], startAngle: number): ProcessedSlice[] { +function processDataIntoSlices(data: PieChartDataPoint[], startAngle: number, getChartColor: (index: number) => string | undefined): ProcessedSlice[] { if (data.length === 0) { return []; } @@ -89,7 +89,7 @@ function processDataIntoSlices(data: PieChartDataPoint[], startAngle: number): P continue; } const sweepAngle = (slice.value / total) * 360; - const color = CHART_COLORS.at(index % CHART_COLORS.length); + const color = getChartColor(index); finalSlices.push({ label: slice.label, value: slice.value, @@ -108,7 +108,9 @@ function processDataIntoSlices(data: PieChartDataPoint[], startAngle: number): P const otherValue = smallSlices.reduce((sum, s) => sum + s.value, 0); const otherPercentage = (otherValue / total) * 100; const sweepAngle = (otherValue / total) * 360; - const otherColor = CHART_COLORS.at(finalSlices.length % CHART_COLORS.length); + + const otherColor = getChartColor(validSlices.length % CHART_COLORS.length); + console.log('otherColor', otherColor); finalSlices.push({ label: PIE_CHART_OTHER_LABEL, value: otherValue, @@ -192,121 +194,104 @@ function findSliceAtPosition( return -1; } -function PieChartContent({data, title, titleIcon, isLoading, valueUnit, onSlicePress}: PieChartProps) { +function PieChartContent({ data, title, titleIcon, isLoading, valueUnit, onSlicePress }: PieChartProps) { const styles = useThemeStyles(); - const [canvasSize, setCanvasSize] = useState({width: 0, height: 0}); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [activeSliceIndex, setActiveSliceIndex] = useState(-1); + const { getChartColor } = useChartColors(); // Shared values for hover state const isHovering = useSharedValue(false); const cursorX = useSharedValue(0); const cursorY = useSharedValue(0); - const handleLayout = useCallback((event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; - setCanvasSize({width, height}); - }, []); + const handleLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setCanvasSize({ width, height }); + }; - // Process data into slices with aggregation - const processedSlices = useMemo(() => processDataIntoSlices(data, PIE_CHART_START_ANGLE), [data]); + // Process data into slices with aggregation, make following code react compiler compatible + const processedSlices: ProcessedSlice[] = processDataIntoSlices(data, PIE_CHART_START_ANGLE, getChartColor); - // Calculate pie geometry - const pieGeometry = useMemo(() => { - const size = Math.min(canvasSize.width, canvasSize.height); - const radius = size / 2; - const centerX = canvasSize.width / 2; - const centerY = canvasSize.height / 2; - return {radius, centerX, centerY}; - }, [canvasSize.width, canvasSize.height]); + // Calculate pie geometry, make following code react compiler compatible + const pieGeometry = { radius: Math.min(canvasSize.width, canvasSize.height) / 2, centerX: canvasSize.width / 2, centerY: canvasSize.height / 2 } as const; // Transform data for Victory Native PolarChart - const chartData = useMemo(() => { - return processedSlices.map((slice) => ({ - label: slice.label, - value: slice.value, - color: slice.color, - })); - }, [processedSlices]); + const chartData = processedSlices.map((slice: { label: string; value: number; color: Color; }) => ({ + label: slice.label, + value: slice.value, + color: slice.color, + })); // Handle hover state updates - const updateActiveSlice = useCallback( - (x: number, y: number) => { - const {radius, centerX, centerY} = pieGeometry; - const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, 0, processedSlices); - setActiveSliceIndex(sliceIndex); - }, - [pieGeometry, processedSlices], - ); + const updateActiveSlice = ( + x: number, y: number) => { + const { radius, centerX, centerY } = pieGeometry; + const sliceIndex = findSliceAtPosition(x, y, centerX, centerY, radius, 0, processedSlices); + setActiveSliceIndex(sliceIndex); + }; // Handle slice press callback - const handleSlicePress = useCallback( - (sliceIndex: number) => { - if (sliceIndex < 0 || sliceIndex >= processedSlices.length) { - return; - } - const slice = processedSlices.at(sliceIndex); - if (!slice || slice.isOther) { - // Don't navigate for "Other" slice - return; - } - const originalDataPoint = data.at(slice.originalIndex); - if (originalDataPoint && onSlicePress) { - onSlicePress(originalDataPoint, slice.originalIndex); - } - }, - [data, processedSlices, onSlicePress], - ); + const handleSlicePress = (sliceIndex: number) => { + if (sliceIndex < 0 || sliceIndex >= processedSlices.length) { + return; + } + const slice = processedSlices.at(sliceIndex); + if (!slice || slice.isOther) { + // Don't navigate for "Other" slice + return; + } + const originalDataPoint = data.at(slice.originalIndex); + if (originalDataPoint && onSlicePress) { + onSlicePress(originalDataPoint, slice.originalIndex); + } + }; // Hover gesture - const hoverGesture = useMemo( + const hoverGesture = () => Gesture.Hover() .onBegin((e) => { 'worklet'; - isHovering.value = true; - cursorX.value = e.x; - cursorY.value = e.y; + isHovering.set(true); + cursorX.set(e.x); + cursorY.set(e.y); scheduleOnRN(updateActiveSlice, e.x, e.y); }) .onUpdate((e) => { 'worklet'; - cursorX.value = e.x; - cursorY.value = e.y; + cursorX.set(e.x); + cursorY.set(e.y); scheduleOnRN(updateActiveSlice, e.x, e.y); }) .onEnd(() => { 'worklet'; - isHovering.value = false; + isHovering.set(false); scheduleOnRN(setActiveSliceIndex, -1); - }), - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps, rulesdir/prefer-narrow-hook-dependencies -- shared values are stable references - [isHovering, cursorX, cursorY, updateActiveSlice], - ); - + }) + ; // Tap gesture for click/tap navigation - const tapGesture = useMemo( + const tapGesture = () => Gesture.Tap().onEnd((e) => { 'worklet'; - const {radius, centerX, centerY} = pieGeometry; + const { radius, centerX, centerY } = pieGeometry; const sliceIndex = findSliceAtPosition(e.x, e.y, centerX, centerY, radius, 0, processedSlices); if (sliceIndex >= 0) { scheduleOnRN(handleSlicePress, sliceIndex); } - }), - [pieGeometry, processedSlices, handleSlicePress], - ); + }); // Combined gestures - Race allows both hover and tap to work independently - const combinedGesture = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + const combinedGesture = Gesture.Race(hoverGesture(), tapGesture()); // Tooltip data - const tooltipData = useMemo(() => { + const tooltipData = () => { if (activeSliceIndex < 0 || activeSliceIndex >= processedSlices.length) { return null; } @@ -321,16 +306,16 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, onSliceP amount: formattedValue, percentage: formattedPercentage, }; - }, [activeSliceIndex, processedSlices, valueUnit]); + }; // Tooltip position (at cursor) const tooltipStyle = useAnimatedStyle(() => { return { position: 'absolute', - left: cursorX.value, - top: cursorY.value - TOOLTIP_BAR_GAP, - transform: [{translateX: '-50%'}, {translateY: '-100%'}], - opacity: isHovering.value ? 1 : 0, + left: cursorX.get(), + top: cursorY.get() - TOOLTIP_BAR_GAP, + transform: [{ translateX: '-50%' }, { translateY: '-100%' }], + opacity: isHovering.get() ? 1 : 0, pointerEvents: 'none', }; }); @@ -349,18 +334,9 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, onSliceP return ( - {!!title && ( - - {!!titleIcon && ( - - )} - {title} - - )} + = 0 && !!tooltipData && ( )} - + + {chartData.map((slice) => ( + + {/* The Dot: Background color is pulled directly from the data slice */} + + + {/* The Label: Text is pulled directly from the data slice label */} + + {slice.label} + + + ))} + + ); } diff --git a/src/components/Charts/PieChart/index.tsx b/src/components/Charts/PieChart/index.tsx index 688b9fc0664c..293dee752031 100644 --- a/src/components/Charts/PieChart/index.tsx +++ b/src/components/Charts/PieChart/index.tsx @@ -1,18 +1,27 @@ import React from 'react'; -import {View} from 'react-native'; -import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; +import { View } from 'react-native'; +import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web'; import ActivityIndicator from '@components/ActivityIndicator'; -import type {PieChartProps} from '@components/Charts/types'; +import type { PieChartProps } from '@components/Charts/types'; import colors from '@styles/theme/colors'; +const getPieChartContent = () => import('./PieChartContent'); + function PieChart(props: PieChartProps) { return ( `/${file}`}} - getComponent={() => import('./PieChartContent')} + opts={{ locateFile: (file: string) => `/${file}` }} + getComponent={getPieChartContent} componentProps={props} fallback={ - + } diff --git a/src/components/Charts/components/ChartHeader.tsx b/src/components/Charts/components/ChartHeader.tsx new file mode 100644 index 000000000000..74dd039aacdf --- /dev/null +++ b/src/components/Charts/components/ChartHeader.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { View } from "react-native"; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import Icon from "@components/Icon"; +import type IconAsset from "@src/types/utils/IconAsset"; +import Text from '@components/Text'; + + +type ChartHeaderProps = { + title: string | undefined; + titleIcon: IconAsset | undefined; +}; + +export default function ChartHeader({ title, titleIcon }: ChartHeaderProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + return ( + !!title && ( + + {!!titleIcon && ( + + )} + {title} + + ) + ); +} diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 2bc9f9faf029..3e27c6922992 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -1,5 +1,5 @@ -import type {Color} from '@shopify/react-native-skia'; -import type {RoundedCorners} from 'victory-native'; +import type { Color } from '@shopify/react-native-skia'; +import type { RoundedCorners } from 'victory-native'; import colors from '@styles/theme/colors'; /** @@ -108,6 +108,16 @@ const PIE_CHART_OTHER_LABEL = 'Other'; /** Starting angle for pie chart (0 = 3 o'clock, -90 = 12 o'clock) */ const PIE_CHART_START_ANGLE = -90; +/** Inner radius for the dot (the part that is visible) */ +const DOT_INNER_RADIUS = 6; + +/** Outer radius for the dot (the part that is the same color as background so the lines do not appear as connected +) */ +const DOT_OUTER_RADIUS = 8; + +/** Frame for the line chart (the lines are only on the left and at the bottom) */ +const LINE_CHART_FRAME = { lineWidth: { left: 1, bottom: 1, top: 0, right: 0 } }; + export { CHART_COLORS, Y_AXIS_TICK_COUNT, @@ -136,4 +146,7 @@ export { PIE_CHART_MAX_SLICES, PIE_CHART_OTHER_LABEL, PIE_CHART_START_ANGLE, + DOT_INNER_RADIUS, + DOT_OUTER_RADIUS, + LINE_CHART_FRAME, }; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 81e31ac56d3d..05c476d109ec 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,2 +1,7 @@ -export {useChartInteractionState} from './useChartInteractionState'; -export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; +export { useChartInteractionState } from './useChartInteractionState'; +export { useChartLabelLayout } from './useChartLabelLayout'; +export { useChartInteractions } from './useChartInteractions'; +export type { HitTestArgs } from './useChartInteractions'; +export type { ChartInteractionState, ChartInteractionStateInit } from './useChartInteractionState'; +export { default as useChartLabelFormats } from './useChartLabelFormats'; +export { default as useChartColors } from './useChartColors'; diff --git a/src/components/Charts/hooks/useChartColors.ts b/src/components/Charts/hooks/useChartColors.ts new file mode 100644 index 000000000000..e8da8fdea31d --- /dev/null +++ b/src/components/Charts/hooks/useChartColors.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import colors from '@styles/theme/colors'; + +/** + * Hook to generate the Expensify Chart Color Palette. + * Sequence logic: + * 1. Row Sequence: 400, 600, 300, 500, 700 + * 2. Hue Order: Yellow, Tangerine, Pink, Green, Ice, Blue + */ +const useChartColors = () => { + const chartPalette = useMemo(() => { + 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 sequence based on index. + * Automatically loops back to the start if the index exceeds 29. + */ + const getChartColor = (index: number): string | undefined => { + if (chartPalette.length === 0) { + return colors.black; // Fallback + } + return chartPalette.at(index % chartPalette.length); + }; + + return { + chartPalette, + getChartColor, + }; +}; + +export default useChartColors; diff --git a/src/components/Charts/hooks/useChartInteractionState.ts b/src/components/Charts/hooks/useChartInteractionState.ts index b4fcd08bd28a..a019d0ea4efc 100644 --- a/src/components/Charts/hooks/useChartInteractionState.ts +++ b/src/components/Charts/hooks/useChartInteractionState.ts @@ -1,7 +1,7 @@ -import {useMemo, useState} from 'react'; -import type {SharedValue} from 'react-native-reanimated'; -import {makeMutable, useAnimatedReaction} from 'react-native-reanimated'; -import {scheduleOnRN} from 'react-native-worklets'; +import { useMemo, useState } from 'react'; +import type { SharedValue } from 'react-native-reanimated'; +import { makeMutable, useAnimatedReaction } from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; /** * Input field type - matches Victory Native's InputFieldType @@ -50,10 +50,10 @@ type ChartInteractionState = { * Hook to track whether interaction is active as React state */ function useIsInteractionActive(state: ChartInteractionState): boolean { - const [isInteractionActive, setIsInteractionActive] = useState(() => state.isActive.value); + const [isInteractionActive, setIsInteractionActive] = useState(() => state.isActive.get()); useAnimatedReaction( - () => state.isActive.value, + () => state.isActive.get(), (val, oldVal) => { if (val === oldVal) { return; @@ -82,11 +82,11 @@ function useIsInteractionActive(state: C * // Use with customGestures and actionsRef * const hoverGesture = Gesture.Hover() * .onUpdate((e) => { - * state.isActive.value = true; + * state.isActive.set(true); * actionsRef.current?.handleTouch(state, e.x, e.y); * }) * .onEnd(() => { - * state.isActive.value = false; + * state.isActive.set(false); * }); * ``` */ @@ -97,7 +97,7 @@ function useChartInteractionState(initia const keys = Object.keys(initialValues.y).join(','); const state = useMemo(() => { - const yState = {} as Record; position: SharedValue}>; + const yState = {} as Record; position: SharedValue }>; for (const [key, initVal] of Object.entries(initialValues.y)) { yState[key as keyof Init['y']] = { @@ -125,8 +125,8 @@ function useChartInteractionState(initia const isActive = useIsInteractionActive(state); - return {state, isActive}; + return { state, isActive }; } -export {useChartInteractionState}; -export type {ChartInteractionState, ChartInteractionStateInit}; +export { useChartInteractionState }; +export type { ChartInteractionState, ChartInteractionStateInit }; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts new file mode 100644 index 000000000000..2b6f5febe54f --- /dev/null +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -0,0 +1,203 @@ +import { useMemo, useRef, useState } from 'react'; +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'; + +/** + * Arguments passed to the checkIsOver callback for hit-testing + */ +type HitTestArgs = { + /** Current raw X position of the cursor */ + cursorX: number; + /** Current raw Y position of the cursor */ + cursorY: number; + /** Calculated X position of the matched data point */ + targetX: number; + /** Calculated Y position of the matched data point */ + targetY: number; + /** The bottom boundary of the chart area */ + chartBottom: number; +}; + +/** + * Configuration for the chart interactions hook + */ +type UseChartInteractionsProps = { + /** Callback triggered when a valid data point is tapped/clicked */ + handlePress: (index: number) => void; + /** * Worklet function to determine if the cursor is technically "hovering" + * over a specific chart element (e.g., within a bar's width or a point's radius). + */ + checkIsOver: (args: HitTestArgs) => boolean; + /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ + barGeometry?: SharedValue<{ barWidth: number; chartBottom: number }>; +}; + +/** * Type for Victory's actionsRef handle. + * Used to manually trigger Victory's internal touch handling logic. + */ +type CartesianActionsHandle = { + handleTouch: (state: unknown, x: number, y: number) => void; +}; + +/** + * Hook to manage complex chart interactions including hover gestures (web), + * tap gestures (mobile/web), hit-testing, and animated tooltip positioning. + * + * It synchronizes high-frequency interaction data from the UI thread to React state + * for metadata display (like tooltips) and navigation. + * + * @param props - Configuration including press handlers and hit-test logic. + * @returns An object containing refs, gestures, and state for the chart component. + * + * @example + * ```tsx + * const { actionsRef, customGestures, activeDataIndex, isTooltipActive, tooltipStyle } = useChartInteractions({ + * handlePress: (index) => console.log("Pressed index:", index), + * checkIsOver: ({ cursorX, targetX, barWidth }) => { + * 'worklet'; + * return Math.abs(cursorX - targetX) < barWidth / 2; + * }, + * barGeometry: myBarSharedValue, + * }); + * + * return ( + * + * + * {isTooltipActive && } + * + * ); + * ``` + */ +function useChartInteractions({ handlePress, checkIsOver, barGeometry }: UseChartInteractionsProps) { + /** Interaction state compatible with Victory Native's internal logic */ + const { state: chartInteractionState, isActive: isTooltipActiveState } = useChartInteractionState({ x: 0, y: { y: 0 } }); + + /** Ref passed to CartesianChart to allow manual touch injection */ + const actionsRef = useRef(null); + + /** React state for the index of the point currently being interacted with */ + const [activeDataIndex, setActiveDataIndex] = useState(-1); + + /** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */ + const [isOverTarget, setIsOverTarget] = useState(false); + + /** + * Derived value performing the hit-test on the UI thread. + * Runs whenever cursor position or matched data points change. + */ + const isCursorOverTarget = useDerivedValue(() => { + const cursorX = chartInteractionState.cursor.x.get(); + const cursorY = chartInteractionState.cursor.y.get(); + const targetX = chartInteractionState.x.position.get(); + const targetY = chartInteractionState.y.y.position.get(); + + const chartBottom = barGeometry?.get().chartBottom ?? 0; + + return checkIsOver({ + cursorX, + cursorY, + targetX, + targetY, + chartBottom, + }); + }); + + /** Syncs the matched data index from the UI thread to React state */ + useAnimatedReaction( + () => chartInteractionState.matchedIndex.get(), + (currentIndex) => { + scheduleOnRN(setActiveDataIndex, currentIndex); + }, + ); + + /** Syncs the hit-test result from the UI thread to React state */ + useAnimatedReaction( + () => isCursorOverTarget.get(), + (isOver) => { + scheduleOnRN(setIsOverTarget, isOver); + }, + ); + + /** * Hover gesture configuration. + * Primarily used for web/desktop to track mouse movement without clicking. + */ + const hoverGesture = useMemo( + () => + Gesture.Hover() + .onBegin((e) => { + 'worklet'; + + chartInteractionState.isActive.set(true); + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + }) + .onUpdate((e) => { + 'worklet'; + + chartInteractionState.cursor.x.set(e.x); + chartInteractionState.cursor.y.set(e.y); + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + }) + .onEnd(() => { + 'worklet'; + + chartInteractionState.isActive.set(false); + }), + [chartInteractionState], + ); + + /** * Tap gesture configuration. + * Handles clicks/touches and triggers handlePress if the hit-test passes. + */ + const tapGesture = useMemo( + () => + Gesture.Tap().onEnd((e) => { + 'worklet'; + + actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + const matchedIndex = chartInteractionState.matchedIndex.get(); + + if (isCursorOverTarget.get() && matchedIndex >= 0) { + scheduleOnRN(handlePress, matchedIndex); + } + }), + [chartInteractionState, isCursorOverTarget, handlePress], + ); + + /** Combined gesture object to be passed to CartesianChart's customGestures prop */ + const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + + /** * Animated style for positioning a tooltip relative to the matched data point. + * Automatically applies vertical offset and centering. + */ + const tooltipStyle = useAnimatedStyle(() => { + return { + position: 'absolute', + left: chartInteractionState.x.position.get(), + top: chartInteractionState.y.y.position.get() - TOOLTIP_BAR_GAP, + transform: [{ translateX: '-50%' }, { translateY: '-100%' }], + opacity: chartInteractionState.isActive.get() ? 1 : 0, + }; + }); + + return { + /** Ref to be passed to CartesianChart */ + actionsRef, + /** Gestures to be passed to CartesianChart */ + customGestures, + /** The currently active data index (React state) */ + activeDataIndex, + /** Whether the tooltip should currently be rendered and visible */ + isTooltipActive: isOverTarget && isTooltipActiveState, + /** Animated styles for the tooltip container */ + tooltipStyle, + }; +} + +export { useChartInteractions }; +export type { HitTestArgs }; diff --git a/src/components/Charts/hooks/useChartLabelFormats.ts b/src/components/Charts/hooks/useChartLabelFormats.ts new file mode 100644 index 000000000000..0beb7d9e0346 --- /dev/null +++ b/src/components/Charts/hooks/useChartLabelFormats.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; + +type ChartDataPoint = { + label: string; +}; + +type UseChartLabelFormatsProps = { + data: ChartDataPoint[]; + yAxisUnit?: string; + labelSkipInterval: number; + labelRotation: number; + truncatedLabels: string[]; +}; + +export default function useChartLabelFormats({ + data, + yAxisUnit, + labelSkipInterval, + labelRotation, + truncatedLabels, +}: UseChartLabelFormatsProps) { + + const formatYAxisLabel = useCallback( + (value: number) => { + const formatted = value.toLocaleString(); + return yAxisUnit ? `${yAxisUnit}${formatted}` : formatted; + }, + [yAxisUnit], + ); + + const formatXAxisLabel = useCallback( + (value: number) => { + const index = Math.round(value); + + // Skip labels based on calculated interval + if (index % labelSkipInterval !== 0) { + return ''; + } + + // 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; + + return sourceToUse.at(index) ?? ''; + }, + [labelSkipInterval, labelRotation, truncatedLabels, data], + ); + + return { + formatXAxisLabel, + formatYAxisLabel, + }; +} diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts new file mode 100644 index 000000000000..42c231a12f6a --- /dev/null +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; +import type { SkFont } from "@shopify/react-native-skia"; +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; // todo find something better than unknown / any +} + +type LabelLayoutConfig = { + data: ChartDataPoint[]; + font: SkFont | null; + chartWidth: number; + containerHeight: number; +} + + +/** + * Measure the width of a text string using the font's glyph widths. + * Uses getGlyphWidths as measureText is not implemented on React Native Web. + */ +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 useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, +}: LabelLayoutConfig) { + return useMemo(() => { + if (!font || chartWidth === 0 || containerHeight === 0 || data.length === 0) { + return { labelRotation: 0, labelSkipInterval: 1, truncatedLabels: data.map((p) => p.label) }; + } + + // 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 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; + } + } + + // === 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°: labelWidth <= availableForRotation + maxAllowedLabelWidth = availableForRotation; + } + + // Generate truncated labels + const finalLabels = data.map((p, i) => truncateToWidth(p.label, labelWidths.at(i) ?? 0, maxAllowedLabelWidth)); + + // === CALCULATE SKIP INTERVAL === + let skipInterval = 1; + 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 + } + + if (effectiveWidth > availableWidthPerBar) { + skipInterval = Math.ceil(effectiveWidth / availableWidthPerBar); + } + + // 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, containerHeight, data]); +}; + + +export { useChartLabelLayout }; +export type { LabelLayoutConfig }; diff --git a/src/components/Charts/index.ts b/src/components/Charts/index.ts index 848d591647db..c18c75c905dc 100644 --- a/src/components/Charts/index.ts +++ b/src/components/Charts/index.ts @@ -1,6 +1,7 @@ import BarChart from './BarChart'; import ChartTooltip from './ChartTooltip'; import PieChart from './PieChart'; +import LineChart from './LineChart'; -export {BarChart, ChartTooltip, PieChart}; +export {BarChart, ChartTooltip, PieChart, LineChart}; export type {BarChartDataPoint, BarChartProps, PieChartDataPoint, PieChartProps} from './types'; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 4c1f4c3b2aa4..95ce6fd9bb46 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -34,6 +34,37 @@ type PieChartProps = { valueUnit?: string; }; +type LineChartDataPoint = { + /** Label displayed in tooltip when hovering over point (e.g., "Jan 2026") */ + label: string; + + /** Value for this point */ + total: number; + + /** Query string for navigation when point is clicked (optional) */ + onClickQuery?: string; +}; + +type LineChartProps = { + /** Data points to display */ + data: LineChartDataPoint[]; + + /** Chart title (e.g., "Top Categories") */ + title?: string; + + /** Icon displayed next to the title */ + titleIcon?: IconAsset; + + /** Whether data is loading */ + isLoading?: boolean; + + /** Callback when a line is pressed */ + onPointPress?: (dataPoint: LineChartDataPoint, index: number) => void; + + /** Symbol/unit prefix for Y-axis labels (e.g., '$', '€'). Empty string or undefined shows raw numbers. */ + yAxisUnit?: string; +}; + type BarChartDataPoint = { /** Label displayed under the bar (e.g., "Amazon", "Travel", "Nov 2025") */ label: string; @@ -71,4 +102,4 @@ type BarChartProps = { useSingleColor?: boolean; }; -export type {BarChartDataPoint, BarChartProps, PieChartDataPoint, PieChartProps}; +export type { BarChartDataPoint, BarChartProps, LineChartDataPoint, LineChartProps, PieChartDataPoint, PieChartProps }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1ae73bff44aa..2bb616e7b0e2 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,10 +1,10 @@ -import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; +import { findFocusedRoute, useFocusEffect, useIsFocused, useNavigation } from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; -import {ScrollView, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; +import { ScrollView, View } from 'react-native'; +import type { OnyxEntry } from 'react-native-onyx'; +import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; @@ -20,7 +20,7 @@ import type { TransactionWithdrawalIDGroupListItemType, } from '@components/SelectionListWithSections/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; -import {WideRHPContext} from '@components/WideRHPContextProvider'; +import { WideRHPContext } from '@components/WideRHPContextProvider'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -33,19 +33,19 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; -import {openOldDotLink} from '@libs/actions/Link'; -import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import type {TransactionPreviewData} from '@libs/actions/Search'; -import {openSearch, setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; +import { openOldDotLink } from '@libs/actions/Link'; +import { turnOffMobileSelectionMode, turnOnMobileSelectionMode } from '@libs/actions/MobileSelectionMode'; +import type { TransactionPreviewData } from '@libs/actions/Search'; +import { openSearch, setOptimisticDataForTransactionThreadPreview } from '@libs/actions/Search'; import Timing from '@libs/actions/Timing'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import { canUseTouchScreen } from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type { PlatformStackNavigationProp } from '@libs/Navigation/PlatformStackNavigation/types'; import Performance from '@libs/Performance'; -import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; -import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import { isSplitAction } from '@libs/ReportSecondaryActionUtils'; +import { canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions } from '@libs/ReportUtils'; +import { buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString } from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, getColumnsToShow, @@ -66,153 +66,261 @@ import { shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; -import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; -import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils'; -import Navigation, {navigationRef} from '@navigation/Navigation'; -import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; +import { cancelSpan, endSpan, startSpan } from '@libs/telemetry/activeSpans'; +import { getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isOnHold, isTransactionPendingDelete, mergeProhibitedViolations, shouldShowViolation } from '@libs/TransactionUtils'; +import Navigation, { navigationRef } from '@navigation/Navigation'; +import type { SearchFullscreenNavigatorParamList } from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; -import type {OutstandingReportsByPolicyIDDerivedValue, Transaction} from '@src/types/onyx'; +import { columnsSelector } from '@src/selectors/AdvancedSearchFiltersForm'; +import { isActionLoadingSetSelector } from '@src/selectors/ReportMetaData'; +import type { OutstandingReportsByPolicyIDDerivedValue, Transaction } from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; -import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type { TransactionViolation } from '@src/types/onyx/TransactionViolation'; +import { isEmptyObject } from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; -import {BarChart, PieChart} from '@components/Charts'; -import type {BarChartDataPoint, PieChartDataPoint} from '@components/Charts/types'; -import {FolderInsights} from '@components/Icon/Expensicons'; +import { BarChart, LineChart, PieChart } from '@components/Charts'; +import type { BarChartDataPoint, LineChartDataPoint, PieChartDataPoint } from '@components/Charts/types'; +import { FolderInsights } from '@components/Icon/Expensicons'; import Text from '@components/Text'; import Button from '@components/Button'; -import {useSearchContext} from './SearchContext'; +import { useSearchContext } from './SearchContext'; import SearchList from './SearchList'; -import {SearchScopeProvider} from './SearchScopeProvider'; -import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; +import { SearchScopeProvider } from './SearchScopeProvider'; +import type { SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder } from './types'; +import { useMemoizedLazyAsset } from '@hooks/useLazyAsset'; +import { loadExpensifyIcon } from '@components/Icon/ExpensifyIconLoader'; +import IconAsset from '@src/types/utils/IconAsset'; // Test data with long labels const TEST_LONG_LABELS_5_BARS: BarChartDataPoint[] = [ - {label: 'Transportation & Commuting', total: 1200, currency: 'USD', onClickQuery: 'type:expense category:"Transportation & Commuting"'}, - {label: 'Food & Dining Expenses', total: 800, currency: 'USD', onClickQuery: 'type:expense category:"Food & Dining Expenses"'}, - {label: 'Monthly Utility Bills', total: 1500, currency: 'USD', onClickQuery: 'type:expense category:"Monthly Utility Bills"'}, - {label: 'Entertainment & Recreation', total: 600, currency: 'USD', onClickQuery: 'type:expense category:"Entertainment & Recreation"'}, - {label: 'Healthcare & Medical', total: 400, currency: 'USD', onClickQuery: 'type:expense category:"Healthcare & Medical"'}, + { label: 'Transportation & Commuting', total: 1200, currency: 'USD', onClickQuery: 'type:expense category:"Transportation & Commuting"' }, + { label: 'Food & Dining Expenses', total: 800, currency: 'USD', onClickQuery: 'type:expense category:"Food & Dining Expenses"' }, + { label: 'Monthly Utility Bills', total: 1500, currency: 'USD', onClickQuery: 'type:expense category:"Monthly Utility Bills"' }, + { label: 'Entertainment & Recreation', total: 600, currency: 'USD', onClickQuery: 'type:expense category:"Entertainment & Recreation"' }, + { label: 'Healthcare & Medical', total: 400, currency: 'USD', onClickQuery: 'type:expense category:"Healthcare & Medical"' }, ]; const TEST_LONG_LABELS_15_BARS: BarChartDataPoint[] = [ - {label: 'Transportation & Commuting', total: 1200, currency: 'USD'}, - {label: 'Food & Dining Expenses', total: 800, currency: 'USD'}, - {label: 'Monthly Utility Bills', total: 1500, currency: 'USD'}, - {label: 'Entertainment & Recreation', total: 600, currency: 'USD'}, - {label: 'Healthcare & Medical', total: 400, currency: 'USD'}, - {label: 'Home Insurance Premium', total: 350, currency: 'USD'}, - {label: 'Education & Learning', total: 500, currency: 'USD'}, - {label: 'Clothing & Accessories', total: 300, currency: 'USD'}, - {label: 'Pet Care & Supplies', total: 250, currency: 'USD'}, - {label: 'Gym & Fitness Membership', total: 150, currency: 'USD'}, - {label: 'Mobile Phone Service', total: 100, currency: 'USD'}, - {label: 'Internet & Streaming', total: 80, currency: 'USD'}, - {label: 'Home Maintenance', total: 450, currency: 'USD'}, - {label: 'Personal Care Products', total: 200, currency: 'USD'}, - {label: 'Charitable Donations', total: 175, currency: 'USD'}, + { label: 'Transportation & Commuting', total: 1200, currency: 'USD' }, + { label: 'Food & Dining Expenses', total: 800, currency: 'USD' }, + { label: 'Monthly Utility Bills', total: 1500, currency: 'USD' }, + { label: 'Entertainment & Recreation', total: 600, currency: 'USD' }, + { label: 'Healthcare & Medical', total: 400, currency: 'USD' }, + { label: 'Home Insurance Premium', total: 350, currency: 'USD' }, + { label: 'Education & Learning', total: 500, currency: 'USD' }, + { label: 'Clothing & Accessories', total: 300, currency: 'USD' }, + { label: 'Pet Care & Supplies', total: 250, currency: 'USD' }, + { label: 'Gym & Fitness Membership', total: 150, currency: 'USD' }, + { label: 'Mobile Phone Service', total: 100, currency: 'USD' }, + { label: 'Internet & Streaming', total: 80, currency: 'USD' }, + { label: 'Home Maintenance', total: 450, currency: 'USD' }, + { label: 'Personal Care Products', total: 200, currency: 'USD' }, + { label: 'Charitable Donations', total: 175, currency: 'USD' }, ]; const TEST_VERY_LONG_LABELS_8_BARS: BarChartDataPoint[] = [ - {label: 'Monthly Transportation and Daily Commuting Expenses', total: 1200, currency: 'USD'}, - {label: 'Grocery Shopping and Restaurant Dining Combined', total: 800, currency: 'USD'}, - {label: 'Home Utilities Including Electric Water and Gas', total: 1500, currency: 'USD'}, - {label: 'Weekend Entertainment and Family Recreation Activities', total: 600, currency: 'USD'}, - {label: 'Annual Healthcare Insurance and Medical Copays', total: 400, currency: 'USD'}, - {label: 'Professional Development and Online Course Subscriptions', total: 350, currency: 'USD'}, - {label: 'Children Education and School Related Expenses', total: 500, currency: 'USD'}, - {label: 'Home Improvement and Maintenance Service Costs', total: 450, currency: 'USD'}, + { label: 'Monthly Transportation and Daily Commuting Expenses', total: 1200, currency: 'USD' }, + { label: 'Grocery Shopping and Restaurant Dining Combined', total: 800, currency: 'USD' }, + { label: 'Home Utilities Including Electric Water and Gas', total: 1500, currency: 'USD' }, + { label: 'Weekend Entertainment and Family Recreation Activities', total: 600, currency: 'USD' }, + { label: 'Annual Healthcare Insurance and Medical Copays', total: 400, currency: 'USD' }, + { label: 'Children Education and School Related Expenses', total: 500, currency: 'USD' }, + { label: 'Home Improvement and Maintenance Service Costs', total: 450, currency: 'USD' }, + { label: 'Professional Development and Online Course Subscriptions', total: 350, currency: 'USD' }, ]; const TEST_MIXED_LABELS_10_BARS: BarChartDataPoint[] = [ - {label: 'Food', total: 1200, currency: 'USD'}, - {label: 'Transportation & Daily Commuting', total: 800, currency: 'USD'}, - {label: 'Bills', total: 1500, currency: 'USD'}, - {label: 'Entertainment and Recreation Activities', total: 600, currency: 'USD'}, - {label: 'Gas', total: 400, currency: 'USD'}, - {label: 'Monthly Insurance Premium', total: 350, currency: 'USD'}, - {label: 'Edu', total: 500, currency: 'USD'}, - {label: 'Clothing & Fashion Accessories', total: 300, currency: 'USD'}, - {label: 'Pet', total: 250, currency: 'USD'}, - {label: 'Gym', total: 150, currency: 'USD'}, + { label: 'Food', total: 1200, currency: 'USD' }, + { label: 'Transportation & Daily Commuting', total: 800, currency: 'USD' }, + { label: 'Bills', total: 1500, currency: 'USD' }, + { label: 'Entertainment and Recreation Activities', total: 600, currency: 'USD' }, + { label: 'Gas', total: 400, currency: 'USD' }, + { label: 'Monthly Insurance Premium', total: 350, currency: 'USD' }, + { label: 'Edu', total: 500, currency: 'USD' }, + { label: 'Clothing & Fashion Accessories', total: 300, currency: 'USD' }, + { label: 'Pet', total: 250, currency: 'USD' }, + { label: 'Gym', total: 150, currency: 'USD' }, ]; const TEST_ONE_LONG_LABEL_10_BARS: BarChartDataPoint[] = [ - {label: 'Food', total: 1200, currency: 'USD'}, - {label: 'Travel', total: 800, currency: 'USD'}, - {label: 'Monthly Transportation and Commuting Expenses for Work', total: 1500, currency: 'USD'}, - {label: 'Fun', total: 600, currency: 'USD'}, - {label: 'Gas', total: 400, currency: 'USD'}, - {label: 'Rent', total: 350, currency: 'USD'}, - {label: 'Gym', total: 500, currency: 'USD'}, - {label: 'Pet', total: 300, currency: 'USD'}, - {label: 'Car', total: 250, currency: 'USD'}, - {label: 'Phone', total: 150, currency: 'USD'}, + { label: 'Food', total: 1200, currency: 'USD' }, + { label: 'Travel', total: 800, currency: 'USD' }, + { label: 'Monthly Transportation and Commuting Expenses for Work', total: 1500, currency: 'USD' }, + { label: 'Fun', total: 600, currency: 'USD' }, + { label: 'Gas', total: 400, currency: 'USD' }, + { label: 'Rent', total: 350, currency: 'USD' }, + { label: 'Gym', total: 500, currency: 'USD' }, + { label: 'Pet', total: 300, currency: 'USD' }, + { label: 'Car', total: 250, currency: 'USD' }, + { label: 'Phone', total: 150, currency: 'USD' }, ]; // PieChart test data const TEST_PIE_CHART_5_SLICES: PieChartDataPoint[] = [ - {label: 'Food & Dining', value: 1200, currency: 'USD', onClickQuery: 'type:expense category:"Food & Dining"'}, - {label: 'Transportation', value: 800, currency: 'USD', onClickQuery: 'type:expense category:Transportation'}, - {label: 'Utilities', value: 500, currency: 'USD', onClickQuery: 'type:expense category:Utilities'}, - {label: 'Entertainment', value: 300, currency: 'USD', onClickQuery: 'type:expense category:Entertainment'}, - {label: 'Healthcare', value: 200, currency: 'USD', onClickQuery: 'type:expense category:Healthcare'}, + { label: 'Food & Dining', value: 1200, currency: 'USD', onClickQuery: 'type:expense category:"Food & Dining"' }, + { label: 'Transportation', value: 800, currency: 'USD', onClickQuery: 'type:expense category:Transportation' }, + { label: 'Utilities', value: 500, currency: 'USD', onClickQuery: 'type:expense category:Utilities' }, + { label: 'Entertainment', value: 300, currency: 'USD', onClickQuery: 'type:expense category:Entertainment' }, + { label: 'Healthcare', value: 200, currency: 'USD', onClickQuery: 'type:expense category:Healthcare' }, ]; const TEST_PIE_CHART_MANY_SLICES: PieChartDataPoint[] = [ - {label: 'Food & Dining', value: 1200, currency: 'USD'}, - {label: 'Transportation', value: 800, currency: 'USD'}, - {label: 'Utilities', value: 500, currency: 'USD'}, - {label: 'Entertainment', value: 300, currency: 'USD'}, - {label: 'Healthcare', value: 200, currency: 'USD'}, - {label: 'Shopping', value: 450, currency: 'USD'}, - {label: 'Travel', value: 350, currency: 'USD'}, - {label: 'Education', value: 250, currency: 'USD'}, - {label: 'Subscriptions', value: 150, currency: 'USD'}, - {label: 'Gym', value: 80, currency: 'USD'}, - {label: 'Coffee', value: 60, currency: 'USD'}, - {label: 'Books', value: 40, currency: 'USD'}, - {label: 'Parking', value: 30, currency: 'USD'}, - {label: 'Tips', value: 20, currency: 'USD'}, - {label: 'Misc', value: 15, currency: 'USD'}, + { label: 'Food & Dining', value: 1200, currency: 'USD' }, + { label: 'Transportation', value: 800, currency: 'USD' }, + { label: 'Utilities', value: 500, currency: 'USD' }, + { label: 'Entertainment', value: 300, currency: 'USD' }, + { label: 'Healthcare', value: 200, currency: 'USD' }, + { label: 'Shopping', value: 450, currency: 'USD' }, + { label: 'Travel', value: 350, currency: 'USD' }, + { label: 'Education', value: 250, currency: 'USD' }, + { label: 'Subscriptions', value: 150, currency: 'USD' }, + { label: 'Gym', value: 80, currency: 'USD' }, + { label: 'Coffee', value: 60, currency: 'USD' }, + { label: 'Books', value: 40, currency: 'USD' }, + { label: 'Parking', value: 30, currency: 'USD' }, + { label: 'Tips', value: 20, currency: 'USD' }, + { label: 'Misc', value: 15, currency: 'USD' }, ]; const TEST_PIE_CHART_WITH_SMALL_SLICES: PieChartDataPoint[] = [ - {label: 'Major Expense', value: 5000, currency: 'USD'}, - {label: 'Medium Expense', value: 1000, currency: 'USD'}, - {label: 'Small 1', value: 50, currency: 'USD'}, - {label: 'Small 2', value: 30, currency: 'USD'}, - {label: 'Tiny 1', value: 10, currency: 'USD'}, - {label: 'Tiny 2', value: 5, currency: 'USD'}, + { label: 'Major Expense', value: 5000, currency: 'USD' }, + { label: 'Medium Expense', value: 1000, currency: 'USD' }, + { label: 'Small 1', value: 50, currency: 'USD' }, + { label: 'Small 2', value: 30, currency: 'USD' }, + { label: 'Tiny 1', value: 10, currency: 'USD' }, + { label: 'Tiny 2', value: 5, currency: 'USD' }, ]; +function TestLineChartWithSlider({ + mainChartData, + mainChartTitle, + mainChartIcon, + isLoading, +}: + { + mainChartData: LineChartDataPoint[]; + mainChartTitle: string; + mainChartIcon: IconAsset + isLoading?: boolean; + }) { + const [pointCount, setPointCount] = React.useState(2); + const styles = useThemeStyles(); + + const testData = React.useMemo(() => { + return Array.from({ length: pointCount }, (_, j) => ({ + label: `Item ${j + 1}`, + total: Math.floor(Math.random() * 900) + 100, + currency: 'USD', + })); + }, [pointCount]); + + return ( + + { + if (!dataPoint.onClickQuery) { + return; + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({ query: dataPoint.onClickQuery })); + }} + isLoading={isLoading} + yAxisUnit="$" + /> + + +