react-native-livechart 4.13.1 → 4.15.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 (41) hide show
  1. package/README.md +4 -0
  2. package/dist/components/LiveChart.d.ts.map +1 -1
  3. package/dist/components/LiveChartSeries.d.ts.map +1 -1
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/core/liveChartEngineTick.d.ts +13 -0
  6. package/dist/core/liveChartEngineTick.d.ts.map +1 -1
  7. package/dist/core/liveChartSeriesEngineTick.d.ts +11 -0
  8. package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
  9. package/dist/core/resolveConfig.d.ts +8 -1
  10. package/dist/core/resolveConfig.d.ts.map +1 -1
  11. package/dist/core/useLiveChartEngine.d.ts +11 -0
  12. package/dist/core/useLiveChartEngine.d.ts.map +1 -1
  13. package/dist/core/useLiveChartSeriesEngine.d.ts +11 -0
  14. package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
  15. package/dist/draw/candle.d.ts.map +1 -1
  16. package/dist/draw/line.d.ts +1 -1
  17. package/dist/draw/line.d.ts.map +1 -1
  18. package/dist/draw/volume.d.ts.map +1 -1
  19. package/dist/hooks/useChartPaths.d.ts.map +1 -1
  20. package/dist/hooks/usePanScroll.d.ts +41 -6
  21. package/dist/hooks/usePanScroll.d.ts.map +1 -1
  22. package/dist/hooks/usePinchZoom.d.ts +8 -1
  23. package/dist/hooks/usePinchZoom.d.ts.map +1 -1
  24. package/dist/types.d.ts +24 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/LiveChart.tsx +11 -0
  28. package/src/components/LiveChartSeries.tsx +11 -0
  29. package/src/constants.ts +1 -0
  30. package/src/core/liveChartEngineTick.ts +53 -4
  31. package/src/core/liveChartSeriesEngineTick.ts +46 -3
  32. package/src/core/resolveConfig.ts +22 -0
  33. package/src/core/useLiveChartEngine.ts +19 -0
  34. package/src/core/useLiveChartSeriesEngine.ts +19 -0
  35. package/src/draw/candle.ts +5 -1
  36. package/src/draw/line.ts +13 -0
  37. package/src/draw/volume.ts +5 -1
  38. package/src/hooks/useChartPaths.ts +4 -0
  39. package/src/hooks/usePanScroll.ts +109 -11
  40. package/src/hooks/usePinchZoom.ts +25 -4
  41. package/src/types.ts +24 -0
@@ -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
  /**
@@ -1993,6 +2006,17 @@ export interface LiveChartCoreProps {
1993
2006
  * axes (prices, market caps).
1994
2007
  */
1995
2008
  maxValue?: number;
2009
+ /**
2010
+ * Live manual Y-range multiplier around the fitted midpoint (`1` = auto-fit).
2011
+ * Drive it from a gesture (e.g. dragging the price axis) to stretch or
2012
+ * compress the price scale TradingView-style; the auto-fit keeps tracking
2013
+ * the visible window underneath. Values must be positive and finite; invalid
2014
+ * values fall back to `1`. Read on the UI thread each frame. Supported by
2015
+ * both `LiveChart` and `LiveChartSeries`. Default `1`.
2016
+ *
2017
+ * @experimental
2018
+ */
2019
+ yRangeScale?: SharedValue<number>;
1996
2020
  /**
1997
2021
  * Label in the empty state when `loading` is false and there are fewer than
1998
2022
  * two samples (line points or committed candles). Default `"No data"`.