react-native-livechart 4.20.0 → 4.21.1

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 (111) hide show
  1. package/dist/components/AxisLabelOverlay.d.ts +5 -1
  2. package/dist/components/AxisLabelOverlay.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +4 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  6. package/dist/components/CustomThresholdBadgeOverlay.d.ts +18 -0
  7. package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -0
  8. package/dist/components/CustomTooltipOverlay.d.ts +4 -2
  9. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  10. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  11. package/dist/components/ExtremaConnectorOverlay.d.ts +5 -1
  12. package/dist/components/ExtremaConnectorOverlay.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts +4 -2
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/ReferenceLineOverlay.d.ts +10 -5
  18. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineSeriesOverlay.d.ts +19 -0
  20. package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -0
  21. package/dist/core/chartDataPresence.d.ts +8 -0
  22. package/dist/core/chartDataPresence.d.ts.map +1 -0
  23. package/dist/core/liveChartEngineTick.d.ts +6 -1
  24. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  25. package/dist/core/resolveConfig.d.ts +32 -1
  26. package/dist/core/resolveConfig.d.ts.map +1 -1
  27. package/dist/core/useLiveChartEngine.d.ts +9 -1
  28. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/candle.d.ts +3 -1
  31. package/dist/draw/candle.d.ts.map +1 -1
  32. package/dist/draw/candleGap.d.ts +14 -0
  33. package/dist/draw/candleGap.d.ts.map +1 -0
  34. package/dist/draw/lineGap.d.ts +25 -0
  35. package/dist/draw/lineGap.d.ts.map +1 -0
  36. package/dist/draw/markerAtlas.d.ts +3 -2
  37. package/dist/draw/markerAtlas.d.ts.map +1 -1
  38. package/dist/hooks/crosshairShared.d.ts +7 -3
  39. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  40. package/dist/hooks/useCandleGapPaths.d.ts +12 -0
  41. package/dist/hooks/useCandleGapPaths.d.ts.map +1 -0
  42. package/dist/hooks/useChartPaths.d.ts +4 -1
  43. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  44. package/dist/hooks/useChartReveal.d.ts +1 -1
  45. package/dist/hooks/useChartReveal.d.ts.map +1 -1
  46. package/dist/hooks/useCrosshair.d.ts +9 -5
  47. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  48. package/dist/hooks/useDegen.d.ts.map +1 -1
  49. package/dist/hooks/useLineGapPaths.d.ts +10 -0
  50. package/dist/hooks/useLineGapPaths.d.ts.map +1 -0
  51. package/dist/hooks/useLiveChartHasData.d.ts +2 -2
  52. package/dist/hooks/useLiveChartHasData.d.ts.map +1 -1
  53. package/dist/hooks/useReferenceLine.d.ts +4 -3
  54. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  55. package/dist/hooks/useReferenceLineSeries.d.ts +23 -0
  56. package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -0
  57. package/dist/hooks/useReverseMorphEngineInputs.d.ts.map +1 -1
  58. package/dist/hooks/useThreshold.d.ts +13 -13
  59. package/dist/hooks/useThreshold.d.ts.map +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/math/candleGaps.d.ts +18 -0
  63. package/dist/math/candleGaps.d.ts.map +1 -0
  64. package/dist/math/referenceLineSeries.d.ts +12 -0
  65. package/dist/math/referenceLineSeries.d.ts.map +1 -0
  66. package/dist/math/referenceLines.d.ts +5 -5
  67. package/dist/math/referenceLines.d.ts.map +1 -1
  68. package/dist/math/spline.d.ts +5 -1
  69. package/dist/math/spline.d.ts.map +1 -1
  70. package/dist/types.d.ts +191 -17
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/components/AxisLabelOverlay.tsx +162 -81
  74. package/src/components/CrosshairOverlay.tsx +394 -147
  75. package/src/components/CustomMarkerOverlay.tsx +9 -6
  76. package/src/components/CustomThresholdBadgeOverlay.tsx +74 -0
  77. package/src/components/CustomTooltipOverlay.tsx +7 -3
  78. package/src/components/DegenParticlesOverlay.tsx +34 -23
  79. package/src/components/ExtremaConnectorOverlay.tsx +43 -3
  80. package/src/components/LiveChart.tsx +1918 -974
  81. package/src/components/LiveChartSeries.tsx +445 -296
  82. package/src/components/MarkerOverlay.tsx +215 -156
  83. package/src/components/ReferenceLineOverlay.tsx +113 -55
  84. package/src/components/ReferenceLineSeriesOverlay.tsx +141 -0
  85. package/src/core/chartDataPresence.ts +24 -0
  86. package/src/core/liveChartEngineTick.ts +34 -1
  87. package/src/core/resolveConfig.ts +186 -0
  88. package/src/core/useLiveChartEngine.ts +36 -1
  89. package/src/core/useLiveChartSeriesEngine.ts +2 -0
  90. package/src/draw/candle.ts +1 -1
  91. package/src/draw/candleGap.ts +132 -0
  92. package/src/draw/lineGap.ts +180 -0
  93. package/src/draw/markerAtlas.ts +8 -3
  94. package/src/hooks/crosshairShared.ts +92 -11
  95. package/src/hooks/useCandleGapPaths.ts +88 -0
  96. package/src/hooks/useChartPaths.ts +81 -22
  97. package/src/hooks/useChartReveal.ts +1 -1
  98. package/src/hooks/useCrosshair.ts +178 -69
  99. package/src/hooks/useDegen.ts +82 -53
  100. package/src/hooks/useLineGapPaths.ts +76 -0
  101. package/src/hooks/useLiveChartHasData.ts +8 -4
  102. package/src/hooks/useReferenceLine.ts +6 -5
  103. package/src/hooks/useReferenceLineSeries.ts +101 -0
  104. package/src/hooks/useReverseMorphEngineInputs.ts +11 -20
  105. package/src/hooks/useThreshold.ts +31 -22
  106. package/src/index.ts +15 -0
  107. package/src/math/candleGaps.ts +120 -0
  108. package/src/math/referenceLineSeries.ts +83 -0
  109. package/src/math/referenceLines.ts +34 -6
  110. package/src/math/spline.ts +15 -5
  111. package/src/types.ts +202 -17
@@ -4,11 +4,10 @@
4
4
  *
5
5
  * @see https://github.com/benjitaylor/liveline
6
6
  */
7
- import { Canvas, Group, Rect } from "@shopify/react-native-skia";
7
+ import { Canvas, Group, Rect, type SkFont } from "@shopify/react-native-skia";
8
8
  import {
9
9
  forwardRef,
10
10
  useImperativeHandle,
11
- useLayoutEffect,
12
11
  useState,
13
12
  } from "react";
14
13
  import { StyleSheet, View } from "react-native";
@@ -28,6 +27,7 @@ import {
28
27
  MAX_MULTI_SERIES,
29
28
  SCRUB_OVERLAY_FADE_MS,
30
29
  } from "../constants";
30
+ import { hasMultiSeriesChartData } from "../core/chartDataPresence";
31
31
  import {
32
32
  lineColorsSignatureFromArray,
33
33
  lineStyleSignatureFromArray,
@@ -146,7 +146,7 @@ function seriesConfigSig(s: SharedValue<SeriesConfig[]>) {
146
146
  * overlay hooks and the color/style reaction state, returning a single render
147
147
  * model so `SeriesChartStack` and `LiveChartSeries` stay small and presentational.
148
148
  */
149
- function useLiveChartSeriesController({
149
+ function resolveLiveChartSeriesInputs({
150
150
  series,
151
151
  theme = "dark",
152
152
  accentColor = DEFAULT_ACCENT_COLOR,
@@ -206,10 +206,6 @@ function useLiveChartSeriesController({
206
206
  renderOffAxisReferenceLine,
207
207
  leftEdgeFade = true,
208
208
  }: LiveChartSeriesProps) {
209
- const fullSeriesOpacity = useSharedValue(1);
210
- const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
211
- const emptyMarkers = useSharedValue<Marker[]>([]);
212
- const markersSV = markers ?? emptyMarkers;
213
209
  const markerClusterCfg = resolveMarkerCluster(markerCluster);
214
210
  const markersActive = markers != null;
215
211
  const yAxisCfg = resolveYAxis(yAxis);
@@ -218,25 +214,13 @@ function useLiveChartSeriesController({
218
214
  const bottomLabelCfg = resolveAxisLabel(bottomLabel);
219
215
  const scrubCfg = resolveScrub(scrub);
220
216
  const scrubEnabled = scrubCfg !== null;
221
- // Opt-in and subordinate to the existing master `tooltip` switch. Keeping
222
- // this null by default preserves LiveChartSeries' historical guide-only scrub.
223
217
  const seriesTooltipCfg =
224
218
  scrubCfg?.tooltip === true ? scrubCfg.seriesTooltip : null;
225
-
226
- // Time-scroll + pinch-zoom (mirrors LiveChart). LiveChartSeries has no static
227
- // mode, so these gate on the props alone. `holdToScrub` requires the scrub
228
- // gesture to wait for a press-and-hold so a quick drag scrolls instead — the
229
- // hold delay is threaded into `useCrosshairSeries` below.
230
219
  const timeScrollEnabled = Boolean(timeScroll);
231
- // Return-to-live glide duration (0 = instant); sibling of `timeScroll` so it
232
- // survives `timeScroll={false}` (the disable that triggers it). See #164.
233
220
  const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
234
- // Overscroll fraction ([0, 1)) — how far pan/zoom may travel past the data
235
- // bounds into blank space. 0 (the default) keeps the classic hard stops.
236
221
  const timeScrollOverscroll = timeScrollEnabled
237
222
  ? resolveOverscroll(timeScroll)
238
223
  : 0;
239
- // Release inertia (fling) — `timeScroll.fling: false` stops the pan dead.
240
224
  const timeScrollFling = resolveFling(timeScroll);
241
225
  const zoomCfg = resolveZoom(zoom);
242
226
  const zoomEnabled = zoomCfg !== null;
@@ -246,22 +230,13 @@ function useLiveChartSeriesController({
246
230
  : "holdToScrub";
247
231
  const timeScrollHoldMs =
248
232
  typeof timeScroll === "object" ? timeScroll.scrubHoldMs : undefined;
249
- // Precedence: explicit scrubHoldMs, then scrub.panGestureDelay, then default.
250
- // `||` (not `??`) skips the resolved panGestureDelay's 0 default.
251
233
  const scrubHoldMs =
252
234
  timeScrollEnabled && scrollGestureMode === "holdToScrub"
253
235
  ? (timeScrollHoldMs ?? (scrubCfg?.panGestureDelay || HOLD_TO_SCRUB_MS))
254
236
  : (scrubCfg?.panGestureDelay ?? 0);
255
-
256
- // Multi-series defaults the scrub selection dot OFF: it can only track one
257
- // line (the leading series), which reads as a bug next to the other series.
258
- // The crosshair line (and the opt-in tooltip's per-series intersection dots)
259
- // mark the scrub point. Passing `selectionDot` explicitly still opts it in.
260
237
  const selectionDotCfg = resolveSelectionDot(selectionDot ?? false);
261
238
  const gridStyleCfg = resolveGridStyle(gridStyle);
262
239
  const dotCfg = resolveMultiSeriesDot(dotProp);
263
- // Outer visible footprint of a dot, including its crisp ring and optional
264
- // blurred glow. Used to keep gutter labels clear of every dot effect.
265
240
  const dotOuterRadius = Math.max(
266
241
  dotCfg.radius + (dotCfg.ring?.width ?? 0),
267
242
  dotCfg.glow ? dotGlowRadialOutset(dotCfg.glow.radius, dotCfg.glow.blur) : 0,
@@ -269,38 +244,228 @@ function useLiveChartSeriesController({
269
244
  const legendCfg = resolveLegend(legendProp);
270
245
  const degenCfg = resolveDegen(degen);
271
246
  const metricsCfg = resolveMetrics(metrics);
272
-
273
247
  const allRefLines = referenceLines ?? [];
274
248
  const refValues = collectReferenceValues(allRefLines);
275
- // Form-A lines a custom renderer owns keep their line stroke but suppress the
276
- // built-in Skia tag. The callback is probed per line on the JS thread, matching
277
- // LiveChart and CustomMarkerOverlay's per-item fallback model.
278
249
  const refLineCustom = customReferenceLineFlags(
279
250
  allRefLines,
280
251
  renderReferenceLine,
281
252
  );
282
- // A full custom tag owns both states, so it takes precedence over an
283
- // off-axis-only renderer for the same line.
284
253
  const refLineOffAxisCustom = customReferenceLineFlags(
285
254
  allRefLines,
286
255
  renderOffAxisReferenceLine,
287
256
  "off-axis",
288
257
  ).map((custom, index) => custom && !refLineCustom[index]);
289
258
  const refLineKeys = referenceLineReactKeys(allRefLines);
290
- // RN custom tags report their measured widths here so the Skia connector can
291
- // start after the native badge instead of the hidden built-in pill.
292
- const refLineCustomTagWidths = useSharedValue<number[]>([]);
293
-
294
259
  const palette = applyPaletteOverride(
295
260
  resolveTheme(accentColor, theme),
296
261
  paletteOverride,
297
262
  );
298
-
299
263
  const leftEdgeFadeCfg = resolveLeftEdgeFade(
300
264
  leftEdgeFade,
301
265
  leftEdgeFadeColorsFromBgRgb(palette.bgRgb),
302
266
  );
303
267
 
268
+ return {
269
+ series,
270
+ lineProp,
271
+ fontProp,
272
+ insets,
273
+ style,
274
+ seriesOpacity,
275
+ canvasMode,
276
+ timeWindow,
277
+ paused,
278
+ loading,
279
+ transitions,
280
+ snapKey,
281
+ smoothing,
282
+ exaggerate,
283
+ nonNegative,
284
+ maxValue,
285
+ yRangeScale,
286
+ windowBuffer,
287
+ nowOverride,
288
+ accessibilityLabel,
289
+ accessibilityRole,
290
+ emptyText,
291
+ formatValue,
292
+ formatTime,
293
+ yAxisCfg,
294
+ xAxisCfg,
295
+ topLabelCfg,
296
+ bottomLabelCfg,
297
+ scrubCfg,
298
+ scrubEnabled,
299
+ seriesTooltipCfg,
300
+ timeScrollEnabled,
301
+ returnToLiveMs,
302
+ timeScrollOverscroll,
303
+ timeScrollFling,
304
+ zoomCfg,
305
+ zoomEnabled,
306
+ scrollGestureMode,
307
+ scrubHoldMs,
308
+ selectionDotCfg,
309
+ gridStyleCfg,
310
+ dotCfg,
311
+ dotOuterRadius,
312
+ legendCfg,
313
+ degenCfg,
314
+ metricsCfg,
315
+ allRefLines,
316
+ refValues,
317
+ refLineCustom,
318
+ refLineOffAxisCustom,
319
+ refLineKeys,
320
+ palette,
321
+ leftEdgeFadeCfg,
322
+ onScrub,
323
+ onGestureStart,
324
+ onGestureEnd,
325
+ onVisibleRangeChange,
326
+ onReachStart,
327
+ onSeriesToggle,
328
+ onDegenShake,
329
+ markers,
330
+ onMarkerPress,
331
+ markerHitRadius,
332
+ markerClusterCfg,
333
+ markersActive,
334
+ renderMarker,
335
+ renderOverlay,
336
+ renderReferenceLine,
337
+ renderOffAxisReferenceLine,
338
+ };
339
+ }
340
+
341
+ function resolveSeriesSnapshotLayout(
342
+ snapshot: SeriesConfig[],
343
+ dot: ReturnType<typeof resolveMultiSeriesDot>,
344
+ dotOuterRadius: number,
345
+ font: SkFont,
346
+ ) {
347
+ const maxLabelWidth = dot.valueLabel
348
+ ? Math.max(
349
+ 0,
350
+ ...snapshot.map((item) =>
351
+ measureFontTextWidth(font, item.label ?? item.id),
352
+ ),
353
+ )
354
+ : 0;
355
+ return {
356
+ maxLabelWidth,
357
+ labelInset: dot.valueLabel ? dotOuterRadius + 8 + maxLabelWidth + 8 : 0,
358
+ representativeValue:
359
+ snapshot.length > 0 ? Math.max(...snapshot.map((item) => item.value)) : 0,
360
+ colors: resolveMultiSeriesLineColorsSnapshot(snapshot),
361
+ styles: resolveMultiSeriesLineStylesSnapshot(snapshot),
362
+ };
363
+ }
364
+
365
+ function composeSeriesRootGesture(
366
+ crosshairGesture: ReturnType<typeof useCrosshairSeries>["gesture"],
367
+ markerTapGesture: ReturnType<typeof useMarkers>["tapGesture"],
368
+ panScrollGesture: ReturnType<typeof usePanScroll>,
369
+ pinchZoomGesture: ReturnType<typeof usePinchZoom>,
370
+ markersActive: boolean,
371
+ timeScrollEnabled: boolean,
372
+ zoomEnabled: boolean,
373
+ scrollGestureMode: "holdToScrub" | "axisDrag",
374
+ ) {
375
+ let gesture = markersActive
376
+ ? Gesture.Race(crosshairGesture, markerTapGesture)
377
+ : crosshairGesture;
378
+ if (timeScrollEnabled) {
379
+ gesture =
380
+ scrollGestureMode === "axisDrag"
381
+ ? Gesture.Exclusive(panScrollGesture, gesture)
382
+ : Gesture.Race(panScrollGesture, gesture);
383
+ }
384
+ return zoomEnabled
385
+ ? Gesture.Simultaneous(gesture, pinchZoomGesture)
386
+ : gesture;
387
+ }
388
+
389
+ function useLiveChartSeriesController(props: LiveChartSeriesProps) {
390
+ const {
391
+ series,
392
+ lineProp,
393
+ fontProp,
394
+ insets,
395
+ style,
396
+ seriesOpacity,
397
+ canvasMode,
398
+ timeWindow,
399
+ paused,
400
+ loading,
401
+ transitions,
402
+ snapKey,
403
+ smoothing,
404
+ exaggerate,
405
+ nonNegative,
406
+ maxValue,
407
+ yRangeScale,
408
+ windowBuffer,
409
+ nowOverride,
410
+ accessibilityLabel,
411
+ accessibilityRole,
412
+ emptyText,
413
+ formatValue,
414
+ formatTime,
415
+ yAxisCfg,
416
+ xAxisCfg,
417
+ topLabelCfg,
418
+ bottomLabelCfg,
419
+ scrubCfg,
420
+ scrubEnabled,
421
+ seriesTooltipCfg,
422
+ timeScrollEnabled,
423
+ returnToLiveMs,
424
+ timeScrollOverscroll,
425
+ timeScrollFling,
426
+ zoomCfg,
427
+ zoomEnabled,
428
+ scrollGestureMode,
429
+ scrubHoldMs,
430
+ selectionDotCfg,
431
+ gridStyleCfg,
432
+ dotCfg,
433
+ dotOuterRadius,
434
+ legendCfg,
435
+ degenCfg,
436
+ metricsCfg,
437
+ allRefLines,
438
+ refValues,
439
+ refLineCustom,
440
+ refLineOffAxisCustom,
441
+ refLineKeys,
442
+ palette,
443
+ leftEdgeFadeCfg,
444
+ onScrub,
445
+ onGestureStart,
446
+ onGestureEnd,
447
+ onVisibleRangeChange,
448
+ onReachStart,
449
+ onSeriesToggle,
450
+ onDegenShake,
451
+ markers,
452
+ onMarkerPress,
453
+ markerHitRadius,
454
+ markerClusterCfg,
455
+ markersActive,
456
+ renderMarker,
457
+ renderOverlay,
458
+ renderReferenceLine,
459
+ renderOffAxisReferenceLine,
460
+ } = resolveLiveChartSeriesInputs(props);
461
+ const fullSeriesOpacity = useSharedValue(1);
462
+ const resolvedSeriesOpacity = seriesOpacity ?? fullSeriesOpacity;
463
+ const emptyMarkers = useSharedValue<Marker[]>([]);
464
+ const markersSV = markers ?? emptyMarkers;
465
+ // RN custom tags report their measured widths here so the Skia connector can
466
+ // start after the native badge instead of the hidden built-in pill.
467
+ const refLineCustomTagWidths = useSharedValue<number[]>([]);
468
+
304
469
  const skiaFont = useChartSkiaFont(
305
470
  fontProp,
306
471
  MONO_FONT_FAMILY,
@@ -308,43 +473,27 @@ function useLiveChartSeriesController({
308
473
  );
309
474
 
310
475
  // Snapshot of the series config (colors, styles, labels) for layout + line
311
- // rendering. Seeded off the render path below and refreshed by the reaction
312
- // further down — reading the `series` SharedValue during render trips
313
- // Reanimated's strict-mode warning. React flushes layout-effect state before
314
- // paint, so the seed causes no flash.
476
+ // rendering. The animated reaction below emits the initial snapshot and every
477
+ // subsequent configuration change without reading the SharedValue in render.
315
478
  const [seriesSnapshot, setSeriesSnapshot] = useState<SeriesConfig[]>([]);
316
- // react-doctor-disable-next-line react-doctor/no-derived-state-effect -- Reanimated: series must be read off the render path
317
- useLayoutEffect(() => {
318
- // `.get()` (not `.value`): React Compiler hoists the `.value` getter read into
319
- // render scope for memoization, which trips Reanimated's strict-mode warning;
320
- // it leaves the `.get()` method call inside the effect.
321
- // react-doctor-disable-next-line react-hooks-js/set-state-in-effect -- Reanimated: seeding from a SharedValue off render is the warning-free path
322
- // eslint-disable-next-line react-hooks/set-state-in-effect -- Reanimated: seed from the SharedValue outside render to avoid strict-mode access warnings
323
- setSeriesSnapshot(series.get().slice());
324
- }, [series]);
325
479
 
326
480
  // Mount per-series drawing worklets only for real series. The previous fixed
327
481
  // 12-slot render kept 144 derived-value mappers alive for the default stroke,
328
482
  // dot, and value-label layers even when a chart contained only one line.
329
483
  const activeSeriesCount = Math.min(seriesSnapshot.length, MAX_MULTI_SERIES);
330
484
 
331
- const maxSeriesLabelWidth = dotCfg.valueLabel
332
- ? Math.max(
333
- 0,
334
- ...seriesSnapshot.map((s) =>
335
- measureFontTextWidth(skiaFont, s.label ?? s.id),
336
- ),
337
- )
338
- : 0;
339
-
340
- const seriesLabelInset = dotCfg.valueLabel
341
- ? dotOuterRadius + 8 + maxSeriesLabelWidth + 8
342
- : 0;
343
-
344
- const representativeValue =
345
- seriesSnapshot.length > 0
346
- ? Math.max(...seriesSnapshot.map((s) => s.value))
347
- : 0;
485
+ const {
486
+ maxLabelWidth: maxSeriesLabelWidth,
487
+ labelInset: seriesLabelInset,
488
+ representativeValue,
489
+ colors: lineColors,
490
+ styles: lineStyles,
491
+ } = resolveSeriesSnapshotLayout(
492
+ seriesSnapshot,
493
+ dotCfg,
494
+ dotOuterRadius,
495
+ skiaFont,
496
+ );
348
497
 
349
498
  const { strokeWidth, padding: effectivePadding } = resolveChartLayout({
350
499
  palette,
@@ -366,11 +515,7 @@ function useLiveChartSeriesController({
366
515
 
367
516
  const hasData = useDerivedValue(() => {
368
517
  "worklet";
369
- const arr = series.value;
370
- for (let i = 0; i < arr.length; i++) {
371
- if (arr[i].data.length >= 2) return true;
372
- }
373
- return false;
518
+ return hasMultiSeriesChartData(series.value);
374
519
  });
375
520
 
376
521
  // Resolve the loading shell: null = not loading, else the styled config.
@@ -422,13 +567,6 @@ function useLiveChartSeriesController({
422
567
  lineProp?.simplify,
423
568
  );
424
569
 
425
- // Per-series colors and stroke styles, derived from the off-render snapshot
426
- // (React state — so no Reanimated read-during-render). The reaction below
427
- // refreshes the snapshot when the series config signature changes. Plain
428
- // derivations — React Compiler memoizes them, so no manual useMemo.
429
- const lineColors = resolveMultiSeriesLineColorsSnapshot(seriesSnapshot);
430
- const lineStyles = resolveMultiSeriesLineStylesSnapshot(seriesSnapshot);
431
-
432
570
  // Read the `series` prop from closure, not a SharedValue passed through
433
571
  // `scheduleOnRN`: the handle serialized across the worklet→JS boundary keeps
434
572
  // the native `.value` accessor but loses the `.get()` method (`.get()` on it
@@ -581,22 +719,16 @@ function useLiveChartSeriesController({
581
719
  onReachStart,
582
720
  });
583
721
 
584
- let rootGesture = markersActive
585
- ? Gesture.Race(crosshair.gesture, markerTapGesture)
586
- : crosshair.gesture;
587
-
588
- // holdToScrub races the scrub (quick drag scrolls; press-hold scrubs);
589
- // axisDrag goes first via Exclusive (fails fast outside the bottom band).
590
- if (timeScrollEnabled) {
591
- rootGesture =
592
- scrollGestureMode === "axisDrag"
593
- ? Gesture.Exclusive(panScrollGesture, rootGesture)
594
- : Gesture.Race(panScrollGesture, rootGesture);
595
- }
596
- // Pinch runs alongside (two-finger, disjoint from the one-finger gestures).
597
- if (zoomEnabled) {
598
- rootGesture = Gesture.Simultaneous(rootGesture, pinchZoomGesture);
599
- }
722
+ const rootGesture = composeSeriesRootGesture(
723
+ crosshair.gesture,
724
+ markerTapGesture,
725
+ panScrollGesture,
726
+ pinchZoomGesture,
727
+ markersActive,
728
+ timeScrollEnabled,
729
+ zoomEnabled,
730
+ scrollGestureMode,
731
+ );
600
732
 
601
733
  const backgroundColor = `rgb(${palette.bgRgb[0]}, ${palette.bgRgb[1]}, ${palette.bgRgb[2]})`;
602
734
 
@@ -656,6 +788,7 @@ function useLiveChartSeriesController({
656
788
  // engine + reveal
657
789
  engine,
658
790
  reveal,
791
+ loadingActive,
659
792
  // loading shell styling (null → not loading)
660
793
  loadingLineColor: loadingCfg?.color,
661
794
  loadingStrokeWidth: loadingCfg?.strokeWidth,
@@ -1037,46 +1170,131 @@ function SeriesTooltipLayer({
1037
1170
  );
1038
1171
  }
1039
1172
 
1040
- export const LiveChartSeries = forwardRef<
1041
- LiveChartHandle,
1042
- LiveChartSeriesProps
1043
- >(function LiveChartSeries(props, ref) {
1044
- const model = useLiveChartSeriesController(props);
1045
- const { viewEnd, viewWindow } = model.engine;
1046
- useImperativeHandle(
1047
- ref,
1048
- () => ({
1049
- resetZoom: () => scheduleOnUI(resetPinchZoom, { viewEnd, viewWindow }),
1050
- }),
1051
- [viewEnd, viewWindow],
1173
+ function SeriesLegend({
1174
+ model,
1175
+ position,
1176
+ }: {
1177
+ model: LiveChartSeriesModel;
1178
+ position: "top" | "bottom";
1179
+ }) {
1180
+ const { legendCfg, series, palette, onSeriesToggle } = model;
1181
+ if (legendCfg.position !== position) return null;
1182
+ return (
1183
+ <SeriesToggleChips
1184
+ series={series}
1185
+ legend={legendCfg}
1186
+ palette={palette}
1187
+ onSeriesToggle={onSeriesToggle}
1188
+ />
1052
1189
  );
1190
+ }
1191
+
1192
+ function SeriesCanvas({ model }: { model: LiveChartSeriesModel }) {
1053
1193
  const {
1054
- rootGesture,
1055
- backgroundColor,
1056
- style,
1057
- onLayout,
1058
- accessibilityLabel,
1059
- accessibilityRole,
1194
+ canvasMode,
1060
1195
  layoutHeight,
1061
- legendCfg,
1062
- series,
1063
- onSeriesToggle,
1064
- leftEdgeFadeCfg,
1065
- effectivePadding,
1066
1196
  engine,
1197
+ backgroundColor,
1198
+ effectivePadding,
1199
+ topConnector,
1200
+ bottomConnector,
1201
+ loadingActive,
1202
+ topLabelCfg,
1203
+ leftEdgeFadeCfg,
1204
+ palette,
1067
1205
  scrubCfg,
1068
1206
  seriesTooltipCfg,
1069
1207
  crosshair,
1070
- palette,
1071
- dotCfg,
1072
- dotOuterRadius,
1073
1208
  selectionDot,
1074
1209
  selectionColor,
1075
- formatValue,
1076
- topLabelCfg,
1077
- bottomLabelCfg,
1078
- topConnector,
1079
- bottomConnector,
1210
+ dotOuterRadius,
1211
+ dotCfg,
1212
+ } = model;
1213
+ const liveDotExtent = Math.max(
1214
+ dotOuterRadius,
1215
+ dotCfg.pulse
1216
+ ? pulseRadialOutset(dotCfg.pulse.maxRadius, dotCfg.pulse.strokeWidth)
1217
+ : 0,
1218
+ );
1219
+ return (
1220
+ <Canvas
1221
+ key={canvasMode}
1222
+ style={{ flex: 1, minHeight: layoutHeight || 1 }}
1223
+ opaque={canvasMode === "opaque"}
1224
+ >
1225
+ {canvasMode === "opaque" ? (
1226
+ <Rect
1227
+ x={0}
1228
+ y={0}
1229
+ width={engine.canvasWidth}
1230
+ height={engine.canvasHeight}
1231
+ color={backgroundColor}
1232
+ />
1233
+ ) : null}
1234
+ <SeriesChartStack model={model} />
1235
+ <ExtremaConnectorOverlay
1236
+ engine={engine}
1237
+ padding={effectivePadding}
1238
+ top={topConnector}
1239
+ bottom={bottomConnector}
1240
+ hideExtrema={loadingActive}
1241
+ suppressBottomWhenCoincident={
1242
+ topLabelCfg?.position === "extrema" ||
1243
+ topLabelCfg?.position === "extrema-edge"
1244
+ }
1245
+ />
1246
+ {leftEdgeFadeCfg ? (
1247
+ <LeftEdgeFade
1248
+ paddingLeft={effectivePadding.left}
1249
+ fadeWidth={leftEdgeFadeCfg.width}
1250
+ startColor={leftEdgeFadeCfg.startColor}
1251
+ endColor={leftEdgeFadeCfg.endColor}
1252
+ engine={engine}
1253
+ opaqueBackgroundRgb={
1254
+ canvasMode === "opaque" ? palette.bgRgb : undefined
1255
+ }
1256
+ />
1257
+ ) : null}
1258
+ <SeriesRefBadgeLayer model={model} />
1259
+ {scrubCfg ? (
1260
+ <CrosshairLine
1261
+ scrubX={crosshair.scrubX}
1262
+ crosshairOpacity={crosshair.crosshairOpacity}
1263
+ engine={engine}
1264
+ padding={effectivePadding}
1265
+ palette={palette}
1266
+ selectionDot={selectionDot}
1267
+ selectionY={crosshair.scrubDotY}
1268
+ scrubActive={crosshair.scrubActive}
1269
+ selectionColor={selectionColor}
1270
+ dimOpacity={scrubCfg.dimOpacity}
1271
+ liveDotExtent={liveDotExtent}
1272
+ crosshairLineColor={
1273
+ seriesTooltipCfg?.guideColor ?? scrubCfg.crosshairLineColor
1274
+ }
1275
+ crosshairStrokeWidth={
1276
+ seriesTooltipCfg?.guideWidth ?? scrubCfg.crosshairStrokeWidth
1277
+ }
1278
+ crosshairOvershoot={scrubCfg.crosshairOvershoot}
1279
+ crosshairFade={scrubCfg.crosshairFade}
1280
+ crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
1281
+ crosshairLineCap={scrubCfg.crosshairLineCap}
1282
+ crosshairDash={
1283
+ seriesTooltipCfg
1284
+ ? seriesTooltipCfg.guideDashPattern
1285
+ : scrubCfg.crosshairDash
1286
+ }
1287
+ crosshairDimColor={scrubCfg.crosshairDimColor}
1288
+ opaqueCanvas={canvasMode === "opaque"}
1289
+ />
1290
+ ) : null}
1291
+ <SeriesValueLabelLayer model={model} />
1292
+ </Canvas>
1293
+ );
1294
+ }
1295
+
1296
+ function SeriesNativeOverlays({ model }: { model: LiveChartSeriesModel }) {
1297
+ const {
1080
1298
  markersActive,
1081
1299
  markersSV,
1082
1300
  markerClusterCfg,
@@ -1089,34 +1307,107 @@ export const LiveChartSeries = forwardRef<
1089
1307
  refLineOffAxisCustom,
1090
1308
  refLineCustomTagWidths,
1091
1309
  overlayScrubFade,
1092
- canvasMode,
1310
+ engine,
1311
+ effectivePadding,
1312
+ series,
1313
+ formatValue,
1314
+ seriesTooltipCfg,
1093
1315
  } = model;
1094
-
1095
- // Mirror the Skia overlay fade onto the RN custom-marker sibling so
1096
- // `scrub.hideOverlaysOnScrub` hides it with the Skia markers.
1097
1316
  const overlayFadeStyle = useAnimatedStyle(() => ({
1098
1317
  opacity: overlayScrubFade.get(),
1099
1318
  }));
1319
+ const hasCustomAnnotations =
1320
+ (markersActive && renderMarker != null) ||
1321
+ ((renderReferenceLine != null || renderOffAxisReferenceLine != null) &&
1322
+ allRefLines.length > 0);
1100
1323
 
1101
- // Extend the scrub dim past the plot's right edge to fully cover the series
1102
- // dots (with their halo) and pulse rings, all centered on that edge. The
1103
- // gutter reserves room beyond this for the value/Y-axis labels, drawn on top.
1104
- const liveDotExtent = Math.max(
1105
- dotOuterRadius,
1106
- dotCfg.pulse
1107
- ? pulseRadialOutset(dotCfg.pulse.maxRadius, dotCfg.pulse.strokeWidth)
1108
- : 0,
1324
+ return (
1325
+ <>
1326
+ {hasCustomAnnotations ? (
1327
+ <Animated.View
1328
+ pointerEvents="box-none"
1329
+ style={[StyleSheet.absoluteFill, overlayFadeStyle]}
1330
+ >
1331
+ {markersActive && renderMarker ? (
1332
+ <CustomMarkerOverlay
1333
+ markers={markersSV}
1334
+ renderMarker={renderMarker}
1335
+ engine={engine}
1336
+ padding={effectivePadding}
1337
+ series={series}
1338
+ cluster={markerClusterCfg}
1339
+ />
1340
+ ) : null}
1341
+ {renderReferenceLine && allRefLines.length > 0 ? (
1342
+ <CustomReferenceLineOverlay
1343
+ lines={allRefLines}
1344
+ renderReferenceLine={renderReferenceLine}
1345
+ custom={refLineCustom}
1346
+ engine={engine}
1347
+ padding={effectivePadding}
1348
+ formatValue={formatValue}
1349
+ tagWidths={refLineCustomTagWidths}
1350
+ />
1351
+ ) : null}
1352
+ {renderOffAxisReferenceLine && allRefLines.length > 0 ? (
1353
+ <CustomReferenceLineOverlay
1354
+ lines={allRefLines}
1355
+ renderReferenceLine={renderOffAxisReferenceLine}
1356
+ custom={refLineOffAxisCustom}
1357
+ engine={engine}
1358
+ padding={effectivePadding}
1359
+ formatValue={formatValue}
1360
+ tagWidths={refLineCustomTagWidths}
1361
+ offAxisOnly
1362
+ />
1363
+ ) : null}
1364
+ </Animated.View>
1365
+ ) : null}
1366
+ {seriesTooltipCfg ? (
1367
+ <View
1368
+ testID="live-chart-series-tooltip-overlay"
1369
+ pointerEvents="none"
1370
+ style={StyleSheet.absoluteFill}
1371
+ >
1372
+ <Canvas style={StyleSheet.absoluteFill}>
1373
+ <SeriesTooltipLayer model={model} config={seriesTooltipCfg} />
1374
+ </Canvas>
1375
+ </View>
1376
+ ) : null}
1377
+ {renderOverlay ? <SeriesCustomConsumerOverlay model={model} /> : null}
1378
+ </>
1109
1379
  );
1380
+ }
1110
1381
 
1111
- const legend =
1112
- legendCfg.position === "top" || legendCfg.position === "bottom" ? (
1113
- <SeriesToggleChips
1114
- series={series}
1115
- legend={legendCfg}
1116
- palette={palette}
1117
- onSeriesToggle={onSeriesToggle}
1118
- />
1119
- ) : null;
1382
+ export const LiveChartSeries = forwardRef<
1383
+ LiveChartHandle,
1384
+ LiveChartSeriesProps
1385
+ >(function LiveChartSeries(props, ref) {
1386
+ const model = useLiveChartSeriesController(props);
1387
+ const { viewEnd, viewWindow } = model.engine;
1388
+ useImperativeHandle(
1389
+ ref,
1390
+ () => ({
1391
+ resetZoom: () => scheduleOnUI(resetPinchZoom, { viewEnd, viewWindow }),
1392
+ }),
1393
+ [viewEnd, viewWindow],
1394
+ );
1395
+ const {
1396
+ rootGesture,
1397
+ backgroundColor,
1398
+ style,
1399
+ onLayout,
1400
+ accessibilityLabel,
1401
+ accessibilityRole,
1402
+ layoutHeight,
1403
+ effectivePadding,
1404
+ engine,
1405
+ palette,
1406
+ formatValue,
1407
+ topLabelCfg,
1408
+ bottomLabelCfg,
1409
+ loadingActive,
1410
+ } = model;
1120
1411
 
1121
1412
  return (
1122
1413
  <View
@@ -1125,92 +1416,14 @@ export const LiveChartSeries = forwardRef<
1125
1416
  accessibilityLabel={accessibilityLabel}
1126
1417
  accessibilityRole={accessibilityRole}
1127
1418
  >
1128
- {legendCfg.position === "top" ? legend : null}
1419
+ <SeriesLegend model={model} position="top" />
1129
1420
  {/* Gesture + layout wrap ONLY the canvas: the legend chips are Pressables
1130
1421
  that must sit outside the scrub gesture to receive taps, and the engine
1131
1422
  canvas height must measure the canvas alone (excluding the legend row),
1132
1423
  else points map into a taller area and the x-axis draws past the edge. */}
1133
1424
  <GestureDetector gesture={rootGesture}>
1134
1425
  <View style={{ flex: 1 }} onLayout={onLayout}>
1135
- {/* Skia chooses TextureView vs SurfaceView when the native Canvas mounts. */}
1136
- <Canvas
1137
- key={canvasMode}
1138
- style={{ flex: 1, minHeight: layoutHeight || 1 }}
1139
- opaque={canvasMode === "opaque"}
1140
- >
1141
- {canvasMode === "opaque" && (
1142
- <Rect
1143
- x={0}
1144
- y={0}
1145
- width={engine.canvasWidth}
1146
- height={engine.canvasHeight}
1147
- color={backgroundColor}
1148
- />
1149
- )}
1150
- <SeriesChartStack model={model} />
1151
-
1152
- {/* "extrema-edge" connector lines (dot → edge readout). Outside the
1153
- stack's degen-shake group so they track the (unshaken) RN dot. */}
1154
- <ExtremaConnectorOverlay
1155
- engine={engine}
1156
- padding={effectivePadding}
1157
- top={topConnector}
1158
- bottom={bottomConnector}
1159
- />
1160
-
1161
- {leftEdgeFadeCfg && (
1162
- <LeftEdgeFade
1163
- paddingLeft={effectivePadding.left}
1164
- fadeWidth={leftEdgeFadeCfg.width}
1165
- startColor={leftEdgeFadeCfg.startColor}
1166
- endColor={leftEdgeFadeCfg.endColor}
1167
- engine={engine}
1168
- opaqueBackgroundRgb={
1169
- canvasMode === "opaque" ? palette.bgRgb : undefined
1170
- }
1171
- />
1172
- )}
1173
-
1174
- {/* Reference-line badges + labels above the fade so they stay crisp. */}
1175
- <SeriesRefBadgeLayer model={model} />
1176
-
1177
- {scrubCfg && (
1178
- <CrosshairLine
1179
- scrubX={crosshair.scrubX}
1180
- crosshairOpacity={crosshair.crosshairOpacity}
1181
- engine={engine}
1182
- padding={effectivePadding}
1183
- palette={palette}
1184
- selectionDot={selectionDot}
1185
- selectionY={crosshair.scrubDotY}
1186
- scrubActive={crosshair.scrubActive}
1187
- selectionColor={selectionColor}
1188
- dimOpacity={scrubCfg.dimOpacity}
1189
- liveDotExtent={liveDotExtent}
1190
- crosshairLineColor={
1191
- seriesTooltipCfg?.guideColor ?? scrubCfg.crosshairLineColor
1192
- }
1193
- crosshairStrokeWidth={
1194
- seriesTooltipCfg?.guideWidth ?? scrubCfg.crosshairStrokeWidth
1195
- }
1196
- crosshairOvershoot={scrubCfg.crosshairOvershoot}
1197
- crosshairFade={scrubCfg.crosshairFade}
1198
- crosshairFadeDistance={scrubCfg.crosshairFadeDistance}
1199
- crosshairLineCap={scrubCfg.crosshairLineCap}
1200
- crosshairDash={
1201
- seriesTooltipCfg
1202
- ? seriesTooltipCfg.guideDashPattern
1203
- : scrubCfg.crosshairDash
1204
- }
1205
- crosshairDimColor={scrubCfg.crosshairDimColor}
1206
- opaqueCanvas={canvasMode === "opaque"}
1207
- />
1208
- )}
1209
-
1210
- {/* Per-series value labels on top of the scrub dim so the dim never
1211
- clips them (they track each series' live value, not the scrub). */}
1212
- <SeriesValueLabelLayer model={model} />
1213
- </Canvas>
1426
+ <SeriesCanvas model={model} />
1214
1427
 
1215
1428
  {/* RN labels floated over the canvas (sibling of <Canvas>, an RN
1216
1429
  view). Inside the canvas wrapper so its top/bottom edges align
@@ -1222,77 +1435,13 @@ export const LiveChartSeries = forwardRef<
1222
1435
  formatValue={formatValue}
1223
1436
  defaultColor={palette.gridLabel}
1224
1437
  padding={effectivePadding}
1438
+ hideExtrema={loadingActive}
1225
1439
  />
1226
1440
 
1227
- {/* Custom annotations — RN views floated over the canvas (non-Skia),
1228
- pinned to their live positions. As a post-Canvas sibling they render
1229
- above the left-edge fade and scrub overlay. The box-none fade wrapper
1230
- keeps `scrub.hideOverlaysOnScrub` behavior aligned with Skia. */}
1231
- {((markersActive && renderMarker) ||
1232
- ((renderReferenceLine || renderOffAxisReferenceLine) &&
1233
- allRefLines.length > 0)) && (
1234
- <Animated.View
1235
- pointerEvents="box-none"
1236
- style={[StyleSheet.absoluteFill, overlayFadeStyle]}
1237
- >
1238
- {markersActive && renderMarker && (
1239
- <CustomMarkerOverlay
1240
- markers={markersSV}
1241
- renderMarker={renderMarker}
1242
- engine={engine}
1243
- padding={effectivePadding}
1244
- series={series}
1245
- cluster={markerClusterCfg}
1246
- />
1247
- )}
1248
- {renderReferenceLine && allRefLines.length > 0 && (
1249
- <CustomReferenceLineOverlay
1250
- lines={allRefLines}
1251
- renderReferenceLine={renderReferenceLine}
1252
- custom={refLineCustom}
1253
- engine={engine}
1254
- padding={effectivePadding}
1255
- formatValue={formatValue}
1256
- tagWidths={refLineCustomTagWidths}
1257
- />
1258
- )}
1259
- {renderOffAxisReferenceLine && allRefLines.length > 0 && (
1260
- <CustomReferenceLineOverlay
1261
- lines={allRefLines}
1262
- renderReferenceLine={renderOffAxisReferenceLine}
1263
- custom={refLineOffAxisCustom}
1264
- engine={engine}
1265
- padding={effectivePadding}
1266
- formatValue={formatValue}
1267
- tagWidths={refLineCustomTagWidths}
1268
- offAxisOnly
1269
- />
1270
- )}
1271
- </Animated.View>
1272
- )}
1273
-
1274
- {/* A dedicated transparent Canvas keeps the UI-thread tooltip above
1275
- custom RN annotations. Rendering it in the primary Canvas would
1276
- put every post-Canvas `renderReferenceLine` sibling above it and
1277
- produce mixed stacking through translucent custom tags. */}
1278
- {seriesTooltipCfg && (
1279
- <View
1280
- testID="live-chart-series-tooltip-overlay"
1281
- pointerEvents="none"
1282
- style={StyleSheet.absoluteFill}
1283
- >
1284
- <Canvas style={StyleSheet.absoluteFill}>
1285
- <SeriesTooltipLayer model={model} config={seriesTooltipCfg} />
1286
- </Canvas>
1287
- </View>
1288
- )}
1289
-
1290
- {/* Custom consumer overlay — topmost RN sibling with the live plot
1291
- rect, including this multi-series chart's resolved axis inset. */}
1292
- {renderOverlay && <SeriesCustomConsumerOverlay model={model} />}
1441
+ <SeriesNativeOverlays model={model} />
1293
1442
  </View>
1294
1443
  </GestureDetector>
1295
- {legendCfg.position === "bottom" ? legend : null}
1444
+ <SeriesLegend model={model} position="bottom" />
1296
1445
  </View>
1297
1446
  );
1298
1447
  });