react-native-livechart 4.13.0 → 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.
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- package/dist/components/SegmentLineGradient.d.ts +23 -0
- package/dist/components/SegmentLineGradient.d.ts.map +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +9 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +7 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +8 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +8 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +8 -0
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/draw/candle.d.ts.map +1 -1
- package/dist/draw/line.d.ts +1 -1
- package/dist/draw/line.d.ts.map +1 -1
- package/dist/draw/volume.d.ts.map +1 -1
- package/dist/hooks/useChartPaths.d.ts.map +1 -1
- package/dist/hooks/usePanScroll.d.ts +41 -6
- package/dist/hooks/usePanScroll.d.ts.map +1 -1
- package/dist/hooks/usePinchZoom.d.ts +8 -1
- package/dist/hooks/usePinchZoom.d.ts.map +1 -1
- package/dist/hooks/useSegmentLineGradient.d.ts.map +1 -1
- package/dist/math/segments.d.ts +4 -0
- package/dist/math/segments.d.ts.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +18 -21
- package/src/components/LiveChartSeries.tsx +9 -0
- package/src/components/SegmentLineGradient.tsx +57 -0
- package/src/constants.ts +1 -0
- package/src/core/liveChartEngineTick.ts +15 -2
- package/src/core/liveChartSeriesEngineTick.ts +12 -1
- package/src/core/resolveConfig.ts +22 -0
- package/src/core/useLiveChartEngine.ts +14 -0
- package/src/core/useLiveChartSeriesEngine.ts +14 -0
- package/src/draw/candle.ts +5 -1
- package/src/draw/line.ts +13 -0
- package/src/draw/volume.ts +5 -1
- package/src/hooks/useChartPaths.ts +4 -0
- package/src/hooks/usePanScroll.ts +109 -11
- package/src/hooks/usePinchZoom.ts +25 -4
- package/src/hooks/useSegmentLineGradient.ts +13 -5
- package/src/math/segments.ts +26 -0
- package/src/types.ts +13 -0
|
@@ -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
|
|
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,
|
|
93
|
-
* the drag reaches the live edge — the signal to
|
|
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,
|
|
313
|
+
withDecay({ velocity, clamp: [lo, hi] }, (finished) => {
|
|
216
314
|
"worklet";
|
|
217
|
-
// Landed
|
|
218
|
-
// stay frozen where inertia died.
|
|
219
|
-
if (finished && (viewEnd.get() ?? edge)
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -4,7 +4,11 @@ import { vec } from "@shopify/react-native-skia";
|
|
|
4
4
|
import type { ChartEngineLayout } from "../core/useLiveChartEngine";
|
|
5
5
|
import type { ResolvedSegment } from "../core/resolveSegment";
|
|
6
6
|
import type { ChartPadding } from "../draw/line";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
padGradientStops,
|
|
9
|
+
segmentGradientStopCount,
|
|
10
|
+
segmentLineGradient,
|
|
11
|
+
} from "../math/segments";
|
|
8
12
|
|
|
9
13
|
const FALLBACK_POSITIONS = [0, 1];
|
|
10
14
|
|
|
@@ -43,11 +47,15 @@ export function useSegmentLineGradient(
|
|
|
43
47
|
);
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
// Fixed stop count: `colors`/`positions` tear independently and Skia treats a
|
|
51
|
+
// length mismatch as a fatal UI-thread throw.
|
|
52
|
+
const stopCount = segmentGradientStopCount(segments);
|
|
53
|
+
|
|
54
|
+
const colors = useDerivedValue(() =>
|
|
55
|
+
padGradientStops(data.value?.colors ?? [baseColor, baseColor], stopCount),
|
|
48
56
|
);
|
|
49
|
-
const positions = useDerivedValue(
|
|
50
|
-
(
|
|
57
|
+
const positions = useDerivedValue(() =>
|
|
58
|
+
padGradientStops(data.value?.positions ?? FALLBACK_POSITIONS, stopCount),
|
|
51
59
|
);
|
|
52
60
|
const gradientEnd = useDerivedValue(() =>
|
|
53
61
|
vec(Math.max(1, engine.canvasWidth.value), 0),
|
package/src/math/segments.ts
CHANGED
|
@@ -171,3 +171,29 @@ export function segmentLineGradient(
|
|
|
171
171
|
|
|
172
172
|
return { colors, positions };
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
/** Stops {@link segmentLineGradient} can emit for `segments` — no per-frame state. */
|
|
176
|
+
export function segmentGradientStopCount(segments: ResolvedSegment[]): number {
|
|
177
|
+
"worklet";
|
|
178
|
+
let n = 2;
|
|
179
|
+
for (let i = 0; i < segments.length; i++) {
|
|
180
|
+
const seg = segments[i];
|
|
181
|
+
if (!seg.recolorLine) continue;
|
|
182
|
+
const cols =
|
|
183
|
+
seg.mutedColors && seg.mutedColors.length >= 2
|
|
184
|
+
? seg.mutedColors.length
|
|
185
|
+
: 2;
|
|
186
|
+
n += cols + 2;
|
|
187
|
+
}
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Resize `stops` to `count` by repeating its last entry. */
|
|
192
|
+
export function padGradientStops<T>(stops: T[], count: number): T[] {
|
|
193
|
+
"worklet";
|
|
194
|
+
if (stops.length === count || stops.length === 0) return stops;
|
|
195
|
+
const out = stops.slice(0, count);
|
|
196
|
+
const last = stops[stops.length - 1];
|
|
197
|
+
while (out.length < count) out.push(last);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
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
|
/**
|