react-native-vroom-chart 0.13.1 → 0.14.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.
@@ -484,17 +484,25 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
484
484
  }
485
485
 
486
486
  if (name == "beginIntervalMorph") {
487
- // beginIntervalMorph() — capture the visible candle geometry so the next
488
- // setCandles can be animated as a reshape. Call before setCandles.
487
+ // beginIntervalMorph(mode?) — capture the visible candle geometry so the
488
+ // next setCandles can animate. Optional string `'fade'` or `'transform'`
489
+ // (default). Call before setCandles.
489
490
  return jsi::Function::createFromHostFunction(
490
491
  rt,
491
492
  jsi::PropNameID::forAscii(rt, "beginIntervalMorph"),
492
- 0,
493
- [this](jsi::Runtime& /*rt2*/,
493
+ 1,
494
+ [this](jsi::Runtime& rt2,
494
495
  const jsi::Value& /*thisVal*/,
495
- const jsi::Value* /*args*/,
496
- size_t /*count*/) -> jsi::Value {
497
- vroom_chart_begin_interval_morph(chart_);
496
+ const jsi::Value* args,
497
+ size_t count) -> jsi::Value {
498
+ int32_t mode = 0;
499
+ if (count >= 1 && args[0].isString()) {
500
+ const auto s = args[0].asString(rt2).utf8(rt2);
501
+ mode = s == "fade" ? 1 : 0;
502
+ } else if (count >= 1 && args[0].isNumber()) {
503
+ mode = args[0].asNumber() != 0 ? 1 : 0;
504
+ }
505
+ vroom_chart_begin_interval_morph(chart_, mode);
498
506
  return jsi::Value::undefined();
499
507
  });
500
508
  }
@@ -420,14 +420,13 @@ void vroom_chart_preserve_price_envelope(VroomChart* chart,
420
420
  double prev_low, double prev_high);
421
421
 
422
422
  // Captures the currently visible candle geometry so the next data swap can be
423
- // animated as a reshape rather than a jump: each candle's wick and body slide
424
- // and stretch into the shape of its counterpart in the new data.
423
+ // animated. `mode` 0 (transform) lerps each visible column into its counterpart,
424
+ // paired by slot counting back from the right edge. `mode` 1 (fade) fades the
425
+ // capture out then the new scene in on the same envelope the axes already use.
425
426
  //
426
- // Candles are paired by *slot* position counting back from the right edge of
427
- // the visible window, which a timeframe switch preserves. Call before
428
- // set_candles, then drive vroom_chart_set_interval_morph from 0 to 1.
429
- // No-op when nothing is visible.
430
- void vroom_chart_begin_interval_morph(VroomChart* chart);
427
+ // Call before set_candles, then drive vroom_chart_set_interval_morph from 0
428
+ // to 1. No-op when nothing is visible.
429
+ void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode);
431
430
 
432
431
  // Advances the interval morph started by vroom_chart_begin_interval_morph. `t`
433
432
  // (clamped to 0..1) is the eased progress: 0 renders the captured geometry
@@ -28,11 +28,13 @@ namespace vroom::candles {
28
28
  // body/wick heights and width shrink to the line). `opacity` (0..1) fades the
29
29
  // whole candle layer out as the line fades in. Both default to a no-op.
30
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.
31
+ // `from` / `from_n` is the outgoing geometry of an interval *transform*, indexed
32
+ // from the right of the visible slice (slot 0 = newest), and `morph_t` (0..1)
33
+ // is the eased progress toward `visible`. Each slot's wick and body interpolate
34
+ // between the two; slots present on only one side fade in or out. A host-chosen
35
+ // fade (see interval_morph_fade) skips this lerp: the chart draws the capture
36
+ // then the new scene via interval_phase instead. `morph_t == 1` (the default)
37
+ // draws `visible` alone.
36
38
  void draw(SkCanvas* canvas,
37
39
  const ::VroomCandle* visible,
38
40
  std::size_t n,
@@ -178,160 +178,274 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
178
178
  // 4.35. Price-series morph state, shared by the gradient fill below and the
179
179
  // series itself (5). `morph_fade` crossfades candles→line and
180
180
  // `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.
181
+ // fade 0 = pure candles, fade 1 = pure line.
182
+ //
183
+ // An interval *transform* reshapes each slot from the geometry it held
184
+ // before the timeframe switch (see `morph_from`). A host-chosen
185
+ // *fade* skips the pairing and uses the same envelope as the axes
186
+ // (`labels::interval_phase`): outgoing snapshot out, then new scene in.
185
187
  const float fade = morph_fade;
186
188
  const float collapse = morph_collapse;
187
189
  const bool morphing = interval_morph_t < 1.f && !morph_from.empty();
188
- const vroom::CandleSnapshot* morph_src = morphing ? morph_from.data() : nullptr;
189
190
  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
- }
191
+ const bool fade_swap = morphing && interval_morph_fade;
192
+ const bool fade_out = fade_swap && axis_phase.outgoing;
193
+ const bool fade_in = fade_swap && !axis_phase.outgoing;
194
+ const bool reshape = morphing && !fade_swap;
195
+ const vroom::CandleSnapshot* morph_src = reshape ? morph_from.data() : nullptr;
196
+ const std::size_t morph_n_draw = reshape ? morph_n : 0;
197
+ const float morph_t = reshape ? interval_morph_t : 1.f;
198
+
199
+ if (fade_out) {
200
+ // First half: only the captured series, at the axis envelope opacity.
201
+ // Layers with no snapshot (volume, overlays, indicators, drawings)
202
+ // would already be the new data, so they stay hidden until the incoming
203
+ // half. morph_t = 0 draws the capture pixel-identically.
204
+ const float layer = axis_phase.opacity;
205
+ if (layer > 0.f) {
206
+ if (fade > 0.f) {
207
+ vroom::ma_overlay::draw_close_gradient(
208
+ canvas, lay, bounds, visible, 0, window_ms,
209
+ visible_start_ms, candle_duration_ms, candle_right,
210
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
211
+ theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
212
+ fade * layer, morph_from.data(), morph_n, 0.f,
213
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
214
+ }
215
+ if (fade < 1.f) {
216
+ vroom::candles::draw(canvas, visible, 0, lay, theme, bounds,
217
+ window_ms, visible_start_ms,
218
+ candle_duration_ms, collapse,
219
+ (1.f - fade) * layer, morph_from.data(),
220
+ morph_n, 0.f);
221
+ }
222
+ if (fade > 0.f) {
223
+ vroom::ma_overlay::draw_close_line(
224
+ canvas, lay, bounds, visible, 0, window_ms,
225
+ visible_start_ms, candle_duration_ms, candle_right,
226
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
227
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
228
+ morph_from.data(), morph_n, 0.f,
229
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
230
+ if (theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
231
+ vroom::ma_overlay::draw_close_tip(
232
+ canvas, lay, bounds, visible, 0, window_ms,
233
+ visible_start_ms, candle_duration_ms, candle_right,
234
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
235
+ theme.colors[VROOM_COLOR_BACKGROUND],
236
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
237
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
238
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
239
+ morph_from.data(), morph_n, 0.f);
240
+ }
241
+ }
242
+ }
243
+ } else {
244
+ // Incoming fade: the new scene at envelope opacity, no slot lerp
245
+ // (`morph_t` is already 1). Transforms use the reshape path above.
246
+ const bool wrap = fade_in && axis_phase.opacity > 0.f &&
247
+ axis_phase.opacity < 0.999f;
248
+ if (wrap) {
249
+ canvas->saveLayerAlpha(
250
+ nullptr, static_cast<U8CPU>(axis_phase.opacity * 255.f + 0.5f));
251
+ }
252
+ const bool draw_new = !fade_in || axis_phase.opacity > 0.f;
253
+
254
+ if (draw_new) {
255
+ // 4.4. Line-mode gradient fill — the wash under the close polyline.
256
+ if (fade > 0.f) {
257
+ vroom::ma_overlay::draw_close_gradient(
258
+ canvas, lay, bounds, visible, n, window_ms,
259
+ visible_start_ms, candle_duration_ms, candle_right,
260
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
261
+ theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY], fade,
262
+ morph_src, morph_n_draw, morph_t,
263
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
264
+ }
204
265
 
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
- }
266
+ // 4.5. Volume bars — drawn under the candles so candles z-index above.
267
+ if (volume_collapse_t < 1.f) {
268
+ vroom::volume::draw(canvas, visible, n, lay, theme, volume,
269
+ volume_collapse_t, volume_collapse_easing,
270
+ window_ms, visible_start_ms,
271
+ candle_duration_ms);
272
+ }
213
273
 
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
- }
274
+ // 4.6. Liquidity bands (resting-order depth).
275
+ vroom::liquidity::draw(canvas, *this, lay, bounds, candle_right,
276
+ candle_area_h);
277
+
278
+ // 4.7. Bollinger Band fill.
279
+ if (bollinger.enabled) {
280
+ ensure_bollinger();
281
+ if (bollinger.fill_enabled &&
282
+ bb_upper_cache.size() == candles.size() &&
283
+ bb_lower_cache.size() == candles.size()) {
284
+ vroom::ma_overlay::fill_between(
285
+ canvas, lay, bounds, visible, n,
286
+ bb_upper_cache.data() + range.start,
287
+ bb_lower_cache.data() + range.start, window_ms,
288
+ visible_start_ms, candle_duration_ms, candle_right,
289
+ candle_area_h, bollinger.upper_color,
290
+ bollinger.fill_opacity);
291
+ }
292
+ }
236
293
 
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
- }
294
+ // 5. Price series — candles, a close-price line, or a blend.
295
+ if (fade < 1.f) {
296
+ vroom::candles::draw(canvas, visible, n, lay, theme, bounds,
297
+ window_ms, visible_start_ms,
298
+ candle_duration_ms, collapse, 1.f - fade,
299
+ morph_src, morph_n_draw, morph_t);
300
+ }
301
+ if (fade > 0.f) {
302
+ vroom::ma_overlay::draw_close_line(
303
+ canvas, lay, bounds, visible, n, window_ms,
304
+ visible_start_ms, candle_duration_ms, candle_right,
305
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
306
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade, morph_src,
307
+ morph_n_draw, morph_t,
308
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
309
+ }
253
310
 
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
- }
311
+ // 5.5. Moving-average overlay lines.
312
+ if (!overlays.empty()) {
313
+ ensure_overlays();
314
+ for (std::size_t k = 0; k < overlays.size(); ++k) {
315
+ if (overlay_caches[k].size() != candles.size()) continue;
316
+ const double* vis = overlay_caches[k].data() + range.start;
317
+ vroom::ma_overlay::draw(
318
+ canvas, lay, bounds, visible, n, vis, window_ms,
319
+ visible_start_ms, candle_duration_ms, candle_right,
320
+ candle_area_h, overlays[k].color, overlays[k].width);
321
+ }
322
+ }
268
323
 
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
- }
324
+ // 5.6. VWAP overlay.
325
+ if (vwap.enabled) {
326
+ ensure_vwap();
327
+ if (vwap_cache.size() == candles.size()) {
328
+ const double* vis = vwap_cache.data() + range.start;
329
+ const unsigned char* brk =
330
+ vwap_breaks.size() == candles.size()
331
+ ? vwap_breaks.data() + range.start
332
+ : nullptr;
333
+ vroom::ma_overlay::draw(
334
+ canvas, lay, bounds, visible, n, vis, window_ms,
335
+ visible_start_ms, candle_duration_ms, candle_right,
336
+ candle_area_h,
337
+ vroom::style::color_or(vwap.color, kVwapLine),
338
+ vroom::style::width_or(vwap.width, 1.5f), brk);
339
+ }
340
+ }
287
341
 
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
- }
342
+ // 5.65. Bollinger Band lines.
343
+ if (bollinger.enabled) {
344
+ ensure_bollinger();
345
+ const std::size_t sz = candles.size();
346
+ if (bb_upper_cache.size() == sz && bb_lower_cache.size() == sz &&
347
+ bb_middle_cache.size() == sz) {
348
+ const auto stroke = [&](const std::vector<double>& cache,
349
+ uint32_t color, float width) {
350
+ vroom::ma_overlay::draw(
351
+ canvas, lay, bounds, visible, n,
352
+ cache.data() + range.start, window_ms,
353
+ visible_start_ms, candle_duration_ms, candle_right,
354
+ candle_area_h, color, width);
355
+ };
356
+ stroke(bb_upper_cache, bollinger.upper_color,
357
+ bollinger.upper_width);
358
+ stroke(bb_lower_cache, bollinger.lower_color,
359
+ bollinger.lower_width);
360
+ stroke(bb_middle_cache, bollinger.middle_color,
361
+ bollinger.middle_width);
362
+ }
363
+ }
309
364
 
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);
365
+ // 5.7. Drawing annotations.
366
+ vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
367
+ candle_area_h);
368
+
369
+ // 5.8. Line-mode tip marker.
370
+ if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
371
+ const auto anchor = vroom::tip_anchor::at(
372
+ range.start, range.end, candles.size(), reshape);
373
+ vroom::ma_overlay::draw_close_tip(
374
+ canvas, lay, bounds, visible, anchor.slot_count, window_ms,
375
+ visible_start_ms, candle_duration_ms, candle_right,
376
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
377
+ theme.colors[VROOM_COLOR_BACKGROUND],
378
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
379
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
380
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
381
+ anchor.use_morph ? morph_src : nullptr,
382
+ anchor.use_morph ? morph_n_draw : 0, morph_t);
383
+ }
314
384
 
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);
385
+ // Incoming fade: indicator panes share the scene opacity (and
386
+ // paint before axis masks so overflow still clips). Transforms
387
+ // keep the settled z-order after the price indicator.
388
+ if (fade_in && lay.indicator_area_h > 0.f) {
389
+ struct ActivePane {
390
+ int order;
391
+ int type;
392
+ };
393
+ ActivePane panes[2];
394
+ int count = 0;
395
+ if (rsi.enabled) panes[count++] = {rsi_order, 0};
396
+ if (macd.enabled) panes[count++] = {macd_order, 1};
397
+ if (count == 2 && panes[0].order > panes[1].order) {
398
+ const ActivePane tmp = panes[0];
399
+ panes[0] = panes[1];
400
+ panes[1] = tmp;
401
+ }
402
+
403
+ const float pane_h =
404
+ height_px * theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
405
+ float pane_top = candle_area_h;
406
+ for (int i = 0; i < count; ++i) {
407
+ const float pane_bottom = pane_top + pane_h;
408
+ if (panes[i].type == 0) {
409
+ ensure_rsi();
410
+ const double* rsi_vis =
411
+ rsi_cache.size() == candles.size()
412
+ ? rsi_cache.data() + range.start
413
+ : nullptr;
414
+ const double* rsi_ma_vis =
415
+ (rsi.ma_visible &&
416
+ rsi_ma_cache.size() == candles.size())
417
+ ? rsi_ma_cache.data() + range.start
418
+ : nullptr;
419
+ vroom::rsi_pane::draw(
420
+ canvas, *this, lay, visible, n, rsi_vis, rsi_ma_vis,
421
+ window_ms, visible_start_ms, candle_duration_ms,
422
+ candle_right, pane_top, pane_bottom);
423
+ } else {
424
+ ensure_macd();
425
+ const double* macd_vis =
426
+ macd_cache.size() == candles.size()
427
+ ? macd_cache.data() + range.start
428
+ : nullptr;
429
+ const double* sig_vis =
430
+ macd_signal_cache.size() == candles.size()
431
+ ? macd_signal_cache.data() + range.start
432
+ : nullptr;
433
+ const double* hist_vis =
434
+ macd_hist_cache.size() == candles.size()
435
+ ? macd_hist_cache.data() + range.start
436
+ : nullptr;
437
+ vroom::macd_pane::draw(
438
+ canvas, *this, lay, visible, n, macd_vis, sig_vis,
439
+ hist_vis, window_ms, visible_start_ms,
440
+ candle_duration_ms, candle_right, pane_top,
441
+ pane_bottom);
442
+ }
443
+ pane_top = pane_bottom;
444
+ }
445
+ }
446
+ }
447
+
448
+ if (wrap) canvas->restore();
335
449
  }
336
450
 
337
451
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
@@ -354,20 +468,25 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
354
468
 
355
469
  // 7.5. Current-price line + box — above labels so the box covers any label
356
470
  // 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);
471
+ // Hidden during a fade's outgoing half: the close is already the new
472
+ // series and there is no snapshot to fade.
473
+ if (!fade_out) {
474
+ vroom::price_indicator::draw(canvas, *this, lay, bounds,
475
+ candle_right, candle_area_h);
476
+
477
+ // 7.55. Consumer-supplied price status lines — same tier as the
478
+ // current-price indicator (their badges must cover the labels
479
+ // underneath), but after it so a resting order at the last close
480
+ // stays readable.
481
+ vroom::price_lines::draw(canvas, *this, lay, bounds, candle_right,
482
+ candle_area_h);
483
+ }
365
484
 
366
485
  // 7.6. Indicator panes stacked below the candles, ordered by enable
367
486
  // sequence (most recently enabled at the bottom). Each pane is
368
487
  // 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) {
488
+ // to fit them (see layout()). A fade already drew them with the scene.
489
+ if (!fade_swap && lay.indicator_area_h > 0.f) {
371
490
  struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
372
491
  ActivePane panes[2];
373
492
  int count = 0;
@@ -73,11 +73,14 @@ struct VroomChart {
73
73
 
74
74
  // Interval morph: the outgoing candle geometry captured when a timeframe
75
75
  // 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.
76
+ // newest). `interval_morph_fade` is the host's choice at capture time:
77
+ // false = slot-lerp each column into its counterpart; true = fade the
78
+ // snapshot out then the new scene in (see labels::interval_phase).
79
+ // Stored as normalized fractions so it survives the new bounds and a
80
+ // resize. Empty when not morphing.
79
81
  std::vector<vroom::CandleSnapshot> morph_from;
80
82
  float interval_morph_t = 1.f; // 1 = not morphing
83
+ bool interval_morph_fade = false;
81
84
  // The pre-switch price scale and time window. The candle capture above is
82
85
  // normalized against these, and the axes keep rendering their old ticks from
83
86
  // them while fading out (see labels::interval_phase), so a label is never
@@ -299,6 +299,7 @@ extern "C" void vroom_chart_reset_view(VroomChart* chart) {
299
299
  // here — drop any capture rather than leaving it to reshape the wrong data.
300
300
  chart->morph_from.clear();
301
301
  chart->interval_morph_t = 1.f;
302
+ chart->interval_morph_fade = false;
302
303
  apply_default_framing(chart);
303
304
  vroom::labels::recompute_axis_width(*chart);
304
305
  if (chart->cb.on_viewport_changed) {
@@ -352,10 +353,11 @@ extern "C" void vroom_chart_preserve_price_envelope(VroomChart* chart,
352
353
  chart->mark_dirty();
353
354
  }
354
355
 
355
- extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart) {
356
+ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode) {
356
357
  if (!chart) return;
357
358
  chart->morph_from.clear();
358
359
  chart->interval_morph_t = 1.f;
360
+ chart->interval_morph_fade = false;
359
361
 
360
362
  const auto lay = chart->layout();
361
363
  const float area_w = vroom::candle_area_width(lay);
@@ -400,6 +402,7 @@ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart) {
400
402
  // Open the morph at 0 rather than leaving it at 1: the caller still has to
401
403
  // push the new candles, and any frame painted in between should show the
402
404
  // captured geometry — which is what the pre-switch frame looked like.
405
+ chart->interval_morph_fade = vroom::interval_morph_is_fade(mode);
403
406
  chart->interval_morph_t = 0.f;
404
407
  }
405
408
 
@@ -409,6 +412,7 @@ extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
409
412
  if (chart->interval_morph_t >= 1.f) {
410
413
  chart->morph_from.clear();
411
414
  chart->morph_from.shrink_to_fit();
415
+ chart->interval_morph_fade = false;
412
416
  }
413
417
  chart->mark_dirty();
414
418
  }
@@ -104,6 +104,10 @@ inline std::size_t morph_from_count(const CandleSnapshot* from,
104
104
  return (from && morph_t < 1.f) ? from_n : 0;
105
105
  }
106
106
 
107
+ // C facade `vroom_chart_begin_interval_morph` mode: 0 = slot-lerp transform,
108
+ // anything else = fade the outgoing snapshot out then the new scene in.
109
+ inline bool interval_morph_is_fade(int32_t mode) { return mode != 0; }
110
+
107
111
  // Returns the indices of candles whose time_ms falls in [start_ms, end_ms].
108
112
  // When both are 0, returns the full range (Phase 1 default-everything behavior).
109
113
  // Candles must be sorted ascending by time_ms (invariant of the public API).