From 708a33ebd455709881c299029da84a33674ddfbd Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 14:25:04 +0100 Subject: [PATCH 01/20] Add chart point metadata --- .../HTMLRenderers/VictoryChartRenderer/types.ts | 12 ++++++++++++ .../utils/getChartPointMetadataKey.ts | 7 +++++++ .../VictoryChartRenderer/utils/parseRawChartData.ts | 11 +++++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 1084bcec1b5e..0916915bb2e8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -11,6 +11,8 @@ type VictoryChartRendererProps = CustomRendererProps; type RawChartData = { x: string | number; y: number; + label?: string; + searchQuery?: string; }; type RawLegendData = { @@ -64,6 +66,13 @@ type CartesianChartData = { [key: `${YKey}`]: number; }; +type ChartPointMetadata = { + label?: string; + searchQuery?: string; +}; + +type ChartPointMetadataByYKey = Partial>>; + type PolarChartData = { [LABEL_KEY]: string | number; [VALUE_KEY]: number; @@ -167,6 +176,7 @@ type ProcessNodeResult = { categories: string[] | undefined; labelItems: LabelItem[]; legendItems: LegendItem[]; + pointMetadata: ChartPointMetadataByYKey; }; /** Partial slice produced by a single per-tag parser before merging. */ @@ -186,6 +196,8 @@ export type { RawShiftedLineSegmentStyle, YKey, CartesianChartData, + ChartPointMetadata, + ChartPointMetadataByYKey, CartesianChartProps, TextAnchor, LabelItem, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey.ts new file mode 100644 index 000000000000..4d2fa3a1861a --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey.ts @@ -0,0 +1,7 @@ +import type {RawChartData} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +function getChartPointMetadataKey(xValue: RawChartData['x']): string { + return `${typeof xValue}:${String(xValue)}`; +} + +export default getChartPointMetadataKey; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts index a411f16a6c40..6ddb5f0efdfa 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts @@ -16,10 +16,17 @@ function parseRawChartData(attribute: string): RawChartData[] { 'y' in parsedData && (typeof parsedData.y === 'string' || typeof parsedData.y === 'number') ) { - data.push({ + const rawChartData: RawChartData = { x: parsedData.x, y: Number(parsedData.y), - }); + }; + if ('label' in parsedData && typeof parsedData.label === 'string') { + rawChartData.label = parsedData.label; + } + if ('searchQuery' in parsedData && typeof parsedData.searchQuery === 'string') { + rawChartData.searchQuery = parsedData.searchQuery; + } + data.push(rawChartData); } } } From 6f8b2db73810eceb64a44c1d04bce705c6864426 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 14:25:18 +0100 Subject: [PATCH 02/20] Thread chart point metadata --- .../context/VictoryChartContext.tsx | 4 ++- .../parsers/processVictoryChartTree.ts | 23 ++++++++++++++-- .../parsers/victorySeriesParser.ts | 27 ++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index 91eb97fbc14c..54b1b2341b4a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -19,6 +19,7 @@ type VictoryChartContextValue = { categories: ProcessNodeResult['categories']; labelItems: ProcessNodeResult['labelItems']; legendItems: ProcessNodeResult['legendItems']; + pointMetadata: ProcessNodeResult['pointMetadata']; chartContentStyles: ReturnType['nodeStyles']; chartContainerStyles: ReturnType['parentNodeStyles']; type: ChartType; @@ -35,7 +36,7 @@ type VictoryChartProviderProps = { /** Supplies parsed chart config to chart sub-components. Callers must parse and validate the tnode first. */ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryChartProviderProps) { - const {data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems} = processedResult; + const {data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems, pointMetadata} = processedResult; const {nodeStyles: chartContentStyles, parentNodeStyles: chartContainerStyles} = parseStyles(tnode); const contextValue: VictoryChartContextValue = { @@ -52,6 +53,7 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC categories, labelItems, legendItems, + pointMetadata, chartContentStyles, chartContainerStyles, type, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts index c2465d422cc6..12bdc4ea485f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts @@ -24,6 +24,7 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root let categories: ProcessNodeResult['categories']; const labelItems: ProcessNodeResult['labelItems'] = []; const legendItems: ProcessNodeResult['legendItems'] = []; + const pointMetadata: ProcessNodeResult['pointMetadata'] = {}; const parser = PARSER_REGISTRY[tnode.tagName ?? '']; if (parser) { @@ -61,10 +62,27 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root if (result.legendItems) { legendItems.push(...result.legendItems); } + if (result.pointMetadata) { + lodashMerge(pointMetadata, result.pointMetadata); + } } // If we have `rootProcessedResult` then forward it as is, otherwise we must be the root so pass the data that we just built - const rootProcessedNodeResult = rootProcessedResult ?? {data, xKey: X_KEY, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems}; + const rootProcessedNodeResult = rootProcessedResult ?? { + data, + xKey: X_KEY, + yKeys, + xAxis, + yAxis, + domain, + domainPadding, + padding, + isHorizontal, + categories, + labelItems, + legendItems, + pointMetadata, + }; for (const child of tnode.children) { const childResult = processVictoryChartTree(child, typeface, rootProcessedNodeResult); @@ -93,9 +111,10 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root } labelItems.push(...childResult.labelItems); legendItems.push(...childResult.legendItems); + lodashMerge(pointMetadata, childResult.pointMetadata); } - return {data, xKey: X_KEY, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems}; + return {data, xKey: X_KEY, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems, pointMetadata}; } export default processVictoryChartTree; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts index 628c2002730d..cda72af4e849 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts @@ -1,6 +1,8 @@ import {X_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; -import type {CartesianChartData, PartialProcessNodeResult, ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import type {CartesianChartData, ChartPointMetadata, PartialProcessNodeResult, ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import getChartPointMetadataKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; +import {parseAttributeAsStringArray} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; import parseRawChartData from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData'; import resolveCategoryIndex from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveCategoryIndex'; @@ -17,7 +19,26 @@ function parseVictorySeriesNode(tnode: TNode, typeface: SkTypeface | null, rootP const points = parseRawChartData(tnode.attributes.data); const yKey = getYKey(tnode); const data: Record = {}; - for (const point of points) { + const pointMetadata: ProcessNodeResult['pointMetadata'] = {}; + const labels = parseAttributeAsStringArray(tnode.attributes.labels); + + for (const [index, point] of points.entries()) { + const metadata: ChartPointMetadata = {}; + const fallbackLabel = labels?.at(index); + if (point.label) { + metadata.label = point.label; + } else if (fallbackLabel) { + metadata.label = fallbackLabel; + } + if (point.searchQuery) { + metadata.searchQuery = point.searchQuery; + } + if (metadata.label || metadata.searchQuery) { + pointMetadata[yKey] = { + ...pointMetadata[yKey], + [getChartPointMetadataKey(point.x)]: metadata, + }; + } if (isHorizontal) { // Even though the X-Axis is going to hold the y values on horizontal mode, it's not the independent axis // thus we cannot use `point.y` as the key since two points can have the same y value. @@ -32,7 +53,7 @@ function parseVictorySeriesNode(tnode: TNode, typeface: SkTypeface | null, rootP } as CartesianChartData; } } - return {data, yKeys: [yKey]}; + return {data, yKeys: [yKey], pointMetadata}; } export default parseVictorySeriesNode; From 7eb6a1d8b976732f2365e1e2df03f0c8a5173e10 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 14:25:30 +0100 Subject: [PATCH 03/20] Add bar interaction hook --- .../hooks/useVictoryBarInteractions.ts | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts new file mode 100644 index 000000000000..8391e5a34677 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -0,0 +1,262 @@ +import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; +import {TOOLTIP_BAR_GAP} from '@components/Charts/hooks'; +import useChartInteractionState from '@components/Charts/hooks/useChartInteractionState'; +import {X_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import getChartPointMetadataKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey'; +import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; +import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; +import type {SearchQueryString} from '@components/Search/types'; + +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import type {TNode} from 'react-native-render-html'; +import type {CartesianChartRenderArg} from 'victory-native'; + +import {useMemo, useState} from 'react'; +import {Platform} from 'react-native'; +import {Gesture} from 'react-native-gesture-handler'; +import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; + +const SHOULD_SHOW_TOOLTIPS = Platform.OS === 'web'; + +type BarSeriesConfig = Partial>; + +type InteractiveBar = { + xValue: CartesianChartData[typeof X_KEY]; + yKey: YKey; + label: string; + searchQuery?: string; + barWidth?: number; +}; + +function collectVerticalBarSeries(children: readonly TNode[]): BarSeriesConfig { + const config: BarSeriesConfig = {}; + + const visit = (node: TNode) => { + if (node.tagName === 'victorybar') { + config[getYKey(node)] = {barWidth: parseAttributeAsNumber(node.attributes.barwidth)}; + return; + } + if (node.tagName === 'victorygroup' && !('horizontal' in node.attributes)) { + for (const child of node.children) { + visit(child); + } + } + }; + + for (const child of children) { + visit(child); + } + + return config; +} + +function buildInteractiveBars(rows: CartesianChartData[], yKeys: YKey[], barSeriesConfig: BarSeriesConfig, pointMetadata: ReturnType['pointMetadata']) { + const interactiveBars: InteractiveBar[] = []; + + for (const row of rows) { + const xValue = row[X_KEY]; + for (const yKey of yKeys) { + if (!(yKey in barSeriesConfig) || typeof row[yKey] !== 'number') { + continue; + } + + const metadata = pointMetadata[yKey]?.[getChartPointMetadataKey(xValue)]; + if (!metadata?.label && !metadata?.searchQuery) { + continue; + } + + interactiveBars.push({ + xValue, + yKey, + label: metadata.label ?? String(xValue), + searchQuery: metadata.searchQuery, + barWidth: barSeriesConfig[yKey]?.barWidth, + }); + } + } + + return interactiveBars; +} + +function useVictoryBarInteractions() { + const {tnode, data, yKeys, pointMetadata, isHorizontal} = useVictoryChartContext(); + const rows = useMemo(() => Object.values(data) as CartesianChartData[], [data]); + const barSeriesConfig = useMemo(() => (isHorizontal ? {} : collectVerticalBarSeries(tnode.children)), [isHorizontal, tnode.children]); + const interactiveBars = useMemo(() => buildInteractiveBars(rows, yKeys, barSeriesConfig, pointMetadata), [barSeriesConfig, pointMetadata, rows, yKeys]); + const {state} = useChartInteractionState(); + const pointX = useSharedValue([]); + const pointY = useSharedValue([]); + const pointWidth = useSharedValue([]); + const hasSearchQuery = useSharedValue([]); + const chartBottom = useSharedValue(0); + + const findMatchedBar = (cursorX: number, cursorY: number) => { + 'worklet'; + + const xs = pointX.get(); + const ys = pointY.get(); + const widths = pointWidth.get(); + const bottom = chartBottom.get(); + let bestIndex = -1; + let bestDistance = Infinity; + + for (let i = 0; i < xs.length; i++) { + const width = widths.at(i) ?? 0; + if (width <= 0) { + continue; + } + + const targetX = xs.at(i) ?? 0; + const targetY = ys.at(i) ?? 0; + const barLeft = targetX - width / 2; + const barRight = targetX + width / 2; + const barTop = Math.min(targetY, bottom); + const barBottom = Math.max(targetY, bottom); + const isHit = cursorX >= barLeft && cursorX <= barRight && cursorY >= barTop && cursorY <= barBottom; + + if (!isHit) { + continue; + } + + const distance = Math.abs(cursorX - targetX); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + + return bestIndex; + }; + + const applyMatch = (index: number) => { + 'worklet'; + + state.matchedIndex.set(index); + if (index < 0) { + return; + } + + state.x.position.set(pointX.get().at(index) ?? 0); + state.x.value.set(index); + state.y.y.position.set(pointY.get().at(index) ?? 0); + }; + + const handlePress = (index: number) => { + const searchQuery = interactiveBars.at(index)?.searchQuery; + if (!searchQuery) { + return; + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery as SearchQueryString})); + }; + + const hoverGesture = Gesture.Hover() + .onBegin((event) => { + 'worklet'; + + state.isActive.set(true); + state.cursor.x.set(event.x); + state.cursor.y.set(event.y); + applyMatch(findMatchedBar(event.x, event.y)); + }) + .onUpdate((event) => { + 'worklet'; + + state.cursor.x.set(event.x); + state.cursor.y.set(event.y); + applyMatch(findMatchedBar(event.x, event.y)); + }) + .onEnd(() => { + 'worklet'; + + state.isActive.set(false); + state.matchedIndex.set(-1); + }); + + const tapGesture = Gesture.Tap().onEnd((event) => { + 'worklet'; + + state.cursor.x.set(event.x); + state.cursor.y.set(event.y); + const index = findMatchedBar(event.x, event.y); + applyMatch(index); + if (index >= 0 && (hasSearchQuery.get().at(index) ?? 0) === 1) { + scheduleOnRN(handlePress, index); + } + }); + + const customGestures = Gesture.Race(hoverGesture, tapGesture); + + const isTooltipActive = useDerivedValue(() => SHOULD_SHOW_TOOLTIPS && state.isActive.get() && state.matchedIndex.get() >= 0); + + const isCursorOverClickable = useDerivedValue(() => { + const index = state.matchedIndex.get(); + return index >= 0 && (hasSearchQuery.get().at(index) ?? 0) === 1; + }); + + const initialTooltipPosition = useDerivedValue(() => { + const index = state.matchedIndex.get(); + if (index < 0) { + return {x: 0, y: 0}; + } + + const targetY = state.y.y.position.get(); + return { + x: state.x.position.get(), + y: Math.min(targetY, chartBottom.get()) - TOOLTIP_BAR_GAP, + }; + }); + + const [activeBarIndex, setActiveBarIndex] = useState(-1); + useAnimatedReaction( + () => state.matchedIndex.get(), + (index) => { + scheduleOnRN(setActiveBarIndex, index); + }, + ); + + const handleRender = (renderArgs: CartesianChartRenderArg) => { + const {points, chartBounds} = renderArgs; + const xs: number[] = []; + const ys: number[] = []; + const widths: number[] = []; + const searchQueryFlags: number[] = []; + + for (const bar of interactiveBars) { + const point = points[bar.yKey]?.find((candidate) => candidate.xValue === bar.xValue); + if (!point || typeof point.y !== 'number') { + continue; + } + + const seriesPointCount = points[bar.yKey]?.length ?? 0; + const fallbackBarWidth = seriesPointCount > 0 ? ((1 - BAR_INNER_PADDING) * (chartBounds.right - chartBounds.left)) / seriesPointCount : 0; + xs.push(point.x); + ys.push(point.y); + widths.push(bar.barWidth ?? fallbackBarWidth); + searchQueryFlags.push(bar.searchQuery ? 1 : 0); + } + + pointX.set(xs); + pointY.set(ys); + pointWidth.set(widths); + hasSearchQuery.set(searchQueryFlags); + chartBottom.set(chartBounds.bottom); + }; + + return { + customGestures, + handleRender, + activeLabel: activeBarIndex >= 0 ? interactiveBars.at(activeBarIndex)?.label : undefined, + hasTooltipLabels: SHOULD_SHOW_TOOLTIPS && interactiveBars.some((bar) => !!bar.label), + isTooltipActive, + isCursorOverClickable, + initialTooltipPosition, + }; +} + +export default useVictoryBarInteractions; From 8b8ee4c3537da4a49362a265ce2dc14c3d0f514a Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 14:25:46 +0100 Subject: [PATCH 04/20] Wire bar chart clicks --- .../Charts/components/ChartTooltip.tsx | 2 +- .../components/VictoryChartCartesian.tsx | 162 ++++++++++++------ 2 files changed, 110 insertions(+), 54 deletions(-) diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index d37840f2a681..c3beb32412b5 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -34,7 +34,7 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi /** Shared value to store the measured width of the tooltip container */ const tooltipMeasuredWidth = useSharedValue(0); - const content = percentage ? `${label} • ${amount} (${percentage})` : `${label} • ${amount}`; + const content = percentage && amount ? `${label} • ${amount} (${percentage})` : amount ? `${label} • ${amount}` : label; /** * Synchronously reset the width and hide the tooltip whenever the content changes. diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 6ab55eb77307..3096666d8ffc 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -1,6 +1,8 @@ +import ChartTooltip from '@components/Charts/components/ChartTooltip'; import ChartFontsLoaderProvider from '@components/Charts/context/ChartFontsLoaderProvider'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {VictoryChartRenderArgsProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; +import useVictoryBarInteractions from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions'; import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; @@ -11,7 +13,11 @@ import useTheme from '@hooks/useTheme'; import ThemeContext from '@styles/theme/context/ThemeContext'; -import React from 'react'; +import type {LayoutChangeEvent} from 'react-native'; + +import React, {useState} from 'react'; +import {StyleSheet} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import {CartesianChart} from 'victory-native'; import VictoryChartLabel from './VictoryChartLabel'; @@ -32,6 +38,25 @@ function VictoryChartCartesian({explicitSize, headless}: VictoryChartCartesianPr const theme = useTheme(); const timezone = useCurrentTimezone(); const designWidth = getChartDesignWidth(explicitSize, chartContentStyles.width); + const [chartWidth, setChartWidth] = useState(designWidth ?? 0); + const {customGestures, handleRender, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); + + const handleLayout = (event: LayoutChangeEvent) => { + setChartWidth(event.nativeEvent.layout.width); + }; + + const cursorStyle = useAnimatedStyle(() => ({ + cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', + })); + + const tooltipWrapperStyle = useAnimatedStyle(() => ({ + bottom: 0, + left: 0, + opacity: isTooltipActive.get() ? 1 : 0, + position: 'absolute', + right: 0, + top: 0, + })); const resolvedXAxis = xAxis ? { @@ -47,61 +72,92 @@ function VictoryChartCartesian({explicitSize, headless}: VictoryChartCartesianPr })); return ( - { - const overlayContent = ( - - {labelItems.map((labelItem) => ( - - ))} - {legendItems.map((legendItem) => ( - - ))} - - ); - - if (headless) { - return {overlayContent}; - } - - // React context does not propagate across the Skia renderOutside boundary. - return ( - - {overlayContent} - - ); - }} + - {(renderArgs) => ( - - {tnode.children.map((child) => ( - - ))} - + { + const overlayContent = ( + + {labelItems.map((labelItem) => ( + + ))} + {legendItems.map((legendItem) => ( + + ))} + + ); + + if (headless) { + return {overlayContent}; + } + + // React context does not propagate across the Skia renderOutside boundary. + return ( + + {overlayContent} + + ); + }} + > + {(renderArgs) => { + handleRender(renderArgs); + + return ( + + {tnode.children.map((child) => ( + + ))} + + ); + }} + + {!!activeLabel && hasTooltipLabels && chartWidth > 0 && ( + + + )} - + ); } +const styles = StyleSheet.create({ + container: { + height: '100%', + position: 'relative', + width: '100%', + }, +}); + export default VictoryChartCartesian; From 39f52b289d46422bab739b1cb4b1888943d1c646 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 14:26:04 +0100 Subject: [PATCH 05/20] Test chart point metadata --- .../VictoryChartAttributesParsers.test.ts | 14 ++++- .../VictoryChartParsers.test.ts | 59 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts index d55b9e4eefa5..1d01cb72d22c 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts @@ -12,12 +12,24 @@ describe('parseRawChartData', () => { }); it('skips invalid entries and properties', () => { - const result = parseRawChartData("[null, 5, 'x', {name: 'A'}, {x: 'test'}, {x: 'abc', y: '10'}, {x: 'water', y: 15}]"); + const result = parseRawChartData("[null, 5, 'x', {name: 'A'}, {x: 'test'}, {x: 'abc', y: '10'}, {x: 'water', y: 15, label: 5, searchQuery: {}}]"); expect(result).toEqual([ {x: 'abc', y: 10}, {x: 'water', y: 15}, ]); }); + + it('preserves supported point metadata', () => { + const result = parseRawChartData("[{x: 1, y: 20, label: 'Jan 2026: $20', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"); + expect(result).toEqual([ + { + x: 1, + y: 20, + label: 'Jan 2026: $20', + searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01', + }, + ]); + }); }); describe('parseRawLabelStyle', () => { diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts index 93247f38ac01..ac609cfc9bb3 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts @@ -7,7 +7,11 @@ import parseVictorySeriesNode from '@components/HTMLEngineProvider/HTMLRenderers import type {TNode} from 'react-native-render-html'; function createNode(tagName: string, attributes: Record = {}, children: TNode[] = []): TNode { - return {tagName, attributes, children} as unknown as TNode; + const node = {tagName, attributes, children, nodeIndex: 0} as unknown as TNode; + children.forEach((child, index) => { + Object.assign(child, {parent: node, nodeIndex: index}); + }); + return node; } // Each value parses to a non-array (raw string, number, object) that the old `?? []` guard let through. @@ -41,6 +45,59 @@ describe('victorySeriesParser', () => { const result = parseVictorySeriesNode(node, null, null); expect(Object.keys(result.data ?? {})).toEqual(['Jan']); }); + + it('preserves point metadata by series and x value', () => { + const node = createNode('victorybar', {data: "[{x: 1, y: 10, label: 'Jan 2026: $10', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"}); + const result = parseVictorySeriesNode(node, null, null); + + expect(result.pointMetadata).toEqual({ + y0: { + 'number:1': { + label: 'Jan 2026: $10', + searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01', + }, + }, + }); + }); + + it('uses labels attribute as fallback metadata', () => { + const node = createNode('victorybar', { + data: "[{x: 1, y: 10}, {x: 2, y: 20, label: 'Custom Feb'}]", + labels: "['Fallback Jan', 'Fallback Feb']", + }); + const result = parseVictorySeriesNode(node, null, null); + + expect(result.pointMetadata).toEqual({ + y0: { + 'number:1': { + label: 'Fallback Jan', + }, + 'number:2': { + label: 'Custom Feb', + }, + }, + }); + }); + + it('keeps metadata for separate series with the same x value', () => { + const firstNode = createNode('victorybar', {data: "[{x: 1, y: 10, label: 'Current Jan'}]"}); + const secondNode = createNode('victorybar', {data: "[{x: 1, y: 20, label: 'Prior Jan'}]"}); + const tree = createNode('victorychart', {}, [firstNode, secondNode]); + const result = processVictoryChartTree(tree, null, null); + + expect(result.pointMetadata).toEqual({ + 'y0-0': { + 'number:1': { + label: 'Current Jan', + }, + }, + 'y0-1': { + 'number:1': { + label: 'Prior Jan', + }, + }, + }); + }); }); describe('victoryPieParser', () => { From 58e3bad6b080e329f961c351b99410633904cf10 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 15:47:33 +0100 Subject: [PATCH 06/20] Split chart interactivity --- .../components/VictoryChartCartesian.tsx | 174 +++++++----------- .../VictoryChartCartesianInteractive.tsx | 71 +++++++ 2 files changed, 135 insertions(+), 110 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 3096666d8ffc..a8f93847a83e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -1,8 +1,7 @@ -import ChartTooltip from '@components/Charts/components/ChartTooltip'; import ChartFontsLoaderProvider from '@components/Charts/context/ChartFontsLoaderProvider'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {VictoryChartRenderArgsProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; -import useVictoryBarInteractions from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions'; +import type {CartesianChartData, CartesianChartProps, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; @@ -13,11 +12,9 @@ import useTheme from '@hooks/useTheme'; import ThemeContext from '@styles/theme/context/ThemeContext'; -import type {LayoutChangeEvent} from 'react-native'; +import type {CartesianChartRenderArg} from 'victory-native'; -import React, {useState} from 'react'; -import {StyleSheet} from 'react-native'; -import Animated, {useAnimatedStyle} from 'react-native-reanimated'; +import React from 'react'; import {CartesianChart} from 'victory-native'; import VictoryChartLabel from './VictoryChartLabel'; @@ -27,36 +24,19 @@ import VictoryChartSeries from './VictoryChartSeries'; type VictoryChartCartesianProps = { explicitSize?: {width: number; height: number}; headless?: boolean; + customGestures?: CartesianChartProps['customGestures']; + onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; /** * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, customGestures, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); const designWidth = getChartDesignWidth(explicitSize, chartContentStyles.width); - const [chartWidth, setChartWidth] = useState(designWidth ?? 0); - const {customGestures, handleRender, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); - - const handleLayout = (event: LayoutChangeEvent) => { - setChartWidth(event.nativeEvent.layout.width); - }; - - const cursorStyle = useAnimatedStyle(() => ({ - cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', - })); - - const tooltipWrapperStyle = useAnimatedStyle(() => ({ - bottom: 0, - left: 0, - opacity: isTooltipActive.get() ? 1 : 0, - position: 'absolute', - right: 0, - top: 0, - })); const resolvedXAxis = xAxis ? { @@ -72,92 +52,66 @@ function VictoryChartCartesian({explicitSize, headless}: VictoryChartCartesianPr })); return ( - { + const overlayContent = ( + + {labelItems.map((labelItem) => ( + + ))} + {legendItems.map((legendItem) => ( + + ))} + + ); + + if (headless) { + return {overlayContent}; + } + + // React context does not propagate across the Skia renderOutside boundary. + return ( + + {overlayContent} + + ); + }} > - { - const overlayContent = ( - - {labelItems.map((labelItem) => ( - - ))} - {legendItems.map((legendItem) => ( - - ))} - - ); - - if (headless) { - return {overlayContent}; - } - - // React context does not propagate across the Skia renderOutside boundary. - return ( - - {overlayContent} - - ); - }} - > - {(renderArgs) => { - handleRender(renderArgs); - - return ( - - {tnode.children.map((child) => ( - - ))} - - ); - }} - - {!!activeLabel && hasTooltipLabels && chartWidth > 0 && ( - - - - )} - + {(renderArgs) => { + onRenderArgs?.(renderArgs); + + return ( + + {tnode.children.map((child) => ( + + ))} + + ); + }} + ); } -const styles = StyleSheet.create({ - container: { - height: '100%', - position: 'relative', - width: '100%', - }, -}); - export default VictoryChartCartesian; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx new file mode 100644 index 000000000000..0cfb6eb81bb7 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -0,0 +1,71 @@ +import ChartTooltip from '@components/Charts/components/ChartTooltip'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import useVictoryBarInteractions from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions'; +import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; + +import type {LayoutChangeEvent} from 'react-native'; + +import React, {useState} from 'react'; +import {StyleSheet} from 'react-native'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; + +import VictoryChartCartesian from './VictoryChartCartesian'; + +const styles = StyleSheet.create({ + container: { + height: '100%', + position: 'relative', + width: '100%', + }, +}); + +function VictoryChartCartesianInteractive() { + const {chartContentStyles} = useVictoryChartContext(); + const designWidth = getChartDesignWidth(undefined, chartContentStyles.width); + const [chartWidth, setChartWidth] = useState(designWidth ?? 0); + const {customGestures, handleRender, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); + + const handleLayout = (event: LayoutChangeEvent) => { + setChartWidth(event.nativeEvent.layout.width); + }; + + const cursorStyle = useAnimatedStyle(() => ({ + cursor: isCursorOverClickable.get() ? 'pointer' : 'auto', + })); + + const tooltipWrapperStyle = useAnimatedStyle(() => ({ + bottom: 0, + left: 0, + opacity: isTooltipActive.get() ? 1 : 0, + position: 'absolute', + right: 0, + top: 0, + })); + + return ( + + + {!!activeLabel && hasTooltipLabels && chartWidth > 0 && ( + + + + )} + + ); +} + +export default VictoryChartCartesianInteractive; From 12f6096b26f741864f8d6e1b749ffcb5bc189bd9 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 15:47:48 +0100 Subject: [PATCH 07/20] Use interactive charts --- .../BaseVictoryChartRenderer.tsx | 4 ++-- .../VictoryChartInteractiveContent.tsx | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartInteractiveContent.tsx diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx index 52ead75f77f0..bc777cb174d5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type {VictoryChartRendererProps} from './types'; import VictoryChartContainer from './components/VictoryChartContainer'; -import VictoryChartContent from './components/VictoryChartContent'; +import VictoryChartInteractiveContent from './components/VictoryChartInteractiveContent'; import {VictoryChartProvider} from './context/VictoryChartContext'; import processVictoryChartTree from './parsers/processVictoryChartTree'; import resolveVictoryChartType from './utils/resolveVictoryChartType'; @@ -40,7 +40,7 @@ function BaseVictoryChartRenderer({tnode}: VictoryChartRendererProps) { type={type} > - + diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartInteractiveContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartInteractiveContent.tsx new file mode 100644 index 000000000000..569b8ec6bd9d --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartInteractiveContent.tsx @@ -0,0 +1,23 @@ +import {CHART_TYPE} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; + +import React from 'react'; + +import VictoryChartCartesianInteractive from './VictoryChartCartesianInteractive'; +import VictoryChartPolar from './VictoryChartPolar'; + +function VictoryChartInteractiveContent() { + const {type} = useVictoryChartContext(); + switch (type) { + case CHART_TYPE.CARTESIAN: + return ; + case CHART_TYPE.POLAR: + return ; + default: + return null; + } +} + +VictoryChartInteractiveContent.displayName = 'VictoryChartInteractiveContent'; + +export default VictoryChartInteractiveContent; From 7b2dc95ce667ab05421e71dfe4f8dc998ec2fb72 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 16:07:04 +0100 Subject: [PATCH 08/20] Apply codebase style and standards --- .../components/VictoryChartCartesian.tsx | 7 +++++++ .../VictoryChartCartesianInteractive.tsx | 12 +++++++---- .../hooks/useVictoryBarInteractions.ts | 20 +++++++++++-------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index a8f93847a83e..d190c341cef3 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -22,9 +22,16 @@ import VictoryChartLegend from './VictoryChartLegend'; import VictoryChartSeries from './VictoryChartSeries'; type VictoryChartCartesianProps = { + /** Explicit width/height when chart is rendered outside auto-layout */ explicitSize?: {width: number; height: number}; + + /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; + + /** Custom gesture configuration forwarded to CartesianChart */ customGestures?: CartesianChartProps['customGestures']; + + /** Callback invoked with render args on each chart render pass */ onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx index 0cfb6eb81bb7..91ae83ae24ec 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -1,3 +1,7 @@ +/** + * Interactive wrapper around VictoryChartCartesian that adds hover tooltips + * and tap-to-navigate behaviour for bar chart series. + */ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import useVictoryBarInteractions from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions'; @@ -23,9 +27,9 @@ function VictoryChartCartesianInteractive() { const {chartContentStyles} = useVictoryChartContext(); const designWidth = getChartDesignWidth(undefined, chartContentStyles.width); const [chartWidth, setChartWidth] = useState(designWidth ?? 0); - const {customGestures, handleRender, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); + const {customGestures, syncBarPositions, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); - const handleLayout = (event: LayoutChangeEvent) => { + const updateChartWidth = (event: LayoutChangeEvent) => { setChartWidth(event.nativeEvent.layout.width); }; @@ -45,11 +49,11 @@ function VictoryChartCartesianInteractive() { return ( {!!activeLabel && hasTooltipLabels && chartWidth > 0 && ( Object.values(data) as CartesianChartData[], [data]); - const barSeriesConfig = useMemo(() => (isHorizontal ? {} : collectVerticalBarSeries(tnode.children)), [isHorizontal, tnode.children]); - const interactiveBars = useMemo(() => buildInteractiveBars(rows, yKeys, barSeriesConfig, pointMetadata), [barSeriesConfig, pointMetadata, rows, yKeys]); + const rows = Object.values(data) as CartesianChartData[]; + const barSeriesConfig = isHorizontal ? {} : collectVerticalBarSeries(tnode.children); + const interactiveBars = buildInteractiveBars(rows, yKeys, barSeriesConfig, pointMetadata); const {state} = useChartInteractionState(); const pointX = useSharedValue([]); const pointY = useSharedValue([]); @@ -147,7 +151,7 @@ function useVictoryBarInteractions() { state.y.y.position.set(pointY.get().at(index) ?? 0); }; - const handlePress = (index: number) => { + const navigateToBarSearch = (index: number) => { const searchQuery = interactiveBars.at(index)?.searchQuery; if (!searchQuery) { return; @@ -186,7 +190,7 @@ function useVictoryBarInteractions() { const index = findMatchedBar(event.x, event.y); applyMatch(index); if (index >= 0 && (hasSearchQuery.get().at(index) ?? 0) === 1) { - scheduleOnRN(handlePress, index); + scheduleOnRN(navigateToBarSearch, index); } }); @@ -220,7 +224,7 @@ function useVictoryBarInteractions() { }, ); - const handleRender = (renderArgs: CartesianChartRenderArg) => { + const syncBarPositions = (renderArgs: CartesianChartRenderArg) => { const {points, chartBounds} = renderArgs; const xs: number[] = []; const ys: number[] = []; @@ -250,7 +254,7 @@ function useVictoryBarInteractions() { return { customGestures, - handleRender, + syncBarPositions, activeLabel: activeBarIndex >= 0 ? interactiveBars.at(activeBarIndex)?.label : undefined, hasTooltipLabels: SHOULD_SHOW_TOOLTIPS && interactiveBars.some((bar) => !!bar.label), isTooltipActive, From 1c88607795d5b998c04c702411b5b184f0c983d5 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 14 Jul 2026 16:28:45 +0100 Subject: [PATCH 09/20] Remove unused ChartPointMetadataByYKey export --- .../HTMLRenderers/VictoryChartRenderer/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 0916915bb2e8..e6d7b0b1925f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -197,7 +197,6 @@ export type { YKey, CartesianChartData, ChartPointMetadata, - ChartPointMetadataByYKey, CartesianChartProps, TextAnchor, LabelItem, From 33deccd0629e4e172e8ca7ef7154fdd5cab0514e Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:15:48 +0100 Subject: [PATCH 10/20] Extend chart interactions --- src/components/Charts/hooks/index.ts | 2 +- .../Charts/hooks/useChartInteractions.ts | 168 +++++++++++++----- .../Charts/hooks/useLabelHitTesting.ts | 2 +- 3 files changed, 123 insertions(+), 49 deletions(-) diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 66dac24e3fdc..5fa23151604b 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -4,7 +4,7 @@ export {default as useChartParagraphs} from './useChartParagraphs'; export {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; export {default as ChartFontsProvider} from '@components/Charts/context/ChartFontsProvider'; export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions'; -export type {HitTestArgs} from './useChartInteractions'; +export type {HitTestArgs, ResolveTargetIndexArgs} from './useChartInteractions'; export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {default as useTooltipData} from './useTooltipData'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 88edae25eb1b..2ca8b9cdf4cc 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -28,6 +28,32 @@ type HitTestArgs = { /** The bottom boundary of the chart area */ chartBottom: number; + + /** The index of the matched target */ + targetIndex: number; +}; + +/** + * Arguments passed to the resolveTargetIndex callback for custom target matching + */ +type ResolveTargetIndexArgs = { + /** Current raw X position of the cursor */ + cursorX: number; + + /** Current raw Y position of the cursor */ + cursorY: number; + + /** X position used for nearest-point matching after any label-area correction */ + touchX: number; + + /** Canvas-space X positions for each target */ + pointX: number[]; + + /** Canvas-space Y positions for each target */ + pointY: number[]; + + /** The bottom boundary of the chart area */ + chartBottom: number; }; /** @@ -43,6 +69,18 @@ type UseChartInteractionsProps = { */ checkIsOver: (args: HitTestArgs) => boolean; + /** + * Optional worklet function to determine if the cursor is over a clickable target. + * Defaults to checkIsOver when omitted. + */ + checkIsClickable?: (args: HitTestArgs) => boolean; + + /** + * Optional worklet function to resolve the matched target index. + * Defaults to nearest-point-by-X matching. + */ + resolveTargetIndex?: (args: ResolveTargetIndexArgs) => number; + /** Worklet function to determine if the cursor is hovering over the label area */ isCursorOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean; @@ -109,7 +147,7 @@ function findClosestPoint(xValues: number[], targetX: number): number { * Uses react native gesture handler gestures directly — no dependency on Victory's actionsRef/handleTouch. * Synchronizes high-frequency UI thread data to React state for tooltip display and navigation. */ -function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) { +function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resolveTargetIndex, isCursorOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState} = useChartInteractionState(); @@ -136,17 +174,79 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso [pointOX, pointOY], ); - /** - * Derived value that checks only whether the cursor is over a clickable element - * (e.g. dot, bar) — excludes labels which show tooltip but aren't clickable. - */ - const isCursorOverClickable = useDerivedValue(() => { + const getHitTestArgs = (targetIndex: number, cursorX: number, cursorY: number, targetX: number, targetY: number, currentChartBottom: number): HitTestArgs => { + 'worklet'; + + return { + cursorX, + cursorY, + targetX, + targetY, + chartBottom: currentChartBottom, + targetIndex, + }; + }; + + const getCurrentHitTestArgs = () => { + 'worklet'; + + const targetIndex = chartInteractionState.matchedIndex.get(); + if (targetIndex < 0) { + return; + } + 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 currentChartBottom = chartBottom?.get() ?? 0; - return checkIsOver({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}); + return getHitTestArgs(targetIndex, cursorX, cursorY, targetX, targetY, currentChartBottom); + }; + + const getResolvedTargetIndex = (cursorX: number, cursorY: number, touchX: number) => { + 'worklet'; + + const ox = pointOX.get(); + const oy = pointOY.get(); + const currentChartBottom = chartBottom?.get() ?? 0; + return ( + resolveTargetIndex?.({ + cursorX, + cursorY, + touchX, + pointX: ox, + pointY: oy, + chartBottom: currentChartBottom, + }) ?? findClosestPoint(ox, touchX) + ); + }; + + const applyTargetIndex = (targetIndex: number) => { + 'worklet'; + + chartInteractionState.matchedIndex.set(targetIndex); + if (targetIndex < 0) { + return; + } + + const ox = pointOX.get(); + const oy = pointOY.get(); + chartInteractionState.x.position.set(ox.at(targetIndex) ?? 0); + chartInteractionState.x.value.set(targetIndex); + chartInteractionState.y.y.position.set(oy.at(targetIndex) ?? 0); + }; + + /** + * Derived value that checks only whether the cursor is over a clickable element + * (e.g. dot, bar) — excludes labels which show tooltip but aren't clickable. + */ + const isCursorOverClickable = useDerivedValue(() => { + const hitTestArgs = getCurrentHitTestArgs(); + if (!hitTestArgs) { + return false; + } + + return (checkIsClickable ?? checkIsOver)(hitTestArgs); }); /** @@ -155,15 +255,15 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso * Includes both clickable targets and labels (for tooltip display). */ const isCursorOverTarget = useDerivedValue(() => { - if (isCursorOverClickable.get()) { + const hitTestArgs = getCurrentHitTestArgs(); + if (!hitTestArgs) { + return false; + } + + if (checkIsOver(hitTestArgs)) { return true; } - 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 currentChartBottom = chartBottom?.get() ?? 0; - return isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false; + return isCursorOverLabel?.(hitTestArgs, hitTestArgs.targetIndex) ?? false; }); /** @@ -190,15 +290,7 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso chartInteractionState.cursor.y.set(e.y); const bottom = chartBottom?.get() ?? e.y; const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - const ox = pointOX.get(); - const oy = pointOY.get(); - const idx = findClosestPoint(ox, touchX); - if (idx >= 0) { - chartInteractionState.matchedIndex.set(idx); - chartInteractionState.x.position.set(ox.at(idx) ?? 0); - chartInteractionState.x.value.set(idx); - chartInteractionState.y.y.position.set(oy.at(idx) ?? 0); - } + applyTargetIndex(getResolvedTargetIndex(e.x, e.y, touchX)); }) .onUpdate((e) => { 'worklet'; @@ -211,15 +303,7 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso if (!isCursorOverTarget.get()) { const bottom = chartBottom?.get() ?? e.y; const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - const ox = pointOX.get(); - const oy = pointOY.get(); - const idx = findClosestPoint(ox, touchX); - if (idx >= 0) { - chartInteractionState.matchedIndex.set(idx); - chartInteractionState.x.position.set(ox.at(idx) ?? 0); - chartInteractionState.x.value.set(idx); - chartInteractionState.y.y.position.set(oy.at(idx) ?? 0); - } + applyTargetIndex(getResolvedTargetIndex(e.x, e.y, touchX)); } }) .onEnd(() => { @@ -240,26 +324,16 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso chartInteractionState.cursor.y.set(e.y); const ox = pointOX.get(); const oy = pointOY.get(); - const idx = findClosestPoint(ox, e.x); + const idx = getResolvedTargetIndex(e.x, e.y, e.x); + applyTargetIndex(idx); if (idx < 0) { return; } const targetX = ox.at(idx) ?? 0; const targetY = oy.at(idx) ?? 0; - chartInteractionState.matchedIndex.set(idx); - chartInteractionState.x.position.set(targetX); - chartInteractionState.x.value.set(idx); - chartInteractionState.y.y.position.set(targetY); const currentChartBottom = chartBottom?.get() ?? 0; - if ( - checkIsOver({ - cursorX: e.x, - cursorY: e.y, - targetX, - targetY, - chartBottom: currentChartBottom, - }) - ) { + const hitTestArgs = getHitTestArgs(idx, e.x, e.y, targetX, targetY, currentChartBottom); + if ((checkIsClickable ?? checkIsOver)(hitTestArgs)) { scheduleOnRN(handlePress, idx); } }); @@ -303,4 +377,4 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso } export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP}; -export type {HitTestArgs}; +export type {HitTestArgs, ResolveTargetIndexArgs}; diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 9ed5d42ed3d0..bb6c3f65267a 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -147,7 +147,7 @@ function useLabelHitTesting({fontManager, fontSize, truncatedLabelWidths, labelR if (tickX === undefined) { continue; } - if (isCursorOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + if (isCursorOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom, targetIndex: i}, i)) { return tickX; } } From 707e3d0a286b2e172da5a3c4b7f8c23b0537bb1c Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:16:01 +0100 Subject: [PATCH 11/20] Fix bar hitboxes --- .../hooks/useVictoryBarInteractions.ts | 181 +++++++----------- .../utils/getVictoryBarInteractionGeometry.ts | 96 ++++++++++ 2 files changed, 169 insertions(+), 108 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts index 44c34ce28543..cd92e01c3341 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -2,16 +2,16 @@ * Manages hover/tap interactions on Victory bar charts, providing hit-testing * against rendered bars, tooltip state, and click-through navigation to search. */ -import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; -import {TOOLTIP_BAR_GAP} from '@components/Charts/hooks'; -import useChartInteractionState from '@components/Charts/hooks/useChartInteractionState'; +import {useChartInteractions} from '@components/Charts/hooks'; +import type {HitTestArgs, ResolveTargetIndexArgs} from '@components/Charts/hooks'; import {X_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; -import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import type {CartesianChartData, PolarChartData, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import getChartPointMetadataKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey'; +import {getVictoryBarInteractionGeometry, isCursorInVerticalBar} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry'; +import type {BarSeriesLayoutConfig} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; -import type {SearchQueryString} from '@components/Search/types'; import Navigation from '@libs/Navigation/Navigation'; @@ -21,23 +21,22 @@ import type {TNode} from 'react-native-render-html'; import type {CartesianChartRenderArg} from 'victory-native'; import {useState} from 'react'; -import {Platform} from 'react-native'; -import {Gesture} from 'react-native-gesture-handler'; -import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated'; +import {useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; -const SHOULD_SHOW_TOOLTIPS = Platform.OS === 'web'; - -type BarSeriesConfig = Partial>; +type BarSeriesConfig = Partial>; type InteractiveBar = { xValue: CartesianChartData[typeof X_KEY]; yKey: YKey; label: string; searchQuery?: string; - barWidth?: number; }; +function isCartesianChartData(row: CartesianChartData | PolarChartData): row is CartesianChartData { + return X_KEY in row; +} + function collectVerticalBarSeries(children: readonly TNode[]): BarSeriesConfig { const config: BarSeriesConfig = {}; @@ -47,8 +46,25 @@ function collectVerticalBarSeries(children: readonly TNode[]): BarSeriesConfig { return; } if (node.tagName === 'victorygroup' && !('horizontal' in node.attributes)) { - for (const child of node.children) { - visit(child); + const barChildren = node.children.filter((child) => child.tagName === 'victorybar'); + const groupYKeys = barChildren.map(getYKey); + const firstBarChild = barChildren.at(0); + const firstBarWidth = firstBarChild ? parseAttributeAsNumber(firstBarChild.attributes.barwidth) : undefined; + + for (let index = 0; index < barChildren.length; index++) { + const child = barChildren.at(index); + if (!child) { + continue; + } + + config[getYKey(child)] = { + group: { + yKeys: groupYKeys, + index, + barWidth: firstBarWidth, + offsetAttribute: node.attributes.offset, + }, + }; } } }; @@ -80,7 +96,6 @@ function buildInteractiveBars(rows: CartesianChartData[], yKeys: YKey[], barSeri yKey, label: metadata.label ?? String(xValue), searchQuery: metadata.searchQuery, - barWidth: barSeriesConfig[yKey]?.barWidth, }); } } @@ -90,23 +105,34 @@ function buildInteractiveBars(rows: CartesianChartData[], yKeys: YKey[], barSeri function useVictoryBarInteractions() { const {tnode, data, yKeys, pointMetadata, isHorizontal} = useVictoryChartContext(); - const rows = Object.values(data) as CartesianChartData[]; + const rows = Object.values(data).filter(isCartesianChartData); const barSeriesConfig = isHorizontal ? {} : collectVerticalBarSeries(tnode.children); const interactiveBars = buildInteractiveBars(rows, yKeys, barSeriesConfig, pointMetadata); - const {state} = useChartInteractionState(); - const pointX = useSharedValue([]); - const pointY = useSharedValue([]); const pointWidth = useSharedValue([]); const hasSearchQuery = useSharedValue([]); const chartBottom = useSharedValue(0); + const yZero = useSharedValue(0); + + const checkIsOverBar = (args: HitTestArgs) => { + 'worklet'; + + const width = pointWidth.get().at(args.targetIndex) ?? 0; + return isCursorInVerticalBar(args.cursorX, args.cursorY, args.targetX, args.targetY, width, yZero.get()); + }; + + const checkIsClickableBar = (args: HitTestArgs) => { + 'worklet'; + + return checkIsOverBar(args) && (hasSearchQuery.get().at(args.targetIndex) ?? 0) === 1; + }; - const findMatchedBar = (cursorX: number, cursorY: number) => { + const resolveBarTargetIndex = (args: ResolveTargetIndexArgs) => { 'worklet'; - const xs = pointX.get(); - const ys = pointY.get(); + const xs = args.pointX; + const ys = args.pointY; const widths = pointWidth.get(); - const bottom = chartBottom.get(); + const zero = yZero.get(); let bestIndex = -1; let bestDistance = Infinity; @@ -118,17 +144,13 @@ function useVictoryBarInteractions() { const targetX = xs.at(i) ?? 0; const targetY = ys.at(i) ?? 0; - const barLeft = targetX - width / 2; - const barRight = targetX + width / 2; - const barTop = Math.min(targetY, bottom); - const barBottom = Math.max(targetY, bottom); - const isHit = cursorX >= barLeft && cursorX <= barRight && cursorY >= barTop && cursorY <= barBottom; + const isHit = isCursorInVerticalBar(args.cursorX, args.cursorY, targetX, targetY, width, zero); if (!isHit) { continue; } - const distance = Math.abs(cursorX - targetX); + const distance = Math.abs(args.cursorX - targetX); if (distance < bestDistance) { bestDistance = distance; bestIndex = i; @@ -138,94 +160,33 @@ function useVictoryBarInteractions() { return bestIndex; }; - const applyMatch = (index: number) => { - 'worklet'; - - state.matchedIndex.set(index); - if (index < 0) { - return; - } - - state.x.position.set(pointX.get().at(index) ?? 0); - state.x.value.set(index); - state.y.y.position.set(pointY.get().at(index) ?? 0); - }; - const navigateToBarSearch = (index: number) => { const searchQuery = interactiveBars.at(index)?.searchQuery; if (!searchQuery) { return; } - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery as SearchQueryString})); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); }; - const hoverGesture = Gesture.Hover() - .onBegin((event) => { - 'worklet'; - - state.isActive.set(true); - state.cursor.x.set(event.x); - state.cursor.y.set(event.y); - applyMatch(findMatchedBar(event.x, event.y)); - }) - .onUpdate((event) => { - 'worklet'; - - state.cursor.x.set(event.x); - state.cursor.y.set(event.y); - applyMatch(findMatchedBar(event.x, event.y)); - }) - .onEnd(() => { - 'worklet'; - - state.isActive.set(false); - state.matchedIndex.set(-1); - }); - - const tapGesture = Gesture.Tap().onEnd((event) => { - 'worklet'; - - state.cursor.x.set(event.x); - state.cursor.y.set(event.y); - const index = findMatchedBar(event.x, event.y); - applyMatch(index); - if (index >= 0 && (hasSearchQuery.get().at(index) ?? 0) === 1) { - scheduleOnRN(navigateToBarSearch, index); - } - }); - - const customGestures = Gesture.Race(hoverGesture, tapGesture); - - const isTooltipActive = useDerivedValue(() => SHOULD_SHOW_TOOLTIPS && state.isActive.get() && state.matchedIndex.get() >= 0); - - const isCursorOverClickable = useDerivedValue(() => { - const index = state.matchedIndex.get(); - return index >= 0 && (hasSearchQuery.get().at(index) ?? 0) === 1; - }); - - const initialTooltipPosition = useDerivedValue(() => { - const index = state.matchedIndex.get(); - if (index < 0) { - return {x: 0, y: 0}; - } - - const targetY = state.y.y.position.get(); - return { - x: state.x.position.get(), - y: Math.min(targetY, chartBottom.get()) - TOOLTIP_BAR_GAP, - }; + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ + handlePress: navigateToBarSearch, + checkIsOver: checkIsOverBar, + checkIsClickable: checkIsClickableBar, + resolveTargetIndex: resolveBarTargetIndex, + chartBottom, + yZero, }); const [activeBarIndex, setActiveBarIndex] = useState(-1); useAnimatedReaction( - () => state.matchedIndex.get(), + () => matchedIndex.get(), (index) => { scheduleOnRN(setActiveBarIndex, index); }, ); const syncBarPositions = (renderArgs: CartesianChartRenderArg) => { - const {points, chartBounds} = renderArgs; + const {points, chartBounds, yScale} = renderArgs; const xs: number[] = []; const ys: number[] = []; const widths: number[] = []; @@ -233,30 +194,34 @@ function useVictoryBarInteractions() { for (const bar of interactiveBars) { const point = points[bar.yKey]?.find((candidate) => candidate.xValue === bar.xValue); - if (!point || typeof point.y !== 'number') { + if (!point) { continue; } const seriesPointCount = points[bar.yKey]?.length ?? 0; - const fallbackBarWidth = seriesPointCount > 0 ? ((1 - BAR_INNER_PADDING) * (chartBounds.right - chartBounds.left)) / seriesPointCount : 0; - xs.push(point.x); - ys.push(point.y); - widths.push(bar.barWidth ?? fallbackBarWidth); + const geometry = getVictoryBarInteractionGeometry(point, chartBounds, seriesPointCount, barSeriesConfig[bar.yKey]); + if (!geometry) { + continue; + } + + xs.push(geometry.x); + ys.push(geometry.y); + widths.push(geometry.width); searchQueryFlags.push(bar.searchQuery ? 1 : 0); } - pointX.set(xs); - pointY.set(ys); + setPointPositions(xs, ys); pointWidth.set(widths); hasSearchQuery.set(searchQueryFlags); chartBottom.set(chartBounds.bottom); + yZero.set(yScale(0)); }; return { customGestures, syncBarPositions, activeLabel: activeBarIndex >= 0 ? interactiveBars.at(activeBarIndex)?.label : undefined, - hasTooltipLabels: SHOULD_SHOW_TOOLTIPS && interactiveBars.some((bar) => !!bar.label), + hasTooltipLabels: interactiveBars.some((bar) => !!bar.label), isTooltipActive, isCursorOverClickable, initialTooltipPosition, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry.ts new file mode 100644 index 000000000000..dc7d54d811f1 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry.ts @@ -0,0 +1,96 @@ +import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; +import type {YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +import type {ChartBounds, PointsArray} from 'victory-native'; + +import parseOffset from './parseOffset'; + +const DEFAULT_BETWEEN_GROUP_PADDING = 0.25; + +type BarGroupLayoutConfig = { + yKeys: YKey[]; + index: number; + barWidth?: number; + offsetAttribute?: string; +}; + +type BarSeriesLayoutConfig = { + barWidth?: number; + group?: BarGroupLayoutConfig; +}; + +type BarInteractionGeometry = { + x: number; + y: number; + width: number; +}; + +function getUngroupedBarWidth(chartBounds: ChartBounds, pointCount: number, customBarWidth?: number): number { + if (customBarWidth) { + return customBarWidth; + } + + if (pointCount <= 0) { + return 0; + } + + const domainWidth = chartBounds.right - chartBounds.left; + const denominator = pointCount - 1 <= 0 ? pointCount : pointCount - 1; + return ((1 - BAR_INNER_PADDING) * domainWidth) / denominator; +} + +function getGroupedBarGeometry(chartBounds: ChartBounds, pointCount: number, group: BarGroupLayoutConfig): Pick & {centerOffset: number} { + const groupCount = group.yKeys.length; + const boundSize = chartBounds.right - chartBounds.left; + const betweenGroupPadding = group.barWidth ? parseOffset(group.offsetAttribute ?? '', chartBounds, groupCount, group.barWidth, pointCount, false) : DEFAULT_BETWEEN_GROUP_PADDING; + const groupWidth = ((1 - betweenGroupPadding) * boundSize) / Math.max(1, pointCount); + const width = group.barWidth ?? ((1 - BAR_INNER_PADDING) * groupWidth) / Math.max(1, groupCount); + const gapWidth = (groupWidth - width * groupCount) / Math.max(1, groupCount - 1); + const centerOffset = -groupWidth / 2 + group.index * (width + gapWidth) + width / 2; + + return {width, centerOffset}; +} + +function getVictoryBarInteractionGeometry( + point: PointsArray[number], + chartBounds: ChartBounds, + pointCount: number, + seriesConfig?: BarSeriesLayoutConfig, +): BarInteractionGeometry | undefined { + if (typeof point.y !== 'number') { + return; + } + + if (seriesConfig?.group) { + const groupedGeometry = getGroupedBarGeometry(chartBounds, pointCount, seriesConfig.group); + return { + x: point.x + groupedGeometry.centerOffset, + y: point.y, + width: groupedGeometry.width, + }; + } + + return { + x: point.x, + y: point.y, + width: getUngroupedBarWidth(chartBounds, pointCount, seriesConfig?.barWidth), + }; +} + +function isCursorInVerticalBar(cursorX: number, cursorY: number, targetX: number, targetY: number, width: number, yZero: number): boolean { + 'worklet'; + + if (width <= 0) { + return false; + } + + const barLeft = targetX - width / 2; + const barRight = targetX + width / 2; + const barTop = Math.min(targetY, yZero); + const barBottom = Math.max(targetY, yZero); + + return cursorX >= barLeft && cursorX <= barRight && cursorY >= barTop && cursorY <= barBottom; +} + +export {getVictoryBarInteractionGeometry, isCursorInVerticalBar}; +export type {BarSeriesLayoutConfig}; From 70723d3df0fff47affeb0dd1b4e94bace50872dd Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:16:16 +0100 Subject: [PATCH 12/20] Test bar hitboxes --- .../getVictoryBarInteractionGeometry.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/unit/components/HTMLEngineProvider/getVictoryBarInteractionGeometry.test.ts diff --git a/tests/unit/components/HTMLEngineProvider/getVictoryBarInteractionGeometry.test.ts b/tests/unit/components/HTMLEngineProvider/getVictoryBarInteractionGeometry.test.ts new file mode 100644 index 000000000000..978923a3ba87 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/getVictoryBarInteractionGeometry.test.ts @@ -0,0 +1,80 @@ +import type {YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import {getVictoryBarInteractionGeometry, isCursorInVerticalBar} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry'; + +import type {ChartBounds, PointsArray} from 'victory-native'; + +const chartBounds: ChartBounds = { + left: 0, + right: 400, + top: 0, + bottom: 300, +}; + +function buildPoint(x: number, y: number): PointsArray[number] { + return { + x, + xValue: 'Jan', + y, + yValue: 10, + }; +} + +describe('getVictoryBarInteractionGeometry', () => { + it('uses the same fallback width formula as victory-native Bar', () => { + const geometry = getVictoryBarInteractionGeometry(buildPoint(100, 80), chartBounds, 5); + + expect(geometry?.width).toBe(70); + expect(geometry?.x).toBe(100); + }); + + it('offsets grouped bars to their rendered side-by-side centers', () => { + const yKeys = ['y0', 'y1'] as YKey[]; + const firstGeometry = getVictoryBarInteractionGeometry(buildPoint(100, 80), chartBounds, 2, { + group: { + yKeys, + index: 0, + }, + }); + const secondGeometry = getVictoryBarInteractionGeometry(buildPoint(100, 120), chartBounds, 2, { + group: { + yKeys, + index: 1, + }, + }); + + expect(firstGeometry?.x).toBeLessThan(100); + expect(secondGeometry?.x).toBeGreaterThan(100); + expect(firstGeometry?.x).not.toBe(secondGeometry?.x); + expect(firstGeometry?.width).toBe(secondGeometry?.width); + }); + + it('mirrors explicit grouped bar width and offset attributes', () => { + const yKeys = ['y0', 'y1'] as YKey[]; + const firstGeometry = getVictoryBarInteractionGeometry(buildPoint(100, 80), chartBounds, 2, { + group: { + yKeys, + index: 0, + barWidth: 20, + offsetAttribute: '30', + }, + }); + const secondGeometry = getVictoryBarInteractionGeometry(buildPoint(100, 120), chartBounds, 2, { + group: { + yKeys, + index: 1, + barWidth: 20, + offsetAttribute: '30', + }, + }); + + expect(firstGeometry?.x).toBe(85); + expect(secondGeometry?.x).toBe(115); + expect(firstGeometry?.width).toBe(20); + expect(secondGeometry?.width).toBe(20); + }); + + it('hit-tests negative bars against the zero baseline instead of the chart bottom', () => { + expect(isCursorInVerticalBar(100, 225, 100, 250, 40, 200)).toBe(true); + expect(isCursorInVerticalBar(100, 275, 100, 250, 40, 200)).toBe(false); + }); +}); From 4a054bdcb2e4692b0dbd38ab55f9f147797dcb56 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:30:39 +0100 Subject: [PATCH 13/20] Clean tooltip content --- src/components/Charts/components/ChartTooltip.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index c3beb32412b5..bc1782b02f0e 100644 --- a/src/components/Charts/components/ChartTooltip.tsx +++ b/src/components/Charts/components/ChartTooltip.tsx @@ -27,6 +27,18 @@ type ChartTooltipProps = { initialTooltipPosition: SharedValue<{x: number; y: number}>; }; +function getTooltipContent(label: string, amount: string, percentage?: string): string { + if (!amount) { + return label; + } + + if (!percentage) { + return `${label} • ${amount}`; + } + + return `${label} • ${amount} (${percentage})`; +} + function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosition}: ChartTooltipProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -34,7 +46,7 @@ function ChartTooltip({label, amount, percentage, chartWidth, initialTooltipPosi /** Shared value to store the measured width of the tooltip container */ const tooltipMeasuredWidth = useSharedValue(0); - const content = percentage && amount ? `${label} • ${amount} (${percentage})` : amount ? `${label} • ${amount}` : label; + const content = getTooltipContent(label, amount, percentage); /** * Synchronously reset the width and hide the tooltip whenever the content changes. From e566fa9b4a486bd02b4b229816d107d46bddeb83 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:30:50 +0100 Subject: [PATCH 14/20] Clean series parser --- .../parsers/victorySeriesParser.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts index cda72af4e849..9a33a3dc2385 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts @@ -42,15 +42,13 @@ function parseVictorySeriesNode(tnode: TNode, typeface: SkTypeface | null, rootP if (isHorizontal) { // Even though the X-Axis is going to hold the y values on horizontal mode, it's not the independent axis // thus we cannot use `point.y` as the key since two points can have the same y value. - data[`${point.y}-${point.x}`] = { - [X_KEY]: point.y, - [yKey]: typeof point.x === 'number' ? point.x : resolveCategoryIndex(categories, String(point.x)), - } as CartesianChartData; + const chartData: CartesianChartData = {[X_KEY]: point.y}; + chartData[yKey] = typeof point.x === 'number' ? point.x : resolveCategoryIndex(categories, String(point.x)); + data[`${point.y}-${point.x}`] = chartData; } else { - data[point.x] = { - [X_KEY]: point.x, - [yKey]: point.y, - } as CartesianChartData; + const chartData: CartesianChartData = {[X_KEY]: point.x}; + chartData[yKey] = point.y; + data[point.x] = chartData; } } return {data, yKeys: [yKey], pointMetadata}; From a6cd0773ae3b2a5a15c0366775bef54b574f8132 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Fri, 17 Jul 2026 14:31:02 +0100 Subject: [PATCH 15/20] Clean parser tests --- .../VictoryChartParsers.test.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts index ac609cfc9bb3..422324f0bf9d 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts @@ -8,9 +8,9 @@ import type {TNode} from 'react-native-render-html'; function createNode(tagName: string, attributes: Record = {}, children: TNode[] = []): TNode { const node = {tagName, attributes, children, nodeIndex: 0} as unknown as TNode; - children.forEach((child, index) => { + for (const [index, child] of children.entries()) { Object.assign(child, {parent: node, nodeIndex: index}); - }); + } return node; } @@ -49,10 +49,11 @@ describe('victorySeriesParser', () => { it('preserves point metadata by series and x value', () => { const node = createNode('victorybar', {data: "[{x: 1, y: 10, label: 'Jan 2026: $10', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"}); const result = parseVictorySeriesNode(node, null, null); + const numberOneKey = 'number:1'; expect(result.pointMetadata).toEqual({ y0: { - 'number:1': { + [numberOneKey]: { label: 'Jan 2026: $10', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01', }, @@ -66,13 +67,15 @@ describe('victorySeriesParser', () => { labels: "['Fallback Jan', 'Fallback Feb']", }); const result = parseVictorySeriesNode(node, null, null); + const numberOneKey = 'number:1'; + const numberTwoKey = 'number:2'; expect(result.pointMetadata).toEqual({ y0: { - 'number:1': { + [numberOneKey]: { label: 'Fallback Jan', }, - 'number:2': { + [numberTwoKey]: { label: 'Custom Feb', }, }, @@ -84,15 +87,18 @@ describe('victorySeriesParser', () => { const secondNode = createNode('victorybar', {data: "[{x: 1, y: 20, label: 'Prior Jan'}]"}); const tree = createNode('victorychart', {}, [firstNode, secondNode]); const result = processVictoryChartTree(tree, null, null); + const firstSeriesKey = 'y0-0'; + const secondSeriesKey = 'y0-1'; + const numberOneKey = 'number:1'; expect(result.pointMetadata).toEqual({ - 'y0-0': { - 'number:1': { + [firstSeriesKey]: { + [numberOneKey]: { label: 'Current Jan', }, }, - 'y0-1': { - 'number:1': { + [secondSeriesKey]: { + [numberOneKey]: { label: 'Prior Jan', }, }, From 59f74d9311034d978983ce6cfc672e97186553e8 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Sun, 19 Jul 2026 22:40:07 +0100 Subject: [PATCH 16/20] Parse chart currency --- .../VictoryChartRenderer/parsers/victorySeriesParser.ts | 3 +++ .../HTMLRenderers/VictoryChartRenderer/types.ts | 2 ++ .../VictoryChartRenderer/utils/parseRawChartData.ts | 3 +++ .../VictoryChartAttributesParsers.test.ts | 7 ++++--- .../HTMLEngineProvider/VictoryChartParsers.test.ts | 5 +++-- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts index 9a33a3dc2385..5aeaac8956c3 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts @@ -30,6 +30,9 @@ function parseVictorySeriesNode(tnode: TNode, typeface: SkTypeface | null, rootP } else if (fallbackLabel) { metadata.label = fallbackLabel; } + if (point.currency) { + metadata.currency = point.currency; + } if (point.searchQuery) { metadata.searchQuery = point.searchQuery; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 80d9f4c3d15a..1094847ac2f0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -12,6 +12,7 @@ type RawChartData = { x: string | number; y: number; label?: string; + currency?: string; searchQuery?: string; }; @@ -68,6 +69,7 @@ type CartesianChartData = { type ChartPointMetadata = { label?: string; + currency?: string; searchQuery?: string; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts index 6ddb5f0efdfa..d6aba0a94d7b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts @@ -23,6 +23,9 @@ function parseRawChartData(attribute: string): RawChartData[] { if ('label' in parsedData && typeof parsedData.label === 'string') { rawChartData.label = parsedData.label; } + if ('currency' in parsedData && typeof parsedData.currency === 'string') { + rawChartData.currency = parsedData.currency; + } if ('searchQuery' in parsedData && typeof parsedData.searchQuery === 'string') { rawChartData.searchQuery = parsedData.searchQuery; } diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts index 1d01cb72d22c..efbf8f9c18c6 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts @@ -12,7 +12,7 @@ describe('parseRawChartData', () => { }); it('skips invalid entries and properties', () => { - const result = parseRawChartData("[null, 5, 'x', {name: 'A'}, {x: 'test'}, {x: 'abc', y: '10'}, {x: 'water', y: 15, label: 5, searchQuery: {}}]"); + const result = parseRawChartData("[null, 5, 'x', {name: 'A'}, {x: 'test'}, {x: 'abc', y: '10'}, {x: 'water', y: 15, label: 5, currency: 5, searchQuery: {}}]"); expect(result).toEqual([ {x: 'abc', y: 10}, {x: 'water', y: 15}, @@ -20,12 +20,13 @@ describe('parseRawChartData', () => { }); it('preserves supported point metadata', () => { - const result = parseRawChartData("[{x: 1, y: 20, label: 'Jan 2026: $20', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"); + const result = parseRawChartData("[{x: 1, y: 20, label: 'January 2026', currency: 'USD', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"); expect(result).toEqual([ { x: 1, y: 20, - label: 'Jan 2026: $20', + label: 'January 2026', + currency: 'USD', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01', }, ]); diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts index 422324f0bf9d..ba91c7bb8518 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartParsers.test.ts @@ -47,14 +47,15 @@ describe('victorySeriesParser', () => { }); it('preserves point metadata by series and x value', () => { - const node = createNode('victorybar', {data: "[{x: 1, y: 10, label: 'Jan 2026: $10', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"}); + const node = createNode('victorybar', {data: "[{x: 1, y: 10, label: 'January 2026', currency: 'USD', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"}); const result = parseVictorySeriesNode(node, null, null); const numberOneKey = 'number:1'; expect(result.pointMetadata).toEqual({ y0: { [numberOneKey]: { - label: 'Jan 2026: $10', + label: 'January 2026', + currency: 'USD', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01', }, }, From 9339b702c874d36bf0bf6b883e62eda210d3d8f3 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Sun, 19 Jul 2026 22:40:20 +0100 Subject: [PATCH 17/20] Derive bar tooltips --- .../VictoryChartCartesianInteractive.tsx | 9 ++-- .../hooks/useVictoryBarInteractions.ts | 48 +++++++++++++++++- .../utils/getVictoryBarTooltipData.ts | 50 +++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx index 91ae83ae24ec..5053c13826ba 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -27,7 +27,7 @@ function VictoryChartCartesianInteractive() { const {chartContentStyles} = useVictoryChartContext(); const designWidth = getChartDesignWidth(undefined, chartContentStyles.width); const [chartWidth, setChartWidth] = useState(designWidth ?? 0); - const {customGestures, syncBarPositions, activeLabel, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); + const {customGestures, syncBarPositions, activeTooltipData, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useVictoryBarInteractions(); const updateChartWidth = (event: LayoutChangeEvent) => { setChartWidth(event.nativeEvent.layout.width); @@ -55,14 +55,15 @@ function VictoryChartCartesianInteractive() { customGestures={customGestures} onRenderArgs={syncBarPositions} /> - {!!activeLabel && hasTooltipLabels && chartWidth > 0 && ( + {!!activeTooltipData && hasTooltipLabels && chartWidth > 0 && ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts index cd92e01c3341..e0317bb2d979 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -10,8 +10,13 @@ import type {CartesianChartData, PolarChartData, YKey} from '@components/HTMLEng import getChartPointMetadataKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartPointMetadataKey'; import {getVictoryBarInteractionGeometry, isCursorInVerticalBar} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry'; import type {BarSeriesLayoutConfig} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarInteractionGeometry'; +import getVictoryBarTooltipData from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData'; +import type {VictoryBarTooltipData} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; @@ -30,6 +35,8 @@ type InteractiveBar = { xValue: CartesianChartData[typeof X_KEY]; yKey: YKey; label: string; + value: number; + currency?: string; searchQuery?: string; }; @@ -95,6 +102,8 @@ function buildInteractiveBars(rows: CartesianChartData[], yKeys: YKey[], barSeri xValue, yKey, label: metadata.label ?? String(xValue), + value: row[yKey], + currency: metadata.currency, searchQuery: metadata.searchQuery, }); } @@ -103,11 +112,45 @@ function buildInteractiveBars(rows: CartesianChartData[], yKeys: YKey[], barSeri return interactiveBars; } +function getVisibleVerticalBarValues(rows: CartesianChartData[], yKeys: YKey[], barSeriesConfig: BarSeriesConfig) { + const values: number[] = []; + + for (const row of rows) { + for (const yKey of yKeys) { + if (!(yKey in barSeriesConfig) || typeof row[yKey] !== 'number') { + continue; + } + + values.push(row[yKey]); + } + } + + return values; +} + +function getActiveTooltipData( + activeBar: InteractiveBar | undefined, + visibleVerticalBarValues: number[], + numberFormat: LocaleContextProps['numberFormat'], +): VictoryBarTooltipData | undefined { + if (!activeBar) { + return undefined; + } + + if (!activeBar.currency) { + return {label: activeBar.label, amount: '', percentage: ''}; + } + + return getVictoryBarTooltipData(activeBar, visibleVerticalBarValues, numberFormat); +} + function useVictoryBarInteractions() { const {tnode, data, yKeys, pointMetadata, isHorizontal} = useVictoryChartContext(); + const {numberFormat} = useLocalize(); const rows = Object.values(data).filter(isCartesianChartData); const barSeriesConfig = isHorizontal ? {} : collectVerticalBarSeries(tnode.children); const interactiveBars = buildInteractiveBars(rows, yKeys, barSeriesConfig, pointMetadata); + const visibleVerticalBarValues = getVisibleVerticalBarValues(rows, yKeys, barSeriesConfig); const pointWidth = useSharedValue([]); const hasSearchQuery = useSharedValue([]); const chartBottom = useSharedValue(0); @@ -185,6 +228,9 @@ function useVictoryBarInteractions() { }, ); + const activeBar = activeBarIndex >= 0 ? interactiveBars.at(activeBarIndex) : undefined; + const activeTooltipData = getActiveTooltipData(activeBar, visibleVerticalBarValues, numberFormat); + const syncBarPositions = (renderArgs: CartesianChartRenderArg) => { const {points, chartBounds, yScale} = renderArgs; const xs: number[] = []; @@ -220,7 +266,7 @@ function useVictoryBarInteractions() { return { customGestures, syncBarPositions, - activeLabel: activeBarIndex >= 0 ? interactiveBars.at(activeBarIndex)?.label : undefined, + activeTooltipData, hasTooltipLabels: interactiveBars.some((bar) => !!bar.label), isTooltipActive, isCursorOverClickable, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts new file mode 100644 index 000000000000..845e277550ed --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts @@ -0,0 +1,50 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import {sanitizeCurrencyCode} from '@libs/CurrencyUtils'; + +import CONST from '@src/CONST'; + +type VictoryBarTooltipPoint = { + label: string; + value: number; + currency?: string; +}; + +type VictoryBarTooltipData = { + label: string; + amount: string; + percentage: string; +}; + +function formatVictoryBarTooltipAmount(value: number, currency: string | undefined, numberFormat: LocaleContextProps['numberFormat']): string { + const fractionDigits = Number.isInteger(value) ? 0 : 2; + + return numberFormat(value, { + style: 'currency', + currency: sanitizeCurrencyCode(currency ?? CONST.CURRENCY.USD), + minimumFractionDigits: fractionDigits, + maximumFractionDigits: 2, + }); +} + +function getVictoryBarTooltipData( + activePoint: VictoryBarTooltipPoint | undefined, + visibleValues: number[], + numberFormat: LocaleContextProps['numberFormat'], +): VictoryBarTooltipData | undefined { + if (!activePoint) { + return undefined; + } + + const totalSum = visibleValues.reduce((sum, value) => sum + Math.abs(value), 0); + const percent = totalSum > 0 ? Math.round((Math.abs(activePoint.value) / totalSum) * 100) : 0; + + return { + label: activePoint.label, + amount: formatVictoryBarTooltipAmount(activePoint.value, activePoint.currency, numberFormat), + percentage: percent < 1 ? '<1%' : `${percent}%`, + }; +} + +export type {VictoryBarTooltipData, VictoryBarTooltipPoint}; +export default getVictoryBarTooltipData; From a56bd9e1061960a07ef9723b826c7e2517f47196 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Sun, 19 Jul 2026 22:54:03 +0100 Subject: [PATCH 18/20] Fix Victory chart gesture coordinates --- .../components/VictoryChartCartesian.tsx | 8 +--- .../VictoryChartCartesianInteractive.tsx | 46 +++++++++---------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index d190c341cef3..336760a5f44c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -1,7 +1,7 @@ import ChartFontsLoaderProvider from '@components/Charts/context/ChartFontsLoaderProvider'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {VictoryChartRenderArgsProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; -import type {CartesianChartData, CartesianChartProps, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; @@ -28,9 +28,6 @@ type VictoryChartCartesianProps = { /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; - /** Custom gesture configuration forwarded to CartesianChart */ - customGestures?: CartesianChartProps['customGestures']; - /** Callback invoked with render args on each chart render pass */ onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; @@ -39,7 +36,7 @@ type VictoryChartCartesianProps = { * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless, customGestures, onRenderArgs}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -68,7 +65,6 @@ function VictoryChartCartesian({explicitSize, headless, customGestures, onRender domain={domain} domainPadding={domainPadding} padding={padding} - customGestures={customGestures} {...getChartLayoutModeProps(explicitSize, headless)} renderOutside={(renderArgs) => { const overlayContent = ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx index 5053c13826ba..974c265615af 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -11,6 +11,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {StyleSheet} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import VictoryChartCartesian from './VictoryChartCartesian'; @@ -47,29 +48,28 @@ function VictoryChartCartesianInteractive() { })); return ( - - - {!!activeTooltipData && hasTooltipLabels && chartWidth > 0 && ( - - - - )} - + + + + {!!activeTooltipData && hasTooltipLabels && chartWidth > 0 && ( + + + + )} + + ); } From 8a74b92b951638519fea8226dabfb804f70be8d5 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Sun, 19 Jul 2026 23:42:57 +0100 Subject: [PATCH 19/20] Fix chart tooltip hover state update --- .../Charts/hooks/useChartInteractions.ts | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 2ca8b9cdf4cc..4a5a673d2420 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -161,6 +161,9 @@ function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resol * Canvas-space y positions for each data point, set by the chart content via setPointPositions. */ const pointOY = useSharedValue([]); + const isCursorOverTarget = useSharedValue(false); + const isCursorOverClickable = useSharedValue(false); + const isTooltipActive = useSharedValue(false); /** * Called by chart content from handleScaleChange to populate canvas positions. @@ -236,41 +239,44 @@ function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resol chartInteractionState.y.y.position.set(oy.at(targetIndex) ?? 0); }; - /** - * Derived value that checks only whether the cursor is over a clickable element - * (e.g. dot, bar) — excludes labels which show tooltip but aren't clickable. - */ - const isCursorOverClickable = useDerivedValue(() => { - const hitTestArgs = getCurrentHitTestArgs(); - if (!hitTestArgs) { + const updateInteractionFlags = (targetIndex: number, cursorX: number, cursorY: number, currentChartBottom: number) => { + 'worklet'; + + const ox = pointOX.get(); + const oy = pointOY.get(); + const targetX = targetIndex >= 0 ? ox.at(targetIndex) : undefined; + const targetY = targetIndex >= 0 ? oy.at(targetIndex) : undefined; + if (targetX === undefined || targetY === undefined) { + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); return false; } - return (checkIsClickable ?? checkIsOver)(hitTestArgs); - }); + const hitTestArgs = getHitTestArgs(targetIndex, cursorX, cursorY, targetX, targetY, currentChartBottom); + const isOverTarget = checkIsOver(hitTestArgs) || (isCursorOverLabel?.(hitTestArgs, targetIndex) ?? false); + const isOverClickableElement = (checkIsClickable ?? checkIsOver)(hitTestArgs); + + isCursorOverTarget.set(isOverTarget); + isCursorOverClickable.set(isOverClickableElement); + isTooltipActive.set(isOverTarget && chartInteractionState.isActive.get()); + + return isOverTarget; + }; + + const updateCurrentInteractionFlags = () => { + 'worklet'; - /** - * Derived value performing the hit-test on the UI thread. - * Runs whenever cursor position or matched data points change. - * Includes both clickable targets and labels (for tooltip display). - */ - const isCursorOverTarget = useDerivedValue(() => { const hitTestArgs = getCurrentHitTestArgs(); if (!hitTestArgs) { + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); return false; } - if (checkIsOver(hitTestArgs)) { - return true; - } - return isCursorOverLabel?.(hitTestArgs, hitTestArgs.targetIndex) ?? false; - }); - - /** - * Derived value that combines hover active state with hit-test result. - * Drives tooltip visibility entirely on the UI thread — no React state needed. - */ - const isTooltipActive = useDerivedValue(() => isCursorOverTarget.get() && chartInteractionState.isActive.get()); + return updateInteractionFlags(hitTestArgs.targetIndex, hitTestArgs.cursorX, hitTestArgs.cursorY, hitTestArgs.chartBottom); + }; /** * Hover gesture to be placed on the full-height outer container (chart + label area). @@ -290,26 +296,34 @@ function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resol chartInteractionState.cursor.y.set(e.y); const bottom = chartBottom?.get() ?? e.y; const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - applyTargetIndex(getResolvedTargetIndex(e.x, e.y, touchX)); + const targetIndex = getResolvedTargetIndex(e.x, e.y, touchX); + applyTargetIndex(targetIndex); + updateInteractionFlags(targetIndex, e.x, e.y, bottom); }) .onUpdate((e) => { 'worklet'; chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); + const bottom = chartBottom?.get() ?? e.y; + const isOverCurrentTarget = updateCurrentInteractionFlags(); // Only update the matched index when the cursor is not over the current target. // This keeps the active index locked while hovering over a bar/point/label, // preventing it from jumping to a different point during continuous movement. - if (!isCursorOverTarget.get()) { - const bottom = chartBottom?.get() ?? e.y; + if (!isOverCurrentTarget) { const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - applyTargetIndex(getResolvedTargetIndex(e.x, e.y, touchX)); + const targetIndex = getResolvedTargetIndex(e.x, e.y, touchX); + applyTargetIndex(targetIndex); + updateInteractionFlags(targetIndex, e.x, e.y, bottom); } }) .onEnd(() => { 'worklet'; chartInteractionState.isActive.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); }); /** @@ -333,7 +347,9 @@ function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resol const targetY = oy.at(idx) ?? 0; const currentChartBottom = chartBottom?.get() ?? 0; const hitTestArgs = getHitTestArgs(idx, e.x, e.y, targetX, targetY, currentChartBottom); - if ((checkIsClickable ?? checkIsOver)(hitTestArgs)) { + const isClickable = (checkIsClickable ?? checkIsOver)(hitTestArgs); + updateInteractionFlags(idx, e.x, e.y, currentChartBottom); + if (isClickable) { scheduleOnRN(handlePress, idx); } }); @@ -367,9 +383,9 @@ function useChartInteractions({handlePress, checkIsOver, checkIsClickable, resol setPointPositions, /** SharedValue for the currently matched data index — read on the UI thread or sync via useAnimatedReaction */ matchedIndex: chartInteractionState.matchedIndex, - /** DerivedValue that is true when the tooltip should be visible */ + /** SharedValue that is true when the tooltip should be visible */ isTooltipActive, - /** DerivedValue that is true when the cursor is over a clickable element (dot/bar, not label) */ + /** SharedValue that is true when the cursor is over a clickable element (dot/bar, not label) */ isCursorOverClickable, /** Raw tooltip positioning data */ initialTooltipPosition, From 7e35367fefa34877cfa79bec5d0eb7da1a96e617 Mon Sep 17 00:00:00 2001 From: Issa Nimaga Date: Tue, 21 Jul 2026 11:21:45 +0100 Subject: [PATCH 20/20] Remove unused Victory chart tooltip type export --- .../VictoryChartRenderer/utils/getVictoryBarTooltipData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts index 845e277550ed..31bb8ff1f5d4 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getVictoryBarTooltipData.ts @@ -46,5 +46,5 @@ function getVictoryBarTooltipData( }; } -export type {VictoryBarTooltipData, VictoryBarTooltipPoint}; +export type {VictoryBarTooltipData}; export default getVictoryBarTooltipData;