@ledgerhq/lumen-ui-rnative-visualization 0.1.14 → 0.1.16

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 (164) 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 +22 -12
  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 +3 -3
  41. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.js.map +1 -1
  42. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.js +5 -5
  43. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.js.map +1 -1
  44. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.js +5 -5
  45. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/constants.js +1 -2
  46. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/constants.js.map +1 -1
  47. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js +31 -10
  48. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.js.map +1 -1
  49. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js +4 -4
  50. package/dist/module/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.js.map +1 -1
  51. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js +35 -7
  52. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js.map +1 -1
  53. package/dist/module/lib/Components/Scrubber/utils.js +64 -0
  54. package/dist/module/lib/Components/Scrubber/utils.js.map +1 -1
  55. package/dist/module/lib/Components/Scrubber/utils.test.js +128 -1
  56. package/dist/module/lib/Components/Scrubber/utils.test.js.map +1 -1
  57. package/dist/module/lib/utils/domain/domain.js.map +1 -1
  58. package/dist/module/lib/utils/scales/scales.js +13 -4
  59. package/dist/module/lib/utils/scales/scales.js.map +1 -1
  60. package/dist/module/lib/utils/scales/scales.test.js +29 -0
  61. package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
  62. package/dist/module/lib/utils/ticks/ticks.js +13 -3
  63. package/dist/module/lib/utils/ticks/ticks.js.map +1 -1
  64. package/dist/module/lib/utils/ticks/ticks.test.js +34 -0
  65. package/dist/module/lib/utils/ticks/ticks.test.js.map +1 -1
  66. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts +5 -0
  67. package/dist/typescript/src/lib/Components/Axis/Axis.constants.d.ts.map +1 -0
  68. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts +39 -0
  69. package/dist/typescript/src/lib/Components/Axis/Axis.types.d.ts.map +1 -1
  70. package/dist/typescript/src/lib/Components/Axis/index.d.ts +3 -0
  71. package/dist/typescript/src/lib/Components/Axis/index.d.ts.map +1 -0
  72. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
  73. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
  74. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts +5 -4
  75. package/dist/typescript/src/lib/Components/CartesianChart/context/useBuildChartContext.d.ts.map +1 -1
  76. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +11 -3
  77. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
  78. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts +18 -8
  79. package/dist/typescript/src/lib/Components/CartesianChart/utils.d.ts.map +1 -1
  80. package/dist/typescript/src/lib/Components/Line/utils.d.ts +4 -0
  81. package/dist/typescript/src/lib/Components/Line/utils.d.ts.map +1 -1
  82. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts +1 -1
  83. package/dist/typescript/src/lib/Components/LineChart/LineChart.d.ts.map +1 -1
  84. package/dist/typescript/src/lib/Components/LineChart/types.d.ts +10 -3
  85. package/dist/typescript/src/lib/Components/LineChart/types.d.ts.map +1 -1
  86. package/dist/typescript/src/lib/Components/Point/Point.d.ts +1 -1
  87. package/dist/typescript/src/lib/Components/Point/Point.d.ts.map +1 -1
  88. package/dist/typescript/src/lib/Components/Point/constants.d.ts +7 -0
  89. package/dist/typescript/src/lib/Components/Point/constants.d.ts.map +1 -0
  90. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts +7 -0
  91. package/dist/typescript/src/lib/Components/Point/pointContext/MagneticPointsProvider.d.ts.map +1 -0
  92. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts +3 -0
  93. package/dist/typescript/src/lib/Components/Point/pointContext/index.d.ts.map +1 -0
  94. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts +13 -0
  95. package/dist/typescript/src/lib/Components/Point/pointContext/magneticPointsContext.d.ts.map +1 -0
  96. package/dist/typescript/src/lib/Components/Point/types.d.ts +7 -0
  97. package/dist/typescript/src/lib/Components/Point/types.d.ts.map +1 -1
  98. package/dist/typescript/src/lib/Components/Point/utils.d.ts +8 -6
  99. package/dist/typescript/src/lib/Components/Point/utils.d.ts.map +1 -1
  100. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts +3 -2
  101. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts.map +1 -1
  102. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.d.ts.map +1 -1
  103. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.d.ts +1 -2
  104. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.d.ts.map +1 -1
  105. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts +5 -1
  106. package/dist/typescript/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.d.ts.map +1 -1
  107. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts +1 -1
  108. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts.map +1 -1
  109. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts +13 -0
  110. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts.map +1 -1
  111. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts +33 -3
  112. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts.map +1 -1
  113. package/dist/typescript/src/lib/utils/domain/domain.d.ts +5 -4
  114. package/dist/typescript/src/lib/utils/domain/domain.d.ts.map +1 -1
  115. package/dist/typescript/src/lib/utils/index.d.ts +1 -1
  116. package/dist/typescript/src/lib/utils/index.d.ts.map +1 -1
  117. package/dist/typescript/src/lib/utils/scales/scales.d.ts +11 -5
  118. package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
  119. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts +13 -5
  120. package/dist/typescript/src/lib/utils/ticks/ticks.d.ts.map +1 -1
  121. package/dist/typescript/src/lib/utils/types.d.ts +3 -28
  122. package/dist/typescript/src/lib/utils/types.d.ts.map +1 -1
  123. package/package.json +4 -4
  124. package/src/lib/Components/Axis/Axis.constants.ts +23 -0
  125. package/src/lib/Components/Axis/Axis.types.ts +40 -0
  126. package/src/lib/Components/Axis/index.ts +2 -0
  127. package/src/lib/Components/CartesianChart/CartesianChart.tsx +39 -23
  128. package/src/lib/Components/CartesianChart/context/useBuildChartContext.test.ts +56 -0
  129. package/src/lib/Components/CartesianChart/context/useBuildChartContext.ts +8 -8
  130. package/src/lib/Components/CartesianChart/types.ts +11 -3
  131. package/src/lib/Components/CartesianChart/utils.ts +22 -12
  132. package/src/lib/Components/Line/utils.test.ts +90 -0
  133. package/src/lib/Components/Line/utils.ts +6 -1
  134. package/src/lib/Components/LineChart/LineChart.stories.tsx +36 -0
  135. package/src/lib/Components/LineChart/LineChart.test.tsx +156 -0
  136. package/src/lib/Components/LineChart/LineChart.tsx +30 -40
  137. package/src/lib/Components/LineChart/types.ts +10 -3
  138. package/src/lib/Components/Point/Point.tsx +9 -3
  139. package/src/lib/Components/Point/constants.ts +6 -0
  140. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.test.tsx +162 -0
  141. package/src/lib/Components/Point/pointContext/MagneticPointsProvider.tsx +45 -0
  142. package/src/lib/Components/Point/pointContext/index.ts +5 -0
  143. package/src/lib/Components/Point/pointContext/magneticPointsContext.ts +19 -0
  144. package/src/lib/Components/Point/types.ts +7 -0
  145. package/src/lib/Components/Point/utils.test.ts +95 -5
  146. package/src/lib/Components/Point/utils.ts +35 -7
  147. package/src/lib/Components/ReferenceLine/utils.ts +4 -7
  148. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/ChartTooltipItem.tsx +3 -3
  149. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.test.tsx +5 -5
  150. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/DefaultScrubberTooltip.tsx +4 -5
  151. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/constants.ts +1 -2
  152. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.test.ts +4 -4
  153. package/src/lib/Components/Scrubber/DefaultScrubberTooltip/utils.ts +29 -15
  154. package/src/lib/Components/Scrubber/ScrubberProvider.tsx +41 -5
  155. package/src/lib/Components/Scrubber/types.ts +13 -0
  156. package/src/lib/Components/Scrubber/utils.test.ts +150 -0
  157. package/src/lib/Components/Scrubber/utils.ts +93 -3
  158. package/src/lib/utils/domain/domain.ts +6 -5
  159. package/src/lib/utils/index.ts +0 -2
  160. package/src/lib/utils/scales/scales.test.ts +19 -0
  161. package/src/lib/utils/scales/scales.ts +15 -9
  162. package/src/lib/utils/ticks/ticks.test.ts +45 -0
  163. package/src/lib/utils/ticks/ticks.ts +19 -9
  164. package/src/lib/utils/types.ts +3 -26
@@ -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
+ });
@@ -4,10 +4,15 @@ import {
4
4
  isNumericScale,
5
5
  } from '../../utils/scales/scales';
6
6
  import type {
7
- AxisConfigProps,
8
7
  CartesianChartContextValue,
9
8
  ChartScaleFunction,
10
9
  } from '../../utils/types';
10
+ import type { BaseAxisProps } from '../Axis';
11
+
12
+ export type MagnetEntry = {
13
+ index: number;
14
+ pixelX: number;
15
+ };
11
16
 
12
17
  const isNumberArray = (arr: string[] | number[]): arr is number[] =>
13
18
  typeof arr[0] === 'number';
@@ -49,7 +54,7 @@ const findClosestIndex = (
49
54
  export const getDataIndexFromPosition = (
50
55
  pixelX: number,
51
56
  scale: ChartScaleFunction,
52
- axisConfig: Partial<AxisConfigProps> | undefined,
57
+ axisConfig: Partial<BaseAxisProps> | undefined,
53
58
  dataLength: number,
54
59
  ): number => {
55
60
  if (dataLength <= 0) return 0;
@@ -116,7 +121,7 @@ export const resolvePixelY = (
116
121
  export const resolvePixelX = (
117
122
  dataIndex: number,
118
123
  getXScale: CartesianChartContextValue['getXScale'],
119
- axisConfig?: AxisConfigProps,
124
+ axisConfig?: BaseAxisProps,
120
125
  ): number | undefined => {
121
126
  const scale = getXScale();
122
127
  if (!scale) return undefined;
@@ -124,3 +129,88 @@ export const resolvePixelX = (
124
129
  const xValue = typeof axisValue === 'number' ? axisValue : dataIndex;
125
130
  return getPointOnScale(xValue, scale);
126
131
  };
132
+
133
+ /**
134
+ * Resolves each magnetic index to its pixel position and returns them sorted
135
+ * by `pixelX` ascending. Indices that cannot be projected are filtered out.
136
+ * The sorted output enables early-exit in {@link applyMagnetisation}.
137
+ */
138
+ export const buildSortedMagnets = ({
139
+ magneticIndices,
140
+ getPixelForIndex,
141
+ }: {
142
+ magneticIndices: ReadonlySet<number>;
143
+ getPixelForIndex: (index: number) => number | undefined;
144
+ }): MagnetEntry[] => {
145
+ if (magneticIndices.size === 0) return [];
146
+
147
+ const magnets: MagnetEntry[] = [];
148
+
149
+ for (const index of magneticIndices) {
150
+ const pixelX = getPixelForIndex(index);
151
+ if (pixelX !== undefined) magnets.push({ index, pixelX });
152
+ }
153
+
154
+ magnets.sort((a, b) => a.pixelX - b.pixelX);
155
+
156
+ return magnets;
157
+ };
158
+
159
+ /**
160
+ * Binary search for the magnet whose `pixelX` is closest to `pixelX`.
161
+ * Returns -1 when the array is empty. Favors the left neighbor on ties.
162
+ */
163
+ export const nearestIndex = (
164
+ sortedMagnets: readonly MagnetEntry[],
165
+ pixelX: number,
166
+ ): number => {
167
+ if (sortedMagnets.length === 0) return -1;
168
+
169
+ let left = 0;
170
+ let right = sortedMagnets.length;
171
+
172
+ while (left < right) {
173
+ const mid = Math.floor((left + right) / 2);
174
+ if (sortedMagnets[mid].pixelX < pixelX) {
175
+ left = mid + 1;
176
+ } else {
177
+ right = mid;
178
+ }
179
+ }
180
+
181
+ if (left === 0) return 0;
182
+ if (left === sortedMagnets.length) return left - 1;
183
+
184
+ const distBefore = pixelX - sortedMagnets[left - 1].pixelX;
185
+ const distAfter = sortedMagnets[left].pixelX - pixelX;
186
+
187
+ return distBefore <= distAfter ? left - 1 : left;
188
+ };
189
+
190
+ /**
191
+ * Given a resolved data index and the raw pixel position, checks whether any
192
+ * magnetic point is within `magnetRadius` pixels of `pixelX`. If so, returns
193
+ * the closest magnetic index; otherwise returns the original `resolvedIndex`.
194
+ */
195
+ export const applyMagnetisation = ({
196
+ resolvedIndex,
197
+ pixelX,
198
+ sortedMagnets,
199
+ magnetRadius,
200
+ }: {
201
+ resolvedIndex: number;
202
+ pixelX: number;
203
+ sortedMagnets: readonly MagnetEntry[];
204
+ magnetRadius: number;
205
+ }): number => {
206
+ if (magnetRadius <= 0 || sortedMagnets.length === 0) return resolvedIndex;
207
+
208
+ const nearest = nearestIndex(sortedMagnets, pixelX);
209
+ if (
210
+ nearest >= 0 &&
211
+ Math.abs(sortedMagnets[nearest].pixelX - pixelX) <= magnetRadius
212
+ ) {
213
+ return sortedMagnets[nearest].index;
214
+ }
215
+ return resolvedIndex;
216
+ };
@@ -1,4 +1,5 @@
1
- import type { AxisBounds, AxisConfigProps, Series } from '../types';
1
+ import type { AxisBounds, BaseAxisProps } from '../../Components/Axis';
2
+ import type { Series } from '../types';
2
3
 
3
4
  /**
4
5
  * Compute the X domain (index-based) from series data and axis config.
@@ -7,7 +8,7 @@ import type { AxisBounds, AxisConfigProps, Series } from '../types';
7
8
  */
8
9
  export const computeXDomain = (
9
10
  series: Series[],
10
- axisConfig?: Partial<AxisConfigProps>,
11
+ axisConfig?: Partial<BaseAxisProps>,
11
12
  ): AxisBounds => {
12
13
  const axisData = axisConfig?.data;
13
14
 
@@ -44,7 +45,7 @@ export const computeXDomain = (
44
45
  */
45
46
  export const computeYDomain = (
46
47
  series: Series[],
47
- axisConfig?: Partial<AxisConfigProps>,
48
+ axisConfig?: Partial<BaseAxisProps>,
48
49
  ): AxisBounds => {
49
50
  let min = 0;
50
51
  let max = 0;
@@ -76,7 +77,7 @@ export const computeYDomain = (
76
77
  */
77
78
  export const computeDataLength = (
78
79
  series: Series[],
79
- axisConfig?: Partial<AxisConfigProps>,
80
+ axisConfig?: Partial<BaseAxisProps>,
80
81
  ): number => {
81
82
  if (axisConfig?.data && axisConfig.data.length > 0) {
82
83
  return axisConfig.data.length;
@@ -86,7 +87,7 @@ export const computeDataLength = (
86
87
 
87
88
  const applyDomainOverride = (
88
89
  autoBounds: AxisBounds,
89
- domainOverride?: AxisConfigProps['domain'],
90
+ domainOverride?: BaseAxisProps['domain'],
90
91
  ): AxisBounds => {
91
92
  if (!domainOverride) return autoBounds;
92
93
 
@@ -1,6 +1,4 @@
1
1
  export type {
2
- AxisBounds,
3
- AxisConfigProps,
4
2
  CartesianChartContextValue,
5
3
  CategoricalScale,
6
4
  ChartInset,
@@ -53,6 +53,25 @@ describe('getNumericScale', () => {
53
53
  expect(range[0]).toBe(20);
54
54
  expect(range[1]).toBe(400);
55
55
  });
56
+
57
+ it('rounds the domain outward when nice is true (default)', () => {
58
+ const scale = getNumericScale({
59
+ scaleType: 'linear',
60
+ domain: { min: 0, max: 3769 },
61
+ range: { min: 0, max: 400 },
62
+ });
63
+ expect(scale.domain()).toEqual([0, 4000]);
64
+ });
65
+
66
+ it('keeps the domain exact when nice is false', () => {
67
+ const scale = getNumericScale({
68
+ scaleType: 'linear',
69
+ domain: { min: 0, max: 3769 },
70
+ range: { min: 0, max: 400 },
71
+ nice: false,
72
+ });
73
+ expect(scale.domain()).toEqual([0, 3769]);
74
+ });
56
75
  });
57
76
 
58
77
  describe('getCategoricalScale', () => {
@@ -1,31 +1,37 @@
1
1
  import { scaleBand, scaleLinear, scaleLog } from 'd3-scale';
2
2
 
3
+ import type { AxisBounds, BaseAxisProps } from '../../Components/Axis';
3
4
  import type {
4
- AxisBounds,
5
- AxisConfigProps,
6
5
  CategoricalScale,
7
6
  ChartScaleFunction,
8
7
  NumericScale,
9
8
  } from '../types';
10
9
 
11
10
  /**
12
- * Creates a numeric scale with `.nice()` applied so the domain
13
- * extends to clean rounded boundaries (e.g. `[4, 98]` → `[0, 100]`).
11
+ * Creates a numeric scale.
12
+ *
13
+ * When `nice` is `true` (default), the domain is rounded outward to clean
14
+ * boundaries via d3's `.nice()` (e.g. `[4, 98]` → `[0, 100]`). Set `nice` to
15
+ * `false` to keep the domain exactly as provided so data fills the range
16
+ * boundary-to-boundary.
14
17
  */
15
18
  export const getNumericScale = ({
16
19
  scaleType,
17
20
  domain,
18
21
  range,
22
+ nice = true,
19
23
  }: {
20
24
  scaleType: 'linear' | 'log';
21
25
  domain: AxisBounds;
22
26
  range: AxisBounds;
27
+ nice?: boolean;
23
28
  }): NumericScale => {
24
29
  const scale = scaleType === 'log' ? scaleLog() : scaleLinear();
25
- return scale
26
- .domain([domain.min, domain.max])
27
- .nice()
28
- .range([range.min, range.max]);
30
+ scale.domain([domain.min, domain.max]);
31
+ if (nice) {
32
+ scale.nice();
33
+ }
34
+ return scale.range([range.min, range.max]);
29
35
  };
30
36
 
31
37
  export const getCategoricalScale = ({
@@ -52,7 +58,7 @@ export const getCategoricalScale = ({
52
58
  * Checks if a scale type config value refers to a band (categorical) scale.
53
59
  */
54
60
  export const isBandScaleType = (
55
- scaleType: AxisConfigProps['scaleType'],
61
+ scaleType: BaseAxisProps['scaleType'],
56
62
  ): scaleType is 'band' => scaleType === 'band';
57
63
 
58
64
  /**
@@ -36,6 +36,24 @@ describe('getTickValues', () => {
36
36
  it('should return full domain for band scales', () => {
37
37
  expect(getTickValues(bandScale)).toEqual([0, 1, 2, 3, 4]);
38
38
  });
39
+
40
+ it('should return numeric axis data verbatim (no d3-invented intermediate ticks)', () => {
41
+ expect(getTickValues(numericScale, undefined, [0, 2, 4])).toEqual([
42
+ 0, 2, 4,
43
+ ]);
44
+ });
45
+
46
+ it('should return indices for string axis data', () => {
47
+ expect(
48
+ getTickValues(numericScale, undefined, ['Jan', 'Feb', 'Mar']),
49
+ ).toEqual([0, 1, 2]);
50
+ });
51
+
52
+ it('should prioritize explicit ticks over axis data', () => {
53
+ expect(
54
+ getTickValues(numericScale, [10, 20], ['Jan', 'Feb', 'Mar']),
55
+ ).toEqual([10, 20]);
56
+ });
39
57
  });
40
58
 
41
59
  describe('getTickPosition', () => {
@@ -121,6 +139,33 @@ describe('buildTicksData', () => {
121
139
  expect(result[1].label).toBe('Tue');
122
140
  });
123
141
 
142
+ it('should derive ticks from numeric axis data (no intermediate ticks)', () => {
143
+ const result = buildTicksData(numericScale, { data: [0, 2, 4] });
144
+ expect(result.map((t: { value: number }) => t.value)).toEqual([0, 2, 4]);
145
+ expect(result.map((t: { label: string }) => t.label)).toEqual([
146
+ '0',
147
+ '2',
148
+ '4',
149
+ ]);
150
+ });
151
+
152
+ it('should derive ticks from string axis data without intermediate values', () => {
153
+ const stringScale = getNumericScale({
154
+ scaleType: 'linear',
155
+ domain: { min: 0, max: 2 },
156
+ range: { min: 0, max: 500 },
157
+ });
158
+ const result = buildTicksData(stringScale, {
159
+ data: ['Jan', 'Feb', 'Mar'],
160
+ });
161
+ expect(result.map((t: { value: number }) => t.value)).toEqual([0, 1, 2]);
162
+ expect(result.map((t: { label: string }) => t.label)).toEqual([
163
+ 'Jan',
164
+ 'Feb',
165
+ 'Mar',
166
+ ]);
167
+ });
168
+
124
169
  it('should apply tick formatter', () => {
125
170
  const formatter = (v: number | string): string => `$${v}`;
126
171
  const result = buildTicksData(numericScale, undefined, [0, 50], formatter);
@@ -1,9 +1,6 @@
1
+ import type { BaseAxisProps } from '../../Components/Axis';
1
2
  import { isCategoricalScale, isNumericScale } from '../scales/scales';
2
- import type {
3
- AxisConfigProps,
4
- ChartScaleFunction,
5
- DrawingArea,
6
- } from '../types';
3
+ import type { ChartScaleFunction, DrawingArea } from '../types';
7
4
 
8
5
  export const APPROXIMATE_TICK_COUNT = 5;
9
6
 
@@ -15,13 +12,26 @@ export type TickData = {
15
12
 
16
13
  /**
17
14
  * Resolves which numeric tick values should appear on the axis.
18
- * Explicit ticks take priority, then scale-specific defaults.
15
+ *
16
+ * Priority:
17
+ * 1. Explicit `ticks` provided by the consumer.
18
+ * 2. Axis `data` — when provided, ticks come from the data itself (numeric
19
+ * values for numeric data, indices for string data) so the rendered ticks
20
+ * mirror exactly what the consumer asked for, with no d3-invented
21
+ * intermediate values.
22
+ * 3. Scale-specific defaults (band domain, or `scale.ticks()` for numeric).
19
23
  */
20
24
  export const getTickValues = (
21
25
  scale: ChartScaleFunction,
22
26
  explicitTicks?: number[],
27
+ axisData?: BaseAxisProps['data'],
23
28
  ): number[] => {
24
29
  if (explicitTicks) return explicitTicks;
30
+ if (axisData && axisData.length > 0) {
31
+ return typeof axisData[0] === 'number'
32
+ ? (axisData as number[])
33
+ : axisData.map((_, i) => i);
34
+ }
25
35
  if (isCategoricalScale(scale)) return scale.domain();
26
36
  if (isNumericScale(scale)) return scale.ticks(APPROXIMATE_TICK_COUNT);
27
37
  return [];
@@ -47,7 +57,7 @@ export const getTickPosition = (
47
57
  */
48
58
  export const getTickLabel = (
49
59
  tick: number,
50
- axisData: AxisConfigProps['data'],
60
+ axisData: BaseAxisProps['data'],
51
61
  formatter?: (value: number | string) => string,
52
62
  ): string => {
53
63
  const hasStringLabels =
@@ -71,12 +81,12 @@ export const getTickLabel = (
71
81
  */
72
82
  export const buildTicksData = (
73
83
  scale: ChartScaleFunction,
74
- axisConfig?: AxisConfigProps,
84
+ axisConfig?: BaseAxisProps,
75
85
  explicitTicks?: number[],
76
86
  formatter?: (value: number | string) => string,
77
87
  ): TickData[] => {
78
- const tickValues = getTickValues(scale, explicitTicks);
79
88
  const axisData = axisConfig?.data;
89
+ const tickValues = getTickValues(scale, explicitTicks, axisData);
80
90
 
81
91
  return tickValues.map((tick) => ({
82
92
  position: getTickPosition(scale, tick),