react-native-vroom-chart 0.16.0 → 0.17.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.
@@ -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
@@ -0,0 +1,183 @@
1
+ #include "loading_line.h"
2
+
3
+ #pragma clang diagnostic push
4
+ #pragma clang diagnostic ignored "-Wdocumentation"
5
+ #include "include/core/SkCanvas.h"
6
+ #include "include/core/SkColor.h"
7
+ #include "include/core/SkPaint.h"
8
+ #include "include/core/SkPath.h"
9
+ #include "include/core/SkPathBuilder.h"
10
+ #include "include/core/SkPoint.h"
11
+ #pragma clang diagnostic pop
12
+
13
+ #include <algorithm>
14
+ #include <cstddef>
15
+ #include <vector>
16
+
17
+ #include "chart.h"
18
+ #include "curve.h"
19
+ #include "loading_wave.h"
20
+ #include "theme.h"
21
+ #include "viewport.h"
22
+
23
+ namespace vroom::loading_line {
24
+
25
+ namespace {
26
+
27
+ constexpr float kStrokeWidthPx = 2.f;
28
+
29
+ // Multiplies into whatever alpha the resolved color already carries.
30
+ SkColor faded(SkColor c, float a) {
31
+ const float scaled = static_cast<float>(SkColorGetA(c)) *
32
+ std::clamp(a, 0.f, 1.f);
33
+ return SkColorSetA(c, static_cast<U8CPU>(scaled + 0.5f));
34
+ }
35
+
36
+ // The loading line's color. A transparent `skeleton` inherits the gridline
37
+ // color, the same sentinel convention the candle border and wick colors use.
38
+ // The gridlines are the chart's existing vocabulary for "structure, not data",
39
+ // which is exactly what the line is: drawing it in that tone keeps it from
40
+ // reading as a series, without needing to be scaled down to stay quiet.
41
+ SkColor stroke_color(const VroomChart& chart) {
42
+ const SkColor c = chart.theme.colors[VROOM_COLOR_SKELETON];
43
+ return SkColorGetA(c) == 0 ? chart.theme.colors[VROOM_COLOR_GRID] : c;
44
+ }
45
+
46
+ // Emits `pts` as a monotone cubic — the same curve the line chart draws (see
47
+ // curve.h), so the loading line and the series it hands off to are shaped by
48
+ // the same math and the transition has no renderer seam to hide.
49
+ //
50
+ // Monotone rather than a plain spline matters even here: by the end of the
51
+ // morph these vertices *are* real price data, and an unconstrained curve would
52
+ // overshoot a sharp reversal and bow outside the candle it is supposed to pass
53
+ // through.
54
+ SkPath spline(const std::vector<SkPoint>& pts) {
55
+ SkPathBuilder path;
56
+ if (pts.empty()) return path.detach();
57
+ path.moveTo(pts[0]);
58
+ // Below three points there is no interior vertex to fit a tangent to.
59
+ if (pts.size() < 3) {
60
+ for (std::size_t i = 1; i < pts.size(); ++i) path.lineTo(pts[i]);
61
+ return path.detach();
62
+ }
63
+
64
+ const auto secant_at = [&](std::size_t i) {
65
+ return curve::secant(pts[i].fX, pts[i].fY,
66
+ pts[i + 1].fX, pts[i + 1].fY);
67
+ };
68
+ const auto tangent_at = [&](std::size_t i) {
69
+ if (i == 0) return secant_at(0);
70
+ if (i + 1 == pts.size()) return secant_at(pts.size() - 2);
71
+ return curve::monotone_tangent(secant_at(i - 1), secant_at(i));
72
+ };
73
+
74
+ for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
75
+ // Flat or backwards spacing has no meaningful tangent, and curving
76
+ // through it would fold the line back on itself.
77
+ if (!(pts[i + 1].fX > pts[i].fX)) {
78
+ path.lineTo(pts[i + 1]);
79
+ continue;
80
+ }
81
+ const curve::Controls c = curve::segment_controls(
82
+ pts[i].fX, pts[i].fY, pts[i + 1].fX, pts[i + 1].fY,
83
+ tangent_at(i), tangent_at(i + 1), 1.f);
84
+ path.cubicTo(c.c1x, c.c1y, c.c2x, c.c2y, pts[i + 1].fX, pts[i + 1].fY);
85
+ }
86
+ return path.detach();
87
+ }
88
+
89
+ void stroke_path(SkCanvas* canvas,
90
+ const VroomChart& chart,
91
+ const SkPath& path,
92
+ float alpha) {
93
+ SkPaint p;
94
+ p.setStyle(SkPaint::kStroke_Style);
95
+ p.setStrokeWidth(kStrokeWidthPx);
96
+ p.setStrokeCap(SkPaint::kRound_Cap);
97
+ p.setStrokeJoin(SkPaint::kRound_Join);
98
+ p.setAntiAlias(true);
99
+ p.setColor(faded(stroke_color(chart), alpha));
100
+ canvas->drawPath(path, p);
101
+ }
102
+
103
+ // Pinned to phase 0 under reduced motion, which leaves a still curve at
104
+ // mid-breath rather than nothing — the placeholder is still worth showing.
105
+ float phase(const VroomChart& chart) {
106
+ return chart.loading_animate ? chart.loading_elapsed_s : 0.f;
107
+ }
108
+
109
+ // Stage 1: the curve alone, sampled across the whole plot.
110
+ SkPath idle_path(const VroomChart& chart, float area_w, float pane_h) {
111
+ const int samples = loading_wave::sample_count(area_w);
112
+ const float elapsed = phase(chart);
113
+ std::vector<SkPoint> pts;
114
+ pts.reserve(static_cast<std::size_t>(samples) + 1);
115
+ for (int i = 0; i <= samples; ++i) {
116
+ const float xf = static_cast<float>(i) / static_cast<float>(samples);
117
+ pts.push_back(SkPoint{xf * area_w,
118
+ loading_wave::y_frac(elapsed, xf) * pane_h});
119
+ }
120
+ return spline(pts);
121
+ }
122
+
123
+ // Stages 2 and 3: the captured vertices, eased from the frozen curve toward the
124
+ // candle centres. `t` of 1 is the shape the candles grow out of.
125
+ SkPath morph_path(const VroomChart& chart,
126
+ const Layout& lay,
127
+ float area_w,
128
+ float pane_h,
129
+ float t) {
130
+ std::vector<SkPoint> pts;
131
+ pts.reserve(chart.loading_line.size());
132
+ for (const LinePoint& pt : chart.loading_line) {
133
+ const float from_y = pt.from_y * pane_h;
134
+ const float to_y = y_at_fraction(lay, pt.to_y);
135
+ pts.push_back(SkPoint{pt.x * area_w, from_y + (to_y - from_y) * t});
136
+ }
137
+ return spline(pts);
138
+ }
139
+
140
+ } // namespace
141
+
142
+ void draw(SkCanvas* canvas, const VroomChart& chart, const Layout& lay) {
143
+ if (!canvas) return;
144
+ const float area_w = candle_area_width(lay);
145
+ const float pane_h = price_pane_bottom(lay);
146
+ if (area_w <= 0.f || pane_h <= 0.f) return;
147
+
148
+ const float b = loading_wave::breath(phase(chart));
149
+
150
+ // A capture present means the data has landed and the line is on its way to
151
+ // the candle centres; absent means nothing has arrived yet.
152
+ if (chart.loading_line.empty()) {
153
+ stroke_path(canvas, chart, idle_path(chart, area_w, pane_h), b);
154
+ return;
155
+ }
156
+ // Settle out of the breath as the line resolves, so it holds still at full
157
+ // strength by the time it reaches the candle centres — subtle at the
158
+ // default gridline tone, but it stops a bright custom color from pulsing
159
+ // while the data lands. Stage 3 then fades from full, where draw_fading
160
+ // picks up.
161
+ const float alpha = b + (1.f - b) * chart.loading_line_t;
162
+ stroke_path(canvas, chart,
163
+ morph_path(chart, lay, area_w, pane_h, chart.loading_line_t),
164
+ alpha);
165
+ }
166
+
167
+ void draw_fading(SkCanvas* canvas,
168
+ const VroomChart& chart,
169
+ const Layout& lay,
170
+ float alpha) {
171
+ if (!canvas || alpha <= 0.f) return;
172
+ if (chart.loading_line.empty()) return;
173
+ const float area_w = candle_area_width(lay);
174
+ const float pane_h = price_pane_bottom(lay);
175
+ if (area_w <= 0.f || pane_h <= 0.f) return;
176
+
177
+ // Held at the morph's end state: the line has already reached the centres,
178
+ // and the candles are growing out of exactly these pixels.
179
+ stroke_path(canvas, chart, morph_path(chart, lay, area_w, pane_h, 1.f),
180
+ alpha);
181
+ }
182
+
183
+ } // namespace vroom::loading_line
@@ -0,0 +1,38 @@
1
+ // The loading line: a single stroke across the plot standing in for a series
2
+ // that hasn't arrived yet, and the bridge the real candles grow out of.
3
+ //
4
+ // Three stages, all drawn by the one function here:
5
+ //
6
+ // 1. idle — a travelling sine across the full width (see loading_wave.h)
7
+ // 2. morph — that sine, frozen, easing into a polyline through the vertical
8
+ // centre of every candle about to be drawn
9
+ // 3. reveal — the polyline holding still at those centres while it fades
10
+ // out and the candles grow outward from it
11
+ //
12
+ // Stages 1 and 2 run instead of the chart, so the axis text, price badge,
13
+ // crosshair and indicator panes stay suppressed until the data is really
14
+ // there. Stage 3 runs over the top of the normal scene.
15
+
16
+ #pragma once
17
+
18
+ class SkCanvas;
19
+ struct VroomChart;
20
+
21
+ namespace vroom {
22
+ struct Layout;
23
+ }
24
+
25
+ namespace vroom::loading_line {
26
+
27
+ // Stages 1 and 2, in place of the chart. Which one it draws depends on whether
28
+ // the chart holds a capture (see VroomChart::begin_loading_morph).
29
+ void draw(SkCanvas* canvas, const VroomChart& chart, const Layout& lay);
30
+
31
+ // Stage 3, over the settled scene. `alpha` is the line's remaining opacity, so
32
+ // the caller can ride it on the same clock as the candles' growth.
33
+ void draw_fading(SkCanvas* canvas,
34
+ const VroomChart& chart,
35
+ const Layout& lay,
36
+ float alpha);
37
+
38
+ } // namespace vroom::loading_line
@@ -0,0 +1,140 @@
1
+ // The idle animation the loading line rides while a series is being fetched.
2
+ // A position across the plot and an elapsed time in; a vertical offset and a
3
+ // brightness out.
4
+ //
5
+ // Three sines, not one. A single sine is too regular to read as anything but a
6
+ // test pattern — the eye finds the repeat immediately and the line stops
7
+ // looking like it's waiting for something. Summing three frequencies that
8
+ // aren't multiples of each other, each drifting at its own rate, gives a curve
9
+ // that keeps rearranging itself.
10
+ //
11
+ // The other half of the effect is restraint: a small amplitude and a faint,
12
+ // slowly breathing opacity. The line is a hint that the chart is alive, not a
13
+ // feature competing with the data about to replace it.
14
+ //
15
+ // Approach adapted from Liveline (github.com/benjitaylor/liveline, MIT), whose
16
+ // loading state solves the same problem well.
17
+ //
18
+ // Skia-free and header-only so the unit tests can cover it; see
19
+ // tests/test_loading_wave.cpp.
20
+
21
+ #pragma once
22
+
23
+ #include <cmath>
24
+
25
+ namespace vroom::loading_wave {
26
+
27
+ constexpr float kTwoPi = 6.283185307179586f;
28
+
29
+ // One component of the curve.
30
+ struct Harmonic {
31
+ float freq; // radians across the full plot width
32
+ float weight; // share of the amplitude
33
+ float drift; // multiple of the base phase speed
34
+ };
35
+
36
+ // Frequencies deliberately not integer multiples of one another: harmonics
37
+ // would lock into one repeating shape, which is the thing being avoided. The
38
+ // drifts are all multiples of a quarter only so the whole curve has an exact
39
+ // period to wrap the phase clock on — see kPeriodSeconds.
40
+ //
41
+ // Four components, weighted toward the middle of the range, because the curve
42
+ // has to sit convincingly next to candlesticks. A gentler two-or-three-crest
43
+ // undulation suits a smooth live line chart, but against minute-resolution
44
+ // price action it reads as decorative, and it makes the hand-off a real change
45
+ // of shape rather than the same curve coming into focus.
46
+ constexpr Harmonic kHarmonics[] = {
47
+ {14.0f, 0.40f, 1.00f}, // carrier — a little over two crests per screen
48
+ {25.0f, 0.28f, 1.25f}, // ripple, running ahead
49
+ {45.0f, 0.16f, 1.50f}, // fine texture, ahead again
50
+ {6.0f, 0.16f, 0.75f}, // a slow swell, running behind
51
+ };
52
+ constexpr int kHarmonicCount = 4;
53
+
54
+ // Base phase speed. Slow on purpose: this plays under a chart that has nothing
55
+ // to say yet, and anything quicker reads as activity.
56
+ constexpr float kDriftRadPerSecond = 1.0f;
57
+
58
+ // One full cycle of the *whole* curve. The drifts differ by quarters, so every
59
+ // component returns to its starting phase only after four base cycles — which
60
+ // is also why the shape takes ~25s to come back around. Callers wrap elapsed
61
+ // time by this so a chart left loading doesn't lose float precision on the
62
+ // phase, and wrapping here is seamless because it is a true period.
63
+ constexpr float kPeriodSeconds = 4.f * kTwoPi / kDriftRadPerSecond;
64
+
65
+ // Peak displacement from the centreline, as a fraction of the pane height. The
66
+ // components rarely peak together, so the curve typically occupies well under
67
+ // this — it is a bound, not the height it looks.
68
+ constexpr float kAmplitudeFrac = 0.10f;
69
+
70
+ // Breathing opacity. A pulse around full strength, not a dimmer: how quiet the
71
+ // line reads is the *color's* job — it inherits the gridline tone by default —
72
+ // and scaling an already-recessive grey down by a third again would leave the
73
+ // curve indistinguishable from the background.
74
+ constexpr float kBreathMin = 0.75f;
75
+ constexpr float kBreathMax = 1.00f;
76
+ // Ten breaths per curve cycle, so the breath wraps with the phase (~2.5s each)
77
+ // and never syncs with the drift into one combined pulse.
78
+ constexpr float kBreathCyclesPerPeriod = 10.f;
79
+
80
+ // Offset at `x_frac` (0 = left edge of the plot, 1 = right) and `elapsed_s`, in
81
+ // -1 .. 1. The weights sum to 1, so that range is tight. Phase runs as
82
+ // (k*x - w*t), which walks the crests rightward — the direction the series
83
+ // grows. Time outside one period is fine; sin wraps on its own.
84
+ inline float at(float elapsed_s, float x_frac) {
85
+ const float base = kDriftRadPerSecond * elapsed_s;
86
+ float sum = 0.f;
87
+ for (int i = 0; i < kHarmonicCount; ++i) {
88
+ const Harmonic& h = kHarmonics[i];
89
+ sum += h.weight * std::sin(h.freq * x_frac - h.drift * base);
90
+ }
91
+ return sum;
92
+ }
93
+
94
+ // The curve's y as a fraction of the pane height (0 = pane top, 1 = bottom),
95
+ // centred on the pane. This is the form the line capture stores, so a resize
96
+ // mid-morph rescales instead of stranding the curve at old pixels.
97
+ inline float y_frac(float elapsed_s, float x_frac) {
98
+ return 0.5f + kAmplitudeFrac * at(elapsed_s, x_frac);
99
+ }
100
+
101
+ // The line's opacity at `elapsed_s`, in kBreathMin .. kBreathMax.
102
+ inline float breath(float elapsed_s) {
103
+ const float mid = (kBreathMin + kBreathMax) * 0.5f;
104
+ const float half = (kBreathMax - kBreathMin) * 0.5f;
105
+ return mid + half * std::sin(kTwoPi * kBreathCyclesPerPeriod * elapsed_s /
106
+ kPeriodSeconds);
107
+ }
108
+
109
+ // The tightest component in the table above.
110
+ constexpr float max_freq() {
111
+ float m = 0.f;
112
+ for (int i = 0; i < kHarmonicCount; ++i) {
113
+ if (kHarmonics[i].freq > m) m = kHarmonics[i].freq;
114
+ }
115
+ return m;
116
+ }
117
+
118
+ // Vertices per cycle of that component needed to keep it from aliasing into a
119
+ // shape the curve doesn't have. Ten is comfortably past the point where the
120
+ // spline through the samples is indistinguishable from the real curve.
121
+ constexpr float kSamplesPerCycle = 10.f;
122
+
123
+ // How many vertices to sample the curve at across a plot `width_px` wide.
124
+ //
125
+ // The floor comes from the harmonic table rather than a literal, so retuning
126
+ // the curve can't silently undersample it — a narrow chart still gets enough
127
+ // vertices to resolve the finest component, it just spaces them more tightly.
128
+ //
129
+ // Both the idle curve and the morph capture size themselves through here, so
130
+ // the two are always sampled the same way and the hand-off has no shape
131
+ // discontinuity to hide.
132
+ inline int sample_count(float width_px) {
133
+ const int floor_n =
134
+ static_cast<int>(max_freq() / kTwoPi * kSamplesPerCycle) + 1;
135
+ const int n = static_cast<int>(width_px / 8.f);
136
+ const int ceil_n = 4 * floor_n;
137
+ return n < floor_n ? floor_n : (n > ceil_n ? ceil_n : n);
138
+ }
139
+
140
+ } // namespace vroom::loading_wave
@@ -19,6 +19,7 @@
19
19
  #include "curve.h"
20
20
  #include "gradient.h"
21
21
  #include "line_morph.h"
22
+ #include "tip_geometry.h"
22
23
  #include "tip_pulse.h"
23
24
  #include "viewport.h"
24
25
 
@@ -129,10 +130,6 @@ inline std::size_t series_slots(std::size_t n, const LineMorph* from,
129
130
  return std::max(n, vroom::morph_line_count(from, morph_t));
130
131
  }
131
132
 
132
- // Width of the background-colored ring that separates the tip dot from the line
133
- // and from the pulse expanding out behind it.
134
- constexpr float kTipBorderPx = 2.f;
135
-
136
133
  // One vertex of the close polyline, in screen space. Slot `k` counts back from
137
134
  // the right edge (0 = the newest close), the pairing a timeframe switch
138
135
  // preserves.
@@ -419,6 +416,7 @@ void draw_close_tip(SkCanvas* canvas,
419
416
  int64_t visible_start_ms,
420
417
  int64_t candle_duration_ms,
421
418
  float candle_right,
419
+ float clip_right,
422
420
  float candle_area_h,
423
421
  uint32_t line_color,
424
422
  uint32_t bg_color,
@@ -447,14 +445,21 @@ void draw_close_tip(SkCanvas* canvas,
447
445
  if (tip.fY < 0.f || tip.fY > candle_area_h) return;
448
446
  if (tip.fX < 0.f || tip.fX > candle_right) return;
449
447
 
450
- // Scaling off the stroke keeps the marker proportionate at any line width;
451
- // the floor stops a hairline chart from getting an invisible dot.
452
- const float w = line_width > 0.f ? line_width : 1.5f;
453
- const float dot_r = std::max(2.f, w * 1.5f);
454
- const float border_r = dot_r + kTipBorderPx;
448
+ const auto geo = vroom::tip_geometry::of(line_width);
449
+ const float dot_r = geo.dot_r;
450
+ const float border_r = geo.border_r;
455
451
 
456
452
  canvas->save();
457
- canvas->clipRect(SkRect::MakeLTRB(0.f, 0.f, candle_right, candle_area_h));
453
+ // Wider than the on-pane test above: the dot is anchored at the newest
454
+ // candle's center, which on a view pinned to the latest bar is closer to
455
+ // candle_right than the dot's own radius, so it has to reach into the gutter
456
+ // to draw in full. `clip_right` is the gutter's far side — the y-axis
457
+ // strip's edge — which the layout keeps wide enough for the dot (see
458
+ // tip_geometry::gutter_px). The pulse ring is much wider still and clips
459
+ // there; that is intended.
460
+ canvas->clipRect(
461
+ SkRect::MakeLTRB(0.f, 0.f, std::max(candle_right, clip_right),
462
+ candle_area_h));
458
463
 
459
464
  // Ring first: the border paints over its inner edge, so it reads as
460
465
  // expanding out from underneath the dot rather than around it.
@@ -131,6 +131,12 @@ void draw_close_gradient(SkCanvas* canvas,
131
131
  //
132
132
  // `pulse_phase` is in cycles and wraps, so the caller can hand over elapsed time
133
133
  // divided by tip_pulse::kPeriodSeconds. Ignored unless `pulse`.
134
+ //
135
+ // `candle_right` bounds where the marked candle may be for the marker to draw at
136
+ // all; `clip_right` bounds the paint, and is wider — the dot overhangs the plot
137
+ // when the newest candle is at the right edge, so it paints into the gutter the
138
+ // layout reserves for it. Callers must therefore draw the tip *after* the axis
139
+ // backgrounds, which mask that gutter.
134
140
  void draw_close_tip(SkCanvas* canvas,
135
141
  const Layout& lay,
136
142
  const PriceBounds& bounds,
@@ -140,6 +146,7 @@ void draw_close_tip(SkCanvas* canvas,
140
146
  int64_t visible_start_ms,
141
147
  int64_t candle_duration_ms,
142
148
  float candle_right,
149
+ float clip_right,
143
150
  float candle_area_h,
144
151
  uint32_t line_color,
145
152
  uint32_t bg_color,
@@ -17,8 +17,10 @@
17
17
  #include <cstring>
18
18
 
19
19
  #include "chart.h"
20
+ #include "color_lerp.h"
20
21
  #include "fonts.h"
21
22
  #include "price_format.h"
23
+ #include "price_indicator_anim.h"
22
24
  #include "theme.h"
23
25
  #include "ticks.h"
24
26
  #include "viewport.h"
@@ -37,17 +39,29 @@ void draw(SkCanvas* canvas,
37
39
  const Layout& lay,
38
40
  const PriceBounds& bounds,
39
41
  float candle_right,
40
- float candle_area_h) {
42
+ float candle_area_h,
43
+ const vroom::CandleSnapshot* morph_from,
44
+ float morph_t) {
41
45
  if (!canvas || chart.candles.empty()) return;
42
46
 
43
47
  // The "current price" is the latest period's close, regardless of whether
44
- // that candle is horizontally in view.
48
+ // that candle is horizontally in view. Mid-morph it is wherever that close
49
+ // has eased to, so the badge stays on the candle's close edge.
45
50
  const ::VroomCandle& last = chart.candles.back();
46
51
  const bool bull = last.close >= last.open;
47
- const SkColor color =
48
- chart.theme.colors[bull ? VROOM_COLOR_ACCENT_BULL : VROOM_COLOR_ACCENT_BEAR];
49
-
50
- const float y = vroom::price_to_y(lay, bounds, last.close);
52
+ const auto level = vroom::price_indicator_anim::level_at(
53
+ lay, bounds, last.close, bull, morph_from, chart.morph_from_bounds,
54
+ morph_t);
55
+
56
+ // Blended rather than switched, so a candle that changes direction under a
57
+ // tick carries the indicator's color with it instead of snapping — the same
58
+ // cross-fade the body itself does.
59
+ const SkColor color = static_cast<SkColor>(vroom::lerp_argb(
60
+ chart.theme.colors[bull ? VROOM_COLOR_ACCENT_BEAR : VROOM_COLOR_ACCENT_BULL],
61
+ chart.theme.colors[bull ? VROOM_COLOR_ACCENT_BULL : VROOM_COLOR_ACCENT_BEAR],
62
+ level.bull_t));
63
+
64
+ const float y = level.y;
51
65
  if (y < 0.f || y > candle_area_h) return; // price scrolled off-range
52
66
 
53
67
  // Dotted line from the left edge to the y-axis separator.
@@ -73,7 +87,7 @@ void draw(SkCanvas* canvas,
73
87
  const vroom::PriceFormat fmt = vroom::with_tick_guard(
74
88
  chart.price_fmt,
75
89
  vroom::pick_price_interval(bounds.max - bounds.min, candle_area_h));
76
- vroom::format_price(buf, sizeof(buf), last.close, fmt);
90
+ vroom::format_price(buf, sizeof(buf), level.price, fmt);
77
91
  const size_t len = std::strlen(buf);
78
92
 
79
93
  // Measure the tight glyph bounds (origin at the baseline) so we can center