react-native-livechart 4.19.0 → 4.21.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 (88) hide show
  1. package/dist/components/CustomThresholdBadgeOverlay.d.ts +18 -0
  2. package/dist/components/CustomThresholdBadgeOverlay.d.ts.map +1 -0
  3. package/dist/components/CustomTooltipOverlay.d.ts +4 -2
  4. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  5. package/dist/components/LiveChart.d.ts +2 -2
  6. package/dist/components/LiveChart.d.ts.map +1 -1
  7. package/dist/components/LiveChartSeries.d.ts +2 -2
  8. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  9. package/dist/components/ReferenceLineOverlay.d.ts +9 -4
  10. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  11. package/dist/components/ReferenceLineSeriesOverlay.d.ts +19 -0
  12. package/dist/components/ReferenceLineSeriesOverlay.d.ts.map +1 -0
  13. package/dist/core/liveChartEngineTick.d.ts +6 -1
  14. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  15. package/dist/core/resolveConfig.d.ts +34 -1
  16. package/dist/core/resolveConfig.d.ts.map +1 -1
  17. package/dist/core/useLiveChartEngine.d.ts +9 -1
  18. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  19. package/dist/draw/candle.d.ts +3 -1
  20. package/dist/draw/candle.d.ts.map +1 -1
  21. package/dist/draw/candleGap.d.ts +14 -0
  22. package/dist/draw/candleGap.d.ts.map +1 -0
  23. package/dist/draw/grid.d.ts +1 -1
  24. package/dist/draw/grid.d.ts.map +1 -1
  25. package/dist/draw/lineGap.d.ts +25 -0
  26. package/dist/draw/lineGap.d.ts.map +1 -0
  27. package/dist/hooks/crosshairShared.d.ts +7 -3
  28. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  29. package/dist/hooks/useCandleGapPaths.d.ts +12 -0
  30. package/dist/hooks/useCandleGapPaths.d.ts.map +1 -0
  31. package/dist/hooks/useChartPaths.d.ts +4 -1
  32. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  33. package/dist/hooks/useCrosshair.d.ts +9 -5
  34. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  35. package/dist/hooks/useLineGapPaths.d.ts +10 -0
  36. package/dist/hooks/useLineGapPaths.d.ts.map +1 -0
  37. package/dist/hooks/usePinchZoom.d.ts +7 -0
  38. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  39. package/dist/hooks/useReferenceLine.d.ts +4 -3
  40. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  41. package/dist/hooks/useReferenceLineSeries.d.ts +23 -0
  42. package/dist/hooks/useReferenceLineSeries.d.ts.map +1 -0
  43. package/dist/hooks/useThreshold.d.ts +13 -13
  44. package/dist/hooks/useThreshold.d.ts.map +1 -1
  45. package/dist/hooks/useYAxis.d.ts +1 -1
  46. package/dist/hooks/useYAxis.d.ts.map +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/math/candleGaps.d.ts +18 -0
  50. package/dist/math/candleGaps.d.ts.map +1 -0
  51. package/dist/math/referenceLineSeries.d.ts +12 -0
  52. package/dist/math/referenceLineSeries.d.ts.map +1 -0
  53. package/dist/math/referenceLines.d.ts +5 -5
  54. package/dist/math/referenceLines.d.ts.map +1 -1
  55. package/dist/math/spline.d.ts +5 -1
  56. package/dist/math/spline.d.ts.map +1 -1
  57. package/dist/types.d.ts +212 -14
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/components/CustomThresholdBadgeOverlay.tsx +74 -0
  61. package/src/components/CustomTooltipOverlay.tsx +7 -3
  62. package/src/components/LiveChart.tsx +353 -56
  63. package/src/components/LiveChartSeries.tsx +28 -6
  64. package/src/components/ReferenceLineOverlay.tsx +38 -7
  65. package/src/components/ReferenceLineSeriesOverlay.tsx +141 -0
  66. package/src/core/liveChartEngineTick.ts +34 -1
  67. package/src/core/resolveConfig.ts +197 -1
  68. package/src/core/useLiveChartEngine.ts +37 -5
  69. package/src/draw/candle.ts +1 -1
  70. package/src/draw/candleGap.ts +132 -0
  71. package/src/draw/grid.ts +34 -4
  72. package/src/draw/lineGap.ts +180 -0
  73. package/src/hooks/crosshairShared.ts +92 -11
  74. package/src/hooks/useCandleGapPaths.ts +88 -0
  75. package/src/hooks/useChartPaths.ts +81 -22
  76. package/src/hooks/useCrosshair.ts +126 -40
  77. package/src/hooks/useLineGapPaths.ts +76 -0
  78. package/src/hooks/usePinchZoom.ts +21 -0
  79. package/src/hooks/useReferenceLine.ts +6 -5
  80. package/src/hooks/useReferenceLineSeries.ts +101 -0
  81. package/src/hooks/useThreshold.ts +31 -22
  82. package/src/hooks/useYAxis.ts +2 -0
  83. package/src/index.ts +16 -0
  84. package/src/math/candleGaps.ts +120 -0
  85. package/src/math/referenceLineSeries.ts +83 -0
  86. package/src/math/referenceLines.ts +34 -6
  87. package/src/math/spline.ts +15 -5
  88. package/src/types.ts +224 -14
@@ -17,7 +17,12 @@ import {
17
17
  type SharedValue,
18
18
  } from "react-native-reanimated";
19
19
  import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
20
- import type { CandlePoint, LiveChartPoint, SeriesConfig } from "../types";
20
+ import type {
21
+ CandleGap,
22
+ CandlePoint,
23
+ LiveChartPoint,
24
+ SeriesConfig,
25
+ } from "../types";
21
26
  import {
22
27
  tickLiveChartEngineFrame,
23
28
  type EngineTickInput,
@@ -95,6 +100,10 @@ export interface EngineConfig {
95
100
  mode?: "line" | "candle";
96
101
  candles?: SharedValue<CandlePoint[]>;
97
102
  liveCandle?: SharedValue<CandlePoint | null>;
103
+ candleGaps?: CandleGap[];
104
+ candleGapBridgeNoTrades?: boolean;
105
+ candleGapBridgeUnavailable?: boolean;
106
+ candleGapBridgeUnknown?: boolean;
98
107
  }
99
108
 
100
109
  /** Canvas, time window, and Y-range — shared by single- and multi-series engines. */
@@ -238,6 +247,10 @@ export interface EngineFrameRefs {
238
247
  modeSV: SharedValue<"line" | "candle">;
239
248
  candles?: SharedValue<CandlePoint[]>;
240
249
  liveCandle?: SharedValue<CandlePoint | null>;
250
+ candleGapsSV?: SharedValue<CandleGap[] | undefined>;
251
+ candleGapBridgeNoTradesSV?: SharedValue<boolean>;
252
+ candleGapBridgeUnavailableSV?: SharedValue<boolean>;
253
+ candleGapBridgeUnknownSV?: SharedValue<boolean>;
241
254
  extremaMinValue: SharedValue<number>;
242
255
  extremaMaxValue: SharedValue<number>;
243
256
  extremaMinTime: SharedValue<number>;
@@ -359,6 +372,12 @@ export function applyLiveChartEngineFrame(
359
372
  input.mode = sv.modeSV.value;
360
373
  input.candles = sv.candles?.value;
361
374
  input.liveCandle = sv.liveCandle?.value;
375
+ input.candleGaps = sv.candleGapsSV?.value;
376
+ input.candleGapBridgeNoTrades =
377
+ sv.candleGapBridgeNoTradesSV?.value ?? false;
378
+ input.candleGapBridgeUnavailable =
379
+ sv.candleGapBridgeUnavailableSV?.value ?? false;
380
+ input.candleGapBridgeUnknown = sv.candleGapBridgeUnknownSV?.value ?? false;
362
381
  tickLiveChartEngineFrame(state, input);
363
382
  sv.displayValue.value = state.displayValue;
364
383
  sv.displayMin.value = state.displayMin;
@@ -452,6 +471,16 @@ export function useLiveChartEngine(
452
471
  () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
453
472
  );
454
473
  const modeSV = useDerivedValue(() => config.mode ?? "line");
474
+ const candleGapsSV = useDerivedValue(() => config.candleGaps);
475
+ const candleGapBridgeNoTradesSV = useDerivedValue(
476
+ () => config.candleGapBridgeNoTrades ?? false,
477
+ );
478
+ const candleGapBridgeUnavailableSV = useDerivedValue(
479
+ () => config.candleGapBridgeUnavailable ?? false,
480
+ );
481
+ const candleGapBridgeUnknownSV = useDerivedValue(
482
+ () => config.candleGapBridgeUnknown ?? false,
483
+ );
455
484
 
456
485
  // Animation state (mutated on UI thread each frame)
457
486
  const displayValue = useSharedValue(0);
@@ -548,15 +577,18 @@ export function useLiveChartEngine(
548
577
  modeSV,
549
578
  candles,
550
579
  liveCandle,
580
+ candleGapsSV,
581
+ candleGapBridgeNoTradesSV,
582
+ candleGapBridgeUnavailableSV,
583
+ candleGapBridgeUnknownSV,
551
584
  extremaMinValue,
552
585
  extremaMaxValue,
553
586
  extremaMinTime,
554
587
  extremaMaxTime,
555
588
  };
556
- const frameScratchRef = useRef<EngineFrameScratch | null>(null);
557
- if (frameScratchRef.current === null) {
558
- frameScratchRef.current = makeEngineFrameScratch();
559
- }
589
+ const [frameScratchRef] = useState(() => ({
590
+ current: makeEngineFrameScratch(),
591
+ }));
560
592
 
561
593
  // `autostart=false` registers the frame callback without running it — the live
562
594
  // loop is fully inert in static mode (the invariant that makes this worth it).
@@ -80,7 +80,7 @@ function candleValueToY(
80
80
  * repainting only the selected body and wick without building another SkPath.
81
81
  */
82
82
  export function computeCandleFocusClip(
83
- candle: CandlePoint | null,
83
+ candle: { time: number } | null,
84
84
  padding: ChartPadding,
85
85
  canvasW: number,
86
86
  canvasH: number,
@@ -0,0 +1,132 @@
1
+ import { CANDLE_METRICS_DEFAULTS } from "../constants";
2
+ import {
3
+ candleAtBucketTime,
4
+ previousCandleAtTime,
5
+ previousCandleCloseAtTime,
6
+ } from "../math/candleGaps";
7
+ import type {
8
+ CandleGap,
9
+ CandleGapKind,
10
+ CandleMetrics,
11
+ CandlePoint,
12
+ } from "../types";
13
+ import type { ChartPadding } from "./line";
14
+
15
+ export interface CandleGapMark {
16
+ x1: number;
17
+ x2: number;
18
+ y: number;
19
+ kind: CandleGapKind;
20
+ }
21
+
22
+ export interface CandleGapGeometry {
23
+ marks: CandleGapMark[];
24
+ }
25
+
26
+ /** Pure worklet geometry for neutral previous-close marks in explicit gaps. */
27
+ export function buildCandleGapGeometry(
28
+ candles: CandlePoint[],
29
+ liveCandle: CandlePoint | null,
30
+ gaps: CandleGap[],
31
+ padding: ChartPadding,
32
+ canvasW: number,
33
+ canvasH: number,
34
+ winStart: number,
35
+ windowSecs: number,
36
+ displayMin: number,
37
+ displayMax: number,
38
+ candleWidthSecs: number,
39
+ bridgeNoTrades: boolean,
40
+ bridgeUnavailable: boolean,
41
+ bridgeUnknown: boolean,
42
+ metrics: CandleMetrics = CANDLE_METRICS_DEFAULTS,
43
+ ): CandleGapGeometry {
44
+ "worklet";
45
+ const chartW = canvasW - padding.left - padding.right;
46
+ const chartH = canvasH - padding.top - padding.bottom;
47
+ const valueRange = displayMax - displayMin;
48
+ if (
49
+ chartW <= 0 ||
50
+ chartH <= 0 ||
51
+ valueRange <= 0 ||
52
+ windowSecs <= 0 ||
53
+ candleWidthSecs <= 0
54
+ ) {
55
+ return { marks: [] };
56
+ }
57
+
58
+ const slotPx = (candleWidthSecs / windowSecs) * chartW;
59
+ const bodyW = Math.max(
60
+ 1,
61
+ Math.min(
62
+ slotPx * metrics.bodyWidthRatio,
63
+ slotPx - metrics.minGapPx,
64
+ metrics.maxBodyPx,
65
+ ),
66
+ );
67
+ const winEnd = winStart + windowSecs;
68
+ const chartLeft = padding.left;
69
+ const chartRight = canvasW - padding.right;
70
+ const marks: CandleGapMark[] = [];
71
+
72
+ for (let gi = 0; gi < gaps.length; gi++) {
73
+ const gap = gaps[gi];
74
+ if (gap.from >= winEnd || gap.to <= winStart) continue;
75
+ const bridge =
76
+ gap.kind === "no-trades"
77
+ ? bridgeNoTrades
78
+ : gap.kind === "unavailable"
79
+ ? bridgeUnavailable
80
+ : bridgeUnknown;
81
+ if (!bridge) continue;
82
+
83
+ const seedTime = Math.max(gap.from, winStart - candleWidthSecs);
84
+ const previous = previousCandleAtTime(candles, liveCandle, seedTime);
85
+ const origin =
86
+ previous?.time ?? candles[0]?.time ?? liveCandle?.time ?? gap.from;
87
+ let bucket =
88
+ origin +
89
+ Math.ceil((gap.from - origin) / candleWidthSecs - 1e-9) *
90
+ candleWidthSecs;
91
+ if (bucket < gap.from) bucket += candleWidthSecs;
92
+ if (bucket + candleWidthSecs < winStart) {
93
+ bucket +=
94
+ Math.floor((winStart - bucket) / candleWidthSecs) * candleWidthSecs;
95
+ while (bucket + candleWidthSecs < winStart) bucket += candleWidthSecs;
96
+ }
97
+
98
+ for (
99
+ ;
100
+ bucket + candleWidthSecs <= gap.to && bucket <= winEnd;
101
+ bucket += candleWidthSecs
102
+ ) {
103
+ if (
104
+ candleAtBucketTime(candles, liveCandle, bucket, candleWidthSecs) !== null
105
+ ) {
106
+ continue;
107
+ }
108
+ const close = previousCandleCloseAtTime(candles, liveCandle, bucket);
109
+ if (close === null) continue;
110
+
111
+ const centerTime = bucket + candleWidthSecs / 2;
112
+ const centerX =
113
+ padding.left + ((centerTime - winStart) / windowSecs) * chartW;
114
+ if (
115
+ centerX < chartLeft - bodyW / 2 ||
116
+ centerX > chartRight + bodyW / 2
117
+ ) {
118
+ continue;
119
+ }
120
+ const y =
121
+ padding.top + ((displayMax - close) / valueRange) * chartH;
122
+ marks.push({
123
+ x1: Math.max(chartLeft, centerX - bodyW / 2),
124
+ x2: Math.min(chartRight, centerX + bodyW / 2),
125
+ y,
126
+ kind: gap.kind,
127
+ });
128
+ }
129
+ }
130
+
131
+ return { marks };
132
+ }
package/src/draw/grid.ts CHANGED
@@ -89,6 +89,7 @@ export function pickInterval(
89
89
  prev: number,
90
90
  ): number {
91
91
  "worklet";
92
+ if (!Number.isFinite(valRange) || !Number.isFinite(pxPerUnit)) return 0;
92
93
  if (prev > 0) {
93
94
  const px = prev * pxPerUnit;
94
95
  if (px >= minGap * 0.5 && px <= minGap * 4) return prev;
@@ -103,6 +104,9 @@ export function pickInterval(
103
104
  for (let s = 0; s < divisorSets.length; s++) {
104
105
  const divs = divisorSets[s];
105
106
  let span = Math.pow(10, Math.ceil(Math.log10(valRange)));
107
+ // `10 ** ceil(log10(Number.MAX_VALUE))` overflows even though the input is
108
+ // finite. Starting from the range itself preserves a finite upper bound.
109
+ if (!Number.isFinite(span) && valRange > 0) span = valRange;
106
110
  let i = 0;
107
111
  while ((span / divs[i % 3]) * pxPerUnit >= minGap) {
108
112
  span /= divs[i % 3];
@@ -165,6 +169,7 @@ export function computeGridEntries(
165
169
  minGap = 36,
166
170
  grid: GridMetrics = GRID_METRICS_DEFAULTS,
167
171
  count = 0,
172
+ intervalScale = 1,
168
173
  ): { entries: YAxisEntry[]; interval: number } {
169
174
  "worklet";
170
175
  const chartH = canvasHeight - padTop - padBottom;
@@ -194,7 +199,29 @@ export function computeGridEntries(
194
199
 
195
200
  const pxPerUnit = chartH / valRange;
196
201
 
197
- const coarse = pickInterval(valRange, pxPerUnit, minGap, prevInterval);
202
+ let appliedIntervalScale = intervalScale;
203
+ let scaledRange = valRange * appliedIntervalScale;
204
+ let pxPerScaledUnit = pxPerUnit / appliedIntervalScale;
205
+ // A positive finite multiplier can still overflow/underflow when combined
206
+ // with the live range. Fall back to source-unit interval selection so an
207
+ // unrepresentable display range cannot poison or freeze this UI worklet.
208
+ if (
209
+ !Number.isFinite(scaledRange) ||
210
+ scaledRange <= 0 ||
211
+ !Number.isFinite(pxPerScaledUnit) ||
212
+ pxPerScaledUnit <= 0
213
+ ) {
214
+ appliedIntervalScale = 1;
215
+ scaledRange = valRange;
216
+ pxPerScaledUnit = pxPerUnit;
217
+ }
218
+ const interval = pickInterval(
219
+ scaledRange,
220
+ pxPerScaledUnit,
221
+ minGap,
222
+ appliedIntervalScale === intervalScale ? prevInterval : 0,
223
+ );
224
+ const coarse = interval / appliedIntervalScale;
198
225
  const fine = coarse / 2;
199
226
  const finePx = fine * pxPerUnit;
200
227
  const fineTarget = fineLineTargetAlpha(finePx, minGap);
@@ -227,7 +254,10 @@ export function computeGridEntries(
227
254
  fromEdge >= fadeZone ? 1 : fromEdge <= 0 ? 0 : fromEdge / fadeZone;
228
255
 
229
256
  const target = (isCoarse ? 1 : fineTarget) * edgeAlpha;
230
- const key = Math.round(val * 1e10);
257
+ // Snap to the exact fine-grid multiple. Unlike a fixed decimal precision,
258
+ // this keeps adjacent sub-1e-10 source values distinct while remaining
259
+ // stable when the same line is recomputed on later frames.
260
+ const key = Math.round(val / fine) * fine;
231
261
  targets[key] = target;
232
262
  }
233
263
 
@@ -265,12 +295,12 @@ export function computeGridEntries(
265
295
  const key = Number(allKeys[i]);
266
296
  const alpha = labelAlphas[key];
267
297
  if (alpha < 0.02) continue;
268
- const val = key / 1e10;
298
+ const val = key;
269
299
  const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
270
300
  /* istanbul ignore next -- vertical clip; y stays in-band when val comes from tracked keys */
271
301
  if (y < padTop - 10 || y > canvasHeight - padBottom + 10) continue;
272
302
  entries.push({ y, label: formatValue(val), alpha });
273
303
  }
274
304
 
275
- return { entries, interval: coarse };
305
+ return { entries, interval };
276
306
  }
@@ -0,0 +1,180 @@
1
+ import type { CandleGap, CandleGapKind, LiveChartPoint } from "../types";
2
+ import type { ChartPadding } from "./line";
3
+
4
+ export interface LineGapBridge {
5
+ x1: number;
6
+ x2: number;
7
+ y: number;
8
+ kind: CandleGapKind;
9
+ }
10
+
11
+ export interface LineGapGeometry {
12
+ bridges: LineGapBridge[];
13
+ }
14
+
15
+ /** Whether an explicit gap conflicts with at least one real line sample. */
16
+ export function lineGapContainsData(
17
+ data: LiveChartPoint[],
18
+ gap: CandleGap,
19
+ ): boolean {
20
+ "worklet";
21
+ let lo = 0;
22
+ let hi = data.length;
23
+ while (lo < hi) {
24
+ const mid = (lo + hi) >> 1;
25
+ if (data[mid].time < gap.from) lo = mid + 1;
26
+ else hi = mid;
27
+ }
28
+ return lo < data.length && data[lo].time < gap.to;
29
+ }
30
+
31
+ /** Latest observed line value strictly before `time`. */
32
+ export function previousLineValueAtTime(
33
+ data: LiveChartPoint[],
34
+ time: number,
35
+ ): number | null {
36
+ "worklet";
37
+ let lo = 0;
38
+ let hi = data.length;
39
+ while (lo < hi) {
40
+ const mid = (lo + hi) >> 1;
41
+ if (data[mid].time < time) lo = mid + 1;
42
+ else hi = mid;
43
+ }
44
+ return lo > 0 ? data[lo - 1].value : null;
45
+ }
46
+
47
+ /**
48
+ * Contiguous point ranges that may be drawn without interpolating across an
49
+ * explicit empty interval. Each pair is `[startPoint, endPointExclusive]`.
50
+ * Real samples inside a declared gap make that gap inert, matching the candle
51
+ * contract that observed market data always wins over presentation metadata.
52
+ */
53
+ export function buildLineGapSegmentRanges(
54
+ points: number[],
55
+ data: LiveChartPoint[],
56
+ gaps: CandleGap[],
57
+ winStart: number,
58
+ windowSecs: number,
59
+ plotLeft: number,
60
+ plotWidth: number,
61
+ out?: number[],
62
+ gapXsOut?: number[],
63
+ ): number[] {
64
+ "worklet";
65
+ const ranges = out ?? [];
66
+ if (out) out.length = 0;
67
+ const count = points.length >> 1;
68
+ if (count < 2 || windowSecs <= 0 || plotWidth <= 0) return ranges;
69
+ if (gaps.length === 0) {
70
+ ranges.push(0, count);
71
+ return ranges;
72
+ }
73
+
74
+ const winEnd = winStart + windowSecs;
75
+ const xScale = plotWidth / windowSecs;
76
+ const gapXs = gapXsOut ?? [];
77
+ if (gapXsOut) gapXsOut.length = 0;
78
+ for (let gi = 0; gi < gaps.length; gi++) {
79
+ const gap = gaps[gi];
80
+ if (
81
+ gap.from >= gap.to ||
82
+ gap.from >= winEnd ||
83
+ gap.to <= winStart ||
84
+ lineGapContainsData(data, gap)
85
+ ) {
86
+ continue;
87
+ }
88
+ gapXs.push(
89
+ plotLeft + (gap.from - winStart) * xScale,
90
+ plotLeft + (gap.to - winStart) * xScale,
91
+ );
92
+ }
93
+ if (gapXs.length === 0) {
94
+ ranges.push(0, count);
95
+ return ranges;
96
+ }
97
+
98
+ let segmentStart = 0;
99
+
100
+ for (let i = 1; i < count; i++) {
101
+ const previousX = points[(i - 1) * 2];
102
+ const currentX = points[i * 2];
103
+ let breaksHere = false;
104
+ for (let gi = 0; gi < gapXs.length; gi += 2) {
105
+ const gapStartX = gapXs[gi];
106
+ const gapEndX = gapXs[gi + 1];
107
+ if (previousX <= gapStartX && currentX >= gapEndX) {
108
+ breaksHere = true;
109
+ break;
110
+ }
111
+ }
112
+ if (!breaksHere) continue;
113
+ if (i - segmentStart >= 2) ranges.push(segmentStart, i);
114
+ segmentStart = i;
115
+ }
116
+
117
+ if (count - segmentStart >= 2) ranges.push(segmentStart, count);
118
+ return ranges;
119
+ }
120
+
121
+ /** Pure worklet geometry for flat previous-value bridges in line gaps. */
122
+ export function buildLineGapGeometry(
123
+ data: LiveChartPoint[],
124
+ gaps: CandleGap[],
125
+ padding: ChartPadding,
126
+ canvasW: number,
127
+ canvasH: number,
128
+ winStart: number,
129
+ windowSecs: number,
130
+ displayMin: number,
131
+ displayMax: number,
132
+ bridgeNoTrades: boolean,
133
+ bridgeUnavailable: boolean,
134
+ bridgeUnknown: boolean,
135
+ ): LineGapGeometry {
136
+ "worklet";
137
+ const plotWidth = canvasW - padding.left - padding.right;
138
+ const plotHeight = canvasH - padding.top - padding.bottom;
139
+ const valueRange = displayMax - displayMin;
140
+ if (plotWidth <= 0 || plotHeight <= 0 || valueRange <= 0 || windowSecs <= 0) {
141
+ return { bridges: [] };
142
+ }
143
+
144
+ const winEnd = winStart + windowSecs;
145
+ const plotRight = canvasW - padding.right;
146
+ const bridges: LineGapBridge[] = [];
147
+ for (let i = 0; i < gaps.length; i++) {
148
+ const gap = gaps[i];
149
+ const bridge =
150
+ gap.kind === "no-trades"
151
+ ? bridgeNoTrades
152
+ : gap.kind === "unavailable"
153
+ ? bridgeUnavailable
154
+ : bridgeUnknown;
155
+ if (
156
+ !bridge ||
157
+ gap.from >= gap.to ||
158
+ gap.from >= winEnd ||
159
+ gap.to <= winStart ||
160
+ lineGapContainsData(data, gap)
161
+ ) {
162
+ continue;
163
+ }
164
+ const previousValue = previousLineValueAtTime(data, gap.from);
165
+ if (previousValue === null) continue;
166
+
167
+ const from = Math.max(gap.from, winStart);
168
+ const to = Math.min(gap.to, winEnd);
169
+ const x1 = padding.left + ((from - winStart) / windowSecs) * plotWidth;
170
+ const x2 = padding.left + ((to - winStart) / windowSecs) * plotWidth;
171
+ if (x2 <= padding.left || x1 >= plotRight || x2 <= x1) continue;
172
+ bridges.push({
173
+ x1: Math.max(padding.left, x1),
174
+ x2: Math.min(plotRight, x2),
175
+ y: padding.top + ((displayMax - previousValue) / valueRange) * plotHeight,
176
+ kind: gap.kind,
177
+ });
178
+ }
179
+ return { bridges };
180
+ }
@@ -4,8 +4,13 @@ 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";
7
+ import {
8
+ candleGapBucketStartAtTime,
9
+ candleGapDefaultLabel,
10
+ pickCandleGapAtTime,
11
+ } from "../math/candleGaps";
7
12
  import { pickCandleAtTime } from "../math/pickCandle";
8
- import type { CandlePoint } from "../types";
13
+ import type { CandleGap, CandlePoint } from "../types";
9
14
 
10
15
  const TOOLTIP_PAD_X = 8;
11
16
  const TOOLTIP_PAD_Y = 6;
@@ -187,6 +192,8 @@ export interface CrosshairState {
187
192
  * inactive) — single-series `useCrosshair` only; undefined on the multi-series
188
193
  * crosshair. Surfaced for a custom candle `renderTooltip`. */
189
194
  scrubCandle?: SharedValue<CandlePoint | null>;
195
+ /** Explicit line or candle gap under the crosshair; single-series only. */
196
+ scrubGap?: SharedValue<CandleGap | null>;
190
197
  /** Canvas-Y where the crosshair line should start so it stops at a top-pinned
191
198
  * custom tooltip's bottom edge instead of running up through it. Written by
192
199
  * {@link CustomTooltipOverlay} (the label's measured bottom) when
@@ -307,6 +314,7 @@ export function snapScrubXToCandleCenter(
307
314
  canvasWidth: number,
308
315
  timestamp: number,
309
316
  windowSecs: number,
317
+ gaps: CandleGap[] = [],
310
318
  ): number {
311
319
  "worklet";
312
320
  const chartW = canvasWidth - padding.left - padding.right;
@@ -314,7 +322,22 @@ export function snapScrubXToCandleCenter(
314
322
  const winStart = timestamp - windowSecs;
315
323
  const t = winStart + ((x - padding.left) / chartW) * windowSecs;
316
324
  const candle = pickCandleAtTime(candles, liveCandle, t, candleWidthSecs);
317
- if (!candle) return x;
325
+ if (!candle) {
326
+ const gap = pickCandleGapAtTime(gaps, t);
327
+ if (!gap) return x;
328
+ const bucket = candleGapBucketStartAtTime(
329
+ gap,
330
+ t,
331
+ candles,
332
+ liveCandle,
333
+ candleWidthSecs,
334
+ );
335
+ if (bucket === null) return x;
336
+ return (
337
+ padding.left +
338
+ ((bucket + candleWidthSecs / 2 - winStart) / windowSecs) * chartW
339
+ );
340
+ }
318
341
  return (
319
342
  padding.left +
320
343
  ((candle.time + candleWidthSecs / 2 - winStart) / windowSecs) * chartW
@@ -559,16 +582,29 @@ export function computeCandleTooltipLayout(
559
582
  formatTime: (t: number) => string,
560
583
  font: SkFont,
561
584
  monoCharWidth = 0,
585
+ gap: CandleGap | null = null,
586
+ gapValue: number | null = null,
562
587
  ): TooltipLayout {
563
588
  "worklet";
564
- if (!scrubActive || !candle) return HIDDEN_TOOLTIP;
565
- const lines: { text: string; dim: boolean }[] = [
566
- { text: `O ${formatValue(candle.open)}`, dim: false },
567
- { text: `H ${formatValue(candle.high)}`, dim: false },
568
- { text: `L ${formatValue(candle.low)}`, dim: false },
569
- { text: `C ${formatValue(candle.close)}`, dim: false },
570
- { text: formatTime(scrubTime), dim: true },
571
- ];
589
+ if (!scrubActive || (!candle && !gap)) return HIDDEN_TOOLTIP;
590
+ const lines: { text: string; dim: boolean }[] = candle
591
+ ? [
592
+ { text: `O ${formatValue(candle.open)}`, dim: false },
593
+ { text: `H ${formatValue(candle.high)}`, dim: false },
594
+ { text: `L ${formatValue(candle.low)}`, dim: false },
595
+ { text: `C ${formatValue(candle.close)}`, dim: false },
596
+ { text: formatTime(scrubTime), dim: true },
597
+ ]
598
+ : [
599
+ {
600
+ text: gap?.label ?? candleGapDefaultLabel(gap!.kind),
601
+ dim: false,
602
+ },
603
+ ...(gapValue === null
604
+ ? []
605
+ : [{ text: `Last ${formatValue(gapValue)}`, dim: false }]),
606
+ { text: formatTime(scrubTime), dim: true },
607
+ ];
572
608
  const layout = computeTooltipLayoutMulti(
573
609
  scrubActive,
574
610
  scrubX,
@@ -582,11 +618,56 @@ export function computeCandleTooltipLayout(
582
618
  // OHLC stack renders `stackedLines`, but a custom `renderTooltip` reads
583
619
  // `valueStr` / `timeStr` off the layout (same as line mode) — so a custom
584
620
  // candle tooltip gets a ready-made close + time without its own formatter.
585
- layout.valueStr = formatValue(candle.close);
621
+ layout.valueStr = candle
622
+ ? formatValue(candle.close)
623
+ : gapValue === null
624
+ ? ""
625
+ : formatValue(gapValue);
586
626
  layout.timeStr = formatTime(scrubTime);
587
627
  return layout;
588
628
  }
589
629
 
630
+ /** Two-row line-mode tooltip for an explicit semantic gap. */
631
+ export function computeLineGapTooltipLayout(
632
+ scrubActive: boolean,
633
+ scrubX: number,
634
+ gap: CandleGap | null,
635
+ scrubTime: number,
636
+ padding: ChartPadding,
637
+ canvasWidth: number,
638
+ formatTime: (t: number) => string,
639
+ font: SkFont,
640
+ monoCharWidth = 0,
641
+ placement: "side" | "top" | "bottom" | "point" = "side",
642
+ showLabel = true,
643
+ showTime = true,
644
+ canvasHeight = 0,
645
+ margin = TOOLTIP_TOP_MARGIN,
646
+ scrubDotY = -1,
647
+ ): TooltipLayout {
648
+ "worklet";
649
+ if (!gap) return HIDDEN_TOOLTIP;
650
+ const label = gap.label ?? candleGapDefaultLabel(gap.kind);
651
+ return computeTooltipLayout(
652
+ scrubActive,
653
+ scrubX,
654
+ 0,
655
+ scrubTime,
656
+ padding,
657
+ canvasWidth,
658
+ () => label,
659
+ formatTime,
660
+ font,
661
+ monoCharWidth,
662
+ placement,
663
+ showLabel,
664
+ showTime,
665
+ canvasHeight,
666
+ margin,
667
+ scrubDotY,
668
+ );
669
+ }
670
+
590
671
  /** Single-series scrub value at window time — extracted for tests. */
591
672
  export function deriveScrubValueSingle(
592
673
  scrubActive: boolean,