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
@@ -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,
@@ -144,6 +145,8 @@ export interface ResolvedXAxisConfig {
144
145
 
145
146
  export interface ResolvedScrubConfig {
146
147
  tooltip: boolean;
148
+ /** Opt-in per-series pill tooltip for LiveChartSeries; null keeps guide-only behavior. */
149
+ seriesTooltip: ResolvedPerSeriesTooltipConfig | null;
147
150
  /** Opacity of content right of the crosshair while scrubbing (dstOut fade). */
148
151
  dimOpacity: number;
149
152
  /** undefined → palette.crosshairLine */
@@ -174,6 +177,36 @@ export interface ResolvedScrubConfig {
174
177
  hideOverlaysOnScrub: boolean;
175
178
  }
176
179
 
180
+ export interface ResolvedPerSeriesTooltipConfig {
181
+ alwaysShow: boolean;
182
+ bucketSeconds: number | undefined;
183
+ formatSeriesValue:
184
+ | ((value: number, seriesId: string) => string)
185
+ | undefined;
186
+ formatTimeRange: ((from: number, to: number) => string) | undefined;
187
+ maxLabelChars: number;
188
+ guideColor: string | undefined;
189
+ guideWidth: number;
190
+ guideDashPattern: number[] | undefined;
191
+ timePillBackground: string | undefined;
192
+ timePillColor: string | undefined;
193
+ timePillBorderColor: string | undefined;
194
+ timePillRadius: number;
195
+ timePillPaddingX: number;
196
+ timePillPaddingY: number;
197
+ seriesPillBackground: string | undefined;
198
+ seriesPillLabelColor: string | undefined;
199
+ seriesPillValueColor: string | undefined;
200
+ seriesPillBorderColor: string | undefined;
201
+ seriesPillRadius: number;
202
+ seriesPillPaddingX: number;
203
+ seriesPillPaddingY: number;
204
+ seriesPillDotSize: number;
205
+ seriesPillDotGap: number;
206
+ seriesPillLabelValueGap: number;
207
+ intersectionDotSize: number;
208
+ }
209
+
177
210
  export interface ResolvedScrubActionConfig {
178
211
  /** Glyph drawn in the action badge. */
179
212
  icon: string;
@@ -628,6 +661,7 @@ export function resolveXAxis(
628
661
 
629
662
  const SCRUB_DEFAULTS: ResolvedScrubConfig = {
630
663
  tooltip: true,
664
+ seriesTooltip: null,
631
665
  dimOpacity: 0.3,
632
666
  crosshairLineColor: undefined,
633
667
  crosshairDash: undefined,
@@ -644,6 +678,55 @@ const SCRUB_DEFAULTS: ResolvedScrubConfig = {
644
678
  hideOverlaysOnScrub: false,
645
679
  };
646
680
 
681
+ const PER_SERIES_TOOLTIP_DEFAULTS: ResolvedPerSeriesTooltipConfig = {
682
+ alwaysShow: false,
683
+ bucketSeconds: undefined,
684
+ formatSeriesValue: undefined,
685
+ formatTimeRange: undefined,
686
+ maxLabelChars: 14,
687
+ guideColor: undefined,
688
+ guideWidth: 1,
689
+ guideDashPattern: [3, 3],
690
+ timePillBackground: undefined,
691
+ timePillColor: undefined,
692
+ timePillBorderColor: undefined,
693
+ timePillRadius: 6,
694
+ timePillPaddingX: 8,
695
+ timePillPaddingY: 4,
696
+ seriesPillBackground: undefined,
697
+ seriesPillLabelColor: undefined,
698
+ seriesPillValueColor: undefined,
699
+ seriesPillBorderColor: undefined,
700
+ seriesPillRadius: 6,
701
+ seriesPillPaddingX: 8,
702
+ seriesPillPaddingY: 4,
703
+ seriesPillDotSize: 8,
704
+ seriesPillDotGap: 6,
705
+ seriesPillLabelValueGap: 6,
706
+ intersectionDotSize: 8,
707
+ };
708
+
709
+ function resolvePerSeriesTooltip(
710
+ prop: boolean | PerSeriesTooltipConfig | undefined,
711
+ ): ResolvedPerSeriesTooltipConfig | null {
712
+ const resolved = resolveToggle(
713
+ prop,
714
+ PER_SERIES_TOOLTIP_DEFAULTS,
715
+ false,
716
+ );
717
+ if (!resolved) return null;
718
+ const dash = typeof prop === "object" ? prop.guideDashPattern : undefined;
719
+ if (dash !== undefined) {
720
+ resolved.guideDashPattern = dash === true ? [3, 3] : dash || undefined;
721
+ }
722
+ resolved.maxLabelChars = Math.max(1, Math.floor(resolved.maxLabelChars));
723
+ resolved.bucketSeconds =
724
+ resolved.bucketSeconds !== undefined && resolved.bucketSeconds > 0
725
+ ? resolved.bucketSeconds
726
+ : undefined;
727
+ return resolved;
728
+ }
729
+
647
730
  /**
648
731
  * Resolves `scrub` prop to a fully-typed config or null (disabled).
649
732
  * `true` → defaults, object → merged with defaults, falsy → null.
@@ -657,6 +740,9 @@ export function resolveScrub(
657
740
  // through, anything falsy → solid (undefined).
658
741
  const dash = typeof prop === "object" ? prop.crosshairDash : undefined;
659
742
  resolved.crosshairDash = dash === true ? [4, 4] : dash || undefined;
743
+ const seriesTooltip =
744
+ typeof prop === "object" ? prop.seriesTooltip : undefined;
745
+ resolved.seriesTooltip = resolvePerSeriesTooltip(seriesTooltip);
660
746
  }
661
747
  return resolved;
662
748
  }
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { type SkFont } from "@shopify/react-native-skia";
2
2
  import { type Gesture } from "react-native-gesture-handler";
3
- import type { SharedValue } from "react-native-reanimated";
3
+ import type { DerivedValue, SharedValue } from "react-native-reanimated";
4
4
  import { measureFontTextWidth } from "../lib/measureFontTextWidth";
5
5
  import { type ChartPadding } from "../draw/line";
6
6
  import { interpolateAtTime } from "../math/interpolate";
@@ -48,6 +48,37 @@ export interface TooltipLayout {
48
48
  baselineY: number;
49
49
  dim: boolean;
50
50
  }[];
51
+ /** LiveChartSeries' opt-in Morfi-style time pill + per-series value pills. */
52
+ perSeries?: {
53
+ /** Idle endpoint mode: value pills only, with no guide or time pill. */
54
+ pinned: boolean;
55
+ timePill?: {
56
+ x: number;
57
+ y: number;
58
+ w: number;
59
+ h: number;
60
+ text: string;
61
+ textX: number;
62
+ baselineY: number;
63
+ };
64
+ pills: {
65
+ id: string;
66
+ color: string;
67
+ anchorX: number;
68
+ anchorY: number;
69
+ x: number;
70
+ y: number;
71
+ w: number;
72
+ h: number;
73
+ dotX: number;
74
+ dotY: number;
75
+ label: string;
76
+ labelX: number;
77
+ value: string;
78
+ valueX: number;
79
+ baselineY: number;
80
+ }[];
81
+ };
51
82
  }
52
83
 
53
84
  export const HIDDEN_TOOLTIP: TooltipLayout = {
@@ -62,6 +93,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
62
93
  line1Y: 0,
63
94
  line2Y: 0,
64
95
  stackedLines: undefined,
96
+ perSeries: undefined,
65
97
  };
66
98
 
67
99
  export interface CrosshairState {
@@ -70,7 +102,9 @@ export interface CrosshairState {
70
102
  scrubTime: SharedValue<number>;
71
103
  scrubValue: SharedValue<number | null>;
72
104
  crosshairOpacity: SharedValue<number>;
73
- tooltipLayout: SharedValue<TooltipLayout>;
105
+ tooltipLayout:
106
+ | SharedValue<TooltipLayout>
107
+ | DerivedValue<TooltipLayout>;
74
108
  /** Scrub intersection Y in canvas px; -1 when there's no dot to draw
75
109
  * (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
76
110
  scrubDotY: SharedValue<number>;
@@ -1,22 +1,95 @@
1
1
  import type { SharedValue } from "react-native-reanimated";
2
2
 
3
3
  type BooleanSharedValue = Pick<SharedValue<boolean>, "get" | "set">;
4
+ type NumberSharedValue = Pick<SharedValue<number>, "get" | "set">;
5
+
6
+ type TouchPoint = { x: number; y: number };
4
7
 
5
8
  type TouchCountEvent = {
6
9
  numberOfTouches: number;
7
10
  };
8
11
 
12
+ type TouchesEvent = TouchCountEvent & {
13
+ changedTouches: TouchPoint[];
14
+ allTouches: TouchPoint[];
15
+ };
16
+
9
17
  type GestureStateManager = {
10
18
  fail: () => void;
11
19
  };
12
20
 
13
- /** Mark the first pointer of a delayed pan as down. Zero-delay pans are inert. */
21
+ /**
22
+ * A pending hold-to-scrub pan must stay within this radius of its touch-down
23
+ * point to activate. Matches the platform long-press convention (RNGH
24
+ * LongPress `maxDist` / UIKit `allowableMovement` default 10) with a little
25
+ * slack for thumbs.
26
+ */
27
+ export const HOLD_MAX_DRIFT_PX = 12;
28
+
29
+ /**
30
+ * An activation earlier than `delayMs - slack` after the current touch went
31
+ * down cannot be a real hold — it is a stale `activateAfterLongPress` timer
32
+ * carried over from a previous touch (see `shouldStartDelayedPan`).
33
+ */
34
+ export const HOLD_TIMER_SLACK_MS = 50;
35
+
36
+ /** Record the first pointer of a delayed pan going down. Zero-delay pans are inert. */
14
37
  export function delayedPanTouchDown(
15
38
  delayMs: number,
39
+ event: TouchesEvent,
16
40
  fingerDown: BooleanSharedValue,
41
+ downX: NumberSharedValue,
42
+ downY: NumberSharedValue,
43
+ downAtMs: NumberSharedValue,
44
+ holdBroken: BooleanSharedValue,
45
+ ): void {
46
+ "worklet";
47
+ if (delayMs <= 0) return;
48
+ fingerDown.set(true);
49
+ const t = event.changedTouches[0];
50
+ if (t) {
51
+ downX.set(t.x);
52
+ downY.set(t.y);
53
+ }
54
+ downAtMs.set(Date.now());
55
+ holdBroken.set(false);
56
+ }
57
+
58
+ /**
59
+ * Enforce the stationary hold: while the delayed pan is still pending, any
60
+ * drift beyond HOLD_MAX_DRIFT_PX fails it outright AND latches `holdBroken`.
61
+ *
62
+ * `manager.fail()` alone is not enough. It only lands if the recognizer is
63
+ * still in a state that can fail, and — on iOS — the native
64
+ * `activateAfterLongPress` timer fires `handleGesture:inState:Active`
65
+ * unconditionally (RNPanHandler.m), so a timer that survived the fail still
66
+ * emits ACTIVE. The latch makes "the finger moved before the hold elapsed" a
67
+ * decision `shouldStartDelayedPan` can re-check at activation time, instead of
68
+ * trusting the native recognizer to have stayed failed.
69
+ *
70
+ * The latch also covers movement that returns to its origin: a finger that
71
+ * wanders 40px away and comes back is within the slop when the timer fires, but
72
+ * it was never a stationary hold.
73
+ */
74
+ export function delayedPanTouchMove(
75
+ delayMs: number,
76
+ event: TouchesEvent,
77
+ manager: GestureStateManager,
78
+ panActivated: BooleanSharedValue,
79
+ downX: NumberSharedValue,
80
+ downY: NumberSharedValue,
81
+ holdBroken: BooleanSharedValue,
17
82
  ): void {
18
83
  "worklet";
19
- if (delayMs > 0) fingerDown.set(true);
84
+ if (delayMs <= 0 || panActivated.get()) return;
85
+ const t = event.allTouches[0];
86
+ if (!t) return;
87
+ const dx = t.x - downX.get();
88
+ const dy = t.y - downY.get();
89
+ if (dx * dx + dy * dy > HOLD_MAX_DRIFT_PX * HOLD_MAX_DRIFT_PX) {
90
+ holdBroken.set(true);
91
+ manager.fail();
92
+ }
20
93
  }
21
94
 
22
95
  /**
@@ -49,15 +122,37 @@ export function delayedPanTouchCancelled(
49
122
  }
50
123
 
51
124
  /**
52
- * Record a real delayed-pan activation, or reject an activation whose timer
53
- * raced the final pointer-up. The rejected path deliberately keeps
54
- * `panActivated` false so a missing native FINALIZE cannot poison the next
55
- * interaction.
125
+ * Record a real delayed-pan activation, or reject a spurious one. Two spurious
126
+ * shapes exist, both from RNGH's long-press timer surviving where it shouldn't:
127
+ *
128
+ * 1. The timer fires after the final pointer already lifted (`fingerDown`
129
+ * false) — the original touch-up race.
130
+ * 2. The timer fires DURING a later touch: fast successive taps can land a new
131
+ * pointer before the failed recognizer's native reset completes, so the
132
+ * previous tap's timer fires only `now - downAtMs < delayMs` into the new
133
+ * touch. A real hold can never activate early, so reject anything faster
134
+ * than `delayMs - HOLD_TIMER_SLACK_MS`.
135
+ *
136
+ * 3. The timer fires after the finger already moved off the touch-down point
137
+ * (`holdBroken`) — it was a drag, not a hold. See `delayedPanTouchMove`.
138
+ *
139
+ * 4. The timer fires after the time-scroll pan already took the touch
140
+ * (`scrollActive`) — the chart is mid-drag, so the hold lost the race. The
141
+ * two pans are composed with `Gesture.Race`, which in RNGH declares NO
142
+ * relation between them (`RaceGestureType = ComposedGestureType`), so
143
+ * nothing native stops a losing recognizer from activating later. This latch
144
+ * is the arbitration.
145
+ *
146
+ * All rejected paths deliberately keep `panActivated` false so a missing
147
+ * native FINALIZE cannot poison the next interaction.
56
148
  */
57
149
  export function shouldStartDelayedPan(
58
150
  delayMs: number,
59
151
  fingerDown: BooleanSharedValue,
60
152
  panActivated: BooleanSharedValue,
153
+ downAtMs: NumberSharedValue,
154
+ holdBroken: BooleanSharedValue,
155
+ scrollActive: BooleanSharedValue,
61
156
  ): boolean {
62
157
  "worklet";
63
158
  if (delayMs <= 0) return true;
@@ -65,6 +160,14 @@ export function shouldStartDelayedPan(
65
160
  panActivated.set(false);
66
161
  return false;
67
162
  }
163
+ if (Date.now() - downAtMs.get() < delayMs - HOLD_TIMER_SLACK_MS) {
164
+ panActivated.set(false);
165
+ return false;
166
+ }
167
+ if (holdBroken.get() || scrollActive.get()) {
168
+ panActivated.set(false);
169
+ return false;
170
+ }
68
171
  panActivated.set(true);
69
172
  return true;
70
173
  }
@@ -73,8 +176,10 @@ export function shouldStartDelayedPan(
73
176
  export function resetDelayedPanGuard(
74
177
  fingerDown: BooleanSharedValue,
75
178
  panActivated: BooleanSharedValue,
179
+ holdBroken: BooleanSharedValue,
76
180
  ): void {
77
181
  "worklet";
78
182
  fingerDown.set(false);
79
183
  panActivated.set(false);
184
+ holdBroken.set(false);
80
185
  }