react-native-vroom-chart 0.1.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/android/README.md +7 -0
  4. package/cpp/README.md +11 -0
  5. package/cpp/VroomChartHostObject.cpp +458 -0
  6. package/cpp/VroomChartHostObject.h +36 -0
  7. package/cpp/VroomJsiInstaller.cpp +69 -0
  8. package/cpp/VroomJsiInstaller.h +10 -0
  9. package/cpp/VroomSkiaContext.cpp +25 -0
  10. package/cpp/VroomSkiaContext.h +30 -0
  11. package/cpp/_core_include/vroom/vroom_chart.h +195 -0
  12. package/cpp/_core_src/candles.cpp +74 -0
  13. package/cpp/_core_src/candles.h +34 -0
  14. package/cpp/_core_src/chart.cpp +317 -0
  15. package/cpp/_core_src/chart.h +167 -0
  16. package/cpp/_core_src/chart_facade.cpp +570 -0
  17. package/cpp/_core_src/chart_internal.h +30 -0
  18. package/cpp/_core_src/crosshair.cpp +65 -0
  19. package/cpp/_core_src/crosshair.h +32 -0
  20. package/cpp/_core_src/fonts.cpp +22 -0
  21. package/cpp/_core_src/fonts.h +25 -0
  22. package/cpp/_core_src/labels.cpp +340 -0
  23. package/cpp/_core_src/labels.h +85 -0
  24. package/cpp/_core_src/ma.cpp +53 -0
  25. package/cpp/_core_src/ma.h +39 -0
  26. package/cpp/_core_src/ma_overlay.cpp +73 -0
  27. package/cpp/_core_src/ma_overlay.h +42 -0
  28. package/cpp/_core_src/macd.cpp +68 -0
  29. package/cpp/_core_src/macd.h +29 -0
  30. package/cpp/_core_src/macd_pane.cpp +199 -0
  31. package/cpp/_core_src/macd_pane.h +41 -0
  32. package/cpp/_core_src/price_indicator.cpp +106 -0
  33. package/cpp/_core_src/price_indicator.h +29 -0
  34. package/cpp/_core_src/rsi.cpp +70 -0
  35. package/cpp/_core_src/rsi.h +32 -0
  36. package/cpp/_core_src/rsi_pane.cpp +167 -0
  37. package/cpp/_core_src/rsi_pane.h +39 -0
  38. package/cpp/_core_src/theme.cpp +41 -0
  39. package/cpp/_core_src/theme.h +23 -0
  40. package/cpp/_core_src/ticks.cpp +49 -0
  41. package/cpp/_core_src/ticks.h +30 -0
  42. package/cpp/_core_src/viewport.cpp +141 -0
  43. package/cpp/_core_src/viewport.h +100 -0
  44. package/cpp/_core_src/volume.cpp +70 -0
  45. package/cpp/_core_src/volume.h +34 -0
  46. package/cpp/_core_src/vwap.cpp +51 -0
  47. package/cpp/_core_src/vwap.h +27 -0
  48. package/ios/README.md +7 -0
  49. package/ios/VroomChartModule.h +11 -0
  50. package/ios/VroomChartModule.mm +44 -0
  51. package/lib/index.d.mts +185 -0
  52. package/lib/index.d.ts +185 -0
  53. package/lib/index.js +429 -0
  54. package/lib/index.js.map +1 -0
  55. package/lib/index.mjs +396 -0
  56. package/lib/index.mjs.map +1 -0
  57. package/package.json +75 -0
  58. package/react-native-vroom-chart.podspec +79 -0
  59. package/src/NativeVroomChart.ts +12 -0
  60. package/src/VroomChart.tsx +382 -0
  61. package/src/index.ts +14 -0
  62. package/src/jsi.d.ts +128 -0
  63. package/src/packCandles.ts +24 -0
  64. package/src/theme.ts +43 -0
  65. package/src/types.ts +27 -0
  66. package/src/useChartCore.ts +135 -0
@@ -0,0 +1,382 @@
1
+ // VroomChart — Phase 3.
2
+ //
3
+ // Owns a SharedValue<SkPicture> driven by:
4
+ // - useChartCore's "initial" picture (when data/size/range change), AND
5
+ // - Pan gesture callbacks that call handle.pan(dx, dy) → fresh picture.
6
+ //
7
+ // Reanimated 4 + RN-Skia 2 propagate SharedValue<SkPicture> changes to
8
+ // <Picture> without a React re-render, so gesture-driven redraws are cheap.
9
+ //
10
+ // Gestures run on the JS thread for now (`runOnJS(true)`) — installing the
11
+ // JSI bindings on the worklet runtime is a later perf optimization.
12
+
13
+ import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
14
+ import { View, type LayoutChangeEvent } from 'react-native';
15
+ import { Canvas, Picture, Skia, type SkPicture } from '@shopify/react-native-skia';
16
+ import {
17
+ Gesture,
18
+ GestureDetector,
19
+ GestureHandlerRootView,
20
+ } from 'react-native-gesture-handler';
21
+ import { useSharedValue } from 'react-native-reanimated';
22
+
23
+ import { useChartCore } from './useChartCore';
24
+ import type { VroomChartProps } from './types';
25
+ import './jsi.d';
26
+
27
+ /**
28
+ * Skia-rendered candlestick chart. Pass OHLCV `candles` and size it via `style`
29
+ * (it fills its parent by default). Pan to scroll, pinch to zoom, drag the
30
+ * price/time axes to rescale, and long-press for the crosshair. Optional
31
+ * indicators (`rsi`, `macd`, `movingAverages`, `vwap`), colors (`theme`), and
32
+ * events (`onCrosshair`, `onViewportChange`) are configured through props.
33
+ *
34
+ * @see {@link VroomChartProps} for the full prop reference.
35
+ */
36
+ export function VroomChart(props: VroomChartProps) {
37
+ const {
38
+ candles,
39
+ width: widthProp,
40
+ height: heightProp,
41
+ style,
42
+ visibleRange,
43
+ theme,
44
+ rsi,
45
+ macd,
46
+ movingAverages,
47
+ vwap,
48
+ crosshairOffset = 40,
49
+ onCrosshair,
50
+ onViewportChange,
51
+ } = props;
52
+
53
+ // Fill the parent by default: measure via onLayout. Explicit width/height
54
+ // props (if given) win per-axis. Until the first layout, dims are 0 and we
55
+ // render nothing (one frame).
56
+ const [measured, setMeasured] = useState({ width: 0, height: 0 });
57
+ const width = widthProp ?? measured.width;
58
+ const height = heightProp ?? measured.height;
59
+
60
+ const onLayout = useCallback((e: LayoutChangeEvent) => {
61
+ const w = Math.round(e.nativeEvent.layout.width);
62
+ const h = Math.round(e.nativeEvent.layout.height);
63
+ setMeasured((prev) =>
64
+ prev.width === w && prev.height === h ? prev : { width: w, height: h },
65
+ );
66
+ }, []);
67
+
68
+ const { handle, picture } = useChartCore(
69
+ candles,
70
+ { width, height },
71
+ visibleRange,
72
+ theme,
73
+ rsi,
74
+ macd,
75
+ movingAverages,
76
+ vwap,
77
+ );
78
+
79
+ // RN-Skia's recorder reads this SharedValue on the UI/render runtime, a beat
80
+ // behind JS-thread writes. If it ever reads null it throws ("Invalid prop
81
+ // value for SkTextBlob received" — RN-Skia's mislabeled SkPicture error), so
82
+ // we seed it with an empty picture and *never* assign null into it.
83
+ const emptyPicture = useMemo(() => {
84
+ const rec = Skia.PictureRecorder();
85
+ rec.beginRecording(Skia.XYWHRect(0, 0, 1, 1));
86
+ return rec.finishRecordingAsPicture();
87
+ }, []);
88
+ const pictureSV = useSharedValue<SkPicture>(emptyPicture);
89
+
90
+ // When the crosshair is showing, pan moves it (instead of scrolling) and
91
+ // pinch is disabled. A ref (not state) so gesture callbacks read it
92
+ // synchronously without re-subscribing. Tap dismisses it.
93
+ const crosshairActive = useRef(false);
94
+
95
+ // timeMs of the candle last reported through onCrosshair, so a drag fires a
96
+ // 'move' event only when it crosses into a *different* candle (one per
97
+ // candle, not per frame). Null while the crosshair is hidden.
98
+ const lastCrosshairTime = useRef<number | null>(null);
99
+
100
+ // Sync the initial picture from useChartCore into the SV whenever it
101
+ // refreshes (data load, size change, externally-controlled range change).
102
+ // Only ever assign a non-null picture (see emptyPicture note above).
103
+ useEffect(() => {
104
+ if (picture) pictureSV.value = picture;
105
+ }, [picture, pictureSV]);
106
+
107
+ // Momentum scroll. After Pan ends with non-trivial velocity, we run a RAF
108
+ // loop that calls handle.pan(dx, 0) each frame with an exponentially
109
+ // decaying velocity. A new pan (or unmount) cancels the loop.
110
+ const decayRaf = useRef<number | null>(null);
111
+ const cancelDecay = useCallback(() => {
112
+ if (decayRaf.current != null) {
113
+ cancelAnimationFrame(decayRaf.current);
114
+ decayRaf.current = null;
115
+ }
116
+ }, []);
117
+ useEffect(() => cancelDecay, [cancelDecay]);
118
+
119
+ // Axis-label fade animation loop. When a gesture changes which labels are
120
+ // active, the C++ side starts ramping their opacities. We keep calling
121
+ // render() on every frame until handle.isAnimating() returns false. The
122
+ // loop is started by gesture callbacks (and the momentum tick) after they
123
+ // update the picture, and self-stops when fades settle.
124
+ const animRaf = useRef<number | null>(null);
125
+ const animTick = useCallback(() => {
126
+ animRaf.current = null;
127
+ if (!handle) return;
128
+ const next = handle.render();
129
+ if (next) pictureSV.value = next;
130
+ if (handle.isAnimating()) {
131
+ animRaf.current = requestAnimationFrame(animTick);
132
+ }
133
+ }, [handle, pictureSV]);
134
+ const maybeStartAnim = useCallback(() => {
135
+ if (animRaf.current != null) return;
136
+ if (!handle?.isAnimating()) return;
137
+ animRaf.current = requestAnimationFrame(animTick);
138
+ }, [handle, animTick]);
139
+ useEffect(() => {
140
+ return () => {
141
+ if (animRaf.current != null) {
142
+ cancelAnimationFrame(animRaf.current);
143
+ animRaf.current = null;
144
+ }
145
+ };
146
+ }, []);
147
+
148
+ // Classifies a touch point into the candle area vs. an axis strip. Axis
149
+ // strips always own their gesture (scale price/time) and take priority over
150
+ // the crosshair: an axis touch never opens, moves, or dismisses it.
151
+ const hitAxis = useCallback(
152
+ (x: number, y: number): 'chart' | 'price-axis' | 'time-axis' | 'indicator' => {
153
+ if (!handle) return 'chart';
154
+ const { yAxisWidth, xAxisHeight, indicatorHeight } =
155
+ handle.getAxisMetrics();
156
+ if (x > width - yAxisWidth) return 'price-axis';
157
+ if (y > height - xAxisHeight) return 'time-axis';
158
+ // The indicator pane sits just above the time-axis strip. A drag here
159
+ // scrolls the candles horizontally (no vertical price change).
160
+ if (indicatorHeight > 0 && y > height - xAxisHeight - indicatorHeight) {
161
+ return 'indicator';
162
+ }
163
+ return 'chart';
164
+ },
165
+ [handle, width, height],
166
+ );
167
+
168
+ // Pan routes to different C++ mutators depending on where it started: the
169
+ // candle area (chart scroll / crosshair move), the y-axis strip (price
170
+ // scale), the x-axis strip (time scale), or the indicator pane (horizontal
171
+ // scroll only). We classify on onStart.
172
+ const panMode = useRef<'chart' | 'price-axis' | 'time-axis' | 'indicator'>(
173
+ 'chart',
174
+ );
175
+
176
+ const pan = Gesture.Pan()
177
+ .runOnJS(true)
178
+ .maxPointers(1) // don't fight Pinch's two-finger gesture
179
+ .onStart((e) => {
180
+ cancelDecay();
181
+ // Always classify — an axis drag controls the axis even while the
182
+ // crosshair is up. Only a chart-area drag interacts with the crosshair.
183
+ panMode.current = hitAxis(e.x, e.y);
184
+ })
185
+ .onChange((e) => {
186
+ if (!handle) return;
187
+ let next: ReturnType<typeof handle.pan> = null;
188
+ if (panMode.current === 'price-axis') {
189
+ next = handle.scalePriceAxis(e.changeY);
190
+ } else if (panMode.current === 'time-axis') {
191
+ next = handle.scaleTimeAxis(e.changeX);
192
+ } else if (panMode.current === 'indicator') {
193
+ // Drag in an indicator pane scrolls the candles horizontally only —
194
+ // no vertical price slide (the pane's scale is fixed).
195
+ next = handle.pan(e.changeX, 0);
196
+ } else if (crosshairActive.current) {
197
+ // Chart area + crosshair up → the drag moves the crosshair instead of
198
+ // scrolling. Vertical line tracks the finger x; the dot/horizontal line
199
+ // stay lifted `crosshairOffset` px above the fingertip.
200
+ const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
201
+ if (ch) pictureSV.value = ch;
202
+ // The line follows the finger every frame (above), but only notify the
203
+ // host when the snapped candle actually changes.
204
+ const c = handle.getCrosshairCandle();
205
+ const t = c?.timeMs ?? null;
206
+ if (t !== lastCrosshairTime.current) {
207
+ lastCrosshairTime.current = t;
208
+ onCrosshair?.({ active: true, candle: c, reason: 'move' });
209
+ }
210
+ return;
211
+ } else {
212
+ // Chart area: 1-finger drag translates both axes. Horizontal
213
+ // component scrolls time, vertical component slides price bounds
214
+ // (axes follow). Diagonal works naturally.
215
+ next = handle.translate(e.changeX, e.changeY);
216
+ }
217
+ if (next) pictureSV.value = next;
218
+ maybeStartAnim();
219
+ })
220
+ .onEnd((e) => {
221
+ if (!handle) return;
222
+ // A chart-area drag with the crosshair up just moved the crosshair —
223
+ // nothing about the viewport changed, and no momentum.
224
+ if (panMode.current === 'chart' && crosshairActive.current) return;
225
+ onViewportChange?.(0, 0);
226
+
227
+ // Axis drags don't get momentum — they're a precise size adjustment.
228
+ // Chart and indicator-pane drags both get horizontal fling momentum.
229
+ if (panMode.current !== 'chart' && panMode.current !== 'indicator') return;
230
+
231
+ let velocity = e.velocityX; // px/s
232
+ const MIN_LAUNCH = 80; // ignore tiny flicks
233
+ const MIN_STOP = 8; // px/s — stop threshold
234
+ const HALF_LIFE_S = 0.35; // velocity halves every 0.35s
235
+ if (Math.abs(velocity) < MIN_LAUNCH) return;
236
+
237
+ let lastTime = performance.now();
238
+ const tick = () => {
239
+ const now = performance.now();
240
+ const dt = (now - lastTime) / 1000;
241
+ lastTime = now;
242
+
243
+ // Frame-time-independent exponential decay.
244
+ velocity *= Math.pow(0.5, dt / HALF_LIFE_S);
245
+ const dx = velocity * dt;
246
+ const next = handle.pan(dx, 0);
247
+ if (next) pictureSV.value = next;
248
+ maybeStartAnim();
249
+
250
+ if (Math.abs(velocity) > MIN_STOP) {
251
+ decayRaf.current = requestAnimationFrame(tick);
252
+ } else {
253
+ decayRaf.current = null;
254
+ }
255
+ };
256
+ decayRaf.current = requestAnimationFrame(tick);
257
+ });
258
+
259
+ // Directional pinch. A single Pinch scale is uniform, so we read the two
260
+ // touch points and track their horizontal/vertical spans independently: a
261
+ // vertical pinch scales price (y), a horizontal pinch scales the time window
262
+ // (x), and a diagonal pinch does both. An axis whose initial span is tiny
263
+ // (fingers ~collinear on that axis) is left alone.
264
+ // Lock the scalable axes at gesture start by orientation: an axis only
265
+ // scales if its initial span is meaningful AND at least AXIS_RATIO of the
266
+ // other axis. This keeps a vertical pinch from ever touching x (and vice
267
+ // versa) — critical because during a vertical pinch the fingers' x-coords
268
+ // drift and cross, sending spanX through ~0 and otherwise exploding frameX.
269
+ const MIN_SPAN = 24; // px — minimum span for an axis to scale at all
270
+ const AXIS_RATIO = 0.5; // axis scales only if its span ≥ this × the other's
271
+ const pinchStart = useRef({
272
+ spanX: 1,
273
+ spanY: 1,
274
+ ratioX: 1,
275
+ ratioY: 1,
276
+ enableX: false,
277
+ enableY: false,
278
+ });
279
+ const pinch = Gesture.Pinch()
280
+ .runOnJS(true)
281
+ .onTouchesDown((e) => {
282
+ if (e.numberOfTouches < 2) return;
283
+ const [a, b] = e.allTouches;
284
+ const spanX = Math.abs(a.x - b.x);
285
+ const spanY = Math.abs(a.y - b.y);
286
+ pinchStart.current = {
287
+ spanX,
288
+ spanY,
289
+ ratioX: 1,
290
+ ratioY: 1,
291
+ enableX: spanX >= MIN_SPAN && spanX >= spanY * AXIS_RATIO,
292
+ enableY: spanY >= MIN_SPAN && spanY >= spanX * AXIS_RATIO,
293
+ };
294
+ })
295
+ .onTouchesMove((e) => {
296
+ if (!handle || crosshairActive.current) return;
297
+ if (e.numberOfTouches < 2) return;
298
+ const [a, b] = e.allTouches;
299
+ const start = pinchStart.current;
300
+ const focalX = (a.x + b.x) * 0.5;
301
+ const focalY = (a.y + b.y) * 0.5;
302
+
303
+ // Per-frame factor = current cumulative ratio / previous. Floor the
304
+ // current span at MIN_SPAN so a near-zero span (fingers crossing on that
305
+ // axis) can't blow the ratio up.
306
+ let frameX = 1;
307
+ if (start.enableX) {
308
+ const ratioX = Math.max(Math.abs(a.x - b.x), MIN_SPAN) / start.spanX;
309
+ frameX = ratioX / start.ratioX;
310
+ start.ratioX = ratioX;
311
+ }
312
+ let frameY = 1;
313
+ if (start.enableY) {
314
+ const ratioY = Math.max(Math.abs(a.y - b.y), MIN_SPAN) / start.spanY;
315
+ frameY = ratioY / start.ratioY;
316
+ start.ratioY = ratioY;
317
+ }
318
+ if (frameX === 1 && frameY === 1) return;
319
+
320
+ const next = handle.zoom(frameX, frameY, focalX, focalY);
321
+ if (next) pictureSV.value = next;
322
+ maybeStartAnim();
323
+ });
324
+
325
+ // Long press shows the crosshair at the press point. A stationary hold never
326
+ // activates `pan` (it needs movement first), so the chart won't scroll under
327
+ // the hold. The dot/horizontal line are lifted above the fingertip.
328
+ const longPress = Gesture.LongPress()
329
+ .runOnJS(true)
330
+ .onStart((e) => {
331
+ if (!handle) return;
332
+ // A long press on an axis strip controls the axis, never the crosshair.
333
+ if (hitAxis(e.x, e.y) !== 'chart') return;
334
+ cancelDecay();
335
+ crosshairActive.current = true;
336
+ const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
337
+ if (ch) pictureSV.value = ch;
338
+ const c = handle.getCrosshairCandle();
339
+ lastCrosshairTime.current = c?.timeMs ?? null;
340
+ onCrosshair?.({ active: true, candle: c, reason: 'show' });
341
+ });
342
+
343
+ // A tap dismisses the crosshair while it's up; otherwise it's a no-op (so it
344
+ // never interferes with normal pan/pinch).
345
+ const tap = Gesture.Tap()
346
+ .runOnJS(true)
347
+ .onStart((e) => {
348
+ if (!handle || !crosshairActive.current) return;
349
+ // A tap on an axis strip controls the axis, never dismisses the crosshair.
350
+ if (hitAxis(e.x, e.y) !== 'chart') return;
351
+ crosshairActive.current = false;
352
+ const ch = handle.clearCrosshair();
353
+ if (ch) pictureSV.value = ch;
354
+ lastCrosshairTime.current = null;
355
+ onCrosshair?.({ active: false, candle: null, reason: 'hide' });
356
+ });
357
+
358
+ const gesture = Gesture.Simultaneous(pan, pinch, longPress, tap);
359
+
360
+ return (
361
+ <GestureHandlerRootView
362
+ onLayout={onLayout}
363
+ style={[
364
+ { width: widthProp, height: heightProp },
365
+ widthProp == null && heightProp == null ? { flex: 1 } : null,
366
+ style,
367
+ ]}
368
+ >
369
+ <GestureDetector gesture={gesture}>
370
+ <View style={{ flex: 1 }}>
371
+ <Canvas style={{ flex: 1 }}>
372
+ {width > 0 && height > 0 ? (
373
+ // pictureSV is always a valid picture (seeded empty, never null),
374
+ // so RN-Skia's UI-thread reader never sees null.
375
+ <Picture picture={pictureSV} />
376
+ ) : null}
377
+ </Canvas>
378
+ </View>
379
+ </GestureDetector>
380
+ </GestureHandlerRootView>
381
+ );
382
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { VroomChart } from './VroomChart';
2
+ export type {
3
+ VroomChartProps,
4
+ Candle,
5
+ CrosshairEvent,
6
+ VroomTheme,
7
+ VroomColor,
8
+ VisibleRange,
9
+ RSIConfig,
10
+ MACDConfig,
11
+ MASource,
12
+ MovingAverageOverlay,
13
+ VWAPConfig,
14
+ } from './types';
package/src/jsi.d.ts ADDED
@@ -0,0 +1,128 @@
1
+ // Ambient declaration for the JSI HostObject installed by VroomChartModule.
2
+
3
+ import type { SkPicture } from '@shopify/react-native-skia';
4
+
5
+ export interface ChartHandle {
6
+ setCandles(buffer: ArrayBuffer): void;
7
+ setSize(width: number, height: number, pxRatio: number): void;
8
+ /**
9
+ * Overrides a single theme color. `key` is a VroomColorKey index (see
10
+ * COLOR_KEYS in theme.ts); `argb` is a packed 0xAARRGGBB integer.
11
+ */
12
+ setColor(key: number, argb: number): void;
13
+ /** Pass 0, 0 to show all candles. */
14
+ setVisibleRange(startMs: number, endMs: number): void;
15
+ /** Shifts the visible range by `dx`/`dy` pixels and returns a fresh picture. */
16
+ pan(dx: number, dy: number): SkPicture | null;
17
+ /**
18
+ * Two-finger translation: shifts the time window AND the price bounds
19
+ * without rescaling. dy > 0 (drag down) moves content down.
20
+ */
21
+ translate(dx: number, dy: number): SkPicture | null;
22
+ /**
23
+ * Directional zoom by per-axis multiplicative factors around focus point
24
+ * (`fx`, `fy`) in pixels. `scaleX` resizes the time window (>1 = wider
25
+ * candles); `scaleY` resizes the price range (>1 = taller candles). Pass 1
26
+ * for an axis to leave it untouched.
27
+ */
28
+ zoom(scaleX: number, scaleY: number, fx: number, fy: number): SkPicture | null;
29
+ /**
30
+ * Drag-on-y-axis price scaling. `dy > 0` widens the price range
31
+ * (candles shrink). Pivots around the price-range center.
32
+ */
33
+ scalePriceAxis(dy: number): SkPicture | null;
34
+ /**
35
+ * Drag-on-x-axis time scaling. `dx > 0` widens the time window
36
+ * (candles thin). Pivots around the right edge so the most recent
37
+ * visible candle stays in place.
38
+ */
39
+ scaleTimeAxis(dx: number): SkPicture | null;
40
+ /**
41
+ * Current axis dimensions in pixels for hit testing in JS gestures.
42
+ * `indicatorHeight` is the below-chart indicator pane height (0 when none).
43
+ */
44
+ getAxisMetrics(): {
45
+ yAxisWidth: number;
46
+ xAxisHeight: number;
47
+ indicatorHeight: number;
48
+ };
49
+ /**
50
+ * Shows the crosshair at (`x`, `y`) in pixels and returns a fresh picture.
51
+ * `y` should already be lifted above the touch point so the dot/horizontal
52
+ * line aren't hidden under the thumb.
53
+ */
54
+ setCrosshair(x: number, y: number): SkPicture | null;
55
+ /** Hides the crosshair and returns a fresh picture. */
56
+ clearCrosshair(): SkPicture | null;
57
+ /**
58
+ * OHLCV of the candle the crosshair currently snaps to, or null when the
59
+ * crosshair is inactive / there are no visible candles. Cheap to poll at
60
+ * gesture rate (no rendering). Call after setCrosshair to read the candle
61
+ * under the new position.
62
+ */
63
+ getCrosshairCandle(): {
64
+ timeMs: number;
65
+ open: number;
66
+ high: number;
67
+ low: number;
68
+ close: number;
69
+ volume: number;
70
+ } | null;
71
+ /**
72
+ * Configures the RSI pane: enable, period (>=2), overbought/oversold band
73
+ * levels (0..100), and the RSI-based MA trendline (toggle + length >=1).
74
+ */
75
+ setRSI(
76
+ enabled: boolean,
77
+ period: number,
78
+ upperBand: number,
79
+ lowerBand: number,
80
+ maEnabled: boolean,
81
+ maPeriod: number,
82
+ ): void;
83
+ /**
84
+ * Configures the MACD pane: enable, fast/slow EMA lengths (slow forced > fast)
85
+ * and the signal-line length. Defaults 12/26/9.
86
+ */
87
+ setMACD(enabled: boolean, fast: number, slow: number, signal: number): void;
88
+ /**
89
+ * Replaces the full set of MA/EMA overlay lines drawn on the price pane.
90
+ * kind: 0=SMA, 1=EMA; source: 0=close,1=open,2=high,3=low,4=hl2,5=hlc3,6=ohlc4;
91
+ * color: packed 0xAARRGGBB; width: stroke px.
92
+ */
93
+ setOverlays(
94
+ overlays: {
95
+ kind: number;
96
+ period: number;
97
+ source: number;
98
+ color: number;
99
+ width: number;
100
+ }[],
101
+ ): void;
102
+ /**
103
+ * Configures the session VWAP overlay. `resetOffsetMin` shifts the session
104
+ * boundary from UTC midnight (minutes); `color` is packed 0xAARRGGBB.
105
+ */
106
+ setVWAP(
107
+ enabled: boolean,
108
+ resetOffsetMin: number,
109
+ color: number,
110
+ width: number,
111
+ ): void;
112
+ /** True while any axis-label fade is still in progress. Drives a RAF loop. */
113
+ isAnimating(): boolean;
114
+ render(): SkPicture | null;
115
+ }
116
+
117
+ export interface VroomChartJSI {
118
+ /** Creates a fresh chart instance. Destroyed when the JS reference is GC'd. */
119
+ create(): ChartHandle;
120
+ }
121
+
122
+ declare global {
123
+ // eslint-disable-next-line no-var
124
+ var VroomChartJSI: VroomChartJSI | undefined;
125
+ }
126
+
127
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
128
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { Candle } from './types';
2
+
3
+ // Wire format must match `VroomCandle` in packages/core/include/vroom/vroom_chart.h:
4
+ // int64_t time_ms; double open, high, low, close, volume;
5
+ // = 48 bytes per candle, 8-byte aligned, little-endian on iOS/Android.
6
+ export const BYTES_PER_CANDLE = 48;
7
+
8
+ // Serializes candles into the packed little-endian buffer the C++ core expects.
9
+ // Pure (no native/Skia deps) so it can be unit-tested in isolation.
10
+ export function packCandles(candles: Candle[]): ArrayBuffer {
11
+ const buf = new ArrayBuffer(candles.length * BYTES_PER_CANDLE);
12
+ const view = new DataView(buf);
13
+ for (let i = 0; i < candles.length; i++) {
14
+ const c = candles[i]!;
15
+ const off = i * BYTES_PER_CANDLE;
16
+ view.setBigInt64(off, BigInt(c.timeMs), true);
17
+ view.setFloat64(off + 8, c.open, true);
18
+ view.setFloat64(off + 16, c.high, true);
19
+ view.setFloat64(off + 24, c.low, true);
20
+ view.setFloat64(off + 32, c.close, true);
21
+ view.setFloat64(off + 40, c.volume, true);
22
+ }
23
+ return buf;
24
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ChartHandle } from './jsi.d';
2
+ import type { VroomColor, VroomTheme } from './types';
3
+
4
+ // Maps each VroomTheme field to its VroomColorKey index in the C++ enum
5
+ // (packages/core/include/vroom/vroom_chart.h). Keep in sync with that enum;
6
+ // new keys are appended there so existing indices never shift.
7
+ export const COLOR_KEYS: Record<keyof VroomTheme, number> = {
8
+ background: 0, // VROOM_COLOR_BACKGROUND
9
+ bull: 1, // VROOM_COLOR_BULL
10
+ bear: 2, // VROOM_COLOR_BEAR
11
+ grid: 4, // VROOM_COLOR_GRID
12
+ axisText: 5, // VROOM_COLOR_AXIS_TEXT
13
+ crosshair: 6, // VROOM_COLOR_CROSSHAIR
14
+ crosshairTarget: 9, // VROOM_COLOR_CROSSHAIR_TARGET
15
+ };
16
+
17
+ // Parses a color into a packed 0xAARRGGBB integer (Skia's ARGB order).
18
+ // - number → taken as already-packed ARGB
19
+ // - '#rgb'-style 6-digit hex → opaque (alpha forced to ff)
20
+ // - 8-digit hex → interpreted as AARRGGBB
21
+ // Returns null for anything malformed so the caller can skip it.
22
+ export function parseColor(value: VroomColor): number | null {
23
+ if (typeof value === 'number') {
24
+ return Number.isFinite(value) ? value >>> 0 : null;
25
+ }
26
+ let s = value.trim();
27
+ if (s.startsWith('#')) s = s.slice(1);
28
+ if (s.length === 6) s = `ff${s}`; // assume opaque
29
+ if (s.length !== 8 || !/^[0-9a-fA-F]{8}$/.test(s)) return null;
30
+ return parseInt(s, 16) >>> 0;
31
+ }
32
+
33
+ // Pushes every provided theme color into the chart core via handle.setColor.
34
+ // Unspecified or unparseable colors are skipped (they keep their default).
35
+ export function applyTheme(handle: ChartHandle, theme: VroomTheme): void {
36
+ (Object.keys(COLOR_KEYS) as (keyof VroomTheme)[]).forEach((field) => {
37
+ const value = theme[field];
38
+ if (value == null) return;
39
+ const argb = parseColor(value);
40
+ if (argb == null) return;
41
+ handle.setColor(COLOR_KEYS[field], argb);
42
+ });
43
+ }
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import type { VroomChartCoreProps } from '@vroomchart/types';
3
+
4
+ // The data/config types are platform-agnostic and live in @vroomchart/types so the
5
+ // native and web components share one identical API. Re-exported here so the
6
+ // package's public surface (via index.ts) is unchanged.
7
+ export type {
8
+ Candle,
9
+ CrosshairEvent,
10
+ VroomColor,
11
+ VroomTheme,
12
+ VisibleRange,
13
+ RSIConfig,
14
+ MASource,
15
+ MovingAverageOverlay,
16
+ VWAPConfig,
17
+ MACDConfig,
18
+ } from '@vroomchart/types';
19
+
20
+ /**
21
+ * Props for the {@link VroomChart} component. The cross-platform props come
22
+ * from {@link VroomChartCoreProps}; `style` is the React Native flavor.
23
+ */
24
+ export type VroomChartProps = VroomChartCoreProps & {
25
+ /** Style for the chart's root view. Defaults to filling the parent. */
26
+ style?: StyleProp<ViewStyle>;
27
+ };