react-native-vroom-chart 0.13.1 → 0.15.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.
@@ -25,6 +25,7 @@
25
25
  #include "chart_internal.h"
26
26
  #include "crosshair.h"
27
27
  #include "drawings.h"
28
+ #include "footprints.h"
28
29
  #include "labels.h"
29
30
  #include "liquidity.h"
30
31
  #include "ma.h"
@@ -125,6 +126,14 @@ void VroomChart::ensure_bollinger() {
125
126
  bollinger_dirty = false;
126
127
  }
127
128
 
129
+ void VroomChart::ensure_footprint_buckets() {
130
+ if (!footprint_buckets_dirty) return;
131
+ footprint_buckets = vroom::footprints::build_buckets(
132
+ candles.data(), candles.size(), candle_duration_ms, footprints.data(),
133
+ footprints.size());
134
+ footprint_buckets_dirty = false;
135
+ }
136
+
128
137
  void VroomChart::draw_chart(SkCanvas* canvas) {
129
138
  begin_frame();
130
139
  const auto lay = layout();
@@ -178,160 +187,274 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
178
187
  // 4.35. Price-series morph state, shared by the gradient fill below and the
179
188
  // series itself (5). `morph_fade` crossfades candles→line and
180
189
  // `morph_collapse` folds each candle toward its close (the line vertex);
181
- // fade 0 = pure candles, fade 1 = pure line. An interval morph
182
- // additionally reshapes each slot from the geometry it held before the
183
- // timeframe switch (see `morph_from`) candle bodies, line vertices and
184
- // the fill alike, so every layer stays in step mid-crossfade.
190
+ // fade 0 = pure candles, fade 1 = pure line.
191
+ //
192
+ // An interval *transform* reshapes each slot from the geometry it held
193
+ // before the timeframe switch (see `morph_from`). A host-chosen
194
+ // *fade* skips the pairing and uses the same envelope as the axes
195
+ // (`labels::interval_phase`): outgoing snapshot out, then new scene in.
185
196
  const float fade = morph_fade;
186
197
  const float collapse = morph_collapse;
187
198
  const bool morphing = interval_morph_t < 1.f && !morph_from.empty();
188
- const vroom::CandleSnapshot* morph_src = morphing ? morph_from.data() : nullptr;
189
199
  const std::size_t morph_n = morphing ? morph_from.size() : 0;
190
- const float morph_t = morphing ? interval_morph_t : 1.f;
191
-
192
- // 4.4. Line-mode gradient fill the wash under the close polyline. Behind the
193
- // volume bars so they stay legible on top of it, which puts it at the
194
- // back of the price pane's content.
195
- if (fade > 0.f) {
196
- vroom::ma_overlay::draw_close_gradient(
197
- canvas, lay, bounds, visible, n, window_ms,
198
- visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
199
- theme.colors[VROOM_COLOR_LINE],
200
- theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
201
- fade, morph_src, morph_n, morph_t,
202
- theme.floats[VROOM_FLOAT_LINE_TENSION]);
203
- }
200
+ const bool fade_swap = morphing && interval_morph_fade;
201
+ const bool fade_out = fade_swap && axis_phase.outgoing;
202
+ const bool fade_in = fade_swap && !axis_phase.outgoing;
203
+ const bool reshape = morphing && !fade_swap;
204
+ const vroom::CandleSnapshot* morph_src = reshape ? morph_from.data() : nullptr;
205
+ const std::size_t morph_n_draw = reshape ? morph_n : 0;
206
+ const float morph_t = reshape ? interval_morph_t : 1.f;
207
+
208
+ if (fade_out) {
209
+ // First half: only the captured series, at the axis envelope opacity.
210
+ // Layers with no snapshot (volume, overlays, indicators, drawings)
211
+ // would already be the new data, so they stay hidden until the incoming
212
+ // half. morph_t = 0 draws the capture pixel-identically.
213
+ const float layer = axis_phase.opacity;
214
+ if (layer > 0.f) {
215
+ if (fade > 0.f) {
216
+ vroom::ma_overlay::draw_close_gradient(
217
+ canvas, lay, bounds, visible, 0, window_ms,
218
+ visible_start_ms, candle_duration_ms, candle_right,
219
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
220
+ theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
221
+ fade * layer, morph_from.data(), morph_n, 0.f,
222
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
223
+ }
224
+ if (fade < 1.f) {
225
+ vroom::candles::draw(canvas, visible, 0, lay, theme, bounds,
226
+ window_ms, visible_start_ms,
227
+ candle_duration_ms, collapse,
228
+ (1.f - fade) * layer, morph_from.data(),
229
+ morph_n, 0.f);
230
+ }
231
+ if (fade > 0.f) {
232
+ vroom::ma_overlay::draw_close_line(
233
+ canvas, lay, bounds, visible, 0, window_ms,
234
+ visible_start_ms, candle_duration_ms, candle_right,
235
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
236
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
237
+ morph_from.data(), morph_n, 0.f,
238
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
239
+ if (theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
240
+ vroom::ma_overlay::draw_close_tip(
241
+ canvas, lay, bounds, visible, 0, window_ms,
242
+ visible_start_ms, candle_duration_ms, candle_right,
243
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
244
+ theme.colors[VROOM_COLOR_BACKGROUND],
245
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
246
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
247
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
248
+ morph_from.data(), morph_n, 0.f);
249
+ }
250
+ }
251
+ }
252
+ } else {
253
+ // Incoming fade: the new scene at envelope opacity, no slot lerp
254
+ // (`morph_t` is already 1). Transforms use the reshape path above.
255
+ const bool wrap = fade_in && axis_phase.opacity > 0.f &&
256
+ axis_phase.opacity < 0.999f;
257
+ if (wrap) {
258
+ canvas->saveLayerAlpha(
259
+ nullptr, static_cast<U8CPU>(axis_phase.opacity * 255.f + 0.5f));
260
+ }
261
+ const bool draw_new = !fade_in || axis_phase.opacity > 0.f;
262
+
263
+ if (draw_new) {
264
+ // 4.4. Line-mode gradient fill — the wash under the close polyline.
265
+ if (fade > 0.f) {
266
+ vroom::ma_overlay::draw_close_gradient(
267
+ canvas, lay, bounds, visible, n, window_ms,
268
+ visible_start_ms, candle_duration_ms, candle_right,
269
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
270
+ theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY], fade,
271
+ morph_src, morph_n_draw, morph_t,
272
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
273
+ }
204
274
 
205
- // 4.5. Volume bars — drawn under the candles so candles z-index above.
206
- // A fully collapsed chart has no bars left to draw, which is also how
207
- // "volume disabled" is represented (set_volume snaps the scalar).
208
- if (volume_collapse_t < 1.f) {
209
- vroom::volume::draw(canvas, visible, n, lay, theme, volume,
210
- volume_collapse_t, volume_collapse_easing,
211
- window_ms, visible_start_ms, candle_duration_ms);
212
- }
275
+ // 4.5. Volume bars — drawn under the candles so candles z-index above.
276
+ if (volume_collapse_t < 1.f) {
277
+ vroom::volume::draw(canvas, visible, n, lay, theme, volume,
278
+ volume_collapse_t, volume_collapse_easing,
279
+ window_ms, visible_start_ms,
280
+ candle_duration_ms);
281
+ }
213
282
 
214
- // 4.6. Liquidity bands (resting-order depth) — behind the candles so the
215
- // candle bodies paint over the volume-driven tint. Anchored in price
216
- // space, so they scale with the y-axis.
217
- vroom::liquidity::draw(canvas, *this, lay, bounds, candle_right,
218
- candle_area_h);
219
-
220
- // 4.7. Bollinger Band fill — the translucent region between the upper and
221
- // lower bands, behind the candles so their bull/bear colors stay
222
- // untinted. The band lines themselves draw above the candles (5.65).
223
- if (bollinger.enabled) {
224
- ensure_bollinger();
225
- if (bollinger.fill_enabled &&
226
- bb_upper_cache.size() == candles.size() &&
227
- bb_lower_cache.size() == candles.size()) {
228
- vroom::ma_overlay::fill_between(
229
- canvas, lay, bounds, visible, n,
230
- bb_upper_cache.data() + range.start,
231
- bb_lower_cache.data() + range.start, window_ms,
232
- visible_start_ms, candle_duration_ms, candle_right,
233
- candle_area_h, bollinger.upper_color, bollinger.fill_opacity);
234
- }
235
- }
283
+ // 4.6. Liquidity bands (resting-order depth).
284
+ vroom::liquidity::draw(canvas, *this, lay, bounds, candle_right,
285
+ candle_area_h);
286
+
287
+ // 4.7. Bollinger Band fill.
288
+ if (bollinger.enabled) {
289
+ ensure_bollinger();
290
+ if (bollinger.fill_enabled &&
291
+ bb_upper_cache.size() == candles.size() &&
292
+ bb_lower_cache.size() == candles.size()) {
293
+ vroom::ma_overlay::fill_between(
294
+ canvas, lay, bounds, visible, n,
295
+ bb_upper_cache.data() + range.start,
296
+ bb_lower_cache.data() + range.start, window_ms,
297
+ visible_start_ms, candle_duration_ms, candle_right,
298
+ candle_area_h, bollinger.upper_color,
299
+ bollinger.fill_opacity);
300
+ }
301
+ }
236
302
 
237
- // 5. Price series — candles, a close-price line, or a blend of the two during
238
- // the candle↔line morph (state hoisted to 4.35 for the gradient fill). The
239
- // line is styled by theme.LINE.
240
- if (fade < 1.f) {
241
- vroom::candles::draw(canvas, visible, n, lay, theme, bounds, window_ms,
242
- visible_start_ms, candle_duration_ms, collapse,
243
- 1.f - fade, morph_src, morph_n, morph_t);
244
- }
245
- if (fade > 0.f) {
246
- vroom::ma_overlay::draw_close_line(
247
- canvas, lay, bounds, visible, n, window_ms,
248
- visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
249
- theme.colors[VROOM_COLOR_LINE], theme.floats[VROOM_FLOAT_LINE_WIDTH_PX],
250
- fade, morph_src, morph_n, morph_t,
251
- theme.floats[VROOM_FLOAT_LINE_TENSION]);
252
- }
303
+ // 5. Price series — candles, a close-price line, or a blend.
304
+ if (fade < 1.f) {
305
+ vroom::candles::draw(canvas, visible, n, lay, theme, bounds,
306
+ window_ms, visible_start_ms,
307
+ candle_duration_ms, collapse, 1.f - fade,
308
+ morph_src, morph_n_draw, morph_t);
309
+ }
310
+ if (fade > 0.f) {
311
+ vroom::ma_overlay::draw_close_line(
312
+ canvas, lay, bounds, visible, n, window_ms,
313
+ visible_start_ms, candle_duration_ms, candle_right,
314
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
315
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade, morph_src,
316
+ morph_n_draw, morph_t,
317
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
318
+ }
253
319
 
254
- // 5.5. Moving-average overlay lines (SMA/EMA) on the price pane, over the
255
- // candles. They share the candle price scale and don't reserve a pane.
256
- if (!overlays.empty()) {
257
- ensure_overlays();
258
- for (std::size_t k = 0; k < overlays.size(); ++k) {
259
- if (overlay_caches[k].size() != candles.size()) continue;
260
- const double* vis = overlay_caches[k].data() + range.start;
261
- vroom::ma_overlay::draw(canvas, lay, bounds, visible, n, vis,
262
- window_ms, visible_start_ms,
263
- candle_duration_ms, candle_right,
264
- candle_area_h, overlays[k].color,
265
- overlays[k].width);
266
- }
267
- }
320
+ // 5.5. Moving-average overlay lines.
321
+ if (!overlays.empty()) {
322
+ ensure_overlays();
323
+ for (std::size_t k = 0; k < overlays.size(); ++k) {
324
+ if (overlay_caches[k].size() != candles.size()) continue;
325
+ const double* vis = overlay_caches[k].data() + range.start;
326
+ vroom::ma_overlay::draw(
327
+ canvas, lay, bounds, visible, n, vis, window_ms,
328
+ visible_start_ms, candle_duration_ms, candle_right,
329
+ candle_area_h, overlays[k].color, overlays[k].width);
330
+ }
331
+ }
268
332
 
269
- // 5.6. VWAP overlay (session, configurable reset) — a single price-pane line
270
- // that breaks at each session reset (vwap_breaks).
271
- if (vwap.enabled) {
272
- ensure_vwap();
273
- if (vwap_cache.size() == candles.size()) {
274
- const double* vis = vwap_cache.data() + range.start;
275
- const unsigned char* brk = vwap_breaks.size() == candles.size()
276
- ? vwap_breaks.data() + range.start
277
- : nullptr;
278
- vroom::ma_overlay::draw(canvas, lay, bounds, visible, n, vis,
279
- window_ms, visible_start_ms,
280
- candle_duration_ms, candle_right,
281
- candle_area_h,
282
- vroom::style::color_or(vwap.color, kVwapLine),
283
- vroom::style::width_or(vwap.width, 1.5f),
284
- brk);
285
- }
286
- }
333
+ // 5.6. VWAP overlay.
334
+ if (vwap.enabled) {
335
+ ensure_vwap();
336
+ if (vwap_cache.size() == candles.size()) {
337
+ const double* vis = vwap_cache.data() + range.start;
338
+ const unsigned char* brk =
339
+ vwap_breaks.size() == candles.size()
340
+ ? vwap_breaks.data() + range.start
341
+ : nullptr;
342
+ vroom::ma_overlay::draw(
343
+ canvas, lay, bounds, visible, n, vis, window_ms,
344
+ visible_start_ms, candle_duration_ms, candle_right,
345
+ candle_area_h,
346
+ vroom::style::color_or(vwap.color, kVwapLine),
347
+ vroom::style::width_or(vwap.width, 1.5f), brk);
348
+ }
349
+ }
287
350
 
288
- // 5.65. Bollinger Band lines — upper, lower, then the basis last so it
289
- // reads on top where the bands pinch. Same price scale as the
290
- // candles; the fill went down in 4.7.
291
- if (bollinger.enabled) {
292
- ensure_bollinger();
293
- const std::size_t sz = candles.size();
294
- if (bb_upper_cache.size() == sz && bb_lower_cache.size() == sz &&
295
- bb_middle_cache.size() == sz) {
296
- const auto stroke = [&](const std::vector<double>& cache,
297
- uint32_t color, float width) {
298
- vroom::ma_overlay::draw(canvas, lay, bounds, visible, n,
299
- cache.data() + range.start, window_ms,
300
- visible_start_ms, candle_duration_ms,
301
- candle_right, candle_area_h, color,
302
- width);
303
- };
304
- stroke(bb_upper_cache, bollinger.upper_color, bollinger.upper_width);
305
- stroke(bb_lower_cache, bollinger.lower_color, bollinger.lower_width);
306
- stroke(bb_middle_cache, bollinger.middle_color, bollinger.middle_width);
307
- }
308
- }
351
+ // 5.65. Bollinger Band lines.
352
+ if (bollinger.enabled) {
353
+ ensure_bollinger();
354
+ const std::size_t sz = candles.size();
355
+ if (bb_upper_cache.size() == sz && bb_lower_cache.size() == sz &&
356
+ bb_middle_cache.size() == sz) {
357
+ const auto stroke = [&](const std::vector<double>& cache,
358
+ uint32_t color, float width) {
359
+ vroom::ma_overlay::draw(
360
+ canvas, lay, bounds, visible, n,
361
+ cache.data() + range.start, window_ms,
362
+ visible_start_ms, candle_duration_ms, candle_right,
363
+ candle_area_h, color, width);
364
+ };
365
+ stroke(bb_upper_cache, bollinger.upper_color,
366
+ bollinger.upper_width);
367
+ stroke(bb_lower_cache, bollinger.lower_color,
368
+ bollinger.lower_width);
369
+ stroke(bb_middle_cache, bollinger.middle_color,
370
+ bollinger.middle_width);
371
+ }
372
+ }
309
373
 
310
- // 5.7. Drawing annotations (committed line tools + the in-progress draft).
311
- // On the price pane above the candles/overlays, below the axis labels.
312
- vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
313
- candle_area_h);
374
+ // 5.7. Drawing annotations.
375
+ vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
376
+ candle_area_h);
377
+
378
+ // 5.8. Line-mode tip marker.
379
+ if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
380
+ const auto anchor = vroom::tip_anchor::at(
381
+ range.start, range.end, candles.size(), reshape);
382
+ vroom::ma_overlay::draw_close_tip(
383
+ canvas, lay, bounds, visible, anchor.slot_count, window_ms,
384
+ visible_start_ms, candle_duration_ms, candle_right,
385
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
386
+ theme.colors[VROOM_COLOR_BACKGROUND],
387
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
388
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
389
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
390
+ anchor.use_morph ? morph_src : nullptr,
391
+ anchor.use_morph ? morph_n_draw : 0, morph_t);
392
+ }
314
393
 
315
- // 5.8. Line-mode tip marker the dot (and optional pulse) at the newest
316
- // close. Above the overlays so it stays the eye's anchor, but before the
317
- // axis masks, which trim the ring at the price scale.
318
- //
319
- // Unlike every layer above, this one anchors to the newest candle in
320
- // the series rather than the newest on screen, so panning into history
321
- // carries the dot off the right edge with its candle (tip_anchor.h).
322
- if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
323
- const auto anchor = vroom::tip_anchor::at(range.start, range.end,
324
- candles.size(), morphing);
325
- vroom::ma_overlay::draw_close_tip(
326
- canvas, lay, bounds, visible, anchor.slot_count, window_ms,
327
- visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
328
- theme.colors[VROOM_COLOR_LINE],
329
- theme.colors[VROOM_COLOR_BACKGROUND],
330
- theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
331
- theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
332
- tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
333
- anchor.use_morph ? morph_src : nullptr,
334
- anchor.use_morph ? morph_n : 0, morph_t);
394
+ // Incoming fade: indicator panes share the scene opacity (and
395
+ // paint before axis masks so overflow still clips). Transforms
396
+ // keep the settled z-order after the price indicator.
397
+ if (fade_in && lay.indicator_area_h > 0.f) {
398
+ struct ActivePane {
399
+ int order;
400
+ int type;
401
+ };
402
+ ActivePane panes[2];
403
+ int count = 0;
404
+ if (rsi.enabled) panes[count++] = {rsi_order, 0};
405
+ if (macd.enabled) panes[count++] = {macd_order, 1};
406
+ if (count == 2 && panes[0].order > panes[1].order) {
407
+ const ActivePane tmp = panes[0];
408
+ panes[0] = panes[1];
409
+ panes[1] = tmp;
410
+ }
411
+
412
+ const float pane_h =
413
+ height_px * theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
414
+ float pane_top = candle_area_h;
415
+ for (int i = 0; i < count; ++i) {
416
+ const float pane_bottom = pane_top + pane_h;
417
+ if (panes[i].type == 0) {
418
+ ensure_rsi();
419
+ const double* rsi_vis =
420
+ rsi_cache.size() == candles.size()
421
+ ? rsi_cache.data() + range.start
422
+ : nullptr;
423
+ const double* rsi_ma_vis =
424
+ (rsi.ma_visible &&
425
+ rsi_ma_cache.size() == candles.size())
426
+ ? rsi_ma_cache.data() + range.start
427
+ : nullptr;
428
+ vroom::rsi_pane::draw(
429
+ canvas, *this, lay, visible, n, rsi_vis, rsi_ma_vis,
430
+ window_ms, visible_start_ms, candle_duration_ms,
431
+ candle_right, pane_top, pane_bottom);
432
+ } else {
433
+ ensure_macd();
434
+ const double* macd_vis =
435
+ macd_cache.size() == candles.size()
436
+ ? macd_cache.data() + range.start
437
+ : nullptr;
438
+ const double* sig_vis =
439
+ macd_signal_cache.size() == candles.size()
440
+ ? macd_signal_cache.data() + range.start
441
+ : nullptr;
442
+ const double* hist_vis =
443
+ macd_hist_cache.size() == candles.size()
444
+ ? macd_hist_cache.data() + range.start
445
+ : nullptr;
446
+ vroom::macd_pane::draw(
447
+ canvas, *this, lay, visible, n, macd_vis, sig_vis,
448
+ hist_vis, window_ms, visible_start_ms,
449
+ candle_duration_ms, candle_right, pane_top,
450
+ pane_bottom);
451
+ }
452
+ pane_top = pane_bottom;
453
+ }
454
+ }
455
+ }
456
+
457
+ if (wrap) canvas->restore();
335
458
  }
336
459
 
337
460
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
@@ -354,20 +477,32 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
354
477
 
355
478
  // 7.5. Current-price line + box — above labels so the box covers any label
356
479
  // it overlaps; tracks the latest close as the price scale moves.
357
- vroom::price_indicator::draw(canvas, *this, lay, bounds,
358
- candle_right, candle_area_h);
359
-
360
- // 7.55. Consumer-supplied price status lines — same tier as the current-price
361
- // indicator (their badges must cover the labels underneath), but after
362
- // it so a resting order at the last close stays readable.
363
- vroom::price_lines::draw(canvas, *this, lay, bounds, candle_right,
364
- candle_area_h);
480
+ // Hidden during a fade's outgoing half: the close is already the new
481
+ // series and there is no snapshot to fade.
482
+ if (!fade_out) {
483
+ vroom::price_indicator::draw(canvas, *this, lay, bounds,
484
+ candle_right, candle_area_h);
485
+
486
+ // 7.55. Consumer-supplied price status lines — same tier as the
487
+ // current-price indicator (their badges must cover the labels
488
+ // underneath), but after it so a resting order at the last close
489
+ // stays readable.
490
+ vroom::price_lines::draw(canvas, *this, lay, bounds, candle_right,
491
+ candle_area_h);
492
+
493
+ // 7.56. Footprint badges — data-anchored chrome that must not be hidden
494
+ // by candles or overlays, so it draws with the price lines rather
495
+ // than back at the drawings layer. Below the crosshair, which the
496
+ // user is actively pointing with.
497
+ ensure_footprint_buckets();
498
+ vroom::footprints::draw(canvas, *this, lay, bounds, window_ms);
499
+ }
365
500
 
366
501
  // 7.6. Indicator panes stacked below the candles, ordered by enable
367
502
  // sequence (most recently enabled at the bottom). Each pane is
368
503
  // INDICATOR_HEIGHT_FRAC of the height; the candle pane already shrank
369
- // to fit them (see layout()).
370
- if (lay.indicator_area_h > 0.f) {
504
+ // to fit them (see layout()). A fade already drew them with the scene.
505
+ if (!fade_swap && lay.indicator_area_h > 0.f) {
371
506
  struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
372
507
  ActivePane panes[2];
373
508
  int count = 0;
@@ -22,6 +22,7 @@
22
22
  #include "include/core/SkRefCnt.h"
23
23
  #pragma clang diagnostic pop
24
24
 
25
+ #include "footprints_layout.h"
25
26
  #include "labels.h"
26
27
  #include "price_format.h"
27
28
  #include "theme.h"
@@ -73,11 +74,14 @@ struct VroomChart {
73
74
 
74
75
  // Interval morph: the outgoing candle geometry captured when a timeframe
75
76
  // switch begins, indexed from the right of the visible slice (slot 0 =
76
- // newest) the pairing the preserved slot grid guarantees. Stored as
77
- // normalized fractions so it survives the new bounds and a resize.
78
- // Empty when not morphing.
77
+ // newest). `interval_morph_fade` is the host's choice at capture time:
78
+ // false = slot-lerp each column into its counterpart; true = fade the
79
+ // snapshot out then the new scene in (see labels::interval_phase).
80
+ // Stored as normalized fractions so it survives the new bounds and a
81
+ // resize. Empty when not morphing.
79
82
  std::vector<vroom::CandleSnapshot> morph_from;
80
83
  float interval_morph_t = 1.f; // 1 = not morphing
84
+ bool interval_morph_fade = false;
81
85
  // The pre-switch price scale and time window. The candle capture above is
82
86
  // normalized against these, and the axes keep rendering their old ticks from
83
87
  // them while fading out (see labels::interval_phase), so a label is never
@@ -277,6 +281,28 @@ struct VroomChart {
277
281
  int32_t dragged_price_line = -1;
278
282
  double dragged_price_line_price = 0.0;
279
283
 
284
+ // --- footprints (executed-trade badges) ---------------------------------
285
+ // Kept in the *host's* order so the indices reported by
286
+ // vroom_chart_footprints_at map straight back to its own trade objects.
287
+ std::vector<VroomFootprint> footprints;
288
+ VroomFootprintStyle footprint_style{};
289
+
290
+ // Footprints grouped onto candles, which is what the renderer and hit-test
291
+ // actually walk. Derived from `footprints` + `candles`, so it is rebuilt
292
+ // lazily (see ensure_footprint_buckets) whenever either changes — that
293
+ // rebuild is what re-groups badges onto the right bars after an interval
294
+ // switch, with no involvement from the host.
295
+ std::vector<vroom::footprints::Bucket> footprint_buckets;
296
+ bool footprint_buckets_dirty = true;
297
+
298
+ // Which badge renders highlighted, keyed the way the hit-test reports it.
299
+ // side -1 = none.
300
+ int64_t hovered_footprint_time_ms = 0;
301
+ int32_t hovered_footprint_side = -1;
302
+
303
+ // Rebuilds footprint_buckets if either input changed since the last call.
304
+ void ensure_footprint_buckets();
305
+
280
306
  // --- theme --------------------------------------------------------------
281
307
  vroom::Theme theme;
282
308