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
@@ -14,6 +14,7 @@ import type {
14
14
  LineStyleConfig,
15
15
  LiveChartMetrics,
16
16
  LiveChartMetricsOverride,
17
+ MarkerClusterConfig,
17
18
  DotConfig,
18
19
  DotRingConfig,
19
20
  MultiSeriesDotConfig,
@@ -28,12 +29,15 @@ import type {
28
29
  ThresholdLineConfig,
29
30
  TradeEvent,
30
31
  ValueLineConfig,
32
+ VolumeConfig,
31
33
  XAxisConfig,
32
34
  YAxisConfig,
35
+ ZoomConfig,
33
36
  } from "../types";
34
37
 
35
38
  import type { ComponentType, ReactElement } from "react";
36
39
  import type { SharedValue } from "react-native-reanimated";
40
+ import type { ResolvedMarkerCluster } from "../math/markerCluster";
37
41
  import {
38
42
  BADGE_METRICS_DEFAULTS,
39
43
  CANDLE_METRICS_DEFAULTS,
@@ -57,10 +61,41 @@ export interface ResolvedBadgeConfig {
57
61
  tail: boolean;
58
62
  position: "right" | "left";
59
63
  background: string | undefined;
64
+ /** undefined → capsule (pillHeight / 2) at render time */
65
+ radius: number | undefined;
66
+ /** undefined → no border */
67
+ borderColor: string | undefined;
68
+ borderWidth: number;
69
+ /** undefined → variant/theme text color at render time */
70
+ textColor: string | undefined;
71
+ fontSize: number | undefined;
72
+ fontFamily: string | undefined;
73
+ fontWeight: FontWeight | undefined;
74
+ offsetX: number;
75
+ offsetY: number;
76
+ /** Track the visible window's right-edge price while scrolled back. */
77
+ followViewEdge: boolean;
60
78
  }
61
79
 
62
80
  export interface ResolvedYAxisConfig {
63
81
  minGap: number;
82
+ /** Fixed label count (≥ 2), or 0 for the dynamic nice-interval grid. */
83
+ count: number;
84
+ /** Float the axis over a full-width plot (no reserved right gutter). */
85
+ float: boolean;
86
+ }
87
+
88
+ export interface ResolvedVolumeConfig {
89
+ /** undefined → use palette.candleUp at render time. */
90
+ upColor: string | undefined;
91
+ /** undefined → use palette.candleDown at render time. */
92
+ downColor: string | undefined;
93
+ /** Reserved band height (px) — the tallest a bar can be. */
94
+ maxHeight: number;
95
+ /** Corner radius (px) of bar tops. */
96
+ radius: number;
97
+ /** Opacity (0..1) applied to the whole band. */
98
+ opacity: number;
64
99
  }
65
100
 
66
101
  /** Resolved straight-line styling (connector, etc.). `color: undefined` → caller default. */
@@ -119,7 +154,7 @@ export interface ResolvedScrubConfig {
119
154
  /** Tooltip pill corner radius in px. */
120
155
  tooltipBorderRadius: number;
121
156
  /** Where the tooltip pill sits relative to the scrub line. */
122
- tooltipPlacement: "side" | "top" | "bottom";
157
+ tooltipPlacement: "side" | "top" | "bottom" | "point";
123
158
  /** Gap (px) between the tooltip and the plot edge it's pinned to. */
124
159
  tooltipMargin: number;
125
160
  /** Show the value row in the default tooltip body. */
@@ -146,6 +181,8 @@ export interface ResolvedScrubActionConfig {
146
181
  /** undefined → no rounding */
147
182
  snap: number | undefined;
148
183
  dismissOnTapOutside: boolean;
184
+ /** Clear the reticle once the action badge fires onScrubAction. */
185
+ dismissOnAction: boolean;
149
186
  }
150
187
 
151
188
  export interface ResolvedGradientConfig {
@@ -336,6 +373,16 @@ const BADGE_DEFAULTS: ResolvedBadgeConfig = {
336
373
  tail: true,
337
374
  position: "right",
338
375
  background: undefined,
376
+ radius: undefined,
377
+ borderColor: undefined,
378
+ borderWidth: 1,
379
+ textColor: undefined,
380
+ fontSize: undefined,
381
+ fontFamily: undefined,
382
+ fontWeight: undefined,
383
+ offsetX: 0,
384
+ offsetY: 0,
385
+ followViewEdge: false,
339
386
  };
340
387
 
341
388
  /**
@@ -350,16 +397,63 @@ export function resolveBadge(
350
397
 
351
398
  const Y_AXIS_DEFAULTS: ResolvedYAxisConfig = {
352
399
  minGap: 36,
400
+ count: 0,
401
+ float: false,
353
402
  };
354
403
 
355
404
  /**
356
405
  * Resolves `yAxis` prop to a fully-typed config or null (disabled).
357
406
  * `true` → defaults, object → merged with defaults, falsy → null.
407
+ * `count` is normalized to a non-negative integer (the grid math clamps the
408
+ * upper bound to the label pool size).
358
409
  */
359
410
  export function resolveYAxis(
360
411
  prop: boolean | YAxisConfig | undefined,
361
412
  ): ResolvedYAxisConfig | null {
362
- return resolveToggle(prop, Y_AXIS_DEFAULTS, false);
413
+ const resolved = resolveToggle(prop, Y_AXIS_DEFAULTS, false);
414
+ if (resolved === null) return null;
415
+ return { ...resolved, count: Math.max(0, Math.floor(resolved.count)) };
416
+ }
417
+
418
+ const VOLUME_DEFAULTS: ResolvedVolumeConfig = {
419
+ upColor: undefined,
420
+ downColor: undefined,
421
+ maxHeight: 48,
422
+ radius: 2,
423
+ opacity: 0.6,
424
+ };
425
+
426
+ /**
427
+ * Resolves the `volume` prop to a fully-typed config or null (disabled).
428
+ * `true` → defaults, object → merged with defaults, falsy → null. Colors left
429
+ * `undefined` fall back to the candle palette at render time.
430
+ */
431
+ export function resolveVolume(
432
+ prop: boolean | VolumeConfig | undefined,
433
+ ): ResolvedVolumeConfig | null {
434
+ return resolveToggle(prop, VOLUME_DEFAULTS, false);
435
+ }
436
+
437
+ export interface ResolvedZoomConfig {
438
+ /** Tightest window (max zoom-in), seconds. `undefined` → `timeWindow / 8`. */
439
+ minTimeWindow: number | undefined;
440
+ /** Widest window (max zoom-out), seconds. `undefined` → full data span. */
441
+ maxTimeWindow: number | undefined;
442
+ }
443
+
444
+ const ZOOM_DEFAULTS: ResolvedZoomConfig = {
445
+ minTimeWindow: undefined,
446
+ maxTimeWindow: undefined,
447
+ };
448
+
449
+ /**
450
+ * Resolves the `zoom` prop to a config or null (disabled). `true` → defaults
451
+ * (bounds derived at gesture time), object → merged, falsy → null.
452
+ */
453
+ export function resolveZoom(
454
+ prop: boolean | ZoomConfig | undefined,
455
+ ): ResolvedZoomConfig | null {
456
+ return resolveToggle(prop, ZOOM_DEFAULTS, false);
363
457
  }
364
458
 
365
459
  const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
@@ -484,6 +578,7 @@ const SCRUB_ACTION_DEFAULTS: ResolvedScrubActionConfig = {
484
578
  timeBadge: false,
485
579
  snap: undefined,
486
580
  dismissOnTapOutside: false,
581
+ dismissOnAction: false,
487
582
  };
488
583
 
489
584
  /**
@@ -496,6 +591,42 @@ export function resolveScrubAction(
496
591
  return resolveToggle(prop, SCRUB_ACTION_DEFAULTS, false);
497
592
  }
498
593
 
594
+ /** Spacing + collapse-threshold defaults for `markerCluster: "stacked"`. */
595
+ const MARKER_CLUSTER_GAP = 2;
596
+ const MARKER_CLUSTER_MAX_BEFORE_GROUP = 5;
597
+ const MARKER_CLUSTER_OVERLAP = 0.75;
598
+
599
+ /**
600
+ * Resolves the `markerCluster` prop (a `"anchored"`/`"stacked"` shorthand or a
601
+ * {@link MarkerClusterConfig} object) to a full config. Always returns a config
602
+ * (never null) — `"anchored"` (the default) is a valid mode the cluster pass
603
+ * treats as "side offsets only, no bucketing". The object form implies
604
+ * `"stacked"` unless `mode` is set.
605
+ */
606
+ export function resolveMarkerCluster(
607
+ prop: "anchored" | "stacked" | MarkerClusterConfig | undefined,
608
+ ): ResolvedMarkerCluster {
609
+ if (typeof prop === "object") {
610
+ return {
611
+ mode: prop.mode ?? "stacked",
612
+ overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP),
613
+ gap: MARKER_CLUSTER_GAP,
614
+ maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP,
615
+ };
616
+ }
617
+ return {
618
+ mode: prop === "stacked" ? "stacked" : "anchored",
619
+ overlap: MARKER_CLUSTER_OVERLAP,
620
+ gap: MARKER_CLUSTER_GAP,
621
+ maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP,
622
+ };
623
+ }
624
+
625
+ /** Clamp to [0, 0.95] so a glyph never fully covers its neighbor (overlap 1). */
626
+ function clamp01(v: number): number {
627
+ return v < 0 ? 0 : v > 0.95 ? 0.95 : v;
628
+ }
629
+
499
630
  const GRADIENT_DEFAULTS: ResolvedGradientConfig = {
500
631
  topOpacity: undefined,
501
632
  bottomOpacity: undefined,
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @see https://github.com/benjitaylor/liveline
7
7
  */
8
- import { useState } from "react";
8
+ import { useEffect, useState } from "react";
9
9
  import {
10
10
  useAnimatedReaction,
11
11
  useDerivedValue,
@@ -27,6 +27,13 @@ export interface EngineConfig {
27
27
  exaggerate?: boolean;
28
28
  referenceValue?: number;
29
29
  referenceValues?: number[];
30
+ /**
31
+ * Live, per-frame Y values to fold into the axis-range fit on top of the static
32
+ * {@link referenceValues} — used so a *dragging* reference line expands the range
33
+ * and the axis follows the finger smoothly (the committed values already sit in
34
+ * `referenceValues`). Read on the UI thread each frame; omit for non-draggable charts.
35
+ */
36
+ liveReferenceValues?: SharedValue<number[]>;
30
37
  nonNegative?: boolean;
31
38
  maxValue?: number;
32
39
  nowOverride?: number;
@@ -50,12 +57,14 @@ export interface ChartEngineLayout {
50
57
  /** Animating window (lerps toward {@link timeWindow}); use for positioning. */
51
58
  displayWindow: SharedValue<number>;
52
59
  /**
53
- * The target window from props — the exact value `displayWindow` eases toward.
54
- * Tick *selection* (which X-axis labels exist) must read this, not
55
- * `displayWindow`: the lerp is asymptotic and never reaches the target, so it
56
- * settles just above or just below it depending on the prior window. Bucketing
57
- * that off-by-epsilon value (e.g. via `niceTimeInterval`) would otherwise make
58
- * the tick cadence depend on where you came from. See issue #126.
60
+ * The *effective* target window — the value `displayWindow` eases toward: the
61
+ * pinch-zoom override ({@link ChartEngineScroll.viewWindow}) when active, else
62
+ * the `timeWindow` prop. Tick *selection* (which X-axis labels exist) must read
63
+ * this, not `displayWindow`: the lerp is asymptotic and never reaches the
64
+ * target, so it settles just above or just below it depending on the prior
65
+ * window. Bucketing that off-by-epsilon value (e.g. via `niceTimeInterval`)
66
+ * would otherwise make the tick cadence depend on where you came from. See
67
+ * issue #126.
59
68
  */
60
69
  timeWindow: SharedValue<number>;
61
70
  canvasWidth: SharedValue<number>;
@@ -76,6 +85,42 @@ export interface ChartEngineExtrema {
76
85
  extremaMaxTime: SharedValue<number>;
77
86
  }
78
87
 
88
+ /**
89
+ * Time-scroll state: lets a pan gesture freeze the window at an absolute time
90
+ * and resume following the live edge. Returned by both engines (intersected onto
91
+ * the state) but kept off {@link ChartEngineLayout} so overlay components — which
92
+ * only read the layout — don't have to carry it.
93
+ */
94
+ export interface ChartEngineScroll {
95
+ /**
96
+ * Absolute right-edge time (unix seconds) to freeze at, or `null` to follow
97
+ * the live edge. Written by the pan-scroll gesture; read by the engine tick.
98
+ */
99
+ viewEnd: SharedValue<number | null>;
100
+ /**
101
+ * The right-edge time the engine would use if following live — advances each
102
+ * frame even while {@link ChartEngineLayout.timestamp} is frozen by a pan.
103
+ */
104
+ liveEdge: SharedValue<number>;
105
+ /**
106
+ * Absolute visible-window width (seconds) to freeze at, or `null` to follow
107
+ * the configured `timeWindow`. Written by the pinch-zoom gesture; the
108
+ * symmetric counterpart of {@link viewEnd} (width vs. right edge). Folded into
109
+ * {@link ChartEngineLayout.timeWindow}, so downstream selection/positioning
110
+ * picks up the zoom for free.
111
+ */
112
+ viewWindow: SharedValue<number | null>;
113
+ }
114
+
115
+ /** Single-series only: smoothed price at the visible window's right edge. */
116
+ export interface ChartEngineEdge {
117
+ /**
118
+ * Smoothed value at the window's right edge — the live value while following,
119
+ * the price at `viewEnd` while scrolled back. For a `followViewEdge` badge.
120
+ */
121
+ edgeValue: SharedValue<number>;
122
+ }
123
+
79
124
  export interface SingleEngineState extends ChartEngineLayout, ChartEngineExtrema {
80
125
  data: SharedValue<LiveChartPoint[]>;
81
126
  value: SharedValue<number>;
@@ -119,6 +164,14 @@ export interface EngineFrameRefs {
119
164
  nowOverrideSV?: SharedValue<number | undefined>;
120
165
  windowBufferSV?: SharedValue<number>;
121
166
  pausedSV: SharedValue<boolean>;
167
+ /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
168
+ viewEndSV?: SharedValue<number | null>;
169
+ /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
170
+ viewWindowSV?: SharedValue<number | null>;
171
+ /** Receives the computed live edge each frame. Optional for callers/tests. */
172
+ liveEdgeSV?: SharedValue<number>;
173
+ /** Receives the smoothed right-edge value each frame. Optional for callers/tests. */
174
+ edgeValueSV?: SharedValue<number>;
122
175
  modeSV: SharedValue<"line" | "candle">;
123
176
  candles?: SharedValue<CandlePoint[]>;
124
177
  liveCandle?: SharedValue<CandlePoint | null>;
@@ -144,6 +197,8 @@ export function applyLiveChartEngineFrame(
144
197
  displayMax: sv.displayMax.value,
145
198
  displayWindow: sv.displayWindow.value,
146
199
  timestamp: sv.timestamp.value,
200
+ liveEdge: sv.liveEdgeSV?.value ?? 0,
201
+ edgeValue: sv.edgeValueSV?.value ?? 0,
147
202
  extremaMinValue: sv.extremaMinValue.value,
148
203
  extremaMaxValue: sv.extremaMaxValue.value,
149
204
  extremaMinTime: sv.extremaMinTime.value,
@@ -167,6 +222,8 @@ export function applyLiveChartEngineFrame(
167
222
  points: sv.data.value,
168
223
  nowSeconds: Date.now() / 1000,
169
224
  paused: sv.pausedSV.value,
225
+ viewEnd: sv.viewEndSV?.value,
226
+ viewWindow: sv.viewWindowSV?.value,
170
227
  mode: sv.modeSV.value,
171
228
  candles: sv.candles?.value,
172
229
  liveCandle: sv.liveCandle?.value,
@@ -176,15 +233,34 @@ export function applyLiveChartEngineFrame(
176
233
  sv.displayMax.value = state.displayMax;
177
234
  sv.displayWindow.value = state.displayWindow;
178
235
  sv.timestamp.value = state.timestamp;
236
+ if (sv.liveEdgeSV) sv.liveEdgeSV.value = state.liveEdge;
237
+ if (sv.edgeValueSV) sv.edgeValueSV.value = state.edgeValue;
179
238
  sv.extremaMinValue.value = state.extremaMinValue;
180
239
  sv.extremaMaxValue.value = state.extremaMaxValue;
181
240
  sv.extremaMinTime.value = state.extremaMinTime;
182
241
  sv.extremaMaxTime.value = state.extremaMaxTime;
183
242
  }
184
243
 
185
- export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
186
- // Low-frequency config → UI thread via useDerivedValue
187
- const timeWindow = useDerivedValue(() => config.timeWindow);
244
+ export function useLiveChartEngine(
245
+ config: EngineConfig,
246
+ ): SingleEngineState & ChartEngineScroll & ChartEngineEdge {
247
+ // Pinch-zoom window-width override (null = follow the configured window).
248
+ // Declared first so `timeWindow` below can fold it in. Defaults to null so
249
+ // charts without `zoom` behave exactly as before.
250
+ const viewWindow = useSharedValue<number | null>(null);
251
+ // A change to the `timeWindow` prop (a range / timeframe selector) is an explicit
252
+ // request to set the window, so it clears any active pinch-zoom override —
253
+ // otherwise the override below (`viewWindow ?? config.timeWindow`) would shadow
254
+ // the new prop forever, and prop-driven window changes would silently no-op.
255
+ useEffect(() => {
256
+ viewWindow.set(null);
257
+ }, [config.timeWindow, viewWindow]);
258
+
259
+ // Low-frequency config → UI thread via useDerivedValue. `timeWindow` is the
260
+ // *effective* target window: the zoom override when set, else the prop. Both
261
+ // the tick's window lerp and the X-axis tick selection read it, so zoom flows
262
+ // downstream for free (mirrors how viewEnd drives `timestamp`).
263
+ const timeWindow = useDerivedValue(() => viewWindow.value ?? config.timeWindow);
188
264
  // Static charts snap to their target in one tick (smoothing=1), so the single
189
265
  // settle reaction below produces the final state with no per-frame easing.
190
266
  const smoothing = useDerivedValue(() =>
@@ -193,7 +269,15 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
193
269
  const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
194
270
  const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
195
271
  const referenceValue = useDerivedValue(() => config.referenceValue);
196
- const referenceValues = useDerivedValue(() => config.referenceValues);
272
+ // Captured directly (not via `config.`) so Reanimated tracks the SharedValue and
273
+ // the merge re-runs each frame while a line is dragged; stable when idle.
274
+ const liveReferenceValuesSV = config.liveReferenceValues;
275
+ const referenceValues = useDerivedValue(() => {
276
+ const base = config.referenceValues;
277
+ const live = liveReferenceValuesSV?.value;
278
+ if (!live || live.length === 0) return base;
279
+ return base && base.length > 0 ? base.concat(live) : live;
280
+ });
197
281
  const nonNegativeSV = useDerivedValue(() => config.nonNegative ?? false);
198
282
  const maxValueSV = useDerivedValue(() => config.maxValue);
199
283
  const nowOverrideSV = useDerivedValue(() => config.nowOverride);
@@ -212,6 +296,14 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
212
296
  const [initialTimestamp] = useState(() => Date.now() / 1000);
213
297
  const timestamp = useSharedValue(initialTimestamp);
214
298
 
299
+ // Pan-scroll state. `viewEnd` null = follow live; a number freezes the right
300
+ // edge at that absolute time. `liveEdge` mirrors the would-be-live timestamp
301
+ // each frame so the gesture can clamp / detect catch-up. Both default to
302
+ // "following" so charts without `timeScroll` behave exactly as before.
303
+ const viewEnd = useSharedValue<number | null>(null);
304
+ const liveEdge = useSharedValue(initialTimestamp);
305
+ const edgeValue = useSharedValue(0);
306
+
215
307
  // Live data extrema (value + time of the visible high / low). NaN until the
216
308
  // first tick finds data — the extrema label stays hidden until then.
217
309
  const extremaMinValue = useSharedValue(NaN);
@@ -250,6 +342,10 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
250
342
  nowOverrideSV,
251
343
  windowBufferSV,
252
344
  pausedSV,
345
+ viewEndSV: viewEnd,
346
+ viewWindowSV: viewWindow,
347
+ liveEdgeSV: liveEdge,
348
+ edgeValueSV: edgeValue,
253
349
  modeSV,
254
350
  candles,
255
351
  liveCandle,
@@ -307,6 +403,10 @@ export function useLiveChartEngine(config: EngineConfig): SingleEngineState {
307
403
  canvasWidth,
308
404
  canvasHeight,
309
405
  timestamp,
406
+ viewEnd,
407
+ viewWindow,
408
+ liveEdge,
409
+ edgeValue,
310
410
  extremaMinValue,
311
411
  extremaMaxValue,
312
412
  extremaMinTime,
@@ -8,7 +8,7 @@ import {
8
8
  import { MS_PER_FRAME_60FPS } from "../constants";
9
9
  import type { LiveChartPoint, SeriesConfig } from "../types";
10
10
  import { tickLiveChartSeriesEngineFrame } from "./liveChartSeriesEngineTick";
11
- import type { MultiEngineState } from "./useLiveChartEngine";
11
+ import type { ChartEngineScroll, MultiEngineState } from "./useLiveChartEngine";
12
12
 
13
13
  export interface MultiSeriesEngineConfig {
14
14
  series: SharedValue<SeriesConfig[]>;
@@ -47,6 +47,12 @@ export interface MultiEngineFrameRefs {
47
47
  nowOverrideSV?: SharedValue<number | undefined>;
48
48
  windowBufferSV?: SharedValue<number>;
49
49
  pausedSV: SharedValue<boolean>;
50
+ /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
51
+ viewEndSV?: SharedValue<number | null>;
52
+ /** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
53
+ viewWindowSV?: SharedValue<number | null>;
54
+ /** Receives the computed live edge each frame. Optional for callers/tests. */
55
+ liveEdgeSV?: SharedValue<number>;
50
56
  extremaMinValue: SharedValue<number>;
51
57
  extremaMaxValue: SharedValue<number>;
52
58
  extremaMinTime: SharedValue<number>;
@@ -105,6 +111,7 @@ export function applyLiveChartSeriesEngineFrame(
105
111
  displayMax: sv.displayMax.value,
106
112
  displayWindow: sv.displayWindow.value,
107
113
  timestamp: sv.timestamp.value,
114
+ liveEdge: sv.liveEdgeSV?.value ?? 0,
108
115
  displayValues,
109
116
  opacities,
110
117
  extremaMinValue: sv.extremaMinValue.value,
@@ -129,11 +136,14 @@ export function applyLiveChartSeriesEngineFrame(
129
136
  series: seriesSnap,
130
137
  nowSeconds: Date.now() / 1000,
131
138
  paused: sv.pausedSV.value,
139
+ viewEnd: sv.viewEndSV?.value,
140
+ viewWindow: sv.viewWindowSV?.value,
132
141
  });
133
142
  sv.displayMin.value = state.displayMin;
134
143
  sv.displayMax.value = state.displayMax;
135
144
  sv.displayWindow.value = state.displayWindow;
136
145
  sv.timestamp.value = state.timestamp;
146
+ if (sv.liveEdgeSV) sv.liveEdgeSV.value = state.liveEdge;
137
147
  sv.displaySeriesValues.value = state.displayValues;
138
148
  sv.seriesOpacities.value = state.opacities;
139
149
  sv.extremaMinValue.value = state.extremaMinValue;
@@ -148,8 +158,11 @@ export function applyLiveChartSeriesEngineFrame(
148
158
  */
149
159
  export function useLiveChartSeriesEngine(
150
160
  config: MultiSeriesEngineConfig,
151
- ): MultiEngineState {
152
- const timeWindow = useDerivedValue(() => config.timeWindow);
161
+ ): MultiEngineState & ChartEngineScroll {
162
+ // Pinch-zoom window-width override (null = follow the configured window).
163
+ // Declared first so `timeWindow` folds it in — see useLiveChartEngine.
164
+ const viewWindow = useSharedValue<number | null>(null);
165
+ const timeWindow = useDerivedValue(() => viewWindow.value ?? config.timeWindow);
153
166
  const smoothing = useDerivedValue(() => config.smoothing);
154
167
  const adaptiveSpeedBoostSV = useDerivedValue(() => config.adaptiveSpeedBoost);
155
168
  const exaggerateSV = useDerivedValue(() => config.exaggerate ?? false);
@@ -170,6 +183,10 @@ export function useLiveChartSeriesEngine(
170
183
  const [initialTimestamp] = useState(() => Date.now() / 1000);
171
184
  const timestamp = useSharedValue(initialTimestamp);
172
185
 
186
+ // Pan-scroll state (see useLiveChartEngine). Defaults to following live.
187
+ const viewEnd = useSharedValue<number | null>(null);
188
+ const liveEdge = useSharedValue(initialTimestamp);
189
+
173
190
  const displaySeriesValues = useSharedValue<number[]>([]);
174
191
  const seriesOpacities = useSharedValue<number[]>([]);
175
192
 
@@ -217,6 +234,9 @@ export function useLiveChartSeriesEngine(
217
234
  nowOverrideSV,
218
235
  windowBufferSV,
219
236
  pausedSV,
237
+ viewEndSV: viewEnd,
238
+ viewWindowSV: viewWindow,
239
+ liveEdgeSV: liveEdge,
220
240
  extremaMinValue,
221
241
  extremaMaxValue,
222
242
  extremaMinTime,
@@ -237,6 +257,9 @@ export function useLiveChartSeriesEngine(
237
257
  canvasWidth,
238
258
  canvasHeight,
239
259
  timestamp,
260
+ viewEnd,
261
+ viewWindow,
262
+ liveEdge,
240
263
  series,
241
264
  displaySeriesValues,
242
265
  seriesOpacities,
package/src/draw/grid.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GRID_METRICS_DEFAULTS } from "../constants";
1
+ import { GRID_METRICS_DEFAULTS, MAX_Y_LABELS } from "../constants";
2
2
  import { lerp } from "../math/lerp";
3
3
  import type { GridMetrics } from "../types";
4
4
 
@@ -8,6 +8,43 @@ export interface YAxisEntry {
8
8
  alpha: number;
9
9
  }
10
10
 
11
+ /**
12
+ * Fixed-count Y-axis: place exactly `count` price labels evenly **in pixels**
13
+ * across the plot band — top = `displayMax`, bottom = `displayMin`. Values track
14
+ * the live range each frame (no nice-number rounding), so the label count stays
15
+ * constant while data streams in.
16
+ *
17
+ * `minGap` is a floor: the count drops to what fits when the plot is too short
18
+ * to space `count` labels at least `minGap` px apart. Clamped to
19
+ * `[2, MAX_Y_LABELS]`. Every entry is fully opaque — there's no per-line fade
20
+ * because the count (and thus each line's pixel slot) doesn't change.
21
+ */
22
+ export function fixedGridEntries(
23
+ displayMax: number,
24
+ valRange: number,
25
+ chartH: number,
26
+ padTop: number,
27
+ count: number,
28
+ minGap: number,
29
+ formatValue: (v: number) => string,
30
+ ): YAxisEntry[] {
31
+ "worklet";
32
+ // minGap floor: at most `floor(chartH / minGap)` gaps fit ⇒ that many + 1 labels.
33
+ const maxFit = Math.floor(chartH / minGap) + 1;
34
+ let n = Math.min(count, maxFit, MAX_Y_LABELS);
35
+ if (n < 2) n = 2;
36
+
37
+ const stepPx = chartH / (n - 1);
38
+ const stepVal = valRange / (n - 1);
39
+ const entries: YAxisEntry[] = [];
40
+ for (let i = 0; i < n; i++) {
41
+ const y = padTop + i * stepPx;
42
+ const val = displayMax - i * stepVal;
43
+ entries.push({ y, label: formatValue(val), alpha: 1 });
44
+ }
45
+ return entries;
46
+ }
47
+
11
48
  /**
12
49
  * Pick a nice Y-axis interval using TradingView's cycling divisor approach.
13
50
  * Hysteresis: once chosen, sticks until pixel spacing falls outside [0.5x, 4x] of minGap.
@@ -94,6 +131,7 @@ export function computeGridEntries(
94
131
  dt: number,
95
132
  minGap = 36,
96
133
  grid: GridMetrics = GRID_METRICS_DEFAULTS,
134
+ count = 0,
97
135
  ): { entries: YAxisEntry[]; interval: number } {
98
136
  "worklet";
99
137
  const chartH = canvasHeight - padTop - padBottom;
@@ -102,6 +140,25 @@ export function computeGridEntries(
102
140
  const valRange = displayMax - displayMin;
103
141
  if (valRange <= 0) return { entries: [], interval: prevInterval };
104
142
 
143
+ // Fixed-count mode bypasses nice-interval picking and per-line fading. Leave
144
+ // `prevInterval`/`labelAlphas` untouched so toggling back to the dynamic grid
145
+ // resumes cleanly. Needs at least 2 labels (a high/low pair) to be meaningful;
146
+ // count of 0 or 1 falls through to the dynamic nice-interval grid.
147
+ if (count >= 2) {
148
+ return {
149
+ entries: fixedGridEntries(
150
+ displayMax,
151
+ valRange,
152
+ chartH,
153
+ padTop,
154
+ count,
155
+ minGap,
156
+ formatValue,
157
+ ),
158
+ interval: prevInterval,
159
+ };
160
+ }
161
+
105
162
  const pxPerUnit = chartH / valRange;
106
163
 
107
164
  const coarse = pickInterval(valRange, pxPerUnit, minGap, prevInterval);
@@ -114,7 +171,19 @@ export function computeGridEntries(
114
171
  // Phase 1: compute target alpha for every grid line
115
172
  const targets: Record<number, number> = {};
116
173
  const first = Math.ceil(displayMin / fine) * fine;
117
- for (let val = first; val <= displayMax; val += fine) {
174
+ // Guard against a non-advancing loop. On a near-flat (but not bit-identical)
175
+ // range, `fine` can drop below ulp(val), so `val += fine === val` and the loop
176
+ // never terminates — freezing the UI thread, since this runs in a worklet every
177
+ // frame. `stepResolves` skips the degenerate range (also catches fine === 0);
178
+ // MAX_GRID_LINES is a hard backstop in case the step stalls mid-loop (real
179
+ // grids only have a handful of lines, so it never bites normal data).
180
+ const stepResolves = first + fine !== first;
181
+ const MAX_GRID_LINES = 1000;
182
+ for (
183
+ let val = first, lineCount = 0;
184
+ stepResolves && val <= displayMax && lineCount < MAX_GRID_LINES;
185
+ val += fine, lineCount++
186
+ ) {
118
187
  const y = gridValueToY(val, displayMax, valRange, padTop, chartH);
119
188
  /* istanbul ignore next -- y stays in chart band for v in [first, displayMax] with consistent toY */
120
189
  if (y < padTop - 2 || y > canvasHeight - padBottom + 2) continue;