react-native-vroom-chart 0.13.1 → 0.15.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.
@@ -36,9 +36,12 @@ import { useReducedMotion, useSharedValue } from 'react-native-reanimated';
36
36
  import { useChartCore } from './useChartCore';
37
37
  import { ease, easingIndex } from './easing';
38
38
  import type { ChartFrame } from './jsi.d';
39
- import type { VroomChartProps } from './types';
39
+ import type { Footprint, VroomChartProps } from './types';
40
40
  import './jsi.d';
41
41
 
42
+ // Mirrors VroomFootprintSide in packages/core/include/vroom/vroom_chart.h.
43
+ const FOOTPRINT_SELL = 1;
44
+
42
45
  function isSkImage(frame: ChartFrame): frame is SkImage {
43
46
  return typeof (frame as SkImage).getImageInfo === 'function';
44
47
  }
@@ -64,6 +67,7 @@ export function VroomChart(props: VroomChartProps) {
64
67
  chartType,
65
68
  transitionMs,
66
69
  transitionEasing,
70
+ intervalTransition,
67
71
  theme,
68
72
  rsi,
69
73
  macd,
@@ -79,6 +83,9 @@ export function VroomChart(props: VroomChartProps) {
79
83
  onPriceLineDrag,
80
84
  onPriceLineDragEnd,
81
85
  onPriceLineClose,
86
+ footprints,
87
+ footprintsStyle,
88
+ onFootprint,
82
89
  } = props;
83
90
 
84
91
  // Fill the parent by default: measure via onLayout. Explicit width/height
@@ -110,6 +117,11 @@ export function VroomChart(props: VroomChartProps) {
110
117
  [priceLines, priceLinesStyle, onPriceLineClose],
111
118
  );
112
119
 
120
+ const footprintsProp = useMemo(
121
+ () => (footprints ? { prints: footprints, style: footprintsStyle } : undefined),
122
+ [footprints, footprintsStyle],
123
+ );
124
+
113
125
  // RN-Skia's recorder reads these SharedValues on the UI/render runtime, a
114
126
  // beat behind JS-thread writes. If it ever reads null it throws ("Invalid
115
127
  // prop value for SkTextBlob received" — RN-Skia's mislabeled SkPicture
@@ -172,7 +184,8 @@ export function VroomChart(props: VroomChartProps) {
172
184
  bollingerBands,
173
185
  volume,
174
186
  priceLinesProp,
175
- { seriesKey, transitionMs, transitionEasing, reduceMotion, onFrame },
187
+ footprintsProp,
188
+ { seriesKey, transitionMs, transitionEasing, intervalTransition, reduceMotion, onFrame },
176
189
  );
177
190
 
178
191
  // When the crosshair is showing, pan moves it (instead of scrolling) and
@@ -180,6 +193,11 @@ export function VroomChart(props: VroomChartProps) {
180
193
  // synchronously without re-subscribing. Tap dismisses it.
181
194
  const crosshairActive = useRef(false);
182
195
 
196
+ // Whether a footprint badge is currently open, so a tap that misses every badge
197
+ // knows whether it has a tooltip to dismiss. A ref for the same reason as
198
+ // crosshairActive: gesture callbacks read it synchronously.
199
+ const footprintActive = useRef(false);
200
+
183
201
  // timeMs of the candle last reported through onCrosshair, so a drag fires a
184
202
  // 'move' event only when it crosses into a *different* candle (one per
185
203
  // candle, not per frame). Null while the crosshair is hidden.
@@ -538,6 +556,31 @@ export function VroomChart(props: VroomChartProps) {
538
556
  'chart' | 'price-axis' | 'time-axis' | 'indicator' | 'price-line'
539
557
  >('chart');
540
558
 
559
+ // Closes an open footprint tooltip. Any viewport change slides the candles out
560
+ // from under it and the crosshair replaces it outright, so the host is told to
561
+ // take it down rather than left holding a position the badge has moved away
562
+ // from. `redraw` is false for callers that render a frame of their own right
563
+ // after — on Android that render rasterizes pixels, so the duplicate is worth
564
+ // skipping.
565
+ const dismissFootprint = (redraw = true) => {
566
+ if (!handle || !footprintActive.current) return;
567
+ footprintActive.current = false;
568
+ handle.setFootprintHover(0, -1);
569
+ if (redraw) {
570
+ const frame = handle.render();
571
+ if (frame) applyFrame(frame);
572
+ }
573
+ onFootprint?.({
574
+ active: false,
575
+ reason: 'hide',
576
+ side: null,
577
+ timeMs: null,
578
+ footprints: [],
579
+ badge: null,
580
+ pane: null,
581
+ });
582
+ };
583
+
541
584
  const pan = Gesture.Pan()
542
585
  .runOnJS(true)
543
586
  .maxPointers(1) // don't fight Pinch's two-finger gesture
@@ -560,6 +603,10 @@ export function VroomChart(props: VroomChartProps) {
560
603
  if (p) applyFrame(p);
561
604
  }
562
605
  }
606
+ // Every mode but a price-line drag moves the viewport, and this one call
607
+ // covers the momentum fling too — decay only ever starts from a pan that
608
+ // already began here.
609
+ if (panMode.current !== 'price-line') dismissFootprint();
563
610
  })
564
611
  .onChange((e) => {
565
612
  if (!handle) return;
@@ -683,6 +730,7 @@ export function VroomChart(props: VroomChartProps) {
683
730
  .runOnJS(true)
684
731
  .onTouchesDown((e) => {
685
732
  if (e.numberOfTouches < 2) return;
733
+ dismissFootprint();
686
734
  const [a, b] = e.allTouches;
687
735
  const spanX = Math.abs(a.x - b.x);
688
736
  const spanY = Math.abs(a.y - b.y);
@@ -738,6 +786,10 @@ export function VroomChart(props: VroomChartProps) {
738
786
  // close button — so it must not raise the crosshair over the top.
739
787
  if (hitPriceLine(e.x, e.y)) return;
740
788
  cancelDecay();
789
+ // The crosshair takes the pane over, so it can't share it with a tooltip.
790
+ // No redraw: setCrosshair below returns a frame that already has the badge
791
+ // un-highlighted.
792
+ dismissFootprint(false);
741
793
  crosshairActive.current = true;
742
794
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
743
795
  if (ch) applyFrame(ch);
@@ -752,13 +804,45 @@ export function VroomChart(props: VroomChartProps) {
752
804
  });
753
805
  });
754
806
 
755
- // A tap activates a price line's close button, and otherwise dismisses the
756
- // crosshair while it's up. Any other tap is a no-op, so it never interferes
757
- // with normal pan/pinch.
807
+ // A tap activates a price line's close button, selects or dismisses a footprint
808
+ // badge, and otherwise dismisses the crosshair while it's up. Any other tap is a
809
+ // no-op, so it never interferes with normal pan/pinch.
758
810
  const tap = Gesture.Tap()
759
811
  .runOnJS(true)
760
812
  .onStart((e) => {
761
813
  if (!handle) return;
814
+
815
+ // Badges get first refusal: one is a ~9px circle, while a price line's grab
816
+ // band spans the pane and would otherwise swallow any badge it crosses.
817
+ // Touch has no hover, so a tap is what opens a footprint here, and the next
818
+ // tap anywhere closes it.
819
+ const prints = footprints ?? [];
820
+ const fp = prints.length ? handle.hitTestFootprint(e.x, e.y) : null;
821
+ if (fp) {
822
+ handle.setFootprintHover(fp.candleTimeMs, fp.side);
823
+ const frame = handle.render();
824
+ if (frame) applyFrame(frame);
825
+ const wasActive = footprintActive.current;
826
+ footprintActive.current = true;
827
+ onFootprint?.({
828
+ active: true,
829
+ reason: wasActive ? 'move' : 'show',
830
+ side: fp.side === FOOTPRINT_SELL ? 'sell' : 'buy',
831
+ timeMs: fp.candleTimeMs,
832
+ // The core reports indices into the array we last pushed, which is this
833
+ // same prop — so this rejoins each badge to the consumer's own objects.
834
+ footprints: fp.indices
835
+ .map((i) => prints[i])
836
+ .filter((f): f is Footprint => f != null),
837
+ badge: { x: fp.x, y: fp.y, radius: fp.radius },
838
+ pane: fp.pane,
839
+ });
840
+ return;
841
+ }
842
+ // A tap that missed every badge dismisses the open one, so the host tooltip
843
+ // goes away the same way the crosshair does.
844
+ dismissFootprint();
845
+
762
846
  // The close button is a tap target whether or not the crosshair is up.
763
847
  const pl = hitPriceLine(e.x, e.y);
764
848
  if (pl && pl.part === 1) {
package/src/index.ts CHANGED
@@ -22,6 +22,13 @@ export type {
22
22
  VolumeConfig,
23
23
  ChartType,
24
24
  TransitionEasing,
25
+ IntervalTransition,
25
26
  PriceLine,
26
27
  PriceLinesStyle,
28
+ Footprint,
29
+ FootprintSide,
30
+ FootprintsStyle,
31
+ FootprintEvent,
32
+ PlotRect,
33
+ DefaultDrawingStyle,
27
34
  } from './types';
package/src/jsi.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Ambient declaration for the JSI HostObject installed by VroomChartModule.
2
2
 
3
3
  import type { SkImage, SkPicture } from '@shopify/react-native-skia';
4
+ import type { IntervalTransition } from '@vroomchart/types';
4
5
 
5
6
  /** Frame the JSI handle returns: an SkPicture on iOS, an SkImage on Android. */
6
7
  export type ChartFrame = SkPicture | SkImage;
@@ -65,15 +66,12 @@ export interface ChartHandle {
65
66
  */
66
67
  preservePriceEnvelope(prevLow: number, prevHigh: number): void;
67
68
  /**
68
- * Capture the visible candle geometry so the next data swap can animate as a
69
- * reshape rather than a jump: each candle's wick and body slide and stretch
70
- * into the shape of its counterpart in the new data.
71
- *
72
- * Candles are paired by *slot* — position counting back from the right edge of
73
- * the visible window, which a timeframe switch preserves. Call before
74
- * setCandles, then drive setIntervalMorph from 0 to 1.
69
+ * Capture the visible candle geometry so the next data swap can animate.
70
+ * `'transform'` (default) lerps each slot into its counterpart; `'fade'`
71
+ * fades the capture out then the new scene in. Call before setCandles,
72
+ * then drive setIntervalMorph from 0 to 1.
75
73
  */
76
- beginIntervalMorph(): void;
74
+ beginIntervalMorph(mode?: IntervalTransition): void;
77
75
  /**
78
76
  * Advance the interval morph started by beginIntervalMorph. `t` (clamped to
79
77
  * 0..1) is the eased progress: 0 renders the captured geometry pixel-
@@ -327,6 +325,45 @@ export interface ChartHandle {
327
325
  * committed price is untouched — restate setPriceLines to apply the move.
328
326
  */
329
327
  setPriceLineDrag(index: number, price: number): void;
328
+ /**
329
+ * Replaces the full set of footprints (plus their shared style). `side` is
330
+ * 0=buy, 1=sell; `timeMs` is the raw execution time — the core buckets each
331
+ * trade onto whichever candle's window contains it and regroups whenever the
332
+ * candles change. Geometry fields at 0 take the core's defaults. Pass an empty
333
+ * `prints` array to clear.
334
+ */
335
+ setFootprints(spec: {
336
+ prints: { timeMs: number; side: number }[];
337
+ radiusPx: number;
338
+ gapPx: number;
339
+ marginPx: number;
340
+ hoverBoost: number;
341
+ }): void;
342
+ /**
343
+ * Hit-tests pixel (x, y) against the footprint badges; null on a miss, nearest
344
+ * center wins when two overlap. `indices` addresses the array last passed to
345
+ * setFootprints and covers *both* sides of that candle, ascending by time, so
346
+ * one call is enough to fill a tooltip; `pane` is the plot rect, for deciding
347
+ * which side of the badge that tooltip fits on. Cheap to call at gesture rate.
348
+ */
349
+ hitTestFootprint(
350
+ x: number,
351
+ y: number,
352
+ ): {
353
+ side: number;
354
+ candleTimeMs: number;
355
+ x: number;
356
+ y: number;
357
+ radius: number;
358
+ pane: { left: number; top: number; right: number; bottom: number };
359
+ indices: number[];
360
+ } | null;
361
+ /**
362
+ * Marks a footprint badge as hovered so it renders highlighted; side -1 clears.
363
+ * The arguments match hitTestFootprint. Touch has no hover, so on RN this
364
+ * tracks the badge the user last tapped.
365
+ */
366
+ setFootprintHover(candleTimeMs: number, side: number): void;
330
367
  /** True while any axis-label fade is still in progress. Drives a RAF loop. */
331
368
  isAnimating(): boolean;
332
369
  render(): ChartFrame | null;
package/src/types.ts CHANGED
@@ -20,8 +20,15 @@ export type {
20
20
  MACDConfig,
21
21
  ChartType,
22
22
  TransitionEasing,
23
+ IntervalTransition,
23
24
  PriceLine,
24
25
  PriceLinesStyle,
26
+ Footprint,
27
+ FootprintSide,
28
+ FootprintsStyle,
29
+ FootprintEvent,
30
+ PlotRect,
31
+ DefaultDrawingStyle,
25
32
  } from '@vroomchart/types';
26
33
 
27
34
  /**
@@ -16,8 +16,11 @@ import type {
16
16
  MovingAverageOverlay,
17
17
  PriceLine,
18
18
  PriceLinesStyle,
19
+ Footprint,
20
+ FootprintsStyle,
19
21
  RSIConfig,
20
22
  TransitionEasing,
23
+ IntervalTransition,
21
24
  VisibleRange,
22
25
  VolumeConfig,
23
26
  VroomTheme,
@@ -202,6 +205,45 @@ function priceLinesToSpec(cfg: PriceLinesProp) {
202
205
  // requires them).
203
206
  const EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
204
207
 
208
+ // Footprints share the price lines' hover weight so the two widgets light up
209
+ // alike. Zeroed geometry defers to the core's own defaults.
210
+ const DEFAULT_FOOTPRINT_HOVER_BOOST = 1.25;
211
+
212
+ // Mirrors VroomFootprintSide in packages/core/include/vroom/vroom_chart.h.
213
+ const FOOTPRINT_BUY = 0;
214
+ const FOOTPRINT_SELL = 1;
215
+
216
+ // A time no real series can contain (~273,000 BCE), still comfortably inside
217
+ // int64. Parks a malformed footprint where the core will never bucket it.
218
+ const UNBUCKETABLE_MS = -8.64e15;
219
+
220
+ /** The footprints + their shared style, as the chart's props express them. */
221
+ export type FootprintsProp = {
222
+ prints: Footprint[];
223
+ style?: FootprintsStyle;
224
+ };
225
+
226
+ function footprintsToSpec(cfg: FootprintsProp) {
227
+ return {
228
+ // Index alignment is load-bearing: the core reports hits as indices into this
229
+ // array and the gesture layer maps them straight back to the consumer's
230
+ // `footprints`. So a non-finite time — which can't be bucketed and would
231
+ // reach the native side as a garbage int64 — is neutralized *in place* rather
232
+ // than filtered out, which would shift every index after it onto the wrong
233
+ // trade.
234
+ prints: cfg.prints.map((f) => ({
235
+ timeMs: Number.isFinite(f.timeMs) ? f.timeMs : UNBUCKETABLE_MS,
236
+ side: f.side === 'sell' ? FOOTPRINT_SELL : FOOTPRINT_BUY,
237
+ })),
238
+ radiusPx: cfg.style?.radius ?? 0,
239
+ gapPx: cfg.style?.gap ?? 0,
240
+ marginPx: cfg.style?.margin ?? 0,
241
+ hoverBoost: cfg.style?.hoverBoost ?? DEFAULT_FOOTPRINT_HOVER_BOOST,
242
+ };
243
+ }
244
+
245
+ const EMPTY_FOOTPRINTS = footprintsToSpec({ prints: [] });
246
+
205
247
  let installed = false;
206
248
  function ensureInstalled(): void {
207
249
  if (installed) return;
@@ -229,6 +271,8 @@ export type TransitionOptions = {
229
271
  transitionMs?: number;
230
272
  /** Curve applied to the morph's progress. Default 'ease-in-out'. */
231
273
  transitionEasing?: TransitionEasing;
274
+ /** `'transform'` (default) slot-lerps; `'fade'` fades out then in. */
275
+ intervalTransition?: IntervalTransition;
232
276
  /** OS reduced-motion preference: skips the capture and snaps. */
233
277
  reduceMotion?: boolean;
234
278
  /** Receives every morph frame. Without one, data swaps snap. */
@@ -265,6 +309,7 @@ export function useChartCore(
265
309
  bollingerBands?: BollingerBandsConfig,
266
310
  volume?: VolumeConfig,
267
311
  priceLines?: PriceLinesProp,
312
+ footprints?: FootprintsProp,
268
313
  transition?: TransitionOptions,
269
314
  ): ChartCoreState {
270
315
  const handleRef = useRef<ChartHandle | null>(null);
@@ -295,11 +340,13 @@ export function useChartCore(
295
340
  ms: number;
296
341
  easing: TransitionEasing | undefined;
297
342
  reduceMotion: boolean;
298
- }>({ ms: 300, easing: undefined, reduceMotion: false });
343
+ interval: IntervalTransition;
344
+ }>({ ms: 300, easing: undefined, reduceMotion: false, interval: 'transform' });
299
345
  animRef.current = {
300
346
  ms: Math.max(0, transition?.transitionMs ?? 300),
301
347
  easing: transition?.transitionEasing,
302
348
  reduceMotion: transition?.reduceMotion ?? false,
349
+ interval: transition?.intervalTransition === 'fade' ? 'fade' : 'transform',
303
350
  };
304
351
  const onFrameRef = useRef(transition?.onFrame);
305
352
  onFrameRef.current = transition?.onFrame;
@@ -356,6 +403,7 @@ export function useChartCore(
356
403
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
357
404
  const volumeKey = volume ? JSON.stringify(volume) : '';
358
405
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
406
+ const footprintsKey = footprints ? JSON.stringify(footprints) : '';
359
407
 
360
408
  useEffect(() => {
361
409
  const h = handleRef.current;
@@ -419,7 +467,7 @@ export function useChartCore(
419
467
  onFrameRef.current != null;
420
468
  if (morphing) {
421
469
  endIntervalMorph();
422
- h.beginIntervalMorph();
470
+ h.beginIntervalMorph(animRef.current.interval);
423
471
  }
424
472
  } else if (transitionKind === 'initial' || transitionKind === 'reset') {
425
473
  // Wholesale reframing — the slot pairing no longer holds, so land any
@@ -487,6 +535,9 @@ export function useChartCore(
487
535
  h.setPriceLines(
488
536
  priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
489
537
  );
538
+ h.setFootprints(
539
+ footprints?.prints.length ? footprintsToSpec(footprints) : EMPTY_FOOTPRINTS,
540
+ );
490
541
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
491
542
  // the VroomBand structs in the JSI handle) — web-only for now.
492
543
  // A just-started morph is already pushing frames straight to the host sink;
@@ -494,10 +545,10 @@ export function useChartCore(
494
545
  // frame 0 is pixel-identical to what's on screen, so there's nothing to show
495
546
  // in the meantime anyway.
496
547
  if (!morphing) setPicture(h.render());
497
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
498
- // represented by their *Key deps.
548
+ // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines/
549
+ // footprints are represented by their *Key deps.
499
550
  // eslint-disable-next-line react-hooks/exhaustive-deps
500
- }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, startIntervalMorph, endIntervalMorph]);
551
+ }, [candles, seriesKey, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey, footprintsKey, startIntervalMorph, endIntervalMorph]);
501
552
 
502
553
  return { handle: handleRef.current, picture, volumeCollapseRef };
503
554
  }