react-native-vroom-chart 0.14.0 → 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
  }
@@ -80,6 +83,9 @@ export function VroomChart(props: VroomChartProps) {
80
83
  onPriceLineDrag,
81
84
  onPriceLineDragEnd,
82
85
  onPriceLineClose,
86
+ footprints,
87
+ footprintsStyle,
88
+ onFootprint,
83
89
  } = props;
84
90
 
85
91
  // Fill the parent by default: measure via onLayout. Explicit width/height
@@ -111,6 +117,11 @@ export function VroomChart(props: VroomChartProps) {
111
117
  [priceLines, priceLinesStyle, onPriceLineClose],
112
118
  );
113
119
 
120
+ const footprintsProp = useMemo(
121
+ () => (footprints ? { prints: footprints, style: footprintsStyle } : undefined),
122
+ [footprints, footprintsStyle],
123
+ );
124
+
114
125
  // RN-Skia's recorder reads these SharedValues on the UI/render runtime, a
115
126
  // beat behind JS-thread writes. If it ever reads null it throws ("Invalid
116
127
  // prop value for SkTextBlob received" — RN-Skia's mislabeled SkPicture
@@ -173,6 +184,7 @@ export function VroomChart(props: VroomChartProps) {
173
184
  bollingerBands,
174
185
  volume,
175
186
  priceLinesProp,
187
+ footprintsProp,
176
188
  { seriesKey, transitionMs, transitionEasing, intervalTransition, reduceMotion, onFrame },
177
189
  );
178
190
 
@@ -181,6 +193,11 @@ export function VroomChart(props: VroomChartProps) {
181
193
  // synchronously without re-subscribing. Tap dismisses it.
182
194
  const crosshairActive = useRef(false);
183
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
+
184
201
  // timeMs of the candle last reported through onCrosshair, so a drag fires a
185
202
  // 'move' event only when it crosses into a *different* candle (one per
186
203
  // candle, not per frame). Null while the crosshair is hidden.
@@ -539,6 +556,31 @@ export function VroomChart(props: VroomChartProps) {
539
556
  'chart' | 'price-axis' | 'time-axis' | 'indicator' | 'price-line'
540
557
  >('chart');
541
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
+
542
584
  const pan = Gesture.Pan()
543
585
  .runOnJS(true)
544
586
  .maxPointers(1) // don't fight Pinch's two-finger gesture
@@ -561,6 +603,10 @@ export function VroomChart(props: VroomChartProps) {
561
603
  if (p) applyFrame(p);
562
604
  }
563
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();
564
610
  })
565
611
  .onChange((e) => {
566
612
  if (!handle) return;
@@ -684,6 +730,7 @@ export function VroomChart(props: VroomChartProps) {
684
730
  .runOnJS(true)
685
731
  .onTouchesDown((e) => {
686
732
  if (e.numberOfTouches < 2) return;
733
+ dismissFootprint();
687
734
  const [a, b] = e.allTouches;
688
735
  const spanX = Math.abs(a.x - b.x);
689
736
  const spanY = Math.abs(a.y - b.y);
@@ -739,6 +786,10 @@ export function VroomChart(props: VroomChartProps) {
739
786
  // close button — so it must not raise the crosshair over the top.
740
787
  if (hitPriceLine(e.x, e.y)) return;
741
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);
742
793
  crosshairActive.current = true;
743
794
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
744
795
  if (ch) applyFrame(ch);
@@ -753,13 +804,45 @@ export function VroomChart(props: VroomChartProps) {
753
804
  });
754
805
  });
755
806
 
756
- // A tap activates a price line's close button, and otherwise dismisses the
757
- // crosshair while it's up. Any other tap is a no-op, so it never interferes
758
- // 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.
759
810
  const tap = Gesture.Tap()
760
811
  .runOnJS(true)
761
812
  .onStart((e) => {
762
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
+
763
846
  // The close button is a tap target whether or not the crosshair is up.
764
847
  const pl = hitPriceLine(e.x, e.y);
765
848
  if (pl && pl.part === 1) {
package/src/index.ts CHANGED
@@ -25,5 +25,10 @@ export type {
25
25
  IntervalTransition,
26
26
  PriceLine,
27
27
  PriceLinesStyle,
28
+ Footprint,
29
+ FootprintSide,
30
+ FootprintsStyle,
31
+ FootprintEvent,
32
+ PlotRect,
28
33
  DefaultDrawingStyle,
29
34
  } from './types';
package/src/jsi.d.ts CHANGED
@@ -325,6 +325,45 @@ export interface ChartHandle {
325
325
  * committed price is untouched — restate setPriceLines to apply the move.
326
326
  */
327
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;
328
367
  /** True while any axis-label fade is still in progress. Drives a RAF loop. */
329
368
  isAnimating(): boolean;
330
369
  render(): ChartFrame | null;
package/src/types.ts CHANGED
@@ -23,6 +23,11 @@ export type {
23
23
  IntervalTransition,
24
24
  PriceLine,
25
25
  PriceLinesStyle,
26
+ Footprint,
27
+ FootprintSide,
28
+ FootprintsStyle,
29
+ FootprintEvent,
30
+ PlotRect,
26
31
  DefaultDrawingStyle,
27
32
  } from '@vroomchart/types';
28
33
 
@@ -16,6 +16,8 @@ import type {
16
16
  MovingAverageOverlay,
17
17
  PriceLine,
18
18
  PriceLinesStyle,
19
+ Footprint,
20
+ FootprintsStyle,
19
21
  RSIConfig,
20
22
  TransitionEasing,
21
23
  IntervalTransition,
@@ -203,6 +205,45 @@ function priceLinesToSpec(cfg: PriceLinesProp) {
203
205
  // requires them).
204
206
  const EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
205
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
+
206
247
  let installed = false;
207
248
  function ensureInstalled(): void {
208
249
  if (installed) return;
@@ -268,6 +309,7 @@ export function useChartCore(
268
309
  bollingerBands?: BollingerBandsConfig,
269
310
  volume?: VolumeConfig,
270
311
  priceLines?: PriceLinesProp,
312
+ footprints?: FootprintsProp,
271
313
  transition?: TransitionOptions,
272
314
  ): ChartCoreState {
273
315
  const handleRef = useRef<ChartHandle | null>(null);
@@ -361,6 +403,7 @@ export function useChartCore(
361
403
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
362
404
  const volumeKey = volume ? JSON.stringify(volume) : '';
363
405
  const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
406
+ const footprintsKey = footprints ? JSON.stringify(footprints) : '';
364
407
 
365
408
  useEffect(() => {
366
409
  const h = handleRef.current;
@@ -492,6 +535,9 @@ export function useChartCore(
492
535
  h.setPriceLines(
493
536
  priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
494
537
  );
538
+ h.setFootprints(
539
+ footprints?.prints.length ? footprintsToSpec(footprints) : EMPTY_FOOTPRINTS,
540
+ );
495
541
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
496
542
  // the VroomBand structs in the JSI handle) — web-only for now.
497
543
  // A just-started morph is already pushing frames straight to the host sink;
@@ -499,10 +545,10 @@ export function useChartCore(
499
545
  // frame 0 is pixel-identical to what's on screen, so there's nothing to show
500
546
  // in the meantime anyway.
501
547
  if (!morphing) setPicture(h.render());
502
- // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines are
503
- // represented by their *Key deps.
548
+ // theme/rsi/macd/movingAverages/vwap/bollingerBands/volume/priceLines/
549
+ // footprints are represented by their *Key deps.
504
550
  // eslint-disable-next-line react-hooks/exhaustive-deps
505
- }, [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]);
506
552
 
507
553
  return { handle: handleRef.current, picture, volumeCollapseRef };
508
554
  }