react-native-livechart 4.2.0 → 4.3.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 +6 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/liveChartEngineTick.d.ts +11 -0
- package/dist/core/liveChartEngineTick.d.ts.map +1 -1
- package/dist/core/liveChartSeriesEngineTick.d.ts +10 -0
- package/dist/core/liveChartSeriesEngineTick.d.ts.map +1 -1
- package/dist/core/resolveConfig.d.ts +11 -1
- package/dist/core/resolveConfig.d.ts.map +1 -1
- package/dist/core/useLiveChartEngine.d.ts +18 -0
- package/dist/core/useLiveChartEngine.d.ts.map +1 -1
- package/dist/core/useLiveChartSeriesEngine.d.ts +17 -0
- package/dist/core/useLiveChartSeriesEngine.d.ts.map +1 -1
- package/dist/hooks/useReferenceDrag.d.ts +12 -4
- package/dist/hooks/useReferenceDrag.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/referenceDrag.d.ts +9 -0
- package/dist/math/referenceDrag.d.ts.map +1 -1
- package/dist/types.d.ts +36 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +35 -22
- package/src/components/LiveChartSeries.tsx +7 -0
- package/src/constants.ts +7 -0
- package/src/core/liveChartEngineTick.ts +42 -7
- package/src/core/liveChartSeriesEngineTick.ts +37 -5
- package/src/core/resolveConfig.ts +21 -0
- package/src/core/useLiveChartEngine.ts +71 -1
- package/src/core/useLiveChartSeriesEngine.ts +65 -1
- package/src/hooks/useReferenceDrag.ts +41 -11
- package/src/index.ts +1 -0
- package/src/math/referenceDrag.ts +19 -0
- package/src/types.ts +37 -0
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
resolvePulse,
|
|
38
38
|
resolveScrub,
|
|
39
39
|
resolveScrubAction,
|
|
40
|
+
resolveReturnToLiveMs,
|
|
40
41
|
resolveSelectionDot,
|
|
41
42
|
resolveThreshold,
|
|
42
43
|
resolveTradeStream,
|
|
@@ -216,6 +217,7 @@ function useLiveChartController({
|
|
|
216
217
|
windowBuffer = 0,
|
|
217
218
|
nowOverride,
|
|
218
219
|
timeScroll = false,
|
|
220
|
+
returnToLive,
|
|
219
221
|
zoom = false,
|
|
220
222
|
accessibilityLabel,
|
|
221
223
|
accessibilityRole = "image",
|
|
@@ -494,6 +496,9 @@ function useLiveChartController({
|
|
|
494
496
|
// onto the JS thread (set by the reaction below, after the engine exists).
|
|
495
497
|
const [scrolledBack, setScrolledBack] = useState(false);
|
|
496
498
|
const timeScrollEnabled = Boolean(timeScroll) && !isStatic;
|
|
499
|
+
// Glide duration for the return-to-live animation (0 = instant). A sibling of
|
|
500
|
+
// `timeScroll` so it survives `timeScroll={false}` (the disable that triggers it).
|
|
501
|
+
const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
|
|
497
502
|
const zoomCfg = resolveZoom(zoom);
|
|
498
503
|
const zoomEnabled = zoomCfg !== null && !isStatic;
|
|
499
504
|
|
|
@@ -552,6 +557,8 @@ function useLiveChartController({
|
|
|
552
557
|
timeWindow,
|
|
553
558
|
paused,
|
|
554
559
|
static: isStatic,
|
|
560
|
+
scrollEnabled: timeScrollEnabled,
|
|
561
|
+
returnToLiveMs,
|
|
555
562
|
smoothing,
|
|
556
563
|
adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
|
|
557
564
|
exaggerate,
|
|
@@ -568,12 +575,16 @@ function useLiveChartController({
|
|
|
568
575
|
|
|
569
576
|
// Mirror the UI-thread scroll state to React so the floating y-axis can keep
|
|
570
577
|
// a right gutter at the live edge and collapse it only while scrolled back.
|
|
571
|
-
// Fires once per null↔frozen transition
|
|
578
|
+
// Fires once per null↔frozen transition. `viewEnd` is only non-null while
|
|
579
|
+
// time-scroll is enabled and actively scrolled (the engine resets it to null
|
|
580
|
+
// when time-scroll is disabled — see #164), so we don't gate on
|
|
581
|
+
// `timeScrollEnabled` here: that would strand `scrolledBack` at `true` after a
|
|
582
|
+
// disable-while-scrolled, wrongly floating the y-axis on a later re-enable.
|
|
572
583
|
useAnimatedReaction(
|
|
573
584
|
() => engine.viewEnd.value != null,
|
|
574
585
|
/* istanbul ignore next -- Reanimated reaction; state mirrored on the JS thread, not exercised under Jest */
|
|
575
586
|
(isScrolled, prev) => {
|
|
576
|
-
if (isScrolled !== prev && yAxisFloat
|
|
587
|
+
if (isScrolled !== prev && yAxisFloat) {
|
|
577
588
|
scheduleOnRN(setScrolledBack, isScrolled);
|
|
578
589
|
}
|
|
579
590
|
},
|
|
@@ -798,13 +809,30 @@ function useLiveChartController({
|
|
|
798
809
|
dragValues,
|
|
799
810
|
);
|
|
800
811
|
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
//
|
|
812
|
+
// Draggable reference lines: a per-line vertical pan that grabs a line near its
|
|
813
|
+
// value and drags it along the Y-axis (with snap / bounds / callbacks). Built
|
|
814
|
+
// unconditionally for stable hook order (and before `useCrosshair` so the scrub
|
|
815
|
+
// can defer to a line under the finger); the gesture self-disables when no line
|
|
816
|
+
// opts in, and it's only composed into the root when `refDragEnabled`.
|
|
817
|
+
const refDragEnabled =
|
|
818
|
+
!isStatic && allRefLines.some((l) => l.draggable === true);
|
|
819
|
+
const { gesture: refDragGesture, hitTest: refDragHitTest } = useReferenceDrag(
|
|
820
|
+
engine,
|
|
821
|
+
effectivePadding,
|
|
822
|
+
allRefLines,
|
|
823
|
+
dragValues,
|
|
824
|
+
dragActive,
|
|
825
|
+
!isStatic,
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
// Combined "defer" hit-test: the scrub-action place-tap and the live scrub both
|
|
829
|
+
// yield to a marker, a pressable badge, or a draggable line under the finger — so
|
|
830
|
+
// a press there is routed to that overlay / drag instead of dropping a reticle or
|
|
831
|
+
// crosshair. (Each hit-test returns false when its feature is off.)
|
|
804
832
|
/* istanbul ignore next -- worklet runs on the UI thread, not in Jest */
|
|
805
833
|
const deferTapHit = (x: number, y: number): boolean => {
|
|
806
834
|
"worklet";
|
|
807
|
-
return markerHitTest(x, y) || refLineHitTest(x, y);
|
|
835
|
+
return markerHitTest(x, y) || refLineHitTest(x, y) || refDragHitTest(x, y);
|
|
808
836
|
};
|
|
809
837
|
|
|
810
838
|
// Time-scroll activation. `holdToScrub`: a quick one-finger drag scrolls while
|
|
@@ -843,7 +871,7 @@ function useLiveChartController({
|
|
|
843
871
|
scrubActionCfg,
|
|
844
872
|
onScrubAction,
|
|
845
873
|
metricsCfg.badge,
|
|
846
|
-
markersActive || refPressActive ? deferTapHit : undefined,
|
|
874
|
+
markersActive || refPressActive || refDragEnabled ? deferTapHit : undefined,
|
|
847
875
|
scrubCfg?.tooltipPlacement ?? "side",
|
|
848
876
|
scrubCfg?.tooltipShowValue ?? true,
|
|
849
877
|
scrubCfg?.tooltipShowTime ?? true,
|
|
@@ -876,21 +904,6 @@ function useLiveChartController({
|
|
|
876
904
|
},
|
|
877
905
|
});
|
|
878
906
|
|
|
879
|
-
// Draggable reference lines: a per-line vertical pan that grabs a line near its
|
|
880
|
-
// value and drags it along the Y-axis (with snap / bounds / callbacks). Built
|
|
881
|
-
// unconditionally for stable hook order; the gesture self-disables when no line
|
|
882
|
-
// opts in, and it's only composed into the root when `refDragEnabled`.
|
|
883
|
-
const refDragEnabled =
|
|
884
|
-
!isStatic && allRefLines.some((l) => l.draggable === true);
|
|
885
|
-
const refDragGesture = useReferenceDrag(
|
|
886
|
-
engine,
|
|
887
|
-
effectivePadding,
|
|
888
|
-
allRefLines,
|
|
889
|
-
dragValues,
|
|
890
|
-
dragActive,
|
|
891
|
-
!isStatic,
|
|
892
|
-
);
|
|
893
|
-
|
|
894
907
|
// Pinch-to-zoom the visible window (two-finger). Anchors at the focal point and
|
|
895
908
|
// writes viewWindow + viewEnd; composes via Simultaneous (it's two-finger, so
|
|
896
909
|
// disjoint from the one-finger pan/scrub). See `zoom`.
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
resolveMarkerCluster,
|
|
37
37
|
resolveMetrics,
|
|
38
38
|
resolveMultiSeriesDot,
|
|
39
|
+
resolveReturnToLiveMs,
|
|
39
40
|
resolveScrub,
|
|
40
41
|
resolveSelectionDot,
|
|
41
42
|
resolveXAxis,
|
|
@@ -147,6 +148,7 @@ function useLiveChartSeriesController({
|
|
|
147
148
|
scrub = true,
|
|
148
149
|
selectionDot,
|
|
149
150
|
timeScroll = false,
|
|
151
|
+
returnToLive,
|
|
150
152
|
zoom = false,
|
|
151
153
|
onScrub,
|
|
152
154
|
onGestureStart,
|
|
@@ -181,6 +183,9 @@ function useLiveChartSeriesController({
|
|
|
181
183
|
// gesture to wait for a press-and-hold so a quick drag scrolls instead — the
|
|
182
184
|
// hold delay is threaded into `useCrosshairSeries` below.
|
|
183
185
|
const timeScrollEnabled = Boolean(timeScroll);
|
|
186
|
+
// Return-to-live glide duration (0 = instant); sibling of `timeScroll` so it
|
|
187
|
+
// survives `timeScroll={false}` (the disable that triggers it). See #164.
|
|
188
|
+
const returnToLiveMs = resolveReturnToLiveMs(returnToLive);
|
|
184
189
|
const zoomCfg = resolveZoom(zoom);
|
|
185
190
|
const zoomEnabled = zoomCfg !== null;
|
|
186
191
|
const scrollGestureMode =
|
|
@@ -296,6 +301,8 @@ function useLiveChartSeriesController({
|
|
|
296
301
|
series: effectiveSeries,
|
|
297
302
|
timeWindow,
|
|
298
303
|
paused,
|
|
304
|
+
scrollEnabled: timeScrollEnabled,
|
|
305
|
+
returnToLiveMs,
|
|
299
306
|
smoothing,
|
|
300
307
|
adaptiveSpeedBoost: metricsCfg.motion.adaptiveSpeedBoost,
|
|
301
308
|
exaggerate,
|
package/src/constants.ts
CHANGED
|
@@ -24,6 +24,13 @@ export const MAX_Y_LABELS = 15;
|
|
|
24
24
|
*/
|
|
25
25
|
export const HOLD_TO_SCRUB_MS = 500;
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Duration (ms) of the "return to live" glide — how long the window takes to ease
|
|
29
|
+
* from a scrolled-back position to the live edge when `timeScroll` is disabled
|
|
30
|
+
* (or a mode switch turns it off). Eased out so it decelerates onto live. See #164.
|
|
31
|
+
*/
|
|
32
|
+
export const RETURN_TO_LIVE_MS = 450;
|
|
33
|
+
|
|
27
34
|
// ─── Metric default tokens (single source of truth for LiveChartMetrics) ─────
|
|
28
35
|
// The resolved `metrics` config (see resolveMetrics) is assembled from these
|
|
29
36
|
// objects. Draw/worklet helpers default their metric params to the matching
|
|
@@ -69,6 +69,17 @@ export interface EngineTickInput {
|
|
|
69
69
|
* resumes following. Takes precedence over {@link paused}.
|
|
70
70
|
*/
|
|
71
71
|
viewEnd?: number | null;
|
|
72
|
+
/**
|
|
73
|
+
* "Return to live" glide (see #164). When time-scroll is disabled while scrolled
|
|
74
|
+
* back, the engine hook clears {@link viewEnd} and animates {@link returnT} from
|
|
75
|
+
* `0`→`1`; each frame the right edge interpolates from {@link returnFrom} (the
|
|
76
|
+
* frozen edge captured at that moment) to the *current* live edge by `returnT`,
|
|
77
|
+
* so the window glides forward and lands exactly on live (no end-snap). At
|
|
78
|
+
* `1`/`undefined` it pins to the live edge — the normal follow behavior.
|
|
79
|
+
*/
|
|
80
|
+
returnT?: number;
|
|
81
|
+
/** Frozen right-edge time the {@link returnT} glide starts from. See {@link returnT}. */
|
|
82
|
+
returnFrom?: number;
|
|
72
83
|
/**
|
|
73
84
|
* Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
|
|
74
85
|
* to follow the configured {@link timeWindow}. Set by the pinch-zoom gesture
|
|
@@ -98,13 +109,38 @@ export function tickLiveChartEngineFrame(
|
|
|
98
109
|
const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
|
|
99
110
|
state.liveEdge = liveEdge;
|
|
100
111
|
const viewEnd = input.viewEnd;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
// First time of the visible series' data — the floor a frozen edge must stay
|
|
113
|
+
// at or above. `-Infinity` when there's no data to bound against, so the freeze
|
|
114
|
+
// still works (we can't strand a plot we have no time bounds for).
|
|
115
|
+
let firstDataTime = -Infinity;
|
|
116
|
+
if (input.mode === "candle") {
|
|
117
|
+
const cs = input.candles;
|
|
118
|
+
if (cs && cs.length > 0) firstDataTime = cs[0].time;
|
|
119
|
+
} else if (input.points.length > 0) {
|
|
120
|
+
firstDataTime = input.points[0].time;
|
|
121
|
+
}
|
|
122
|
+
// Freeze the right edge while the pan gesture has parked `viewEnd` behind the
|
|
123
|
+
// live edge AND that edge still sits within the data. A frozen edge stranded
|
|
124
|
+
// before the active series' first point falls through to following live (so a
|
|
125
|
+
// line/candle span mismatch never strands the window on an empty plot). When
|
|
126
|
+
// time-scroll is disabled the hook clears `viewEnd` (and kicks off the glide
|
|
127
|
+
// below), so a stale edge can't keep the window frozen. See #164.
|
|
128
|
+
const scrolledBack =
|
|
129
|
+
viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
|
|
130
|
+
if (scrolledBack) {
|
|
104
131
|
state.timestamp = viewEnd;
|
|
105
132
|
} else if (!input.paused) {
|
|
106
|
-
// Following
|
|
107
|
-
|
|
133
|
+
// Following live. While a "return to live" glide is in flight (returnT < 1)
|
|
134
|
+
// ease the right edge from the frozen `returnFrom` to the *current* live edge
|
|
135
|
+
// by returnT — converges exactly on live with no end-snap. Otherwise pin to
|
|
136
|
+
// the live edge (the steady-state follow).
|
|
137
|
+
const returnT = input.returnT;
|
|
138
|
+
if (returnT != null && returnT < 1 && input.returnFrom != null) {
|
|
139
|
+
state.timestamp =
|
|
140
|
+
input.returnFrom + (liveEdge - input.returnFrom) * returnT;
|
|
141
|
+
} else {
|
|
142
|
+
state.timestamp = liveEdge;
|
|
143
|
+
}
|
|
108
144
|
}
|
|
109
145
|
// else: paused with no active pan → leave the frozen timestamp untouched.
|
|
110
146
|
|
|
@@ -272,8 +308,7 @@ export function tickLiveChartEngineFrame(
|
|
|
272
308
|
// Edge value: the price at the visible window's right edge. Following live →
|
|
273
309
|
// track the live value (badge unchanged). Scrolled back → track the last
|
|
274
310
|
// visible point/candle close, lerped so a `followViewEdge` badge glides to the
|
|
275
|
-
// last price as you pan.
|
|
276
|
-
const scrolledBack = viewEnd != null && viewEnd < liveEdge;
|
|
311
|
+
// last price as you pan. Reuses the gated `scrolledBack` computed above.
|
|
277
312
|
if (!scrolledBack) {
|
|
278
313
|
state.edgeValue = state.displayValue;
|
|
279
314
|
} else {
|
|
@@ -56,6 +56,16 @@ export interface MultiEngineTickInput {
|
|
|
56
56
|
* precedence over {@link paused}.
|
|
57
57
|
*/
|
|
58
58
|
viewEnd?: number | null;
|
|
59
|
+
/**
|
|
60
|
+
* "Return to live" glide (see #164). When time-scroll is disabled while scrolled
|
|
61
|
+
* back, the hook clears {@link viewEnd} and animates {@link returnT} `0`→`1`;
|
|
62
|
+
* each frame the right edge interpolates from {@link returnFrom} to the *current*
|
|
63
|
+
* live edge by `returnT`, gliding onto live with no end-snap. `1`/`undefined`
|
|
64
|
+
* pins to the live edge.
|
|
65
|
+
*/
|
|
66
|
+
returnT?: number;
|
|
67
|
+
/** Frozen right-edge time the {@link returnT} glide starts from. */
|
|
68
|
+
returnFrom?: number;
|
|
59
69
|
/**
|
|
60
70
|
* Absolute visible-window width (seconds) to freeze at, or `null`/`undefined`
|
|
61
71
|
* to follow {@link timeWindow}. Drives pinch-zoom (see `usePinchZoom`); the
|
|
@@ -78,12 +88,34 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
78
88
|
const liveEdge = baseNow + (input.windowBuffer ?? 0) * input.timeWindow;
|
|
79
89
|
state.liveEdge = liveEdge;
|
|
80
90
|
const viewEnd = input.viewEnd;
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
// Earliest first-point time across visible series — the floor a frozen edge
|
|
92
|
+
// must stay at or above. `-Infinity` when no visible series has data, so the
|
|
93
|
+
// freeze still works (nothing to bound the window against).
|
|
94
|
+
let firstDataTime = Infinity;
|
|
95
|
+
for (let i = 0; i < input.series.length; i++) {
|
|
96
|
+
if (input.series[i].visible === false) continue;
|
|
97
|
+
const d = input.series[i].data;
|
|
98
|
+
if (d.length > 0 && d[0].time < firstDataTime) firstDataTime = d[0].time;
|
|
99
|
+
}
|
|
100
|
+
if (firstDataTime === Infinity) firstDataTime = -Infinity;
|
|
101
|
+
// Freeze the right edge while the gesture has parked `viewEnd` behind the live
|
|
102
|
+
// edge AND that edge still sits within the data; a stranded edge falls through
|
|
103
|
+
// to following live. When time-scroll is disabled the hook clears `viewEnd` and
|
|
104
|
+
// kicks off the glide below, so a stale edge can't keep the window frozen. #164.
|
|
105
|
+
const scrolledBack =
|
|
106
|
+
viewEnd != null && viewEnd < liveEdge && viewEnd >= firstDataTime;
|
|
107
|
+
if (scrolledBack) {
|
|
83
108
|
state.timestamp = viewEnd;
|
|
84
109
|
} else if (!input.paused) {
|
|
85
|
-
// Following
|
|
86
|
-
|
|
110
|
+
// Following live; ease from the frozen `returnFrom` to the current live edge
|
|
111
|
+
// while a "return to live" glide is in flight (returnT < 1), else pin to live.
|
|
112
|
+
const returnT = input.returnT;
|
|
113
|
+
if (returnT != null && returnT < 1 && input.returnFrom != null) {
|
|
114
|
+
state.timestamp =
|
|
115
|
+
input.returnFrom + (liveEdge - input.returnFrom) * returnT;
|
|
116
|
+
} else {
|
|
117
|
+
state.timestamp = liveEdge;
|
|
118
|
+
}
|
|
87
119
|
}
|
|
88
120
|
// else: paused with no active pan → leave the frozen timestamp untouched.
|
|
89
121
|
|
|
@@ -115,7 +147,7 @@ export function tickLiveChartSeriesEngineFrame(
|
|
|
115
147
|
// window's right edge, so they must track each series' value AT that edge
|
|
116
148
|
// (`timestamp`), not the live value — otherwise the dot floats at the current
|
|
117
149
|
// price while the line ends in the past. Mirrors single-series `edgeValue`.
|
|
118
|
-
|
|
150
|
+
// Reuses the gated `scrolledBack` computed above.
|
|
119
151
|
|
|
120
152
|
for (let i = 0; i < n; i++) {
|
|
121
153
|
let target = series[i].value;
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
SelectionDotConfig,
|
|
26
26
|
SelectionDotProps,
|
|
27
27
|
SelectionDotRingConfig,
|
|
28
|
+
ReturnToLiveConfig,
|
|
28
29
|
ThresholdConfig,
|
|
29
30
|
ThresholdLineConfig,
|
|
30
31
|
TradeEvent,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
FADE_EDGE_WIDTH,
|
|
46
47
|
GRID_METRICS_DEFAULTS,
|
|
47
48
|
MOTION_METRICS_DEFAULTS,
|
|
49
|
+
RETURN_TO_LIVE_MS,
|
|
48
50
|
} from "../constants";
|
|
49
51
|
|
|
50
52
|
// ─── Resolved types (all fields required, no optionals) ──────────────────────
|
|
@@ -456,6 +458,25 @@ export function resolveZoom(
|
|
|
456
458
|
return resolveToggle(prop, ZOOM_DEFAULTS, false);
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Resolves `timeScroll.returnToLive` to the "return to live" glide duration in ms,
|
|
463
|
+
* where `0` means an instant snap (no animation):
|
|
464
|
+
* - `undefined` / `true` → default {@link RETURN_TO_LIVE_MS}
|
|
465
|
+
* - `false` → `0` (instant)
|
|
466
|
+
* - `{ duration }` → that duration (a non-positive value collapses to `0`)
|
|
467
|
+
*
|
|
468
|
+
* See {@link ReturnToLiveConfig} / #164.
|
|
469
|
+
*/
|
|
470
|
+
export function resolveReturnToLiveMs(
|
|
471
|
+
prop: boolean | ReturnToLiveConfig | undefined,
|
|
472
|
+
): number {
|
|
473
|
+
if (prop === false) return 0;
|
|
474
|
+
if (prop == null || prop === true) return RETURN_TO_LIVE_MS;
|
|
475
|
+
const d = prop.duration;
|
|
476
|
+
if (d == null) return RETURN_TO_LIVE_MS;
|
|
477
|
+
return d > 0 ? d : 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
459
480
|
const AXIS_LABEL_DEFAULTS: ResolvedAxisLabelConfig = {
|
|
460
481
|
format: undefined,
|
|
461
482
|
color: undefined,
|
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { useEffect, useState } from "react";
|
|
9
9
|
import {
|
|
10
|
+
cancelAnimation,
|
|
11
|
+
Easing,
|
|
10
12
|
useAnimatedReaction,
|
|
11
13
|
useDerivedValue,
|
|
12
14
|
useFrameCallback,
|
|
13
15
|
useSharedValue,
|
|
16
|
+
withTiming,
|
|
14
17
|
type SharedValue,
|
|
15
18
|
} from "react-native-reanimated";
|
|
16
|
-
import { MS_PER_FRAME_60FPS } from "../constants";
|
|
19
|
+
import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
|
|
17
20
|
import type { CandlePoint, LiveChartPoint, SeriesConfig } from "../types";
|
|
18
21
|
import { tickLiveChartEngineFrame } from "./liveChartEngineTick";
|
|
19
22
|
|
|
@@ -39,6 +42,20 @@ export interface EngineConfig {
|
|
|
39
42
|
nowOverride?: number;
|
|
40
43
|
windowBuffer?: number;
|
|
41
44
|
paused?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the `timeScroll` gesture is active. Flipping this to `false` while
|
|
47
|
+
* scrolled back clears {@link ChartEngineScroll.viewEnd} and **glides** the
|
|
48
|
+
* window back to the live edge (a brief eased animation, not an instant jump),
|
|
49
|
+
* so disabling time-scroll — or a mode switch that turns it off — never strands
|
|
50
|
+
* the chart at a past position. See #164.
|
|
51
|
+
*/
|
|
52
|
+
scrollEnabled?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
|
|
55
|
+
* `false` while scrolled back. `0` snaps instantly (no animation). Defaults to
|
|
56
|
+
* {@link RETURN_TO_LIVE_MS}. Resolved from `timeScroll.returnToLive`. See #164.
|
|
57
|
+
*/
|
|
58
|
+
returnToLiveMs?: number;
|
|
42
59
|
/**
|
|
43
60
|
* Loop-free render: settle the display state once (smoothing forced to 1, no
|
|
44
61
|
* per-frame frame callback) for many small charts in a list. See
|
|
@@ -166,6 +183,10 @@ export interface EngineFrameRefs {
|
|
|
166
183
|
pausedSV: SharedValue<boolean>;
|
|
167
184
|
/** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
|
|
168
185
|
viewEndSV?: SharedValue<number | null>;
|
|
186
|
+
/** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
|
|
187
|
+
returnTSV?: SharedValue<number>;
|
|
188
|
+
/** Frozen right-edge time the return glide starts from. Optional. */
|
|
189
|
+
returnFromSV?: SharedValue<number>;
|
|
169
190
|
/** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
|
|
170
191
|
viewWindowSV?: SharedValue<number | null>;
|
|
171
192
|
/** Receives the computed live edge each frame. Optional for callers/tests. */
|
|
@@ -223,6 +244,8 @@ export function applyLiveChartEngineFrame(
|
|
|
223
244
|
nowSeconds: Date.now() / 1000,
|
|
224
245
|
paused: sv.pausedSV.value,
|
|
225
246
|
viewEnd: sv.viewEndSV?.value,
|
|
247
|
+
returnT: sv.returnTSV?.value,
|
|
248
|
+
returnFrom: sv.returnFromSV?.value,
|
|
226
249
|
viewWindow: sv.viewWindowSV?.value,
|
|
227
250
|
mode: sv.modeSV.value,
|
|
228
251
|
candles: sv.candles?.value,
|
|
@@ -283,6 +306,14 @@ export function useLiveChartEngine(
|
|
|
283
306
|
const nowOverrideSV = useDerivedValue(() => config.nowOverride);
|
|
284
307
|
const windowBufferSV = useDerivedValue(() => config.windowBuffer ?? 0);
|
|
285
308
|
const pausedSV = useDerivedValue(() => config.paused ?? false);
|
|
309
|
+
// Whether time-scroll is active. Drives the return-to-live reaction below
|
|
310
|
+
// (the tick no longer reads it — clearing `viewEnd` is what makes it follow).
|
|
311
|
+
// Defaults to enabled so a caller that omits it behaves as before.
|
|
312
|
+
const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
|
|
313
|
+
// Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
|
|
314
|
+
const returnToLiveMsSV = useDerivedValue(
|
|
315
|
+
() => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
|
|
316
|
+
);
|
|
286
317
|
const modeSV = useDerivedValue(() => config.mode ?? "line");
|
|
287
318
|
|
|
288
319
|
// Animation state (mutated on UI thread each frame)
|
|
@@ -303,6 +334,12 @@ export function useLiveChartEngine(
|
|
|
303
334
|
const viewEnd = useSharedValue<number | null>(null);
|
|
304
335
|
const liveEdge = useSharedValue(initialTimestamp);
|
|
305
336
|
const edgeValue = useSharedValue(0);
|
|
337
|
+
// "Return to live" glide (see #164). When time-scroll is disabled while scrolled
|
|
338
|
+
// back, the reaction below clears `viewEnd`, snapshots the frozen edge into
|
|
339
|
+
// `returnFrom`, and animates `returnT` 0→1; the tick eases the right edge onto
|
|
340
|
+
// the live edge over that progress. `returnT` rests at 1 (no glide in flight).
|
|
341
|
+
const returnT = useSharedValue(1);
|
|
342
|
+
const returnFrom = useSharedValue(0);
|
|
306
343
|
|
|
307
344
|
// Live data extrema (value + time of the visible high / low). NaN until the
|
|
308
345
|
// first tick finds data — the extrema label stays hidden until then.
|
|
@@ -343,6 +380,8 @@ export function useLiveChartEngine(
|
|
|
343
380
|
windowBufferSV,
|
|
344
381
|
pausedSV,
|
|
345
382
|
viewEndSV: viewEnd,
|
|
383
|
+
returnTSV: returnT,
|
|
384
|
+
returnFromSV: returnFrom,
|
|
346
385
|
viewWindowSV: viewWindow,
|
|
347
386
|
liveEdgeSV: liveEdge,
|
|
348
387
|
edgeValueSV: edgeValue,
|
|
@@ -362,6 +401,37 @@ export function useLiveChartEngine(
|
|
|
362
401
|
applyLiveChartEngineFrame(frameInfo, frameRefs);
|
|
363
402
|
}, !config.static);
|
|
364
403
|
|
|
404
|
+
// When time-scroll is disabled while scrolled back, return the window to the
|
|
405
|
+
// live edge. With a positive duration this glides: snapshot the frozen edge into
|
|
406
|
+
// `returnFrom`, clear `viewEnd` (so the tick stops freezing and follows), and
|
|
407
|
+
// animate `returnT` 0→1 — the tick interpolates `returnFrom`→live by that
|
|
408
|
+
// progress, landing exactly on live. Duration 0 (`returnToLive: false`) just
|
|
409
|
+
// clears `viewEnd` for an instant snap. Clearing `viewEnd` also means a later
|
|
410
|
+
// re-enable resumes from live, not the stale frozen position. All on the UI
|
|
411
|
+
// thread, no JS round-trip. See #164.
|
|
412
|
+
useAnimatedReaction(
|
|
413
|
+
() => scrollEnabledSV.value,
|
|
414
|
+
/* istanbul ignore next -- Reanimated reaction driven by a prop→derived change; not exercised under the SharedValue mock (see the viewWindow-reset test note), verified in-app */
|
|
415
|
+
(enabled, prev) => {
|
|
416
|
+
if (prev === true && !enabled && viewEnd.value != null) {
|
|
417
|
+
const ms = returnToLiveMsSV.value;
|
|
418
|
+
if (ms > 0) {
|
|
419
|
+
returnFrom.value = viewEnd.value;
|
|
420
|
+
cancelAnimation(viewEnd);
|
|
421
|
+
viewEnd.value = null;
|
|
422
|
+
returnT.value = 0;
|
|
423
|
+
returnT.value = withTiming(1, {
|
|
424
|
+
duration: ms,
|
|
425
|
+
easing: Easing.out(Easing.cubic),
|
|
426
|
+
});
|
|
427
|
+
} else {
|
|
428
|
+
cancelAnimation(viewEnd);
|
|
429
|
+
viewEnd.value = null; // returnT stays 1 → tick pins to live (instant)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
365
435
|
// One-shot settle for static charts: the fingerprint changes when the canvas
|
|
366
436
|
// lays out (width 0→real) or the framing inputs change, and the handler runs a
|
|
367
437
|
// single snap tick (smoothing=1). Inert when not static — `prepare` returns a
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
+
cancelAnimation,
|
|
4
|
+
Easing,
|
|
5
|
+
useAnimatedReaction,
|
|
3
6
|
useDerivedValue,
|
|
4
7
|
useFrameCallback,
|
|
5
8
|
useSharedValue,
|
|
9
|
+
withTiming,
|
|
6
10
|
type SharedValue,
|
|
7
11
|
} from "react-native-reanimated";
|
|
8
|
-
import { MS_PER_FRAME_60FPS } from "../constants";
|
|
12
|
+
import { MS_PER_FRAME_60FPS, RETURN_TO_LIVE_MS } from "../constants";
|
|
9
13
|
import type { LiveChartPoint, SeriesConfig } from "../types";
|
|
10
14
|
import { tickLiveChartSeriesEngineFrame } from "./liveChartSeriesEngineTick";
|
|
11
15
|
import type { ChartEngineScroll, MultiEngineState } from "./useLiveChartEngine";
|
|
@@ -24,6 +28,19 @@ export interface MultiSeriesEngineConfig {
|
|
|
24
28
|
nowOverride?: number;
|
|
25
29
|
windowBuffer?: number;
|
|
26
30
|
paused?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the `timeScroll` gesture is active. Flipping to `false` while scrolled
|
|
33
|
+
* back clears {@link ChartEngineScroll.viewEnd} and **glides** the window back to
|
|
34
|
+
* the live edge (eased, not an instant jump), so disabling time-scroll never
|
|
35
|
+
* strands the chart at a past position. See #164.
|
|
36
|
+
*/
|
|
37
|
+
scrollEnabled?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Duration (ms) of the return-to-live glide when {@link scrollEnabled} flips to
|
|
40
|
+
* `false` while scrolled back. `0` snaps instantly. Defaults to
|
|
41
|
+
* {@link RETURN_TO_LIVE_MS}. Resolved from `timeScroll.returnToLive`. See #164.
|
|
42
|
+
*/
|
|
43
|
+
returnToLiveMs?: number;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
export interface MultiEngineFrameRefs {
|
|
@@ -49,6 +66,10 @@ export interface MultiEngineFrameRefs {
|
|
|
49
66
|
pausedSV: SharedValue<boolean>;
|
|
50
67
|
/** Pan-scroll right-edge override (null = follow live). Optional for callers/tests. */
|
|
51
68
|
viewEndSV?: SharedValue<number | null>;
|
|
69
|
+
/** "Return to live" glide progress (0→1); the right edge eases to live. Optional. */
|
|
70
|
+
returnTSV?: SharedValue<number>;
|
|
71
|
+
/** Frozen right-edge time the return glide starts from. Optional. */
|
|
72
|
+
returnFromSV?: SharedValue<number>;
|
|
52
73
|
/** Pinch-zoom window-width override (null = follow timeWindow). Optional for callers/tests. */
|
|
53
74
|
viewWindowSV?: SharedValue<number | null>;
|
|
54
75
|
/** Receives the computed live edge each frame. Optional for callers/tests. */
|
|
@@ -137,6 +158,8 @@ export function applyLiveChartSeriesEngineFrame(
|
|
|
137
158
|
nowSeconds: Date.now() / 1000,
|
|
138
159
|
paused: sv.pausedSV.value,
|
|
139
160
|
viewEnd: sv.viewEndSV?.value,
|
|
161
|
+
returnT: sv.returnTSV?.value,
|
|
162
|
+
returnFrom: sv.returnFromSV?.value,
|
|
140
163
|
viewWindow: sv.viewWindowSV?.value,
|
|
141
164
|
});
|
|
142
165
|
sv.displayMin.value = state.displayMin;
|
|
@@ -173,6 +196,13 @@ export function useLiveChartSeriesEngine(
|
|
|
173
196
|
const nowOverrideSV = useDerivedValue(() => config.nowOverride);
|
|
174
197
|
const windowBufferSV = useDerivedValue(() => config.windowBuffer ?? 0);
|
|
175
198
|
const pausedSV = useDerivedValue(() => config.paused ?? false);
|
|
199
|
+
// Whether time-scroll is active — drives the return-to-live reaction below.
|
|
200
|
+
// Defaults to enabled (legacy behavior).
|
|
201
|
+
const scrollEnabledSV = useDerivedValue(() => config.scrollEnabled ?? true);
|
|
202
|
+
// Return-to-live glide duration (ms); 0 = instant snap. Read by the reaction.
|
|
203
|
+
const returnToLiveMsSV = useDerivedValue(
|
|
204
|
+
() => config.returnToLiveMs ?? RETURN_TO_LIVE_MS,
|
|
205
|
+
);
|
|
176
206
|
|
|
177
207
|
const displayMin = useSharedValue(0);
|
|
178
208
|
const displayMax = useSharedValue(1);
|
|
@@ -186,6 +216,10 @@ export function useLiveChartSeriesEngine(
|
|
|
186
216
|
// Pan-scroll state (see useLiveChartEngine). Defaults to following live.
|
|
187
217
|
const viewEnd = useSharedValue<number | null>(null);
|
|
188
218
|
const liveEdge = useSharedValue(initialTimestamp);
|
|
219
|
+
// "Return to live" glide (see #164) — `returnT` rests at 1; the reaction below
|
|
220
|
+
// animates it 0→1 from `returnFrom` when time-scroll is disabled while scrolled.
|
|
221
|
+
const returnT = useSharedValue(1);
|
|
222
|
+
const returnFrom = useSharedValue(0);
|
|
189
223
|
|
|
190
224
|
const displaySeriesValues = useSharedValue<number[]>([]);
|
|
191
225
|
const seriesOpacities = useSharedValue<number[]>([]);
|
|
@@ -235,6 +269,8 @@ export function useLiveChartSeriesEngine(
|
|
|
235
269
|
windowBufferSV,
|
|
236
270
|
pausedSV,
|
|
237
271
|
viewEndSV: viewEnd,
|
|
272
|
+
returnTSV: returnT,
|
|
273
|
+
returnFromSV: returnFrom,
|
|
238
274
|
viewWindowSV: viewWindow,
|
|
239
275
|
liveEdgeSV: liveEdge,
|
|
240
276
|
extremaMinValue,
|
|
@@ -246,6 +282,34 @@ export function useLiveChartSeriesEngine(
|
|
|
246
282
|
);
|
|
247
283
|
});
|
|
248
284
|
|
|
285
|
+
// Return the window to live when time-scroll is disabled while scrolled back
|
|
286
|
+
// (mirrors useLiveChartEngine): with a positive duration, glide — snapshot the
|
|
287
|
+
// frozen edge, clear `viewEnd`, and animate `returnT` 0→1 so the tick eases
|
|
288
|
+
// `returnFrom`→live; duration 0 just clears `viewEnd` for an instant snap.
|
|
289
|
+
// Clearing `viewEnd` also means a later re-enable resumes from live. See #164.
|
|
290
|
+
useAnimatedReaction(
|
|
291
|
+
() => scrollEnabledSV.value,
|
|
292
|
+
/* istanbul ignore next -- Reanimated reaction driven by a prop→derived change; not exercised under the SharedValue mock, verified in-app */
|
|
293
|
+
(enabled, prev) => {
|
|
294
|
+
if (prev === true && !enabled && viewEnd.value != null) {
|
|
295
|
+
const ms = returnToLiveMsSV.value;
|
|
296
|
+
if (ms > 0) {
|
|
297
|
+
returnFrom.value = viewEnd.value;
|
|
298
|
+
cancelAnimation(viewEnd);
|
|
299
|
+
viewEnd.value = null;
|
|
300
|
+
returnT.value = 0;
|
|
301
|
+
returnT.value = withTiming(1, {
|
|
302
|
+
duration: ms,
|
|
303
|
+
easing: Easing.out(Easing.cubic),
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
cancelAnimation(viewEnd);
|
|
307
|
+
viewEnd.value = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
|
|
249
313
|
return {
|
|
250
314
|
data,
|
|
251
315
|
value,
|