react-native-livechart 4.11.0 → 4.13.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 (82) hide show
  1. package/dist/components/CrosshairLine.d.ts +12 -2
  2. package/dist/components/CrosshairLine.d.ts.map +1 -1
  3. package/dist/components/CrosshairOverlay.d.ts +12 -2
  4. package/dist/components/CrosshairOverlay.d.ts.map +1 -1
  5. package/dist/components/CustomReferenceLineOverlay.d.ts +15 -10
  6. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  8. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  9. package/dist/components/DotOverlay.d.ts +3 -5
  10. package/dist/components/DotOverlay.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/PerSeriesTooltipOverlay.d.ts +21 -0
  14. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  15. package/dist/components/ReferenceLineOverlay.d.ts +27 -6
  16. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  17. package/dist/components/YAxisOverlay.d.ts +8 -2
  18. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  19. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  20. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  21. package/dist/core/resolveConfig.d.ts +45 -0
  22. package/dist/core/resolveConfig.d.ts.map +1 -1
  23. package/dist/draw/grid.d.ts +13 -0
  24. package/dist/draw/grid.d.ts.map +1 -1
  25. package/dist/hooks/crosshairSeries.d.ts +10 -2
  26. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  27. package/dist/hooks/crosshairShared.d.ts +55 -5
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  30. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  31. package/dist/hooks/useChartPaths.d.ts +5 -9
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useCrosshair.d.ts +13 -1
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useCrosshairSeries.d.ts +27 -3
  36. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  37. package/dist/hooks/useCrosshairVisibleOpacity.d.ts +8 -0
  38. package/dist/hooks/useCrosshairVisibleOpacity.d.ts.map +1 -0
  39. package/dist/hooks/useMomentum.d.ts +1 -1
  40. package/dist/hooks/useMomentum.d.ts.map +1 -1
  41. package/dist/hooks/usePanScroll.d.ts +15 -1
  42. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  43. package/dist/hooks/useReferenceLine.d.ts +10 -1
  44. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/math/lerp.d.ts.map +1 -1
  48. package/dist/math/momentum.d.ts +1 -1
  49. package/dist/math/momentum.d.ts.map +1 -1
  50. package/dist/math/referenceLines.d.ts +7 -0
  51. package/dist/math/referenceLines.d.ts.map +1 -1
  52. package/dist/types.d.ts +207 -18
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/components/CrosshairLine.tsx +39 -8
  56. package/src/components/CrosshairOverlay.tsx +49 -15
  57. package/src/components/CustomReferenceLineOverlay.tsx +42 -13
  58. package/src/components/CustomTooltipOverlay.tsx +15 -3
  59. package/src/components/DotOverlay.tsx +24 -8
  60. package/src/components/LiveChart.tsx +182 -39
  61. package/src/components/LiveChartSeries.tsx +195 -16
  62. package/src/components/PerSeriesTooltipOverlay.tsx +290 -0
  63. package/src/components/ReferenceLineOverlay.tsx +288 -141
  64. package/src/components/YAxisOverlay.tsx +39 -10
  65. package/src/core/liveIndicatorVisibility.ts +25 -0
  66. package/src/core/resolveConfig.ts +115 -0
  67. package/src/draw/grid.ts +33 -0
  68. package/src/hooks/crosshairSeries.ts +262 -33
  69. package/src/hooks/crosshairShared.ts +117 -6
  70. package/src/hooks/delayedPanGuard.ts +111 -6
  71. package/src/hooks/useChartPaths.ts +25 -15
  72. package/src/hooks/useCrosshair.ts +103 -32
  73. package/src/hooks/useCrosshairSeries.ts +126 -11
  74. package/src/hooks/useCrosshairVisibleOpacity.ts +57 -0
  75. package/src/hooks/useMomentum.ts +8 -1
  76. package/src/hooks/usePanScroll.ts +46 -5
  77. package/src/hooks/useReferenceLine.ts +26 -1
  78. package/src/index.ts +1 -0
  79. package/src/math/lerp.ts +7 -0
  80. package/src/math/momentum.ts +22 -5
  81. package/src/math/referenceLines.ts +38 -0
  82. package/src/types.ts +215 -18
@@ -0,0 +1,25 @@
1
+ import type { TimeScrollConfig } from "../types";
2
+
3
+ /**
4
+ * Resolve whether historical time-scroll views should suppress indicators that
5
+ * still point at the live price.
6
+ */
7
+ export function resolveHideLiveOnScrollBack(
8
+ timeScroll: boolean | TimeScrollConfig | undefined,
9
+ followViewEdge: boolean,
10
+ ): boolean {
11
+ const hide =
12
+ typeof timeScroll === "object"
13
+ ? (timeScroll.hideLiveOnScrollBack ?? true)
14
+ : true;
15
+ return hide && !followViewEdge;
16
+ }
17
+
18
+ /** Worklet-safe opacity gate shared by the live badge, dot, and dashed value line. */
19
+ export function liveIndicatorScrollOpacity(
20
+ hideLiveOnScrollBack: boolean,
21
+ viewEnd: number | null,
22
+ ): 0 | 1 {
23
+ "worklet";
24
+ return hideLiveOnScrollBack && viewEnd != null ? 0 : 1;
25
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  ReferenceLine,
25
25
  ScrubActionConfig,
26
26
  ScrubConfig,
27
+ PerSeriesTooltipConfig,
27
28
  SelectionDotConfig,
28
29
  SelectionDotProps,
29
30
  SelectionDotRingConfig,
@@ -88,6 +89,10 @@ export interface ResolvedYAxisConfig {
88
89
  minGap: number;
89
90
  /** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
90
91
  count: number;
92
+ /** undefined → keep the default centered-gutter label placement. */
93
+ labelRightMargin: number | undefined;
94
+ /** undefined → no gap; inert unless labelRightMargin is set. */
95
+ gridEndGap: number | undefined;
91
96
  /** Float the axis over a full-width plot (no reserved right gutter). */
92
97
  float: boolean;
93
98
  }
@@ -144,10 +149,22 @@ export interface ResolvedXAxisConfig {
144
149
 
145
150
  export interface ResolvedScrubConfig {
146
151
  tooltip: boolean;
152
+ /** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
153
+ seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
147
154
  /** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
148
155
  dimOpacity: number;
149
156
  /** undefined → palette.crosshairLine */
150
157
  crosshairLineColor: string | undefined;
158
+ /** Vertical crosshair line width in px. */
159
+ crosshairStrokeWidth: number;
160
+ /** Extension past the plot's top and bottom edges in px. */
161
+ crosshairOvershoot: number;
162
+ /** Fade the crosshair near the live edge. */
163
+ crosshairFade: boolean;
164
+ /** Visible-crosshair fade distance near the live edge in px. */
165
+ crosshairFadeDistance: number;
166
+ /** undefined preserves the existing Skia line-cap default. */
167
+ crosshairLineCap: "butt" | "round" | "square" | undefined;
151
168
  /** Dash intervals `[on, off, …]` for the crosshair line; undefined → solid. */
152
169
  crosshairDash: number[] | undefined;
153
170
  /** undefined → palette.crosshairDim */
@@ -172,6 +189,38 @@ export interface ResolvedScrubConfig {
172
189
  panGestureDelay: number;
173
190
  /** Fade markers + reference lines out while scrubbing. */
174
191
  hideOverlaysOnScrub: boolean;
192
+ /** Reject outside plain-scrub starts and clamp active scrub X to the plot. */
193
+ clampToPlot: boolean;
194
+ }
195
+
196
+ export interface ResolvedPerSeriesTooltipConfig {
197
+ alwaysShow: boolean;
198
+ bucketSeconds: number | undefined;
199
+ formatSeriesValue:
200
+ | ((value: number, seriesId: string) => string)
201
+ | undefined;
202
+ formatTimeRange: ((from: number, to: number) => string) | undefined;
203
+ maxLabelChars: number;
204
+ guideColor: string | undefined;
205
+ guideWidth: number;
206
+ guideDashPattern: number[] | undefined;
207
+ timePillBackground: string | undefined;
208
+ timePillColor: string | undefined;
209
+ timePillBorderColor: string | undefined;
210
+ timePillRadius: number;
211
+ timePillPaddingX: number;
212
+ timePillPaddingY: number;
213
+ seriesPillBackground: string | undefined;
214
+ seriesPillLabelColor: string | undefined;
215
+ seriesPillValueColor: string | undefined;
216
+ seriesPillBorderColor: string | undefined;
217
+ seriesPillRadius: number;
218
+ seriesPillPaddingX: number;
219
+ seriesPillPaddingY: number;
220
+ seriesPillDotSize: number;
221
+ seriesPillDotGap: number;
222
+ seriesPillLabelValueGap: number;
223
+ intersectionDotSize: number;
175
224
  }
176
225
 
177
226
  export interface ResolvedScrubActionConfig {
@@ -432,6 +481,8 @@ export function resolveBadge(
432
481
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
433
482
  minGap: 36,
434
483
  count: 0,
484
+ labelRightMargin: undefined,
485
+ gridEndGap: undefined,
435
486
  float: false,
436
487
  };
437
488
 
@@ -628,8 +679,14 @@ export function resolveXAxis(
628
679
 
629
680
  const SCRUB_DEFAULTS: ResolvedScrubConfig = {
630
681
  tooltip: true,
682
+ seriesTooltip: null,
631
683
  dimOpacity: 0.3,
632
684
  crosshairLineColor: undefined,
685
+ crosshairStrokeWidth: 1,
686
+ crosshairOvershoot: 0,
687
+ crosshairFade: true,
688
+ crosshairFadeDistance: 4,
689
+ crosshairLineCap: undefined,
633
690
  crosshairDash: undefined,
634
691
  crosshairDimColor: undefined,
635
692
  tooltipBackground: undefined,
@@ -642,8 +699,58 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
642
699
  tooltipShowTime: true,
643
700
  panGestureDelay: 0,
644
701
  hideOverlaysOnScrub: false,
702
+ clampToPlot: false,
645
703
  };
646
704
 
705
+ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
706
+ alwaysShow: false,
707
+ bucketSeconds: undefined,
708
+ formatSeriesValue: undefined,
709
+ formatTimeRange: undefined,
710
+ maxLabelChars: 14,
711
+ guideColor: undefined,
712
+ guideWidth: 1,
713
+ guideDashPattern: [3, 3],
714
+ timePillBackground: undefined,
715
+ timePillColor: undefined,
716
+ timePillBorderColor: undefined,
717
+ timePillRadius: 6,
718
+ timePillPaddingX: 8,
719
+ timePillPaddingY: 4,
720
+ seriesPillBackground: undefined,
721
+ seriesPillLabelColor: undefined,
722
+ seriesPillValueColor: undefined,
723
+ seriesPillBorderColor: undefined,
724
+ seriesPillRadius: 6,
725
+ seriesPillPaddingX: 8,
726
+ seriesPillPaddingY: 4,
727
+ seriesPillDotSize: 8,
728
+ seriesPillDotGap: 6,
729
+ seriesPillLabelValueGap: 6,
730
+ intersectionDotSize: 8,
731
+ };
732
+
733
+ function resolvePerSeriesTooltip(
734
+ prop: boolean | PerSeriesTooltipConfig | undefined,
735
+ ): ResolvedPerSeriesTooltipConfig | null {
736
+ const resolved = resolveToggle(
737
+ prop,
738
+ PER_SERIES_TOOLTIP_DEFAULTS,
739
+ false,
740
+ );
741
+ if (!resolved) return null;
742
+ const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
743
+ if (dash !== undefined) {
744
+ resolved.guideDashPattern = dash === true ? [3, 3] : dash || undefined;
745
+ }
746
+ resolved.maxLabelChars = Math.max(1, Math.floor(resolved.maxLabelChars));
747
+ resolved.bucketSeconds =
748
+ resolved.bucketSeconds !== undefined && resolved.bucketSeconds > 0
749
+ ? resolved.bucketSeconds
750
+ : undefined;
751
+ return resolved;
752
+ }
753
+
647
754
  /**
648
755
  * Resolves `scrub` prop to a fully-typed config or null (disabled).
649
756
  * `true` → defaults, object → merged with defaults, falsy → null.
@@ -657,6 +764,14 @@ export function resolveScrub(
657
764
  // through, anything falsy → solid (undefined).
658
765
  const dash = typeof prop === "object" ? prop.crosshairDash : undefined;
659
766
  resolved.crosshairDash = dash === true ? [4, 4] : dash || undefined;
767
+ const seriesTooltip =
768
+ typeof prop === "object" ? prop.seriesTooltip : undefined;
769
+ resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
770
+ resolved.crosshairOvershoot = Math.max(0, resolved.crosshairOvershoot);
771
+ resolved.crosshairFadeDistance = Math.max(
772
+ 0,
773
+ resolved.crosshairFadeDistance,
774
+ );
660
775
  }
661
776
  return resolved;
662
777
  }
package/src/draw/grid.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { GRID_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
2
+ import type { SkFont } from "@shopify/react-native-skia";
3
+ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
2
4
  import { lerp } from "../math/lerp";
3
5
  import type { GridMetrics } from "../types";
4
6
 
@@ -8,6 +10,37 @@ export interface YAxisEntry {
8
10
  alpha: number;
9
11
  }
10
12
 
13
+ export interface RightAnchoredYAxisColumnLayout {
14
+ /** Shared left X for every label in the column. */
15
+ labelX: number;
16
+ /** Grid/reference-line end X immediately before the column gap. */
17
+ gridEndX: number;
18
+ }
19
+
20
+ /**
21
+ * Measure the widest Y-axis label and anchor the shared left-aligned column to
22
+ * a fixed canvas-edge margin. Shared by the axis and reference-line worklets so
23
+ * their right edges cannot drift apart.
24
+ */
25
+ export function rightAnchoredYAxisColumnLayout(
26
+ canvasWidth: number,
27
+ entries: readonly YAxisEntry[],
28
+ font: SkFont,
29
+ labelRightMargin: number,
30
+ gridEndGap = 0,
31
+ ): RightAnchoredYAxisColumnLayout {
32
+ "worklet";
33
+ let maxTextW = 0;
34
+ for (let i = 0; i < entries.length; i++) {
35
+ maxTextW = Math.max(
36
+ maxTextW,
37
+ measureFontTextWidth(font, entries[i].label),
38
+ );
39
+ }
40
+ const labelX = canvasWidth - labelRightMargin - maxTextW;
41
+ return { labelX, gridEndX: labelX - gridEndGap };
42
+ }
43
+
11
44
  /**
12
45
  * Fixed-count Y-axis: place exactly `count` price labels evenly **in pixels**
13
46
  * across the plot band — top = `displayMax`, bottom = `displayMin`. Values track
@@ -1,13 +1,25 @@
1
1
  import { type SkFont } from "@shopify/react-native-skia";
2
+ import { MAX_MULTI_SERIES } from "../constants";
3
+ import type { ResolvedPerSeriesTooltipConfig } from "../core/resolveConfig";
2
4
  import { type ChartPadding } from "../draw/line";
5
+ import { measureFontTextWidth } from "../lib/measureFontTextWidth";
3
6
  import { interpolateAtTime } from "../math/interpolate";
4
7
  import type { ScrubSeriesValue, SeriesConfig } from "../types";
5
8
  import {
6
- computeTooltipLayoutMulti,
7
9
  HIDDEN_TOOLTIP,
8
10
  type TooltipLayout,
9
11
  } from "./crosshairShared";
10
12
 
13
+ const TOOLTIP_EDGE_GAP = 4;
14
+ const TOOLTIP_SIDE_GAP = 8;
15
+ const TOOLTIP_STACK_GAP = 4;
16
+ const TOOLTIP_TOP_GAP = 4;
17
+
18
+ function valueAtTime(series: SeriesConfig, time: number): number | null {
19
+ "worklet";
20
+ return interpolateAtTime(series.data, time);
21
+ }
22
+
11
23
  export function interpolateSeriesAtTime(
12
24
  series: SeriesConfig[],
13
25
  time: number,
@@ -17,7 +29,7 @@ export function interpolateSeriesAtTime(
17
29
  let primary: number | null = null;
18
30
  for (let i = 0; i < series.length; i++) {
19
31
  if (series[i].visible === false) continue;
20
- const v = interpolateAtTime(series[i].data, time);
32
+ const v = valueAtTime(series[i], time);
21
33
  if (v === null) continue;
22
34
  seriesValues.push({
23
35
  id: series[i].id,
@@ -29,50 +41,267 @@ export function interpolateSeriesAtTime(
29
41
  return { primary, seriesValues };
30
42
  }
31
43
 
32
- /** Series-chart scrub primary value at window time extracted for tests. */
33
- export function deriveScrubValueSeries(
34
- scrubActive: boolean,
35
- scrubTime: number,
36
- series: SeriesConfig[],
37
- ): number | null {
44
+ /** Infer a bucket width from the latest positive interval in a visible series. */
45
+ export function estimateSeriesBucketSeconds(series: SeriesConfig[]): number {
38
46
  "worklet";
39
- if (!scrubActive || scrubTime < 0) return null;
40
- return interpolateSeriesAtTime(series, scrubTime).primary;
47
+ for (let i = 0; i < series.length; i++) {
48
+ if (series[i].visible === false) continue;
49
+ const points = series[i].data;
50
+ for (let j = points.length - 1; j > 0; j--) {
51
+ const delta = points[j].time - points[j - 1].time;
52
+ if (delta > 0) return delta;
53
+ }
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ /** Truncate a series label with the same ellipsis treatment as Morfi web. */
59
+ export function truncateSeriesTooltipLabel(
60
+ label: string,
61
+ maxChars: number,
62
+ ): string {
63
+ "worklet";
64
+ if (label.length <= maxChars) return label;
65
+ return `${label.slice(0, Math.max(1, maxChars - 1))}…`;
66
+ }
67
+
68
+ function clamp(value: number, min: number, max: number): number {
69
+ "worklet";
70
+ if (max < min) return min;
71
+ return value < min ? min : value > max ? max : value;
41
72
  }
42
73
 
43
- /** Series-chart stacked tooltip at scrub time — extracted for tests. */
44
- export function computeSeriesScrubTooltipLayout(
74
+ /**
75
+ * Morfi-style per-series scrub tooltip geometry. Pure/worklet-safe so the
76
+ * entire readout can be recomputed on the UI thread without React renders.
77
+ */
78
+ export function computePerSeriesTooltipLayout(
45
79
  scrubActive: boolean,
46
80
  scrubX: number,
47
81
  scrubTime: number,
48
82
  series: SeriesConfig[],
83
+ displaySeriesValues: number[],
84
+ colors: string[],
85
+ displayMin: number,
86
+ displayMax: number,
49
87
  padding: ChartPadding,
50
88
  canvasWidth: number,
51
- formatValue: (v: number) => string,
52
- formatTime: (t: number) => string,
89
+ canvasHeight: number,
90
+ maxTime: number,
91
+ formatValue: (value: number) => string,
92
+ formatTime: (time: number) => string,
53
93
  font: SkFont,
94
+ config: ResolvedPerSeriesTooltipConfig,
54
95
  ): TooltipLayout {
55
96
  "worklet";
56
- if (!scrubActive || scrubTime < 0) return HIDDEN_TOOLTIP;
57
- const r = interpolateSeriesAtTime(series, scrubTime);
58
- if (r.primary === null) return HIDDEN_TOOLTIP;
59
- const lineObjs: { text: string; dim: boolean }[] = [
60
- { text: formatTime(scrubTime), dim: true },
61
- ];
62
- for (let k = 0; k < r.seriesValues.length; k++) {
63
- const sv = r.seriesValues[k];
64
- const label = sv.label ?? sv.id;
65
- lineObjs.push({
66
- text: `${label}: ${formatValue(sv.value)}`,
67
- dim: false,
97
+ const pinned = !scrubActive && config.alwaysShow;
98
+ if ((!scrubActive && !pinned) || canvasWidth <= 0 || canvasHeight <= 0) {
99
+ return HIDDEN_TOOLTIP;
100
+ }
101
+
102
+ const plotLeft = padding.left;
103
+ const plotRight = canvasWidth - padding.right;
104
+ const plotTop = padding.top;
105
+ const plotBottom = canvasHeight - padding.bottom;
106
+ if (plotRight <= plotLeft || plotBottom <= plotTop) return HIDDEN_TOOLTIP;
107
+
108
+ const anchorX = pinned
109
+ ? plotRight
110
+ : clamp(scrubX, plotLeft, plotRight);
111
+ const activeTime = Math.min(scrubTime, maxTime);
112
+ if (!pinned && activeTime < 0) return HIDDEN_TOOLTIP;
113
+
114
+ const fm = font.getMetrics();
115
+ const lineH = Math.max(1, -fm.ascent + fm.descent);
116
+ const seriesPillH = lineH + config.seriesPillPaddingY * 2;
117
+ const maxPillW = Math.max(1, plotRight - plotLeft - TOOLTIP_EDGE_GAP * 2);
118
+ const valueFormatter = config.formatSeriesValue;
119
+
120
+ const pills: NonNullable<
121
+ NonNullable<TooltipLayout["perSeries"]>["pills"]
122
+ > = [];
123
+ const preferredY: number[] = [];
124
+ const renderedSeriesCount = Math.min(series.length, MAX_MULTI_SERIES);
125
+ for (let i = 0; i < renderedSeriesCount; i++) {
126
+ const item = series[i];
127
+ if (item.visible === false || item.data.length === 0) continue;
128
+ const value = pinned
129
+ ? (displaySeriesValues[i] ?? item.value)
130
+ : valueAtTime(item, activeTime);
131
+ if (value === null || !Number.isFinite(value)) continue;
132
+
133
+ const label = truncateSeriesTooltipLabel(
134
+ item.label ?? item.id,
135
+ config.maxLabelChars,
136
+ );
137
+ const valueText = valueFormatter
138
+ ? valueFormatter(value, item.id)
139
+ : formatValue(value);
140
+ const labelW = measureFontTextWidth(font, label);
141
+ const valueW = measureFontTextWidth(font, valueText);
142
+ const labelGap = label.length > 0 ? config.seriesPillLabelValueGap : 0;
143
+ const rawPillW =
144
+ config.seriesPillPaddingX * 2 +
145
+ config.seriesPillDotSize +
146
+ config.seriesPillDotGap +
147
+ labelW +
148
+ labelGap +
149
+ valueW;
150
+ const pillW = Math.min(rawPillW, maxPillW);
151
+ let pillX = anchorX + TOOLTIP_SIDE_GAP;
152
+ if (pillX + pillW > plotRight - TOOLTIP_EDGE_GAP) {
153
+ pillX = anchorX - pillW - TOOLTIP_SIDE_GAP;
154
+ }
155
+ pillX = clamp(
156
+ pillX,
157
+ plotLeft + TOOLTIP_EDGE_GAP,
158
+ plotRight - TOOLTIP_EDGE_GAP - pillW,
159
+ );
160
+
161
+ const chartH = plotBottom - plotTop;
162
+ const valRange = displayMax - displayMin;
163
+ const anchorY =
164
+ valRange === 0
165
+ ? plotTop + chartH / 2
166
+ : clamp(
167
+ plotTop + ((displayMax - value) / valRange) * chartH,
168
+ plotTop,
169
+ plotBottom,
170
+ );
171
+ const dotX =
172
+ pillX + config.seriesPillPaddingX + config.seriesPillDotSize / 2;
173
+ const dotY = anchorY;
174
+ const labelX =
175
+ dotX + config.seriesPillDotSize / 2 + config.seriesPillDotGap;
176
+ const valueX = labelX + labelW + labelGap;
177
+ pills.push({
178
+ id: item.id,
179
+ color: colors[i] ?? item.color ?? "#ffffff",
180
+ anchorX,
181
+ anchorY,
182
+ x: pillX,
183
+ y: anchorY - seriesPillH / 2,
184
+ w: pillW,
185
+ h: seriesPillH,
186
+ dotX,
187
+ dotY,
188
+ label,
189
+ labelX,
190
+ value: valueText,
191
+ valueX,
192
+ baselineY: 0,
68
193
  });
194
+ preferredY.push(anchorY - seriesPillH / 2);
69
195
  }
70
- return computeTooltipLayoutMulti(
71
- scrubActive,
72
- scrubX,
73
- lineObjs,
74
- padding,
75
- canvasWidth,
76
- font,
196
+ if (pills.length === 0) return HIDDEN_TOOLTIP;
197
+
198
+ let timePill: NonNullable<
199
+ NonNullable<TooltipLayout["perSeries"]>["timePill"]
200
+ > | undefined;
201
+ if (!pinned) {
202
+ const bucketSeconds =
203
+ config.bucketSeconds ?? estimateSeriesBucketSeconds(series);
204
+ const from = activeTime;
205
+ const to = Math.max(from, Math.min(from + bucketSeconds, maxTime));
206
+ const timeText = config.formatTimeRange
207
+ ? config.formatTimeRange(from, to)
208
+ : bucketSeconds > 0
209
+ ? `${formatTime(from)} – ${formatTime(to)}`
210
+ : formatTime(from);
211
+ const timeW = Math.min(
212
+ measureFontTextWidth(font, timeText) + config.timePillPaddingX * 2,
213
+ maxPillW,
214
+ );
215
+ const timeH = lineH + config.timePillPaddingY * 2;
216
+ const timeX = clamp(
217
+ anchorX - timeW / 2,
218
+ plotLeft + TOOLTIP_EDGE_GAP,
219
+ plotRight - TOOLTIP_EDGE_GAP - timeW,
220
+ );
221
+ const timeY = plotTop + TOOLTIP_TOP_GAP;
222
+ timePill = {
223
+ x: timeX,
224
+ y: timeY,
225
+ w: timeW,
226
+ h: timeH,
227
+ text: timeText,
228
+ textX: timeX + config.timePillPaddingX,
229
+ baselineY: timeY + config.timePillPaddingY - fm.ascent,
230
+ };
231
+ }
232
+
233
+ // Collision avoidance: sort by preferred position, push down, then pull the
234
+ // stack back up from the bottom. If the plot is extremely short, compress the
235
+ // inter-pill gap to zero before accepting overlap as the unavoidable fallback.
236
+ const topBound = Math.min(
237
+ plotBottom - seriesPillH,
238
+ timePill
239
+ ? Math.max(plotTop, timePill.y + timePill.h + TOOLTIP_STACK_GAP)
240
+ : plotTop,
77
241
  );
242
+ const bottomBound = plotBottom;
243
+ const order = pills.map((_, index) => index);
244
+ order.sort((a, b) => preferredY[a] - preferredY[b]);
245
+
246
+ let stackGap = TOOLTIP_STACK_GAP;
247
+ const availableH = Math.max(0, bottomBound - topBound);
248
+ const requiredWithoutGaps = pills.length * seriesPillH;
249
+ if (pills.length > 1 && requiredWithoutGaps + stackGap * (pills.length - 1) > availableH) {
250
+ stackGap = Math.max(
251
+ 0,
252
+ (availableH - requiredWithoutGaps) / (pills.length - 1),
253
+ );
254
+ }
255
+
256
+ let cursor = topBound;
257
+ for (let i = 0; i < order.length; i++) {
258
+ const index = order[i];
259
+ const y = clamp(
260
+ Math.max(preferredY[index], cursor),
261
+ topBound,
262
+ Math.max(topBound, bottomBound - seriesPillH),
263
+ );
264
+ pills[index].y = y;
265
+ cursor = y + seriesPillH + stackGap;
266
+ }
267
+ for (let i = order.length - 2; i >= 0; i--) {
268
+ const index = order[i];
269
+ const next = pills[order[i + 1]];
270
+ pills[index].y = Math.max(
271
+ topBound,
272
+ Math.min(pills[index].y, next.y - seriesPillH - stackGap),
273
+ );
274
+ }
275
+ for (let i = 0; i < pills.length; i++) {
276
+ pills[i].dotY = pills[i].y + pills[i].h / 2;
277
+ pills[i].baselineY =
278
+ pills[i].y + config.seriesPillPaddingY - fm.ascent;
279
+ }
280
+
281
+ const firstRect = timePill ?? pills[0];
282
+ return {
283
+ x: firstRect.x,
284
+ y: firstRect.y,
285
+ w: firstRect.w,
286
+ h: firstRect.h,
287
+ valueStr: "",
288
+ timeStr: "",
289
+ valueTextX: -400,
290
+ timeTextX: -400,
291
+ line1Y: 0,
292
+ line2Y: 0,
293
+ stackedLines: undefined,
294
+ perSeries: { pinned, timePill, pills },
295
+ };
296
+ }
297
+
298
+ /** Series-chart scrub primary value at window time — extracted for tests. */
299
+ export function deriveScrubValueSeries(
300
+ scrubActive: boolean,
301
+ scrubTime: number,
302
+ series: SeriesConfig[],
303
+ ): number | null {
304
+ "worklet";
305
+ if (!scrubActive || scrubTime < 0) return null;
306
+ return interpolateSeriesAtTime(series, scrubTime).primary;
78
307
  }