From 7fcae5891cdf2856d2c02161c731f957c0c0fa2f Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 17 Sep 2026 16:49:17 +0200 Subject: [PATCH 1/3] Add horizontal bar chart variant for wide layouts --- .../Charts/BarChart/BarChartContent.tsx | 22 +- .../BarChart/HorizontalBarChartContent.tsx | 371 ++++++++++++++++++ .../Charts/BarChart/index.native.tsx | 19 +- src/components/Charts/BarChart/index.tsx | 12 +- .../Charts/components/ChartYAxisLabels.tsx | 7 +- .../Charts/hooks/useChartInteractions.ts | 14 +- 6 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 src/components/Charts/BarChart/HorizontalBarChartContent.tsx diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 9dec8d814710..134c54e4007e 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -33,6 +33,8 @@ import {Bar, CartesianChart} from 'victory-native'; import type {CartesianChartProps, ChartDataPoint} from '..'; +import HorizontalBarChartContentBody from './HorizontalBarChartContent'; + /** Extra pixel spacing between the chart boundary and the data range, applied per side (Victory's `domainPadding` prop) * We need bottom: 1 for proper display of the bottom label */ @@ -43,9 +45,15 @@ type BarChartProps = CartesianChartProps & { /** When true, all bars use the same color. When false (default), each bar uses a different color from the palette. */ useSingleColor?: boolean; + + /** When true, renders horizontal bars (value on the x-axis) instead of the default vertical bars. */ + isHorizontal?: boolean; }; -function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { +/** Props for an orientation-specific body. Orientation is resolved by the wrapper, so the bodies never receive `isHorizontal`. */ +type BarChartBodyProps = Omit; + +function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartBodyProps) { const theme = useTheme(); const styles = useThemeStyles(); const fontManager = useChartFontManager(); @@ -171,7 +179,7 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l })); const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { - const dataIndex = point.xValue as number; + const dataIndex = Number(point.xValue); const dataPoint = data.at(dataIndex); const barColor = useSingleColor ? defaultBarColor : VictoryTheme.colors.getColor(dataIndex); @@ -291,13 +299,9 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l ); } -function BarChartContent(props: BarChartProps) { - return ( - - - - ); +function BarChartContent({isHorizontal = false, ...props}: BarChartProps) { + return {isHorizontal ? : }; } export default BarChartContent; -export type {BarChartProps}; +export type {BarChartProps, BarChartBodyProps}; diff --git a/src/components/Charts/BarChart/HorizontalBarChartContent.tsx b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx new file mode 100644 index 000000000000..8c8ec0bd03c4 --- /dev/null +++ b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx @@ -0,0 +1,371 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import ChartTooltipLayer from '@components/Charts/components/ChartTooltipLayer'; +import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; +import type {HitTestArgs, ResolveTargetIndexArgs} from '@components/Charts/hooks'; +import {useChartFontManager, useChartInteractions, useChartLabelFormats, useChartParagraphs, useDynamicYDomain} from '@components/Charts/hooks'; +import {findClosestPoint} from '@components/Charts/hooks/useChartInteractions'; +import {calculateMinDomainPadding, getFontLineMetrics, measureTextWidth} from '@components/Charts/utils'; +import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, MAX_Y_AXIS_LABEL_WIDTH} from '@components/Charts/VictoryTheme'; + +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import colors from '@styles/theme/colors'; +import variables from '@styles/variables'; + +import type {NonUniformRRect, SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {LayoutChangeEvent} from 'react-native'; +import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; + +import {Paragraph, Path, Skia} from '@shopify/react-native-skia'; +import React, {useState} from 'react'; +import {View} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; +import {CartesianChart} from 'victory-native'; + +import type {BarChartBodyProps} from './BarChartContent'; + +/** Extra pixel spacing between the chart boundary and the data range. `right` keeps the longest bar's tip and its tooltip off the edge. */ +const BASE_DOMAIN_PADDING = {top: 8, bottom: 8, left: 0, right: 8}; + +/** Gap between the bar tip and the tooltip pointer, lifting the tooltip clear of the bar. */ +const TOOLTIP_TIP_GAP = 16; + +/** Fraction of each row reserved as gap, leaving a thin centered bar (matches the ranking design). */ +const HORIZONTAL_BAR_PADDING = 0.7; + +/** Large corner radius so the bar tip renders as a pill; clamped to half the bar thickness below. */ +const BAR_TIP_RADIUS = 999; + +/** Horizontal gap between the category labels and the bars. Wider than the default axis gap for readability. */ +const CATEGORY_LABEL_GAP = 24; + +/** + * Builds a bar path with only the tip end rounded and the axis end square. + * victory-native's BarGroup keys its corner flip on the y value, which for a horizontal chart is the + * category index (never negative), so it always rounds the right end. We round per bar off the value's + * sign instead: positive bars round the right (tip on the right of the zero axis), negative bars round the left. + */ +function buildHorizontalBarPath(x: number, y: number, width: number, height: number, radius: number, roundedTipOnRight: boolean) { + const cornerRadius = Math.max(0, Math.min(radius, height / 2, width)); + const tip = {x: cornerRadius, y: cornerRadius}; + const flat = {x: 0, y: 0}; + const rrect: NonUniformRRect = { + rect: {x, y, width, height}, + topLeft: roundedTipOnRight ? flat : tip, + topRight: roundedTipOnRight ? tip : flat, + bottomRight: roundedTipOnRight ? tip : flat, + bottomLeft: roundedTipOnRight ? flat : tip, + }; + const path = Skia.Path.Make(); + path.addRRect(rrect); + return path; +} + +type ValueAxisLabelsProps = { + /** Value ticks provided by victory-native for the x-axis. */ + xTicks: number[]; + + /** Maps a value tick to its x-pixel position. */ + xScale: Scale; + + /** Y-pixel coordinate of the bottom edge of the plot area. */ + chartBottom: number; + + /** Font size used for rendering labels. */ + fontSize: number; + + /** Font manager for Paragraph API rendering with multi-font fallback. */ + fontManager: SkTypefaceFontProvider; + + /** Fill color for the label text. */ + labelColor: string; + + /** Formats a numeric value to its display string. */ + formatValue: (value: number) => string; +}; + +/** Renders the value labels below the x-axis for a horizontal bar chart, centered on each tick. */ +function ValueAxisLabels({xTicks, xScale, chartBottom, fontSize, fontManager, labelColor, formatValue}: ValueAxisLabelsProps) { + const formattedLabels = xTicks.map((tick) => formatValue(tick)); + const paragraphs = useChartParagraphs(formattedLabels, fontManager, fontSize, labelColor, MAX_Y_AXIS_LABEL_WIDTH); + // Text is drawn from its top-left, so the label top sits one label gap below the plot's bottom edge. + const labelTop = chartBottom + VictoryTheme.axis.labelGap; + + return xTicks.map((tick, i) => { + const paraData = paragraphs.at(i); + if (!paraData) { + return null; + } + const tickX = xScale(tick); + return ( + + ); + }); +} + +function HorizontalBarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onBarPress}: BarChartBodyProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const fontManager = useChartFontManager(); + const [chartWidth, setChartWidth] = useState(0); + const [barAreaHeight, setBarAreaHeight] = useState(0); + const barColor = colors.blue400; + + // Transpose: value on the x-axis, category index on the y-axis. + // Categories are reversed (index 0 mapped to the top row) so a descending-sorted ranking reads top-to-bottom. + const lastIndex = data.length - 1; + const chartData = data.map((point, index) => ({ + x: point.total, + y: lastIndex - index, + })); + + const valueDomain = useDynamicYDomain(data); + + const {formatValue} = useChartLabelFormats({ + data, + unit: yAxisUnit, + unitPosition: yAxisUnitPosition, + }); + + const handleBarPress = (index: number) => { + if (index < 0 || index >= data.length) { + return; + } + const dataPoint = data.at(index); + if (dataPoint && onBarPress) { + onBarPress(dataPoint, index); + } + }; + + const handleLayout = (event: LayoutChangeEvent) => { + setChartWidth(event.nativeEvent.layout.width); + }; + + const domainPadding = (() => { + if (barAreaHeight === 0) { + return BASE_DOMAIN_PADDING; + } + const verticalPadding = calculateMinDomainPadding(barAreaHeight, data.length, HORIZONTAL_BAR_PADDING); + return {...BASE_DOMAIN_PADDING, top: verticalPadding, bottom: verticalPadding}; + })(); + + const barThickness = useSharedValue(0); + const rowHeight = useSharedValue(0); + const plotLeft = useSharedValue(0); + + const handleChartBoundsChange = (bounds: ChartBounds) => { + const plotHeight = bounds.bottom - bounds.top; + setBarAreaHeight(plotHeight); + plotLeft.set(bounds.left); + barThickness.set(data.length > 0 ? (1 - HORIZONTAL_BAR_PADDING) * (plotHeight / data.length) : 0); + }; + + const checkIsOverBar = (args: HitTestArgs) => { + 'worklet'; + + // Bars are thin, so treat the whole category row inside the plot area as the hover/press target. + // Gate on the plot's left edge (not the zero axis) so bars extending left of zero stay hittable. + const band = rowHeight.get(); + if (band === 0) { + return false; + } + const rowTop = args.targetY - band / 2; + const rowBottom = args.targetY + band / 2; + + return args.cursorX >= plotLeft.get() && args.cursorY >= rowTop && args.cursorY <= rowBottom; + }; + + const resolveTargetIndex = (args: ResolveTargetIndexArgs) => { + 'worklet'; + + // Categories run along the y-axis, so match the nearest point by y instead of the default nearest-by-x. + return findClosestPoint(args.pointY, args.cursorY); + }; + + const resolveTooltipPosition = (targetX: number, targetY: number) => { + 'worklet'; + + // Anchor the tooltip above the bar's tip (the data end: right for positive values, left for negative). + return {x: targetX, y: targetY - barThickness.get() / 2 - TOOLTIP_TIP_GAP}; + }; + + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ + handlePress: handleBarPress, + checkIsOver: checkIsOverBar, + resolveTargetIndex, + resolveTooltipPosition, + }); + + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + const oy = chartData.map((point) => yScale(point.y)); + setPointPositions( + chartData.map((point) => xScale(point.x)), + oy, + ); + + // Derive the hover band from the real center-to-center row spacing. domainPadding compresses the + // rows inward, so areaHeight / count would overestimate the spacing and make adjacent bands overlap. + let minGap = 0; + for (let i = 1; i < oy.length; i++) { + const gap = Math.abs((oy.at(i) ?? 0) - (oy.at(i - 1) ?? 0)); + minGap = minGap === 0 ? gap : Math.min(minGap, gap); + } + rowHeight.set(minGap > 0 ? minGap : Number.MAX_SAFE_INTEGER); + }; + + const cursorStyle = useAnimatedStyle(() => ({ + cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', + })); + + const categoryLabelWidth = (() => { + if (!fontManager || data.length === 0) { + return 0; + } + let widest = 0; + for (const point of data) { + widest = Math.max(widest, measureTextWidth(point.label, fontManager, variables.iconSizeExtraSmall)); + } + return Math.min(MAX_Y_AXIS_LABEL_WIDTH, widest); + })(); + + const {ascent, descent} = fontManager ? getFontLineMetrics(fontManager, variables.iconSizeExtraSmall) : {ascent: 0, descent: 0}; + const valueLabelHeight = Math.abs(ascent) + Math.abs(descent); + const labelSpace = VictoryTheme.axis.labelGap + valueLabelHeight; + + const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + if (!fontManager) { + return null; + } + + const chartBoundsBottom = args.chartBounds.bottom; + + return ( + <> + + point.y)} + yScale={args.yScale} + chartBounds={args.chartBounds} + fontSize={variables.iconSizeExtraSmall} + fontManager={fontManager} + labelColor={theme.textSupporting} + formatValue={(yValue: number) => data.at(lastIndex - yValue)?.label ?? ''} + labelGap={CATEGORY_LABEL_GAP} + leftAlign + /> + + ); + }; + + const dynamicChartStyle = {height: CHART_CONTENT_MIN_HEIGHT + labelSpace}; + const chartPadding = { + ...VictoryTheme.axis.padding, + bottom: labelSpace + VictoryTheme.axis.padding.bottom, + left: categoryLabelWidth + CATEGORY_LABEL_GAP + GLYPH_PADDING, + }; + + // Draw each bar as its own Skia path so the rounded pill sits on the value tip and the axis end stays square, + // for both positive (right-pointing) and negative (left-pointing) bars. thickness mirrors BarGroup's own + // barWidth for a single series: (1 - betweenGroupPadding) * plotHeight / groupCount. + const renderBars = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { + const plotHeight = args.chartBounds.bottom - args.chartBounds.top; + const thickness = data.length > 0 ? (1 - HORIZONTAL_BAR_PADDING) * (plotHeight / data.length) : 0; + if (thickness <= 0) { + return null; + } + const radius = Math.min(BAR_TIP_RADIUS, thickness / 2); + const zeroX = args.xScale(0); + + return args.points.y.map((point, index) => { + if (typeof point.y !== 'number') { + return null; + } + const tipX = point.x; + const roundedTipOnRight = Number(point.xValue) >= 0; + const path = buildHorizontalBarPath(Math.min(tipX, zeroX), point.y - thickness / 2, Math.abs(tipX - zeroX), thickness, radius, roundedTipOnRight); + + return ( + + ); + }); + }; + + if (isLoading || !fontManager) { + return ( + + + + ); + } + + if (data.length === 0) { + return null; + } + + return ( + + + {chartWidth > 0 && ( + + {renderBars} + + )} + + + + ); +} + +export default HorizontalBarChartContentBody; diff --git a/src/components/Charts/BarChart/index.native.tsx b/src/components/Charts/BarChart/index.native.tsx index 8e5ada472b0f..bd13dde17926 100644 --- a/src/components/Charts/BarChart/index.native.tsx +++ b/src/components/Charts/BarChart/index.native.tsx @@ -1,3 +1,8 @@ +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; + import React from 'react'; import type {BarChartProps} from './BarChartContent'; @@ -5,9 +10,17 @@ import type {BarChartProps} from './BarChartContent'; import BarChartContent from './BarChartContent'; function BarChart(props: BarChartProps) { - return ; -} + // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP). + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const isHorizontal = !shouldUseNarrowLayout && isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); -BarChart.displayName = 'BarChart'; + return ( + + ); +} export default BarChart; diff --git a/src/components/Charts/BarChart/index.tsx b/src/components/Charts/BarChart/index.tsx index 1a7d477d6588..d9705f17427b 100644 --- a/src/components/Charts/BarChart/index.tsx +++ b/src/components/Charts/BarChart/index.tsx @@ -1,15 +1,25 @@ import SkiaWebChart from '@components/Charts/SkiaWebChart'; +import usePermissions from '@hooks/usePermissions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; + import React from 'react'; import type {BarChartProps} from './BarChartContent'; const getBarChartContent = () => import('./BarChartContent'); function BarChart(props: BarChartProps) { + // Horizontal bars on wide layouts, vertical on narrow (mobile/RHP). A single lazy module receives orientation as a prop. + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const isHorizontal = !shouldUseNarrowLayout && isBetaEnabled(CONST.BETAS.INSIGHTS_PAGE); + return ( ); } diff --git a/src/components/Charts/components/ChartYAxisLabels.tsx b/src/components/Charts/components/ChartYAxisLabels.tsx index f77ba922f13a..0bbdf5ac061f 100644 --- a/src/components/Charts/components/ChartYAxisLabels.tsx +++ b/src/components/Charts/components/ChartYAxisLabels.tsx @@ -32,9 +32,12 @@ type ChartYAxisLabelsProps = { /** When true, labels are left-aligned starting at the left edge of the chart instead of right-aligned. */ leftAlign?: boolean; + + /** Horizontal gap between the labels and the chart's left edge. Defaults to the shared axis gap. */ + labelGap?: number; }; -function ChartYAxisLabels({yTicks, yScale, chartBounds, fontSize, fontManager, labelColor, formatValue, leftAlign = false}: ChartYAxisLabelsProps) { +function ChartYAxisLabels({yTicks, yScale, chartBounds, fontSize, fontManager, labelColor, formatValue, leftAlign = false, labelGap = VictoryTheme.axis.labelGap}: ChartYAxisLabelsProps) { const formattedLabels = yTicks.map((tick) => formatValue(tick)); const paragraphs = useChartParagraphs(formattedLabels, fontManager, fontSize, labelColor, MAX_Y_AXIS_LABEL_WIDTH); @@ -49,7 +52,7 @@ function ChartYAxisLabels({yTicks, yScale, chartBounds, fontSize, fontManager, l return null; } - const x = chartBounds.left - VictoryTheme.axis.labelGap + GLYPH_PADDING - (leftAlign ? maxWidth : paraData.width); + const x = chartBounds.left - labelGap + GLYPH_PADDING - (leftAlign ? maxWidth : paraData.width); const tickY = yScale(tick); return ( diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 663016c91083..3a8a8d8490fd 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -98,6 +98,13 @@ type UseChartInteractionsProps = { /** Optional shared value containing the y-axis zero position */ yZero?: SharedValue; + /** + * Optional worklet to override the default tooltip anchor. Receives the matched point's canvas + * position and the y-axis zero, and returns the tooltip anchor. Defaults to anchoring above the + * top of a vertical bar. Horizontal bar charts pass this to anchor above the bar's tip instead. + */ + resolveTooltipPosition?: (targetX: number, targetY: number, currentYZero: number) => {x: number; y: number}; + /** Scale applied to the rendered chart container */ coordinateScale?: number; }; @@ -165,6 +172,7 @@ function useChartInteractions({ resolveLabelTouchX, chartBottom, yZero, + resolveTooltipPosition, coordinateScale = 1, }: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ @@ -385,13 +393,17 @@ function useChartInteractions({ * compose them into their own useAnimatedStyle. */ const initialTooltipPosition = useDerivedValue(() => { + const targetX = chartInteractionState.x.position.get(); const targetY = chartInteractionState.y.y.position.get(); const currentYZero = yZero?.get() ?? targetY; + if (resolveTooltipPosition) { + return resolveTooltipPosition(targetX, targetY, currentYZero); + } // Position tooltip at the top of the bar (min of targetY and yZero) const barTopY = Math.min(targetY, currentYZero); return { - x: chartInteractionState.x.position.get(), + x: targetX, y: barTopY - TOOLTIP_BAR_GAP, }; }); From 234788d2e03c6c84fe01d931ff27151f02f1b20b Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 17 Sep 2026 17:03:33 +0200 Subject: [PATCH 2/3] Reduce horizontal bar chart tooltip tip gap --- src/components/Charts/BarChart/HorizontalBarChartContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/BarChart/HorizontalBarChartContent.tsx b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx index 8c8ec0bd03c4..17373be9ce81 100644 --- a/src/components/Charts/BarChart/HorizontalBarChartContent.tsx +++ b/src/components/Charts/BarChart/HorizontalBarChartContent.tsx @@ -30,7 +30,7 @@ import type {BarChartBodyProps} from './BarChartContent'; const BASE_DOMAIN_PADDING = {top: 8, bottom: 8, left: 0, right: 8}; /** Gap between the bar tip and the tooltip pointer, lifting the tooltip clear of the bar. */ -const TOOLTIP_TIP_GAP = 16; +const TOOLTIP_TIP_GAP = 4; /** Fraction of each row reserved as gap, leaving a thin centered bar (matches the ranking design). */ const HORIZONTAL_BAR_PADDING = 0.7; From b8bee3e2e7e7fdb61e0cf411b9a328ea719e0ef7 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 17 Sep 2026 17:06:14 +0200 Subject: [PATCH 3/3] Add rrect to cspell dictionary --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index f49f431b9d67..2f7709837f13 100644 --- a/cspell.json +++ b/cspell.json @@ -950,6 +950,7 @@ "resultsbox", "retryable", "rideshare", + "rrect", "Rightworks", "RNCORE", "RNFS",