react-native-vroom-chart 0.6.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.
Files changed (49) hide show
  1. package/cpp/VroomChartHostObject.cpp +281 -33
  2. package/cpp/_core_include/vroom/vroom_chart.h +231 -36
  3. package/cpp/_core_src/bollinger.h +1 -1
  4. package/cpp/_core_src/candles.cpp +138 -41
  5. package/cpp/_core_src/candles.h +11 -1
  6. package/cpp/_core_src/chart.cpp +91 -40
  7. package/cpp/_core_src/chart.h +69 -29
  8. package/cpp/_core_src/chart_facade.cpp +247 -69
  9. package/cpp/_core_src/drawings.cpp +147 -4
  10. package/cpp/_core_src/drawings.h +10 -5
  11. package/cpp/_core_src/gradient.cpp +46 -0
  12. package/cpp/_core_src/gradient.h +31 -0
  13. package/cpp/_core_src/labels.cpp +49 -16
  14. package/cpp/_core_src/labels.h +33 -4
  15. package/cpp/_core_src/liquidity.cpp +3 -37
  16. package/cpp/_core_src/ma_overlay.cpp +174 -12
  17. package/cpp/_core_src/ma_overlay.h +55 -3
  18. package/cpp/_core_src/macd.cpp +13 -42
  19. package/cpp/_core_src/macd.h +11 -8
  20. package/cpp/_core_src/macd_pane.cpp +57 -27
  21. package/cpp/_core_src/price_line_layout.h +1 -1
  22. package/cpp/_core_src/rsi.cpp +4 -18
  23. package/cpp/_core_src/rsi.h +6 -6
  24. package/cpp/_core_src/rsi_pane.cpp +34 -19
  25. package/cpp/_core_src/series_ma.cpp +64 -0
  26. package/cpp/_core_src/series_ma.h +30 -0
  27. package/cpp/_core_src/style_inherit.h +35 -0
  28. package/cpp/_core_src/theme.cpp +2 -1
  29. package/cpp/_core_src/viewport.cpp +36 -12
  30. package/cpp/_core_src/viewport.h +50 -0
  31. package/cpp/_core_src/volume.cpp +33 -8
  32. package/cpp/_core_src/volume.h +12 -1
  33. package/cpp/_core_src/volume_anim.cpp +32 -0
  34. package/cpp/_core_src/volume_anim.h +41 -0
  35. package/lib/index.d.mts +206 -31
  36. package/lib/index.d.ts +206 -31
  37. package/lib/index.js +324 -44
  38. package/lib/index.js.map +1 -1
  39. package/lib/index.mjs +329 -52
  40. package/lib/index.mjs.map +1 -1
  41. package/package.json +1 -1
  42. package/src/VroomChart.tsx +100 -17
  43. package/src/dataTransitions.ts +148 -0
  44. package/src/easing.ts +40 -0
  45. package/src/index.ts +9 -0
  46. package/src/jsi.d.ts +126 -19
  47. package/src/theme.ts +1 -0
  48. package/src/types.ts +3 -0
  49. package/src/useChartCore.ts +273 -30
@@ -1,7 +1,11 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { MutableRefObject } from 'react';
2
3
  import type { SkPicture } from '@shopify/react-native-skia';
3
4
 
4
5
  import NativeVroomChart from './NativeVroomChart';
6
+ import type { DataTransition } from './dataTransitions';
7
+ import { classifyTransition, inferStepMs, timeframeWindow } from './dataTransitions';
8
+ import { ease } from './easing';
5
9
  import type { ChartHandle } from './jsi.d';
6
10
  import { packCandles } from './packCandles';
7
11
  import { applyTheme, parseColor } from './theme';
@@ -14,7 +18,9 @@ import type {
14
18
  PriceLine,
15
19
  PriceLinesStyle,
16
20
  RSIConfig,
21
+ TransitionEasing,
17
22
  VisibleRange,
23
+ VolumeConfig,
18
24
  VroomTheme,
19
25
  VWAPConfig,
20
26
  } from './types';
@@ -30,17 +36,49 @@ const MA_SOURCES = [
30
36
  'ohlc4',
31
37
  ] as const;
32
38
 
39
+ // An unset style color marshals as the core's transparent inherit sentinel.
40
+ const inheritColor = (v: string | number | undefined): number =>
41
+ (v != null ? parseColor(v) : null) ?? 0;
42
+
33
43
  function overlayToNumeric(o: MovingAverageOverlay) {
34
44
  const srcIdx = o.source ? MA_SOURCES.indexOf(o.source) : 0;
35
45
  return {
36
- kind: o.kind === 'ema' ? 1 : 0,
37
- period: o.length,
46
+ kind: o.maType === 'ema' ? 1 : 0,
47
+ period: o.period,
38
48
  source: srcIdx < 0 ? 0 : srcIdx,
39
49
  color: (o.color != null ? parseColor(o.color) : null) ?? 0xff2962ff,
40
50
  width: o.width ?? 1.5,
41
51
  };
42
52
  }
43
53
 
54
+ function rsiToSpec(cfg: RSIConfig | undefined) {
55
+ return {
56
+ enabled: cfg?.enabled ?? false,
57
+ period: cfg?.period ?? 14,
58
+ upperBand: cfg?.upperBand ?? 70,
59
+ lowerBand: cfg?.lowerBand ?? 30,
60
+ maPeriod: cfg?.maPeriod ?? 14,
61
+ maKind: cfg?.maType === 'ema' ? 1 : 0,
62
+ maVisible: cfg?.maVisible ?? true,
63
+ lineColor: inheritColor(cfg?.lineColor),
64
+ lineWidth: cfg?.lineWidth ?? -1,
65
+ lineVisible: cfg?.lineVisible ?? true,
66
+ maColor: inheritColor(cfg?.maColor),
67
+ maWidth: cfg?.maWidth ?? -1,
68
+ bandColor: inheritColor(cfg?.bandColor),
69
+ bandsVisible: cfg?.bandsVisible ?? true,
70
+ };
71
+ }
72
+
73
+ function vwapToSpec(cfg: VWAPConfig | undefined) {
74
+ return {
75
+ enabled: cfg?.enabled ?? false,
76
+ resetOffsetMin: cfg?.resetMinutes ?? 0,
77
+ color: inheritColor(cfg?.color),
78
+ width: cfg?.width ?? -1,
79
+ };
80
+ }
81
+
44
82
  // Bollinger defaults: blue bands / orange basis, matching the repo palette.
45
83
  const DEFAULT_BB_BAND_COLOR = 0xff2962ff;
46
84
  const DEFAULT_BB_BASIS_COLOR = 0xffff6d00;
@@ -52,7 +90,7 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
52
90
  period: cfg?.period ?? 20,
53
91
  mult: cfg?.stdDev ?? 2,
54
92
  source: srcIdx < 0 ? 0 : srcIdx,
55
- basisKind: cfg?.basis === 'ema' ? 1 : 0,
93
+ basisKind: cfg?.maType === 'ema' ? 1 : 0,
56
94
  upperColor:
57
95
  (cfg?.upperColor != null ? parseColor(cfg.upperColor) : null) ??
58
96
  DEFAULT_BB_BAND_COLOR,
@@ -65,11 +103,51 @@ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
65
103
  (cfg?.lowerColor != null ? parseColor(cfg.lowerColor) : null) ??
66
104
  DEFAULT_BB_BAND_COLOR,
67
105
  lowerWidth: cfg?.lowerWidth ?? 1,
68
- fillEnabled: cfg?.fill ?? true,
106
+ fillEnabled: cfg?.fillVisible ?? true,
69
107
  fillOpacity: cfg?.fillOpacity ?? 0.1,
70
108
  };
71
109
  }
72
110
 
111
+ function macdToSpec(cfg: MACDConfig | undefined) {
112
+ const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
113
+ return {
114
+ enabled: cfg?.enabled ?? false,
115
+ fast: cfg?.fast ?? 12,
116
+ slow: cfg?.slow ?? 26,
117
+ signal: cfg?.signal ?? 9,
118
+ source: srcIdx < 0 ? 0 : srcIdx,
119
+ maKind: cfg?.maType === 'sma' ? 0 : 1,
120
+ signalMaKind: cfg?.signalMaType === 'sma' ? 0 : 1,
121
+ lineColor: inheritColor(cfg?.lineColor),
122
+ lineWidth: cfg?.lineWidth ?? -1,
123
+ lineVisible: cfg?.lineVisible ?? true,
124
+ signalColor: inheritColor(cfg?.signalColor),
125
+ signalWidth: cfg?.signalWidth ?? -1,
126
+ signalVisible: cfg?.signalVisible ?? true,
127
+ histVisible: cfg?.histogramVisible ?? true,
128
+ histUpColor: inheritColor(cfg?.histogramUpColor),
129
+ histUpFadingColor: inheritColor(cfg?.histogramUpFadingColor),
130
+ histDownColor: inheritColor(cfg?.histogramDownColor),
131
+ histDownFadingColor: inheritColor(cfg?.histogramDownFadingColor),
132
+ zeroColor: inheritColor(cfg?.zeroLineColor),
133
+ zeroVisible: cfg?.zeroLineVisible ?? true,
134
+ };
135
+ }
136
+
137
+ // Unset style fields go down as the core's inherit sentinels (negative float,
138
+ // transparent color) rather than as literal defaults, so the theme keys stay in
139
+ // charge of anything the consumer didn't set.
140
+ function volumeToSpec(cfg: VolumeConfig | undefined) {
141
+ return {
142
+ enabled: cfg?.enabled ?? true,
143
+ heightFrac: cfg?.height ?? -1,
144
+ opacity: cfg?.opacity ?? -1,
145
+ radiusPx: cfg?.radius ?? -1,
146
+ upColor: (cfg?.upColor != null ? parseColor(cfg.upColor) : null) ?? 0,
147
+ downColor: (cfg?.downColor != null ? parseColor(cfg.downColor) : null) ?? 0,
148
+ };
149
+ }
150
+
73
151
  // Price-line defaults: a soft red dotted rule with a dark translucent label,
74
152
  // close in weight to the current-price indicator it sits beside.
75
153
  const DEFAULT_PRICE_LINE_COLOR = 0xffef5350;
@@ -136,10 +214,38 @@ function ensureInstalled(): void {
136
214
  installed = true;
137
215
  }
138
216
 
217
+ /** Progress + curve of the staggered volume-bar collapse. See setVolumeCollapse. */
218
+ export type VolumeCollapse = { t: number; easing: number };
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
+
139
239
  export type ChartCoreState = {
140
240
  handle: ChartHandle | null;
141
241
  /** Picture freshly rendered after the latest data/size/range push. */
142
242
  picture: SkPicture | null;
243
+ /**
244
+ * The last volume collapse handed to the core, or null before the first push.
245
+ * VroomChart's animation loop owns this — it lives here only so the data effect
246
+ * can restore it, since setVolume snaps the scalar (see below).
247
+ */
248
+ volumeCollapseRef: MutableRefObject<VolumeCollapse | null>;
143
249
  };
144
250
 
145
251
  // Owns a ChartHandle and produces an "initial" picture whenever data, size,
@@ -158,13 +264,24 @@ export function useChartCore(
158
264
  movingAverages?: MovingAverageOverlay[],
159
265
  vwap?: VWAPConfig,
160
266
  bollingerBands?: BollingerBandsConfig,
267
+ volume?: VolumeConfig,
161
268
  priceLines?: PriceLinesProp,
269
+ transition?: TransitionOptions,
162
270
  ): ChartCoreState {
163
271
  const handleRef = useRef<ChartHandle | null>(null);
164
272
  // Push setDefaultCandleWidth only once (first load): setCandles re-runs on
165
273
  // every data change, and the core setter re-frames when candles are present,
166
274
  // so re-pushing would snap the view away from the user's pan/zoom.
167
275
  const defaultWidthAppliedRef = useRef(false);
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);
168
285
  const [picture, setPicture] = useState<SkPicture | null>(null);
169
286
 
170
287
  if (!handleRef.current && size.width > 0 && size.height > 0) {
@@ -172,6 +289,56 @@ export function useChartCore(
172
289
  handleRef.current = globalThis.VroomChartJSI!.create();
173
290
  }
174
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
+
175
342
  // When no visibleRange is provided, leave the range entirely to the C++
176
343
  // side (which defaults to a sensible recent window on first setCandles).
177
344
  // Only push setVisibleRange when the caller is actively controlling it,
@@ -188,6 +355,7 @@ export function useChartCore(
188
355
  const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
189
356
  const vwapKey = vwap ? JSON.stringify(vwap) : '';
190
357
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
358
+ const volumeKey = volume ? JSON.stringify(volume) : '';
191
359
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
192
360
 
193
361
  useEffect(() => {
@@ -206,8 +374,89 @@ export function useChartCore(
206
374
  h.setDefaultCandleWidth(defaultCandleWidth);
207
375
  defaultWidthAppliedRef.current = true;
208
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;
209
381
  if (candles.length > 0) {
210
- 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
+ }
211
460
  }
212
461
  if (explicit) {
213
462
  h.setVisibleRange(startMs, endMs);
@@ -217,38 +466,32 @@ export function useChartCore(
217
466
  if (theme) {
218
467
  applyTheme(h, theme);
219
468
  }
220
- h.setRSI(
221
- rsi?.enabled ?? false,
222
- rsi?.period ?? 14,
223
- rsi?.upperBand ?? 70,
224
- rsi?.lowerBand ?? 30,
225
- rsi?.maEnabled ?? true,
226
- rsi?.maPeriod ?? 14,
227
- );
228
- h.setMACD(
229
- macd?.enabled ?? false,
230
- macd?.fast ?? 12,
231
- macd?.slow ?? 26,
232
- macd?.signal ?? 9,
233
- );
469
+ h.setRSI(rsiToSpec(rsi));
470
+ h.setMACD(macdToSpec(macd));
234
471
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
235
- h.setVWAP(
236
- vwap?.enabled ?? false,
237
- vwap?.resetMinutes ?? 0,
238
- (vwap?.color != null ? parseColor(vwap.color) : null) ?? 0xff00bcd4,
239
- vwap?.width ?? 1.5,
240
- );
472
+ h.setVWAP(vwapToSpec(vwap));
241
473
  h.setBollinger(bollingerToSpec(bollingerBands));
474
+ h.setVolume(volumeToSpec(volume));
475
+ // setVolume snaps the collapse scalar to its `enabled`, which would cut a
476
+ // toggle animation short whenever this effect re-runs (a streaming candle, a
477
+ // resize). Hand the in-flight value back; VroomChart's loop drives it from
478
+ // there.
479
+ const collapse = volumeCollapseRef.current;
480
+ if (collapse) h.setVolumeCollapse(collapse.t, collapse.easing);
242
481
  h.setPriceLines(
243
482
  priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
244
483
  );
245
484
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
246
485
  // the VroomBand structs in the JSI handle) — web-only for now.
247
- setPicture(h.render());
248
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/priceLines are
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());
491
+ // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
249
492
  // represented by their *Key deps.
250
493
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
- }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, priceLinesKey]);
494
+ }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
252
495
 
253
- return { handle: handleRef.current, picture };
496
+ return { handle: handleRef.current, picture, volumeCollapseRef };
254
497
  }