react-native-vroom-chart 0.4.0 → 0.6.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.
@@ -48,9 +48,15 @@ export function VroomChart(props: VroomChartProps) {
48
48
  macd,
49
49
  movingAverages,
50
50
  vwap,
51
+ bollingerBands,
51
52
  crosshairOffset = 40,
52
53
  onCrosshair,
53
54
  onViewportChange,
55
+ priceLines,
56
+ priceLinesStyle,
57
+ onPriceLineDrag,
58
+ onPriceLineDragEnd,
59
+ onPriceLineClose,
54
60
  } = props;
55
61
 
56
62
  // Fill the parent by default: measure via onLayout. Explicit width/height
@@ -68,6 +74,20 @@ export function VroomChart(props: VroomChartProps) {
68
74
  );
69
75
  }, []);
70
76
 
77
+ // The close button is callback-gated, so whether a handler exists is part of
78
+ // what gets rendered.
79
+ const priceLinesProp = useMemo(
80
+ () =>
81
+ priceLines
82
+ ? {
83
+ lines: priceLines,
84
+ style: priceLinesStyle,
85
+ hasCloseHandler: onPriceLineClose != null,
86
+ }
87
+ : undefined,
88
+ [priceLines, priceLinesStyle, onPriceLineClose],
89
+ );
90
+
71
91
  const { handle, picture } = useChartCore(
72
92
  candles,
73
93
  { width, height },
@@ -79,6 +99,8 @@ export function VroomChart(props: VroomChartProps) {
79
99
  macd,
80
100
  movingAverages,
81
101
  vwap,
102
+ bollingerBands,
103
+ priceLinesProp,
82
104
  );
83
105
 
84
106
  // RN-Skia's recorder reads this SharedValue on the UI/render runtime, a beat
@@ -236,13 +258,31 @@ export function VroomChart(props: VroomChartProps) {
236
258
  [handle, width, height],
237
259
  );
238
260
 
261
+ // Hit-tests the price lines at a touch point, resolving the core's index back
262
+ // to the line it belongs to. Null when nothing was hit.
263
+ const hitPriceLine = useCallback(
264
+ (x: number, y: number) => {
265
+ if (!handle || !priceLines?.length) return null;
266
+ const hit = handle.hitTestPriceLine(x, y);
267
+ const line = hit ? priceLines[hit.index] : undefined;
268
+ return hit && line ? { index: hit.index, part: hit.part, line } : null;
269
+ },
270
+ [handle, priceLines],
271
+ );
272
+
273
+ // A price line being dragged vertically: its core index, its id, and the last
274
+ // previewed price (the payload for the drop).
275
+ const priceDrag = useRef<{ index: number; id: string; price: number } | null>(
276
+ null,
277
+ );
278
+
239
279
  // Pan routes to different C++ mutators depending on where it started: the
240
280
  // candle area (chart scroll / crosshair move), the y-axis strip (price
241
- // scale), the x-axis strip (time scale), or the indicator pane (horizontal
242
- // scroll only). We classify on onStart.
243
- const panMode = useRef<'chart' | 'price-axis' | 'time-axis' | 'indicator'>(
244
- 'chart',
245
- );
281
+ // scale), the x-axis strip (time scale), the indicator pane (horizontal
282
+ // scroll only), or a draggable price line. We classify on onStart.
283
+ const panMode = useRef<
284
+ 'chart' | 'price-axis' | 'time-axis' | 'indicator' | 'price-line'
285
+ >('chart');
246
286
 
247
287
  const pan = Gesture.Pan()
248
288
  .runOnJS(true)
@@ -252,6 +292,20 @@ export function VroomChart(props: VroomChartProps) {
252
292
  // Always classify — an axis drag controls the axis even while the
253
293
  // crosshair is up. Only a chart-area drag interacts with the crosshair.
254
294
  panMode.current = hitAxis(e.x, e.y);
295
+ // A draggable price line takes the drag over from the chart. Seeding the
296
+ // preview at the committed price puts the label in drag styling before the
297
+ // first move, so the grab registers immediately.
298
+ priceDrag.current = null;
299
+ if (handle && panMode.current === 'chart' && !crosshairActive.current) {
300
+ const pl = hitPriceLine(e.x, e.y);
301
+ if (pl && pl.part === 0) {
302
+ panMode.current = 'price-line';
303
+ priceDrag.current = { index: pl.index, id: pl.line.id, price: pl.line.price };
304
+ handle.setPriceLineDrag(pl.index, pl.line.price);
305
+ const p = handle.render();
306
+ if (p) pictureSV.value = p;
307
+ }
308
+ }
255
309
  })
256
310
  .onChange((e) => {
257
311
  if (!handle) return;
@@ -264,6 +318,16 @@ export function VroomChart(props: VroomChartProps) {
264
318
  // Drag in an indicator pane scrolls the candles horizontally only —
265
319
  // no vertical price slide (the pane's scale is fixed).
266
320
  next = handle.pan(e.changeX, 0);
321
+ } else if (panMode.current === 'price-line') {
322
+ // Preview the price under the finger; nothing is committed until the drop.
323
+ const g = priceDrag.current;
324
+ if (!g) return;
325
+ const c = handle.coordAt(e.x, e.y);
326
+ if (!c) return;
327
+ g.price = c.price;
328
+ handle.setPriceLineDrag(g.index, c.price);
329
+ onPriceLineDrag?.(g.id, c.price);
330
+ next = handle.render();
267
331
  } else if (crosshairActive.current) {
268
332
  // Chart area + crosshair up → the drag moves the crosshair instead of
269
333
  // scrolling. Vertical line tracks the finger x; the dot/horizontal line
@@ -292,6 +356,18 @@ export function VroomChart(props: VroomChartProps) {
292
356
  })
293
357
  .onEnd((e) => {
294
358
  if (!handle) return;
359
+ // Price-line drop. The preview always clears here: the line is a controlled
360
+ // prop, so it only really moves once the host restates it — which means a
361
+ // rejected (or ignored) move reverts on its own.
362
+ if (panMode.current === 'price-line') {
363
+ const g = priceDrag.current;
364
+ priceDrag.current = null;
365
+ handle.setPriceLineDrag(-1, 0);
366
+ const p = handle.render();
367
+ if (p) pictureSV.value = p;
368
+ if (g) onPriceLineDragEnd?.(g.id, g.price);
369
+ return;
370
+ }
295
371
  // A chart-area drag with the crosshair up just moved the crosshair —
296
372
  // nothing about the viewport changed, and no momentum.
297
373
  if (panMode.current === 'chart' && crosshairActive.current) return;
@@ -404,6 +480,9 @@ export function VroomChart(props: VroomChartProps) {
404
480
  if (!handle) return;
405
481
  // A long press on an axis strip controls the axis, never the crosshair.
406
482
  if (hitAxis(e.x, e.y) !== 'chart') return;
483
+ // A press on a price line belongs to that line — dragging it or tapping its
484
+ // close button — so it must not raise the crosshair over the top.
485
+ if (hitPriceLine(e.x, e.y)) return;
407
486
  cancelDecay();
408
487
  crosshairActive.current = true;
409
488
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
@@ -419,12 +498,20 @@ export function VroomChart(props: VroomChartProps) {
419
498
  });
420
499
  });
421
500
 
422
- // A tap dismisses the crosshair while it's up; otherwise it's a no-op (so it
423
- // never interferes with normal pan/pinch).
501
+ // A tap activates a price line's close button, and otherwise dismisses the
502
+ // crosshair while it's up. Any other tap is a no-op, so it never interferes
503
+ // with normal pan/pinch.
424
504
  const tap = Gesture.Tap()
425
505
  .runOnJS(true)
426
506
  .onStart((e) => {
427
- if (!handle || !crosshairActive.current) return;
507
+ if (!handle) return;
508
+ // The close button is a tap target whether or not the crosshair is up.
509
+ const pl = hitPriceLine(e.x, e.y);
510
+ if (pl && pl.part === 1) {
511
+ onPriceLineClose?.(pl.line.id);
512
+ return;
513
+ }
514
+ if (!crosshairActive.current) return;
428
515
  // A tap on an axis strip controls the axis, never dismisses the crosshair.
429
516
  if (hitAxis(e.x, e.y) !== 'chart') return;
430
517
  crosshairActive.current = false;
package/src/index.ts CHANGED
@@ -11,5 +11,8 @@ export type {
11
11
  MASource,
12
12
  MovingAverageOverlay,
13
13
  VWAPConfig,
14
+ BollingerBandsConfig,
14
15
  ChartType,
16
+ PriceLine,
17
+ PriceLinesStyle,
15
18
  } from './types';
package/src/jsi.d.ts CHANGED
@@ -142,6 +142,74 @@ export interface ChartHandle {
142
142
  color: number,
143
143
  width: number,
144
144
  ): void;
145
+ /**
146
+ * Configures the Bollinger Bands overlay (three price-pane lines + optional
147
+ * fill between the bands). source/basisKind mirror setOverlays' encodings;
148
+ * colors are packed 0xAARRGGBB; fillOpacity is 0..1.
149
+ */
150
+ setBollinger(spec: {
151
+ enabled: boolean;
152
+ period: number;
153
+ mult: number;
154
+ source: number;
155
+ basisKind: number;
156
+ upperColor: number;
157
+ upperWidth: number;
158
+ middleColor: number;
159
+ middleWidth: number;
160
+ lowerColor: number;
161
+ lowerWidth: number;
162
+ fillEnabled: boolean;
163
+ fillOpacity: number;
164
+ }): void;
165
+ /**
166
+ * The continuous data coordinate at pixel (x, y) — not snapped to a candle
167
+ * slot. Null when there are no candles or the viewport is degenerate. Cheap to
168
+ * call at gesture rate (no rendering).
169
+ */
170
+ coordAt(x: number, y: number): { timeMs: number; price: number } | null;
171
+ /**
172
+ * Replaces the full set of price status lines (plus their shared style).
173
+ * lineStyle: 0=solid,1=dotted,2=dashed; color/bodyBg are packed 0xAARRGGBB;
174
+ * flags is a bitmask (1=draggable, 2=closable, 4=axis label, 8=extend left);
175
+ * align: 0=left,1=center,2=right. Pass an empty `lines` array to clear.
176
+ */
177
+ setPriceLines(spec: {
178
+ lines: {
179
+ price: number;
180
+ color: number;
181
+ width: number;
182
+ lineStyle: number;
183
+ text: string;
184
+ quantity: string;
185
+ flags: number;
186
+ }[];
187
+ bodyBg: number;
188
+ fontSizePx: number;
189
+ lineLengthFrac: number;
190
+ align: number;
191
+ hoverBoost: number;
192
+ }): void;
193
+ /**
194
+ * Hit-tests pixel (x, y) against the price lines. `part` is 0 for the line or
195
+ * its label body (the drag target) and 1 for the close button; close buttons
196
+ * win over lines and the nearest in y wins among several candidates. Only
197
+ * draggable lines report part 0 and only closable lines report part 1. Cheap
198
+ * to call at gesture rate (no rendering).
199
+ */
200
+ hitTestPriceLine(x: number, y: number): { index: number; part: number } | null;
201
+ /**
202
+ * Marks a price line's segment as hovered so it renders highlighted; -1 clears.
203
+ * `part` matches hitTestPriceLine. Touch has no hover state, so this is here
204
+ * for pointer devices and web parity.
205
+ */
206
+ setPriceLineHover(index: number, part: number): void;
207
+ /**
208
+ * Drives the live drag preview: the line, its label and its badge render at
209
+ * `price` with a ghost at the committed one. Pass -1 to end the preview. The
210
+ * committed price is untouched — restate setPriceLines to apply the move.
211
+ */
212
+ setPriceLineDrag(index: number, price: number): void;
145
213
  /** True while any axis-label fade is still in progress. Drives a RAF loop. */
146
214
  isAnimating(): boolean;
147
215
  render(): SkPicture | null;
package/src/types.ts CHANGED
@@ -14,8 +14,11 @@ export type {
14
14
  MASource,
15
15
  MovingAverageOverlay,
16
16
  VWAPConfig,
17
+ BollingerBandsConfig,
17
18
  MACDConfig,
18
19
  ChartType,
20
+ PriceLine,
21
+ PriceLinesStyle,
19
22
  } from '@vroomchart/types';
20
23
 
21
24
  /**
@@ -6,10 +6,13 @@ import type { ChartHandle } from './jsi.d';
6
6
  import { packCandles } from './packCandles';
7
7
  import { applyTheme, parseColor } from './theme';
8
8
  import type {
9
+ BollingerBandsConfig,
9
10
  Candle,
10
11
  ChartType,
11
12
  MACDConfig,
12
13
  MovingAverageOverlay,
14
+ PriceLine,
15
+ PriceLinesStyle,
13
16
  RSIConfig,
14
17
  VisibleRange,
15
18
  VroomTheme,
@@ -38,6 +41,90 @@ function overlayToNumeric(o: MovingAverageOverlay) {
38
41
  };
39
42
  }
40
43
 
44
+ // Bollinger defaults: blue bands / orange basis, matching the repo palette.
45
+ const DEFAULT_BB_BAND_COLOR = 0xff2962ff;
46
+ const DEFAULT_BB_BASIS_COLOR = 0xffff6d00;
47
+
48
+ function bollingerToSpec(cfg: BollingerBandsConfig | undefined) {
49
+ const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
50
+ return {
51
+ enabled: cfg?.enabled ?? false,
52
+ period: cfg?.period ?? 20,
53
+ mult: cfg?.stdDev ?? 2,
54
+ source: srcIdx < 0 ? 0 : srcIdx,
55
+ basisKind: cfg?.basis === 'ema' ? 1 : 0,
56
+ upperColor:
57
+ (cfg?.upperColor != null ? parseColor(cfg.upperColor) : null) ??
58
+ DEFAULT_BB_BAND_COLOR,
59
+ upperWidth: cfg?.upperWidth ?? 1,
60
+ middleColor:
61
+ (cfg?.middleColor != null ? parseColor(cfg.middleColor) : null) ??
62
+ DEFAULT_BB_BASIS_COLOR,
63
+ middleWidth: cfg?.middleWidth ?? 1,
64
+ lowerColor:
65
+ (cfg?.lowerColor != null ? parseColor(cfg.lowerColor) : null) ??
66
+ DEFAULT_BB_BAND_COLOR,
67
+ lowerWidth: cfg?.lowerWidth ?? 1,
68
+ fillEnabled: cfg?.fill ?? true,
69
+ fillOpacity: cfg?.fillOpacity ?? 0.1,
70
+ };
71
+ }
72
+
73
+ // Price-line defaults: a soft red dotted rule with a dark translucent label,
74
+ // close in weight to the current-price indicator it sits beside.
75
+ const DEFAULT_PRICE_LINE_COLOR = 0xffef5350;
76
+ const DEFAULT_PRICE_LINE_BODY_BG = 0xd91c2128;
77
+ const DEFAULT_PRICE_LINE_HOVER_BOOST = 1.25;
78
+
79
+ const LINE_STYLES = { solid: 0, dotted: 1, dashed: 2 } as const;
80
+
81
+ // Mirrors VroomPriceLineFlags in packages/core/include/vroom/vroom_chart.h.
82
+ const PRICE_LINE_DRAGGABLE = 1 << 0;
83
+ const PRICE_LINE_CLOSABLE = 1 << 1;
84
+ const PRICE_LINE_AXIS_LABEL = 1 << 2;
85
+ const PRICE_LINE_EXTEND_LEFT = 1 << 3;
86
+
87
+ /** The price lines + their shared style, as the chart's props express them. */
88
+ export type PriceLinesProp = {
89
+ lines: PriceLine[];
90
+ style?: PriceLinesStyle;
91
+ /**
92
+ * Whether the host supplied a close handler. The close button is
93
+ * callback-gated, so with nothing for it to do it isn't drawn at all.
94
+ */
95
+ hasCloseHandler: boolean;
96
+ };
97
+
98
+ function priceLinesToSpec(cfg: PriceLinesProp) {
99
+ return {
100
+ lines: cfg.lines.map((l) => ({
101
+ price: l.price,
102
+ color:
103
+ (l.color != null ? parseColor(l.color) : null) ?? DEFAULT_PRICE_LINE_COLOR,
104
+ width: l.width ?? 1,
105
+ lineStyle: LINE_STYLES[l.lineStyle ?? 'dotted'],
106
+ text: l.text ?? '',
107
+ quantity: l.quantity ?? '',
108
+ flags:
109
+ (l.draggable ? PRICE_LINE_DRAGGABLE : 0) |
110
+ (cfg.hasCloseHandler && l.closable !== false ? PRICE_LINE_CLOSABLE : 0) |
111
+ (l.axisLabel !== false ? PRICE_LINE_AXIS_LABEL : 0) |
112
+ (l.extendLeft !== false ? PRICE_LINE_EXTEND_LEFT : 0),
113
+ })),
114
+ bodyBg:
115
+ (cfg.style?.bodyBackground != null ? parseColor(cfg.style.bodyBackground) : null) ??
116
+ DEFAULT_PRICE_LINE_BODY_BG,
117
+ fontSizePx: cfg.style?.fontSize ?? 0,
118
+ lineLengthFrac: cfg.style?.inset ?? 0,
119
+ align: cfg.style?.align === 'left' ? 0 : cfg.style?.align === 'center' ? 1 : 2,
120
+ hoverBoost: cfg.style?.hoverBoost ?? DEFAULT_PRICE_LINE_HOVER_BOOST,
121
+ };
122
+ }
123
+
124
+ // Cleared overlay: no lines (the style values are irrelevant, but the spec shape
125
+ // requires them).
126
+ const EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
127
+
41
128
  let installed = false;
42
129
  function ensureInstalled(): void {
43
130
  if (installed) return;
@@ -70,6 +157,8 @@ export function useChartCore(
70
157
  macd?: MACDConfig,
71
158
  movingAverages?: MovingAverageOverlay[],
72
159
  vwap?: VWAPConfig,
160
+ bollingerBands?: BollingerBandsConfig,
161
+ priceLines?: PriceLinesProp,
73
162
  ): ChartCoreState {
74
163
  const handleRef = useRef<ChartHandle | null>(null);
75
164
  // Push setDefaultCandleWidth only once (first load): setCandles re-runs on
@@ -98,6 +187,8 @@ export function useChartCore(
98
187
  const macdKey = macd ? JSON.stringify(macd) : '';
99
188
  const maKey = movingAverages ? JSON.stringify(movingAverages) : '';
100
189
  const vwapKey = vwap ? JSON.stringify(vwap) : '';
190
+ const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : '';
191
+ const priceLinesKey = priceLines ? JSON.stringify(priceLines) : '';
101
192
 
102
193
  useEffect(() => {
103
194
  const h = handleRef.current;
@@ -147,12 +238,17 @@ export function useChartCore(
147
238
  (vwap?.color != null ? parseColor(vwap.color) : null) ?? 0xff00bcd4,
148
239
  vwap?.width ?? 1.5,
149
240
  );
241
+ h.setBollinger(bollingerToSpec(bollingerBands));
242
+ h.setPriceLines(
243
+ priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES,
244
+ );
150
245
  // TODO(rn-parity): mirror the web `liquidity` overlay here (setLiquidity +
151
246
  // the VroomBand structs in the JSI handle) — web-only for now.
152
247
  setPicture(h.render());
153
- // theme/rsi/macd/movingAverages/vwap are represented by their *Key deps.
248
+ // theme/rsi/macd/movingAverages/vwap/bollingerBands/priceLines are
249
+ // represented by their *Key deps.
154
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
155
- }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey]);
251
+ }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, priceLinesKey]);
156
252
 
157
253
  return { handle: handleRef.current, picture };
158
254
  }