react-native-livechart 4.2.1 → 4.4.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/MarkerOverlay.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/draw/markerAtlas.d.ts +9 -2
- package/dist/draw/markerAtlas.d.ts.map +1 -1
- package/dist/hooks/useMarkers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/math/markerCluster.d.ts +8 -1
- package/dist/math/markerCluster.d.ts.map +1 -1
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LiveChart.tsx +13 -2
- package/src/components/LiveChartSeries.tsx +7 -0
- package/src/components/MarkerOverlay.tsx +135 -37
- 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 +25 -0
- package/src/core/useLiveChartEngine.ts +71 -1
- package/src/core/useLiveChartSeriesEngine.ts +65 -1
- package/src/draw/markerAtlas.ts +30 -1
- package/src/hooks/useMarkers.ts +2 -0
- package/src/index.ts +2 -0
- package/src/math/markerCluster.ts +8 -1
- package/src/types.ts +84 -0
package/package.json
CHANGED
|
@@ -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
|
},
|
|
@@ -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,
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
Path,
|
|
5
5
|
Skia,
|
|
6
6
|
type SkFont,
|
|
7
|
+
type SkRect,
|
|
8
|
+
type SkRSXform,
|
|
7
9
|
} from "@shopify/react-native-skia";
|
|
8
10
|
import { useMemo, useRef, useState } from "react";
|
|
9
11
|
import { PixelRatio } from "react-native";
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
BADGE_TEXT_SCALE,
|
|
21
23
|
buildMarkerAtlas,
|
|
22
24
|
defaultMarkerColor,
|
|
25
|
+
GROUP_BADGE_SIG,
|
|
23
26
|
groupBgSig,
|
|
24
27
|
groupCountText,
|
|
25
28
|
groupGlyphSig,
|
|
@@ -47,6 +50,77 @@ import type {
|
|
|
47
50
|
|
|
48
51
|
const OFF = -9999;
|
|
49
52
|
|
|
53
|
+
/** Size of the `showGroupCount` corner badge relative to a full count badge. */
|
|
54
|
+
const GROUP_CORNER_SCALE = 0.62;
|
|
55
|
+
/** How far the corner count badge sits toward the glyph's top-right corner
|
|
56
|
+
* (1 = centered exactly on the corner; < 1 pulls it inward). */
|
|
57
|
+
const GROUP_CORNER_INSET = 0.82;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Append a collapsed-cluster count badge (round bg + centered, proportionally
|
|
61
|
+
* kerned digits) to the atlas blit lists at `(cx, cy)`, scaled by `mul` — full
|
|
62
|
+
* size as the default group badge, shrunk in a glyph's corner for `showGroupCount`.
|
|
63
|
+
*
|
|
64
|
+
* A **module-level** worklet (not a closure inside the per-frame derived value):
|
|
65
|
+
* the `transforms` / `sprites` arrays are passed in by reference so the pushes land
|
|
66
|
+
* on the real per-frame lists. A nested `"worklet"` closure would capture them by
|
|
67
|
+
* serialized copy, so its pushes would be silently dropped and the badge never drawn.
|
|
68
|
+
*/
|
|
69
|
+
function pushGroupCountBadge(
|
|
70
|
+
transforms: SkRSXform[],
|
|
71
|
+
sprites: SkRect[],
|
|
72
|
+
cells: Record<string, AtlasCell>,
|
|
73
|
+
digitWidths: Record<string, number>,
|
|
74
|
+
invScale: number,
|
|
75
|
+
cx: number,
|
|
76
|
+
cy: number,
|
|
77
|
+
repColor: string,
|
|
78
|
+
count: number,
|
|
79
|
+
mul: number,
|
|
80
|
+
): void {
|
|
81
|
+
"worklet";
|
|
82
|
+
const text = groupCountText(count);
|
|
83
|
+
const bg = cells[groupBgSig(repColor)];
|
|
84
|
+
if (bg) {
|
|
85
|
+
transforms.push(
|
|
86
|
+
Skia.RSXform(invScale * mul, 0, cx - (bg.w * mul) / 2, cy - (bg.h * mul) / 2),
|
|
87
|
+
);
|
|
88
|
+
sprites.push(bg.rect);
|
|
89
|
+
}
|
|
90
|
+
// Lay digits out proportionally (each by its own ink width), scaled to fit the
|
|
91
|
+
// small round badge, centered. A negative kern (fraction of a digit) closes the
|
|
92
|
+
// font's side-bearing gap so "12" reads tight, not "1 2".
|
|
93
|
+
const S = BADGE_TEXT_SCALE;
|
|
94
|
+
let maxDW = 1;
|
|
95
|
+
for (let d = 0; d < 10; d++) {
|
|
96
|
+
const dw = digitWidths["" + d] ?? 0;
|
|
97
|
+
if (dw > maxDW) maxDW = dw;
|
|
98
|
+
}
|
|
99
|
+
const kern = -maxDW * S * 0.42;
|
|
100
|
+
let totalW = 0;
|
|
101
|
+
for (let c = 0; c < text.length; c++) {
|
|
102
|
+
totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
|
|
103
|
+
}
|
|
104
|
+
let dx = cx - (totalW * mul) / 2;
|
|
105
|
+
for (let c = 0; c < text.length; c++) {
|
|
106
|
+
const w = (digitWidths[text[c]] ?? 0) * S * mul;
|
|
107
|
+
const gc = cells[groupGlyphSig(text[c])];
|
|
108
|
+
if (gc) {
|
|
109
|
+
const gcx = dx + w / 2;
|
|
110
|
+
transforms.push(
|
|
111
|
+
Skia.RSXform(
|
|
112
|
+
invScale * S * mul,
|
|
113
|
+
0,
|
|
114
|
+
gcx - (gc.w * S * mul) / 2,
|
|
115
|
+
cy - (gc.h * S * mul) / 2,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
sprites.push(gc.rect);
|
|
119
|
+
}
|
|
120
|
+
dx += w + kern * mul;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
50
124
|
/**
|
|
51
125
|
* Self-projecting fallback for the axis-anchored kinds (graduation stem + flag,
|
|
52
126
|
* clawback box) that can't be fixed-size atlas sprites. Rare, so the per-glyph
|
|
@@ -246,6 +320,14 @@ export function MarkerOverlay({
|
|
|
246
320
|
// retina canvases instead of being upscaled from a logical-sized texture.
|
|
247
321
|
const dpr = PixelRatio.get();
|
|
248
322
|
const clusterStacked = cluster.mode === "stacked";
|
|
323
|
+
// A dedicated group badge (object form) bakes one extra atlas cell; its image
|
|
324
|
+
// identity + styling drive the rebuild key alongside the marker appearances.
|
|
325
|
+
const groupBadgeCfg =
|
|
326
|
+
typeof cluster.groupBadge === "object" ? cluster.groupBadge : undefined;
|
|
327
|
+
const groupBadgeImage = groupBadgeCfg?.image;
|
|
328
|
+
const groupBadgeKey = groupBadgeCfg
|
|
329
|
+
? `${groupBadgeCfg.icon ?? ""}|${groupBadgeCfg.color ?? ""}|${groupBadgeCfg.pill ? 1 : 0}|${groupBadgeCfg.size ?? ""}`
|
|
330
|
+
: "";
|
|
249
331
|
const atlas = useMemo(
|
|
250
332
|
() =>
|
|
251
333
|
buildMarkerAtlas(
|
|
@@ -254,9 +336,10 @@ export function MarkerOverlay({
|
|
|
254
336
|
font,
|
|
255
337
|
dpr,
|
|
256
338
|
clusterStacked,
|
|
339
|
+
groupBadgeCfg,
|
|
257
340
|
),
|
|
258
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey capture the inputs that change cell pixels
|
|
259
|
-
[appearanceKey, paletteKey, font, dpr, clusterStacked],
|
|
341
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- appearanceKey/paletteKey/groupBadgeKey capture the inputs that change cell pixels
|
|
342
|
+
[appearanceKey, paletteKey, font, dpr, clusterStacked, groupBadgeKey, groupBadgeImage],
|
|
260
343
|
);
|
|
261
344
|
const cells: Record<string, AtlasCell> = atlas.cells;
|
|
262
345
|
const atlasImage = atlas.image;
|
|
@@ -301,8 +384,8 @@ export function MarkerOverlay({
|
|
|
301
384
|
lineLinear,
|
|
302
385
|
});
|
|
303
386
|
clusterMarkers(ms, buf, { config: cluster });
|
|
304
|
-
const transforms = [];
|
|
305
|
-
const sprites = [];
|
|
387
|
+
const transforms: SkRSXform[] = [];
|
|
388
|
+
const sprites: SkRect[] = [];
|
|
306
389
|
for (let i = 0; i < ms.length; i++) {
|
|
307
390
|
const pt = buf[i];
|
|
308
391
|
// Collapsed-cluster members fold into their representative's badge.
|
|
@@ -311,46 +394,61 @@ export function MarkerOverlay({
|
|
|
311
394
|
if (isConnectorMarker(m)) continue;
|
|
312
395
|
// Custom-rendered markers are floated as RN views, not drawn here.
|
|
313
396
|
if (customIds[m.id]) continue;
|
|
314
|
-
// Collapsed cluster
|
|
315
|
-
//
|
|
397
|
+
// Collapsed cluster. By default a round count badge; with
|
|
398
|
+
// `groupBadge: "marker"` the representative marker's own glyph, or with a
|
|
399
|
+
// `MarkerGroupBadge` object a dedicated baked badge — either optionally
|
|
400
|
+
// carrying a corner count, all within this one `drawAtlas`.
|
|
316
401
|
if (pt.isGrouped) {
|
|
317
402
|
const repColor = m.color ?? defaultMarkerColor(m.kind, palette);
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
403
|
+
const gb = cluster.groupBadge;
|
|
404
|
+
// Pick the non-count glyph cell: a dedicated group badge (object form)
|
|
405
|
+
// or the representative marker's own appearance.
|
|
406
|
+
const glyphCell =
|
|
407
|
+
typeof gb === "object" && gb !== null
|
|
408
|
+
? cells[GROUP_BADGE_SIG]
|
|
409
|
+
: gb === "marker"
|
|
410
|
+
? cells[markerAppearanceSig(m)]
|
|
411
|
+
: undefined;
|
|
412
|
+
if (glyphCell) {
|
|
321
413
|
transforms.push(
|
|
322
|
-
Skia.RSXform(
|
|
414
|
+
Skia.RSXform(
|
|
415
|
+
invScale,
|
|
416
|
+
0,
|
|
417
|
+
pt.x - glyphCell.w / 2,
|
|
418
|
+
pt.y - glyphCell.h / 2,
|
|
419
|
+
),
|
|
323
420
|
);
|
|
324
|
-
sprites.push(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
let totalW = 0;
|
|
338
|
-
for (let c = 0; c < text.length; c++) {
|
|
339
|
-
totalW += (digitWidths[text[c]] ?? 0) * S + (c > 0 ? kern : 0);
|
|
340
|
-
}
|
|
341
|
-
let dx = pt.x - totalW / 2;
|
|
342
|
-
for (let c = 0; c < text.length; c++) {
|
|
343
|
-
const w = (digitWidths[text[c]] ?? 0) * S;
|
|
344
|
-
const gc = cells[groupGlyphSig(text[c])];
|
|
345
|
-
if (gc) {
|
|
346
|
-
const cx = dx + w / 2;
|
|
347
|
-
transforms.push(
|
|
348
|
-
Skia.RSXform(invScale * S, 0, cx - (gc.w * S) / 2, pt.y - (gc.h * S) / 2),
|
|
421
|
+
sprites.push(glyphCell.rect);
|
|
422
|
+
if (cluster.showGroupCount) {
|
|
423
|
+
pushGroupCountBadge(
|
|
424
|
+
transforms,
|
|
425
|
+
sprites,
|
|
426
|
+
cells,
|
|
427
|
+
digitWidths,
|
|
428
|
+
invScale,
|
|
429
|
+
pt.x + (glyphCell.w / 2) * GROUP_CORNER_INSET,
|
|
430
|
+
pt.y - (glyphCell.h / 2) * GROUP_CORNER_INSET,
|
|
431
|
+
repColor,
|
|
432
|
+
pt.groupCount,
|
|
433
|
+
GROUP_CORNER_SCALE,
|
|
349
434
|
);
|
|
350
|
-
sprites.push(gc.rect);
|
|
351
435
|
}
|
|
352
|
-
|
|
436
|
+
continue;
|
|
353
437
|
}
|
|
438
|
+
// Default — or fall back here when a non-count badge has no baked cell
|
|
439
|
+
// (e.g. an empty group-badge config): the round count badge.
|
|
440
|
+
pushGroupCountBadge(
|
|
441
|
+
transforms,
|
|
442
|
+
sprites,
|
|
443
|
+
cells,
|
|
444
|
+
digitWidths,
|
|
445
|
+
invScale,
|
|
446
|
+
pt.x,
|
|
447
|
+
pt.y,
|
|
448
|
+
repColor,
|
|
449
|
+
pt.groupCount,
|
|
450
|
+
1,
|
|
451
|
+
);
|
|
354
452
|
continue;
|
|
355
453
|
}
|
|
356
454
|
const cell = cells[markerAppearanceSig(m)];
|
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,
|
|
@@ -613,6 +634,8 @@ export function resolveMarkerCluster(
|
|
|
613
634
|
overlap: clamp01(prop.overlap ?? MARKER_CLUSTER_OVERLAP),
|
|
614
635
|
gap: MARKER_CLUSTER_GAP,
|
|
615
636
|
maxBeforeGroup: prop.maxBeforeGroup ?? MARKER_CLUSTER_MAX_BEFORE_GROUP,
|
|
637
|
+
groupBadge: prop.groupBadge ?? "count",
|
|
638
|
+
showGroupCount: prop.showGroupCount ?? false,
|
|
616
639
|
};
|
|
617
640
|
}
|
|
618
641
|
return {
|
|
@@ -621,6 +644,8 @@ export function resolveMarkerCluster(
|
|
|
621
644
|
overlap: MARKER_CLUSTER_OVERLAP,
|
|
622
645
|
gap: MARKER_CLUSTER_GAP,
|
|
623
646
|
maxBeforeGroup: MARKER_CLUSTER_MAX_BEFORE_GROUP,
|
|
647
|
+
groupBadge: "count",
|
|
648
|
+
showGroupCount: false,
|
|
624
649
|
};
|
|
625
650
|
}
|
|
626
651
|
|
|
@@ -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
|