react-native-vroom-chart 0.13.1 → 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.
@@ -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).
@@ -299,6 +303,7 @@ extern "C" void vroom_chart_reset_view(VroomChart* chart) {
299
303
  // here — drop any capture rather than leaving it to reshape the wrong data.
300
304
  chart->morph_from.clear();
301
305
  chart->interval_morph_t = 1.f;
306
+ chart->interval_morph_fade = false;
302
307
  apply_default_framing(chart);
303
308
  vroom::labels::recompute_axis_width(*chart);
304
309
  if (chart->cb.on_viewport_changed) {
@@ -352,10 +357,11 @@ extern "C" void vroom_chart_preserve_price_envelope(VroomChart* chart,
352
357
  chart->mark_dirty();
353
358
  }
354
359
 
355
- extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart) {
360
+ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode) {
356
361
  if (!chart) return;
357
362
  chart->morph_from.clear();
358
363
  chart->interval_morph_t = 1.f;
364
+ chart->interval_morph_fade = false;
359
365
 
360
366
  const auto lay = chart->layout();
361
367
  const float area_w = vroom::candle_area_width(lay);
@@ -400,6 +406,7 @@ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart) {
400
406
  // Open the morph at 0 rather than leaving it at 1: the caller still has to
401
407
  // push the new candles, and any frame painted in between should show the
402
408
  // captured geometry — which is what the pre-switch frame looked like.
409
+ chart->interval_morph_fade = vroom::interval_morph_is_fade(mode);
403
410
  chart->interval_morph_t = 0.f;
404
411
  }
405
412
 
@@ -409,6 +416,7 @@ extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
409
416
  if (chart->interval_morph_t >= 1.f) {
410
417
  chart->morph_from.clear();
411
418
  chart->morph_from.shrink_to_fit();
419
+ chart->interval_morph_fade = false;
412
420
  }
413
421
  chart->mark_dirty();
414
422
  }
@@ -1019,6 +1027,110 @@ extern "C" void vroom_chart_set_price_line_drag(VroomChart* chart, int32_t index
1019
1027
  chart->mark_dirty();
1020
1028
  }
1021
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
+
1022
1134
  extern "C" void vroom_chart_set_draft(VroomChart* chart, int64_t a_time,
1023
1135
  double a_price, bool has_b, int64_t b_time,
1024
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
@@ -0,0 +1,45 @@
1
+ // Footprints — circular badges marking where a trader entered and exited, drawn
2
+ // above the candle each trade fell in.
3
+ //
4
+ // A buy is a "+" in the bull color, a sell a "-" in the bear color, both glyphs in
5
+ // VROOM_COLOR_BADGE_TEXT. Only one badge per side per candle renders however many
6
+ // fills went into it; the host learns the full set through
7
+ // vroom_chart_footprints_at and draws its own tooltip.
8
+ //
9
+ // Bucketing and badge geometry live in footprints_layout.h so the draw and
10
+ // hit-test passes agree by construction.
11
+
12
+ #pragma once
13
+
14
+ #include <cstdint>
15
+
16
+ #include "vroom/vroom_chart.h"
17
+
18
+ class SkCanvas;
19
+ struct VroomChart;
20
+
21
+ namespace vroom {
22
+ struct Layout;
23
+ struct PriceBounds;
24
+ } // namespace vroom
25
+
26
+ namespace vroom::footprints {
27
+
28
+ // Draws every visible footprint badge, clipped to the price pane. `window_ms` is
29
+ // the visible time span, used to place each badge over its candle's slot center.
30
+ void draw(SkCanvas* canvas,
31
+ const VroomChart& chart,
32
+ const Layout& lay,
33
+ const PriceBounds& bounds,
34
+ int64_t window_ms);
35
+
36
+ // Hit-tests pixel (x, y) against the badges. The returned hit's `side` is -1 on a
37
+ // miss; when two badges overlap the nearest center wins. Derives geometry from the
38
+ // same layout functions as draw, so what the user sees is what they can hover.
39
+ ::VroomFootprintHit hit_test(const VroomChart& chart,
40
+ const Layout& lay,
41
+ const PriceBounds& bounds,
42
+ float x,
43
+ float y);
44
+
45
+ } // namespace vroom::footprints
@@ -0,0 +1,140 @@
1
+ #include "footprints_layout.h"
2
+
3
+ #include <algorithm>
4
+ #include <cmath>
5
+
6
+ namespace vroom::footprints {
7
+
8
+ int32_t bucket_index(const ::VroomCandle* candles, size_t count,
9
+ int64_t duration_ms, int64_t time_ms) {
10
+ if (!candles || count == 0 || duration_ms <= 0) return -1;
11
+
12
+ // Last candle that opened at or before the trade.
13
+ const ::VroomCandle* it =
14
+ std::upper_bound(candles, candles + count, time_ms,
15
+ [](int64_t t, const ::VroomCandle& c) { return t < c.time_ms; });
16
+ if (it == candles) return -1; // before the first bar
17
+ const int32_t i = static_cast<int32_t>((it - 1) - candles);
18
+
19
+ // Inside that bar's window, or in a gap where no bar exists.
20
+ if (time_ms >= candles[i].time_ms + duration_ms) return -1;
21
+ return i;
22
+ }
23
+
24
+ std::vector<Bucket> build_buckets(const ::VroomCandle* candles, size_t count,
25
+ int64_t duration_ms,
26
+ const ::VroomFootprint* prints, size_t print_count) {
27
+ std::vector<Bucket> out;
28
+ if (!candles || count == 0 || !prints || print_count == 0) return out;
29
+
30
+ // Bucket per candle index, then compact. Candle counts stay in the low
31
+ // thousands, so a flat scratch vector beats a map on both sides of the ledger.
32
+ std::vector<int32_t> slot(count, -1);
33
+
34
+ for (size_t i = 0; i < print_count; ++i) {
35
+ const ::VroomFootprint& p = prints[i];
36
+ if (p.side != VROOM_FOOTPRINT_BUY && p.side != VROOM_FOOTPRINT_SELL) continue;
37
+
38
+ const int32_t ci = bucket_index(candles, count, duration_ms, p.time_ms);
39
+ if (ci < 0) continue;
40
+
41
+ if (slot[static_cast<size_t>(ci)] < 0) {
42
+ slot[static_cast<size_t>(ci)] = static_cast<int32_t>(out.size());
43
+ Bucket b;
44
+ b.candle_time_ms = candles[ci].time_ms;
45
+ out.push_back(std::move(b));
46
+ }
47
+ Bucket& b = out[static_cast<size_t>(slot[static_cast<size_t>(ci)])];
48
+ if (p.side == VROOM_FOOTPRINT_BUY) {
49
+ b.buys.push_back(static_cast<int32_t>(i));
50
+ } else {
51
+ b.sells.push_back(static_cast<int32_t>(i));
52
+ }
53
+ }
54
+
55
+ // The host's array can arrive in any order, so sort each side by time and read
56
+ // the latest off the end.
57
+ const auto by_time = [prints](int32_t a, int32_t b) {
58
+ const int64_t ta = prints[static_cast<size_t>(a)].time_ms;
59
+ const int64_t tb = prints[static_cast<size_t>(b)].time_ms;
60
+ // Fall back to the host's order so equal timestamps stay stable.
61
+ return ta != tb ? ta < tb : a < b;
62
+ };
63
+ for (Bucket& b : out) {
64
+ std::sort(b.buys.begin(), b.buys.end(), by_time);
65
+ std::sort(b.sells.begin(), b.sells.end(), by_time);
66
+ if (!b.buys.empty()) {
67
+ b.buy_latest_ms = prints[static_cast<size_t>(b.buys.back())].time_ms;
68
+ }
69
+ if (!b.sells.empty()) {
70
+ b.sell_latest_ms = prints[static_cast<size_t>(b.sells.back())].time_ms;
71
+ }
72
+ }
73
+
74
+ // Buckets were created in first-seen order; callers want them by candle.
75
+ std::sort(out.begin(), out.end(), [](const Bucket& a, const Bucket& b) {
76
+ return a.candle_time_ms < b.candle_time_ms;
77
+ });
78
+ return out;
79
+ }
80
+
81
+ Metrics metrics_from(const ::VroomFootprintStyle* style) {
82
+ Metrics m;
83
+ if (!style) return m;
84
+ if (style->radius_px > 0.f) m.radius = style->radius_px;
85
+ if (style->gap_px > 0.f) m.gap = style->gap_px;
86
+ if (style->margin_px > 0.f) m.margin = style->margin_px;
87
+ if (style->hover_boost > 0.f) m.hover_boost = style->hover_boost;
88
+ return m;
89
+ }
90
+
91
+ Stack layout_stack(const Bucket& bucket, float high_y, float pane_top,
92
+ float radius, float gap, float margin) {
93
+ Stack out;
94
+ if (radius <= 0.f) radius = kRadius;
95
+ if (gap <= 0.f) gap = kGap;
96
+ if (margin <= 0.f) margin = kMargin;
97
+
98
+ const bool buys = bucket.has_buys();
99
+ const bool sells = bucket.has_sells();
100
+ if (!buys && !sells) return out;
101
+
102
+ if (buys && sells) {
103
+ // Bottom-to-top replays the order the trades happened in. Ties go to the
104
+ // buy, so an entry and exit stamped the same ms still lay out predictably.
105
+ const bool buy_first = bucket.buy_latest_ms <= bucket.sell_latest_ms;
106
+ out.sides[0] = buy_first ? VROOM_FOOTPRINT_BUY : VROOM_FOOTPRINT_SELL;
107
+ out.sides[1] = buy_first ? VROOM_FOOTPRINT_SELL : VROOM_FOOTPRINT_BUY;
108
+ out.count = 2;
109
+ } else {
110
+ out.sides[0] = buys ? VROOM_FOOTPRINT_BUY : VROOM_FOOTPRINT_SELL;
111
+ out.count = 1;
112
+ }
113
+
114
+ // Slot 0 clears the high by `margin`; each further slot clears the one below
115
+ // it by `gap`, which is what keeps them separately hoverable.
116
+ const float pitch = 2.f * radius + gap;
117
+ for (int32_t i = 0; i < out.count; ++i) {
118
+ out.y[i] = high_y - margin - radius - static_cast<float>(i) * pitch;
119
+ }
120
+
121
+ // A bar near the top of the view would push its stack off-screen; slide the
122
+ // whole stack down so the topmost badge stays inside the pane. Moving both
123
+ // together preserves the order the stack encodes.
124
+ const float top_edge = out.y[out.count - 1] - radius;
125
+ if (top_edge < pane_top) {
126
+ const float shift = pane_top - top_edge;
127
+ for (int32_t i = 0; i < out.count; ++i) out.y[i] += shift;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ bool hits_badge(float cx, float cy, float radius, float x, float y) {
133
+ if (radius <= 0.f) return false;
134
+ const float dx = x - cx;
135
+ const float dy = y - cy;
136
+ const float r = radius + kHitTolerance;
137
+ return dx * dx + dy * dy <= r * r;
138
+ }
139
+
140
+ } // namespace vroom::footprints
@@ -0,0 +1,94 @@
1
+ // Footprint bucketing + badge geometry — pure functions, no Skia, no state.
2
+ //
3
+ // Split out from footprints.cpp for the same reason as price_line_layout.h: the
4
+ // render pass and the hit-test pass must agree on where every badge sits, or the
5
+ // user ends up hovering nothing where a badge clearly is. Being Skia-free it also
6
+ // unit-tests without a Skia checkout.
7
+
8
+ #pragma once
9
+
10
+ #include <cstddef>
11
+ #include <cstdint>
12
+ #include <vector>
13
+
14
+ #include "vroom/vroom_chart.h"
15
+
16
+ namespace vroom::footprints {
17
+
18
+ // Badge radius, the gap between two stacked badges, and the gap between the
19
+ // candle's high and the first badge. Defaults for a zero/negative style field.
20
+ constexpr float kRadius = 9.f;
21
+ constexpr float kGap = 4.f;
22
+ constexpr float kMargin = 8.f;
23
+ constexpr float kHoverBoost = 1.25f;
24
+
25
+ // Slop around the circle so a 9px badge is still a comfortable target, on a
26
+ // mouse and under a thumb alike.
27
+ constexpr float kHitTolerance = 3.f;
28
+
29
+ // One candle's footprints, split by side. Both lists hold indices into the array
30
+ // the host passed to vroom_chart_set_footprints — its order, so a caller can map
31
+ // them straight back to its own trade objects — sorted ascending by time.
32
+ struct Bucket {
33
+ int64_t candle_time_ms = 0;
34
+ std::vector<int32_t> buys;
35
+ std::vector<int32_t> sells;
36
+ // Time of the last trade on each side, which decides the stack order. Only
37
+ // meaningful when the matching list is non-empty.
38
+ int64_t buy_latest_ms = 0;
39
+ int64_t sell_latest_ms = 0;
40
+
41
+ bool has_buys() const { return !buys.empty(); }
42
+ bool has_sells() const { return !sells.empty(); }
43
+ // Every candle that made it into a bucket has at least one badge to draw.
44
+ int32_t badge_count() const { return (has_buys() ? 1 : 0) + (has_sells() ? 1 : 0); }
45
+ };
46
+
47
+ // Index of the candle whose [time_ms, time_ms + duration_ms) window contains
48
+ // `time_ms`, or -1 when it falls in no window — before the first candle, or in a
49
+ // gap (a weekend, a halt) where no bar exists. Candles must be ascending.
50
+ int32_t bucket_index(const ::VroomCandle* candles, size_t count,
51
+ int64_t duration_ms, int64_t time_ms);
52
+
53
+ // Groups `prints` onto candles. The result is ascending by candle time and holds
54
+ // only candles that got at least one footprint, so it is also the dedupe: however
55
+ // many trades a bar collected, it comes back as one bucket with at most two sides.
56
+ // Footprints with an unknown side, or that land in no candle's window, are dropped.
57
+ std::vector<Bucket> build_buckets(const ::VroomCandle* candles, size_t count,
58
+ int64_t duration_ms,
59
+ const ::VroomFootprint* prints, size_t print_count);
60
+
61
+ // The laid-out badge stack for one candle: one or two centers, going up from the
62
+ // candle's high. Slot 0 is the one nearest the bar.
63
+ struct Stack {
64
+ int32_t count = 0;
65
+ int32_t sides[2]{}; // VroomFootprintSide per slot
66
+ float y[2]{}; // center y per slot
67
+ };
68
+
69
+ // Stacks `bucket`'s badges above `high_y` (the pixel y of the candle's high).
70
+ //
71
+ // The side whose latest trade came *first* takes the lower slot, so reading the
72
+ // stack bottom-to-top replays the order the trades happened in. The whole stack
73
+ // shifts down if it would poke out the top of the pane, keeping badges on a bar
74
+ // near the high of the view visible and hoverable rather than clipped away.
75
+ //
76
+ // Style fields at or below zero fall back to kRadius / kGap / kMargin.
77
+ Stack layout_stack(const Bucket& bucket, float high_y, float pane_top,
78
+ float radius, float gap, float margin);
79
+
80
+ // True when (x, y) is within a badge of `radius` centered at (cx, cy), plus
81
+ // kHitTolerance.
82
+ bool hits_badge(float cx, float cy, float radius, float x, float y);
83
+
84
+ // Resolves the style struct's raw fields to the values the layout should use.
85
+ // A null `style`, or any field left at zero, takes the default.
86
+ struct Metrics {
87
+ float radius = kRadius;
88
+ float gap = kGap;
89
+ float margin = kMargin;
90
+ float hover_boost = kHoverBoost;
91
+ };
92
+ Metrics metrics_from(const ::VroomFootprintStyle* style);
93
+
94
+ } // namespace vroom::footprints
@@ -1,10 +1,10 @@
1
1
  // The pulsing ring at the line chart's tip — a phase in, a radius and two
2
2
  // alphas out (VROOM_FLOAT_LINE_TIP_PULSE).
3
3
  //
4
- // Shape and timing follow TradingView's last-price animation, whose numbers are
5
- // well-tuned: expand while the fill washes out and the edge sharpens, keep
6
- // expanding while the edge fades, then rest. That rest is nearly half the period
7
- // and it is what makes the ring read as a heartbeat instead of a strobe.
4
+ // Shape and timing follow the last-price animation charting tools converged on:
5
+ // expand while the fill washes out and the edge sharpens, keep expanding while
6
+ // the edge fades, then rest. That rest is nearly half the period and it is what
7
+ // makes the ring read as a heartbeat instead of a strobe.
8
8
  //
9
9
  // Radii come out as multiples of the ring's start radius rather than pixels,
10
10
  // because the tip dot scales with the line width and the ring has to scale with