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/src/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  export { VroomChart } from './VroomChart';
2
+ export {
3
+ classifyTransition,
4
+ inferStepMs,
5
+ timeframeWindow,
6
+ type DataTransition,
7
+ } from './dataTransitions';
2
8
  export type {
3
9
  VroomChartProps,
4
10
  Candle,
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
  /**
@@ -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
- h.setCandles(packCandles(candles));
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
- setPicture(h.render());
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
  }