react-native-livechart 4.22.2 → 4.24.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 (62) hide show
  1. package/dist/components/CrosshairLine.d.ts +3 -2
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +3 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/DotOverlay.d.ts +8 -1
  6. package/dist/components/DotOverlay.d.ts.map +1 -1
  7. package/dist/components/LiveChart.d.ts.map +1 -1
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/SelectionDot.d.ts +6 -3
  10. package/dist/components/SelectionDot.d.ts.map +1 -1
  11. package/dist/components/YAxisOverlay.d.ts +3 -1
  12. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  13. package/dist/core/liveChartEngineTick.d.ts +13 -0
  14. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  15. package/dist/core/resolveConfig.d.ts +2 -1
  16. package/dist/core/resolveConfig.d.ts.map +1 -1
  17. package/dist/core/useLiveChartEngine.d.ts +5 -1
  18. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  19. package/dist/hooks/useBadge.d.ts.map +1 -1
  20. package/dist/hooks/useCandlePaths.d.ts +3 -1
  21. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  22. package/dist/hooks/useChartColors.d.ts +5 -3
  23. package/dist/hooks/useChartColors.d.ts.map +1 -1
  24. package/dist/hooks/useDegen.d.ts +1 -1
  25. package/dist/hooks/useDegen.d.ts.map +1 -1
  26. package/dist/hooks/useMarkers.d.ts +3 -1
  27. package/dist/hooks/useMarkers.d.ts.map +1 -1
  28. package/dist/hooks/useReferenceDrag.d.ts.map +1 -1
  29. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  30. package/dist/hooks/useTradeStream.d.ts +3 -1
  31. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/lib/measureFontTextWidth.d.ts +10 -1
  35. package/dist/lib/measureFontTextWidth.d.ts.map +1 -1
  36. package/dist/math/referenceDrag.d.ts +9 -0
  37. package/dist/math/referenceDrag.d.ts.map +1 -1
  38. package/dist/types.d.ts +53 -6
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/components/CrosshairLine.tsx +4 -2
  42. package/src/components/CrosshairOverlay.tsx +5 -3
  43. package/src/components/DotOverlay.tsx +36 -1
  44. package/src/components/LiveChart.tsx +39 -6
  45. package/src/components/LiveChartSeries.tsx +51 -23
  46. package/src/components/SelectionDot.tsx +7 -3
  47. package/src/components/YAxisOverlay.tsx +16 -10
  48. package/src/core/liveChartEngineTick.ts +99 -6
  49. package/src/core/resolveConfig.ts +3 -1
  50. package/src/core/useLiveChartEngine.ts +114 -41
  51. package/src/hooks/useBadge.ts +7 -2
  52. package/src/hooks/useCandlePaths.ts +10 -8
  53. package/src/hooks/useChartColors.ts +27 -13
  54. package/src/hooks/useDegen.ts +2 -0
  55. package/src/hooks/useMarkers.ts +3 -0
  56. package/src/hooks/useReferenceDrag.ts +12 -7
  57. package/src/hooks/useReferenceLine.ts +30 -4
  58. package/src/hooks/useTradeStream.ts +3 -0
  59. package/src/index.ts +1 -0
  60. package/src/lib/measureFontTextWidth.ts +26 -2
  61. package/src/math/referenceDrag.ts +19 -0
  62. package/src/types.ts +54 -6
@@ -1,3 +1,4 @@
1
+ import type { SharedValue } from "react-native-reanimated";
1
2
  import type { ResolvedGradientConfig } from "../core/resolveConfig";
2
3
  import type { ChartPadding } from "../draw/line";
3
4
  import { parseColorRgb } from "../theme";
@@ -12,9 +13,10 @@ export interface ChartColors {
12
13
  gradientTopColor: string;
13
14
  /** Bottom gradient stop — custom opacity or palette default. */
14
15
  gradientBottomColor: string;
15
- /** Gradient color stops (top → bottom). Custom `colors` when provided, else the
16
- * 2-stop `[gradientTopColor, gradientBottomColor]` fallback. */
17
- gradientColors: string[];
16
+ /** Gradient color stops (top → bottom). Custom `colors` when provided (static
17
+ * or animated), else the 2-stop `[gradientTopColor, gradientBottomColor]`
18
+ * fallback. */
19
+ gradientColors: string[] | SharedValue<string[]>;
18
20
  /** Stop positions matching `gradientColors`, or undefined for even spacing. */
19
21
  gradientPositions: number[] | undefined;
20
22
  }
@@ -45,16 +47,28 @@ export function useChartColors(
45
47
  : palette.fillBottom;
46
48
 
47
49
  const customColors = gradientCfg?.colors;
48
- const hasCustomColors = Array.isArray(customColors) && customColors.length >= 2;
49
- const gradientColors = hasCustomColors
50
- ? customColors
51
- : [gradientTopColor, gradientBottomColor];
52
- const gradientPositions =
53
- hasCustomColors &&
54
- gradientCfg?.positions !== undefined &&
55
- gradientCfg.positions.length === customColors.length
56
- ? gradientCfg.positions
57
- : undefined;
50
+ const positions = gradientCfg?.positions;
51
+ const staticColors = Array.isArray(customColors) ? customColors : undefined;
52
+ const hasStaticColors = staticColors !== undefined && staticColors.length >= 2;
53
+ // An animated `colors` can't be measured on the JS thread without reading it
54
+ // during render, so it's trusted to hold ≥2 stops and to match `positions`.
55
+ const isAnimatedColors = customColors !== undefined && staticColors === undefined;
56
+
57
+ let gradientColors: string[] | SharedValue<string[]> = [
58
+ gradientTopColor,
59
+ gradientBottomColor,
60
+ ];
61
+ let gradientPositions: number[] | undefined;
62
+ if (isAnimatedColors) {
63
+ gradientColors = customColors;
64
+ gradientPositions = positions;
65
+ } else if (hasStaticColors) {
66
+ gradientColors = staticColors;
67
+ gradientPositions =
68
+ positions !== undefined && positions.length === staticColors.length
69
+ ? positions
70
+ : undefined;
71
+ }
58
72
 
59
73
  return {
60
74
  backgroundColor,
@@ -46,6 +46,7 @@ export function useDegen(
46
46
  momentumSV: SharedValue<Momentum>,
47
47
  cfg: ResolvedDegenConfig | null,
48
48
  onShake?: (payload: DegenShakePayload) => void,
49
+ isFrameLoopActive?: SharedValue<boolean>,
49
50
  ): {
50
51
  pack: SharedValue<Float64Array<ArrayBuffer>>;
51
52
  packRevision: SharedValue<number>;
@@ -186,6 +187,7 @@ export function useDegen(
186
187
  useFrameCallback(
187
188
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
188
189
  "worklet";
190
+ if (isFrameLoopActive?.get() === false) return;
189
191
  const now = engine.timestamp.get();
190
192
  const buf = pack.get();
191
193
  const slots = slotCountSV.get();
@@ -55,6 +55,8 @@ export function useMarkers(
55
55
  lineLinear = false,
56
56
  /** Collision config; default `"anchored"` (no stacking). */
57
57
  cluster: ResolvedMarkerCluster = ANCHORED_CLUSTER,
58
+ /** Runtime gate shared with the chart engine. */
59
+ isFrameLoopActive?: SharedValue<boolean>,
58
60
  ): {
59
61
  projected: SharedValue<ProjectedMarker[]>;
60
62
  tapGesture: ReturnType<typeof Gesture.Tap>;
@@ -80,6 +82,7 @@ export function useMarkers(
80
82
  const markerFrameCallback = useFrameCallback(
81
83
  /* istanbul ignore next -- worklet runs on UI thread, not in Jest */ () => {
82
84
  "worklet";
85
+ if (isFrameLoopActive?.get() === false) return;
83
86
  const cache = cacheRef.current!;
84
87
  if (!active) {
85
88
  if (projected.get().length > 0) projected.set([]);
@@ -12,6 +12,7 @@ import type { ChartPadding } from "../draw/line";
12
12
  import {
13
13
  clampToBounds,
14
14
  nearestDraggableIndex,
15
+ referenceDragOwnsTouch,
15
16
  referenceValueOut,
16
17
  resolveDragIntent,
17
18
  } from "../math/referenceDrag";
@@ -257,17 +258,21 @@ export function useReferenceDrag(
257
258
  activated.set(false);
258
259
  };
259
260
 
260
- // Geometric hit-test shared with the scrub gesture: is (x,y) within reach of a
261
- // draggable line's handle? The scrub's `onStart` consults this to bail on a
262
- // press over a line, so it never drops a crosshair there even though it activates
263
- // independently of this manual-activation pan (the `Exclusive` priority alone
264
- // doesn't hold scrub back while this gesture is merely pressed). x is unused —
265
- // a Form-A line spans the full width, so only the y-reach matters.
261
+ // Hit-test shared with the scrub gesture: does this drag own the touch? The
262
+ // scrub's `onStart` consults it to bail, so it never drops a crosshair on a
263
+ // line even though it activates independently of this manual-activation pan
264
+ // (the `Exclusive` priority alone doesn't hold scrub back — not while this
265
+ // gesture is merely pressed, and not while it is dragging either). Once a line
266
+ // is grabbed the answer is yes regardless of position: the scrub asks with its
267
+ // touch-DOWN point, which a longer drag has carried the line away from, so the
268
+ // geometric test alone said "no line here" and a crosshair opened mid-drag.
269
+ // Otherwise it is the y-reach around the handles (x is unused — a Form-A line
270
+ // spans the full width).
266
271
  /* istanbul ignore next -- worklet, runs on the UI thread */
267
272
  const hitTest = (_x: number, y: number): boolean => {
268
273
  "worklet";
269
274
  if (!anyDraggable) return false;
270
- return nearestDraggableIndex(handleYs.get(), y, GRAB_SLOP) >= 0;
275
+ return referenceDragOwnsTouch(dragIndex.get(), handleYs.get(), y, GRAB_SLOP);
271
276
  };
272
277
 
273
278
  const gesture = Gesture.Pan()
@@ -1,4 +1,5 @@
1
1
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
2
+ import { useRef } from "react";
2
3
 
3
4
  import type { SkFont } from "@shopify/react-native-skia";
4
5
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
@@ -7,7 +8,10 @@ import {
7
8
  type YAxisEntry,
8
9
  } from "../draw/grid";
9
10
  import type { ChartPadding } from "../draw/line";
10
- import { measureFontTextWidth } from "../lib/measureFontTextWidth";
11
+ import {
12
+ measureFontTextWidth,
13
+ type TextWidthCache,
14
+ } from "../lib/measureFontTextWidth";
11
15
  import {
12
16
  classifyReferenceEdge,
13
17
  referenceLineForm,
@@ -120,6 +124,7 @@ function badgeGeometry(
120
124
  x1: number,
121
125
  x2: number,
122
126
  font: SkFont,
127
+ textWidthCache?: TextWidthCache,
123
128
  ): BadgeGeometry {
124
129
  "worklet";
125
130
  // Measure the icon's visual bounds (not just width): `SkiaText` draws from the
@@ -129,7 +134,9 @@ function badgeGeometry(
129
134
  // price-pill centering in computeActionBadgeLayout.
130
135
  const iconBounds = icon ? font.measureText(icon) : null;
131
136
  const iconW = iconBounds ? iconBounds.width : 0;
132
- const textW = text ? measureFontTextWidth(font, text) : 0;
137
+ const textW = text
138
+ ? measureFontTextWidth(font, text, textWidthCache)
139
+ : 0;
133
140
 
134
141
  let contentW = 0;
135
142
  let count = 0;
@@ -310,6 +317,7 @@ export function useReferenceLine(
310
317
  const form = line ? referenceLineForm(line) : "none";
311
318
  // Badge presentation depends only on the (stable) line props — resolve once.
312
319
  const badge = line ? resolveReferenceBadge(line) : null;
320
+ const textWidthCacheRef = useRef<TextWidthCache>({});
313
321
 
314
322
  return useDerivedValue<ReferenceLineLayout>(() => {
315
323
  if (!line || form === "none") return INVISIBLE;
@@ -447,7 +455,16 @@ export function useReferenceLine(
447
455
  ? chartTop + OFF_AXIS_EDGE_INSET
448
456
  : chartBottom - OFF_AXIS_EDGE_INSET;
449
457
  const text = referenceBadgeText(line, badge, v, formatValue, true);
450
- const g = badgeGeometry(badge.position, badge.icon, text, true, x1, x2, font);
458
+ const g = badgeGeometry(
459
+ badge.position,
460
+ badge.icon,
461
+ text,
462
+ true,
463
+ x1,
464
+ x2,
465
+ font,
466
+ textWidthCacheRef.current,
467
+ );
451
468
  return {
452
469
  ...INVISIBLE,
453
470
  visible: true,
@@ -476,7 +493,16 @@ export function useReferenceLine(
476
493
  // In-range pill badge (the `badge` config, not the legacy off-axis-only flag).
477
494
  if (badge && badge.inRange) {
478
495
  const text = referenceBadgeText(line, badge, v, formatValue, false);
479
- const g = badgeGeometry(badge.position, badge.icon, text, false, x1, x2, font);
496
+ const g = badgeGeometry(
497
+ badge.position,
498
+ badge.icon,
499
+ text,
500
+ false,
501
+ x1,
502
+ x2,
503
+ font,
504
+ textWidthCacheRef.current,
505
+ );
480
506
  return {
481
507
  ...INVISIBLE,
482
508
  visible: true,
@@ -28,6 +28,8 @@ export function useTradeStream(
28
28
  active: boolean,
29
29
  /** Static charts run no loops: register without starting. Default `true`. */
30
30
  autostart = true,
31
+ /** Runtime gate shared with the chart engine. */
32
+ isFrameLoopActive?: SharedValue<boolean>,
31
33
  ): SharedValue<TradeMarker[]> {
32
34
  const markers = useSharedValue<TradeMarker[]>([]);
33
35
  const state = useSharedValue<TradeStreamState>(createTradeStreamState());
@@ -37,6 +39,7 @@ export function useTradeStream(
37
39
  frameInfo,
38
40
  ) => {
39
41
  "worklet";
42
+ if (isFrameLoopActive?.get() === false) return;
40
43
  if (!active) {
41
44
  if (markers.get().length > 0) markers.set([]);
42
45
  return;
package/src/index.ts CHANGED
@@ -75,6 +75,7 @@ export type {
75
75
  LegendStyle,
76
76
  LineConfig,
77
77
  LiveChartCoreProps,
78
+ LiveChartFrameStats,
78
79
  LiveChartHandle,
79
80
  LiveChartMetrics,
80
81
  LiveChartMetricsOverride,
@@ -1,10 +1,34 @@
1
1
  import type { SkFont } from "@shopify/react-native-skia";
2
2
 
3
+ /** Caller-owned last-value cache for repeated UI-thread text measurement. */
4
+ export interface TextWidthCache {
5
+ /** Font used for the cached measurement. */
6
+ font?: SkFont;
7
+ /** Text used for the cached measurement. */
8
+ text?: string;
9
+ /** Cached width. */
10
+ width?: number;
11
+ }
12
+
3
13
  /**
4
14
  * Horizontal layout width for `text` via Skia `measureText` (replaces deprecated `getTextWidth`).
5
15
  * Marked worklet for use inside Reanimated `useDerivedValue`.
6
16
  */
7
- export function measureFontTextWidth(font: SkFont, text: string): number {
17
+ export function measureFontTextWidth(
18
+ font: SkFont,
19
+ text: string,
20
+ cache?: TextWidthCache,
21
+ ): number {
8
22
  "worklet";
9
- return font.measureText(text).width;
23
+ if (cache?.font === font && cache.text === text && cache.width !== undefined) {
24
+ return cache.width;
25
+ }
26
+
27
+ const width = font.measureText(text).width;
28
+ if (cache) {
29
+ cache.font = font;
30
+ cache.text = text;
31
+ cache.width = width;
32
+ }
33
+ return width;
10
34
  }
@@ -43,6 +43,25 @@ export function nearestDraggableIndex(
43
43
  return best;
44
44
  }
45
45
 
46
+ /**
47
+ * Whether the reference-drag gesture owns the current touch — the answer the
48
+ * scrub's activation guard needs. A **grabbed** line (`dragIndex >= 0`, pressed
49
+ * or already dragging) owns the touch outright, wherever the asking gesture says
50
+ * the finger is: the scrub pan can activate mid-drag and asks with its
51
+ * touch-DOWN point, which a longer drag has carried the line away from. With no
52
+ * line grabbed, it is the geometric reach test around the handles.
53
+ */
54
+ export function referenceDragOwnsTouch(
55
+ dragIndex: number,
56
+ handleYs: number[],
57
+ y: number,
58
+ slop: number,
59
+ ): boolean {
60
+ "worklet";
61
+ if (dragIndex >= 0) return true;
62
+ return nearestDraggableIndex(handleYs, y, slop) >= 0;
63
+ }
64
+
46
65
  /**
47
66
  * Resolve a grabbed line's drag intent from the touch travel (px) since the grab.
48
67
  * Once a line is grabbed it **owns** the touch: any drag past `threshold` px in
package/src/types.ts CHANGED
@@ -16,6 +16,16 @@ export interface LiveChartPoint {
16
16
  value: number;
17
17
  }
18
18
 
19
+ /** Development counters for continuous engine-frame state updates. */
20
+ export interface LiveChartFrameStats {
21
+ /** Frame callbacks that ran while the chart was live. */
22
+ frames: number;
23
+ /** Frames that changed at least one tracked engine value. */
24
+ published: number;
25
+ /** Frames whose tracked engine values were unchanged. */
26
+ skipped: number;
27
+ }
28
+
19
29
  /** Direction of recent price movement, used for dot/badge coloring and degen effects. */
20
30
  export type Momentum = "up" | "down" | "flat";
21
31
 
@@ -457,8 +467,18 @@ export interface LineConfig {
457
467
  * noise reduction.
458
468
  */
459
469
  simplify?: number;
460
- /** Line color override. Defaults to palette-derived accent. */
461
- color?: string;
470
+ /**
471
+ * Line color override. Defaults to palette-derived accent.
472
+ *
473
+ * Pass a `SharedValue<string>` to animate the stroke color on the UI thread
474
+ * (for example with `interpolateColor`) without re-rendering. The animated
475
+ * color reaches the stroke and the built-in scrub selection dot. The parts
476
+ * that derive from the line color at render time (a custom
477
+ * `selectionDot.component`'s `color`, the segment base color, the area-dot
478
+ * tint, and the threshold split stroke's rest color) use the palette line
479
+ * color instead.
480
+ */
481
+ color?: string | SharedValue<string>;
462
482
  /**
463
483
  * Two or more CSS color strings → horizontal gradient along the stroke
464
484
  * (left → right). Takes precedence over `color` when set.
@@ -628,10 +648,18 @@ export interface GradientConfig {
628
648
  topOpacity?: number;
629
649
  /** Opacity at the bottom of the gradient. Default `0`. */
630
650
  bottomOpacity?: number;
631
- /** Explicit gradient color stops (top → bottom) for the area fill. Overrides
632
- * topOpacity/bottomOpacity when provided. Must have at least 2 entries. */
633
- colors?: string[];
634
- /** Optional stop positions (0..1, ascending) matching `colors` length. */
651
+ /**
652
+ * Explicit gradient color stops (top → bottom) for the area fill. Overrides
653
+ * topOpacity/bottomOpacity when provided. Must have at least 2 entries.
654
+ *
655
+ * Pass a `SharedValue<string[]>` to animate the stops on the UI thread
656
+ * without re-rendering. Keep at least 2 entries in it at all times.
657
+ */
658
+ colors?: string[] | SharedValue<string[]>;
659
+ /**
660
+ * Optional stop positions (0..1, ascending) matching `colors` length. With
661
+ * animated `colors`, the positions apply as given, so keep the lengths equal.
662
+ */
635
663
  positions?: number[];
636
664
  }
637
665
 
@@ -707,6 +735,9 @@ export interface VolumeConfig {
707
735
 
708
736
  /** Y-axis grid configuration. */
709
737
  export interface YAxisConfig {
738
+ /** Price-label side. For left placement, reserve the label gutter with insets.left.
739
+ * Right-side float/labelRightMargin options do not affect left labels. Default "right". */
740
+ side?: "left" | "right";
710
741
  /** Minimum pixel gap between grid lines. Default `36`. */
711
742
  minGap?: number;
712
743
  /**
@@ -2664,6 +2695,23 @@ export interface LiveChartProps extends LiveChartCoreProps {
2664
2695
  * switching back to live restarts the suspended loops and catches up. Frame the data
2665
2696
  * with `timeWindow` + `nowOverride` (see the historical-data-fill pattern). */
2666
2697
  static?: boolean;
2698
+ /**
2699
+ * Runtime gate for continuous frame work. Set this SharedValue to `false`
2700
+ * while a parent scroll gesture needs the UI thread; data keeps updating and
2701
+ * the chart catches up when it becomes `true`. Unlike {@link static}, this
2702
+ * keeps pan, zoom, scrub, and draggable reference-line gestures mounted.
2703
+ *
2704
+ * @experimental
2705
+ */
2706
+ isFrameLoopActive?: SharedValue<boolean>;
2707
+ /**
2708
+ * Optional development counter for engine frames that did or did not change
2709
+ * tracked engine values. It is not a count of Skia redraws. Supplying it adds
2710
+ * one SharedValue write per active engine frame.
2711
+ *
2712
+ * @experimental
2713
+ */
2714
+ debugFrameStats?: SharedValue<LiveChartFrameStats>;
2667
2715
  /**
2668
2716
  * Render a custom overlay floated over the chart canvas, handed a price↔pixel /
2669
2717
  * time↔pixel {@link ChartOverlayContext} so it can track the auto-rescaling axis