react-native-vroom-chart 0.7.0 → 0.8.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 +131 -1
- package/cpp/_core_include/vroom/vroom_chart.h +51 -14
- package/cpp/_core_src/chart.h +14 -9
- package/cpp/_core_src/chart_facade.cpp +41 -3
- package/cpp/_core_src/drawings.cpp +147 -4
- package/cpp/_core_src/drawings.h +10 -5
- package/lib/index.d.mts +46 -4
- package/lib/index.d.ts +46 -4
- package/lib/index.js +196 -41
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +201 -49
- package/lib/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/VroomChart.tsx +33 -16
- package/src/dataTransitions.ts +148 -0
- package/src/index.ts +6 -0
- package/src/jsi.d.ts +51 -0
- package/src/useChartCore.ts +171 -4
package/src/index.ts
CHANGED
package/src/jsi.d.ts
CHANGED
|
@@ -27,6 +27,57 @@ export interface ChartHandle {
|
|
|
27
27
|
setChartType(mode: number): void;
|
|
28
28
|
/** Candle↔line morph blend: collapse folds candles to close, fade crossfades. */
|
|
29
29
|
setMorph(collapse: number, fade: number): void;
|
|
30
|
+
/** The current visible time window. {startMs: 0, endMs: 0} = uninitialized. */
|
|
31
|
+
getVisibleRange(): { startMs: number; endMs: number };
|
|
32
|
+
/**
|
|
33
|
+
* Reset to the fresh-mount view: frame the most recent ~80 candles and
|
|
34
|
+
* re-enable continuous y auto-fit (the price range follows the visible
|
|
35
|
+
* candles until the next manual y gesture). Use when the data series is
|
|
36
|
+
* wholesale replaced — e.g. switching assets.
|
|
37
|
+
*/
|
|
38
|
+
resetView(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Re-enable continuous y auto-fit only; the time window is untouched. Use
|
|
41
|
+
* after repositioning the window for a same-asset data swap (e.g. a
|
|
42
|
+
* timeframe switch) so the price scale re-fits the newly visible candles.
|
|
43
|
+
*/
|
|
44
|
+
resetPriceScale(): void;
|
|
45
|
+
/**
|
|
46
|
+
* The visible price *envelope* — the min low / max high across the currently
|
|
47
|
+
* visible candles, i.e. the extent the candles occupy rather than the (wider)
|
|
48
|
+
* axis range. Null when no candles are visible.
|
|
49
|
+
*/
|
|
50
|
+
getVisiblePriceEnvelope(): { low: number; high: number } | null;
|
|
51
|
+
/**
|
|
52
|
+
* Scale lock for a same-asset data swap that re-buckets the same price action
|
|
53
|
+
* into a different high-low span (a timeframe switch). Rescales a *manual*
|
|
54
|
+
* price range so the visible envelope keeps the exact pixel height and
|
|
55
|
+
* position the given pre-swap envelope had — so candles don't suddenly shrink
|
|
56
|
+
* or grow when the interval changes.
|
|
57
|
+
*
|
|
58
|
+
* Call after setCandles + setVisibleRange, passing the envelope read by
|
|
59
|
+
* getVisiblePriceEnvelope before the swap. A no-op in auto-y mode (auto-fit is
|
|
60
|
+
* already span-invariant); falls back to resetPriceScale when either envelope
|
|
61
|
+
* is degenerate.
|
|
62
|
+
*/
|
|
63
|
+
preservePriceEnvelope(prevLow: number, prevHigh: number): void;
|
|
64
|
+
/**
|
|
65
|
+
* Capture the visible candle geometry so the next data swap can animate as a
|
|
66
|
+
* reshape rather than a jump: each candle's wick and body slide and stretch
|
|
67
|
+
* into the shape of its counterpart in the new data.
|
|
68
|
+
*
|
|
69
|
+
* Candles are paired by *slot* — position counting back from the right edge of
|
|
70
|
+
* the visible window, which a timeframe switch preserves. Call before
|
|
71
|
+
* setCandles, then drive setIntervalMorph from 0 to 1.
|
|
72
|
+
*/
|
|
73
|
+
beginIntervalMorph(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Advance the interval morph started by beginIntervalMorph. `t` (clamped to
|
|
76
|
+
* 0..1) is the eased progress: 0 renders the captured geometry pixel-
|
|
77
|
+
* identically to the pre-swap frame, 1 renders the new candles and releases
|
|
78
|
+
* the capture. Driven per-frame by the host animation loop.
|
|
79
|
+
*/
|
|
80
|
+
setIntervalMorph(t: number): void;
|
|
30
81
|
/** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
|
|
31
82
|
pan(dx: number, dy: number): SkPicture | null;
|
|
32
83
|
/**
|
package/src/useChartCore.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { MutableRefObject } from 'react';
|
|
3
3
|
import type { SkPicture } from '@shopify/react-native-skia';
|
|
4
4
|
|
|
5
5
|
import NativeVroomChart from './NativeVroomChart';
|
|
6
|
+
import type { DataTransition } from './dataTransitions';
|
|
7
|
+
import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
|
|
8
|
+
import { ease } from './easing';
|
|
6
9
|
import type { ChartHandle } from './jsi.d';
|
|
7
10
|
import { packCandles } from './packCandles';
|
|
8
11
|
import { applyTheme, parseColor } from './theme';
|
|
@@ -15,6 +18,7 @@ import type {
|
|
|
15
18
|
PriceLine,
|
|
16
19
|
PriceLinesStyle,
|
|
17
20
|
RSIConfig,
|
|
21
|
+
TransitionEasing,
|
|
18
22
|
VisibleRange,
|
|
19
23
|
VolumeConfig,
|
|
20
24
|
VroomTheme,
|
|
@@ -213,6 +217,25 @@ function ensureInstalled(): void {
|
|
|
213
217
|
/** Progress + curve of the staggered volume-bar collapse. See setVolumeCollapse. */
|
|
214
218
|
export type VolumeCollapse = { t: number; easing: number };
|
|
215
219
|
|
|
220
|
+
/**
|
|
221
|
+
* How a data swap should animate, plus where its frames go. The interval morph
|
|
222
|
+
* has to be started from inside the data effect (it needs the pre-swap capture),
|
|
223
|
+
* but it repaints at 60fps — far too often for React state — so the host passes
|
|
224
|
+
* a sink that writes straight into the picture SharedValue.
|
|
225
|
+
*/
|
|
226
|
+
export type TransitionOptions = {
|
|
227
|
+
/** Identity of the series; a change forces a full view reset. */
|
|
228
|
+
seriesKey?: string;
|
|
229
|
+
/** Duration of the interval morph in ms. 0 snaps. Default 300. */
|
|
230
|
+
transitionMs?: number;
|
|
231
|
+
/** Curve applied to the morph's progress. Default 'ease-in-out'. */
|
|
232
|
+
transitionEasing?: TransitionEasing;
|
|
233
|
+
/** OS reduced-motion preference: skips the capture and snaps. */
|
|
234
|
+
reduceMotion?: boolean;
|
|
235
|
+
/** Receives every morph frame. Without one, data swaps snap. */
|
|
236
|
+
onFrame?: (picture: SkPicture) => void;
|
|
237
|
+
};
|
|
238
|
+
|
|
216
239
|
export type ChartCoreState = {
|
|
217
240
|
handle: ChartHandle | null;
|
|
218
241
|
/** Picture freshly rendered after the latest data/size/range push. */
|
|
@@ -243,6 +266,7 @@ export function useChartCore(
|
|
|
243
266
|
bollingerBands?: BollingerBandsConfig,
|
|
244
267
|
volume?: VolumeConfig,
|
|
245
268
|
priceLines?: PriceLinesProp,
|
|
269
|
+
transition?: TransitionOptions,
|
|
246
270
|
): ChartCoreState {
|
|
247
271
|
const handleRef = useRef<ChartHandle | null>(null);
|
|
248
272
|
// Push setDefaultCandleWidth only once (first load): setCandles re-runs on
|
|
@@ -250,6 +274,14 @@ export function useChartCore(
|
|
|
250
274
|
// so re-pushing would snap the view away from the user's pan/zoom.
|
|
251
275
|
const defaultWidthAppliedRef = useRef(false);
|
|
252
276
|
const volumeCollapseRef = useRef<VolumeCollapse | null>(null);
|
|
277
|
+
// What the core currently holds, for classifying the next data change. Keyed
|
|
278
|
+
// by handle so a recreated core is treated as a fresh initial load.
|
|
279
|
+
const prevDataRef = useRef<{
|
|
280
|
+
handle: ChartHandle;
|
|
281
|
+
candles: Candle[];
|
|
282
|
+
seriesKey?: string;
|
|
283
|
+
} | null>(null);
|
|
284
|
+
const intervalMorphRaf = useRef<number | null>(null);
|
|
253
285
|
const [picture, setPicture] = useState<SkPicture | null>(null);
|
|
254
286
|
|
|
255
287
|
if (!handleRef.current && size.width > 0 && size.height > 0) {
|
|
@@ -257,6 +289,56 @@ export function useChartCore(
|
|
|
257
289
|
handleRef.current = globalThis.VroomChartJSI!.create();
|
|
258
290
|
}
|
|
259
291
|
|
|
292
|
+
// Animation config and frame sink in refs, refreshed each render, so changing
|
|
293
|
+
// the duration, curve or callback identity doesn't re-run the data effect
|
|
294
|
+
// below (which would re-push every candle).
|
|
295
|
+
const animRef = useRef<{
|
|
296
|
+
ms: number;
|
|
297
|
+
easing: TransitionEasing | undefined;
|
|
298
|
+
reduceMotion: boolean;
|
|
299
|
+
}>({ ms: 300, easing: undefined, reduceMotion: false });
|
|
300
|
+
animRef.current = {
|
|
301
|
+
ms: Math.max(0, transition?.transitionMs ?? 300),
|
|
302
|
+
easing: transition?.transitionEasing,
|
|
303
|
+
reduceMotion: transition?.reduceMotion ?? false,
|
|
304
|
+
};
|
|
305
|
+
const onFrameRef = useRef(transition?.onFrame);
|
|
306
|
+
onFrameRef.current = transition?.onFrame;
|
|
307
|
+
const seriesKey = transition?.seriesKey;
|
|
308
|
+
|
|
309
|
+
// Stop an in-flight interval morph and land the core on the new candles.
|
|
310
|
+
const endIntervalMorph = useCallback(() => {
|
|
311
|
+
if (intervalMorphRaf.current != null) {
|
|
312
|
+
cancelAnimationFrame(intervalMorphRaf.current);
|
|
313
|
+
intervalMorphRaf.current = null;
|
|
314
|
+
}
|
|
315
|
+
handleRef.current?.setIntervalMorph(1);
|
|
316
|
+
}, []);
|
|
317
|
+
|
|
318
|
+
// Runs the interval morph clock. The core holds the pre-swap geometry (see
|
|
319
|
+
// beginIntervalMorph) and reshapes each candle slot toward its new counterpart.
|
|
320
|
+
const startIntervalMorph = useCallback((h: ChartHandle) => {
|
|
321
|
+
const { ms, easing } = animRef.current;
|
|
322
|
+
const start = performance.now();
|
|
323
|
+
const step = (now: number) => {
|
|
324
|
+
const p = Math.min(1, (now - start) / ms);
|
|
325
|
+
h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
|
|
326
|
+
const pic = h.render();
|
|
327
|
+
if (pic) onFrameRef.current?.(pic);
|
|
328
|
+
intervalMorphRaf.current = p < 1 ? requestAnimationFrame(step) : null;
|
|
329
|
+
};
|
|
330
|
+
intervalMorphRaf.current = requestAnimationFrame(step);
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
return () => {
|
|
335
|
+
if (intervalMorphRaf.current != null) {
|
|
336
|
+
cancelAnimationFrame(intervalMorphRaf.current);
|
|
337
|
+
intervalMorphRaf.current = null;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
260
342
|
// When no visibleRange is provided, leave the range entirely to the C++
|
|
261
343
|
// side (which defaults to a sensible recent window on first setCandles).
|
|
262
344
|
// Only push setVisibleRange when the caller is actively controlling it,
|
|
@@ -292,8 +374,89 @@ export function useChartCore(
|
|
|
292
374
|
h.setDefaultCandleWidth(defaultCandleWidth);
|
|
293
375
|
defaultWidthAppliedRef.current = true;
|
|
294
376
|
}
|
|
377
|
+
// How the new candles relate to what the core holds decides what happens to
|
|
378
|
+
// the viewport: a stream leaves it alone, a timeframe switch re-anchors and
|
|
379
|
+
// morphs into it, a different asset resets it.
|
|
380
|
+
let morphing = false;
|
|
295
381
|
if (candles.length > 0) {
|
|
296
|
-
|
|
382
|
+
const prev = prevDataRef.current;
|
|
383
|
+
const freshHandle = prev == null || prev.handle !== h;
|
|
384
|
+
if (freshHandle || prev.candles !== candles || prev.seriesKey !== seriesKey) {
|
|
385
|
+
// A fresh core frames itself (its window starts at 0/0); an explicit
|
|
386
|
+
// visibleRange prop overrides any auto behavior, so treat the change
|
|
387
|
+
// like a stream and let the range application below win.
|
|
388
|
+
const transitionKind: DataTransition = freshHandle
|
|
389
|
+
? 'initial'
|
|
390
|
+
: explicit
|
|
391
|
+
? 'stream'
|
|
392
|
+
: classifyTransition(prev.candles, candles, seriesKey !== prev.seriesKey);
|
|
393
|
+
|
|
394
|
+
// Capture the outgoing view before setCandles re-infers the candle
|
|
395
|
+
// period from the new data.
|
|
396
|
+
let tfArgs: {
|
|
397
|
+
oldWindow: VisibleRange;
|
|
398
|
+
oldStepMs: number;
|
|
399
|
+
oldLastMs: number;
|
|
400
|
+
} | null = null;
|
|
401
|
+
// The pre-swap candle envelope, used to scale-lock the y-axis below.
|
|
402
|
+
let prevEnvelope: { low: number; high: number } | null = null;
|
|
403
|
+
if (transitionKind === 'timeframe' && prev != null) {
|
|
404
|
+
const oldWindow = h.getVisibleRange();
|
|
405
|
+
const oldStepMs = inferStepMs(prev.candles);
|
|
406
|
+
if (oldWindow.endMs > oldWindow.startMs && oldStepMs != null) {
|
|
407
|
+
tfArgs = {
|
|
408
|
+
oldWindow,
|
|
409
|
+
oldStepMs,
|
|
410
|
+
oldLastMs: prev.candles[prev.candles.length - 1].timeMs,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
prevEnvelope = h.getVisiblePriceEnvelope();
|
|
414
|
+
// Capture the outgoing candle geometry, but only when it will actually
|
|
415
|
+
// be animated so a disabled animation costs no snapshot. A switch
|
|
416
|
+
// during a morph restarts from the data the core currently holds.
|
|
417
|
+
morphing =
|
|
418
|
+
animRef.current.ms > 0 &&
|
|
419
|
+
!animRef.current.reduceMotion &&
|
|
420
|
+
onFrameRef.current != null;
|
|
421
|
+
if (morphing) {
|
|
422
|
+
endIntervalMorph();
|
|
423
|
+
h.beginIntervalMorph();
|
|
424
|
+
}
|
|
425
|
+
} else if (transitionKind === 'initial' || transitionKind === 'reset') {
|
|
426
|
+
// Wholesale reframing — the slot pairing no longer holds, so land any
|
|
427
|
+
// in-flight morph rather than reshaping into unrelated data.
|
|
428
|
+
endIntervalMorph();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
h.setCandles(packCandles(candles));
|
|
432
|
+
|
|
433
|
+
if (transitionKind === 'timeframe') {
|
|
434
|
+
const newStepMs = inferStepMs(candles);
|
|
435
|
+
if (tfArgs && newStepMs != null) {
|
|
436
|
+
const w = timeframeWindow(
|
|
437
|
+
tfArgs.oldWindow,
|
|
438
|
+
tfArgs.oldStepMs,
|
|
439
|
+
tfArgs.oldLastMs,
|
|
440
|
+
newStepMs,
|
|
441
|
+
candles[candles.length - 1].timeMs,
|
|
442
|
+
);
|
|
443
|
+
h.setVisibleRange(w.startMs, w.endMs);
|
|
444
|
+
}
|
|
445
|
+
// Scale-lock the y-axis: the same price action re-buckets into a
|
|
446
|
+
// smaller/larger high-low span, so a manual price range is rescaled to
|
|
447
|
+
// keep the candle envelope at the pixel height it just had instead of
|
|
448
|
+
// snapping back to auto-fit. A no-op in auto-y mode, which is already
|
|
449
|
+
// span-invariant.
|
|
450
|
+
if (prevEnvelope) h.preservePriceEnvelope(prevEnvelope.low, prevEnvelope.high);
|
|
451
|
+
else h.resetPriceScale();
|
|
452
|
+
// Started after the new bounds are in place: the snapshot is in band
|
|
453
|
+
// fractions, so frame 0 still matches the pre-switch pixels exactly.
|
|
454
|
+
if (morphing) startIntervalMorph(h);
|
|
455
|
+
} else if (transitionKind === 'reset') {
|
|
456
|
+
h.resetView();
|
|
457
|
+
}
|
|
458
|
+
prevDataRef.current = { handle: h, candles, seriesKey };
|
|
459
|
+
}
|
|
297
460
|
}
|
|
298
461
|
if (explicit) {
|
|
299
462
|
h.setVisibleRange(startMs, endMs);
|
|
@@ -320,11 +483,15 @@ export function useChartCore(
|
|
|
320
483
|
);
|
|
321
484
|
// TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
|
|
322
485
|
// the VroomBand structs in the JSI handle) — web-only for now.
|
|
323
|
-
|
|
486
|
+
// A just-started morph is already pushing frames straight to the host sink;
|
|
487
|
+
// this snapshot would land on top of them a frame or two later. The morph's
|
|
488
|
+
// frame 0 is pixel-identical to what's on screen, so there's nothing to show
|
|
489
|
+
// in the meantime anyway.
|
|
490
|
+
if (!morphing) setPicture(h.render());
|
|
324
491
|
// theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
|
|
325
492
|
// represented by their *Key deps.
|
|
326
493
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
327
|
-
}, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey]);
|
|
494
|
+
}, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
|
|
328
495
|
|
|
329
496
|
return { handle: handleRef.current, picture, volumeCollapseRef };
|
|
330
497
|
}
|