react-native-livechart 4.13.1 → 4.14.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 (40) hide show
  1. package/dist/components/LiveChart.d.ts.map +1 -1
  2. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/core/liveChartEngineTick.d.ts +9 -0
  5. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  6. package/dist/core/liveChartSeriesEngineTick.d.ts +7 -0
  7. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  8. package/dist/core/resolveConfig.d.ts +8 -1
  9. package/dist/core/resolveConfig.d.ts.map +1 -1
  10. package/dist/core/useLiveChartEngine.d.ts +8 -0
  11. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  12. package/dist/core/useLiveChartSeriesEngine.d.ts +8 -0
  13. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  14. package/dist/draw/candle.d.ts.map +1 -1
  15. package/dist/draw/line.d.ts +1 -1
  16. package/dist/draw/line.d.ts.map +1 -1
  17. package/dist/draw/volume.d.ts.map +1 -1
  18. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  19. package/dist/hooks/usePanScroll.d.ts +41 -6
  20. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  21. package/dist/hooks/usePinchZoom.d.ts +8 -1
  22. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  23. package/dist/types.d.ts +13 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/LiveChart.tsx +9 -0
  27. package/src/components/LiveChartSeries.tsx +9 -0
  28. package/src/constants.ts +1 -0
  29. package/src/core/liveChartEngineTick.ts +15 -2
  30. package/src/core/liveChartSeriesEngineTick.ts +12 -1
  31. package/src/core/resolveConfig.ts +22 -0
  32. package/src/core/useLiveChartEngine.ts +14 -0
  33. package/src/core/useLiveChartSeriesEngine.ts +14 -0
  34. package/src/draw/candle.ts +5 -1
  35. package/src/draw/line.ts +13 -0
  36. package/src/draw/volume.ts +5 -1
  37. package/src/hooks/useChartPaths.ts +4 -0
  38. package/src/hooks/usePanScroll.ts +109 -11
  39. package/src/hooks/usePinchZoom.ts +25 -4
  40. package/src/types.ts +13 -0
@@ -46,6 +46,7 @@ import {
46
46
  resolveScrub,
47
47
  resolveScrubAction,
48
48
  resolveTransitions,
49
+ resolveOverscroll,
49
50
  resolveReturnToLiveMs,
50
51
  resolveSelectionDot,
51
52
  resolveThreshold,
@@ -607,6 +608,11 @@ function useLiveChartController({
607
608
  // Glide duration for the return-to-live animation (0 = instant). A sibling of
608
609
  // `timeScroll` so it survives `timeScroll={false}` (the disable that triggers it).
609
610
  const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
611
+ // Overscroll fraction ([0, 1)) — how far pan/zoom may travel past the data
612
+ // bounds into blank space. 0 (the default) keeps the classic hard stops.
613
+ const timeScrollOverscroll = timeScrollEnabled
614
+ ? resolveOverscroll(timeScroll)
615
+ : 0;
610
616
  const zoomCfg = resolveZoom(zoom);
611
617
  const zoomEnabled = zoomCfg !== null && !isStatic;
612
618
 
@@ -677,6 +683,7 @@ function useLiveChartController({
677
683
  static: isStatic,
678
684
  snapKey,
679
685
  scrollEnabled: timeScrollEnabled,
686
+ allowFutureViewEnd: timeScrollOverscroll > 0,
680
687
  returnToLiveMs,
681
688
  smoothing,
682
689
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
@@ -1089,6 +1096,7 @@ function useLiveChartController({
1089
1096
  minTime: scrollMinTime,
1090
1097
  enabled: timeScrollEnabled,
1091
1098
  mode: scrollGestureMode,
1099
+ overscroll: timeScrollOverscroll,
1092
1100
  scrollActive,
1093
1101
  // Once a scrub is engaged the chart is locked: scrolling goes inert so the
1094
1102
  // finger only moves the price indicator across a fixed window.
@@ -1111,6 +1119,7 @@ function useLiveChartController({
1111
1119
  enabled: zoomEnabled,
1112
1120
  minTimeWindow: zoomCfg?.minTimeWindow,
1113
1121
  maxTimeWindow: zoomCfg?.maxTimeWindow,
1122
+ overscroll: timeScrollOverscroll,
1114
1123
  onZoomStart: () => {
1115
1124
  "worklet";
1116
1125
  crosshairScrubActive.set(false);
@@ -39,6 +39,7 @@ import {
39
39
  resolveMarkerCluster,
40
40
  resolveMetrics,
41
41
  resolveMultiSeriesDot,
42
+ resolveOverscroll,
42
43
  resolveReturnToLiveMs,
43
44
  resolveScrub,
44
45
  resolveSelectionDot,
@@ -211,6 +212,11 @@ function useLiveChartSeriesController({
211
212
  // Return-to-live glide duration (0 = instant); sibling of `timeScroll` so it
212
213
  // survives `timeScroll={false}` (the disable that triggers it). See #164.
213
214
  const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
215
+ // Overscroll fraction ([0, 1)) — how far pan/zoom may travel past the data
216
+ // bounds into blank space. 0 (the default) keeps the classic hard stops.
217
+ const timeScrollOverscroll = timeScrollEnabled
218
+ ? resolveOverscroll(timeScroll)
219
+ : 0;
214
220
  const zoomCfg = resolveZoom(zoom);
215
221
  const zoomEnabled = zoomCfg !== null;
216
222
  const scrollGestureMode =
@@ -367,6 +373,7 @@ function useLiveChartSeriesController({
367
373
  paused,
368
374
  snapKey,
369
375
  scrollEnabled: timeScrollEnabled,
376
+ allowFutureViewEnd: timeScrollOverscroll > 0,
370
377
  returnToLiveMs,
371
378
  smoothing,
372
379
  adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
@@ -508,6 +515,7 @@ function useLiveChartSeriesController({
508
515
  minTime: scrollMinTime,
509
516
  enabled: timeScrollEnabled,
510
517
  mode: scrollGestureMode,
518
+ overscroll: timeScrollOverscroll,
511
519
  scrollActive,
512
520
  // Once a scrub is engaged the chart is locked: scrolling goes inert so the
513
521
  // finger only moves the price indicator across a fixed window.
@@ -526,6 +534,7 @@ function useLiveChartSeriesController({
526
534
  enabled: zoomEnabled,
527
535
  minTimeWindow: zoomCfg?.minTimeWindow,
528
536
  maxTimeWindow: zoomCfg?.maxTimeWindow,
537
+ overscroll: timeScrollOverscroll,
529
538
  onZoomStart: () => {
530
539
  "worklet";
531
540
  crosshairScrubActive.set(false);
package/src/constants.ts CHANGED
@@ -68,6 +68,7 @@ export const CANDLE_METRICS_DEFAULTS: CandleMetrics = {
68
68
  minBodyPx: 1,
69
69
  maxBodyPx: 40,
70
70
  bodyWidthRatio: 0.8,
71
+ minGapPx: 2,
71
72
  bodyRadius: 0,
72
73
  wickWidth: 1,
73
74
  };
@@ -93,6 +93,15 @@ export interface EngineTickInput {
93
93
  * resumes following. Takes precedence over {@link paused}.
94
94
  */
95
95
  viewEnd?: number | null;
96
+ /**
97
+ * Honor a {@link viewEnd} parked at or past the live edge (blank future space)
98
+ * instead of falling through to following live. Set when `timeScroll.overscroll`
99
+ * is active — the pan/pinch gestures may then park the right edge beyond the
100
+ * live edge, and the engine must freeze there or the gestures render nothing.
101
+ * The `viewEnd >= firstDataTime` strand-guard applies in both modes. Default
102
+ * `false` (classic behavior: only a past `viewEnd` freezes).
103
+ */
104
+ allowFutureViewEnd?: boolean;
96
105
  /**
97
106
  * "Return to live" glide (see #164). When time-scroll is disabled while scrolled
98
107
  * back, the engine hook clears {@link viewEnd} and animates {@link returnT} from
@@ -148,9 +157,13 @@ export function tickLiveChartEngineFrame(
148
157
  // before the active series' first point falls through to following live (so a
149
158
  // line/candle span mismatch never strands the window on an empty plot). When
150
159
  // time-scroll is disabled the hook clears `viewEnd` (and kicks off the glide
151
- // below), so a stale edge can't keep the window frozen. See #164.
160
+ // below), so a stale edge can't keep the window frozen. See #164. With
161
+ // `allowFutureViewEnd` (timeScroll.overscroll) an edge parked past live is
162
+ // honored too — the data-overlap strand-guard stays in both modes.
152
163
  const scrolledBack =
153
- viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
164
+ viewEnd != null &&
165
+ viewEnd >= firstDataTime &&
166
+ (viewEnd < liveEdge || input.allowFutureViewEnd === true);
154
167
  if (scrolledBack) {
155
168
  state.timestamp = viewEnd;
156
169
  } else if (!input.paused) {
@@ -67,6 +67,13 @@ export interface MultiEngineTickInput {
67
67
  * precedence over {@link paused}.
68
68
  */
69
69
  viewEnd?: number | null;
70
+ /**
71
+ * Honor a {@link viewEnd} parked at or past the live edge (blank future space)
72
+ * instead of falling through to following live. Set when `timeScroll.overscroll`
73
+ * is active. The `viewEnd >= firstDataTime` strand-guard applies in both modes.
74
+ * Default `false` (classic behavior: only a past `viewEnd` freezes).
75
+ */
76
+ allowFutureViewEnd?: boolean;
70
77
  /**
71
78
  * "Return to live" glide (see #164). When time-scroll is disabled while scrolled
72
79
  * back, the hook clears {@link viewEnd} and animates {@link returnT} `0`→`1`;
@@ -113,8 +120,12 @@ export function tickLiveChartSeriesEngineFrame(
113
120
  // edge AND that edge still sits within the data; a stranded edge falls through
114
121
  // to following live. When time-scroll is disabled the hook clears `viewEnd` and
115
122
  // kicks off the glide below, so a stale edge can't keep the window frozen. #164.
123
+ // With `allowFutureViewEnd` (timeScroll.overscroll) an edge parked past live is
124
+ // honored too — the data-overlap strand-guard stays in both modes.
116
125
  const scrolledBack =
117
- viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
126
+ viewEnd != null &&
127
+ viewEnd >= firstDataTime &&
128
+ (viewEnd < liveEdge || input.allowFutureViewEnd === true);
118
129
  if (scrolledBack) {
119
130
  state.timestamp = viewEnd;
120
131
  } else if (!input.paused) {
@@ -30,6 +30,7 @@ import type {
30
30
  SelectionDotRingConfig,
31
31
  ReturnToLiveConfig,
32
32
  ThresholdConfig,
33
+ TimeScrollConfig,
33
34
  ThresholdLineConfig,
34
35
  TradeEvent,
35
36
  TransitionConfig,
@@ -560,6 +561,27 @@ export function resolveReturnToLiveMs(
560
561
  return d > 0 ? d : 0;
561
562
  }
562
563
 
564
+ /**
565
+ * Overscroll ceiling — `overscroll: 1` would let the window scroll fully past
566
+ * the data (an all-blank plot), so the fraction clamps just short of it.
567
+ */
568
+ const MAX_OVERSCROLL = 0.99;
569
+
570
+ /**
571
+ * Resolves `timeScroll.overscroll` to a clamped `[0, 1)` fraction of the
572
+ * visible window that pan / fling / pinch may travel past the data bounds.
573
+ * `0` (booleans, omitted, or non-positive values) keeps the classic hard
574
+ * stops at the oldest data and the live edge. See {@link TimeScrollConfig}.
575
+ */
576
+ export function resolveOverscroll(
577
+ prop: boolean | TimeScrollConfig | undefined,
578
+ ): number {
579
+ if (prop == null || typeof prop === "boolean") return 0;
580
+ const v = prop.overscroll;
581
+ if (v == null || !(v > 0)) return 0;
582
+ return v < MAX_OVERSCROLL ? v : MAX_OVERSCROLL;
583
+ }
584
+
563
585
  /**
564
586
  * Resolved transition durations. `undefined` for a field means "use the
565
587
  * component's built-in default" (so we don't duplicate the default constants
@@ -65,6 +65,12 @@ export interface EngineConfig {
65
65
  * the chart at a past position. See #164.
66
66
  */
67
67
  scrollEnabled?: boolean;
68
+ /**
69
+ * Honor a `viewEnd` parked at or past the live edge — set when
70
+ * `timeScroll.overscroll` is active so the pan/pinch gestures can drag into
71
+ * blank future space. See {@link EngineTickInput.allowFutureViewEnd}.
72
+ */
73
+ allowFutureViewEnd?: boolean;
68
74
  /**
69
75
  * Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
70
76
  * `false` while scrolled back. `0` snaps instantly (no animation). Defaults to
@@ -208,6 +214,8 @@ export interface EngineFrameRefs {
208
214
  pausedSV: SharedValue<boolean>;
209
215
  /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
210
216
  viewEndSV?: SharedValue<number | null>;
217
+ /** Honor a `viewEnd` past the live edge (timeScroll.overscroll). Optional. */
218
+ allowFutureViewEndSV?: SharedValue<boolean>;
211
219
  /** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
212
220
  returnTSV?: SharedValue<number>;
213
221
  /** Frozen right-edge time the return glide starts from. Optional. */
@@ -340,6 +348,7 @@ export function applyLiveChartEngineFrame(
340
348
  input.paused = sv.pausedSV.value;
341
349
  input.snap = snap;
342
350
  input.viewEnd = sv.viewEndSV?.value;
351
+ input.allowFutureViewEnd = sv.allowFutureViewEndSV?.value ?? false;
343
352
  input.returnT = sv.returnTSV?.value;
344
353
  input.returnFrom = sv.returnFromSV?.value;
345
354
  input.viewWindow = sv.viewWindowSV?.value;
@@ -430,6 +439,10 @@ export function useLiveChartEngine(
430
439
  // (the tick no longer reads it — clearing `viewEnd` is what makes it follow).
431
440
  // Defaults to enabled so a caller that omits it behaves as before.
432
441
  const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
442
+ // Honor a viewEnd parked past the live edge (timeScroll.overscroll).
443
+ const allowFutureViewEndSV = useDerivedValue(
444
+ () => config.allowFutureViewEnd ?? false,
445
+ );
433
446
  // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
434
447
  const returnToLiveMsSV = useDerivedValue(
435
448
  () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
@@ -516,6 +529,7 @@ export function useLiveChartEngine(
516
529
  windowBufferSV,
517
530
  pausedSV,
518
531
  viewEndSV: viewEnd,
532
+ allowFutureViewEndSV,
519
533
  returnTSV: returnT,
520
534
  returnFromSV: returnFrom,
521
535
  viewWindowSV: viewWindow,
@@ -39,6 +39,12 @@ export interface MultiSeriesEngineConfig {
39
39
  * strands the chart at a past position. See #164.
40
40
  */
41
41
  scrollEnabled?: boolean;
42
+ /**
43
+ * Honor a `viewEnd` parked at or past the live edge — set when
44
+ * `timeScroll.overscroll` is active so the pan/pinch gestures can drag into
45
+ * blank future space. See {@link MultiEngineTickInput.allowFutureViewEnd}.
46
+ */
47
+ allowFutureViewEnd?: boolean;
42
48
  /**
43
49
  * Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
44
50
  * `false` while scrolled back. `0` snaps instantly. Defaults to
@@ -77,6 +83,8 @@ export interface MultiEngineFrameRefs {
77
83
  pausedSV: SharedValue<boolean>;
78
84
  /** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
79
85
  viewEndSV?: SharedValue<number | null>;
86
+ /** Honor a `viewEnd` past the live edge (timeScroll.overscroll). Optional. */
87
+ allowFutureViewEndSV?: SharedValue<boolean>;
80
88
  /** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
81
89
  returnTSV?: SharedValue<number>;
82
90
  /** Frozen right-edge time the return glide starts from. Optional. */
@@ -240,6 +248,7 @@ export function applyLiveChartSeriesEngineFrame(
240
248
  input.paused = sv.pausedSV.value;
241
249
  input.snap = snap;
242
250
  input.viewEnd = sv.viewEndSV?.value;
251
+ input.allowFutureViewEnd = sv.allowFutureViewEndSV?.value ?? false;
243
252
  input.returnT = sv.returnTSV?.value;
244
253
  input.returnFrom = sv.returnFromSV?.value;
245
254
  input.viewWindow = sv.viewWindowSV?.value;
@@ -287,6 +296,10 @@ export function useLiveChartSeriesEngine(
287
296
  // Whether time-scroll is active — drives the return-to-live reaction below.
288
297
  // Defaults to enabled (legacy behavior).
289
298
  const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
299
+ // Honor a viewEnd parked past the live edge (timeScroll.overscroll).
300
+ const allowFutureViewEndSV = useDerivedValue(
301
+ () => config.allowFutureViewEnd ?? false,
302
+ );
290
303
  // Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
291
304
  const returnToLiveMsSV = useDerivedValue(
292
305
  () => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
@@ -362,6 +375,7 @@ export function useLiveChartSeriesEngine(
362
375
  windowBufferSV,
363
376
  pausedSV,
364
377
  viewEndSV: viewEnd,
378
+ allowFutureViewEndSV,
365
379
  returnTSV: returnT,
366
380
  returnFromSV: returnFrom,
367
381
  viewWindowSV: viewWindow,
@@ -144,7 +144,11 @@ export function buildCandleGeometry(
144
144
  const slotPx = (candleWidthSecs / windowSecs) * chartW;
145
145
  const bodyW = Math.max(
146
146
  1,
147
- Math.min(slotPx * metrics.bodyWidthRatio, slotPx - 2, metrics.maxBodyPx),
147
+ Math.min(
148
+ slotPx * metrics.bodyWidthRatio,
149
+ slotPx - metrics.minGapPx,
150
+ metrics.maxBodyPx,
151
+ ),
148
152
  );
149
153
 
150
154
  const bodies: CandleRect[] = [];
package/src/draw/line.ts CHANGED
@@ -229,6 +229,7 @@ export function buildLinePoints(
229
229
  canvasHeight: number,
230
230
  padding: ChartPadding,
231
231
  out?: number[],
232
+ omitTipBeyondData = false,
232
233
  ): number[] {
233
234
  "worklet";
234
235
  const pts: number[] = out ?? [];
@@ -328,6 +329,18 @@ export function buildLinePoints(
328
329
  }
329
330
  }
330
331
 
332
+ // A scrolled-back / overscrolled window can end after the last data point;
333
+ // the synthetic tip would then fabricate a flat segment from that point to
334
+ // the right edge. Callers pass `omitTipBeyondData` to end the line at the
335
+ // last real point instead.
336
+ if (
337
+ omitTipBeyondData &&
338
+ endIdx === data.length &&
339
+ data[endIdx - 1].time < now
340
+ ) {
341
+ return pts;
342
+ }
343
+
331
344
  // Live tip at current time with smoothed value
332
345
  pts.push(
333
346
  padding.left + chartW,
@@ -113,7 +113,11 @@ export function buildVolumeGeometry(
113
113
  const slotPx = (candleWidthSecs / windowSecs) * chartW;
114
114
  const bodyW = Math.max(
115
115
  1,
116
- Math.min(slotPx * metrics.bodyWidthRatio, slotPx - 2, metrics.maxBodyPx),
116
+ Math.min(
117
+ slotPx * metrics.bodyWidthRatio,
118
+ slotPx - metrics.minGapPx,
119
+ metrics.maxBodyPx,
120
+ ),
117
121
  );
118
122
 
119
123
  const winEnd = winStart + windowSecs;
@@ -105,6 +105,10 @@ export function useChartPaths(
105
105
  engine.canvasHeight.get(),
106
106
  padding,
107
107
  buf,
108
+ // Only a live-following chart may extend the line to the right edge; a
109
+ // parked (scrolled-back / overscrolled) window ends at its last real
110
+ // point rather than fabricating a flat line into dataless space.
111
+ engine.viewEnd.get() != null,
108
112
  );
109
113
 
110
114
  // Skip blending when fully revealed or no morphT provided
@@ -1,6 +1,8 @@
1
1
  import { Gesture } from "react-native-gesture-handler";
2
2
  import {
3
3
  cancelAnimation,
4
+ useAnimatedReaction,
5
+ useDerivedValue,
4
6
  useSharedValue,
5
7
  withDecay,
6
8
  type SharedValue,
@@ -71,26 +73,85 @@ export interface UsePanScrollOptions {
71
73
  * price indicator. Cleared when the finger lifts (the scrub pan's finalize).
72
74
  */
73
75
  scrubActive?: SharedValue<boolean>;
76
+ /**
77
+ * Fraction of the visible window (0–1) the pan may travel past the data
78
+ * bounds — into blank future space beyond the live edge and blank history
79
+ * before the oldest point (TradingView-style free dragging). `0` (default)
80
+ * keeps the classic hard stops at the data. Resolved from
81
+ * `timeScroll.overscroll` (see `resolveOverscroll`).
82
+ */
83
+ overscroll?: number;
74
84
  }
75
85
 
86
+ /**
87
+ * Snap-to-follow zone around the live edge, as a fraction of the window. With
88
+ * overscroll a released drag/fling that settles within this zone re-attaches to
89
+ * live; anything further out stays parked where it stopped.
90
+ */
91
+ export const FOLLOW_SNAP = 0.02;
92
+
76
93
  /**
77
94
  * Smallest valid right-edge time: keeps the window's left edge
78
95
  * (`rightEdge - window`) from passing `minTime`, and never exceeds the live edge
79
- * so the `[lo, liveEdge]` clamp range stays valid when history is short.
96
+ * so the `[lo, liveEdge]` clamp range stays valid when history is short. With
97
+ * `overscroll` the left edge may pass `minTime` by that fraction of the window,
98
+ * exposing blank space before the oldest data.
80
99
  */
81
100
  export function panLowerBound(
82
101
  minTime: number,
83
102
  windowSecs: number,
84
103
  liveEdge: number,
104
+ overscroll: number = 0,
105
+ ): number {
106
+ "worklet";
107
+ return Math.min(minTime + windowSecs * (1 - overscroll), liveEdge);
108
+ }
109
+
110
+ /**
111
+ * Largest valid right-edge time: the live edge, pushed past it by `overscroll`
112
+ * (a fraction of the window) so the latest data can be dragged toward the middle
113
+ * of the plot, leaving blank future space on the right. `0` = the live edge.
114
+ */
115
+ export function panUpperBound(
116
+ windowSecs: number,
117
+ liveEdge: number,
118
+ overscroll: number,
85
119
  ): number {
86
120
  "worklet";
87
- return Math.min(minTime + windowSecs, liveEdge);
121
+ return liveEdge + windowSecs * overscroll;
122
+ }
123
+
124
+ /**
125
+ * Re-clamp a parked right edge after the overscroll setting changes. Reducing
126
+ * the allowance must not leave a stale future/history position outside the new
127
+ * bounds; at the classic live-edge hard stop, `null` resumes following live.
128
+ */
129
+ export function clampViewEndForOverscroll(
130
+ viewEnd: number,
131
+ minTime: number,
132
+ windowSecs: number,
133
+ liveEdge: number,
134
+ overscroll: number,
135
+ ): number | null {
136
+ "worklet";
137
+ const lo = panLowerBound(minTime, windowSecs, liveEdge, overscroll);
138
+ if (viewEnd < lo) return lo;
139
+ const hi = panUpperBound(windowSecs, liveEdge, overscroll);
140
+ if (viewEnd >= hi) return overscroll > 0 ? hi : null;
141
+ return viewEnd;
88
142
  }
89
143
 
90
144
  /**
91
145
  * Next right-edge time after dragging `changeX` px (drag right ⇒ reveal earlier
92
- * time ⇒ smaller right edge), clamped to `[lo, liveEdge]`. Returns `null` once
93
- * the drag reaches the live edge — the signal to resume following.
146
+ * time ⇒ smaller right edge), clamped to `[lo, panUpperBound]`. Without
147
+ * overscroll, returns `null` once the drag reaches the live edge — the signal to
148
+ * resume following.
149
+ *
150
+ * With overscroll it must NOT return `null` mid-drag: the pan's `onChange`
151
+ * re-derives `cur` from `viewEnd ?? liveEdge` each frame, so snapping to follow
152
+ * re-anchors every per-frame delta at the live edge and the drag can't escape it
153
+ * in either direction. Re-attaching to live happens only in the release decay
154
+ * callback (see `usePanScroll`), inside the {@link FOLLOW_SNAP} zone.
94
155
  */
95
156
  export function nextViewEnd(
96
157
  cur: number,
@@ -99,10 +160,16 @@ export function nextViewEnd(
99
160
  windowSecs: number,
100
161
  liveEdge: number,
101
162
  lo: number,
163
+ overscroll: number = 0,
102
164
  ): number | null {
103
165
  "worklet";
104
166
  let next = cur - (changeX / chartW) * windowSecs;
105
167
  if (next < lo) next = lo;
168
+ if (overscroll > 0) {
169
+ const hi = panUpperBound(windowSecs, liveEdge, overscroll);
170
+ if (next > hi) next = hi;
171
+ return next;
172
+ }
106
173
  if (next > liveEdge) next = liveEdge;
107
174
  return next >= liveEdge ? null : next;
108
175
  }
@@ -150,6 +217,7 @@ export function usePanScroll({
150
217
  onScrollStart,
151
218
  scrollActive,
152
219
  scrubActive,
220
+ overscroll = 0,
153
221
  }: UsePanScrollOptions): ReturnType<typeof Gesture.Pan> {
154
222
  const { viewEnd, liveEdge, displayWindow, canvasWidth, canvasHeight } = engine;
155
223
  const padLeft = padding.left;
@@ -161,6 +229,31 @@ export function usePanScroll({
161
229
  const startX = useSharedValue(0);
162
230
  const startY = useSharedValue(0);
163
231
  const armed = useSharedValue(false);
232
+ const overscrollSV = useDerivedValue(() => overscroll);
233
+
234
+ // A runtime config change (the demo exposes Off / 50% / 90%) applies to an
235
+ // already parked window immediately. Without this, disabling overscroll while
236
+ // parked in the future leaves a numeric `viewEnd`; re-enabling it later revives
237
+ // that stale future position and makes the chart jump without a gesture.
238
+ useAnimatedReaction(
239
+ () => overscrollSV.value,
240
+ /* istanbul ignore next -- UI-thread config reaction; pure clamp is unit-tested */
241
+ (nextOverscroll) => {
242
+ const cur = viewEnd.get();
243
+ if (cur == null) return;
244
+ const next = clampViewEndForOverscroll(
245
+ cur,
246
+ minTime.get(),
247
+ displayWindow.get(),
248
+ liveEdge.get(),
249
+ nextOverscroll,
250
+ );
251
+ if (next !== cur) {
252
+ cancelAnimation(viewEnd);
253
+ viewEnd.set(next);
254
+ }
255
+ },
256
+ );
164
257
 
165
258
  const onStart =
166
259
  /* istanbul ignore next -- gesture worklet runs on the UI thread, not in Jest */
@@ -194,8 +287,8 @@ export function usePanScroll({
194
287
  if (chartW <= 0) return;
195
288
  const edge = liveEdge.get();
196
289
  const cur = viewEnd.get() ?? edge;
197
- const lo = panLowerBound(minTime.get(), win, edge);
198
- viewEnd.set(nextViewEnd(cur, e.changeX, chartW, win, edge, lo));
290
+ const lo = panLowerBound(minTime.get(), win, edge, overscroll);
291
+ viewEnd.set(nextViewEnd(cur, e.changeX, chartW, win, edge, lo, overscroll));
199
292
  };
200
293
 
201
294
  const onEnd =
@@ -208,15 +301,20 @@ export function usePanScroll({
208
301
  const chartW = canvasWidth.get() - padLeft - padRight;
209
302
  if (chartW <= 0) return;
210
303
  const edge = liveEdge.get();
211
- const lo = panLowerBound(minTime.get(), win, edge);
304
+ const lo = panLowerBound(minTime.get(), win, edge, overscroll);
305
+ const hi = overscroll > 0 ? panUpperBound(win, edge, overscroll) : edge;
306
+ // With overscroll the snap-to-follow zone widens from the exact live edge
307
+ // to FOLLOW_SNAP of the window around it — this release callback is the
308
+ // ONLY place that re-attaches to live (never mid-drag, see nextViewEnd).
309
+ const snapZone = overscroll > 0 ? win * FOLLOW_SNAP : 1e-3;
212
310
  const velocity = flingVelocity(e.velocityX, chartW, win);
213
311
  cancelAnimation(viewEnd);
214
312
  viewEnd.set(
215
- withDecay({ velocity, clamp: [lo, edge] }, (finished) => {
313
+ withDecay({ velocity, clamp: [lo, hi] }, (finished) => {
216
314
  "worklet";
217
- // Landed on the live-edge clamp → resume following; stopped short →
218
- // stay frozen where inertia died.
219
- if (finished && (viewEnd.get() ?? edge) >= edge - 1e-3) {
315
+ // Landed near the live edge → resume following; stopped short (or
316
+ // past, with overscroll) → stay frozen where inertia died.
317
+ if (finished && Math.abs((viewEnd.get() ?? edge) - edge) <= snapZone) {
220
318
  viewEnd.set(null);
221
319
  }
222
320
  }),
@@ -6,7 +6,7 @@ import {
6
6
  } from "react-native-reanimated";
7
7
 
8
8
  import type { ChartPadding } from "../draw/line";
9
- import { panLowerBound } from "./usePanScroll";
9
+ import { FOLLOW_SNAP, panLowerBound, panUpperBound } from "./usePanScroll";
10
10
 
11
11
  /** Engine SharedValues the pinch-zoom gesture reads/writes. */
12
12
  export interface PinchZoomEngineRefs {
@@ -44,6 +44,13 @@ export interface UsePinchZoomOptions {
44
44
  maxTimeWindow?: number;
45
45
  /** Worklet fired when a pinch activates — e.g. to clear a lingering crosshair. */
46
46
  onZoomStart?: () => void;
47
+ /**
48
+ * Fraction of the visible window (0–1) the zoomed window may travel past the
49
+ * data bounds — blank future space beyond the live edge, blank history before
50
+ * the oldest point. `0` (default) keeps the classic hard stops. Shared with
51
+ * {@link usePanScroll} (resolved from `timeScroll.overscroll`).
52
+ */
53
+ overscroll?: number;
47
54
  }
48
55
 
49
56
  /** Clamp a window width to `[minWin, maxWin]`. */
@@ -131,6 +138,7 @@ export function usePinchZoom({
131
138
  minTimeWindow,
132
139
  maxTimeWindow,
133
140
  onZoomStart,
141
+ overscroll = 0,
134
142
  }: UsePinchZoomOptions): ReturnType<typeof Gesture.Pinch> {
135
143
  const { viewWindow, viewEnd, liveEdge, displayWindow, canvasWidth } = engine;
136
144
  const padLeft = padding.left;
@@ -175,14 +183,27 @@ export function usePinchZoom({
175
183
  // doesn't compound; the right edge then keeps it under the (live) focal.
176
184
  const focalT = focalTime(e.focalX, padLeft, chartW, sEnd - sWin, sWin);
177
185
  let newEnd = zoomViewEnd(focalT, e.focalX, padLeft, chartW, newWin);
178
- const lo = panLowerBound(minTime.get(), newWin, edge);
186
+ const lo = panLowerBound(minTime.get(), newWin, edge, overscroll);
179
187
  if (newEnd < lo) newEnd = lo;
180
- if (newEnd > edge) newEnd = edge;
188
+ const hi = overscroll > 0 ? panUpperBound(newWin, edge, overscroll) : edge;
189
+ if (newEnd > hi) newEnd = hi;
181
190
  viewWindow.set(newWin);
182
191
  // Track the displayed window 1:1 (bypass the frame-loop lerp lag) so the
183
192
  // focal anchor is pixel-accurate during the gesture.
184
193
  displayWindow.set(newWin);
185
- viewEnd.set(newEnd >= edge ? null : newEnd);
194
+ // Unlike the pan (see nextViewEnd), snapping to `null` mid-gesture is safe
195
+ // here: the pinch maps cumulative scale/focal from a gesture-START snapshot
196
+ // (`startViewEnd`), not per-frame deltas, so a snap can't re-anchor it.
197
+ // With overscroll, snap within the FOLLOW_SNAP zone; without, at the edge.
198
+ viewEnd.set(
199
+ overscroll > 0
200
+ ? Math.abs(newEnd - edge) <= newWin * FOLLOW_SNAP
201
+ ? null
202
+ : newEnd
203
+ : newEnd >= edge
204
+ ? null
205
+ : newEnd,
206
+ );
186
207
  };
187
208
 
188
209
  return Gesture.Pinch().enabled(enabled).onStart(onStart).onChange(onChange);
package/src/types.ts CHANGED
@@ -1700,6 +1700,8 @@ export interface CandleMetrics {
1700
1700
  maxBodyPx: number;
1701
1701
  /** Body width as a fraction of the per-candle slot width (0–1). Default `0.8`. */
1702
1702
  bodyWidthRatio: number;
1703
+ /** Minimum horizontal gap (px) enforced between adjacent candle bodies. `0` lets `bodyWidthRatio` alone control body width on dense charts. Default `2`. */
1704
+ minGapPx: number;
1703
1705
  /** Corner radius (px) of candle bodies. `0` = sharp corners. Default `0`. */
1704
1706
  bodyRadius: number;
1705
1707
  /** Wick (high–low line) stroke width in px. Default `1`. */
@@ -1778,6 +1780,17 @@ export interface TimeScrollConfig {
1778
1780
  * visible edge price, which IS in view.
1779
1781
  */
1780
1782
  hideLiveOnScrollBack?: boolean;
1783
+ /**
1784
+ * How far pan / fling / pinch may travel past the data bounds, as a fraction
1785
+ * of the visible window (`0`–`1`). With overscroll you can drag the latest
1786
+ * candle toward the middle of the plot (blank future space on the right) or
1787
+ * pan left past the oldest point into blank history — TradingView-style free
1788
+ * dragging. A released drag that settles within a small zone of the live edge
1789
+ * re-attaches to live. Default `0`: the window hard-stops at the oldest data
1790
+ * and the live edge (the classic behavior). Values outside `[0, 1)` are
1791
+ * clamped. Applies to the pinch-zoom (`zoom`) clamps too.
1792
+ */
1793
+ overscroll?: number;
1781
1794
  }
1782
1795
 
1783
1796
  /**