From f0d8e81004613143969b3ee9160d00e863debde6 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 27 Feb 2026 16:07:10 +0100 Subject: [PATCH 01/33] add tooltip on hover of labels, add debounce on hover start --- .../Charts/BarChart/BarChartContent.tsx | 115 +++++----- .../Charts/LineChart/LineChartContent.tsx | 216 ++++++++++++------ .../Charts/hooks/useChartInteractions.ts | 54 +++-- 3 files changed, 251 insertions(+), 134 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 15de5c4bf21f8..4f6dc86619e63 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, {useCallback, useMemo, 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 {ChartBounds, PointsArray, Scale} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; @@ -130,6 +131,12 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } const barLeft = args.targetX - currentBarWidth / 2; const barRight = args.targetX + currentBarWidth / 2; + + const isInLabelArea = args.chartBottom > 0 && args.cursorY >= args.chartBottom && args.cursorX >= barLeft && args.cursorX <= barRight; + if (isInLabelArea) { + return true; + } + // For positive bars: targetY < yZero, bar goes from targetY (top) to yZero (bottom) // For negative bars: targetY > yZero, bar goes from yZero (top) to targetY (bottom) const barTop = Math.min(args.targetY, currentYZero); @@ -140,7 +147,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni [barWidth, yZero], ); - const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {actionsRef, customGestures, outerHoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, chartBottom, @@ -197,60 +204,62 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni title={title} titleIcon={titleIcon} /> - - {chartWidth > 0 && ( - + + {chartWidth > 0 && ( + - {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - + lineWidth: X_AXIS_LINE_WIDTH, + // Victory-native positions x-axis labels at: chartBounds.bottom + labelOffset + fontSize. + // We subtract descent (fontSize - ascent) so the gap from chart to the ascent line equals AXIS_LABEL_GAP. + labelOffset: AXIS_LABEL_GAP - Math.abs(font?.getMetrics().descent ?? 0), + formatXLabel: formatLabel, + labelRotate: labelRotation, + labelOverflow: 'visible', + }} + yAxis={[ + { + font, + labelColor: theme.textSupporting, + formatYLabel: formatValue, + tickCount: Y_AXIS_TICK_COUNT, + lineWidth: Y_AXIS_LINE_WIDTH, + lineColor: theme.border, + labelOffset: AXIS_LABEL_GAP, + domain: yAxisDomain, + }, + ]} + frame={{lineWidth: 0}} + data={chartData} + > + {({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 8d8f11b7a06e3..39fe346592c83 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -2,6 +2,8 @@ import {Group, Text as SkiaText, useFont, vec} from '@shopify/react-native-skia' import React, {useCallback, useMemo, 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} from 'victory-native'; import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; @@ -69,9 +71,15 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setChartWidth(event.nativeEvent.layout.width); }, []); - const handleChartBoundsChange = useCallback((bounds: ChartBounds) => { - setPlotAreaWidth(bounds.right - bounds.left); - }, []); + const chartBottom = useSharedValue(0); + + const handleChartBoundsChange = useCallback( + (bounds: ChartBounds) => { + setPlotAreaWidth(bounds.right - bounds.left); + chartBottom.set(bounds.bottom); + }, + [chartBottom], + ); // Calculate dynamic domain padding for centered labels // Optimize by reducing wasted space when edge labels are shorter than tick spacing @@ -146,9 +154,87 @@ 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 checkIsOverLabel = useCallback( + (args: HitTestArgs, activeIndex: number) => { + 'worklet'; + + const labelWidth = labelWidths.at(activeIndex) ?? 0; + const fontMetrics = font?.getMetrics(); + if (!fontMetrics) { + return false; + } + const ascent = Math.abs(fontMetrics.ascent); + const descent = Math.abs(fontMetrics.descent); + // center of label when looking vertically + const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 2; + if (angleRad === 0) { + return ( + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && + args.cursorX >= args.targetX - labelWidth / 2 && + args.cursorX <= args.targetX + labelWidth / 2 + ); + } + // When labels are rotated 45° we need to check it the other way + if (angleRad < 1) { + console.log('activeIndex', activeIndex); + const rightUpperCorner = { + x: args.targetX - (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), + y: labelY + (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), + }; + const rightLowerCorner = { + x: rightUpperCorner.x + variables.iconSizeExtraSmall * Math.sin(angleRad), + y: rightUpperCorner.y + variables.iconSizeExtraSmall * Math.sin(angleRad), + }; + const leftUpperCorner = { + x: rightUpperCorner.x - labelWidth * Math.sin(angleRad), + y: rightUpperCorner.y + labelWidth * Math.sin(angleRad), + }; + const leftLowerCorner = { + x: rightLowerCorner.x - labelWidth * Math.sin(angleRad), + y: rightLowerCorner.y + labelWidth * Math.sin(angleRad), + }; + + // Point-in-convex-polygon test using cross products + // Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper + const corners = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]; + const px = args.cursorX; + const py = args.cursorY; + 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) * (py - a.y) - (b.y - a.y) * (px - a.x); + if (cross !== 0) { + const crossSign = cross > 0 ? 1 : -1; + if (sign === 0) { + sign = crossSign; + } else if (crossSign !== sign) { + return false; + } + } + } + return true; + } + // the last case when labels are rotated 90° + return ( + args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && + args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && + args.cursorY >= labelY + variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 + ); + }, + [angleRad, font, labelWidths], + ); + + const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, + checkIsOverLabel, + chartBottom, }); const tooltipData = useTooltipData(activeDataIndex, data, formatValue); @@ -242,66 +328,68 @@ 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/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 62bd4c218a05b..ee5debcc6ebdb 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -35,6 +35,8 @@ type UseChartInteractionsProps = { * 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 */ + checkIsOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean; /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ chartBottom?: SharedValue; yZero?: SharedValue; @@ -52,7 +54,7 @@ type CartesianActionsHandle = { * Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning. * 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, checkIsOverLabel, chartBottom, yZero}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState(); @@ -76,14 +78,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, + }) || + (checkIsOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, activeDataIndex) ?? false) + ); }); /** Syncs the matched data index from the UI thread to React state */ @@ -103,8 +107,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,21 +123,28 @@ 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; + actionsRef.current?.handleTouch(chartInteractionState, e.x, Math.min(e.y, bottom)); }) .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; + actionsRef.current?.handleTouch(chartInteractionState, e.x, Math.min(e.y, bottom)); + } }) .onEnd(() => { 'worklet'; chartInteractionState.isActive.set(false); }), - [chartInteractionState], + [chartInteractionState, chartBottom, isCursorOverTarget], ); /** @@ -166,8 +181,8 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us [chartInteractionState, checkIsOver, chartBottom, handlePress], ); - /** Combined gesture object to be passed to CartesianChart's customGestures prop */ - const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + /** Tap-only gesture passed to CartesianChart. Hover is handled by hoverGesture on the container. */ + const customGestures = useMemo(() => Gesture.Race(tapGesture), [tapGesture]); /** * Raw tooltip positioning data. @@ -189,8 +204,13 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us return { /** Ref to be passed to CartesianChart */ actionsRef, - /** Gestures to be passed to CartesianChart */ + /** Tap-only gesture to be passed to CartesianChart's customGestures prop */ customGestures, + /** + * Hover gesture to be attached to the full-height container wrapping CartesianChart. + * Covers both the plot area and the label area below chartBounds.bottom. + */ + hoverGesture, /** The currently active data index (React state) */ activeDataIndex, /** Whether the tooltip should currently be rendered and visible */ From ad89eec0c30d72909a3c58642cafe5c4c7ec8f16 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 3 Mar 2026 11:47:21 +0100 Subject: [PATCH 02/33] fix hover when entering the label from the left --- .../Charts/LineChart/LineChartContent.tsx | 44 ++++++++++++++++++- .../Charts/hooks/useChartInteractions.ts | 23 +++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 39fe346592c83..cd2fdb68a2b80 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -4,7 +4,7 @@ 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} from 'victory-native'; +import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; import {CartesianChart, Line, Scatter} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; @@ -73,6 +73,16 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const chartBottom = useSharedValue(0); + /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ + const tickXPositions = useSharedValue([]); + + const handleScaleChange = useCallback( + (xScale: Scale, _yScale: Scale) => { + tickXPositions.set(data.map((_, i) => xScale(i))); + }, + [data, tickXPositions], + ); + const handleChartBoundsChange = useCallback( (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); @@ -230,10 +240,41 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn [angleRad, font, labelWidths], ); + /** + * 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 = useCallback( + (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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + return tickX; + } + } + return cursorX; + }, + [tickXPositions, chartBottom, labelSkipInterval, checkIsOverLabel], + ); + const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, checkIsOverLabel, + resolveLabelTouchX: findLabelCursorX, chartBottom, }); @@ -342,6 +383,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn actionsRef={actionsRef} customGestures={customGestures} onChartBoundsChange={handleChartBoundsChange} + onScaleChange={handleScaleChange} renderOutside={renderCustomXLabels} xAxis={{ tickCount: data.length, diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index ee5debcc6ebdb..74946544eaf9a 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -37,6 +37,13 @@ type UseChartInteractionsProps = { checkIsOver: (args: HitTestArgs) => boolean; /** Worklet function to determine if the cursor is hovering over the label area */ checkIsOverLabel?: (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; yZero?: SharedValue; @@ -54,7 +61,7 @@ type CartesianActionsHandle = { * Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning. * Synchronizes high-frequency UI thread data to React state for tooltip display and navigation. */ -function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, chartBottom, yZero}: UseChartInteractionsProps) { +function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState(); @@ -86,7 +93,7 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, chart targetY, chartBottom: currentChartBottom, }) || - (checkIsOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, activeDataIndex) ?? false) + (checkIsOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false) ); }); @@ -124,7 +131,12 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, chart chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); const bottom = chartBottom?.get() ?? e.y; - actionsRef.current?.handleTouch(chartInteractionState, e.x, Math.min(e.y, bottom)); + // When entering from within the label area, snap to the tick X of the label + // the cursor is visually inside rather than using the raw cursor X. + // This fixes cases where rotated labels extend past the midpoint to the + // previous tick, causing Victory to match the wrong data point. + const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; + actionsRef.current?.handleTouch(chartInteractionState, touchX, Math.min(e.y, bottom)); }) .onUpdate((e) => { 'worklet'; @@ -136,7 +148,8 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, chart // preventing it from jumping to a different point during continuous movement. if (!isCursorOverTarget.get()) { const bottom = chartBottom?.get() ?? e.y; - actionsRef.current?.handleTouch(chartInteractionState, e.x, Math.min(e.y, bottom)); + const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; + actionsRef.current?.handleTouch(chartInteractionState, touchX, Math.min(e.y, bottom)); } }) .onEnd(() => { @@ -144,7 +157,7 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, chart chartInteractionState.isActive.set(false); }), - [chartInteractionState, chartBottom, isCursorOverTarget], + [chartInteractionState, chartBottom, isCursorOverTarget, resolveLabelTouchX], ); /** From 0350a55b1426e08fc5ead1077664baf575cbb951 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 4 Mar 2026 16:11:11 +0100 Subject: [PATCH 03/33] add tooltip on label hover in pie chart --- .../Charts/PieChart/PieChartContent.tsx | 15 +++++++++++---- src/components/Charts/types.ts | 6 ++++++ src/components/Charts/utils.ts | 17 +++++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) 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/types.ts b/src/components/Charts/types.ts index 92b6bf0f41f41..43b10c46278aa 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -63,6 +63,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}; }; export type {ChartDataPoint, ChartProps, CartesianChartProps, PieSlice, UnitPosition, UnitWithFallback}; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index ac7bb158d6c33..3fd01e7d371e6 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 type { SkFont } from '@shopify/react-native-skia'; import colors from '@styles/theme/colors'; -import type {ChartDataPoint, PieSlice} from './types'; +import type { ChartDataPoint, PieSlice } from './types'; /** * Expensify Chart Color Palette. @@ -151,19 +151,22 @@ 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 []; } return data - .map((point, index) => ({label: point.label, absTotal: Math.abs(point.total), originalIndex: index})) + .map((point, index) => ({ label: point.label, absTotal: Math.abs(point.total), originalIndex: index })) .sort((a, b) => b.absTotal - a.absTotal) - .reduce<{slices: PieSlice[]; angle: number}>( + .reduce<{ slices: PieSlice[]; angle: number }>( (acc, slice, index) => { const fraction = slice.absTotal / total; const sweepAngle = fraction * 360; + const angle = acc.angle + sweepAngle / 2; + const tooltipX = pieGeometry.centerX + (pieGeometry.radius / 1.5) * Math.cos((angle * Math.PI) / 180); + const tooltipY = pieGeometry.centerY + (pieGeometry.radius / 1.5) * Math.sin((angle * Math.PI) / 180); acc.slices.push({ label: slice.label, value: slice.absTotal, @@ -172,11 +175,13 @@ 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; }, - {slices: [], angle: startAngle}, + { slices: [], angle: startAngle }, ).slices; } From fbec98d5a0d3a45647575755fc8f6a18041f6c3b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 4 Mar 2026 16:11:51 +0100 Subject: [PATCH 04/33] adjust label bounding box positioning --- .../Charts/LineChart/LineChartContent.tsx | 111 +++++++++--------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 6d6fa2f9ae221..b99570181ea8e 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -177,7 +177,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const ascent = Math.abs(fontMetrics.ascent); const descent = Math.abs(fontMetrics.descent); // center of label when looking vertically - const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 2; + const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 3; if (angleRad === 0) { return ( args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && @@ -385,59 +385,62 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn title={title} titleIcon={titleIcon} /> - - {chartWidth > 0 && ( - - {({points}) => ( - - )} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - + + + {chartWidth > 0 && ( + + {({points}) => ( + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + )} + + ); } From 7ab38fb2cac2d9b61cd3ef6a3e91ad1a7fcf802a Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 4 Mar 2026 16:12:07 +0100 Subject: [PATCH 05/33] start implementing hover on label in bar chart --- .../Charts/BarChart/BarChartContent.tsx | 102 ++++++++++++++++-- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 0af799b0975ba..963d4064895ad 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -14,7 +14,7 @@ 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 {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -103,6 +103,9 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const chartBottom = useSharedValue(0); const yZero = useSharedValue(0); + /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ + const tickXPositions = useSharedValue([]); + const handleChartBoundsChange = useCallback( (bounds: ChartBounds) => { const domainWidth = bounds.right - bounds.left; @@ -116,12 +119,24 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni ); const handleScaleChange = useCallback( - (_xScale: Scale, yScale: Scale) => { + (xScale: Scale, yScale: Scale) => { yZero.set(yScale(0)); + tickXPositions.set(data.map((_, i) => xScale(i))); }, - [yZero], + [yZero, data, tickXPositions], ); + // Measure label widths for custom positioning in `renderOutside` + const labelWidths = useMemo(() => { + if (!font) { + return [] as number[]; + } + return truncatedLabels.map((label) => measureTextWidth(label, font)); + }, [font, truncatedLabels]); + + // Convert hook's degree rotation to radians for hover label testing + const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + const checkIsOverBar = useCallback( (args: HitTestArgs) => { 'worklet'; @@ -134,11 +149,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const barLeft = args.targetX - currentBarWidth / 2; const barRight = args.targetX + currentBarWidth / 2; - const isInLabelArea = args.chartBottom > 0 && args.cursorY >= args.chartBottom && args.cursorX >= barLeft && args.cursorX <= barRight; - if (isInLabelArea) { - return true; - } - // For positive bars: targetY < yZero, bar goes from targetY (top) to yZero (bottom) // For negative bars: targetY > yZero, bar goes from yZero (top) to targetY (bottom) const barTop = Math.min(args.targetY, currentYZero); @@ -149,9 +159,81 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni [barWidth, yZero], ); - const {actionsRef, customGestures, outerHoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const checkIsOverLabel = useCallback( + (args: HitTestArgs, activeIndex: number) => { + 'worklet'; + + const labelWidth = labelWidths.at(activeIndex) ?? 0; + const fontMetrics = font?.getMetrics(); + if (!fontMetrics) { + return false; + } + const ascent = Math.abs(fontMetrics.ascent); + const descent = Math.abs(fontMetrics.descent); + const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 2; + console.log('label position x y', args.targetX, labelY, args.chartBottom); + console.log('cursor position', args.cursorX, args.cursorY); + if (angleRad === 0) { + return ( + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && + args.cursorX >= args.targetX - labelWidth / 2 && + args.cursorX <= args.targetX + labelWidth / 2 + ); + } + if (angleRad < 1) { + return ( + args.cursorX >= args.targetX - labelWidth / 2 && + args.cursorX <= args.targetX + labelWidth / 2 && + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 + ); + } + return ( + args.cursorX >= args.targetX - labelWidth / 2 && + args.cursorX <= args.targetX + labelWidth / 2 && + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 + ); + }, + [labelWidths, angleRad, font], + ); + + /** + * 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 = useCallback( + (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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + return tickX; + } + } + return cursorX; + }, + [tickXPositions, chartBottom, labelSkipInterval, checkIsOverLabel], + ); + + const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, + checkIsOverLabel, + resolveLabelTouchX: findLabelCursorX, chartBottom, yZero, }); @@ -206,7 +288,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni title={title} titleIcon={titleIcon} /> - + Date: Wed, 4 Mar 2026 17:03:11 +0100 Subject: [PATCH 06/33] fix hovering for line chart labels --- .../Charts/LineChart/LineChartContent.tsx | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 70edbe0d534fc..dc4385c73b9b9 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -1,5 +1,5 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; @@ -17,7 +17,7 @@ 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 {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'; @@ -70,10 +70,23 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setChartWidth(event.nativeEvent.layout.width); }; + const chartBottom = useSharedValue(0); + + /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ + const tickXPositions = useSharedValue([]); + + const handleScaleChange = useCallback( + (xScale: Scale) => { + tickXPositions.set(data.map((_, i) => xScale(i))); + }, + [data, tickXPositions], + ); + const handleChartBoundsChange = (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); setBoundsLeft(bounds.left); setBoundsRight(bounds.right); + chartBottom.set(bounds.bottom); }; const domainPadding = (() => { @@ -120,6 +133,17 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn allowTightDiagonalPacking: true, }); + // Measure label widths for custom positioning in `renderOutside` + const labelWidths = useMemo(() => { + if (!font) { + return [] as number[]; + } + return truncatedLabels.map((label) => measureTextWidth(label, font)); + }, [font, truncatedLabels]); + + // Convert hook's degree rotation to radians for Skia rendering + const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + const {formatValue} = useChartLabelFormats({ data, font, @@ -158,7 +182,6 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn } // When labels are rotated 45° we need to check it the other way if (angleRad < 1) { - console.log('activeIndex', activeIndex); const rightUpperCorner = { x: args.targetX - (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), y: labelY + (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), From 4bbc98b4b70873f6b12e5b1c1acc9ad29d2f4c7b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 5 Mar 2026 11:42:27 +0100 Subject: [PATCH 07/33] add hover for barChart --- .../Charts/BarChart/BarChartContent.tsx | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index c9eacc971aa81..50f1468522b98 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -161,16 +161,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } const ascent = Math.abs(fontMetrics.ascent); const descent = Math.abs(fontMetrics.descent); - const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 2; + + const centeredUpwardOffset = angleRad > 0 ? (Math.max(...labelWidths) / 2) * Math.sin(angleRad) : 0; + const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset; if (angleRad === 0) { - return ( - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && - args.cursorX >= args.targetX - labelWidth / 2 && - args.cursorX <= args.targetX + labelWidth / 2 - ); - } - if (angleRad < 1) { return ( args.cursorX >= args.targetX - labelWidth / 2 && args.cursorX <= args.targetX + labelWidth / 2 && @@ -178,6 +172,47 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 ); } + if (angleRad < 1) { + const rightUpperCorner = { + x: args.targetX + (labelWidth / 2) * Math.sin(angleRad), + y: labelY - (labelWidth / 2) * Math.sin(angleRad) - variables.iconSizeExtraSmall / 2, + }; + const rightLowerCorner = { + x: rightUpperCorner.x + variables.iconSizeExtraSmall * Math.sin(angleRad), + y: rightUpperCorner.y + variables.iconSizeExtraSmall * Math.sin(angleRad), + }; + const leftUpperCorner = { + x: rightUpperCorner.x - labelWidth * Math.sin(angleRad), + y: rightUpperCorner.y + labelWidth * Math.sin(angleRad), + }; + const leftLowerCorner = { + x: rightLowerCorner.x - labelWidth * Math.sin(angleRad), + y: rightLowerCorner.y + labelWidth * Math.sin(angleRad), + }; + // Point-in-convex-polygon test using cross products + // Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper + const corners = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]; + const px = args.cursorX; + const py = args.cursorY; + 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) * (py - a.y) - (b.y - a.y) * (px - a.x); + if (cross !== 0) { + const crossSign = cross > 0 ? 1 : -1; + if (sign === 0) { + sign = crossSign; + } else if (crossSign !== sign) { + return false; + } + } + } + return true; + } return ( args.cursorX >= args.targetX - labelWidth / 2 && args.cursorX <= args.targetX + labelWidth / 2 && From 57c670712926399fcbeaca4909b5f9de951e880e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 5 Mar 2026 11:45:11 +0100 Subject: [PATCH 08/33] fix prettier --- src/components/Charts/utils.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index ed9b6f1a2e5c7..9a0f45869c12c 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -1,7 +1,7 @@ -import type { SkFont } from '@shopify/react-native-skia'; +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 type { ChartDataPoint, LabelRotation, PieSlice } from './types'; +import type {ChartDataPoint, LabelRotation, PieSlice} from './types'; /** * Expensify Chart Color Palette. @@ -152,16 +152,16 @@ 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, pieGeometry: { centerX: number, centerY: number, radius: 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 []; } return data - .map((point, index) => ({ label: point.label, absTotal: Math.abs(point.total), originalIndex: index })) + .map((point, index) => ({label: point.label, absTotal: Math.abs(point.total), originalIndex: index})) .sort((a, b) => b.absTotal - a.absTotal) - .reduce<{ slices: PieSlice[]; angle: number }>( + .reduce<{slices: PieSlice[]; angle: number}>( (acc, slice, index) => { const fraction = slice.absTotal / total; const sweepAngle = fraction * 360; @@ -177,12 +177,12 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number, pieGe endAngle: acc.angle + sweepAngle, originalIndex: slice.originalIndex, ordinalIndex: index, - tooltipPosition: { x: tooltipX, y: tooltipY } + tooltipPosition: {x: tooltipX, y: tooltipY}, }); acc.angle += sweepAngle; return acc; }, - { slices: [], angle: startAngle }, + {slices: [], angle: startAngle}, ).slices; } From a9fcd5ff793b15fefad9a53f9e122a9e9b4db8e5 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 5 Mar 2026 13:14:40 +0100 Subject: [PATCH 09/33] fix check for vertical labels in bar chart --- src/components/Charts/BarChart/BarChartContent.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 50f1468522b98..950767152956b 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -214,10 +214,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return true; } return ( - args.cursorX >= args.targetX - labelWidth / 2 && - args.cursorX <= args.targetX + labelWidth / 2 && - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 + args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && + args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && + args.cursorY >= labelY - labelWidth / 2 && + args.cursorY <= labelY + labelWidth / 2 ); }, [labelWidths, angleRad, font], From d0f65ffffeebcf96433857f55c57b072b186be2e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 5 Mar 2026 13:27:45 +0100 Subject: [PATCH 10/33] fix tests typecheck, fix prettier --- tests/unit/components/Charts/utils.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index 2e2987c8b19be..f22c9e051a152 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -227,8 +227,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 +266,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 +274,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 +295,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 +306,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 +321,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 +334,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 +346,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,7 +360,7 @@ 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); From 5729b2eb41134922d0c60f947b7af627c073f43b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 5 Mar 2026 16:18:24 +0100 Subject: [PATCH 11/33] fix bounding boxes for line and bar chart, memoize whats possible --- .../Charts/BarChart/BarChartContent.tsx | 69 +++++++++++++------ .../Charts/LineChart/LineChartContent.tsx | 59 +++++++++++----- 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 950767152956b..07996a2742abd 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -133,6 +133,35 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni // Convert hook's degree rotation to radians for hover label testing const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + /** + * Pre-computed geometry for label hit-testing. + * Extracted from the worklet so that font metrics, trig, spread-array max, and per-label + * scaled widths are calculated once per layout/rotation change rather than on every hover event. + */ + const labelHitGeometry = useMemo(() => { + 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 maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0; + const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0; + return { + /** Constant offset from chartBottom to the label Y baseline */ + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - variables.iconSizeExtraSmall / 3, + /** iconSize * sin(angle) — step from upper to lower corner */ + iconSin: variables.iconSizeExtraSmall * sinA, + /** Per-label: (labelWidth / 2) * sin(angle) — right-corner anchor offset for 45° */ + halfLabelSins: labelWidths.map((w) => (w / 2) * sinA), + /** Per-label: labelWidth * sin(angle) — left-corner offset for 45° */ + labelSins: labelWidths.map((w) => w * sinA), + /** Per-label: labelWidth / 2 — bounds half-extent for 0° and 90° */ + halfWidths: labelWidths.map((w) => w / 2), + }; + }, [font, angleRad, labelWidths]); + const checkIsOverBar = (args: HitTestArgs) => { 'worklet'; @@ -154,40 +183,39 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni (args: HitTestArgs, activeIndex: number) => { 'worklet'; - const labelWidth = labelWidths.at(activeIndex) ?? 0; - const fontMetrics = font?.getMetrics(); - if (!fontMetrics) { + if (!labelHitGeometry) { return false; } - const ascent = Math.abs(fontMetrics.ascent); - const descent = Math.abs(fontMetrics.descent); + const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; + const halfLabelWidth = halfWidths.at(activeIndex) ?? 0; + const labelY = args.chartBottom + labelYOffset; - const centeredUpwardOffset = angleRad > 0 ? (Math.max(...labelWidths) / 2) * Math.sin(angleRad) : 0; - const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset; if (angleRad === 0) { return ( - args.cursorX >= args.targetX - labelWidth / 2 && - args.cursorX <= args.targetX + labelWidth / 2 && + args.cursorX >= args.targetX - halfLabelWidth && + args.cursorX <= args.targetX + halfLabelWidth && args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 ); } if (angleRad < 1) { + const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; + const labelSin = labelSins.at(activeIndex) ?? 0; const rightUpperCorner = { - x: args.targetX + (labelWidth / 2) * Math.sin(angleRad), - y: labelY - (labelWidth / 2) * Math.sin(angleRad) - variables.iconSizeExtraSmall / 2, + x: args.targetX + halfLabelSin, + y: labelY - halfLabelSin, }; const rightLowerCorner = { - x: rightUpperCorner.x + variables.iconSizeExtraSmall * Math.sin(angleRad), - y: rightUpperCorner.y + variables.iconSizeExtraSmall * Math.sin(angleRad), + x: rightUpperCorner.x + iconSin, + y: rightUpperCorner.y + iconSin, }; const leftUpperCorner = { - x: rightUpperCorner.x - labelWidth * Math.sin(angleRad), - y: rightUpperCorner.y + labelWidth * Math.sin(angleRad), + x: rightUpperCorner.x - labelSin, + y: rightUpperCorner.y + labelSin, }; const leftLowerCorner = { - x: rightLowerCorner.x - labelWidth * Math.sin(angleRad), - y: rightLowerCorner.y + labelWidth * Math.sin(angleRad), + x: rightLowerCorner.x - labelSin, + y: rightLowerCorner.y + labelSin, }; // Point-in-convex-polygon test using cross products // Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper @@ -213,14 +241,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } return true; } + // 90° return ( args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && - args.cursorY >= labelY - labelWidth / 2 && - args.cursorY <= labelY + labelWidth / 2 + args.cursorY >= labelY - halfLabelWidth && + args.cursorY <= labelY + halfLabelWidth ); }, - [labelWidths, angleRad, font], + [angleRad, labelHitGeometry], ); /** diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index dc4385c73b9b9..b6a3180b56725 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -144,6 +144,34 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn // Convert hook's degree rotation to radians for Skia rendering const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + /** + * Pre-computed geometry for label hit-testing. + * Extracted from the worklet so that font metrics, trig, and per-label scaled widths + * are calculated once per layout/rotation change rather than on every hover event. + */ + const labelHitGeometry = useMemo(() => { + 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); + + return { + /** Constant offset from chartBottom to the label Y baseline */ + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 1.5, + /** (iconSize / 3) * sin(angle) — right-corner anchor horizontal/vertical offset */ + iconThirdSin: (variables.iconSizeExtraSmall / 3) * sinA, + /** iconSize * sin(angle) — step from upper to lower corner */ + iconSin: variables.iconSizeExtraSmall * sinA, + /** Per-label: labelWidth * sin(angle) — left-corner horizontal/vertical offset */ + labelSins: labelWidths.map((w) => w * sinA), + /** Original widths kept here so the worklet needs only one closure capture */ + widths: labelWidths, + }; + }, [font, angleRad, labelWidths]); + const {formatValue} = useChartLabelFormats({ data, font, @@ -163,15 +191,13 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn (args: HitTestArgs, activeIndex: number) => { 'worklet'; - const labelWidth = labelWidths.at(activeIndex) ?? 0; - const fontMetrics = font?.getMetrics(); - if (!fontMetrics) { + if (!labelHitGeometry) { return false; } - const ascent = Math.abs(fontMetrics.ascent); - const descent = Math.abs(fontMetrics.descent); - // center of label when looking vertically - const labelY = args.chartBottom + AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 3; + const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; + const labelWidth = widths.at(activeIndex) ?? 0; + const labelY = args.chartBottom + labelYOffset; + if (angleRad === 0) { return ( args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && @@ -182,21 +208,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn } // When labels are rotated 45° we need to check it the other way if (angleRad < 1) { + const labelSin = labelSins.at(activeIndex) ?? 0; const rightUpperCorner = { - x: args.targetX - (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), - y: labelY + (variables.iconSizeExtraSmall / 3) * Math.sin(angleRad), + x: args.targetX - iconThirdSin, + y: labelY + iconThirdSin, }; const rightLowerCorner = { - x: rightUpperCorner.x + variables.iconSizeExtraSmall * Math.sin(angleRad), - y: rightUpperCorner.y + variables.iconSizeExtraSmall * Math.sin(angleRad), + x: rightUpperCorner.x + iconSin, + y: rightUpperCorner.y + iconSin, }; const leftUpperCorner = { - x: rightUpperCorner.x - labelWidth * Math.sin(angleRad), - y: rightUpperCorner.y + labelWidth * Math.sin(angleRad), + x: rightUpperCorner.x - labelSin, + y: rightUpperCorner.y + labelSin, }; const leftLowerCorner = { - x: rightLowerCorner.x - labelWidth * Math.sin(angleRad), - y: rightLowerCorner.y + labelWidth * Math.sin(angleRad), + x: rightLowerCorner.x - labelSin, + y: rightLowerCorner.y + labelSin, }; // Point-in-convex-polygon test using cross products @@ -231,7 +258,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 ); }, - [angleRad, font, labelWidths], + [angleRad, labelHitGeometry], ); /** From 021808f6df12dd51521119d61dcef38c73037b3e Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 16:28:16 +0100 Subject: [PATCH 12/33] add tests for new function --- src/components/Charts/utils.ts | 24 +++++++- tests/unit/components/Charts/utils.test.ts | 67 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 9a0f45869c12c..787a76732b4f4 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -288,7 +288,28 @@ 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 { + 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; +} export { getChartColor, DEFAULT_CHART_COLOR, @@ -307,4 +328,5 @@ export { labelOverhang, edgeLabelsFit, edgeMaxLabelWidth, + isCursorInSkewedLabel, }; diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index f22c9e051a152..720494ca61c3a 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -7,6 +7,7 @@ import { effectiveWidth, findSliceAtPosition, isAngleInSlice, + isCursorInSkewedLabel, labelOverhang, maxVisibleCount, normalizeAngle, @@ -367,3 +368,69 @@ describe('processDataIntoSlices', () => { 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); + }); +}); From f21d49184a30cd6795e9960ac729ade3d60d0524 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 16:28:51 +0100 Subject: [PATCH 13/33] make bar and line charts use the new function --- .../Charts/BarChart/BarChartContent.tsx | 31 +++------------- .../Charts/LineChart/LineChartContent.tsx | 35 ++++--------------- 2 files changed, 11 insertions(+), 55 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 07996a2742abd..d55a1422e3492 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -15,7 +15,7 @@ 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 {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, isCursorInSkewedLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -183,7 +183,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni (args: HitTestArgs, activeIndex: number) => { 'worklet'; - if (!labelHitGeometry) { + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { return false; } const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; @@ -198,6 +198,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 ); } + // 45° if (angleRad < 1) { const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; const labelSin = labelSins.at(activeIndex) ?? 0; @@ -217,29 +218,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni x: rightLowerCorner.x - labelSin, y: rightLowerCorner.y + labelSin, }; - // Point-in-convex-polygon test using cross products - // Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper - const corners = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]; - const px = args.cursorX; - const py = args.cursorY; - 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) * (py - a.y) - (b.y - a.y) * (px - a.x); - if (cross !== 0) { - const crossSign = cross > 0 ? 1 : -1; - if (sign === 0) { - sign = crossSign; - } else if (crossSign !== sign) { - return false; - } - } - } - return true; + return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); } // 90° return ( @@ -249,7 +228,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni args.cursorY <= labelY + halfLabelWidth ); }, - [angleRad, labelHitGeometry], + [angleRad, labelHitGeometry, labelSkipInterval], ); /** diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index b6a3180b56725..4bd265ae9be1c 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -17,7 +17,7 @@ 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 {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, isCursorInSkewedLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -191,7 +191,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn (args: HitTestArgs, activeIndex: number) => { 'worklet'; - if (!labelHitGeometry) { + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { return false; } const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; @@ -206,7 +206,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn args.cursorX <= args.targetX + labelWidth / 2 ); } - // When labels are rotated 45° we need to check it the other way + // 45° if (angleRad < 1) { const labelSin = labelSins.at(activeIndex) ?? 0; const rightUpperCorner = { @@ -225,32 +225,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn x: rightLowerCorner.x - labelSin, y: rightLowerCorner.y + labelSin, }; - - // Point-in-convex-polygon test using cross products - // Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper - const corners = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]; - const px = args.cursorX; - const py = args.cursorY; - 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) * (py - a.y) - (b.y - a.y) * (px - a.x); - if (cross !== 0) { - const crossSign = cross > 0 ? 1 : -1; - if (sign === 0) { - sign = crossSign; - } else if (crossSign !== sign) { - return false; - } - } - } - return true; + return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); } - // the last case when labels are rotated 90° + // 90° return ( args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && @@ -258,7 +235,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 ); }, - [angleRad, labelHitGeometry], + [angleRad, labelHitGeometry, labelSkipInterval], ); /** From bc68d1db8d97d03e4f3ac6e835e6693921bef8cb Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 16:35:28 +0100 Subject: [PATCH 14/33] remove manual memoization in lineChartContent --- .../Charts/LineChart/LineChartContent.tsx | 141 ++++++++---------- 1 file changed, 66 insertions(+), 75 deletions(-) diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 4bd265ae9be1c..9fb40971bfa64 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -1,5 +1,5 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; @@ -75,12 +75,9 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ const tickXPositions = useSharedValue([]); - const handleScaleChange = useCallback( - (xScale: Scale) => { - tickXPositions.set(data.map((_, i) => xScale(i))); - }, - [data, tickXPositions], - ); + const handleScaleChange = (xScale: Scale) => { + tickXPositions.set(data.map((_, i) => xScale(i))); + }; const handleChartBoundsChange = (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); @@ -187,56 +184,53 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }; - const checkIsOverLabel = useCallback( - (args: HitTestArgs, activeIndex: number) => { - 'worklet'; + const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { + 'worklet'; - if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { - return false; - } - const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; - const labelWidth = widths.at(activeIndex) ?? 0; - const labelY = args.chartBottom + labelYOffset; - - if (angleRad === 0) { - return ( - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && - args.cursorX >= args.targetX - labelWidth / 2 && - args.cursorX <= args.targetX + labelWidth / 2 - ); - } - // 45° - if (angleRad < 1) { - const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = { - x: args.targetX - iconThirdSin, - y: labelY + iconThirdSin, - }; - 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, - }; - return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); - } - // 90° + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { + return false; + } + const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; + const labelWidth = widths.at(activeIndex) ?? 0; + const labelY = args.chartBottom + labelYOffset; + + if (angleRad === 0) { return ( - args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && - args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && - args.cursorY >= labelY + variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && + args.cursorX >= args.targetX - labelWidth / 2 && + args.cursorX <= args.targetX + labelWidth / 2 ); - }, - [angleRad, labelHitGeometry, labelSkipInterval], - ); + } + // 45° + if (angleRad < 1) { + const labelSin = labelSins.at(activeIndex) ?? 0; + const rightUpperCorner = { + x: args.targetX - iconThirdSin, + y: labelY + iconThirdSin, + }; + 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, + }; + return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); + } + // 90° + return ( + args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && + args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && + args.cursorY >= labelY + variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 + ); + }; /** * Scans every visible label's bounding box using its own tick X as the anchor. @@ -245,28 +239,25 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn * 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 = useCallback( - (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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { - return tickX; - } + 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; } - return cursorX; - }, - [tickXPositions, chartBottom, labelSkipInterval, checkIsOverLabel], - ); + const tickX = positions.at(i); + if (tickX === undefined) { + continue; + } + if (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + return tickX; + } + } + return cursorX; + }; const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, From a937fe6a6ee32960336b0479deb35bb447b508ad Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 16:43:19 +0100 Subject: [PATCH 15/33] remove manual memoization from bar chart --- .../Charts/BarChart/BarChartContent.tsx | 145 ++++++++---------- 1 file changed, 68 insertions(+), 77 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index d55a1422e3492..e6922a387152f 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,5 +1,5 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; @@ -114,13 +114,10 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setBoundsRight(bounds.right); }; - const handleScaleChange = useCallback( - (xScale: Scale, yScale: Scale) => { - yZero.set(yScale(0)); - tickXPositions.set(data.map((_, i) => xScale(i))); - }, - [yZero, data, tickXPositions], - ); + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + yZero.set(yScale(0)); + tickXPositions.set(data.map((_, i) => xScale(i))); + }; // Measure label widths for custom positioning in `renderOutside` const labelWidths = useMemo(() => { @@ -179,57 +176,54 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const checkIsOverLabel = useCallback( - (args: HitTestArgs, activeIndex: number) => { - 'worklet'; + const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { + 'worklet'; - if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { - return false; - } - const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; - const halfLabelWidth = halfWidths.at(activeIndex) ?? 0; - const labelY = args.chartBottom + labelYOffset; - - if (angleRad === 0) { - return ( - args.cursorX >= args.targetX - halfLabelWidth && - args.cursorX <= args.targetX + halfLabelWidth && - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 - ); - } - // 45° - if (angleRad < 1) { - const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; - const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = { - x: args.targetX + halfLabelSin, - y: labelY - halfLabelSin, - }; - 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, - }; - return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); - } - // 90° + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { + return false; + } + const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; + const halfLabelWidth = halfWidths.at(activeIndex) ?? 0; + const labelY = args.chartBottom + labelYOffset; + + if (angleRad === 0) { return ( - args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && - args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && - args.cursorY >= labelY - halfLabelWidth && - args.cursorY <= labelY + halfLabelWidth + args.cursorX >= args.targetX - halfLabelWidth && + args.cursorX <= args.targetX + halfLabelWidth && + args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && + args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 ); - }, - [angleRad, labelHitGeometry, labelSkipInterval], - ); + } + // 45° + if (angleRad < 1) { + const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; + const labelSin = labelSins.at(activeIndex) ?? 0; + const rightUpperCorner = { + x: args.targetX + halfLabelSin, + y: labelY - halfLabelSin, + }; + 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, + }; + return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); + } + // 90° + return ( + args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && + args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && + args.cursorY >= labelY - halfLabelWidth && + args.cursorY <= labelY + halfLabelWidth + ); + }; /** * Scans every visible label's bounding box using its own tick X as the anchor. @@ -238,28 +232,25 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni * 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 = useCallback( - (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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { - return tickX; - } + 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; } - return cursorX; - }, - [tickXPositions, chartBottom, labelSkipInterval, checkIsOverLabel], - ); + const tickX = positions.at(i); + if (tickX === undefined) { + continue; + } + if (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + return tickX; + } + } + return cursorX; + }; const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, From f06e95aa75fe4c2674097008ae7839a706eff249 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 16:59:25 +0100 Subject: [PATCH 16/33] add utils for label hit testins/ refactor and simplify code --- .../Charts/BarChart/BarChartContent.tsx | 61 ++++++++----------- .../Charts/LineChart/LineChartContent.tsx | 60 ++++++++---------- src/components/Charts/utils.ts | 36 +++++++++++ 3 files changed, 87 insertions(+), 70 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index e6922a387152f..367b6afd1ff35 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -15,7 +15,7 @@ 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 {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, isCursorInSkewedLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, isCursorOverChartLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -176,53 +176,44 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; + const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { 'worklet'; if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { return false; } + + const padding = variables.iconSizeExtraSmall / 2; + const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; const halfLabelWidth = halfWidths.at(activeIndex) ?? 0; const labelY = args.chartBottom + labelYOffset; - if (angleRad === 0) { - return ( - args.cursorX >= args.targetX - halfLabelWidth && - args.cursorX <= args.targetX + halfLabelWidth && - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 - ); - } - // 45° - if (angleRad < 1) { + let corners45: Array<{x: number; y: number}> | undefined; + if (angleRad > 0 && angleRad < 1) { const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = { - x: args.targetX + halfLabelSin, - y: labelY - halfLabelSin, - }; - 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, - }; - return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); + const rightUpperCorner = {x: args.targetX + halfLabelSin, y: labelY - halfLabelSin}; + 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]; } - // 90° - return ( - args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && - args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && - args.cursorY >= labelY - halfLabelWidth && - args.cursorY <= labelY + halfLabelWidth - ); + + // Shared hit-test from utils; run in worklet via closure (boolean return) + return isCursorOverChartLabel({ + cursorX: args.cursorX, + cursorY: args.cursorY, + targetX: args.targetX, + labelY, + angleRad, + halfWidth: halfLabelWidth, + padding, + corners45, + yMin90: labelY - halfLabelWidth + padding, + yMax90: labelY + halfLabelWidth + padding, + }); }; /** diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 9fb40971bfa64..e5cdb592adbd9 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -17,7 +17,7 @@ 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 {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, isCursorInSkewedLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, isCursorOverChartLabel, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -190,46 +190,36 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { return false; } + + const padding = variables.iconSizeExtraSmall / 2; + const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; const labelWidth = widths.at(activeIndex) ?? 0; const labelY = args.chartBottom + labelYOffset; - if (angleRad === 0) { - return ( - args.cursorY >= labelY - variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + variables.iconSizeExtraSmall / 2 && - args.cursorX >= args.targetX - labelWidth / 2 && - args.cursorX <= args.targetX + labelWidth / 2 - ); - } - // 45° - if (angleRad < 1) { + let corners45: Array<{x: number; y: number}> | undefined; + if (angleRad > 0 && angleRad < 1) { const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = { - x: args.targetX - iconThirdSin, - y: labelY + iconThirdSin, - }; - 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, - }; - return isCursorInSkewedLabel(args.cursorX, args.cursorY, [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]); + const rightUpperCorner = {x: args.targetX - iconThirdSin, y: labelY + iconThirdSin}; + 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]; } - // 90° - return ( - args.cursorX >= args.targetX - variables.iconSizeExtraSmall / 2 && - args.cursorX <= args.targetX + variables.iconSizeExtraSmall / 2 && - args.cursorY >= labelY + variables.iconSizeExtraSmall / 2 && - args.cursorY <= labelY + labelWidth + variables.iconSizeExtraSmall / 2 - ); + + // Shared hit-test from utils; run in worklet via closure (boolean return) + return isCursorOverChartLabel({ + cursorX: args.cursorX, + cursorY: args.cursorY, + targetX: args.targetX, + labelY, + angleRad, + halfWidth: labelWidth / 2, + padding, + corners45, + yMin90: labelY + padding, + yMax90: labelY + labelWidth + padding, + }); }; /** diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 787a76732b4f4..133b124d99f00 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -310,6 +310,39 @@ function isCursorInSkewedLabel(cursorX: number, cursorY: number, corners: Array< } 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(params: ChartLabelHitTestParams): boolean { + const {cursorX, cursorY, targetX, labelY, angleRad, halfWidth, padding, corners45, yMin90, yMax90} = params; + 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, DEFAULT_CHART_COLOR, @@ -329,4 +362,7 @@ export { edgeLabelsFit, edgeMaxLabelWidth, isCursorInSkewedLabel, + isCursorOverChartLabel, }; + +export type {ChartLabelHitTestParams}; From 8d8a70aa6d93bcbc5e2f223aec5561bad4eae08b Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 17:03:25 +0100 Subject: [PATCH 17/33] add tests for common function in label hover --- tests/unit/components/Charts/utils.test.ts | 110 +++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/unit/components/Charts/utils.test.ts b/tests/unit/components/Charts/utils.test.ts index 720494ca61c3a..acb6011d99fae 100644 --- a/tests/unit/components/Charts/utils.test.ts +++ b/tests/unit/components/Charts/utils.test.ts @@ -8,6 +8,7 @@ import { findSliceAtPosition, isAngleInSlice, isCursorInSkewedLabel, + isCursorOverChartLabel, labelOverhang, maxVisibleCount, normalizeAngle, @@ -434,3 +435,112 @@ describe('isCursorInSkewedLabel', () => { 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); + }); + }); +}); From 4002dbb0ccd274a4208d34adfda0e78c054cf3f3 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 17:17:30 +0100 Subject: [PATCH 18/33] fix prettier --- src/components/Charts/BarChart/BarChartContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 367b6afd1ff35..6002cd97f2304 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -176,7 +176,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { 'worklet'; From 1187bf82118ec3c1baf00e78676491a856ac9515 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Fri, 6 Mar 2026 17:35:53 +0100 Subject: [PATCH 19/33] remove argument unpacking --- src/components/Charts/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 133b124d99f00..85e97304d401e 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -331,8 +331,7 @@ type ChartLabelHitTestParams = { * 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(params: ChartLabelHitTestParams): boolean { - const {cursorX, cursorY, targetX, labelY, angleRad, halfWidth, padding, corners45, yMin90, yMax90} = params; +function isCursorOverChartLabel({cursorX, cursorY, targetX, labelY, angleRad, halfWidth, padding, corners45, yMin90, yMax90}: ChartLabelHitTestParams): boolean { if (angleRad === 0) { return cursorY >= labelY - padding && cursorY <= labelY + padding && cursorX >= targetX - halfWidth && cursorX <= targetX + halfWidth; } From ba84519bf7714ce62bb1fef71876efac6b343960 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 9 Mar 2026 09:46:32 +0100 Subject: [PATCH 20/33] fix error on native devices --- src/components/Charts/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 85e97304d401e..3c503f8f9095a 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -332,6 +332,8 @@ type ChartLabelHitTestParams = { * 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; } From 8b43d2fc16e34d9480f7aa0efc4e15de3e7b197a Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 9 Mar 2026 10:11:12 +0100 Subject: [PATCH 21/33] move redundant code to hook --- .../Charts/BarChart/BarChartContent.tsx | 148 ++++----------- .../Charts/LineChart/LineChartContent.tsx | 163 +++++----------- src/components/Charts/hooks/index.ts | 2 + .../Charts/hooks/useLabelHitTesting.ts | 174 ++++++++++++++++++ 4 files changed, 253 insertions(+), 234 deletions(-) create mode 100644 src/components/Charts/hooks/useLabelHitTesting.ts diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 6002cd97f2304..565aeea2a527f 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -1,5 +1,5 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; @@ -12,10 +12,10 @@ 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 fontSource from '@components/Charts/font'; -import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {HitTestArgs,ComputeGeometryFn} 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, isCursorOverChartLabel, measureTextWidth, rotatedLabelYOffset} 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'; @@ -29,6 +29,28 @@ 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); + const halfWidths = labelWidths.map((w) => w / 2); + return { + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - variables.iconSizeExtraSmall / 3, + 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; @@ -100,8 +122,14 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const chartBottom = useSharedValue(0); const yZero = useSharedValue(0); - /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ - const tickXPositions = useSharedValue([]); + const {checkIsOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + font, + truncatedLabels, + labelRotation, + labelSkipInterval, + chartBottom, + computeGeometry: computeBarLabelGeometry, + }); const handleChartBoundsChange = (bounds: ChartBounds) => { const domainWidth = bounds.right - bounds.left; @@ -116,49 +144,9 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const handleScaleChange = (xScale: Scale, yScale: Scale) => { yZero.set(yScale(0)); - tickXPositions.set(data.map((_, i) => xScale(i))); + updateTickPositions(xScale, data.length); }; - // Measure label widths for custom positioning in `renderOutside` - const labelWidths = useMemo(() => { - if (!font) { - return [] as number[]; - } - return truncatedLabels.map((label) => measureTextWidth(label, font)); - }, [font, truncatedLabels]); - - // Convert hook's degree rotation to radians for hover label testing - const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; - - /** - * Pre-computed geometry for label hit-testing. - * Extracted from the worklet so that font metrics, trig, spread-array max, and per-label - * scaled widths are calculated once per layout/rotation change rather than on every hover event. - */ - const labelHitGeometry = useMemo(() => { - 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 maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0; - const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0; - return { - /** Constant offset from chartBottom to the label Y baseline */ - labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - variables.iconSizeExtraSmall / 3, - /** iconSize * sin(angle) — step from upper to lower corner */ - iconSin: variables.iconSizeExtraSmall * sinA, - /** Per-label: (labelWidth / 2) * sin(angle) — right-corner anchor offset for 45° */ - halfLabelSins: labelWidths.map((w) => (w / 2) * sinA), - /** Per-label: labelWidth * sin(angle) — left-corner offset for 45° */ - labelSins: labelWidths.map((w) => w * sinA), - /** Per-label: labelWidth / 2 — bounds half-extent for 0° and 90° */ - halfWidths: labelWidths.map((w) => w / 2), - }; - }, [font, angleRad, labelWidths]); - const checkIsOverBar = (args: HitTestArgs) => { 'worklet'; @@ -176,72 +164,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { - 'worklet'; - - if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { - return false; - } - - const padding = variables.iconSizeExtraSmall / 2; - - const {labelYOffset, iconSin, halfLabelSins, labelSins, halfWidths} = labelHitGeometry; - const halfLabelWidth = halfWidths.at(activeIndex) ?? 0; - const labelY = args.chartBottom + labelYOffset; - - let corners45: Array<{x: number; y: number}> | undefined; - if (angleRad > 0 && angleRad < 1) { - const halfLabelSin = halfLabelSins.at(activeIndex) ?? 0; - const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = {x: args.targetX + halfLabelSin, y: labelY - halfLabelSin}; - 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]; - } - - // Shared hit-test from utils; run in worklet via closure (boolean return) - return isCursorOverChartLabel({ - cursorX: args.cursorX, - cursorY: args.cursorY, - targetX: args.targetX, - labelY, - angleRad, - halfWidth: halfLabelWidth, - padding, - corners45, - yMin90: labelY - halfLabelWidth + padding, - yMax90: labelY + halfLabelWidth + padding, - }); - }; - - /** - * 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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { - return tickX; - } - } - return cursorX; - }; - const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index e5cdb592adbd9..b818a902f38d3 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -1,5 +1,5 @@ import {useFont} from '@shopify/react-native-skia'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; import {GestureDetector} from 'react-native-gesture-handler'; @@ -14,10 +14,10 @@ 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 fontSource from '@components/Charts/font'; -import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {HitTestArgs,ComputeGeometryFn} 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, isCursorOverChartLabel, measureTextWidth, rotatedLabelYOffset} 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'; @@ -35,6 +35,25 @@ 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; + return { + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 1.5, + 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; @@ -72,20 +91,6 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const chartBottom = useSharedValue(0); - /** Pixel-space X position of each tick, filled by onScaleChange and used for label hit-testing */ - const tickXPositions = useSharedValue([]); - - const handleScaleChange = (xScale: Scale) => { - tickXPositions.set(data.map((_, i) => xScale(i))); - }; - - const handleChartBoundsChange = (bounds: ChartBounds) => { - setPlotAreaWidth(bounds.right - bounds.left); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); - chartBottom.set(bounds.bottom); - }; - const domainPadding = (() => { if (chartWidth === 0 || data.length === 0) { return BASE_DOMAIN_PADDING; @@ -130,45 +135,6 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn allowTightDiagonalPacking: true, }); - // Measure label widths for custom positioning in `renderOutside` - const labelWidths = useMemo(() => { - if (!font) { - return [] as number[]; - } - return truncatedLabels.map((label) => measureTextWidth(label, font)); - }, [font, truncatedLabels]); - - // Convert hook's degree rotation to radians for Skia rendering - const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; - - /** - * Pre-computed geometry for label hit-testing. - * Extracted from the worklet so that font metrics, trig, and per-label scaled widths - * are calculated once per layout/rotation change rather than on every hover event. - */ - const labelHitGeometry = useMemo(() => { - 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); - - return { - /** Constant offset from chartBottom to the label Y baseline */ - labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 1.5, - /** (iconSize / 3) * sin(angle) — right-corner anchor horizontal/vertical offset */ - iconThirdSin: (variables.iconSizeExtraSmall / 3) * sinA, - /** iconSize * sin(angle) — step from upper to lower corner */ - iconSin: variables.iconSizeExtraSmall * sinA, - /** Per-label: labelWidth * sin(angle) — left-corner horizontal/vertical offset */ - labelSins: labelWidths.map((w) => w * sinA), - /** Original widths kept here so the worklet needs only one closure capture */ - widths: labelWidths, - }; - }, [font, angleRad, labelWidths]); - const {formatValue} = useChartLabelFormats({ data, font, @@ -176,77 +142,32 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn unitPosition: yAxisUnitPosition, }); - const checkIsOverDot = (args: HitTestArgs) => { - 'worklet'; + const {checkIsOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + font, + truncatedLabels, + labelRotation, + labelSkipInterval, + chartBottom, + computeGeometry: computeLineLabelGeometry, + }); - const dx = args.cursorX - args.targetX; - const dy = args.cursorY - args.targetY; - return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; + const handleScaleChange = (xScale: Scale) => { + updateTickPositions(xScale, data.length); }; - const checkIsOverLabel = (args: HitTestArgs, activeIndex: number) => { - 'worklet'; - - if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { - return false; - } - - const padding = variables.iconSizeExtraSmall / 2; - - const {labelYOffset, iconThirdSin, iconSin, labelSins, widths} = labelHitGeometry; - const labelWidth = widths.at(activeIndex) ?? 0; - const labelY = args.chartBottom + labelYOffset; - - let corners45: Array<{x: number; y: number}> | undefined; - if (angleRad > 0 && angleRad < 1) { - const labelSin = labelSins.at(activeIndex) ?? 0; - const rightUpperCorner = {x: args.targetX - iconThirdSin, y: labelY + iconThirdSin}; - 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]; - } - - // Shared hit-test from utils; run in worklet via closure (boolean return) - return isCursorOverChartLabel({ - cursorX: args.cursorX, - cursorY: args.cursorY, - targetX: args.targetX, - labelY, - angleRad, - halfWidth: labelWidth / 2, - padding, - corners45, - yMin90: labelY + padding, - yMax90: labelY + labelWidth + padding, - }); + const handleChartBoundsChange = (bounds: ChartBounds) => { + setPlotAreaWidth(bounds.right - bounds.left); + setBoundsLeft(bounds.left); + setBoundsRight(bounds.right); + chartBottom.set(bounds.bottom); }; - /** - * 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 => { + const checkIsOverDot = (args: HitTestArgs) => { '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 (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { - return tickX; - } - } - return cursorX; + const dx = args.cursorX - args.targetX; + const dy = args.cursorY - args.targetY; + return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }; const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ 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/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts new file mode 100644 index 0000000000000..1ca4567ddd304 --- /dev/null +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -0,0 +1,174 @@ +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 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 = { + ascent: number; + descent: number; + sinA: number; + angleRad: number; + labelWidths: number[]; + 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 checkIsOverLabel / 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 checkIsOverLabel = (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 < 1) { + 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 (checkIsOverLabel({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 {checkIsOverLabel, findLabelCursorX, updateTickPositions}; +} + +export default useLabelHitTesting; +export type {ComputeGeometryFn, ComputeGeometryInput, LabelHitGeometry}; From a051d7eda57b5645b11b54514ee661e13b04944d Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 9 Mar 2026 10:16:09 +0100 Subject: [PATCH 22/33] fix prettier --- src/components/Charts/BarChart/BarChartContent.tsx | 2 +- src/components/Charts/LineChart/LineChartContent.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 565aeea2a527f..51068b2e70907 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -12,7 +12,7 @@ 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 fontSource from '@components/Charts/font'; -import type {HitTestArgs,ComputeGeometryFn} 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, rotatedLabelYOffset} from '@components/Charts/utils'; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index b818a902f38d3..bf256e01a2f90 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -14,7 +14,7 @@ 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 fontSource from '@components/Charts/font'; -import type {HitTestArgs,ComputeGeometryFn} 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, rotatedLabelYOffset} from '@components/Charts/utils'; From 79c5642f15f3856d9716fcb06e4ce1a7786f6b35 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Mon, 9 Mar 2026 10:47:26 +0100 Subject: [PATCH 23/33] add review suggestins --- src/components/Charts/BarChart/BarChartContent.tsx | 1 + src/components/Charts/constants.ts | 7 +++++++ src/components/Charts/hooks/useLabelHitTesting.ts | 3 ++- src/components/Charts/utils.ts | 6 +++--- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 51068b2e70907..f5b89c17e87fe 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -40,6 +40,7 @@ const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angl const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA); const halfWidths = labelWidths.map((w) => w / 2); return { + // variables.iconSizeExtraSmall / 3 is the vertical offset of label from the axis line labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - variables.iconSizeExtraSmall / 3, iconSin: variables.iconSizeExtraSmall * sinA, labelSins: labelWidths.map((w) => w * sinA), diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 8a952af70b641..3b49d7692ece2 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_RAD_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_RAD_THRESHOLD, + PIE_CHART_TOOLTIP_RADIUS_DISTANCE, }; diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 1ca4567ddd304..deaebe901069d 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -3,6 +3,7 @@ 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_RAD_THRESHOLD} from '@components/Charts/constants'; import type {LabelRotation} from '@components/Charts/types'; import {isCursorOverChartLabel, measureTextWidth} from '@components/Charts/utils'; import variables from '@styles/variables'; @@ -110,7 +111,7 @@ function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInte const labelY = args.chartBottom + labelYOffset; let corners45: Array<{x: number; y: number}> | undefined; - if (angleRad > 0 && angleRad < 1) { + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RAD_THRESHOLD) { const labelSin = labelSins.at(activeIndex) ?? 0; const anchorDX = cornerAnchorDX.at(activeIndex) ?? 0; const anchorDY = cornerAnchorDY.at(activeIndex) ?? 0; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 3c503f8f9095a..61964422a0cb4 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'; /** @@ -166,8 +166,8 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number, pieGe const fraction = slice.absTotal / total; const sweepAngle = fraction * 360; const angle = acc.angle + sweepAngle / 2; - const tooltipX = pieGeometry.centerX + (pieGeometry.radius / 1.5) * Math.cos((angle * Math.PI) / 180); - const tooltipY = pieGeometry.centerY + (pieGeometry.radius / 1.5) * Math.sin((angle * Math.PI) / 180); + 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, From c32aa1c97e085145a97bb8e0796155f8f95631f6 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 10 Mar 2026 17:01:44 +0100 Subject: [PATCH 24/33] fix tapping on mobile devices, make hovering more accuratee --- .../Charts/BarChart/BarChartContent.tsx | 32 ++-- .../Charts/LineChart/LineChartContent.tsx | 28 ++-- .../Charts/hooks/useChartInteractions.ts | 152 +++++++++++++----- 3 files changed, 149 insertions(+), 63 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index f5b89c17e87fe..263a1d114486b 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -10,7 +10,7 @@ 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_RAD_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 {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; @@ -37,11 +37,17 @@ const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; 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); + 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_RAD_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 - variables.iconSizeExtraSmall / 3, + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset, iconSin: variables.iconSizeExtraSmall * sinA, labelSins: labelWidths.map((w) => w * sinA), halfWidths, @@ -143,11 +149,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setBoundsRight(bounds.right); }; - const handleScaleChange = (xScale: Scale, yScale: Scale) => { - yZero.set(yScale(0)); - updateTickPositions(xScale, data.length); - }; - const checkIsOverBar = (args: HitTestArgs) => { 'worklet'; @@ -165,7 +166,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, checkIsOverLabel, @@ -174,6 +175,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni 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) => { @@ -234,7 +244,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni title={title} titleIcon={titleIcon} /> - + { const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA; + let additionalOffset = 0; + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RAD_THRESHOLD) { + additionalOffset = variables.iconSizeExtraSmall / 1.5; + } else if (angleRad > 1) { + additionalOffset = variables.iconSizeExtraSmall / 3; + } return { - labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - variables.iconSizeExtraSmall / 1.5, + 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), @@ -151,10 +157,6 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn computeGeometry: computeLineLabelGeometry, }); - const handleScaleChange = (xScale: Scale) => { - updateTickPositions(xScale, data.length); - }; - const handleChartBoundsChange = (bounds: ChartBounds) => { setPlotAreaWidth(bounds.right - bounds.left); setBoundsLeft(bounds.left); @@ -170,7 +172,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }; - const {actionsRef, customGestures, hoverGesture, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, checkIsOverLabel, @@ -178,6 +180,14 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn 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'>) => { @@ -231,7 +241,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn title={title} titleIcon={titleIcon} /> - + 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 RNGH 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, checkIsOverLabel, 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. @@ -131,12 +187,16 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); const bottom = chartBottom?.get() ?? e.y; - // When entering from within the label area, snap to the tick X of the label - // the cursor is visually inside rather than using the raw cursor X. - // This fixes cases where rotated labels extend past the midpoint to the - // previous tick, causing Victory to match the wrong data point. const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - actionsRef.current?.handleTouch(chartInteractionState, touchX, Math.min(e.y, bottom)); + 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'; @@ -149,7 +209,15 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol if (!isCursorOverTarget.get()) { const bottom = chartBottom?.get() ?? e.y; const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; - actionsRef.current?.handleTouch(chartInteractionState, touchX, Math.min(e.y, bottom)); + 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(() => { @@ -157,46 +225,48 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol chartInteractionState.isActive.set(false); }), - [chartInteractionState, chartBottom, isCursorOverTarget, resolveLabelTouchX], + [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], ); - /** Tap-only gesture passed to CartesianChart. Hover is handled by hoverGesture on the container. */ - const customGestures = useMemo(() => Gesture.Race(tapGesture), [tapGesture]); - /** * Raw tooltip positioning data. * We return these as individual derived values so the caller can @@ -214,16 +284,16 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol }; }); + const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + return { - /** Ref to be passed to CartesianChart */ - actionsRef, - /** Tap-only gesture to be passed to CartesianChart's customGestures prop */ + /** Custom gestures to be passed to CartesianChart */ customGestures, /** - * Hover gesture to be attached to the full-height container wrapping CartesianChart. - * Covers both the plot area and the label area below chartBounds.bottom. + * 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). */ - hoverGesture, + setPointPositions, /** The currently active data index (React state) */ activeDataIndex, /** Whether the tooltip should currently be rendered and visible */ From a44344237f6e7a96a75b8be6a423af74e0994755 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 10 Mar 2026 17:08:25 +0100 Subject: [PATCH 25/33] fix error on mobile about worklets --- src/components/Charts/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 61964422a0cb4..2eec6d6617318 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -291,6 +291,8 @@ function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: Labe // 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); From 9bb193258c7317a647b275c385bf913a1528e892 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 10 Mar 2026 17:09:18 +0100 Subject: [PATCH 26/33] fix spellcheck --- src/components/Charts/hooks/useChartInteractions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 3fa35e2485ffc..ac1bf45caf618 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -94,7 +94,7 @@ function findClosestPoint(xValues: number[], targetX: number): number { /** * Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning. - * Uses RNGH gestures directly — no dependency on Victory's actionsRef/handleTouch. + * 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, checkIsOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) { From e9555506066c4279688fd655be37ac8a18c20ab4 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 10 Mar 2026 17:16:41 +0100 Subject: [PATCH 27/33] update tests --- .../Charts/hooks/useChartInteractions.ts | 2 +- .../Charts/useChartInteractions.test.ts | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/unit/components/Charts/useChartInteractions.test.ts diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index ac1bf45caf618..45631cdba4d49 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -303,5 +303,5 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol }; } -export {useChartInteractions, TOOLTIP_BAR_GAP}; +export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP}; export type {HitTestArgs}; diff --git a/tests/unit/components/Charts/useChartInteractions.test.ts b/tests/unit/components/Charts/useChartInteractions.test.ts new file mode 100644 index 0000000000000..84395f4e98f0e --- /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-neighbour selection', () => { + it('picks the closer neighbour 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 neighbour)', () => { + // Exactly at the midpoint: distance to both neighbours is equal. + // The implementation returns the right neighbour 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 checkIsOverLabel and resolveLabelTouchX', () => { + const {result} = renderHook(() => + useChartInteractions({ + ...defaultProps, + checkIsOverLabel: () => false, + resolveLabelTouchX: (x: number) => x, + }), + ); + + expect(result.current.customGestures).toBeTruthy(); + }); +}); From ae6aed569547281ec62007a319b655d4f4b78c53 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Tue, 10 Mar 2026 17:23:00 +0100 Subject: [PATCH 28/33] fix spelling --- .../components/Charts/useChartInteractions.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/components/Charts/useChartInteractions.test.ts b/tests/unit/components/Charts/useChartInteractions.test.ts index 84395f4e98f0e..d90fc721a098e 100644 --- a/tests/unit/components/Charts/useChartInteractions.test.ts +++ b/tests/unit/components/Charts/useChartInteractions.test.ts @@ -45,8 +45,8 @@ describe('findClosestPoint', () => { }); }); - describe('nearest-neighbour selection', () => { - it('picks the closer neighbour when targetX falls between two values', () => { + 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); @@ -54,9 +54,9 @@ describe('findClosestPoint', () => { expect(findClosestPoint(xs, 60)).toBe(1); }); - it('breaks ties in favour of the higher index (right neighbour)', () => { - // Exactly at the midpoint: distance to both neighbours is equal. - // The implementation returns the right neighbour when distances are equal. + 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); }); From 9ba69f55083f6ae6d245335f6c7edc8eb9d66856 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 11 Mar 2026 12:26:49 +0100 Subject: [PATCH 29/33] fix missing spaces in useChartInteractions --- src/components/Charts/hooks/useChartInteractions.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 45631cdba4d49..c5bdd4469ff30 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -30,13 +30,16 @@ 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 */ checkIsOverLabel?: (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 @@ -44,8 +47,11 @@ type UseChartInteractionsProps = { * 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; }; From 29ca3fcd43743a11e79a1c078e5bed77acee27d8 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 11 Mar 2026 12:41:28 +0100 Subject: [PATCH 30/33] fix missing spaces --- src/components/Charts/hooks/useChartInteractions.ts | 4 ++++ src/components/Charts/hooks/useLabelHitTesting.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index c5bdd4469ff30..e8e299b459783 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -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; }; diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index deaebe901069d..60a47239f2621 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -12,18 +12,25 @@ 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[]; }; @@ -45,6 +52,7 @@ type UseLabelHitTestingParams = { labelRotation: LabelRotation; labelSkipInterval: number; chartBottom: SharedValue; + /** * Chart-specific geometry factory. * Receives font metrics, trig values, and per-label widths; returns the From 39ecf082fb5d27f91d1f4f4bd386499298e81656 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 11 Mar 2026 12:49:32 +0100 Subject: [PATCH 31/33] add comments --- src/components/Charts/hooks/useLabelHitTesting.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 60a47239f2621..6729e42755004 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -36,11 +36,22 @@ type LabelHitGeometry = { }; 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; }; From be86d9cdac689705c205a158fbf98437535b55d7 Mon Sep 17 00:00:00 2001 From: borys3kk Date: Wed, 11 Mar 2026 18:28:36 +0100 Subject: [PATCH 32/33] fxi puneets review comments --- .../Charts/BarChart/BarChartContent.tsx | 8 ++++---- .../Charts/LineChart/LineChartContent.tsx | 16 ++++++++++++---- src/components/Charts/constants.ts | 4 ++-- .../Charts/hooks/useChartInteractions.ts | 6 +++--- .../Charts/hooks/useLabelHitTesting.ts | 12 ++++++------ .../Charts/useChartInteractions.test.ts | 4 ++-- 6 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 263a1d114486b..a52486782278f 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -10,7 +10,7 @@ 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, DIAGONAL_ANGLE_RAD_THRESHOLD, 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 {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; @@ -40,7 +40,7 @@ const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angl 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_RAD_THRESHOLD) { + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { additionalOffset = variables.iconSizeExtraSmall / 1.5; } else if (angleRad > 1) { additionalOffset = variables.iconSizeExtraSmall / 3; @@ -129,7 +129,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const chartBottom = useSharedValue(0); const yZero = useSharedValue(0); - const {checkIsOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ font, truncatedLabels, labelRotation, @@ -169,7 +169,7 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, - checkIsOverLabel, + isCursorOverLabel, resolveLabelTouchX: findLabelCursorX, chartBottom, yZero, diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index a6e71f3666177..12d121d446fe0 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -12,7 +12,15 @@ 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, DIAGONAL_ANGLE_RAD_THRESHOLD, 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 {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; @@ -43,7 +51,7 @@ const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; const computeLineLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => { const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA; let additionalOffset = 0; - if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RAD_THRESHOLD) { + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { additionalOffset = variables.iconSizeExtraSmall / 1.5; } else if (angleRad > 1) { additionalOffset = variables.iconSizeExtraSmall / 3; @@ -148,7 +156,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn unitPosition: yAxisUnitPosition, }); - const {checkIsOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ font, truncatedLabels, labelRotation, @@ -175,7 +183,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, - checkIsOverLabel, + isCursorOverLabel, resolveLabelTouchX: findLabelCursorX, chartBottom, }); diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 3b49d7692ece2..2f175d5798c44 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -37,7 +37,7 @@ const ELLIPSIS = '...'; const MIN_TRUNCATED_CHARS = 10; /** Radian threshold separating diagonal from vertical label hit-test */ -const DIAGONAL_ANGLE_RAD_THRESHOLD = 1; +const DIAGONAL_ANGLE_RADIAN_THRESHOLD = 1; const PIE_CHART_TOOLTIP_RADIUS_DISTANCE = 2 / 3; @@ -54,6 +54,6 @@ export { LABEL_PADDING, ELLIPSIS, MIN_TRUNCATED_CHARS, - DIAGONAL_ANGLE_RAD_THRESHOLD, + DIAGONAL_ANGLE_RADIAN_THRESHOLD, PIE_CHART_TOOLTIP_RADIUS_DISTANCE, }; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index e8e299b459783..6bcaabffb5251 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -42,7 +42,7 @@ type UseChartInteractionsProps = { checkIsOver: (args: HitTestArgs) => boolean; /** Worklet function to determine if the cursor is hovering over the label area */ - checkIsOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean; + isCursorOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean; /** * Optional worklet that, when the cursor is in the label area, scans all label bounding @@ -107,7 +107,7 @@ function findClosestPoint(xValues: number[], targetX: number): number { * Uses react native gesture handler gestures directly — no dependency on Victory's actionsRef/handleTouch. * Synchronizes high-frequency UI thread data to React state for tooltip display and navigation. */ -function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resolveLabelTouchX, 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(); @@ -159,7 +159,7 @@ function useChartInteractions({handlePress, checkIsOver, checkIsOverLabel, resol targetY, chartBottom: currentChartBottom, }) || - (checkIsOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false) + (isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false) ); }); diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts index 6729e42755004..3065f038942a4 100644 --- a/src/components/Charts/hooks/useLabelHitTesting.ts +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -3,7 +3,7 @@ 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_RAD_THRESHOLD} from '@components/Charts/constants'; +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'; @@ -77,7 +77,7 @@ type UseLabelHitTestingParams = { * Shared hook for x-axis label hit-testing in cartesian charts. * * Encapsulates label width measurement, angle conversion, pre-computed hit geometry, - * and the checkIsOverLabel / findLabelCursorX worklets — all of which are identical + * 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 @@ -117,7 +117,7 @@ function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInte * Hit-tests whether the cursor is over the x-axis label at `activeIndex`. * Supports 0°, ~45° (parallelogram), and 90° label orientations. */ - const checkIsOverLabel = (args: HitTestArgs, activeIndex: number): boolean => { + const isCursorOverLabel = (args: HitTestArgs, activeIndex: number): boolean => { 'worklet'; if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { @@ -130,7 +130,7 @@ function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInte const labelY = args.chartBottom + labelYOffset; let corners45: Array<{x: number; y: number}> | undefined; - if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RAD_THRESHOLD) { + 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; @@ -175,7 +175,7 @@ function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInte if (tickX === undefined) { continue; } - if (checkIsOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + if (isCursorOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { return tickX; } } @@ -187,7 +187,7 @@ function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInte tickXPositions.set(Array.from({length: dataLength}, (_, i) => xScale(i))); }; - return {checkIsOverLabel, findLabelCursorX, updateTickPositions}; + return {isCursorOverLabel, findLabelCursorX, updateTickPositions}; } export default useLabelHitTesting; diff --git a/tests/unit/components/Charts/useChartInteractions.test.ts b/tests/unit/components/Charts/useChartInteractions.test.ts index d90fc721a098e..5d04487e64264 100644 --- a/tests/unit/components/Charts/useChartInteractions.test.ts +++ b/tests/unit/components/Charts/useChartInteractions.test.ts @@ -145,11 +145,11 @@ describe('useChartInteractions', () => { expect(result.current.isTooltipActive).toBe(false); }); - it('accepts optional checkIsOverLabel and resolveLabelTouchX', () => { + it('accepts optional isCursorOverLabel and resolveLabelTouchX', () => { const {result} = renderHook(() => useChartInteractions({ ...defaultProps, - checkIsOverLabel: () => false, + isCursorOverLabel: () => false, resolveLabelTouchX: (x: number) => x, }), ); From 8913c7f7d4a3293b47f7c10ba213711ece6147fa Mon Sep 17 00:00:00 2001 From: borys3kk Date: Thu, 12 Mar 2026 10:34:14 +0100 Subject: [PATCH 33/33] fix prettier --- src/components/Charts/BarChart/BarChartContent.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index a52486782278f..41a0ee56a6a00 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -10,7 +10,15 @@ 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, DIAGONAL_ANGLE_RADIAN_THRESHOLD, 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 {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks';