react-native-vroom-chart 0.16.0 → 0.18.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 (44) hide show
  1. package/README.md +1 -1
  2. package/android/CMakeLists.txt +12 -2
  3. package/android/README.md +16 -6
  4. package/android/build.gradle +10 -0
  5. package/android/src/main/cpp/OnLoad.cpp +10 -0
  6. package/android/src/main/cpp/VroomChartJsiBindings.cpp +35 -0
  7. package/android/src/main/cpp/VroomChartJsiBindings.h +27 -0
  8. package/android/src/main/java/com/vroom/chart/VroomChartModule.kt +15 -11
  9. package/cpp/VroomChartHostObject.cpp +81 -0
  10. package/cpp/VroomJsiInstaller.cpp +6 -0
  11. package/cpp/_core_include/vroom/vroom_chart.h +41 -0
  12. package/cpp/_core_src/candles.cpp +15 -14
  13. package/cpp/_core_src/chart.cpp +297 -28
  14. package/cpp/_core_src/chart.h +71 -2
  15. package/cpp/_core_src/chart_facade.cpp +27 -0
  16. package/cpp/_core_src/color_lerp.h +28 -0
  17. package/cpp/_core_src/loading_line.cpp +183 -0
  18. package/cpp/_core_src/loading_line.h +38 -0
  19. package/cpp/_core_src/loading_wave.h +140 -0
  20. package/cpp/_core_src/ma_overlay.cpp +15 -10
  21. package/cpp/_core_src/ma_overlay.h +7 -0
  22. package/cpp/_core_src/price_indicator.cpp +21 -7
  23. package/cpp/_core_src/price_indicator.h +11 -1
  24. package/cpp/_core_src/price_indicator_anim.h +81 -0
  25. package/cpp/_core_src/theme.cpp +5 -0
  26. package/cpp/_core_src/tip_geometry.h +55 -0
  27. package/cpp/_core_src/viewport.h +33 -0
  28. package/ios/README.md +17 -5
  29. package/ios/VroomChartModule.h +3 -6
  30. package/ios/VroomChartModule.mm +17 -23
  31. package/lib/index.d.mts +30 -0
  32. package/lib/index.d.ts +30 -0
  33. package/lib/index.js +75 -14
  34. package/lib/index.js.map +1 -1
  35. package/lib/index.mjs +75 -14
  36. package/lib/index.mjs.map +1 -1
  37. package/package.json +9 -9
  38. package/react-native-vroom-chart.podspec +1 -1
  39. package/src/NativeVroomChart.ts +7 -4
  40. package/src/VroomChart.tsx +12 -0
  41. package/src/jsi.d.ts +42 -0
  42. package/src/theme.ts +1 -0
  43. package/src/useChartCore.ts +129 -7
  44. package/android/src/main/cpp/VroomChartJni.cpp +0 -24
@@ -9,6 +9,7 @@
9
9
 
10
10
  #include "chart.h"
11
11
 
12
+ #include <algorithm>
12
13
  #include <cmath>
13
14
 
14
15
  #pragma clang diagnostic push
@@ -32,6 +33,8 @@
32
33
  #include "ichimoku.h"
33
34
  #include "labels.h"
34
35
  #include "liquidity.h"
36
+ #include "loading_line.h"
37
+ #include "loading_wave.h"
35
38
  #include "ma.h"
36
39
  #include "ma_overlay.h"
37
40
  #include "macd.h"
@@ -42,6 +45,7 @@
42
45
  #include "rsi_pane.h"
43
46
  #include "style_inherit.h"
44
47
  #include "tip_anchor.h"
48
+ #include "tip_geometry.h"
45
49
  #include "tip_pulse.h"
46
50
  #include "volume.h"
47
51
  #include "vwap.h"
@@ -67,13 +71,30 @@ vroom::Layout VroomChart::layout() const {
67
71
  (rsi.enabled ? 1 : 0) + (macd.enabled ? 1 : 0) + (atr.enabled ? 1 : 0);
68
72
  const float indicator_h = static_cast<float>(pane_count) * height_px *
69
73
  theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
74
+
75
+ // The line chart's tip dot is anchored to the newest close and overhangs the
76
+ // plot's right edge by its own radius, so the gutter has to be at least that
77
+ // wide or the dot lands under the y-axis strip (see tip_geometry.h). Only
78
+ // line mode needs it, and interpolating on morph_fade rather than switching
79
+ // on chart_type lets the few pixels of reflow ride the candles→line
80
+ // crossfade instead of stepping the instant the mode changes.
81
+ const float base_pad = theme.floats[VROOM_FLOAT_RIGHT_PADDING_PX];
82
+ float right_pad = base_pad;
83
+ if (theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
84
+ const float wanted = std::max(
85
+ base_pad, vroom::tip_geometry::gutter_px(
86
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX]));
87
+ right_pad = base_pad + (wanted - base_pad) *
88
+ std::clamp(morph_fade, 0.f, 1.f);
89
+ }
90
+
70
91
  return vroom::Layout{
71
92
  width_px,
72
93
  height_px,
73
94
  vroom::axis_extent(axis_w, y_axis_collapse_t),
74
95
  vroom::axis_extent(theme.floats[VROOM_FLOAT_X_AXIS_HEIGHT_PX],
75
96
  x_axis_collapse_t),
76
- theme.floats[VROOM_FLOAT_RIGHT_PADDING_PX],
97
+ right_pad,
77
98
  theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO],
78
99
  0.05f,
79
100
  0.05f,
@@ -544,6 +565,23 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
544
565
  bg.setColor(theme.colors[VROOM_COLOR_BACKGROUND]);
545
566
  canvas->drawRect(SkRect::MakeWH(width_px, height_px), bg);
546
567
 
568
+ // 1b. Loading line, in place of the scene (stages 1 and 2). Above the
569
+ // `candles.empty()` return because `loading` is authoritative: the host
570
+ // may still be holding the previous asset's bars while the new one
571
+ // loads, and the line has to win over them. Returning here is also what
572
+ // suppresses the axis text, price badge, crosshair and indicator panes
573
+ // until stage 3 releases the flag.
574
+ if (loading) {
575
+ if (loading_fade_in < 1.f) {
576
+ canvas->saveLayerAlphaf(nullptr, loading_fade_in);
577
+ vroom::loading_line::draw(canvas, *this, lay);
578
+ canvas->restore();
579
+ } else {
580
+ vroom::loading_line::draw(canvas, *this, lay);
581
+ }
582
+ return;
583
+ }
584
+
547
585
  if (candles.empty()) return;
548
586
 
549
587
  // 2. Visible slice + bounds + window_ms + geometry
@@ -562,6 +600,10 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
562
600
  const float candle_area_h = vroom::price_pane_bottom(lay);
563
601
  const float candle_right =
564
602
  width_px - lay.y_axis_width_px - lay.right_padding_px;
603
+ // The line tip is the one plot layer allowed past candle_right: its dot
604
+ // overhangs the newest candle's center, which the pinned-to-latest framing
605
+ // puts within a few pixels of that edge. It stops at the y-axis strip.
606
+ const float tip_clip_right = width_px - lay.y_axis_width_px;
565
607
 
566
608
  // 3. Update label fade state ONCE per frame — both gridlines and labels
567
609
  // share these opacities so their animations stay in lockstep.
@@ -606,6 +648,15 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
606
648
  const std::size_t morph_n_draw = reshape ? morph_n : 0;
607
649
  const float morph_t = reshape ? interval_morph_t : 1.f;
608
650
 
651
+ // Line-mode tip marker, resolved by whichever branch below draws the price
652
+ // series and painted at step 6.5. It is the only plot layer that has to
653
+ // outlive the axis backgrounds, so it can't be drawn in place like the rest.
654
+ float tip_opacity = 0.f;
655
+ std::size_t tip_slots = 0;
656
+ const vroom::CandleSnapshot* tip_from = nullptr;
657
+ std::size_t tip_from_n = 0;
658
+ float tip_morph_t = 1.f;
659
+
609
660
  if (fade_out) {
610
661
  // First half: only what was captured, at the axis envelope opacity.
611
662
  // Layers with no snapshot (volume, liquidity, FVG, drawings) would
@@ -653,17 +704,11 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
653
704
  theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
654
705
  morph_from.data(), morph_n, 0.f,
655
706
  theme.floats[VROOM_FLOAT_LINE_TENSION]);
656
- if (theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
657
- vroom::ma_overlay::draw_close_tip(
658
- canvas, lay, bounds, visible, 0, window_ms,
659
- visible_start_ms, candle_duration_ms, candle_right,
660
- candle_area_h, theme.colors[VROOM_COLOR_LINE],
661
- theme.colors[VROOM_COLOR_BACKGROUND],
662
- theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade * layer,
663
- theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
664
- tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
665
- morph_from.data(), morph_n, 0.f);
666
- }
707
+ tip_opacity = fade * layer;
708
+ tip_slots = 0;
709
+ tip_from = morph_from.data();
710
+ tip_from_n = morph_n;
711
+ tip_morph_t = 0.f;
667
712
  }
668
713
 
669
714
  // Over the candles, and the panes below them. One layer for both:
@@ -702,9 +747,19 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
702
747
  }
703
748
 
704
749
  // 4.5. Volume bars — drawn under the candles so candles z-index above.
705
- if (volume_collapse_t < 1.f) {
750
+ // Through a loading reveal they grow up off the axis on the
751
+ // same clock as the candles, borrowing the collapse they
752
+ // already have. They carry no morph snapshot of their own, so
753
+ // without this they'd snap in at full height while the price
754
+ // series is still a thread lying on the line. A render-time
755
+ // override, not a state change: the host still owns the
756
+ // collapse for the volume toggle.
757
+ const float vol_t = loading_line_revealing
758
+ ? std::max(volume_collapse_t, 1.f - interval_morph_t)
759
+ : volume_collapse_t;
760
+ if (vol_t < 1.f) {
706
761
  vroom::volume::draw(canvas, visible, n, lay, theme, volume,
707
- volume_collapse_t, volume_collapse_easing,
762
+ vol_t, volume_collapse_easing,
708
763
  window_ms, visible_start_ms,
709
764
  candle_duration_ms);
710
765
  }
@@ -753,20 +808,16 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
753
808
  vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
754
809
  candle_area_h);
755
810
 
756
- // 5.8. Line-mode tip marker.
757
- if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
811
+ // 5.8. Line-mode tip marker. The saveLayer this branch may be
812
+ // wrapped in doesn't reach step 6.5, so fold its alpha in.
813
+ if (fade > 0.f) {
758
814
  const auto anchor = vroom::tip_anchor::at(
759
815
  range.start, range.end, candles.size(), reshape);
760
- vroom::ma_overlay::draw_close_tip(
761
- canvas, lay, bounds, visible, anchor.slot_count, window_ms,
762
- visible_start_ms, candle_duration_ms, candle_right,
763
- candle_area_h, theme.colors[VROOM_COLOR_LINE],
764
- theme.colors[VROOM_COLOR_BACKGROUND],
765
- theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
766
- theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
767
- tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
768
- anchor.use_morph ? morph_src : nullptr,
769
- anchor.use_morph ? morph_n_draw : 0, morph_t);
816
+ tip_opacity = fade * (wrap ? axis_phase.opacity : 1.f);
817
+ tip_slots = anchor.slot_count;
818
+ tip_from = anchor.use_morph ? morph_src : nullptr;
819
+ tip_from_n = anchor.use_morph ? morph_n_draw : 0;
820
+ tip_morph_t = morph_t;
770
821
  }
771
822
 
772
823
  // Incoming fade: indicator panes share the scene opacity (and
@@ -782,6 +833,15 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
782
833
  if (wrap) canvas->restore();
783
834
  }
784
835
 
836
+ // 5.9. Loading line, stage 3. Over the series it handed off to, on the same
837
+ // clock, so it reads as the bars peeling away from the line rather than
838
+ // two things dissolving independently. Before the axis masks below,
839
+ // since it belongs inside the plot.
840
+ if (loading_line_revealing) {
841
+ vroom::loading_line::draw_fading(canvas, *this, lay,
842
+ 1.f - interval_morph_t);
843
+ }
844
+
785
845
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
786
846
  // line is intentionally omitted for now. The bottom strip anchors at
787
847
  // x_axis_top (below any indicator pane) so it never paints over it.
@@ -796,6 +856,24 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
796
856
  SkRect::MakeXYWH(candle_right, 0, axis_block_w, height_px),
797
857
  axis_bg);
798
858
 
859
+ // 6.5. Line-mode tip marker, resolved at 5.8. After the masks because the
860
+ // dot sits on the newest close, which a view following the latest bar
861
+ // parks within a few pixels of candle_right — closer than the dot's own
862
+ // radius — so it paints into the gutter the layout widened for it. The
863
+ // clip still stops at the y-axis strip, so nothing here can reach the
864
+ // price labels; the pulse ring simply ends there.
865
+ if (tip_opacity > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
866
+ vroom::ma_overlay::draw_close_tip(
867
+ canvas, lay, bounds, visible, tip_slots, window_ms,
868
+ visible_start_ms, candle_duration_ms, candle_right, tip_clip_right,
869
+ candle_area_h, theme.colors[VROOM_COLOR_LINE],
870
+ theme.colors[VROOM_COLOR_BACKGROUND],
871
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], tip_opacity,
872
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
873
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds, tip_from,
874
+ tip_from_n, tip_morph_t);
875
+ }
876
+
799
877
  // 7. Labels (read from y_fades / x_fades, no state mutation here)
800
878
  vroom::labels::draw_y_labels(canvas, *this, lay, axis_bounds);
801
879
  vroom::labels::draw_x_labels(canvas, *this, lay, axis_start_ms, axis_end_ms);
@@ -805,8 +883,19 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
805
883
  // Hidden during a fade's outgoing half: the close is already the new
806
884
  // series and there is no snapshot to fade.
807
885
  if (!fade_out) {
808
- vroom::price_indicator::draw(canvas, *this, lay, bounds,
809
- candle_right, candle_area_h);
886
+ // Eased off the same capture the candles reshape through, so the badge
887
+ // rides the bar's close edge instead of landing on the new price a
888
+ // whole animation early. The pairing rule is the tip marker's: the
889
+ // capture only stands for the newest candle while that candle is the
890
+ // one at the right edge.
891
+ const auto price_anchor = vroom::tip_anchor::at(
892
+ range.start, range.end, candles.size(), reshape);
893
+ const vroom::CandleSnapshot* price_from =
894
+ (price_anchor.use_morph && morph_src && morph_n_draw > 0)
895
+ ? morph_src
896
+ : nullptr;
897
+ vroom::price_indicator::draw(canvas, *this, lay, bounds, candle_right,
898
+ candle_area_h, price_from, morph_t);
810
899
 
811
900
  // 7.54. Fair Value Gap labels — the boxes themselves are back behind
812
901
  // the candles, but their text has to clear the bars it sits over.
@@ -882,6 +971,182 @@ void VroomChart::begin_frame() {
882
971
  // and a chart left open for hours would otherwise lose float precision on it.
883
972
  tip_pulse_elapsed_s =
884
973
  std::fmod(tip_pulse_elapsed_s + dt, vroom::tip_pulse::kPeriodSeconds);
974
+
975
+ if (loading) {
976
+ // Frozen once a capture exists: stage 2 morphs away from the phase the
977
+ // sine held when the data landed, and advancing it underneath would
978
+ // drag the morph's own start point around.
979
+ if (loading_animate && loading_line.empty()) {
980
+ loading_elapsed_s = std::fmod(loading_elapsed_s + dt,
981
+ vroom::loading_wave::kPeriodSeconds);
982
+ }
983
+ // Ramps regardless of `loading_animate`: reduced motion suppresses the
984
+ // wave's movement, not the appearance of the line itself, which would
985
+ // otherwise pop in at full strength.
986
+ constexpr float kFadeInSeconds = 0.3f;
987
+ loading_fade_in = std::min(1.f, loading_fade_in + dt / kFadeInSeconds);
988
+ }
989
+ }
990
+
991
+ void VroomChart::set_loading(bool on, bool animate) {
992
+ loading_animate = animate;
993
+ if (on == loading) return; // a re-push of the same state isn't a restart
994
+ loading = on;
995
+
996
+ // Either direction abandons any hand-off in flight. Going false without
997
+ // passing through the two stages is the cancel path (fetch failed, asset
998
+ // switched again mid-load), and it should leave nothing behind.
999
+ loading_line.clear();
1000
+ loading_line_t = 1.f;
1001
+ loading_line_revealing = false;
1002
+ if (!on) {
1003
+ mark_dirty();
1004
+ return;
1005
+ }
1006
+
1007
+ loading_elapsed_s = 0.f;
1008
+ loading_fade_in = 0.f;
1009
+ mark_dirty();
1010
+ }
1011
+
1012
+ // Visible slice + price bounds the two hand-off stages both need. Mirrors what
1013
+ // draw_chart computes in step 2; `n == 0` means there is nothing to hand off to.
1014
+ VroomChart::LoadingTarget VroomChart::loading_target() const {
1015
+ LoadingTarget t{};
1016
+ if (candles.empty()) return t;
1017
+ const auto range = vroom::visible_indices(candles.data(), candles.size(),
1018
+ visible_start_ms, visible_end_ms);
1019
+ const std::size_t n = range.end - range.start;
1020
+ if (n == 0) return t;
1021
+ t.visible = candles.data() + range.start;
1022
+ t.n = n;
1023
+ t.bounds = price_bounds_manual
1024
+ ? price_bounds
1025
+ : vroom::auto_price_bounds(t.visible, n);
1026
+ return t;
1027
+ }
1028
+
1029
+ void VroomChart::begin_loading_morph() {
1030
+ if (!loading) return;
1031
+
1032
+ const auto lay = layout();
1033
+ const float area_w = vroom::candle_area_width(lay);
1034
+ const auto target = loading_target();
1035
+ if (target.n == 0 || area_w <= 0.f) return;
1036
+
1037
+ const int64_t window_ms = visible_end_ms - visible_start_ms;
1038
+ const float elapsed = loading_animate ? loading_elapsed_s : 0.f;
1039
+
1040
+ const auto push = [&](float xf, float to_y) {
1041
+ vroom::LinePoint p{};
1042
+ p.x = xf;
1043
+ // Sampling the sine at this vertex's own x is what makes the freeze
1044
+ // invisible: the curve the user was watching passes through this point
1045
+ // already, so stage 2 starts on the pixels stage 1 ended on.
1046
+ p.from_y = vroom::loading_wave::y_frac(elapsed,
1047
+ std::clamp(xf, 0.f, 1.f));
1048
+ p.to_y = to_y;
1049
+ loading_line.push_back(p);
1050
+ };
1051
+ // Mid-range, not mid-body: the bars grow outward symmetrically from this
1052
+ // line, so anchoring on the body would leave one wick reaching further
1053
+ // than the other.
1054
+ const auto center_frac = [&](const ::VroomCandle& c) {
1055
+ return static_cast<float>(
1056
+ vroom::price_fraction(target.bounds, (c.high + c.low) * 0.5));
1057
+ };
1058
+ const auto center_x_frac = [&](const ::VroomCandle& c) {
1059
+ return vroom::candle_center_x(lay, c.time_ms, candle_duration_ms,
1060
+ visible_start_ms, window_ms) / area_w;
1061
+ };
1062
+
1063
+ // Left to right, the order a polyline wants. (The reveal below indexes from
1064
+ // the right instead, because that's what the morph machinery expects.)
1065
+ loading_line.clear();
1066
+ loading_line.reserve(target.n + 2);
1067
+
1068
+ // Spans the data doesn't reach — a short series in a wider window — are
1069
+ // sampled at the idle curve's own resolution and flattened to the nearest
1070
+ // candle's level. So the line still begins as the curve across the *whole*
1071
+ // plot and eases into a level run leading into the series; leaving those
1072
+ // spans out instead would snap the line's length on the first frame.
1073
+ const int across = vroom::loading_wave::sample_count(area_w);
1074
+ const float step = 1.f / static_cast<float>(across);
1075
+ const float first_xf = center_x_frac(target.visible[0]);
1076
+ const float last_xf = center_x_frac(target.visible[target.n - 1]);
1077
+
1078
+ for (int i = 0; static_cast<float>(i) * step < first_xf; ++i) {
1079
+ push(static_cast<float>(i) * step, center_frac(target.visible[0]));
1080
+ }
1081
+ for (std::size_t i = 0; i < target.n; ++i) {
1082
+ push(center_x_frac(target.visible[i]), center_frac(target.visible[i]));
1083
+ }
1084
+ for (int i = static_cast<int>(std::ceil(last_xf / step)); i <= across; ++i) {
1085
+ push(static_cast<float>(i) * step,
1086
+ center_frac(target.visible[target.n - 1]));
1087
+ }
1088
+
1089
+ loading_line_t = 0.f;
1090
+ // `loading` stays set: the axes, price badge and panes have no business
1091
+ // appearing until the candles themselves do, in stage 3.
1092
+ mark_dirty();
1093
+ }
1094
+
1095
+ void VroomChart::begin_loading_reveal() {
1096
+ if (!loading) return;
1097
+ loading = false;
1098
+
1099
+ const auto lay = layout();
1100
+ const float area_w = vroom::candle_area_width(lay);
1101
+ const auto target = loading_target();
1102
+ if (target.n == 0 || area_w <= 0.f) {
1103
+ loading_line.clear();
1104
+ mark_dirty();
1105
+ return;
1106
+ }
1107
+
1108
+ const int64_t window_ms = visible_end_ms - visible_start_ms;
1109
+
1110
+ // Right-to-left, matching how the morph machinery indexes slots (slot 0 =
1111
+ // newest bar at the right edge).
1112
+ morph_from.clear();
1113
+ morph_from.reserve(target.n);
1114
+ for (std::size_t slot = 0; slot < target.n; ++slot) {
1115
+ const ::VroomCandle& c = target.visible[target.n - 1 - slot];
1116
+ const float center = static_cast<float>(
1117
+ vroom::price_fraction(target.bounds, (c.high + c.low) * 0.5));
1118
+
1119
+ vroom::CandleSnapshot s{};
1120
+ s.x = vroom::candle_center_x(lay, c.time_ms, candle_duration_ms,
1121
+ visible_start_ms, window_ms) / area_w;
1122
+ // Every price collapsed onto the centre, so the slot starts as a
1123
+ // zero-height sliver sitting on the line and the existing lerp is what
1124
+ // grows it out to full open/high/low/close.
1125
+ s.open = s.high = s.low = s.close = center;
1126
+ s.bull = c.close >= c.open;
1127
+ // Starts the colour blend from fully transparent rather than from the
1128
+ // skeleton grey: the bar emerges from the line, so a grey body would
1129
+ // just smear the line thicker on the first frames.
1130
+ s.skeleton = true;
1131
+ s.skeleton_alpha = 0.f;
1132
+ morph_from.push_back(s);
1133
+ }
1134
+
1135
+ morph_from_bounds = target.bounds;
1136
+ interval_morph_t = 0.f;
1137
+ // Slot-lerp, not crossfade: each bar has a counterpart to grow out of, so
1138
+ // pairing column to column is what makes the line *become* the data instead
1139
+ // of one scene dissolving into another.
1140
+ interval_morph_fade = false;
1141
+ // Keeps the axes out of the interval envelope (see labels::interval_phase),
1142
+ // which is what the flag actually selects. A timeframe switch runs the
1143
+ // outgoing ticks out and the new ones in; here there are no outgoing ticks
1144
+ // — stages 1 and 2 draw no axis at all — so that envelope would hold the
1145
+ // real labels back for the first half of the reveal. Per-label fades bring
1146
+ // them up from nothing instead, in step with the bars.
1147
+ morph_is_stream = true;
1148
+ loading_line_revealing = true;
1149
+ mark_dirty();
885
1150
  }
886
1151
 
887
1152
  bool VroomChart::tip_pulse_active() const {
@@ -902,6 +1167,10 @@ bool VroomChart::is_animating_now() const {
902
1167
  // The pulse never finishes on its own, so this is what keeps the host loops
903
1168
  // requeueing frames for it.
904
1169
  if (tip_pulse_active()) return true;
1170
+ // Same deal for the loading line's wave, except a still line (reduced
1171
+ // motion, once faded in) has nothing left to redraw and may go idle. The
1172
+ // two hand-off stages are host-driven, so they need nothing here.
1173
+ if (loading && (loading_animate || loading_fade_in < 1.f)) return true;
905
1174
  for (const auto& f : y_fades) {
906
1175
  if (f.opacity != f.target) return true;
907
1176
  }
@@ -92,6 +92,42 @@ struct VroomChart {
92
92
  // is_animating_now keeps the host's redraw loop alive (see tip_pulse.h).
93
93
  float tip_pulse_elapsed_s = 0.f;
94
94
 
95
+ // --- loading line -------------------------------------------------------
96
+ // See loading_line.h for the three stages this state drives.
97
+ //
98
+ // Authoritative, not inferred: the host tells us it's loading *and* that it
99
+ // has no data, because an empty `candles` alone can't be trusted here. The
100
+ // hosts' data effects skip pushing an empty array (it would read as "hold
101
+ // the last frame"), so a chart mid-asset-switch can be loading while still
102
+ // holding the previous asset's bars — inferring from emptiness would show
103
+ // that stale series as if it were the new one's.
104
+ //
105
+ // True for stages 1 and 2 both: the line stands in for the chart until the
106
+ // candles actually take over in stage 3.
107
+ bool loading = false;
108
+ // Whether the wave travels. Cleared for reduced motion, which both freezes
109
+ // the line at phase 0 and keeps is_animating_now from pinning a host loop.
110
+ bool loading_animate = true;
111
+ // Phase of the wave, in seconds, advanced by begin_frame like the tip pulse
112
+ // above and wrapped for the same reason.
113
+ float loading_elapsed_s = 0.f;
114
+ // Fades the line in on arrival, so a chart that resolves instantly from
115
+ // cache doesn't flash a placeholder.
116
+ float loading_fade_in = 0.f;
117
+
118
+ // The line's morph vertices, left to right, captured when the data lands.
119
+ // Empty means stage 1 — nothing to morph toward yet — which is what
120
+ // loading_line::draw switches on.
121
+ std::vector<vroom::LinePoint> loading_line;
122
+ // Stage 2's progress, 0 = the frozen sine, 1 = through the candle centres.
123
+ // Parked at 1 when no morph is running.
124
+ float loading_line_t = 1.f;
125
+ // Set for stage 3, where the line rides `interval_morph_t` out on the same
126
+ // clock as the candles growing out of it. A flag rather than its own float
127
+ // because the two have to finish together to avoid a line left hanging over
128
+ // a settled chart.
129
+ bool loading_line_revealing = false;
130
+
95
131
  // Interval morph: the outgoing candle geometry captured when a timeframe
96
132
  // switch begins, indexed from the right of the visible slice (slot 0 =
97
133
  // newest). `interval_morph_fade` is the host's choice at capture time:
@@ -508,6 +544,38 @@ struct VroomChart {
508
544
  // draw_chart.
509
545
  void begin_frame();
510
546
 
547
+ // Enters or leaves the loading line. `animate` false pins it still for
548
+ // reduced motion. Entering restarts the phase and the fade-in; leaving
549
+ // outright (rather than through the two calls below) drops the line with no
550
+ // hand-off, which is what an error or a cancelled fetch wants.
551
+ void set_loading(bool on, bool animate);
552
+
553
+ // Stage 2. Call *after* pushing the real candles: it needs them to know
554
+ // where the line is heading. Freezes the sine at its current phase and
555
+ // pairs each vertex with the vertical centre of the candle that will occupy
556
+ // that column, then hands `loading_line_t` to the host to drive 0 → 1.
557
+ //
558
+ // Freezing rather than letting the wave run underneath is deliberate — a
559
+ // moving source makes the morph read as two animations fighting instead of
560
+ // one shape resolving.
561
+ void begin_loading_morph();
562
+
563
+ // The visible slice and price bounds the hand-off aims at, as draw_chart
564
+ // would resolve them. `n == 0` when there is nothing to hand off to.
565
+ struct LoadingTarget {
566
+ const ::VroomCandle* visible = nullptr;
567
+ std::size_t n = 0;
568
+ vroom::PriceBounds bounds{};
569
+ };
570
+ LoadingTarget loading_target() const;
571
+
572
+ // Stage 3. Captures every candle collapsed onto its own vertical centre at
573
+ // zero alpha and hands it to the interval-morph machinery, so the existing
574
+ // slot lerp grows each bar outward from the line while candles::draw blends
575
+ // its colour up out of nothing. Also releases `loading`, letting the axes,
576
+ // price badge and panes come back.
577
+ void begin_loading_reveal();
578
+
511
579
  // True when the line tip's pulse ring is on screen and looping. Gated on
512
580
  // line mode and on having data, so a chart that isn't showing the ring can
513
581
  // still go idle.
@@ -517,7 +585,8 @@ struct VroomChart {
517
585
  // SkPictureRecorder.
518
586
  void rebuild_chart_picture();
519
587
 
520
- // True if any axis label is mid-fade, or the tip pulse is running. Used by
521
- // the JS-side animation loop to know when to keep ticking.
588
+ // True if any axis label is mid-fade, the tip pulse is running, or the
589
+ // loading skeleton is waving. Used by the JS-side animation loop to know
590
+ // when to keep ticking.
522
591
  bool is_animating_now() const;
523
592
  };
@@ -497,6 +497,28 @@ extern "C" void vroom_chart_begin_stream_morph(VroomChart* chart) {
497
497
  chart->interval_morph_t = 0.f;
498
498
  }
499
499
 
500
+ extern "C" void vroom_chart_set_loading(VroomChart* chart, int32_t on,
501
+ int32_t animate) {
502
+ if (!chart) return;
503
+ chart->set_loading(on != 0, animate != 0);
504
+ }
505
+
506
+ extern "C" void vroom_chart_begin_loading_morph(VroomChart* chart) {
507
+ if (!chart) return;
508
+ chart->begin_loading_morph();
509
+ }
510
+
511
+ extern "C" void vroom_chart_set_loading_morph(VroomChart* chart, float t) {
512
+ if (!chart) return;
513
+ chart->loading_line_t = std::clamp(t, 0.f, 1.f);
514
+ chart->mark_dirty();
515
+ }
516
+
517
+ extern "C" void vroom_chart_begin_loading_reveal(VroomChart* chart) {
518
+ if (!chart) return;
519
+ chart->begin_loading_reveal();
520
+ }
521
+
500
522
  extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
501
523
  if (!chart) return;
502
524
  chart->interval_morph_t = std::clamp(t, 0.f, 1.f);
@@ -507,6 +529,11 @@ extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
507
529
  chart->morph_lines.shrink_to_fit();
508
530
  chart->interval_morph_fade = false;
509
531
  chart->morph_is_stream = false;
532
+ // The loading line rides this same clock out (see draw_chart 5.9), so
533
+ // it has to be released here too or it would hang over a settled chart.
534
+ chart->loading_line_revealing = false;
535
+ chart->loading_line.clear();
536
+ chart->loading_line.shrink_to_fit();
510
537
  }
511
538
  chart->mark_dirty();
512
539
  }
@@ -0,0 +1,28 @@
1
+ // Blending two packed ARGB colors.
2
+ //
3
+ // Shared because anything anchored to the newest candle has to cross-fade when
4
+ // that candle changes direction mid-morph: the body itself (candles.cpp) and
5
+ // the current-price indicator (price_indicator.cpp) both flip between the bull
6
+ // and bear accents, and a snap in either while the other eases is visible.
7
+ //
8
+ // Skia-free so the pure translation units can use it.
9
+
10
+ #pragma once
11
+
12
+ #include <cstdint>
13
+
14
+ namespace vroom {
15
+
16
+ // Channel-wise, including alpha. `t` is expected in [0,1].
17
+ inline uint32_t lerp_argb(uint32_t a, uint32_t b, float t) {
18
+ uint32_t out = 0;
19
+ for (int shift = 0; shift < 32; shift += 8) {
20
+ const float ca = static_cast<float>((a >> shift) & 0xFFu);
21
+ const float cb = static_cast<float>((b >> shift) & 0xFFu);
22
+ const auto v = static_cast<uint32_t>(ca + (cb - ca) * t + 0.5f);
23
+ out |= v << shift;
24
+ }
25
+ return out;
26
+ }
27
+
28
+ } // namespace vroom