From 611c387f757d35c0b8fd236ffa4fa50016f2cf20 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 20 Jan 2026 11:26:05 +0100 Subject: [PATCH 01/10] add line chart --- src/components/ChartKit/ChartContent.tsx | 33 +- .../Charts/BarChart/BarChartContent.tsx | 288 +++++----- src/components/Charts/ChartTooltip.tsx | 2 +- .../Charts/LineChart/LineChartContent.tsx | 168 ++++++ .../Charts/LineChart/index.native.tsx | 12 + src/components/Charts/LineChart/index.tsx | 25 + src/components/Charts/hooks/index.ts | 5 +- .../Charts/hooks/useChartLabelLayout.ts | 145 +++++ src/components/Charts/index.ts | 3 +- src/components/Charts/types.ts | 33 +- src/components/Search/index.tsx | 522 ++++++++++-------- src/languages/en.ts | 5 + src/pages/Search/SearchPageWide.tsx | 26 +- src/styles/index.ts | 135 +++-- 14 files changed, 930 insertions(+), 472 deletions(-) create mode 100644 src/components/Charts/LineChart/LineChartContent.tsx create mode 100644 src/components/Charts/LineChart/index.native.tsx create mode 100644 src/components/Charts/LineChart/index.tsx create mode 100644 src/components/Charts/hooks/useChartLabelLayout.ts diff --git a/src/components/ChartKit/ChartContent.tsx b/src/components/ChartKit/ChartContent.tsx index 1cd64770e1ed..eac33d282dc8 100644 --- a/src/components/ChartKit/ChartContent.tsx +++ b/src/components/ChartKit/ChartContent.tsx @@ -1,31 +1,32 @@ -import React, {useState} from 'react'; -import type {LayoutChangeEvent} from 'react-native'; -import {View} from 'react-native'; -import {CartesianChart, Line} from 'victory-native'; +import React, { useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { View } from 'react-native'; +import { CartesianChart, Line, Scatter } from 'victory-native'; +import LineChart from './LineChart'; 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}, + { 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 [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const handleLayout = (event: LayoutChangeEvent) => { - const {width, height} = event.nativeEvent.layout; + const { width, height } = event.nativeEvent.layout; if (width > 0 && height > 0) { - setDimensions({width, height}); + setDimensions({ width, height }); } }; return ( {dimensions && ( @@ -34,7 +35,7 @@ function ChartContent() { xKey="x" yKeys={['y']} > - {({points}) => ( + {({ points }) => ( ): number { - if (!fontInstance) { - return 0; - } - const glyphIDs = fontInstance.getGlyphIDs(text); - const glyphWidths = fontInstance.getGlyphWidths(glyphIDs); - return glyphWidths.reduce((sum, w) => sum + w, 0); -} +// /** +// * 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 = { @@ -74,19 +74,19 @@ type CartesianActionsHandle = { 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 { 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; + const { width, height } = event.nativeEvent.layout; setChartWidth(width); setContainerHeight(height); }, []); @@ -100,115 +100,123 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl const domainPadding = useMemo(() => { if (chartWidth === 0) { - return {left: 0, right: 0, top: DOMAIN_PADDING.top, bottom: DOMAIN_PADDING.bottom}; + 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}; + 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; - } - 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; - } - } - - // === 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}; - }, [font, chartWidth, containerHeight, data]); + // 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; + // } + // 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; + // } + // } + + // // === 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}; + // }, [font, chartWidth, containerHeight, data]); + + + const { labelRotation, labelSkipInterval, truncatedLabels } = useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, + }); const formatYAxisLabel = useCallback( (value: number) => { @@ -235,7 +243,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl const [isOverBar, setIsOverBar] = useState(false); // 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) => { @@ -252,7 +260,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl // 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 { barWidth, chartBottom } = barGeometry.get(); const cursorX = chartInteractionState.cursor.x.get(); const cursorY = chartInteractionState.cursor.y.get(); @@ -304,7 +312,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl position: 'absolute', left: chartInteractionState.x.position.get(), top: chartInteractionState.y.y.position.get() - TOOLTIP_BAR_GAP, - transform: [{translateX: '-50%'}, {translateY: '-100%'}], + transform: [{ translateX: '-50%' }, { translateY: '-100%' }], opacity: chartInteractionState.isActive.get() ? 1 : 0, }; }); @@ -361,7 +369,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, useSingl const matchedIndex = chartInteractionState.matchedIndex.value; // Check if tap is over the bar (not just nearest) - const {barWidth, chartBottom} = barGeometry.value; + const { barWidth, chartBottom } = barGeometry.value; const barCenterX = chartInteractionState.x.position.value; const barTop = chartInteractionState.y.y.position.value; const barLeft = barCenterX - barWidth / 2; @@ -464,10 +472,10 @@ 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))} )} 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..d7437298a927 --- /dev/null +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -0,0 +1,168 @@ +import { useFont } from '@shopify/react-native-skia'; +import React, { useCallback, useMemo, useState } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import { View } from 'react-native'; +import { CartesianChart, Line, Scatter, useChartPressState } from 'victory-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import colors from '@styles/theme/colors'; +import variables from '@styles/variables'; +import type { LineChartProps } from '@components/Charts/types'; +import { useChartLabelLayout } from '@components/Charts/hooks'; +import { CHART_PADDING, Y_AXIS_DOMAIN, Y_AXIS_LABEL_OFFSET, Y_AXIS_TICK_COUNT } from '@components/Charts/constants'; + +const data1 = [ + // Generated points + ...Array.from({ length: 24 }, (_, i) => { + const x = i; + const y = 30 + 22 * Math.sin(x / 5) + Math.random() * 10; // Simulated variation + return { x, y: Math.round(y * 34) }; + }), +]; + + +const ticks = [0, 500, 1000, 1500, 2000] + + +function LineChart({ data, title, titleIcon, isLoading, onPointPress, yAxisUnit }: LineChartProps) { + + const [chartWidth, setChartWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const { translate } = useLocalize(); + + const { state, isActive } = useChartPressState({ x: 0, y: { y: 0 } }); + + const handleLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setChartWidth(width); + setContainerHeight(height); + }; + /** Expensify Neue font path for web builds */ + const EXPENSIFY_NEUE_FONT_URL = '/fonts/ExpensifyNeue-Regular.woff'; + + const styles = useThemeStyles(); + const theme = useTheme(); + const font = useFont(EXPENSIFY_NEUE_FONT_URL, 13); + const CHART_COLORS = [colors.yellow400, colors.tangerine400, colors.pink400, colors.green400, colors.ice400]; + const formatYaxisLabel = (value: number) => { + return yAxisUnit ? `${yAxisUnit} ${value}` : value.toString(); + }; + + const chartData = useMemo(() => { + return data.map((point, index) => ({ + x: index, + y: point.total, + })); + }, [data]); + + const { labelRotation, labelSkipInterval, truncatedLabels } = useChartLabelLayout({ + data, + font, + chartWidth, + containerHeight, + }); + + const formatYAxisLabel = useCallback((value: number) => { + const formatted = value.toLocaleString(); + return yAxisUnit ? `${yAxisUnit} ${formatted}` : formatted; + }, [yAxisUnit]); + + const formatXAxisLabel = useCallback((value: number) => { + console.log('value', value, 'labelSkipInterval', labelSkipInterval, 'truncatedLabels', truncatedLabels); + const index = Math.round(value); + if (index % labelSkipInterval !== 0) { + return ''; + } + console.log('truncatedLabels.at(index)', truncatedLabels.at(index)); + return truncatedLabels.at(index) ?? ''; + }, [truncatedLabels, labelSkipInterval]); + + + console.log('chartData', chartData); + // todo add ticks values computation based on the data + + return ( + + + + {translate('search.charts.line.spendOverTime')} + + + {chartWidth > 0 && ( + + {({ points }) => ( + <> + + {points.y.map((point) => { + const color = CHART_COLORS.at(4); + return ( + <> + + + + ); + })} + + )} + + )} + + + ); +} + +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..bc476d51318b --- /dev/null +++ b/src/components/Charts/LineChart/index.tsx @@ -0,0 +1,25 @@ +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'; + +function LineChart(props: LineChartProps) { + return ( + `/${file}` }} + getComponent={() => import('./LineChartContent')} + componentProps={props} + fallback={ + + + + } + /> + ); +} + +LineChart.displayName = 'LineChart'; + +export default LineChart; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 81e31ac56d3d..71e01f56a068 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -1,2 +1,3 @@ -export {useChartInteractionState} from './useChartInteractionState'; -export type {ChartInteractionState, ChartInteractionStateInit} from './useChartInteractionState'; +export { useChartInteractionState } from './useChartInteractionState'; +export { useChartLabelLayout } from './useChartLabelLayout'; +export type { ChartInteractionState, ChartInteractionStateInit } from './useChartInteractionState'; diff --git a/src/components/Charts/hooks/useChartLabelLayout.ts b/src/components/Charts/hooks/useChartLabelLayout.ts new file mode 100644 index 000000000000..e3605919b020 --- /dev/null +++ b/src/components/Charts/hooks/useChartLabelLayout.ts @@ -0,0 +1,145 @@ +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]: 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. + */ +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 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; + } + 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; + } + } + + // === 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 }; + + }, [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..5e13bfd2b04e 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..801a83abded9 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,133 +66,166 @@ 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 styles from '@styles/index'; // 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: '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' }, ]; 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, + isLoading, +}: + { + mainChartData: LineChartDataPoint[]; + mainChartTitle?: string; + 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 ( + + + + ); +} // Test component for bar chart with controls function TestBarChartWithSlider({ mainChartData, @@ -207,12 +240,12 @@ function TestBarChartWithSlider({ const styles = useThemeStyles(); const testData = React.useMemo(() => { - return Array.from({length: barCount}, (_, j) => ({ + return Array.from({ length: barCount }, (_, j) => ({ label: `Item ${j + 1}`, total: Math.floor(Math.random() * 900) + 100, currency: 'USD', })); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [barCount]); return ( @@ -227,17 +260,17 @@ function TestBarChartWithSlider({ if (!dataPoint.onClickQuery) { return; } - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: dataPoint.onClickQuery})); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({ query: dataPoint.onClickQuery })); }} /> - - + +