@ledgerhq/lumen-ui-rnative-visualization 0.1.14 → 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 (152) 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/DefaultScrubberTooltip.test.js +4 -4
  41. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js +31 -10
  42. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js.map +1 -1
  43. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js +4 -4
  44. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js.map +1 -1
  45. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js +35 -7
  46. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js.map +1 -1
  47. package/dist/module/lib/Components/Scrubber/utils.js +64 -0
  48. package/dist/module/lib/Components/Scrubber/utils.js.map +1 -1
  49. package/dist/module/lib/Components/Scrubber/utils.test.js +128 -1
  50. package/dist/module/lib/Components/Scrubber/utils.test.js.map +1 -1
  51. package/dist/module/lib/utils/domain/domain.js.map +1 -1
  52. package/dist/module/lib/utils/scales/scales.js +13 -4
  53. package/dist/module/lib/utils/scales/scales.js.map +1 -1
  54. package/dist/module/lib/utils/scales/scales.test.js +29 -0
  55. package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
  56. package/dist/module/lib/utils/ticks/ticks.js +13 -3
  57. package/dist/module/lib/utils/ticks/ticks.js.map +1 -1
  58. package/dist/module/lib/utils/ticks/ticks.test.js +34 -0
  59. package/dist/module/lib/utils/ticks/ticks.test.js.map +1 -1
  60. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts +5 -0
  61. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts.map +1 -0
  62. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts +39 -0
  63. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts.map +1 -1
  64. package/dist/typescript/src/lib/Components/Axis/index.d.ts +3 -0
  65. package/dist/typescript/src/lib/Components/Axis/index.d.ts.map +1 -0
  66. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
  67. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
  68. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts +5 -4
  69. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts.map +1 -1
  70. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +11 -3
  71. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
  72. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts +18 -8
  73. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts.map +1 -1
  74. package/dist/typescript/src/lib/Components/Line/utils.d.ts +4 -0
  75. package/dist/typescript/src/lib/Components/Line/utils.d.ts.map +1 -1
  76. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts +1 -1
  77. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts.map +1 -1
  78. package/dist/typescript/src/lib/Components/LineChart/types.d.ts +10 -3
  79. package/dist/typescript/src/lib/Components/LineChart/types.d.ts.map +1 -1
  80. package/dist/typescript/src/lib/Components/Point/Point.d.ts +1 -1
  81. package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -1
  82. package/dist/typescript/src/lib/Components/Point/constants.d.ts +7 -0
  83. package/dist/typescript/src/lib/Components/Point/constants.d.ts.map +1 -0
  84. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts +7 -0
  85. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts.map +1 -0
  86. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts +3 -0
  87. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts.map +1 -0
  88. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts +13 -0
  89. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts.map +1 -0
  90. package/dist/typescript/src/lib/Components/Point/types.d.ts +7 -0
  91. package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -1
  92. package/dist/typescript/src/lib/Components/Point/utils.d.ts +8 -6
  93. package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -1
  94. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts +3 -2
  95. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts.map +1 -1
  96. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts +5 -1
  97. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts.map +1 -1
  98. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts +1 -1
  99. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts.map +1 -1
  100. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts +13 -0
  101. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts.map +1 -1
  102. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts +33 -3
  103. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts.map +1 -1
  104. package/dist/typescript/src/lib/utils/domain/domain.d.ts +5 -4
  105. package/dist/typescript/src/lib/utils/domain/domain.d.ts.map +1 -1
  106. package/dist/typescript/src/lib/utils/index.d.ts +1 -1
  107. package/dist/typescript/src/lib/utils/index.d.ts.map +1 -1
  108. package/dist/typescript/src/lib/utils/scales/scales.d.ts +11 -5
  109. package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
  110. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts +13 -5
  111. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts.map +1 -1
  112. package/dist/typescript/src/lib/utils/types.d.ts +3 -28
  113. package/dist/typescript/src/lib/utils/types.d.ts.map +1 -1
  114. package/package.json +4 -4
  115. package/src/lib/Components/Axis/Axis.constants.ts +23 -0
  116. package/src/lib/Components/Axis/Axis.types.ts +40 -0
  117. package/src/lib/Components/Axis/index.ts +2 -0
  118. package/src/lib/Components/CartesianChart/CartesianChart.tsx +39 -23
  119. package/src/lib/Components/CartesianChart/context/useBuildChartContext.test.ts +56 -0
  120. package/src/lib/Components/CartesianChart/context/useBuildChartContext.ts +8 -8
  121. package/src/lib/Components/CartesianChart/types.ts +11 -3
  122. package/src/lib/Components/CartesianChart/utils.ts +18 -8
  123. package/src/lib/Components/Line/utils.test.ts +90 -0
  124. package/src/lib/Components/Line/utils.ts +6 -1
  125. package/src/lib/Components/LineChart/LineChart.stories.tsx +36 -0
  126. package/src/lib/Components/LineChart/LineChart.test.tsx +156 -0
  127. package/src/lib/Components/LineChart/LineChart.tsx +30 -40
  128. package/src/lib/Components/LineChart/types.ts +10 -3
  129. package/src/lib/Components/Point/Point.tsx +9 -3
  130. package/src/lib/Components/Point/constants.ts +6 -0
  131. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.test.tsx +162 -0
  132. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.tsx +45 -0
  133. package/src/lib/Components/Point/pointContext/index.ts +5 -0
  134. package/src/lib/Components/Point/pointContext/magneticPointsContext.ts +19 -0
  135. package/src/lib/Components/Point/types.ts +7 -0
  136. package/src/lib/Components/Point/utils.test.ts +95 -5
  137. package/src/lib/Components/Point/utils.ts +35 -7
  138. package/src/lib/Components/ReferenceLine/utils.ts +4 -7
  139. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.tsx +4 -4
  140. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.ts +4 -4
  141. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.ts +29 -15
  142. package/src/lib/Components/Scrubber/ScrubberProvider.tsx +41 -5
  143. package/src/lib/Components/Scrubber/types.ts +13 -0
  144. package/src/lib/Components/Scrubber/utils.test.ts +150 -0
  145. package/src/lib/Components/Scrubber/utils.ts +93 -3
  146. package/src/lib/utils/domain/domain.ts +6 -5
  147. package/src/lib/utils/index.ts +0 -2
  148. package/src/lib/utils/scales/scales.test.ts +19 -0
  149. package/src/lib/utils/scales/scales.ts +15 -9
  150. package/src/lib/utils/ticks/ticks.test.ts +45 -0
  151. package/src/lib/utils/ticks/ticks.ts +19 -9
  152. package/src/lib/utils/types.ts +3 -26
@@ -1,14 +1,14 @@
1
- import { describe, expect, it } from '@jest/globals';
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { renderHook } from '@testing-library/react-native';
2
3
 
4
+ import type { BaseAxisProps } from '../Axis';
5
+ import { ARROW_HEIGHT, ARROW_WIDTH, GAP, LABEL_FONT_SIZE } from './constants';
3
6
  import {
4
- ARROW_HEIGHT,
5
- ARROW_WIDTH,
6
7
  buildArrowPoints,
7
8
  computeLabelY,
8
- GAP,
9
9
  isWithinBounds,
10
- LABEL_FONT_SIZE,
11
10
  resolveLabel,
11
+ useMagneticRegistration,
12
12
  } from './utils';
13
13
 
14
14
  describe('isWithinBounds', () => {
@@ -116,3 +116,93 @@ describe('computeLabelY', () => {
116
116
  expect(result).toBe(pixelY + radius + GAP + GAP + LABEL_FONT_SIZE);
117
117
  });
118
118
  });
119
+
120
+ const makeContext = () => ({
121
+ register: jest.fn(),
122
+ unregister: jest.fn(),
123
+ version: 0,
124
+ getMagneticPoints: () => new Set<number>(),
125
+ });
126
+
127
+ const noAxisConfig = (): BaseAxisProps | undefined => undefined;
128
+
129
+ describe('useMagneticRegistration', () => {
130
+ it('registers the data index when magnetic is true', () => {
131
+ const ctx = makeContext();
132
+ renderHook(() => useMagneticRegistration(true, 3, noAxisConfig, ctx));
133
+
134
+ expect(ctx.register).toHaveBeenCalledWith(3);
135
+ });
136
+
137
+ it('does not register when magnetic is false', () => {
138
+ const ctx = makeContext();
139
+ renderHook(() => useMagneticRegistration(false, 3, noAxisConfig, ctx));
140
+
141
+ expect(ctx.register).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('unregisters on unmount', () => {
145
+ const ctx = makeContext();
146
+ const { unmount } = renderHook(() =>
147
+ useMagneticRegistration(true, 3, noAxisConfig, ctx),
148
+ );
149
+
150
+ unmount();
151
+ expect(ctx.unregister).toHaveBeenCalledWith(3);
152
+ });
153
+
154
+ it('resolves dataX through axis config data when provided', () => {
155
+ const ctx = makeContext();
156
+ const getXAxisConfig = (): BaseAxisProps => ({
157
+ scaleType: 'band',
158
+ data: [100, 200, 300],
159
+ });
160
+
161
+ renderHook(() => useMagneticRegistration(true, 200, getXAxisConfig, ctx));
162
+
163
+ expect(ctx.register).toHaveBeenCalledWith(1);
164
+ });
165
+
166
+ it('does not register when axis data exists but does not contain the value', () => {
167
+ const ctx = makeContext();
168
+ const getXAxisConfig = (): BaseAxisProps => ({
169
+ scaleType: 'band',
170
+ data: [100, 200, 300],
171
+ });
172
+
173
+ renderHook(() => useMagneticRegistration(true, 999, getXAxisConfig, ctx));
174
+
175
+ expect(ctx.register).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('re-registers when dataX changes', () => {
179
+ const ctx = makeContext();
180
+ const { rerender } = renderHook(
181
+ ({ dataX }: { dataX: number }) =>
182
+ useMagneticRegistration(true, dataX, noAxisConfig, ctx),
183
+ { initialProps: { dataX: 2 } },
184
+ );
185
+
186
+ expect(ctx.register).toHaveBeenCalledWith(2);
187
+
188
+ rerender({ dataX: 4 });
189
+
190
+ expect(ctx.unregister).toHaveBeenCalledWith(2);
191
+ expect(ctx.register).toHaveBeenCalledWith(4);
192
+ });
193
+
194
+ it('unregisters and stops when magnetic switches to false', () => {
195
+ const ctx = makeContext();
196
+ const { rerender } = renderHook(
197
+ ({ magnetic }: { magnetic: boolean }) =>
198
+ useMagneticRegistration(magnetic, 3, noAxisConfig, ctx),
199
+ { initialProps: { magnetic: true } },
200
+ );
201
+
202
+ expect(ctx.register).toHaveBeenCalledWith(3);
203
+
204
+ rerender({ magnetic: false });
205
+
206
+ expect(ctx.unregister).toHaveBeenCalledWith(3);
207
+ });
208
+ });
@@ -1,11 +1,9 @@
1
- import type { DrawingArea } from '../../utils/types';
1
+ import { useEffect } from 'react';
2
2
 
3
- export const DEFAULT_SIZE = 10;
4
- export const STROKE_WIDTH = 2;
5
- export const LABEL_FONT_SIZE = 10;
6
- export const ARROW_WIDTH = 6;
7
- export const ARROW_HEIGHT = 4;
8
- export const GAP = 4;
3
+ import type { DrawingArea } from '../../utils/types';
4
+ import type { BaseAxisProps } from '../Axis';
5
+ import { LABEL_FONT_SIZE, ARROW_WIDTH, ARROW_HEIGHT, GAP } from './constants';
6
+ import type { MagneticPointsContextValue } from './pointContext/magneticPointsContext';
9
7
 
10
8
  export const isWithinBounds = (
11
9
  px: number,
@@ -53,6 +51,36 @@ export const resolveLabel = (
53
51
  return resolved === '' ? undefined : resolved;
54
52
  };
55
53
 
54
+ export const resolveDataXToIndex = (
55
+ dataX: number,
56
+ axisConfig: BaseAxisProps | undefined,
57
+ ): number | undefined => {
58
+ const axisData = axisConfig?.data;
59
+ if (!axisData || typeof axisData[0] !== 'number') return dataX;
60
+ const index = (axisData as number[]).indexOf(dataX);
61
+ if (index === -1) return undefined;
62
+ return index;
63
+ };
64
+
65
+ /**
66
+ * Registers/unregisters a data index as a magnetic snap target
67
+ * when the Point has `magnetic` enabled.
68
+ */
69
+ export const useMagneticRegistration = (
70
+ magnetic: boolean,
71
+ dataX: number,
72
+ getXAxisConfig: () => BaseAxisProps | undefined,
73
+ { register, unregister }: MagneticPointsContextValue,
74
+ ): void => {
75
+ useEffect(() => {
76
+ if (!magnetic) return;
77
+ const index = resolveDataXToIndex(dataX, getXAxisConfig());
78
+ if (index === undefined) return;
79
+ register(index);
80
+ return () => unregister(index);
81
+ }, [magnetic, dataX, getXAxisConfig, register, unregister]);
82
+ };
83
+
56
84
  /**
57
85
  * Computes the vertical position of the label text baseline.
58
86
  */
@@ -1,9 +1,6 @@
1
1
  import { getPointOnScale, isCategoricalScale } from '../../utils/scales/scales';
2
- import type {
3
- AxisConfigProps,
4
- ChartScaleFunction,
5
- DrawingArea,
6
- } from '../../utils/types';
2
+ import type { ChartScaleFunction, DrawingArea } from '../../utils/types';
3
+ import type { BaseAxisProps } from '../Axis';
7
4
 
8
5
  import type { LabelAlignment, LabelPosition } from './types';
9
6
 
@@ -28,7 +25,7 @@ type ResolvePixelParams = {
28
25
  scale: ChartScaleFunction | undefined;
29
26
  axis: 'x' | 'y';
30
27
  drawingArea: DrawingArea;
31
- axisConfig?: AxisConfigProps;
28
+ axisConfig?: BaseAxisProps;
32
29
  };
33
30
 
34
31
  type LabelParams = {
@@ -95,7 +92,7 @@ export const isPixelWithinDrawingArea = (
95
92
  */
96
93
  const resolveDataValue = (
97
94
  index: number,
98
- axisConfig?: AxisConfigProps,
95
+ axisConfig?: BaseAxisProps,
99
96
  ): number | undefined => {
100
97
  const data = axisConfig?.data;
101
98
  if (!data || data.length === 0) return index;
@@ -148,7 +148,7 @@ describe('DefaultScrubberTooltip', () => {
148
148
  scrubberProps: { tooltip: defaultTooltip },
149
149
  scrubberContext: scrubberAt(2),
150
150
  });
151
- expect(getByTestId('chart-tooltip-rect').props.x).toBe(210);
151
+ expect(getByTestId('chart-tooltip-rect').props.x).toBe(260);
152
152
  });
153
153
 
154
154
  it('flips tooltip left when right side would overflow (index 3)', () => {
@@ -158,7 +158,7 @@ describe('DefaultScrubberTooltip', () => {
158
158
  },
159
159
  scrubberContext: scrubberAt(3),
160
160
  });
161
- expect(getByTestId('chart-tooltip-rect').props.x).toBe(145);
161
+ expect(getByTestId('chart-tooltip-rect').props.x).toBe(220);
162
162
  });
163
163
 
164
164
  it('flips at the rightmost scrubber position (index 4)', () => {
@@ -168,7 +168,7 @@ describe('DefaultScrubberTooltip', () => {
168
168
  },
169
169
  scrubberContext: scrubberAt(4),
170
170
  });
171
- expect(getByTestId('chart-tooltip-rect').props.x).toBe(220);
171
+ expect(getByTestId('chart-tooltip-rect').props.x).toBe(320);
172
172
  });
173
173
 
174
174
  it('custom offset shifts the no-flip tooltip position', () => {
@@ -178,7 +178,7 @@ describe('DefaultScrubberTooltip', () => {
178
178
  },
179
179
  scrubberContext: scrubberAt(2),
180
180
  });
181
- expect(getByTestId('chart-tooltip-rect').props.x).toBe(220);
181
+ expect(getByTestId('chart-tooltip-rect').props.x).toBe(270);
182
182
  });
183
183
 
184
184
  it('wider minWidth can trigger a flip that the default width would not', () => {
@@ -192,7 +192,7 @@ describe('measureWidths', () => {
192
192
  titleRef,
193
193
  labelRefs,
194
194
  valueRefs,
195
- );
195
+ ).promise;
196
196
 
197
197
  expect(result).toEqual({ title: 50, labels: [30], values: [20] });
198
198
  raf.mockRestore();
@@ -212,7 +212,7 @@ describe('measureWidths', () => {
212
212
  titleRef,
213
213
  labelRefs,
214
214
  valueRefs,
215
- );
215
+ ).promise;
216
216
 
217
217
  expect(result.title).toBe(0);
218
218
  raf.mockRestore();
@@ -235,7 +235,7 @@ describe('measureWidths', () => {
235
235
  titleRef,
236
236
  labelRefs,
237
237
  valueRefs,
238
- );
238
+ ).promise;
239
239
 
240
240
  expect(result.labels).toEqual([10, 20]);
241
241
  expect(result.values).toEqual([5, 15]);
@@ -256,7 +256,7 @@ describe('measureWidths', () => {
256
256
  titleRef,
257
257
  labelRefs,
258
258
  valueRefs,
259
- );
259
+ ).promise;
260
260
 
261
261
  expect(result).toEqual({ title: 0, labels: [0], values: [0] });
262
262
  raf.mockRestore();
@@ -107,29 +107,41 @@ export const computeItemsBaseY = (
107
107
  return drawingAreaY + PADDING_Y + titleBlockHeight;
108
108
  };
109
109
 
110
- const waitForAnimationFrame = (): Promise<void> =>
111
- new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
110
+ const waitForAnimationFrame = (): {
111
+ promise: Promise<void>;
112
+ cancel: () => void;
113
+ } => {
114
+ let id: number;
115
+ const promise = new Promise<void>((resolve) => {
116
+ id = requestAnimationFrame(() => resolve());
117
+ });
118
+ return { promise, cancel: () => cancelAnimationFrame(id) };
119
+ };
112
120
 
113
121
  /**
114
122
  * Reads the rendered widths of all tooltip text elements via async `getBBox`.
115
123
  * Defers until the next animation frame so layout has settled.
124
+ * Returns a cancel function so callers can abort the pending RAF.
116
125
  */
117
- export const measureWidths = async (
126
+ export const measureWidths = (
118
127
  items: ChartTooltipItemData[],
119
128
  hasTitle: boolean,
120
129
  titleRef: RefObject<SvgBBoxElement | null>,
121
130
  labelRefs: RefObject<(SvgBBoxElement | null)[]>,
122
131
  valueRefs: RefObject<(SvgBBoxElement | null)[]>,
123
- ): Promise<Widths> => {
124
- await waitForAnimationFrame();
125
- const title = hasTitle ? await safeGetBBoxWidth(titleRef.current) : 0;
126
- const labels = await Promise.all(
127
- items.map((_, i) => safeGetBBoxWidth(labelRefs.current[i])),
128
- );
129
- const values = await Promise.all(
130
- items.map((_, i) => safeGetBBoxWidth(valueRefs.current[i])),
131
- );
132
- return { title, labels, values };
132
+ ): { promise: Promise<Widths>; cancel: () => void } => {
133
+ const raf = waitForAnimationFrame();
134
+ const promise = raf.promise.then(async () => {
135
+ const title = hasTitle ? await safeGetBBoxWidth(titleRef.current) : 0;
136
+ const labels = await Promise.all(
137
+ items.map((_, i) => safeGetBBoxWidth(labelRefs.current[i])),
138
+ );
139
+ const values = await Promise.all(
140
+ items.map((_, i) => safeGetBBoxWidth(valueRefs.current[i])),
141
+ );
142
+ return { title, labels, values };
143
+ });
144
+ return { promise, cancel: raf.cancel };
133
145
  };
134
146
 
135
147
  /**
@@ -186,18 +198,20 @@ export function useTooltipMeasurement(
186
198
 
187
199
  let cancelled = false;
188
200
 
189
- void measureWidths(
201
+ const { promise, cancel } = measureWidths(
190
202
  itemsRef.current,
191
203
  hasTitleRef.current,
192
204
  titleRef,
193
205
  labelRefs,
194
206
  valueRefs,
195
- ).then((result) => {
207
+ );
208
+ void promise.then((result) => {
196
209
  if (!cancelled) setWidths(result);
197
210
  });
198
211
 
199
212
  return () => {
200
213
  cancelled = true;
214
+ cancel();
201
215
  };
202
216
  }, [shapeKey]);
203
217
 
@@ -5,9 +5,15 @@ 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';
11
17
 
12
18
  const LONG_PRESS_MIN_DURATION_MS = 50;
13
19
 
@@ -30,29 +36,46 @@ export function ScrubberProvider({
30
36
  height,
31
37
  enableScrubbing,
32
38
  onScrubberPositionChange,
39
+ magnetRadius = 6,
40
+ style,
33
41
  }: Readonly<ScrubberProviderProps>) {
34
42
  const [scrubberPosition, setScrubberPosition] = useState<
35
43
  number | undefined
36
44
  >();
37
45
 
38
46
  const { getXScale, getXAxisConfig, dataLength } = useCartesianChartContext();
47
+ const { getMagneticPoints, version } = useMagneticPointsContext();
39
48
 
40
49
  // All values touched by the gesture's JS-thread callback live in a single
41
50
  // ref. Reading via `latest.current` keeps the callbacks reference-stable
42
51
  // across data, scale, and prop updates, which in turn keeps `composed` and
43
52
  // the gesture surface JSX stable so re-rendering the provider on each
44
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
+
45
64
  const latest = useRef({
46
65
  getXScale,
47
66
  getXAxisConfig,
48
67
  dataLength,
49
68
  onChange: onScrubberPositionChange,
50
69
  lastIndex: undefined as number | undefined,
70
+ magnetRadius,
71
+ sortedMagnets,
51
72
  });
52
73
  latest.current.getXScale = getXScale;
53
74
  latest.current.getXAxisConfig = getXAxisConfig;
54
75
  latest.current.dataLength = dataLength;
55
76
  latest.current.onChange = onScrubberPositionChange;
77
+ latest.current.magnetRadius = magnetRadius;
78
+ latest.current.sortedMagnets = sortedMagnets;
56
79
 
57
80
  const setScrubberPositionAndNotify = useCallback(
58
81
  (index: number | undefined) => {
@@ -78,12 +101,25 @@ export function ScrubberProvider({
78
101
  const ref = latest.current;
79
102
  const scale = ref.getXScale();
80
103
  if (!scale || ref.dataLength <= 0) return;
81
- const index = getDataIndexFromPosition(
104
+
105
+ const xAxisConfig = ref.getXAxisConfig();
106
+
107
+ let index = getDataIndexFromPosition(
82
108
  pixelX,
83
109
  scale,
84
- ref.getXAxisConfig(),
110
+ xAxisConfig,
85
111
  ref.dataLength,
86
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
+
87
123
  setScrubberPositionAndNotify(index);
88
124
  },
89
125
  [setScrubberPositionAndNotify],
@@ -128,7 +164,7 @@ export function ScrubberProvider({
128
164
  // context as expected.
129
165
  const surface = useMemo(
130
166
  () => (
131
- <View style={{ width, height }}>
167
+ <View style={[{ width, height }, style]}>
132
168
  {children}
133
169
  {enableScrubbing && (
134
170
  <GestureDetector gesture={composed}>
@@ -140,7 +176,7 @@ export function ScrubberProvider({
140
176
  )}
141
177
  </View>
142
178
  ),
143
- [width, height, children, enableScrubbing, composed],
179
+ [width, height, style, children, enableScrubbing, composed],
144
180
  );
145
181
 
146
182
  const contextValue = useMemo(
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
2
3
 
3
4
  import type { DrawingArea } from '../../utils/types';
4
5
 
@@ -35,6 +36,18 @@ export type ScrubberProviderProps = {
35
36
  * Optional external callback fired whenever the scrubber position changes.
36
37
  */
37
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>;
38
51
  };
39
52
 
40
53
  export type ChartTooltipItemData = {
@@ -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
+ });