react-native-livechart 4.11.0 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/components/CrosshairLine.d.ts +3 -1
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
  4. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  5. package/dist/components/DotOverlay.d.ts +3 -5
  6. package/dist/components/DotOverlay.d.ts.map +1 -1
  7. package/dist/components/LiveChart.d.ts.map +1 -1
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
  10. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  11. package/dist/components/ReferenceLineOverlay.d.ts +20 -6
  12. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  13. package/dist/components/YAxisOverlay.d.ts +3 -1
  14. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  15. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  16. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  17. package/dist/core/resolveConfig.d.ts +29 -0
  18. package/dist/core/resolveConfig.d.ts.map +1 -1
  19. package/dist/hooks/crosshairSeries.d.ts +10 -2
  20. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  21. package/dist/hooks/crosshairShared.d.ts +33 -2
  22. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  23. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  24. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  25. package/dist/hooks/useChartPaths.d.ts +5 -9
  26. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  27. package/dist/hooks/useCrosshair.d.ts +7 -1
  28. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  29. package/dist/hooks/useCrosshairSeries.d.ts +22 -3
  30. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  31. package/dist/hooks/useMomentum.d.ts +1 -1
  32. package/dist/hooks/useMomentum.d.ts.map +1 -1
  33. package/dist/hooks/usePanScroll.d.ts +15 -1
  34. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/math/lerp.d.ts.map +1 -1
  38. package/dist/math/momentum.d.ts +1 -1
  39. package/dist/math/momentum.d.ts.map +1 -1
  40. package/dist/math/referenceLines.d.ts +7 -0
  41. package/dist/math/referenceLines.d.ts.map +1 -1
  42. package/dist/types.d.ts +160 -18
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/CrosshairLine.tsx +4 -1
  46. package/src/components/CustomReferenceLineOverlay.tsx +42 -13
  47. package/src/components/DotOverlay.tsx +24 -8
  48. package/src/components/LiveChart.tsx +157 -37
  49. package/src/components/LiveChartSeries.tsx +128 -16
  50. package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
  51. package/src/components/ReferenceLineOverlay.tsx +274 -141
  52. package/src/components/YAxisOverlay.tsx +5 -1
  53. package/src/core/liveIndicatorVisibility.ts +25 -0
  54. package/src/core/resolveConfig.ts +86 -0
  55. package/src/hooks/crosshairSeries.ts +262 -33
  56. package/src/hooks/crosshairShared.ts +36 -2
  57. package/src/hooks/delayedPanGuard.ts +111 -6
  58. package/src/hooks/useChartPaths.ts +25 -15
  59. package/src/hooks/useCrosshair.ts +57 -4
  60. package/src/hooks/useCrosshairSeries.ts +95 -7
  61. package/src/hooks/useMomentum.ts +8 -1
  62. package/src/hooks/usePanScroll.ts +46 -5
  63. package/src/index.ts +1 -0
  64. package/src/math/lerp.ts +7 -0
  65. package/src/math/momentum.ts +22 -5
  66. package/src/math/referenceLines.ts +38 -0
  67. package/src/types.ts +168 -18
@@ -28,7 +28,6 @@ import {
28
28
  lineStyleSignatureFromArray,
29
29
  resolveMultiSeriesLineColorsSnapshot,
30
30
  resolveMultiSeriesLineStylesSnapshot,
31
- type SeriesLineStyle,
32
31
  } from "../core/multiSeriesLayout";
33
32
  import {
34
33
  resolveAxisLabel,
@@ -53,6 +52,7 @@ import { pulseRadialOutset } from "../draw/line";
53
52
  import { resolveChartLayout } from "../hooks/resolveChartLayout";
54
53
  import { useCanvasLayout } from "../hooks/useCanvasLayout";
55
54
  import { useChartReveal } from "../hooks/useChartReveal";
55
+ import { useChartOverlayContext } from "../hooks/useChartOverlayContext";
56
56
  import { useChartSkiaFont } from "../hooks/useChartSkiaFont";
57
57
  import { useCrosshairSeries } from "../hooks/useCrosshairSeries";
58
58
  import { useMarkers } from "../hooks/useMarkers";
@@ -70,7 +70,10 @@ import {
70
70
  } from "../lib/format";
71
71
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
72
72
  import { MONO_FONT_FAMILY } from "../lib/monoFontFamily";
73
- import { collectReferenceValues } from "../math/referenceLines";
73
+ import {
74
+ collectReferenceValues,
75
+ referenceLineReactKeys,
76
+ } from "../math/referenceLines";
74
77
  import {
75
78
  applyPaletteOverride,
76
79
  leftEdgeFadeColorsFromBgRgb,
@@ -78,6 +81,7 @@ import {
78
81
  } from "../theme";
79
82
  import type { LiveChartSeriesProps, Marker, SeriesConfig } from "../types";
80
83
  import { AxisLabelOverlay } from "./AxisLabelOverlay";
84
+ import { ChartOverlayLayer } from "./ChartOverlayLayer";
81
85
  import {
82
86
  ExtremaConnectorOverlay,
83
87
  labelConnector,
@@ -96,6 +100,7 @@ import { MultiSeriesDots } from "./MultiSeriesDots";
96
100
  import { MultiSeriesStroke } from "./MultiSeriesStroke";
97
101
  import { MultiSeriesValueLabels } from "./MultiSeriesValueLabels";
98
102
  import { MultiSeriesValueLines } from "./MultiSeriesValueLines";
103
+ import { PerSeriesTooltipOverlay } from "./PerSeriesTooltipOverlay";
99
104
  import { ReferenceLineOverlay } from "./ReferenceLineOverlay";
100
105
  import { SeriesToggleChips } from "./SeriesToggleChips";
101
106
  import { XAxisOverlay } from "./XAxisOverlay";
@@ -177,7 +182,9 @@ function useLiveChartSeriesController({
177
182
  markerHitRadius = 16,
178
183
  markerCluster,
179
184
  renderMarker,
185
+ renderOverlay,
180
186
  renderReferenceLine,
187
+ renderOffAxisReferenceLine,
181
188
  leftEdgeFade = true,
182
189
  }: LiveChartSeriesProps) {
183
190
  const emptyMarkers = useSharedValue<Marker[]>([]);
@@ -190,6 +197,10 @@ function useLiveChartSeriesController({
190
197
  const bottomLabelCfg = resolveAxisLabel(bottomLabel);
191
198
  const scrubCfg = resolveScrub(scrub);
192
199
  const scrubEnabled = scrubCfg !== null;
200
+ // Opt-in and subordinate to the existing master `tooltip` switch. Keeping
201
+ // this null by default preserves LiveChartSeries' historical guide-only scrub.
202
+ const seriesTooltipCfg =
203
+ scrubCfg?.tooltip === true ? scrubCfg.seriesTooltip : null;
193
204
 
194
205
  // Time-scroll + pinch-zoom (mirrors LiveChart). LiveChartSeries has no static
195
206
  // mode, so these gate on the props alone. `holdToScrub` requires the scrub
@@ -216,8 +227,8 @@ function useLiveChartSeriesController({
216
227
 
217
228
  // Multi-series defaults the scrub selection dot OFF: it can only track one
218
229
  // line (the leading series), which reads as a bug next to the other series.
219
- // The crosshair line + per-series tooltip stack already mark the scrub point.
220
- // Passing `selectionDot` explicitly (true / config) still opts it in.
230
+ // The crosshair line (and the opt-in tooltip's per-series intersection dots)
231
+ // mark the scrub point. Passing `selectionDot` explicitly still opts it in.
221
232
  const selectionDotCfg = resolveSelectionDot(selectionDot ?? false);
222
233
  const gridStyleCfg = resolveGridStyle(gridStyle);
223
234
  const dotCfg = resolveMultiSeriesDot(dotProp);
@@ -237,6 +248,17 @@ function useLiveChartSeriesController({
237
248
  allRefLines,
238
249
  renderReferenceLine,
239
250
  );
251
+ // A full custom tag owns both states, so it takes precedence over an
252
+ // off-axis-only renderer for the same line.
253
+ const refLineOffAxisCustom = customReferenceLineFlags(
254
+ allRefLines,
255
+ renderOffAxisReferenceLine,
256
+ "off-axis",
257
+ ).map((custom, index) => custom && !refLineCustom[index]);
258
+ const refLineKeys = referenceLineReactKeys(allRefLines);
259
+ // RN custom tags report their measured widths here so the Skia connector can
260
+ // start after the native badge instead of the hidden built-in pill.
261
+ const refLineCustomTagWidths = useSharedValue<number[]>([]);
240
262
 
241
263
  const palette = applyPaletteOverride(
242
264
  resolveTheme(accentColor, theme),
@@ -266,6 +288,7 @@ function useLiveChartSeriesController({
266
288
  // render scope for memoization, which trips Reanimated's strict-mode warning;
267
289
  // it leaves the `.get()` method call inside the effect.
268
290
  // react-doctor-disable-next-line react-hooks-js/set-state-in-effect -- Reanimated: seeding from a SharedValue off render is the warning-free path
291
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Reanimated: seed from the SharedValue outside render to avoid strict-mode access warnings
269
292
  setSeriesSnapshot(series.get().slice());
270
293
  }, [series]);
271
294
 
@@ -407,6 +430,20 @@ function useLiveChartSeriesController({
407
430
  skiaFont,
408
431
  );
409
432
 
433
+ // Cross-gesture arbitration for the one-finger touch. `Gesture.Race` below is
434
+ // NOT arbitration — RNGH's Race adds no relation between its children, so both
435
+ // pans recognize independently and each can activate while the other already
436
+ // owns the touch. This latch (written by the scroll pan, read by the scrub's
437
+ // long-press guard) makes "the scroll already won" a hard fact; `scrubActive`
438
+ // (written by the crosshair, read by the scroll pan) is the mirror image.
439
+ const scrollActive = useSharedValue(false);
440
+ // `liveEdge` includes the optional right breathing-room buffer. The time pill
441
+ // must clamp against the real "now" anchor so its live bucket never ends in
442
+ // that future buffer.
443
+ const tooltipMaxTime = useDerivedValue(
444
+ () => engine.liveEdge.get() - windowBuffer * timeWindow,
445
+ );
446
+
410
447
  const crosshair = useCrosshairSeries(
411
448
  engine,
412
449
  effectivePadding,
@@ -415,6 +452,17 @@ function useLiveChartSeriesController({
415
452
  scrubHoldMs,
416
453
  onGestureStart,
417
454
  onGestureEnd,
455
+ scrollActive,
456
+ seriesTooltipCfg
457
+ ? {
458
+ config: seriesTooltipCfg,
459
+ formatValue,
460
+ formatTime,
461
+ font: skiaFont,
462
+ colors: lineColors,
463
+ maxTime: tooltipMaxTime,
464
+ }
465
+ : undefined,
418
466
  );
419
467
 
420
468
  // Capture only the shared value in the worklets below. Referencing
@@ -458,6 +506,10 @@ function useLiveChartSeriesController({
458
506
  minTime: scrollMinTime,
459
507
  enabled: timeScrollEnabled,
460
508
  mode: scrollGestureMode,
509
+ scrollActive,
510
+ // Once a scrub is engaged the chart is locked: scrolling goes inert so the
511
+ // finger only moves the price indicator across a fixed window.
512
+ scrubActive: crosshairScrubActive,
461
513
  onScrollStart: () => {
462
514
  "worklet";
463
515
  crosshairScrubActive.set(false);
@@ -535,6 +587,7 @@ function useLiveChartSeriesController({
535
587
  yAxisCfg,
536
588
  xAxisCfg,
537
589
  scrubCfg,
590
+ seriesTooltipCfg,
538
591
  gridStyleCfg,
539
592
  dotCfg,
540
593
  dotOuterRadius,
@@ -542,7 +595,10 @@ function useLiveChartSeriesController({
542
595
  degenCfg,
543
596
  metricsCfg,
544
597
  allRefLines,
598
+ refLineKeys,
545
599
  refLineCustom,
600
+ refLineOffAxisCustom,
601
+ refLineCustomTagWidths,
546
602
  leftEdgeFadeCfg,
547
603
  // theme / layout / fonts
548
604
  palette,
@@ -580,7 +636,9 @@ function useLiveChartSeriesController({
580
636
  markerGroupOpacity,
581
637
  overlayScrubFade,
582
638
  renderMarker,
639
+ renderOverlay,
583
640
  renderReferenceLine,
641
+ renderOffAxisReferenceLine,
584
642
  // selection dot: resolved config + fallback color (the leading series' color)
585
643
  selectionDot: selectionDotCfg,
586
644
  selectionColor: lineColors[0],
@@ -638,6 +696,7 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
638
696
  loadingSpeed,
639
697
  canvasMode,
640
698
  activeSeriesCount,
699
+ refLineKeys,
641
700
  } = model;
642
701
 
643
702
  return (
@@ -657,15 +716,12 @@ function SeriesChartStack({ model }: { model: LiveChartSeriesModel }) {
657
716
  </Group>
658
717
  )}
659
718
 
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. */}
719
+ {/* Fade group lets `scrub.hideOverlaysOnScrub` ease the lines out. Explicit
720
+ ids keep each reference line mounted when the caller reorders it. */}
664
721
  <Group opacity={overlayScrubFade}>
665
722
  {allRefLines.map((rl, i) => (
666
- /* react-doctor-disable-next-line react-doctor/no-array-index-as-key */
667
723
  <ReferenceLineOverlay
668
- key={i}
724
+ key={refLineKeys[i]}
669
725
  engine={engine}
670
726
  padding={effectivePadding}
671
727
  line={rl}
@@ -823,7 +879,10 @@ function SeriesValueLabelLayer({ model }: { model: LiveChartSeriesModel }) {
823
879
  function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
824
880
  const {
825
881
  allRefLines,
882
+ refLineKeys,
826
883
  refLineCustom,
884
+ refLineOffAxisCustom,
885
+ refLineCustomTagWidths,
827
886
  engine,
828
887
  effectivePadding,
829
888
  palette,
@@ -831,15 +890,13 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
831
890
  skiaFont,
832
891
  degenShakeTransform,
833
892
  overlayScrubFade,
834
- canvasMode,
835
893
  } = model;
836
894
  if (allRefLines.length === 0) return null;
837
895
  return (
838
896
  <Group transform={degenShakeTransform} opacity={overlayScrubFade}>
839
897
  {allRefLines.map((rl, i) => (
840
- /* react-doctor-disable-next-line react-doctor/no-array-index-as-key */
841
898
  <ReferenceLineOverlay
842
- key={i}
899
+ key={refLineKeys[i]}
843
900
  engine={engine}
844
901
  padding={effectivePadding}
845
902
  line={rl}
@@ -848,12 +905,21 @@ function SeriesRefBadgeLayer({ model }: { model: LiveChartSeriesModel }) {
848
905
  font={skiaFont}
849
906
  badgeLayer
850
907
  suppressTag={refLineCustom[i]}
908
+ suppressTagWhenOffAxis={refLineOffAxisCustom[i]}
909
+ customTagWidths={refLineCustomTagWidths}
851
910
  />
852
911
  ))}
853
912
  </Group>
854
913
  );
855
914
  }
856
915
 
916
+ /** Owns the price/time projection worklets for the multi-series custom overlay. */
917
+ function SeriesCustomConsumerOverlay({ model }: { model: LiveChartSeriesModel }) {
918
+ const { engine, effectivePadding, renderOverlay } = model;
919
+ const overlayContext = useChartOverlayContext(engine, effectivePadding);
920
+ return <ChartOverlayLayer render={renderOverlay!} context={overlayContext} />;
921
+ }
922
+
857
923
  export function LiveChartSeries(props: LiveChartSeriesProps) {
858
924
  const model = useLiveChartSeriesController(props);
859
925
  const {
@@ -871,6 +937,7 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
871
937
  effectivePadding,
872
938
  engine,
873
939
  scrubCfg,
940
+ seriesTooltipCfg,
874
941
  crosshair,
875
942
  palette,
876
943
  dotCfg,
@@ -886,11 +953,17 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
886
953
  markersSV,
887
954
  markerClusterCfg,
888
955
  renderMarker,
956
+ renderOverlay,
889
957
  renderReferenceLine,
958
+ renderOffAxisReferenceLine,
890
959
  allRefLines,
891
960
  refLineCustom,
961
+ refLineOffAxisCustom,
962
+ refLineCustomTagWidths,
892
963
  overlayScrubFade,
893
964
  canvasMode,
965
+ activeSeriesCount,
966
+ skiaFont,
894
967
  } = model;
895
968
 
896
969
  // Mirror the Skia overlay fade onto the RN custom-marker sibling so
@@ -988,8 +1061,16 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
988
1061
  selectionColor={selectionColor}
989
1062
  dimOpacity={scrubCfg.dimOpacity}
990
1063
  liveDotExtent={liveDotExtent}
991
- crosshairLineColor={scrubCfg.crosshairLineColor}
992
- crosshairDash={scrubCfg.crosshairDash}
1064
+ crosshairLineColor={
1065
+ seriesTooltipCfg?.guideColor ??
1066
+ scrubCfg.crosshairLineColor
1067
+ }
1068
+ crosshairLineWidth={seriesTooltipCfg?.guideWidth}
1069
+ crosshairDash={
1070
+ seriesTooltipCfg
1071
+ ? seriesTooltipCfg.guideDashPattern
1072
+ : scrubCfg.crosshairDash
1073
+ }
993
1074
  crosshairDimColor={scrubCfg.crosshairDimColor}
994
1075
  opaqueCanvas={canvasMode === "opaque"}
995
1076
  />
@@ -998,6 +1079,19 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
998
1079
  {/* Per-series value labels on top of the scrub dim so the dim never
999
1080
  clips them (they track each series' live value, not the scrub). */}
1000
1081
  <SeriesValueLabelLayer model={model} />
1082
+
1083
+ {seriesTooltipCfg && (
1084
+ <PerSeriesTooltipOverlay
1085
+ layout={crosshair.tooltipLayout}
1086
+ font={skiaFont}
1087
+ palette={palette}
1088
+ config={seriesTooltipCfg}
1089
+ seriesCount={activeSeriesCount}
1090
+ tooltipBackground={scrubCfg?.tooltipBackground}
1091
+ tooltipColor={scrubCfg?.tooltipColor}
1092
+ tooltipBorderColor={scrubCfg?.tooltipBorderColor}
1093
+ />
1094
+ )}
1001
1095
  </Canvas>
1002
1096
 
1003
1097
  {/* RN labels floated over the canvas (sibling of <Canvas>, an RN
@@ -1017,7 +1111,8 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
1017
1111
  above the left-edge fade and scrub overlay. The box-none fade wrapper
1018
1112
  keeps `scrub.hideOverlaysOnScrub` behavior aligned with Skia. */}
1019
1113
  {((markersActive && renderMarker) ||
1020
- (renderReferenceLine && allRefLines.length > 0)) && (
1114
+ ((renderReferenceLine || renderOffAxisReferenceLine) &&
1115
+ allRefLines.length > 0)) && (
1021
1116
  <Animated.View
1022
1117
  pointerEvents="box-none"
1023
1118
  style={[StyleSheet.absoluteFill, overlayFadeStyle]}
@@ -1040,10 +1135,27 @@ export function LiveChartSeries(props: LiveChartSeriesProps) {
1040
1135
  engine={engine}
1041
1136
  padding={effectivePadding}
1042
1137
  formatValue={formatValue}
1138
+ tagWidths={refLineCustomTagWidths}
1139
+ />
1140
+ )}
1141
+ {renderOffAxisReferenceLine && allRefLines.length > 0 && (
1142
+ <CustomReferenceLineOverlay
1143
+ lines={allRefLines}
1144
+ renderReferenceLine={renderOffAxisReferenceLine}
1145
+ custom={refLineOffAxisCustom}
1146
+ engine={engine}
1147
+ padding={effectivePadding}
1148
+ formatValue={formatValue}
1149
+ tagWidths={refLineCustomTagWidths}
1150
+ offAxisOnly
1043
1151
  />
1044
1152
  )}
1045
1153
  </Animated.View>
1046
1154
  )}
1155
+
1156
+ {/* Custom consumer overlay — topmost RN sibling with the live plot
1157
+ rect, including this multi-series chart's resolved axis inset. */}
1158
+ {renderOverlay && <SeriesCustomConsumerOverlay model={model} />}
1047
1159
  </View>
1048
1160
  </GestureDetector>
1049
1161
  {legendCfg.position === "bottom" ? legend : null}
@@ -0,0 +1,287 @@
1
+ import {
2
+ Circle,
3
+ Group,
4
+ RoundedRect,
5
+ Text as SkiaText,
6
+ type SkFont,
7
+ } from "@shopify/react-native-skia";
8
+ import {
9
+ useDerivedValue,
10
+ type DerivedValue,
11
+ type SharedValue,
12
+ } from "react-native-reanimated";
13
+ import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
14
+ import type { TooltipLayout } from "../hooks/crosshairShared";
15
+ import type { LiveChartPalette } from "../types";
16
+
17
+ type TooltipLayoutValue =
18
+ | SharedValue<TooltipLayout>
19
+ | DerivedValue<TooltipLayout>;
20
+
21
+ function TimePill({
22
+ layout,
23
+ font,
24
+ palette,
25
+ config,
26
+ background,
27
+ color,
28
+ borderColor,
29
+ }: {
30
+ layout: TooltipLayoutValue;
31
+ font: SkFont;
32
+ palette: LiveChartPalette;
33
+ config: ResolvedPerSeriesTooltipConfig;
34
+ background?: string;
35
+ color?: string;
36
+ borderColor?: string;
37
+ }) {
38
+ const opacity = useDerivedValue(() =>
39
+ layout.value.perSeries?.timePill ? 1 : 0,
40
+ );
41
+ const x = useDerivedValue(
42
+ () => layout.value.perSeries?.timePill?.x ?? -400,
43
+ );
44
+ const y = useDerivedValue(
45
+ () => layout.value.perSeries?.timePill?.y ?? 0,
46
+ );
47
+ const w = useDerivedValue(
48
+ () => layout.value.perSeries?.timePill?.w ?? 0,
49
+ );
50
+ const h = useDerivedValue(
51
+ () => layout.value.perSeries?.timePill?.h ?? 0,
52
+ );
53
+ const text = useDerivedValue(
54
+ () => layout.value.perSeries?.timePill?.text ?? "",
55
+ );
56
+ const textX = useDerivedValue(
57
+ () => layout.value.perSeries?.timePill?.textX ?? -400,
58
+ );
59
+ const baselineY = useDerivedValue(
60
+ () => layout.value.perSeries?.timePill?.baselineY ?? 0,
61
+ );
62
+ const clip = useDerivedValue(() => ({
63
+ x: layout.value.perSeries?.timePill?.x ?? -400,
64
+ y: layout.value.perSeries?.timePill?.y ?? 0,
65
+ width: layout.value.perSeries?.timePill?.w ?? 0,
66
+ height: layout.value.perSeries?.timePill?.h ?? 0,
67
+ }));
68
+
69
+ return (
70
+ <Group opacity={opacity}>
71
+ <RoundedRect
72
+ x={x}
73
+ y={y}
74
+ width={w}
75
+ height={h}
76
+ r={config.timePillRadius}
77
+ color={background ?? palette.tooltipBg}
78
+ />
79
+ <RoundedRect
80
+ x={x}
81
+ y={y}
82
+ width={w}
83
+ height={h}
84
+ r={config.timePillRadius}
85
+ color={borderColor ?? palette.tooltipBorder}
86
+ style="stroke"
87
+ strokeWidth={1}
88
+ />
89
+ <Group clip={clip}>
90
+ <SkiaText
91
+ x={textX}
92
+ y={baselineY}
93
+ text={text}
94
+ font={font}
95
+ color={color ?? palette.tooltipText}
96
+ />
97
+ </Group>
98
+ </Group>
99
+ );
100
+ }
101
+
102
+ function SeriesPill({
103
+ index,
104
+ layout,
105
+ font,
106
+ palette,
107
+ config,
108
+ background,
109
+ labelColor,
110
+ valueColor,
111
+ borderColor,
112
+ }: {
113
+ index: number;
114
+ layout: TooltipLayoutValue;
115
+ font: SkFont;
116
+ palette: LiveChartPalette;
117
+ config: ResolvedPerSeriesTooltipConfig;
118
+ background?: string;
119
+ labelColor?: string;
120
+ valueColor?: string;
121
+ borderColor?: string;
122
+ }) {
123
+ const opacity = useDerivedValue(() => {
124
+ const pills = layout.value.perSeries?.pills;
125
+ return pills && index < pills.length ? 1 : 0;
126
+ });
127
+ const x = useDerivedValue(
128
+ () => layout.value.perSeries?.pills[index]?.x ?? -400,
129
+ );
130
+ const y = useDerivedValue(
131
+ () => layout.value.perSeries?.pills[index]?.y ?? 0,
132
+ );
133
+ const w = useDerivedValue(
134
+ () => layout.value.perSeries?.pills[index]?.w ?? 0,
135
+ );
136
+ const h = useDerivedValue(
137
+ () => layout.value.perSeries?.pills[index]?.h ?? 0,
138
+ );
139
+ const anchorX = useDerivedValue(
140
+ () => layout.value.perSeries?.pills[index]?.anchorX ?? -400,
141
+ );
142
+ const anchorY = useDerivedValue(
143
+ () => layout.value.perSeries?.pills[index]?.anchorY ?? -400,
144
+ );
145
+ const dotX = useDerivedValue(
146
+ () => layout.value.perSeries?.pills[index]?.dotX ?? -400,
147
+ );
148
+ const dotY = useDerivedValue(
149
+ () => layout.value.perSeries?.pills[index]?.dotY ?? -400,
150
+ );
151
+ const dotColor = useDerivedValue(
152
+ () => layout.value.perSeries?.pills[index]?.color ?? palette.tooltipText,
153
+ );
154
+ const label = useDerivedValue(
155
+ () => layout.value.perSeries?.pills[index]?.label ?? "",
156
+ );
157
+ const labelX = useDerivedValue(
158
+ () => layout.value.perSeries?.pills[index]?.labelX ?? -400,
159
+ );
160
+ const value = useDerivedValue(
161
+ () => layout.value.perSeries?.pills[index]?.value ?? "",
162
+ );
163
+ const valueX = useDerivedValue(
164
+ () => layout.value.perSeries?.pills[index]?.valueX ?? -400,
165
+ );
166
+ const baselineY = useDerivedValue(
167
+ () => layout.value.perSeries?.pills[index]?.baselineY ?? 0,
168
+ );
169
+ const clip = useDerivedValue(() => ({
170
+ x: layout.value.perSeries?.pills[index]?.x ?? -400,
171
+ y: layout.value.perSeries?.pills[index]?.y ?? 0,
172
+ width: layout.value.perSeries?.pills[index]?.w ?? 0,
173
+ height: layout.value.perSeries?.pills[index]?.h ?? 0,
174
+ }));
175
+
176
+ return (
177
+ <Group opacity={opacity}>
178
+ <Circle
179
+ cx={anchorX}
180
+ cy={anchorY}
181
+ r={config.intersectionDotSize / 2}
182
+ color={dotColor}
183
+ />
184
+ <RoundedRect
185
+ x={x}
186
+ y={y}
187
+ width={w}
188
+ height={h}
189
+ r={config.seriesPillRadius}
190
+ color={background ?? palette.tooltipBg}
191
+ />
192
+ <RoundedRect
193
+ x={x}
194
+ y={y}
195
+ width={w}
196
+ height={h}
197
+ r={config.seriesPillRadius}
198
+ color={borderColor ?? palette.tooltipBorder}
199
+ style="stroke"
200
+ strokeWidth={1}
201
+ />
202
+ <Group clip={clip}>
203
+ <Circle
204
+ cx={dotX}
205
+ cy={dotY}
206
+ r={config.seriesPillDotSize / 2}
207
+ color={dotColor}
208
+ />
209
+ <SkiaText
210
+ x={labelX}
211
+ y={baselineY}
212
+ text={label}
213
+ font={font}
214
+ color={labelColor ?? palette.tooltipText}
215
+ />
216
+ <SkiaText
217
+ x={valueX}
218
+ y={baselineY}
219
+ text={value}
220
+ font={font}
221
+ color={valueColor ?? labelColor ?? palette.tooltipText}
222
+ />
223
+ </Group>
224
+ </Group>
225
+ );
226
+ }
227
+
228
+ /** Skia-only per-series tooltip renderer; every position is SharedValue-driven. */
229
+ export function PerSeriesTooltipOverlay({
230
+ layout,
231
+ font,
232
+ palette,
233
+ config,
234
+ seriesCount,
235
+ tooltipBackground,
236
+ tooltipColor,
237
+ tooltipBorderColor,
238
+ }: {
239
+ layout: TooltipLayoutValue;
240
+ font: SkFont;
241
+ palette: LiveChartPalette;
242
+ config: ResolvedPerSeriesTooltipConfig;
243
+ seriesCount: number;
244
+ tooltipBackground?: string;
245
+ tooltipColor?: string;
246
+ tooltipBorderColor?: string;
247
+ }) {
248
+ const timeBackground =
249
+ config.timePillBackground ?? tooltipBackground;
250
+ const timeColor = config.timePillColor ?? tooltipColor;
251
+ const timeBorder =
252
+ config.timePillBorderColor ?? tooltipBorderColor;
253
+ const seriesBackground =
254
+ config.seriesPillBackground ?? tooltipBackground;
255
+ const seriesLabelColor =
256
+ config.seriesPillLabelColor ?? tooltipColor;
257
+ const seriesBorder =
258
+ config.seriesPillBorderColor ?? tooltipBorderColor;
259
+
260
+ return (
261
+ <Group>
262
+ <TimePill
263
+ layout={layout}
264
+ font={font}
265
+ palette={palette}
266
+ config={config}
267
+ background={timeBackground}
268
+ color={timeColor}
269
+ borderColor={timeBorder}
270
+ />
271
+ {Array.from({ length: seriesCount }, (_, index) => (
272
+ <SeriesPill
273
+ key={index}
274
+ index={index}
275
+ layout={layout}
276
+ font={font}
277
+ palette={palette}
278
+ config={config}
279
+ background={seriesBackground}
280
+ labelColor={seriesLabelColor}
281
+ valueColor={config.seriesPillValueColor}
282
+ borderColor={seriesBorder}
283
+ />
284
+ ))}
285
+ </Group>
286
+ );
287
+ }