diff --git a/src/components/Charts/components/ChartTooltip.tsx b/src/components/Charts/components/ChartTooltip.tsx index d37840f2a681..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 ? `${label} • ${amount} (${percentage})` : `${label} • ${amount}`; + const content = getTooltipContent(label, amount, percentage); /** * Synchronously reset the width and hide the tooltip whenever the content changes. 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..663016c91083 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; @@ -59,8 +97,17 @@ type UseChartInteractionsProps = { /** Optional shared value containing the y-axis zero position */ yZero?: SharedValue; + + /** Scale applied to the rendered chart container */ + coordinateScale?: number; }; +function normalizeChartCoordinate(coordinate: number, coordinateScale: number): number { + 'worklet'; + + return Number.isFinite(coordinateScale) && coordinateScale > 0 ? coordinate / coordinateScale : coordinate; +} + /** * Binary search over canvas x positions to find the index of the closest data point. * Equivalent to victory-native's internal findClosestPoint utility. @@ -109,7 +156,17 @@ 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, + coordinateScale = 1, +}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState} = useChartInteractionState(); @@ -123,6 +180,9 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso * 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. @@ -136,41 +196,106 @@ 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 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}); - }); + const getHitTestArgs = (targetIndex: number, cursorX: number, cursorY: number, targetX: number, targetY: number, currentChartBottom: number): HitTestArgs => { + '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(() => { - if (isCursorOverClickable.get()) { - return true; + 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 isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false; - }); + return getHitTestArgs(targetIndex, cursorX, cursorY, targetX, targetY, currentChartBottom); + }; - /** - * 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()); + 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); + }; + + 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; + } + + 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'; + + const hitTestArgs = getCurrentHitTestArgs(); + if (!hitTestArgs) { + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); + return false; + } + + return updateInteractionFlags(hitTestArgs.targetIndex, hitTestArgs.cursorX, hitTestArgs.cursorY, hitTestArgs.chartBottom); + }; /** * Hover gesture to be placed on the full-height outer container (chart + label area). @@ -185,47 +310,43 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso .onBegin((e) => { 'worklet'; + const cursorX = normalizeChartCoordinate(e.x, coordinateScale); + const cursorY = normalizeChartCoordinate(e.y, coordinateScale); chartInteractionState.isActive.set(true); - chartInteractionState.cursor.x.set(e.x); - 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); - } + chartInteractionState.cursor.x.set(cursorX); + chartInteractionState.cursor.y.set(cursorY); + const bottom = chartBottom?.get() ?? cursorY; + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + applyTargetIndex(targetIndex); + updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); }) .onUpdate((e) => { 'worklet'; - chartInteractionState.cursor.x.set(e.x); - chartInteractionState.cursor.y.set(e.y); + const cursorX = normalizeChartCoordinate(e.x, coordinateScale); + const cursorY = normalizeChartCoordinate(e.y, coordinateScale); + chartInteractionState.cursor.x.set(cursorX); + chartInteractionState.cursor.y.set(cursorY); + const bottom = chartBottom?.get() ?? cursorY; + 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; - 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); - } + if (!isOverCurrentTarget) { + const touchX = cursorY >= bottom && resolveLabelTouchX ? resolveLabelTouchX(cursorX, cursorY) : cursorX; + const targetIndex = getResolvedTargetIndex(cursorX, cursorY, touchX); + applyTargetIndex(targetIndex); + updateInteractionFlags(targetIndex, cursorX, cursorY, bottom); } }) .onEnd(() => { 'worklet'; chartInteractionState.isActive.set(false); + isCursorOverTarget.set(false); + isCursorOverClickable.set(false); + isTooltipActive.set(false); }); /** @@ -236,30 +357,24 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso Gesture.Tap().onEnd((e) => { 'worklet'; - chartInteractionState.cursor.x.set(e.x); - chartInteractionState.cursor.y.set(e.y); + const cursorX = normalizeChartCoordinate(e.x, coordinateScale); + const cursorY = normalizeChartCoordinate(e.y, coordinateScale); + chartInteractionState.cursor.x.set(cursorX); + chartInteractionState.cursor.y.set(cursorY); const ox = pointOX.get(); const oy = pointOY.get(); - const idx = findClosestPoint(ox, e.x); + const idx = getResolvedTargetIndex(cursorX, cursorY, cursorX); + 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, cursorX, cursorY, targetX, targetY, currentChartBottom); + const isClickable = (checkIsClickable ?? checkIsOver)(hitTestArgs); + updateInteractionFlags(idx, cursorX, cursorY, currentChartBottom); + if (isClickable) { scheduleOnRN(handlePress, idx); } }); @@ -293,14 +408,14 @@ function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, reso 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, }; } -export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP}; -export type {HitTestArgs}; +export {useChartInteractions, findClosestPoint, normalizeChartCoordinate, TOOLTIP_BAR_GAP}; +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; } } 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/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 6ab55eb77307..336760a5f44c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -1,6 +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, 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'; @@ -11,6 +12,8 @@ import useTheme from '@hooks/useTheme'; import ThemeContext from '@styles/theme/context/ThemeContext'; +import type {CartesianChartRenderArg} from 'victory-native'; + import React from 'react'; import {CartesianChart} from 'victory-native'; @@ -19,15 +22,21 @@ 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; + + /** Callback invoked with render args on each chart render pass */ + 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, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -89,17 +98,21 @@ function VictoryChartCartesian({explicitSize, headless}: VictoryChartCartesianPr ); }} > - {(renderArgs) => ( - - {tnode.children.map((child) => ( - - ))} - - )} + {(renderArgs) => { + onRenderArgs?.(renderArgs); + + return ( + + {tnode.children.map((child) => ( + + ))} + + ); + }} ); } 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..c98ac6650813 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesianInteractive.tsx @@ -0,0 +1,81 @@ +/** + * 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'; +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 {GestureDetector} from 'react-native-gesture-handler'; +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, syncBarPositions, activeTooltipData, hasInteractiveBars, hasTooltipLabels, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = + useVictoryBarInteractions(); + + const updateChartWidth = (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, + })); + + if (!hasInteractiveBars) { + return ; + } + + return ( + + + + {!!activeTooltipData && hasTooltipLabels && chartWidth > 0 && ( + + + + )} + + + ); +} + +export default VictoryChartCartesianInteractive; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/VictoryChartContainerFixed.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/VictoryChartContainerFixed.tsx index d0cb82f60982..22bd2aa3d598 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/VictoryChartContainerFixed.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/VictoryChartContainerFixed.tsx @@ -1,5 +1,6 @@ import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {VictoryChartLayoutScaleProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import useTheme from '@hooks/useTheme'; @@ -53,7 +54,9 @@ function VictoryChartContainerFixed({children, layout, themeStyles}: VictoryChar return ( - {children} + + {children} + ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/index.native.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/index.native.tsx index bec5e82996be..1acb8a4c131a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/index.native.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContainer/index.native.tsx @@ -1,5 +1,6 @@ import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {VictoryChartLayoutScaleProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext'; import computeChartScale from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeChartScale'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; @@ -45,7 +46,9 @@ function VictoryChartContainer({children}: {children: React.ReactNode}) { return ( - {children} + + {children} + ); } 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; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index c3afd5446121..f51afbfbd0f9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -21,6 +21,7 @@ type VictoryChartContextValue = { categories: ProcessNodeResult['categories']; labelItems: ProcessNodeResult['labelItems']; legendItems: ProcessNodeResult['legendItems']; + pointMetadata: ProcessNodeResult['pointMetadata']; chartContentStyles: ReturnType['nodeStyles']; chartContainerStyles: ReturnType['parentNodeStyles']; type: ChartType; @@ -37,7 +38,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 parsedDesignHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; const itemCount = categories?.length ?? Object.keys(data).length; @@ -66,6 +67,7 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC padding, isHorizontal, categories, + pointMetadata, labelItems: effectiveLabelItems, legendItems: effectiveLegendItems, chartContentStyles: effectiveChartContentStyles, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext.tsx new file mode 100644 index 000000000000..65140d3ae06b --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext.tsx @@ -0,0 +1,15 @@ +import React, {createContext, useContext} from 'react'; + +const VictoryChartLayoutScaleContext = createContext(1); + +function VictoryChartLayoutScaleProvider({scale, children}: {scale: number; children: React.ReactNode}) { + return {children}; +} + +VictoryChartLayoutScaleProvider.displayName = 'VictoryChartLayoutScaleProvider'; + +function useVictoryChartLayoutScale(): number { + return useContext(VictoryChartLayoutScaleContext); +} + +export {VictoryChartLayoutScaleProvider, useVictoryChartLayoutScale}; 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..0eaee1fe536b --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/hooks/useVictoryBarInteractions.ts @@ -0,0 +1,275 @@ +/** + * Manages hover/tap interactions on Victory bar charts, providing hit-testing + * against rendered bars, tooltip state, and click-through navigation to search. + */ +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 {useVictoryChartLayoutScale} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartLayoutContext'; +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 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'; + +import ROUTES from '@src/ROUTES'; + +import type {TNode} from 'react-native-render-html'; +import type {CartesianChartRenderArg} from 'victory-native'; + +import {useState} from 'react'; +import {useAnimatedReaction, useSharedValue} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; + +type BarSeriesConfig = Partial>; + +type InteractiveBar = { + xValue: CartesianChartData[typeof X_KEY]; + yKey: YKey; + label: string; + value: number; + currency?: string; + searchQuery?: string; +}; + +function isCartesianChartData(row: CartesianChartData | PolarChartData): row is CartesianChartData { + return X_KEY in row; +} + +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; + } + const isHorizontalGroup = 'horizontal' in node.attributes && node.attributes.horizontal !== 'false'; + if (node.tagName === 'victorygroup' && !isHorizontalGroup) { + 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, + }, + }; + } + } + }; + + 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), + value: row[yKey], + currency: metadata.currency, + searchQuery: metadata.searchQuery, + }); + } + } + + 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 coordinateScale = useVictoryChartLayoutScale(); + 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); + 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 resolveBarTargetIndex = (args: ResolveTargetIndexArgs) => { + 'worklet'; + + const xs = args.pointX; + const ys = args.pointY; + const widths = pointWidth.get(); + const zero = yZero.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 isHit = isCursorInVerticalBar(args.cursorX, args.cursorY, targetX, targetY, width, zero); + + if (!isHit) { + continue; + } + + const distance = Math.abs(args.cursorX - targetX); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + + return bestIndex; + }; + + const navigateToBarSearch = (index: number) => { + const searchQuery = interactiveBars.at(index)?.searchQuery; + if (!searchQuery) { + return; + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + }; + + const {customGestures, setPointPositions, matchedIndex, isTooltipActive, isCursorOverClickable, initialTooltipPosition} = useChartInteractions({ + handlePress: navigateToBarSearch, + checkIsOver: checkIsOverBar, + checkIsClickable: checkIsClickableBar, + resolveTargetIndex: resolveBarTargetIndex, + chartBottom, + yZero, + coordinateScale, + }); + + const [activeBarIndex, setActiveBarIndex] = useState(-1); + useAnimatedReaction( + () => matchedIndex.get(), + (index) => { + scheduleOnRN(setActiveBarIndex, index); + }, + ); + + 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[] = []; + const ys: number[] = []; + const widths: number[] = []; + const searchQueryFlags: number[] = []; + + for (const bar of interactiveBars) { + const seriesPoints = points[bar.yKey]; + const point = seriesPoints?.find((candidate) => candidate.xValue === bar.xValue); + const geometry = point ? getVictoryBarInteractionGeometry(point, chartBounds, seriesPoints.length, barSeriesConfig[bar.yKey]) : undefined; + + xs.push(geometry?.x ?? 0); + ys.push(geometry?.y ?? 0); + widths.push(geometry?.width ?? 0); + searchQueryFlags.push(geometry && bar.searchQuery ? 1 : 0); + } + + setPointPositions(xs, ys); + pointWidth.set(widths); + hasSearchQuery.set(searchQueryFlags); + chartBottom.set(chartBounds.bottom); + yZero.set(yScale(0)); + }; + + return { + customGestures, + syncBarPositions, + hasInteractiveBars: interactiveBars.length > 0, + activeTooltipData, + hasTooltipLabels: interactiveBars.some((bar) => !!bar.label), + isTooltipActive, + isCursorOverClickable, + initialTooltipPosition, + }; +} + +export default useVictoryBarInteractions; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts index 2b77c8f8c4c6..8d44f9b7f822 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts @@ -26,6 +26,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) { @@ -66,6 +67,9 @@ 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 @@ -83,6 +87,7 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root categories, labelItems, legendItems, + pointMetadata, }; for (const child of tnode.children) { @@ -115,6 +120,7 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root } labelItems.push(...childResult.labelItems); legendItems.push(...childResult.legendItems); + lodashMerge(pointMetadata, childResult.pointMetadata); } return { @@ -131,6 +137,7 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null, root categories, labelItems, legendItems, + pointMetadata, }; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victorySeriesParser.ts index 628c2002730d..5aeaac8956c3 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,22 +19,42 @@ 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.currency) { + metadata.currency = point.currency; + } + 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. - 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]}; + return {data, yKeys: [yKey], pointMetadata}; } export default parseVictorySeriesNode; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 7f97895214c9..1094847ac2f0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -11,6 +11,9 @@ type VictoryChartRendererProps = CustomRendererProps; type RawChartData = { x: string | number; y: number; + label?: string; + currency?: string; + searchQuery?: string; }; type RawLegendData = { @@ -64,6 +67,14 @@ type CartesianChartData = { [key: `${YKey}`]: number; }; +type ChartPointMetadata = { + label?: string; + currency?: string; + searchQuery?: string; +}; + +type ChartPointMetadataByYKey = Partial>>; + type PolarChartData = { [LABEL_KEY]: string | number; [VALUE_KEY]: number; @@ -178,6 +189,7 @@ type ProcessNodeResult = { categories: string[] | undefined; labelItems: LabelItem[]; legendItems: LegendItem[]; + pointMetadata: ChartPointMetadataByYKey; }; /** Partial slice produced by a single per-tag parser before merging. */ @@ -197,6 +209,7 @@ export type { RawShiftedLineSegmentStyle, YKey, CartesianChartData, + ChartPointMetadata, CartesianChartProps, TextAnchor, ResolvedPieLabel, 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/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}; 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..31bb8ff1f5d4 --- /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}; +export default getVictoryBarTooltipData; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts index a411f16a6c40..d6aba0a94d7b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseRawChartData.ts @@ -16,10 +16,20 @@ 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 ('currency' in parsedData && typeof parsedData.currency === 'string') { + rawChartData.currency = parsedData.currency; + } + if ('searchQuery' in parsedData && typeof parsedData.searchQuery === 'string') { + rawChartData.searchQuery = parsedData.searchQuery; + } + data.push(rawChartData); } } } diff --git a/tests/unit/components/Charts/useChartInteractions.test.ts b/tests/unit/components/Charts/useChartInteractions.test.ts index 9932150207e2..0f5ffb8cb1b9 100644 --- a/tests/unit/components/Charts/useChartInteractions.test.ts +++ b/tests/unit/components/Charts/useChartInteractions.test.ts @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-native'; -import {findClosestPoint, useChartInteractions} from '@components/Charts/hooks/useChartInteractions'; +import {findClosestPoint, normalizeChartCoordinate, useChartInteractions} from '@components/Charts/hooks/useChartInteractions'; import {useSharedValue} from 'react-native-reanimated'; @@ -90,6 +90,21 @@ describe('findClosestPoint', () => { }); }); +describe('normalizeChartCoordinate', () => { + it('leaves coordinates unchanged at the design scale', () => { + expect(normalizeChartCoordinate(120, 1)).toBe(120); + }); + + it('maps rendered coordinates back to design coordinates', () => { + expect(normalizeChartCoordinate(120, 0.5)).toBe(240); + }); + + it('leaves coordinates unchanged when the scale is invalid', () => { + expect(normalizeChartCoordinate(120, 0)).toBe(120); + expect(normalizeChartCoordinate(120, Number.NaN)).toBe(120); + }); +}); + /** * useChartInteractions hook */ diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts index d55b9e4eefa5..efbf8f9c18c6 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartAttributesParsers.test.ts @@ -12,12 +12,25 @@ 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, currency: 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: 'January 2026', currency: 'USD', searchQuery: 'type:expense date>=2026-01-01 date<2026-02-01'}]"); + expect(result).toEqual([ + { + x: 1, + y: 20, + label: 'January 2026', + currency: 'USD', + 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..ba91c7bb8518 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; + for (const [index, child] of children.entries()) { + 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,66 @@ 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: '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: 'January 2026', + currency: 'USD', + 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); + const numberOneKey = 'number:1'; + const numberTwoKey = 'number:2'; + + expect(result.pointMetadata).toEqual({ + y0: { + [numberOneKey]: { + label: 'Fallback Jan', + }, + [numberTwoKey]: { + 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); + const firstSeriesKey = 'y0-0'; + const secondSeriesKey = 'y0-1'; + const numberOneKey = 'number:1'; + + expect(result.pointMetadata).toEqual({ + [firstSeriesKey]: { + [numberOneKey]: { + label: 'Current Jan', + }, + }, + [secondSeriesKey]: { + [numberOneKey]: { + label: 'Prior Jan', + }, + }, + }); + }); }); describe('victoryPieParser', () => { 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); + }); +});