react-native-vroom-chart 0.6.0 → 0.7.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.
Files changed (46) hide show
  1. package/cpp/VroomChartHostObject.cpp +150 -32
  2. package/cpp/_core_include/vroom/vroom_chart.h +180 -22
  3. package/cpp/_core_src/bollinger.h +1 -1
  4. package/cpp/_core_src/candles.cpp +138 -41
  5. package/cpp/_core_src/candles.h +11 -1
  6. package/cpp/_core_src/chart.cpp +91 -40
  7. package/cpp/_core_src/chart.h +55 -20
  8. package/cpp/_core_src/chart_facade.cpp +206 -66
  9. package/cpp/_core_src/gradient.cpp +46 -0
  10. package/cpp/_core_src/gradient.h +31 -0
  11. package/cpp/_core_src/labels.cpp +49 -16
  12. package/cpp/_core_src/labels.h +33 -4
  13. package/cpp/_core_src/liquidity.cpp +3 -37
  14. package/cpp/_core_src/ma_overlay.cpp +174 -12
  15. package/cpp/_core_src/ma_overlay.h +55 -3
  16. package/cpp/_core_src/macd.cpp +13 -42
  17. package/cpp/_core_src/macd.h +11 -8
  18. package/cpp/_core_src/macd_pane.cpp +57 -27
  19. package/cpp/_core_src/price_line_layout.h +1 -1
  20. package/cpp/_core_src/rsi.cpp +4 -18
  21. package/cpp/_core_src/rsi.h +6 -6
  22. package/cpp/_core_src/rsi_pane.cpp +34 -19
  23. package/cpp/_core_src/series_ma.cpp +64 -0
  24. package/cpp/_core_src/series_ma.h +30 -0
  25. package/cpp/_core_src/style_inherit.h +35 -0
  26. package/cpp/_core_src/theme.cpp +2 -1
  27. package/cpp/_core_src/viewport.cpp +36 -12
  28. package/cpp/_core_src/viewport.h +50 -0
  29. package/cpp/_core_src/volume.cpp +33 -8
  30. package/cpp/_core_src/volume.h +12 -1
  31. package/cpp/_core_src/volume_anim.cpp +32 -0
  32. package/cpp/_core_src/volume_anim.h +41 -0
  33. package/lib/index.d.mts +161 -28
  34. package/lib/index.d.ts +161 -28
  35. package/lib/index.js +156 -31
  36. package/lib/index.js.map +1 -1
  37. package/lib/index.mjs +156 -31
  38. package/lib/index.mjs.map +1 -1
  39. package/package.json +3 -3
  40. package/src/VroomChart.tsx +69 -3
  41. package/src/easing.ts +40 -0
  42. package/src/index.ts +3 -0
  43. package/src/jsi.d.ts +76 -20
  44. package/src/theme.ts +1 -0
  45. package/src/types.ts +3 -0
  46. package/src/useChartCore.ts +103 -27
@@ -20,6 +20,31 @@ namespace {
20
20
  inline uint32_t resolve_color(uint32_t color, uint32_t fill) {
21
21
  return (color >> 24) == 0 ? fill : color;
22
22
  }
23
+
24
+ // Channel-wise ARGB blend, for a candle that changes direction mid-morph.
25
+ inline uint32_t lerp_argb(uint32_t a, uint32_t b, float t) {
26
+ uint32_t out = 0;
27
+ for (int shift = 0; shift < 32; shift += 8) {
28
+ const float ca = static_cast<float>((a >> shift) & 0xFFu);
29
+ const float cb = static_cast<float>((b >> shift) & 0xFFu);
30
+ const auto v = static_cast<uint32_t>(ca + (cb - ca) * t + 0.5f);
31
+ out |= v << shift;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ inline uint32_t scale_alpha(uint32_t argb, float a) {
37
+ const auto alpha =
38
+ static_cast<uint32_t>(static_cast<float>((argb >> 24) & 0xFFu) * a + 0.5f);
39
+ return (alpha << 24) | (argb & 0x00FFFFFFu);
40
+ }
41
+
42
+ inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
43
+
44
+ // One slot's wick/body extents in pixels.
45
+ struct SlotGeom {
46
+ float x, open, high, low, close;
47
+ };
23
48
  } // namespace
24
49
 
25
50
  void draw(SkCanvas* canvas,
@@ -32,13 +57,23 @@ void draw(SkCanvas* canvas,
32
57
  int64_t visible_start_ms,
33
58
  int64_t candle_duration_ms,
34
59
  float collapse,
35
- float opacity) {
36
- if (!canvas || n == 0) return;
60
+ float opacity,
61
+ const CandleSnapshot* from,
62
+ std::size_t from_n,
63
+ float morph_t) {
64
+ if (!canvas) return;
37
65
 
38
66
  collapse = std::clamp(collapse, 0.f, 1.f);
39
67
  opacity = std::clamp(opacity, 0.f, 1.f);
68
+ morph_t = std::clamp(morph_t, 0.f, 1.f);
40
69
  if (opacity <= 0.f) return;
41
70
 
71
+ // A finished morph drops the capture, reducing the loop below to the plain
72
+ // candle path.
73
+ const std::size_t from_count = vroom::morph_from_count(from, from_n, morph_t);
74
+ const std::size_t slots = std::max(n, from_count);
75
+ if (slots == 0) return;
76
+
42
77
  // During the candle→line morph, fade the entire candle layer as one unit so
43
78
  // overlapping bars/wicks composite cleanly instead of stacking alpha.
44
79
  const bool fade_layer = opacity < 0.999f;
@@ -60,13 +95,20 @@ void draw(SkCanvas* canvas,
60
95
  const bool wick_round = theme.floats[VROOM_FLOAT_WICK_ROUND_CAP] > 0.5f;
61
96
 
62
97
  constexpr float kBorderWidthPx = 1.f;
98
+ const uint32_t wick_color_bull =
99
+ resolve_color(theme.colors[VROOM_COLOR_WICK_BULL], fill_bull);
100
+ const uint32_t wick_color_bear =
101
+ resolve_color(theme.colors[VROOM_COLOR_WICK_BEAR], fill_bear);
102
+ const uint32_t border_color_bull =
103
+ resolve_color(theme.colors[VROOM_COLOR_BORDER_BULL], fill_bull);
104
+ const uint32_t border_color_bear =
105
+ resolve_color(theme.colors[VROOM_COLOR_BORDER_BEAR], fill_bear);
106
+
63
107
  // Draw the border only when it differs from the fill; an inherited/transparent
64
108
  // border color (the default, and the "hide" path) renders nothing — so a hidden
65
109
  // border costs no pixels and never affects candle width.
66
- const bool draw_border_bull =
67
- resolve_color(theme.colors[VROOM_COLOR_BORDER_BULL], fill_bull) != fill_bull;
68
- const bool draw_border_bear =
69
- resolve_color(theme.colors[VROOM_COLOR_BORDER_BEAR], fill_bear) != fill_bear;
110
+ const bool draw_border_bull = border_color_bull != fill_bull;
111
+ const bool draw_border_bear = border_color_bear != fill_bear;
70
112
 
71
113
  SkPaint bull_paint;
72
114
  bull_paint.setAntiAlias(true);
@@ -78,15 +120,13 @@ void draw(SkCanvas* canvas,
78
120
 
79
121
  SkPaint wick_bull;
80
122
  wick_bull.setAntiAlias(true);
81
- wick_bull.setColor(
82
- resolve_color(theme.colors[VROOM_COLOR_WICK_BULL], fill_bull));
123
+ wick_bull.setColor(wick_color_bull);
83
124
  wick_bull.setStrokeWidth(theme.floats[VROOM_FLOAT_WICK_WIDTH_PX]);
84
125
  wick_bull.setStyle(SkPaint::kStroke_Style);
85
126
  wick_bull.setStrokeCap(wick_round ? SkPaint::kRound_Cap : SkPaint::kButt_Cap);
86
127
 
87
128
  SkPaint wick_bear = wick_bull;
88
- wick_bear.setColor(
89
- resolve_color(theme.colors[VROOM_COLOR_WICK_BEAR], fill_bear));
129
+ wick_bear.setColor(wick_color_bear);
90
130
 
91
131
  // 1px body outlines, drawn *inside* the body (see the inset at draw time) so
92
132
  // a contrasting border never widens the candle. Default sentinel resolves to
@@ -95,52 +135,110 @@ void draw(SkCanvas* canvas,
95
135
  border_bull.setAntiAlias(true);
96
136
  border_bull.setStyle(SkPaint::kStroke_Style);
97
137
  border_bull.setStrokeWidth(kBorderWidthPx);
98
- border_bull.setColor(
99
- resolve_color(theme.colors[VROOM_COLOR_BORDER_BULL], fill_bull));
138
+ border_bull.setColor(border_color_bull);
100
139
 
101
140
  SkPaint border_bear = border_bull;
102
- border_bear.setColor(
103
- resolve_color(theme.colors[VROOM_COLOR_BORDER_BEAR], fill_bear));
141
+ border_bear.setColor(border_color_bear);
142
+
143
+ // Scratch paints for the slots that need a per-slot color (see below).
144
+ SkPaint slot_wick, slot_fill, slot_border;
104
145
 
105
146
  const float half_body = body_w * 0.5f;
147
+ const float area_w = vroom::candle_area_width(lay);
106
148
 
107
- for (std::size_t i = 0; i < n; ++i) {
108
- const auto& c = visible[i];
109
- const bool bull = c.close >= c.open;
149
+ // Iterate slots counting back from the right edge — the pairing a timeframe
150
+ // switch preserves descending so the paint order stays oldest-first and
151
+ // touching wicks/borders composite exactly as they do without a morph.
152
+ for (std::size_t k = slots; k-- > 0;) {
153
+ const ::VroomCandle* to = (k < n) ? &visible[n - 1 - k] : nullptr;
154
+ const CandleSnapshot* frm = (k < from_count) ? &from[k] : nullptr;
155
+
156
+ SlotGeom g{};
157
+ bool bull;
158
+ float alpha = 1.f;
159
+
160
+ if (to) {
161
+ const SlotGeom t{
162
+ vroom::candle_center_x(lay, to->time_ms, candle_duration_ms,
163
+ visible_start_ms, window_ms),
164
+ vroom::price_to_y(lay, bounds, to->open),
165
+ vroom::price_to_y(lay, bounds, to->high),
166
+ vroom::price_to_y(lay, bounds, to->low),
167
+ vroom::price_to_y(lay, bounds, to->close),
168
+ };
169
+ bull = to->close >= to->open;
170
+ if (frm) {
171
+ // The snapshot is in band fractions, so it lands on the same
172
+ // pixels it occupied pre-switch even though the bounds changed.
173
+ g.x = lerp(frm->x * area_w, t.x, morph_t);
174
+ g.open = lerp(vroom::y_at_fraction(lay, frm->open), t.open, morph_t);
175
+ g.high = lerp(vroom::y_at_fraction(lay, frm->high), t.high, morph_t);
176
+ g.low = lerp(vroom::y_at_fraction(lay, frm->low), t.low, morph_t);
177
+ g.close =
178
+ lerp(vroom::y_at_fraction(lay, frm->close), t.close, morph_t);
179
+ } else {
180
+ g = t;
181
+ // A slot with no counterpart: fade in rather than pop.
182
+ if (from_count > 0) alpha = morph_t;
183
+ }
184
+ } else {
185
+ g = {frm->x * area_w,
186
+ vroom::y_at_fraction(lay, frm->open),
187
+ vroom::y_at_fraction(lay, frm->high),
188
+ vroom::y_at_fraction(lay, frm->low),
189
+ vroom::y_at_fraction(lay, frm->close)};
190
+ bull = frm->bull;
191
+ alpha = 1.f - morph_t; // the new data dropped this slot
192
+ }
193
+ if (alpha <= 0.f) continue;
110
194
 
111
- const float cx = vroom::candle_center_x(
112
- lay, c.time_ms, candle_duration_ms,
113
- visible_start_ms, window_ms);
114
- const float y_close = vroom::price_to_y(lay, bounds, c.close);
115
195
  // Collapse high/low/open toward the close so the candle folds into its
116
196
  // close point (the line vertex) as `collapse` → 1.
117
- const float y_high =
118
- vroom::price_to_y(lay, bounds, c.high) * (1.f - collapse) +
119
- y_close * collapse;
120
- const float y_low =
121
- vroom::price_to_y(lay, bounds, c.low) * (1.f - collapse) +
122
- y_close * collapse;
123
- const float y_open =
124
- vroom::price_to_y(lay, bounds, c.open) * (1.f - collapse) +
125
- y_close * collapse;
126
-
127
- canvas->drawLine(cx, y_high, cx, y_low,
128
- bull ? wick_bull : wick_bear);
197
+ const float y_close = g.close;
198
+ const float y_high = g.high * (1.f - collapse) + y_close * collapse;
199
+ const float y_low = g.low * (1.f - collapse) + y_close * collapse;
200
+ const float y_open = g.open * (1.f - collapse) + y_close * collapse;
201
+
202
+ // Fast path: the prebuilt bull/bear paints. A faded slot and a candle
203
+ // that changes direction mid-morph both need per-slot colors.
204
+ const SkPaint* wick_p = bull ? &wick_bull : &wick_bear;
205
+ const SkPaint* fill_p = bull ? &bull_paint : &bear_paint;
206
+ const SkPaint* border_p = bull ? &border_bull : &border_bear;
207
+ bool draw_border = bull ? draw_border_bull : draw_border_bear;
208
+
209
+ const bool flip = frm && to && frm->bull != bull;
210
+ if (flip || alpha < 0.999f) {
211
+ const auto blend = [&](uint32_t c_bull, uint32_t c_bear) {
212
+ uint32_t c = bull ? c_bull : c_bear;
213
+ if (flip) c = lerp_argb(bull ? c_bear : c_bull, c, morph_t);
214
+ return scale_alpha(c, alpha);
215
+ };
216
+ slot_wick = *wick_p;
217
+ slot_wick.setColor(blend(wick_color_bull, wick_color_bear));
218
+ slot_fill = *fill_p;
219
+ slot_fill.setColor(blend(fill_bull, fill_bear));
220
+ slot_border = *border_p;
221
+ slot_border.setColor(blend(border_color_bull, border_color_bear));
222
+ wick_p = &slot_wick;
223
+ fill_p = &slot_fill;
224
+ border_p = &slot_border;
225
+ if (flip) draw_border = draw_border_bull || draw_border_bear;
226
+ }
227
+
228
+ canvas->drawLine(g.x, y_high, g.x, y_low, *wick_p);
129
229
 
130
230
  const float y_top = std::min(y_open, y_close);
131
231
  const float y_bot = std::max(y_open, y_close);
132
232
  const float h = std::max(1.f, y_bot - y_top);
133
- const SkRect body = SkRect::MakeXYWH(cx - half_body, y_top, body_w, h);
233
+ const SkRect body = SkRect::MakeXYWH(g.x - half_body, y_top, body_w, h);
134
234
  // Corner radius clamped so thin candles / short bodies don't over-round.
135
235
  const float r = std::min({body_r, body_w * 0.5f, h * 0.5f});
136
- const SkPaint& fill = bull ? bull_paint : bear_paint;
137
236
  if (r > 0.f) {
138
- canvas->drawRoundRect(body, r, r, fill);
237
+ canvas->drawRoundRect(body, r, r, *fill_p);
139
238
  } else {
140
- canvas->drawRect(body, fill);
239
+ canvas->drawRect(body, *fill_p);
141
240
  }
142
241
 
143
- const bool draw_border = bull ? draw_border_bull : draw_border_bear;
144
242
  // Inset by half the stroke so the centered stroke falls fully within the
145
243
  // body: the border overlaps the fill's outer edge instead of straddling
146
244
  // it, keeping the candle's footprint exactly body_w. Skip when the body is
@@ -148,13 +246,12 @@ void draw(SkCanvas* canvas,
148
246
  if (draw_border && body_w > kBorderWidthPx && h > kBorderWidthPx) {
149
247
  const SkRect inner =
150
248
  body.makeInset(kBorderWidthPx * 0.5f, kBorderWidthPx * 0.5f);
151
- const SkPaint& bp = bull ? border_bull : border_bear;
152
249
  // Concentric radius so the inside border follows the rounded body.
153
250
  const float ir = std::max(0.f, r - kBorderWidthPx * 0.5f);
154
251
  if (ir > 0.f) {
155
- canvas->drawRoundRect(inner, ir, ir, bp);
252
+ canvas->drawRoundRect(inner, ir, ir, *border_p);
156
253
  } else {
157
- canvas->drawRect(inner, bp);
254
+ canvas->drawRect(inner, *border_p);
158
255
  }
159
256
  }
160
257
  }
@@ -12,6 +12,7 @@
12
12
  class SkCanvas;
13
13
 
14
14
  namespace vroom {
15
+ struct CandleSnapshot;
15
16
  struct Layout;
16
17
  struct PriceBounds;
17
18
  struct Theme;
@@ -26,6 +27,12 @@ namespace vroom::candles {
26
27
  // candle→line transition: 0 = normal candle, 1 = a flat point at the close (the
27
28
  // body/wick heights and width shrink to the line). `opacity` (0..1) fades the
28
29
  // whole candle layer out as the line fades in. Both default to a no-op.
30
+ //
31
+ // `from` / `from_n` is the outgoing geometry of an interval morph, indexed from
32
+ // the right of the visible slice (slot 0 = newest), and `morph_t` (0..1) is the
33
+ // eased progress toward `visible`. Each slot's wick and body interpolate between
34
+ // the two; slots present on only one side fade in or out. `morph_t == 1` (the
35
+ // default) draws `visible` alone.
29
36
  void draw(SkCanvas* canvas,
30
37
  const ::VroomCandle* visible,
31
38
  std::size_t n,
@@ -36,6 +43,9 @@ void draw(SkCanvas* canvas,
36
43
  int64_t visible_start_ms,
37
44
  int64_t candle_duration_ms,
38
45
  float collapse = 0.f,
39
- float opacity = 1.f);
46
+ float opacity = 1.f,
47
+ const CandleSnapshot* from = nullptr,
48
+ std::size_t from_n = 0,
49
+ float morph_t = 1.f);
40
50
 
41
51
  } // namespace vroom::candles
@@ -33,11 +33,16 @@
33
33
  #include "price_lines.h"
34
34
  #include "rsi.h"
35
35
  #include "rsi_pane.h"
36
+ #include "style_inherit.h"
36
37
  #include "volume.h"
37
38
  #include "vwap.h"
38
39
  #include "theme.h"
39
40
  #include "viewport.h"
40
41
 
42
+ namespace {
43
+ constexpr SkColor kVwapLine = 0xff00bcd4; // cyan
44
+ } // namespace
45
+
41
46
  VroomChart::VroomChart() : theme(vroom::default_theme()) {}
42
47
 
43
48
  void VroomChart::mark_dirty() {
@@ -49,7 +54,7 @@ vroom::Layout VroomChart::layout() const {
49
54
  const float axis_w = axis_width_px > 0.f
50
55
  ? axis_width_px
51
56
  : width_px * theme.floats[VROOM_FLOAT_Y_AXIS_WIDTH_RATIO];
52
- const int pane_count = (rsi_enabled ? 1 : 0) + (macd_enabled ? 1 : 0);
57
+ const int pane_count = (rsi.enabled ? 1 : 0) + (macd.enabled ? 1 : 0);
53
58
  const float indicator_h = static_cast<float>(pane_count) * height_px *
54
59
  theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
55
60
  return vroom::Layout{
@@ -66,10 +71,11 @@ vroom::Layout VroomChart::layout() const {
66
71
  }
67
72
 
68
73
  void VroomChart::ensure_rsi() {
69
- if (!rsi_enabled || !rsi_dirty) return;
70
- vroom::rsi::compute(candles.data(), candles.size(), rsi_period, rsi_cache);
71
- if (rsi_ma_enabled) {
72
- vroom::rsi::compute_ma(rsi_cache, rsi_ma_period, rsi_ma_cache);
74
+ if (!rsi.enabled || !rsi_dirty) return;
75
+ vroom::rsi::compute(candles.data(), candles.size(), rsi.period, rsi_cache);
76
+ if (rsi.ma_visible) {
77
+ vroom::rsi::compute_ma(rsi_cache, rsi.ma_period, rsi.ma_kind,
78
+ rsi_ma_cache);
73
79
  } else {
74
80
  rsi_ma_cache.clear();
75
81
  }
@@ -77,9 +83,10 @@ void VroomChart::ensure_rsi() {
77
83
  }
78
84
 
79
85
  void VroomChart::ensure_macd() {
80
- if (!macd_enabled || !macd_dirty) return;
81
- vroom::macd::compute(candles.data(), candles.size(), macd_fast, macd_slow,
82
- macd_signal, macd_cache, macd_signal_cache,
86
+ if (!macd.enabled || !macd_dirty) return;
87
+ vroom::macd::compute(candles.data(), candles.size(), macd.fast, macd.slow,
88
+ macd.signal, macd.source, macd.ma_kind,
89
+ macd.signal_ma_kind, macd_cache, macd_signal_cache,
83
90
  macd_hist_cache);
84
91
  macd_dirty = false;
85
92
  }
@@ -96,8 +103,8 @@ void VroomChart::ensure_overlays() {
96
103
  }
97
104
 
98
105
  void VroomChart::ensure_vwap() {
99
- if (!vwap_enabled || !vwap_dirty) return;
100
- vroom::vwap::compute(candles.data(), candles.size(), vwap_reset_offset_min,
106
+ if (!vwap.enabled || !vwap_dirty) return;
107
+ vroom::vwap::compute(candles.data(), candles.size(), vwap.reset_offset_min,
101
108
  vwap_cache, vwap_breaks);
102
109
  vwap_dirty = false;
103
110
  }
@@ -112,6 +119,7 @@ void VroomChart::ensure_bollinger() {
112
119
  }
113
120
 
114
121
  void VroomChart::draw_chart(SkCanvas* canvas) {
122
+ begin_frame();
115
123
  const auto lay = layout();
116
124
 
117
125
  // 1. Background
@@ -140,19 +148,60 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
140
148
 
141
149
  // 3. Update label fade state ONCE per frame — both gridlines and labels
142
150
  // share these opacities so their animations stay in lockstep.
143
- vroom::labels::update_y_fades(*this, lay, bounds);
144
- vroom::labels::update_x_fades(*this, lay);
151
+ // While an interval morph fades the old ticks out, the axes are still laid
152
+ // out against the pre-switch scale and window so nothing appears to move;
153
+ // they adopt the new ones at the midpoint, invisible. Every axis call in
154
+ // the frame has to agree on which of the two it's using.
155
+ const auto axis_phase = vroom::labels::interval_phase(*this);
156
+ const auto& axis_bounds = axis_phase.outgoing ? morph_from_bounds : bounds;
157
+ const int64_t axis_start_ms =
158
+ axis_phase.outgoing ? morph_from_start_ms : visible_start_ms;
159
+ const int64_t axis_end_ms =
160
+ axis_phase.outgoing ? morph_from_end_ms : visible_end_ms;
161
+ vroom::labels::update_y_fades(*this, lay, axis_bounds);
162
+ vroom::labels::update_x_fades(*this, lay, axis_start_ms, axis_end_ms);
145
163
 
146
164
  // 4. Gridlines — drawn before candles so candle bodies overlay them.
147
165
  // Vertical (time) gridlines are intentionally disabled for now —
148
166
  // re-enable by adding `vroom::labels::draw_x_gridlines(canvas, *this,
149
- // candle_right, candle_area_h);` here.
150
- vroom::labels::draw_y_gridlines(canvas, *this, lay, bounds,
167
+ // axis_start_ms, axis_end_ms, candle_right, candle_area_h);` here.
168
+ vroom::labels::draw_y_gridlines(canvas, *this, lay, axis_bounds,
151
169
  candle_right, candle_area_h);
152
170
 
171
+ // 4.35. Price-series morph state, shared by the gradient fill below and the
172
+ // series itself (5). `morph_fade` crossfades candles→line and
173
+ // `morph_collapse` folds each candle toward its close (the line vertex);
174
+ // fade 0 = pure candles, fade 1 = pure line. An interval morph
175
+ // additionally reshapes each slot from the geometry it held before the
176
+ // timeframe switch (see `morph_from`) — candle bodies, line vertices and
177
+ // the fill alike, so every layer stays in step mid-crossfade.
178
+ const float fade = morph_fade;
179
+ const float collapse = morph_collapse;
180
+ const bool morphing = interval_morph_t < 1.f && !morph_from.empty();
181
+ const vroom::CandleSnapshot* morph_src = morphing ? morph_from.data() : nullptr;
182
+ const std::size_t morph_n = morphing ? morph_from.size() : 0;
183
+ const float morph_t = morphing ? interval_morph_t : 1.f;
184
+
185
+ // 4.4. Line-mode gradient fill — the wash under the close polyline. Behind the
186
+ // volume bars so they stay legible on top of it, which puts it at the
187
+ // back of the price pane's content.
188
+ if (fade > 0.f) {
189
+ vroom::ma_overlay::draw_close_gradient(
190
+ canvas, lay, bounds, visible, n, window_ms,
191
+ visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
192
+ theme.colors[VROOM_COLOR_LINE],
193
+ theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
194
+ fade, morph_src, morph_n, morph_t);
195
+ }
196
+
153
197
  // 4.5. Volume bars — drawn under the candles so candles z-index above.
154
- vroom::volume::draw(canvas, visible, n, lay, theme,
155
- window_ms, visible_start_ms, candle_duration_ms);
198
+ // A fully collapsed chart has no bars left to draw, which is also how
199
+ // "volume disabled" is represented (set_volume snaps the scalar).
200
+ if (volume_collapse_t < 1.f) {
201
+ vroom::volume::draw(canvas, visible, n, lay, theme, volume,
202
+ volume_collapse_t, volume_collapse_easing,
203
+ window_ms, visible_start_ms, candle_duration_ms);
204
+ }
156
205
 
157
206
  // 4.6. Liquidity bands (resting-order depth) — behind the candles so the
158
207
  // candle bodies paint over the volume-driven tint. Anchored in price
@@ -178,25 +227,19 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
178
227
  }
179
228
 
180
229
  // 5. Price series — candles, a close-price line, or a blend of the two during
181
- // the candle↔line morph. `morph_fade` crossfades candles→line and
182
- // `morph_collapse` folds each candle toward its close (the line vertex).
183
- // fade 0 = pure candles, fade 1 = pure line. The line reuses the MA-overlay
184
- // polyline routine, fed the visible closes and styled by theme.LINE.
185
- const float fade = morph_fade;
186
- const float collapse = morph_collapse;
230
+ // the candle↔line morph (state hoisted to 4.35 for the gradient fill). The
231
+ // line is styled by theme.LINE.
187
232
  if (fade < 1.f) {
188
233
  vroom::candles::draw(canvas, visible, n, lay, theme, bounds, window_ms,
189
234
  visible_start_ms, candle_duration_ms, collapse,
190
- 1.f - fade);
235
+ 1.f - fade, morph_src, morph_n, morph_t);
191
236
  }
192
237
  if (fade > 0.f) {
193
- std::vector<double> closes(n);
194
- for (std::size_t i = 0; i < n; ++i) closes[i] = visible[i].close;
195
- vroom::ma_overlay::draw(
196
- canvas, lay, bounds, visible, n, closes.data(), window_ms,
238
+ vroom::ma_overlay::draw_close_line(
239
+ canvas, lay, bounds, visible, n, window_ms,
197
240
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
198
241
  theme.colors[VROOM_COLOR_LINE], theme.floats[VROOM_FLOAT_LINE_WIDTH_PX],
199
- nullptr, fade);
242
+ fade, morph_src, morph_n, morph_t);
200
243
  }
201
244
 
202
245
  // 5.5. Moving-average overlay lines (SMA/EMA) on the price pane, over the
@@ -216,7 +259,7 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
216
259
 
217
260
  // 5.6. VWAP overlay (session, configurable reset) — a single price-pane line
218
261
  // that breaks at each session reset (vwap_breaks).
219
- if (vwap_enabled) {
262
+ if (vwap.enabled) {
220
263
  ensure_vwap();
221
264
  if (vwap_cache.size() == candles.size()) {
222
265
  const double* vis = vwap_cache.data() + range.start;
@@ -226,7 +269,10 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
226
269
  vroom::ma_overlay::draw(canvas, lay, bounds, visible, n, vis,
227
270
  window_ms, visible_start_ms,
228
271
  candle_duration_ms, candle_right,
229
- candle_area_h, vwap_color, vwap_width, brk);
272
+ candle_area_h,
273
+ vroom::style::color_or(vwap.color, kVwapLine),
274
+ vroom::style::width_or(vwap.width, 1.5f),
275
+ brk);
230
276
  }
231
277
  }
232
278
 
@@ -272,8 +318,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
272
318
  axis_bg);
273
319
 
274
320
  // 7. Labels (read from y_fades / x_fades, no state mutation here)
275
- vroom::labels::draw_y_labels(canvas, *this, lay, bounds);
276
- vroom::labels::draw_x_labels(canvas, *this, lay);
321
+ vroom::labels::draw_y_labels(canvas, *this, lay, axis_bounds);
322
+ vroom::labels::draw_x_labels(canvas, *this, lay, axis_start_ms, axis_end_ms);
277
323
 
278
324
  // 7.5. Current-price line + box — above labels so the box covers any label
279
325
  // it overlaps; tracks the latest close as the price scale moves.
@@ -294,8 +340,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
294
340
  struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
295
341
  ActivePane panes[2];
296
342
  int count = 0;
297
- if (rsi_enabled) panes[count++] = {rsi_order, 0};
298
- if (macd_enabled) panes[count++] = {macd_order, 1};
343
+ if (rsi.enabled) panes[count++] = {rsi_order, 0};
344
+ if (macd.enabled) panes[count++] = {macd_order, 1};
299
345
  if (count == 2 && panes[0].order > panes[1].order) {
300
346
  const ActivePane tmp = panes[0];
301
347
  panes[0] = panes[1];
@@ -312,7 +358,7 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
312
358
  const double* rsi_vis = rsi_cache.size() == candles.size()
313
359
  ? rsi_cache.data() + range.start : nullptr;
314
360
  const double* rsi_ma_vis =
315
- (rsi_ma_enabled && rsi_ma_cache.size() == candles.size())
361
+ (rsi.ma_visible && rsi_ma_cache.size() == candles.size())
316
362
  ? rsi_ma_cache.data() + range.start : nullptr;
317
363
  vroom::rsi_pane::draw(canvas, *this, lay, visible, n, rsi_vis,
318
364
  rsi_ma_vis, window_ms, visible_start_ms,
@@ -361,19 +407,24 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
361
407
  vroom::labels::gc_x_fades(*this);
362
408
  }
363
409
 
364
- void VroomChart::rebuild_chart_picture() {
365
- // Compute dt for fade animations. We cap large gaps (resume from
366
- // background, long idle) so we don't snap animations to completion.
367
- auto now = std::chrono::steady_clock::now();
410
+ void VroomChart::begin_frame() {
411
+ // dt for the fade animations. A large gap (resume from background, or an idle
412
+ // chart that a click just woke up) is clamped to a nominal frame rather than
413
+ // zeroed: dt == 0 means "snap" to the fade updaters, which would finish every
414
+ // fade in the first frame after any idle period.
415
+ constexpr float kNominalFrameSeconds = 1.f / 60.f;
416
+ const auto now = std::chrono::steady_clock::now();
368
417
  float dt = 0.f;
369
418
  if (anim_started) {
370
419
  dt = std::chrono::duration<float>(now - last_anim_tick).count();
371
- if (dt > 0.1f) dt = 0.f;
420
+ if (dt > 0.1f) dt = kNominalFrameSeconds;
372
421
  }
373
422
  last_anim_tick = now;
374
423
  anim_started = true;
375
424
  last_dt_seconds = dt;
425
+ }
376
426
 
427
+ void VroomChart::rebuild_chart_picture() {
377
428
  SkPictureRecorder recorder;
378
429
  SkCanvas* canvas = recorder.beginRecording(SkRect::MakeWH(width_px, height_px));
379
430
  draw_chart(canvas);