react-native-vroom-chart 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,7 +42,7 @@ ChartHostObject::~ChartHostObject() {
42
42
  std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
43
43
  jsi::Runtime& rt) {
44
44
  std::vector<jsi::PropNameID> out;
45
- out.reserve(39);
45
+ out.reserve(42);
46
46
  out.push_back(jsi::PropNameID::forAscii(rt, "setCandles"));
47
47
  out.push_back(jsi::PropNameID::forAscii(rt, "setSize"));
48
48
  out.push_back(jsi::PropNameID::forAscii(rt, "setColor"));
@@ -82,6 +82,9 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
82
82
  out.push_back(jsi::PropNameID::forAscii(rt, "hitTestPriceLine"));
83
83
  out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLineHover"));
84
84
  out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLineDrag"));
85
+ out.push_back(jsi::PropNameID::forAscii(rt, "setFootprints"));
86
+ out.push_back(jsi::PropNameID::forAscii(rt, "hitTestFootprint"));
87
+ out.push_back(jsi::PropNameID::forAscii(rt, "setFootprintHover"));
85
88
  out.push_back(jsi::PropNameID::forAscii(rt, "render"));
86
89
  return out;
87
90
  }
@@ -1189,6 +1192,120 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
1189
1192
  });
1190
1193
  }
1191
1194
 
1195
+ if (name == "setFootprints") {
1196
+ // setFootprints({ prints: [{ timeMs, side }, ...], radiusPx, gapPx, marginPx,
1197
+ // hoverBoost }) — replaces the full set of executed-trade badges. The core
1198
+ // buckets them onto candles itself, so raw fill times are what to pass. No
1199
+ // render; the next render() picks it up.
1200
+ return jsi::Function::createFromHostFunction(
1201
+ rt,
1202
+ jsi::PropNameID::forAscii(rt, "setFootprints"),
1203
+ 1,
1204
+ [this](jsi::Runtime& rt2,
1205
+ const jsi::Value& /*thisVal*/,
1206
+ const jsi::Value* args,
1207
+ size_t count) -> jsi::Value {
1208
+ if (count < 1 || !args[0].isObject()) return jsi::Value::undefined();
1209
+ auto cfg = args[0].asObject(rt2);
1210
+ auto prints_val = cfg.getProperty(rt2, "prints");
1211
+ if (!prints_val.isObject()) return jsi::Value::undefined();
1212
+ auto prints_obj = prints_val.asObject(rt2);
1213
+ if (!prints_obj.isArray(rt2)) return jsi::Value::undefined();
1214
+ auto arr = prints_obj.asArray(rt2);
1215
+ const size_t len = arr.size(rt2);
1216
+ std::vector<VroomFootprint> prints(len);
1217
+ for (size_t i = 0; i < len; ++i) {
1218
+ auto f = arr.getValueAtIndex(rt2, i).asObject(rt2);
1219
+ prints[i].time_ms =
1220
+ static_cast<int64_t>(f.getProperty(rt2, "timeMs").asNumber());
1221
+ prints[i].side =
1222
+ static_cast<int32_t>(f.getProperty(rt2, "side").asNumber());
1223
+ }
1224
+ VroomFootprintStyle style{};
1225
+ style.radius_px = static_cast<float>(
1226
+ cfg.getProperty(rt2, "radiusPx").asNumber());
1227
+ style.gap_px = static_cast<float>(
1228
+ cfg.getProperty(rt2, "gapPx").asNumber());
1229
+ style.margin_px = static_cast<float>(
1230
+ cfg.getProperty(rt2, "marginPx").asNumber());
1231
+ style.hover_boost = static_cast<float>(
1232
+ cfg.getProperty(rt2, "hoverBoost").asNumber());
1233
+ vroom_chart_set_footprints(chart_, prints.data(), prints.size(), &style);
1234
+ return jsi::Value::undefined();
1235
+ });
1236
+ }
1237
+
1238
+ if (name == "hitTestFootprint") {
1239
+ // hitTestFootprint(x, y) -> { side, candleTimeMs, x, y, radius, pane,
1240
+ // indices } | null. `indices` addresses the array last passed to
1241
+ // setFootprints and covers *both* sides of that candle, so one call fills a
1242
+ // tooltip; `pane` is the plot rect, for deciding which side of the badge that
1243
+ // tooltip fits on. No rendering.
1244
+ return jsi::Function::createFromHostFunction(
1245
+ rt,
1246
+ jsi::PropNameID::forAscii(rt, "hitTestFootprint"),
1247
+ 2,
1248
+ [this](jsi::Runtime& rt2,
1249
+ const jsi::Value& /*thisVal*/,
1250
+ const jsi::Value* args,
1251
+ size_t count) -> jsi::Value {
1252
+ if (count < 2) return jsi::Value::null();
1253
+ VroomFootprintHit hit{};
1254
+ if (!vroom_chart_hit_test_footprint(
1255
+ chart_, static_cast<float>(args[0].asNumber()),
1256
+ static_cast<float>(args[1].asNumber()), &hit)) {
1257
+ return jsi::Value::null();
1258
+ }
1259
+
1260
+ const int32_t n =
1261
+ vroom_chart_footprints_at(chart_, hit.candle_time_ms, nullptr, 0);
1262
+ std::vector<int32_t> idx(static_cast<size_t>(n > 0 ? n : 0));
1263
+ if (n > 0) {
1264
+ vroom_chart_footprints_at(chart_, hit.candle_time_ms, idx.data(), n);
1265
+ }
1266
+ jsi::Array indices(rt2, idx.size());
1267
+ for (size_t i = 0; i < idx.size(); ++i) {
1268
+ indices.setValueAtIndex(rt2, i, jsi::Value(idx[i]));
1269
+ }
1270
+
1271
+ jsi::Object pane(rt2);
1272
+ pane.setProperty(rt2, "left", hit.pane_left);
1273
+ pane.setProperty(rt2, "top", hit.pane_top);
1274
+ pane.setProperty(rt2, "right", hit.pane_right);
1275
+ pane.setProperty(rt2, "bottom", hit.pane_bottom);
1276
+
1277
+ jsi::Object obj(rt2);
1278
+ obj.setProperty(rt2, "side", hit.side);
1279
+ obj.setProperty(rt2, "candleTimeMs",
1280
+ static_cast<double>(hit.candle_time_ms));
1281
+ obj.setProperty(rt2, "x", hit.center_x);
1282
+ obj.setProperty(rt2, "y", hit.center_y);
1283
+ obj.setProperty(rt2, "radius", hit.radius);
1284
+ obj.setProperty(rt2, "pane", std::move(pane));
1285
+ obj.setProperty(rt2, "indices", std::move(indices));
1286
+ return obj;
1287
+ });
1288
+ }
1289
+
1290
+ if (name == "setFootprintHover") {
1291
+ // setFootprintHover(candleTimeMs, side) — highlight one badge; side -1
1292
+ // clears. Touch has no hover, so on RN this tracks the tapped badge.
1293
+ return jsi::Function::createFromHostFunction(
1294
+ rt,
1295
+ jsi::PropNameID::forAscii(rt, "setFootprintHover"),
1296
+ 2,
1297
+ [this](jsi::Runtime& /*rt2*/,
1298
+ const jsi::Value& /*thisVal*/,
1299
+ const jsi::Value* args,
1300
+ size_t count) -> jsi::Value {
1301
+ if (count < 2) return jsi::Value::undefined();
1302
+ vroom_chart_set_footprint_hover(
1303
+ chart_, static_cast<int64_t>(args[0].asNumber()),
1304
+ static_cast<int32_t>(args[1].asNumber()));
1305
+ return jsi::Value::undefined();
1306
+ });
1307
+ }
1308
+
1192
1309
  if (name == "render") {
1193
1310
  return jsi::Function::createFromHostFunction(
1194
1311
  rt,
@@ -257,6 +257,53 @@ typedef struct VroomPriceLineStyle {
257
257
  float hover_boost; // brightness multiplier for the hovered segment (1 = flat)
258
258
  } VroomPriceLineStyle;
259
259
 
260
+ // ---- Footprints (executed-trade badges) -----------------------------------
261
+
262
+ typedef enum {
263
+ VROOM_FOOTPRINT_BUY = 0, // position entry — "+" badge in the bull color
264
+ VROOM_FOOTPRINT_SELL = 1, // position exit — "-" badge in the bear color
265
+ } VroomFootprintSide;
266
+
267
+ // A single executed trade, drawn as a circular badge above the candle it fell in.
268
+ //
269
+ // `time_ms` is the raw execution time, *not* a bar-open time: the core buckets
270
+ // each footprint into whichever candle's window contains it, so one array renders
271
+ // correctly at every interval and re-groups by itself when the candles change.
272
+ // Order is irrelevant — the core sorts within each bucket.
273
+ //
274
+ // Only the fields the renderer needs live here; a host's per-trade payload (id,
275
+ // price, size) stays on the host side and is rejoined via the indices reported by
276
+ // vroom_chart_footprints_at.
277
+ typedef struct VroomFootprint {
278
+ int64_t time_ms;
279
+ int32_t side; // VroomFootprintSide
280
+ } VroomFootprint;
281
+
282
+ // Layout/style shared by every footprint badge.
283
+ typedef struct VroomFootprintStyle {
284
+ float radius_px; // badge radius; <= 0 falls back to 9
285
+ float gap_px; // vertical gap between the two stacked badges; < 0 => 4
286
+ float margin_px; // gap between the candle's high and the first badge; < 0 => 8
287
+ float hover_boost; // brightness multiplier for the hovered badge (1 = flat)
288
+ } VroomFootprintStyle;
289
+
290
+ // The badge under a pixel, as reported by vroom_chart_hit_test_footprint.
291
+ typedef struct VroomFootprintHit {
292
+ int64_t candle_time_ms; // bar-open time of the candle the badge sits on
293
+ int32_t side; // VroomFootprintSide — which of the candle's two badges
294
+ float center_x; // badge center, in the same px space as the hit test
295
+ float center_y;
296
+ float radius;
297
+ // The plot rect the badge was clipped to — same px space, axis strips
298
+ // excluded. Reported because a host placing its own tooltip has no other way
299
+ // to know it: the surface it measures includes the price and time axes, so
300
+ // sizing against that overstates the room next to a badge near the edge.
301
+ float pane_left;
302
+ float pane_top;
303
+ float pane_right;
304
+ float pane_bottom;
305
+ } VroomFootprintHit;
306
+
260
307
  // A continuous data coordinate at a pixel position (no candle snapping). Used to
261
308
  // translate a drawing-tool click into a data-space anchor.
262
309
  typedef struct VroomCoord {
@@ -689,6 +736,43 @@ void vroom_chart_set_price_line_hover(VroomChart* chart, int32_t index,
689
736
  void vroom_chart_set_price_line_drag(VroomChart* chart, int32_t index,
690
737
  double price);
691
738
 
739
+ // Replaces the full set of footprints and their shared style. Pass count 0 to
740
+ // clear; `style` may be null when count is 0.
741
+ //
742
+ // Footprints are grouped onto candles by timestamp, so at most two badges render
743
+ // per candle: one for that bar's buys and one for its sells, however many trades
744
+ // went into each. When a candle has both, they stack upward with `gap_px` between
745
+ // them, the side whose latest trade came first sitting nearest the bar. Badges sit
746
+ // above the candle's high, clipped to the price pane, and re-group on their own
747
+ // whenever the candles change (a new interval, more history) — the host never
748
+ // re-buckets anything.
749
+ void vroom_chart_set_footprints(VroomChart* chart, const VroomFootprint* prints,
750
+ size_t count,
751
+ const VroomFootprintStyle* style);
752
+
753
+ // Hit-tests pixel (x_px, y_px) against the footprint badges. On a hit fills *out
754
+ // with the badge's candle, side and pixel geometry and returns true; returns false
755
+ // on a miss (out untouched). `out` may be null to test without reading the hit.
756
+ // When two badges overlap the nearest center wins.
757
+ bool vroom_chart_hit_test_footprint(VroomChart* chart, float x_px, float y_px,
758
+ VroomFootprintHit* out);
759
+
760
+ // Fills `out_indices` with the indices — into the array last passed to
761
+ // vroom_chart_set_footprints — of every footprint bucketed into the candle opening
762
+ // at `candle_time_ms`, *both* sides, ascending by time. Returns the total count,
763
+ // which may exceed `max` (only the first `max` are written), or 0 when that candle
764
+ // has no footprints. Pass a null `out_indices` with max 0 to size the buffer first.
765
+ //
766
+ // This is how a host rejoins a hit badge to its own trade objects: hit-test to get
767
+ // the candle, then map these indices back through the array it supplied.
768
+ int32_t vroom_chart_footprints_at(VroomChart* chart, int64_t candle_time_ms,
769
+ int32_t* out_indices, int32_t max);
770
+
771
+ // Marks one footprint badge as hovered so it renders highlighted. `side` -1
772
+ // clears the hover; `candle_time_ms` and `side` match vroom_chart_hit_test_footprint.
773
+ void vroom_chart_set_footprint_hover(VroomChart* chart, int64_t candle_time_ms,
774
+ int32_t side);
775
+
692
776
  // Sets the transient in-progress "draft" the drawing tool shows while the user
693
777
  // places points. Node A is always shown; when `has_b`, node B is shown too.
694
778
  // `guide != 0` also draws the live preview (a guideline for a line, or a preview
@@ -25,6 +25,7 @@
25
25
  #include "chart_internal.h"
26
26
  #include "crosshair.h"
27
27
  #include "drawings.h"
28
+ #include "footprints.h"
28
29
  #include "labels.h"
29
30
  #include "liquidity.h"
30
31
  #include "ma.h"
@@ -125,6 +126,14 @@ void VroomChart::ensure_bollinger() {
125
126
  bollinger_dirty = false;
126
127
  }
127
128
 
129
+ void VroomChart::ensure_footprint_buckets() {
130
+ if (!footprint_buckets_dirty) return;
131
+ footprint_buckets = vroom::footprints::build_buckets(
132
+ candles.data(), candles.size(), candle_duration_ms, footprints.data(),
133
+ footprints.size());
134
+ footprint_buckets_dirty = false;
135
+ }
136
+
128
137
  void VroomChart::draw_chart(SkCanvas* canvas) {
129
138
  begin_frame();
130
139
  const auto lay = layout();
@@ -480,6 +489,13 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
480
489
  // stays readable.
481
490
  vroom::price_lines::draw(canvas, *this, lay, bounds, candle_right,
482
491
  candle_area_h);
492
+
493
+ // 7.56. Footprint badges — data-anchored chrome that must not be hidden
494
+ // by candles or overlays, so it draws with the price lines rather
495
+ // than back at the drawings layer. Below the crosshair, which the
496
+ // user is actively pointing with.
497
+ ensure_footprint_buckets();
498
+ vroom::footprints::draw(canvas, *this, lay, bounds, window_ms);
483
499
  }
484
500
 
485
501
  // 7.6. Indicator panes stacked below the candles, ordered by enable
@@ -22,6 +22,7 @@
22
22
  #include "include/core/SkRefCnt.h"
23
23
  #pragma clang diagnostic pop
24
24
 
25
+ #include "footprints_layout.h"
25
26
  #include "labels.h"
26
27
  #include "price_format.h"
27
28
  #include "theme.h"
@@ -280,6 +281,28 @@ struct VroomChart {
280
281
  int32_t dragged_price_line = -1;
281
282
  double dragged_price_line_price = 0.0;
282
283
 
284
+ // --- footprints (executed-trade badges) ---------------------------------
285
+ // Kept in the *host's* order so the indices reported by
286
+ // vroom_chart_footprints_at map straight back to its own trade objects.
287
+ std::vector<VroomFootprint> footprints;
288
+ VroomFootprintStyle footprint_style{};
289
+
290
+ // Footprints grouped onto candles, which is what the renderer and hit-test
291
+ // actually walk. Derived from `footprints` + `candles`, so it is rebuilt
292
+ // lazily (see ensure_footprint_buckets) whenever either changes — that
293
+ // rebuild is what re-groups badges onto the right bars after an interval
294
+ // switch, with no involvement from the host.
295
+ std::vector<vroom::footprints::Bucket> footprint_buckets;
296
+ bool footprint_buckets_dirty = true;
297
+
298
+ // Which badge renders highlighted, keyed the way the hit-test reports it.
299
+ // side -1 = none.
300
+ int64_t hovered_footprint_time_ms = 0;
301
+ int32_t hovered_footprint_side = -1;
302
+
303
+ // Rebuilds footprint_buckets if either input changed since the last call.
304
+ void ensure_footprint_buckets();
305
+
283
306
  // --- theme --------------------------------------------------------------
284
307
  vroom::Theme theme;
285
308
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  #include "chart.h"
15
15
  #include "drawings.h"
16
+ #include "footprints.h"
16
17
  #include "labels.h"
17
18
  #include "price_lines.h"
18
19
  #include "viewport.h"
@@ -186,6 +187,9 @@ extern "C" void vroom_chart_set_candles(VroomChart* chart, const VroomCandle* da
186
187
  chart->overlays_dirty = true;
187
188
  chart->vwap_dirty = true;
188
189
  chart->bollinger_dirty = true;
190
+ // New bars (or a whole new interval) mean the footprint grouping no longer
191
+ // matches the data — regroup on next use.
192
+ chart->footprint_buckets_dirty = true;
189
193
 
190
194
  // Infer the candle period from the first interval. Robust enough for
191
195
  // uniform-duration series (the only kind we model today).
@@ -1023,6 +1027,110 @@ extern "C" void vroom_chart_set_price_line_drag(VroomChart* chart, int32_t index
1023
1027
  chart->mark_dirty();
1024
1028
  }
1025
1029
 
1030
+ // ---- Footprints ------------------------------------------------------------
1031
+
1032
+ extern "C" void vroom_chart_set_footprints(VroomChart* chart,
1033
+ const VroomFootprint* prints,
1034
+ size_t count,
1035
+ const VroomFootprintStyle* style) {
1036
+ if (!chart) return;
1037
+ // Stored in the caller's order: vroom_chart_footprints_at hands these indices
1038
+ // back, and they're only useful if they still address the caller's array.
1039
+ if (count > 0 && prints) {
1040
+ chart->footprints.assign(prints, prints + count);
1041
+ } else {
1042
+ chart->footprints.clear();
1043
+ }
1044
+ if (style) chart->footprint_style = *style;
1045
+ chart->footprint_buckets_dirty = true;
1046
+ // A badge that no longer exists must not keep its highlight.
1047
+ if (chart->footprints.empty()) chart->hovered_footprint_side = -1;
1048
+ chart->mark_dirty();
1049
+ }
1050
+
1051
+ extern "C" bool vroom_chart_hit_test_footprint(VroomChart* chart, float x_px,
1052
+ float y_px,
1053
+ VroomFootprintHit* out) {
1054
+ if (!chart || chart->footprints.empty() || chart->candles.empty()) return false;
1055
+ chart->ensure_footprint_buckets();
1056
+ if (chart->footprint_buckets.empty()) return false;
1057
+
1058
+ // Same bounds and pane geometry as draw_chart, so the badges hit where they
1059
+ // render.
1060
+ const auto lay = chart->layout();
1061
+ const auto range = vroom::visible_indices(
1062
+ chart->candles.data(), chart->candles.size(),
1063
+ chart->visible_start_ms, chart->visible_end_ms);
1064
+ const size_t n = range.end - range.start;
1065
+ const auto bounds =
1066
+ chart->price_bounds_manual
1067
+ ? chart->price_bounds
1068
+ : vroom::auto_price_bounds(chart->candles.data() + range.start, n);
1069
+
1070
+ const auto hit = vroom::footprints::hit_test(*chart, lay, bounds, x_px, y_px);
1071
+ if (hit.side < 0) return false;
1072
+ if (out) *out = hit;
1073
+ return true;
1074
+ }
1075
+
1076
+ extern "C" int32_t vroom_chart_footprints_at(VroomChart* chart,
1077
+ int64_t candle_time_ms,
1078
+ int32_t* out_indices, int32_t max) {
1079
+ if (!chart || chart->footprints.empty()) return 0;
1080
+ chart->ensure_footprint_buckets();
1081
+
1082
+ const auto& buckets = chart->footprint_buckets;
1083
+ const auto it = std::lower_bound(
1084
+ buckets.begin(), buckets.end(), candle_time_ms,
1085
+ [](const vroom::footprints::Bucket& b, int64_t t) {
1086
+ return b.candle_time_ms < t;
1087
+ });
1088
+ if (it == buckets.end() || it->candle_time_ms != candle_time_ms) return 0;
1089
+
1090
+ // Both sides, ascending by time — merge the two already-sorted lists rather
1091
+ // than re-sorting them.
1092
+ const auto& buys = it->buys;
1093
+ const auto& sells = it->sells;
1094
+ const int32_t total = static_cast<int32_t>(buys.size() + sells.size());
1095
+ if (!out_indices || max <= 0) return total;
1096
+
1097
+ const auto& fps = chart->footprints;
1098
+ int32_t written = 0;
1099
+ size_t bi = 0;
1100
+ size_t si = 0;
1101
+ while (written < max && (bi < buys.size() || si < sells.size())) {
1102
+ bool take_buy;
1103
+ if (bi >= buys.size()) {
1104
+ take_buy = false;
1105
+ } else if (si >= sells.size()) {
1106
+ take_buy = true;
1107
+ } else {
1108
+ const int64_t tb = fps[static_cast<size_t>(buys[bi])].time_ms;
1109
+ const int64_t ts = fps[static_cast<size_t>(sells[si])].time_ms;
1110
+ take_buy = tb <= ts;
1111
+ }
1112
+ out_indices[written++] = take_buy ? buys[bi++] : sells[si++];
1113
+ }
1114
+ return total;
1115
+ }
1116
+
1117
+ extern "C" void vroom_chart_set_footprint_hover(VroomChart* chart,
1118
+ int64_t candle_time_ms,
1119
+ int32_t side) {
1120
+ if (!chart) return;
1121
+ if (side != VROOM_FOOTPRINT_BUY && side != VROOM_FOOTPRINT_SELL) {
1122
+ side = -1;
1123
+ candle_time_ms = 0;
1124
+ }
1125
+ if (chart->hovered_footprint_side == side &&
1126
+ chart->hovered_footprint_time_ms == candle_time_ms) {
1127
+ return; // hover fires on every pointer move; don't redraw for nothing
1128
+ }
1129
+ chart->hovered_footprint_side = side;
1130
+ chart->hovered_footprint_time_ms = candle_time_ms;
1131
+ chart->mark_dirty();
1132
+ }
1133
+
1026
1134
  extern "C" void vroom_chart_set_draft(VroomChart* chart, int64_t a_time,
1027
1135
  double a_price, bool has_b, int64_t b_time,
1028
1136
  double b_price, bool guide, uint32_t color,
@@ -0,0 +1,226 @@
1
+ #include "footprints.h"
2
+
3
+ #pragma clang diagnostic push
4
+ #pragma clang diagnostic ignored "-Wdocumentation"
5
+ #include "include/core/SkCanvas.h"
6
+ #include "include/core/SkColor.h"
7
+ #include "include/core/SkPaint.h"
8
+ #include "include/core/SkRect.h"
9
+ #pragma clang diagnostic pop
10
+
11
+ #include <algorithm>
12
+ #include <cmath>
13
+
14
+ #include "chart.h"
15
+ #include "footprints_layout.h"
16
+ #include "theme.h"
17
+ #include "viewport.h"
18
+
19
+ namespace vroom::footprints {
20
+
21
+ namespace {
22
+
23
+ // The glyph is a bar (minus) or a crossed pair of bars (plus) sized as a fraction
24
+ // of the badge, so restyling the radius scales the whole mark.
25
+ constexpr float kGlyphFrac = 0.52f; // glyph length / diameter
26
+ constexpr float kStrokeFrac = 0.20f; // stroke width / radius
27
+
28
+ // The hovered badge gets a soft ring outside its edge — the "lit up" state in the
29
+ // reference, and the only affordance that reads at a 9px radius.
30
+ constexpr float kHaloWidthFrac = 0.42f; // ring stroke width / radius
31
+ constexpr float kHaloGapFrac = 0.30f; // clear space between badge and ring
32
+ constexpr U8CPU kHaloAlpha = 0x66;
33
+
34
+ // Brightens `c`'s channels by `mul` (clamped at white), leaving alpha alone.
35
+ // Matches price_lines::boost so hover feels the same across both widgets.
36
+ SkColor boost(SkColor c, float mul) {
37
+ if (mul <= 1.f) return c;
38
+ const auto up = [mul](U8CPU v) {
39
+ return static_cast<U8CPU>(
40
+ std::min(255.f, static_cast<float>(v) * mul + 0.5f));
41
+ };
42
+ return SkColorSetARGB(SkColorGetA(c), up(SkColorGetR(c)), up(SkColorGetG(c)),
43
+ up(SkColorGetB(c)));
44
+ }
45
+
46
+ // One laid-out badge, in pixels.
47
+ struct Badge {
48
+ float cx = 0.f;
49
+ float cy = 0.f;
50
+ float radius = 0.f;
51
+ int32_t side = -1;
52
+ int64_t candle_time_ms = 0;
53
+ };
54
+
55
+ // Walks every badge that could be on screen, in draw order, handing each to `fn`.
56
+ // Both passes go through here: the badge the user sees and the badge they can
57
+ // hover are the same object by construction.
58
+ template <typename Fn>
59
+ void for_each_badge(const VroomChart& chart, const Layout& lay,
60
+ const PriceBounds& bounds, int64_t window_ms, Fn&& fn) {
61
+ if (chart.footprint_buckets.empty() || chart.candles.empty()) return;
62
+ if (window_ms <= 0) return;
63
+
64
+ const Metrics m = metrics_from(&chart.footprint_style);
65
+ const float pane_bottom = vroom::price_pane_bottom(lay);
66
+ const float pane_right = vroom::candle_area_width(lay);
67
+
68
+ const ::VroomCandle* candles = chart.candles.data();
69
+ const size_t candle_count = chart.candles.size();
70
+
71
+ // Only the buckets whose candles are in (or just off) the window matter. One
72
+ // bar of slack each side keeps a badge from popping in at the edge.
73
+ const int64_t slack = chart.candle_duration_ms;
74
+ const auto& buckets = chart.footprint_buckets;
75
+ auto it = std::lower_bound(buckets.begin(), buckets.end(),
76
+ chart.visible_start_ms - slack,
77
+ [](const Bucket& b, int64_t t) {
78
+ return b.candle_time_ms < t;
79
+ });
80
+
81
+ for (; it != buckets.end(); ++it) {
82
+ if (it->candle_time_ms > chart.visible_end_ms + slack) break;
83
+
84
+ // The bar this bucket belongs to, for its high.
85
+ const ::VroomCandle* c =
86
+ std::lower_bound(candles, candles + candle_count, it->candle_time_ms,
87
+ [](const ::VroomCandle& a, int64_t t) {
88
+ return a.time_ms < t;
89
+ });
90
+ if (c == candles + candle_count || c->time_ms != it->candle_time_ms) {
91
+ continue; // the bar went away; the bucket is about to be rebuilt
92
+ }
93
+
94
+ const float cx = vroom::candle_center_x(lay, it->candle_time_ms,
95
+ chart.candle_duration_ms,
96
+ chart.visible_start_ms, window_ms);
97
+ // Cheap reject before laying the stack out.
98
+ if (cx < -m.radius || cx > pane_right + m.radius) continue;
99
+
100
+ const float high_y = vroom::price_to_y(lay, bounds, c->high);
101
+ const Stack stack = layout_stack(*it, high_y, 0.f, m.radius, m.gap, m.margin);
102
+
103
+ for (int32_t i = 0; i < stack.count; ++i) {
104
+ // A bar whose high is below the pane (scrolled off the bottom) parks
105
+ // its stack out of sight; skip rather than draw over the x-axis.
106
+ if (stack.y[i] - m.radius > pane_bottom) continue;
107
+ Badge b;
108
+ b.cx = cx;
109
+ b.cy = stack.y[i];
110
+ b.radius = m.radius;
111
+ b.side = stack.sides[i];
112
+ b.candle_time_ms = it->candle_time_ms;
113
+ fn(b, m);
114
+ }
115
+ }
116
+ }
117
+
118
+ // Buy = "+", sell = "-": one horizontal bar, plus a vertical one for an entry.
119
+ void draw_glyph(SkCanvas* canvas, const Badge& b, SkColor color) {
120
+ SkPaint p;
121
+ p.setAntiAlias(true);
122
+ p.setColor(color);
123
+ p.setStyle(SkPaint::kStroke_Style);
124
+ p.setStrokeWidth(std::max(1.f, b.radius * kStrokeFrac));
125
+ p.setStrokeCap(SkPaint::kRound_Cap);
126
+
127
+ const float arm = b.radius * kGlyphFrac;
128
+ canvas->drawLine(b.cx - arm, b.cy, b.cx + arm, b.cy, p);
129
+ if (b.side == VROOM_FOOTPRINT_BUY) {
130
+ canvas->drawLine(b.cx, b.cy - arm, b.cx, b.cy + arm, p);
131
+ }
132
+ }
133
+
134
+ } // namespace
135
+
136
+ void draw(SkCanvas* canvas,
137
+ const VroomChart& chart,
138
+ const Layout& lay,
139
+ const PriceBounds& bounds,
140
+ int64_t window_ms) {
141
+ if (!canvas || chart.footprints.empty()) return;
142
+
143
+ const float pane_bottom = vroom::price_pane_bottom(lay);
144
+ const float pane_right = vroom::candle_area_width(lay);
145
+ if (pane_right <= 0.f || pane_bottom <= 0.f) return;
146
+
147
+ const SkColor bull = chart.theme.colors[VROOM_COLOR_BULL];
148
+ const SkColor bear = chart.theme.colors[VROOM_COLOR_BEAR];
149
+ const SkColor glyph = chart.theme.colors[VROOM_COLOR_BADGE_TEXT];
150
+
151
+ // Badges belong to the plot, not the axis strips: a bar at the right edge
152
+ // must not spill its badge over the price labels.
153
+ canvas->save();
154
+ canvas->clipRect(SkRect::MakeLTRB(0.f, 0.f, pane_right, pane_bottom));
155
+
156
+ for_each_badge(chart, lay, bounds, window_ms,
157
+ [&](const Badge& b, const Metrics& m) {
158
+ const bool hovered = chart.hovered_footprint_side == b.side &&
159
+ chart.hovered_footprint_time_ms == b.candle_time_ms;
160
+ const SkColor base = b.side == VROOM_FOOTPRINT_BUY ? bull : bear;
161
+ const SkColor fill = hovered ? boost(base, m.hover_boost) : base;
162
+
163
+ if (hovered) {
164
+ SkPaint halo;
165
+ halo.setAntiAlias(true);
166
+ halo.setStyle(SkPaint::kStroke_Style);
167
+ halo.setStrokeWidth(b.radius * kHaloWidthFrac);
168
+ halo.setColor(SkColorSetA(fill, kHaloAlpha));
169
+ const float r =
170
+ b.radius * (1.f + kHaloGapFrac + kHaloWidthFrac * 0.5f);
171
+ canvas->drawCircle(b.cx, b.cy, r, halo);
172
+ }
173
+
174
+ SkPaint body;
175
+ body.setAntiAlias(true);
176
+ body.setColor(fill);
177
+ canvas->drawCircle(b.cx, b.cy, b.radius, body);
178
+
179
+ draw_glyph(canvas, b, glyph);
180
+ });
181
+
182
+ canvas->restore();
183
+ }
184
+
185
+ ::VroomFootprintHit hit_test(const VroomChart& chart,
186
+ const Layout& lay,
187
+ const PriceBounds& bounds,
188
+ float x,
189
+ float y) {
190
+ ::VroomFootprintHit best{};
191
+ best.side = -1;
192
+
193
+ // The rect the badges are clipped to. Reported on every hit so the host can
194
+ // place a tooltip against the real plot edge rather than the element's.
195
+ const float pane_right = vroom::candle_area_width(lay);
196
+ const float pane_bottom = vroom::price_pane_bottom(lay);
197
+ best.pane_left = 0.f;
198
+ best.pane_top = 0.f;
199
+ best.pane_right = pane_right;
200
+ best.pane_bottom = pane_bottom;
201
+
202
+ const int64_t window_ms = chart.visible_end_ms - chart.visible_start_ms;
203
+ // Outside the plot there is nothing to hit, matching the draw-time clip.
204
+ if (x < 0.f || x > pane_right) return best;
205
+ if (y < 0.f || y > pane_bottom) return best;
206
+
207
+ float best_d2 = 0.f;
208
+ for_each_badge(chart, lay, bounds, window_ms, [&](const Badge& b, const Metrics&) {
209
+ if (!hits_badge(b.cx, b.cy, b.radius, x, y)) return;
210
+ // Stacked badges are drawn with a gap, but a generous hit tolerance can
211
+ // still overlap; the nearest center is the one the user meant.
212
+ const float dx = x - b.cx;
213
+ const float dy = y - b.cy;
214
+ const float d2 = dx * dx + dy * dy;
215
+ if (best.side >= 0 && d2 >= best_d2) return;
216
+ best_d2 = d2;
217
+ best.candle_time_ms = b.candle_time_ms;
218
+ best.side = b.side;
219
+ best.center_x = b.cx;
220
+ best.center_y = b.cy;
221
+ best.radius = b.radius;
222
+ });
223
+ return best;
224
+ }
225
+
226
+ } // namespace vroom::footprints