react-native-livechart 4.10.0 → 4.11.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 (73) hide show
  1. package/README.md +24 -0
  2. package/dist/components/CrosshairLine.d.ts +3 -1
  3. package/dist/components/CrosshairLine.d.ts.map +1 -1
  4. package/dist/components/CrosshairOverlay.d.ts +3 -1
  5. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  6. package/dist/components/CustomReferenceLineOverlay.d.ts +4 -2
  7. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.d.ts.map +1 -1
  9. package/dist/components/LeftEdgeFade.d.ts +3 -1
  10. package/dist/components/LeftEdgeFade.d.ts.map +1 -1
  11. package/dist/components/LiveChart.d.ts.map +1 -1
  12. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  13. package/dist/components/LoadingOverlay.d.ts +3 -1
  14. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  15. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  16. package/dist/components/MultiSeriesDots.d.ts +3 -1
  17. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  18. package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
  19. package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
  20. package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
  21. package/dist/components/MultiSeriesValueLines.d.ts +3 -1
  22. package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
  23. package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
  24. package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
  25. package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
  26. package/dist/core/useLiveChartEngine.d.ts +9 -1
  27. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  28. package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
  29. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  30. package/dist/draw/particleAtlas.d.ts +3 -2
  31. package/dist/draw/particleAtlas.d.ts.map +1 -1
  32. package/dist/hooks/crosshairShared.d.ts +4 -0
  33. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  34. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  35. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  36. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  37. package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
  38. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/math/squiggly.d.ts +6 -3
  42. package/dist/math/squiggly.d.ts.map +1 -1
  43. package/dist/math/threshold.d.ts +2 -2
  44. package/dist/types.d.ts +28 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +1 -1
  47. package/src/components/CrosshairLine.tsx +17 -0
  48. package/src/components/CrosshairOverlay.tsx +48 -35
  49. package/src/components/CustomReferenceLineOverlay.tsx +16 -7
  50. package/src/components/DegenParticlesOverlay.tsx +40 -5
  51. package/src/components/LeftEdgeFade.tsx +12 -6
  52. package/src/components/LiveChart.tsx +29 -16
  53. package/src/components/LiveChartSeries.tsx +94 -21
  54. package/src/components/LoadingOverlay.tsx +26 -12
  55. package/src/components/MarkerOverlay.tsx +20 -3
  56. package/src/components/MultiSeriesDots.tsx +4 -2
  57. package/src/components/MultiSeriesTooltipStack.tsx +4 -2
  58. package/src/components/MultiSeriesValueLabels.tsx +4 -2
  59. package/src/components/MultiSeriesValueLines.tsx +4 -2
  60. package/src/components/ThresholdSplitShader.tsx +5 -37
  61. package/src/components/thresholdSplitShaderSource.ts +70 -0
  62. package/src/core/useLiveChartEngine.ts +110 -42
  63. package/src/core/useLiveChartSeriesEngine.ts +137 -70
  64. package/src/draw/particleAtlas.ts +21 -10
  65. package/src/hooks/crosshairShared.ts +5 -0
  66. package/src/hooks/useChartPaths.ts +11 -0
  67. package/src/hooks/useCrosshair.ts +16 -10
  68. package/src/hooks/useCrosshairSeries.ts +11 -6
  69. package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
  70. package/src/index.ts +1 -0
  71. package/src/math/squiggly.ts +28 -11
  72. package/src/math/threshold.ts +2 -2
  73. package/src/types.ts +33 -3
@@ -1,10 +1,4 @@
1
- import {
2
- useEffect,
3
- useLayoutEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
2
  import { StyleSheet, View } from "react-native";
9
3
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
10
4
  import Animated, {
@@ -27,6 +21,7 @@ import {
27
21
  Group,
28
22
  LinearGradient,
29
23
  Path,
24
+ Rect,
30
25
  vec,
31
26
  } from "@shopify/react-native-skia";
32
27
 
@@ -253,6 +248,7 @@ function useLiveChartController({
253
248
  font: fontProp,
254
249
  insets,
255
250
  style,
251
+ canvasMode = "transparent",
256
252
 
257
253
  // ── Candlestick ─────────────────────────────────────────────────────────
258
254
  mode = "line",
@@ -1204,6 +1200,7 @@ function useLiveChartController({
1204
1200
  return {
1205
1201
  // passthrough props the render needs
1206
1202
  style,
1203
+ canvasMode,
1207
1204
  accessibilityLabel,
1208
1205
  accessibilityRole,
1209
1206
  emptyText,
@@ -1362,9 +1359,7 @@ function ChartWithDegen({
1362
1359
  onDegenShake,
1363
1360
  isStatic,
1364
1361
  );
1365
- return (
1366
- <ChartView model={model} yAxisEntries={yAxisEntries} degen={state} />
1367
- );
1362
+ return <ChartView model={model} yAxisEntries={yAxisEntries} degen={state} />;
1368
1363
  }
1369
1364
 
1370
1365
  /**
@@ -1721,6 +1716,7 @@ function ChartStack({
1721
1716
  loadingStrokeWidth,
1722
1717
  loadingAmplitude,
1723
1718
  loadingSpeed,
1719
+ canvasMode,
1724
1720
  } = model;
1725
1721
  return (
1726
1722
  <Group transform={degen?.shakeTransform}>
@@ -1939,6 +1935,7 @@ function ChartStack({
1939
1935
  lineStrokeWidth={loadingStrokeWidth}
1940
1936
  waveAmplitude={loadingAmplitude}
1941
1937
  waveSpeed={loadingSpeed}
1938
+ opaqueCanvas={canvasMode === "opaque"}
1942
1939
  />
1943
1940
  </Group>
1944
1941
  );
@@ -2003,6 +2000,7 @@ function ChartScrubLayer({
2003
2000
  selectionDot,
2004
2001
  selectionColor,
2005
2002
  renderTooltip,
2003
+ canvasMode,
2006
2004
  } = model;
2007
2005
  // A custom tooltip is an RN overlay (sibling of <Canvas>), so the built-in
2008
2006
  // Skia tooltip is suppressed here while it's active — the line pill in line
@@ -2047,6 +2045,7 @@ function ChartScrubLayer({
2047
2045
  tooltipBorderRadius={scrubCfg.tooltipBorderRadius}
2048
2046
  tooltipShowValue={scrubCfg.tooltipShowValue}
2049
2047
  tooltipShowTime={scrubCfg.tooltipShowTime}
2048
+ opaqueCanvas={canvasMode === "opaque"}
2050
2049
  >
2051
2050
  {/* Candle charts render a multi-line OHLC tooltip; the line
2052
2051
  chart falls back to CrosshairOverlay's default value/time
@@ -2364,6 +2363,7 @@ function ChartView({
2364
2363
  extremaTimeOffset,
2365
2364
  topConnector,
2366
2365
  bottomConnector,
2366
+ canvasMode,
2367
2367
  } = model;
2368
2368
 
2369
2369
  return (
@@ -2375,7 +2375,21 @@ function ChartView({
2375
2375
  accessibilityLabel={accessibilityLabel}
2376
2376
  accessibilityRole={accessibilityRole}
2377
2377
  >
2378
- <Canvas style={{ flex: 1 }}>
2378
+ {/* Skia chooses TextureView vs SurfaceView when the native Canvas mounts. */}
2379
+ <Canvas
2380
+ key={canvasMode}
2381
+ style={{ flex: 1 }}
2382
+ opaque={canvasMode === "opaque"}
2383
+ >
2384
+ {canvasMode === "opaque" && (
2385
+ <Rect
2386
+ x={0}
2387
+ y={0}
2388
+ width={engine.canvasWidth}
2389
+ height={engine.canvasHeight}
2390
+ color={backgroundColor}
2391
+ />
2392
+ )}
2379
2393
  {/* Background fills first, then the left-edge fade (a canvas-space sibling
2380
2394
  so dstOut blends correctly), then the line stack on top — so the fade
2381
2395
  softens only the fills and the line stays crisp at the left edge. */}
@@ -2392,15 +2406,14 @@ function ChartView({
2392
2406
  startColor={leftEdgeFadeCfg.startColor}
2393
2407
  endColor={leftEdgeFadeCfg.endColor}
2394
2408
  engine={engine}
2409
+ opaqueBackgroundRgb={
2410
+ canvasMode === "opaque" ? palette.bgRgb : undefined
2411
+ }
2395
2412
  />
2396
2413
  )}
2397
2414
 
2398
2415
  {/* Line stack above the fade so the line stays crisp at the left edge. */}
2399
- <ChartStack
2400
- model={model}
2401
- yAxisEntries={yAxisEntries}
2402
- degen={degen}
2403
- />
2416
+ <ChartStack model={model} yAxisEntries={yAxisEntries} degen={degen} />
2404
2417
 
2405
2418
  {/* "extrema-edge" connector lines (dot → edge readout), above the chart
2406
2419
  content so the dashed guide reads over the line / candles. */}
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @see https://github.com/benjitaylor/liveline
6
6
  */
7
- import { Canvas, Group } from "@shopify/react-native-skia";
7
+ import { Canvas, Group, Rect } from "@shopify/react-native-skia";
8
8
  import { useLayoutEffect, useState } from "react";
9
9
  import { StyleSheet, View } from "react-native";
10
10
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
@@ -83,6 +83,10 @@ import {
83
83
  labelConnector,
84
84
  } from "./ExtremaConnectorOverlay";
85
85
  import { CustomMarkerOverlay } from "./CustomMarkerOverlay";
86
+ import {
87
+ CustomReferenceLineOverlay,
88
+ customReferenceLineFlags,
89
+ } from "./CustomReferenceLineOverlay";
86
90
  import { CrosshairLine } from "./CrosshairLine";
87
91
  import { DegenParticlesOverlay } from "./DegenParticlesOverlay";
88
92
  import { LeftEdgeFade } from "./LeftEdgeFade";
@@ -128,6 +132,7 @@ function useLiveChartSeriesController({
128
132
  font: fontProp,
129
133
  insets,
130
134
  style,
135
+ canvasMode = "transparent",
131
136
  timeWindow = 30,
132
137
  paused = false,
133
138
  loading = false,
@@ -172,6 +177,7 @@ function useLiveChartSeriesController({
172
177
  markerHitRadius = 16,
173
178
  markerCluster,
174
179
  renderMarker,
180
+ renderReferenceLine,
175
181
  leftEdgeFade = true,
176
182
  }: LiveChartSeriesProps) {
177
183
  const emptyMarkers = useSharedValue<Marker[]>([]);
@@ -224,6 +230,13 @@ function useLiveChartSeriesController({
224
230
 
225
231
  const allRefLines = referenceLines ?? [];
226
232
  const refValues = collectReferenceValues(allRefLines);
233
+ // Form-A lines a custom renderer owns keep their line stroke but suppress the
234
+ // built-in Skia tag. The callback is probed per line on the JS thread, matching
235
+ // LiveChart and CustomMarkerOverlay's per-item fallback model.
236
+ const refLineCustom = customReferenceLineFlags(
237
+ allRefLines,
238
+ renderReferenceLine,
239
+ );
227
240
 
228
241
  const palette = applyPaletteOverride(
229
242
  resolveTheme(accentColor, theme),
@@ -256,6 +269,11 @@ function useLiveChartSeriesController({
256
269
  setSeriesSnapshot(series.get().slice());
257
270
  }, [series]);
258
271
 
272
+ // Mount per-series drawing worklets only for real series. The previous fixed
273
+ // 12-slot render kept 144 derived-value mappers alive for the default stroke,
274
+ // dot, and value-label layers even when a chart contained only one line.
275
+ const activeSeriesCount = Math.min(seriesSnapshot.length, MAX_MULTI_SERIES);
276
+
259
277
  const maxSeriesLabelWidth = dotCfg.valueLabel
260
278
  ? Math.max(
261
279
  0,
@@ -336,7 +354,11 @@ function useLiveChartSeriesController({
336
354
  nowOverride,
337
355
  });
338
356
  const { layoutHeight, onLayout } = useCanvasLayout(engine);
339
- const linePaths = useMultiSeriesLinePaths(engine, effectivePadding);
357
+ const linePaths = useMultiSeriesLinePaths(
358
+ engine,
359
+ effectivePadding,
360
+ activeSeriesCount,
361
+ );
340
362
 
341
363
  // Per-series colors and stroke styles, derived from the off-render snapshot
342
364
  // (React state — so no Reanimated read-during-render). The reaction below
@@ -503,6 +525,7 @@ function useLiveChartSeriesController({
503
525
  // passthrough props the render needs
504
526
  series,
505
527
  style,
528
+ canvasMode,
506
529
  accessibilityLabel,
507
530
  accessibilityRole,
508
531
  emptyText,
@@ -519,6 +542,7 @@ function useLiveChartSeriesController({
519
542
  degenCfg,
520
543
  metricsCfg,
521
544
  allRefLines,
545
+ refLineCustom,
522
546
  leftEdgeFadeCfg,
523
547
  // theme / layout / fonts
524
548
  palette,
@@ -540,6 +564,7 @@ function useLiveChartSeriesController({
540
564
  layoutHeight,
541
565
  onLayout,
542
566
  linePaths,
567
+ activeSeriesCount,
543
568
  lineColors,
544
569
  lineStyles,
545
570
  degenPack,
@@ -555,6 +580,7 @@ function useLiveChartSeriesController({
555
580
  markerGroupOpacity,
556
581
  overlayScrubFade,
557
582
  renderMarker,
583
+ renderReferenceLine,
558
584
  // selection dot: resolved config + fallback color (the leading series' color)
559
585
  selectionDot: selectionDotCfg,
560
586
  selectionColor: lineColors[0],
@@ -610,6 +636,8 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
610
636
  loadingStrokeWidth,
611
637
  loadingAmplitude,
612
638
  loadingSpeed,
639
+ canvasMode,
640
+ activeSeriesCount,
613
641
  } = model;
614
642
 
615
643
  return (
@@ -629,12 +657,13 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
629
657
  </Group>
630
658
  )}
631
659
 
632
- {/* Index keys: reference lines are a positional array and two may share
633
- value + label (e.g. duplicate working orders at the same price), which a
634
- content-derived key would collapse to one. Fade group lets
635
- `scrub.hideOverlaysOnScrub` ease the lines out while scrubbing. */}
660
+ {/* Reference lines are index-addressed throughout the drag/press API, and
661
+ duplicate working orders may share all visible content. Positional keys
662
+ keep both orders mounted without collapsing them to one React child.
663
+ Fade group lets `scrub.hideOverlaysOnScrub` ease the lines out. */}
636
664
  <Group opacity={overlayScrubFade}>
637
665
  {allRefLines.map((rl, i) => (
666
+ /* react-doctor-disable-next-line react-doctor/no-array-index-as-key */
638
667
  <ReferenceLineOverlay
639
668
  key={i}
640
669
  engine={engine}
@@ -654,12 +683,13 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
654
683
  padding={effectivePadding}
655
684
  colors={lineColors}
656
685
  config={dotCfg.valueLine}
686
+ seriesCount={activeSeriesCount}
657
687
  />
658
688
  </Group>
659
689
  )}
660
690
 
661
691
  <Group opacity={reveal.lineOpacity}>
662
- {Array.from({ length: MAX_MULTI_SERIES }, (_, i) => (
692
+ {Array.from({ length: activeSeriesCount }, (_, i) => (
663
693
  <MultiSeriesStroke
664
694
  key={i}
665
695
  index={i}
@@ -694,6 +724,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
694
724
  color={dotCfg.color}
695
725
  pulse={dotCfg.pulse}
696
726
  viewEnd={engine.viewEnd}
727
+ seriesCount={activeSeriesCount}
697
728
  />
698
729
  </Group>
699
730
  )}
@@ -749,6 +780,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
749
780
  lineStrokeWidth={loadingStrokeWidth}
750
781
  waveAmplitude={loadingAmplitude}
751
782
  waveSpeed={loadingSpeed}
783
+ opaqueCanvas={canvasMode === "opaque"}
752
784
  />
753
785
  </Group>
754
786
  );
@@ -767,6 +799,7 @@ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
767
799
  skiaFont,
768
800
  reveal,
769
801
  degenShakeTransform,
802
+ activeSeriesCount,
770
803
  } = model;
771
804
  if (!dotCfg.valueLabel) return null;
772
805
  return (
@@ -778,6 +811,7 @@ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
778
811
  colors={lineColors}
779
812
  font={skiaFont}
780
813
  dotRadius={dotOuterRadius}
814
+ seriesCount={activeSeriesCount}
781
815
  />
782
816
  </Group>
783
817
  </Group>
@@ -789,6 +823,7 @@ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
789
823
  function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
790
824
  const {
791
825
  allRefLines,
826
+ refLineCustom,
792
827
  engine,
793
828
  effectivePadding,
794
829
  palette,
@@ -796,11 +831,13 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
796
831
  skiaFont,
797
832
  degenShakeTransform,
798
833
  overlayScrubFade,
834
+ canvasMode,
799
835
  } = model;
800
836
  if (allRefLines.length === 0) return null;
801
837
  return (
802
838
  <Group transform={degenShakeTransform} opacity={overlayScrubFade}>
803
839
  {allRefLines.map((rl, i) => (
840
+ /* react-doctor-disable-next-line react-doctor/no-array-index-as-key */
804
841
  <ReferenceLineOverlay
805
842
  key={i}
806
843
  engine={engine}
@@ -810,6 +847,7 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
810
847
  formatValue={formatValue}
811
848
  font={skiaFont}
812
849
  badgeLayer
850
+ suppressTag={refLineCustom[i]}
813
851
  />
814
852
  ))}
815
853
  </Group>
@@ -848,7 +886,11 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
848
886
  markersSV,
849
887
  markerClusterCfg,
850
888
  renderMarker,
889
+ renderReferenceLine,
890
+ allRefLines,
891
+ refLineCustom,
851
892
  overlayScrubFade,
893
+ canvasMode,
852
894
  } = model;
853
895
 
854
896
  // Mirror the Skia overlay fade onto the RN custom-marker sibling so
@@ -891,7 +933,21 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
891
933
  else points map into a taller area and the x-axis draws past the edge. */}
892
934
  <GestureDetector gesture={rootGesture}>
893
935
  <View style={{ flex: 1 }} onLayout={onLayout}>
894
- <Canvas style={{ flex: 1, minHeight: layoutHeight || 1 }}>
936
+ {/* Skia chooses TextureView vs SurfaceView when the native Canvas mounts. */}
937
+ <Canvas
938
+ key={canvasMode}
939
+ style={{ flex: 1, minHeight: layoutHeight || 1 }}
940
+ opaque={canvasMode === "opaque"}
941
+ >
942
+ {canvasMode === "opaque" && (
943
+ <Rect
944
+ x={0}
945
+ y={0}
946
+ width={engine.canvasWidth}
947
+ height={engine.canvasHeight}
948
+ color={backgroundColor}
949
+ />
950
+ )}
895
951
  <SeriesChartStack model={model} />
896
952
 
897
953
  {/* "extrema-edge" connector lines (dot → edge readout). Outside the
@@ -910,6 +966,9 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
910
966
  startColor={leftEdgeFadeCfg.startColor}
911
967
  endColor={leftEdgeFadeCfg.endColor}
912
968
  engine={engine}
969
+ opaqueBackgroundRgb={
970
+ canvasMode === "opaque" ? palette.bgRgb : undefined
971
+ }
913
972
  />
914
973
  )}
915
974
 
@@ -932,6 +991,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
932
991
  crosshairLineColor={scrubCfg.crosshairLineColor}
933
992
  crosshairDash={scrubCfg.crosshairDash}
934
993
  crosshairDimColor={scrubCfg.crosshairDimColor}
994
+ opaqueCanvas={canvasMode === "opaque"}
935
995
  />
936
996
  )}
937
997
 
@@ -952,23 +1012,36 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
952
1012
  padding={effectivePadding}
953
1013
  />
954
1014
 
955
- {/* Custom-rendered markers — RN views floated over the canvas
956
- (non-Skia), pinned to each marker's live position. Box-none fade
957
- wrapper so `scrub.hideOverlaysOnScrub` hides them with the Skia
958
- markers (full-bleed; children keep their own absolute positions). */}
959
- {markersActive && renderMarker && (
1015
+ {/* Custom annotations — RN views floated over the canvas (non-Skia),
1016
+ pinned to their live positions. As a post-Canvas sibling they render
1017
+ above the left-edge fade and scrub overlay. The box-none fade wrapper
1018
+ keeps `scrub.hideOverlaysOnScrub` behavior aligned with Skia. */}
1019
+ {((markersActive && renderMarker) ||
1020
+ (renderReferenceLine && allRefLines.length > 0)) && (
960
1021
  <Animated.View
961
1022
  pointerEvents="box-none"
962
1023
  style={[StyleSheet.absoluteFill, overlayFadeStyle]}
963
1024
  >
964
- <CustomMarkerOverlay
965
- markers={markersSV}
966
- renderMarker={renderMarker}
967
- engine={engine}
968
- padding={effectivePadding}
969
- series={series}
970
- cluster={markerClusterCfg}
971
- />
1025
+ {markersActive && renderMarker && (
1026
+ <CustomMarkerOverlay
1027
+ markers={markersSV}
1028
+ renderMarker={renderMarker}
1029
+ engine={engine}
1030
+ padding={effectivePadding}
1031
+ series={series}
1032
+ cluster={markerClusterCfg}
1033
+ />
1034
+ )}
1035
+ {renderReferenceLine && allRefLines.length > 0 && (
1036
+ <CustomReferenceLineOverlay
1037
+ lines={allRefLines}
1038
+ renderReferenceLine={renderReferenceLine}
1039
+ custom={refLineCustom}
1040
+ engine={engine}
1041
+ padding={effectivePadding}
1042
+ formatValue={formatValue}
1043
+ />
1044
+ )}
972
1045
  </Animated.View>
973
1046
  )}
974
1047
  </View>
@@ -8,6 +8,7 @@ import {
8
8
  vec,
9
9
  type SkFont,
10
10
  } from "@shopify/react-native-skia";
11
+ import { useRef } from "react";
11
12
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
12
13
  import {
13
14
  BADGE_METRICS_DEFAULTS,
@@ -67,6 +68,7 @@ export function LoadingOverlay({
67
68
  lineStrokeWidth,
68
69
  waveAmplitude = 14,
69
70
  waveSpeed = 1,
71
+ opaqueCanvas = false,
70
72
  }: {
71
73
  engine: ChartEngineLayout;
72
74
  padding: ChartPadding;
@@ -86,6 +88,8 @@ export function LoadingOverlay({
86
88
  waveAmplitude?: number;
87
89
  /** Breathing-wave speed multiplier. */
88
90
  waveSpeed?: number;
91
+ /** Paint the owned background instead of erasing destination alpha. */
92
+ opaqueCanvas?: boolean;
89
93
  /** Mirror the badge prop so labels align with GridOverlay's label positions. */
90
94
  badge?: boolean;
91
95
  /** Whether the badge tail spike is shown; affects the left inset used for skeleton alignment. */
@@ -99,15 +103,26 @@ export function LoadingOverlay({
99
103
  }) {
100
104
  // Same left-inset formula as GridOverlay (only used when badge=true)
101
105
  const leftInset =
102
- badgeMetrics.dotGap + badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
106
+ badgeMetrics.dotGap +
107
+ badgeTailAndCap(font.getSize(), badgeTail, badgeMetrics);
103
108
 
104
109
  // Loading-shell color (squiggle + skeleton placeholders) and squiggle stroke,
105
110
  // both overridable via `loading={{ color, strokeWidth }}`.
106
111
  const loadingColor = lineColor ?? palette.gridLine;
107
112
  const loadingStroke = lineStrokeWidth ?? strokeWidth;
113
+ const [bgR, bgG, bgB] = palette.bgRgb;
114
+ const gapMaskColors = opaqueCanvas
115
+ ? [
116
+ `rgba(${bgR},${bgG},${bgB},0)`,
117
+ `rgb(${bgR},${bgG},${bgB})`,
118
+ `rgb(${bgR},${bgG},${bgB})`,
119
+ `rgba(${bgR},${bgG},${bgB},0)`,
120
+ ]
121
+ : ["rgba(0,0,0,0)", "rgba(0,0,0,1)", "rgba(0,0,0,1)", "rgba(0,0,0,0)"];
108
122
 
109
123
  // Squiggly path — built into a reused PathBuilder and detach()-ed each frame.
110
124
  const squigglyBuilder = usePathBuilder();
125
+ const squigglyPtsRef = useRef<number[]>([]);
111
126
 
112
127
  // Squiggly path — animated each frame via timestamp
113
128
  const squigglyPath = useDerivedValue(() => {
@@ -122,6 +137,7 @@ export function LoadingOverlay({
122
137
  engine.timestamp.get(),
123
138
  waveAmplitude,
124
139
  waveSpeed,
140
+ squigglyPtsRef.current,
125
141
  );
126
142
  return buildSplineDetached(b, pts);
127
143
  });
@@ -254,17 +270,15 @@ export function LoadingOverlay({
254
270
  />
255
271
 
256
272
  {/* Erase a soft horizontal band through the squiggle for the label (dstOut) */}
257
- <Group opacity={showGapGroup} blendMode="dstOut">
273
+ <Group
274
+ opacity={showGapGroup}
275
+ blendMode={opaqueCanvas ? undefined : "dstOut"}
276
+ >
258
277
  <Rect x={gapLeft} y={gapTop} width={gapWidth} height={gapHeight}>
259
278
  <LinearGradient
260
279
  start={vec(0, 0)}
261
280
  end={gapGradEnd}
262
- colors={[
263
- "rgba(0,0,0,0)",
264
- "rgba(0,0,0,1)",
265
- "rgba(0,0,0,1)",
266
- "rgba(0,0,0,0)",
267
- ]}
281
+ colors={gapMaskColors}
268
282
  positions={gapGradientPositions}
269
283
  />
270
284
  </Rect>
@@ -279,7 +293,7 @@ export function LoadingOverlay({
279
293
  width={RECT_W}
280
294
  height={RECT_H}
281
295
  r={RECT_R}
282
- color={loadingColor}
296
+ color={loadingColor}
283
297
  />
284
298
  <RoundedRect
285
299
  x={lx}
@@ -287,7 +301,7 @@ export function LoadingOverlay({
287
301
  width={RECT_W}
288
302
  height={RECT_H}
289
303
  r={RECT_R}
290
- color={loadingColor}
304
+ color={loadingColor}
291
305
  />
292
306
  <RoundedRect
293
307
  x={lx}
@@ -295,7 +309,7 @@ export function LoadingOverlay({
295
309
  width={RECT_W}
296
310
  height={RECT_H}
297
311
  r={RECT_R}
298
- color={loadingColor}
312
+ color={loadingColor}
299
313
  />
300
314
  <RoundedRect
301
315
  x={lx}
@@ -303,7 +317,7 @@ export function LoadingOverlay({
303
317
  width={RECT_W}
304
318
  height={RECT_H}
305
319
  r={RECT_R}
306
- color={loadingColor}
320
+ color={loadingColor}
307
321
  />
308
322
  </>
309
323
  ) : null}
@@ -351,6 +351,18 @@ export function MarkerOverlay({
351
351
  if (projRef.current === null) {
352
352
  projRef.current = { a: [], b: [], tick: false };
353
353
  }
354
+ const atlasFrameRef = useRef<{
355
+ a: { transforms: SkRSXform[]; sprites: SkRect[] };
356
+ b: { transforms: SkRSXform[]; sprites: SkRect[] };
357
+ tick: boolean;
358
+ } | null>(null);
359
+ if (atlasFrameRef.current === null) {
360
+ atlasFrameRef.current = {
361
+ a: { transforms: [], sprites: [] },
362
+ b: { transforms: [], sprites: [] },
363
+ tick: false,
364
+ };
365
+ }
354
366
 
355
367
  // Single per-frame worklet: project all markers, then emit a transform +
356
368
  // source rect for each visible atlas marker. Three mappers total regardless
@@ -377,8 +389,13 @@ export function MarkerOverlay({
377
389
  lineLinear,
378
390
  });
379
391
  clusterMarkers(ms, buf, { config: cluster });
380
- const transforms: SkRSXform[] = [];
381
- const sprites: SkRect[] = [];
392
+ const atlasFrames = atlasFrameRef.current!;
393
+ atlasFrames.tick = !atlasFrames.tick;
394
+ const frame = atlasFrames.tick ? atlasFrames.a : atlasFrames.b;
395
+ const transforms = frame.transforms;
396
+ const sprites = frame.sprites;
397
+ transforms.length = 0;
398
+ sprites.length = 0;
382
399
  for (let i = 0; i < ms.length; i++) {
383
400
  const pt = buf[i];
384
401
  // Collapsed-cluster members fold into their representative's badge.
@@ -455,7 +472,7 @@ export function MarkerOverlay({
455
472
  );
456
473
  sprites.push(cell.rect);
457
474
  }
458
- return { transforms, sprites };
475
+ return frame;
459
476
  },
460
477
  [
461
478
  cells,
@@ -1,7 +1,6 @@
1
1
  import { Circle, Group } from "@shopify/react-native-skia";
2
2
 
3
3
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
- import { MAX_MULTI_SERIES } from "../constants";
5
4
  import type { ChartPadding } from "../draw/line";
6
5
  import type {
7
6
  ResolvedDotRingConfig,
@@ -118,6 +117,7 @@ export function MultiSeriesDots({
118
117
  color,
119
118
  pulse,
120
119
  viewEnd,
120
+ seriesCount,
121
121
  }: {
122
122
  engine: MultiEngineState;
123
123
  padding: ChartPadding;
@@ -132,10 +132,12 @@ export function MultiSeriesDots({
132
132
  pulse: ResolvedPulseConfig | null;
133
133
  /** Pan/zoom right-edge override — pulse is frozen-out while scrolled back. */
134
134
  viewEnd?: SharedValue<number | null>;
135
+ /** Number of live series slots to mount. */
136
+ seriesCount: number;
135
137
  }) {
136
138
  return (
137
139
  <Group>
138
- {Array.from({ length: MAX_MULTI_SERIES }, (_, i) => (
140
+ {Array.from({ length: seriesCount }, (_, i) => (
139
141
  <SeriesDotAtIndex
140
142
  key={i}
141
143
  index={i}
@@ -4,11 +4,13 @@ import {
4
4
  type SkFont,
5
5
  } from "@shopify/react-native-skia";
6
6
  import { useDerivedValue, type SharedValue } from "react-native-reanimated";
7
- import { MAX_MULTI_SERIES } from "../constants";
8
7
  import type { TooltipLayout } from "../hooks/crosshairShared";
9
8
  import type { LiveChartPalette } from "../types";
10
9
 
11
- const TOOLTIP_STACK_SLOTS = MAX_MULTI_SERIES + 1;
10
+ // Candle tooltips have exactly O/H/L/C + time. This component is only mounted
11
+ // for candle mode, so reserving the multi-series capacity registered 40 unused
12
+ // derived-value mappers (8 empty rows × 5 mappers).
13
+ const TOOLTIP_STACK_SLOTS = 5;
12
14
 
13
15
  function TooltipStackLine({
14
16
  index,
@@ -5,7 +5,6 @@ import {
5
5
  } from "@shopify/react-native-skia";
6
6
  import { useDerivedValue } from "react-native-reanimated";
7
7
 
8
- import { MAX_MULTI_SERIES } from "../constants";
9
8
  import type { ChartPadding } from "../draw/line";
10
9
  import type { MultiEngineState } from "../core/useLiveChartEngine";
11
10
 
@@ -78,16 +77,19 @@ export function MultiSeriesValueLabels({
78
77
  colors,
79
78
  font,
80
79
  dotRadius,
80
+ seriesCount,
81
81
  }: {
82
82
  engine: MultiEngineState;
83
83
  padding: ChartPadding;
84
84
  colors: string[];
85
85
  font: SkFont;
86
86
  dotRadius: number;
87
+ /** Number of live series slots to mount. */
88
+ seriesCount: number;
87
89
  }) {
88
90
  return (
89
91
  <Group>
90
- {Array.from({ length: MAX_MULTI_SERIES }, (_, i) => (
92
+ {Array.from({ length: seriesCount }, (_, i) => (
91
93
  <SeriesValueLabelAtIndex
92
94
  key={i}
93
95
  index={i}