react-native-livechart 3.12.0 → 4.1.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 (140) hide show
  1. package/dist/components/BadgeOverlay.d.ts +9 -1
  2. package/dist/components/BadgeOverlay.d.ts.map +1 -1
  3. package/dist/components/ChartOverlayLayer.d.ts +19 -0
  4. package/dist/components/ChartOverlayLayer.d.ts.map +1 -0
  5. package/dist/components/CustomMarkerOverlay.d.ts +16 -6
  6. package/dist/components/CustomMarkerOverlay.d.ts.map +1 -1
  7. package/dist/components/CustomReferenceLineOverlay.d.ts +36 -0
  8. package/dist/components/CustomReferenceLineOverlay.d.ts.map +1 -0
  9. package/dist/components/CustomTooltipOverlay.d.ts +5 -2
  10. package/dist/components/CustomTooltipOverlay.d.ts.map +1 -1
  11. package/dist/components/DotOverlay.d.ts +7 -1
  12. package/dist/components/DotOverlay.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/MarkerOverlay.d.ts +6 -3
  16. package/dist/components/MarkerOverlay.d.ts.map +1 -1
  17. package/dist/components/MultiSeriesDots.d.ts +4 -1
  18. package/dist/components/MultiSeriesDots.d.ts.map +1 -1
  19. package/dist/components/ReferenceLineGroupOverlay.d.ts +27 -0
  20. package/dist/components/ReferenceLineGroupOverlay.d.ts.map +1 -0
  21. package/dist/components/ReferenceLineOverlay.d.ts +25 -2
  22. package/dist/components/ReferenceLineOverlay.d.ts.map +1 -1
  23. package/dist/components/XAxisOverlay.d.ts +7 -1
  24. package/dist/components/XAxisOverlay.d.ts.map +1 -1
  25. package/dist/components/YAxisOverlay.d.ts +12 -1
  26. package/dist/components/YAxisOverlay.d.ts.map +1 -1
  27. package/dist/constants.d.ts +13 -0
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/core/liveChartEngineTick.d.ts +29 -0
  30. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  31. package/dist/core/liveChartSeriesEngineTick.d.ts +18 -0
  32. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  33. package/dist/core/resolveConfig.d.ts +62 -2
  34. package/dist/core/resolveConfig.d.ts.map +1 -1
  35. package/dist/core/useLiveChartEngine.d.ts +58 -7
  36. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  37. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -2
  38. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  39. package/dist/draw/grid.d.ts +13 -1
  40. package/dist/draw/grid.d.ts.map +1 -1
  41. package/dist/draw/markerAtlas.d.ts +20 -2
  42. package/dist/draw/markerAtlas.d.ts.map +1 -1
  43. package/dist/draw/volume.d.ts +25 -0
  44. package/dist/draw/volume.d.ts.map +1 -0
  45. package/dist/hooks/crosshairShared.d.ts +12 -6
  46. package/dist/hooks/crosshairShared.d.ts.map +1 -1
  47. package/dist/hooks/overlayScale.d.ts +22 -0
  48. package/dist/hooks/overlayScale.d.ts.map +1 -0
  49. package/dist/hooks/resolveChartLayout.d.ts +9 -0
  50. package/dist/hooks/resolveChartLayout.d.ts.map +1 -1
  51. package/dist/hooks/useBadge.d.ts +22 -1
  52. package/dist/hooks/useBadge.d.ts.map +1 -1
  53. package/dist/hooks/useCandlePaths.d.ts +6 -0
  54. package/dist/hooks/useCandlePaths.d.ts.map +1 -1
  55. package/dist/hooks/useChartOverlayContext.d.ts +45 -0
  56. package/dist/hooks/useChartOverlayContext.d.ts.map +1 -0
  57. package/dist/hooks/useChartPaths.d.ts +8 -1
  58. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  59. package/dist/hooks/useCrosshair.d.ts +9 -2
  60. package/dist/hooks/useCrosshair.d.ts.map +1 -1
  61. package/dist/hooks/useLiveDot.d.ts +6 -1
  62. package/dist/hooks/useLiveDot.d.ts.map +1 -1
  63. package/dist/hooks/useMarkers.d.ts +9 -4
  64. package/dist/hooks/useMarkers.d.ts.map +1 -1
  65. package/dist/hooks/usePanScroll.d.ts +82 -0
  66. package/dist/hooks/usePanScroll.d.ts.map +1 -0
  67. package/dist/hooks/usePinchZoom.d.ts +74 -0
  68. package/dist/hooks/usePinchZoom.d.ts.map +1 -0
  69. package/dist/hooks/useReferenceDrag.d.ts +24 -0
  70. package/dist/hooks/useReferenceDrag.d.ts.map +1 -0
  71. package/dist/hooks/useReferenceLine.d.ts +23 -4
  72. package/dist/hooks/useReferenceLine.d.ts.map +1 -1
  73. package/dist/hooks/useReferenceLinePress.d.ts +5 -1
  74. package/dist/hooks/useReferenceLinePress.d.ts.map +1 -1
  75. package/dist/hooks/useVisibleRange.d.ts +39 -0
  76. package/dist/hooks/useVisibleRange.d.ts.map +1 -0
  77. package/dist/hooks/useYAxis.d.ts +1 -1
  78. package/dist/hooks/useYAxis.d.ts.map +1 -1
  79. package/dist/index.d.ts +2 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/math/markerCluster.d.ts +47 -0
  82. package/dist/math/markerCluster.d.ts.map +1 -0
  83. package/dist/math/markers.d.ts +10 -0
  84. package/dist/math/markers.d.ts.map +1 -1
  85. package/dist/math/referenceDrag.d.ts +24 -0
  86. package/dist/math/referenceDrag.d.ts.map +1 -0
  87. package/dist/math/referenceGroup.d.ts +31 -0
  88. package/dist/math/referenceGroup.d.ts.map +1 -0
  89. package/dist/math/referenceLines.d.ts +33 -7
  90. package/dist/math/referenceLines.d.ts.map +1 -1
  91. package/dist/types.d.ts +524 -21
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/components/BadgeOverlay.tsx +26 -1
  95. package/src/components/ChartOverlayLayer.tsx +32 -0
  96. package/src/components/CustomMarkerOverlay.tsx +150 -57
  97. package/src/components/CustomReferenceLineOverlay.tsx +250 -0
  98. package/src/components/CustomTooltipOverlay.tsx +27 -7
  99. package/src/components/DotOverlay.tsx +9 -0
  100. package/src/components/LiveChart.tsx +550 -32
  101. package/src/components/LiveChartSeries.tsx +111 -5
  102. package/src/components/MarkerOverlay.tsx +92 -6
  103. package/src/components/MultiSeriesDots.tsx +12 -3
  104. package/src/components/ReferenceLineGroupOverlay.tsx +207 -0
  105. package/src/components/ReferenceLineOverlay.tsx +105 -32
  106. package/src/components/XAxisOverlay.tsx +9 -2
  107. package/src/components/YAxisOverlay.tsx +49 -28
  108. package/src/constants.ts +17 -0
  109. package/src/core/liveChartEngineTick.ts +81 -8
  110. package/src/core/liveChartSeriesEngineTick.ts +50 -9
  111. package/src/core/resolveConfig.ts +133 -2
  112. package/src/core/useLiveChartEngine.ts +111 -11
  113. package/src/core/useLiveChartSeriesEngine.ts +26 -3
  114. package/src/draw/grid.ts +71 -2
  115. package/src/draw/markerAtlas.ts +117 -3
  116. package/src/draw/volume.ts +190 -0
  117. package/src/hooks/crosshairShared.ts +37 -9
  118. package/src/hooks/overlayScale.ts +59 -0
  119. package/src/hooks/resolveChartLayout.ts +33 -0
  120. package/src/hooks/useBadge.ts +89 -21
  121. package/src/hooks/useCandlePaths.ts +104 -7
  122. package/src/hooks/useChartOverlayContext.ts +93 -0
  123. package/src/hooks/useChartPaths.ts +14 -1
  124. package/src/hooks/useCrosshair.ts +33 -2
  125. package/src/hooks/useLiveDot.ts +16 -5
  126. package/src/hooks/useMarkers.ts +39 -11
  127. package/src/hooks/usePanScroll.ts +253 -0
  128. package/src/hooks/usePinchZoom.ts +189 -0
  129. package/src/hooks/useReferenceDrag.ts +254 -0
  130. package/src/hooks/useReferenceLine.ts +56 -9
  131. package/src/hooks/useReferenceLinePress.ts +16 -2
  132. package/src/hooks/useVisibleRange.ts +119 -0
  133. package/src/hooks/useYAxis.ts +2 -0
  134. package/src/index.ts +17 -1
  135. package/src/math/markerCluster.ts +214 -0
  136. package/src/math/markers.ts +35 -3
  137. package/src/math/referenceDrag.ts +66 -0
  138. package/src/math/referenceGroup.ts +65 -0
  139. package/src/math/referenceLines.ts +76 -19
  140. package/src/types.ts +544 -21
@@ -18,10 +18,20 @@ export interface ReferenceLineLayout {
18
18
  y: number;
19
19
  /** Band bottom-edge y (value/time band); equals `y` for a plain line. */
20
20
  yBottom: number;
21
- /** Left x extent. */
21
+ /** Left x extent of the plot (badge / label / connector anchor). */
22
22
  x1: number;
23
- /** Right x extent. */
23
+ /** Right x extent of the plot (badge / label / connector anchor). */
24
24
  x2: number;
25
+ /**
26
+ * Left x of the **drawn** line / band. Equals {@link x1} normally, or `0`
27
+ * (canvas edge) when the line is full-width. Decoupled from {@link x1} so the
28
+ * line can run through the gutter while the badge/label stays in the plot.
29
+ */
30
+ lineX1: number;
31
+ /** Right x of the drawn line / band: {@link x2} normally, or the canvas width when full-width. */
32
+ lineX2: number;
33
+ /** Stroke the plain horizontal line (true for a plain Form-A line, or a full-width badged line). */
34
+ drawLine: boolean;
25
35
  /** Resolved label text (with appended value when `showValue`); "" when hidden. */
26
36
  label: string;
27
37
  labelX: number;
@@ -54,6 +64,9 @@ const INVISIBLE: ReferenceLineLayout = {
54
64
  yBottom: -1,
55
65
  x1: 0,
56
66
  x2: 0,
67
+ lineX1: 0,
68
+ lineX2: 0,
69
+ drawLine: false,
57
70
  label: "",
58
71
  labelX: 0,
59
72
  labelY: -1,
@@ -96,7 +109,7 @@ interface BadgeGeometry {
96
109
  * inside the pill, with a dashed connector running to the opposite edge.
97
110
  */
98
111
  function badgeGeometry(
99
- position: "left" | "right",
112
+ position: "left" | "center" | "right",
100
113
  icon: string,
101
114
  text: string,
102
115
  hasChevron: boolean,
@@ -134,7 +147,9 @@ function badgeGeometry(
134
147
  const pillX =
135
148
  position === "right"
136
149
  ? x2 - BADGE_EDGE_INSET - pillW
137
- : x1 + BADGE_EDGE_INSET;
150
+ : position === "center"
151
+ ? (x1 + x2) / 2 - pillW / 2
152
+ : x1 + BADGE_EDGE_INSET;
138
153
 
139
154
  let cursor = pillX + BADGE_PAD_X;
140
155
  let chevronCx = -1;
@@ -159,13 +174,14 @@ function badgeGeometry(
159
174
  connStart = x1;
160
175
  connEnd = end;
161
176
  }
162
- } else {
177
+ } else if (position === "left") {
163
178
  const start = pillX + pillW + 4;
164
179
  if (start < x2) {
165
180
  connStart = start;
166
181
  connEnd = x2;
167
182
  }
168
183
  }
184
+ // "center" floats the pill at the value with no connector.
169
185
 
170
186
  return { pillX, pillW, iconX, textX, chevronCx, connStart, connEnd };
171
187
  }
@@ -213,6 +229,8 @@ export function computeReferenceBadgeRect(
213
229
  dMax: number,
214
230
  font: SkFont,
215
231
  formatValue: (value: number) => string,
232
+ /** Live value override (e.g. a dragged value), defaulting to `line.value`. */
233
+ valueOverride?: number,
216
234
  ): ReferenceBadgeRect | null {
217
235
  "worklet";
218
236
  const badge = resolveReferenceBadge(line);
@@ -228,7 +246,7 @@ export function computeReferenceBadgeRect(
228
246
 
229
247
  const x1 = padding.left;
230
248
  const x2 = canvasWidth - padding.right;
231
- const v = line.value;
249
+ const v = valueOverride ?? line.value;
232
250
  const edge = classifyReferenceEdge(v, dMin, dMax);
233
251
 
234
252
  let y: number;
@@ -267,6 +285,14 @@ export function useReferenceLine(
267
285
  line: ReferenceLine | undefined,
268
286
  formatValue: (v: number) => string,
269
287
  font: SkFont,
288
+ /**
289
+ * Per-line live value overrides (e.g. dragged values), index-aligned with the
290
+ * chart's `referenceLines`. When provided and the slot at {@link index} holds a
291
+ * number, the Form-A line tracks that value instead of the static `line.value`,
292
+ * so a drag updates the rendered line/badge on the UI thread. Omit for static lines.
293
+ */
294
+ dragValues?: SharedValue<number[]>,
295
+ index = 0,
270
296
  ): SharedValue<ReferenceLineLayout> {
271
297
  const form = line ? referenceLineForm(line) : "none";
272
298
  // Badge presentation depends only on the (stable) line props — resolve once.
@@ -284,6 +310,11 @@ export function useReferenceLine(
284
310
  const chartH = chartBottom - chartTop;
285
311
  const x1 = padding.left;
286
312
  const x2 = w - padding.right;
313
+ // Full-width lines/bands run edge-to-edge through the gutters (0..canvas);
314
+ // the badge/label anchor (x1/x2) stays at the plot edges either way.
315
+ const fullWidth = line.fullWidth ?? false;
316
+ const lineX1 = fullWidth ? 0 : x1;
317
+ const lineX2 = fullWidth ? w : x2;
287
318
 
288
319
  const fm = font.getMetrics();
289
320
  const baselineOffset = (fm.ascent + fm.descent) / 2;
@@ -320,6 +351,9 @@ export function useReferenceLine(
320
351
  yBottom: chartBottom,
321
352
  x1: bx1,
322
353
  x2: bx2,
354
+ // Time band is vertical and time-bounded — full-width does not apply.
355
+ lineX1: bx1,
356
+ lineX2: bx2,
323
357
  label,
324
358
  labelX,
325
359
  labelY: chartTop - fm.ascent + 2,
@@ -361,6 +395,8 @@ export function useReferenceLine(
361
395
  yBottom: yBot,
362
396
  x1,
363
397
  x2,
398
+ lineX1,
399
+ lineX2,
364
400
  label,
365
401
  labelX,
366
402
  labelY: yTop - fm.ascent + 2,
@@ -369,7 +405,11 @@ export function useReferenceLine(
369
405
 
370
406
  // ── Form A — horizontal line (with optional pill badge) ──────────────────
371
407
  if (line.value === undefined) return INVISIBLE;
372
- const v = line.value;
408
+ // Effective value: a live drag override (if present) else the static prop.
409
+ const v =
410
+ dragValues && dragValues.value[index] != null
411
+ ? dragValues.value[index]
412
+ : line.value;
373
413
  const edge = classifyReferenceEdge(v, dMin, dMax);
374
414
 
375
415
  // Off-screen: pin the badge to the nearest edge with a chevron (when a badge
@@ -418,6 +458,10 @@ export function useReferenceLine(
418
458
  yBottom: y,
419
459
  x1,
420
460
  x2,
461
+ lineX1,
462
+ lineX2,
463
+ // Full-width: the edge-to-edge line replaces the dashed connector.
464
+ drawLine: fullWidth,
421
465
  label: text,
422
466
  labelX: g.textX,
423
467
  labelY: y - baselineOffset,
@@ -426,8 +470,8 @@ export function useReferenceLine(
426
470
  pillW: g.pillW,
427
471
  iconX: g.iconX,
428
472
  icon: badge.icon,
429
- connStart: g.connStart,
430
- connEnd: g.connEnd,
473
+ connStart: fullWidth ? -1 : g.connStart,
474
+ connEnd: fullWidth ? -1 : g.connEnd,
431
475
  };
432
476
  }
433
477
 
@@ -449,6 +493,9 @@ export function useReferenceLine(
449
493
  yBottom: y,
450
494
  x1,
451
495
  x2,
496
+ lineX1,
497
+ lineX2,
498
+ drawLine: true,
452
499
  label,
453
500
  labelX,
454
501
  labelY: y - baselineOffset,
@@ -1,6 +1,6 @@
1
1
  import type { SkFont } from "@shopify/react-native-skia";
2
2
  import { Gesture } from "react-native-gesture-handler";
3
- import { useDerivedValue } from "react-native-reanimated";
3
+ import { useDerivedValue, type SharedValue } from "react-native-reanimated";
4
4
  import { scheduleOnRN } from "react-native-worklets";
5
5
 
6
6
  import type { ChartEngineLayout } from "../core/useLiveChartEngine";
@@ -33,6 +33,9 @@ export function useReferenceLinePress(
33
33
  /** Touch-target inflation around each pill, in px. */
34
34
  hitSlop: number,
35
35
  onPress?: (line: ReferenceLine, index: number) => void,
36
+ /** Per-line live value overrides (dragged values) so a draggable line's hit-rect
37
+ * tracks the drag, index-aligned with `lines`. */
38
+ dragValues?: SharedValue<number[]>,
36
39
  ): {
37
40
  tapGesture: ReturnType<typeof Gesture.Tap>;
38
41
  hitTest: (x: number, y: number) => boolean;
@@ -47,9 +50,20 @@ export function useReferenceLinePress(
47
50
  const dMin = engine.displayMin.value;
48
51
  const dMax = engine.displayMax.value;
49
52
  const out: (ReferenceBadgeRect | null)[] = [];
53
+ const dv = dragValues?.value;
50
54
  for (let i = 0; i < lines.length; i++) {
51
55
  out.push(
52
- computeReferenceBadgeRect(lines[i], w, h, padding, dMin, dMax, font, formatValue),
56
+ computeReferenceBadgeRect(
57
+ lines[i],
58
+ w,
59
+ h,
60
+ padding,
61
+ dMin,
62
+ dMax,
63
+ font,
64
+ formatValue,
65
+ dv ? dv[i] : undefined,
66
+ ),
53
67
  );
54
68
  }
55
69
  return out;
@@ -0,0 +1,119 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import {
3
+ useAnimatedReaction,
4
+ type SharedValue,
5
+ } from "react-native-reanimated";
6
+ import { scheduleOnRN } from "react-native-worklets";
7
+
8
+ import type { VisibleRange } from "../types";
9
+
10
+ export type { VisibleRange };
11
+
12
+ /** Engine SharedValues the visible-range reaction reads. */
13
+ export interface VisibleRangeEngineRefs {
14
+ timestamp: SharedValue<number>;
15
+ displayWindow: SharedValue<number>;
16
+ viewEnd: SharedValue<number | null>;
17
+ }
18
+
19
+ export interface UseVisibleRangeOptions {
20
+ engine: VisibleRangeEngineRefs;
21
+ /** Earliest retained time (unix seconds) — used for the `onReachStart` trigger. */
22
+ minTime: SharedValue<number>;
23
+ /** Fires (throttled to ~1 Hz) when the visible window's edges change. */
24
+ onVisibleRangeChange?: (range: VisibleRange) => void;
25
+ /**
26
+ * Fires once when the window's left edge comes within one window-width of the
27
+ * earliest retained data — the cue to lazily page in older history. Re-arms
28
+ * after the edge moves back out.
29
+ */
30
+ onReachStart?: () => void;
31
+ }
32
+
33
+ /**
34
+ * Throttle key for a visible range: integer seconds of each edge plus the
35
+ * near-start flag. The reaction emits only when this changes, so a live chart
36
+ * (whose right edge slides every frame) notifies at most ~once per second rather
37
+ * than per frame, and `onReachStart` edge-triggers off the trailing flag.
38
+ */
39
+ export function rangeSignature(
40
+ startSec: number,
41
+ endSec: number,
42
+ near: boolean,
43
+ ): string {
44
+ "worklet";
45
+ return `${Math.round(startSec)}|${Math.round(endSec)}|${near ? 1 : 0}`;
46
+ }
47
+
48
+ /** Whether the window's left edge is within `threshold` seconds of the oldest data. */
49
+ export function isNearStart(
50
+ winStart: number,
51
+ minTime: number,
52
+ threshold: number,
53
+ ): boolean {
54
+ "worklet";
55
+ return winStart <= minTime + threshold;
56
+ }
57
+
58
+ /**
59
+ * Bridges the UI-thread visible window to JS paging callbacks. A reaction watches
60
+ * the window edges and `scheduleOnRN`s `onVisibleRangeChange` (throttled) and
61
+ * `onReachStart` (edge-triggered near the oldest data). Inert when both callbacks
62
+ * are absent. Used by both `LiveChart` and `LiveChartSeries`.
63
+ */
64
+ export function useVisibleRange({
65
+ engine,
66
+ minTime,
67
+ onVisibleRangeChange,
68
+ onReachStart,
69
+ }: UseVisibleRangeOptions): void {
70
+ // Latest callbacks behind refs so the (deps-stable) dispatchers below always
71
+ // call the current prop without re-subscribing the reaction each render.
72
+ const rangeCb = useRef(onVisibleRangeChange);
73
+ const reachCb = useRef(onReachStart);
74
+ useEffect(() => {
75
+ rangeCb.current = onVisibleRangeChange;
76
+ reachCb.current = onReachStart;
77
+ }, [onVisibleRangeChange, onReachStart]);
78
+
79
+ const emitRange = useCallback(
80
+ /* istanbul ignore next -- dispatched via scheduleOnRN from the UI thread, not in Jest */
81
+ (startSec: number, endSec: number, following: boolean) => {
82
+ rangeCb.current?.({ startSec, endSec, following });
83
+ },
84
+ [],
85
+ );
86
+ const emitReachStart = useCallback(
87
+ /* istanbul ignore next -- dispatched via scheduleOnRN from the UI thread, not in Jest */
88
+ () => {
89
+ reachCb.current?.();
90
+ },
91
+ [],
92
+ );
93
+
94
+ const active = onVisibleRangeChange != null || onReachStart != null;
95
+
96
+ useAnimatedReaction(
97
+ /* istanbul ignore next -- Reanimated reaction worklet; runs on the UI thread, not in Jest */
98
+ () => {
99
+ if (!active) return ""; // inert — never changes, so the reaction never fires
100
+ const end = engine.timestamp.value;
101
+ const win = engine.displayWindow.value;
102
+ const start = end - win;
103
+ return rangeSignature(start, end, isNearStart(start, minTime.value, win));
104
+ },
105
+ /* istanbul ignore next -- Reanimated reaction; dispatches on the JS thread, not exercised under Jest */
106
+ (curr, prev) => {
107
+ if (!active || curr === "" || curr === prev) return;
108
+ const end = engine.timestamp.value;
109
+ const win = engine.displayWindow.value;
110
+ const start = end - win;
111
+ const following = engine.viewEnd.value == null;
112
+ const near = isNearStart(start, minTime.value, win);
113
+ const wasNear = typeof prev === "string" && prev.endsWith("|1");
114
+ scheduleOnRN(emitRange, start, end, following);
115
+ if (near && !wasNear) scheduleOnRN(emitReachStart);
116
+ },
117
+ [active, emitRange, emitReachStart],
118
+ );
119
+ }
@@ -19,6 +19,7 @@ export function useYAxis(
19
19
  font: SkFont,
20
20
  minGap = 36,
21
21
  gridMetrics: GridMetrics = GRID_METRICS_DEFAULTS,
22
+ count = 0,
22
23
  ) {
23
24
  const prevInterval = useSharedValue(0);
24
25
  const labelAlphas = useSharedValue<Record<number, number>>({});
@@ -39,6 +40,7 @@ export function useYAxis(
39
40
  dt,
40
41
  minGap,
41
42
  gridMetrics,
43
+ count,
42
44
  );
43
45
 
44
46
  prevInterval.set(result.interval);
package/src/index.ts CHANGED
@@ -25,6 +25,10 @@ export { MONO_FONT_FAMILY } from "./lib/monoFontFamily";
25
25
  export { useDegen } from "./hooks/useDegen";
26
26
  export { useTradeStream } from "./hooks/useTradeStream";
27
27
 
28
+ // ── renderOverlay bridge hooks (reactive price→Y / time→X for custom overlays) ──
29
+
30
+ export { usePriceY, useTimeX } from "./hooks/useChartOverlayContext";
31
+
28
32
  // ── Types ────────────────────────────────────────────────────────────────────
29
33
 
30
34
  export type {
@@ -32,10 +36,14 @@ export type {
32
36
  AxisLabelConfig,
33
37
  BadgeConfig,
34
38
  BadgeMetrics,
39
+ BadgeStyleConfig,
35
40
  BadgeVariant,
36
41
  CandleMetrics,
37
42
  CandlePoint,
38
43
  ChartInsets,
44
+ ChartOverlayContext,
45
+ ChartPlotRect,
46
+ ChartScale,
39
47
  ChartSegment,
40
48
  DegenOptions,
41
49
  DegenShakePayload,
@@ -57,8 +65,11 @@ export type {
57
65
  LiveChartProps,
58
66
  LiveChartSeriesProps,
59
67
  Marker,
60
- MarkerHoverEvent,
68
+ MarkerClusterConfig,
61
69
  MarkerKind,
70
+ MarkerPressEvent,
71
+ MarkerRenderContext,
72
+ MarkerSide,
62
73
  Momentum,
63
74
  MomentumConfig,
64
75
  MotionMetrics,
@@ -66,6 +77,8 @@ export type {
66
77
  PulseConfig,
67
78
  ReferenceLine,
68
79
  ReferenceLineBadgeConfig,
80
+ ReferenceLineGroupingConfig,
81
+ ReferenceLineRenderProps,
69
82
  ScrubActionConfig,
70
83
  ScrubActionPoint,
71
84
  ScrubConfig,
@@ -83,6 +96,9 @@ export type {
83
96
  TooltipRenderProps,
84
97
  TradeEvent,
85
98
  ValueLineConfig,
99
+ VisibleRange,
100
+ VolumeConfig,
86
101
  XAxisConfig,
87
102
  YAxisConfig,
103
+ ZoomConfig,
88
104
  } from "./types";
@@ -0,0 +1,214 @@
1
+ import type { Marker, MarkerSide } from "../types";
2
+ import type { ProjectedMarker } from "./markers";
3
+
4
+ /**
5
+ * Resolved {@link LiveChartProps.markerCluster} config (see `resolveMarkerCluster`).
6
+ */
7
+ export interface ResolvedMarkerCluster {
8
+ /** `"anchored"` = no collision handling (glyphs overlap). `"stacked"` = fan +
9
+ * collapse co-located markers. */
10
+ mode: "anchored" | "stacked";
11
+ /** Fraction (0–1) that adjacent co-located glyphs overlap when fanned. The fan
12
+ * step is `glyphHeight * (1 - overlap)`; `glyphHeight` is a slightly generous
13
+ * primitive estimate, so the *visible* overlap runs a touch lower. */
14
+ overlap: number;
15
+ /** Extra px between a sided glyph and its anchor. */
16
+ gap: number;
17
+ /** Collapse a co-located run to a single count badge once it exceeds this many. */
18
+ maxBeforeGroup: number;
19
+ }
20
+
21
+ export interface ClusterMarkersOpts {
22
+ config: ResolvedMarkerCluster;
23
+ }
24
+
25
+ /** Glyph box used when `marker.size` is unset — mirrors `markerAtlas.DEFAULT_ICON_SIZE`. */
26
+ const DEFAULT_GLYPH = 16;
27
+
28
+ /**
29
+ * Estimated vertical (≈ horizontal) footprint of a marker glyph in px. Derived
30
+ * from primitives only (no font metrics / atlas), so the hit-test hook and the
31
+ * overlays compute identical offsets from identical inputs — keeping the drawn
32
+ * and hit-tested positions in sync. Not pixel-exact with the atlas cell; the
33
+ * generous default `markerHitRadius` absorbs the small difference.
34
+ */
35
+ export function glyphHeight(m: Marker): number {
36
+ "worklet";
37
+ const size = m.size ?? DEFAULT_GLYPH;
38
+ // The pill badge adds padding + a background ring around the icon glyph.
39
+ return m.pill ? size + 8 : size;
40
+ }
41
+
42
+ function sideOf(m: Marker): MarkerSide {
43
+ "worklet";
44
+ return m.side ?? "center";
45
+ }
46
+
47
+ /**
48
+ * Axis-anchored kinds (graduation stem, clawback box) whose geometry is pinned
49
+ * to the baseline — they aren't fixed-size glyphs, so they don't stack or
50
+ * collapse. Mirrors `markerAtlas.isConnectorMarker` (duplicated to keep this
51
+ * math module free of the Skia-importing draw layer).
52
+ */
53
+ function isConnector(m: Marker): boolean {
54
+ "worklet";
55
+ return !m.image && !m.icon && (m.kind === "graduation" || m.kind === "clawback");
56
+ }
57
+
58
+ /** Stable rank so the index sort groups markers by side. */
59
+ function sideRank(side: MarkerSide): number {
60
+ "worklet";
61
+ return side === "above" ? 0 : side === "center" ? 1 : 2;
62
+ }
63
+
64
+ /**
65
+ * Lay out one co-located bucket `idx[s..e)` (already ordered by time), mutating
66
+ * `proj`. All glyphs sit at the same `side` height (above / below / on the line)
67
+ * and fan out HORIZONTALLY with a slight overlap — the "( ) ) )" stacked-coins
68
+ * look, centered on the time anchor. When the bucket is larger than
69
+ * `maxBeforeGroup` it collapses instead: one representative (the newest) is
70
+ * flagged `isGrouped` with `groupCount`, the rest are `hidden` and point back via
71
+ * `groupRep` (so a tap can gather the members).
72
+ */
73
+ function layoutBucket(
74
+ markers: Marker[],
75
+ proj: ProjectedMarker[],
76
+ idx: number[],
77
+ s: number,
78
+ e: number,
79
+ side: MarkerSide,
80
+ opts: ClusterMarkersOpts,
81
+ ): void {
82
+ "worklet";
83
+ const count = e - s;
84
+ const anchorX = proj[idx[s]].x;
85
+ const anchorY = proj[idx[s]].y;
86
+ const { gap, maxBeforeGroup } = opts.config;
87
+
88
+ // Tallest glyph in the bucket sets the side offset and the horizontal step.
89
+ let h = 0;
90
+ for (let k = s; k < e; k++) {
91
+ const gh = glyphHeight(markers[idx[k]]);
92
+ if (gh > h) h = gh;
93
+ }
94
+ const sideDy =
95
+ side === "above" ? -(h / 2 + gap) : side === "below" ? h / 2 + gap : 0;
96
+
97
+ if (count > maxBeforeGroup) {
98
+ // Collapse to a count badge at the representative (newest = last by time).
99
+ const rep = idx[e - 1];
100
+ for (let k = s; k < e; k++) {
101
+ const p = proj[idx[k]];
102
+ p.groupRep = rep;
103
+ if (idx[k] === rep) {
104
+ p.x = anchorX;
105
+ p.y = anchorY + sideDy;
106
+ p.isGrouped = true;
107
+ p.groupCount = count;
108
+ p.hidden = false;
109
+ } else {
110
+ p.hidden = true;
111
+ }
112
+ }
113
+ return;
114
+ }
115
+
116
+ // Horizontal overlapping fan, centered on the anchor x. Glyphs share the side
117
+ // height. Positions run right→left as the bucket order advances, so the newest
118
+ // (last, highest array index) sits LEFTMOST — and since the overlay draws in
119
+ // array order, it paints on top: a left-over-right cascade ("(" over ")" over ")").
120
+ const step = h * (1 - opts.config.overlap);
121
+ for (let j = 0; j < count; j++) {
122
+ const p = proj[idx[s + j]];
123
+ p.x = anchorX + ((count - 1) / 2 - j) * step;
124
+ p.y = anchorY + sideDy;
125
+ p.hidden = false;
126
+ p.isGrouped = false;
127
+ p.groupCount = 0;
128
+ p.groupRep = -1;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Collision pass over projected markers, mutating `proj` in place.
134
+ *
135
+ * `"anchored"` mode only applies each marker's {@link Marker.side} offset (a lone
136
+ * glyph sitting above/below its anchor); it does no bucketing, so charts that
137
+ * don't use `side` are untouched. `"stacked"` mode additionally buckets
138
+ * co-located markers (same side, overlapping x/y) and fans or collapses them.
139
+ *
140
+ * Runs every frame on the UI thread, so grouping is zoom/scroll-reactive. Reads
141
+ * the *raw* projected positions from `projectMarkers` and overwrites them.
142
+ */
143
+ export function clusterMarkers(
144
+ markers: Marker[],
145
+ proj: ProjectedMarker[],
146
+ opts: ClusterMarkersOpts,
147
+ ): void {
148
+ "worklet";
149
+ const n = markers.length;
150
+
151
+ if (opts.config.mode !== "stacked") {
152
+ // Side-only: shift sided glyphs off their anchor; no neighbor interaction.
153
+ for (let i = 0; i < n; i++) {
154
+ const p = proj[i];
155
+ if (!p.visible || isConnector(markers[i])) continue;
156
+ const side = sideOf(markers[i]);
157
+ if (side === "center") continue;
158
+ const off = glyphHeight(markers[i]) / 2 + opts.config.gap;
159
+ p.y += side === "above" ? -off : off;
160
+ }
161
+ return;
162
+ }
163
+
164
+ // Collect visible indices, then order by (side, x, y, time) so co-located
165
+ // same-side markers are adjacent and fan in time order.
166
+ const idx: number[] = [];
167
+ for (let i = 0; i < n; i++) if (proj[i].visible && !isConnector(markers[i])) idx.push(i);
168
+ idx.sort((a, b) => {
169
+ const sr = sideRank(sideOf(markers[a])) - sideRank(sideOf(markers[b]));
170
+ if (sr !== 0) return sr;
171
+ if (proj[a].x !== proj[b].x) return proj[a].x - proj[b].x;
172
+ if (proj[a].y !== proj[b].y) return proj[a].y - proj[b].y;
173
+ return markers[a].time - markers[b].time;
174
+ });
175
+
176
+ // Sweep into buckets: same side and within a glyph of the bucket's anchor in
177
+ // BOTH axes (so two markers at the same time but far apart in value don't merge).
178
+ let s = 0;
179
+ while (s < idx.length) {
180
+ const side = sideOf(markers[idx[s]]);
181
+ const startX = proj[idx[s]].x;
182
+ const startY = proj[idx[s]].y;
183
+ const colPx = glyphHeight(markers[idx[s]]);
184
+ let e = s + 1;
185
+ while (
186
+ e < idx.length &&
187
+ sideOf(markers[idx[e]]) === side &&
188
+ Math.abs(proj[idx[e]].x - startX) <= colPx &&
189
+ Math.abs(proj[idx[e]].y - startY) <= colPx
190
+ ) {
191
+ e++;
192
+ }
193
+ layoutBucket(markers, proj, idx, s, e, side, opts);
194
+ s = e;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Markers belonging to the collapsed cluster whose representative is `repIndex`
200
+ * (ordered by time). Call after {@link clusterMarkers} has populated `proj`.
201
+ */
202
+ export function clusterMembers(
203
+ markers: Marker[],
204
+ proj: ProjectedMarker[],
205
+ repIndex: number,
206
+ ): Marker[] {
207
+ "worklet";
208
+ const out: Marker[] = [];
209
+ for (let i = 0; i < markers.length; i++) {
210
+ if (proj[i] && proj[i].groupRep === repIndex) out.push(markers[i]);
211
+ }
212
+ out.sort((a, b) => a.time - b.time);
213
+ return out;
214
+ }
@@ -7,6 +7,16 @@ export interface ProjectedMarker {
7
7
  x: number;
8
8
  y: number;
9
9
  visible: boolean;
10
+ /** Collapsed cluster member that the representative stands in for — not drawn,
11
+ * not hit-tested. Set by the cluster pass; `false` outside `markerCluster`. */
12
+ hidden: boolean;
13
+ /** This marker is the representative (count badge) of a collapsed cluster. */
14
+ isGrouped: boolean;
15
+ /** Size of the collapsed cluster this marker represents (`0` when not grouped). */
16
+ groupCount: number;
17
+ /** Index of the cluster's representative marker for collapsed members (and the
18
+ * representative itself), else `-1`. Used to gather members on tap. */
19
+ groupRep: number;
10
20
  }
11
21
 
12
22
  export interface ProjectMarkersOpts {
@@ -47,6 +57,12 @@ function projectInto(
47
57
  target: ProjectedMarker,
48
58
  ): void {
49
59
  "worklet";
60
+ // Reset cluster fields so a reused buffer slot defaults to un-clustered; the
61
+ // cluster pass (when it runs) overwrites them. Lets anchored mode skip the pass.
62
+ target.hidden = false;
63
+ target.isGrouped = false;
64
+ target.groupCount = 0;
65
+ target.groupRep = -1;
50
66
  const w = opts.canvasWidth;
51
67
  const h = opts.canvasHeight;
52
68
  const chartLeft = opts.padLeft;
@@ -121,7 +137,15 @@ export function projectMarkers(
121
137
  for (let i = 0; i < markers.length; i++) {
122
138
  let cur = out[i];
123
139
  if (!cur) {
124
- cur = { x: 0, y: 0, visible: false };
140
+ cur = {
141
+ x: 0,
142
+ y: 0,
143
+ visible: false,
144
+ hidden: false,
145
+ isGrouped: false,
146
+ groupCount: 0,
147
+ groupRep: -1,
148
+ };
125
149
  out[i] = cur;
126
150
  }
127
151
  const m = markers[i];
@@ -142,7 +166,15 @@ export function projectPoint(
142
166
  opts: ProjectMarkersOpts,
143
167
  ): ProjectedMarker {
144
168
  "worklet";
145
- const target: ProjectedMarker = { x: 0, y: 0, visible: false };
169
+ const target: ProjectedMarker = {
170
+ x: 0,
171
+ y: 0,
172
+ visible: false,
173
+ hidden: false,
174
+ isGrouped: false,
175
+ groupCount: 0,
176
+ groupRep: -1,
177
+ };
146
178
  projectInto(time, value, seriesId, opts, target);
147
179
  return target;
148
180
  }
@@ -175,7 +207,7 @@ export function nearestMarkerIndex(
175
207
  let bestD = radius * radius;
176
208
  for (let i = 0; i < positions.length; i++) {
177
209
  const p = positions[i];
178
- if (!p.visible) continue;
210
+ if (!p.visible || p.hidden) continue;
179
211
  const dx = p.x - x;
180
212
  const dy = p.y - y;
181
213
  const d = dx * dx + dy * dy;