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.
- package/cpp/VroomChartHostObject.cpp +133 -8
- package/cpp/_core_include/vroom/vroom_chart.h +90 -7
- package/cpp/_core_src/candles.h +7 -5
- package/cpp/_core_src/chart.cpp +287 -152
- package/cpp/_core_src/chart.h +29 -3
- package/cpp/_core_src/chart_facade.cpp +113 -1
- package/cpp/_core_src/footprints.cpp +226 -0
- package/cpp/_core_src/footprints.h +45 -0
- package/cpp/_core_src/footprints_layout.cpp +140 -0
- package/cpp/_core_src/footprints_layout.h +94 -0
- package/cpp/_core_src/tip_pulse.h +4 -4
- package/cpp/_core_src/viewport.h +4 -0
- package/lib/index.d.mts +186 -6
- package/lib/index.d.ts +186 -6
- package/lib/index.js +89 -7
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +89 -7
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +89 -5
- package/src/index.ts +7 -0
- package/src/jsi.d.ts +45 -8
- package/src/types.ts +7 -0
- package/src/useChartCore.ts +56 -5
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|