react-native-vroom-chart 0.1.0 → 0.1.2

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.
@@ -8,6 +8,9 @@
8
8
 
9
9
  #pragma once
10
10
 
11
+ #include <cstdint>
12
+
13
+ #include "viewport.h"
11
14
  #include "vroom/vroom_chart.h"
12
15
 
13
16
  class SkCanvas;
@@ -22,11 +25,20 @@ namespace vroom::crosshair {
22
25
  // (the bottom of the indicator region, so it stays visible over any RSI/MACD
23
26
  // panes); pass `candle_area_h` for both when there are no panes. `snap_x` is the
24
27
  // candle-snapped x for the vertical line and ring.
28
+ //
29
+ // Two axis badges are drawn over the strips: a date/time badge (formatted from
30
+ // `snap_time_ms`) centered on the vertical line in the x-axis strip, and a
31
+ // price badge (the price at the crosshair y, via `lay`/`bounds`) centered in
32
+ // the y-axis strip. Both render on top of the axis labels and the current-price
33
+ // indicator since the crosshair is the last thing drawn.
25
34
  void draw(SkCanvas* canvas,
26
35
  const VroomChart& chart,
36
+ const vroom::Layout& lay,
37
+ const vroom::PriceBounds& bounds,
27
38
  float candle_right,
28
39
  float candle_area_h,
29
40
  float vline_bottom,
30
- float snap_x);
41
+ float snap_x,
42
+ int64_t snap_time_ms);
31
43
 
32
44
  } // namespace vroom::crosshair
@@ -171,17 +171,16 @@ void update_x_fades(VroomChart& chart, const Layout& lay) {
171
171
  if (candle_area_w <= 0.f) return;
172
172
 
173
173
  const int64_t window_ms = chart.visible_end_ms - chart.visible_start_ms;
174
- const int64_t interval = vroom::pick_time_interval(window_ms, candle_area_w);
175
- if (interval <= 0) return;
174
+ const vroom::TimeTick tick =
175
+ vroom::pick_time_tick(window_ms, candle_area_w);
176
176
 
177
177
  for (auto& f : chart.x_fades) f.target = 0.f;
178
178
 
179
- int64_t t = (chart.visible_start_ms / interval) * interval;
180
- if (t < chart.visible_start_ms) t += interval;
179
+ int64_t t = vroom::first_tick_at_or_after(chart.visible_start_ms, tick);
181
180
  constexpr int kMaxLabels = 64;
182
181
  int promoted = 0;
183
182
  for (; t <= chart.visible_end_ms && promoted < kMaxLabels;
184
- t += interval, ++promoted) {
183
+ t = vroom::next_tick(t, tick), ++promoted) {
185
184
  bool found = false;
186
185
  for (auto& f : chart.x_fades) {
187
186
  if (f.time_ms == t) {
@@ -238,8 +237,9 @@ void draw_x_labels(SkCanvas* canvas,
238
237
  if (candle_area_w <= 0.f) return;
239
238
 
240
239
  const int64_t window_ms = chart.visible_end_ms - chart.visible_start_ms;
241
- // Recompute interval here just to decide HH:MM vs MM/DD formatting.
242
- const int64_t interval = vroom::pick_time_interval(window_ms, candle_area_w);
240
+ // Recompute the cadence here just to decide label formatting.
241
+ const vroom::TimeTick tick =
242
+ vroom::pick_time_tick(window_ms, candle_area_w);
243
243
 
244
244
  SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
245
245
  font.setSubpixel(true);
@@ -257,7 +257,9 @@ void draw_x_labels(SkCanvas* canvas,
257
257
 
258
258
  const float baseline_y =
259
259
  candle_area_h + (lay.x_axis_height_px + cap_h) * 0.5f;
260
- const bool use_date = interval >= 24LL * 60 * 60 * 1000;
260
+ const bool use_date =
261
+ tick.unit == vroom::TimeUnit::Fixed &&
262
+ tick.step_ms >= 24LL * 60 * 60 * 1000;
261
263
  for (const auto& f : chart.x_fades) {
262
264
  if (f.opacity <= 1e-3f) continue;
263
265
  const float frac =
@@ -269,7 +271,16 @@ void draw_x_labels(SkCanvas* canvas,
269
271
  const time_t time_s = static_cast<time_t>(f.time_ms / 1000);
270
272
  struct tm tm_buf;
271
273
  localtime_r(&time_s, &tm_buf);
272
- if (use_date) {
274
+ if (tick.unit == vroom::TimeUnit::Year) {
275
+ std::snprintf(buf, sizeof(buf), "%d", tm_buf.tm_year + 1900);
276
+ } else if (tick.unit == vroom::TimeUnit::Month) {
277
+ // Show the year at January so month ticks read across years.
278
+ if (tm_buf.tm_mon == 0) {
279
+ std::snprintf(buf, sizeof(buf), "%d", tm_buf.tm_year + 1900);
280
+ } else {
281
+ std::strftime(buf, sizeof(buf), "%b", &tm_buf);
282
+ }
283
+ } else if (use_date) {
273
284
  std::snprintf(buf, sizeof(buf), "%02d/%02d",
274
285
  tm_buf.tm_mon + 1, tm_buf.tm_mday);
275
286
  } else {
@@ -52,7 +52,10 @@ void draw(SkCanvas* canvas,
52
52
  if (band_h <= 0.f) return;
53
53
 
54
54
  const float mid = (pane_top + pane_bottom) * 0.5f;
55
- const float half = band_h * 0.5f * kPadFrac;
55
+ // User y-zoom scales the amplitude about the zero line (mid). 1.0 = the
56
+ // default auto-fit; >1 zooms in, <1 zooms out.
57
+ const float half =
58
+ band_h * 0.5f * kPadFrac * static_cast<float>(chart.macd_y_scale);
56
59
 
57
60
  // Auto-scale symmetric about zero across all visible finite values.
58
61
  double scale = 0.0;
@@ -100,8 +103,8 @@ void draw(SkCanvas* canvas,
100
103
  vroom::candle_body_width(lay, window_ms, candle_duration_ms);
101
104
  const float half_body = body_w * 0.5f;
102
105
  const float zero_y = y_for(0.0);
103
- const SkColor bull = chart.theme.colors[VROOM_COLOR_BULL];
104
- const SkColor bear = chart.theme.colors[VROOM_COLOR_BEAR];
106
+ const SkColor bull = chart.theme.colors[VROOM_COLOR_ACCENT_BULL];
107
+ const SkColor bear = chart.theme.colors[VROOM_COLOR_ACCENT_BEAR];
105
108
  for (std::size_t i = 0; i < n; ++i) {
106
109
  const double h = hist_visible[i];
107
110
  if (!std::isfinite(h)) continue;
@@ -43,7 +43,7 @@ void draw(SkCanvas* canvas,
43
43
  const ::VroomCandle& last = chart.candles.back();
44
44
  const bool bull = last.close >= last.open;
45
45
  const SkColor color =
46
- chart.theme.colors[bull ? VROOM_COLOR_BULL : VROOM_COLOR_BEAR];
46
+ chart.theme.colors[bull ? VROOM_COLOR_ACCENT_BULL : VROOM_COLOR_ACCENT_BEAR];
47
47
 
48
48
  const float y = vroom::price_to_y(lay, bounds, last.close);
49
49
  if (y < 0.f || y > candle_area_h) return; // price scrolled off-range
@@ -50,8 +50,13 @@ void draw(SkCanvas* canvas,
50
50
  const float band_h = pane_bottom - pane_top;
51
51
  if (band_h <= 0.f) return;
52
52
 
53
+ // Map 0..100 about the pane center so the user y-zoom scales symmetrically
54
+ // around 50. z == 1 reproduces the default fit (0 -> pane_bottom, 100 ->
55
+ // pane_top); z > 1 zooms in (taller), z < 1 zooms out (flatter).
56
+ const float center_y = (pane_top + pane_bottom) * 0.5f;
57
+ const float z = static_cast<float>(chart.rsi_y_scale);
53
58
  auto y_for = [&](double v) -> float {
54
- return pane_bottom - static_cast<float>(v / 100.0) * band_h;
59
+ return center_y - static_cast<float>((v - 50.0) / 100.0) * band_h * z;
55
60
  };
56
61
 
57
62
  // Mask the band with the background so candles/volume that overflow below
@@ -16,6 +16,12 @@ constexpr uint32_t kDefaultColors[VROOM_COLOR_COUNT_] = {
16
16
  0xff161b22, // TOOLTIP_BG
17
17
  0xffc9d1d9, // TOOLTIP_TEXT
18
18
  0xff3e4855, // CROSSHAIR_TARGET — CROSSHAIR lightened 30% (prior derived ring)
19
+ 0x00000000, // BORDER_BULL — transparent sentinel: inherit BULL fill
20
+ 0x00000000, // BORDER_BEAR — transparent sentinel: inherit BEAR fill
21
+ 0x00000000, // WICK_BULL — transparent sentinel: inherit BULL fill
22
+ 0x00000000, // WICK_BEAR — transparent sentinel: inherit BEAR fill
23
+ 0xff26a69a, // ACCENT_BULL — classic teal-green (price indicator, volume, MACD)
24
+ 0xffef5350, // ACCENT_BEAR — classic red
19
25
  };
20
26
 
21
27
  constexpr float kDefaultFloats[VROOM_FLOAT_COUNT_] = {
@@ -2,10 +2,44 @@
2
2
 
3
3
  #include <algorithm>
4
4
  #include <cmath>
5
+ #include <ctime>
5
6
 
6
7
  namespace vroom {
7
8
 
8
- int64_t pick_time_interval(int64_t window_ms, float candle_area_w) {
9
+ namespace {
10
+
11
+ // Approximate average lengths used only to choose a cadence (the actual ticks
12
+ // land on exact calendar boundaries). 30.436875 d / 365.2425 d.
13
+ constexpr int64_t kMonthMs = 2'629'746'000LL;
14
+ constexpr int64_t kYearMs = 31'556'952'000LL;
15
+
16
+ // Smallest 1/2/5 × 10ⁿ integer that is >= x (>= 1).
17
+ int nice_ceil_int(double x) {
18
+ if (x <= 1.0) return 1;
19
+ const double mag = std::pow(10.0, std::floor(std::log10(x)));
20
+ for (double m : {1.0, 2.0, 5.0, 10.0}) {
21
+ const double cand = m * mag;
22
+ if (cand >= x) return static_cast<int>(cand);
23
+ }
24
+ return static_cast<int>(10.0 * mag);
25
+ }
26
+
27
+ // Local-time midnight of year/mon(0-11)/mday as epoch ms.
28
+ int64_t ymd_to_ms(int year, int mon, int mday) {
29
+ std::tm t{};
30
+ t.tm_year = year - 1900;
31
+ t.tm_mon = mon;
32
+ t.tm_mday = mday;
33
+ t.tm_hour = 0;
34
+ t.tm_min = 0;
35
+ t.tm_sec = 0;
36
+ t.tm_isdst = -1;
37
+ return static_cast<int64_t>(std::mktime(&t)) * 1000;
38
+ }
39
+
40
+ } // namespace
41
+
42
+ TimeTick pick_time_tick(int64_t window_ms, float candle_area_w) {
9
43
  static constexpr int64_t kIntervals[] = {
10
44
  60'000LL, // 1m
11
45
  5 * 60'000LL, // 5m
@@ -20,15 +54,80 @@ int64_t pick_time_interval(int64_t window_ms, float candle_area_w) {
20
54
  2 * 24 * 60 * 60'000LL, // 2d
21
55
  7 * 24 * 60 * 60'000LL, // 1w
22
56
  };
23
- if (window_ms <= 0 || candle_area_w <= 0.f) return kIntervals[0];
24
- const double ms_per_px =
25
- static_cast<double>(window_ms) / candle_area_w;
57
+ if (window_ms <= 0 || candle_area_w <= 0.f) {
58
+ return {TimeUnit::Fixed, kIntervals[0], 0};
59
+ }
60
+ const double ms_per_px = static_cast<double>(window_ms) / candle_area_w;
61
+
26
62
  for (int64_t i : kIntervals) {
27
63
  if (static_cast<double>(i) / ms_per_px >= kXLabelMinSpacing) {
28
- return i;
64
+ return {TimeUnit::Fixed, i, 0};
29
65
  }
30
66
  }
31
- return kIntervals[sizeof(kIntervals) / sizeof(kIntervals[0]) - 1];
67
+
68
+ static constexpr int kMonthSteps[] = {1, 3, 6};
69
+ for (int s : kMonthSteps) {
70
+ const double span = static_cast<double>(s) *
71
+ static_cast<double>(kMonthMs);
72
+ if (span / ms_per_px >= kXLabelMinSpacing) {
73
+ return {TimeUnit::Month, 0, s};
74
+ }
75
+ }
76
+
77
+ // Years: smallest nice step whose spacing clears the minimum. Unbounded, so
78
+ // arbitrarily wide windows still get non-overlapping labels.
79
+ const double needed = (static_cast<double>(kXLabelMinSpacing) * ms_per_px) /
80
+ static_cast<double>(kYearMs);
81
+ return {TimeUnit::Year, 0, nice_ceil_int(needed)};
82
+ }
83
+
84
+ int64_t first_tick_at_or_after(int64_t from_ms, const TimeTick& tick) {
85
+ if (tick.unit == TimeUnit::Fixed) {
86
+ const int64_t step = tick.step_ms;
87
+ if (step <= 0) return from_ms;
88
+ int64_t t = (from_ms / step) * step;
89
+ if (t < from_ms) t += step;
90
+ return t;
91
+ }
92
+
93
+ const time_t from_s = static_cast<time_t>(from_ms / 1000);
94
+ std::tm lt{};
95
+ localtime_r(&from_s, &lt);
96
+ const int step = tick.step > 0 ? tick.step : 1;
97
+
98
+ if (tick.unit == TimeUnit::Year) {
99
+ int year = ((lt.tm_year + 1900 + step - 1) / step) * step;
100
+ for (;;) {
101
+ const int64_t ms = ymd_to_ms(year, 0, 1);
102
+ if (ms >= from_ms) return ms;
103
+ year += step;
104
+ }
105
+ }
106
+
107
+ // Month: align the month index (year*12 + mon) up to a multiple of step.
108
+ int mi = (((lt.tm_year + 1900) * 12 + lt.tm_mon) + step - 1) / step * step;
109
+ for (;;) {
110
+ const int64_t ms = ymd_to_ms(mi / 12, mi % 12, 1);
111
+ if (ms >= from_ms) return ms;
112
+ mi += step;
113
+ }
114
+ }
115
+
116
+ int64_t next_tick(int64_t t, const TimeTick& tick) {
117
+ if (tick.unit == TimeUnit::Fixed) {
118
+ return t + (tick.step_ms > 0 ? tick.step_ms : 0);
119
+ }
120
+
121
+ const time_t s = static_cast<time_t>(t / 1000);
122
+ std::tm lt{};
123
+ localtime_r(&s, &lt);
124
+ const int step = tick.step > 0 ? tick.step : 1;
125
+
126
+ if (tick.unit == TimeUnit::Year) {
127
+ return ymd_to_ms(lt.tm_year + 1900 + step, 0, 1);
128
+ }
129
+ const int mi = (lt.tm_year + 1900) * 12 + lt.tm_mon + step;
130
+ return ymd_to_ms(mi / 12, mi % 12, 1);
32
131
  }
33
132
 
34
133
  double pick_price_interval(double range, float candle_area_h) {
@@ -17,10 +17,33 @@ inline constexpr float kXLabelMinSpacing = 50.f;
17
17
  // Target spacing for y-axis labels — used to derive a target count.
18
18
  inline constexpr float kYLabelTargetSpacing = 55.f;
19
19
 
20
- // Selects the smallest time interval whose pixel-spacing meets the minimum.
21
- // Returns milliseconds. Hierarchy: 1m 5m 15m 30m 1h 2h 4h → 6h →
22
- // 12h 1d 2d 1w.
23
- int64_t pick_time_interval(int64_t window_ms, float candle_area_w);
20
+ // X-axis tick cadence. The hierarchy starts with fixed millisecond intervals
21
+ // (1m 1w) and, beyond a week, switches to calendar-aware month/year steps so
22
+ // labels land on real month and year boundaries instead of drifting epoch
23
+ // multiples.
24
+ enum class TimeUnit { Fixed, Month, Year };
25
+
26
+ // A chosen tick cadence. For Fixed, ticks land on epoch multiples of step_ms.
27
+ // For Month/Year, ticks land on local-calendar boundaries `step` units apart
28
+ // (e.g. {Month, step=3} → quarter starts; {Year, step=1} → Jan 1 each year).
29
+ struct TimeTick {
30
+ TimeUnit unit;
31
+ int64_t step_ms; // valid iff unit == Fixed
32
+ int step; // valid iff unit == Month or Year
33
+ };
34
+
35
+ // Selects the coarsest-enough cadence whose on-screen spacing meets the minimum.
36
+ // Hierarchy: 1m → 5m → 15m → 30m → 1h → 2h → 4h → 6h → 12h → 1d → 2d → 1w →
37
+ // 1mo → 3mo → 6mo → 1y → 2y → 5y → … (the year step grows as 1/2/5 × 10ⁿ so
38
+ // arbitrarily wide windows never overlap).
39
+ TimeTick pick_time_tick(int64_t window_ms, float candle_area_w);
40
+
41
+ // First tick at or after from_ms for the given cadence. Calendar units align to
42
+ // local month/year boundaries.
43
+ int64_t first_tick_at_or_after(int64_t from_ms, const TimeTick& tick);
44
+
45
+ // The next tick strictly after t for the given cadence.
46
+ int64_t next_tick(int64_t t, const TimeTick& tick);
24
47
 
25
48
  // "Nice number" tick selection for the y-axis: snaps to 1, 2, or 5 × 10ⁿ
26
49
  // based on the price range and target spacing. Returns the price interval
@@ -1,6 +1,7 @@
1
1
  #include "viewport.h"
2
2
 
3
3
  #include <algorithm>
4
+ #include <cmath>
4
5
  #include <limits>
5
6
 
6
7
  namespace vroom {
@@ -66,6 +67,62 @@ size_t snap_index_to_candle(const Layout& layout,
66
67
  : static_cast<size_t>(it - 1 - candles);
67
68
  }
68
69
 
70
+ SnapResult snap_to_slot(const Layout& layout,
71
+ const ::VroomCandle* candles,
72
+ size_t count,
73
+ int64_t candle_duration_ms,
74
+ int64_t visible_start_ms,
75
+ int64_t window_ms,
76
+ float x_px) {
77
+ if (count == 0) return {visible_start_ms, false, 0};
78
+
79
+ const float usable =
80
+ layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
81
+ if (usable <= 0.f || window_ms <= 0 || candle_duration_ms <= 0) {
82
+ return {candles[0].time_ms, true, 0};
83
+ }
84
+
85
+ // Clamp to the usable candle width so snapping never runs past the visible
86
+ // right edge into off-screen space.
87
+ const float clamped_x = std::clamp(x_px, 0.f, usable);
88
+
89
+ // Pixel x -> the period start time of the candle that would sit there.
90
+ const double target_center =
91
+ static_cast<double>(visible_start_ms) +
92
+ (static_cast<double>(clamped_x) / static_cast<double>(usable)) *
93
+ static_cast<double>(window_ms);
94
+ const int64_t key =
95
+ static_cast<int64_t>(target_center) - candle_duration_ms / 2;
96
+
97
+ const int64_t last_time = candles[count - 1].time_ms;
98
+ if (key > last_time) {
99
+ // Past the last candle: snap to the nearest candle-aligned grid slot.
100
+ // Within half a period of the last candle we still land on it (steps
101
+ // 0); beyond that we snap to an empty future slot with no candle data.
102
+ const double k = static_cast<double>(key - last_time) /
103
+ static_cast<double>(candle_duration_ms);
104
+ const int64_t steps = std::llround(k);
105
+ if (steps <= 0) return {last_time, true, count - 1};
106
+ return {last_time + steps * candle_duration_ms, false, 0};
107
+ }
108
+
109
+ // Populated region: pick whichever of the lower_bound candle / its
110
+ // predecessor is closer in time.
111
+ auto* it = std::lower_bound(
112
+ candles, candles + count, key,
113
+ [](const ::VroomCandle& c, int64_t t) { return c.time_ms < t; });
114
+ if (it == candles) return {candles[0].time_ms, true, 0};
115
+ if (it == candles + count) {
116
+ return {candles[count - 1].time_ms, true, count - 1};
117
+ }
118
+ const int64_t hi = it->time_ms;
119
+ const int64_t lo = (it - 1)->time_ms;
120
+ const size_t idx = (hi - key < key - lo)
121
+ ? static_cast<size_t>(it - candles)
122
+ : static_cast<size_t>(it - 1 - candles);
123
+ return {candles[idx].time_ms, true, idx};
124
+ }
125
+
69
126
  float snap_x_to_candle(const Layout& layout,
70
127
  const ::VroomCandle* candles,
71
128
  size_t count,
@@ -79,10 +136,10 @@ float snap_x_to_candle(const Layout& layout,
79
136
  layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
80
137
  if (usable <= 0.f) return x_px;
81
138
 
82
- const size_t idx = snap_index_to_candle(
139
+ const SnapResult snap = snap_to_slot(
83
140
  layout, candles, count, candle_duration_ms, visible_start_ms,
84
141
  window_ms, x_px);
85
- return candle_center_x(layout, candles[idx].time_ms, candle_duration_ms,
142
+ return candle_center_x(layout, snap.time_ms, candle_duration_ms,
86
143
  visible_start_ms, window_ms);
87
144
  }
88
145
 
@@ -138,4 +195,17 @@ float price_to_y(const Layout& layout,
138
195
  return bot - static_cast<float>(t) * draw_h; // invert: high price → low y
139
196
  }
140
197
 
198
+ double y_to_price(const Layout& layout,
199
+ const PriceBounds& bounds,
200
+ float y) {
201
+ const float candle_area_h = price_pane_bottom(layout);
202
+ const float top = candle_area_h * layout.top_padding_frac;
203
+ const float bot = candle_area_h * (1.f - layout.bottom_padding_frac);
204
+ const float draw_h = bot - top;
205
+ const double range = bounds.max - bounds.min;
206
+ if (draw_h <= 0.f || range <= 0.0) return bounds.min;
207
+ const double t = (bot - y) / draw_h; // 0 at min (bottom), 1 at top
208
+ return bounds.min + t * range;
209
+ }
210
+
141
211
  } // namespace vroom
@@ -79,10 +79,32 @@ size_t snap_index_to_candle(const Layout& layout,
79
79
  int64_t window_ms,
80
80
  float x_px);
81
81
 
82
- // On-screen x of the candle whose center is nearest pixel x_px, searching
83
- // [candles, candles+count). Clamps to the first/last candle. Returns x_px
84
- // unchanged when there are no candles or the window is degenerate. Used to snap
85
- // the crosshair to candles.
82
+ // Result of snapping a pixel x to the candle grid. The grid extends past the
83
+ // last candle into the empty "future" space: slots there sit at
84
+ // last_candle.time_ms + k × candle_duration_ms and carry no candle data.
85
+ struct SnapResult {
86
+ int64_t time_ms; // period-start time of the snapped slot (real or future)
87
+ bool has_candle; // true when a real candle sits at this slot
88
+ size_t index; // index into [candles, candles+count); valid iff has_candle
89
+ };
90
+
91
+ // Snaps pixel x_px to the nearest candle-grid slot. Over the populated region
92
+ // this returns the nearest real candle (has_candle = true). To the right of the
93
+ // last candle it snaps to future grid slots (has_candle = false). x_px is
94
+ // clamped to the usable candle width so snapping never runs past the visible
95
+ // right edge. Precondition: count > 0 and window_ms > 0 (callers guard).
96
+ SnapResult snap_to_slot(const Layout& layout,
97
+ const ::VroomCandle* candles,
98
+ size_t count,
99
+ int64_t candle_duration_ms,
100
+ int64_t visible_start_ms,
101
+ int64_t window_ms,
102
+ float x_px);
103
+
104
+ // On-screen x of the slot whose center is nearest pixel x_px, searching
105
+ // [candles, candles+count) and the future grid past the last candle. Returns
106
+ // x_px unchanged when there are no candles or the window is degenerate. Used to
107
+ // snap the crosshair to candles (and to future candle-aligned slots).
86
108
  float snap_x_to_candle(const Layout& layout,
87
109
  const ::VroomCandle* candles,
88
110
  size_t count,
@@ -97,4 +119,8 @@ PriceBounds price_bounds(const ::VroomCandle* candles, size_t count);
97
119
  // Map a price to y in pixels. y=0 is top of the chart.
98
120
  float price_to_y(const Layout& layout, const PriceBounds& bounds, double price);
99
121
 
122
+ // Inverse of price_to_y: map a pixel y in the price pane back to a price.
123
+ // Returns bounds.min for a degenerate range or draw band.
124
+ double y_to_price(const Layout& layout, const PriceBounds& bounds, float y);
125
+
100
126
  } // namespace vroom
@@ -43,12 +43,12 @@ void draw(SkCanvas* canvas,
43
43
  const float opacity = theme.floats[VROOM_FLOAT_VOLUME_OPACITY];
44
44
  SkPaint bull_paint;
45
45
  bull_paint.setAntiAlias(true);
46
- bull_paint.setColor(theme.colors[VROOM_COLOR_BULL]);
46
+ bull_paint.setColor(theme.colors[VROOM_COLOR_ACCENT_BULL]);
47
47
  bull_paint.setAlphaf(opacity);
48
48
 
49
49
  SkPaint bear_paint;
50
50
  bear_paint.setAntiAlias(true);
51
- bear_paint.setColor(theme.colors[VROOM_COLOR_BEAR]);
51
+ bear_paint.setColor(theme.colors[VROOM_COLOR_ACCENT_BEAR]);
52
52
  bear_paint.setAlphaf(opacity);
53
53
 
54
54
  for (std::size_t i = 0; i < n; ++i) {
package/lib/index.d.mts CHANGED
@@ -20,8 +20,18 @@ type Candle = {
20
20
  type CrosshairEvent = {
21
21
  /** True while the crosshair is showing; false when it's dismissed. */
22
22
  active: boolean;
23
- /** OHLCV of the candle under the crosshair, or null when inactive. */
23
+ /**
24
+ * OHLCV of the candle under the crosshair, or null when inactive. Also null
25
+ * when the crosshair is parked on a *future* candle-aligned slot in the empty
26
+ * space ahead of the most recent candle (no candle exists there yet) — use
27
+ * `timeMs` to read the slot's time in that case.
28
+ */
24
29
  candle: Candle | null;
30
+ /**
31
+ * Bar-open time (Unix epoch ms) of the slot the crosshair snaps to, including
32
+ * future candle-aligned slots past the last candle. Null when inactive.
33
+ */
34
+ timeMs: number | null;
25
35
  /**
26
36
  * Why this event fired — lets the host react differently (e.g. haptics):
27
37
  * 'show' — long-press activated the crosshair
@@ -42,10 +52,22 @@ type VroomColor = string | number;
42
52
  type VroomTheme = {
43
53
  /** Chart + axis-strip background. */
44
54
  background?: VroomColor;
45
- /** Up candles (also bull wicks, bull volume bars, rising price indicator). */
55
+ /** Up candle body fill. Wick and border default to this unless overridden. */
46
56
  bull?: VroomColor;
47
- /** Down candles (also bear wicks, bear volume bars, falling price indicator). */
57
+ /** Down candle body fill. Wick and border default to this unless overridden. */
48
58
  bear?: VroomColor;
59
+ /** Generic up color for the price indicator, volume bars, and MACD histogram. Defaults to teal-green; independent of `bull`. */
60
+ accentBull?: VroomColor;
61
+ /** Generic down color for the price indicator, volume bars, and MACD histogram. Defaults to red; independent of `bear`. */
62
+ accentBear?: VroomColor;
63
+ /** Up candle body 1px border. Defaults to the bull fill color. */
64
+ borderBull?: VroomColor;
65
+ /** Down candle body 1px border. Defaults to the bear fill color. */
66
+ borderBear?: VroomColor;
67
+ /** Up candle wick color. Defaults to the bull fill color. */
68
+ wickBull?: VroomColor;
69
+ /** Down candle wick color. Defaults to the bear fill color. */
70
+ wickBear?: VroomColor;
49
71
  /** Gridlines. */
50
72
  grid?: VroomColor;
51
73
  /** Axis label text (price + time). */
package/lib/index.d.ts CHANGED
@@ -20,8 +20,18 @@ type Candle = {
20
20
  type CrosshairEvent = {
21
21
  /** True while the crosshair is showing; false when it's dismissed. */
22
22
  active: boolean;
23
- /** OHLCV of the candle under the crosshair, or null when inactive. */
23
+ /**
24
+ * OHLCV of the candle under the crosshair, or null when inactive. Also null
25
+ * when the crosshair is parked on a *future* candle-aligned slot in the empty
26
+ * space ahead of the most recent candle (no candle exists there yet) — use
27
+ * `timeMs` to read the slot's time in that case.
28
+ */
24
29
  candle: Candle | null;
30
+ /**
31
+ * Bar-open time (Unix epoch ms) of the slot the crosshair snaps to, including
32
+ * future candle-aligned slots past the last candle. Null when inactive.
33
+ */
34
+ timeMs: number | null;
25
35
  /**
26
36
  * Why this event fired — lets the host react differently (e.g. haptics):
27
37
  * 'show' — long-press activated the crosshair
@@ -42,10 +52,22 @@ type VroomColor = string | number;
42
52
  type VroomTheme = {
43
53
  /** Chart + axis-strip background. */
44
54
  background?: VroomColor;
45
- /** Up candles (also bull wicks, bull volume bars, rising price indicator). */
55
+ /** Up candle body fill. Wick and border default to this unless overridden. */
46
56
  bull?: VroomColor;
47
- /** Down candles (also bear wicks, bear volume bars, falling price indicator). */
57
+ /** Down candle body fill. Wick and border default to this unless overridden. */
48
58
  bear?: VroomColor;
59
+ /** Generic up color for the price indicator, volume bars, and MACD histogram. Defaults to teal-green; independent of `bull`. */
60
+ accentBull?: VroomColor;
61
+ /** Generic down color for the price indicator, volume bars, and MACD histogram. Defaults to red; independent of `bear`. */
62
+ accentBear?: VroomColor;
63
+ /** Up candle body 1px border. Defaults to the bull fill color. */
64
+ borderBull?: VroomColor;
65
+ /** Down candle body 1px border. Defaults to the bear fill color. */
66
+ borderBear?: VroomColor;
67
+ /** Up candle wick color. Defaults to the bull fill color. */
68
+ wickBull?: VroomColor;
69
+ /** Down candle wick color. Defaults to the bear fill color. */
70
+ wickBear?: VroomColor;
49
71
  /** Gridlines. */
50
72
  grid?: VroomColor;
51
73
  /** Axis label text (price + time). */
package/lib/index.js CHANGED
@@ -80,8 +80,20 @@ var COLOR_KEYS = {
80
80
  // VROOM_COLOR_AXIS_TEXT
81
81
  crosshair: 6,
82
82
  // VROOM_COLOR_CROSSHAIR
83
- crosshairTarget: 9
83
+ crosshairTarget: 9,
84
84
  // VROOM_COLOR_CROSSHAIR_TARGET
85
+ borderBull: 10,
86
+ // VROOM_COLOR_BORDER_BULL
87
+ borderBear: 11,
88
+ // VROOM_COLOR_BORDER_BEAR
89
+ wickBull: 12,
90
+ // VROOM_COLOR_WICK_BULL
91
+ wickBear: 13,
92
+ // VROOM_COLOR_WICK_BEAR
93
+ accentBull: 14,
94
+ // VROOM_COLOR_ACCENT_BULL
95
+ accentBear: 15
96
+ // VROOM_COLOR_ACCENT_BEAR
85
97
  };
86
98
  function parseColor(value) {
87
99
  if (typeof value === "number") {
@@ -297,11 +309,11 @@ function VroomChart(props) {
297
309
  } else if (crosshairActive.current) {
298
310
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
299
311
  if (ch) pictureSV.value = ch;
300
- const c = handle.getCrosshairCandle();
301
- const t = c?.timeMs ?? null;
312
+ const info = handle.getCrosshairInfo();
313
+ const t = info?.timeMs ?? null;
302
314
  if (t !== lastCrosshairTime.current) {
303
315
  lastCrosshairTime.current = t;
304
- onCrosshair?.({ active: true, candle: c, reason: "move" });
316
+ onCrosshair?.({ active: true, candle: info?.candle ?? null, timeMs: t, reason: "move" });
305
317
  }
306
318
  return;
307
319
  } else {
@@ -391,9 +403,14 @@ function VroomChart(props) {
391
403
  crosshairActive.current = true;
392
404
  const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
393
405
  if (ch) pictureSV.value = ch;
394
- const c = handle.getCrosshairCandle();
395
- lastCrosshairTime.current = c?.timeMs ?? null;
396
- onCrosshair?.({ active: true, candle: c, reason: "show" });
406
+ const info = handle.getCrosshairInfo();
407
+ lastCrosshairTime.current = info?.timeMs ?? null;
408
+ onCrosshair?.({
409
+ active: true,
410
+ candle: info?.candle ?? null,
411
+ timeMs: info?.timeMs ?? null,
412
+ reason: "show"
413
+ });
397
414
  });
398
415
  const tap = import_react_native_gesture_handler.Gesture.Tap().runOnJS(true).onStart((e) => {
399
416
  if (!handle || !crosshairActive.current) return;
@@ -402,7 +419,7 @@ function VroomChart(props) {
402
419
  const ch = handle.clearCrosshair();
403
420
  if (ch) pictureSV.value = ch;
404
421
  lastCrosshairTime.current = null;
405
- onCrosshair?.({ active: false, candle: null, reason: "hide" });
422
+ onCrosshair?.({ active: false, candle: null, timeMs: null, reason: "hide" });
406
423
  });
407
424
  const gesture = import_react_native_gesture_handler.Gesture.Simultaneous(pan, pinch, longPress, tap);
408
425
  return /* @__PURE__ */ import_react2.default.createElement(