react-native-vroom-chart 0.14.0 → 0.16.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 +289 -2
- package/cpp/_core_include/vroom/vroom_chart.h +221 -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/chart.cpp +477 -172
- package/cpp/_core_src/chart.h +173 -1
- package/cpp/_core_src/chart_facade.cpp +317 -23
- package/cpp/_core_src/fair_value_gaps.cpp +76 -0
- package/cpp/_core_src/fair_value_gaps.h +54 -0
- package/cpp/_core_src/footprints.cpp +226 -0
- package/cpp/_core_src/footprints.h +45 -0
- package/cpp/_core_src/footprints_layout.cpp +140 -0
- package/cpp/_core_src/footprints_layout.h +94 -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/ma_overlay.cpp +259 -36
- package/cpp/_core_src/ma_overlay.h +57 -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/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/tip_pulse.h +4 -4
- package/cpp/_core_src/viewport.h +35 -0
- package/lib/index.d.mts +385 -3
- package/lib/index.d.ts +385 -3
- package/lib/index.js +305 -7
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +305 -7
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +108 -7
- package/src/dataTransitions.ts +47 -0
- package/src/index.ts +10 -0
- package/src/jsi.d.ts +136 -1
- package/src/types.ts +10 -0
- package/src/useChartCore.ts +330 -5
package/src/useChartCore.ts
CHANGED
|
@@ -3,22 +3,34 @@ 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,
|
|
18
27
|
PriceLinesStyle,
|
|
28
|
+
Footprint,
|
|
29
|
+
FootprintsStyle,
|
|
19
30
|
RSIConfig,
|
|
20
31
|
TransitionEasing,
|
|
21
32
|
IntervalTransition,
|
|
33
|
+
StreamTransition,
|
|
22
34
|
VisibleRange,
|
|
23
35
|
VolumeConfig,
|
|
24
36
|
VroomTheme,
|
|
@@ -36,6 +48,9 @@ const MA_SOURCES = [
|
|
|
36
48
|
'ohlc4',
|
|
37
49
|
] as const;
|
|
38
50
|
|
|
51
|
+
// Mirrors vroom::atr::Smoothing order in packages/core/src/atr.h.
|
|
52
|
+
const ATR_SMOOTHINGS = ['rma', 'sma', 'ema'] as const;
|
|
53
|
+
|
|
39
54
|
// An unset style color marshals as the core's transparent inherit sentinel.
|
|
40
55
|
const inheritColor = (v: string | number | undefined): number =>
|
|
41
56
|
(v != null ? parseColor(v) : null) ?? 0;
|
|
@@ -67,6 +82,7 @@ function rsiToSpec(cfg: RSIConfig | undefined) {
|
|
|
67
82
|
maWidth: cfg?.maWidth ?? -1,
|
|
68
83
|
bandColor: inheritColor(cfg?.bandColor),
|
|
69
84
|
bandsVisible: cfg?.bandsVisible ?? true,
|
|
85
|
+
extremeFill: cfg?.extremeFill ?? true,
|
|
70
86
|
};
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -108,6 +124,89 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
|
|
|
108
124
|
};
|
|
109
125
|
}
|
|
110
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
|
+
|
|
111
210
|
function macdToSpec(cfg: MACDConfig | undefined) {
|
|
112
211
|
const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
|
|
113
212
|
return {
|
|
@@ -134,6 +233,16 @@ function macdToSpec(cfg: MACDConfig | undefined) {
|
|
|
134
233
|
};
|
|
135
234
|
}
|
|
136
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
|
+
|
|
137
246
|
// Unset style fields go down as the core's inherit sentinels (negative float,
|
|
138
247
|
// transparent color) rather than as literal defaults, so the theme keys stay in
|
|
139
248
|
// charge of anything the consumer didn't set.
|
|
@@ -203,6 +312,45 @@ function priceLinesToSpec(cfg: PriceLinesProp) {
|
|
|
203
312
|
// requires them).
|
|
204
313
|
const EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
|
|
205
314
|
|
|
315
|
+
// Footprints share the price lines' hover weight so the two widgets light up
|
|
316
|
+
// alike. Zeroed geometry defers to the core's own defaults.
|
|
317
|
+
const DEFAULT_FOOTPRINT_HOVER_BOOST = 1.25;
|
|
318
|
+
|
|
319
|
+
// Mirrors VroomFootprintSide in packages/core/include/vroom/vroom_chart.h.
|
|
320
|
+
const FOOTPRINT_BUY = 0;
|
|
321
|
+
const FOOTPRINT_SELL = 1;
|
|
322
|
+
|
|
323
|
+
// A time no real series can contain (~273,000 BCE), still comfortably inside
|
|
324
|
+
// int64. Parks a malformed footprint where the core will never bucket it.
|
|
325
|
+
const UNBUCKETABLE_MS = -8.64e15;
|
|
326
|
+
|
|
327
|
+
/** The footprints + their shared style, as the chart's props express them. */
|
|
328
|
+
export type FootprintsProp = {
|
|
329
|
+
prints: Footprint[];
|
|
330
|
+
style?: FootprintsStyle;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function footprintsToSpec(cfg: FootprintsProp) {
|
|
334
|
+
return {
|
|
335
|
+
// Index alignment is load-bearing: the core reports hits as indices into this
|
|
336
|
+
// array and the gesture layer maps them straight back to the consumer's
|
|
337
|
+
// `footprints`. So a non-finite time — which can't be bucketed and would
|
|
338
|
+
// reach the native side as a garbage int64 — is neutralized *in place* rather
|
|
339
|
+
// than filtered out, which would shift every index after it onto the wrong
|
|
340
|
+
// trade.
|
|
341
|
+
prints: cfg.prints.map((f) => ({
|
|
342
|
+
timeMs: Number.isFinite(f.timeMs) ? f.timeMs : UNBUCKETABLE_MS,
|
|
343
|
+
side: f.side === 'sell' ? FOOTPRINT_SELL : FOOTPRINT_BUY,
|
|
344
|
+
})),
|
|
345
|
+
radiusPx: cfg.style?.radius ?? 0,
|
|
346
|
+
gapPx: cfg.style?.gap ?? 0,
|
|
347
|
+
marginPx: cfg.style?.margin ?? 0,
|
|
348
|
+
hoverBoost: cfg.style?.hoverBoost ?? DEFAULT_FOOTPRINT_HOVER_BOOST,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const EMPTY_FOOTPRINTS = footprintsToSpec({ prints: [] });
|
|
353
|
+
|
|
206
354
|
let installed = false;
|
|
207
355
|
function ensureInstalled(): void {
|
|
208
356
|
if (installed) return;
|
|
@@ -232,6 +380,10 @@ export type TransitionOptions = {
|
|
|
232
380
|
transitionEasing?: TransitionEasing;
|
|
233
381
|
/** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
|
|
234
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;
|
|
235
387
|
/** OS reduced-motion preference: skips the capture and snaps. */
|
|
236
388
|
reduceMotion?: boolean;
|
|
237
389
|
/** Receives every morph frame. Without one, data swaps snap. */
|
|
@@ -263,11 +415,15 @@ export function useChartCore(
|
|
|
263
415
|
theme?: VroomTheme,
|
|
264
416
|
rsi?: RSIConfig,
|
|
265
417
|
macd?: MACDConfig,
|
|
418
|
+
atr?: ATRConfig,
|
|
266
419
|
movingAverages?: MovingAverageOverlay[],
|
|
267
420
|
vwap?: VWAPConfig,
|
|
268
421
|
bollingerBands?: BollingerBandsConfig,
|
|
422
|
+
ichimoku?: IchimokuConfig,
|
|
423
|
+
fairValueGaps?: FairValueGapsConfig,
|
|
269
424
|
volume?: VolumeConfig,
|
|
270
425
|
priceLines?: PriceLinesProp,
|
|
426
|
+
footprints?: FootprintsProp,
|
|
271
427
|
transition?: TransitionOptions,
|
|
272
428
|
): ChartCoreState {
|
|
273
429
|
const handleRef = useRef<ChartHandle | null>(null);
|
|
@@ -284,6 +440,13 @@ export function useChartCore(
|
|
|
284
440
|
seriesKey?: string;
|
|
285
441
|
} | null>(null);
|
|
286
442
|
const intervalMorphRaf = useRef<number | null>(null);
|
|
443
|
+
const streamRaf = useRef<number | null>(null);
|
|
444
|
+
// Where an in-flight stream shift is headed, so cancelling it can land there
|
|
445
|
+
// rather than stranding the view mid-slide.
|
|
446
|
+
const streamWindowRef = useRef<VisibleRange | null>(null);
|
|
447
|
+
// Whether that loop is the one driving the morph scalar, so settling it never
|
|
448
|
+
// cuts short a timeframe switch that happens to overlap.
|
|
449
|
+
const streamMorphRef = useRef(false);
|
|
287
450
|
const [picture, setPicture] = useState<ChartFrame | null>(null);
|
|
288
451
|
|
|
289
452
|
if (!handleRef.current && size.width > 0 && size.height > 0) {
|
|
@@ -299,12 +462,25 @@ export function useChartCore(
|
|
|
299
462
|
easing: TransitionEasing | undefined;
|
|
300
463
|
reduceMotion: boolean;
|
|
301
464
|
interval: IntervalTransition;
|
|
302
|
-
|
|
465
|
+
stream: StreamTransition;
|
|
466
|
+
streamMs: number;
|
|
467
|
+
}>({
|
|
468
|
+
ms: 300,
|
|
469
|
+
easing: undefined,
|
|
470
|
+
reduceMotion: false,
|
|
471
|
+
interval: 'transform',
|
|
472
|
+
stream: 'none',
|
|
473
|
+
streamMs: 150,
|
|
474
|
+
});
|
|
303
475
|
animRef.current = {
|
|
304
476
|
ms: Math.max(0, transition?.transitionMs ?? 300),
|
|
305
477
|
easing: transition?.transitionEasing,
|
|
306
478
|
reduceMotion: transition?.reduceMotion ?? false,
|
|
307
479
|
interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
|
|
480
|
+
stream: transition?.streamTransition === 'transform' ? 'transform' : 'none',
|
|
481
|
+
// Shorter than transitionMs by default: ticks can land faster than a 300ms
|
|
482
|
+
// curve, and every one that does interrupts the last.
|
|
483
|
+
streamMs: Math.max(0, transition?.streamTransitionMs ?? 150),
|
|
308
484
|
};
|
|
309
485
|
const onFrameRef = useRef(transition?.onFrame);
|
|
310
486
|
onFrameRef.current = transition?.onFrame;
|
|
@@ -334,12 +510,91 @@ export function useChartCore(
|
|
|
334
510
|
intervalMorphRaf.current = requestAnimationFrame(step);
|
|
335
511
|
}, []);
|
|
336
512
|
|
|
513
|
+
// Stops an in-flight stream animation and puts the chart somewhere coherent.
|
|
514
|
+
//
|
|
515
|
+
// A pending window shift always lands on its target: abandoned mid-slide it
|
|
516
|
+
// would strand the view between two bars, half a candle off the grid.
|
|
517
|
+
//
|
|
518
|
+
// `keepMorph` is for a tick restarting on top of one already running —
|
|
519
|
+
// beginStreamMorph blends out of the geometry currently on screen, so landing
|
|
520
|
+
// that geometry first would throw away the very thing it resumes from.
|
|
521
|
+
const settleStream = useCallback((keepMorph = false) => {
|
|
522
|
+
if (streamRaf.current != null) {
|
|
523
|
+
cancelAnimationFrame(streamRaf.current);
|
|
524
|
+
streamRaf.current = null;
|
|
525
|
+
}
|
|
526
|
+
const h = handleRef.current;
|
|
527
|
+
const target = streamWindowRef.current;
|
|
528
|
+
streamWindowRef.current = null;
|
|
529
|
+
if (target) h?.setVisibleRange(target.startMs, target.endMs);
|
|
530
|
+
if (streamMorphRef.current && !keepMorph) {
|
|
531
|
+
streamMorphRef.current = false;
|
|
532
|
+
h?.setIntervalMorph(1);
|
|
533
|
+
}
|
|
534
|
+
}, []);
|
|
535
|
+
|
|
536
|
+
// Runs the clock for a live update. One loop drives both halves so they land
|
|
537
|
+
// on the same frame.
|
|
538
|
+
//
|
|
539
|
+
// `window` is null for a plain tick; for an append it is where the view has to
|
|
540
|
+
// end up. The slide is measured from wherever the window is *now*, so a shift
|
|
541
|
+
// interrupting another continues from the current position instead of
|
|
542
|
+
// snapping back to the start of the last one.
|
|
543
|
+
const startStreamAnim = useCallback(
|
|
544
|
+
(h: ChartHandle, morphing: boolean, window: VisibleRange | null) => {
|
|
545
|
+
const { streamMs, easing } = animRef.current;
|
|
546
|
+
let from = window ? h.getVisibleRange() : null;
|
|
547
|
+
// What the previous frame left the window at. Anything else — a pan, a
|
|
548
|
+
// pinch — lands somewhere different, which is how the slide notices it is
|
|
549
|
+
// no longer the only thing moving the view and gets out of the way.
|
|
550
|
+
// Cheaper than teaching every gesture to cancel it, and it can't miss one.
|
|
551
|
+
let applied: VisibleRange | null = null;
|
|
552
|
+
streamWindowRef.current = window;
|
|
553
|
+
streamMorphRef.current = morphing;
|
|
554
|
+
const start = performance.now();
|
|
555
|
+
const step = (now: number) => {
|
|
556
|
+
if (from && applied) {
|
|
557
|
+
const now_w = h.getVisibleRange();
|
|
558
|
+
if (now_w.startMs !== applied.startMs || now_w.endMs !== applied.endMs) {
|
|
559
|
+
from = null;
|
|
560
|
+
streamWindowRef.current = null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const p = Math.min(1, (now - start) / streamMs);
|
|
564
|
+
const e = p < 1 ? ease(easing, p) : 1;
|
|
565
|
+
if (morphing) h.setIntervalMorph(e);
|
|
566
|
+
if (from && window) {
|
|
567
|
+
applied = {
|
|
568
|
+
startMs: Math.round(from.startMs + (window.startMs - from.startMs) * e),
|
|
569
|
+
endMs: Math.round(from.endMs + (window.endMs - from.endMs) * e),
|
|
570
|
+
};
|
|
571
|
+
h.setVisibleRange(applied.startMs, applied.endMs);
|
|
572
|
+
}
|
|
573
|
+
const pic = h.render();
|
|
574
|
+
if (pic) onFrameRef.current?.(pic);
|
|
575
|
+
if (p < 1) {
|
|
576
|
+
streamRaf.current = requestAnimationFrame(step);
|
|
577
|
+
} else {
|
|
578
|
+
streamRaf.current = null;
|
|
579
|
+
streamWindowRef.current = null;
|
|
580
|
+
streamMorphRef.current = false;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
streamRaf.current = requestAnimationFrame(step);
|
|
584
|
+
},
|
|
585
|
+
[],
|
|
586
|
+
);
|
|
587
|
+
|
|
337
588
|
useEffect(() => {
|
|
338
589
|
return () => {
|
|
339
590
|
if (intervalMorphRaf.current != null) {
|
|
340
591
|
cancelAnimationFrame(intervalMorphRaf.current);
|
|
341
592
|
intervalMorphRaf.current = null;
|
|
342
593
|
}
|
|
594
|
+
if (streamRaf.current != null) {
|
|
595
|
+
cancelAnimationFrame(streamRaf.current);
|
|
596
|
+
streamRaf.current = null;
|
|
597
|
+
}
|
|
343
598
|
};
|
|
344
599
|
}, []);
|
|
345
600
|
|
|
@@ -356,16 +611,24 @@ export function useChartCore(
|
|
|
356
611
|
const themeKey = theme ? JSON.stringify(theme) : '';
|
|
357
612
|
const rsiKey = rsi ? JSON.stringify(rsi) : '';
|
|
358
613
|
const macdKey = macd ? JSON.stringify(macd) : '';
|
|
614
|
+
const atrKey = atr ? JSON.stringify(atr) : '';
|
|
359
615
|
const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
|
|
360
616
|
const vwapKey = vwap ? JSON.stringify(vwap) : '';
|
|
361
617
|
const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
|
|
618
|
+
const ichimokuKey = ichimoku ? JSON.stringify(ichimoku) : '';
|
|
619
|
+
const fvgKey = fairValueGaps ? JSON.stringify(fairValueGaps) : '';
|
|
362
620
|
const volumeKey = volume ? JSON.stringify(volume) : '';
|
|
363
621
|
const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
|
|
622
|
+
const footprintsKey = footprints ? JSON.stringify(footprints) : '';
|
|
364
623
|
|
|
365
624
|
useEffect(() => {
|
|
366
625
|
const h = handleRef.current;
|
|
367
626
|
if (!h) return;
|
|
368
627
|
h.setSize(size.width, size.height, size.pxRatio ?? 1);
|
|
628
|
+
// Ahead of setCandles, like setDefaultCandleWidth below: the default framing
|
|
629
|
+
// runs inside setCandles and reserves room past the newest candle for
|
|
630
|
+
// Ichimoku's leading spans, so it has to already know they're coming.
|
|
631
|
+
h.setIchimoku(ichimokuToSpec(ichimoku));
|
|
369
632
|
// Drive the initial zoom from a target candle width. Pushed once, before the
|
|
370
633
|
// first setCandles (while the core window is still 0/0), and only when the
|
|
371
634
|
// caller isn't explicitly controlling the range.
|
|
@@ -404,6 +667,58 @@ export function useChartCore(
|
|
|
404
667
|
} | null = null;
|
|
405
668
|
// The pre-swap candle envelope, used to scale-lock the y-axis below.
|
|
406
669
|
let prevEnvelope: { low: number; high: number } | null = null;
|
|
670
|
+
// Set for an animated live update: whether the last bar reshapes, and
|
|
671
|
+
// the window an appended bar should pull the view to.
|
|
672
|
+
let stream: { morph: boolean; window: VisibleRange | null } | null = null;
|
|
673
|
+
if (transitionKind === 'stream' && prev != null && !explicit) {
|
|
674
|
+
const { stream: mode, streamMs, reduceMotion } = animRef.current;
|
|
675
|
+
const stepMs = inferStepMs(candles);
|
|
676
|
+
if (
|
|
677
|
+
mode === 'transform' &&
|
|
678
|
+
streamMs > 0 &&
|
|
679
|
+
stepMs != null &&
|
|
680
|
+
!reduceMotion &&
|
|
681
|
+
onFrameRef.current != null
|
|
682
|
+
) {
|
|
683
|
+
const lastMs = candles[candles.length - 1].timeMs;
|
|
684
|
+
const prevLastMs = prev.candles[prev.candles.length - 1].timeMs;
|
|
685
|
+
if (classifyStream(prev.candles, candles) === 'append') {
|
|
686
|
+
// Pull the window along by exactly what the data advanced, so the
|
|
687
|
+
// series translates a whole slot and the newest bar holds its
|
|
688
|
+
// place on screen. Only for a view still following the newest bar
|
|
689
|
+
// — someone reading history keeps their window.
|
|
690
|
+
//
|
|
691
|
+
// No capture here: slots pair from the right edge, so the new bar
|
|
692
|
+
// would take the previous one's geometry and drag every candle
|
|
693
|
+
// onto its neighbour. Translating the window moves them by their
|
|
694
|
+
// own timestamps instead.
|
|
695
|
+
const w = h.getVisibleRange();
|
|
696
|
+
const prevStepMs = inferStepMs(prev.candles) ?? stepMs;
|
|
697
|
+
if (isPinnedToLatest(w, prevLastMs, prevStepMs)) {
|
|
698
|
+
const by = lastMs - prevLastMs;
|
|
699
|
+
stream = {
|
|
700
|
+
morph: false,
|
|
701
|
+
window: { startMs: w.startMs + by, endMs: w.endMs + by },
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
stream = { morph: true, window: null };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (stream?.morph) {
|
|
709
|
+
// Keep the geometry on screen for beginStreamMorph to resume from:
|
|
710
|
+
// at any real tick rate most ticks interrupt the previous one, and
|
|
711
|
+
// that continuity is what keeps the bar from stuttering.
|
|
712
|
+
settleStream(true);
|
|
713
|
+
h.beginStreamMorph();
|
|
714
|
+
} else {
|
|
715
|
+
// An append has no use for a capture — it would pair the new bar
|
|
716
|
+
// with the old one's geometry and drag the whole series along.
|
|
717
|
+
settleStream();
|
|
718
|
+
}
|
|
719
|
+
} else if (transitionKind === 'stream') {
|
|
720
|
+
settleStream();
|
|
721
|
+
}
|
|
407
722
|
if (transitionKind === 'timeframe' && prev != null) {
|
|
408
723
|
const oldWindow = h.getVisibleRange();
|
|
409
724
|
const oldStepMs = inferStepMs(prev.candles);
|
|
@@ -456,6 +771,10 @@ export function useChartCore(
|
|
|
456
771
|
// Started after the new bounds are in place: the snapshot is in band
|
|
457
772
|
// fractions, so frame 0 still matches the pre-switch pixels exactly.
|
|
458
773
|
if (morphing) startIntervalMorph(h);
|
|
774
|
+
} else if (stream) {
|
|
775
|
+
// After setCandles, so the capture (and the window it slides from) is
|
|
776
|
+
// measured against the data the animation is heading toward.
|
|
777
|
+
startStreamAnim(h, stream.morph, stream.window);
|
|
459
778
|
} else if (transitionKind === 'reset') {
|
|
460
779
|
h.resetView();
|
|
461
780
|
}
|
|
@@ -479,9 +798,11 @@ export function useChartCore(
|
|
|
479
798
|
}
|
|
480
799
|
h.setRSI(rsiToSpec(rsi));
|
|
481
800
|
h.setMACD(macdToSpec(macd));
|
|
801
|
+
h.setATR(atrToSpec(atr));
|
|
482
802
|
h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
|
|
483
803
|
h.setVWAP(vwapToSpec(vwap));
|
|
484
804
|
h.setBollinger(bollingerToSpec(bollingerBands));
|
|
805
|
+
h.setFairValueGaps(fvgToSpec(fairValueGaps));
|
|
485
806
|
h.setVolume(volumeToSpec(volume));
|
|
486
807
|
// setVolume snaps the collapse scalar to its `enabled`, which would cut a
|
|
487
808
|
// toggle animation short whenever this effect re-runs (a streaming candle, a
|
|
@@ -492,6 +813,9 @@ export function useChartCore(
|
|
|
492
813
|
h.setPriceLines(
|
|
493
814
|
priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
|
|
494
815
|
);
|
|
816
|
+
h.setFootprints(
|
|
817
|
+
footprints?.prints.length ? footprintsToSpec(footprints) : EMPTY_FOOTPRINTS,
|
|
818
|
+
);
|
|
495
819
|
// TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
|
|
496
820
|
// the VroomBand structs in the JSI handle) — web-only for now.
|
|
497
821
|
// A just-started morph is already pushing frames straight to the host sink;
|
|
@@ -499,10 +823,11 @@ export function useChartCore(
|
|
|
499
823
|
// frame 0 is pixel-identical to what's on screen, so there's nothing to show
|
|
500
824
|
// in the meantime anyway.
|
|
501
825
|
if (!morphing) setPicture(h.render());
|
|
502
|
-
// theme/rsi/macd/movingAverages/vwap/bollingerBands/
|
|
503
|
-
// represented by their *Key
|
|
826
|
+
// theme/rsi/macd/atr/movingAverages/vwap/bollingerBands/ichimoku/
|
|
827
|
+
// fairValueGaps/volume/priceLines/footprints are represented by their *Key
|
|
828
|
+
// deps.
|
|
504
829
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
505
|
-
}, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
|
|
830
|
+
}, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, atrKey, maKey, vwapKey, bollingerKey, ichimokuKey, fvgKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, endIntervalMorph, startStreamAnim, settleStream]);
|
|
506
831
|
|
|
507
832
|
return { handle: handleRef.current, picture, volumeCollapseRef };
|
|
508
833
|
}
|