diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx
index d563e4bae0463..41a0ee56a6a00 100644
--- a/src/components/Charts/BarChart/BarChartContent.tsx
+++ b/src/components/Charts/BarChart/BarChartContent.tsx
@@ -2,6 +2,7 @@ import {useFont} from '@shopify/react-native-skia';
import React, {useState} from 'react';
import type {LayoutChangeEvent} from 'react-native';
import {View} from 'react-native';
+import {GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native';
import {Bar, CartesianChart} from 'victory-native';
@@ -9,12 +10,20 @@ import ActivityIndicator from '@components/ActivityIndicator';
import ChartHeader from '@components/Charts/components/ChartHeader';
import ChartTooltip from '@components/Charts/components/ChartTooltip';
import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels';
-import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants';
+import {
+ AXIS_LABEL_GAP,
+ CHART_CONTENT_MIN_HEIGHT,
+ CHART_PADDING,
+ DIAGONAL_ANGLE_RADIAN_THRESHOLD,
+ X_AXIS_LINE_WIDTH,
+ Y_AXIS_LINE_WIDTH,
+ Y_AXIS_TICK_COUNT,
+} from '@components/Charts/constants';
import fontSource from '@components/Charts/font';
-import type {HitTestArgs} from '@components/Charts/hooks';
-import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks';
+import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks';
+import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks';
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
-import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils';
+import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -28,6 +37,35 @@ const BAR_INNER_PADDING = 0.3;
*/
const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0};
+/**
+ * Bar chart geometry for label hit-testing.
+ * Labels are center-anchored: the 45° parallelogram's upper-right corner is offset
+ * by (halfLabelWidth * sinA) right and up, so the box straddles the tick symmetrically.
+ */
+const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
+ const maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0;
+ const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0;
+ const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA - variables.iconSizeExtraSmall / 3);
+ const halfWidths = labelWidths.map((w) => w / 2);
+ let additionalOffset = 0;
+ if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) {
+ additionalOffset = variables.iconSizeExtraSmall / 1.5;
+ } else if (angleRad > 1) {
+ additionalOffset = variables.iconSizeExtraSmall / 3;
+ }
+ return {
+ // variables.iconSizeExtraSmall / 3 is the vertical offset of label from the axis line
+ labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset,
+ iconSin: variables.iconSizeExtraSmall * sinA,
+ labelSins: labelWidths.map((w) => w * sinA),
+ halfWidths,
+ cornerAnchorDX: halfLabelSins,
+ cornerAnchorDY: halfLabelSins.map((v) => -v),
+ yMin90Offsets: halfWidths.map((hw) => -hw + padding),
+ yMax90Offsets: halfWidths.map((hw) => hw + padding),
+ };
+};
+
type BarChartProps = CartesianChartProps & {
/** Callback when a bar is pressed */
onBarPress?: (dataPoint: ChartDataPoint, index: number) => void;
@@ -99,6 +137,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
const chartBottom = useSharedValue(0);
const yZero = useSharedValue(0);
+ const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({
+ font,
+ truncatedLabels,
+ labelRotation,
+ labelSkipInterval,
+ chartBottom,
+ computeGeometry: computeBarLabelGeometry,
+ });
+
const handleChartBoundsChange = (bounds: ChartBounds) => {
const domainWidth = bounds.right - bounds.left;
const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length;
@@ -110,10 +157,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
setBoundsRight(bounds.right);
};
- const handleScaleChange = (_xScale: Scale, yScale: Scale) => {
- yZero.set(yScale(0));
- };
-
const checkIsOverBar = (args: HitTestArgs) => {
'worklet';
@@ -124,19 +167,31 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
}
const barLeft = args.targetX - currentBarWidth / 2;
const barRight = args.targetX + currentBarWidth / 2;
+
const barTop = Math.min(args.targetY, currentYZero);
const barBottom = Math.max(args.targetY, currentYZero);
return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom;
};
- const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
+ const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
handlePress: handleBarPress,
checkIsOver: checkIsOverBar,
+ isCursorOverLabel,
+ resolveLabelTouchX: findLabelCursorX,
chartBottom,
yZero,
});
+ const handleScaleChange = (xScale: Scale, yScale: Scale) => {
+ yZero.set(yScale(0));
+ updateTickPositions(xScale, data.length);
+ setPointPositions(
+ chartData.map((point) => xScale(point.x)),
+ chartData.map((point) => yScale(point.y)),
+ );
+ };
+
const tooltipData = useTooltipData(activeDataIndex, data, formatValue);
const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => {
@@ -197,53 +252,53 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni
title={title}
titleIcon={titleIcon}
/>
-
- {chartWidth > 0 && (
-
- {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}>}
-
- )}
- {isTooltipActive && !!tooltipData && (
-
- )}
-
+
+
+ {chartWidth > 0 && (
+
+ {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}>}
+
+ )}
+ {isTooltipActive && !!tooltipData && (
+
+ )}
+
+
);
}
diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx
index 12d8cbe458b57..12d121d446fe0 100644
--- a/src/components/Charts/LineChart/LineChartContent.tsx
+++ b/src/components/Charts/LineChart/LineChartContent.tsx
@@ -2,7 +2,9 @@ import {useFont} from '@shopify/react-native-skia';
import React, {useState} from 'react';
import type {LayoutChangeEvent} from 'react-native';
import {View} from 'react-native';
-import type {CartesianChartRenderArg, ChartBounds} from 'victory-native';
+import {GestureDetector} from 'react-native-gesture-handler';
+import {useSharedValue} from 'react-native-reanimated';
+import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native';
import {CartesianChart, Line} from 'victory-native';
import ActivityIndicator from '@components/ActivityIndicator';
import ChartHeader from '@components/Charts/components/ChartHeader';
@@ -10,12 +12,20 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip';
import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels';
import LeftFrameLine from '@components/Charts/components/LeftFrameLine';
import ScatterPoints from '@components/Charts/components/ScatterPoints';
-import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants';
+import {
+ AXIS_LABEL_GAP,
+ CHART_CONTENT_MIN_HEIGHT,
+ CHART_PADDING,
+ DIAGONAL_ANGLE_RADIAN_THRESHOLD,
+ X_AXIS_LINE_WIDTH,
+ Y_AXIS_LINE_WIDTH,
+ Y_AXIS_TICK_COUNT,
+} from '@components/Charts/constants';
import fontSource from '@components/Charts/font';
-import type {HitTestArgs} from '@components/Charts/hooks';
-import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks';
+import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks';
+import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks';
import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types';
-import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth} from '@components/Charts/utils';
+import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -33,6 +43,31 @@ const MIN_SAFE_PADDING = DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS;
/** Base domain padding applied to all sides */
const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0};
+/**
+ * Line chart geometry for label hit-testing.
+ * Labels are start-anchored at the tick: the 45° parallelogram's upper-right corner is
+ * offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line.
+ */
+const computeLineLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => {
+ const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA;
+ let additionalOffset = 0;
+ if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) {
+ additionalOffset = variables.iconSizeExtraSmall / 1.5;
+ } else if (angleRad > 1) {
+ additionalOffset = variables.iconSizeExtraSmall / 3;
+ }
+ return {
+ labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - additionalOffset,
+ iconSin: variables.iconSizeExtraSmall * sinA,
+ labelSins: labelWidths.map((w) => w * sinA),
+ halfWidths: labelWidths.map((w) => w / 2),
+ cornerAnchorDX: labelWidths.map(() => -iconThirdSin),
+ cornerAnchorDY: labelWidths.map(() => iconThirdSin),
+ yMin90Offsets: labelWidths.map(() => padding),
+ yMax90Offsets: labelWidths.map((w) => w + padding),
+ };
+};
+
type LineChartProps = CartesianChartProps & {
/** Callback when a data point is pressed */
onPointPress?: (dataPoint: ChartDataPoint, index: number) => void;
@@ -68,11 +103,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
setChartWidth(event.nativeEvent.layout.width);
};
- const handleChartBoundsChange = (bounds: ChartBounds) => {
- setPlotAreaWidth(bounds.right - bounds.left);
- setBoundsLeft(bounds.left);
- setBoundsRight(bounds.right);
- };
+ const chartBottom = useSharedValue(0);
const domainPadding = (() => {
if (chartWidth === 0 || data.length === 0) {
@@ -125,6 +156,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
unitPosition: yAxisUnitPosition,
});
+ const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({
+ font,
+ truncatedLabels,
+ labelRotation,
+ labelSkipInterval,
+ chartBottom,
+ computeGeometry: computeLineLabelGeometry,
+ });
+
+ const handleChartBoundsChange = (bounds: ChartBounds) => {
+ setPlotAreaWidth(bounds.right - bounds.left);
+ setBoundsLeft(bounds.left);
+ setBoundsRight(bounds.right);
+ chartBottom.set(bounds.bottom);
+ };
+
const checkIsOverDot = (args: HitTestArgs) => {
'worklet';
@@ -133,11 +180,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS;
};
- const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
+ const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({
handlePress: handlePointPress,
checkIsOver: checkIsOverDot,
+ isCursorOverLabel,
+ resolveLabelTouchX: findLabelCursorX,
+ chartBottom,
});
+ const handleScaleChange = (xScale: Scale, yScale: Scale) => {
+ updateTickPositions(xScale, data.length);
+ setPointPositions(
+ chartData.map((point) => xScale(point.x)),
+ chartData.map((point) => yScale(point.y)),
+ );
+ };
+
const tooltipData = useTooltipData(activeDataIndex, data, formatValue);
const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => {
@@ -191,59 +249,60 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn
title={title}
titleIcon={titleIcon}
/>
-
- {chartWidth > 0 && (
-
- {({points}) => (
-
- )}
-
- )}
- {isTooltipActive && !!tooltipData && (
-
- )}
-
+
+
+ {chartWidth > 0 && (
+
+ {({points}) => (
+
+ )}
+
+ )}
+ {isTooltipActive && !!tooltipData && (
+
+ )}
+
+
);
}
diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx
index c95c174547892..483ecbcb7ad97 100644
--- a/src/components/Charts/PieChart/PieChartContent.tsx
+++ b/src/components/Charts/PieChart/PieChartContent.tsx
@@ -43,18 +43,18 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, valueUni
setCanvasHeight(event.nativeEvent.layout.height);
};
+ // Calculate pie geometry
+ const pieGeometry = {radius: Math.min(canvasWidth, canvasHeight) / 2, centerX: canvasWidth / 2, centerY: canvasHeight / 2};
+
// Slices are sorted by absolute value (largest first) for color assignment,
// so slice indices don't match the original data array. We map back via
// originalIndex so the tooltip can display the original (possibly negative) value.
- const processedSlices = processDataIntoSlices(data, PIE_CHART_START_ANGLE);
+ const processedSlices = processDataIntoSlices(data, PIE_CHART_START_ANGLE, pieGeometry);
const activeOriginalDataIndex = activeSliceIndex >= 0 ? (processedSlices.at(activeSliceIndex)?.originalIndex ?? -1) : -1;
const {formatValue} = useChartLabelFormats({data, unit: valueUnit, unitPosition: valueUnitPosition});
const tooltipData = useTooltipData(activeOriginalDataIndex, data, formatValue);
- // Calculate pie geometry
- const pieGeometry = {radius: Math.min(canvasWidth, canvasHeight) / 2, centerX: canvasWidth / 2, centerY: canvasHeight / 2};
-
// Handle hover state updates
const updateActiveSlice = (x: number, y: number) => {
const {radius, centerX, centerY} = pieGeometry;
@@ -125,6 +125,13 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, valueUni
{
+ tooltipPosition.set(slice.tooltipPosition);
+ setActiveSliceIndex(slice.ordinalIndex);
+ }}
+ onMouseLeave={() => {
+ setActiveSliceIndex(-1);
+ }}
>
{slice.label}
diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts
index 8a952af70b641..2f175d5798c44 100644
--- a/src/components/Charts/constants.ts
+++ b/src/components/Charts/constants.ts
@@ -36,6 +36,11 @@ const ELLIPSIS = '...';
/** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */
const MIN_TRUNCATED_CHARS = 10;
+/** Radian threshold separating diagonal from vertical label hit-test */
+const DIAGONAL_ANGLE_RADIAN_THRESHOLD = 1;
+
+const PIE_CHART_TOOLTIP_RADIUS_DISTANCE = 2 / 3;
+
export {
Y_AXIS_TICK_COUNT,
AXIS_LABEL_GAP,
@@ -49,4 +54,6 @@ export {
LABEL_PADDING,
ELLIPSIS,
MIN_TRUNCATED_CHARS,
+ DIAGONAL_ANGLE_RADIAN_THRESHOLD,
+ PIE_CHART_TOOLTIP_RADIUS_DISTANCE,
};
diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts
index 962dd8188f397..6a12830acb06a 100644
--- a/src/components/Charts/hooks/index.ts
+++ b/src/components/Charts/hooks/index.ts
@@ -4,3 +4,5 @@ export type {HitTestArgs} from './useChartInteractions';
export {default as useChartLabelFormats} from './useChartLabelFormats';
export {default as useDynamicYDomain} from './useDynamicYDomain';
export {useTooltipData} from './useTooltipData';
+export {default as useLabelHitTesting} from './useLabelHitTesting';
+export type {ComputeGeometryFn, ComputeGeometryInput} from './useLabelHitTesting';
diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts
index 62bd4c218a05b..6bcaabffb5251 100644
--- a/src/components/Charts/hooks/useChartInteractions.ts
+++ b/src/components/Charts/hooks/useChartInteractions.ts
@@ -1,7 +1,7 @@
-import {useMemo, useRef, useState} from 'react';
+import {useCallback, useMemo, useState} from 'react';
import {Gesture} from 'react-native-gesture-handler';
import type {SharedValue} from 'react-native-reanimated';
-import {useAnimatedReaction, useDerivedValue} from 'react-native-reanimated';
+import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';
import {useChartInteractionState} from './useChartInteractionState';
@@ -14,12 +14,16 @@ const TOOLTIP_BAR_GAP = 8;
type HitTestArgs = {
/** Current raw X position of the cursor */
cursorX: number;
+
/** Current raw Y position of the cursor */
cursorY: number;
+
/** Calculated X position of the matched data point */
targetX: number;
+
/** Calculated Y position of the matched data point */
targetY: number;
+
/** The bottom boundary of the chart area */
chartBottom: number;
};
@@ -30,41 +34,112 @@ type HitTestArgs = {
type UseChartInteractionsProps = {
/** Callback triggered when a valid data point is tapped/clicked */
handlePress: (index: number) => void;
+
/**
* Worklet function to determine if the cursor is technically "hovering"
* over a specific chart element (e.g., within a bar's width or a point's radius).
*/
checkIsOver: (args: HitTestArgs) => boolean;
+
+ /** Worklet function to determine if the cursor is hovering over the label area */
+ isCursorOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean;
+
+ /**
+ * Optional worklet that, when the cursor is in the label area, scans all label bounding
+ * boxes and returns the tick X of the label the cursor is inside (or the raw cursor X if
+ * no label matches). Used to correct Victory's nearest-point-by-X algorithm for rotated
+ * labels whose bounding boxes extend past the midpoint between adjacent ticks.
+ */
+ resolveLabelTouchX?: (cursorX: number, cursorY: number) => number;
+
/** Optional shared value containing bar dimensions used for hit-testing in bar charts */
chartBottom?: SharedValue;
+
+ /** Optional shared value containing the y-axis zero position */
yZero?: SharedValue;
};
/**
- * Type for Victory's actionsRef handle.
- * Used to manually trigger Victory's internal touch handling logic.
+ * Binary search over canvas x positions to find the index of the closest data point.
+ * Equivalent to victory-native's internal findClosestPoint utility.
*/
-type CartesianActionsHandle = {
- handleTouch: (state: unknown, x: number, y: number) => void;
-};
+function findClosestPoint(xValues: number[], targetX: number): number {
+ 'worklet';
+
+ const n = xValues.length;
+ if (n === 0) {
+ return -1;
+ }
+ if (targetX <= (xValues.at(0) ?? 0)) {
+ return 0;
+ }
+ if (targetX >= (xValues.at(-1) ?? 0)) {
+ return n - 1;
+ }
+ let lo = 0;
+ let hi = n;
+ let mid = 0;
+ while (lo < hi) {
+ mid = Math.floor((lo + hi) / 2);
+ const midVal = xValues.at(mid) ?? 0;
+ if (midVal === targetX) {
+ return mid;
+ }
+ if (targetX < midVal) {
+ if (mid > 0 && targetX > (xValues.at(mid - 1) ?? 0)) {
+ const prevVal = xValues.at(mid - 1) ?? 0;
+ return targetX - prevVal >= midVal - targetX ? mid : mid - 1;
+ }
+ hi = mid;
+ } else {
+ if (mid < n - 1 && targetX < (xValues.at(mid + 1) ?? 0)) {
+ const nextVal = xValues.at(mid + 1) ?? 0;
+ return targetX - midVal >= nextVal - targetX ? mid + 1 : mid;
+ }
+ lo = mid + 1;
+ }
+ }
+ return mid;
+}
/**
* Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning.
+ * 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, chartBottom, yZero}: UseChartInteractionsProps) {
+function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) {
/** Interaction state compatible with Victory Native's internal logic */
const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState();
- /** Ref passed to CartesianChart to allow manual touch injection */
- const actionsRef = useRef(null);
-
/** React state for the index of the point currently being interacted with */
const [activeDataIndex, setActiveDataIndex] = useState(-1);
/** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */
const [isOverTarget, setIsOverTarget] = useState(false);
+ /**
+ * Canvas-space x positions for each data point, set by the chart content via setPointPositions.
+ * These replace Victory's internal tData.ox array, enabling worklet-safe nearest-point lookup.
+ */
+ const pointOX = useSharedValue([]);
+
+ /**
+ * Canvas-space y positions for each data point, set by the chart content via setPointPositions.
+ */
+ const pointOY = useSharedValue([]);
+
+ /**
+ * Called by chart content from handleScaleChange to populate canvas positions.
+ * Must be called with the positions derived from the current d3 scale.
+ */
+ const setPointPositions = useCallback(
+ (ox: number[], oy: number[]) => {
+ pointOX.set(ox);
+ pointOY.set(oy);
+ },
+ [pointOX, pointOY],
+ );
+
/**
* Derived value performing the hit-test on the UI thread.
* Runs whenever cursor position or matched data points change.
@@ -76,14 +151,16 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
const targetY = chartInteractionState.y.y.position.get();
const currentChartBottom = chartBottom?.get() ?? 0;
-
- return checkIsOver({
- cursorX,
- cursorY,
- targetX,
- targetY,
- chartBottom: currentChartBottom,
- });
+ return (
+ checkIsOver({
+ cursorX,
+ cursorY,
+ targetX,
+ targetY,
+ chartBottom: currentChartBottom,
+ }) ||
+ (isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false)
+ );
});
/** Syncs the matched data index from the UI thread to React state */
@@ -103,8 +180,12 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
);
/**
- * Hover gesture configuration.
- * Primarily used for web/desktop to track mouse movement without clicking.
+ * Hover gesture to be placed on the full-height outer container (chart + label area).
+ * Clamps the y coordinate to chartBottom before passing to Victory so that hovering
+ * over x-axis labels below the plot area still resolves the nearest data point.
+ * This gesture is returned separately and must NOT be passed to CartesianChart's
+ * customGestures prop, because Victory's internal GestureHandler view only covers
+ * the plot area and would drop events from the label area.
*/
const hoverGesture = useMemo(
() =>
@@ -115,60 +196,87 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
chartInteractionState.isActive.set(true);
chartInteractionState.cursor.x.set(e.x);
chartInteractionState.cursor.y.set(e.y);
- actionsRef.current?.handleTouch(chartInteractionState, e.x, 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);
+ }
})
.onUpdate((e) => {
'worklet';
chartInteractionState.cursor.x.set(e.x);
chartInteractionState.cursor.y.set(e.y);
- actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y);
+ // 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);
+ }
+ }
})
.onEnd(() => {
'worklet';
chartInteractionState.isActive.set(false);
}),
- [chartInteractionState],
+ [chartInteractionState, chartBottom, isCursorOverTarget, resolveLabelTouchX, pointOX, pointOY],
);
/**
- * Tap gesture configuration.
- * Handles clicks/touches and triggers handlePress if Victory matched a data point.
+ * Tap gesture. Resolves the nearest data point entirely on the UI thread,
+ * then schedules handlePress on the JS thread if the cursor is over the target.
*/
const tapGesture = useMemo(
() =>
Gesture.Tap().onEnd((e) => {
'worklet';
- // Update cursor position
chartInteractionState.cursor.x.set(e.x);
chartInteractionState.cursor.y.set(e.y);
-
- // Let Victory calculate which data point was tapped
- actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y);
- const matchedIndex = chartInteractionState.matchedIndex.get();
-
- // If Victory matched a valid data point, trigger the press handler
+ const ox = pointOX.get();
+ const oy = pointOY.get();
+ const idx = findClosestPoint(ox, e.x);
+ 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 (
- matchedIndex >= 0 &&
checkIsOver({
cursorX: e.x,
cursorY: e.y,
- targetX: chartInteractionState.x.position.get(),
- targetY: chartInteractionState.y.y.position.get(),
- chartBottom: chartBottom?.get() ?? 0,
+ targetX,
+ targetY,
+ chartBottom: currentChartBottom,
})
) {
- scheduleOnRN(handlePress, matchedIndex);
+ scheduleOnRN(handlePress, idx);
}
}),
- [chartInteractionState, checkIsOver, chartBottom, handlePress],
+ [chartInteractionState, pointOX, pointOY, chartBottom, checkIsOver, handlePress],
);
- /** Combined gesture object to be passed to CartesianChart's customGestures prop */
- const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]);
-
/**
* Raw tooltip positioning data.
* We return these as individual derived values so the caller can
@@ -186,11 +294,16 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
};
});
+ const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]);
+
return {
- /** Ref to be passed to CartesianChart */
- actionsRef,
- /** Gestures to be passed to CartesianChart */
+ /** Custom gestures to be passed to CartesianChart */
customGestures,
+ /**
+ * Call this from handleScaleChange with the canvas x/y positions of each data point.
+ * Derived from the d3 scale: ox[i] = xScale(i), oy[i] = yScale(data[i].total).
+ */
+ setPointPositions,
/** The currently active data index (React state) */
activeDataIndex,
/** Whether the tooltip should currently be rendered and visible */
@@ -200,5 +313,5 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us
};
}
-export {useChartInteractions, TOOLTIP_BAR_GAP};
+export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP};
export type {HitTestArgs};
diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts
new file mode 100644
index 0000000000000..3065f038942a4
--- /dev/null
+++ b/src/components/Charts/hooks/useLabelHitTesting.ts
@@ -0,0 +1,194 @@
+import type {SkFont} from '@shopify/react-native-skia';
+import {useMemo} from 'react';
+import type {SharedValue} from 'react-native-reanimated';
+import {useSharedValue} from 'react-native-reanimated';
+import type {Scale} from 'victory-native';
+import {DIAGONAL_ANGLE_RADIAN_THRESHOLD} from '@components/Charts/constants';
+import type {LabelRotation} from '@components/Charts/types';
+import {isCursorOverChartLabel, measureTextWidth} from '@components/Charts/utils';
+import variables from '@styles/variables';
+import type {HitTestArgs} from './useChartInteractions';
+
+type LabelHitGeometry = {
+ /** Constant vertical offset from chartBottom to the label Y baseline */
+ labelYOffset: number;
+
+ /** iconSize * sin(angle) — diagonal step from upper to lower corner */
+ iconSin: number;
+
+ /** Per-label: labelWidth * sin(angle) — left-corner offset for the 45° parallelogram */
+ labelSins: number[];
+
+ /** Per-label: labelWidth / 2 — half-extent for 0° and 90° hit bounds */
+ halfWidths: number[];
+
+ /** Per-label: rightUpperCorner.x = targetX + cornerAnchorDX[i] */
+ cornerAnchorDX: number[];
+
+ /** Per-label: rightUpperCorner.y = labelY + cornerAnchorDY[i] */
+ cornerAnchorDY: number[];
+
+ /** Per-label: yMin90 = labelY + yMin90Offsets[i] */
+ yMin90Offsets: number[];
+
+ /** Per-label: yMax90 = labelY + yMax90Offsets[i] */
+ yMax90Offsets: number[];
+};
+
+type ComputeGeometryInput = {
+ /** The ascent of the font */
+ ascent: number;
+
+ /** The descent of the font */
+ descent: number;
+
+ /** The sine of the angle */
+ sinA: number;
+
+ /** The angle in radians */
+ angleRad: number;
+
+ /** The widths of the labels */
+ labelWidths: number[];
+
+ /** The padding of the labels */
+ padding: number;
+};
+
+type ComputeGeometryFn = (input: ComputeGeometryInput) => LabelHitGeometry;
+
+type UseLabelHitTestingParams = {
+ font: SkFont | null | undefined;
+ truncatedLabels: string[];
+ labelRotation: LabelRotation;
+ labelSkipInterval: number;
+ chartBottom: SharedValue;
+
+ /**
+ * Chart-specific geometry factory.
+ * Receives font metrics, trig values, and per-label widths; returns the
+ * normalized geometry shape. Define as a module-level constant to keep
+ * the useMemo dependency stable.
+ */
+ computeGeometry: ComputeGeometryFn;
+};
+
+/**
+ * Shared hook for x-axis label hit-testing in cartesian charts.
+ *
+ * Encapsulates label width measurement, angle conversion, pre-computed hit geometry,
+ * and the isCursorOverLabel / findLabelCursorX worklets — all of which are identical
+ * between bar and line chart except for how the hit geometry is computed.
+ *
+ * Chart-specific geometry (45° corner anchor offsets, 90° vertical bounds) is supplied
+ * via the `computeGeometry` callback, which should be a stable module-level constant.
+ */
+function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInterval, chartBottom, computeGeometry}: UseLabelHitTestingParams) {
+ const tickXPositions = useSharedValue([]);
+
+ const labelWidths = useMemo(() => {
+ if (!font) {
+ return [] as number[];
+ }
+ return truncatedLabels.map((label) => measureTextWidth(label, font));
+ }, [font, truncatedLabels]);
+
+ const angleRad = (Math.abs(labelRotation) * Math.PI) / 180;
+
+ /**
+ * Pre-computed geometry for label hit-testing.
+ * All per-label arrays and trig values are resolved once per layout/rotation change
+ * rather than on every hover event. The `computeGeometry` callback supplies the
+ * chart-specific differences (bar vs. line anchor offsets).
+ */
+ const labelHitGeometry = useMemo((): LabelHitGeometry | null => {
+ if (!font) {
+ return null;
+ }
+ const metrics = font.getMetrics();
+ const ascent = Math.abs(metrics.ascent);
+ const descent = Math.abs(metrics.descent);
+ const sinA = Math.sin(angleRad);
+ const padding = variables.iconSizeExtraSmall / 2;
+ return computeGeometry({ascent, descent, sinA, angleRad, labelWidths, padding});
+ }, [font, angleRad, labelWidths, computeGeometry]);
+
+ /**
+ * Hit-tests whether the cursor is over the x-axis label at `activeIndex`.
+ * Supports 0°, ~45° (parallelogram), and 90° label orientations.
+ */
+ const isCursorOverLabel = (args: HitTestArgs, activeIndex: number): boolean => {
+ 'worklet';
+
+ if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) {
+ return false;
+ }
+
+ const {labelYOffset, iconSin, labelSins, halfWidths, cornerAnchorDX, cornerAnchorDY, yMin90Offsets, yMax90Offsets} = labelHitGeometry;
+ const padding = variables.iconSizeExtraSmall / 2;
+ const halfWidth = halfWidths.at(activeIndex) ?? 0;
+ const labelY = args.chartBottom + labelYOffset;
+
+ let corners45: Array<{x: number; y: number}> | undefined;
+ if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) {
+ const labelSin = labelSins.at(activeIndex) ?? 0;
+ const anchorDX = cornerAnchorDX.at(activeIndex) ?? 0;
+ const anchorDY = cornerAnchorDY.at(activeIndex) ?? 0;
+ const rightUpperCorner = {x: args.targetX + anchorDX, y: labelY + anchorDY};
+ const rightLowerCorner = {x: rightUpperCorner.x + iconSin, y: rightUpperCorner.y + iconSin};
+ const leftUpperCorner = {x: rightUpperCorner.x - labelSin, y: rightUpperCorner.y + labelSin};
+ const leftLowerCorner = {x: rightLowerCorner.x - labelSin, y: rightLowerCorner.y + labelSin};
+ corners45 = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner];
+ }
+
+ return isCursorOverChartLabel({
+ cursorX: args.cursorX,
+ cursorY: args.cursorY,
+ targetX: args.targetX,
+ labelY,
+ angleRad,
+ halfWidth,
+ padding,
+ corners45,
+ yMin90: labelY + (yMin90Offsets.at(activeIndex) ?? 0),
+ yMax90: labelY + (yMax90Offsets.at(activeIndex) ?? 0),
+ });
+ };
+
+ /**
+ * Scans every visible label's bounding box using its own tick X as the anchor.
+ * Returns that tick's X position when the cursor is inside, otherwise returns
+ * the raw cursor X unchanged.
+ * Used to correct Victory's nearest-point-by-X algorithm for rotated labels whose
+ * bounding boxes can extend past the midpoint to the adjacent tick.
+ */
+ const findLabelCursorX = (cursorX: number, cursorY: number): number => {
+ 'worklet';
+
+ const positions = tickXPositions.get();
+ const currentChartBottom = chartBottom.get();
+ for (let i = 0; i < positions.length; i++) {
+ if (i % labelSkipInterval !== 0) {
+ continue;
+ }
+ const tickX = positions.at(i);
+ if (tickX === undefined) {
+ continue;
+ }
+ if (isCursorOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) {
+ return tickX;
+ }
+ }
+ return cursorX;
+ };
+
+ /** Updates the tick X positions from the chart's x scale. Call from `onScaleChange`. */
+ const updateTickPositions = (xScale: Scale, dataLength: number) => {
+ tickXPositions.set(Array.from({length: dataLength}, (_, i) => xScale(i)));
+ };
+
+ return {isCursorOverLabel, findLabelCursorX, updateTickPositions};
+}
+
+export default useLabelHitTesting;
+export type {ComputeGeometryFn, ComputeGeometryInput, LabelHitGeometry};
diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts
index 72c7f008c49fd..c7654c2c41f8e 100644
--- a/src/components/Charts/types.ts
+++ b/src/components/Charts/types.ts
@@ -65,6 +65,12 @@ type PieSlice = {
/** Index in the original unsorted data array, used to map back for tooltips */
originalIndex: number;
+
+ /** Ordinal position in the processed slice list (0 = largest slice). */
+ ordinalIndex: number;
+
+ /** Position of the tooltip on label hover. */
+ tooltipPosition: {x: number; y: number};
};
type LabelRotation = ValueOf;
diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts
index 07a2de4d7589b..2eec6d6617318 100644
--- a/src/components/Charts/utils.ts
+++ b/src/components/Charts/utils.ts
@@ -1,6 +1,6 @@
import type {SkFont} from '@shopify/react-native-skia';
import colors from '@styles/theme/colors';
-import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from './constants';
+import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, PIE_CHART_TOOLTIP_RADIUS_DISTANCE, SIN_45} from './constants';
import type {ChartDataPoint, LabelRotation, PieSlice} from './types';
/**
@@ -152,7 +152,7 @@ function findSliceAtPosition(cursorX: number, cursorY: number, centerX: number,
/**
* Process raw data into pie chart slices sorted by absolute value descending.
*/
-function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieSlice[] {
+function processDataIntoSlices(data: ChartDataPoint[], startAngle: number, pieGeometry: {centerX: number; centerY: number; radius: number}): PieSlice[] {
const total = data.reduce((sum, point) => sum + Math.abs(point.total), 0);
if (total === 0) {
return [];
@@ -165,6 +165,9 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieS
(acc, slice, index) => {
const fraction = slice.absTotal / total;
const sweepAngle = fraction * 360;
+ const angle = acc.angle + sweepAngle / 2;
+ const tooltipX = pieGeometry.centerX + pieGeometry.radius * PIE_CHART_TOOLTIP_RADIUS_DISTANCE * Math.cos((angle * Math.PI) / 180);
+ const tooltipY = pieGeometry.centerY + pieGeometry.radius * PIE_CHART_TOOLTIP_RADIUS_DISTANCE * Math.sin((angle * Math.PI) / 180);
acc.slices.push({
label: slice.label,
value: slice.absTotal,
@@ -173,6 +176,8 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieS
startAngle: acc.angle,
endAngle: acc.angle + sweepAngle,
originalIndex: slice.originalIndex,
+ ordinalIndex: index,
+ tooltipPosition: {x: tooltipX, y: tooltipY},
});
acc.angle += sweepAngle;
return acc;
@@ -283,6 +288,63 @@ function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: Labe
}
return Infinity;
}
+// Point-in-convex-polygon test using cross products
+// Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper
+function isCursorInSkewedLabel(cursorX: number, cursorY: number, corners: Array<{x: number; y: number}>): boolean {
+ 'worklet';
+
+ let sign = 0;
+ for (let i = 0; i < corners.length; i++) {
+ const a = corners.at(i);
+ const b = corners.at((i + 1) % corners.length);
+ if (a == null || b == null) {
+ continue;
+ }
+ const cross = (b.x - a.x) * (cursorY - a.y) - (b.y - a.y) * (cursorX - a.x);
+ if (cross !== 0) {
+ const crossSign = cross > 0 ? 1 : -1;
+ if (sign === 0) {
+ sign = crossSign;
+ } else if (crossSign !== sign) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+/** Params for axis-aligned and 45° label hit-test; 90° uses yMin90/yMax90. */
+type ChartLabelHitTestParams = {
+ cursorX: number;
+ cursorY: number;
+ targetX: number;
+ labelY: number;
+ angleRad: number;
+ halfWidth: number;
+ padding: number;
+ /** For 45°: corners [rightUpper, rightLower, leftLower, leftUpper]. */
+ corners45?: Array<{x: number; y: number}>;
+ /** For 90° vertical label: vertical bounds. */
+ yMin90: number;
+ yMax90: number;
+};
+
+/**
+ * Shared hit-test for chart x-axis labels at 0°, 45°, or 90°.
+ * Used by BarChart and LineChart to detect cursor over rotated labels.
+ */
+function isCursorOverChartLabel({cursorX, cursorY, targetX, labelY, angleRad, halfWidth, padding, corners45, yMin90, yMax90}: ChartLabelHitTestParams): boolean {
+ 'worklet';
+
+ if (angleRad === 0) {
+ return cursorY >= labelY - padding && cursorY <= labelY + padding && cursorX >= targetX - halfWidth && cursorX <= targetX + halfWidth;
+ }
+ if (angleRad < 1 && corners45?.length === 4) {
+ return isCursorInSkewedLabel(cursorX, cursorY, corners45);
+ }
+ // 90°
+ return cursorX >= targetX - padding && cursorX <= targetX + padding && cursorY >= yMin90 && cursorY <= yMax90;
+}
export {
getChartColor,
@@ -302,4 +364,8 @@ export {
labelOverhang,
edgeLabelsFit,
edgeMaxLabelWidth,
+ isCursorInSkewedLabel,
+ isCursorOverChartLabel,
};
+
+export type {ChartLabelHitTestParams};
diff --git a/tests/unit/components/Charts/useChartInteractions.test.ts b/tests/unit/components/Charts/useChartInteractions.test.ts
new file mode 100644
index 0000000000000..5d04487e64264
--- /dev/null
+++ b/tests/unit/components/Charts/useChartInteractions.test.ts
@@ -0,0 +1,159 @@
+import {renderHook} from '@testing-library/react-native';
+import {useSharedValue} from 'react-native-reanimated';
+import {findClosestPoint, useChartInteractions} from '@components/Charts/hooks/useChartInteractions';
+
+/**
+ * findClosestPoint — binary search for the nearest canvas-x index
+ */
+describe('findClosestPoint', () => {
+ describe('empty and degenerate arrays', () => {
+ it('returns -1 for an empty array', () => {
+ expect(findClosestPoint([], 50)).toBe(-1);
+ });
+
+ it('returns 0 for a single-element array regardless of targetX', () => {
+ expect(findClosestPoint([100], 0)).toBe(0);
+ expect(findClosestPoint([100], 100)).toBe(0);
+ expect(findClosestPoint([100], 999)).toBe(0);
+ });
+ });
+
+ describe('exact matches', () => {
+ it('returns the exact index when targetX matches a value', () => {
+ expect(findClosestPoint([10, 20, 30, 40], 20)).toBe(1);
+ expect(findClosestPoint([10, 20, 30, 40], 30)).toBe(2);
+ });
+
+ it('returns 0 when targetX matches the first element', () => {
+ expect(findClosestPoint([10, 20, 30], 10)).toBe(0);
+ });
+
+ it('returns last index when targetX matches the last element', () => {
+ expect(findClosestPoint([10, 20, 30], 30)).toBe(2);
+ });
+ });
+
+ describe('clamping at boundaries', () => {
+ it('returns 0 when targetX is below all values', () => {
+ expect(findClosestPoint([10, 20, 30], -5)).toBe(0);
+ expect(findClosestPoint([10, 20, 30], 0)).toBe(0);
+ });
+
+ it('returns last index when targetX is above all values', () => {
+ expect(findClosestPoint([10, 20, 30], 100)).toBe(2);
+ expect(findClosestPoint([10, 20, 30], 30)).toBe(2);
+ });
+ });
+
+ describe('nearest-neighbor selection', () => {
+ it('picks the closer neighbor when targetX falls between two values', () => {
+ const xs = [0, 100, 200, 300];
+ // 40 is closer to 0 (distance 40) than to 100 (distance 60)
+ expect(findClosestPoint(xs, 40)).toBe(0);
+ // 60 is closer to 100 (distance 40) than to 0 (distance 60)
+ expect(findClosestPoint(xs, 60)).toBe(1);
+ });
+
+ it('breaks ties in favour of the higher index (right neighbor)', () => {
+ // Exactly at the midpoint: distance to both neighbors is equal.
+ // The implementation returns the right neighbor when distances are equal.
+ const xs = [0, 100];
+ expect(findClosestPoint(xs, 50)).toBe(1);
+ });
+
+ it('handles non-uniform spacing correctly', () => {
+ const xs = [0, 10, 110, 120];
+ // 9 is closer to 10 (distance 1) than to 0 (distance 9)
+ expect(findClosestPoint(xs, 9)).toBe(1);
+ });
+ });
+
+ describe('real-world chart-like inputs', () => {
+ it('resolves correctly for evenly spaced monthly ticks', () => {
+ // 12 monthly ticks spaced 50px apart starting at 25px
+ const xs = Array.from({length: 12}, (_, i) => 25 + i * 50);
+
+ // cursor at 24px — left of the first tick → clamp to 0
+ expect(findClosestPoint(xs, 24)).toBe(0);
+
+ // cursor exactly at tick 6 (index 6 = 325px)
+ expect(findClosestPoint(xs, 325)).toBe(6);
+
+ // cursor between tick 3 (175) and tick 4 (225): midpoint = 200 → ties right (4)
+ expect(findClosestPoint(xs, 200)).toBe(4);
+
+ // cursor at 590px — right of the last tick (575px) → clamp to 11
+ expect(findClosestPoint(xs, 590)).toBe(11);
+ });
+ });
+});
+
+/**
+ * useChartInteractions hook
+ */
+describe('useChartInteractions', () => {
+ const noop = () => undefined;
+ const alwaysFalse = () => false;
+
+ const defaultProps = {
+ handlePress: noop,
+ checkIsOver: alwaysFalse,
+ };
+
+ it('returns a customGestures object', () => {
+ const {result} = renderHook(() => useChartInteractions(defaultProps));
+
+ expect(result.current.customGestures).toBeTruthy();
+ });
+
+ it('returns a setPointPositions function', () => {
+ const {result} = renderHook(() => useChartInteractions(defaultProps));
+
+ expect(typeof result.current.setPointPositions).toBe('function');
+ });
+
+ it('starts with activeDataIndex of -1 and isTooltipActive false', () => {
+ const {result} = renderHook(() => useChartInteractions(defaultProps));
+
+ expect(result.current.activeDataIndex).toBe(-1);
+ expect(result.current.isTooltipActive).toBe(false);
+ });
+
+ it('setPointPositions does not throw for typical canvas coordinates', () => {
+ const {result} = renderHook(() => useChartInteractions(defaultProps));
+
+ expect(() => {
+ result.current.setPointPositions([10, 60, 110, 160, 210], [80, 60, 100, 40, 90]);
+ }).not.toThrow();
+ });
+
+ it('setPointPositions accepts empty arrays', () => {
+ const {result} = renderHook(() => useChartInteractions(defaultProps));
+
+ expect(() => {
+ result.current.setPointPositions([], []);
+ }).not.toThrow();
+ });
+
+ it('accepts optional chartBottom and yZero shared values', () => {
+ const {result} = renderHook(() => {
+ const chartBottom = useSharedValue(200);
+ const yZero = useSharedValue(200);
+ return useChartInteractions({...defaultProps, chartBottom, yZero});
+ });
+
+ expect(result.current.isTooltipActive).toBe(false);
+ });
+
+ it('accepts optional isCursorOverLabel and resolveLabelTouchX', () => {
+ const {result} = renderHook(() =>
+ useChartInteractions({
+ ...defaultProps,
+ isCursorOverLabel: () => false,
+ resolveLabelTouchX: (x: number) => x,
+ }),
+ );
+
+ expect(result.current.customGestures).toBeTruthy();
+ });
+});
diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts
index 2e2987c8b19be..acb6011d99fae 100644
--- a/tests/unit/components/Charts/utils.test.ts
+++ b/tests/unit/components/Charts/utils.test.ts
@@ -7,6 +7,8 @@ import {
effectiveWidth,
findSliceAtPosition,
isAngleInSlice,
+ isCursorInSkewedLabel,
+ isCursorOverChartLabel,
labelOverhang,
maxVisibleCount,
normalizeAngle,
@@ -227,8 +229,8 @@ describe('isAngleInSlice', () => {
describe('findSliceAtPosition', () => {
const makeSlices = (): PieSlice[] => [
- {label: 'A', value: 75, color: '#000', percentage: 75, startAngle: -90, endAngle: 180, originalIndex: 0},
- {label: 'B', value: 25, color: '#fff', percentage: 25, startAngle: 180, endAngle: 270, originalIndex: 1},
+ {label: 'A', value: 75, color: '#000', percentage: 75, startAngle: -90, endAngle: 180, originalIndex: 0, ordinalIndex: 0, tooltipPosition: {x: 0, y: 0}},
+ {label: 'B', value: 25, color: '#fff', percentage: 25, startAngle: 180, endAngle: 270, originalIndex: 1, ordinalIndex: 1, tooltipPosition: {x: 0, y: 0}},
];
const center = 100;
@@ -266,7 +268,7 @@ describe('findSliceAtPosition', () => {
describe('processDataIntoSlices', () => {
it('returns empty array for empty data', () => {
- expect(processDataIntoSlices([], 0)).toEqual([]);
+ expect(processDataIntoSlices([], 0, {centerX: 0, centerY: 0, radius: 0})).toEqual([]);
});
it('returns empty array when all values are zero', () => {
@@ -274,12 +276,12 @@ describe('processDataIntoSlices', () => {
{label: 'A', total: 0},
{label: 'B', total: 0},
];
- expect(processDataIntoSlices(data, 0)).toEqual([]);
+ expect(processDataIntoSlices(data, 0, {centerX: 0, centerY: 0, radius: 0})).toEqual([]);
});
it('creates a single slice covering 360 degrees for one data point', () => {
const data: ChartDataPoint[] = [{label: 'Only', total: 100}];
- const slices = processDataIntoSlices(data, -90);
+ const slices = processDataIntoSlices(data, -90, {centerX: 0, centerY: 0, radius: 0});
expect(slices).toHaveLength(1);
expect(slices.at(0)?.label).toBe('Only');
@@ -295,7 +297,7 @@ describe('processDataIntoSlices', () => {
{label: 'Small', total: 10},
{label: 'Large', total: 90},
];
- const slices = processDataIntoSlices(data, 0);
+ const slices = processDataIntoSlices(data, 0, {centerX: 0, centerY: 0, radius: 0});
expect(slices.at(0)?.label).toBe('Large');
expect(slices.at(1)?.label).toBe('Small');
@@ -306,7 +308,7 @@ describe('processDataIntoSlices', () => {
{label: 'Positive', total: 75},
{label: 'Negative', total: -25},
];
- const slices = processDataIntoSlices(data, 0);
+ const slices = processDataIntoSlices(data, 0, {centerX: 0, centerY: 0, radius: 0});
expect(slices).toHaveLength(2);
expect(slices.at(0)?.value).toBe(75);
@@ -321,7 +323,7 @@ describe('processDataIntoSlices', () => {
{label: 'Medium', total: 50},
{label: 'Large', total: 100},
];
- const slices = processDataIntoSlices(data, 0);
+ const slices = processDataIntoSlices(data, 0, {centerX: 0, centerY: 0, radius: 0});
expect(slices.at(0)?.originalIndex).toBe(2); // Large was at index 2
expect(slices.at(1)?.originalIndex).toBe(1); // Medium was at index 1
@@ -334,7 +336,7 @@ describe('processDataIntoSlices', () => {
{label: 'B', total: 33},
{label: 'C', total: 34},
];
- const slices = processDataIntoSlices(data, -90);
+ const slices = processDataIntoSlices(data, -90, {centerX: 0, centerY: 0, radius: 0});
const totalSweep = slices.reduce((sum, s) => sum + (s.endAngle - s.startAngle), 0);
expect(totalSweep).toBeCloseTo(360, 5);
@@ -346,7 +348,7 @@ describe('processDataIntoSlices', () => {
{label: 'B', total: 30},
{label: 'C', total: 20},
];
- const slices = processDataIntoSlices(data, -90);
+ const slices = processDataIntoSlices(data, -90, {centerX: 0, centerY: 0, radius: 0});
for (let i = 1; i < slices.length; i++) {
expect(slices.at(i)?.startAngle).toBeCloseTo(slices.at(i - 1)?.endAngle ?? 0, 10);
@@ -360,10 +362,185 @@ describe('processDataIntoSlices', () => {
{label: 'C', total: 20},
{label: 'D', total: 10},
];
- const slices = processDataIntoSlices(data, 0);
+ const slices = processDataIntoSlices(data, 0, {centerX: 0, centerY: 0, radius: 0});
const colors = slices.map((s) => s.color);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(4);
});
});
+
+describe('isCursorInSkewedLabel', () => {
+ // Axis-aligned unit square: clockwise rightUpper -> rightLower -> leftLower -> leftUpper
+ const unitSquare = [
+ {x: 1, y: 0},
+ {x: 1, y: 1},
+ {x: 0, y: 1},
+ {x: 0, y: 0},
+ ];
+
+ it('returns true when cursor is inside the quadrilateral', () => {
+ expect(isCursorInSkewedLabel(0.5, 0.5, unitSquare)).toBe(true);
+ });
+
+ it('returns false when cursor is to the left of the polygon', () => {
+ expect(isCursorInSkewedLabel(-0.5, 0.5, unitSquare)).toBe(false);
+ });
+
+ it('returns false when cursor is to the right of the polygon', () => {
+ expect(isCursorInSkewedLabel(1.5, 0.5, unitSquare)).toBe(false);
+ });
+
+ it('returns false when cursor is above the polygon', () => {
+ expect(isCursorInSkewedLabel(0.5, -0.5, unitSquare)).toBe(false);
+ });
+
+ it('returns false when cursor is below the polygon', () => {
+ expect(isCursorInSkewedLabel(0.5, 1.5, unitSquare)).toBe(false);
+ });
+
+ it('returns true when cursor is on an edge (boundary)', () => {
+ expect(isCursorInSkewedLabel(1, 0.5, unitSquare)).toBe(true);
+ expect(isCursorInSkewedLabel(0.5, 0, unitSquare)).toBe(true);
+ });
+
+ it('returns true when cursor is at a vertex', () => {
+ expect(isCursorInSkewedLabel(0, 0, unitSquare)).toBe(true);
+ expect(isCursorInSkewedLabel(1, 1, unitSquare)).toBe(true);
+ });
+
+ it('returns true when cursor is inside a skewed (45°) parallelogram', () => {
+ // Parallelogram: rightUpper (5,0), rightLower (6,1), leftLower (1,1), leftUpper (0,0)
+ const skewed = [
+ {x: 5, y: 0},
+ {x: 6, y: 1},
+ {x: 1, y: 1},
+ {x: 0, y: 0},
+ ];
+ expect(isCursorInSkewedLabel(3, 0.5, skewed)).toBe(true);
+ });
+
+ it('returns false when cursor is outside a skewed parallelogram', () => {
+ const skewed = [
+ {x: 5, y: 0},
+ {x: 6, y: 1},
+ {x: 1, y: 1},
+ {x: 0, y: 0},
+ ];
+ expect(isCursorInSkewedLabel(10, 10, skewed)).toBe(false);
+ expect(isCursorInSkewedLabel(-1, 0.5, skewed)).toBe(false);
+ });
+
+ it('returns true for empty corners (no edges to cross)', () => {
+ expect(isCursorInSkewedLabel(0, 0, [])).toBe(true);
+ });
+});
+
+describe('isCursorOverChartLabel', () => {
+ const baseParams = {
+ targetX: 10,
+ labelY: 20,
+ halfWidth: 5,
+ padding: 2,
+ yMin90: 15,
+ yMax90: 25,
+ };
+
+ describe('0° (horizontal label)', () => {
+ const params = () => ({...baseParams, angleRad: 0, cursorX: 10, cursorY: 20});
+
+ it('returns true when cursor is inside the horizontal label box', () => {
+ expect(isCursorOverChartLabel({...params()})).toBe(true);
+ expect(isCursorOverChartLabel({...params(), cursorX: 12, cursorY: 21})).toBe(true);
+ });
+
+ it('returns false when cursor is to the left of the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 2})).toBe(false);
+ });
+
+ it('returns false when cursor is to the right of the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 18})).toBe(false);
+ });
+
+ it('returns false when cursor is above the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorY: 15})).toBe(false);
+ });
+
+ it('returns false when cursor is below the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorY: 25})).toBe(false);
+ });
+
+ it('returns true when cursor is on the horizontal boundary', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 5, cursorY: 20})).toBe(true);
+ expect(isCursorOverChartLabel({...params(), cursorX: 15, cursorY: 20})).toBe(true);
+ });
+
+ it('returns true when cursor is on the vertical boundary', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 10, cursorY: 18})).toBe(true);
+ expect(isCursorOverChartLabel({...params(), cursorX: 10, cursorY: 22})).toBe(true);
+ });
+ });
+
+ describe('45° (skewed label)', () => {
+ const unitSquare = [
+ {x: 1, y: 0},
+ {x: 1, y: 1},
+ {x: 0, y: 1},
+ {x: 0, y: 0},
+ ];
+
+ it('returns true when cursor is inside the skewed quadrilateral', () => {
+ expect(
+ isCursorOverChartLabel({
+ ...baseParams,
+ angleRad: Math.PI / 4,
+ cursorX: 0.5,
+ cursorY: 0.5,
+ corners45: unitSquare,
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false when cursor is outside the skewed quadrilateral', () => {
+ expect(
+ isCursorOverChartLabel({
+ ...baseParams,
+ angleRad: Math.PI / 4,
+ cursorX: -1,
+ cursorY: 0.5,
+ corners45: unitSquare,
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('90° (vertical label)', () => {
+ const params = () => ({...baseParams, angleRad: Math.PI / 2, cursorX: 10, cursorY: 20});
+
+ it('returns true when cursor is inside the vertical label band', () => {
+ expect(isCursorOverChartLabel({...params()})).toBe(true);
+ expect(isCursorOverChartLabel({...params(), cursorY: 18})).toBe(true);
+ });
+
+ it('returns false when cursor is to the left of the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 5})).toBe(false);
+ });
+
+ it('returns false when cursor is to the right of the label', () => {
+ expect(isCursorOverChartLabel({...params(), cursorX: 15})).toBe(false);
+ });
+
+ it('returns false when cursor is above the vertical bounds', () => {
+ expect(isCursorOverChartLabel({...params(), cursorY: 10})).toBe(false);
+ });
+
+ it('returns false when cursor is below the vertical bounds', () => {
+ expect(isCursorOverChartLabel({...params(), cursorY: 30})).toBe(false);
+ });
+
+ it('returns true when cursor is on vertical boundary', () => {
+ expect(isCursorOverChartLabel({...params(), cursorY: 15})).toBe(true);
+ expect(isCursorOverChartLabel({...params(), cursorY: 25})).toBe(true);
+ });
+ });
+});