react-native-vroom-chart 0.8.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.
@@ -294,6 +294,9 @@ typedef enum {
294
294
  VROOM_FLOAT_VOLUME_RADIUS_PX, // volume bar top-corner radius px (0 = square)
295
295
  VROOM_FLOAT_LINE_WIDTH_PX, // line-chart-mode polyline stroke width px
296
296
  VROOM_FLOAT_LINE_GRADIENT_OPACITY, // fill under the line at its strongest (0 disables)
297
+ VROOM_FLOAT_LINE_TENSION, // 0..1 line-chart corner smoothing (0 = straight)
298
+ VROOM_FLOAT_LINE_TIP_DOT, // 0/1: dot at the line's newest end (default on)
299
+ VROOM_FLOAT_LINE_TIP_PULSE, // 0/1: expanding ring around that dot
297
300
  VROOM_FLOAT_COUNT_
298
301
  } VroomFloatKey;
299
302
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  #include "chart.h"
11
11
 
12
+ #include <cmath>
13
+
12
14
  #pragma clang diagnostic push
13
15
  #pragma clang diagnostic ignored "-Wdocumentation"
14
16
  #include "include/core/SkCanvas.h"
@@ -34,6 +36,7 @@
34
36
  #include "rsi.h"
35
37
  #include "rsi_pane.h"
36
38
  #include "style_inherit.h"
39
+ #include "tip_pulse.h"
37
40
  #include "volume.h"
38
41
  #include "vwap.h"
39
42
  #include "theme.h"
@@ -191,7 +194,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
191
194
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
192
195
  theme.colors[VROOM_COLOR_LINE],
193
196
  theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
194
- fade, morph_src, morph_n, morph_t);
197
+ fade, morph_src, morph_n, morph_t,
198
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
195
199
  }
196
200
 
197
201
  // 4.5. Volume bars — drawn under the candles so candles z-index above.
@@ -239,7 +243,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
239
243
  canvas, lay, bounds, visible, n, window_ms,
240
244
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
241
245
  theme.colors[VROOM_COLOR_LINE], theme.floats[VROOM_FLOAT_LINE_WIDTH_PX],
242
- fade, morph_src, morph_n, morph_t);
246
+ fade, morph_src, morph_n, morph_t,
247
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
243
248
  }
244
249
 
245
250
  // 5.5. Moving-average overlay lines (SMA/EMA) on the price pane, over the
@@ -303,6 +308,21 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
303
308
  vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
304
309
  candle_area_h);
305
310
 
311
+ // 5.8. Line-mode tip marker — the dot (and optional pulse) at the newest
312
+ // close. Above the overlays so it stays the eye's anchor, but before the
313
+ // axis masks, which trim the ring at the price scale.
314
+ if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
315
+ vroom::ma_overlay::draw_close_tip(
316
+ canvas, lay, bounds, visible, n, window_ms, visible_start_ms,
317
+ candle_duration_ms, candle_right, candle_area_h,
318
+ theme.colors[VROOM_COLOR_LINE],
319
+ theme.colors[VROOM_COLOR_BACKGROUND],
320
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
321
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
322
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
323
+ morph_src, morph_n, morph_t);
324
+ }
325
+
306
326
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
307
327
  // line is intentionally omitted for now. The bottom strip anchors at
308
328
  // x_axis_top (below any indicator pane) so it never paints over it.
@@ -422,6 +442,17 @@ void VroomChart::begin_frame() {
422
442
  last_anim_tick = now;
423
443
  anim_started = true;
424
444
  last_dt_seconds = dt;
445
+
446
+ // Wrapped rather than accumulated: the phase is the only thing anyone reads,
447
+ // and a chart left open for hours would otherwise lose float precision on it.
448
+ tip_pulse_elapsed_s =
449
+ std::fmod(tip_pulse_elapsed_s + dt, vroom::tip_pulse::kPeriodSeconds);
450
+ }
451
+
452
+ bool VroomChart::tip_pulse_active() const {
453
+ return morph_fade > 0.f && !candles.empty() &&
454
+ theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f &&
455
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f;
425
456
  }
426
457
 
427
458
  void VroomChart::rebuild_chart_picture() {
@@ -433,6 +464,9 @@ void VroomChart::rebuild_chart_picture() {
433
464
  }
434
465
 
435
466
  bool VroomChart::is_animating_now() const {
467
+ // The pulse never finishes on its own, so this is what keeps the host loops
468
+ // requeueing frames for it.
469
+ if (tip_pulse_active()) return true;
436
470
  for (const auto& f : y_fades) {
437
471
  if (f.opacity != f.target) return true;
438
472
  }
@@ -64,6 +64,12 @@ struct VroomChart {
64
64
  float morph_collapse = 0.f;
65
65
  float morph_fade = 0.f;
66
66
 
67
+ // Phase of the line-tip pulse, in seconds into its cycle. Unlike every other
68
+ // animation here this one is core-driven: it loops with no endpoint for the
69
+ // host to ease toward, so begin_frame advances it from the frame clock and
70
+ // is_animating_now keeps the host's redraw loop alive (see tip_pulse.h).
71
+ float tip_pulse_elapsed_s = 0.f;
72
+
67
73
  // Interval morph: the outgoing candle geometry captured when a timeframe
68
74
  // switch begins, indexed from the right of the visible slice (slot 0 =
69
75
  // newest) — the pairing the preserved slot grid guarantees. Stored as
@@ -307,14 +313,20 @@ struct VroomChart {
307
313
  void draw_chart(SkCanvas* canvas);
308
314
 
309
315
  // Per-frame animation bookkeeping: computes `last_dt_seconds`, which paces
310
- // the label fades. Called at the top of draw_chart.
316
+ // the label fades, and advances `tip_pulse_elapsed_s`. Called at the top of
317
+ // draw_chart.
311
318
  void begin_frame();
312
319
 
320
+ // True when the line tip's pulse ring is on screen and looping. Gated on
321
+ // line mode and on having data, so a chart that isn't showing the ring can
322
+ // still go idle.
323
+ bool tip_pulse_active() const;
324
+
313
325
  // Re-records `chart_picture` by invoking draw_chart on a fresh
314
326
  // SkPictureRecorder.
315
327
  void rebuild_chart_picture();
316
328
 
317
- // True if any axis label is mid-fade. Used by the JS-side animation loop
318
- // to know when to keep ticking.
329
+ // True if any axis label is mid-fade, or the tip pulse is running. Used by
330
+ // the JS-side animation loop to know when to keep ticking.
319
331
  bool is_animating_now() const;
320
332
  };
@@ -32,6 +32,17 @@ constexpr double kAxisDragSensitivity = 300.0;
32
32
  constexpr double kMinCandleBodyPx = 1.5;
33
33
  constexpr double kMaxCandleBodyPx = 32.0;
34
34
 
35
+ // Trailing gap the default framing leaves between the newest candle and the
36
+ // price axis. The newest bar flush against the axis reads as cramped, and in line
37
+ // mode the tip marker's pulse needs somewhere to expand into — the axis mask
38
+ // (draw_chart step 6) paints over the gutter, so clearance has to come from the
39
+ // time window rather than from right_padding_px.
40
+ //
41
+ // A gap this small is far under the 3/4-window cap, so it neither triggers the
42
+ // pan rubber-band nor gets clamped away; a timeframe switch carries it over in
43
+ // slots (see timeframeWindow in the hosts), which preserves it in pixels.
44
+ constexpr double kRightGapPx = 12.0;
45
+
35
46
  // Hard cap on the future gap (visible_end - last_candle): 3/4 of the window so
36
47
  // at least 25% of the chart still shows candles. Never forces an existing larger
37
48
  // gap (e.g. from the x-axis drag zoom) to snap back — only limits new growth.
@@ -70,14 +81,29 @@ void apply_default_framing(VroomChart* chart) {
70
81
  const int64_t right_edge_ms =
71
82
  chart->candles.back().time_ms + chart->candle_duration_ms;
72
83
 
84
+ const auto lay = chart->layout();
85
+ const double usable =
86
+ lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
87
+
88
+ // Places a window of the given width with kRightGapPx of air past the newest
89
+ // candle. Shifts the window forward rather than widening it, so the framing
90
+ // keeps whatever candle width it just computed. Falls back to a flush right
91
+ // edge when the layout isn't measured yet and px can't be converted to time.
92
+ const auto frame = [&](int64_t window_ms) {
93
+ const int64_t gap_ms =
94
+ usable > 0.0 && window_ms > 0
95
+ ? static_cast<int64_t>((kRightGapPx * static_cast<double>(window_ms)) /
96
+ usable)
97
+ : 0;
98
+ chart->visible_end_ms = right_edge_ms + gap_ms;
99
+ chart->visible_start_ms = chart->visible_end_ms - window_ms;
100
+ };
101
+
73
102
  // Px-driven framing. Reuses the inversion from vroom_chart_scale_time_axis:
74
103
  // body_w = usable × (dur / window) × ratio → window = usable × dur × ratio / body_w.
75
104
  // Needs a valid layout (width_px); until size is known it falls through to
76
105
  // the candle-count default below.
77
106
  if (chart->default_candle_px > 0.f) {
78
- const auto lay = chart->layout();
79
- const double usable =
80
- lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
81
107
  const double ratio = chart->theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO];
82
108
  const double dur = static_cast<double>(chart->candle_duration_ms);
83
109
  const double body = std::clamp(static_cast<double>(chart->default_candle_px),
@@ -88,8 +114,7 @@ void apply_default_framing(VroomChart* chart) {
88
114
  chart->candles.back().time_ms - chart->candles.front().time_ms;
89
115
  window_ms = std::clamp<int64_t>(window_ms, chart->candle_duration_ms,
90
116
  span > 0 ? span : window_ms);
91
- chart->visible_end_ms = right_edge_ms;
92
- chart->visible_start_ms = chart->visible_end_ms - window_ms;
117
+ frame(window_ms);
93
118
  return;
94
119
  }
95
120
  }
@@ -98,8 +123,7 @@ void apply_default_framing(VroomChart* chart) {
98
123
  const size_t start_idx = chart->candles.size() > kDefaultVisible
99
124
  ? chart->candles.size() - kDefaultVisible
100
125
  : 0;
101
- chart->visible_start_ms = chart->candles[start_idx].time_ms;
102
- chart->visible_end_ms = right_edge_ms;
126
+ frame(right_edge_ms - chart->candles[start_idx].time_ms);
103
127
  }
104
128
 
105
129
  // On the first manual-y gesture, adopt the on-screen auto-fit bounds so the
@@ -0,0 +1,67 @@
1
+ // Monotone cubic interpolation — the math behind the line chart's optional
2
+ // corner smoothing (VROOM_FLOAT_LINE_TENSION).
3
+ //
4
+ // Deliberately monotone (Fritsch-Carlson) rather than a plain Catmull-Rom or
5
+ // cardinal spline. An unconstrained spline through the closes overshoots on a
6
+ // sharp reversal, and on a price chart that isn't just ugly — the curve dips
7
+ // below the period's low and draws a price that never traded, next to candles
8
+ // that show the real one during the candle-to-line crossfade. Limiting the
9
+ // tangents keeps every local extreme on an actual data point.
10
+ //
11
+ // Skia-free and header-only so the unit tests can cover it (see
12
+ // tests/test_curve.cpp); ma_overlay.cpp owns the walk and the path emission.
13
+
14
+ #pragma once
15
+
16
+ #include <algorithm>
17
+ #include <cmath>
18
+
19
+ namespace vroom::curve {
20
+
21
+ // The two interior control points of a cubic Bezier segment, in whatever space
22
+ // the endpoints were given in (pixels, at every call site here).
23
+ struct Controls {
24
+ float c1x, c1y;
25
+ float c2x, c2y;
26
+ };
27
+
28
+ // Slope of the straight line between two points. Non-increasing x yields 0,
29
+ // which keeps a degenerate span (duplicate timestamps, or the compressed
30
+ // spacing an interval morph can pass through) from producing infinities.
31
+ inline float secant(float x0, float y0, float x1, float y1) {
32
+ const float h = x1 - x0;
33
+ return h > 0.f ? (y1 - y0) / h : 0.f;
34
+ }
35
+
36
+ // Tangent at a point with neighboring secants `d_prev` and `d_next`.
37
+ inline float monotone_tangent(float d_prev, float d_next) {
38
+ // Opposite signs (or a flat neighbor) mean this point is a local extreme.
39
+ // A zero tangent is what pins the curve to it instead of sailing past.
40
+ if (d_prev * d_next <= 0.f) return 0.f;
41
+ // Fritsch-Carlson: holding the tangent within three times the smaller
42
+ // neighboring secant is what makes each segment monotone, and is also why
43
+ // the control points below can never leave the endpoints' y range.
44
+ const float limit = 3.f * std::min(std::fabs(d_prev), std::fabs(d_next));
45
+ return std::clamp((d_prev + d_next) * 0.5f, -limit, limit);
46
+ }
47
+
48
+ // Control points for the segment p0 -> p1, given the tangents chosen for each
49
+ // endpoint and how far to lean into them.
50
+ //
51
+ // `tension` 0 puts both tangents back on the segment's own secant, which lands
52
+ // the controls exactly on its thirds — the cubic *is* the straight line. So the
53
+ // knob is continuous at zero rather than snapping into a curve, and callers can
54
+ // interpolate it without a visible discontinuity.
55
+ inline Controls segment_controls(float x0, float y0,
56
+ float x1, float y1,
57
+ float m0, float m1,
58
+ float tension) {
59
+ const float d = secant(x0, y0, x1, y1);
60
+ const float a = d + (m0 - d) * tension;
61
+ const float b = d + (m1 - d) * tension;
62
+ const float third = (x1 - x0) / 3.f;
63
+ return Controls{x0 + third, y0 + a * third,
64
+ x1 - third, y1 - b * third};
65
+ }
66
+
67
+ } // namespace vroom::curve
@@ -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