react-native-livechart 4.10.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 (114) hide show
  1. package/README.md +24 -0
  2. package/dist/components/CrosshairLine.d.ts +5 -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 +19 -12
  7. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -1
  8. package/dist/components/DegenParticlesOverlay.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/LeftEdgeFade.d.ts +3 -1
  12. package/dist/components/LeftEdgeFade.d.ts.map +1 -1
  13. package/dist/components/LiveChart.d.ts.map +1 -1
  14. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  15. package/dist/components/LoadingOverlay.d.ts +3 -1
  16. package/dist/components/LoadingOverlay.d.ts.map +1 -1
  17. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  18. package/dist/components/MultiSeriesDots.d.ts +3 -1
  19. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  20. package/dist/components/MultiSeriesTooltipStack.d.ts.map +1 -1
  21. package/dist/components/MultiSeriesValueLabels.d.ts +3 -1
  22. package/dist/components/MultiSeriesValueLabels.d.ts.map +1 -1
  23. package/dist/components/MultiSeriesValueLines.d.ts +3 -1
  24. package/dist/components/MultiSeriesValueLines.d.ts.map +1 -1
  25. package/dist/components/PerSeriesTooltipOverlay.d.ts +19 -0
  26. package/dist/components/PerSeriesTooltipOverlay.d.ts.map +1 -0
  27. package/dist/components/ReferenceLineOverlay.d.ts +20 -6
  28. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  29. package/dist/components/ThresholdSplitShader.d.ts.map +1 -1
  30. package/dist/components/YAxisOverlay.d.ts +3 -1
  31. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  32. package/dist/components/thresholdSplitShaderSource.d.ts +13 -0
  33. package/dist/components/thresholdSplitShaderSource.d.ts.map +1 -0
  34. package/dist/core/liveIndicatorVisibility.d.ts +9 -0
  35. package/dist/core/liveIndicatorVisibility.d.ts.map +1 -0
  36. package/dist/core/resolveConfig.d.ts +29 -0
  37. package/dist/core/resolveConfig.d.ts.map +1 -1
  38. package/dist/core/useLiveChartEngine.d.ts +9 -1
  39. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  40. package/dist/core/useLiveChartSeriesEngine.d.ts +10 -5
  41. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  42. package/dist/draw/particleAtlas.d.ts +3 -2
  43. package/dist/draw/particleAtlas.d.ts.map +1 -1
  44. package/dist/hooks/crosshairSeries.d.ts +10 -2
  45. package/dist/hooks/crosshairSeries.d.ts.map +1 -1
  46. package/dist/hooks/crosshairShared.d.ts +37 -2
  47. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  48. package/dist/hooks/delayedPanGuard.d.ts +66 -8
  49. package/dist/hooks/delayedPanGuard.d.ts.map +1 -1
  50. package/dist/hooks/useChartPaths.d.ts +5 -9
  51. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  52. package/dist/hooks/useCrosshair.d.ts +7 -1
  53. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  54. package/dist/hooks/useCrosshairSeries.d.ts +22 -3
  55. package/dist/hooks/useCrosshairSeries.d.ts.map +1 -1
  56. package/dist/hooks/useMomentum.d.ts +1 -1
  57. package/dist/hooks/useMomentum.d.ts.map +1 -1
  58. package/dist/hooks/useMultiSeriesLinePaths.d.ts +4 -4
  59. package/dist/hooks/useMultiSeriesLinePaths.d.ts.map +1 -1
  60. package/dist/hooks/usePanScroll.d.ts +15 -1
  61. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/math/lerp.d.ts.map +1 -1
  65. package/dist/math/momentum.d.ts +1 -1
  66. package/dist/math/momentum.d.ts.map +1 -1
  67. package/dist/math/referenceLines.d.ts +7 -0
  68. package/dist/math/referenceLines.d.ts.map +1 -1
  69. package/dist/math/squiggly.d.ts +6 -3
  70. package/dist/math/squiggly.d.ts.map +1 -1
  71. package/dist/math/threshold.d.ts +2 -2
  72. package/dist/types.d.ts +184 -16
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +1 -1
  75. package/src/components/CrosshairLine.tsx +21 -1
  76. package/src/components/CrosshairOverlay.tsx +48 -35
  77. package/src/components/CustomReferenceLineOverlay.tsx +58 -20
  78. package/src/components/DegenParticlesOverlay.tsx +40 -5
  79. package/src/components/DotOverlay.tsx +24 -8
  80. package/src/components/LeftEdgeFade.tsx +12 -6
  81. package/src/components/LiveChart.tsx +186 -53
  82. package/src/components/LiveChartSeries.tsx +214 -29
  83. package/src/components/LoadingOverlay.tsx +26 -12
  84. package/src/components/MarkerOverlay.tsx +20 -3
  85. package/src/components/MultiSeriesDots.tsx +4 -2
  86. package/src/components/MultiSeriesTooltipStack.tsx +4 -2
  87. package/src/components/MultiSeriesValueLabels.tsx +4 -2
  88. package/src/components/MultiSeriesValueLines.tsx +4 -2
  89. package/src/components/PerSeriesTooltipOverlay.tsx +287 -0
  90. package/src/components/ReferenceLineOverlay.tsx +274 -141
  91. package/src/components/ThresholdSplitShader.tsx +5 -37
  92. package/src/components/YAxisOverlay.tsx +5 -1
  93. package/src/components/thresholdSplitShaderSource.ts +70 -0
  94. package/src/core/liveIndicatorVisibility.ts +25 -0
  95. package/src/core/resolveConfig.ts +86 -0
  96. package/src/core/useLiveChartEngine.ts +110 -42
  97. package/src/core/useLiveChartSeriesEngine.ts +137 -70
  98. package/src/draw/particleAtlas.ts +21 -10
  99. package/src/hooks/crosshairSeries.ts +262 -33
  100. package/src/hooks/crosshairShared.ts +41 -2
  101. package/src/hooks/delayedPanGuard.ts +111 -6
  102. package/src/hooks/useChartPaths.ts +36 -15
  103. package/src/hooks/useCrosshair.ts +73 -14
  104. package/src/hooks/useCrosshairSeries.ts +106 -13
  105. package/src/hooks/useMomentum.ts +8 -1
  106. package/src/hooks/useMultiSeriesLinePaths.ts +15 -9
  107. package/src/hooks/usePanScroll.ts +46 -5
  108. package/src/index.ts +2 -0
  109. package/src/math/lerp.ts +7 -0
  110. package/src/math/momentum.ts +22 -5
  111. package/src/math/referenceLines.ts +38 -0
  112. package/src/math/squiggly.ts +28 -11
  113. package/src/math/threshold.ts +2 -2
  114. package/src/types.ts +197 -17
@@ -62,7 +62,8 @@ export interface ParticleInstance {
62
62
  * scale = r / spriteRadius
63
63
  * alpha = life * particleOpacity
64
64
  * Inactive (active <= 0.5), pre-spawn (dt < 0) and fully-faded (life <= 0)
65
- * slots are skipped.
65
+ * slots are skipped. Pass distinct `out` and `pool` arrays to retain inactive
66
+ * instance objects for later bursts while returning only the active prefix.
66
67
  */
67
68
  export function buildParticleInstances(
68
69
  buf: Float64Array,
@@ -71,9 +72,13 @@ export function buildParticleInstances(
71
72
  burstDur: number,
72
73
  particleOpacity: number,
73
74
  spriteRadius: number,
75
+ out?: ParticleInstance[],
76
+ pool?: ParticleInstance[],
74
77
  ): ParticleInstance[] {
75
78
  "worklet";
76
- const out: ParticleInstance[] = [];
79
+ const target = out ?? [];
80
+ const instances = pool ?? target;
81
+ let count = 0;
77
82
  for (let i = 0; i < slots; i++) {
78
83
  const base = i * DEGEN_STRIDE;
79
84
  if (!(buf[base + 5] > 0.5)) continue;
@@ -83,13 +88,19 @@ export function buildParticleInstances(
83
88
  if (life <= 0) continue;
84
89
  const size = buf[base + 6] || 1;
85
90
  const r = size * (0.5 + life * 0.5);
86
- out.push({
87
- x: buf[base + 0],
88
- y: buf[base + 1],
89
- scale: r / spriteRadius,
90
- alpha: life * particleOpacity,
91
- colorIndex: Math.max(0, Math.floor(buf[base + 7])),
92
- });
91
+ let instance = instances[count];
92
+ if (!instance) {
93
+ instance = { x: 0, y: 0, scale: 0, alpha: 0, colorIndex: 0 };
94
+ instances[count] = instance;
95
+ }
96
+ instance.x = buf[base + 0];
97
+ instance.y = buf[base + 1];
98
+ instance.scale = r / spriteRadius;
99
+ instance.alpha = life * particleOpacity;
100
+ instance.colorIndex = Math.max(0, Math.floor(buf[base + 7]));
101
+ target[count] = instance;
102
+ count += 1;
93
103
  }
94
- return out;
104
+ target.length = count;
105
+ return target;
95
106
  }
@@ -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";
@@ -14,6 +14,11 @@ const TOOLTIP_EDGE_GAP = 4;
14
14
  const TOOLTIP_TOP_MARGIN = 8;
15
15
  const FADE_ZONE = 4;
16
16
 
17
+ /** Horizontal travel required before a plain scrub claims the touch. */
18
+ export const SCRUB_ACTIVATE_X_PX = 20;
19
+ /** Vertical travel that fails a plain scrub so a parent scroll gesture can win. */
20
+ export const SCRUB_FAIL_Y_PX = 10;
21
+
17
22
  /** Measure rendered text so the pill and its content share the same centre. */
18
23
  function measureTooltipTextWidth(
19
24
  font: SkFont,
@@ -43,6 +48,37 @@ export interface TooltipLayout {
43
48
  baselineY: number;
44
49
  dim: boolean;
45
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
+ };
46
82
  }
47
83
 
48
84
  export const HIDDEN_TOOLTIP: TooltipLayout = {
@@ -57,6 +93,7 @@ export const HIDDEN_TOOLTIP: TooltipLayout = {
57
93
  line1Y: 0,
58
94
  line2Y: 0,
59
95
  stackedLines: undefined,
96
+ perSeries: undefined,
60
97
  };
61
98
 
62
99
  export interface CrosshairState {
@@ -65,7 +102,9 @@ export interface CrosshairState {
65
102
  scrubTime: SharedValue<number>;
66
103
  scrubValue: SharedValue<number | null>;
67
104
  crosshairOpacity: SharedValue<number>;
68
- tooltipLayout: SharedValue<TooltipLayout>;
105
+ tooltipLayout:
106
+ | SharedValue<TooltipLayout>
107
+ | DerivedValue<TooltipLayout>;
69
108
  /** Scrub intersection Y in canvas px; -1 when there's no dot to draw
70
109
  * (inactive / no value / degenerate range). See {@link computeScrubDotY}. */
71
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
  }