react-native-vroom-chart 0.7.0 → 0.9.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.
@@ -115,6 +115,99 @@ void draw_path(SkCanvas* canvas, const VroomChart& chart, const Layout& lay,
115
115
  canvas->drawPath(builder.detach(), paint);
116
116
  }
117
117
 
118
+ // Strokes straight segments through `pts` (already in pixels). Unlike a pencil
119
+ // stroke a path's vertices were each placed deliberately, so the corners are the
120
+ // point — no smoothing. Round joins/caps keep the corners from spiking at the
121
+ // sharp angles a wave count tends to produce.
122
+ void draw_polyline(SkCanvas* canvas, const std::vector<SkPoint>& pts,
123
+ SkColor color, float width) {
124
+ if (pts.size() < 2) return;
125
+ SkPathBuilder builder;
126
+ builder.moveTo(pts[0]);
127
+ for (size_t i = 1; i < pts.size(); ++i) builder.lineTo(pts[i]);
128
+
129
+ SkPaint paint;
130
+ paint.setAntiAlias(true);
131
+ paint.setColor(color);
132
+ paint.setStyle(SkPaint::kStroke_Style);
133
+ paint.setStrokeWidth(width > 0.f ? width : 2.f);
134
+ paint.setStrokeCap(SkPaint::kRound_Cap);
135
+ paint.setStrokeJoin(SkPaint::kRound_Join);
136
+ canvas->drawPath(builder.detach(), paint);
137
+ }
138
+
139
+ // Apex-to-base length of the arrowhead. Scales with the stroke so a thicker
140
+ // path gets a proportionally bigger head.
141
+ float arrow_head_len(float width) {
142
+ return 5.f + 2.5f * (width > 0.f ? width : 2.f);
143
+ }
144
+
145
+ // Fills a small triangular arrowhead with its apex at `tip`, pointing away from
146
+ // `from`. Skipped when the segment is too short to seat one (otherwise the head
147
+ // would swallow the whole segment and flip direction on tiny jitters).
148
+ void draw_arrow_head(SkCanvas* canvas, SkPoint tip, SkPoint from, SkColor color,
149
+ float width) {
150
+ const float len = arrow_head_len(width);
151
+ const float half = len * 0.45f; // base half-width
152
+ const float dx = tip.fX - from.fX;
153
+ const float dy = tip.fY - from.fY;
154
+ const float dist = std::sqrt(dx * dx + dy * dy);
155
+ if (dist < len) return;
156
+
157
+ const float ux = dx / dist; // unit vector pointing at the tip
158
+ const float uy = dy / dist;
159
+ const SkPoint base{tip.fX - ux * len, tip.fY - uy * len};
160
+
161
+ SkPathBuilder head;
162
+ head.moveTo(tip);
163
+ head.lineTo(base.fX - uy * half, base.fY + ux * half); // base, one side
164
+ head.lineTo(base.fX + uy * half, base.fY - ux * half); // base, the other
165
+ head.close();
166
+
167
+ SkPaint paint;
168
+ paint.setAntiAlias(true);
169
+ paint.setColor(color);
170
+ paint.setStyle(SkPaint::kFill_Style);
171
+ canvas->drawPath(head.detach(), paint);
172
+ }
173
+
174
+ // Projects every point of a path/stroke to pixels in one pass, so the render and
175
+ // arrowhead code can index them without re-projecting.
176
+ std::vector<SkPoint> project_all(const VroomChart& chart, const Layout& lay,
177
+ const PriceBounds& bounds, int64_t window_ms,
178
+ const std::vector<VroomDrawPoint>& pts) {
179
+ std::vector<SkPoint> out;
180
+ out.reserve(pts.size());
181
+ for (const auto& p : pts) out.push_back(to_px(chart, lay, bounds, window_ms, p));
182
+ return out;
183
+ }
184
+
185
+ // Draws a whole arrow-tipped path from its already-projected points: the
186
+ // segments, then the head on the last one.
187
+ void draw_arrow_path(SkCanvas* canvas, const std::vector<SkPoint>& pts,
188
+ SkColor color, float width) {
189
+ if (pts.size() < 2) return;
190
+ const SkPoint tip = pts.back();
191
+ const SkPoint prev = pts[pts.size() - 2];
192
+ const float len = arrow_head_len(width);
193
+ const float dx = tip.fX - prev.fX;
194
+ const float dy = tip.fY - prev.fY;
195
+ const float dist = std::sqrt(dx * dx + dy * dy);
196
+ if (dist < len) { // no room for a head — just the bare segments
197
+ draw_polyline(canvas, pts, color, width);
198
+ return;
199
+ }
200
+
201
+ // Stop the stroke at the head's base rather than running it to the apex:
202
+ // its round cap is a half-disc of radius width/2 centred on the endpoint, so
203
+ // at the apex it bulges past the point and blunts it. The base is wider than
204
+ // the stroke, so the cap ends up hidden inside the triangle.
205
+ std::vector<SkPoint> body(pts);
206
+ body.back() = SkPoint{tip.fX - dx / dist * len, tip.fY - dy / dist * len};
207
+ draw_polyline(canvas, body, color, width);
208
+ draw_arrow_head(canvas, tip, prev, color, width);
209
+ }
210
+
118
211
  void draw_node(SkCanvas* canvas, SkPoint pt, float scale = 1.f) {
119
212
  SkPaint fill;
120
213
  fill.setAntiAlias(true);
@@ -232,6 +325,12 @@ void draw(SkCanvas* canvas,
232
325
  static_cast<SkColor>(d.color), d.width);
233
326
  continue;
234
327
  }
328
+ if (d.kind == 3) {
329
+ draw_arrow_path(
330
+ canvas, project_all(chart, lay, bounds, window_ms, d.points),
331
+ static_cast<SkColor>(d.color), d.width);
332
+ continue;
333
+ }
235
334
  const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
236
335
  const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
237
336
  if (d.kind == 1) {
@@ -255,7 +354,16 @@ void draw(SkCanvas* canvas,
255
354
  const auto& d = chart.drawings[chart.selected_drawing];
256
355
  const SkPoint sa = to_px(chart, lay, bounds, window_ms, d.a);
257
356
  const SkPoint sb = to_px(chart, lay, bounds, window_ms, d.b);
258
- if (d.kind == 2) {
357
+ if (d.kind == 3) {
358
+ // Path: every vertex was placed by hand, so every one is a grab
359
+ // handle. The one being dragged renders 50% larger, like a line's.
360
+ const auto pts = project_all(chart, lay, bounds, window_ms, d.points);
361
+ for (size_t i = 0; i < pts.size(); ++i) {
362
+ draw_node(canvas, pts[i],
363
+ chart.grabbed_endpoint == static_cast<int32_t>(i) ? 1.5f
364
+ : 1.f);
365
+ }
366
+ } else if (d.kind == 2) {
259
367
  // Pencil: anchors on the first/last point are a visual cue that the
260
368
  // stroke is movable — they aren't grab handles, so never enlarged.
261
369
  draw_node(canvas, sa);
@@ -289,6 +397,28 @@ void draw(SkCanvas* canvas,
289
397
  return;
290
398
  }
291
399
 
400
+ // 1d. Path in progress: the vertices placed so far, plus a rubber-band
401
+ // segment to the cursor. The arrow rides the leading edge — on the
402
+ // cursor while drawing, and (once committed) on the final vertex — so
403
+ // the preview reads as the arrow it's about to become.
404
+ if (chart.draft_kind == 3) {
405
+ if (chart.draft_points.empty()) return;
406
+ auto pts = project_all(chart, lay, bounds, window_ms, chart.draft_points);
407
+ if (chart.draft_has_b) {
408
+ pts.push_back(to_px(chart, lay, bounds, window_ms, chart.draft_b));
409
+ }
410
+ canvas->save();
411
+ canvas->clipRect(clip);
412
+ draw_arrow_path(canvas, pts, static_cast<SkColor>(chart.draft_color),
413
+ chart.draft_width);
414
+ canvas->restore();
415
+ // Node dots on the placed vertices only (not the cursor) — unclipped, so
416
+ // a vertex on the very edge still renders whole.
417
+ const size_t placed = chart.draft_points.size();
418
+ for (size_t i = 0; i < placed; ++i) draw_node(canvas, pts[i]);
419
+ return;
420
+ }
421
+
292
422
  const SkPoint a = to_px(chart, lay, bounds, window_ms, chart.draft_a);
293
423
  const bool has_b = chart.draft_has_b;
294
424
  const SkPoint b =
@@ -339,7 +469,18 @@ HitResult hit_test(const VroomChart& chart,
339
469
  const auto& d = chart.drawings[chart.selected_drawing];
340
470
  const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
341
471
  const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
342
- if (d.kind == 2) {
472
+ if (d.kind == 3) {
473
+ // Path: every vertex is a handle. Later vertices win an overlap, so
474
+ // stacked points stay individually reachable by peeling them off.
475
+ for (size_t i = d.points.size(); i-- > 0;) {
476
+ const SkPoint p = to_px(chart, lay, bounds, window_ms, d.points[i]);
477
+ if (std::hypot(x - p.fX, y - p.fY) <= kHandleHit) {
478
+ return HitResult{chart.selected_drawing,
479
+ VROOM_DRAW_PART_VERTEX + static_cast<int32_t>(i),
480
+ 0.f};
481
+ }
482
+ }
483
+ } else if (d.kind == 2) {
343
484
  // Pencil has no grab handles — its anchors translate like any other
344
485
  // part of the stroke, so fall through to the body pass.
345
486
  } else if (d.kind == 1) {
@@ -367,13 +508,15 @@ HitResult hit_test(const VroomChart& chart,
367
508
  float best_t = 0.f;
368
509
  for (size_t i = 0; i < chart.drawings.size(); ++i) {
369
510
  const auto& d = chart.drawings[i];
370
- if (d.kind == 2) {
511
+ // Pencil and path share the polyline distance — a path's straight
512
+ // segments are exactly what dist_to_path already measures against.
513
+ if (d.kind == 2 || d.kind == 3) {
371
514
  const float dist = dist_to_path(x, y, chart, lay, bounds, window_ms,
372
515
  d.points, kBodyHit);
373
516
  if (dist <= best) { // <= so later (topmost) drawings win ties
374
517
  best = dist;
375
518
  best_i = static_cast<int32_t>(i);
376
- best_part = 5;
519
+ best_part = d.kind == 3 ? 6 : 5;
377
520
  best_t = 0.f;
378
521
  }
379
522
  continue;
@@ -1,10 +1,11 @@
1
1
  // Drawing annotations — user-placed line tools rendered on the price pane.
2
2
  //
3
3
  // Two kinds of geometry are drawn here:
4
- // * Committed lines (chart.drawings) — two-point trendlines anchored in data
5
- // space, so they stay glued to the candles as the user pans/zooms.
4
+ // * Committed shapes (chart.drawings) — lines, boxes, freehand strokes and
5
+ // arrow-tipped paths, anchored in data space so they stay glued to the
6
+ // candles as the user pans/zooms.
6
7
  // * The transient "draft" (chart.draft_*) — the in-progress node dots and the
7
- // live guideline shown while the user is placing a line.
8
+ // live guideline shown while the user is placing a shape.
8
9
  //
9
10
  // Like ma_overlay / crosshair this is its own module so the chart orchestrator
10
11
  // stays thin. Drawn after the overlays and before the axis backgrounds.
@@ -39,8 +40,12 @@ void draw(SkCanvas* canvas,
39
40
  // * pencil — always 5 (stroke body). A freehand stroke has no grab handles:
40
41
  // its end anchors are a visual cue only and translate the whole
41
42
  // path like any other part of it.
42
- // Corner/endpoint hits are only reported for the currently selected drawing
43
- // (whose handles are visible).
43
+ // * path — 6 (path body) or VROOM_DRAW_PART_VERTEX + i for vertex i. Unlike
44
+ // a pencil every vertex was placed deliberately, so every one is a
45
+ // grab handle; the offset keeps those indices clear of the small
46
+ // part numbers above.
47
+ // Corner/endpoint/vertex hits are only reported for the currently selected
48
+ // drawing (whose handles are visible).
44
49
  struct HitResult {
45
50
  int32_t index;
46
51
  int32_t part;
@@ -14,8 +14,11 @@
14
14
 
15
15
  #include <algorithm>
16
16
  #include <cmath>
17
+ #include <vector>
17
18
 
19
+ #include "curve.h"
18
20
  #include "gradient.h"
21
+ #include "tip_pulse.h"
19
22
  #include "viewport.h"
20
23
 
21
24
  namespace vroom::ma_overlay {
@@ -48,6 +51,48 @@ void stroke_path(SkCanvas* canvas,
48
51
 
49
52
  inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
50
53
 
54
+ // Width of the background-colored ring that separates the tip dot from the line
55
+ // and from the pulse expanding out behind it.
56
+ constexpr float kTipBorderPx = 2.f;
57
+
58
+ // One vertex of the close polyline, in screen space. Slot `k` counts back from
59
+ // the right edge (0 = the newest close), the pairing a timeframe switch
60
+ // preserves.
61
+ //
62
+ // Shared by the path builder and the tip marker: the dot has to sit on the exact
63
+ // pixel the line ends at, including mid-interval-morph where both coordinates
64
+ // are interpolated against the outgoing capture.
65
+ SkPoint close_vertex(const Layout& lay,
66
+ const PriceBounds& bounds,
67
+ const ::VroomCandle* visible,
68
+ std::size_t n,
69
+ int64_t window_ms,
70
+ int64_t visible_start_ms,
71
+ int64_t candle_duration_ms,
72
+ const CandleSnapshot* from,
73
+ std::size_t from_count,
74
+ float morph_t,
75
+ std::size_t k) {
76
+ const ::VroomCandle* to = (k < n) ? &visible[n - 1 - k] : nullptr;
77
+ const CandleSnapshot* frm = (k < from_count) ? &from[k] : nullptr;
78
+ if (to) {
79
+ const float tx = vroom::candle_center_x(
80
+ lay, to->time_ms, candle_duration_ms, visible_start_ms, window_ms);
81
+ const float ty = vroom::price_to_y(lay, bounds, to->close);
82
+ if (!frm) return SkPoint{tx, ty};
83
+ // The capture is in band fractions, so it lands on the same pixels it
84
+ // occupied pre-switch even though the bounds changed.
85
+ const float area_w = vroom::candle_area_width(lay);
86
+ return SkPoint{
87
+ lerp(frm->x * area_w, tx, morph_t),
88
+ lerp(vroom::y_at_fraction(lay, frm->close), ty, morph_t)};
89
+ }
90
+ // A slot only the outgoing data had. Counts match in practice, so this just
91
+ // keeps gappy or short-history data from breaking the line.
92
+ return SkPoint{frm->x * vroom::candle_area_width(lay),
93
+ vroom::y_at_fraction(lay, frm->close)};
94
+ }
95
+
51
96
  // The close-price polyline, walked left to right. Shared by the stroke and the
52
97
  // gradient fill so both derive from identical geometry, mid-morph included.
53
98
  //
@@ -55,6 +100,10 @@ inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
55
100
  // timeframe switch preserves — which walks the line left to right, so the vertex
56
101
  // order matches draw()'s at morph_t == 1. Every candle has a close, so there are
57
102
  // no gaps to break the subpath on.
103
+ //
104
+ // `tension` above 0 rounds the corners (see curve.h). It smooths the *screen*
105
+ // positions rather than the closes, which is what keeps the line glued to the
106
+ // candles mid-interval-morph, where those positions are themselves interpolated.
58
107
  SkPath build_close_path(const Layout& lay,
59
108
  const PriceBounds& bounds,
60
109
  const ::VroomCandle* visible,
@@ -64,45 +113,72 @@ SkPath build_close_path(const Layout& lay,
64
113
  int64_t candle_duration_ms,
65
114
  const CandleSnapshot* from,
66
115
  std::size_t from_n,
67
- float morph_t) {
116
+ float morph_t,
117
+ float tension) {
68
118
  const std::size_t from_count = vroom::morph_from_count(from, from_n, morph_t);
69
119
  const std::size_t slots = std::max(n, from_count);
70
120
  if (slots == 0) return SkPath();
71
- const float area_w = vroom::candle_area_width(lay);
72
121
 
73
- SkPathBuilder path;
74
- bool pen_down = false;
75
- for (std::size_t k = slots; k-- > 0;) {
76
- const ::VroomCandle* to = (k < n) ? &visible[n - 1 - k] : nullptr;
77
- const CandleSnapshot* frm = (k < from_count) ? &from[k] : nullptr;
78
-
79
- float x, y;
80
- if (to) {
81
- const float tx = vroom::candle_center_x(
82
- lay, to->time_ms, candle_duration_ms, visible_start_ms, window_ms);
83
- const float ty = vroom::price_to_y(lay, bounds, to->close);
84
- if (frm) {
85
- // The capture is in band fractions, so it lands on the same pixels
86
- // it occupied pre-switch even though the bounds changed.
87
- x = lerp(frm->x * area_w, tx, morph_t);
88
- y = lerp(vroom::y_at_fraction(lay, frm->close), ty, morph_t);
122
+ // Vertex `i` of the left-to-right walk, so the smoothing pass below can look
123
+ // ahead without a second copy of the morph interpolation.
124
+ const auto vertex = [&](std::size_t i) -> SkPoint {
125
+ return close_vertex(lay, bounds, visible, n, window_ms, visible_start_ms,
126
+ candle_duration_ms, from, from_count, morph_t,
127
+ slots - 1 - i);
128
+ };
129
+
130
+ // Straight segments by default, and below three points there's no interior
131
+ // vertex to round. Kept as its own pass so the common case emits exactly the
132
+ // path it always has.
133
+ if (!(tension > 0.f) || slots < 3) {
134
+ SkPathBuilder path;
135
+ for (std::size_t i = 0; i < slots; ++i) {
136
+ const SkPoint p = vertex(i);
137
+ if (i == 0) {
138
+ path.moveTo(p);
89
139
  } else {
90
- x = tx;
91
- y = ty;
140
+ path.lineTo(p);
92
141
  }
93
- } else {
94
- // A slot only the outgoing data had. Counts match in practice, so this
95
- // just keeps gappy or short-history data from breaking the line.
96
- x = frm->x * area_w;
97
- y = vroom::y_at_fraction(lay, frm->close);
98
142
  }
143
+ return path.detach();
144
+ }
99
145
 
100
- if (pen_down) {
101
- path.lineTo(x, y);
102
- } else {
103
- path.moveTo(x, y);
104
- pen_down = true;
146
+ // A tangent needs a neighbor on both sides, so smoothing can't stay
147
+ // streaming. Reused across frames — rendering is single-threaded per chart
148
+ // and the buffer is only live for this call — so it stops allocating after
149
+ // the first frame at a given zoom.
150
+ static thread_local std::vector<SkPoint> pts;
151
+ pts.clear();
152
+ pts.reserve(slots);
153
+ for (std::size_t i = 0; i < slots; ++i) pts.push_back(vertex(i));
154
+
155
+ const auto secant_at = [&](std::size_t i) {
156
+ return vroom::curve::secant(pts[i].fX, pts[i].fY,
157
+ pts[i + 1].fX, pts[i + 1].fY);
158
+ };
159
+ // Endpoints have only one neighboring secant to lean on; interiors get the
160
+ // monotone-limited blend of both. Recomputing a secant or two beats carrying
161
+ // a second buffer.
162
+ const auto tangent_at = [&](std::size_t i) {
163
+ if (i == 0) return secant_at(0);
164
+ if (i + 1 == pts.size()) return secant_at(pts.size() - 2);
165
+ return vroom::curve::monotone_tangent(secant_at(i - 1), secant_at(i));
166
+ };
167
+
168
+ SkPathBuilder path;
169
+ path.moveTo(pts[0]);
170
+ for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
171
+ // Flat or backwards spacing — duplicate timestamps, or the compressed x
172
+ // a morph can pass through — has no meaningful tangent, and curving
173
+ // through it would fold the line back on itself.
174
+ if (!(pts[i + 1].fX > pts[i].fX)) {
175
+ path.lineTo(pts[i + 1]);
176
+ continue;
105
177
  }
178
+ const vroom::curve::Controls c = vroom::curve::segment_controls(
179
+ pts[i].fX, pts[i].fY, pts[i + 1].fX, pts[i + 1].fY,
180
+ tangent_at(i), tangent_at(i + 1), tension);
181
+ path.cubicTo(c.c1x, c.c1y, c.c2x, c.c2y, pts[i + 1].fX, pts[i + 1].fY);
106
182
  }
107
183
  return path.detach();
108
184
  }
@@ -170,15 +246,17 @@ void draw_close_line(SkCanvas* canvas,
170
246
  float opacity,
171
247
  const CandleSnapshot* from,
172
248
  std::size_t from_n,
173
- float morph_t) {
249
+ float morph_t,
250
+ float tension) {
174
251
  if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
175
252
  opacity = std::clamp(opacity, 0.f, 1.f);
176
253
  if (opacity <= 0.f) return;
177
254
  morph_t = std::clamp(morph_t, 0.f, 1.f);
255
+ tension = std::clamp(tension, 0.f, 1.f);
178
256
 
179
257
  const SkPath path = build_close_path(lay, bounds, visible, n, window_ms,
180
258
  visible_start_ms, candle_duration_ms,
181
- from, from_n, morph_t);
259
+ from, from_n, morph_t, tension);
182
260
  if (path.isEmpty()) return;
183
261
 
184
262
  stroke_path(canvas, path, candle_right, candle_area_h, color, width, opacity);
@@ -199,17 +277,19 @@ void draw_close_gradient(SkCanvas* canvas,
199
277
  float opacity,
200
278
  const CandleSnapshot* from,
201
279
  std::size_t from_n,
202
- float morph_t) {
280
+ float morph_t,
281
+ float tension) {
203
282
  if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
204
283
  gradient_opacity = std::clamp(gradient_opacity, 0.f, 1.f);
205
284
  opacity = std::clamp(opacity, 0.f, 1.f);
206
285
  const float alpha = gradient_opacity * opacity;
207
286
  if (alpha <= 0.f) return;
208
287
  morph_t = std::clamp(morph_t, 0.f, 1.f);
288
+ tension = std::clamp(tension, 0.f, 1.f);
209
289
 
210
290
  const SkPath line = build_close_path(lay, bounds, visible, n, window_ms,
211
291
  visible_start_ms, candle_duration_ms,
212
- from, from_n, morph_t);
292
+ from, from_n, morph_t, tension);
213
293
  if (line.isEmpty()) return;
214
294
 
215
295
  // The polyline runs strictly left to right, so its bounds give the first and
@@ -240,6 +320,92 @@ void draw_close_gradient(SkCanvas* canvas,
240
320
  canvas->restore();
241
321
  }
242
322
 
323
+ void draw_close_tip(SkCanvas* canvas,
324
+ const Layout& lay,
325
+ const PriceBounds& bounds,
326
+ const ::VroomCandle* visible,
327
+ std::size_t n,
328
+ int64_t window_ms,
329
+ int64_t visible_start_ms,
330
+ int64_t candle_duration_ms,
331
+ float candle_right,
332
+ float candle_area_h,
333
+ uint32_t line_color,
334
+ uint32_t bg_color,
335
+ float line_width,
336
+ float opacity,
337
+ bool pulse,
338
+ float pulse_phase,
339
+ const CandleSnapshot* from,
340
+ std::size_t from_n,
341
+ float morph_t) {
342
+ if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
343
+ opacity = std::clamp(opacity, 0.f, 1.f);
344
+ if (opacity <= 0.f) return;
345
+ morph_t = std::clamp(morph_t, 0.f, 1.f);
346
+
347
+ const std::size_t from_count = vroom::morph_from_count(from, from_n, morph_t);
348
+ const std::size_t slots = std::max(n, from_count);
349
+ if (slots == 0) return;
350
+
351
+ const SkPoint tip =
352
+ close_vertex(lay, bounds, visible, n, window_ms, visible_start_ms,
353
+ candle_duration_ms, from, from_count, morph_t, 0);
354
+ // Panned or scaled out of the pane: the line's end isn't on screen, so
355
+ // there's nothing to mark. The clip below would hide it anyway, but the ring
356
+ // is wide enough that a tip just off-pane would still bleed in.
357
+ if (tip.fY < 0.f || tip.fY > candle_area_h) return;
358
+ if (tip.fX < 0.f || tip.fX > candle_right) return;
359
+
360
+ // Scaling off the stroke keeps the marker proportionate at any line width;
361
+ // the floor stops a hairline chart from getting an invisible dot.
362
+ const float w = line_width > 0.f ? line_width : 1.5f;
363
+ const float dot_r = std::max(2.f, w * 1.5f);
364
+ const float border_r = dot_r + kTipBorderPx;
365
+
366
+ canvas->save();
367
+ canvas->clipRect(SkRect::MakeLTRB(0.f, 0.f, candle_right, candle_area_h));
368
+
369
+ // Ring first: the border paints over its inner edge, so it reads as
370
+ // expanding out from underneath the dot rather than around it.
371
+ if (pulse) {
372
+ const vroom::tip_pulse::Frame f = vroom::tip_pulse::at(pulse_phase);
373
+ const float r = border_r * f.radius_mul;
374
+ if (f.fill_alpha > 0.f) {
375
+ SkPaint fill;
376
+ fill.setAntiAlias(true);
377
+ fill.setColor(static_cast<SkColor>(line_color));
378
+ fill.setAlphaf(fill.getAlphaf() * f.fill_alpha * opacity);
379
+ canvas->drawCircle(tip.fX, tip.fY, r, fill);
380
+ }
381
+ if (f.stroke_alpha > 0.f) {
382
+ SkPaint edge;
383
+ edge.setAntiAlias(true);
384
+ edge.setColor(static_cast<SkColor>(line_color));
385
+ edge.setAlphaf(edge.getAlphaf() * f.stroke_alpha * opacity);
386
+ edge.setStyle(SkPaint::kStroke_Style);
387
+ edge.setStrokeWidth(1.f);
388
+ canvas->drawCircle(tip.fX, tip.fY, r, edge);
389
+ }
390
+ }
391
+
392
+ // Filled rather than stroked: an annulus stroked around the dot leaves an
393
+ // antialiased seam where the two meet, which shows up as a dark hairline.
394
+ SkPaint border;
395
+ border.setAntiAlias(true);
396
+ border.setColor(static_cast<SkColor>(bg_color));
397
+ border.setAlphaf(border.getAlphaf() * opacity);
398
+ canvas->drawCircle(tip.fX, tip.fY, border_r, border);
399
+
400
+ SkPaint dot;
401
+ dot.setAntiAlias(true);
402
+ dot.setColor(static_cast<SkColor>(line_color));
403
+ dot.setAlphaf(dot.getAlphaf() * opacity);
404
+ canvas->drawCircle(tip.fX, tip.fY, dot_r, dot);
405
+
406
+ canvas->restore();
407
+ }
408
+
243
409
  void fill_between(SkCanvas* canvas,
244
410
  const Layout& lay,
245
411
  const PriceBounds& bounds,
@@ -48,6 +48,9 @@ void draw(SkCanvas* canvas,
48
48
  // had before the timeframe switch to its new one. `morph_t == 1` (the default)
49
49
  // draws `visible` alone.
50
50
  //
51
+ // `tension` (0..1) rounds the corners, monotone-limited so the curve can't
52
+ // overshoot into a price that never traded (see curve.h). 0 is straight segments.
53
+ //
51
54
  // Separate from draw() because the capture holds candle closes — it can't stand
52
55
  // in for an indicator's values.
53
56
  void draw_close_line(SkCanvas* canvas,
@@ -65,12 +68,14 @@ void draw_close_line(SkCanvas* canvas,
65
68
  float opacity = 1.f,
66
69
  const CandleSnapshot* from = nullptr,
67
70
  std::size_t from_n = 0,
68
- float morph_t = 1.f);
71
+ float morph_t = 1.f,
72
+ float tension = 0.f);
69
73
 
70
74
  // The area beneath the close-price polyline, filled with `color` ramping from
71
75
  // `gradient_opacity` at the line's peak to fully transparent at the pane bottom.
72
- // Takes the same geometry and morph arguments as draw_close_line and builds the
73
- // identical polyline, so the fill tracks the line through a timeframe switch.
76
+ // Takes the same geometry, morph and tension arguments as draw_close_line and
77
+ // builds the identical path, so the fill tracks the line through a timeframe
78
+ // switch and follows the same rounded corners.
74
79
  //
75
80
  // `opacity` multiplies the ramp, which is what fades the fill in alongside the
76
81
  // line during the candle→line morph. Draws nothing when either opacity is 0.
@@ -91,7 +96,39 @@ void draw_close_gradient(SkCanvas* canvas,
91
96
  float opacity = 1.f,
92
97
  const CandleSnapshot* from = nullptr,
93
98
  std::size_t from_n = 0,
94
- float morph_t = 1.f);
99
+ float morph_t = 1.f,
100
+ float tension = 0.f);
101
+
102
+ // The marker at the line's newest end: a dot in `line_color` sized off
103
+ // `line_width`, ringed by a 2px disc of `bg_color` that separates it from the
104
+ // line's stroke cap, and optionally a pulse expanding out from behind it.
105
+ //
106
+ // Takes the same geometry and morph arguments as draw_close_line and reads the
107
+ // same vertex, so the dot stays on the line's end pixel through a timeframe
108
+ // switch. `opacity` scales everything, fading the marker in with the line during
109
+ // the candle→line morph.
110
+ //
111
+ // `pulse_phase` is in cycles and wraps, so the caller can hand over elapsed time
112
+ // divided by tip_pulse::kPeriodSeconds. Ignored unless `pulse`.
113
+ void draw_close_tip(SkCanvas* canvas,
114
+ const Layout& lay,
115
+ const PriceBounds& bounds,
116
+ const ::VroomCandle* visible,
117
+ std::size_t n,
118
+ int64_t window_ms,
119
+ int64_t visible_start_ms,
120
+ int64_t candle_duration_ms,
121
+ float candle_right,
122
+ float candle_area_h,
123
+ uint32_t line_color,
124
+ uint32_t bg_color,
125
+ float line_width,
126
+ float opacity = 1.f,
127
+ bool pulse = false,
128
+ float pulse_phase = 0.f,
129
+ const CandleSnapshot* from = nullptr,
130
+ std::size_t from_n = 0,
131
+ float morph_t = 1.f);
95
132
 
96
133
  // Fills the closed region between two aligned series (NaN where undefined)
97
134
  // with `color` at its alpha × `opacity` — used for the Bollinger Band fill.
@@ -39,6 +39,9 @@ constexpr float kDefaultFloats[VROOM_FLOAT_COUNT_] = {
39
39
  0.f, // VOLUME_RADIUS_PX — square by default
40
40
  1.5f, // LINE_WIDTH_PX — line-chart polyline stroke width
41
41
  0.28f, // LINE_GRADIENT_OPACITY — fill under the line, ramped to 0 at the pane bottom
42
+ 0.f, // LINE_TENSION — straight segments by default
43
+ 1.f, // LINE_TIP_DOT — the line's leading end is marked by default
44
+ 0.f, // LINE_TIP_PULSE — the ring is opt-in; it never lets the chart idle
42
45
  };
43
46
 
44
47
  } // namespace