react-native-vroom-chart 0.5.0 → 0.7.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 (58) hide show
  1. package/android/CMakeLists.txt +143 -0
  2. package/android/README.md +60 -5
  3. package/android/build.gradle +115 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/cpp/VroomChartJni.cpp +24 -0
  6. package/android/src/main/java/com/vroom/chart/VroomChartModule.kt +31 -0
  7. package/android/src/main/java/com/vroom/chart/VroomChartPackage.kt +31 -0
  8. package/cpp/VroomChartHostObject.cpp +368 -33
  9. package/cpp/VroomJsiInstaller.cpp +11 -1
  10. package/cpp/VroomSkiaContext.cpp +15 -7
  11. package/cpp/_core_include/vroom/vroom_chart.h +250 -22
  12. package/cpp/_core_src/bollinger.h +1 -1
  13. package/cpp/_core_src/candles.cpp +138 -41
  14. package/cpp/_core_src/candles.h +11 -1
  15. package/cpp/_core_src/chart.cpp +98 -40
  16. package/cpp/_core_src/chart.h +82 -20
  17. package/cpp/_core_src/chart_facade.cpp +297 -66
  18. package/cpp/_core_src/gradient.cpp +46 -0
  19. package/cpp/_core_src/gradient.h +31 -0
  20. package/cpp/_core_src/labels.cpp +49 -16
  21. package/cpp/_core_src/labels.h +33 -4
  22. package/cpp/_core_src/liquidity.cpp +3 -37
  23. package/cpp/_core_src/ma_overlay.cpp +174 -12
  24. package/cpp/_core_src/ma_overlay.h +55 -3
  25. package/cpp/_core_src/macd.cpp +13 -42
  26. package/cpp/_core_src/macd.h +11 -8
  27. package/cpp/_core_src/macd_pane.cpp +57 -27
  28. package/cpp/_core_src/price_line_layout.cpp +87 -0
  29. package/cpp/_core_src/price_line_layout.h +80 -0
  30. package/cpp/_core_src/price_lines.cpp +428 -0
  31. package/cpp/_core_src/price_lines.h +56 -0
  32. package/cpp/_core_src/rsi.cpp +4 -18
  33. package/cpp/_core_src/rsi.h +6 -6
  34. package/cpp/_core_src/rsi_pane.cpp +34 -19
  35. package/cpp/_core_src/series_ma.cpp +64 -0
  36. package/cpp/_core_src/series_ma.h +30 -0
  37. package/cpp/_core_src/style_inherit.h +35 -0
  38. package/cpp/_core_src/theme.cpp +2 -1
  39. package/cpp/_core_src/viewport.cpp +36 -12
  40. package/cpp/_core_src/viewport.h +50 -0
  41. package/cpp/_core_src/volume.cpp +33 -8
  42. package/cpp/_core_src/volume.h +12 -1
  43. package/cpp/_core_src/volume_anim.cpp +32 -0
  44. package/cpp/_core_src/volume_anim.h +41 -0
  45. package/lib/index.d.mts +261 -25
  46. package/lib/index.d.ts +261 -25
  47. package/lib/index.js +252 -36
  48. package/lib/index.js.map +1 -1
  49. package/lib/index.mjs +252 -36
  50. package/lib/index.mjs.map +1 -1
  51. package/package.json +5 -4
  52. package/src/VroomChart.tsx +162 -11
  53. package/src/easing.ts +40 -0
  54. package/src/index.ts +5 -0
  55. package/src/jsi.d.ts +124 -20
  56. package/src/theme.ts +1 -0
  57. package/src/types.ts +5 -0
  58. package/src/useChartCore.ts +166 -28
package/lib/index.js CHANGED
@@ -104,8 +104,10 @@ var FLOAT_KEYS = {
104
104
  // VROOM_FLOAT_CANDLE_RADIUS_PX
105
105
  volumeRadius: 10,
106
106
  // VROOM_FLOAT_VOLUME_RADIUS_PX
107
- lineWidth: 11
107
+ lineWidth: 11,
108
108
  // VROOM_FLOAT_LINE_WIDTH_PX
109
+ lineGradientOpacity: 12
110
+ // VROOM_FLOAT_LINE_GRADIENT_OPACITY
109
111
  };
110
112
  var BOOL_KEYS = {
111
113
  wickRoundCap: 9
@@ -151,16 +153,43 @@ var MA_SOURCES = [
151
153
  "hlc3",
152
154
  "ohlc4"
153
155
  ];
156
+ var inheritColor = (v) => (v != null ? parseColor(v) : null) ?? 0;
154
157
  function overlayToNumeric(o) {
155
158
  const srcIdx = o.source ? MA_SOURCES.indexOf(o.source) : 0;
156
159
  return {
157
- kind: o.kind === "ema" ? 1 : 0,
158
- period: o.length,
160
+ kind: o.maType === "ema" ? 1 : 0,
161
+ period: o.period,
159
162
  source: srcIdx < 0 ? 0 : srcIdx,
160
163
  color: (o.color != null ? parseColor(o.color) : null) ?? 4280902399,
161
164
  width: o.width ?? 1.5
162
165
  };
163
166
  }
167
+ function rsiToSpec(cfg) {
168
+ return {
169
+ enabled: cfg?.enabled ?? false,
170
+ period: cfg?.period ?? 14,
171
+ upperBand: cfg?.upperBand ?? 70,
172
+ lowerBand: cfg?.lowerBand ?? 30,
173
+ maPeriod: cfg?.maPeriod ?? 14,
174
+ maKind: cfg?.maType === "ema" ? 1 : 0,
175
+ maVisible: cfg?.maVisible ?? true,
176
+ lineColor: inheritColor(cfg?.lineColor),
177
+ lineWidth: cfg?.lineWidth ?? -1,
178
+ lineVisible: cfg?.lineVisible ?? true,
179
+ maColor: inheritColor(cfg?.maColor),
180
+ maWidth: cfg?.maWidth ?? -1,
181
+ bandColor: inheritColor(cfg?.bandColor),
182
+ bandsVisible: cfg?.bandsVisible ?? true
183
+ };
184
+ }
185
+ function vwapToSpec(cfg) {
186
+ return {
187
+ enabled: cfg?.enabled ?? false,
188
+ resetOffsetMin: cfg?.resetMinutes ?? 0,
189
+ color: inheritColor(cfg?.color),
190
+ width: cfg?.width ?? -1
191
+ };
192
+ }
164
193
  var DEFAULT_BB_BAND_COLOR = 4280902399;
165
194
  var DEFAULT_BB_BASIS_COLOR = 4294929664;
166
195
  function bollingerToSpec(cfg) {
@@ -170,17 +199,79 @@ function bollingerToSpec(cfg) {
170
199
  period: cfg?.period ?? 20,
171
200
  mult: cfg?.stdDev ?? 2,
172
201
  source: srcIdx < 0 ? 0 : srcIdx,
173
- basisKind: cfg?.basis === "ema" ? 1 : 0,
202
+ basisKind: cfg?.maType === "ema" ? 1 : 0,
174
203
  upperColor: (cfg?.upperColor != null ? parseColor(cfg.upperColor) : null) ?? DEFAULT_BB_BAND_COLOR,
175
204
  upperWidth: cfg?.upperWidth ?? 1,
176
205
  middleColor: (cfg?.middleColor != null ? parseColor(cfg.middleColor) : null) ?? DEFAULT_BB_BASIS_COLOR,
177
206
  middleWidth: cfg?.middleWidth ?? 1,
178
207
  lowerColor: (cfg?.lowerColor != null ? parseColor(cfg.lowerColor) : null) ?? DEFAULT_BB_BAND_COLOR,
179
208
  lowerWidth: cfg?.lowerWidth ?? 1,
180
- fillEnabled: cfg?.fill ?? true,
209
+ fillEnabled: cfg?.fillVisible ?? true,
181
210
  fillOpacity: cfg?.fillOpacity ?? 0.1
182
211
  };
183
212
  }
213
+ function macdToSpec(cfg) {
214
+ const srcIdx = cfg?.source ? MA_SOURCES.indexOf(cfg.source) : 0;
215
+ return {
216
+ enabled: cfg?.enabled ?? false,
217
+ fast: cfg?.fast ?? 12,
218
+ slow: cfg?.slow ?? 26,
219
+ signal: cfg?.signal ?? 9,
220
+ source: srcIdx < 0 ? 0 : srcIdx,
221
+ maKind: cfg?.maType === "sma" ? 0 : 1,
222
+ signalMaKind: cfg?.signalMaType === "sma" ? 0 : 1,
223
+ lineColor: inheritColor(cfg?.lineColor),
224
+ lineWidth: cfg?.lineWidth ?? -1,
225
+ lineVisible: cfg?.lineVisible ?? true,
226
+ signalColor: inheritColor(cfg?.signalColor),
227
+ signalWidth: cfg?.signalWidth ?? -1,
228
+ signalVisible: cfg?.signalVisible ?? true,
229
+ histVisible: cfg?.histogramVisible ?? true,
230
+ histUpColor: inheritColor(cfg?.histogramUpColor),
231
+ histUpFadingColor: inheritColor(cfg?.histogramUpFadingColor),
232
+ histDownColor: inheritColor(cfg?.histogramDownColor),
233
+ histDownFadingColor: inheritColor(cfg?.histogramDownFadingColor),
234
+ zeroColor: inheritColor(cfg?.zeroLineColor),
235
+ zeroVisible: cfg?.zeroLineVisible ?? true
236
+ };
237
+ }
238
+ function volumeToSpec(cfg) {
239
+ return {
240
+ enabled: cfg?.enabled ?? true,
241
+ heightFrac: cfg?.height ?? -1,
242
+ opacity: cfg?.opacity ?? -1,
243
+ radiusPx: cfg?.radius ?? -1,
244
+ upColor: (cfg?.upColor != null ? parseColor(cfg.upColor) : null) ?? 0,
245
+ downColor: (cfg?.downColor != null ? parseColor(cfg.downColor) : null) ?? 0
246
+ };
247
+ }
248
+ var DEFAULT_PRICE_LINE_COLOR = 4293874512;
249
+ var DEFAULT_PRICE_LINE_BODY_BG = 3642499368;
250
+ var DEFAULT_PRICE_LINE_HOVER_BOOST = 1.25;
251
+ var LINE_STYLES = { solid: 0, dotted: 1, dashed: 2 };
252
+ var PRICE_LINE_DRAGGABLE = 1 << 0;
253
+ var PRICE_LINE_CLOSABLE = 1 << 1;
254
+ var PRICE_LINE_AXIS_LABEL = 1 << 2;
255
+ var PRICE_LINE_EXTEND_LEFT = 1 << 3;
256
+ function priceLinesToSpec(cfg) {
257
+ return {
258
+ lines: cfg.lines.map((l) => ({
259
+ price: l.price,
260
+ color: (l.color != null ? parseColor(l.color) : null) ?? DEFAULT_PRICE_LINE_COLOR,
261
+ width: l.width ?? 1,
262
+ lineStyle: LINE_STYLES[l.lineStyle ?? "dotted"],
263
+ text: l.text ?? "",
264
+ quantity: l.quantity ?? "",
265
+ flags: (l.draggable ? PRICE_LINE_DRAGGABLE : 0) | (cfg.hasCloseHandler && l.closable !== false ? PRICE_LINE_CLOSABLE : 0) | (l.axisLabel !== false ? PRICE_LINE_AXIS_LABEL : 0) | (l.extendLeft !== false ? PRICE_LINE_EXTEND_LEFT : 0)
266
+ })),
267
+ bodyBg: (cfg.style?.bodyBackground != null ? parseColor(cfg.style.bodyBackground) : null) ?? DEFAULT_PRICE_LINE_BODY_BG,
268
+ fontSizePx: cfg.style?.fontSize ?? 0,
269
+ lineLengthFrac: cfg.style?.inset ?? 0,
270
+ align: cfg.style?.align === "left" ? 0 : cfg.style?.align === "center" ? 1 : 2,
271
+ hoverBoost: cfg.style?.hoverBoost ?? DEFAULT_PRICE_LINE_HOVER_BOOST
272
+ };
273
+ }
274
+ var EMPTY_PRICE_LINES = priceLinesToSpec({ lines: [], hasCloseHandler: false });
184
275
  var installed = false;
185
276
  function ensureInstalled() {
186
277
  if (installed) return;
@@ -191,9 +282,10 @@ function ensureInstalled() {
191
282
  }
192
283
  installed = true;
193
284
  }
194
- function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType, theme, rsi, macd, movingAverages, vwap, bollingerBands) {
285
+ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType, theme, rsi, macd, movingAverages, vwap, bollingerBands, volume, priceLines) {
195
286
  const handleRef = (0, import_react.useRef)(null);
196
287
  const defaultWidthAppliedRef = (0, import_react.useRef)(false);
288
+ const volumeCollapseRef = (0, import_react.useRef)(null);
197
289
  const [picture, setPicture] = (0, import_react.useState)(null);
198
290
  if (!handleRef.current && size.width > 0 && size.height > 0) {
199
291
  ensureInstalled();
@@ -208,6 +300,8 @@ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType
208
300
  const maKey = movingAverages ? JSON.stringify(movingAverages) : "";
209
301
  const vwapKey = vwap ? JSON.stringify(vwap) : "";
210
302
  const bollingerKey = bollingerBands ? JSON.stringify(bollingerBands) : "";
303
+ const volumeKey = volume ? JSON.stringify(volume) : "";
304
+ const priceLinesKey = priceLines ? JSON.stringify(priceLines) : "";
211
305
  (0, import_react.useEffect)(() => {
212
306
  const h = handleRef.current;
213
307
  if (!h) return;
@@ -225,31 +319,44 @@ function useChartCore(candles, size, visibleRange, defaultCandleWidth, chartType
225
319
  if (theme) {
226
320
  applyTheme(h, theme);
227
321
  }
228
- h.setRSI(
229
- rsi?.enabled ?? false,
230
- rsi?.period ?? 14,
231
- rsi?.upperBand ?? 70,
232
- rsi?.lowerBand ?? 30,
233
- rsi?.maEnabled ?? true,
234
- rsi?.maPeriod ?? 14
235
- );
236
- h.setMACD(
237
- macd?.enabled ?? false,
238
- macd?.fast ?? 12,
239
- macd?.slow ?? 26,
240
- macd?.signal ?? 9
241
- );
322
+ h.setRSI(rsiToSpec(rsi));
323
+ h.setMACD(macdToSpec(macd));
242
324
  h.setOverlays((movingAverages ?? []).map(overlayToNumeric));
243
- h.setVWAP(
244
- vwap?.enabled ?? false,
245
- vwap?.resetMinutes ?? 0,
246
- (vwap?.color != null ? parseColor(vwap.color) : null) ?? 4278238420,
247
- vwap?.width ?? 1.5
248
- );
325
+ h.setVWAP(vwapToSpec(vwap));
249
326
  h.setBollinger(bollingerToSpec(bollingerBands));
327
+ h.setVolume(volumeToSpec(volume));
328
+ const collapse = volumeCollapseRef.current;
329
+ if (collapse) h.setVolumeCollapse(collapse.t, collapse.easing);
330
+ h.setPriceLines(
331
+ priceLines?.lines.length ? priceLinesToSpec(priceLines) : EMPTY_PRICE_LINES
332
+ );
250
333
  setPicture(h.render());
251
- }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey]);
252
- return { handle: handleRef.current, picture };
334
+ }, [candles, size.width, size.height, size.pxRatio, explicit, startMs, endMs, defaultCandleWidth, themeKey, rsiKey, macdKey, maKey, vwapKey, bollingerKey, volumeKey, priceLinesKey]);
335
+ return { handle: handleRef.current, picture, volumeCollapseRef };
336
+ }
337
+
338
+ // src/easing.ts
339
+ function ease(kind, p) {
340
+ switch (kind) {
341
+ case "linear":
342
+ return p;
343
+ case "ease-in":
344
+ return p * p;
345
+ case "ease-out":
346
+ return p * (2 - p);
347
+ default:
348
+ return p * p * (3 - 2 * p);
349
+ }
350
+ }
351
+ var EASINGS = [
352
+ "linear",
353
+ "ease-in",
354
+ "ease-out",
355
+ "ease-in-out"
356
+ ];
357
+ function easingIndex(kind) {
358
+ const i = kind ? EASINGS.indexOf(kind) : -1;
359
+ return i < 0 ? EASINGS.indexOf("ease-in-out") : i;
253
360
  }
254
361
 
255
362
  // src/VroomChart.tsx
@@ -263,15 +370,22 @@ function VroomChart(props) {
263
370
  defaultCandleWidth,
264
371
  chartType,
265
372
  transitionMs,
373
+ transitionEasing,
266
374
  theme,
267
375
  rsi,
268
376
  macd,
269
377
  movingAverages,
270
378
  vwap,
271
379
  bollingerBands,
380
+ volume,
272
381
  crosshairOffset = 40,
273
382
  onCrosshair,
274
- onViewportChange
383
+ onViewportChange,
384
+ priceLines,
385
+ priceLinesStyle,
386
+ onPriceLineDrag,
387
+ onPriceLineDragEnd,
388
+ onPriceLineClose
275
389
  } = props;
276
390
  const [measured, setMeasured] = (0, import_react2.useState)({ width: 0, height: 0 });
277
391
  const width = widthProp ?? measured.width;
@@ -283,7 +397,15 @@ function VroomChart(props) {
283
397
  (prev) => prev.width === w && prev.height === h ? prev : { width: w, height: h }
284
398
  );
285
399
  }, []);
286
- const { handle, picture } = useChartCore(
400
+ const priceLinesProp = (0, import_react2.useMemo)(
401
+ () => priceLines ? {
402
+ lines: priceLines,
403
+ style: priceLinesStyle,
404
+ hasCloseHandler: onPriceLineClose != null
405
+ } : void 0,
406
+ [priceLines, priceLinesStyle, onPriceLineClose]
407
+ );
408
+ const { handle, picture, volumeCollapseRef } = useChartCore(
287
409
  candles,
288
410
  { width, height },
289
411
  visibleRange,
@@ -294,7 +416,9 @@ function VroomChart(props) {
294
416
  macd,
295
417
  movingAverages,
296
418
  vwap,
297
- bollingerBands
419
+ bollingerBands,
420
+ volume,
421
+ priceLinesProp
298
422
  );
299
423
  const emptyPicture = (0, import_react2.useMemo)(() => {
300
424
  const rec = import_react_native_skia.Skia.PictureRecorder();
@@ -341,6 +465,8 @@ function VroomChart(props) {
341
465
  const morphRaf = (0, import_react2.useRef)(null);
342
466
  const morphFade = (0, import_react2.useRef)(null);
343
467
  const morphHandle = (0, import_react2.useRef)(null);
468
+ const easingRef = (0, import_react2.useRef)(transitionEasing);
469
+ easingRef.current = transitionEasing;
344
470
  (0, import_react2.useEffect)(() => {
345
471
  if (!handle) return void 0;
346
472
  const target = chartType === "line" ? 1 : 0;
@@ -370,8 +496,7 @@ function VroomChart(props) {
370
496
  const step = (now) => {
371
497
  if (startTs == null) startTs = now;
372
498
  const prog = Math.min(1, (now - startTs) / dur);
373
- const e = prog * prog * (3 - 2 * prog);
374
- const fade = from + (target - from) * e;
499
+ const fade = from + (target - from) * ease(easingRef.current, prog);
375
500
  morphFade.current = fade;
376
501
  handle.setMorph(fade, fade);
377
502
  const p = handle.render();
@@ -394,6 +519,51 @@ function VroomChart(props) {
394
519
  }
395
520
  };
396
521
  }, [handle, chartType, transitionMs, pictureSV]);
522
+ const volumeRaf = (0, import_react2.useRef)(null);
523
+ const volumeHandle = (0, import_react2.useRef)(null);
524
+ (0, import_react2.useEffect)(() => {
525
+ if (!handle) return void 0;
526
+ const target = volume?.enabled ?? true ? 0 : 1;
527
+ const easing = easingIndex(easingRef.current);
528
+ if (volumeHandle.current !== handle || volumeCollapseRef.current == null) {
529
+ volumeHandle.current = handle;
530
+ volumeCollapseRef.current = { t: target, easing };
531
+ return void 0;
532
+ }
533
+ if (volumeCollapseRef.current.t === target) return void 0;
534
+ if (volumeRaf.current != null) {
535
+ cancelAnimationFrame(volumeRaf.current);
536
+ volumeRaf.current = null;
537
+ }
538
+ const dur = Math.max(0, transitionMs ?? 300);
539
+ if (dur === 0) {
540
+ volumeCollapseRef.current = { t: target, easing };
541
+ handle.setVolumeCollapse(target, easing);
542
+ const p = handle.render();
543
+ if (p) pictureSV.value = p;
544
+ return void 0;
545
+ }
546
+ const from = volumeCollapseRef.current.t;
547
+ let startTs = null;
548
+ const step = (now) => {
549
+ if (startTs == null) startTs = now;
550
+ const prog = Math.min(1, (now - startTs) / dur);
551
+ const t = prog < 1 ? from + (target - from) * prog : target;
552
+ const kind = easingIndex(easingRef.current);
553
+ volumeCollapseRef.current = { t, easing: kind };
554
+ handle.setVolumeCollapse(t, kind);
555
+ const p = handle.render();
556
+ if (p) pictureSV.value = p;
557
+ volumeRaf.current = prog < 1 ? requestAnimationFrame(step) : null;
558
+ };
559
+ volumeRaf.current = requestAnimationFrame(step);
560
+ return () => {
561
+ if (volumeRaf.current != null) {
562
+ cancelAnimationFrame(volumeRaf.current);
563
+ volumeRaf.current = null;
564
+ }
565
+ };
566
+ }, [handle, volume?.enabled, transitionMs, pictureSV, volumeCollapseRef]);
397
567
  const hitAxis = (0, import_react2.useCallback)(
398
568
  (x, y) => {
399
569
  if (!handle) return "chart";
@@ -407,12 +577,33 @@ function VroomChart(props) {
407
577
  },
408
578
  [handle, width, height]
409
579
  );
410
- const panMode = (0, import_react2.useRef)(
411
- "chart"
580
+ const hitPriceLine = (0, import_react2.useCallback)(
581
+ (x, y) => {
582
+ if (!handle || !priceLines?.length) return null;
583
+ const hit = handle.hitTestPriceLine(x, y);
584
+ const line = hit ? priceLines[hit.index] : void 0;
585
+ return hit && line ? { index: hit.index, part: hit.part, line } : null;
586
+ },
587
+ [handle, priceLines]
412
588
  );
589
+ const priceDrag = (0, import_react2.useRef)(
590
+ null
591
+ );
592
+ const panMode = (0, import_react2.useRef)("chart");
413
593
  const pan = import_react_native_gesture_handler.Gesture.Pan().runOnJS(true).maxPointers(1).onStart((e) => {
414
594
  cancelDecay();
415
595
  panMode.current = hitAxis(e.x, e.y);
596
+ priceDrag.current = null;
597
+ if (handle && panMode.current === "chart" && !crosshairActive.current) {
598
+ const pl = hitPriceLine(e.x, e.y);
599
+ if (pl && pl.part === 0) {
600
+ panMode.current = "price-line";
601
+ priceDrag.current = { index: pl.index, id: pl.line.id, price: pl.line.price };
602
+ handle.setPriceLineDrag(pl.index, pl.line.price);
603
+ const p = handle.render();
604
+ if (p) pictureSV.value = p;
605
+ }
606
+ }
416
607
  }).onChange((e) => {
417
608
  if (!handle) return;
418
609
  let next = null;
@@ -422,6 +613,15 @@ function VroomChart(props) {
422
613
  next = handle.scaleTimeAxis(e.changeX);
423
614
  } else if (panMode.current === "indicator") {
424
615
  next = handle.pan(e.changeX, 0);
616
+ } else if (panMode.current === "price-line") {
617
+ const g = priceDrag.current;
618
+ if (!g) return;
619
+ const c = handle.coordAt(e.x, e.y);
620
+ if (!c) return;
621
+ g.price = c.price;
622
+ handle.setPriceLineDrag(g.index, c.price);
623
+ onPriceLineDrag?.(g.id, c.price);
624
+ next = handle.render();
425
625
  } else if (crosshairActive.current) {
426
626
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
427
627
  if (ch) pictureSV.value = ch;
@@ -439,6 +639,15 @@ function VroomChart(props) {
439
639
  maybeStartAnim();
440
640
  }).onEnd((e) => {
441
641
  if (!handle) return;
642
+ if (panMode.current === "price-line") {
643
+ const g = priceDrag.current;
644
+ priceDrag.current = null;
645
+ handle.setPriceLineDrag(-1, 0);
646
+ const p = handle.render();
647
+ if (p) pictureSV.value = p;
648
+ if (g) onPriceLineDragEnd?.(g.id, g.price);
649
+ return;
650
+ }
442
651
  if (panMode.current === "chart" && crosshairActive.current) return;
443
652
  onViewportChange?.(0, 0);
444
653
  if (panMode.current !== "chart" && panMode.current !== "indicator") return;
@@ -515,6 +724,7 @@ function VroomChart(props) {
515
724
  const longPress = import_react_native_gesture_handler.Gesture.LongPress().runOnJS(true).onStart((e) => {
516
725
  if (!handle) return;
517
726
  if (hitAxis(e.x, e.y) !== "chart") return;
727
+ if (hitPriceLine(e.x, e.y)) return;
518
728
  cancelDecay();
519
729
  crosshairActive.current = true;
520
730
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
@@ -531,7 +741,13 @@ function VroomChart(props) {
531
741
  });
532
742
  });
533
743
  const tap = import_react_native_gesture_handler.Gesture.Tap().runOnJS(true).onStart((e) => {
534
- if (!handle || !crosshairActive.current) return;
744
+ if (!handle) return;
745
+ const pl = hitPriceLine(e.x, e.y);
746
+ if (pl && pl.part === 1) {
747
+ onPriceLineClose?.(pl.line.id);
748
+ return;
749
+ }
750
+ if (!crosshairActive.current) return;
535
751
  if (hitAxis(e.x, e.y) !== "chart") return;
536
752
  crosshairActive.current = false;
537
753
  const ch = handle.clearCrosshair();