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.
@@ -1,6 +1,8 @@
1
1
  #include "VroomChartHostObject.h"
2
2
 
3
3
  #include <cstring>
4
+ #include <string>
5
+ #include <vector>
4
6
 
5
7
  #include "chart_internal.h"
6
8
  #include "vroom/vroom_chart.h"
@@ -27,7 +29,7 @@ ChartHostObject::~ChartHostObject() {
27
29
  std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
28
30
  jsi::Runtime& rt) {
29
31
  std::vector<jsi::PropNameID> out;
30
- out.reserve(24);
32
+ out.reserve(29);
31
33
  out.push_back(jsi::PropNameID::forAscii(rt, "setCandles"));
32
34
  out.push_back(jsi::PropNameID::forAscii(rt, "setSize"));
33
35
  out.push_back(jsi::PropNameID::forAscii(rt, "setColor"));
@@ -51,10 +53,77 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
51
53
  out.push_back(jsi::PropNameID::forAscii(rt, "setMACD"));
52
54
  out.push_back(jsi::PropNameID::forAscii(rt, "setOverlays"));
53
55
  out.push_back(jsi::PropNameID::forAscii(rt, "setVWAP"));
56
+ out.push_back(jsi::PropNameID::forAscii(rt, "setBollinger"));
57
+ out.push_back(jsi::PropNameID::forAscii(rt, "coordAt"));
58
+ out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLines"));
59
+ out.push_back(jsi::PropNameID::forAscii(rt, "hitTestPriceLine"));
60
+ out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLineHover"));
61
+ out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLineDrag"));
54
62
  out.push_back(jsi::PropNameID::forAscii(rt, "render"));
55
63
  return out;
56
64
  }
57
65
 
66
+ #if defined(__ANDROID__)
67
+ // Android only: RN-Skia is compiled into its own librnskia.so, separate from
68
+ // libvroomchart.so. Constructing a RNSkia::JsiSkPicture directly here (as iOS
69
+ // does, below) gives it a vtable/typeinfo from *our* .so; when RN-Skia's own
70
+ // compiled code later consumes it (e.g. Convertor.h's
71
+ // `getPropertyValue<sk_sp<SkPicture>>`, which does
72
+ // `value.asObject(rt).asHostObject<JsiSkPicture>(rt)` — a dynamic_pointer_cast
73
+ // under the hood), the cast fails cross-.so with "Object is not a HostObject
74
+ // of desired type", because the object's *runtime* type was never actually
75
+ // compiled inside librnskia.so. iOS statically links everything into one
76
+ // binary, so no such mismatch exists there.
77
+ //
78
+ // The fix: build the picture through RN-Skia's own public JS API instead
79
+ // (`Skia.Picture.MakePicture(bytes)`, the same call `require(...).png`-style
80
+ // static pictures use), so the resulting JsiSkPicture is genuinely
81
+ // constructed by librnskia.so's own code. This costs a serialize +
82
+ // re-parse of the picture's draw ops per call — real overhead on a gesture
83
+ // hot path — but is the only ABI-safe option found so far. Revisit if
84
+ // profiling shows this mattering (e.g. a merged-.so build, or a lighter
85
+ // bridge that avoids the round trip).
86
+ #include "include/core/SkData.h"
87
+
88
+ namespace {
89
+ class SkDataMutableBuffer : public facebook::jsi::MutableBuffer {
90
+ public:
91
+ explicit SkDataMutableBuffer(sk_sp<SkData> data) : data_(std::move(data)) {}
92
+ size_t size() const override { return data_->size(); }
93
+ uint8_t* data() override {
94
+ return const_cast<uint8_t*>(
95
+ static_cast<const uint8_t*>(data_->data()));
96
+ }
97
+
98
+ private:
99
+ sk_sp<SkData> data_;
100
+ };
101
+ } // namespace
102
+
103
+ static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
104
+ const sk_sp<SkPicture>& pic) {
105
+ if (!pic) return facebook::jsi::Value::null();
106
+ sk_sp<SkData> serialized = pic->serialize();
107
+ if (!serialized) return facebook::jsi::Value::null();
108
+
109
+ auto buffer = std::make_shared<SkDataMutableBuffer>(std::move(serialized));
110
+ jsi::ArrayBuffer arrayBuffer(rt, buffer);
111
+
112
+ auto skiaApi = rt.global().getProperty(rt, "SkiaApi");
113
+ if (!skiaApi.isObject()) return facebook::jsi::Value::null();
114
+ auto pictureFactory = skiaApi.asObject(rt).getProperty(rt, "Picture");
115
+ if (!pictureFactory.isObject()) return facebook::jsi::Value::null();
116
+ auto makePicture =
117
+ pictureFactory.asObject(rt).getPropertyAsFunction(rt, "MakePicture");
118
+
119
+ // Mirrors JsiSkPictureFactory::MakePicture's expected argument shape: an
120
+ // object with a `.buffer` property holding the ArrayBuffer (matching a
121
+ // Uint8Array-like value; the factory ignores everything else about it).
122
+ jsi::Object arg(rt);
123
+ arg.setProperty(rt, "buffer", arrayBuffer);
124
+ return makePicture.call(rt, arg);
125
+ }
126
+ #else
58
127
  // Shared helper: wraps a fresh picture for return to JS, with memory pressure
59
128
  // reported to Hermes so GC keeps up under gesture-rate churn.
60
129
  static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
@@ -65,6 +134,7 @@ static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
65
134
  return JSI_CREATE_HOST_OBJECT_WITH_MEMORY_PRESSURE(rt, host,
66
135
  /*context=*/nullptr);
67
136
  }
137
+ #endif
68
138
 
69
139
  jsi::Value ChartHostObject::get(jsi::Runtime& rt,
70
140
  const jsi::PropNameID& propName) {
@@ -551,6 +621,200 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
551
621
  });
552
622
  }
553
623
 
624
+ if (name == "setBollinger") {
625
+ // setBollinger({enabled, period, mult, source, basisKind, upperColor,
626
+ // upperWidth, middleColor, middleWidth, lowerColor, lowerWidth,
627
+ // fillEnabled, fillOpacity}) — Bollinger Bands overlay. No render; the
628
+ // next render() picks it up.
629
+ return jsi::Function::createFromHostFunction(
630
+ rt,
631
+ jsi::PropNameID::forAscii(rt, "setBollinger"),
632
+ 1,
633
+ [this](jsi::Runtime& rt2,
634
+ const jsi::Value& /*thisVal*/,
635
+ const jsi::Value* args,
636
+ size_t count) -> jsi::Value {
637
+ if (count < 1 || !args[0].isObject()) return jsi::Value::undefined();
638
+ auto s = args[0].asObject(rt2);
639
+ VroomBollinger cfg{};
640
+ cfg.enabled = s.getProperty(rt2, "enabled").asBool() ? 1 : 0;
641
+ cfg.period = static_cast<int32_t>(
642
+ s.getProperty(rt2, "period").asNumber());
643
+ cfg.mult = static_cast<float>(
644
+ s.getProperty(rt2, "mult").asNumber());
645
+ cfg.source = static_cast<int32_t>(
646
+ s.getProperty(rt2, "source").asNumber());
647
+ cfg.basis_kind = static_cast<int32_t>(
648
+ s.getProperty(rt2, "basisKind").asNumber());
649
+ cfg.upper_color = static_cast<uint32_t>(
650
+ s.getProperty(rt2, "upperColor").asNumber());
651
+ cfg.upper_width = static_cast<float>(
652
+ s.getProperty(rt2, "upperWidth").asNumber());
653
+ cfg.middle_color = static_cast<uint32_t>(
654
+ s.getProperty(rt2, "middleColor").asNumber());
655
+ cfg.middle_width = static_cast<float>(
656
+ s.getProperty(rt2, "middleWidth").asNumber());
657
+ cfg.lower_color = static_cast<uint32_t>(
658
+ s.getProperty(rt2, "lowerColor").asNumber());
659
+ cfg.lower_width = static_cast<float>(
660
+ s.getProperty(rt2, "lowerWidth").asNumber());
661
+ cfg.fill_enabled =
662
+ s.getProperty(rt2, "fillEnabled").asBool() ? 1 : 0;
663
+ cfg.fill_opacity = static_cast<float>(
664
+ s.getProperty(rt2, "fillOpacity").asNumber());
665
+ vroom_chart_set_bollinger(chart_, &cfg);
666
+ return jsi::Value::undefined();
667
+ });
668
+ }
669
+
670
+ if (name == "coordAt") {
671
+ // coordAt(x, y) -> { timeMs, price } | null. The continuous data coordinate
672
+ // at a pixel — not snapped to a candle slot. Null when there are no candles
673
+ // or the viewport is degenerate. No rendering.
674
+ return jsi::Function::createFromHostFunction(
675
+ rt,
676
+ jsi::PropNameID::forAscii(rt, "coordAt"),
677
+ 2,
678
+ [this](jsi::Runtime& rt2,
679
+ const jsi::Value& /*thisVal*/,
680
+ const jsi::Value* args,
681
+ size_t count) -> jsi::Value {
682
+ if (count < 2) return jsi::Value::null();
683
+ VroomCoord c{};
684
+ if (!vroom_chart_coord_at(chart_,
685
+ static_cast<float>(args[0].asNumber()),
686
+ static_cast<float>(args[1].asNumber()), &c)) {
687
+ return jsi::Value::null();
688
+ }
689
+ jsi::Object obj(rt2);
690
+ obj.setProperty(rt2, "timeMs", static_cast<double>(c.time_ms));
691
+ obj.setProperty(rt2, "price", c.price);
692
+ return obj;
693
+ });
694
+ }
695
+
696
+ if (name == "setPriceLines") {
697
+ // setPriceLines({ lines: [{ price, color, width, lineStyle, text, quantity,
698
+ // flags }, ...], bodyBg, fontSizePx, lineLengthFrac, align, hoverBoost }) —
699
+ // replaces the full set of price status lines. No render; the next render()
700
+ // picks it up.
701
+ return jsi::Function::createFromHostFunction(
702
+ rt,
703
+ jsi::PropNameID::forAscii(rt, "setPriceLines"),
704
+ 1,
705
+ [this](jsi::Runtime& rt2,
706
+ const jsi::Value& /*thisVal*/,
707
+ const jsi::Value* args,
708
+ size_t count) -> jsi::Value {
709
+ if (count < 1 || !args[0].isObject()) return jsi::Value::undefined();
710
+ auto cfg = args[0].asObject(rt2);
711
+ auto lines_val = cfg.getProperty(rt2, "lines");
712
+ if (!lines_val.isObject()) return jsi::Value::undefined();
713
+ auto lines_obj = lines_val.asObject(rt2);
714
+ if (!lines_obj.isArray(rt2)) return jsi::Value::undefined();
715
+ auto arr = lines_obj.asArray(rt2);
716
+ const size_t len = arr.size(rt2);
717
+ std::vector<VroomPriceLine> lines(len);
718
+ // Label storage, kept alive until set_price_lines has copied it.
719
+ std::vector<std::string> texts(len);
720
+ std::vector<std::string> quantities(len);
721
+ for (size_t i = 0; i < len; ++i) {
722
+ auto l = arr.getValueAtIndex(rt2, i).asObject(rt2);
723
+ lines[i].price = l.getProperty(rt2, "price").asNumber();
724
+ lines[i].color = static_cast<uint32_t>(
725
+ l.getProperty(rt2, "color").asNumber());
726
+ lines[i].width = static_cast<float>(
727
+ l.getProperty(rt2, "width").asNumber());
728
+ lines[i].line_style = static_cast<int32_t>(
729
+ l.getProperty(rt2, "lineStyle").asNumber());
730
+ texts[i] = l.getProperty(rt2, "text").asString(rt2).utf8(rt2);
731
+ quantities[i] =
732
+ l.getProperty(rt2, "quantity").asString(rt2).utf8(rt2);
733
+ lines[i].text = texts[i].c_str();
734
+ lines[i].quantity = quantities[i].c_str();
735
+ lines[i].flags = static_cast<int32_t>(
736
+ l.getProperty(rt2, "flags").asNumber());
737
+ }
738
+ VroomPriceLineStyle style{};
739
+ style.body_bg = static_cast<uint32_t>(
740
+ cfg.getProperty(rt2, "bodyBg").asNumber());
741
+ style.font_size_px = static_cast<float>(
742
+ cfg.getProperty(rt2, "fontSizePx").asNumber());
743
+ style.line_length_frac = static_cast<float>(
744
+ cfg.getProperty(rt2, "lineLengthFrac").asNumber());
745
+ style.align = static_cast<int32_t>(
746
+ cfg.getProperty(rt2, "align").asNumber());
747
+ style.hover_boost = static_cast<float>(
748
+ cfg.getProperty(rt2, "hoverBoost").asNumber());
749
+ vroom_chart_set_price_lines(chart_, lines.data(), lines.size(), &style);
750
+ return jsi::Value::undefined();
751
+ });
752
+ }
753
+
754
+ if (name == "hitTestPriceLine") {
755
+ // hitTestPriceLine(x, y) -> { index, part } | null. `part` is 0 for the line
756
+ // or its label body (the drag target) and 1 for the close button. Cheap
757
+ // enough to call at gesture rate — no rendering.
758
+ return jsi::Function::createFromHostFunction(
759
+ rt,
760
+ jsi::PropNameID::forAscii(rt, "hitTestPriceLine"),
761
+ 2,
762
+ [this](jsi::Runtime& rt2,
763
+ const jsi::Value& /*thisVal*/,
764
+ const jsi::Value* args,
765
+ size_t count) -> jsi::Value {
766
+ if (count < 2) return jsi::Value::null();
767
+ int32_t index = -1, part = -1;
768
+ if (!vroom_chart_hit_test_price_line(
769
+ chart_, static_cast<float>(args[0].asNumber()),
770
+ static_cast<float>(args[1].asNumber()), &index, &part)) {
771
+ return jsi::Value::null();
772
+ }
773
+ jsi::Object obj(rt2);
774
+ obj.setProperty(rt2, "index", index);
775
+ obj.setProperty(rt2, "part", part);
776
+ return obj;
777
+ });
778
+ }
779
+
780
+ if (name == "setPriceLineHover") {
781
+ // setPriceLineHover(index, part) — highlight a price line's segment; -1
782
+ // clears. No hover on touch, so this exists for parity/pointer devices.
783
+ return jsi::Function::createFromHostFunction(
784
+ rt,
785
+ jsi::PropNameID::forAscii(rt, "setPriceLineHover"),
786
+ 2,
787
+ [this](jsi::Runtime& /*rt2*/,
788
+ const jsi::Value& /*thisVal*/,
789
+ const jsi::Value* args,
790
+ size_t count) -> jsi::Value {
791
+ if (count < 2) return jsi::Value::undefined();
792
+ vroom_chart_set_price_line_hover(
793
+ chart_, static_cast<int32_t>(args[0].asNumber()),
794
+ static_cast<int32_t>(args[1].asNumber()));
795
+ return jsi::Value::undefined();
796
+ });
797
+ }
798
+
799
+ if (name == "setPriceLineDrag") {
800
+ // setPriceLineDrag(index, price) — live drag preview; index -1 ends it. The
801
+ // committed price is untouched: restate setPriceLines to apply a move.
802
+ return jsi::Function::createFromHostFunction(
803
+ rt,
804
+ jsi::PropNameID::forAscii(rt, "setPriceLineDrag"),
805
+ 2,
806
+ [this](jsi::Runtime& /*rt2*/,
807
+ const jsi::Value& /*thisVal*/,
808
+ const jsi::Value* args,
809
+ size_t count) -> jsi::Value {
810
+ if (count < 2) return jsi::Value::undefined();
811
+ vroom_chart_set_price_line_drag(
812
+ chart_, static_cast<int32_t>(args[0].asNumber()),
813
+ args[1].asNumber());
814
+ return jsi::Value::undefined();
815
+ });
816
+ }
817
+
554
818
  if (name == "render") {
555
819
  return jsi::Function::createFromHostFunction(
556
820
  rt,
@@ -40,7 +40,17 @@ static void ensureAxisTypeface(jsi::Runtime& runtime) {
40
40
  if (!ctx) return;
41
41
  auto mgr = ctx->createFontMgr();
42
42
  if (!mgr) return;
43
- auto tf = mgr->matchFamilyStyle(nullptr, SkFontStyle()); // system default
43
+
44
+ // A null family name is meant to request "the platform default" — CoreText
45
+ // (iOS) honors that, but Android's SkFontMgr_New_Android does not: it
46
+ // returns null unless given an actual family name, even though its
47
+ // underlying font set (sans-serif, arial, ...) is perfectly populated. Fall
48
+ // back to "sans-serif" (Android's standard generic-family alias) whenever
49
+ // the null-family lookup comes back empty.
50
+ auto tf = mgr->matchFamilyStyle(nullptr, SkFontStyle());
51
+ if (!tf) {
52
+ tf = mgr->matchFamilyStyle("sans-serif", SkFontStyle());
53
+ }
44
54
  if (!tf) return;
45
55
  vroom::set_axis_typeface(tf);
46
56
  done = true;
@@ -12,13 +12,21 @@ std::shared_ptr<RNSkia::RNSkPlatformContext> getRNSkContext(
12
12
  if (!obj.isHostObject(runtime)) return nullptr;
13
13
  auto host = obj.asHostObject(runtime);
14
14
 
15
- auto base = std::dynamic_pointer_cast<RNSkia::JsiSkHostObject>(host);
16
- if (!base) return nullptr;
17
-
18
- // The accessor class adds no fields; static_cast is safe even though the
19
- // runtime type is JsiSkApi (a different JsiSkHostObject subclass). We only
20
- // call the inherited non-virtual getContext() through this view.
21
- auto* accessor = static_cast<HostObjectAccessor*>(base.get());
15
+ // `global.SkiaApi` is always installed by RN-Skia's own
16
+ // RNSkManager::installBindings() as exactly a `JsiSkApi` host object — never
17
+ // any other JsiSkHostObject subclass — so this cast is safe as a plain
18
+ // static_cast, without any RTTI check. We deliberately avoid
19
+ // dynamic_pointer_cast here: on Android, RN-Skia is compiled into its own
20
+ // librnskia.so, separate from libvroomchart.so, so a `JsiSkApi` constructed
21
+ // by librnskia.so's code carries typeinfo from that .so; a dynamic_cast
22
+ // performed here (in libvroomchart.so) sees an unmerged RTTI record for
23
+ // "the same" class and always fails, even though the object is perfectly
24
+ // valid to use. (Same underlying issue as the JsiSkPicture cross-.so cast
25
+ // documented in VroomChartHostObject.cpp — but there RN-Skia's own compiled
26
+ // code does the cast on an object *we* construct, so we can't avoid it;
27
+ // here *we* do the cast on an object *they* construct, so we can just skip
28
+ // the runtime check we don't need.)
29
+ auto* accessor = static_cast<HostObjectAccessor*>(host.get());
22
30
  return accessor->getContext();
23
31
  }
24
32
 
@@ -49,6 +49,26 @@ typedef struct VroomOverlay {
49
49
  float width; // stroke width in px
50
50
  } VroomOverlay;
51
51
 
52
+ // Bollinger Bands overlay drawn on the price pane: a basis MA of `source` over
53
+ // `period`, banded at ± `mult` × population standard deviation of the same
54
+ // window. Per TradingView semantics the stdev always uses the window's
55
+ // arithmetic mean, even when `basis_kind` selects an EMA basis line.
56
+ typedef struct VroomBollinger {
57
+ int32_t enabled; // 0/1
58
+ int32_t period; // lookback in candles (clamped >= 1; default 20)
59
+ float mult; // stdev multiplier (clamped >= 0; default 2)
60
+ int32_t source; // 0=close,1=open,2=high,3=low,4=hl2,5=hlc3,6=ohlc4
61
+ int32_t basis_kind; // 0 = SMA, 1 = EMA
62
+ uint32_t upper_color; // 0xAARRGGBB
63
+ float upper_width; // stroke px
64
+ uint32_t middle_color;
65
+ float middle_width;
66
+ uint32_t lower_color;
67
+ float lower_width;
68
+ int32_t fill_enabled; // 0/1: translucent fill between upper and lower
69
+ float fill_opacity; // 0..1, multiplied into upper_color's alpha
70
+ } VroomBollinger;
71
+
52
72
  // A drawing anchor in data space (so a drawing tracks the candles on pan/zoom).
53
73
  typedef struct VroomDrawPoint {
54
74
  int64_t time_ms; // epoch milliseconds (not snapped to a candle slot)
@@ -98,6 +118,44 @@ typedef struct VroomLiquidityStyle {
98
118
  float width_frac;
99
119
  } VroomLiquidityStyle;
100
120
 
121
+ // ---- Price status lines ---------------------------------------------------
122
+
123
+ // Bit flags for VroomPriceLine::flags.
124
+ typedef enum {
125
+ VROOM_PRICE_LINE_DRAGGABLE = 1 << 0, // the line can be dragged vertically
126
+ VROOM_PRICE_LINE_CLOSABLE = 1 << 1, // render the trailing close ("x") button
127
+ VROOM_PRICE_LINE_AXIS_LABEL = 1 << 2, // render the price badge in the y-axis strip
128
+ VROOM_PRICE_LINE_EXTEND_LEFT = 1 << 3, // extend the line to the pane's left edge
129
+ } VroomPriceLineFlags;
130
+
131
+ // A consumer-supplied horizontal status line at a fixed price — the primitive
132
+ // behind resting limit orders, take-profits, liquidation levels and the like.
133
+ //
134
+ // Visually: a line across the price pane ending in a label group made of a body
135
+ // pill (`text`), an optional solid-filled `quantity` pill, and an optional close
136
+ // button, plus an optional price badge in the y-axis strip.
137
+ //
138
+ // `text` / `quantity` are UTF-8 and copied internally, so the caller may free
139
+ // them as soon as the call returns. A null or empty string hides that segment.
140
+ typedef struct VroomPriceLine {
141
+ double price;
142
+ uint32_t color; // 0xAARRGGBB — line, border, body text, close icon
143
+ float width; // stroke width in px
144
+ int32_t line_style; // 0 = solid, 1 = dotted, 2 = dashed
145
+ const char* text; // body label
146
+ const char* quantity; // trailing solid-fill segment
147
+ int32_t flags; // bitwise-or of VroomPriceLineFlags
148
+ } VroomPriceLine;
149
+
150
+ // Layout/style shared by every price line, so the per-line struct stays small.
151
+ typedef struct VroomPriceLineStyle {
152
+ uint32_t body_bg; // translucent body/close-button pill fill (0xAARRGGBB)
153
+ float font_size_px; // 0 = inherit VROOM_FLOAT_AXIS_FONT_SIZE_PX
154
+ float line_length_frac; // 0..1 of pane width: the label group's right-edge inset
155
+ int32_t align; // 0 = left, 1 = center, 2 = right
156
+ float hover_boost; // brightness multiplier for the hovered segment (1 = flat)
157
+ } VroomPriceLineStyle;
158
+
101
159
  // A continuous data coordinate at a pixel position (no candle snapping). Used to
102
160
  // translate a drawing-tool click into a data-space anchor.
103
161
  typedef struct VroomCoord {
@@ -321,6 +379,11 @@ void vroom_chart_set_overlays(VroomChart* chart, const VroomOverlay* overlays,
321
379
  void vroom_chart_set_vwap(VroomChart* chart, bool enabled, int reset_offset_min,
322
380
  uint32_t color, float width);
323
381
 
382
+ // Configures the Bollinger Bands overlay (three price-pane lines + an optional
383
+ // translucent fill between the bands; no pane is reserved). Color/width/fill
384
+ // changes only re-render; enabled/period/mult/source/basis changes recompute.
385
+ void vroom_chart_set_bollinger(VroomChart* chart, const VroomBollinger* cfg);
386
+
324
387
  // ---- Drawings (line annotations) ------------------------------------------
325
388
 
326
389
  // Replaces the full set of committed line drawings (data-anchored, so they track
@@ -365,6 +428,38 @@ void vroom_chart_translate_drawing(VroomChart* chart, int32_t index,
365
428
  void vroom_chart_set_liquidity(VroomChart* chart, const VroomBand* bands,
366
429
  size_t count, const VroomLiquidityStyle* style);
367
430
 
431
+ // ---- Price status lines ---------------------------------------------------
432
+
433
+ // Replaces the full set of price status lines and their shared style. Lines
434
+ // render on the price pane above the axis labels (so their badges cover any
435
+ // label they overlap) and below the crosshair. A line whose price maps outside
436
+ // the price pane is skipped rather than clamped. Pass count 0 to clear;
437
+ // `style` may be null when count is 0.
438
+ void vroom_chart_set_price_lines(VroomChart* chart, const VroomPriceLine* lines,
439
+ size_t count,
440
+ const VroomPriceLineStyle* style);
441
+
442
+ // Hit-tests pixel (x_px, y_px) against the price lines. On a hit, fills
443
+ // *out_index with the line index and *out_part with 0 (the line or its label
444
+ // body — the drag target) or 1 (the close button), and returns true. When
445
+ // several lines are within tolerance the nearest in y wins. Only draggable
446
+ // lines report part 0 and only closable lines report part 1. Returns false on a
447
+ // miss (out params untouched). Either out pointer may be null.
448
+ bool vroom_chart_hit_test_price_line(VroomChart* chart, float x_px, float y_px,
449
+ int32_t* out_index, int32_t* out_part);
450
+
451
+ // Marks a price line's segment as hovered so it renders highlighted. `index` -1
452
+ // clears the hover. `part` matches vroom_chart_hit_test_price_line.
453
+ void vroom_chart_set_price_line_hover(VroomChart* chart, int32_t index,
454
+ int32_t part);
455
+
456
+ // Drives the live drag preview: the line, its label and its badge render at
457
+ // `price` instead of the committed one, and a faint ghost marks where it
458
+ // started. `index` -1 ends the preview. The committed price is never mutated —
459
+ // the host applies (or rejects) the new price by restating its lines.
460
+ void vroom_chart_set_price_line_drag(VroomChart* chart, int32_t index,
461
+ double price);
462
+
368
463
  // Sets the transient in-progress "draft" the drawing tool shows while the user
369
464
  // places points. Node A is always shown; when `has_b`, node B is shown too.
370
465
  // `guide != 0` also draws the live preview (a guideline for a line, or a preview
@@ -0,0 +1,43 @@
1
+ #include "bollinger.h"
2
+
3
+ #include <cmath> // std::nan, std::sqrt
4
+
5
+ #include "ma.h"
6
+
7
+ namespace vroom::bollinger {
8
+
9
+ void compute(const ::VroomCandle* candles, std::size_t n, int period,
10
+ double mult, int source, int basis_kind,
11
+ std::vector<double>& middle, std::vector<double>& upper,
12
+ std::vector<double>& lower) {
13
+ vroom::ma::compute(candles, n, basis_kind, period, source, middle);
14
+ upper.assign(n, std::nan(""));
15
+ lower.assign(n, std::nan(""));
16
+ if (!candles || period < 1) return;
17
+ const std::size_t P = static_cast<std::size_t>(period);
18
+ if (n < P) return;
19
+
20
+ std::vector<double> src(n);
21
+ for (std::size_t i = 0; i < n; ++i) src[i] = vroom::ma::source_value(candles[i], source);
22
+
23
+ // Two-pass stdev around the true window mean per bar. O(n·period), but
24
+ // numerically stable — the rolling Σx²−n·mean² form cancels catastrophically
25
+ // on large prices with small deviations (e.g. BTC-scale values).
26
+ for (std::size_t i = P - 1; i < n; ++i) {
27
+ const std::size_t s = i + 1 - P;
28
+ double mean = 0.0;
29
+ for (std::size_t j = s; j <= i; ++j) mean += src[j];
30
+ mean /= static_cast<double>(P);
31
+ double var = 0.0;
32
+ for (std::size_t j = s; j <= i; ++j) {
33
+ const double d = src[j] - mean;
34
+ var += d * d;
35
+ }
36
+ var /= static_cast<double>(P);
37
+ const double band = mult * std::sqrt(var);
38
+ upper[i] = middle[i] + band;
39
+ lower[i] = middle[i] - band;
40
+ }
41
+ }
42
+
43
+ } // namespace vroom::bollinger
@@ -0,0 +1,31 @@
1
+ // Bollinger Bands over a candle source series — pure, no Skia, so it builds
2
+ // into the unit-test target. Drawn as price-pane overlay lines + a band fill.
3
+
4
+ #pragma once
5
+
6
+ #include <cstddef>
7
+ #include <vector>
8
+
9
+ #include "vroom/vroom_chart.h" // ::VroomCandle
10
+
11
+ namespace vroom::bollinger {
12
+
13
+ // Computes the three band series over [candles, candles+n). `period` is clamped
14
+ // to >= 1; `mult` is the standard-deviation multiplier. `source` is a
15
+ // vroom::ma::Source index and `basis_kind` a vroom::ma::Kind (SMA/EMA).
16
+ //
17
+ // middle = ma::compute(basis_kind, period, source)
18
+ // upper/lower = middle ± mult * population stdev of source over the trailing
19
+ // period window
20
+ //
21
+ // The stdev always uses the window's arithmetic mean, even when basis_kind
22
+ // selects an EMA basis line (TradingView semantics).
23
+ //
24
+ // Each output is resized to n; values are NaN for i < period-1 and when
25
+ // n < period.
26
+ void compute(const ::VroomCandle* candles, std::size_t n, int period,
27
+ double mult, int source, int basis_kind,
28
+ std::vector<double>& middle, std::vector<double>& upper,
29
+ std::vector<double>& lower);
30
+
31
+ } // namespace vroom::bollinger
@@ -18,6 +18,7 @@
18
18
  #include "include/core/SkRect.h"
19
19
  #pragma clang diagnostic pop
20
20
 
21
+ #include "bollinger.h"
21
22
  #include "candles.h"
22
23
  #include "chart_internal.h"
23
24
  #include "crosshair.h"
@@ -29,6 +30,7 @@
29
30
  #include "macd.h"
30
31
  #include "macd_pane.h"
31
32
  #include "price_indicator.h"
33
+ #include "price_lines.h"
32
34
  #include "rsi.h"
33
35
  #include "rsi_pane.h"
34
36
  #include "volume.h"
@@ -100,6 +102,15 @@ void VroomChart::ensure_vwap() {
100
102
  vwap_dirty = false;
101
103
  }
102
104
 
105
+ void VroomChart::ensure_bollinger() {
106
+ if (!bollinger.enabled || !bollinger_dirty) return;
107
+ vroom::bollinger::compute(candles.data(), candles.size(), bollinger.period,
108
+ bollinger.mult, bollinger.source,
109
+ bollinger.basis_kind, bb_middle_cache,
110
+ bb_upper_cache, bb_lower_cache);
111
+ bollinger_dirty = false;
112
+ }
113
+
103
114
  void VroomChart::draw_chart(SkCanvas* canvas) {
104
115
  const auto lay = layout();
105
116
 
@@ -149,6 +160,23 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
149
160
  vroom::liquidity::draw(canvas, *this, lay, bounds, candle_right,
150
161
  candle_area_h);
151
162
 
163
+ // 4.7. Bollinger Band fill — the translucent region between the upper and
164
+ // lower bands, behind the candles so their bull/bear colors stay
165
+ // untinted. The band lines themselves draw above the candles (5.65).
166
+ if (bollinger.enabled) {
167
+ ensure_bollinger();
168
+ if (bollinger.fill_enabled &&
169
+ bb_upper_cache.size() == candles.size() &&
170
+ bb_lower_cache.size() == candles.size()) {
171
+ vroom::ma_overlay::fill_between(
172
+ canvas, lay, bounds, visible, n,
173
+ bb_upper_cache.data() + range.start,
174
+ bb_lower_cache.data() + range.start, window_ms,
175
+ visible_start_ms, candle_duration_ms, candle_right,
176
+ candle_area_h, bollinger.upper_color, bollinger.fill_opacity);
177
+ }
178
+ }
179
+
152
180
  // 5. Price series — candles, a close-price line, or a blend of the two during
153
181
  // the candle↔line morph. `morph_fade` crossfades candles→line and
154
182
  // `morph_collapse` folds each candle toward its close (the line vertex).
@@ -202,6 +230,28 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
202
230
  }
203
231
  }
204
232
 
233
+ // 5.65. Bollinger Band lines — upper, lower, then the basis last so it
234
+ // reads on top where the bands pinch. Same price scale as the
235
+ // candles; the fill went down in 4.7.
236
+ if (bollinger.enabled) {
237
+ ensure_bollinger();
238
+ const std::size_t sz = candles.size();
239
+ if (bb_upper_cache.size() == sz && bb_lower_cache.size() == sz &&
240
+ bb_middle_cache.size() == sz) {
241
+ const auto stroke = [&](const std::vector<double>& cache,
242
+ uint32_t color, float width) {
243
+ vroom::ma_overlay::draw(canvas, lay, bounds, visible, n,
244
+ cache.data() + range.start, window_ms,
245
+ visible_start_ms, candle_duration_ms,
246
+ candle_right, candle_area_h, color,
247
+ width);
248
+ };
249
+ stroke(bb_upper_cache, bollinger.upper_color, bollinger.upper_width);
250
+ stroke(bb_lower_cache, bollinger.lower_color, bollinger.lower_width);
251
+ stroke(bb_middle_cache, bollinger.middle_color, bollinger.middle_width);
252
+ }
253
+ }
254
+
205
255
  // 5.7. Drawing annotations (committed line tools + the in-progress draft).
206
256
  // On the price pane above the candles/overlays, below the axis labels.
207
257
  vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
@@ -230,6 +280,12 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
230
280
  vroom::price_indicator::draw(canvas, *this, lay, bounds,
231
281
  candle_right, candle_area_h);
232
282
 
283
+ // 7.55. Consumer-supplied price status lines — same tier as the current-price
284
+ // indicator (their badges must cover the labels underneath), but after
285
+ // it so a resting order at the last close stays readable.
286
+ vroom::price_lines::draw(canvas, *this, lay, bounds, candle_right,
287
+ candle_area_h);
288
+
233
289
  // 7.6. Indicator panes stacked below the candles, ordered by enable
234
290
  // sequence (most recently enabled at the bottom). Each pane is
235
291
  // INDICATOR_HEIGHT_FRAC of the height; the candle pane already shrank