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); + }); + }); +});