react-native-vroom-chart 0.11.0 → 0.12.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.
@@ -63,6 +63,7 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
63
63
  out.push_back(jsi::PropNameID::forAscii(rt, "setBollinger"));
64
64
  out.push_back(jsi::PropNameID::forAscii(rt, "setVolume"));
65
65
  out.push_back(jsi::PropNameID::forAscii(rt, "setVolumeCollapse"));
66
+ out.push_back(jsi::PropNameID::forAscii(rt, "setAxisCollapse"));
66
67
  out.push_back(jsi::PropNameID::forAscii(rt, "coordAt"));
67
68
  out.push_back(jsi::PropNameID::forAscii(rt, "setPriceLines"));
68
69
  out.push_back(jsi::PropNameID::forAscii(rt, "hitTestPriceLine"));
@@ -919,6 +920,27 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
919
920
  });
920
921
  }
921
922
 
923
+ if (name == "setAxisCollapse") {
924
+ // setAxisCollapse(yT, xT) — hides the price / time axis strips. Both take
925
+ // EASED progress (0 shown, 1 hidden); there is no per-element stagger for
926
+ // the core to distribute, so the host owns the curve.
927
+ return jsi::Function::createFromHostFunction(
928
+ rt,
929
+ jsi::PropNameID::forAscii(rt, "setAxisCollapse"),
930
+ 2,
931
+ [this](jsi::Runtime& /*rt2*/,
932
+ const jsi::Value& /*thisVal*/,
933
+ const jsi::Value* args,
934
+ size_t count) -> jsi::Value {
935
+ if (count < 2) return jsi::Value::undefined();
936
+ vroom_chart_set_axis_collapse(
937
+ chart_,
938
+ static_cast<float>(args[0].asNumber()),
939
+ static_cast<float>(args[1].asNumber()));
940
+ return jsi::Value::undefined();
941
+ });
942
+ }
943
+
922
944
  if (name == "coordAt") {
923
945
  // coordAt(x, y) -> { timeMs, price } | null. The continuous data coordinate
924
946
  // at a pixel — not snapped to a candle slot. Null when there are no candles
@@ -187,6 +187,13 @@ typedef struct VroomDrawing {
187
187
  int32_t kind; // 0 = line, 1 = box, 2 = pencil, 3 = path
188
188
  const VroomDrawPoint* points; // pencil/path points (kind 2/3), else null
189
189
  int32_t point_count; // number of `points`, else 0
190
+ // Box (kind 1) interior fill, 0xAARRGGBB. Alpha 0 means unset, in which case
191
+ // the interior falls back to `color` at 10% alpha. Ignored by other kinds.
192
+ uint32_t fill;
193
+ // Protects the drawing from editing: hit_test reports its body (so a host
194
+ // can still select it and offer to unlock) but never its handles, and no
195
+ // handles are drawn. Rendering is otherwise unaffected.
196
+ bool locked;
190
197
  } VroomDrawing;
191
198
 
192
199
  // A resting-liquidity band: a price interval carrying a total order size on one
@@ -257,6 +264,14 @@ typedef struct VroomCoord {
257
264
  double price;
258
265
  } VroomCoord;
259
266
 
267
+ // An axis-aligned rectangle in CSS pixels, relative to the chart's top-left.
268
+ typedef struct VroomRect {
269
+ float x;
270
+ float y;
271
+ float width;
272
+ float height;
273
+ } VroomRect;
274
+
260
275
  // ---- Styling keys ---------------------------------------------------------
261
276
 
262
277
  typedef enum {
@@ -267,8 +282,12 @@ typedef enum {
267
282
  VROOM_COLOR_GRID,
268
283
  VROOM_COLOR_AXIS_TEXT,
269
284
  VROOM_COLOR_CROSSHAIR,
270
- VROOM_COLOR_TOOLTIP_BG,
271
- VROOM_COLOR_TOOLTIP_TEXT,
285
+ // Retired (was VROOM_COLOR_TOOLTIP_BG, never rendered). Held open because
286
+ // the TypeScript theme maps hardcode the indices below it.
287
+ VROOM_COLOR_RESERVED_7_,
288
+ // Text drawn on a filled badge: the current-price indicator, the crosshair's
289
+ // axis badges, and price-line pills. Defaults to white.
290
+ VROOM_COLOR_BADGE_TEXT,
272
291
  VROOM_COLOR_CROSSHAIR_TARGET, // the hollow ring/dot at the intersection
273
292
  VROOM_COLOR_BORDER_BULL, // bull body 1px outline; 0 alpha => inherit BULL fill
274
293
  VROOM_COLOR_BORDER_BEAR, // bear body 1px outline; 0 alpha => inherit BEAR fill
@@ -549,6 +568,19 @@ void vroom_chart_set_volume(VroomChart* chart, const VroomVolume* cfg);
549
568
  // `enabled`, so the host only needs it while animating.
550
569
  void vroom_chart_set_volume_collapse(VroomChart* chart, float t, int32_t easing);
551
570
 
571
+ // Collapses the axis strips: 0 leaves a strip at full size, 1 hides it. Driven
572
+ // per-frame by the host animation loop, which owns the eased clock — unlike the
573
+ // volume collapse there is no per-element stagger, so these take *eased*
574
+ // progress directly.
575
+ //
576
+ // Hiding is a collapse rather than a zeroed dimension because the y-axis width
577
+ // is measured from the widest price label and recomputed on most data changes,
578
+ // which would overwrite a zero. The price pane grows into whatever the strips
579
+ // give up, and each strip's contents (labels, price badge, crosshair and
580
+ // price-line badges) fade out over the first half of the collapse so text is
581
+ // never squeezed into a strip too narrow to hold it.
582
+ void vroom_chart_set_axis_collapse(VroomChart* chart, float y_t, float x_t);
583
+
552
584
  // ---- Drawings (line annotations) ------------------------------------------
553
585
 
554
586
  // Replaces the full set of committed line drawings (data-anchored, so they track
@@ -578,6 +610,18 @@ bool vroom_chart_hit_test_drawing(VroomChart* chart, float x_px, float y_px,
578
610
  int32_t* out_index, int32_t* out_part,
579
611
  float* out_t);
580
612
 
613
+ // Fills *out with the pixel bounding box of committed drawing `index` and
614
+ // returns true; false (out untouched) for an out-of-range index, an empty
615
+ // series, or a degenerate viewport.
616
+ //
617
+ // The rectangle spans the shape's painted extent — every anchor of a pencil or
618
+ // path, both corners of a line or box — grown by half the stroke width, so it
619
+ // covers the pixels the stroke actually paints. It is the anchor a host uses to
620
+ // position UI against the selection, and because it is computed from live core
621
+ // state it stays correct mid-reshape, before the edit reaches the host's array.
622
+ bool vroom_chart_drawing_bounds(VroomChart* chart, int32_t index,
623
+ VroomRect* out);
624
+
581
625
  // Selects a committed drawing (renders its handles). `index` -1 clears the
582
626
  // selection. `grabbed_endpoint` renders that handle 50% larger while it's being
583
627
  // dragged (0/1 for a line, 0 for the box corner the host normalized to, the
@@ -64,13 +64,16 @@ vroom::Layout VroomChart::layout() const {
64
64
  return vroom::Layout{
65
65
  width_px,
66
66
  height_px,
67
- axis_w,
68
- theme.floats[VROOM_FLOAT_X_AXIS_HEIGHT_PX],
67
+ vroom::axis_extent(axis_w, y_axis_collapse_t),
68
+ vroom::axis_extent(theme.floats[VROOM_FLOAT_X_AXIS_HEIGHT_PX],
69
+ x_axis_collapse_t),
69
70
  theme.floats[VROOM_FLOAT_RIGHT_PADDING_PX],
70
71
  theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO],
71
72
  0.05f,
72
73
  0.05f,
73
74
  indicator_h,
75
+ vroom::axis_opacity(y_axis_collapse_t),
76
+ vroom::axis_opacity(x_axis_collapse_t),
74
77
  };
75
78
  }
76
79
 
@@ -191,6 +191,13 @@ struct VroomChart {
191
191
  float volume_collapse_t = 0.f;
192
192
  int32_t volume_collapse_easing = VROOM_EASING_IN_OUT;
193
193
 
194
+ // Collapse of the axis strips, driven per-frame by the host animation loop:
195
+ // 0 = full strip, 1 = hidden. Unlike the volume collapse these change the
196
+ // layout, so the price pane grows into whatever the strips give up. The host
197
+ // pre-eases them (there is no per-element stagger to distribute here).
198
+ float y_axis_collapse_t = 0.f;
199
+ float x_axis_collapse_t = 0.f;
200
+
194
201
  // --- drawings (annotations) --------------------------------------------
195
202
  // Committed drawings, anchored in data space so they track the candles on
196
203
  // pan/zoom. Drawn on the price pane above candles/overlays.
@@ -206,6 +213,8 @@ struct VroomChart {
206
213
  float width = 2.f;
207
214
  int32_t kind = 0;
208
215
  std::vector<VroomDrawPoint> points; // pencil path (kind 2)
216
+ uint32_t fill = 0; // 0 alpha = derive from color
217
+ bool locked = false;
209
218
  };
210
219
  std::vector<StoredDrawing> drawings;
211
220
 
@@ -926,6 +926,8 @@ extern "C" void vroom_chart_set_drawings(VroomChart* chart,
926
926
  d.color = src.color;
927
927
  d.width = src.width;
928
928
  d.kind = src.kind;
929
+ d.fill = src.fill;
930
+ d.locked = src.locked;
929
931
  if ((src.kind == 2 || src.kind == 3) && src.points && src.point_count > 0) {
930
932
  d.points.assign(src.points, src.points + src.point_count);
931
933
  // Keep a/b mirroring the path ends so bounds/handle code is uniform.
@@ -1152,6 +1154,31 @@ extern "C" bool vroom_chart_hit_test_drawing(VroomChart* chart, float x_px,
1152
1154
  return true;
1153
1155
  }
1154
1156
 
1157
+ extern "C" bool vroom_chart_drawing_bounds(VroomChart* chart, int32_t index,
1158
+ VroomRect* out) {
1159
+ if (!chart || !out || chart->candles.empty()) return false;
1160
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
1161
+ if (window_ms <= 0) return false;
1162
+ const auto lay = chart->layout();
1163
+ // Same bounds as coord_at / draw_chart so the rectangle matches the render.
1164
+ const auto range = vroom::visible_indices(
1165
+ chart->candles.data(), chart->candles.size(),
1166
+ chart->visible_start_ms, chart->visible_end_ms);
1167
+ const size_t n = range.end - range.start;
1168
+ const auto bounds =
1169
+ chart->price_bounds_manual
1170
+ ? chart->price_bounds
1171
+ : vroom::auto_price_bounds(chart->candles.data() + range.start, n);
1172
+ vroom::drawing_bounds::RectPx r{};
1173
+ if (!vroom::drawings::bounds_of(*chart, lay, bounds, window_ms, index, &r))
1174
+ return false;
1175
+ out->x = r.x;
1176
+ out->y = r.y;
1177
+ out->width = r.width;
1178
+ out->height = r.height;
1179
+ return true;
1180
+ }
1181
+
1155
1182
  extern "C" bool vroom_chart_hit_test_price_line(VroomChart* chart, float x_px,
1156
1183
  float y_px, int32_t* out_index,
1157
1184
  int32_t* out_part) {
@@ -1374,6 +1401,15 @@ extern "C" void vroom_chart_set_volume_collapse(VroomChart* chart, float t,
1374
1401
  chart->mark_dirty();
1375
1402
  }
1376
1403
 
1404
+ extern "C" void vroom_chart_set_axis_collapse(VroomChart* chart, float y_t,
1405
+ float x_t) {
1406
+ if (!chart) return;
1407
+ chart->y_axis_collapse_t = std::clamp(y_t, 0.f, 1.f);
1408
+ chart->x_axis_collapse_t = std::clamp(x_t, 0.f, 1.f);
1409
+ // Changes the layout, so every cached pane geometry has to be re-derived.
1410
+ chart->mark_dirty();
1411
+ }
1412
+
1377
1413
  // ---- Direct draw (used by hosts that don't need the SkPicture cache) ------
1378
1414
 
1379
1415
  extern "C" void vroom_chart_draw(VroomChart* chart, SkCanvas* canvas) {
@@ -42,7 +42,9 @@ void draw_badge(SkCanvas* canvas,
42
42
  float cx,
43
43
  float cy,
44
44
  SkColor fill,
45
- SkColor text_color) {
45
+ SkColor text_color,
46
+ float opacity) {
47
+ if (opacity <= 0.f) return;
46
48
  const size_t len = std::strlen(text);
47
49
  SkRect tb;
48
50
  const float text_w = font.measureText(text, len, SkTextEncoding::kUTF8, &tb);
@@ -54,11 +56,13 @@ void draw_badge(SkCanvas* canvas,
54
56
  SkPaint box;
55
57
  box.setAntiAlias(true);
56
58
  box.setColor(fill);
59
+ box.setAlphaf(box.getAlphaf() * opacity);
57
60
  canvas->drawRRect(SkRRect::MakeRectXY(rect, kCorner, kCorner), box);
58
61
 
59
62
  SkPaint text_paint;
60
63
  text_paint.setAntiAlias(true);
61
64
  text_paint.setColor(text_color);
65
+ text_paint.setAlphaf(text_paint.getAlphaf() * opacity);
62
66
  canvas->drawString(text, cx - text_w * 0.5f,
63
67
  cy - (tb.fTop + tb.fBottom) * 0.5f, font, text_paint);
64
68
  }
@@ -108,8 +112,8 @@ void draw(SkCanvas* canvas,
108
112
  ring.setStrokeWidth(2.f); // thicker border so the dot reads clearly
109
113
  canvas->drawCircle(cx, cy, kRingRadius, ring);
110
114
 
111
- // Axis badges (neutral grey, white text) sit on top of the axis labels and
112
- // the current-price indicator since the crosshair is the last draw step.
115
+ // Axis badges sit on top of the axis labels and the current-price indicator
116
+ // since the crosshair is the last draw step.
113
117
  // They need the axis typeface; if it isn't loaded the lines alone suffice.
114
118
  auto tf = vroom::axis_typeface();
115
119
  if (!tf) return;
@@ -119,6 +123,7 @@ void draw(SkCanvas* canvas,
119
123
  font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
120
124
 
121
125
  const SkColor badge_fill = chart.theme.colors[VROOM_COLOR_CROSSHAIR_TARGET];
126
+ const SkColor badge_text = chart.theme.colors[VROOM_COLOR_BADGE_TEXT];
122
127
 
123
128
  // Date/time badge over the x-axis strip, centered on the vertical line and
124
129
  // vertically aligned with the time labels (centered in the strip).
@@ -140,7 +145,7 @@ void draw(SkCanvas* canvas,
140
145
  const float badge_cx =
141
146
  std::clamp(cx, half_w, std::max(half_w, candle_right - half_w));
142
147
  draw_badge(canvas, font, buf, badge_cx, strip_center_y, badge_fill,
143
- SK_ColorWHITE);
148
+ badge_text, lay.x_axis_opacity);
144
149
  }
145
150
 
146
151
  // Price badge over the y-axis strip, centered on the horizontal line and
@@ -155,7 +160,7 @@ void draw(SkCanvas* canvas,
155
160
  vroom::format_price(buf, sizeof(buf), price, fmt);
156
161
  const float axis_center_x = lay.width_px - lay.y_axis_width_px * 0.5f;
157
162
  draw_badge(canvas, font, buf, axis_center_x, cy, badge_fill,
158
- SK_ColorWHITE);
163
+ badge_text, lay.y_axis_opacity);
159
164
  }
160
165
  }
161
166
 
@@ -0,0 +1,70 @@
1
+ // The pixel bounding box of a drawing (see bounds_of in drawings.cpp).
2
+ //
3
+ // A host anchoring UI to a selected drawing needs one rectangle, whatever the
4
+ // shape: a box and a freehand stroke both have to yield something positionable.
5
+ // Reducing the projected anchors to their extent gives that uniformly, where the
6
+ // anchors themselves would not — a pencil stroke's `a`/`b` are just its first
7
+ // and last samples, which say nothing about how far the stroke wandered between
8
+ // them.
9
+ //
10
+ // The extent is grown by half the stroke width on every side, because a stroke
11
+ // straddles its path: a 2px line through y=100 paints 99..101. Without that the
12
+ // rectangle would clip the very pixels it is meant to describe.
13
+ //
14
+ // Skia-free and header-only so the unit tests can cover it; see
15
+ // tests/test_drawing_bounds.cpp.
16
+
17
+ #pragma once
18
+
19
+ #include <algorithm>
20
+ #include <cmath>
21
+
22
+ namespace vroom::drawing_bounds {
23
+
24
+ // An axis-aligned rectangle in CSS pixels, relative to the chart's top-left.
25
+ struct RectPx {
26
+ float x = 0.f;
27
+ float y = 0.f;
28
+ float width = 0.f;
29
+ float height = 0.f;
30
+ };
31
+
32
+ // Accumulates projected points into their extent. Points are fed one at a time
33
+ // so callers can project straight from their own storage without materializing
34
+ // an intermediate array.
35
+ struct Accumulator {
36
+ float min_x = 0.f;
37
+ float min_y = 0.f;
38
+ float max_x = 0.f;
39
+ float max_y = 0.f;
40
+ bool has = false;
41
+
42
+ // Non-finite coordinates are dropped rather than propagated: a single NaN
43
+ // would otherwise poison every comparison and hand the host an unusable
44
+ // rectangle.
45
+ void add(float x, float y) {
46
+ if (!std::isfinite(x) || !std::isfinite(y)) return;
47
+ if (!has) {
48
+ min_x = max_x = x;
49
+ min_y = max_y = y;
50
+ has = true;
51
+ return;
52
+ }
53
+ min_x = std::min(min_x, x);
54
+ max_x = std::max(max_x, x);
55
+ min_y = std::min(min_y, y);
56
+ max_y = std::max(max_y, y);
57
+ }
58
+
59
+ // The extent grown by half of `stroke_width` on each side. A single point
60
+ // yields a zero-extent rectangle inflated to the stroke's own footprint,
61
+ // which is exactly what a one-sample pencil stroke paints.
62
+ RectPx to_rect(float stroke_width) const {
63
+ if (!has) return RectPx{};
64
+ const float pad = std::max(stroke_width, 0.f) * 0.5f;
65
+ return RectPx{min_x - pad, min_y - pad, (max_x - min_x) + pad * 2.f,
66
+ (max_y - min_y) + pad * 2.f};
67
+ }
68
+ };
69
+
70
+ } // namespace vroom::drawing_bounds
@@ -51,17 +51,23 @@ SkRect box_rect(SkPoint a, SkPoint b) {
51
51
  std::max(a.fX, b.fX), std::max(a.fY, b.fY));
52
52
  }
53
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) {
54
+ // Strokes the box outline and paints its interior, spanning opposite corners
55
+ // `a` and `b`. `fill_color` is used as given; alpha 0 means the caller didn't
56
+ // set one, and the interior falls back to a faint tint of the border (~10% of
57
+ // its alpha) — a solid default would hide the candles the box was drawn around.
58
+ void draw_box(SkCanvas* canvas, SkPoint a, SkPoint b, SkColor color, float width,
59
+ SkColor fill_color) {
57
60
  const SkRect r = box_rect(a, b);
58
61
 
59
62
  SkPaint fill;
60
63
  fill.setAntiAlias(true);
61
64
  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
+ if (SkColorGetA(fill_color) != 0) {
66
+ fill.setColor(fill_color);
67
+ } else {
68
+ const U8CPU fill_alpha = static_cast<U8CPU>(SkColorGetA(color) * 0.1f);
69
+ fill.setColor(SkColorSetA(color, fill_alpha));
70
+ }
65
71
  canvas->drawRect(r, fill);
66
72
 
67
73
  SkPaint border;
@@ -334,7 +340,8 @@ void draw(SkCanvas* canvas,
334
340
  const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
335
341
  const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
336
342
  if (d.kind == 1) {
337
- draw_box(canvas, a, b, static_cast<SkColor>(d.color), d.width);
343
+ draw_box(canvas, a, b, static_cast<SkColor>(d.color), d.width,
344
+ static_cast<SkColor>(d.fill));
338
345
  continue;
339
346
  }
340
347
  SkPaint line;
@@ -348,9 +355,12 @@ void draw(SkCanvas* canvas,
348
355
  }
349
356
 
350
357
  // 1b. Handles on the selected committed drawing (unclipped, like the draft's
351
- // dots). The grabbed endpoint renders 50% larger.
358
+ // dots). The grabbed endpoint renders 50% larger. A locked drawing shows
359
+ // none: nothing about it can be dragged, so an affordance saying
360
+ // otherwise would be a lie.
352
361
  if (chart.selected_drawing >= 0 &&
353
- static_cast<size_t>(chart.selected_drawing) < chart.drawings.size()) {
362
+ static_cast<size_t>(chart.selected_drawing) < chart.drawings.size() &&
363
+ !chart.drawings[chart.selected_drawing].locked) {
354
364
  const auto& d = chart.drawings[chart.selected_drawing];
355
365
  const SkPoint sa = to_px(chart, lay, bounds, window_ms, d.a);
356
366
  const SkPoint sb = to_px(chart, lay, bounds, window_ms, d.b);
@@ -432,8 +442,10 @@ void draw(SkCanvas* canvas,
432
442
  canvas->save();
433
443
  canvas->clipRect(clip);
434
444
  if (chart.draft_kind == 1) {
445
+ // The draft has no fill of its own — the preview shows the default
446
+ // tint until the box is committed with one.
435
447
  draw_box(canvas, a, b, static_cast<SkColor>(chart.draft_color),
436
- chart.draft_width);
448
+ chart.draft_width, SK_ColorTRANSPARENT);
437
449
  } else {
438
450
  SkPaint guide;
439
451
  guide.setAntiAlias(true);
@@ -463,9 +475,13 @@ HitResult hit_test(const VroomChart& chart,
463
475
  if (window_ms <= 0 || chart.drawings.empty()) return miss;
464
476
 
465
477
  // Grab-priority: if a drawing is selected, its (visible) handles win first.
478
+ // A locked drawing draws no handles, so there is nothing here to grab — it
479
+ // falls through to the body pass and stays selectable, which is how a host
480
+ // toolbar can still reach it to unlock it.
466
481
  constexpr float kHandleHit = kNodeRingRadius + 6.f;
467
482
  if (chart.selected_drawing >= 0 &&
468
- static_cast<size_t>(chart.selected_drawing) < chart.drawings.size()) {
483
+ static_cast<size_t>(chart.selected_drawing) < chart.drawings.size() &&
484
+ !chart.drawings[chart.selected_drawing].locked) {
469
485
  const auto& d = chart.drawings[chart.selected_drawing];
470
486
  const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
471
487
  const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
@@ -546,4 +562,37 @@ HitResult hit_test(const VroomChart& chart,
546
562
  return best_i >= 0 ? HitResult{best_i, best_part, best_t} : miss;
547
563
  }
548
564
 
565
+ bool bounds_of(const VroomChart& chart,
566
+ const Layout& lay,
567
+ const PriceBounds& bounds,
568
+ int64_t window_ms,
569
+ int32_t index,
570
+ vroom::drawing_bounds::RectPx* out) {
571
+ if (!out || window_ms <= 0) return false;
572
+ if (index < 0 || static_cast<size_t>(index) >= chart.drawings.size())
573
+ return false;
574
+ const auto& d = chart.drawings[static_cast<size_t>(index)];
575
+
576
+ vroom::drawing_bounds::Accumulator acc;
577
+ if ((d.kind == 2 || d.kind == 3) && !d.points.empty()) {
578
+ // Pencil/path: every sample counts. `a`/`b` only mirror the first and
579
+ // last, so they would miss however far the stroke wandered between them.
580
+ for (const auto& p : d.points) {
581
+ const SkPoint q = to_px(chart, lay, bounds, window_ms, p);
582
+ acc.add(q.fX, q.fY);
583
+ }
584
+ } else {
585
+ // Line/box: the two stored corners already span the shape — a box's
586
+ // derived corners are exactly their extent.
587
+ const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
588
+ const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
589
+ acc.add(a.fX, a.fY);
590
+ acc.add(b.fX, b.fY);
591
+ }
592
+ if (!acc.has) return false;
593
+
594
+ *out = acc.to_rect(d.width > 0.f ? d.width : 2.f);
595
+ return true;
596
+ }
597
+
549
598
  } // namespace vroom::drawings
@@ -12,6 +12,7 @@
12
12
 
13
13
  #pragma once
14
14
 
15
+ #include "drawing_bounds.h"
15
16
  #include "viewport.h"
16
17
  #include "vroom/vroom_chart.h"
17
18
 
@@ -59,4 +60,18 @@ HitResult hit_test(const VroomChart& chart,
59
60
  float x,
60
61
  float y);
61
62
 
63
+ // The pixel bounding box of committed drawing `index`, written to `out`. False
64
+ // (leaving `out` untouched) for an out-of-range index or a degenerate viewport.
65
+ //
66
+ // The rectangle covers the shape's painted extent — every anchor of a pencil or
67
+ // path, both corners of a line or box — grown by half the stroke width. It is
68
+ // what the host positions a selection toolbar against, so it tracks the drawing
69
+ // live through a pan, a zoom, and a reshape in progress.
70
+ bool bounds_of(const VroomChart& chart,
71
+ const vroom::Layout& lay,
72
+ const vroom::PriceBounds& bounds,
73
+ int64_t window_ms,
74
+ int32_t index,
75
+ vroom::drawing_bounds::RectPx* out);
76
+
62
77
  } // namespace vroom::drawings
@@ -147,6 +147,9 @@ void draw_y_labels(SkCanvas* canvas,
147
147
  const PriceBounds& bounds) {
148
148
  auto tf = vroom::axis_typeface();
149
149
  if (!tf) return;
150
+ // The fade state still advances in update_y_fades, so a hidden axis picks up
151
+ // wherever the ticks moved to once it is revealed again.
152
+ if (lay.y_axis_opacity <= 0.f) return;
150
153
 
151
154
  const float candle_area_h = vroom::price_pane_bottom(lay);
152
155
 
@@ -186,7 +189,7 @@ void draw_y_labels(SkCanvas* canvas,
186
189
  if (baseline_y - cap_h < 0.f) continue;
187
190
  if (baseline_y > candle_area_h) continue;
188
191
 
189
- text_paint.setAlphaf(f.opacity);
192
+ text_paint.setAlphaf(f.opacity * lay.y_axis_opacity);
190
193
  canvas->drawString(buf, text_x, baseline_y, font, text_paint);
191
194
  }
192
195
  }
@@ -279,6 +282,8 @@ void draw_x_labels(SkCanvas* canvas,
279
282
  auto tf = vroom::axis_typeface();
280
283
  if (!tf || chart.candles.empty()) return;
281
284
  if (end_ms <= start_ms) return;
285
+ // See draw_y_labels: update_x_fades keeps running while the strip is hidden.
286
+ if (lay.x_axis_opacity <= 0.f) return;
282
287
 
283
288
  // X-axis labels live in the bottom strip, which stays anchored regardless
284
289
  // of any indicator pane above it.
@@ -345,7 +350,7 @@ void draw_x_labels(SkCanvas* canvas,
345
350
  if (text_x < 0.f) continue;
346
351
  if (text_x + text_w > candle_area_w) continue;
347
352
 
348
- text_paint.setAlphaf(f.opacity);
353
+ text_paint.setAlphaf(f.opacity * lay.x_axis_opacity);
349
354
  canvas->drawString(buf, text_x, baseline_y, font, text_paint);
350
355
  }
351
356
  }
@@ -211,14 +211,15 @@ void draw(SkCanvas* canvas,
211
211
  }
212
212
  canvas->restore();
213
213
 
214
- // "0" label in the y-axis strip at the zero line.
215
- if (tf) {
214
+ // "0" label in the y-axis strip at the zero line — hidden with that strip.
215
+ if (tf && lay.y_axis_opacity > 0.f) {
216
216
  SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
217
217
  font.setSubpixel(true);
218
218
  font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
219
219
  SkPaint text_paint;
220
220
  text_paint.setAntiAlias(true);
221
221
  text_paint.setColor(chart.theme.colors[VROOM_COLOR_AXIS_TEXT]);
222
+ text_paint.setAlphaf(text_paint.getAlphaf() * lay.y_axis_opacity);
222
223
  SkRect tb;
223
224
  const float tw =
224
225
  font.measureText("0", 1, SkTextEncoding::kUTF8, &tb);
@@ -59,9 +59,11 @@ void draw(SkCanvas* canvas,
59
59
  canvas->drawLine(0.f, y, candle_right, y, line);
60
60
 
61
61
  // Price box in the y-axis strip. Needs the axis typeface; if it isn't
62
- // loaded yet, the line alone still conveys the level.
62
+ // loaded yet, the line alone still conveys the level. Same when the y-axis
63
+ // is hidden — the level stays readable, it just loses its badge.
63
64
  auto tf = vroom::axis_typeface();
64
65
  if (!tf) return;
66
+ if (lay.y_axis_opacity <= 0.f) return;
65
67
 
66
68
  SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
67
69
  font.setSubpixel(true);
@@ -95,14 +97,16 @@ void draw(SkCanvas* canvas,
95
97
  SkPaint box;
96
98
  box.setAntiAlias(true);
97
99
  box.setColor(color);
100
+ box.setAlphaf(box.getAlphaf() * lay.y_axis_opacity);
98
101
  canvas->drawRRect(SkRRect::MakeRectXY(rect, kCorner, kCorner), box);
99
102
 
100
- // White price text, horizontally centered on the same axis center, and
101
- // vertically centered: shift the baseline so the glyph bounds' midpoint
102
- // lands on `y` (= the box center and the dotted line).
103
+ // Price text horizontally centered on the same axis center, and vertically
104
+ // centered: shift the baseline so the glyph bounds' midpoint lands on `y`
105
+ // (= the box center and the dotted line).
103
106
  SkPaint text_paint;
104
107
  text_paint.setAntiAlias(true);
105
- text_paint.setColor(SK_ColorWHITE);
108
+ text_paint.setColor(chart.theme.colors[VROOM_COLOR_BADGE_TEXT]);
109
+ text_paint.setAlphaf(text_paint.getAlphaf() * lay.y_axis_opacity);
106
110
  const float text_x = axis_center_x - text_w * 0.5f;
107
111
  const float baseline_y = y - (tb.fTop + tb.fBottom) * 0.5f;
108
112
  canvas->drawString(buf, text_x, baseline_y, font, text_paint);
@@ -215,8 +215,10 @@ void draw_axis_badge(SkCanvas* canvas,
215
215
  double price,
216
216
  float y,
217
217
  SkColor fill,
218
+ SkColor text_color,
218
219
  const PriceFormat& fmt) {
219
220
  if (lay.y_axis_width_px <= 0.f) return;
221
+ if (lay.y_axis_opacity <= 0.f) return;
220
222
 
221
223
  char buf[48];
222
224
  vroom::format_price(buf, sizeof(buf), price, fmt);
@@ -233,11 +235,13 @@ void draw_axis_badge(SkCanvas* canvas,
233
235
  SkPaint box;
234
236
  box.setAntiAlias(true);
235
237
  box.setColor(fill);
238
+ box.setAlphaf(box.getAlphaf() * lay.y_axis_opacity);
236
239
  canvas->drawRRect(SkRRect::MakeRectXY(rect, kCorner, kCorner), box);
237
240
 
238
241
  SkPaint text;
239
242
  text.setAntiAlias(true);
240
- text.setColor(SK_ColorWHITE);
243
+ text.setColor(text_color);
244
+ text.setAlphaf(text.getAlphaf() * lay.y_axis_opacity);
241
245
  canvas->drawString(buf, cx - text_w * 0.5f,
242
246
  y - (tb.fTop + tb.fBottom) * 0.5f, font, text);
243
247
  }
@@ -254,6 +258,7 @@ void draw(SkCanvas* canvas,
254
258
  if (chart.price_lines.empty()) return;
255
259
 
256
260
  const VroomPriceLineStyle& style = chart.price_line_style;
261
+ const SkColor badge_text = chart.theme.colors[VROOM_COLOR_BADGE_TEXT];
257
262
  SkFont font;
258
263
  const bool has_font = label_font(chart, &font);
259
264
  const vroom::PriceFormat fmt = vroom::with_tick_guard(
@@ -336,12 +341,12 @@ void draw(SkCanvas* canvas,
336
341
  line_color);
337
342
  }
338
343
  if (!group.quantity.empty()) {
339
- // Solid fill + white text, so size reads at a glance against the
344
+ // Solid fill + badge text, so size reads at a glance against the
340
345
  // translucent body.
341
346
  draw_pill(canvas, group.quantity, first == &group.quantity,
342
347
  last == &group.quantity, line_color, SK_ColorTRANSPARENT);
343
348
  draw_centered_text(canvas, font, pl.quantity, group.quantity, y,
344
- SK_ColorWHITE);
349
+ badge_text);
345
350
  }
346
351
  if (!group.close.empty()) {
347
352
  const SkColor close_color =
@@ -357,7 +362,7 @@ void draw(SkCanvas* canvas,
357
362
 
358
363
  if (has_font && (pl.flags & VROOM_PRICE_LINE_AXIS_LABEL) != 0) {
359
364
  draw_axis_badge(canvas, font, lay, render_price(chart, i), y,
360
- line_color, fmt);
365
+ line_color, badge_text, fmt);
361
366
  }
362
367
  }
363
368
  }
@@ -158,13 +158,15 @@ void draw(SkCanvas* canvas,
158
158
  // 70 / 30 labels in the y-axis strip (drawn after the clip; the strip's
159
159
  // background was already masked by draw_chart before this call). They label
160
160
  // the band rules, so they go with them.
161
- if (tf && cfg.bands_visible) {
161
+ // The band labels live in the y-axis strip, so they go with it when it hides.
162
+ if (tf && cfg.bands_visible && lay.y_axis_opacity > 0.f) {
162
163
  SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
163
164
  font.setSubpixel(true);
164
165
  font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
165
166
  SkPaint text_paint;
166
167
  text_paint.setAntiAlias(true);
167
168
  text_paint.setColor(chart.theme.colors[VROOM_COLOR_AXIS_TEXT]);
169
+ text_paint.setAlphaf(text_paint.getAlphaf() * lay.y_axis_opacity);
168
170
 
169
171
  const float axis_center_x = lay.width_px - lay.y_axis_width_px * 0.5f;
170
172
  auto label = [&](const char* s, float y) {