@ledgerhq/lumen-ui-rnative-visualization 0.1.10 → 0.1.11

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 (163) hide show
  1. package/dist/module/lib/Components/Axis/XAxis/XAxis.js +1 -1
  2. package/dist/module/lib/Components/Axis/XAxis/XAxis.js.map +1 -1
  3. package/dist/module/lib/Components/Axis/YAxis/YAxis.js +1 -1
  4. package/dist/module/lib/Components/Axis/YAxis/YAxis.js.map +1 -1
  5. package/dist/module/lib/Components/CartesianChart/CartesianChart.js +31 -28
  6. package/dist/module/lib/Components/CartesianChart/CartesianChart.js.map +1 -1
  7. package/dist/module/lib/Components/CartesianChart/RevealClip/RevealClipDefs.js +53 -0
  8. package/dist/module/lib/Components/CartesianChart/RevealClip/RevealClipDefs.js.map +1 -0
  9. package/dist/module/lib/Components/CartesianChart/RevealClip/RevealClipDefs.test.js +144 -0
  10. package/dist/module/lib/Components/CartesianChart/RevealClip/RevealClipDefs.test.js.map +1 -0
  11. package/dist/module/lib/Components/CartesianChart/RevealClip/context.js +8 -0
  12. package/dist/module/lib/Components/CartesianChart/RevealClip/context.js.map +1 -0
  13. package/dist/module/lib/Components/CartesianChart/RevealClip/index.js +5 -0
  14. package/dist/module/lib/Components/CartesianChart/RevealClip/index.js.map +1 -0
  15. package/dist/module/lib/Components/CartesianChart/RevealClip/types.js +4 -0
  16. package/dist/module/lib/Components/CartesianChart/RevealClip/types.js.map +1 -0
  17. package/dist/module/lib/Components/CartesianChart/RevealClip/utils.js +32 -0
  18. package/dist/module/lib/Components/CartesianChart/RevealClip/utils.js.map +1 -0
  19. package/dist/module/lib/Components/CartesianChart/utils.js +4 -4
  20. package/dist/module/lib/Components/Line/Line.js +5 -2
  21. package/dist/module/lib/Components/Line/Line.js.map +1 -1
  22. package/dist/module/lib/Components/LineChart/LineChart.js +7 -1
  23. package/dist/module/lib/Components/LineChart/LineChart.js.map +1 -1
  24. package/dist/module/lib/Components/LineChart/LineChart.test.js +25 -0
  25. package/dist/module/lib/Components/LineChart/LineChart.test.js.map +1 -1
  26. package/dist/module/lib/Components/Point/Point.js +3 -0
  27. package/dist/module/lib/Components/Point/Point.js.map +1 -1
  28. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.js +125 -0
  29. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.js.map +1 -0
  30. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.stories.js +111 -0
  31. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.stories.js.map +1 -0
  32. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.test.js +338 -0
  33. package/dist/module/lib/Components/ReferenceLine/ReferenceLine.test.js.map +1 -0
  34. package/dist/module/lib/Components/ReferenceLine/constants.js +5 -0
  35. package/dist/module/lib/Components/ReferenceLine/constants.js.map +1 -0
  36. package/dist/module/lib/Components/ReferenceLine/index.js +5 -0
  37. package/dist/module/lib/Components/ReferenceLine/index.js.map +1 -0
  38. package/dist/module/lib/Components/ReferenceLine/types.js +2 -0
  39. package/dist/module/lib/Components/ReferenceLine/types.js.map +1 -0
  40. package/dist/module/lib/Components/ReferenceLine/utils.js +121 -0
  41. package/dist/module/lib/Components/ReferenceLine/utils.js.map +1 -0
  42. package/dist/module/lib/Components/ReferenceLine/utils.test.js +297 -0
  43. package/dist/module/lib/Components/ReferenceLine/utils.test.js.map +1 -0
  44. package/dist/module/lib/Components/Scrubber/Scrubber.js +152 -0
  45. package/dist/module/lib/Components/Scrubber/Scrubber.js.map +1 -0
  46. package/dist/module/lib/Components/Scrubber/Scrubber.stories.js +113 -0
  47. package/dist/module/lib/Components/Scrubber/Scrubber.stories.js.map +1 -0
  48. package/dist/module/lib/Components/Scrubber/Scrubber.test.js +132 -0
  49. package/dist/module/lib/Components/Scrubber/Scrubber.test.js.map +1 -0
  50. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js +105 -0
  51. package/dist/module/lib/Components/Scrubber/ScrubberProvider.js.map +1 -0
  52. package/dist/module/lib/Components/Scrubber/ScrubberProvider.test.js +217 -0
  53. package/dist/module/lib/Components/Scrubber/ScrubberProvider.test.js.map +1 -0
  54. package/dist/module/lib/Components/Scrubber/constants.js +10 -0
  55. package/dist/module/lib/Components/Scrubber/constants.js.map +1 -0
  56. package/dist/module/lib/Components/Scrubber/context/index.js +4 -0
  57. package/dist/module/lib/Components/Scrubber/context/index.js.map +1 -0
  58. package/dist/module/lib/Components/Scrubber/context/scrubberContext.js +10 -0
  59. package/dist/module/lib/Components/Scrubber/context/scrubberContext.js.map +1 -0
  60. package/dist/module/lib/Components/Scrubber/index.js +5 -0
  61. package/dist/module/lib/Components/Scrubber/index.js.map +1 -0
  62. package/dist/module/lib/Components/Scrubber/types.js +4 -0
  63. package/dist/module/lib/Components/Scrubber/types.js.map +1 -0
  64. package/dist/module/lib/Components/Scrubber/utils.js +83 -0
  65. package/dist/module/lib/Components/Scrubber/utils.js.map +1 -0
  66. package/dist/module/lib/Components/Scrubber/utils.test.js +192 -0
  67. package/dist/module/lib/Components/Scrubber/utils.test.js.map +1 -0
  68. package/dist/module/lib/Components/index.js +2 -0
  69. package/dist/module/lib/Components/index.js.map +1 -1
  70. package/dist/module/lib/utils/scales/scales.js +16 -8
  71. package/dist/module/lib/utils/scales/scales.js.map +1 -1
  72. package/dist/module/lib/utils/scales/scales.test.js +32 -1
  73. package/dist/module/lib/utils/scales/scales.test.js.map +1 -1
  74. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts +1 -1
  75. package/dist/typescript/src/lib/Components/CartesianChart/CartesianChart.d.ts.map +1 -1
  76. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/RevealClipDefs.d.ts +3 -0
  77. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/RevealClipDefs.d.ts.map +1 -0
  78. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/context.d.ts +7 -0
  79. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/context.d.ts.map +1 -0
  80. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/index.d.ts +3 -0
  81. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/index.d.ts.map +1 -0
  82. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/types.d.ts +38 -0
  83. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/types.d.ts.map +1 -0
  84. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/utils.d.ts +17 -0
  85. package/dist/typescript/src/lib/Components/CartesianChart/RevealClip/utils.d.ts.map +1 -0
  86. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts +17 -0
  87. package/dist/typescript/src/lib/Components/CartesianChart/types.d.ts.map +1 -1
  88. package/dist/typescript/src/lib/Components/Line/Line.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 +16 -0
  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.map +1 -1
  94. package/dist/typescript/src/lib/Components/ReferenceLine/ReferenceLine.d.ts +3 -0
  95. package/dist/typescript/src/lib/Components/ReferenceLine/ReferenceLine.d.ts.map +1 -0
  96. package/dist/typescript/src/lib/Components/ReferenceLine/constants.d.ts +3 -0
  97. package/dist/typescript/src/lib/Components/ReferenceLine/constants.d.ts.map +1 -0
  98. package/dist/typescript/src/lib/Components/ReferenceLine/index.d.ts +3 -0
  99. package/dist/typescript/src/lib/Components/ReferenceLine/index.d.ts.map +1 -0
  100. package/dist/typescript/src/lib/Components/ReferenceLine/types.d.ts +74 -0
  101. package/dist/typescript/src/lib/Components/ReferenceLine/types.d.ts.map +1 -0
  102. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts +55 -0
  103. package/dist/typescript/src/lib/Components/ReferenceLine/utils.d.ts.map +1 -0
  104. package/dist/typescript/src/lib/Components/Scrubber/Scrubber.d.ts +17 -0
  105. package/dist/typescript/src/lib/Components/Scrubber/Scrubber.d.ts.map +1 -0
  106. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts +16 -0
  107. package/dist/typescript/src/lib/Components/Scrubber/ScrubberProvider.d.ts.map +1 -0
  108. package/dist/typescript/src/lib/Components/Scrubber/constants.d.ts +8 -0
  109. package/dist/typescript/src/lib/Components/Scrubber/constants.d.ts.map +1 -0
  110. package/dist/typescript/src/lib/Components/Scrubber/context/index.d.ts +2 -0
  111. package/dist/typescript/src/lib/Components/Scrubber/context/index.d.ts.map +1 -0
  112. package/dist/typescript/src/lib/Components/Scrubber/context/scrubberContext.d.ts +8 -0
  113. package/dist/typescript/src/lib/Components/Scrubber/context/scrubberContext.d.ts.map +1 -0
  114. package/dist/typescript/src/lib/Components/Scrubber/index.d.ts +4 -0
  115. package/dist/typescript/src/lib/Components/Scrubber/index.d.ts.map +1 -0
  116. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts +57 -0
  117. package/dist/typescript/src/lib/Components/Scrubber/types.d.ts.map +1 -0
  118. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts +22 -0
  119. package/dist/typescript/src/lib/Components/Scrubber/utils.d.ts.map +1 -0
  120. package/dist/typescript/src/lib/Components/index.d.ts +2 -0
  121. package/dist/typescript/src/lib/Components/index.d.ts.map +1 -1
  122. package/dist/typescript/src/lib/utils/scales/scales.d.ts +6 -0
  123. package/dist/typescript/src/lib/utils/scales/scales.d.ts.map +1 -1
  124. package/package.json +6 -3
  125. package/src/lib/Components/Axis/XAxis/XAxis.tsx +1 -1
  126. package/src/lib/Components/Axis/YAxis/YAxis.tsx +1 -1
  127. package/src/lib/Components/CartesianChart/CartesianChart.tsx +32 -32
  128. package/src/lib/Components/CartesianChart/RevealClip/RevealClipDefs.test.tsx +129 -0
  129. package/src/lib/Components/CartesianChart/RevealClip/RevealClipDefs.tsx +59 -0
  130. package/src/lib/Components/CartesianChart/RevealClip/context.ts +13 -0
  131. package/src/lib/Components/CartesianChart/RevealClip/index.ts +2 -0
  132. package/src/lib/Components/CartesianChart/RevealClip/types.ts +38 -0
  133. package/src/lib/Components/CartesianChart/RevealClip/utils.ts +51 -0
  134. package/src/lib/Components/CartesianChart/types.ts +17 -0
  135. package/src/lib/Components/CartesianChart/utils.ts +4 -4
  136. package/src/lib/Components/Line/Line.tsx +5 -3
  137. package/src/lib/Components/LineChart/LineChart.test.tsx +23 -0
  138. package/src/lib/Components/LineChart/LineChart.tsx +6 -0
  139. package/src/lib/Components/LineChart/types.ts +16 -0
  140. package/src/lib/Components/Point/Point.tsx +3 -1
  141. package/src/lib/Components/ReferenceLine/ReferenceLine.stories.tsx +109 -0
  142. package/src/lib/Components/ReferenceLine/ReferenceLine.test.tsx +269 -0
  143. package/src/lib/Components/ReferenceLine/ReferenceLine.tsx +149 -0
  144. package/src/lib/Components/ReferenceLine/constants.ts +2 -0
  145. package/src/lib/Components/ReferenceLine/index.ts +2 -0
  146. package/src/lib/Components/ReferenceLine/types.ts +79 -0
  147. package/src/lib/Components/ReferenceLine/utils.test.ts +332 -0
  148. package/src/lib/Components/ReferenceLine/utils.ts +175 -0
  149. package/src/lib/Components/Scrubber/Scrubber.stories.tsx +122 -0
  150. package/src/lib/Components/Scrubber/Scrubber.test.tsx +110 -0
  151. package/src/lib/Components/Scrubber/Scrubber.tsx +189 -0
  152. package/src/lib/Components/Scrubber/ScrubberProvider.test.tsx +223 -0
  153. package/src/lib/Components/Scrubber/ScrubberProvider.tsx +132 -0
  154. package/src/lib/Components/Scrubber/constants.ts +7 -0
  155. package/src/lib/Components/Scrubber/context/index.ts +1 -0
  156. package/src/lib/Components/Scrubber/context/scrubberContext.ts +14 -0
  157. package/src/lib/Components/Scrubber/index.ts +3 -0
  158. package/src/lib/Components/Scrubber/types.ts +59 -0
  159. package/src/lib/Components/Scrubber/utils.test.ts +172 -0
  160. package/src/lib/Components/Scrubber/utils.ts +126 -0
  161. package/src/lib/Components/index.ts +2 -0
  162. package/src/lib/utils/scales/scales.test.ts +22 -0
  163. package/src/lib/utils/scales/scales.ts +19 -9
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
+ import { ThemeProvider } from '@ledgerhq/lumen-ui-rnative';
4
+ import { render } from '@testing-library/react-native';
5
+ import type { ReactNode, ComponentProps } from 'react';
6
+
7
+ import { CartesianChart } from '../CartesianChart';
8
+ import { ScrubberContextProvider } from './context';
9
+ import { Scrubber } from './Scrubber';
10
+ import type { ScrubberContextValue } from './types';
11
+
12
+ const sampleSeries = [
13
+ { id: 's1', stroke: '#7B61FF', data: [10, 20, 30, 40, 50] },
14
+ { id: 's2', stroke: '#44D7B6', data: [50, 40, 30, 20, 10] },
15
+ ];
16
+
17
+ const Wrapper = ({ children }: { children: ReactNode }) => (
18
+ <ThemeProvider themes={ledgerLiveThemes} colorScheme='light'>
19
+ {children}
20
+ </ThemeProvider>
21
+ );
22
+
23
+ const activeScrubberContext = {
24
+ enableScrubbing: true,
25
+ scrubberPosition: 2,
26
+ onScrubberPositionChange: () => undefined,
27
+ };
28
+
29
+ const renderScrubber = ({
30
+ scrubberProps = {},
31
+ scrubberContext = activeScrubberContext,
32
+ }: {
33
+ scrubberProps?: ComponentProps<typeof Scrubber>;
34
+ scrubberContext?: ScrubberContextValue;
35
+ } = {}) =>
36
+ render(
37
+ <Wrapper>
38
+ <CartesianChart series={sampleSeries} width={400} height={200}>
39
+ <ScrubberContextProvider value={scrubberContext}>
40
+ <Scrubber {...scrubberProps} />
41
+ </ScrubberContextProvider>
42
+ </CartesianChart>
43
+ </Wrapper>,
44
+ );
45
+
46
+ describe('Scrubber', () => {
47
+ it('renders nothing when no scrubber position is active', () => {
48
+ const { queryByTestId } = renderScrubber({
49
+ scrubberContext: {
50
+ enableScrubbing: true,
51
+ scrubberPosition: undefined,
52
+ onScrubberPositionChange: () => undefined,
53
+ },
54
+ });
55
+ expect(queryByTestId('scrubber')).toBeNull();
56
+ });
57
+
58
+ it('renders the scrubber group when a position is active', () => {
59
+ const { getByTestId } = renderScrubber();
60
+ expect(getByTestId('scrubber')).toBeTruthy();
61
+ });
62
+
63
+ it('renders the reference line by default', () => {
64
+ const { getByTestId } = renderScrubber();
65
+ expect(getByTestId('scrubber-line')).toBeTruthy();
66
+ });
67
+
68
+ it('hides the reference line when hideLine is true', () => {
69
+ const { queryByTestId } = renderScrubber({
70
+ scrubberProps: { hideLine: true },
71
+ });
72
+ expect(queryByTestId('scrubber-line')).toBeNull();
73
+ });
74
+
75
+ it('renders the overlay by default', () => {
76
+ const { getByTestId } = renderScrubber();
77
+ expect(getByTestId('scrubber-overlay')).toBeTruthy();
78
+ });
79
+
80
+ it('hides the overlay when hideOverlay is true', () => {
81
+ const { queryByTestId } = renderScrubber({
82
+ scrubberProps: { hideOverlay: true },
83
+ });
84
+ expect(queryByTestId('scrubber-overlay')).toBeNull();
85
+ });
86
+
87
+ it('does not render beacons by default', () => {
88
+ const { queryAllByTestId } = renderScrubber();
89
+ expect(queryAllByTestId(/scrubber-beacon-/)).toHaveLength(0);
90
+ });
91
+
92
+ it('renders one beacon per series when showBeacons is true', () => {
93
+ const { getAllByTestId } = renderScrubber({
94
+ scrubberProps: { showBeacons: true },
95
+ });
96
+ expect(getAllByTestId(/scrubber-beacon-/)).toHaveLength(2);
97
+ });
98
+
99
+ it('renders a label when a label function is provided', () => {
100
+ const { getByTestId } = renderScrubber({
101
+ scrubberProps: { label: (i: number) => `Index ${i}` },
102
+ });
103
+ expect(getByTestId('scrubber-label')).toBeTruthy();
104
+ });
105
+
106
+ it('does not render a label when no label function is provided', () => {
107
+ const { queryByTestId } = renderScrubber();
108
+ expect(queryByTestId('scrubber-label')).toBeNull();
109
+ });
110
+ });
@@ -0,0 +1,189 @@
1
+ import { useTheme } from '@ledgerhq/lumen-ui-rnative';
2
+ import { useId, useMemo } from 'react';
3
+ import {
4
+ Circle,
5
+ Defs,
6
+ G,
7
+ Line,
8
+ LinearGradient,
9
+ Rect,
10
+ Stop,
11
+ Text as SvgText,
12
+ } from 'react-native-svg';
13
+
14
+ import { useCartesianChartContext } from '../CartesianChart/context';
15
+ import {
16
+ BEACON_RADIUS,
17
+ BEACON_STROKE_WIDTH,
18
+ LABEL_OFFSET_Y,
19
+ LINE_GRADIENT_EDGE_OPACITY,
20
+ OVERLAY_LINE_INSET,
21
+ OVERLAY_OFFSET,
22
+ OVERLAY_OPACITY,
23
+ } from './constants';
24
+ import { useScrubberContext } from './context';
25
+ import type { ScrubberProps } from './types';
26
+ import { resolvePixelX, resolvePixelY } from './utils';
27
+
28
+ /**
29
+ * Renders the scrubber visuals: vertical reference line, future-data overlay
30
+ * rect, per-series beacon dots, and an optional formatted label above the line.
31
+ *
32
+ * Must be used as a child of `LineChart` (or `CartesianChart`) with
33
+ * `enableScrubbing` enabled. Renders nothing when no scrubber position is active.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <LineChart series={data} enableScrubbing>
38
+ * <Scrubber label={(i) => data[i].date} />
39
+ * </LineChart>
40
+ * ```
41
+ */
42
+ export function Scrubber({
43
+ label,
44
+ hideLine = false,
45
+ hideOverlay = false,
46
+ showBeacons = false,
47
+ }: Readonly<ScrubberProps>) {
48
+ const lineGradientId = useId();
49
+ const { theme } = useTheme();
50
+ const { scrubberPosition } = useScrubberContext();
51
+ const {
52
+ getXScale,
53
+ getXAxisConfig,
54
+ getYScale,
55
+ drawingArea,
56
+ series,
57
+ seriesMap,
58
+ } = useCartesianChartContext();
59
+
60
+ const pixelX = useMemo(() => {
61
+ if (scrubberPosition === undefined) return undefined;
62
+ return resolvePixelX(scrubberPosition, getXScale, getXAxisConfig());
63
+ }, [scrubberPosition, getXScale, getXAxisConfig]);
64
+
65
+ const beacons = useMemo(() => {
66
+ if (scrubberPosition === undefined || !showBeacons) return [];
67
+ return series
68
+ .map((s) => {
69
+ const seriesData = seriesMap.get(s.id)?.data;
70
+ const pixelY = resolvePixelY(scrubberPosition, seriesData, getYScale);
71
+ if (pixelY === undefined) return null;
72
+ return { id: s.id, stroke: s.stroke, pixelY };
73
+ })
74
+ .filter(
75
+ (b): b is { id: string; stroke: string; pixelY: number } => b !== null,
76
+ );
77
+ }, [scrubberPosition, showBeacons, series, seriesMap, getYScale]);
78
+
79
+ const resolvedLabel = useMemo(() => {
80
+ if (scrubberPosition === undefined || !label) return undefined;
81
+ return label(scrubberPosition);
82
+ }, [scrubberPosition, label]);
83
+
84
+ if (scrubberPosition === undefined || pixelX === undefined) {
85
+ return null;
86
+ }
87
+
88
+ const {
89
+ x: drawX,
90
+ y: drawY,
91
+ width: drawWidth,
92
+ height: drawHeight,
93
+ } = drawingArea;
94
+
95
+ const overlayX = pixelX + OVERLAY_LINE_INSET;
96
+ const overlayY = drawY - OVERLAY_OFFSET;
97
+ const overlayWidth = Math.max(
98
+ 0,
99
+ drawX + drawWidth - pixelX - OVERLAY_LINE_INSET + OVERLAY_OFFSET,
100
+ );
101
+ const overlayHeight = drawHeight + OVERLAY_OFFSET * 2;
102
+
103
+ const borderMutedColor = theme.colors.border.base;
104
+ const backgroundBaseColor = theme.colors.bg.base;
105
+ const textBaseColor = theme.colors.text.base;
106
+ const bgCanvasColor = theme.colors.bg.canvas;
107
+
108
+ return (
109
+ <G testID='scrubber'>
110
+ {!hideLine && (
111
+ <>
112
+ <Defs>
113
+ <LinearGradient
114
+ id={lineGradientId}
115
+ gradientUnits='userSpaceOnUse'
116
+ x1={pixelX}
117
+ y1={drawY}
118
+ x2={pixelX}
119
+ y2={drawY + drawHeight}
120
+ >
121
+ <Stop
122
+ offset='0%'
123
+ stopColor={borderMutedColor}
124
+ stopOpacity={LINE_GRADIENT_EDGE_OPACITY}
125
+ />
126
+ <Stop offset='20%' stopColor={borderMutedColor} stopOpacity={1} />
127
+ <Stop offset='80%' stopColor={borderMutedColor} stopOpacity={1} />
128
+ <Stop
129
+ offset='100%'
130
+ stopColor={borderMutedColor}
131
+ stopOpacity={LINE_GRADIENT_EDGE_OPACITY}
132
+ />
133
+ </LinearGradient>
134
+ </Defs>
135
+ <Line
136
+ testID='scrubber-line'
137
+ x1={pixelX}
138
+ y1={drawY}
139
+ x2={pixelX}
140
+ y2={drawY + drawHeight}
141
+ stroke={`url(#${lineGradientId})`}
142
+ strokeWidth={OVERLAY_LINE_INSET}
143
+ />
144
+ </>
145
+ )}
146
+
147
+ {!hideOverlay && (
148
+ <Rect
149
+ testID='scrubber-overlay'
150
+ x={overlayX}
151
+ y={overlayY}
152
+ width={overlayWidth}
153
+ height={overlayHeight}
154
+ fill={backgroundBaseColor}
155
+ opacity={OVERLAY_OPACITY}
156
+ />
157
+ )}
158
+
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
+ {showBeacons &&
175
+ beacons.map((beacon) => (
176
+ <Circle
177
+ key={beacon.id}
178
+ testID={`scrubber-beacon-${beacon.id}`}
179
+ cx={pixelX}
180
+ cy={beacon.pixelY}
181
+ r={BEACON_RADIUS}
182
+ fill={beacon.stroke}
183
+ stroke={bgCanvasColor}
184
+ strokeWidth={BEACON_STROKE_WIDTH}
185
+ />
186
+ ))}
187
+ </G>
188
+ );
189
+ }
@@ -0,0 +1,223 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
3
+ import { ThemeProvider } from '@ledgerhq/lumen-ui-rnative';
4
+ import { fireEvent, render } from '@testing-library/react-native';
5
+ import type { ReactNode } from 'react';
6
+ import { Pressable, Text } from 'react-native';
7
+
8
+ import { CartesianChart } from '../CartesianChart';
9
+ import { useScrubberContext } from './context';
10
+
11
+ const sampleSeries = [
12
+ { id: 's1', stroke: '#7B61FF', data: [10, 20, 30, 40, 50] },
13
+ ];
14
+
15
+ const Wrapper = ({ children }: { children: ReactNode }) => (
16
+ <ThemeProvider themes={ledgerLiveThemes} colorScheme='light'>
17
+ {children}
18
+ </ThemeProvider>
19
+ );
20
+
21
+ const ContextConsumer = () => {
22
+ const { scrubberPosition, enableScrubbing } = useScrubberContext();
23
+ return (
24
+ <Text testID='context-output'>
25
+ {`enabled:${enableScrubbing},pos:${scrubberPosition ?? 'none'}`}
26
+ </Text>
27
+ );
28
+ };
29
+
30
+ describe('ScrubberProvider', () => {
31
+ it('renders children', () => {
32
+ const { getByTestId } = render(
33
+ <Wrapper>
34
+ <CartesianChart
35
+ series={sampleSeries}
36
+ width={400}
37
+ height={200}
38
+ enableScrubbing={true}
39
+ >
40
+ <Text testID='child'>hello</Text>
41
+ </CartesianChart>
42
+ </Wrapper>,
43
+ );
44
+ expect(getByTestId('child')).toBeTruthy();
45
+ });
46
+
47
+ it('renders the long-press + pan gesture overlay when enableScrubbing is true', () => {
48
+ const { getByTestId } = render(
49
+ <Wrapper>
50
+ <CartesianChart
51
+ series={sampleSeries}
52
+ width={400}
53
+ height={200}
54
+ enableScrubbing={true}
55
+ >
56
+ <Text>child</Text>
57
+ </CartesianChart>
58
+ </Wrapper>,
59
+ );
60
+ expect(getByTestId('scrubber-gesture-overlay')).toBeTruthy();
61
+ });
62
+
63
+ it('does not render the gesture overlay when enableScrubbing is false', () => {
64
+ const { queryByTestId } = render(
65
+ <Wrapper>
66
+ <CartesianChart
67
+ series={sampleSeries}
68
+ width={400}
69
+ height={200}
70
+ enableScrubbing={false}
71
+ >
72
+ <Text>child</Text>
73
+ </CartesianChart>
74
+ </Wrapper>,
75
+ );
76
+ expect(queryByTestId('scrubber-gesture-overlay')).toBeNull();
77
+ });
78
+
79
+ it('provides initial scrubberPosition as undefined via context', () => {
80
+ const { getByTestId } = render(
81
+ <Wrapper>
82
+ <CartesianChart
83
+ series={sampleSeries}
84
+ width={400}
85
+ height={200}
86
+ enableScrubbing={true}
87
+ >
88
+ <ContextConsumer />
89
+ </CartesianChart>
90
+ </Wrapper>,
91
+ );
92
+ expect(getByTestId('context-output').props.children).toBe(
93
+ 'enabled:true,pos:none',
94
+ );
95
+ });
96
+
97
+ it('calls onScrubberPositionChange when position is updated via context', () => {
98
+ const onScrubberPositionChange = jest.fn();
99
+
100
+ const ContextTrigger = () => {
101
+ const { onScrubberPositionChange: updatePosition } = useScrubberContext();
102
+ return (
103
+ <>
104
+ <Pressable testID='set-position' onPress={() => updatePosition(2)} />
105
+ <Pressable
106
+ testID='clear-position'
107
+ onPress={() => updatePosition(undefined)}
108
+ />
109
+ </>
110
+ );
111
+ };
112
+
113
+ const { getByTestId } = render(
114
+ <Wrapper>
115
+ <CartesianChart
116
+ series={sampleSeries}
117
+ width={400}
118
+ height={200}
119
+ enableScrubbing={true}
120
+ onScrubberPositionChange={onScrubberPositionChange}
121
+ >
122
+ <ContextTrigger />
123
+ </CartesianChart>
124
+ </Wrapper>,
125
+ );
126
+
127
+ fireEvent.press(getByTestId('set-position'));
128
+ expect(onScrubberPositionChange).toHaveBeenCalledWith(2);
129
+
130
+ fireEvent.press(getByTestId('clear-position'));
131
+ expect(onScrubberPositionChange).toHaveBeenCalledWith(undefined);
132
+ expect(onScrubberPositionChange).toHaveBeenCalledTimes(2);
133
+ });
134
+
135
+ it('does not fire onScrubberPositionChange when the index is unchanged', () => {
136
+ const onScrubberPositionChange = jest.fn();
137
+
138
+ const ContextTrigger = () => {
139
+ const { onScrubberPositionChange: updatePosition } = useScrubberContext();
140
+ return (
141
+ <Pressable testID='set-position' onPress={() => updatePosition(2)} />
142
+ );
143
+ };
144
+
145
+ const { getByTestId } = render(
146
+ <Wrapper>
147
+ <CartesianChart
148
+ series={sampleSeries}
149
+ width={400}
150
+ height={200}
151
+ enableScrubbing={true}
152
+ onScrubberPositionChange={onScrubberPositionChange}
153
+ >
154
+ <ContextTrigger />
155
+ </CartesianChart>
156
+ </Wrapper>,
157
+ );
158
+
159
+ fireEvent.press(getByTestId('set-position'));
160
+ fireEvent.press(getByTestId('set-position'));
161
+ fireEvent.press(getByTestId('set-position'));
162
+ expect(onScrubberPositionChange).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('clamps out-of-range index set via context', () => {
166
+ const onScrubberPositionChange = jest.fn();
167
+
168
+ const ContextTrigger = () => {
169
+ const { onScrubberPositionChange: updatePosition } = useScrubberContext();
170
+ return (
171
+ <Pressable testID='set-position' onPress={() => updatePosition(999)} />
172
+ );
173
+ };
174
+
175
+ const { getByTestId } = render(
176
+ <Wrapper>
177
+ <CartesianChart
178
+ series={sampleSeries}
179
+ width={400}
180
+ height={200}
181
+ enableScrubbing={true}
182
+ onScrubberPositionChange={onScrubberPositionChange}
183
+ >
184
+ <ContextTrigger />
185
+ </CartesianChart>
186
+ </Wrapper>,
187
+ );
188
+
189
+ fireEvent.press(getByTestId('set-position'));
190
+ expect(onScrubberPositionChange).toHaveBeenCalledWith(
191
+ sampleSeries[0].data.length - 1,
192
+ );
193
+ });
194
+
195
+ it('does not fire callback when dataLength is 0', () => {
196
+ const onScrubberPositionChange = jest.fn();
197
+ const emptySeries = [{ id: 's1', stroke: '#7B61FF', data: [] as number[] }];
198
+
199
+ const ContextTrigger = () => {
200
+ const { onScrubberPositionChange: updatePosition } = useScrubberContext();
201
+ return (
202
+ <Pressable testID='set-position' onPress={() => updatePosition(0)} />
203
+ );
204
+ };
205
+
206
+ const { getByTestId } = render(
207
+ <Wrapper>
208
+ <CartesianChart
209
+ series={emptySeries}
210
+ width={400}
211
+ height={200}
212
+ enableScrubbing={true}
213
+ onScrubberPositionChange={onScrubberPositionChange}
214
+ >
215
+ <ContextTrigger />
216
+ </CartesianChart>
217
+ </Wrapper>,
218
+ );
219
+
220
+ fireEvent.press(getByTestId('set-position'));
221
+ expect(onScrubberPositionChange).not.toHaveBeenCalled();
222
+ });
223
+ });
@@ -0,0 +1,132 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated, { useSharedValue } from 'react-native-reanimated';
5
+ import { scheduleOnRN } from 'react-native-worklets';
6
+
7
+ import { useCartesianChartContext } from '../CartesianChart/context';
8
+ import { ScrubberContextProvider } from './context';
9
+ import type { ScrubberProviderProps } from './types';
10
+ import { getDataIndexFromPosition } from './utils';
11
+
12
+ /**
13
+ * Provides scrubbing interaction for a chart.
14
+ *
15
+ * Must be placed as a child of `CartesianChartProvider` (so it can access the
16
+ * x-scale) but outside the `<Svg>` element (so it can render a transparent
17
+ * gesture-capture `View` overlay). `CartesianChart` handles this positioning
18
+ * automatically when `enableScrubbing` is true.
19
+ *
20
+ * Activation requires a 50 ms long press before panning begins. This prevents
21
+ * accidental scrubbing when the user is scrolling a parent `ScrollView` or
22
+ * `FlatList`. The `isScrubbing` shared value gates the pan via manual activation
23
+ * so scroll and scrub gestures never compete.
24
+ */
25
+ export function ScrubberProvider({
26
+ children,
27
+ width,
28
+ height,
29
+ enableScrubbing,
30
+ onScrubberPositionChange,
31
+ }: Readonly<ScrubberProviderProps>) {
32
+ const [scrubberPosition, setScrubberPosition] = useState<
33
+ number | undefined
34
+ >();
35
+ const lastPositionRef = useRef<number | undefined>(undefined);
36
+
37
+ const { getXScale, getXAxisConfig, dataLength } = useCartesianChartContext();
38
+
39
+ const isScrubbing = useSharedValue(false);
40
+
41
+ const setScrubberPositionAndNotify = useCallback(
42
+ (index: number | undefined) => {
43
+ const clamped =
44
+ index === undefined || dataLength <= 0
45
+ ? undefined
46
+ : Math.max(0, Math.min(index, dataLength - 1));
47
+ if (clamped === lastPositionRef.current) return;
48
+ lastPositionRef.current = clamped;
49
+ setScrubberPosition(clamped);
50
+ onScrubberPositionChange?.(clamped);
51
+ },
52
+ [dataLength, onScrubberPositionChange],
53
+ );
54
+
55
+ const handlePositionChange = useCallback(
56
+ (pixelX: number | null) => {
57
+ if (pixelX === null) {
58
+ setScrubberPositionAndNotify(undefined);
59
+ return;
60
+ }
61
+
62
+ const scale = getXScale();
63
+ if (!scale || dataLength <= 0) return;
64
+
65
+ const axisConfig = getXAxisConfig();
66
+ const index = getDataIndexFromPosition(
67
+ pixelX,
68
+ scale,
69
+ axisConfig,
70
+ dataLength,
71
+ );
72
+ setScrubberPositionAndNotify(index);
73
+ },
74
+ [getXScale, getXAxisConfig, dataLength, setScrubberPositionAndNotify],
75
+ );
76
+
77
+ const resetScrubber = useCallback((): void => {
78
+ 'worklet';
79
+ isScrubbing.value = false;
80
+ scheduleOnRN(handlePositionChange, null);
81
+ }, [isScrubbing, handlePositionChange]);
82
+
83
+ const composed = useMemo(() => {
84
+ const longPress = Gesture.LongPress()
85
+ .minDuration(50)
86
+ .onStart((e) => {
87
+ 'worklet';
88
+ isScrubbing.value = true;
89
+ scheduleOnRN(handlePositionChange, e.x);
90
+ });
91
+
92
+ const pan = Gesture.Pan()
93
+ .manualActivation(true)
94
+ .onTouchesMove((_, manager) => {
95
+ 'worklet';
96
+ if (isScrubbing.value) manager.activate();
97
+ })
98
+ .onUpdate((e) => {
99
+ 'worklet';
100
+ scheduleOnRN(handlePositionChange, e.x);
101
+ })
102
+ .onEnd(resetScrubber)
103
+ .onFinalize(resetScrubber);
104
+
105
+ return Gesture.Simultaneous(longPress, pan);
106
+ }, [isScrubbing, handlePositionChange, resetScrubber]);
107
+
108
+ const contextValue = useMemo(
109
+ () => ({
110
+ enableScrubbing,
111
+ scrubberPosition,
112
+ onScrubberPositionChange: setScrubberPositionAndNotify,
113
+ }),
114
+ [enableScrubbing, scrubberPosition, setScrubberPositionAndNotify],
115
+ );
116
+
117
+ return (
118
+ <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>
130
+ </ScrubberContextProvider>
131
+ );
132
+ }
@@ -0,0 +1,7 @@
1
+ export const BEACON_RADIUS = 5;
2
+ export const BEACON_STROKE_WIDTH = 2;
3
+ export const LABEL_OFFSET_Y = 12;
4
+ export const OVERLAY_OFFSET = 2;
5
+ export const OVERLAY_LINE_INSET = 0.5;
6
+ export const OVERLAY_OPACITY = 0.8;
7
+ export const LINE_GRADIENT_EDGE_OPACITY = 0.1;
@@ -0,0 +1 @@
1
+ export { ScrubberContextProvider, useScrubberContext } from './scrubberContext';
@@ -0,0 +1,14 @@
1
+ import { createSafeContext } from '@ledgerhq/lumen-utils-shared';
2
+
3
+ import type { ScrubberContextValue } from '../types';
4
+
5
+ const [ScrubberContextProvider, _useScrubberSafeContext] =
6
+ createSafeContext<ScrubberContextValue>('Scrubber');
7
+
8
+ export const useScrubberContext = (): ScrubberContextValue =>
9
+ _useScrubberSafeContext({
10
+ consumerName: 'useScrubberContext',
11
+ contextRequired: true,
12
+ });
13
+
14
+ export { ScrubberContextProvider };
@@ -0,0 +1,3 @@
1
+ export { Scrubber } from './Scrubber';
2
+ export { useScrubberContext } from './context';
3
+ export type { ScrubberProps, ScrubberContextValue } from './types';