@ledgerhq/lumen-ui-rnative-visualization 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/module/lib/Components/Axis/Axis.constants.js +21 -0
  2. package/dist/module/lib/Components/Axis/Axis.constants.js.map +1 -0
  3. package/dist/module/lib/Components/Axis/index.js +5 -0
  4. package/dist/module/lib/Components/Axis/index.js.map +1 -0
  5. package/dist/module/lib/Components/CartesianChart/CartesianChart.js +34 -22
  6. package/dist/module/lib/Components/CartesianChart/CartesianChart.js.map +1 -1
  7. package/dist/module/lib/Components/CartesianChart/context/useBuildChartContext.js +5 -4
  8. package/dist/module/lib/Components/CartesianChart/context/useBuildChartContext.js.map +1 -1
  9. package/dist/module/lib/Components/CartesianChart/context/useBuildChartContext.test.js +54 -0
  10. package/dist/module/lib/Components/CartesianChart/context/useBuildChartContext.test.js.map +1 -1
  11. package/dist/module/lib/Components/CartesianChart/utils.js +18 -8
  12. package/dist/module/lib/Components/CartesianChart/utils.js.map +1 -1
  13. package/dist/module/lib/Components/Line/utils.js +6 -1
  14. package/dist/module/lib/Components/Line/utils.js.map +1 -1
  15. package/dist/module/lib/Components/Line/utils.test.js +88 -0
  16. package/dist/module/lib/Components/Line/utils.test.js.map +1 -0
  17. package/dist/module/lib/Components/LineChart/LineChart.js +19 -31
  18. package/dist/module/lib/Components/LineChart/LineChart.js.map +1 -1
  19. package/dist/module/lib/Components/LineChart/LineChart.stories.js +38 -0
  20. package/dist/module/lib/Components/LineChart/LineChart.stories.js.map +1 -1
  21. package/dist/module/lib/Components/LineChart/LineChart.test.js +192 -0
  22. package/dist/module/lib/Components/LineChart/LineChart.test.js.map +1 -1
  23. package/dist/module/lib/Components/Point/Point.js +8 -2
  24. package/dist/module/lib/Components/Point/Point.js.map +1 -1
  25. package/dist/module/lib/Components/Point/constants.js +9 -0
  26. package/dist/module/lib/Components/Point/constants.js.map +1 -0
  27. package/dist/module/lib/Components/Point/pointContext/MagneticPointsProvider.js +35 -0
  28. package/dist/module/lib/Components/Point/pointContext/MagneticPointsProvider.js.map +1 -0
  29. package/dist/module/lib/Components/Point/pointContext/MagneticPointsProvider.test.js +153 -0
  30. package/dist/module/lib/Components/Point/pointContext/MagneticPointsProvider.test.js.map +1 -0
  31. package/dist/module/lib/Components/Point/pointContext/index.js +5 -0
  32. package/dist/module/lib/Components/Point/pointContext/index.js.map +1 -0
  33. package/dist/module/lib/Components/Point/pointContext/magneticPointsContext.js +10 -0
  34. package/dist/module/lib/Components/Point/pointContext/magneticPointsContext.js.map +1 -0
  35. package/dist/module/lib/Components/Point/utils.js +26 -6
  36. package/dist/module/lib/Components/Point/utils.js.map +1 -1
  37. package/dist/module/lib/Components/Point/utils.test.js +84 -2
  38. package/dist/module/lib/Components/Point/utils.test.js.map +1 -1
  39. package/dist/module/lib/Components/ReferenceLine/utils.js.map +1 -1
  40. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.js +52 -0
  41. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.js.map +1 -0
  42. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.js +81 -0
  43. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.js.map +1 -0
  44. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.js +340 -0
  45. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.js.map +1 -0
  46. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/constants.js +12 -0
  47. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/constants.js.map +1 -0
  48. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js +166 -0
  49. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js.map +1 -0
  50. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js +253 -0
  51. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js.map +1 -0
  52. package/dist/module/lib/Components/Scrubber/Scrubber.js +40 -21
  53. package/dist/module/lib/Components/Scrubber/Scrubber.js.map +1 -1
  54. package/dist/module/lib/Components/Scrubber/Scrubber.stories.js +80 -30
  55. package/dist/module/lib/Components/Scrubber/Scrubber.stories.js.map +1 -1
  56. package/dist/module/lib/Components/Scrubber/Scrubber.test.js +10 -5
  57. package/dist/module/lib/Components/Scrubber/Scrubber.test.js.map +1 -1
  58. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js +81 -29
  59. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js.map +1 -1
  60. package/dist/module/lib/Components/Scrubber/utils.js +64 -0
  61. package/dist/module/lib/Components/Scrubber/utils.js.map +1 -1
  62. package/dist/module/lib/Components/Scrubber/utils.test.js +128 -1
  63. package/dist/module/lib/Components/Scrubber/utils.test.js.map +1 -1
  64. package/dist/module/lib/utils/domain/domain.js.map +1 -1
  65. package/dist/module/lib/utils/scales/scales.js +13 -4
  66. package/dist/module/lib/utils/scales/scales.js.map +1 -1
  67. package/dist/module/lib/utils/scales/scales.test.js +29 -0
  68. package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
  69. package/dist/module/lib/utils/ticks/ticks.js +13 -3
  70. package/dist/module/lib/utils/ticks/ticks.js.map +1 -1
  71. package/dist/module/lib/utils/ticks/ticks.test.js +34 -0
  72. package/dist/module/lib/utils/ticks/ticks.test.js.map +1 -1
  73. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts +5 -0
  74. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts.map +1 -0
  75. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts +39 -0
  76. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts.map +1 -1
  77. package/dist/typescript/src/lib/Components/Axis/index.d.ts +3 -0
  78. package/dist/typescript/src/lib/Components/Axis/index.d.ts.map +1 -0
  79. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
  80. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
  81. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts +5 -4
  82. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts.map +1 -1
  83. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +11 -3
  84. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
  85. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts +18 -8
  86. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts.map +1 -1
  87. package/dist/typescript/src/lib/Components/Line/utils.d.ts +4 -0
  88. package/dist/typescript/src/lib/Components/Line/utils.d.ts.map +1 -1
  89. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts +1 -1
  90. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts.map +1 -1
  91. package/dist/typescript/src/lib/Components/LineChart/types.d.ts +10 -3
  92. package/dist/typescript/src/lib/Components/LineChart/types.d.ts.map +1 -1
  93. package/dist/typescript/src/lib/Components/Point/Point.d.ts +1 -1
  94. package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -1
  95. package/dist/typescript/src/lib/Components/Point/constants.d.ts +7 -0
  96. package/dist/typescript/src/lib/Components/Point/constants.d.ts.map +1 -0
  97. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts +7 -0
  98. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts.map +1 -0
  99. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts +3 -0
  100. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts.map +1 -0
  101. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts +13 -0
  102. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts.map +1 -0
  103. package/dist/typescript/src/lib/Components/Point/types.d.ts +7 -0
  104. package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -1
  105. package/dist/typescript/src/lib/Components/Point/utils.d.ts +8 -6
  106. package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -1
  107. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts +3 -2
  108. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts.map +1 -1
  109. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.d.ts +9 -0
  110. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.d.ts.map +1 -0
  111. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.d.ts +12 -0
  112. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.d.ts.map +1 -0
  113. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.d.ts +10 -0
  114. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.d.ts.map +1 -0
  115. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts +69 -0
  116. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts.map +1 -0
  117. package/dist/typescript/src/lib/Components/Scrubber/Scrubber.d.ts +14 -2
  118. package/dist/typescript/src/lib/Components/Scrubber/Scrubber.d.ts.map +1 -1
  119. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts +1 -1
  120. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts.map +1 -1
  121. package/dist/typescript/src/lib/Components/Scrubber/index.d.ts +1 -1
  122. package/dist/typescript/src/lib/Components/Scrubber/index.d.ts.map +1 -1
  123. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts +116 -4
  124. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts.map +1 -1
  125. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts +33 -3
  126. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts.map +1 -1
  127. package/dist/typescript/src/lib/utils/domain/domain.d.ts +5 -4
  128. package/dist/typescript/src/lib/utils/domain/domain.d.ts.map +1 -1
  129. package/dist/typescript/src/lib/utils/index.d.ts +1 -1
  130. package/dist/typescript/src/lib/utils/index.d.ts.map +1 -1
  131. package/dist/typescript/src/lib/utils/scales/scales.d.ts +11 -5
  132. package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
  133. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts +13 -5
  134. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts.map +1 -1
  135. package/dist/typescript/src/lib/utils/types.d.ts +3 -28
  136. package/dist/typescript/src/lib/utils/types.d.ts.map +1 -1
  137. package/package.json +4 -4
  138. package/src/lib/Components/Axis/Axis.constants.ts +23 -0
  139. package/src/lib/Components/Axis/Axis.types.ts +40 -0
  140. package/src/lib/Components/Axis/index.ts +2 -0
  141. package/src/lib/Components/CartesianChart/CartesianChart.tsx +39 -24
  142. package/src/lib/Components/CartesianChart/context/useBuildChartContext.test.ts +56 -0
  143. package/src/lib/Components/CartesianChart/context/useBuildChartContext.ts +8 -8
  144. package/src/lib/Components/CartesianChart/types.ts +11 -3
  145. package/src/lib/Components/CartesianChart/utils.ts +18 -8
  146. package/src/lib/Components/Line/utils.test.ts +90 -0
  147. package/src/lib/Components/Line/utils.ts +6 -1
  148. package/src/lib/Components/LineChart/LineChart.stories.tsx +36 -0
  149. package/src/lib/Components/LineChart/LineChart.test.tsx +156 -0
  150. package/src/lib/Components/LineChart/LineChart.tsx +30 -40
  151. package/src/lib/Components/LineChart/types.ts +10 -3
  152. package/src/lib/Components/Point/Point.tsx +9 -3
  153. package/src/lib/Components/Point/constants.ts +6 -0
  154. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.test.tsx +162 -0
  155. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.tsx +45 -0
  156. package/src/lib/Components/Point/pointContext/index.ts +5 -0
  157. package/src/lib/Components/Point/pointContext/magneticPointsContext.ts +19 -0
  158. package/src/lib/Components/Point/types.ts +7 -0
  159. package/src/lib/Components/Point/utils.test.ts +95 -5
  160. package/src/lib/Components/Point/utils.ts +35 -7
  161. package/src/lib/Components/ReferenceLine/utils.ts +4 -7
  162. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.tsx +54 -0
  163. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.tsx +259 -0
  164. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.tsx +105 -0
  165. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.ts +9 -0
  166. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.ts +264 -0
  167. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.ts +243 -0
  168. package/src/lib/Components/Scrubber/Scrubber.stories.tsx +104 -42
  169. package/src/lib/Components/Scrubber/Scrubber.test.tsx +7 -5
  170. package/src/lib/Components/Scrubber/Scrubber.tsx +47 -24
  171. package/src/lib/Components/Scrubber/ScrubberProvider.tsx +92 -28
  172. package/src/lib/Components/Scrubber/index.ts +10 -1
  173. package/src/lib/Components/Scrubber/types.ts +122 -4
  174. package/src/lib/Components/Scrubber/utils.test.ts +150 -0
  175. package/src/lib/Components/Scrubber/utils.ts +93 -3
  176. package/src/lib/utils/domain/domain.ts +6 -5
  177. package/src/lib/utils/index.ts +0 -2
  178. package/src/lib/utils/scales/scales.test.ts +19 -0
  179. package/src/lib/utils/scales/scales.ts +15 -9
  180. package/src/lib/utils/ticks/ticks.test.ts +45 -0
  181. package/src/lib/utils/ticks/ticks.ts +19 -9
  182. package/src/lib/utils/types.ts +3 -26
@@ -8,26 +8,26 @@ import {
8
8
  LinearGradient,
9
9
  Rect,
10
10
  Stop,
11
- Text as SvgText,
12
11
  } from 'react-native-svg';
13
12
 
14
13
  import { useCartesianChartContext } from '../CartesianChart/context';
15
14
  import {
16
15
  BEACON_RADIUS,
17
16
  BEACON_STROKE_WIDTH,
18
- LABEL_OFFSET_Y,
19
17
  LINE_GRADIENT_EDGE_OPACITY,
20
18
  OVERLAY_LINE_INSET,
21
19
  OVERLAY_OFFSET,
22
20
  OVERLAY_OPACITY,
23
21
  } from './constants';
24
22
  import { useScrubberContext } from './context';
23
+ import { DefaultScrubberTooltip } from './DefaultScrubberTooltip/DefaultScrubberTooltip';
25
24
  import type { ScrubberProps } from './types';
26
25
  import { resolvePixelX, resolvePixelY } from './utils';
27
26
 
28
27
  /**
29
28
  * Renders the scrubber visuals: vertical reference line, future-data overlay
30
- * rect, per-series beacon dots, and an optional formatted label above the line.
29
+ * rect, per-series beacon dots, optional label above the line, and an optional
30
+ * tooltip when {@link ScrubberProps.tooltip} is set, using {@link DefaultScrubberTooltip}.
31
31
  *
32
32
  * Must be used as a child of `LineChart` (or `CartesianChart`) with
33
33
  * `enableScrubbing` enabled. Renders nothing when no scrubber position is active.
@@ -38,12 +38,23 @@ import { resolvePixelX, resolvePixelY } from './utils';
38
38
  * <Scrubber label={(i) => data[i].date} />
39
39
  * </LineChart>
40
40
  * ```
41
+ *
42
+ * @example Tooltip
43
+ * ```tsx
44
+ * <Scrubber
45
+ * tooltip={(i) => ({
46
+ * title: `${counts[i]} Transactions`,
47
+ * items: [{ label: 'Index', value: String(i) }],
48
+ * minWidth: 160,
49
+ * })}
50
+ * />
51
+ * ```
41
52
  */
42
53
  export function Scrubber({
43
- label,
44
54
  hideLine = false,
45
55
  hideOverlay = false,
46
56
  showBeacons = false,
57
+ tooltip,
47
58
  }: Readonly<ScrubberProps>) {
48
59
  const lineGradientId = useId();
49
60
  const { theme } = useTheme();
@@ -76,10 +87,27 @@ export function Scrubber({
76
87
  );
77
88
  }, [scrubberPosition, showBeacons, series, seriesMap, getYScale]);
78
89
 
79
- const resolvedLabel = useMemo(() => {
80
- if (scrubberPosition === undefined || !label) return undefined;
81
- return label(scrubberPosition);
82
- }, [scrubberPosition, label]);
90
+ const tooltipPayload = useMemo(() => {
91
+ if (scrubberPosition === undefined || !tooltip) {
92
+ return undefined;
93
+ }
94
+
95
+ const content = tooltip(scrubberPosition);
96
+
97
+ if (content.items.length === 0) return undefined;
98
+
99
+ const resolvedTitle =
100
+ typeof content.title === 'function'
101
+ ? content.title(scrubberPosition)
102
+ : content.title;
103
+
104
+ return {
105
+ items: content.items,
106
+ resolvedTitle,
107
+ offset: content.offset,
108
+ minWidth: content.minWidth,
109
+ };
110
+ }, [scrubberPosition, tooltip]);
83
111
 
84
112
  if (scrubberPosition === undefined || pixelX === undefined) {
85
113
  return null;
@@ -102,7 +130,6 @@ export function Scrubber({
102
130
 
103
131
  const borderMutedColor = theme.colors.border.base;
104
132
  const backgroundBaseColor = theme.colors.bg.base;
105
- const textBaseColor = theme.colors.text.base;
106
133
  const bgCanvasColor = theme.colors.bg.canvas;
107
134
 
108
135
  return (
@@ -156,21 +183,6 @@ export function Scrubber({
156
183
  />
157
184
  )}
158
185
 
159
- {resolvedLabel !== undefined && (
160
- <SvgText
161
- testID='scrubber-label'
162
- x={pixelX}
163
- y={drawY - LABEL_OFFSET_Y}
164
- textAnchor='middle'
165
- fill={textBaseColor}
166
- fontSize={theme.typographies.body4.fontSize}
167
- fontWeight={theme.typographies.body4.fontWeight}
168
- fontFamily={theme.fontFamilies.sans}
169
- >
170
- {resolvedLabel}
171
- </SvgText>
172
- )}
173
-
174
186
  {showBeacons &&
175
187
  beacons.map((beacon) => (
176
188
  <Circle
@@ -184,6 +196,17 @@ export function Scrubber({
184
196
  strokeWidth={BEACON_STROKE_WIDTH}
185
197
  />
186
198
  ))}
199
+
200
+ {tooltipPayload !== undefined && (
201
+ <DefaultScrubberTooltip
202
+ pixelX={pixelX}
203
+ drawingArea={drawingArea}
204
+ title={tooltipPayload.resolvedTitle}
205
+ items={tooltipPayload.items}
206
+ offset={tooltipPayload.offset}
207
+ minWidth={tooltipPayload.minWidth}
208
+ />
209
+ )}
187
210
  </G>
188
211
  );
189
212
  }
@@ -5,9 +5,17 @@ import Animated, { useSharedValue } from 'react-native-reanimated';
5
5
  import { scheduleOnRN } from 'react-native-worklets';
6
6
 
7
7
  import { useCartesianChartContext } from '../CartesianChart/context';
8
+ import { useMagneticPointsContext } from '../Point/pointContext';
8
9
  import { ScrubberContextProvider } from './context';
9
10
  import type { ScrubberProviderProps } from './types';
10
- import { getDataIndexFromPosition } from './utils';
11
+ import {
12
+ applyMagnetisation,
13
+ getDataIndexFromPosition,
14
+ resolvePixelX,
15
+ buildSortedMagnets,
16
+ } from './utils';
17
+
18
+ const LONG_PRESS_MIN_DURATION_MS = 50;
11
19
 
12
20
  /**
13
21
  * Provides scrubbing interaction for a chart.
@@ -28,28 +36,60 @@ export function ScrubberProvider({
28
36
  height,
29
37
  enableScrubbing,
30
38
  onScrubberPositionChange,
39
+ magnetRadius = 6,
40
+ style,
31
41
  }: Readonly<ScrubberProviderProps>) {
32
42
  const [scrubberPosition, setScrubberPosition] = useState<
33
43
  number | undefined
34
44
  >();
35
- const lastPositionRef = useRef<number | undefined>(undefined);
36
45
 
37
46
  const { getXScale, getXAxisConfig, dataLength } = useCartesianChartContext();
47
+ const { getMagneticPoints, version } = useMagneticPointsContext();
38
48
 
39
- const isScrubbing = useSharedValue(false);
49
+ // All values touched by the gesture's JS-thread callback live in a single
50
+ // ref. Reading via `latest.current` keeps the callbacks reference-stable
51
+ // across data, scale, and prop updates, which in turn keeps `composed` and
52
+ // the gesture surface JSX stable so re-rendering the provider on each
53
+ // index change never tears down the GestureDetector or the View tree.
54
+ const sortedMagnets = useMemo(() => {
55
+ const magneticIndices = getMagneticPoints();
56
+ return buildSortedMagnets({
57
+ magneticIndices,
58
+ getPixelForIndex: (index) =>
59
+ resolvePixelX(index, getXScale, getXAxisConfig()),
60
+ });
61
+ // eslint-disable-next-line react-hooks/exhaustive-deps
62
+ }, [version, getXScale, getXAxisConfig, getMagneticPoints]);
63
+
64
+ const latest = useRef({
65
+ getXScale,
66
+ getXAxisConfig,
67
+ dataLength,
68
+ onChange: onScrubberPositionChange,
69
+ lastIndex: undefined as number | undefined,
70
+ magnetRadius,
71
+ sortedMagnets,
72
+ });
73
+ latest.current.getXScale = getXScale;
74
+ latest.current.getXAxisConfig = getXAxisConfig;
75
+ latest.current.dataLength = dataLength;
76
+ latest.current.onChange = onScrubberPositionChange;
77
+ latest.current.magnetRadius = magnetRadius;
78
+ latest.current.sortedMagnets = sortedMagnets;
40
79
 
41
80
  const setScrubberPositionAndNotify = useCallback(
42
81
  (index: number | undefined) => {
82
+ const ref = latest.current;
43
83
  const clamped =
44
- index === undefined || dataLength <= 0
84
+ index === undefined || ref.dataLength <= 0
45
85
  ? undefined
46
- : Math.max(0, Math.min(index, dataLength - 1));
47
- if (clamped === lastPositionRef.current) return;
48
- lastPositionRef.current = clamped;
86
+ : Math.max(0, Math.min(index, ref.dataLength - 1));
87
+ if (clamped === ref.lastIndex) return;
88
+ ref.lastIndex = clamped;
49
89
  setScrubberPosition(clamped);
50
- onScrubberPositionChange?.(clamped);
90
+ ref.onChange?.(clamped);
51
91
  },
52
- [dataLength, onScrubberPositionChange],
92
+ [],
53
93
  );
54
94
 
55
95
  const handlePositionChange = useCallback(
@@ -58,22 +98,35 @@ export function ScrubberProvider({
58
98
  setScrubberPositionAndNotify(undefined);
59
99
  return;
60
100
  }
101
+ const ref = latest.current;
102
+ const scale = ref.getXScale();
103
+ if (!scale || ref.dataLength <= 0) return;
61
104
 
62
- const scale = getXScale();
63
- if (!scale || dataLength <= 0) return;
105
+ const xAxisConfig = ref.getXAxisConfig();
64
106
 
65
- const axisConfig = getXAxisConfig();
66
- const index = getDataIndexFromPosition(
107
+ let index = getDataIndexFromPosition(
67
108
  pixelX,
68
109
  scale,
69
- axisConfig,
70
- dataLength,
110
+ xAxisConfig,
111
+ ref.dataLength,
71
112
  );
113
+
114
+ if (ref.magnetRadius > 0) {
115
+ index = applyMagnetisation({
116
+ resolvedIndex: index,
117
+ pixelX,
118
+ sortedMagnets: ref.sortedMagnets,
119
+ magnetRadius: ref.magnetRadius,
120
+ });
121
+ }
122
+
72
123
  setScrubberPositionAndNotify(index);
73
124
  },
74
- [getXScale, getXAxisConfig, dataLength, setScrubberPositionAndNotify],
125
+ [setScrubberPositionAndNotify],
75
126
  );
76
127
 
128
+ const isScrubbing = useSharedValue(false);
129
+
77
130
  const resetScrubber = useCallback((): void => {
78
131
  'worklet';
79
132
  isScrubbing.value = false;
@@ -82,7 +135,7 @@ export function ScrubberProvider({
82
135
 
83
136
  const composed = useMemo(() => {
84
137
  const longPress = Gesture.LongPress()
85
- .minDuration(50)
138
+ .minDuration(LONG_PRESS_MIN_DURATION_MS)
86
139
  .onStart((e) => {
87
140
  'worklet';
88
141
  isScrubbing.value = true;
@@ -105,6 +158,27 @@ export function ScrubberProvider({
105
158
  return Gesture.Simultaneous(longPress, pan);
106
159
  }, [isScrubbing, handlePositionChange, resetScrubber]);
107
160
 
161
+ // Memoizing the surface lets React's element-identity bailout skip
162
+ // reconciling the View / GestureDetector / chart `children` subtree when
163
+ // only `scrubberPosition` changes. The Scrubber consumer still updates via
164
+ // context as expected.
165
+ const surface = useMemo(
166
+ () => (
167
+ <View style={[{ width, height }, style]}>
168
+ {children}
169
+ {enableScrubbing && (
170
+ <GestureDetector gesture={composed}>
171
+ <Animated.View
172
+ testID='scrubber-gesture-overlay'
173
+ style={StyleSheet.absoluteFill}
174
+ />
175
+ </GestureDetector>
176
+ )}
177
+ </View>
178
+ ),
179
+ [width, height, style, children, enableScrubbing, composed],
180
+ );
181
+
108
182
  const contextValue = useMemo(
109
183
  () => ({
110
184
  enableScrubbing,
@@ -116,17 +190,7 @@ export function ScrubberProvider({
116
190
 
117
191
  return (
118
192
  <ScrubberContextProvider value={contextValue}>
119
- <View style={{ width, height }}>
120
- {children}
121
- {enableScrubbing && (
122
- <GestureDetector gesture={composed}>
123
- <Animated.View
124
- testID='scrubber-gesture-overlay'
125
- style={StyleSheet.absoluteFill}
126
- />
127
- </GestureDetector>
128
- )}
129
- </View>
193
+ {surface}
130
194
  </ScrubberContextProvider>
131
195
  );
132
196
  }
@@ -1,3 +1,12 @@
1
1
  export { Scrubber } from './Scrubber';
2
2
  export { useScrubberContext } from './context';
3
- export type { ScrubberProps, ScrubberContextValue } from './types';
3
+
4
+ export type {
5
+ ChartTooltipItemData,
6
+ ChartTooltipItemProps,
7
+ ScrubberContextValue,
8
+ ScrubberProps,
9
+ ScrubberTooltipContent,
10
+ ScrubberTooltipProps,
11
+ SvgBBoxElement,
12
+ } from './types';
@@ -1,4 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ import type { DrawingArea } from '../../utils/types';
2
5
 
3
6
  export type ScrubberContextValue = {
4
7
  /**
@@ -33,14 +36,123 @@ export type ScrubberProviderProps = {
33
36
  * Optional external callback fired whenever the scrubber position changes.
34
37
  */
35
38
  onScrubberPositionChange?: (index: number | undefined) => void;
39
+ /**
40
+ * Pixel radius within which the scrubber magnetically snaps to registered
41
+ * magnetic points. Set to `0` to disable magnetisation.
42
+ * @default 6
43
+ */
44
+ magnetRadius?: number;
45
+ /**
46
+ * Optional style applied to the wrapping `View` that holds the chart and the
47
+ * gesture overlay.Used to offset the(enlarged) SVG group so its drawing area
48
+ * aligns with the container footprint.
49
+ */
50
+ style?: StyleProp<ViewStyle>;
36
51
  };
37
52
 
38
- export type ScrubberProps = {
53
+ export type ChartTooltipItemData = {
54
+ /**
55
+ * Label displayed on the left side of the row.
56
+ */
57
+ label: string | number;
58
+ /**
59
+ * Value displayed on the right side of the row.
60
+ */
61
+ value: string | number;
62
+ };
63
+
64
+ export type ChartTooltipItemProps = ChartTooltipItemData & {
65
+ /**
66
+ * X position in SVG coordinate space.
67
+ * @default 0
68
+ */
69
+ x?: number;
70
+ /**
71
+ * Y midline position in SVG coordinate space.
72
+ * @default 0
73
+ */
74
+ y?: number;
75
+ /**
76
+ * Width allocated to this row, used to right-align the value text.
77
+ */
78
+ width: number;
79
+ /**
80
+ * Optional callback ref forwarded to the label `<Text>` element.
81
+ * Useful to measure the label's natural width via `getBBox` for auto-fit layouts.
82
+ */
83
+ labelRef?: (el: SvgBBoxElement | null) => void;
84
+ /**
85
+ * Optional callback ref forwarded to the value `<Text>` element.
86
+ * Useful to measure the value's natural width via `getBBox` for auto-fit layouts.
87
+ */
88
+ valueRef?: (el: SvgBBoxElement | null) => void;
89
+ };
90
+
91
+ /**
92
+ * Minimal interface for an SVG element that supports `getBBox`.
93
+ * Handles both react-native-svg (returns `Rect | undefined`) and web DOM SVG
94
+ * (returns `DOMRect` synchronously or a Promise on native bridge).
95
+ */
96
+ export type SvgBBoxElement = {
97
+ getBBox: () =>
98
+ | { width: number }
99
+ | undefined
100
+ | Promise<{ width: number } | undefined>;
101
+ };
102
+
103
+ export type ScrubberTooltipLayoutProps = {
39
104
  /**
40
- * Formats a label string shown above the reference line for a given data index.
41
- * When omitted, no label is rendered.
105
+ * Horizontal gap in pixels between the scrubber line and the tooltip box.
106
+ * @default 10
42
107
  */
43
- label?: (dataIndex: number) => string;
108
+ offset?: number;
109
+ /**
110
+ * Minimum width in pixels. The tooltip auto-fits to the rendered content
111
+ * but never collapses below this floor; raise it to avoid jitter when
112
+ * value length changes between indices.
113
+ * @default 80
114
+ */
115
+ minWidth?: number;
116
+ };
117
+
118
+ export type ScrubberTooltipProps = ScrubberTooltipLayoutProps & {
119
+ /**
120
+ * Horizontal pixel position of the scrubber line within the SVG coordinate
121
+ * space. Used to place the tooltip left or right of the line.
122
+ */
123
+ pixelX: number;
124
+ /**
125
+ * Bounding box of the chart's drawing area (x, y, width, height). Used to
126
+ * constrain the tooltip so it never overflows the chart boundaries.
127
+ */
128
+ drawingArea: DrawingArea;
129
+ /**
130
+ * Optional title displayed at the top of the tooltip. Omit to render
131
+ * the tooltip without a title row.
132
+ */
133
+ title?: string | number;
134
+ /**
135
+ * List of label/value pairs rendered as rows inside the tooltip body.
136
+ */
137
+ items: ChartTooltipItemData[];
138
+ };
139
+
140
+ /**
141
+ * Return value of the `tooltip` callback on {@link ScrubberProps}.
142
+ */
143
+ export type ScrubberTooltipContent = ScrubberTooltipLayoutProps & {
144
+ /**
145
+ * Optional header. Static value or callback per data index.
146
+ * A callback may return `undefined` to suppress the title.
147
+ */
148
+ title?: string | number | ((index: number) => string | number | undefined);
149
+ /**
150
+ * Tooltip rows for this index. Return an empty array to hide the tooltip.
151
+ */
152
+ items: ChartTooltipItemData[];
153
+ };
154
+
155
+ export type ScrubberProps = {
44
156
  /**
45
157
  * Hides the vertical reference line.
46
158
  * @default false
@@ -56,4 +168,10 @@ export type ScrubberProps = {
56
168
  * @default false
57
169
  */
58
170
  showBeacons?: boolean;
171
+ /**
172
+ * Produces tooltip content for the active data index. When set, {@link DefaultScrubberTooltip}
173
+ * is rendered. Optional `offset` and `minWidth` on the returned object tune layout.
174
+ * Return `{ items: [] }` to hide the tooltip at an index.
175
+ */
176
+ tooltip?: (dataIndex: number) => ScrubberTooltipContent;
59
177
  };
@@ -5,10 +5,14 @@ import {
5
5
  getNumericScale,
6
6
  } from '../../utils/scales/scales';
7
7
  import {
8
+ applyMagnetisation,
9
+ buildSortedMagnets,
8
10
  getDataIndexFromPosition,
11
+ nearestIndex,
9
12
  resolvePixelX,
10
13
  resolvePixelY,
11
14
  } from './utils';
15
+ import type { MagnetEntry } from './utils';
12
16
 
13
17
  describe('getDataIndexFromPosition', () => {
14
18
  describe('with a categorical (band) scale', () => {
@@ -170,3 +174,149 @@ describe('resolvePixelY', () => {
170
174
  expect(result).toBe(scale(50));
171
175
  });
172
176
  });
177
+
178
+ describe('buildSortedMagnets', () => {
179
+ it('returns an empty array when the set is empty', () => {
180
+ const result = buildSortedMagnets({
181
+ magneticIndices: new Set(),
182
+ getPixelForIndex: () => 0,
183
+ });
184
+ expect(result).toEqual([]);
185
+ });
186
+
187
+ it('returns entries sorted by pixelX', () => {
188
+ const result = buildSortedMagnets({
189
+ magneticIndices: new Set([3, 1, 2]),
190
+ getPixelForIndex: (i) => i * 50,
191
+ });
192
+ expect(result).toEqual([
193
+ { index: 1, pixelX: 50 },
194
+ { index: 2, pixelX: 100 },
195
+ { index: 3, pixelX: 150 },
196
+ ]);
197
+ });
198
+
199
+ it('filters out indices where getPixelForIndex returns undefined', () => {
200
+ const result = buildSortedMagnets({
201
+ magneticIndices: new Set([0, 1, 2]),
202
+ getPixelForIndex: (i) => (i === 1 ? undefined : i * 100),
203
+ });
204
+ expect(result).toEqual([
205
+ { index: 0, pixelX: 0 },
206
+ { index: 2, pixelX: 200 },
207
+ ]);
208
+ });
209
+
210
+ it('returns an empty array when all indices resolve to undefined', () => {
211
+ const result = buildSortedMagnets({
212
+ magneticIndices: new Set([0, 1]),
213
+ getPixelForIndex: () => undefined,
214
+ });
215
+ expect(result).toEqual([]);
216
+ });
217
+ });
218
+
219
+ describe('nearestIndex', () => {
220
+ const magnets = (...pixelXs: number[]): MagnetEntry[] =>
221
+ pixelXs.map((px, i) => ({ index: i, pixelX: px }));
222
+
223
+ it('returns -1 for an empty array', () => {
224
+ expect(nearestIndex([], 100)).toBe(-1);
225
+ });
226
+
227
+ it('returns 0 for a single-element array', () => {
228
+ expect(nearestIndex(magnets(200), 100)).toBe(0);
229
+ });
230
+
231
+ it('returns the index of the exact match', () => {
232
+ expect(nearestIndex(magnets(100, 200, 300), 200)).toBe(1);
233
+ });
234
+
235
+ it('returns the closer index when target is between two values', () => {
236
+ expect(nearestIndex(magnets(100, 200), 160)).toBe(1);
237
+ expect(nearestIndex(magnets(100, 200), 140)).toBe(0);
238
+ });
239
+
240
+ it('favors the left index when equidistant', () => {
241
+ expect(nearestIndex(magnets(100, 200), 150)).toBe(0);
242
+ });
243
+
244
+ it('returns the last index when target is beyond all values', () => {
245
+ expect(nearestIndex(magnets(100, 200), 500)).toBe(1);
246
+ });
247
+
248
+ it('returns the first index when target is before all values', () => {
249
+ expect(nearestIndex(magnets(100, 200), 0)).toBe(0);
250
+ });
251
+ });
252
+
253
+ describe('applyMagnetisation', () => {
254
+ const toMagnets = (...indices: number[]): MagnetEntry[] =>
255
+ indices.map((i) => ({ index: i, pixelX: i * 100 }));
256
+
257
+ it('returns resolvedIndex when sortedMagnets is empty', () => {
258
+ expect(
259
+ applyMagnetisation({
260
+ resolvedIndex: 2,
261
+ pixelX: 200,
262
+ sortedMagnets: [],
263
+ magnetRadius: 30,
264
+ }),
265
+ ).toBe(2);
266
+ });
267
+
268
+ it('returns resolvedIndex when magnetRadius is 0', () => {
269
+ expect(
270
+ applyMagnetisation({
271
+ resolvedIndex: 1,
272
+ pixelX: 200,
273
+ sortedMagnets: toMagnets(2),
274
+ magnetRadius: 0,
275
+ }),
276
+ ).toBe(1);
277
+ });
278
+
279
+ it('returns resolvedIndex when nearest magnet is outside radius', () => {
280
+ expect(
281
+ applyMagnetisation({
282
+ resolvedIndex: 2,
283
+ pixelX: 200,
284
+ sortedMagnets: toMagnets(0, 4),
285
+ magnetRadius: 30,
286
+ }),
287
+ ).toBe(2);
288
+ });
289
+
290
+ it('snaps to nearest magnet when within radius', () => {
291
+ expect(
292
+ applyMagnetisation({
293
+ resolvedIndex: 2,
294
+ pixelX: 280,
295
+ sortedMagnets: toMagnets(3),
296
+ magnetRadius: 30,
297
+ }),
298
+ ).toBe(3);
299
+ });
300
+
301
+ it('snaps at the exact boundary of magnetRadius', () => {
302
+ expect(
303
+ applyMagnetisation({
304
+ resolvedIndex: 2,
305
+ pixelX: 270,
306
+ sortedMagnets: toMagnets(3),
307
+ magnetRadius: 30,
308
+ }),
309
+ ).toBe(3);
310
+ });
311
+
312
+ it('does not snap when distance exceeds magnetRadius by 1', () => {
313
+ expect(
314
+ applyMagnetisation({
315
+ resolvedIndex: 2,
316
+ pixelX: 269,
317
+ sortedMagnets: toMagnets(3),
318
+ magnetRadius: 30,
319
+ }),
320
+ ).toBe(2);
321
+ });
322
+ });