react-native-vroom-chart 0.15.0 → 0.17.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/cpp/VroomChartHostObject.cpp +252 -1
- package/cpp/_core_include/vroom/vroom_chart.h +178 -4
- package/cpp/_core_src/atr.cpp +67 -0
- package/cpp/_core_src/atr.h +44 -0
- package/cpp/_core_src/atr_pane.cpp +162 -0
- package/cpp/_core_src/atr_pane.h +48 -0
- package/cpp/_core_src/candles.cpp +15 -14
- package/cpp/_core_src/chart.cpp +758 -200
- package/cpp/_core_src/chart.h +221 -3
- package/cpp/_core_src/chart_facade.cpp +236 -23
- package/cpp/_core_src/color_lerp.h +28 -0
- package/cpp/_core_src/fair_value_gaps.cpp +76 -0
- package/cpp/_core_src/fair_value_gaps.h +54 -0
- package/cpp/_core_src/fvg_overlay.cpp +262 -0
- package/cpp/_core_src/fvg_overlay.h +43 -0
- package/cpp/_core_src/ichimoku.cpp +65 -0
- package/cpp/_core_src/ichimoku.h +45 -0
- package/cpp/_core_src/labels.cpp +3 -0
- package/cpp/_core_src/line_morph.h +159 -0
- package/cpp/_core_src/loading_line.cpp +183 -0
- package/cpp/_core_src/loading_line.h +38 -0
- package/cpp/_core_src/loading_wave.h +140 -0
- package/cpp/_core_src/ma_overlay.cpp +273 -45
- package/cpp/_core_src/ma_overlay.h +64 -2
- package/cpp/_core_src/macd.cpp +24 -1
- package/cpp/_core_src/macd.h +16 -0
- package/cpp/_core_src/macd_pane.cpp +71 -62
- package/cpp/_core_src/macd_pane.h +13 -1
- package/cpp/_core_src/pane_series.h +169 -0
- package/cpp/_core_src/price_indicator.cpp +21 -7
- package/cpp/_core_src/price_indicator.h +11 -1
- package/cpp/_core_src/price_indicator_anim.h +81 -0
- package/cpp/_core_src/rsi.cpp +4 -0
- package/cpp/_core_src/rsi.h +7 -0
- package/cpp/_core_src/rsi_pane.cpp +85 -29
- package/cpp/_core_src/rsi_pane.h +11 -1
- package/cpp/_core_src/theme.cpp +5 -0
- package/cpp/_core_src/tip_geometry.h +55 -0
- package/cpp/_core_src/viewport.h +68 -0
- package/lib/index.d.mts +281 -3
- package/lib/index.d.ts +281 -3
- package/lib/index.js +292 -14
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +292 -14
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +33 -3
- package/src/dataTransitions.ts +47 -0
- package/src/index.ts +5 -0
- package/src/jsi.d.ts +139 -1
- package/src/theme.ts +1 -0
- package/src/types.ts +5 -0
- package/src/useChartCore.ts +402 -8
package/src/useChartCore.ts
CHANGED
|
@@ -3,15 +3,24 @@ import type { MutableRefObject } from 'react';
|
|
|
3
3
|
|
|
4
4
|
import NativeVroomChart from './NativeVroomChart';
|
|
5
5
|
import type { DataTransition } from './dataTransitions';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
classifyStream,
|
|
8
|
+
classifyTransition,
|
|
9
|
+
inferStepMs,
|
|
10
|
+
isPinnedToLatest,
|
|
11
|
+
timeframeWindow,
|
|
12
|
+
} from './dataTransitions';
|
|
7
13
|
import { ease } from './easing';
|
|
8
14
|
import type { ChartFrame, ChartHandle } from './jsi.d';
|
|
9
15
|
import { packCandles } from './packCandles';
|
|
10
16
|
import { applyTheme, parseColor, FLOAT_LINE_TIP_PULSE } from './theme';
|
|
11
17
|
import type {
|
|
12
18
|
BollingerBandsConfig,
|
|
19
|
+
ATRConfig,
|
|
13
20
|
Candle,
|
|
14
21
|
ChartType,
|
|
22
|
+
FairValueGapsConfig,
|
|
23
|
+
IchimokuConfig,
|
|
15
24
|
MACDConfig,
|
|
16
25
|
MovingAverageOverlay,
|
|
17
26
|
PriceLine,
|
|
@@ -21,6 +30,7 @@ import type {
|
|
|
21
30
|
RSIConfig,
|
|
22
31
|
TransitionEasing,
|
|
23
32
|
IntervalTransition,
|
|
33
|
+
StreamTransition,
|
|
24
34
|
VisibleRange,
|
|
25
35
|
VolumeConfig,
|
|
26
36
|
VroomTheme,
|
|
@@ -38,6 +48,9 @@ const MA_SOURCES = [
|
|
|
38
48
|
'ohlc4',
|
|
39
49
|
] as const;
|
|
40
50
|
|
|
51
|
+
// Mirrors vroom::atr::Smoothing order in packages/core/src/atr.h.
|
|
52
|
+
const ATR_SMOOTHINGS = ['rma', 'sma', 'ema'] as const;
|
|
53
|
+
|
|
41
54
|
// An unset style color marshals as the core's transparent inherit sentinel.
|
|
42
55
|
const inheritColor = (v: string | number | undefined): number =>
|
|
43
56
|
(v != null ? parseColor(v) : null) ?? 0;
|
|
@@ -69,6 +82,7 @@ function rsiToSpec(cfg: RSIConfig | undefined) {
|
|
|
69
82
|
maWidth: cfg?.maWidth ?? -1,
|
|
70
83
|
bandColor: inheritColor(cfg?.bandColor),
|
|
71
84
|
bandsVisible: cfg?.bandsVisible ?? true,
|
|
85
|
+
extremeFill: cfg?.extremeFill ?? true,
|
|
72
86
|
};
|
|
73
87
|
}
|
|
74
88
|
|
|
@@ -110,6 +124,89 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
|
|
|
110
124
|
};
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
// Ichimoku defaults. Green and red do double duty: they color span A and kijun,
|
|
128
|
+
// and tint the cloud for whichever span is on top.
|
|
129
|
+
const DEFAULT_ICH_GREEN = 0xff26a69a;
|
|
130
|
+
const DEFAULT_ICH_RED = 0xffef5350;
|
|
131
|
+
const DEFAULT_ICH_BLUE = 0xff2962ff;
|
|
132
|
+
const DEFAULT_ICH_ORANGE = 0xffff6d00;
|
|
133
|
+
const DEFAULT_ICH_TEAL = 0xff00bcd4;
|
|
134
|
+
|
|
135
|
+
function ichimokuToSpec(cfg: IchimokuConfig | undefined) {
|
|
136
|
+
const color = (v: string | number | undefined, fallback: number) =>
|
|
137
|
+
(v != null ? parseColor(v) : null) ?? fallback;
|
|
138
|
+
return {
|
|
139
|
+
enabled: cfg?.enabled ?? false,
|
|
140
|
+
tenkanPeriod: cfg?.tenkanPeriod ?? 9,
|
|
141
|
+
kijunPeriod: cfg?.kijunPeriod ?? 26,
|
|
142
|
+
senkouBPeriod: cfg?.senkouBPeriod ?? 52,
|
|
143
|
+
displacement: cfg?.displacement ?? 26,
|
|
144
|
+
tenkanColor: color(cfg?.tenkanColor, DEFAULT_ICH_BLUE),
|
|
145
|
+
tenkanWidth: cfg?.tenkanWidth ?? 1,
|
|
146
|
+
tenkanEnabled: cfg?.tenkanVisible ?? true,
|
|
147
|
+
kijunColor: color(cfg?.kijunColor, DEFAULT_ICH_RED),
|
|
148
|
+
kijunWidth: cfg?.kijunWidth ?? 1,
|
|
149
|
+
kijunEnabled: cfg?.kijunVisible ?? true,
|
|
150
|
+
senkouAColor: color(cfg?.senkouAColor, DEFAULT_ICH_GREEN),
|
|
151
|
+
senkouAWidth: cfg?.senkouAWidth ?? 1,
|
|
152
|
+
senkouAEnabled: cfg?.senkouAVisible ?? true,
|
|
153
|
+
senkouBColor: color(cfg?.senkouBColor, DEFAULT_ICH_ORANGE),
|
|
154
|
+
senkouBWidth: cfg?.senkouBWidth ?? 1,
|
|
155
|
+
senkouBEnabled: cfg?.senkouBVisible ?? true,
|
|
156
|
+
chikouColor: color(cfg?.chikouColor, DEFAULT_ICH_TEAL),
|
|
157
|
+
chikouWidth: cfg?.chikouWidth ?? 1,
|
|
158
|
+
chikouEnabled: cfg?.chikouVisible ?? true,
|
|
159
|
+
cloudEnabled: cfg?.cloudVisible ?? true,
|
|
160
|
+
bullishCloudColor: color(cfg?.bullishCloudColor, DEFAULT_ICH_GREEN),
|
|
161
|
+
bearishCloudColor: color(cfg?.bearishCloudColor, DEFAULT_ICH_RED),
|
|
162
|
+
cloudOpacity: cfg?.cloudOpacity ?? 0.15,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fair Value Gap defaults. The border colors fall back to the fill color, so a
|
|
167
|
+
// config that only restyles the fill keeps its outline in the same hue.
|
|
168
|
+
const DEFAULT_FVG_GREEN = 0xff26a69a;
|
|
169
|
+
const DEFAULT_FVG_RED = 0xffef5350;
|
|
170
|
+
const FVG_FILL_TYPES = ['close', 'wick'] as const;
|
|
171
|
+
const FVG_BORDER_STYLES = ['solid', 'dotted', 'dashed'] as const;
|
|
172
|
+
|
|
173
|
+
function fvgToSpec(cfg: FairValueGapsConfig | undefined) {
|
|
174
|
+
const color = (v: string | number | undefined, fallback: number) =>
|
|
175
|
+
(v != null ? parseColor(v) : null) ?? fallback;
|
|
176
|
+
const bullish = color(cfg?.bullishColor, DEFAULT_FVG_GREEN);
|
|
177
|
+
const bearish = color(cfg?.bearishColor, DEFAULT_FVG_RED);
|
|
178
|
+
return {
|
|
179
|
+
enabled: cfg?.enabled ?? false,
|
|
180
|
+
maxBarsBack: cfg?.maxBarsBack ?? 300,
|
|
181
|
+
waitForClose: cfg?.waitForClose ?? false,
|
|
182
|
+
fillType: Math.max(0, FVG_FILL_TYPES.indexOf(cfg?.fillType ?? 'close')),
|
|
183
|
+
deleteAfterFill: cfg?.deleteAfterFill ?? true,
|
|
184
|
+
extendBoxes: cfg?.extendBoxes ?? false,
|
|
185
|
+
boxLength: cfg?.boxLength ?? 20,
|
|
186
|
+
bullishColor: bullish,
|
|
187
|
+
bearishColor: bearish,
|
|
188
|
+
opacity: cfg?.opacity ?? 0.15,
|
|
189
|
+
borderEnabled: cfg?.borderVisible ?? true,
|
|
190
|
+
borderStyle: Math.max(
|
|
191
|
+
0,
|
|
192
|
+
FVG_BORDER_STYLES.indexOf(cfg?.borderStyle ?? 'solid'),
|
|
193
|
+
),
|
|
194
|
+
borderWidth: cfg?.borderWidth ?? 1,
|
|
195
|
+
bullishBorderColor: color(cfg?.bullishBorderColor, bullish),
|
|
196
|
+
bearishBorderColor: color(cfg?.bearishBorderColor, bearish),
|
|
197
|
+
labelsEnabled: cfg?.showLabels ?? true,
|
|
198
|
+
label: cfg?.label ?? 'FVG',
|
|
199
|
+
labelDistance: cfg?.labelDistance ?? 10,
|
|
200
|
+
// Alpha 0 is the core's "inherit the border color" sentinel.
|
|
201
|
+
labelColor: color(cfg?.labelColor, 0),
|
|
202
|
+
labelFontSize: cfg?.labelFontSize ?? 0,
|
|
203
|
+
showInverse: cfg?.showInverse ?? false,
|
|
204
|
+
inverseBullishColor: color(cfg?.inverseBullishColor, bullish),
|
|
205
|
+
inverseBearishColor: color(cfg?.inverseBearishColor, bearish),
|
|
206
|
+
inverseLabel: cfg?.inverseLabel ?? 'iFVG',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
113
210
|
function macdToSpec(cfg: MACDConfig | undefined) {
|
|
114
211
|
const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
|
|
115
212
|
return {
|
|
@@ -136,6 +233,16 @@ function macdToSpec(cfg: MACDConfig | undefined) {
|
|
|
136
233
|
};
|
|
137
234
|
}
|
|
138
235
|
|
|
236
|
+
function atrToSpec(cfg: ATRConfig | undefined) {
|
|
237
|
+
return {
|
|
238
|
+
enabled: cfg?.enabled ?? false,
|
|
239
|
+
period: cfg?.period ?? 14,
|
|
240
|
+
smoothing: Math.max(0, ATR_SMOOTHINGS.indexOf(cfg?.smoothing ?? 'rma')),
|
|
241
|
+
lineColor: inheritColor(cfg?.lineColor),
|
|
242
|
+
lineWidth: cfg?.lineWidth ?? -1,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
139
246
|
// Unset style fields go down as the core's inherit sentinels (negative float,
|
|
140
247
|
// transparent color) rather than as literal defaults, so the theme keys stay in
|
|
141
248
|
// charge of anything the consumer didn't set.
|
|
@@ -273,6 +380,10 @@ export type TransitionOptions = {
|
|
|
273
380
|
transitionEasing?: TransitionEasing;
|
|
274
381
|
/** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
|
|
275
382
|
intervalTransition?: IntervalTransition;
|
|
383
|
+
/** `'transform'` eases live updates; `'none'` (default) snaps them. */
|
|
384
|
+
streamTransition?: StreamTransition;
|
|
385
|
+
/** Duration of the stream animation in ms. 0 snaps. Default 150. */
|
|
386
|
+
streamTransitionMs?: number;
|
|
276
387
|
/** OS reduced-motion preference: skips the capture and snaps. */
|
|
277
388
|
reduceMotion?: boolean;
|
|
278
389
|
/** Receives every morph frame. Without one, data swaps snap. */
|
|
@@ -304,13 +415,19 @@ export function useChartCore(
|
|
|
304
415
|
theme?: VroomTheme,
|
|
305
416
|
rsi?: RSIConfig,
|
|
306
417
|
macd?: MACDConfig,
|
|
418
|
+
atr?: ATRConfig,
|
|
307
419
|
movingAverages?: MovingAverageOverlay[],
|
|
308
420
|
vwap?: VWAPConfig,
|
|
309
421
|
bollingerBands?: BollingerBandsConfig,
|
|
422
|
+
ichimoku?: IchimokuConfig,
|
|
423
|
+
fairValueGaps?: FairValueGapsConfig,
|
|
310
424
|
volume?: VolumeConfig,
|
|
311
425
|
priceLines?: PriceLinesProp,
|
|
312
426
|
footprints?: FootprintsProp,
|
|
313
427
|
transition?: TransitionOptions,
|
|
428
|
+
// Trails the config params because it's data state, not configuration: it
|
|
429
|
+
// pairs with `candles` above (see showLoadingLine below).
|
|
430
|
+
loading?: boolean,
|
|
314
431
|
): ChartCoreState {
|
|
315
432
|
const handleRef = useRef<ChartHandle | null>(null);
|
|
316
433
|
// Push setDefaultCandleWidth only once (first load): setCandles re-runs on
|
|
@@ -326,6 +443,13 @@ export function useChartCore(
|
|
|
326
443
|
seriesKey?: string;
|
|
327
444
|
} | null>(null);
|
|
328
445
|
const intervalMorphRaf = useRef<number | null>(null);
|
|
446
|
+
const streamRaf = useRef<number | null>(null);
|
|
447
|
+
// Where an in-flight stream shift is headed, so cancelling it can land there
|
|
448
|
+
// rather than stranding the view mid-slide.
|
|
449
|
+
const streamWindowRef = useRef<VisibleRange | null>(null);
|
|
450
|
+
// Whether that loop is the one driving the morph scalar, so settling it never
|
|
451
|
+
// cuts short a timeframe switch that happens to overlap.
|
|
452
|
+
const streamMorphRef = useRef(false);
|
|
329
453
|
const [picture, setPicture] = useState<ChartFrame | null>(null);
|
|
330
454
|
|
|
331
455
|
if (!handleRef.current && size.width > 0 && size.height > 0) {
|
|
@@ -341,33 +465,63 @@ export function useChartCore(
|
|
|
341
465
|
easing: TransitionEasing | undefined;
|
|
342
466
|
reduceMotion: boolean;
|
|
343
467
|
interval: IntervalTransition;
|
|
344
|
-
|
|
468
|
+
stream: StreamTransition;
|
|
469
|
+
streamMs: number;
|
|
470
|
+
}>({
|
|
471
|
+
ms: 300,
|
|
472
|
+
easing: undefined,
|
|
473
|
+
reduceMotion: false,
|
|
474
|
+
interval: 'transform',
|
|
475
|
+
stream: 'none',
|
|
476
|
+
streamMs: 150,
|
|
477
|
+
});
|
|
345
478
|
animRef.current = {
|
|
346
479
|
ms: Math.max(0, transition?.transitionMs ?? 300),
|
|
347
480
|
easing: transition?.transitionEasing,
|
|
348
481
|
reduceMotion: transition?.reduceMotion ?? false,
|
|
349
482
|
interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
|
|
483
|
+
stream: transition?.streamTransition === 'transform' ? 'transform' : 'none',
|
|
484
|
+
// Shorter than transitionMs by default: ticks can land faster than a 300ms
|
|
485
|
+
// curve, and every one that does interrupts the last.
|
|
486
|
+
streamMs: Math.max(0, transition?.streamTransitionMs ?? 150),
|
|
350
487
|
};
|
|
351
488
|
const onFrameRef = useRef(transition?.onFrame);
|
|
352
489
|
onFrameRef.current = transition?.onFrame;
|
|
353
490
|
const seriesKey = transition?.seriesKey;
|
|
354
491
|
|
|
492
|
+
// Set only while the loading hand-off is in its *first* stage, which is the
|
|
493
|
+
// one an interruption can't simply land: stage one leaves `loading` set in
|
|
494
|
+
// the core, and only stage two releases it.
|
|
495
|
+
const loadingHandoffRef = useRef(false);
|
|
496
|
+
|
|
355
497
|
// Stop an in-flight interval morph and land the core on the new candles.
|
|
356
498
|
const endIntervalMorph = useCallback(() => {
|
|
357
499
|
if (intervalMorphRaf.current != null) {
|
|
358
500
|
cancelAnimationFrame(intervalMorphRaf.current);
|
|
359
501
|
intervalMorphRaf.current = null;
|
|
360
502
|
}
|
|
361
|
-
handleRef.current
|
|
503
|
+
const h = handleRef.current;
|
|
504
|
+
// Walk a half-finished hand-off through the rest of its stages rather than
|
|
505
|
+
// just stopping the clock, or the core would be left drawing the loading
|
|
506
|
+
// line over the data it was supposed to hand off to.
|
|
507
|
+
if (loadingHandoffRef.current) {
|
|
508
|
+
loadingHandoffRef.current = false;
|
|
509
|
+
h?.setLoadingMorph(1);
|
|
510
|
+
h?.beginLoadingReveal();
|
|
511
|
+
}
|
|
512
|
+
h?.setIntervalMorph(1);
|
|
362
513
|
}, []);
|
|
363
514
|
|
|
364
515
|
// Runs the interval morph clock. The core holds the pre-swap geometry (see
|
|
365
516
|
// beginIntervalMorph) and reshapes each candle slot toward its new counterpart.
|
|
366
|
-
|
|
517
|
+
// `durationMs` overrides transitionMs for the loading hand-off, which splits
|
|
518
|
+
// it across two stages.
|
|
519
|
+
const startIntervalMorph = useCallback((h: ChartHandle, durationMs?: number) => {
|
|
367
520
|
const { ms, easing } = animRef.current;
|
|
521
|
+
const dur = durationMs ?? ms;
|
|
368
522
|
const start = performance.now();
|
|
369
523
|
const step = (now: number) => {
|
|
370
|
-
const p = Math.min(1, (now - start) /
|
|
524
|
+
const p = Math.min(1, (now - start) / dur);
|
|
371
525
|
h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
|
|
372
526
|
const pic = h.render();
|
|
373
527
|
if (pic) onFrameRef.current?.(pic);
|
|
@@ -376,12 +530,129 @@ export function useChartCore(
|
|
|
376
530
|
intervalMorphRaf.current = requestAnimationFrame(step);
|
|
377
531
|
}, []);
|
|
378
532
|
|
|
533
|
+
// Hands the loading line over to the data that just landed, in two stages:
|
|
534
|
+
// the line reshapes into the series' silhouette, then the candles grow out of
|
|
535
|
+
// it while it fades.
|
|
536
|
+
//
|
|
537
|
+
// Sequential rather than overlapped — the shape has to read as the data
|
|
538
|
+
// before the bars start emerging from it — so the two split transitionMs and
|
|
539
|
+
// the whole hand-off costs what any other transition costs.
|
|
540
|
+
const startLoadingHandoff = useCallback(
|
|
541
|
+
(h: ChartHandle) => {
|
|
542
|
+
const { ms, easing } = animRef.current;
|
|
543
|
+
const half = ms / 2;
|
|
544
|
+
loadingHandoffRef.current = true;
|
|
545
|
+
h.beginLoadingMorph();
|
|
546
|
+
const start = performance.now();
|
|
547
|
+
const step = (now: number) => {
|
|
548
|
+
const p = Math.min(1, (now - start) / half);
|
|
549
|
+
h.setLoadingMorph(p < 1 ? ease(easing, p) : 1);
|
|
550
|
+
const pic = h.render();
|
|
551
|
+
if (pic) onFrameRef.current?.(pic);
|
|
552
|
+
if (p < 1) {
|
|
553
|
+
intervalMorphRaf.current = requestAnimationFrame(step);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
intervalMorphRaf.current = null;
|
|
557
|
+
// Past here an interruption is an ordinary interval morph again: the
|
|
558
|
+
// core has left the loading state, so landing the clock is enough.
|
|
559
|
+
loadingHandoffRef.current = false;
|
|
560
|
+
// Stage two rides the interval-morph clock, which also carries the
|
|
561
|
+
// line's fade-out — so the bars' growth and the line's exit finish
|
|
562
|
+
// together instead of one outlasting the other.
|
|
563
|
+
h.beginLoadingReveal();
|
|
564
|
+
startIntervalMorph(h, half);
|
|
565
|
+
};
|
|
566
|
+
intervalMorphRaf.current = requestAnimationFrame(step);
|
|
567
|
+
},
|
|
568
|
+
[startIntervalMorph],
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// Stops an in-flight stream animation and puts the chart somewhere coherent.
|
|
572
|
+
//
|
|
573
|
+
// A pending window shift always lands on its target: abandoned mid-slide it
|
|
574
|
+
// would strand the view between two bars, half a candle off the grid.
|
|
575
|
+
//
|
|
576
|
+
// `keepMorph` is for a tick restarting on top of one already running —
|
|
577
|
+
// beginStreamMorph blends out of the geometry currently on screen, so landing
|
|
578
|
+
// that geometry first would throw away the very thing it resumes from.
|
|
579
|
+
const settleStream = useCallback((keepMorph = false) => {
|
|
580
|
+
if (streamRaf.current != null) {
|
|
581
|
+
cancelAnimationFrame(streamRaf.current);
|
|
582
|
+
streamRaf.current = null;
|
|
583
|
+
}
|
|
584
|
+
const h = handleRef.current;
|
|
585
|
+
const target = streamWindowRef.current;
|
|
586
|
+
streamWindowRef.current = null;
|
|
587
|
+
if (target) h?.setVisibleRange(target.startMs, target.endMs);
|
|
588
|
+
if (streamMorphRef.current && !keepMorph) {
|
|
589
|
+
streamMorphRef.current = false;
|
|
590
|
+
h?.setIntervalMorph(1);
|
|
591
|
+
}
|
|
592
|
+
}, []);
|
|
593
|
+
|
|
594
|
+
// Runs the clock for a live update. One loop drives both halves so they land
|
|
595
|
+
// on the same frame.
|
|
596
|
+
//
|
|
597
|
+
// `window` is null for a plain tick; for an append it is where the view has to
|
|
598
|
+
// end up. The slide is measured from wherever the window is *now*, so a shift
|
|
599
|
+
// interrupting another continues from the current position instead of
|
|
600
|
+
// snapping back to the start of the last one.
|
|
601
|
+
const startStreamAnim = useCallback(
|
|
602
|
+
(h: ChartHandle, morphing: boolean, window: VisibleRange | null) => {
|
|
603
|
+
const { streamMs, easing } = animRef.current;
|
|
604
|
+
let from = window ? h.getVisibleRange() : null;
|
|
605
|
+
// What the previous frame left the window at. Anything else — a pan, a
|
|
606
|
+
// pinch — lands somewhere different, which is how the slide notices it is
|
|
607
|
+
// no longer the only thing moving the view and gets out of the way.
|
|
608
|
+
// Cheaper than teaching every gesture to cancel it, and it can't miss one.
|
|
609
|
+
let applied: VisibleRange | null = null;
|
|
610
|
+
streamWindowRef.current = window;
|
|
611
|
+
streamMorphRef.current = morphing;
|
|
612
|
+
const start = performance.now();
|
|
613
|
+
const step = (now: number) => {
|
|
614
|
+
if (from && applied) {
|
|
615
|
+
const now_w = h.getVisibleRange();
|
|
616
|
+
if (now_w.startMs !== applied.startMs || now_w.endMs !== applied.endMs) {
|
|
617
|
+
from = null;
|
|
618
|
+
streamWindowRef.current = null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const p = Math.min(1, (now - start) / streamMs);
|
|
622
|
+
const e = p < 1 ? ease(easing, p) : 1;
|
|
623
|
+
if (morphing) h.setIntervalMorph(e);
|
|
624
|
+
if (from && window) {
|
|
625
|
+
applied = {
|
|
626
|
+
startMs: Math.round(from.startMs + (window.startMs - from.startMs) * e),
|
|
627
|
+
endMs: Math.round(from.endMs + (window.endMs - from.endMs) * e),
|
|
628
|
+
};
|
|
629
|
+
h.setVisibleRange(applied.startMs, applied.endMs);
|
|
630
|
+
}
|
|
631
|
+
const pic = h.render();
|
|
632
|
+
if (pic) onFrameRef.current?.(pic);
|
|
633
|
+
if (p < 1) {
|
|
634
|
+
streamRaf.current = requestAnimationFrame(step);
|
|
635
|
+
} else {
|
|
636
|
+
streamRaf.current = null;
|
|
637
|
+
streamWindowRef.current = null;
|
|
638
|
+
streamMorphRef.current = false;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
streamRaf.current = requestAnimationFrame(step);
|
|
642
|
+
},
|
|
643
|
+
[],
|
|
644
|
+
);
|
|
645
|
+
|
|
379
646
|
useEffect(() => {
|
|
380
647
|
return () => {
|
|
381
648
|
if (intervalMorphRaf.current != null) {
|
|
382
649
|
cancelAnimationFrame(intervalMorphRaf.current);
|
|
383
650
|
intervalMorphRaf.current = null;
|
|
384
651
|
}
|
|
652
|
+
if (streamRaf.current != null) {
|
|
653
|
+
cancelAnimationFrame(streamRaf.current);
|
|
654
|
+
streamRaf.current = null;
|
|
655
|
+
}
|
|
385
656
|
};
|
|
386
657
|
}, []);
|
|
387
658
|
|
|
@@ -393,14 +664,28 @@ export function useChartCore(
|
|
|
393
664
|
const startMs = visibleRange?.startMs ?? 0;
|
|
394
665
|
const endMs = visibleRange?.endMs ?? 0;
|
|
395
666
|
|
|
667
|
+
// The core trusts `setLoading` outright, so the "and no data yet" half of the
|
|
668
|
+
// condition is decided here. Both halves matter: without `loading` a chart
|
|
669
|
+
// that legitimately has no bars would wave a placeholder forever, and without
|
|
670
|
+
// the emptiness check a background refresh of a loaded series would blank the
|
|
671
|
+
// chart the user is already reading.
|
|
672
|
+
const showLoadingLine = loading === true && candles.length === 0;
|
|
673
|
+
// Tracks whether the *core* is currently showing the line, which is what
|
|
674
|
+
// decides if the next data push is a hand-off. Distinct from `showLoadingLine`:
|
|
675
|
+
// that is this render's intent, this is what's on screen.
|
|
676
|
+
const lineUpRef = useRef(false);
|
|
677
|
+
|
|
396
678
|
// Stable deps so inline `theme={{...}}` / `rsi={{...}}` literals don't re-run
|
|
397
679
|
// the effect every render — only when the actual values change.
|
|
398
680
|
const themeKey = theme ? JSON.stringify(theme) : '';
|
|
399
681
|
const rsiKey = rsi ? JSON.stringify(rsi) : '';
|
|
400
682
|
const macdKey = macd ? JSON.stringify(macd) : '';
|
|
683
|
+
const atrKey = atr ? JSON.stringify(atr) : '';
|
|
401
684
|
const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
|
|
402
685
|
const vwapKey = vwap ? JSON.stringify(vwap) : '';
|
|
403
686
|
const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
|
|
687
|
+
const ichimokuKey = ichimoku ? JSON.stringify(ichimoku) : '';
|
|
688
|
+
const fvgKey = fairValueGaps ? JSON.stringify(fairValueGaps) : '';
|
|
404
689
|
const volumeKey = volume ? JSON.stringify(volume) : '';
|
|
405
690
|
const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
|
|
406
691
|
const footprintsKey = footprints ? JSON.stringify(footprints) : '';
|
|
@@ -409,6 +694,10 @@ export function useChartCore(
|
|
|
409
694
|
const h = handleRef.current;
|
|
410
695
|
if (!h) return;
|
|
411
696
|
h.setSize(size.width, size.height, size.pxRatio ?? 1);
|
|
697
|
+
// Ahead of setCandles, like setDefaultCandleWidth below: the default framing
|
|
698
|
+
// runs inside setCandles and reserves room past the newest candle for
|
|
699
|
+
// Ichimoku's leading spans, so it has to already know they're coming.
|
|
700
|
+
h.setIchimoku(ichimokuToSpec(ichimoku));
|
|
412
701
|
// Drive the initial zoom from a target candle width. Pushed once, before the
|
|
413
702
|
// first setCandles (while the core window is still 0/0), and only when the
|
|
414
703
|
// caller isn't explicitly controlling the range.
|
|
@@ -425,6 +714,31 @@ export function useChartCore(
|
|
|
425
714
|
// the viewport: a stream leaves it alone, a timeframe switch re-anchors and
|
|
426
715
|
// morphs into it, a different asset resets it.
|
|
427
716
|
let morphing = false;
|
|
717
|
+
|
|
718
|
+
if (showLoadingLine) {
|
|
719
|
+
// Clear the core's buffer, which the `candles.length > 0` gate below
|
|
720
|
+
// otherwise never does: pushing an empty array is treated as "hold the
|
|
721
|
+
// last frame" everywhere else, so a chart switching assets would still be
|
|
722
|
+
// holding the previous one's bars underneath the line — and would
|
|
723
|
+
// classify the incoming series as a timeframe switch rather than a fresh
|
|
724
|
+
// load. Scoped to the loading case so that hold-the-last-frame behavior
|
|
725
|
+
// is untouched for every other empty push.
|
|
726
|
+
if (!lineUpRef.current) {
|
|
727
|
+
endIntervalMorph();
|
|
728
|
+
settleStream();
|
|
729
|
+
h.setCandles(packCandles([]));
|
|
730
|
+
prevDataRef.current = null;
|
|
731
|
+
}
|
|
732
|
+
h.setLoading(true, !animRef.current.reduceMotion);
|
|
733
|
+
lineUpRef.current = true;
|
|
734
|
+
} else if (lineUpRef.current && candles.length === 0) {
|
|
735
|
+
// Loading resolved to nothing — an empty result, or an error the consumer
|
|
736
|
+
// handled. There's no geometry to morph into, so drop the line rather
|
|
737
|
+
// than leaving it waving at data that isn't coming.
|
|
738
|
+
h.setLoading(false, true);
|
|
739
|
+
lineUpRef.current = false;
|
|
740
|
+
}
|
|
741
|
+
|
|
428
742
|
if (candles.length > 0) {
|
|
429
743
|
const prev = prevDataRef.current;
|
|
430
744
|
const freshHandle = prev == null || prev.handle !== h;
|
|
@@ -447,6 +761,60 @@ export function useChartCore(
|
|
|
447
761
|
} | null = null;
|
|
448
762
|
// The pre-swap candle envelope, used to scale-lock the y-axis below.
|
|
449
763
|
let prevEnvelope: { low: number; high: number } | null = null;
|
|
764
|
+
// Set when this push is the loading line's hand-off to real data.
|
|
765
|
+
let handOff = false;
|
|
766
|
+
// Set for an animated live update: whether the last bar reshapes, and
|
|
767
|
+
// the window an appended bar should pull the view to.
|
|
768
|
+
let stream: { morph: boolean; window: VisibleRange | null } | null = null;
|
|
769
|
+
if (transitionKind === 'stream' && prev != null && !explicit) {
|
|
770
|
+
const { stream: mode, streamMs, reduceMotion } = animRef.current;
|
|
771
|
+
const stepMs = inferStepMs(candles);
|
|
772
|
+
if (
|
|
773
|
+
mode === 'transform' &&
|
|
774
|
+
streamMs > 0 &&
|
|
775
|
+
stepMs != null &&
|
|
776
|
+
!reduceMotion &&
|
|
777
|
+
onFrameRef.current != null
|
|
778
|
+
) {
|
|
779
|
+
const lastMs = candles[candles.length - 1].timeMs;
|
|
780
|
+
const prevLastMs = prev.candles[prev.candles.length - 1].timeMs;
|
|
781
|
+
if (classifyStream(prev.candles, candles) === 'append') {
|
|
782
|
+
// Pull the window along by exactly what the data advanced, so the
|
|
783
|
+
// series translates a whole slot and the newest bar holds its
|
|
784
|
+
// place on screen. Only for a view still following the newest bar
|
|
785
|
+
// — someone reading history keeps their window.
|
|
786
|
+
//
|
|
787
|
+
// No capture here: slots pair from the right edge, so the new bar
|
|
788
|
+
// would take the previous one's geometry and drag every candle
|
|
789
|
+
// onto its neighbour. Translating the window moves them by their
|
|
790
|
+
// own timestamps instead.
|
|
791
|
+
const w = h.getVisibleRange();
|
|
792
|
+
const prevStepMs = inferStepMs(prev.candles) ?? stepMs;
|
|
793
|
+
if (isPinnedToLatest(w, prevLastMs, prevStepMs)) {
|
|
794
|
+
const by = lastMs - prevLastMs;
|
|
795
|
+
stream = {
|
|
796
|
+
morph: false,
|
|
797
|
+
window: { startMs: w.startMs + by, endMs: w.endMs + by },
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
stream = { morph: true, window: null };
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (stream?.morph) {
|
|
805
|
+
// Keep the geometry on screen for beginStreamMorph to resume from:
|
|
806
|
+
// at any real tick rate most ticks interrupt the previous one, and
|
|
807
|
+
// that continuity is what keeps the bar from stuttering.
|
|
808
|
+
settleStream(true);
|
|
809
|
+
h.beginStreamMorph();
|
|
810
|
+
} else {
|
|
811
|
+
// An append has no use for a capture — it would pair the new bar
|
|
812
|
+
// with the old one's geometry and drag the whole series along.
|
|
813
|
+
settleStream();
|
|
814
|
+
}
|
|
815
|
+
} else if (transitionKind === 'stream') {
|
|
816
|
+
settleStream();
|
|
817
|
+
}
|
|
450
818
|
if (transitionKind === 'timeframe' && prev != null) {
|
|
451
819
|
const oldWindow = h.getVisibleRange();
|
|
452
820
|
const oldStepMs = inferStepMs(prev.candles);
|
|
@@ -475,6 +843,22 @@ export function useChartCore(
|
|
|
475
843
|
endIntervalMorph();
|
|
476
844
|
}
|
|
477
845
|
|
|
846
|
+
// The loading line's data has landed, so it hands over to the series
|
|
847
|
+
// instead of the chart cutting to it. Always classified 'initial' (the
|
|
848
|
+
// loading branch above cleared prevDataRef), so this runs after that
|
|
849
|
+
// branch's endIntervalMorph.
|
|
850
|
+
if (lineUpRef.current) {
|
|
851
|
+
lineUpRef.current = false;
|
|
852
|
+
handOff =
|
|
853
|
+
animRef.current.ms > 0 &&
|
|
854
|
+
!animRef.current.reduceMotion &&
|
|
855
|
+
onFrameRef.current != null;
|
|
856
|
+
// Snap path only. The hand-off itself starts after setCandles and the
|
|
857
|
+
// framing below: both its stages aim at where the candles will
|
|
858
|
+
// actually sit, so neither can be set up until they're there.
|
|
859
|
+
if (!handOff) h.setLoading(false, true);
|
|
860
|
+
}
|
|
861
|
+
|
|
478
862
|
h.setCandles(packCandles(candles));
|
|
479
863
|
|
|
480
864
|
if (transitionKind === 'timeframe') {
|
|
@@ -499,9 +883,16 @@ export function useChartCore(
|
|
|
499
883
|
// Started after the new bounds are in place: the snapshot is in band
|
|
500
884
|
// fractions, so frame 0 still matches the pre-switch pixels exactly.
|
|
501
885
|
if (morphing) startIntervalMorph(h);
|
|
886
|
+
} else if (stream) {
|
|
887
|
+
// After setCandles, so the capture (and the window it slides from) is
|
|
888
|
+
// measured against the data the animation is heading toward.
|
|
889
|
+
startStreamAnim(h, stream.morph, stream.window);
|
|
502
890
|
} else if (transitionKind === 'reset') {
|
|
503
891
|
h.resetView();
|
|
504
892
|
}
|
|
893
|
+
// After setCandles and the framing above, so the line aims at — and the
|
|
894
|
+
// candles grow from — the geometry each bar will actually occupy.
|
|
895
|
+
if (handOff) startLoadingHandoff(h);
|
|
505
896
|
prevDataRef.current = { handle: h, candles, seriesKey };
|
|
506
897
|
}
|
|
507
898
|
}
|
|
@@ -522,9 +913,11 @@ export function useChartCore(
|
|
|
522
913
|
}
|
|
523
914
|
h.setRSI(rsiToSpec(rsi));
|
|
524
915
|
h.setMACD(macdToSpec(macd));
|
|
916
|
+
h.setATR(atrToSpec(atr));
|
|
525
917
|
h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
|
|
526
918
|
h.setVWAP(vwapToSpec(vwap));
|
|
527
919
|
h.setBollinger(bollingerToSpec(bollingerBands));
|
|
920
|
+
h.setFairValueGaps(fvgToSpec(fairValueGaps));
|
|
528
921
|
h.setVolume(volumeToSpec(volume));
|
|
529
922
|
// setVolume snaps the collapse scalar to its `enabled`, which would cut a
|
|
530
923
|
// toggle animation short whenever this effect re-runs (a streaming candle, a
|
|
@@ -545,10 +938,11 @@ export function useChartCore(
|
|
|
545
938
|
// frame 0 is pixel-identical to what's on screen, so there's nothing to show
|
|
546
939
|
// in the meantime anyway.
|
|
547
940
|
if (!morphing) setPicture(h.render());
|
|
548
|
-
// theme/rsi/macd/movingAverages/vwap/bollingerBands/
|
|
549
|
-
// footprints are represented by their *Key
|
|
941
|
+
// theme/rsi/macd/atr/movingAverages/vwap/bollingerBands/ichimoku/
|
|
942
|
+
// fairValueGaps/volume/priceLines/footprints are represented by their *Key
|
|
943
|
+
// deps.
|
|
550
944
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
551
|
-
}, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, endIntervalMorph]);
|
|
945
|
+
}, [candles, showLoadingLine, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, atrKey, maKey, vwapKey, bollingerKey, ichimokuKey, fvgKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, startLoadingHandoff, endIntervalMorph, startStreamAnim, settleStream]);
|
|
552
946
|
|
|
553
947
|
return { handle: handleRef.current, picture, volumeCollapseRef };
|
|
554
948
|
}
|