react-native-vroom-chart 0.12.0 → 0.13.1

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.
@@ -1,18 +1,31 @@
1
1
  // VroomChart — Phase 3.
2
2
  //
3
- // Owns a SharedValue<SkPicture> driven by:
4
- // - useChartCore's "initial" picture (when data/size/range change), AND
5
- // - Pan gesture callbacks that call handle.pan(dx, dy) → fresh picture.
3
+ // Owns SharedValues driven by:
4
+ // - useChartCore's "initial" frame (when data/size/range change), AND
5
+ // - Pan gesture callbacks that call handle.pan(dx, dy) → a fresh frame.
6
6
  //
7
- // Reanimated 4 + RN-Skia 2 propagate SharedValue<SkPicture> changes to
8
- // <Picture> without a React re-render, so gesture-driven redraws are cheap.
7
+ // iOS wraps an SkPicture in-process. Android rasterizes to an SkImage (the
8
+ // two Skia copies can't share a picture pointer) so pan/zoom don't serialize
9
+ // the scene — and the system typeface — on every frame.
10
+ //
11
+ // Reanimated 4 + RN-Skia 2 propagate SharedValue changes to <Picture>/<Image>
12
+ // without a React re-render, so gesture-driven redraws are cheap.
9
13
  //
10
14
  // Gestures run on the JS thread for now (`runOnJS(true)`) — installing the
11
15
  // JSI bindings on the worklet runtime is a later perf optimization.
12
16
 
13
17
  import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
14
- import { View, type LayoutChangeEvent } from 'react-native';
15
- import { Canvas, Picture, Skia, type SkPicture } from '@shopify/react-native-skia';
18
+ import { PixelRatio, View, type LayoutChangeEvent } from 'react-native';
19
+ import {
20
+ AlphaType,
21
+ Canvas,
22
+ ColorType,
23
+ Image,
24
+ Picture,
25
+ Skia,
26
+ type SkImage,
27
+ type SkPicture,
28
+ } from '@shopify/react-native-skia';
16
29
  import {
17
30
  Gesture,
18
31
  GestureDetector,
@@ -22,9 +35,14 @@ import { useReducedMotion, useSharedValue } from 'react-native-reanimated';
22
35
 
23
36
  import { useChartCore } from './useChartCore';
24
37
  import { ease, easingIndex } from './easing';
38
+ import type { ChartFrame } from './jsi.d';
25
39
  import type { VroomChartProps } from './types';
26
40
  import './jsi.d';
27
41
 
42
+ function isSkImage(frame: ChartFrame): frame is SkImage {
43
+ return typeof (frame as SkImage).getImageInfo === 'function';
44
+ }
45
+
28
46
  /**
29
47
  * Skia-rendered candlestick chart. Pass OHLCV `candles` and size it via `style`
30
48
  * (it fills its parent by default). Pan to scroll, pinch to zoom, drag the
@@ -92,16 +110,39 @@ export function VroomChart(props: VroomChartProps) {
92
110
  [priceLines, priceLinesStyle, onPriceLineClose],
93
111
  );
94
112
 
95
- // RN-Skia's recorder reads this SharedValue on the UI/render runtime, a beat
96
- // behind JS-thread writes. If it ever reads null it throws ("Invalid prop
97
- // value for SkTextBlob received" — RN-Skia's mislabeled SkPicture error), so
98
- // we seed it with an empty picture and *never* assign null into it.
113
+ // RN-Skia's recorder reads these SharedValues on the UI/render runtime, a
114
+ // beat behind JS-thread writes. If it ever reads null it throws ("Invalid
115
+ // prop value for SkTextBlob received" — RN-Skia's mislabeled SkPicture
116
+ // error), so we seed them and *never* assign null. Android writes the image
117
+ // SV (raster path); iOS writes the picture SV. The unused layer stays a
118
+ // transparent 1×1 so it doesn't cover the other.
99
119
  const emptyPicture = useMemo(() => {
100
120
  const rec = Skia.PictureRecorder();
101
121
  rec.beginRecording(Skia.XYWHRect(0, 0, 1, 1));
102
122
  return rec.finishRecordingAsPicture();
103
123
  }, []);
124
+ const emptyImage = useMemo(() => {
125
+ const data = Skia.Data.fromBytes(new Uint8Array(4));
126
+ return Skia.Image.MakeImage(
127
+ {
128
+ width: 1,
129
+ height: 1,
130
+ colorType: ColorType.RGBA_8888,
131
+ alphaType: AlphaType.Premul,
132
+ },
133
+ data,
134
+ 4,
135
+ )!;
136
+ }, []);
104
137
  const pictureSV = useSharedValue<SkPicture>(emptyPicture);
138
+ const imageSV = useSharedValue<SkImage>(emptyImage);
139
+ const applyFrame = useCallback(
140
+ (frame: ChartFrame) => {
141
+ if (isSkImage(frame)) imageSV.value = frame;
142
+ else pictureSV.value = frame;
143
+ },
144
+ [imageSV, pictureSV],
145
+ );
105
146
 
106
147
  // An OS reduced-motion preference snaps every transition, the way
107
148
  // prefers-reduced-motion does on web.
@@ -111,15 +152,15 @@ export function VroomChart(props: VroomChartProps) {
111
152
  // capture) but repaints every frame, so it writes straight into the SV rather
112
153
  // than through React state — the same bypass the gesture handlers use.
113
154
  const onFrame = useCallback(
114
- (p: SkPicture) => {
115
- pictureSV.value = p;
155
+ (p: ChartFrame) => {
156
+ applyFrame(p);
116
157
  },
117
- [pictureSV],
158
+ [applyFrame],
118
159
  );
119
160
 
120
161
  const { handle, picture, volumeCollapseRef } = useChartCore(
121
162
  candles,
122
- { width, height },
163
+ { width, height, pxRatio: PixelRatio.get() },
123
164
  visibleRange,
124
165
  defaultCandleWidth,
125
166
  chartType,
@@ -166,11 +207,11 @@ export function VroomChart(props: VroomChartProps) {
166
207
  animRaf.current = null;
167
208
  if (!handle) return;
168
209
  const next = handle.render();
169
- if (next) pictureSV.value = next;
210
+ if (next) applyFrame(next);
170
211
  if (handle.isAnimating()) {
171
212
  animRaf.current = requestAnimationFrame(animTick);
172
213
  }
173
- }, [handle, pictureSV]);
214
+ }, [handle, applyFrame]);
174
215
  const maybeStartAnim = useCallback(() => {
175
216
  if (animRaf.current != null) return;
176
217
  if (!handle?.isAnimating()) return;
@@ -194,9 +235,9 @@ export function VroomChart(props: VroomChartProps) {
194
235
  // pulse on needs — otherwise the ring wouldn't move until you touched the
195
236
  // chart.
196
237
  useEffect(() => {
197
- if (picture) pictureSV.value = picture;
238
+ if (picture) applyFrame(picture);
198
239
  maybeStartAnim();
199
- }, [picture, pictureSV, maybeStartAnim]);
240
+ }, [picture, applyFrame, maybeStartAnim]);
200
241
 
201
242
  // Candle↔line morph. When `chartType` changes we drive the core per-frame with
202
243
  // a (collapse, fade) blend and push a fresh picture into the SV each frame — the
@@ -225,7 +266,7 @@ export function VroomChart(props: VroomChartProps) {
225
266
  morphFade.current = target;
226
267
  handle.setChartType(target);
227
268
  const p = handle.render();
228
- if (p) pictureSV.value = p;
269
+ if (p) applyFrame(p);
229
270
  maybeStartAnim();
230
271
  return undefined;
231
272
  }
@@ -243,7 +284,7 @@ export function VroomChart(props: VroomChartProps) {
243
284
  morphFade.current = target;
244
285
  handle.setChartType(target);
245
286
  const p = handle.render();
246
- if (p) pictureSV.value = p;
287
+ if (p) applyFrame(p);
247
288
  maybeStartAnim();
248
289
  return undefined;
249
290
  }
@@ -258,7 +299,7 @@ export function VroomChart(props: VroomChartProps) {
258
299
  // Reduced motion still crossfades, but skips the vertical collapse.
259
300
  handle.setMorph(reduceMotion ? 0 : fade, fade);
260
301
  const p = handle.render();
261
- if (p) pictureSV.value = p;
302
+ if (p) applyFrame(p);
262
303
  if (prog < 1) {
263
304
  morphRaf.current = requestAnimationFrame(step);
264
305
  } else {
@@ -266,7 +307,7 @@ export function VroomChart(props: VroomChartProps) {
266
307
  morphFade.current = target;
267
308
  handle.setChartType(target); // lock the exact endpoint
268
309
  const q = handle.render();
269
- if (q) pictureSV.value = q;
310
+ if (q) applyFrame(q);
270
311
  maybeStartAnim();
271
312
  }
272
313
  };
@@ -279,9 +320,9 @@ export function VroomChart(props: VroomChartProps) {
279
320
  }
280
321
  };
281
322
  // maybeStartAnim is memoized on [handle, animTick] and animTick on
282
- // [handle, pictureSV], both already deps here — so it adds no new restarts
323
+ // [handle, applyFrame], both already deps here — so it adds no new restarts
283
324
  // of this clock.
284
- }, [handle, chartType, transitionMs, reduceMotion, pictureSV, maybeStartAnim]);
325
+ }, [handle, chartType, transitionMs, reduceMotion, applyFrame, maybeStartAnim]);
285
326
 
286
327
  // Volume-bar collapse. The core staggers the bars itself — tallest falling
287
328
  // first, all landing together — so unlike the loop above this one hands it
@@ -321,7 +362,7 @@ export function VroomChart(props: VroomChartProps) {
321
362
  volumeCollapseRef.current = { t: target, easing };
322
363
  handle.setVolumeCollapse(target, easing);
323
364
  const p = handle.render();
324
- if (p) pictureSV.value = p;
365
+ if (p) applyFrame(p);
325
366
  maybeStartAnim();
326
367
  return undefined;
327
368
  }
@@ -338,7 +379,7 @@ export function VroomChart(props: VroomChartProps) {
338
379
  volumeCollapseRef.current = { t, easing: kind };
339
380
  handle.setVolumeCollapse(t, kind);
340
381
  const p = handle.render();
341
- if (p) pictureSV.value = p;
382
+ if (p) applyFrame(p);
342
383
  if (prog < 1) {
343
384
  volumeRaf.current = requestAnimationFrame(step);
344
385
  } else {
@@ -359,7 +400,7 @@ export function VroomChart(props: VroomChartProps) {
359
400
  volume?.enabled,
360
401
  transitionMs,
361
402
  reduceMotion,
362
- pictureSV,
403
+ applyFrame,
363
404
  volumeCollapseRef,
364
405
  maybeStartAnim,
365
406
  ]);
@@ -386,7 +427,7 @@ export function VroomChart(props: VroomChartProps) {
386
427
  axisCollapse.current = { y: targetY, x: targetX };
387
428
  handle.setAxisCollapse(targetY, targetX);
388
429
  const p = handle.render();
389
- if (p) pictureSV.value = p;
430
+ if (p) applyFrame(p);
390
431
  maybeStartAnim();
391
432
  return undefined;
392
433
  }
@@ -408,7 +449,7 @@ export function VroomChart(props: VroomChartProps) {
408
449
  axisCollapse.current = { y: targetY, x: targetX };
409
450
  handle.setAxisCollapse(targetY, targetX);
410
451
  const p = handle.render();
411
- if (p) pictureSV.value = p;
452
+ if (p) applyFrame(p);
412
453
  maybeStartAnim();
413
454
  return undefined;
414
455
  }
@@ -425,7 +466,7 @@ export function VroomChart(props: VroomChartProps) {
425
466
  axisCollapse.current = { y, x };
426
467
  handle.setAxisCollapse(y, x);
427
468
  const p = handle.render();
428
- if (p) pictureSV.value = p;
469
+ if (p) applyFrame(p);
429
470
  if (prog < 1) {
430
471
  axisRaf.current = requestAnimationFrame(step);
431
472
  } else {
@@ -447,7 +488,7 @@ export function VroomChart(props: VroomChartProps) {
447
488
  showXAxis,
448
489
  transitionMs,
449
490
  reduceMotion,
450
- pictureSV,
491
+ applyFrame,
451
492
  maybeStartAnim,
452
493
  ]);
453
494
 
@@ -516,7 +557,7 @@ export function VroomChart(props: VroomChartProps) {
516
557
  priceDrag.current = { index: pl.index, id: pl.line.id, price: pl.line.price };
517
558
  handle.setPriceLineDrag(pl.index, pl.line.price);
518
559
  const p = handle.render();
519
- if (p) pictureSV.value = p;
560
+ if (p) applyFrame(p);
520
561
  }
521
562
  }
522
563
  })
@@ -546,7 +587,7 @@ export function VroomChart(props: VroomChartProps) {
546
587
  // scrolling. Vertical line tracks the finger x; the dot/horizontal line
547
588
  // stay lifted `crosshairOffset` px above the fingertip.
548
589
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
549
- if (ch) pictureSV.value = ch;
590
+ if (ch) applyFrame(ch);
550
591
  // The line follows the finger every frame (above), but only notify the
551
592
  // host when the snapped slot actually changes. The slot has a timeMs
552
593
  // even in the empty space ahead of the last candle, where candle=null.
@@ -564,7 +605,7 @@ export function VroomChart(props: VroomChartProps) {
564
605
  // (axes follow). Diagonal works naturally.
565
606
  next = handle.translate(e.changeX, e.changeY);
566
607
  }
567
- if (next) pictureSV.value = next;
608
+ if (next) applyFrame(next);
568
609
  maybeStartAnim();
569
610
  })
570
611
  .onEnd((e) => {
@@ -577,7 +618,7 @@ export function VroomChart(props: VroomChartProps) {
577
618
  priceDrag.current = null;
578
619
  handle.setPriceLineDrag(-1, 0);
579
620
  const p = handle.render();
580
- if (p) pictureSV.value = p;
621
+ if (p) applyFrame(p);
581
622
  if (g) onPriceLineDragEnd?.(g.id, g.price);
582
623
  return;
583
624
  }
@@ -606,7 +647,7 @@ export function VroomChart(props: VroomChartProps) {
606
647
  velocity *= Math.pow(0.5, dt / HALF_LIFE_S);
607
648
  const dx = velocity * dt;
608
649
  const next = handle.pan(dx, 0);
609
- if (next) pictureSV.value = next;
650
+ if (next) applyFrame(next);
610
651
  maybeStartAnim();
611
652
 
612
653
  if (Math.abs(velocity) > MIN_STOP) {
@@ -680,7 +721,7 @@ export function VroomChart(props: VroomChartProps) {
680
721
  if (frameX === 1 && frameY === 1) return;
681
722
 
682
723
  const next = handle.zoom(frameX, frameY, focalX, focalY);
683
- if (next) pictureSV.value = next;
724
+ if (next) applyFrame(next);
684
725
  maybeStartAnim();
685
726
  });
686
727
 
@@ -699,7 +740,7 @@ export function VroomChart(props: VroomChartProps) {
699
740
  cancelDecay();
700
741
  crosshairActive.current = true;
701
742
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
702
- if (ch) pictureSV.value = ch;
743
+ if (ch) applyFrame(ch);
703
744
  const info = handle.getCrosshairInfo();
704
745
  lastCrosshairTime.current = info?.timeMs ?? null;
705
746
  onCrosshair?.({
@@ -729,7 +770,7 @@ export function VroomChart(props: VroomChartProps) {
729
770
  if (hitAxis(e.x, e.y) !== 'chart') return;
730
771
  crosshairActive.current = false;
731
772
  const ch = handle.clearCrosshair();
732
- if (ch) pictureSV.value = ch;
773
+ if (ch) applyFrame(ch);
733
774
  lastCrosshairTime.current = null;
734
775
  onCrosshair?.({ active: false, candle: null, timeMs: null, price: null, reason: 'hide' });
735
776
  });
@@ -749,9 +790,17 @@ export function VroomChart(props: VroomChartProps) {
749
790
  <View style={{ flex: 1 }}>
750
791
  <Canvas style={{ flex: 1 }}>
751
792
  {width > 0 && height > 0 ? (
752
- // pictureSV is always a valid picture (seeded empty, never null),
753
- // so RN-Skia's UI-thread reader never sees null.
754
- <Picture picture={pictureSV} />
793
+ <>
794
+ <Picture picture={pictureSV} />
795
+ <Image
796
+ image={imageSV}
797
+ x={0}
798
+ y={0}
799
+ width={width}
800
+ height={height}
801
+ fit="fill"
802
+ />
803
+ </>
755
804
  ) : null}
756
805
  </Canvas>
757
806
  </View>
package/src/jsi.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // Ambient declaration for the JSI HostObject installed by VroomChartModule.
2
2
 
3
- import type { SkPicture } from '@shopify/react-native-skia';
3
+ import type { SkImage, SkPicture } from '@shopify/react-native-skia';
4
+
5
+ /** Frame the JSI handle returns: an SkPicture on iOS, an SkImage on Android. */
6
+ export type ChartFrame = SkPicture | SkImage;
4
7
 
5
8
  export interface ChartHandle {
6
9
  setCandles(buffer: ArrayBuffer): void;
@@ -79,30 +82,30 @@ export interface ChartHandle {
79
82
  */
80
83
  setIntervalMorph(t: number): void;
81
84
  /** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
82
- pan(dx: number, dy: number): SkPicture | null;
85
+ pan(dx: number, dy: number): ChartFrame | null;
83
86
  /**
84
87
  * Two-finger translation: shifts the time window AND the price bounds
85
88
  * without rescaling. dy > 0 (drag down) moves content down.
86
89
  */
87
- translate(dx: number, dy: number): SkPicture | null;
90
+ translate(dx: number, dy: number): ChartFrame | null;
88
91
  /**
89
92
  * Directional zoom by per-axis multiplicative factors around focus point
90
93
  * (`fx`, `fy`) in pixels. `scaleX` resizes the time window (>1 = wider
91
94
  * candles); `scaleY` resizes the price range (>1 = taller candles). Pass 1
92
95
  * for an axis to leave it untouched.
93
96
  */
94
- zoom(scaleX: number, scaleY: number, fx: number, fy: number): SkPicture | null;
97
+ zoom(scaleX: number, scaleY: number, fx: number, fy: number): ChartFrame | null;
95
98
  /**
96
99
  * Drag-on-y-axis price scaling. `dy > 0` widens the price range
97
100
  * (candles shrink). Pivots around the price-range center.
98
101
  */
99
- scalePriceAxis(dy: number): SkPicture | null;
102
+ scalePriceAxis(dy: number): ChartFrame | null;
100
103
  /**
101
104
  * Drag-on-x-axis time scaling. `dx > 0` widens the time window
102
105
  * (candles thin). Pivots around the right edge so the most recent
103
106
  * visible candle stays in place.
104
107
  */
105
- scaleTimeAxis(dx: number): SkPicture | null;
108
+ scaleTimeAxis(dx: number): ChartFrame | null;
106
109
  /**
107
110
  * Current axis dimensions in pixels for hit testing in JS gestures.
108
111
  * `indicatorHeight` is the below-chart indicator pane height (0 when none).
@@ -117,9 +120,9 @@ export interface ChartHandle {
117
120
  * `y` should already be lifted above the touch point so the dot/horizontal
118
121
  * line aren't hidden under the thumb.
119
122
  */
120
- setCrosshair(x: number, y: number): SkPicture | null;
123
+ setCrosshair(x: number, y: number): ChartFrame | null;
121
124
  /** Hides the crosshair and returns a fresh picture. */
122
- clearCrosshair(): SkPicture | null;
125
+ clearCrosshair(): ChartFrame | null;
123
126
  /**
124
127
  * OHLCV of the candle the crosshair currently snaps to, or null when the
125
128
  * crosshair is inactive / there are no visible candles. Cheap to poll at
@@ -326,7 +329,7 @@ export interface ChartHandle {
326
329
  setPriceLineDrag(index: number, price: number): void;
327
330
  /** True while any axis-label fade is still in progress. Drives a RAF loop. */
328
331
  isAnimating(): boolean;
329
- render(): SkPicture | null;
332
+ render(): ChartFrame | null;
330
333
  }
331
334
 
332
335
  export interface VroomChartJSI {
@@ -1,12 +1,11 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { MutableRefObject } from 'react';
3
- import type { SkPicture } from '@shopify/react-native-skia';
4
3
 
5
4
  import NativeVroomChart from './NativeVroomChart';
6
5
  import type { DataTransition } from './dataTransitions';
7
6
  import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
8
7
  import { ease } from './easing';
9
- import type { ChartHandle } from './jsi.d';
8
+ import type { ChartFrame, ChartHandle } from './jsi.d';
10
9
  import { packCandles } from './packCandles';
11
10
  import { applyTheme, parseColor, FLOAT_LINE_TIP_PULSE } from './theme';
12
11
  import type {
@@ -233,13 +232,13 @@ export type TransitionOptions = {
233
232
  /** OS reduced-motion preference: skips the capture and snaps. */
234
233
  reduceMotion?: boolean;
235
234
  /** Receives every morph frame. Without one, data swaps snap. */
236
- onFrame?: (picture: SkPicture) => void;
235
+ onFrame?: (picture: ChartFrame) => void;
237
236
  };
238
237
 
239
238
  export type ChartCoreState = {
240
239
  handle: ChartHandle | null;
241
240
  /** Picture freshly rendered after the latest data/size/range push. */
242
- picture: SkPicture | null;
241
+ picture: ChartFrame | null;
243
242
  /**
244
243
  * The last volume collapse handed to the core, or null before the first push.
245
244
  * VroomChart's animation loop owns this — it lives here only so the data effect
@@ -282,7 +281,7 @@ export function useChartCore(
282
281
  seriesKey?: string;
283
282
  } | null>(null);
284
283
  const intervalMorphRaf = useRef<number | null>(null);
285
- const [picture, setPicture] = useState<SkPicture | null>(null);
284
+ const [picture, setPicture] = useState<ChartFrame | null>(null);
286
285
 
287
286
  if (!handleRef.current && size.width > 0 && size.height > 0) {
288
287
  ensureInstalled();