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.
- package/cpp/VroomChartHostObject.cpp +118 -1
- package/cpp/_core_include/vroom/vroom_chart.h +84 -0
- package/cpp/_core_src/chart.cpp +16 -0
- package/cpp/_core_src/chart.h +23 -0
- package/cpp/_core_src/chart_facade.cpp +108 -0
- package/cpp/_core_src/footprints.cpp +226 -0
- package/cpp/_core_src/footprints.h +45 -0
- package/cpp/_core_src/footprints_layout.cpp +140 -0
- package/cpp/_core_src/footprints_layout.h +94 -0
- package/cpp/_core_src/tip_pulse.h +4 -4
- package/lib/index.d.mts +135 -1
- package/lib/index.d.ts +135 -1
- package/lib/index.js +83 -3
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +83 -3
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +87 -4
- package/src/index.ts +5 -0
- package/src/jsi.d.ts +39 -0
- package/src/types.ts +5 -0
- package/src/useChartCore.ts +49 -3
package/src/VroomChart.tsx
CHANGED
|
@@ -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,
|
|
757
|
-
// crosshair while it's up. Any other tap is a
|
|
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
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
package/src/useChartCore.ts
CHANGED
|
@@ -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
|
|
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
|
}
|