react-native-vroom-chart 0.8.0 → 0.9.1

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.
@@ -294,6 +294,9 @@ typedef enum {
294
294
  VROOM_FLOAT_VOLUME_RADIUS_PX, // volume bar top-corner radius px (0 = square)
295
295
  VROOM_FLOAT_LINE_WIDTH_PX, // line-chart-mode polyline stroke width px
296
296
  VROOM_FLOAT_LINE_GRADIENT_OPACITY, // fill under the line at its strongest (0 disables)
297
+ VROOM_FLOAT_LINE_TENSION, // 0..1 line-chart corner smoothing (0 = straight)
298
+ VROOM_FLOAT_LINE_TIP_DOT, // 0/1: dot at the line's newest end (default on)
299
+ VROOM_FLOAT_LINE_TIP_PULSE, // 0/1: expanding ring around that dot
297
300
  VROOM_FLOAT_COUNT_
298
301
  } VroomFloatKey;
299
302
 
@@ -342,7 +345,8 @@ void vroom_chart_set_visible_range(VroomChart* chart, int64_t start_ms, int64_t
342
345
  // Target candle *body* width in px for the default/reset framing. Larger = more
343
346
  // zoomed in (fewer candles), smaller = more zoomed out. 0 restores the legacy
344
347
  // "most recent ~80 candles" default. Affects initial framing only; an explicit
345
- // set_visible_range still overrides it.
348
+ // set_visible_range still overrides it. A series shorter than the implied window
349
+ // is left-padded with empty slots so bars stay this wide instead of stretching.
346
350
  void vroom_chart_set_default_candle_width(VroomChart* chart, float px);
347
351
 
348
352
  // Chart render mode: 0 = candlesticks (default), 1 = line chart (a polyline
@@ -9,6 +9,8 @@
9
9
 
10
10
  #include "chart.h"
11
11
 
12
+ #include <cmath>
13
+
12
14
  #pragma clang diagnostic push
13
15
  #pragma clang diagnostic ignored "-Wdocumentation"
14
16
  #include "include/core/SkCanvas.h"
@@ -34,6 +36,7 @@
34
36
  #include "rsi.h"
35
37
  #include "rsi_pane.h"
36
38
  #include "style_inherit.h"
39
+ #include "tip_pulse.h"
37
40
  #include "volume.h"
38
41
  #include "vwap.h"
39
42
  #include "theme.h"
@@ -191,7 +194,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
191
194
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
192
195
  theme.colors[VROOM_COLOR_LINE],
193
196
  theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
194
- fade, morph_src, morph_n, morph_t);
197
+ fade, morph_src, morph_n, morph_t,
198
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
195
199
  }
196
200
 
197
201
  // 4.5. Volume bars — drawn under the candles so candles z-index above.
@@ -239,7 +243,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
239
243
  canvas, lay, bounds, visible, n, window_ms,
240
244
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
241
245
  theme.colors[VROOM_COLOR_LINE], theme.floats[VROOM_FLOAT_LINE_WIDTH_PX],
242
- fade, morph_src, morph_n, morph_t);
246
+ fade, morph_src, morph_n, morph_t,
247
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
243
248
  }
244
249
 
245
250
  // 5.5. Moving-average overlay lines (SMA/EMA) on the price pane, over the
@@ -303,6 +308,21 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
303
308
  vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
304
309
  candle_area_h);
305
310
 
311
+ // 5.8. Line-mode tip marker — the dot (and optional pulse) at the newest
312
+ // close. Above the overlays so it stays the eye's anchor, but before the
313
+ // axis masks, which trim the ring at the price scale.
314
+ if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
315
+ vroom::ma_overlay::draw_close_tip(
316
+ canvas, lay, bounds, visible, n, window_ms, visible_start_ms,
317
+ candle_duration_ms, candle_right, candle_area_h,
318
+ theme.colors[VROOM_COLOR_LINE],
319
+ theme.colors[VROOM_COLOR_BACKGROUND],
320
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
321
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
322
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
323
+ morph_src, morph_n, morph_t);
324
+ }
325
+
306
326
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
307
327
  // line is intentionally omitted for now. The bottom strip anchors at
308
328
  // x_axis_top (below any indicator pane) so it never paints over it.
@@ -422,6 +442,17 @@ void VroomChart::begin_frame() {
422
442
  last_anim_tick = now;
423
443
  anim_started = true;
424
444
  last_dt_seconds = dt;
445
+
446
+ // Wrapped rather than accumulated: the phase is the only thing anyone reads,
447
+ // and a chart left open for hours would otherwise lose float precision on it.
448
+ tip_pulse_elapsed_s =
449
+ std::fmod(tip_pulse_elapsed_s + dt, vroom::tip_pulse::kPeriodSeconds);
450
+ }
451
+
452
+ bool VroomChart::tip_pulse_active() const {
453
+ return morph_fade > 0.f && !candles.empty() &&
454
+ theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f &&
455
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f;
425
456
  }
426
457
 
427
458
  void VroomChart::rebuild_chart_picture() {
@@ -433,6 +464,9 @@ void VroomChart::rebuild_chart_picture() {
433
464
  }
434
465
 
435
466
  bool VroomChart::is_animating_now() const {
467
+ // The pulse never finishes on its own, so this is what keeps the host loops
468
+ // requeueing frames for it.
469
+ if (tip_pulse_active()) return true;
436
470
  for (const auto& f : y_fades) {
437
471
  if (f.opacity != f.target) return true;
438
472
  }
@@ -64,6 +64,12 @@ struct VroomChart {
64
64
  float morph_collapse = 0.f;
65
65
  float morph_fade = 0.f;
66
66
 
67
+ // Phase of the line-tip pulse, in seconds into its cycle. Unlike every other
68
+ // animation here this one is core-driven: it loops with no endpoint for the
69
+ // host to ease toward, so begin_frame advances it from the frame clock and
70
+ // is_animating_now keeps the host's redraw loop alive (see tip_pulse.h).
71
+ float tip_pulse_elapsed_s = 0.f;
72
+
67
73
  // Interval morph: the outgoing candle geometry captured when a timeframe
68
74
  // switch begins, indexed from the right of the visible slice (slot 0 =
69
75
  // newest) — the pairing the preserved slot grid guarantees. Stored as
@@ -307,14 +313,20 @@ struct VroomChart {
307
313
  void draw_chart(SkCanvas* canvas);
308
314
 
309
315
  // Per-frame animation bookkeeping: computes `last_dt_seconds`, which paces
310
- // the label fades. Called at the top of draw_chart.
316
+ // the label fades, and advances `tip_pulse_elapsed_s`. Called at the top of
317
+ // draw_chart.
311
318
  void begin_frame();
312
319
 
320
+ // True when the line tip's pulse ring is on screen and looping. Gated on
321
+ // line mode and on having data, so a chart that isn't showing the ring can
322
+ // still go idle.
323
+ bool tip_pulse_active() const;
324
+
313
325
  // Re-records `chart_picture` by invoking draw_chart on a fresh
314
326
  // SkPictureRecorder.
315
327
  void rebuild_chart_picture();
316
328
 
317
- // True if any axis label is mid-fade. Used by the JS-side animation loop
318
- // to know when to keep ticking.
329
+ // True if any axis label is mid-fade, or the tip pulse is running. Used by
330
+ // the JS-side animation loop to know when to keep ticking.
319
331
  bool is_animating_now() const;
320
332
  };
@@ -32,6 +32,17 @@ constexpr double kAxisDragSensitivity = 300.0;
32
32
  constexpr double kMinCandleBodyPx = 1.5;
33
33
  constexpr double kMaxCandleBodyPx = 32.0;
34
34
 
35
+ // Trailing gap the default framing leaves between the newest candle and the
36
+ // price axis. The newest bar flush against the axis reads as cramped, and in line
37
+ // mode the tip marker's pulse needs somewhere to expand into — the axis mask
38
+ // (draw_chart step 6) paints over the gutter, so clearance has to come from the
39
+ // time window rather than from right_padding_px.
40
+ //
41
+ // A gap this small is far under the 3/4-window cap, so it neither triggers the
42
+ // pan rubber-band nor gets clamped away; a timeframe switch carries it over in
43
+ // slots (see timeframeWindow in the hosts), which preserves it in pixels.
44
+ constexpr double kRightGapPx = 12.0;
45
+
35
46
  // Hard cap on the future gap (visible_end - last_candle): 3/4 of the window so
36
47
  // at least 25% of the chart still shows candles. Never forces an existing larger
37
48
  // gap (e.g. from the x-axis drag zoom) to snap back — only limits new growth.
@@ -39,6 +50,21 @@ int64_t max_future_gap(int64_t window_ms, int64_t cur_future) {
39
50
  return std::max<int64_t>((window_ms * 3) / 4, cur_future);
40
51
  }
41
52
 
53
+ // Pan/translate future allowance. A window longer than the data has nowhere
54
+ // useful to scroll, so don't grow the future gap — growing it used to combine
55
+ // with a first-candle start pin and latch the view. Dense series keep the
56
+ // usual 3/4-window rubber-band.
57
+ int64_t pan_max_future(int64_t window_ms, int64_t cur_future,
58
+ int64_t first_time, int64_t last_time,
59
+ int64_t candle_duration_ms) {
60
+ const int64_t dur = candle_duration_ms > 0 ? candle_duration_ms : 0;
61
+ const int64_t data_extent = last_time + dur - first_time;
62
+ if (data_extent >= 0 && window_ms > data_extent) {
63
+ return std::max<int64_t>(0, cur_future);
64
+ }
65
+ return max_future_gap(window_ms, cur_future);
66
+ }
67
+
42
68
  // Rubber-band a forward (into-future) pan delta: once the current future gap is
43
69
  // past the soft cap (window/2), damp the delta quadratically toward zero as it
44
70
  // nears the hard cap (3*window/4). Backward (into-past) deltas are unaffected.
@@ -70,27 +96,48 @@ void apply_default_framing(VroomChart* chart) {
70
96
  const int64_t right_edge_ms =
71
97
  chart->candles.back().time_ms + chart->candle_duration_ms;
72
98
 
99
+ const auto lay = chart->layout();
100
+ const double usable =
101
+ lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
102
+
103
+ // Places a window of the given width with kRightGapPx of air past the newest
104
+ // candle. Shifts the window forward rather than widening it, so the framing
105
+ // keeps whatever candle width it just computed. Falls back to a flush right
106
+ // edge when the layout isn't measured yet and px can't be converted to time.
107
+ const auto frame = [&](int64_t window_ms) {
108
+ const int64_t gap_ms =
109
+ usable > 0.0 && window_ms > 0
110
+ ? static_cast<int64_t>((kRightGapPx * static_cast<double>(window_ms)) /
111
+ usable)
112
+ : 0;
113
+ chart->visible_end_ms = right_edge_ms + gap_ms;
114
+ chart->visible_start_ms = chart->visible_end_ms - window_ms;
115
+ };
116
+
73
117
  // Px-driven framing. Reuses the inversion from vroom_chart_scale_time_axis:
74
118
  // body_w = usable × (dur / window) × ratio → window = usable × dur × ratio / body_w.
75
119
  // Needs a valid layout (width_px); until size is known it falls through to
76
120
  // the candle-count default below.
77
121
  if (chart->default_candle_px > 0.f) {
78
- const auto lay = chart->layout();
79
- const double usable =
80
- lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
81
122
  const double ratio = chart->theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO];
82
123
  const double dur = static_cast<double>(chart->candle_duration_ms);
83
124
  const double body = std::clamp(static_cast<double>(chart->default_candle_px),
84
125
  kMinCandleBodyPx, kMaxCandleBodyPx);
85
126
  if (usable > 0.0 && ratio > 0.0 && dur > 0.0 && body > 0.0) {
86
- int64_t window_ms = static_cast<int64_t>((usable * dur * ratio) / body);
87
- const int64_t span =
88
- chart->candles.back().time_ms - chart->candles.front().time_ms;
89
- window_ms = std::clamp<int64_t>(window_ms, chart->candle_duration_ms,
90
- span > 0 ? span : window_ms);
91
- chart->visible_end_ms = right_edge_ms;
92
- chart->visible_start_ms = chart->visible_end_ms - window_ms;
93
- return;
127
+ int64_t window_ms = vroom::window_for_body_width(
128
+ lay, chart->candle_duration_ms, body);
129
+ // Floor at one slot so the window is valid. Do NOT clamp to the
130
+ // data span: a short series would then stretch each bar to fill
131
+ // the plot, the opposite of a target body width. Empty space on
132
+ // the left is intentional — same rule as timeframeWindow (width
133
+ // wins).
134
+ if (window_ms > 0) {
135
+ if (window_ms < chart->candle_duration_ms) {
136
+ window_ms = chart->candle_duration_ms;
137
+ }
138
+ frame(window_ms);
139
+ return;
140
+ }
94
141
  }
95
142
  }
96
143
 
@@ -98,8 +145,7 @@ void apply_default_framing(VroomChart* chart) {
98
145
  const size_t start_idx = chart->candles.size() > kDefaultVisible
99
146
  ? chart->candles.size() - kDefaultVisible
100
147
  : 0;
101
- chart->visible_start_ms = chart->candles[start_idx].time_ms;
102
- chart->visible_end_ms = right_edge_ms;
148
+ frame(right_edge_ms - chart->candles[start_idx].time_ms);
103
149
  }
104
150
 
105
151
  // On the first manual-y gesture, adopt the on-screen auto-fit bounds so the
@@ -380,30 +426,23 @@ extern "C" void vroom_chart_pan(VroomChart* chart, float dx_px, float /*dy_px*/)
380
426
  (-dx_px / usable_px) * static_cast<float>(window_ms));
381
427
  if (delta_ms == 0) return;
382
428
 
383
- // The right edge can overshoot the last candle into empty "future" space.
384
- // Free scrolling up to half a window (candles fill >= 50%); from there to
385
- // 3/4 of a window (candles fill >= 25%) panning forward is rubber-banded so
386
- // it slows and hard-stops at the cap. The x-axis drag zoom can leave a gap
387
- // larger than the cap (it zooms in until ~one candle remains), so the cap
388
- // never forces it back — it just shrinks naturally as the user scrolls.
389
- // Left edge still hard-clamps at the first candle.
429
+ // Forward pans rubber-band between half and 3/4 of a window of empty
430
+ // future (see damp_future_delta / max_future_gap). Past clamping lives in
431
+ // clamp_shifted_time_window: dense series pin start at the first candle;
432
+ // a window longer than the data keeps empty past instead of latching.
390
433
  const int64_t first_time = chart->candles.front().time_ms;
391
434
  const int64_t last_time = chart->candles.back().time_ms;
392
435
  const int64_t cur_future = chart->visible_end_ms - last_time;
393
436
  const int64_t eff_delta = damp_future_delta(delta_ms, cur_future, window_ms);
394
437
 
395
- int64_t new_start = chart->visible_start_ms + eff_delta;
396
- int64_t new_end = chart->visible_end_ms + eff_delta;
397
-
398
- const int64_t max_future = max_future_gap(window_ms, cur_future);
399
- if (new_end > last_time + max_future) {
400
- new_end = last_time + max_future;
401
- new_start = new_end - window_ms;
402
- }
403
- if (new_start < first_time) {
404
- new_start = first_time;
405
- new_end = new_start + window_ms;
406
- }
438
+ const auto clamped = vroom::clamp_shifted_time_window(
439
+ chart->visible_start_ms + eff_delta,
440
+ chart->visible_end_ms + eff_delta,
441
+ first_time, last_time, chart->candle_duration_ms,
442
+ pan_max_future(window_ms, cur_future, first_time, last_time,
443
+ chart->candle_duration_ms));
444
+ const int64_t new_start = clamped.start_ms;
445
+ const int64_t new_end = clamped.end_ms;
407
446
 
408
447
  if (new_start == chart->visible_start_ms &&
409
448
  new_end == chart->visible_end_ms) {
@@ -443,22 +482,16 @@ extern "C" void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_p
443
482
  const int64_t cur_future = chart->visible_end_ms - last_time;
444
483
  const int64_t eff_delta =
445
484
  damp_future_delta(delta_ms, cur_future, window_ms);
446
- int64_t new_start = chart->visible_start_ms + eff_delta;
447
- int64_t new_end = chart->visible_end_ms + eff_delta;
448
- const int64_t max_future =
449
- max_future_gap(window_ms, cur_future);
450
- if (new_end > last_time + max_future) {
451
- new_end = last_time + max_future;
452
- new_start = new_end - window_ms;
453
- }
454
- if (new_start < first_time) {
455
- new_start = first_time;
456
- new_end = new_start + window_ms;
457
- }
458
- if (new_start != chart->visible_start_ms ||
459
- new_end != chart->visible_end_ms) {
460
- chart->visible_start_ms = new_start;
461
- chart->visible_end_ms = new_end;
485
+ const auto clamped = vroom::clamp_shifted_time_window(
486
+ chart->visible_start_ms + eff_delta,
487
+ chart->visible_end_ms + eff_delta,
488
+ first_time, last_time, chart->candle_duration_ms,
489
+ pan_max_future(window_ms, cur_future, first_time, last_time,
490
+ chart->candle_duration_ms));
491
+ if (clamped.start_ms != chart->visible_start_ms ||
492
+ clamped.end_ms != chart->visible_end_ms) {
493
+ chart->visible_start_ms = clamped.start_ms;
494
+ chart->visible_end_ms = clamped.end_ms;
462
495
  changed = true;
463
496
  }
464
497
  }
@@ -573,9 +606,9 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
573
606
  if (new_window_ms > max_window) new_window_ms = max_window;
574
607
 
575
608
  // Pivot around the right edge — most-recent visible candle stays put.
609
+ // A window longer than the data may start before the first candle; that
610
+ // empty past is how defaultCandleWidth wins, so don't pin start to first.
576
611
  int64_t new_start = chart->visible_end_ms - new_window_ms;
577
- const int64_t first_time = chart->candles.front().time_ms;
578
- if (new_start < first_time) new_start = first_time;
579
612
 
580
613
  if (new_start == chart->visible_start_ms) return;
581
614
  chart->visible_start_ms = new_start;
@@ -755,17 +788,14 @@ extern "C" void vroom_chart_zoom(VroomChart* chart, float scale_x, float scale_y
755
788
  const int64_t last_time = chart->candles.back().time_ms;
756
789
  // Cap the future gap at 3/4 of the (new) window so >= 25% of the
757
790
  // chart still shows candles, without snapping back an existing
758
- // larger gap (e.g. from the x-axis drag zoom).
759
- const int64_t max_future = max_future_gap(
760
- new_window, chart->visible_end_ms - last_time);
761
- if (new_end > last_time + max_future) {
762
- new_end = last_time + max_future;
763
- new_start = new_end - new_window;
764
- }
765
- if (new_start < first_time) {
766
- new_start = first_time;
767
- new_end = new_start + new_window;
768
- }
791
+ // larger gap (e.g. from the x-axis drag zoom). Past clamping
792
+ // matches pan: a window longer than the data keeps empty left.
793
+ const auto clamped = vroom::clamp_shifted_time_window(
794
+ new_start, new_end, first_time, last_time,
795
+ chart->candle_duration_ms,
796
+ max_future_gap(new_window, chart->visible_end_ms - last_time));
797
+ new_start = clamped.start_ms;
798
+ new_end = clamped.end_ms;
769
799
  if (new_start != chart->visible_start_ms ||
770
800
  new_end != chart->visible_end_ms) {
771
801
  chart->visible_start_ms = new_start;
@@ -21,6 +21,7 @@
21
21
  #include "chart.h"
22
22
  #include "fonts.h"
23
23
  #include "theme.h"
24
+ #include "ticks.h"
24
25
 
25
26
  namespace vroom::crosshair {
26
27
 
@@ -146,7 +147,10 @@ void draw(SkCanvas* canvas,
146
147
  if (lay.y_axis_width_px > 0.f) {
147
148
  const double price = vroom::y_to_price(lay, bounds, cy);
148
149
  char buf[32];
149
- std::snprintf(buf, sizeof(buf), "%.2f", price);
150
+ const int decimals = vroom::price_decimals(
151
+ vroom::pick_price_interval(bounds.max - bounds.min,
152
+ vroom::price_pane_bottom(lay)));
153
+ vroom::format_price(buf, sizeof(buf), price, decimals);
150
154
  const float axis_center_x = lay.width_px - lay.y_axis_width_px * 0.5f;
151
155
  draw_badge(canvas, font, buf, axis_center_x, cy, badge_fill,
152
156
  SK_ColorWHITE);
@@ -0,0 +1,67 @@
1
+ // Monotone cubic interpolation — the math behind the line chart's optional
2
+ // corner smoothing (VROOM_FLOAT_LINE_TENSION).
3
+ //
4
+ // Deliberately monotone (Fritsch-Carlson) rather than a plain Catmull-Rom or
5
+ // cardinal spline. An unconstrained spline through the closes overshoots on a
6
+ // sharp reversal, and on a price chart that isn't just ugly — the curve dips
7
+ // below the period's low and draws a price that never traded, next to candles
8
+ // that show the real one during the candle-to-line crossfade. Limiting the
9
+ // tangents keeps every local extreme on an actual data point.
10
+ //
11
+ // Skia-free and header-only so the unit tests can cover it (see
12
+ // tests/test_curve.cpp); ma_overlay.cpp owns the walk and the path emission.
13
+
14
+ #pragma once
15
+
16
+ #include <algorithm>
17
+ #include <cmath>
18
+
19
+ namespace vroom::curve {
20
+
21
+ // The two interior control points of a cubic Bezier segment, in whatever space
22
+ // the endpoints were given in (pixels, at every call site here).
23
+ struct Controls {
24
+ float c1x, c1y;
25
+ float c2x, c2y;
26
+ };
27
+
28
+ // Slope of the straight line between two points. Non-increasing x yields 0,
29
+ // which keeps a degenerate span (duplicate timestamps, or the compressed
30
+ // spacing an interval morph can pass through) from producing infinities.
31
+ inline float secant(float x0, float y0, float x1, float y1) {
32
+ const float h = x1 - x0;
33
+ return h > 0.f ? (y1 - y0) / h : 0.f;
34
+ }
35
+
36
+ // Tangent at a point with neighboring secants `d_prev` and `d_next`.
37
+ inline float monotone_tangent(float d_prev, float d_next) {
38
+ // Opposite signs (or a flat neighbor) mean this point is a local extreme.
39
+ // A zero tangent is what pins the curve to it instead of sailing past.
40
+ if (d_prev * d_next <= 0.f) return 0.f;
41
+ // Fritsch-Carlson: holding the tangent within three times the smaller
42
+ // neighboring secant is what makes each segment monotone, and is also why
43
+ // the control points below can never leave the endpoints' y range.
44
+ const float limit = 3.f * std::min(std::fabs(d_prev), std::fabs(d_next));
45
+ return std::clamp((d_prev + d_next) * 0.5f, -limit, limit);
46
+ }
47
+
48
+ // Control points for the segment p0 -> p1, given the tangents chosen for each
49
+ // endpoint and how far to lean into them.
50
+ //
51
+ // `tension` 0 puts both tangents back on the segment's own secant, which lands
52
+ // the controls exactly on its thirds — the cubic *is* the straight line. So the
53
+ // knob is continuous at zero rather than snapping into a curve, and callers can
54
+ // interpolate it without a visible discontinuity.
55
+ inline Controls segment_controls(float x0, float y0,
56
+ float x1, float y1,
57
+ float m0, float m1,
58
+ float tension) {
59
+ const float d = secant(x0, y0, x1, y1);
60
+ const float a = d + (m0 - d) * tension;
61
+ const float b = d + (m1 - d) * tension;
62
+ const float third = (x1 - x0) / 3.f;
63
+ return Controls{x0 + third, y0 + a * third,
64
+ x1 - third, y1 - b * third};
65
+ }
66
+
67
+ } // namespace vroom::curve
@@ -153,13 +153,15 @@ void draw_y_labels(SkCanvas* canvas,
153
153
  // Horizontal center of the y-axis container ([width - y_axis_width, width]).
154
154
  // Labels (and the price box) center on this so their text shares a column.
155
155
  const float axis_center_x = lay.width_px - lay.y_axis_width_px * 0.5f;
156
+ const int decimals = vroom::price_decimals(
157
+ vroom::pick_price_interval(bounds.max - bounds.min, candle_area_h));
156
158
 
157
159
  for (const auto& f : chart.y_fades) {
158
160
  if (f.opacity <= 1e-3f) continue;
159
161
  const float y = vroom::price_to_y(lay, bounds, f.price);
160
162
 
161
163
  char buf[32];
162
- std::snprintf(buf, sizeof(buf), "%.2f", f.price);
164
+ vroom::format_price(buf, sizeof(buf), f.price, decimals);
163
165
  const size_t len = std::strlen(buf);
164
166
  const float text_w = font.measureText(
165
167
  buf, len, SkTextEncoding::kUTF8);
@@ -367,9 +369,12 @@ void recompute_axis_width(VroomChart& chart) {
367
369
  }
368
370
 
369
371
  SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
372
+ const auto lay = chart.layout();
373
+ const int decimals = vroom::price_decimals(
374
+ vroom::pick_price_interval(hi - lo, vroom::price_pane_bottom(lay)));
370
375
  char buf_hi[32], buf_lo[32];
371
- std::snprintf(buf_hi, sizeof(buf_hi), "%.2f", hi);
372
- std::snprintf(buf_lo, sizeof(buf_lo), "%.2f", lo);
376
+ vroom::format_price(buf_hi, sizeof(buf_hi), hi, decimals);
377
+ vroom::format_price(buf_lo, sizeof(buf_lo), lo, decimals);
373
378
  const float w_hi = font.measureText(
374
379
  buf_hi, std::strlen(buf_hi), SkTextEncoding::kUTF8);
375
380
  const float w_lo = font.measureText(