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.
- package/dist/components/LiveChart.d.ts.map +1 -1
- package/dist/components/LiveChartSeries.d.ts.map +1 -1
- 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/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +9 -0
- package/src/components/LiveChartSeries.tsx +9 -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/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
|
@@ -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 &&
|
|
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 &&
|
|
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,
|
package/src/draw/candle.ts
CHANGED
|
@@ -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(
|
|
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,
|
package/src/draw/volume.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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);
|
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
|
/**
|