react-native-livechart 4.22.2 → 4.23.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 (50) hide show
  1. package/dist/components/DotOverlay.d.ts +8 -1
  2. package/dist/components/DotOverlay.d.ts.map +1 -1
  3. package/dist/components/LiveChart.d.ts.map +1 -1
  4. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  5. package/dist/components/YAxisOverlay.d.ts +3 -1
  6. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  7. package/dist/core/liveChartEngineTick.d.ts +13 -0
  8. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  9. package/dist/core/resolveConfig.d.ts +1 -0
  10. package/dist/core/resolveConfig.d.ts.map +1 -1
  11. package/dist/core/useLiveChartEngine.d.ts +5 -1
  12. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  13. package/dist/hooks/useBadge.d.ts.map +1 -1
  14. package/dist/hooks/useCandlePaths.d.ts +3 -1
  15. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  16. package/dist/hooks/useDegen.d.ts +1 -1
  17. package/dist/hooks/useDegen.d.ts.map +1 -1
  18. package/dist/hooks/useMarkers.d.ts +3 -1
  19. package/dist/hooks/useMarkers.d.ts.map +1 -1
  20. package/dist/hooks/useReferenceDrag.d.ts.map +1 -1
  21. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  22. package/dist/hooks/useTradeStream.d.ts +3 -1
  23. package/dist/hooks/useTradeStream.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/lib/measureFontTextWidth.d.ts +10 -1
  27. package/dist/lib/measureFontTextWidth.d.ts.map +1 -1
  28. package/dist/math/referenceDrag.d.ts +9 -0
  29. package/dist/math/referenceDrag.d.ts.map +1 -1
  30. package/dist/types.d.ts +29 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/components/DotOverlay.tsx +36 -1
  34. package/src/components/LiveChart.tsx +26 -3
  35. package/src/components/LiveChartSeries.tsx +51 -23
  36. package/src/components/YAxisOverlay.tsx +16 -10
  37. package/src/core/liveChartEngineTick.ts +99 -6
  38. package/src/core/resolveConfig.ts +2 -0
  39. package/src/core/useLiveChartEngine.ts +108 -41
  40. package/src/hooks/useBadge.ts +7 -2
  41. package/src/hooks/useCandlePaths.ts +10 -8
  42. package/src/hooks/useDegen.ts +2 -0
  43. package/src/hooks/useMarkers.ts +3 -0
  44. package/src/hooks/useReferenceDrag.ts +12 -7
  45. package/src/hooks/useReferenceLine.ts +30 -4
  46. package/src/hooks/useTradeStream.ts +3 -0
  47. package/src/index.ts +1 -0
  48. package/src/lib/measureFontTextWidth.ts +26 -2
  49. package/src/math/referenceDrag.ts +19 -0
  50. package/src/types.ts +30 -0
@@ -41,6 +41,10 @@ export interface EngineTickMutable {
41
41
  extremaMaxTime: number;
42
42
  /** Previous frame's yRangeScale — detects an in-flight scale drag. */
43
43
  lastYRangeScale?: number;
44
+ /** Canvas width observed by the previous measured tick. */
45
+ lastCanvasWidth?: number;
46
+ /** Canvas height observed by the previous measured tick. */
47
+ lastCanvasHeight?: number;
44
48
  }
45
49
 
46
50
  export interface EngineTickInput {
@@ -142,6 +146,40 @@ export interface EngineTickInput {
142
146
  candleGapBridgeUnknown?: boolean;
143
147
  }
144
148
 
149
+ const SUBPIXEL_SETTLE_THRESHOLD = 0.5;
150
+
151
+ /**
152
+ * Lerps toward a target, then lands exactly once the remaining distance is
153
+ * smaller than half a rendered pixel.
154
+ */
155
+ export function lerpAndSettle(
156
+ current: number,
157
+ target: number,
158
+ speed: number,
159
+ dt: number,
160
+ unitsPerPixel: number,
161
+ ): number {
162
+ "worklet";
163
+ const next = lerp(current, target, speed, dt);
164
+ const epsilon = Math.abs(unitsPerPixel) * SUBPIXEL_SETTLE_THRESHOLD;
165
+ return epsilon > 0 && Math.abs(target - next) <= epsilon ? target : next;
166
+ }
167
+
168
+ /**
169
+ * Advances a live timestamp only after the chart has moved by half a pixel.
170
+ */
171
+ export function advanceTimestampByPixel(
172
+ current: number,
173
+ target: number,
174
+ secondsPerPixel: number,
175
+ ): number {
176
+ "worklet";
177
+ const step = Math.abs(secondsPerPixel) * SUBPIXEL_SETTLE_THRESHOLD;
178
+ if (!(step > 0) || !Number.isFinite(step) || target <= current) return target;
179
+ const elapsedSteps = Math.floor((target - current) / step);
180
+ return elapsedSteps > 0 ? current + elapsedSteps * step : current;
181
+ }
182
+
145
183
  /**
146
184
  * One frame of the live chart engine (mirrors `useLiveChartEngine` worklet body).
147
185
  * Mutates `state` in place for testability and reuse from the hook.
@@ -153,7 +191,6 @@ export function tickLiveChartEngineFrame(
153
191
  "worklet";
154
192
  const baseNow = input.nowOverride ?? input.nowSeconds ?? Date.now() / 1000;
155
193
  const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
156
- state.liveEdge = liveEdge;
157
194
  const viewEnd = input.viewEnd;
158
195
  // First time of the visible series' data — the floor a frozen edge must stay
159
196
  // at or above. `-Infinity` when there's no data to bound against, so the freeze
@@ -177,6 +214,17 @@ export function tickLiveChartEngineFrame(
177
214
  viewEnd != null &&
178
215
  viewEnd >= firstDataTime &&
179
216
  (viewEnd < liveEdge || input.allowFutureViewEnd === true);
217
+ const canvasChanged =
218
+ state.lastCanvasWidth !== input.canvasWidth ||
219
+ state.lastCanvasHeight !== input.canvasHeight;
220
+ state.liveEdge =
221
+ !canvasChanged && input.canvasWidth > 0 && input.timeWindow > 0
222
+ ? advanceTimestampByPixel(
223
+ state.liveEdge,
224
+ liveEdge,
225
+ input.timeWindow / input.canvasWidth,
226
+ )
227
+ : liveEdge;
180
228
  if (scrolledBack) {
181
229
  state.timestamp = viewEnd;
182
230
  } else if (!input.paused) {
@@ -188,6 +236,16 @@ export function tickLiveChartEngineFrame(
188
236
  if (returnT != null && returnT < 1 && input.returnFrom != null) {
189
237
  state.timestamp =
190
238
  input.returnFrom + (liveEdge - input.returnFrom) * returnT;
239
+ } else if (
240
+ !canvasChanged &&
241
+ input.canvasWidth > 0 &&
242
+ input.timeWindow > 0
243
+ ) {
244
+ state.timestamp = advanceTimestampByPixel(
245
+ state.timestamp,
246
+ liveEdge,
247
+ input.timeWindow / input.canvasWidth,
248
+ );
191
249
  } else {
192
250
  state.timestamp = liveEdge;
193
251
  }
@@ -195,6 +253,8 @@ export function tickLiveChartEngineFrame(
195
253
  // else: paused with no active pan → leave the frozen timestamp untouched.
196
254
 
197
255
  if (input.canvasWidth === 0 || input.canvasHeight === 0) return;
256
+ state.lastCanvasWidth = input.canvasWidth;
257
+ state.lastCanvasHeight = input.canvasHeight;
198
258
 
199
259
  const speed = input.smoothing;
200
260
  // One-shot settle (snapKey change): collapse this frame's easing so the
@@ -214,16 +274,30 @@ export function tickLiveChartEngineFrame(
214
274
  (1 - gapRatio) *
215
275
  (input.adaptiveSpeedBoost ?? MOTION_METRICS_DEFAULTS.adaptiveSpeedBoost);
216
276
 
277
+ const valueUnitsPerPixel =
278
+ (state.displayMax - state.displayMin) / input.canvasHeight;
217
279
  state.displayValue = snap
218
280
  ? target
219
- : lerp(state.displayValue, target, adaptiveSpeed, input.dt);
281
+ : lerpAndSettle(
282
+ state.displayValue,
283
+ target,
284
+ adaptiveSpeed,
285
+ input.dt,
286
+ valueUnitsPerPixel,
287
+ );
220
288
 
221
289
  // Pinch-zoom: ease toward the zoom override when set, else the configured
222
290
  // window. Mirrors the viewEnd freeze above (width vs. right edge).
223
291
  const targetWindow = input.viewWindow ?? input.timeWindow;
224
292
  state.displayWindow = snap
225
293
  ? targetWindow
226
- : lerp(state.displayWindow, targetWindow, speed, input.dt);
294
+ : lerpAndSettle(
295
+ state.displayWindow,
296
+ targetWindow,
297
+ speed,
298
+ input.dt,
299
+ targetWindow / input.canvasWidth,
300
+ );
227
301
 
228
302
  const winStart = state.timestamp - state.displayWindow;
229
303
 
@@ -437,16 +511,29 @@ export function tickLiveChartEngineFrame(
437
511
  const maxV = input.maxValue;
438
512
  if (maxV !== undefined && tMax > maxV) tMax = maxV;
439
513
 
514
+ const rangeUnitsPerPixel = (tMax - tMin) / input.canvasHeight;
440
515
  if (snap || yScaleDragging || tMin < state.displayMin) {
441
516
  state.displayMin = tMin;
442
517
  } else {
443
- state.displayMin = lerp(state.displayMin, tMin, speed, input.dt);
518
+ state.displayMin = lerpAndSettle(
519
+ state.displayMin,
520
+ tMin,
521
+ speed,
522
+ input.dt,
523
+ rangeUnitsPerPixel,
524
+ );
444
525
  }
445
526
 
446
527
  if (snap || yScaleDragging || tMax > state.displayMax) {
447
528
  state.displayMax = tMax;
448
529
  } else {
449
- state.displayMax = lerp(state.displayMax, tMax, speed, input.dt);
530
+ state.displayMax = lerpAndSettle(
531
+ state.displayMax,
532
+ tMax,
533
+ speed,
534
+ input.dt,
535
+ rangeUnitsPerPixel,
536
+ );
450
537
  }
451
538
  }
452
539
 
@@ -485,6 +572,12 @@ export function tickLiveChartEngineFrame(
485
572
  }
486
573
  state.edgeValue = snap
487
574
  ? edgeTarget
488
- : lerp(state.edgeValue, edgeTarget, speed, input.dt);
575
+ : lerpAndSettle(
576
+ state.edgeValue,
577
+ edgeTarget,
578
+ speed,
579
+ input.dt,
580
+ valueUnitsPerPixel,
581
+ );
489
582
  }
490
583
  }
@@ -94,6 +94,7 @@ export interface ResolvedBadgeConfig {
94
94
  }
95
95
 
96
96
  export interface ResolvedYAxisConfig {
97
+ side: "left" | "right";
97
98
  minGap: number;
98
99
  /** Multiplier used to choose and align representable dynamic nice intervals. */
99
100
  intervalScale: number;
@@ -531,6 +532,7 @@ export function resolveBadge(
531
532
  }
532
533
 
533
534
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
535
+ side: "right",
534
536
  minGap: 36,
535
537
  intervalScale: 1,
536
538
  count: 0,
@@ -20,6 +20,7 @@ import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
20
20
  import type {
21
21
  CandleGap,
22
22
  CandlePoint,
23
+ LiveChartFrameStats,
23
24
  LiveChartPoint,
24
25
  SeriesConfig,
25
26
  } from "../types";
@@ -93,6 +94,10 @@ export interface EngineConfig {
93
94
  * {@link LiveChartProps.static}.
94
95
  */
95
96
  static?: boolean;
97
+ /** Runtime gate for the frame loop. Gestures and configuration stay mounted. */
98
+ isFrameLoopActive?: SharedValue<boolean>;
99
+ /** Optional development-only frame publication counters. */
100
+ debugFrameStats?: SharedValue<LiveChartFrameStats>;
96
101
  /**
97
102
  * Opaque key that snaps the framing to its target in one frame whenever it
98
103
  * changes (the next tick bypasses `smoothing` for the window, Y-range, and
@@ -282,6 +287,8 @@ export function makeEngineFrameScratch(): EngineFrameScratch {
282
287
  extremaMaxValue: NaN,
283
288
  extremaMinTime: NaN,
284
289
  extremaMaxTime: NaN,
290
+ lastCanvasWidth: undefined,
291
+ lastCanvasHeight: undefined,
285
292
  },
286
293
  input: {
287
294
  dt: MS_PER_FRAME_60FPS,
@@ -323,6 +330,8 @@ export function applyLiveChartEngineFrame(
323
330
  extremaMaxValue: NaN,
324
331
  extremaMinTime: NaN,
325
332
  extremaMaxTime: NaN,
333
+ lastCanvasWidth: undefined,
334
+ lastCanvasHeight: undefined,
326
335
  };
327
336
  state.displayValue = sv.displayValue.value;
328
337
  state.displayMin = sv.displayMin.value;
@@ -408,6 +417,40 @@ export function applyLiveChartEngineFrame(
408
417
  export function useLiveChartEngine(
409
418
  config: EngineConfig,
410
419
  ): SingleEngineState & ChartEngineScroll & ChartEngineEdge {
420
+ const {
421
+ data,
422
+ value,
423
+ timeWindow: configuredTimeWindow,
424
+ smoothing: configuredSmoothing,
425
+ adaptiveSpeedBoost,
426
+ exaggerate,
427
+ referenceValue: configuredReferenceValue,
428
+ referenceValues: configuredReferenceValues,
429
+ liveReferenceValues,
430
+ thresholdRangePoints: configuredThresholdRangePoints,
431
+ thresholdRangeExtendToNow: configuredThresholdRangeExtendToNow,
432
+ thresholdRangeExtendToStart: configuredThresholdRangeExtendToStart,
433
+ nonNegative,
434
+ maxValue,
435
+ yRangeScale,
436
+ nowOverride,
437
+ windowBuffer,
438
+ paused,
439
+ scrollEnabled,
440
+ allowFutureViewEnd,
441
+ returnToLiveMs,
442
+ static: isStatic,
443
+ isFrameLoopActive,
444
+ debugFrameStats,
445
+ snapKey,
446
+ mode,
447
+ candles,
448
+ liveCandle,
449
+ candleGaps,
450
+ candleGapBridgeNoTrades,
451
+ candleGapBridgeUnavailable,
452
+ candleGapBridgeUnknown,
453
+ } = config;
411
454
  // Pinch-zoom window-width override (null = follow the configured window).
412
455
  // Declared first so `timeWindow` below can fold it in. Defaults to null so
413
456
  // charts without `zoom` behave exactly as before.
@@ -418,26 +461,28 @@ export function useLiveChartEngine(
418
461
  // the new prop forever, and prop-driven window changes would silently no-op.
419
462
  useEffect(() => {
420
463
  viewWindow.set(null);
421
- }, [config.timeWindow, viewWindow]);
464
+ }, [configuredTimeWindow, viewWindow]);
422
465
 
423
466
  // Low-frequency config → UI thread via useDerivedValue. `timeWindow` is the
424
467
  // *effective* target window: the zoom override when set, else the prop. Both
425
468
  // the tick's window lerp and the X-axis tick selection read it, so zoom flows
426
469
  // downstream for free (mirrors how viewEnd drives `timestamp`).
427
- const timeWindow = useDerivedValue(() => viewWindow.value ?? config.timeWindow);
470
+ const timeWindow = useDerivedValue(
471
+ () => viewWindow.value ?? configuredTimeWindow,
472
+ );
428
473
  // Static charts snap to their target in one tick (smoothing=1), so the single
429
474
  // settle reaction below produces the final state with no per-frame easing.
430
475
  const smoothing = useDerivedValue(() =>
431
- config.static ? 1 : config.smoothing,
476
+ isStatic ? 1 : configuredSmoothing,
432
477
  );
433
- const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
434
- const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
435
- const referenceValue = useDerivedValue(() => config.referenceValue);
478
+ const adaptiveSpeedBoostSV = useDerivedValue(() => adaptiveSpeedBoost);
479
+ const exaggerateSV = useDerivedValue(() => exaggerate ?? false);
480
+ const referenceValue = useDerivedValue(() => configuredReferenceValue);
436
481
  // Captured directly (not via `config.`) so Reanimated tracks the SharedValue and
437
482
  // the merge re-runs each frame while a line is dragged; stable when idle.
438
- const liveReferenceValuesSV = config.liveReferenceValues;
483
+ const liveReferenceValuesSV = liveReferenceValues;
439
484
  const referenceValues = useDerivedValue(() => {
440
- const base = config.referenceValues;
485
+ const base = configuredReferenceValues;
441
486
  const live = liveReferenceValuesSV?.value;
442
487
  if (!live || live.length === 0) return base;
443
488
  return base && base.length > 0 ? base.concat(live) : live;
@@ -446,56 +491,56 @@ export function useLiveChartEngine(
446
491
  // directly (not via `config.`) like `liveReferenceValues`, so the live
447
492
  // `threshold.series` SharedValue is tracked and the tick sees fresh points.
448
493
  const thresholdRangeSourceSV =
449
- config.thresholdRangePoints !== undefined &&
450
- !Array.isArray(config.thresholdRangePoints)
451
- ? config.thresholdRangePoints
494
+ configuredThresholdRangePoints !== undefined &&
495
+ !Array.isArray(configuredThresholdRangePoints)
496
+ ? configuredThresholdRangePoints
452
497
  : null;
453
498
  const thresholdRangePoints = useDerivedValue<LiveChartPoint[] | undefined>(
454
499
  () =>
455
500
  thresholdRangeSourceSV
456
501
  ? thresholdRangeSourceSV.value
457
- : (config.thresholdRangePoints as LiveChartPoint[] | undefined),
502
+ : (configuredThresholdRangePoints as LiveChartPoint[] | undefined),
458
503
  );
459
504
  const thresholdRangeExtendToNow = useDerivedValue(
460
- () => config.thresholdRangeExtendToNow ?? true,
505
+ () => configuredThresholdRangeExtendToNow ?? true,
461
506
  );
462
507
  const thresholdRangeExtendToStart = useDerivedValue(
463
- () => config.thresholdRangeExtendToStart ?? true,
508
+ () => configuredThresholdRangeExtendToStart ?? true,
464
509
  );
465
- const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
466
- const maxValueSV = useDerivedValue(() => config.maxValue);
467
- const nowOverrideSV = useDerivedValue(() => config.nowOverride);
468
- const windowBufferSV = useDerivedValue(() => config.windowBuffer ?? 0);
469
- const pausedSV = useDerivedValue(() => config.paused ?? false);
510
+ const nonNegativeSV = useDerivedValue(() => nonNegative ?? false);
511
+ const maxValueSV = useDerivedValue(() => maxValue);
512
+ const nowOverrideSV = useDerivedValue(() => nowOverride);
513
+ const windowBufferSV = useDerivedValue(() => windowBuffer ?? 0);
514
+ const pausedSV = useDerivedValue(() => paused ?? false);
470
515
  // Whether time-scroll is active. Drives the return-to-live reaction below
471
516
  // (the tick no longer reads it — clearing `viewEnd` is what makes it follow).
472
517
  // Defaults to enabled so a caller that omits it behaves as before.
473
- const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
518
+ const scrollEnabledSV = useDerivedValue(() => scrollEnabled ?? true);
474
519
  // Honor a viewEnd parked past the live edge (timeScroll.overscroll).
475
520
  const allowFutureViewEndSV = useDerivedValue(
476
- () => config.allowFutureViewEnd ?? false,
521
+ () => allowFutureViewEnd ?? false,
477
522
  );
478
523
  // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
479
524
  const returnToLiveMsSV = useDerivedValue(
480
- () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
525
+ () => returnToLiveMs ?? RETURN_TO_LIVE_MS,
481
526
  );
482
- const modeSV = useDerivedValue(() => config.mode ?? "line");
483
- const candleGapsSV = useDerivedValue(() => config.candleGaps);
527
+ const modeSV = useDerivedValue(() => mode ?? "line");
528
+ const candleGapsSV = useDerivedValue(() => candleGaps);
484
529
  const candleGapBridgeNoTradesSV = useDerivedValue(
485
- () => config.candleGapBridgeNoTrades ?? false,
530
+ () => candleGapBridgeNoTrades ?? false,
486
531
  );
487
532
  const candleGapBridgeUnavailableSV = useDerivedValue(
488
- () => config.candleGapBridgeUnavailable ?? false,
533
+ () => candleGapBridgeUnavailable ?? false,
489
534
  );
490
535
  const candleGapBridgeUnknownSV = useDerivedValue(
491
- () => config.candleGapBridgeUnknown ?? false,
536
+ () => candleGapBridgeUnknown ?? false,
492
537
  );
493
538
 
494
539
  // Animation state (mutated on UI thread each frame)
495
540
  const displayValue = useSharedValue(0);
496
541
  const displayMin = useSharedValue(0);
497
542
  const displayMax = useSharedValue(1);
498
- const displayWindow = useSharedValue(config.timeWindow);
543
+ const displayWindow = useSharedValue(configuredTimeWindow);
499
544
  const canvasWidth = useSharedValue(0);
500
545
  const canvasHeight = useSharedValue(0);
501
546
  // Seed once; overwritten by the frame callback on the first tick.
@@ -525,8 +570,6 @@ export function useLiveChartEngine(
525
570
 
526
571
  // High-frequency data reads directly from the caller's shared values —
527
572
  // no useDerivedValue bridging, no closure serialization per tick.
528
- const { data, value, candles, liveCandle } = config;
529
-
530
573
  // One-shot "snap the framing" flag. The effect below flips it to `true` when
531
574
  // `snapKey` changes; the next frame consumes it (bypassing `smoothing` for the
532
575
  // window / range / value) and clears it, so a timeframe / dataset switch lands
@@ -534,20 +577,20 @@ export function useLiveChartEngine(
534
577
  const snapSV = useSharedValue(false);
535
578
  // Compare against the previous key (not a "first render" flag) so React 18
536
579
  // StrictMode's double-invoked mount effect can't fire a spurious snap.
537
- const lastSnapKey = useRef(config.snapKey);
580
+ const lastSnapKey = useRef(snapKey);
538
581
  // Layout effect, not passive: a passive effect runs after the commit has
539
582
  // painted, so the frame between them draws the new data/candleWidth against
540
583
  // the OLD framing (a one-frame squeeze on a timeframe switch). Flipping the
541
584
  // flag before paint lets the first frame with the new props consume the snap.
542
585
  useLayoutEffect(() => {
543
- if (config.snapKey === lastSnapKey.current) return;
544
- lastSnapKey.current = config.snapKey;
586
+ if (snapKey === lastSnapKey.current) return;
587
+ lastSnapKey.current = snapKey;
545
588
  snapSV.set(true);
546
- }, [config.snapKey, snapSV]);
589
+ }, [snapKey, snapSV]);
547
590
 
548
591
  // Static charts run zero per-frame loops. `isStaticSV` gates both the
549
592
  // frame-callback autostart and the one-shot settle reaction below.
550
- const isStaticSV = useDerivedValue(() => config.static ?? false);
593
+ const isStaticSV = useDerivedValue(() => isStatic ?? false);
551
594
 
552
595
  // Single refs object shared by the frame callback and the settle reaction so
553
596
  // they can never drift out of sync. Keep its identity stable across unrelated
@@ -576,7 +619,7 @@ export function useLiveChartEngine(
576
619
  thresholdRangeExtendToNow,
577
620
  nonNegativeSV,
578
621
  maxValueSV,
579
- yRangeScaleSV: config.yRangeScale,
622
+ yRangeScaleSV: yRangeScale,
580
623
  nowOverrideSV,
581
624
  windowBufferSV,
582
625
  pausedSV,
@@ -610,7 +653,6 @@ export function useLiveChartEngine(
610
653
  candles,
611
654
  canvasHeight,
612
655
  canvasWidth,
613
- config.yRangeScale,
614
656
  data,
615
657
  displayMax,
616
658
  displayMin,
@@ -638,12 +680,13 @@ export function useLiveChartEngine(
638
680
  thresholdRangeExtendToNow,
639
681
  thresholdRangeExtendToStart,
640
682
  thresholdRangePoints,
641
- timestamp,
642
683
  timeWindow,
684
+ timestamp,
643
685
  value,
644
686
  viewEnd,
645
687
  viewWindow,
646
688
  windowBufferSV,
689
+ yRangeScale,
647
690
  ],
648
691
  );
649
692
  const frameScratchRef = useRef<EngineFrameScratch | null>(null);
@@ -657,13 +700,37 @@ export function useLiveChartEngine(
657
700
  // loop is fully inert in static mode (the invariant that makes this worth it).
658
701
  const engineFrameCallback = useFrameCallback((frameInfo) => {
659
702
  "worklet";
703
+ if (isFrameLoopActive?.get() === false) return;
704
+ const beforeValue = debugFrameStats ? displayValue.get() : 0;
705
+ const beforeMin = debugFrameStats ? displayMin.get() : 0;
706
+ const beforeMax = debugFrameStats ? displayMax.get() : 0;
707
+ const beforeWindow = debugFrameStats ? displayWindow.get() : 0;
708
+ const beforeTimestamp = debugFrameStats ? timestamp.get() : 0;
709
+ const beforeLiveEdge = debugFrameStats ? liveEdge.get() : 0;
710
+ const beforeEdgeValue = debugFrameStats ? edgeValue.get() : 0;
660
711
  applyLiveChartEngineFrame(frameInfo, frameRefs, frameScratchRef.current!);
661
- }, !config.static);
712
+ if (debugFrameStats) {
713
+ const didPublish =
714
+ beforeValue !== displayValue.get() ||
715
+ beforeMin !== displayMin.get() ||
716
+ beforeMax !== displayMax.get() ||
717
+ beforeWindow !== displayWindow.get() ||
718
+ beforeTimestamp !== timestamp.get() ||
719
+ beforeLiveEdge !== liveEdge.get() ||
720
+ beforeEdgeValue !== edgeValue.get();
721
+ const previous = debugFrameStats.get();
722
+ debugFrameStats.set({
723
+ frames: previous.frames + 1,
724
+ published: previous.published + (didPublish ? 1 : 0),
725
+ skipped: previous.skipped + (didPublish ? 0 : 1),
726
+ });
727
+ }
728
+ }, !isStatic);
662
729
  useEffect(() => {
663
730
  // Reanimated only seeds `isActive` from `autostart` on mount. Keep the
664
731
  // imperative state aligned when a chart switches between static and live.
665
- engineFrameCallback.setActive(!config.static);
666
- }, [config.static, engineFrameCallback]);
732
+ engineFrameCallback.setActive(!isStatic);
733
+ }, [isStatic, engineFrameCallback]);
667
734
 
668
735
  // When time-scroll is disabled while scrolled back, return the window to the
669
736
  // live edge. With a positive duration this glides: snapshot the frozen edge into
@@ -1,4 +1,5 @@
1
1
  import { type SkFont } from "@shopify/react-native-skia";
2
+ import { useRef } from "react";
2
3
  import {
3
4
  useDerivedValue,
4
5
  useSharedValue,
@@ -9,7 +10,10 @@ import {
9
10
  MOTION_METRICS_DEFAULTS,
10
11
  MS_PER_FRAME_60FPS,
11
12
  } from "../constants";
12
- import { measureFontTextWidth } from "../lib/measureFontTextWidth";
13
+ import {
14
+ measureFontTextWidth,
15
+ type TextWidthCache,
16
+ } from "../lib/measureFontTextWidth";
13
17
  import type { ChartEngineWithLiveValue } from "../core/useLiveChartEngine";
14
18
  import {
15
19
  badgeTailAndCap,
@@ -64,6 +68,7 @@ export function useBadge(
64
68
  const colorR = useSharedValue(0);
65
69
  const colorG = useSharedValue(0);
66
70
  const colorB = useSharedValue(0);
71
+ const textWidthCacheRef = useRef<TextWidthCache>({});
67
72
 
68
73
  const upRgb = hexToRgb(palette.dotUp);
69
74
  const downRgb = hexToRgb(palette.dotDown);
@@ -103,7 +108,7 @@ export function useBadge(
103
108
  : padding.top + ((dMax - liveVal) / valRange) * chartH;
104
109
 
105
110
  const text = formatValue(liveVal);
106
- const textW = measureFontTextWidth(font, text);
111
+ const textW = measureFontTextWidth(font, text, textWidthCacheRef.current);
107
112
 
108
113
  const pillH = font.getSize() + badgeMetrics.padY * 2;
109
114
  // `midY` is the pill's vertical center (the tail anchors here); `capR` is the
@@ -38,6 +38,8 @@ export function useCandleWidthLerp(
38
38
  autostart: boolean,
39
39
  /** Only advance the displayed width while candle mode is active. */
40
40
  active: boolean,
41
+ /** Runtime gate shared with the chart engine. */
42
+ isFrameLoopActive?: SharedValue<boolean>,
41
43
  ): SharedValue<number> {
42
44
  const targetCandleWidth = useDerivedValue(() => candleWidthSecs);
43
45
  const displayCandleWidth = useSharedValue(candleWidthSecs);
@@ -51,16 +53,16 @@ export function useCandleWidthLerp(
51
53
 
52
54
  const widthFrameCallback = useFrameCallback((frameInfo) => {
53
55
  "worklet";
54
- if (!active) return;
56
+ if (!active || isFrameLoopActive?.get() === false) return;
55
57
  const dt = frameInfo.timeSincePreviousFrame ?? MS_PER_FRAME_60FPS;
56
- displayCandleWidth.set(
57
- lerp(
58
- displayCandleWidth.get(),
59
- targetCandleWidth.get(),
60
- widthLerpSpeed.get(),
61
- dt,
62
- ),
58
+ const target = targetCandleWidth.get();
59
+ const next = lerp(
60
+ displayCandleWidth.get(),
61
+ target,
62
+ widthLerpSpeed.get(),
63
+ dt,
63
64
  );
65
+ displayCandleWidth.set(Math.abs(target - next) < 0.01 ? target : next);
64
66
  }, autostart);
65
67
  useEffect(() => {
66
68
  // `autostart` is an initial seed in Reanimated, not a reactive switch.
@@ -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()