@obitrain/charts 0.10.4 → 0.12.0

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 (132) hide show
  1. package/README.md +6 -4
  2. package/lib/commonjs/Charts/BottomAxis/AxisLine.js +1 -1
  3. package/lib/commonjs/Charts/BottomAxis/BottomAxis.js +54 -14
  4. package/lib/commonjs/Charts/BottomAxis/BottomAxis.js.map +1 -1
  5. package/lib/commonjs/Charts/BottomAxis/Tick.js +29 -13
  6. package/lib/commonjs/Charts/BottomAxis/Tick.js.map +1 -1
  7. package/lib/commonjs/Charts/BottomAxis/index.js.map +1 -1
  8. package/lib/commonjs/Charts/Cursor.js +33 -13
  9. package/lib/commonjs/Charts/Cursor.js.map +1 -1
  10. package/lib/commonjs/Charts/Dot.js +51 -0
  11. package/lib/commonjs/Charts/Dot.js.map +1 -0
  12. package/lib/commonjs/Charts/Dots.js +74 -0
  13. package/lib/commonjs/Charts/Dots.js.map +1 -0
  14. package/lib/commonjs/Charts/Linechart/index.js +1 -1
  15. package/lib/commonjs/Charts/YAxis.js +77 -0
  16. package/lib/commonjs/Charts/YAxis.js.map +1 -0
  17. package/lib/commonjs/Charts/ZoomableLinechart/ScalablePath.js +5 -4
  18. package/lib/commonjs/Charts/ZoomableLinechart/ScalablePath.js.map +1 -1
  19. package/lib/commonjs/Charts/ZoomableLinechart/index.js +1 -1
  20. package/lib/commonjs/Charts/gesture.js +90 -38
  21. package/lib/commonjs/Charts/gesture.js.map +1 -1
  22. package/lib/commonjs/Charts/graphUtils.js +69 -16
  23. package/lib/commonjs/Charts/graphUtils.js.map +1 -1
  24. package/lib/commonjs/Charts/index.js +39 -0
  25. package/lib/commonjs/Charts/index.js.map +1 -1
  26. package/lib/commonjs/Charts/maths.js +107 -16
  27. package/lib/commonjs/Charts/maths.js.map +1 -1
  28. package/lib/module/Charts/BottomAxis/AxisLine.js +1 -1
  29. package/lib/module/Charts/BottomAxis/AxisLine.js.map +1 -1
  30. package/lib/module/Charts/BottomAxis/BottomAxis.js +56 -15
  31. package/lib/module/Charts/BottomAxis/BottomAxis.js.map +1 -1
  32. package/lib/module/Charts/BottomAxis/Tick.js +29 -11
  33. package/lib/module/Charts/BottomAxis/Tick.js.map +1 -1
  34. package/lib/module/Charts/BottomAxis/index.js.map +1 -1
  35. package/lib/module/Charts/Cursor.js +35 -15
  36. package/lib/module/Charts/Cursor.js.map +1 -1
  37. package/lib/module/Charts/Dot.js +47 -0
  38. package/lib/module/Charts/Dot.js.map +1 -0
  39. package/lib/module/Charts/Dots.js +70 -0
  40. package/lib/module/Charts/Dots.js.map +1 -0
  41. package/lib/module/Charts/Linechart/index.js +1 -1
  42. package/lib/module/Charts/Linechart/index.js.map +1 -1
  43. package/lib/module/Charts/YAxis.js +73 -0
  44. package/lib/module/Charts/YAxis.js.map +1 -0
  45. package/lib/module/Charts/ZoomableLinechart/ScalablePath.js +5 -4
  46. package/lib/module/Charts/ZoomableLinechart/ScalablePath.js.map +1 -1
  47. package/lib/module/Charts/ZoomableLinechart/index.js +1 -1
  48. package/lib/module/Charts/ZoomableLinechart/index.js.map +1 -1
  49. package/lib/module/Charts/gesture.js +90 -39
  50. package/lib/module/Charts/gesture.js.map +1 -1
  51. package/lib/module/Charts/graphUtils.js +71 -18
  52. package/lib/module/Charts/graphUtils.js.map +1 -1
  53. package/lib/module/Charts/index.js +5 -2
  54. package/lib/module/Charts/index.js.map +1 -1
  55. package/lib/module/Charts/maths.js +101 -14
  56. package/lib/module/Charts/maths.js.map +1 -1
  57. package/lib/typescript/commonjs/src/Charts/BottomAxis/AxisLine.d.ts +1 -1
  58. package/lib/typescript/commonjs/src/Charts/BottomAxis/AxisLine.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/src/Charts/BottomAxis/BottomAxis.d.ts +26 -3
  60. package/lib/typescript/commonjs/src/Charts/BottomAxis/BottomAxis.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/src/Charts/BottomAxis/Tick.d.ts +16 -3
  62. package/lib/typescript/commonjs/src/Charts/BottomAxis/Tick.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/src/Charts/BottomAxis/index.d.ts +1 -1
  64. package/lib/typescript/commonjs/src/Charts/BottomAxis/index.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/Charts/Cursor.d.ts +5 -2
  66. package/lib/typescript/commonjs/src/Charts/Cursor.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/Charts/Dot.d.ts +22 -0
  68. package/lib/typescript/commonjs/src/Charts/Dot.d.ts.map +1 -0
  69. package/lib/typescript/commonjs/src/Charts/Dots.d.ts +28 -0
  70. package/lib/typescript/commonjs/src/Charts/Dots.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/src/Charts/Linechart/index.d.ts +1 -1
  72. package/lib/typescript/commonjs/src/Charts/Linechart/index.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/Charts/YAxis.d.ts +21 -0
  74. package/lib/typescript/commonjs/src/Charts/YAxis.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/ScalablePath.d.ts +2 -2
  76. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/ScalablePath.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/index.d.ts +1 -1
  78. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/index.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/src/Charts/gesture.d.ts +6 -0
  80. package/lib/typescript/commonjs/src/Charts/gesture.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/src/Charts/graphUtils.d.ts +1 -1
  82. package/lib/typescript/commonjs/src/Charts/graphUtils.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/src/Charts/index.d.ts +9 -3
  84. package/lib/typescript/commonjs/src/Charts/index.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/src/Charts/maths.d.ts +15 -1
  86. package/lib/typescript/commonjs/src/Charts/maths.d.ts.map +1 -1
  87. package/lib/typescript/module/src/Charts/BottomAxis/AxisLine.d.ts +1 -1
  88. package/lib/typescript/module/src/Charts/BottomAxis/AxisLine.d.ts.map +1 -1
  89. package/lib/typescript/module/src/Charts/BottomAxis/BottomAxis.d.ts +26 -3
  90. package/lib/typescript/module/src/Charts/BottomAxis/BottomAxis.d.ts.map +1 -1
  91. package/lib/typescript/module/src/Charts/BottomAxis/Tick.d.ts +16 -3
  92. package/lib/typescript/module/src/Charts/BottomAxis/Tick.d.ts.map +1 -1
  93. package/lib/typescript/module/src/Charts/BottomAxis/index.d.ts +1 -1
  94. package/lib/typescript/module/src/Charts/BottomAxis/index.d.ts.map +1 -1
  95. package/lib/typescript/module/src/Charts/Cursor.d.ts +5 -2
  96. package/lib/typescript/module/src/Charts/Cursor.d.ts.map +1 -1
  97. package/lib/typescript/module/src/Charts/Dot.d.ts +22 -0
  98. package/lib/typescript/module/src/Charts/Dot.d.ts.map +1 -0
  99. package/lib/typescript/module/src/Charts/Dots.d.ts +28 -0
  100. package/lib/typescript/module/src/Charts/Dots.d.ts.map +1 -0
  101. package/lib/typescript/module/src/Charts/Linechart/index.d.ts +1 -1
  102. package/lib/typescript/module/src/Charts/Linechart/index.d.ts.map +1 -1
  103. package/lib/typescript/module/src/Charts/YAxis.d.ts +21 -0
  104. package/lib/typescript/module/src/Charts/YAxis.d.ts.map +1 -0
  105. package/lib/typescript/module/src/Charts/ZoomableLinechart/ScalablePath.d.ts +2 -2
  106. package/lib/typescript/module/src/Charts/ZoomableLinechart/ScalablePath.d.ts.map +1 -1
  107. package/lib/typescript/module/src/Charts/ZoomableLinechart/index.d.ts +1 -1
  108. package/lib/typescript/module/src/Charts/ZoomableLinechart/index.d.ts.map +1 -1
  109. package/lib/typescript/module/src/Charts/gesture.d.ts +6 -0
  110. package/lib/typescript/module/src/Charts/gesture.d.ts.map +1 -1
  111. package/lib/typescript/module/src/Charts/graphUtils.d.ts +1 -1
  112. package/lib/typescript/module/src/Charts/graphUtils.d.ts.map +1 -1
  113. package/lib/typescript/module/src/Charts/index.d.ts +9 -3
  114. package/lib/typescript/module/src/Charts/index.d.ts.map +1 -1
  115. package/lib/typescript/module/src/Charts/maths.d.ts +15 -1
  116. package/lib/typescript/module/src/Charts/maths.d.ts.map +1 -1
  117. package/package.json +5 -5
  118. package/src/Charts/BottomAxis/AxisLine.tsx +1 -1
  119. package/src/Charts/BottomAxis/BottomAxis.tsx +100 -19
  120. package/src/Charts/BottomAxis/Tick.tsx +61 -15
  121. package/src/Charts/BottomAxis/index.tsx +5 -1
  122. package/src/Charts/Cursor.tsx +38 -10
  123. package/src/Charts/Dot.tsx +64 -0
  124. package/src/Charts/Dots.tsx +96 -0
  125. package/src/Charts/Linechart/index.tsx +1 -1
  126. package/src/Charts/YAxis.tsx +103 -0
  127. package/src/Charts/ZoomableLinechart/ScalablePath.tsx +8 -9
  128. package/src/Charts/ZoomableLinechart/index.tsx +1 -1
  129. package/src/Charts/gesture.ts +132 -71
  130. package/src/Charts/graphUtils.ts +78 -16
  131. package/src/Charts/index.tsx +9 -0
  132. package/src/Charts/maths.ts +107 -16
@@ -1,10 +1,9 @@
1
- import { clamp } from '@shopify/react-native-skia';
2
- import { useCallback } from 'react';
1
+ import { clamp } from 'react-native-skia';
2
+ import { useCallback, useMemo, useRef } from 'react';
3
3
  import { Gesture, type PanGesture, type PinchGesture } from 'react-native-gesture-handler';
4
4
  import {
5
5
  useAnimatedReaction,
6
6
  useSharedValue,
7
- withTiming,
8
7
  type SharedValue,
9
8
  } from 'react-native-reanimated';
10
9
  import { scheduleOnRN } from 'react-native-worklets';
@@ -24,6 +23,23 @@ export const getPositionWl = function (
24
23
  return (position - focalX) * scale + focalX + offsetX;
25
24
  };
26
25
 
26
+ /**
27
+ * Offset range keeping the content edges pinned to the viewport:
28
+ * leftBound (position 0) <= startOffset and rightBound (position width)
29
+ * >= width + startOffset. Collapses to [startOffset, startOffset] at scale 1.
30
+ */
31
+ export const getOffsetBoundsWl = function (
32
+ width: number,
33
+ startOffset: number,
34
+ focalX: number,
35
+ scale: number
36
+ ): [min: number, max: number] {
37
+ 'worklet';
38
+ const maxOffset = startOffset + focalX * (scale - 1);
39
+ const minOffset = startOffset + (1 - scale) * (width - focalX);
40
+ return [minOffset, maxOffset];
41
+ };
42
+
27
43
  export type AxisGestureProps = {
28
44
  width: number;
29
45
  startOffset?: number;
@@ -55,6 +71,8 @@ export const useScalableGesture = (props: AxisGestureProps): ScalableGesture =>
55
71
  // For panning
56
72
  const offsetX = useSharedValue(startOffset);
57
73
  const lastOffsetX = useSharedValue(startOffset);
74
+ // Pinch pivot bookkeeping (see pinchGesture)
75
+ const needsPivot = useSharedValue(true);
58
76
 
59
77
  const reset = useCallback(() => {
60
78
  scale.value = startScale;
@@ -74,54 +92,85 @@ export const useScalableGesture = (props: AxisGestureProps): ScalableGesture =>
74
92
  startScale,
75
93
  ]);
76
94
 
77
- const panGesture = Gesture.Pan()
78
- .onUpdate((event) => {
79
- const newOffsetX = lastOffsetX.value + event.translationX;
80
- offsetX.value = newOffsetX;
81
- })
82
- .onEnd(() => {
83
- lastScale.value = scale.value;
84
- lastFocalX.value = focalX.value;
85
-
86
- const leftBound = getPositionWl(
87
- 0,
88
- focalX.value,
89
- scale.value,
90
- offsetX.value
91
- );
92
- const rightBound = getPositionWl(
93
- width,
94
- focalX.value,
95
- scale.value,
96
- offsetX.value
97
- );
98
-
99
- let newOffset;
100
-
101
- if (leftBound > startOffset) {
102
- newOffset = offsetX.value - leftBound + startOffset;
103
- } else if (rightBound < width + startOffset) {
104
- newOffset = offsetX.value + width - rightBound + startOffset;
105
- } else {
106
- newOffset = offsetX.value;
107
- }
95
+ // Memoized: handing a new gesture instance to GestureDetector mid-gesture
96
+ // resets the active pan and makes the chart jump
97
+ const panGesture = useMemo(
98
+ () =>
99
+ Gesture.Pan()
100
+ // Single-finger only: two fingers belong to the pinch, whose focal
101
+ // rebase would otherwise fight the pan over offsetX
102
+ .maxPointers(1)
103
+ // Incremental deltas (changeX) with a live clamp keep the content
104
+ // edges pinned: no overscroll, no snap-back to interrupt
105
+ .onChange((event) => {
106
+ const [minOffset, maxOffset] = getOffsetBoundsWl(
107
+ width,
108
+ startOffset,
109
+ focalX.value,
110
+ scale.value
111
+ );
112
+ offsetX.value = clamp(
113
+ offsetX.value + event.changeX,
114
+ minOffset,
115
+ maxOffset
116
+ );
117
+ })
118
+ .onEnd(() => {
119
+ lastScale.value = scale.value;
120
+ lastFocalX.value = focalX.value;
121
+ lastOffsetX.value = offsetX.value;
122
+ }),
123
+ [focalX, lastFocalX, lastOffsetX, lastScale, offsetX, scale, startOffset, width]
124
+ );
108
125
 
109
- offsetX.value = withTiming(newOffset, { duration: 300 });
110
- lastOffsetX.value = newOffset;
111
- });
112
-
113
- const pinchGesture = Gesture.Pinch()
114
- .onUpdate((event) => {
115
- // Rebase offsetX so moving the focal point doesn't shift the content
116
- offsetX.value += (focalX.value - event.focalX) * (1 - scale.value);
117
- focalX.value = event.focalX;
118
- scale.value = clamp(lastScale.value * event.scale, 1, Infinity);
119
- })
120
- .onEnd(() => {
121
- lastScale.value = scale.value;
122
- lastFocalX.value = focalX.value;
123
- lastOffsetX.value = offsetX.value;
124
- });
126
+ const pinchGesture = useMemo(
127
+ () =>
128
+ Gesture.Pinch()
129
+ .onStart(() => {
130
+ needsPivot.value = true;
131
+ })
132
+ .onUpdate((event) => {
133
+ // The pinch can keep firing in the single-finger tail of the
134
+ // gesture, tracking the remaining finger as its focal: ignore it
135
+ // (numberOfPointers is missing on web — let those through)
136
+ if ((event.numberOfPointers ?? 2) < 2) {
137
+ needsPivot.value = true;
138
+ return;
139
+ }
140
+ const prevScale = scale.value;
141
+ const newScale = clamp(lastScale.value * event.scale, 1, Infinity);
142
+ if (needsPivot.value) {
143
+ // New pivot: rebase offsetX so re-anchoring under the fingers
144
+ // doesn't shift the content
145
+ const rebased =
146
+ offsetX.value + (focalX.value - event.focalX) * (1 - prevScale);
147
+ offsetX.value = (rebased * newScale) / prevScale;
148
+ needsPivot.value = false;
149
+ } else {
150
+ // Keep the content glued to both fingers: the offset follows the
151
+ // focal translation and rescales with the zoom
152
+ offsetX.value =
153
+ (event.focalX - focalX.value + offsetX.value / prevScale) *
154
+ newScale;
155
+ }
156
+ focalX.value = event.focalX;
157
+ scale.value = newScale;
158
+ // Zooming moves the bounds: keep the content edges pinned
159
+ const [minOffset, maxOffset] = getOffsetBoundsWl(
160
+ width,
161
+ startOffset,
162
+ focalX.value,
163
+ scale.value
164
+ );
165
+ offsetX.value = clamp(offsetX.value, minOffset, maxOffset);
166
+ })
167
+ .onEnd(() => {
168
+ lastScale.value = scale.value;
169
+ lastFocalX.value = focalX.value;
170
+ lastOffsetX.value = offsetX.value;
171
+ }),
172
+ [focalX, lastFocalX, lastOffsetX, lastScale, needsPivot, offsetX, scale, startOffset, width]
173
+ );
125
174
 
126
175
  return {
127
176
  scale,
@@ -143,6 +192,14 @@ export const useUpdateAxis = function (props: UpdateAxisProps) {
143
192
  const { scale, scales, onScaleChange } = props;
144
193
  const currentIndex = useSharedValue(0);
145
194
 
195
+ // Stable dispatcher so an inline onScaleChange doesn't re-register the
196
+ // reaction (and disturb the UI thread) on every render
197
+ const onScaleChangeRef = useRef(onScaleChange);
198
+ onScaleChangeRef.current = onScaleChange;
199
+ const dispatchScaleChange = useCallback((index: number) => {
200
+ onScaleChangeRef.current?.(index);
201
+ }, []);
202
+
146
203
  useAnimatedReaction(
147
204
  () => scale.value,
148
205
  (currentScale, _) => {
@@ -158,11 +215,12 @@ export const useUpdateAxis = function (props: UpdateAxisProps) {
158
215
  currentIndex.value !== i
159
216
  ) {
160
217
  currentIndex.value = i;
161
- if (onScaleChange !== undefined) scheduleOnRN(onScaleChange, i);
218
+ scheduleOnRN(dispatchScaleChange, i);
162
219
  break;
163
220
  }
164
221
  }
165
- }
222
+ },
223
+ [scales, dispatchScaleChange]
166
224
  );
167
225
 
168
226
  return { currentIndex };
@@ -219,25 +277,28 @@ export const useCursorGesture = function (props: UseCursorGestureProps) {
219
277
  const xPosition = useSharedValue(0);
220
278
  const yPosition = useSharedValue(height);
221
279
 
222
- const setPosition = (x: number, y: number) => {
223
- 'worklet';
224
- yPosition.value = clamp(y, 0, height);
225
- if (isContinuous || points === undefined) {
226
- xPosition.value = clamp(x, 0, width);
227
- return;
228
- }
229
- const _closestDot = getClosestPoint(clamp(x, 0, width), points.value);
230
- if (closestDataPoint !== undefined) closestDataPoint.value = _closestDot;
231
- xPosition.value = _closestDot.x;
232
- };
233
-
234
- const panGesture = Gesture.Pan()
235
- .onBegin((event) => setPosition(event.x, event.y))
236
- .onUpdate((event) => setPosition(event.x, event.y));
237
-
238
- const tapGesture = Gesture.Tap().onBegin((event) =>
239
- setPosition(event.x, event.y)
240
- );
280
+ // Memoized: a new gesture instance on every render can reset an active pan
281
+ const { panGesture, tapGesture } = useMemo(() => {
282
+ const setPosition = (x: number, y: number) => {
283
+ 'worklet';
284
+ yPosition.value = clamp(y, 0, height);
285
+ if (isContinuous || points === undefined) {
286
+ xPosition.value = clamp(x, 0, width);
287
+ return;
288
+ }
289
+ const _closestDot = getClosestPoint(clamp(x, 0, width), points.value);
290
+ if (closestDataPoint !== undefined) closestDataPoint.value = _closestDot;
291
+ xPosition.value = _closestDot.x;
292
+ };
293
+ return {
294
+ panGesture: Gesture.Pan()
295
+ .onBegin((event) => setPosition(event.x, event.y))
296
+ .onUpdate((event) => setPosition(event.x, event.y)),
297
+ tapGesture: Gesture.Tap().onBegin((event) =>
298
+ setPosition(event.x, event.y)
299
+ ),
300
+ };
301
+ }, [width, height, isContinuous, points, closestDataPoint, xPosition, yPosition]);
241
302
 
242
303
  // Re-snap the cursor when the data points change (e.g. switching graphs)
243
304
  useAnimatedReaction(
@@ -3,16 +3,23 @@ import {
3
3
  Skia,
4
4
  type PathCommand,
5
5
  type SkPath,
6
- } from '@shopify/react-native-skia';
6
+ } from 'react-native-skia';
7
+ import { useMemo, useRef } from 'react';
7
8
  import { scaleLinear, type ScaleLinear } from 'd3-scale';
8
9
  import * as shape from 'd3-shape';
9
10
  import {
10
11
  useAnimatedReaction,
12
+ useSharedValue,
11
13
  withTiming,
12
14
  type SharedValue,
13
15
  } from 'react-native-reanimated';
14
16
  import { getPositionWl } from './gesture';
15
- import { getYForX } from './maths';
17
+ import {
18
+ commandsToBezier,
19
+ findBezierIndex,
20
+ getYBeyondPathEnds,
21
+ getYOnBezier,
22
+ } from './maths';
16
23
  import type { AnimatedDot, DataPoint } from './types';
17
24
 
18
25
  export type BuildGraphConfig = {
@@ -47,12 +54,23 @@ export const buildGraph = function (
47
54
  if (data.length === 0)
48
55
  throw new Error('buildGraph requires at least one data point');
49
56
 
50
- const minX = config?.minX ?? Math.min(...data.map((d) => d[0]));
51
- const maxX = config?.maxX ?? Math.max(...data.map((d) => d[0]));
57
+ // Loops rather than Math.min(...data): spreading a long series can exceed the engine's argument limit
58
+ let dataMinX = Infinity;
59
+ let dataMaxX = -Infinity;
60
+ let dataMinY = Infinity;
61
+ let dataMaxY = -Infinity;
62
+ for (const [x, y] of data) {
63
+ if (x < dataMinX) dataMinX = x;
64
+ if (x > dataMaxX) dataMaxX = x;
65
+ if (y < dataMinY) dataMinY = y;
66
+ if (y > dataMaxY) dataMaxY = y;
67
+ }
68
+ const minX = config?.minX ?? dataMinX;
69
+ const maxX = config?.maxX ?? dataMaxX;
52
70
  const scaleX = scaleLinear().domain([minX, maxX]).range([0, width]);
53
71
 
54
- const minY = config?.minY ?? Math.min(...data.map((d) => d[1]));
55
- const maxY = config?.maxY ?? Math.max(...data.map((d) => d[1]));
72
+ const minY = config?.minY ?? dataMinY;
73
+ const maxY = config?.maxY ?? dataMaxY;
56
74
  const scaleY = scaleLinear().domain([minY, maxY]).range([height, 0]);
57
75
 
58
76
  const path = shape
@@ -173,18 +191,39 @@ export const useDotsTransition = function (props: UseDotAnimationProps) {
173
191
  translateWl = defaultTranslateTransitionWl,
174
192
  } = props;
175
193
 
194
+ // Normalized once per path change, not per dot per frame
195
+ const commands = useSharedValue<PathCommand[]>([]);
196
+ const visibleCount = useSharedValue(0);
197
+ // Bumped when the data or the dots are replaced, so they get retargeted
198
+ // even if the graph index is unchanged
199
+ const generationRef = useRef(0);
200
+ const generation = useMemo(
201
+ () => (generationRef.current += 1),
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
+ [dataPoints, dots]
204
+ );
205
+
206
+ // Retarget on graph or data change only: re-assigning x while it animates
207
+ // would restart the animation every frame
176
208
  useAnimatedReaction(
177
209
  () => ({
178
210
  _currentGraph: currentGraph.value,
179
- _currentCommands: path.value.toCmds(),
180
- // Track the animating x values so y stays glued to the curve
181
- // even when the path is swapped instantly or tweens on another duration
182
- _xs: dots.map((d) => d.x.value),
211
+ _path: path.value,
212
+ _generation: generation,
183
213
  }),
184
- ({ _currentGraph, _currentCommands }) => {
214
+ ({ _currentGraph, _path, _generation }, previous) => {
215
+ commands.value = commandsToBezier(_path.toCmds());
216
+ if (
217
+ previous !== null &&
218
+ previous._currentGraph === _currentGraph &&
219
+ previous._generation === _generation
220
+ )
221
+ return;
222
+
185
223
  const newDataPoints = dataPoints[_currentGraph];
186
224
  if (newDataPoints === undefined)
187
225
  throw new Error('Data points cannot be undefined');
226
+ visibleCount.value = newDataPoints.length;
188
227
  dots.forEach((_dot, i) => {
189
228
  const _newDot = newDataPoints[i];
190
229
  if (!_newDot) {
@@ -192,12 +231,35 @@ export const useDotsTransition = function (props: UseDotAnimationProps) {
192
231
  return;
193
232
  }
194
233
  _dot.x.value = translateWl(_newDot.x);
195
- const newY = getYForX(_currentCommands, _dot.x.value);
196
- if (newY !== undefined) {
197
- _dot.y.value = newY;
198
- }
199
234
  _dot.opacity.value = opacityWl(1);
200
235
  });
201
- }
236
+ },
237
+ // Explicit deps: without them the reaction re-registers and re-fires on
238
+ // every parent render, restarting every dot animation on the UI thread
239
+ [currentGraph, path, dataPoints, dots, opacityWl, translateWl, generation]
240
+ );
241
+
242
+ // Keep y glued to the curve while x animates or the path tweens
243
+ useAnimatedReaction(
244
+ () => ({ _cmds: commands.value, _xs: dots.map((d) => d.x.value) }),
245
+ ({ _cmds, _xs }) => {
246
+ const count = Math.min(visibleCount.value, dots.length);
247
+ // Dots are in x order, so one forward walk over the segments serves them all
248
+ let segment = 0;
249
+ for (let i = 0; i < count; i++) {
250
+ const x = _xs[i]!;
251
+ let index = findBezierIndex(_cmds, x, segment);
252
+ if (index === -1 && segment > 1) index = findBezierIndex(_cmds, x);
253
+ let newY: number | undefined;
254
+ if (index === -1) {
255
+ newY = getYBeyondPathEnds(_cmds, x);
256
+ } else {
257
+ segment = index;
258
+ newY = getYOnBezier(_cmds, index, x);
259
+ }
260
+ if (newY !== undefined) dots[i]!.y.value = newY;
261
+ }
262
+ },
263
+ [dots]
202
264
  );
203
265
  };
@@ -1,11 +1,18 @@
1
1
  export { AxisLine, BottomAxis, Tick } from './BottomAxis';
2
2
  export type {
3
3
  AxisLineProps,
4
+ AxisTick,
4
5
  BottomAxisProps,
5
6
  TickProps,
6
7
  } from './BottomAxis';
7
8
  export { Cursor } from './Cursor';
8
9
  export type { CursorProps } from './Cursor';
10
+ export { Dot } from './Dot';
11
+ export type { DotProps } from './Dot';
12
+ export { Dots } from './Dots';
13
+ export type { DotShape, DotsProps } from './Dots';
14
+ export { YAxis, getPaddedTicks } from './YAxis';
15
+ export type { YAxisProps } from './YAxis';
9
16
  export { LineChart } from './Linechart';
10
17
  export type { LineChartProps, LinePath } from './Linechart';
11
18
  export { ScalablePath, ZoomableLineChart } from './ZoomableLinechart';
@@ -15,6 +22,7 @@ export type {
15
22
  } from './ZoomableLinechart';
16
23
  export {
17
24
  getClosestPoint,
25
+ getOffsetBoundsWl,
18
26
  getPositionWl,
19
27
  useCursorGesture,
20
28
  useScalableGesture,
@@ -44,6 +52,7 @@ export {
44
52
  commandsToBezier,
45
53
  cubicBezierYForX,
46
54
  getYForX,
55
+ getYForXOnBeziers,
47
56
  magnitude,
48
57
  normalize,
49
58
  selectCurve,
@@ -2,8 +2,8 @@ import type {
2
2
  PathCommand,
3
3
  SkPoint,
4
4
  Vector,
5
- } from '@shopify/react-native-skia';
6
- import { PathVerb, vec } from '@shopify/react-native-skia';
5
+ } from 'react-native-skia';
6
+ import { PathVerb } from 'react-native-skia';
7
7
 
8
8
  // code from William Candillon
9
9
 
@@ -155,7 +155,10 @@ export const selectCurve = (
155
155
  ): Cubic | undefined => {
156
156
  'worklet';
157
157
 
158
- let from: Vector = vec(0, 0);
158
+ // Plain numbers while scanning: vec() allocates a JSI host object, which
159
+ // dominated the UI thread when called per dot per frame
160
+ let fromX = 0;
161
+ let fromY = 0;
159
162
  for (let i = 0; i < cmds.length; i++) {
160
163
  const cmd = cmds[i];
161
164
  if (cmd == null) {
@@ -163,20 +166,21 @@ export const selectCurve = (
163
166
  }
164
167
 
165
168
  if (cmd[0] === PathVerb.Move) {
166
- from = vec(cmd[1], cmd[2]);
169
+ fromX = cmd[1]!;
170
+ fromY = cmd[2]!;
167
171
  } else if (cmd[0] === PathVerb.Cubic) {
168
- const c1 = vec(cmd[1], cmd[2]);
169
- const c2 = vec(cmd[3], cmd[4]);
170
- const to = vec(cmd[5], cmd[6]);
171
- if (x >= from.x && x <= to.x) {
172
+ const toX = cmd[5]!;
173
+ const toY = cmd[6]!;
174
+ if (x >= fromX && x <= toX) {
172
175
  return {
173
- from,
174
- c1,
175
- c2,
176
- to,
176
+ from: { x: fromX, y: fromY },
177
+ c1: { x: cmd[1]!, y: cmd[2]! },
178
+ c2: { x: cmd[3]!, y: cmd[4]! },
179
+ to: { x: toX, y: toY },
177
180
  };
178
181
  }
179
- from = to;
182
+ fromX = toX;
183
+ fromY = toY;
180
184
  }
181
185
  }
182
186
 
@@ -229,13 +233,100 @@ export const getYForX = (
229
233
  precision = 2
230
234
  ): number | undefined => {
231
235
  'worklet';
232
- const cmdsNorm = commandsToBezier(cmds);
233
- const c = selectCurve(cmdsNorm, x);
236
+ const c = selectCurve(commandsToBezier(cmds), x);
234
237
  if (c == null) return undefined;
235
-
236
238
  return cubicBezierYForX(x, c.from, c.c1, c.c2, c.to, precision);
237
239
  };
238
240
 
241
+ /**
242
+ * Index of the cubic whose x range contains `x`, scanning from `start`; -1 if none.
243
+ * `cmds` must come from {@link commandsToBezier}.
244
+ */
245
+ export const findBezierIndex = (
246
+ cmds: PathCommand[],
247
+ x: number,
248
+ start = 0
249
+ ): number => {
250
+ 'worklet';
251
+ for (let i = Math.max(start, 1); i < cmds.length; i++) {
252
+ const cmd = cmds[i]!;
253
+ if (cmd[0] !== PathVerb.Cubic) continue;
254
+ const prev = cmds[i - 1]!;
255
+ const fromX = prev[prev.length - 2]!;
256
+ if (x >= fromX && x <= cmd[5]!) return i;
257
+ }
258
+ return -1;
259
+ };
260
+
261
+ const EPSILON = 1e-6;
262
+
263
+ /** y of the cubic at `cmds[index]` for `x`, without allocating; undefined when x has no t in [0, 1]. */
264
+ export const getYOnBezier = (
265
+ cmds: PathCommand[],
266
+ index: number,
267
+ x: number
268
+ ): number | undefined => {
269
+ 'worklet';
270
+ const prev = cmds[index - 1]!;
271
+ const cmd = cmds[index]!;
272
+ const x0 = prev[prev.length - 2]!;
273
+ const y0 = prev[prev.length - 1]!;
274
+ const x1 = cmd[1]!;
275
+ const y1 = cmd[2]!;
276
+ const x2 = cmd[3]!;
277
+ const y2 = cmd[4]!;
278
+ const x3 = cmd[5]!;
279
+ const y3 = cmd[6]!;
280
+
281
+ // commandsToBezier turns lines into cubics with c1 = from and c2 = to
282
+ if (x1 === x0 && y1 === y0 && x2 === x3 && y2 === y3) {
283
+ return x3 === x0 ? y0 : y0 + ((y3 - y0) * (x - x0)) / (x3 - x0);
284
+ }
285
+ // A vertical curve has no single y for x: use its start, like a vertical line
286
+ if (x0 === x1 && x1 === x2 && x2 === x3) return y0;
287
+
288
+ const roots = solveCubic(
289
+ -x0 + 3 * x1 - 3 * x2 + x3,
290
+ 3 * x0 - 6 * x1 + 3 * x2,
291
+ -3 * x0 + 3 * x1,
292
+ x0 - x
293
+ );
294
+ for (let i = 0; i < roots.length; i++) {
295
+ const t = roots[i]!;
296
+ if (t >= -EPSILON && t <= 1 + EPSILON) {
297
+ return cubicSolve(Math.min(1, Math.max(0, t)), y0, y1, y2, y3);
298
+ }
299
+ }
300
+ return undefined;
301
+ };
302
+
303
+ /**
304
+ * y of the path's start when `x` is left of it, of its end when right of it, else undefined.
305
+ * Endpoints are stored as float32, so a point's exact x can fall a hair outside the path.
306
+ */
307
+ export const getYBeyondPathEnds = (
308
+ cmds: PathCommand[],
309
+ x: number
310
+ ): number | undefined => {
311
+ 'worklet';
312
+ const first = cmds[0];
313
+ const last = cmds[cmds.length - 1];
314
+ if (first === undefined || last === undefined) return undefined;
315
+ if (x < first[1]!) return first[2];
316
+ if (x > last[last.length - 2]!) return last[last.length - 1];
317
+ return undefined;
318
+ };
319
+
320
+ /** Same as {@link getYForX} for commands already passed through {@link commandsToBezier}. */
321
+ export const getYForXOnBeziers = (
322
+ cmds: PathCommand[],
323
+ x: number
324
+ ): number | undefined => {
325
+ 'worklet';
326
+ const index = findBezierIndex(cmds, x);
327
+ return index === -1 ? undefined : getYOnBezier(cmds, index, x);
328
+ };
329
+
239
330
  // See Path/getPath2D
240
331
 
241
332
  export const magnitude = (p: SkPoint): number =>