react-native-vroom-chart 0.16.0 → 0.18.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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/android/CMakeLists.txt +12 -2
  3. package/android/README.md +16 -6
  4. package/android/build.gradle +10 -0
  5. package/android/src/main/cpp/OnLoad.cpp +10 -0
  6. package/android/src/main/cpp/VroomChartJsiBindings.cpp +35 -0
  7. package/android/src/main/cpp/VroomChartJsiBindings.h +27 -0
  8. package/android/src/main/java/com/vroom/chart/VroomChartModule.kt +15 -11
  9. package/cpp/VroomChartHostObject.cpp +81 -0
  10. package/cpp/VroomJsiInstaller.cpp +6 -0
  11. package/cpp/_core_include/vroom/vroom_chart.h +41 -0
  12. package/cpp/_core_src/candles.cpp +15 -14
  13. package/cpp/_core_src/chart.cpp +297 -28
  14. package/cpp/_core_src/chart.h +71 -2
  15. package/cpp/_core_src/chart_facade.cpp +27 -0
  16. package/cpp/_core_src/color_lerp.h +28 -0
  17. package/cpp/_core_src/loading_line.cpp +183 -0
  18. package/cpp/_core_src/loading_line.h +38 -0
  19. package/cpp/_core_src/loading_wave.h +140 -0
  20. package/cpp/_core_src/ma_overlay.cpp +15 -10
  21. package/cpp/_core_src/ma_overlay.h +7 -0
  22. package/cpp/_core_src/price_indicator.cpp +21 -7
  23. package/cpp/_core_src/price_indicator.h +11 -1
  24. package/cpp/_core_src/price_indicator_anim.h +81 -0
  25. package/cpp/_core_src/theme.cpp +5 -0
  26. package/cpp/_core_src/tip_geometry.h +55 -0
  27. package/cpp/_core_src/viewport.h +33 -0
  28. package/ios/README.md +17 -5
  29. package/ios/VroomChartModule.h +3 -6
  30. package/ios/VroomChartModule.mm +17 -23
  31. package/lib/index.d.mts +30 -0
  32. package/lib/index.d.ts +30 -0
  33. package/lib/index.js +75 -14
  34. package/lib/index.js.map +1 -1
  35. package/lib/index.mjs +75 -14
  36. package/lib/index.mjs.map +1 -1
  37. package/package.json +9 -9
  38. package/react-native-vroom-chart.podspec +1 -1
  39. package/src/NativeVroomChart.ts +7 -4
  40. package/src/VroomChart.tsx +12 -0
  41. package/src/jsi.d.ts +42 -0
  42. package/src/theme.ts +1 -0
  43. package/src/useChartCore.ts +129 -7
  44. package/android/src/main/cpp/VroomChartJni.cpp +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-vroom-chart",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Mobile-first Skia candlestick chart for React Native",
5
5
  "license": "MIT",
6
6
  "author": "Darion Welch",
@@ -61,19 +61,19 @@
61
61
  "peerDependencies": {
62
62
  "@shopify/react-native-skia": ">=2.11.0",
63
63
  "react": "*",
64
- "react-native": "*",
64
+ "react-native": ">=0.78",
65
65
  "react-native-gesture-handler": ">=2.16.0",
66
66
  "react-native-reanimated": ">=4.0.0",
67
67
  "react-native-worklets": ">=0.7.0"
68
68
  },
69
69
  "devDependencies": {
70
- "@shopify/react-native-skia": "2.11.0",
71
- "@types/react": "~19.1.0",
72
- "react": "19.1.0",
73
- "react-native": "0.81.5",
74
- "react-native-gesture-handler": "~2.28.0",
75
- "react-native-reanimated": "~4.1.7",
76
- "react-native-worklets": "0.7.4",
70
+ "@shopify/react-native-skia": "2.12.0",
71
+ "@types/react": "~19.2.0",
72
+ "react": "19.2.3",
73
+ "react-native": "0.86.3",
74
+ "react-native-gesture-handler": "~2.32.0",
75
+ "react-native-reanimated": "~4.5.1",
76
+ "react-native-worklets": "0.10.1",
77
77
  "tsup": "^8.5.1",
78
78
  "typescript": "~5.9.2",
79
79
  "vitest": "^3.2.6",
@@ -37,7 +37,7 @@ Pod::Spec.new do |s|
37
37
  s.homepage = "https://github.com/keepitreal/vroom"
38
38
  s.license = { :type => "MIT" }
39
39
  s.authors = { "vroom" => "noreply@example.com" }
40
- s.platforms = { :ios => "14.0" }
40
+ s.platforms = { :ios => "15.1" }
41
41
  s.source = { :git => "" }
42
42
 
43
43
  s.requires_arc = true
@@ -1,10 +1,13 @@
1
1
  import type { TurboModule } from 'react-native';
2
2
  import { TurboModuleRegistry } from 'react-native';
3
3
 
4
- // TurboModule spec consumed by codegen. The only method is `install`, which
5
- // the native side uses to install the `global.VroomChartJSI` host object the
6
- // first time it's called from JS. All real chart operations go through that
7
- // host object, not through the TurboModule itself.
4
+ // TurboModule spec consumed by codegen. The native side installs the
5
+ // `global.VroomChartJSI` host object when React Native instantiates this
6
+ // module, not when `install` is called see the JSI-bindings hooks in
7
+ // ../ios/VroomChartModule.mm and ../android/src/main/cpp/VroomChartJsiBindings.cpp.
8
+ // `install` remains so the spec has a method to generate and so JS has a way
9
+ // to force instantiation; all real chart operations go through the host
10
+ // object, not through the TurboModule itself.
8
11
  export interface Spec extends TurboModule {
9
12
  install(): boolean;
10
13
  }
@@ -59,6 +59,7 @@ function isSkImage(frame: ChartFrame): frame is SkImage {
59
59
  export function VroomChart(props: VroomChartProps) {
60
60
  const {
61
61
  candles,
62
+ loading,
62
63
  seriesKey,
63
64
  width: widthProp,
64
65
  height: heightProp,
@@ -204,8 +205,15 @@ export function VroomChart(props: VroomChartProps) {
204
205
  reduceMotion,
205
206
  onFrame,
206
207
  },
208
+ loading,
207
209
  );
208
210
 
211
+ // Same condition useChartCore draws the line on: a refresh that still has
212
+ // data keeps the chart interactive. Every gesture below is gated on this —
213
+ // there's nothing to pan, zoom or inspect while the line is up, and a
214
+ // crosshair reading prices off a placeholder walk would be actively wrong.
215
+ const showLoadingLine = loading === true && candles.length === 0;
216
+
209
217
  // When the crosshair is showing, pan moves it (instead of scrolling) and
210
218
  // pinch is disabled. A ref (not state) so gesture callbacks read it
211
219
  // synchronously without re-subscribing. Tap dismisses it.
@@ -600,6 +608,7 @@ export function VroomChart(props: VroomChartProps) {
600
608
  };
601
609
 
602
610
  const pan = Gesture.Pan()
611
+ .enabled(!showLoadingLine)
603
612
  .runOnJS(true)
604
613
  .maxPointers(1) // don't fight Pinch's two-finger gesture
605
614
  .onStart((e) => {
@@ -745,6 +754,7 @@ export function VroomChart(props: VroomChartProps) {
745
754
  enableY: false,
746
755
  });
747
756
  const pinch = Gesture.Pinch()
757
+ .enabled(!showLoadingLine)
748
758
  .runOnJS(true)
749
759
  .onTouchesDown((e) => {
750
760
  if (e.numberOfTouches < 2) return;
@@ -795,6 +805,7 @@ export function VroomChart(props: VroomChartProps) {
795
805
  // activates `pan` (it needs movement first), so the chart won't scroll under
796
806
  // the hold. The dot/horizontal line are lifted above the fingertip.
797
807
  const longPress = Gesture.LongPress()
808
+ .enabled(!showLoadingLine)
798
809
  .runOnJS(true)
799
810
  .onStart((e) => {
800
811
  if (!handle) return;
@@ -826,6 +837,7 @@ export function VroomChart(props: VroomChartProps) {
826
837
  // badge, and otherwise dismisses the crosshair while it's up. Any other tap is a
827
838
  // no-op, so it never interferes with normal pan/pinch.
828
839
  const tap = Gesture.Tap()
840
+ .enabled(!showLoadingLine)
829
841
  .runOnJS(true)
830
842
  .onStart((e) => {
831
843
  if (!handle) return;
package/src/jsi.d.ts CHANGED
@@ -92,6 +92,48 @@ export interface ChartHandle {
92
92
  * the capture. Driven per-frame by the host animation loop.
93
93
  */
94
94
  setIntervalMorph(t: number): void;
95
+ /**
96
+ * Shows or hides the loading line: a single stroke drawn across the plot in a
97
+ * slow travelling sine, for a chart that is laid out but has no data yet.
98
+ * Suppresses the axis text, price badge, crosshair and indicator panes while
99
+ * it's up.
100
+ *
101
+ * `on` must mean "loading *and* holding no data" — the core takes it at face
102
+ * value rather than checking its candle buffer, since a host mid-asset-switch
103
+ * can still be holding the previous asset's bars. Pass `animate: false` for
104
+ * reduced motion: the line draws still and the chart may go idle.
105
+ *
106
+ * Passing `false` abandons any hand-off in flight, which is what a failed or
107
+ * superseded fetch wants. To hand off to data instead, use the two stages
108
+ * below.
109
+ */
110
+ setLoading(on: boolean, animate?: boolean): void;
111
+ /**
112
+ * Hand-off stage one: reshapes the loading line into the series. Freezes the
113
+ * sine where it is and aims each vertex at the vertical centre of the candle
114
+ * that will occupy its column, so the line resolves into the silhouette of
115
+ * the data.
116
+ *
117
+ * Call *after* setCandles — it reads them to know where to aim — then drive
118
+ * {@link setLoadingMorph} from 0 to 1. The axes and the candles stay
119
+ * suppressed throughout; stage two brings them in.
120
+ */
121
+ beginLoadingMorph(): void;
122
+ /**
123
+ * Advances stage one. `t` (clamped to 0..1) is the eased progress: 0 renders
124
+ * the frozen sine, 1 the polyline through the candle centres.
125
+ */
126
+ setLoadingMorph(t: number): void;
127
+ /**
128
+ * Hand-off stage two: the candles take over. Captures each one collapsed onto
129
+ * its own vertical centre at zero alpha and leaves the loading state, so the
130
+ * bars grow outward from the line and their color fades up as it fades out.
131
+ *
132
+ * Call once stage one has reached 1, then drive {@link setIntervalMorph} from
133
+ * 0 to 1 — the line's fade-out rides that same clock, so the two finish
134
+ * together.
135
+ */
136
+ beginLoadingReveal(): void;
95
137
  /** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
96
138
  pan(dx: number, dy: number): ChartFrame | null;
97
139
  /**
package/src/theme.ts CHANGED
@@ -20,6 +20,7 @@ export const COLOR_KEYS: Partial<Record<keyof VroomTheme, number>> = {
20
20
  accentBull: 14, // VROOM_COLOR_ACCENT_BULL
21
21
  accentBear: 15, // VROOM_COLOR_ACCENT_BEAR
22
22
  lineColor: 16, // VROOM_COLOR_LINE
23
+ skeleton: 17, // VROOM_COLOR_SKELETON
23
24
  };
24
25
 
25
26
  // Maps each numeric VroomTheme field to its VroomFloatKey index.
@@ -354,10 +354,17 @@ const EMPTY_FOOTPRINTS = footprintsToSpec({ prints: [] });
354
354
  let installed = false;
355
355
  function ensureInstalled(): void {
356
356
  if (installed) return;
357
- const ok = NativeVroomChart.install();
358
- if (!ok) throw new Error('VroomChartModule.install() returned false');
357
+ // The bindings are installed by the TurboModule itself, when the runtime
358
+ // hands it over (installJSIBindingsWithRuntime:callInvoker: on iOS,
359
+ // getBindingsInstaller() on Android). Touching the module is what forces
360
+ // that to have happened; the global is the thing worth checking.
361
+ NativeVroomChart.install();
359
362
  if (typeof globalThis.VroomChartJSI === 'undefined') {
360
- throw new Error('global.VroomChartJSI undefined after install()');
363
+ throw new Error(
364
+ 'global.VroomChartJSI is undefined — VroomChartModule did not install ' +
365
+ 'its JSI bindings. This requires react-native >= 0.78 with the New ' +
366
+ 'Architecture enabled.',
367
+ );
361
368
  }
362
369
  installed = true;
363
370
  }
@@ -425,6 +432,9 @@ export function useChartCore(
425
432
  priceLines?: PriceLinesProp,
426
433
  footprints?: FootprintsProp,
427
434
  transition?: TransitionOptions,
435
+ // Trails the config params because it's data state, not configuration: it
436
+ // pairs with `candles` above (see showLoadingLine below).
437
+ loading?: boolean,
428
438
  ): ChartCoreState {
429
439
  const handleRef = useRef<ChartHandle | null>(null);
430
440
  // Push setDefaultCandleWidth only once (first load): setCandles re-runs on
@@ -486,22 +496,39 @@ export function useChartCore(
486
496
  onFrameRef.current = transition?.onFrame;
487
497
  const seriesKey = transition?.seriesKey;
488
498
 
499
+ // Set only while the loading hand-off is in its *first* stage, which is the
500
+ // one an interruption can't simply land: stage one leaves `loading` set in
501
+ // the core, and only stage two releases it.
502
+ const loadingHandoffRef = useRef(false);
503
+
489
504
  // Stop an in-flight interval morph and land the core on the new candles.
490
505
  const endIntervalMorph = useCallback(() => {
491
506
  if (intervalMorphRaf.current != null) {
492
507
  cancelAnimationFrame(intervalMorphRaf.current);
493
508
  intervalMorphRaf.current = null;
494
509
  }
495
- handleRef.current?.setIntervalMorph(1);
510
+ const h = handleRef.current;
511
+ // Walk a half-finished hand-off through the rest of its stages rather than
512
+ // just stopping the clock, or the core would be left drawing the loading
513
+ // line over the data it was supposed to hand off to.
514
+ if (loadingHandoffRef.current) {
515
+ loadingHandoffRef.current = false;
516
+ h?.setLoadingMorph(1);
517
+ h?.beginLoadingReveal();
518
+ }
519
+ h?.setIntervalMorph(1);
496
520
  }, []);
497
521
 
498
522
  // Runs the interval morph clock. The core holds the pre-swap geometry (see
499
523
  // beginIntervalMorph) and reshapes each candle slot toward its new counterpart.
500
- const startIntervalMorph = useCallback((h: ChartHandle) => {
524
+ // `durationMs` overrides transitionMs for the loading hand-off, which splits
525
+ // it across two stages.
526
+ const startIntervalMorph = useCallback((h: ChartHandle, durationMs?: number) => {
501
527
  const { ms, easing } = animRef.current;
528
+ const dur = durationMs ?? ms;
502
529
  const start = performance.now();
503
530
  const step = (now: number) => {
504
- const p = Math.min(1, (now - start) / ms);
531
+ const p = Math.min(1, (now - start) / dur);
505
532
  h.setIntervalMorph(p < 1 ? ease(easing, p) : 1);
506
533
  const pic = h.render();
507
534
  if (pic) onFrameRef.current?.(pic);
@@ -510,6 +537,44 @@ export function useChartCore(
510
537
  intervalMorphRaf.current = requestAnimationFrame(step);
511
538
  }, []);
512
539
 
540
+ // Hands the loading line over to the data that just landed, in two stages:
541
+ // the line reshapes into the series' silhouette, then the candles grow out of
542
+ // it while it fades.
543
+ //
544
+ // Sequential rather than overlapped — the shape has to read as the data
545
+ // before the bars start emerging from it — so the two split transitionMs and
546
+ // the whole hand-off costs what any other transition costs.
547
+ const startLoadingHandoff = useCallback(
548
+ (h: ChartHandle) => {
549
+ const { ms, easing } = animRef.current;
550
+ const half = ms / 2;
551
+ loadingHandoffRef.current = true;
552
+ h.beginLoadingMorph();
553
+ const start = performance.now();
554
+ const step = (now: number) => {
555
+ const p = Math.min(1, (now - start) / half);
556
+ h.setLoadingMorph(p < 1 ? ease(easing, p) : 1);
557
+ const pic = h.render();
558
+ if (pic) onFrameRef.current?.(pic);
559
+ if (p < 1) {
560
+ intervalMorphRaf.current = requestAnimationFrame(step);
561
+ return;
562
+ }
563
+ intervalMorphRaf.current = null;
564
+ // Past here an interruption is an ordinary interval morph again: the
565
+ // core has left the loading state, so landing the clock is enough.
566
+ loadingHandoffRef.current = false;
567
+ // Stage two rides the interval-morph clock, which also carries the
568
+ // line's fade-out — so the bars' growth and the line's exit finish
569
+ // together instead of one outlasting the other.
570
+ h.beginLoadingReveal();
571
+ startIntervalMorph(h, half);
572
+ };
573
+ intervalMorphRaf.current = requestAnimationFrame(step);
574
+ },
575
+ [startIntervalMorph],
576
+ );
577
+
513
578
  // Stops an in-flight stream animation and puts the chart somewhere coherent.
514
579
  //
515
580
  // A pending window shift always lands on its target: abandoned mid-slide it
@@ -606,6 +671,17 @@ export function useChartCore(
606
671
  const startMs = visibleRange?.startMs ?? 0;
607
672
  const endMs = visibleRange?.endMs ?? 0;
608
673
 
674
+ // The core trusts `setLoading` outright, so the "and no data yet" half of the
675
+ // condition is decided here. Both halves matter: without `loading` a chart
676
+ // that legitimately has no bars would wave a placeholder forever, and without
677
+ // the emptiness check a background refresh of a loaded series would blank the
678
+ // chart the user is already reading.
679
+ const showLoadingLine = loading === true && candles.length === 0;
680
+ // Tracks whether the *core* is currently showing the line, which is what
681
+ // decides if the next data push is a hand-off. Distinct from `showLoadingLine`:
682
+ // that is this render's intent, this is what's on screen.
683
+ const lineUpRef = useRef(false);
684
+
609
685
  // Stable deps so inline `theme={{...}}` / `rsi={{...}}` literals don't re-run
610
686
  // the effect every render — only when the actual values change.
611
687
  const themeKey = theme ? JSON.stringify(theme) : '';
@@ -645,6 +721,31 @@ export function useChartCore(
645
721
  // the viewport: a stream leaves it alone, a timeframe switch re-anchors and
646
722
  // morphs into it, a different asset resets it.
647
723
  let morphing = false;
724
+
725
+ if (showLoadingLine) {
726
+ // Clear the core's buffer, which the `candles.length > 0` gate below
727
+ // otherwise never does: pushing an empty array is treated as "hold the
728
+ // last frame" everywhere else, so a chart switching assets would still be
729
+ // holding the previous one's bars underneath the line — and would
730
+ // classify the incoming series as a timeframe switch rather than a fresh
731
+ // load. Scoped to the loading case so that hold-the-last-frame behavior
732
+ // is untouched for every other empty push.
733
+ if (!lineUpRef.current) {
734
+ endIntervalMorph();
735
+ settleStream();
736
+ h.setCandles(packCandles([]));
737
+ prevDataRef.current = null;
738
+ }
739
+ h.setLoading(true, !animRef.current.reduceMotion);
740
+ lineUpRef.current = true;
741
+ } else if (lineUpRef.current && candles.length === 0) {
742
+ // Loading resolved to nothing — an empty result, or an error the consumer
743
+ // handled. There's no geometry to morph into, so drop the line rather
744
+ // than leaving it waving at data that isn't coming.
745
+ h.setLoading(false, true);
746
+ lineUpRef.current = false;
747
+ }
748
+
648
749
  if (candles.length > 0) {
649
750
  const prev = prevDataRef.current;
650
751
  const freshHandle = prev == null || prev.handle !== h;
@@ -667,6 +768,8 @@ export function useChartCore(
667
768
  } | null = null;
668
769
  // The pre-swap candle envelope, used to scale-lock the y-axis below.
669
770
  let prevEnvelope: { low: number; high: number } | null = null;
771
+ // Set when this push is the loading line's hand-off to real data.
772
+ let handOff = false;
670
773
  // Set for an animated live update: whether the last bar reshapes, and
671
774
  // the window an appended bar should pull the view to.
672
775
  let stream: { morph: boolean; window: VisibleRange | null } | null = null;
@@ -747,6 +850,22 @@ export function useChartCore(
747
850
  endIntervalMorph();
748
851
  }
749
852
 
853
+ // The loading line's data has landed, so it hands over to the series
854
+ // instead of the chart cutting to it. Always classified 'initial' (the
855
+ // loading branch above cleared prevDataRef), so this runs after that
856
+ // branch's endIntervalMorph.
857
+ if (lineUpRef.current) {
858
+ lineUpRef.current = false;
859
+ handOff =
860
+ animRef.current.ms > 0 &&
861
+ !animRef.current.reduceMotion &&
862
+ onFrameRef.current != null;
863
+ // Snap path only. The hand-off itself starts after setCandles and the
864
+ // framing below: both its stages aim at where the candles will
865
+ // actually sit, so neither can be set up until they're there.
866
+ if (!handOff) h.setLoading(false, true);
867
+ }
868
+
750
869
  h.setCandles(packCandles(candles));
751
870
 
752
871
  if (transitionKind === 'timeframe') {
@@ -778,6 +897,9 @@ export function useChartCore(
778
897
  } else if (transitionKind === 'reset') {
779
898
  h.resetView();
780
899
  }
900
+ // After setCandles and the framing above, so the line aims at — and the
901
+ // candles grow from — the geometry each bar will actually occupy.
902
+ if (handOff) startLoadingHandoff(h);
781
903
  prevDataRef.current = { handle: h, candles, seriesKey };
782
904
  }
783
905
  }
@@ -827,7 +949,7 @@ export function useChartCore(
827
949
  // fairValueGaps/volume/priceLines/footprints are represented by their *Key
828
950
  // deps.
829
951
  // eslint-disable-next-line react-hooks/exhaustive-deps
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]);
952
+ }, [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]);
831
953
 
832
954
  return { handle: handleRef.current, picture, volumeCollapseRef };
833
955
  }
@@ -1,24 +0,0 @@
1
- // JNI entry point for VroomChartModule.install() (see
2
- // ../java/com/vroom/chart/VroomChartModule.kt). This is the Android
3
- // equivalent of ../../ios/VroomChartModule.mm's `-install` method: it
4
- // reinterprets the JSI runtime pointer handed over from Java and calls the
5
- // same platform-agnostic vroom::installJsi() the iOS bridge uses.
6
-
7
- #include <jni.h>
8
-
9
- #include <jsi/jsi.h>
10
-
11
- #include "VroomJsiInstaller.h"
12
-
13
- extern "C" JNIEXPORT jboolean JNICALL
14
- Java_com_vroom_chart_VroomChartModule_nativeInstall(JNIEnv* /*env*/,
15
- jobject /*thiz*/,
16
- jlong runtimePointer) {
17
- auto* runtime =
18
- reinterpret_cast<facebook::jsi::Runtime*>(runtimePointer);
19
- if (runtime == nullptr) {
20
- return JNI_FALSE;
21
- }
22
- vroom::installJsi(*runtime);
23
- return JNI_TRUE;
24
- }