react-native-vroom-chart 0.2.0 → 0.5.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.
@@ -12,6 +12,7 @@
12
12
  #include <limits>
13
13
 
14
14
  #include "chart.h"
15
+ #include "drawings.h"
15
16
  #include "labels.h"
16
17
  #include "viewport.h"
17
18
 
@@ -130,6 +131,7 @@ extern "C" void vroom_chart_set_candles(VroomChart* chart, const VroomCandle* da
130
131
  chart->macd_dirty = true;
131
132
  chart->overlays_dirty = true;
132
133
  chart->vwap_dirty = true;
134
+ chart->bollinger_dirty = true;
133
135
 
134
136
  // Infer the candle period from the first interval. Robust enough for
135
137
  // uniform-duration series (the only kind we model today).
@@ -157,6 +159,7 @@ extern "C" void vroom_chart_append_candle(VroomChart* chart, const VroomCandle*
157
159
  chart->macd_dirty = true;
158
160
  chart->overlays_dirty = true;
159
161
  chart->vwap_dirty = true;
162
+ chart->bollinger_dirty = true;
160
163
  chart->mark_dirty();
161
164
  }
162
165
 
@@ -167,6 +170,7 @@ extern "C" void vroom_chart_update_last(VroomChart* chart, const VroomCandle* c)
167
170
  chart->macd_dirty = true;
168
171
  chart->overlays_dirty = true;
169
172
  chart->vwap_dirty = true;
173
+ chart->bollinger_dirty = true;
170
174
  chart->mark_dirty();
171
175
  }
172
176
 
@@ -204,6 +208,26 @@ extern "C" void vroom_chart_set_default_candle_width(VroomChart* chart, float px
204
208
  chart->mark_dirty();
205
209
  }
206
210
 
211
+ extern "C" void vroom_chart_set_chart_type(VroomChart* chart, int32_t mode) {
212
+ if (!chart) return;
213
+ chart->chart_type = mode;
214
+ // Snap the morph blend to the target so a direct set (no animation) renders
215
+ // the requested mode immediately. The JS animation loop uses set_morph for
216
+ // the in-between frames and then calls this to lock the final state.
217
+ const float t = mode == 1 ? 1.f : 0.f;
218
+ chart->morph_collapse = t;
219
+ chart->morph_fade = t;
220
+ chart->mark_dirty();
221
+ }
222
+
223
+ extern "C" void vroom_chart_set_morph(VroomChart* chart, float collapse,
224
+ float fade) {
225
+ if (!chart) return;
226
+ chart->morph_collapse = collapse;
227
+ chart->morph_fade = fade;
228
+ chart->mark_dirty();
229
+ }
230
+
207
231
  extern "C" void vroom_chart_get_visible_range(VroomChart* chart,
208
232
  int64_t* out_start_ms,
209
233
  int64_t* out_end_ms) {
@@ -749,7 +773,31 @@ extern "C" void vroom_chart_set_drawings(VroomChart* chart,
749
773
  const VroomDrawing* drawings,
750
774
  size_t count) {
751
775
  if (!chart) return;
752
- chart->drawings.assign(drawings, drawings + count);
776
+ // Deep-copy into the owning form: a pencil's path lives in the chart, so the
777
+ // caller's `points` buffer may be freed as soon as this returns.
778
+ chart->drawings.clear();
779
+ chart->drawings.reserve(count);
780
+ for (size_t i = 0; i < count; ++i) {
781
+ const VroomDrawing& src = drawings[i];
782
+ VroomChart::StoredDrawing d;
783
+ d.a = src.a;
784
+ d.b = src.b;
785
+ d.color = src.color;
786
+ d.width = src.width;
787
+ d.kind = src.kind;
788
+ if (src.kind == 2 && src.points && src.point_count > 0) {
789
+ d.points.assign(src.points, src.points + src.point_count);
790
+ // Keep a/b mirroring the path ends so bounds/handle code is uniform.
791
+ d.a = d.points.front();
792
+ d.b = d.points.back();
793
+ }
794
+ chart->drawings.push_back(std::move(d));
795
+ }
796
+ // Drop selection if the new set no longer contains that index.
797
+ if (chart->selected_drawing >= static_cast<int32_t>(count)) {
798
+ chart->selected_drawing = -1;
799
+ chart->grabbed_endpoint = -1;
800
+ }
753
801
  chart->mark_dirty();
754
802
  }
755
803
 
@@ -767,7 +815,7 @@ extern "C" void vroom_chart_set_liquidity(VroomChart* chart,
767
815
  extern "C" void vroom_chart_set_draft(VroomChart* chart, int64_t a_time,
768
816
  double a_price, bool has_b, int64_t b_time,
769
817
  double b_price, bool guide, uint32_t color,
770
- float width) {
818
+ float width, int32_t kind) {
771
819
  if (!chart) return;
772
820
  chart->draft_active = true;
773
821
  chart->draft_a = VroomDrawPoint{a_time, a_price};
@@ -776,6 +824,30 @@ extern "C" void vroom_chart_set_draft(VroomChart* chart, int64_t a_time,
776
824
  chart->draft_guide = guide;
777
825
  chart->draft_color = color;
778
826
  chart->draft_width = width;
827
+ chart->draft_kind = kind;
828
+ chart->mark_dirty();
829
+ }
830
+
831
+ extern "C" void vroom_chart_start_draft_stroke(VroomChart* chart, uint32_t color,
832
+ float width) {
833
+ if (!chart) return;
834
+ chart->draft_active = true;
835
+ chart->draft_kind = 2;
836
+ chart->draft_guide = true;
837
+ chart->draft_has_b = false;
838
+ chart->draft_color = color;
839
+ chart->draft_width = width;
840
+ chart->draft_points.clear();
841
+ chart->mark_dirty();
842
+ }
843
+
844
+ extern "C" void vroom_chart_append_draft_point(VroomChart* chart, int64_t time_ms,
845
+ double price) {
846
+ if (!chart) return;
847
+ if (!chart->draft_active || chart->draft_kind != 2) return;
848
+ chart->draft_points.push_back(VroomDrawPoint{time_ms, price});
849
+ // draft_a doubles as the stroke's first point for the node-dot code path.
850
+ if (chart->draft_points.size() == 1) chart->draft_a = chart->draft_points.front();
779
851
  chart->mark_dirty();
780
852
  }
781
853
 
@@ -783,6 +855,7 @@ extern "C" void vroom_chart_clear_draft(VroomChart* chart) {
783
855
  if (!chart) return;
784
856
  if (!chart->draft_active) return;
785
857
  chart->draft_active = false;
858
+ chart->draft_points.clear();
786
859
  chart->mark_dirty();
787
860
  }
788
861
 
@@ -808,6 +881,91 @@ extern "C" bool vroom_chart_coord_at(VroomChart* chart, float x_px, float y_px,
808
881
  return true;
809
882
  }
810
883
 
884
+ extern "C" bool vroom_chart_project(VroomChart* chart, int64_t time_ms,
885
+ double price, float* out_x, float* out_y) {
886
+ if (!chart || chart->candles.empty()) return false;
887
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
888
+ if (window_ms <= 0) return false;
889
+ const auto lay = chart->layout();
890
+ // Same bounds as coord_at / draw_chart so the projection matches the render.
891
+ const auto range = vroom::visible_indices(
892
+ chart->candles.data(), chart->candles.size(),
893
+ chart->visible_start_ms, chart->visible_end_ms);
894
+ const size_t n = range.end - range.start;
895
+ const auto bounds =
896
+ chart->price_bounds_manual
897
+ ? chart->price_bounds
898
+ : vroom::auto_price_bounds(chart->candles.data() + range.start, n);
899
+ if (out_x)
900
+ *out_x = vroom::x_at_time(lay, chart->visible_start_ms, window_ms, time_ms);
901
+ if (out_y) *out_y = vroom::price_to_y(lay, bounds, price);
902
+ return true;
903
+ }
904
+
905
+ extern "C" bool vroom_chart_hit_test_drawing(VroomChart* chart, float x_px,
906
+ float y_px, int32_t* out_index,
907
+ int32_t* out_part, float* out_t) {
908
+ if (!chart || chart->candles.empty()) return false;
909
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
910
+ if (window_ms <= 0) return false;
911
+ const auto lay = chart->layout();
912
+ // Same bounds as coord_at / draw_chart so screen geometry matches the render.
913
+ const auto range = vroom::visible_indices(
914
+ chart->candles.data(), chart->candles.size(),
915
+ chart->visible_start_ms, chart->visible_end_ms);
916
+ const size_t n = range.end - range.start;
917
+ const auto bounds =
918
+ chart->price_bounds_manual
919
+ ? chart->price_bounds
920
+ : vroom::auto_price_bounds(chart->candles.data() + range.start, n);
921
+ const auto hit = vroom::drawings::hit_test(*chart, lay, bounds, window_ms, x_px, y_px);
922
+ if (hit.index < 0) return false;
923
+ if (out_index) *out_index = hit.index;
924
+ if (out_part) *out_part = hit.part;
925
+ if (out_t) *out_t = hit.t;
926
+ return true;
927
+ }
928
+
929
+ extern "C" void vroom_chart_set_selected_drawing(VroomChart* chart,
930
+ int32_t index,
931
+ int32_t grabbed_endpoint) {
932
+ if (!chart) return;
933
+ if (index < 0 || index >= static_cast<int32_t>(chart->drawings.size())) {
934
+ index = -1;
935
+ grabbed_endpoint = -1;
936
+ }
937
+ chart->selected_drawing = index;
938
+ chart->grabbed_endpoint = grabbed_endpoint;
939
+ chart->mark_dirty();
940
+ }
941
+
942
+ extern "C" void vroom_chart_move_drawing_endpoint(VroomChart* chart, int32_t index,
943
+ int32_t endpoint, int64_t time_ms,
944
+ double price) {
945
+ if (!chart) return;
946
+ if (index < 0 || index >= static_cast<int32_t>(chart->drawings.size())) return;
947
+ VroomDrawPoint& pt = endpoint == 0 ? chart->drawings[index].a
948
+ : chart->drawings[index].b;
949
+ pt.time_ms = time_ms;
950
+ pt.price = price;
951
+ chart->mark_dirty();
952
+ }
953
+
954
+ extern "C" void vroom_chart_translate_drawing(VroomChart* chart, int32_t index,
955
+ int64_t d_time_ms, double d_price) {
956
+ if (!chart) return;
957
+ if (index < 0 || index >= static_cast<int32_t>(chart->drawings.size())) return;
958
+ auto& d = chart->drawings[index];
959
+ const auto shift = [&](VroomDrawPoint& p) {
960
+ p.time_ms += d_time_ms;
961
+ p.price += d_price;
962
+ };
963
+ shift(d.a);
964
+ shift(d.b);
965
+ for (VroomDrawPoint& p : d.points) shift(p);
966
+ chart->mark_dirty();
967
+ }
968
+
811
969
  // ---- Indicators -----------------------------------------------------------
812
970
 
813
971
  extern "C" void vroom_chart_set_rsi(VroomChart* chart, bool enabled, int period,
@@ -894,6 +1052,29 @@ extern "C" void vroom_chart_set_vwap(VroomChart* chart, bool enabled,
894
1052
  chart->mark_dirty();
895
1053
  }
896
1054
 
1055
+ extern "C" void vroom_chart_set_bollinger(VroomChart* chart,
1056
+ const VroomBollinger* cfg) {
1057
+ if (!chart || !cfg) return;
1058
+ VroomBollinger next = *cfg;
1059
+ next.enabled = next.enabled ? 1 : 0;
1060
+ next.fill_enabled = next.fill_enabled ? 1 : 0;
1061
+ if (next.period < 1) next.period = 1;
1062
+ if (!(next.mult >= 0.f)) next.mult = 0.f;
1063
+ next.fill_opacity = std::clamp(next.fill_opacity, 0.f, 1.f);
1064
+
1065
+ // Only the series-affecting fields force a recompute; style and fill
1066
+ // changes are render-only.
1067
+ const VroomBollinger& cur = chart->bollinger;
1068
+ const bool recompute = cur.enabled != next.enabled ||
1069
+ cur.period != next.period ||
1070
+ cur.mult != next.mult ||
1071
+ cur.source != next.source ||
1072
+ cur.basis_kind != next.basis_kind;
1073
+ chart->bollinger = next;
1074
+ if (recompute) chart->bollinger_dirty = true;
1075
+ chart->mark_dirty();
1076
+ }
1077
+
897
1078
  // ---- Direct draw (used by hosts that don't need the SkPicture cache) ------
898
1079
 
899
1080
  extern "C" void vroom_chart_draw(VroomChart* chart, SkCanvas* canvas) {
@@ -5,10 +5,16 @@
5
5
  #include "include/core/SkCanvas.h"
6
6
  #include "include/core/SkColor.h"
7
7
  #include "include/core/SkPaint.h"
8
+ #include "include/core/SkPath.h"
9
+ #include "include/core/SkPathBuilder.h"
8
10
  #include "include/core/SkPoint.h"
9
11
  #include "include/core/SkRect.h"
10
12
  #pragma clang diagnostic pop
11
13
 
14
+ #include <algorithm>
15
+ #include <array>
16
+ #include <cmath>
17
+
12
18
  #include "chart.h"
13
19
 
14
20
  namespace vroom::drawings {
@@ -31,18 +37,176 @@ SkPoint to_px(const VroomChart& chart, const Layout& lay, const PriceBounds& bou
31
37
  return SkPoint{x, y};
32
38
  }
33
39
 
34
- void draw_node(SkCanvas* canvas, SkPoint pt) {
40
+ // The four corners of the box with opposite corners `a` and `b`, in the fixed
41
+ // order used by hit-test `part` indices: a, (b.x,a.y), b, (a.x,b.y). Corner k's
42
+ // diagonal is corner (k+2)%4.
43
+ std::array<SkPoint, 4> box_corners(SkPoint a, SkPoint b) {
44
+ return {SkPoint{a.fX, a.fY}, SkPoint{b.fX, a.fY}, SkPoint{b.fX, b.fY},
45
+ SkPoint{a.fX, b.fY}};
46
+ }
47
+
48
+ // The normalized (L,T,R,B) rectangle spanning opposite corners `a` and `b`.
49
+ SkRect box_rect(SkPoint a, SkPoint b) {
50
+ return SkRect::MakeLTRB(std::min(a.fX, b.fX), std::min(a.fY, b.fY),
51
+ std::max(a.fX, b.fX), std::max(a.fY, b.fY));
52
+ }
53
+
54
+ // Strokes the box outline and paints a faint fill (~10% of the border alpha) of
55
+ // the same color, spanning opposite corners `a` and `b`.
56
+ void draw_box(SkCanvas* canvas, SkPoint a, SkPoint b, SkColor color, float width) {
57
+ const SkRect r = box_rect(a, b);
58
+
59
+ SkPaint fill;
60
+ fill.setAntiAlias(true);
61
+ fill.setStyle(SkPaint::kFill_Style);
62
+ // Faint fill: 10% of the border's alpha, same RGB.
63
+ const U8CPU fill_alpha = static_cast<U8CPU>(SkColorGetA(color) * 0.1f);
64
+ fill.setColor(SkColorSetA(color, fill_alpha));
65
+ canvas->drawRect(r, fill);
66
+
67
+ SkPaint border;
68
+ border.setAntiAlias(true);
69
+ border.setColor(color);
70
+ border.setStyle(SkPaint::kStroke_Style);
71
+ border.setStrokeWidth(width > 0.f ? width : 2.f);
72
+ canvas->drawRect(r, border);
73
+ }
74
+
75
+ // Strokes a freehand path through `pts` (data space). The polyline is smoothed
76
+ // with quadratic curves through segment midpoints — each captured point becomes
77
+ // a control point, so the stroke reads as one organic curve instead of a chain
78
+ // of visible corners. Round caps/joins give it a pen-like feel.
79
+ void draw_path(SkCanvas* canvas, const VroomChart& chart, const Layout& lay,
80
+ const PriceBounds& bounds, int64_t window_ms,
81
+ const std::vector<VroomDrawPoint>& pts, SkColor color,
82
+ float width) {
83
+ if (pts.empty()) return;
84
+ const float w = width > 0.f ? width : 2.f;
85
+
86
+ SkPaint paint;
87
+ paint.setAntiAlias(true);
88
+ paint.setColor(color);
89
+
90
+ // A stroke that's still a single sample renders as a dot, so pressing down
91
+ // gives immediate feedback before the pointer moves.
92
+ if (pts.size() == 1) {
93
+ const SkPoint p = to_px(chart, lay, bounds, window_ms, pts[0]);
94
+ paint.setStyle(SkPaint::kFill_Style);
95
+ canvas->drawCircle(p.fX, p.fY, w * 0.5f, paint);
96
+ return;
97
+ }
98
+
99
+ SkPathBuilder builder;
100
+ builder.moveTo(to_px(chart, lay, bounds, window_ms, pts[0]));
101
+ // Curve through every interior point, ending each quad at the midpoint of
102
+ // the next segment; finish with a straight run to the final sample.
103
+ for (size_t i = 1; i + 1 < pts.size(); ++i) {
104
+ const SkPoint cur = to_px(chart, lay, bounds, window_ms, pts[i]);
105
+ const SkPoint next = to_px(chart, lay, bounds, window_ms, pts[i + 1]);
106
+ builder.quadTo(cur, SkPoint{(cur.fX + next.fX) * 0.5f,
107
+ (cur.fY + next.fY) * 0.5f});
108
+ }
109
+ builder.lineTo(to_px(chart, lay, bounds, window_ms, pts.back()));
110
+
111
+ paint.setStyle(SkPaint::kStroke_Style);
112
+ paint.setStrokeWidth(w);
113
+ paint.setStrokeCap(SkPaint::kRound_Cap);
114
+ paint.setStrokeJoin(SkPaint::kRound_Join);
115
+ canvas->drawPath(builder.detach(), paint);
116
+ }
117
+
118
+ void draw_node(SkCanvas* canvas, SkPoint pt, float scale = 1.f) {
35
119
  SkPaint fill;
36
120
  fill.setAntiAlias(true);
37
121
  fill.setColor(kNodeFill);
38
- canvas->drawCircle(pt.fX, pt.fY, kNodeFillRadius, fill);
122
+ canvas->drawCircle(pt.fX, pt.fY, kNodeFillRadius * scale, fill);
39
123
 
40
124
  SkPaint border;
41
125
  border.setAntiAlias(true);
42
126
  border.setColor(kNodeBorder);
43
127
  border.setStyle(SkPaint::kStroke_Style);
44
128
  border.setStrokeWidth(kNodeBorderWidth);
45
- canvas->drawCircle(pt.fX, pt.fY, kNodeRingRadius, border);
129
+ canvas->drawCircle(pt.fX, pt.fY, kNodeRingRadius * scale, border);
130
+ }
131
+
132
+ // Clamped projection parameter (0..1) of point (px,py) onto segment
133
+ // (ax,ay)-(bx,by): 0 at A, 1 at B.
134
+ float segment_t(float px, float py, float ax, float ay, float bx, float by) {
135
+ const float dx = bx - ax;
136
+ const float dy = by - ay;
137
+ const float len2 = dx * dx + dy * dy;
138
+ const float t = len2 > 0.f ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0.f;
139
+ return std::clamp(t, 0.f, 1.f);
140
+ }
141
+
142
+ // Shortest distance from point (px,py) to segment (ax,ay)-(bx,by), in px.
143
+ float dist_to_segment(float px, float py, float ax, float ay, float bx, float by) {
144
+ const float t = segment_t(px, py, ax, ay, bx, by);
145
+ const float cx = ax + t * (bx - ax);
146
+ const float cy = ay + t * (by - ay);
147
+ const float ex = px - cx;
148
+ const float ey = py - cy;
149
+ return std::sqrt(ex * ex + ey * ey);
150
+ }
151
+
152
+ // True when (px,py) is inside the box (the grabbable fill area).
153
+ bool point_in_rect(float px, float py, SkPoint a, SkPoint b) {
154
+ const SkRect r = box_rect(a, b);
155
+ return px >= r.fLeft && px <= r.fRight && py >= r.fTop && py <= r.fBottom;
156
+ }
157
+
158
+ // Shortest distance from (px,py) to the freehand polyline through `pts`, in px.
159
+ // Cheap bounding-box reject first (a stroke can hold many points and most
160
+ // hit-tests miss), then distance-to-segment across consecutive samples — the
161
+ // same two-stage approach Excalidraw uses for its freedraw elements. Returns a
162
+ // value greater than `tol` on a clear miss.
163
+ float dist_to_path(float px, float py, const VroomChart& chart, const Layout& lay,
164
+ const PriceBounds& bounds, int64_t window_ms,
165
+ const std::vector<VroomDrawPoint>& pts, float tol) {
166
+ const float kMiss = tol + 1.f;
167
+ if (pts.size() < 2) return kMiss;
168
+
169
+ float min_x = 0.f, max_x = 0.f, min_y = 0.f, max_y = 0.f;
170
+ for (size_t i = 0; i < pts.size(); ++i) {
171
+ const SkPoint p = to_px(chart, lay, bounds, window_ms, pts[i]);
172
+ if (i == 0) {
173
+ min_x = max_x = p.fX;
174
+ min_y = max_y = p.fY;
175
+ } else {
176
+ min_x = std::min(min_x, p.fX);
177
+ max_x = std::max(max_x, p.fX);
178
+ min_y = std::min(min_y, p.fY);
179
+ max_y = std::max(max_y, p.fY);
180
+ }
181
+ }
182
+ if (px < min_x - tol || px > max_x + tol || py < min_y - tol ||
183
+ py > max_y + tol) {
184
+ return kMiss;
185
+ }
186
+
187
+ float best = kMiss;
188
+ SkPoint prev = to_px(chart, lay, bounds, window_ms, pts[0]);
189
+ for (size_t i = 1; i < pts.size(); ++i) {
190
+ const SkPoint cur = to_px(chart, lay, bounds, window_ms, pts[i]);
191
+ best = std::min(best,
192
+ dist_to_segment(px, py, prev.fX, prev.fY, cur.fX, cur.fY));
193
+ prev = cur;
194
+ }
195
+ return best;
196
+ }
197
+
198
+ // Shortest distance from (px,py) to the box's four edges, in px (0 when on an
199
+ // edge). Used so the border is grabbable even outside the fill's tolerance.
200
+ float dist_to_rect_edges(float px, float py, SkPoint a, SkPoint b) {
201
+ const auto c = box_corners(a, b);
202
+ float best = dist_to_segment(px, py, c[0].fX, c[0].fY, c[1].fX, c[1].fY);
203
+ for (int i = 1; i < 4; ++i) {
204
+ const SkPoint& p0 = c[i];
205
+ const SkPoint& p1 = c[(i + 1) % 4];
206
+ best = std::min(best,
207
+ dist_to_segment(px, py, p0.fX, p0.fY, p1.fX, p1.fY));
208
+ }
209
+ return best;
46
210
  }
47
211
  } // namespace
48
212
 
@@ -62,9 +226,18 @@ void draw(SkCanvas* canvas,
62
226
  if (!chart.drawings.empty()) {
63
227
  canvas->save();
64
228
  canvas->clipRect(clip);
65
- for (const VroomDrawing& d : chart.drawings) {
229
+ for (const auto& d : chart.drawings) {
230
+ if (d.kind == 2) {
231
+ draw_path(canvas, chart, lay, bounds, window_ms, d.points,
232
+ static_cast<SkColor>(d.color), d.width);
233
+ continue;
234
+ }
66
235
  const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
67
236
  const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
237
+ if (d.kind == 1) {
238
+ draw_box(canvas, a, b, static_cast<SkColor>(d.color), d.width);
239
+ continue;
240
+ }
68
241
  SkPaint line;
69
242
  line.setAntiAlias(true);
70
243
  line.setColor(static_cast<SkColor>(d.color));
@@ -75,34 +248,159 @@ void draw(SkCanvas* canvas,
75
248
  canvas->restore();
76
249
  }
77
250
 
251
+ // 1b. Handles on the selected committed drawing (unclipped, like the draft's
252
+ // dots). The grabbed endpoint renders 50% larger.
253
+ if (chart.selected_drawing >= 0 &&
254
+ static_cast<size_t>(chart.selected_drawing) < chart.drawings.size()) {
255
+ const auto& d = chart.drawings[chart.selected_drawing];
256
+ const SkPoint sa = to_px(chart, lay, bounds, window_ms, d.a);
257
+ const SkPoint sb = to_px(chart, lay, bounds, window_ms, d.b);
258
+ if (d.kind == 2) {
259
+ // Pencil: anchors on the first/last point are a visual cue that the
260
+ // stroke is movable — they aren't grab handles, so never enlarged.
261
+ draw_node(canvas, sa);
262
+ draw_node(canvas, sb);
263
+ } else if (d.kind == 1) {
264
+ // Four corner handles. The gesture layer stores the grabbed corner
265
+ // as endpoint `a` (its diagonal as `b`), so corner 0 is the active
266
+ // one while dragging.
267
+ const auto corners = box_corners(sa, sb);
268
+ for (int i = 0; i < 4; ++i) {
269
+ draw_node(canvas, corners[i],
270
+ (chart.grabbed_endpoint == 0 && i == 0) ? 1.5f : 1.f);
271
+ }
272
+ } else {
273
+ draw_node(canvas, sa, chart.grabbed_endpoint == 0 ? 1.5f : 1.f);
274
+ draw_node(canvas, sb, chart.grabbed_endpoint == 1 ? 1.5f : 1.f);
275
+ }
276
+ }
277
+
78
278
  if (!chart.draft_active) return;
79
279
 
280
+ // 1c. Freehand stroke in progress: draw the live path, clipped like the
281
+ // committed shapes. It grows a point at a time via append_draft_point.
282
+ if (chart.draft_kind == 2) {
283
+ if (chart.draft_points.empty()) return;
284
+ canvas->save();
285
+ canvas->clipRect(clip);
286
+ draw_path(canvas, chart, lay, bounds, window_ms, chart.draft_points,
287
+ static_cast<SkColor>(chart.draft_color), chart.draft_width);
288
+ canvas->restore();
289
+ return;
290
+ }
291
+
80
292
  const SkPoint a = to_px(chart, lay, bounds, window_ms, chart.draft_a);
81
293
  const bool has_b = chart.draft_has_b;
82
294
  const SkPoint b =
83
295
  has_b ? to_px(chart, lay, bounds, window_ms, chart.draft_b) : a;
84
296
 
85
- // 2. Guideline preview (A->B), clipped to the candle area. Only while the
86
- // second point is still being placed (draft_guide); once committed, the
87
- // solid segment comes from chart.drawings instead.
297
+ // 2. Live preview (A->B), clipped to the candle area. Only while the second
298
+ // point is still being placed (draft_guide); once committed, the solid
299
+ // shape comes from chart.drawings instead. A box previews as a rectangle;
300
+ // a line as a guideline.
88
301
  if (chart.draft_guide && has_b) {
89
302
  canvas->save();
90
303
  canvas->clipRect(clip);
91
- SkPaint guide;
92
- guide.setAntiAlias(true);
93
- guide.setColor(static_cast<SkColor>(chart.draft_color));
94
- guide.setStyle(SkPaint::kStroke_Style);
95
- guide.setStrokeWidth(chart.draft_width > 0.f ? chart.draft_width : 2.f);
96
- canvas->drawLine(a, b, guide);
304
+ if (chart.draft_kind == 1) {
305
+ draw_box(canvas, a, b, static_cast<SkColor>(chart.draft_color),
306
+ chart.draft_width);
307
+ } else {
308
+ SkPaint guide;
309
+ guide.setAntiAlias(true);
310
+ guide.setColor(static_cast<SkColor>(chart.draft_color));
311
+ guide.setStyle(SkPaint::kStroke_Style);
312
+ guide.setStrokeWidth(chart.draft_width > 0.f ? chart.draft_width : 2.f);
313
+ canvas->drawLine(a, b, guide);
314
+ }
97
315
  canvas->restore();
98
316
  }
99
317
 
100
318
  // 3. Node dots on top (not clipped, so an edge dot still renders fully).
101
319
  // While guiding (placing the second point) only the anchor dot shows; the
102
- // moving end is conveyed by the guideline. Once selected (committed) both
103
- // endpoints show dots.
320
+ // moving end is conveyed by the preview shape. Once selected (committed)
321
+ // both endpoints show dots.
104
322
  draw_node(canvas, a);
105
323
  if (has_b && !chart.draft_guide) draw_node(canvas, b);
106
324
  }
107
325
 
326
+ HitResult hit_test(const VroomChart& chart,
327
+ const Layout& lay,
328
+ const PriceBounds& bounds,
329
+ int64_t window_ms,
330
+ float x,
331
+ float y) {
332
+ HitResult miss{-1, -1, 0.f};
333
+ if (window_ms <= 0 || chart.drawings.empty()) return miss;
334
+
335
+ // Grab-priority: if a drawing is selected, its (visible) handles win first.
336
+ constexpr float kHandleHit = kNodeRingRadius + 6.f;
337
+ if (chart.selected_drawing >= 0 &&
338
+ static_cast<size_t>(chart.selected_drawing) < chart.drawings.size()) {
339
+ const auto& d = chart.drawings[chart.selected_drawing];
340
+ const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
341
+ const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
342
+ if (d.kind == 2) {
343
+ // Pencil has no grab handles — its anchors translate like any other
344
+ // part of the stroke, so fall through to the body pass.
345
+ } else if (d.kind == 1) {
346
+ // Box: four corner handles (part 0..3).
347
+ const auto corners = box_corners(a, b);
348
+ for (int c = 0; c < 4; ++c) {
349
+ if (std::hypot(x - corners[c].fX, y - corners[c].fY) <= kHandleHit)
350
+ return HitResult{chart.selected_drawing, c, 0.f};
351
+ }
352
+ } else {
353
+ if (std::hypot(x - a.fX, y - a.fY) <= kHandleHit)
354
+ return HitResult{chart.selected_drawing, 0, 0.f};
355
+ if (std::hypot(x - b.fX, y - b.fY) <= kHandleHit)
356
+ return HitResult{chart.selected_drawing, 1, 1.f};
357
+ }
358
+ }
359
+
360
+ // Otherwise the nearest drawing body within tolerance (topmost = last drawn).
361
+ // Line body is distance-to-segment (part 2); box body is the interior (dist
362
+ // 0) or a nearby edge (part 4).
363
+ constexpr float kBodyHit = 6.f;
364
+ float best = kBodyHit;
365
+ int32_t best_i = -1;
366
+ int32_t best_part = 2;
367
+ float best_t = 0.f;
368
+ for (size_t i = 0; i < chart.drawings.size(); ++i) {
369
+ const auto& d = chart.drawings[i];
370
+ if (d.kind == 2) {
371
+ const float dist = dist_to_path(x, y, chart, lay, bounds, window_ms,
372
+ d.points, kBodyHit);
373
+ if (dist <= best) { // <= so later (topmost) drawings win ties
374
+ best = dist;
375
+ best_i = static_cast<int32_t>(i);
376
+ best_part = 5;
377
+ best_t = 0.f;
378
+ }
379
+ continue;
380
+ }
381
+ const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
382
+ const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
383
+ if (d.kind == 1) {
384
+ const float dist = point_in_rect(x, y, a, b)
385
+ ? 0.f
386
+ : dist_to_rect_edges(x, y, a, b);
387
+ if (dist <= best) { // <= so later (topmost) drawings win ties
388
+ best = dist;
389
+ best_i = static_cast<int32_t>(i);
390
+ best_part = 4;
391
+ best_t = 0.f;
392
+ }
393
+ } else {
394
+ const float dist = dist_to_segment(x, y, a.fX, a.fY, b.fX, b.fY);
395
+ if (dist <= best) { // <= so later (topmost) drawings win ties
396
+ best = dist;
397
+ best_i = static_cast<int32_t>(i);
398
+ best_part = 2;
399
+ best_t = segment_t(x, y, a.fX, a.fY, b.fX, b.fY);
400
+ }
401
+ }
402
+ }
403
+ return best_i >= 0 ? HitResult{best_i, best_part, best_t} : miss;
404
+ }
405
+
108
406
  } // namespace vroom::drawings
@@ -31,4 +31,27 @@ void draw(SkCanvas* canvas,
31
31
  float candle_right,
32
32
  float candle_area_h);
33
33
 
34
+ // Result of a hit-test against the committed drawings. `index` is the drawing
35
+ // index (or -1 for a miss). `part` depends on the drawing kind:
36
+ // * line — 0 (endpoint A), 1 (endpoint B), or 2 (line body).
37
+ // * box — 0..3 (the four corners, in the order returned by box_corners:
38
+ // a, (b.x,a.y), b, (a.x,b.y)) or 4 (box body: interior or edges).
39
+ // * pencil — always 5 (stroke body). A freehand stroke has no grab handles:
40
+ // its end anchors are a visual cue only and translate the whole
41
+ // path like any other part of it.
42
+ // Corner/endpoint hits are only reported for the currently selected drawing
43
+ // (whose handles are visible).
44
+ struct HitResult {
45
+ int32_t index;
46
+ int32_t part;
47
+ float t; // 0..1 grab position along a line segment (A→B); 0 for box/handle hits
48
+ };
49
+
50
+ HitResult hit_test(const VroomChart& chart,
51
+ const vroom::Layout& lay,
52
+ const vroom::PriceBounds& bounds,
53
+ int64_t window_ms,
54
+ float x,
55
+ float y);
56
+
34
57
  } // namespace vroom::drawings