react-native-vroom-chart 0.6.0 → 0.8.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 (49) hide show
  1. package/cpp/VroomChartHostObject.cpp +281 -33
  2. package/cpp/_core_include/vroom/vroom_chart.h +231 -36
  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 +69 -29
  8. package/cpp/_core_src/chart_facade.cpp +247 -69
  9. package/cpp/_core_src/drawings.cpp +147 -4
  10. package/cpp/_core_src/drawings.h +10 -5
  11. package/cpp/_core_src/gradient.cpp +46 -0
  12. package/cpp/_core_src/gradient.h +31 -0
  13. package/cpp/_core_src/labels.cpp +49 -16
  14. package/cpp/_core_src/labels.h +33 -4
  15. package/cpp/_core_src/liquidity.cpp +3 -37
  16. package/cpp/_core_src/ma_overlay.cpp +174 -12
  17. package/cpp/_core_src/ma_overlay.h +55 -3
  18. package/cpp/_core_src/macd.cpp +13 -42
  19. package/cpp/_core_src/macd.h +11 -8
  20. package/cpp/_core_src/macd_pane.cpp +57 -27
  21. package/cpp/_core_src/price_line_layout.h +1 -1
  22. package/cpp/_core_src/rsi.cpp +4 -18
  23. package/cpp/_core_src/rsi.h +6 -6
  24. package/cpp/_core_src/rsi_pane.cpp +34 -19
  25. package/cpp/_core_src/series_ma.cpp +64 -0
  26. package/cpp/_core_src/series_ma.h +30 -0
  27. package/cpp/_core_src/style_inherit.h +35 -0
  28. package/cpp/_core_src/theme.cpp +2 -1
  29. package/cpp/_core_src/viewport.cpp +36 -12
  30. package/cpp/_core_src/viewport.h +50 -0
  31. package/cpp/_core_src/volume.cpp +33 -8
  32. package/cpp/_core_src/volume.h +12 -1
  33. package/cpp/_core_src/volume_anim.cpp +32 -0
  34. package/cpp/_core_src/volume_anim.h +41 -0
  35. package/lib/index.d.mts +206 -31
  36. package/lib/index.d.ts +206 -31
  37. package/lib/index.js +324 -44
  38. package/lib/index.js.map +1 -1
  39. package/lib/index.mjs +329 -52
  40. package/lib/index.mjs.map +1 -1
  41. package/package.json +1 -1
  42. package/src/VroomChart.tsx +100 -17
  43. package/src/dataTransitions.ts +148 -0
  44. package/src/easing.ts +40 -0
  45. package/src/index.ts +9 -0
  46. package/src/jsi.d.ts +126 -19
  47. package/src/theme.ts +1 -0
  48. package/src/types.ts +3 -0
  49. package/src/useChartCore.ts +273 -30
@@ -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);
@@ -64,6 +64,21 @@ struct VroomChart {
64
64
  float morph_collapse = 0.f;
65
65
  float morph_fade = 0.f;
66
66
 
67
+ // Interval morph: the outgoing candle geometry captured when a timeframe
68
+ // switch begins, indexed from the right of the visible slice (slot 0 =
69
+ // newest) — the pairing the preserved slot grid guarantees. Stored as
70
+ // normalized fractions so it survives the new bounds and a resize.
71
+ // Empty when not morphing.
72
+ std::vector<vroom::CandleSnapshot> morph_from;
73
+ float interval_morph_t = 1.f; // 1 = not morphing
74
+ // The pre-switch price scale and time window. The candle capture above is
75
+ // normalized against these, and the axes keep rendering their old ticks from
76
+ // them while fading out (see labels::interval_phase), so a label is never
77
+ // drawn at a position it didn't already occupy.
78
+ vroom::PriceBounds morph_from_bounds{0.0, 1.0};
79
+ int64_t morph_from_start_ms = 0;
80
+ int64_t morph_from_end_ms = 0;
81
+
67
82
  // Cached y-axis width in pixels, sized to fit the widest formatted price
68
83
  // label. 0 = uncomputed; layout() falls back to a width ratio.
69
84
  float axis_width_px = 0.f;
@@ -86,24 +101,27 @@ struct VroomChart {
86
101
  // RSI, drawn in a pane below the candles. rsi_cache is aligned to `candles`
87
102
  // (one value per candle, NaN where undefined) and recomputed lazily by
88
103
  // ensure_rsi() when rsi_dirty (set on any data or config change).
89
- bool rsi_enabled = false;
90
- int rsi_period = 14;
91
- double rsi_upper = 70.0; // overbought band
92
- double rsi_lower = 30.0; // oversold band
93
- bool rsi_ma_enabled = true; // RSI-based moving average (trendline)
94
- int rsi_ma_period = 14;
104
+ // Defaults: 14-period RSI with 70/30 bands and a 14-period SMA trendline,
105
+ // every style field on its inherit sentinel.
106
+ VroomRSI rsi{0, 14, 70.0, 30.0, 14, 0, 1,
107
+ 0u, -1.f, 1, // RSI line
108
+ 0u, -1.f, // trendline
109
+ 0u, 1}; // band rules
95
110
  std::vector<double> rsi_cache; // RSI per candle (NaN where undefined)
96
- std::vector<double> rsi_ma_cache; // SMA of rsi_cache (empty if MA off)
111
+ std::vector<double> rsi_ma_cache; // MA of rsi_cache (empty if MA off)
97
112
  bool rsi_dirty = true;
98
113
  // User y-axis zoom for the RSI pane (drag on its y-axis strip). 1.0 = the
99
114
  // default 0..100 fit; >1 zooms in (taller), <1 zooms out. Anchored at 50.
100
115
  double rsi_y_scale = 1.0;
101
116
 
102
117
  // MACD, drawn in its own pane. Caches aligned to `candles` (NaN warmup).
103
- bool macd_enabled = false;
104
- int macd_fast = 12;
105
- int macd_slow = 26;
106
- int macd_signal = 9;
118
+ // Defaults: 12/26/9 EMAs of close, every style field on its inherit
119
+ // sentinel so an untouched chart keeps the stock look.
120
+ VroomMACD macd{0, 12, 26, 9, 0, 1, 1,
121
+ 0u, -1.f, 1, // MACD line
122
+ 0u, -1.f, 1, // signal line
123
+ 1, 0u, 0u, 0u, 0u, // histogram
124
+ 0u, 1}; // zero line
107
125
  std::vector<double> macd_cache;
108
126
  std::vector<double> macd_signal_cache;
109
127
  std::vector<double> macd_hist_cache;
@@ -128,10 +146,8 @@ struct VroomChart {
128
146
 
129
147
  // VWAP overlay (session anchor, configurable reset time). Single line on the
130
148
  // price pane; vwap_breaks marks session resets so the line lifts the pen.
131
- bool vwap_enabled = false;
132
- int vwap_reset_offset_min = 0; // session boundary offset from UTC midnight
133
- uint32_t vwap_color = 0xff00bcd4; // cyan
134
- float vwap_width = 1.5f;
149
+ // Style fields sit on their inherit sentinel (cyan, 1.5px).
150
+ VroomVWAP vwap{0, 0, 0u, -1.f};
135
151
  std::vector<double> vwap_cache;
136
152
  std::vector<unsigned char> vwap_breaks;
137
153
  bool vwap_dirty = true;
@@ -150,13 +166,26 @@ struct VroomChart {
150
166
  std::vector<double> bb_lower_cache;
151
167
  bool bollinger_dirty = true;
152
168
 
169
+ // Volume bars (price pane, under the candles). On by default with every
170
+ // style field left on its inherit sentinel, so an untouched chart looks
171
+ // exactly as it did before the config existed. No cache — bar heights come
172
+ // straight off the visible candles' volume.
173
+ VroomVolume volume{1, -1.f, -1.f, -1.f, 0u, 0u};
174
+ // Staggered collapse of those bars, driven per-frame by the host animation
175
+ // loop: 0 = full height, 1 = all flat. Doubles as the visibility gate, since
176
+ // a fully collapsed chart draws nothing — set_volume snaps it to match
177
+ // `volume.enabled`, and the host overrides it while animating.
178
+ float volume_collapse_t = 0.f;
179
+ int32_t volume_collapse_easing = VROOM_EASING_IN_OUT;
180
+
153
181
  // --- drawings (annotations) --------------------------------------------
154
182
  // Committed drawings, anchored in data space so they track the candles on
155
183
  // pan/zoom. Drawn on the price pane above candles/overlays.
156
184
  //
157
- // Mirrors the public VroomDrawing but *owns* its points, so a pencil path
158
- // (kind 2) can carry a variable number of them. For line/box `points` is
159
- // empty and only a/b are used; for pencil a/b mirror the first/last point.
185
+ // Mirrors the public VroomDrawing but *owns* its points, so a pencil stroke
186
+ // (kind 2) or path (kind 3) can carry a variable number of them. For
187
+ // line/box `points` is empty and only a/b are used; for pencil/path a/b
188
+ // mirror the first/last point.
160
189
  struct StoredDrawing {
161
190
  VroomDrawPoint a{};
162
191
  VroomDrawPoint b{};
@@ -171,7 +200,9 @@ struct VroomChart {
171
200
  // draft_a is always drawn (node dot); draft_b is drawn when draft_has_b.
172
201
  // draft_guide draws the live guideline A->B; when false only node dots show
173
202
  // (the committed segment renders via `drawings`). draft_color/draft_width
174
- // style the guideline to match the eventual line.
203
+ // style the guideline to match the eventual line. For a path (draft_kind 3)
204
+ // draft_points holds the vertices placed so far and draft_b is the cursor
205
+ // the rubber-band segment runs to (when draft_has_b).
175
206
  bool draft_active = false;
176
207
  VroomDrawPoint draft_a{};
177
208
  bool draft_has_b = false;
@@ -179,14 +210,16 @@ struct VroomChart {
179
210
  bool draft_guide = false;
180
211
  uint32_t draft_color = 0xff2962ff;
181
212
  float draft_width = 2.f;
182
- int32_t draft_kind = 0; // 0 = line, 1 = box, 2 = pencil (VroomDrawing)
183
- // Freehand stroke in progress (draft_kind 2), grown one point at a time.
213
+ int32_t draft_kind = 0; // 0 = line, 1 = box, 2 = pencil, 3 = path
214
+ // Points of the in-progress stroke (draft_kind 2, grown one at a time) or
215
+ // path (draft_kind 3, restated per click/move).
184
216
  std::vector<VroomDrawPoint> draft_points;
185
217
 
186
218
  // Selection/editing state for committed drawings. selected_drawing indexes
187
- // `drawings` (or -1); its endpoints render as handles. grabbed_endpoint is
188
- // 0 (A) or 1 (B) while that handle is being dragged (rendered 50% larger),
189
- // else -1.
219
+ // `drawings` (or -1); its handles render on top. grabbed_endpoint is the
220
+ // handle being dragged — 0 (A) or 1 (B) for a line, 0 for a box (the host
221
+ // normalizes the grabbed corner to endpoint 0), the vertex index for a path
222
+ // — rendered 50% larger; -1 when none.
190
223
  int32_t selected_drawing = -1;
191
224
  int32_t grabbed_endpoint = -1;
192
225
 
@@ -235,9 +268,9 @@ struct VroomChart {
235
268
  std::chrono::steady_clock::time_point last_anim_tick{};
236
269
  bool anim_started = false;
237
270
 
238
- // dt of the current rebuild — populated by rebuild_chart_picture and
239
- // consumed by the label-fade updaters. Public so the labels namespace
240
- // can read it without a getter.
271
+ // dt of the current frame — populated by begin_frame and consumed by the
272
+ // label-fade updaters. Public so the labels namespace can read it without a
273
+ // getter.
241
274
  float last_dt_seconds = 0.f;
242
275
 
243
276
  // --- methods ------------------------------------------------------------
@@ -267,11 +300,18 @@ struct VroomChart {
267
300
  // indicator is enabled.
268
301
  void ensure_bollinger();
269
302
 
270
- // The main drawing pass. Calls into the labels and candles modules.
303
+ // The main drawing pass. Calls into the labels and candles modules, and owns
304
+ // the per-frame animation clock (see begin_frame) so every host gets it —
305
+ // the web build draws straight through vroom_chart_draw rather than the
306
+ // SkPicture cache below.
271
307
  void draw_chart(SkCanvas* canvas);
272
308
 
309
+ // Per-frame animation bookkeeping: computes `last_dt_seconds`, which paces
310
+ // the label fades. Called at the top of draw_chart.
311
+ void begin_frame();
312
+
273
313
  // Re-records `chart_picture` by invoking draw_chart on a fresh
274
- // SkPictureRecorder. Also computes `last_dt_seconds` for fade animation.
314
+ // SkPictureRecorder.
275
315
  void rebuild_chart_picture();
276
316
 
277
317
  // True if any axis label is mid-fade. Used by the JS-side animation loop