@obitrain/charts 0.11.0 → 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 (121) hide show
  1. package/README.md +2 -2
  2. package/lib/commonjs/Charts/BottomAxis/AxisLine.js +1 -1
  3. package/lib/commonjs/Charts/BottomAxis/BottomAxis.js +1 -1
  4. package/lib/commonjs/Charts/BottomAxis/Tick.js +2 -2
  5. package/lib/commonjs/Charts/BottomAxis/Tick.js.map +1 -1
  6. package/lib/commonjs/Charts/Cursor.js +15 -7
  7. package/lib/commonjs/Charts/Cursor.js.map +1 -1
  8. package/lib/commonjs/Charts/Dot.js +1 -1
  9. package/lib/commonjs/Charts/Dots.js +3 -3
  10. package/lib/commonjs/Charts/Dots.js.map +1 -1
  11. package/lib/commonjs/Charts/Linechart/index.js +1 -1
  12. package/lib/commonjs/Charts/YAxis.js +38 -25
  13. package/lib/commonjs/Charts/YAxis.js.map +1 -1
  14. package/lib/commonjs/Charts/ZoomableLinechart/ScalablePath.js +5 -4
  15. package/lib/commonjs/Charts/ZoomableLinechart/ScalablePath.js.map +1 -1
  16. package/lib/commonjs/Charts/ZoomableLinechart/index.js +1 -1
  17. package/lib/commonjs/Charts/gesture.js +23 -14
  18. package/lib/commonjs/Charts/gesture.js.map +1 -1
  19. package/lib/commonjs/Charts/graphUtils.js +66 -16
  20. package/lib/commonjs/Charts/graphUtils.js.map +1 -1
  21. package/lib/commonjs/Charts/index.js +6 -0
  22. package/lib/commonjs/Charts/index.js.map +1 -1
  23. package/lib/commonjs/Charts/maths.js +107 -16
  24. package/lib/commonjs/Charts/maths.js.map +1 -1
  25. package/lib/module/Charts/BottomAxis/AxisLine.js +1 -1
  26. package/lib/module/Charts/BottomAxis/AxisLine.js.map +1 -1
  27. package/lib/module/Charts/BottomAxis/BottomAxis.js +1 -1
  28. package/lib/module/Charts/BottomAxis/BottomAxis.js.map +1 -1
  29. package/lib/module/Charts/BottomAxis/Tick.js +3 -3
  30. package/lib/module/Charts/BottomAxis/Tick.js.map +1 -1
  31. package/lib/module/Charts/Cursor.js +16 -8
  32. package/lib/module/Charts/Cursor.js.map +1 -1
  33. package/lib/module/Charts/Dot.js +1 -1
  34. package/lib/module/Charts/Dot.js.map +1 -1
  35. package/lib/module/Charts/Dots.js +3 -3
  36. package/lib/module/Charts/Dots.js.map +1 -1
  37. package/lib/module/Charts/Linechart/index.js +1 -1
  38. package/lib/module/Charts/Linechart/index.js.map +1 -1
  39. package/lib/module/Charts/YAxis.js +39 -25
  40. package/lib/module/Charts/YAxis.js.map +1 -1
  41. package/lib/module/Charts/ZoomableLinechart/ScalablePath.js +5 -4
  42. package/lib/module/Charts/ZoomableLinechart/ScalablePath.js.map +1 -1
  43. package/lib/module/Charts/ZoomableLinechart/index.js +1 -1
  44. package/lib/module/Charts/ZoomableLinechart/index.js.map +1 -1
  45. package/lib/module/Charts/gesture.js +23 -14
  46. package/lib/module/Charts/gesture.js.map +1 -1
  47. package/lib/module/Charts/graphUtils.js +68 -18
  48. package/lib/module/Charts/graphUtils.js.map +1 -1
  49. package/lib/module/Charts/index.js +1 -1
  50. package/lib/module/Charts/index.js.map +1 -1
  51. package/lib/module/Charts/maths.js +101 -14
  52. package/lib/module/Charts/maths.js.map +1 -1
  53. package/lib/typescript/commonjs/src/Charts/BottomAxis/AxisLine.d.ts +1 -1
  54. package/lib/typescript/commonjs/src/Charts/BottomAxis/AxisLine.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/src/Charts/BottomAxis/BottomAxis.d.ts +1 -1
  56. package/lib/typescript/commonjs/src/Charts/BottomAxis/BottomAxis.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/src/Charts/BottomAxis/Tick.d.ts +1 -1
  58. package/lib/typescript/commonjs/src/Charts/BottomAxis/Tick.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/src/Charts/Cursor.d.ts +2 -2
  60. package/lib/typescript/commonjs/src/Charts/Cursor.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/src/Charts/Dot.d.ts +1 -1
  62. package/lib/typescript/commonjs/src/Charts/Dot.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/src/Charts/Dots.d.ts +1 -1
  64. package/lib/typescript/commonjs/src/Charts/Dots.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/src/Charts/Linechart/index.d.ts +1 -1
  66. package/lib/typescript/commonjs/src/Charts/Linechart/index.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/Charts/YAxis.d.ts +3 -4
  68. package/lib/typescript/commonjs/src/Charts/YAxis.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/ScalablePath.d.ts +1 -1
  70. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/ScalablePath.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/index.d.ts +1 -1
  72. package/lib/typescript/commonjs/src/Charts/ZoomableLinechart/index.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/Charts/gesture.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/src/Charts/graphUtils.d.ts +1 -1
  75. package/lib/typescript/commonjs/src/Charts/graphUtils.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/src/Charts/index.d.ts +1 -1
  77. package/lib/typescript/commonjs/src/Charts/index.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/src/Charts/maths.d.ts +15 -1
  79. package/lib/typescript/commonjs/src/Charts/maths.d.ts.map +1 -1
  80. package/lib/typescript/module/src/Charts/BottomAxis/AxisLine.d.ts +1 -1
  81. package/lib/typescript/module/src/Charts/BottomAxis/AxisLine.d.ts.map +1 -1
  82. package/lib/typescript/module/src/Charts/BottomAxis/BottomAxis.d.ts +1 -1
  83. package/lib/typescript/module/src/Charts/BottomAxis/BottomAxis.d.ts.map +1 -1
  84. package/lib/typescript/module/src/Charts/BottomAxis/Tick.d.ts +1 -1
  85. package/lib/typescript/module/src/Charts/BottomAxis/Tick.d.ts.map +1 -1
  86. package/lib/typescript/module/src/Charts/Cursor.d.ts +2 -2
  87. package/lib/typescript/module/src/Charts/Cursor.d.ts.map +1 -1
  88. package/lib/typescript/module/src/Charts/Dot.d.ts +1 -1
  89. package/lib/typescript/module/src/Charts/Dot.d.ts.map +1 -1
  90. package/lib/typescript/module/src/Charts/Dots.d.ts +1 -1
  91. package/lib/typescript/module/src/Charts/Dots.d.ts.map +1 -1
  92. package/lib/typescript/module/src/Charts/Linechart/index.d.ts +1 -1
  93. package/lib/typescript/module/src/Charts/Linechart/index.d.ts.map +1 -1
  94. package/lib/typescript/module/src/Charts/YAxis.d.ts +3 -4
  95. package/lib/typescript/module/src/Charts/YAxis.d.ts.map +1 -1
  96. package/lib/typescript/module/src/Charts/ZoomableLinechart/ScalablePath.d.ts +1 -1
  97. package/lib/typescript/module/src/Charts/ZoomableLinechart/ScalablePath.d.ts.map +1 -1
  98. package/lib/typescript/module/src/Charts/ZoomableLinechart/index.d.ts +1 -1
  99. package/lib/typescript/module/src/Charts/ZoomableLinechart/index.d.ts.map +1 -1
  100. package/lib/typescript/module/src/Charts/gesture.d.ts.map +1 -1
  101. package/lib/typescript/module/src/Charts/graphUtils.d.ts +1 -1
  102. package/lib/typescript/module/src/Charts/graphUtils.d.ts.map +1 -1
  103. package/lib/typescript/module/src/Charts/index.d.ts +1 -1
  104. package/lib/typescript/module/src/Charts/index.d.ts.map +1 -1
  105. package/lib/typescript/module/src/Charts/maths.d.ts +15 -1
  106. package/lib/typescript/module/src/Charts/maths.d.ts.map +1 -1
  107. package/package.json +5 -5
  108. package/src/Charts/BottomAxis/AxisLine.tsx +1 -1
  109. package/src/Charts/BottomAxis/BottomAxis.tsx +1 -1
  110. package/src/Charts/BottomAxis/Tick.tsx +7 -5
  111. package/src/Charts/Cursor.tsx +17 -9
  112. package/src/Charts/Dot.tsx +1 -1
  113. package/src/Charts/Dots.tsx +3 -3
  114. package/src/Charts/Linechart/index.tsx +1 -1
  115. package/src/Charts/YAxis.tsx +43 -36
  116. package/src/Charts/ZoomableLinechart/ScalablePath.tsx +7 -8
  117. package/src/Charts/ZoomableLinechart/index.tsx +1 -1
  118. package/src/Charts/gesture.ts +23 -20
  119. package/src/Charts/graphUtils.ts +75 -16
  120. package/src/Charts/index.tsx +1 -0
  121. package/src/Charts/maths.ts +107 -16
@@ -1,7 +1,7 @@
1
- import { Circle, type PathCommand } from '@shopify/react-native-skia';
2
- import type { FC } from 'react';
1
+ import { Circle, type PathCommand } from 'react-native-skia';
2
+ import { useEffect, type FC } from 'react';
3
3
  import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
4
- import { getYForX } from './maths';
4
+ import { commandsToBezier, getYForXOnBeziers } from './maths';
5
5
 
6
6
  const CURSOR_SIZE = 10;
7
7
 
@@ -27,16 +27,24 @@ const Cursor: FC<CursorProps> = function ({
27
27
  translateY,
28
28
  size = CURSOR_SIZE,
29
29
  }) {
30
- if (currentValue !== undefined && translateY !== undefined) {
31
- console.warn('currentValue has no effect when translateY is set');
32
- }
30
+ const conflictingProps = currentValue !== undefined && translateY !== undefined;
31
+ useEffect(() => {
32
+ if (conflictingProps)
33
+ console.warn('currentValue has no effect when translateY is set');
34
+ }, [conflictingProps]);
33
35
 
34
- const derivedTranslateY = useDerivedValue(() => {
36
+ // Normalized once per path change, not on every cursor move
37
+ const beziers = useDerivedValue(() => {
35
38
  const _commands = commands?.value;
36
- if (_commands === undefined) {
39
+ return _commands === undefined ? undefined : commandsToBezier(_commands);
40
+ });
41
+
42
+ const derivedTranslateY = useDerivedValue(() => {
43
+ const _beziers = beziers.value;
44
+ if (_beziers === undefined) {
37
45
  return 0;
38
46
  }
39
- const _value = getYForX(_commands, positionX.value) ?? 0;
47
+ const _value = getYForXOnBeziers(_beziers, positionX.value) ?? 0;
40
48
  if (translateY === undefined && currentValue !== undefined) {
41
49
  currentValue.value = _value;
42
50
  }
@@ -1,4 +1,4 @@
1
- import { Circle, Group, type Color } from '@shopify/react-native-skia';
1
+ import { Circle, Group, type Color } from 'react-native-skia';
2
2
  import { memo } from 'react';
3
3
  import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
4
4
  import { getPositionWl } from './gesture';
@@ -1,4 +1,4 @@
1
- import { Path, Skia, type Color } from '@shopify/react-native-skia';
1
+ import { Path, Skia, type Color } from 'react-native-skia';
2
2
  import { type FC } from 'react';
3
3
  import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
4
4
  import { getPositionWl } from './gesture';
@@ -43,7 +43,7 @@ const Dots: FC<DotsProps> = function (props) {
43
43
  } = props;
44
44
 
45
45
  const path = useDerivedValue(() => {
46
- const p = Skia.Path.Make();
46
+ const p = Skia.PathBuilder.Make();
47
47
  const margin = r + strokeWidth;
48
48
  for (const dot of dots) {
49
49
  if (dot.opacity.value < 0.5) continue;
@@ -74,7 +74,7 @@ const Dots: FC<DotsProps> = function (props) {
74
74
  p.addCircle(x, y, r);
75
75
  }
76
76
  }
77
- return p;
77
+ return p.build();
78
78
  });
79
79
 
80
80
  return (
@@ -1,4 +1,4 @@
1
- import { Canvas, Group, Path, type SkPath } from '@shopify/react-native-skia';
1
+ import { Canvas, Group, Path, type SkPath } from 'react-native-skia';
2
2
  import type { FC } from 'react';
3
3
  import React from 'react';
4
4
  import type { StyleProp, ViewStyle } from 'react-native';
@@ -5,8 +5,8 @@ import {
5
5
  vec,
6
6
  type Color,
7
7
  type SkFont,
8
- } from '@shopify/react-native-skia';
9
- import { type FC } from 'react';
8
+ } from 'react-native-skia';
9
+ import { memo, useMemo, type FC } from 'react';
10
10
  import { StyleSheet } from 'react-native';
11
11
 
12
12
  export type YAxisProps = {
@@ -52,45 +52,52 @@ const YAxis: FC<YAxisProps> = function (props) {
52
52
  formatLabel = defaultFormatLabel,
53
53
  } = props;
54
54
 
55
- const values =
56
- props.values ??
57
- Array.from(
58
- { length: nbTicks },
59
- (_, i) => minY + ((i + 1) * (maxY - minY)) / nbTicks
60
- );
55
+ // Measured once per input change: parents re-render while panning
56
+ const rows = useMemo(() => {
57
+ // A flat range would divide by zero
58
+ const span = maxY - minY || 1;
59
+ const values =
60
+ props.values ??
61
+ Array.from(
62
+ { length: nbTicks },
63
+ (_, i) => minY + ((i + 1) * (maxY - minY)) / nbTicks
64
+ );
65
+ return values.map((value) => {
66
+ const label = formatLabel(value);
67
+ const labelWidth = font
68
+ .getGlyphWidths(font.getGlyphIDs(label))
69
+ .reduce((a, b) => a + b, 0);
70
+ return {
71
+ label,
72
+ labelWidth,
73
+ transform: [{ translateY: height - ((value - minY) * height) / span }],
74
+ p1: vec(0, 0),
75
+ p2: vec(showLabels ? width - 10 - labelWidth : width, 0),
76
+ };
77
+ });
78
+ }, [props.values, nbTicks, minY, maxY, height, width, font, showLabels, formatLabel]);
61
79
 
62
80
  return (
63
81
  <Group>
64
- {values.map((value, i) => {
65
- const y = height - ((value - minY) * height) / (maxY - minY);
66
- const label = formatLabel(value);
67
- const labelWidth = font
68
- .getGlyphWidths(font.getGlyphIDs(label))
69
- .reduce((a, b) => a + b, 0);
70
- const lineWidth = showLabels ? width - 10 - labelWidth : width;
71
-
72
- return (
73
- <Group key={`YTick-${i}`} transform={[{ translateY: y }]}>
74
- <Line
75
- p1={vec(0, 0)}
76
- p2={vec(lineWidth, 0)}
77
- color={color}
78
- strokeWidth={strokeWidth}
82
+ {rows.map((row, i) => (
83
+ <Group key={`YTick-${i}`} transform={row.transform}>
84
+ <Line p1={row.p1} p2={row.p2} color={color} strokeWidth={strokeWidth} />
85
+ {showLabels ? (
86
+ <Text
87
+ text={row.label}
88
+ x={width - row.labelWidth}
89
+ y={font.getSize() / 2.5}
90
+ font={font}
91
+ color={labelColor ?? color}
79
92
  />
80
- {showLabels ? (
81
- <Text
82
- text={label}
83
- x={width - labelWidth}
84
- y={font.getSize() / 2.5}
85
- font={font}
86
- color={labelColor ?? color}
87
- />
88
- ) : null}
89
- </Group>
90
- );
91
- })}
93
+ ) : null}
94
+ </Group>
95
+ ))}
92
96
  </Group>
93
97
  );
94
98
  };
95
99
 
96
- export { YAxis };
100
+ const MemoYAxis = memo(YAxis);
101
+ MemoYAxis.displayName = 'YAxis';
102
+
103
+ export { MemoYAxis as YAxis };
@@ -1,4 +1,4 @@
1
- import { Path, Skia, type SkPath } from '@shopify/react-native-skia';
1
+ import { Path, Skia, type SkPath } from 'react-native-skia';
2
2
  import { type FC } from 'react';
3
3
  import { useDerivedValue, type SharedValue } from 'react-native-reanimated';
4
4
 
@@ -16,13 +16,12 @@ const ScalablePath: FC<ScalablePathProps> = function (props) {
16
16
 
17
17
  const animatedPath = useDerivedValue(() => {
18
18
  // Affine equivalent of getPositionWl: x' = scale * x + focalX * (1 - scale) + offsetX
19
- const _path = path.value.copy();
20
- _path.transform(
21
- Skia.Matrix()
22
- .translate(focalX.value * (1 - scale.value) + offsetX.value, 0)
23
- .scale(scale.value, 1)
24
- );
25
- return _path;
19
+ // Plain 3x3 matrix: a Skia.Matrix() would allocate a native object per frame
20
+ const s = scale.value;
21
+ const tx = focalX.value * (1 - s) + offsetX.value;
22
+ return Skia.PathBuilder.MakeFromPath(path.value)
23
+ .transform([s, 0, tx, 0, 1, 0, 0, 0, 1])
24
+ .build();
26
25
  });
27
26
 
28
27
  return (
@@ -1,4 +1,4 @@
1
- import { Canvas, Group, type SkPath } from '@shopify/react-native-skia';
1
+ import { Canvas, Group, type SkPath } from 'react-native-skia';
2
2
  import type { FC } from 'react';
3
3
  import React from 'react';
4
4
  import type { StyleProp, ViewStyle } from 'react-native';
@@ -1,4 +1,4 @@
1
- import { clamp } from '@shopify/react-native-skia';
1
+ import { clamp } from 'react-native-skia';
2
2
  import { useCallback, useMemo, useRef } from 'react';
3
3
  import { Gesture, type PanGesture, type PinchGesture } from 'react-native-gesture-handler';
4
4
  import {
@@ -277,25 +277,28 @@ export const useCursorGesture = function (props: UseCursorGestureProps) {
277
277
  const xPosition = useSharedValue(0);
278
278
  const yPosition = useSharedValue(height);
279
279
 
280
- const setPosition = (x: number, y: number) => {
281
- 'worklet';
282
- yPosition.value = clamp(y, 0, height);
283
- if (isContinuous || points === undefined) {
284
- xPosition.value = clamp(x, 0, width);
285
- return;
286
- }
287
- const _closestDot = getClosestPoint(clamp(x, 0, width), points.value);
288
- if (closestDataPoint !== undefined) closestDataPoint.value = _closestDot;
289
- xPosition.value = _closestDot.x;
290
- };
291
-
292
- const panGesture = Gesture.Pan()
293
- .onBegin((event) => setPosition(event.x, event.y))
294
- .onUpdate((event) => setPosition(event.x, event.y));
295
-
296
- const tapGesture = Gesture.Tap().onBegin((event) =>
297
- setPosition(event.x, event.y)
298
- );
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]);
299
302
 
300
303
  // Re-snap the cursor when the data points change (e.g. switching graphs)
301
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,15 +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
  },
202
237
  // Explicit deps: without them the reaction re-registers and re-fires on
203
238
  // every parent render, restarting every dot animation on the UI thread
204
- [currentGraph, path, dataPoints, dots, opacityWl, translateWl]
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]
205
264
  );
206
265
  };
@@ -52,6 +52,7 @@ export {
52
52
  commandsToBezier,
53
53
  cubicBezierYForX,
54
54
  getYForX,
55
+ getYForXOnBeziers,
55
56
  magnitude,
56
57
  normalize,
57
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 =>