react-native-vroom-chart 0.1.0 → 0.1.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.
@@ -42,6 +42,7 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
42
42
  out.push_back(jsi::PropNameID::forAscii(rt, "setCrosshair"));
43
43
  out.push_back(jsi::PropNameID::forAscii(rt, "clearCrosshair"));
44
44
  out.push_back(jsi::PropNameID::forAscii(rt, "getCrosshairCandle"));
45
+ out.push_back(jsi::PropNameID::forAscii(rt, "getCrosshairInfo"));
45
46
  out.push_back(jsi::PropNameID::forAscii(rt, "setRSI"));
46
47
  out.push_back(jsi::PropNameID::forAscii(rt, "setMACD"));
47
48
  out.push_back(jsi::PropNameID::forAscii(rt, "setOverlays"));
@@ -336,6 +337,42 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
336
337
  });
337
338
  }
338
339
 
340
+ if (name == "getCrosshairInfo") {
341
+ // getCrosshairInfo() -> { timeMs, candle: {...} | null } | null. The slot
342
+ // the crosshair snaps to (real candle or a future candle-aligned slot past
343
+ // the last bar); null when the crosshair is inactive. `candle` is null in
344
+ // the empty space ahead of the most recent candle. No rendering.
345
+ return jsi::Function::createFromHostFunction(
346
+ rt,
347
+ jsi::PropNameID::forAscii(rt, "getCrosshairInfo"),
348
+ 0,
349
+ [this](jsi::Runtime& rt2,
350
+ const jsi::Value& /*thisVal*/,
351
+ const jsi::Value* /*args*/,
352
+ size_t /*count*/) -> jsi::Value {
353
+ VroomCrosshairInfo info{};
354
+ if (!vroom_chart_get_crosshair_info(chart_, &info)) {
355
+ return jsi::Value::null();
356
+ }
357
+ jsi::Object obj(rt2);
358
+ obj.setProperty(rt2, "timeMs", static_cast<double>(info.time_ms));
359
+ if (info.has_candle) {
360
+ jsi::Object c(rt2);
361
+ c.setProperty(rt2, "timeMs",
362
+ static_cast<double>(info.candle.time_ms));
363
+ c.setProperty(rt2, "open", info.candle.open);
364
+ c.setProperty(rt2, "high", info.candle.high);
365
+ c.setProperty(rt2, "low", info.candle.low);
366
+ c.setProperty(rt2, "close", info.candle.close);
367
+ c.setProperty(rt2, "volume", info.candle.volume);
368
+ obj.setProperty(rt2, "candle", c);
369
+ } else {
370
+ obj.setProperty(rt2, "candle", jsi::Value::null());
371
+ }
372
+ return obj;
373
+ });
374
+ }
375
+
339
376
  if (name == "setRSI") {
340
377
  // setRSI(enabled, period, upperBand, lowerBand, maEnabled, maPeriod) —
341
378
  // configures the RSI pane. No render; the next render() picks it up.
@@ -145,6 +145,24 @@ void vroom_chart_clear_crosshair(VroomChart* chart);
145
145
  // from the current crosshair x and visible window, matching what's rendered.
146
146
  bool vroom_chart_get_crosshair_candle(VroomChart* chart, VroomCandle* out);
147
147
 
148
+ // What the crosshair currently snaps to, including future candle-aligned slots
149
+ // to the right of the last candle (empty space the chart leaves ahead of the
150
+ // most recent bar). `time_ms` is the snapped slot's period-start time — always
151
+ // valid. `has_candle` is true when a real candle sits at that slot, in which
152
+ // case `candle` holds its OHLCV; in the future region `has_candle` is false and
153
+ // `candle` is left untouched.
154
+ typedef struct VroomCrosshairInfo {
155
+ int64_t time_ms;
156
+ bool has_candle;
157
+ VroomCandle candle;
158
+ } VroomCrosshairInfo;
159
+
160
+ // Fills *out with the snapped slot the crosshair currently sits on and returns
161
+ // true. Returns false (leaving *out untouched) when the crosshair is inactive
162
+ // or there are no visible candles. Stateless — recomputes the snap from the
163
+ // current crosshair x and visible window, matching what's rendered.
164
+ bool vroom_chart_get_crosshair_info(VroomChart* chart, VroomCrosshairInfo* out);
165
+
148
166
  // ---- Indicators -----------------------------------------------------------
149
167
 
150
168
  // Configures the RSI indicator (rendered in a pane below the candles). `period`
@@ -256,11 +256,17 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
256
256
  // horizontal line + ring stay in the price pane (clamped to
257
257
  // candle_area_h).
258
258
  if (crosshair_active) {
259
- const float snap_x = vroom::snap_x_to_candle(
259
+ // Snap once: the slot gives both the vertical line's x (its center) and
260
+ // the time shown in the date badge. (snap_x_to_candle does exactly this
261
+ // internally, so the line position is unchanged.)
262
+ const vroom::SnapResult snap = vroom::snap_to_slot(
260
263
  lay, visible, n, candle_duration_ms, visible_start_ms, window_ms,
261
264
  crosshair_x_px);
262
- vroom::crosshair::draw(canvas, *this, candle_right, candle_area_h,
263
- vroom::x_axis_top(lay), snap_x);
265
+ const float snap_x = vroom::candle_center_x(
266
+ lay, snap.time_ms, candle_duration_ms, visible_start_ms, window_ms);
267
+ vroom::crosshair::draw(canvas, *this, lay, bounds, candle_right,
268
+ candle_area_h, vroom::x_axis_top(lay), snap_x,
269
+ snap.time_ms);
264
270
  }
265
271
 
266
272
  // 8. GC fades that have fully faded out and aren't coming back.
@@ -9,6 +9,7 @@
9
9
  #include <algorithm>
10
10
  #include <cstddef>
11
11
  #include <cstdint>
12
+ #include <limits>
12
13
 
13
14
  #include "chart.h"
14
15
  #include "labels.h"
@@ -29,6 +30,30 @@ constexpr double kAxisDragSensitivity = 300.0;
29
30
  constexpr double kMinCandleBodyPx = 1.5;
30
31
  constexpr double kMaxCandleBodyPx = 32.0;
31
32
 
33
+ // Hard cap on the future gap (visible_end - last_candle): 3/4 of the window so
34
+ // at least 25% of the chart still shows candles. Never forces an existing larger
35
+ // gap (e.g. from the x-axis drag zoom) to snap back — only limits new growth.
36
+ int64_t max_future_gap(int64_t window_ms, int64_t cur_future) {
37
+ return std::max<int64_t>((window_ms * 3) / 4, cur_future);
38
+ }
39
+
40
+ // Rubber-band a forward (into-future) pan delta: once the current future gap is
41
+ // past the soft cap (window/2), damp the delta quadratically toward zero as it
42
+ // nears the hard cap (3*window/4). Backward (into-past) deltas are unaffected.
43
+ int64_t damp_future_delta(int64_t delta_ms, int64_t cur_future,
44
+ int64_t window_ms) {
45
+ if (delta_ms <= 0) return delta_ms; // only resist forward motion
46
+ const int64_t soft = window_ms / 2;
47
+ if (cur_future <= soft) return delta_ms; // free until the soft cap
48
+ const int64_t hard = (window_ms * 3) / 4;
49
+ const double span = static_cast<double>(hard - soft);
50
+ if (span <= 0.0) return 0;
51
+ const double depth = std::clamp(
52
+ static_cast<double>(cur_future - soft) / span, 0.0, 1.0);
53
+ const double resist = (1.0 - depth) * (1.0 - depth);
54
+ return static_cast<int64_t>(static_cast<double>(delta_ms) * resist);
55
+ }
56
+
32
57
  } // namespace
33
58
 
34
59
  // ---- Lifecycle -------------------------------------------------------------
@@ -72,9 +97,11 @@ extern "C" void vroom_chart_set_candles(VroomChart* chart, const VroomCandle* da
72
97
  chart->visible_end_ms = chart->candles.back().time_ms;
73
98
  }
74
99
 
75
- // Snap the price (y-axis) bounds to whatever's visible right now. After
76
- // this, only zoom / axis-drags change them; panning preserves them.
77
- if (!chart->candles.empty()) {
100
+ // Snap the price (y-axis) bounds to whatever's visible but only on the
101
+ // first load. After they're initialized, leave them alone so live data
102
+ // updates (new / updated candles) preserve the user's pan/scale; from then
103
+ // on only zoom / axis-drags / vertical translate change them.
104
+ if (!chart->candles.empty() && !chart->price_bounds_initialized) {
78
105
  const auto idx = vroom::visible_indices(
79
106
  chart->candles.data(), chart->candles.size(),
80
107
  chart->visible_start_ms, chart->visible_end_ms);
@@ -154,15 +181,22 @@ extern "C" void vroom_chart_pan(VroomChart* chart, float dx_px, float /*dy_px*/)
154
181
  (-dx_px / usable_px) * static_cast<float>(window_ms));
155
182
  if (delta_ms == 0) return;
156
183
 
157
- int64_t new_start = chart->visible_start_ms + delta_ms;
158
- int64_t new_end = chart->visible_end_ms + delta_ms;
159
-
160
- // Right edge can overshoot the last candle by up to half a window so the
161
- // user can scroll into empty "future" space (e.g., to view right-anchored
162
- // indicators). Left edge still hard-clamps at the first candle.
184
+ // The right edge can overshoot the last candle into empty "future" space.
185
+ // Free scrolling up to half a window (candles fill >= 50%); from there to
186
+ // 3/4 of a window (candles fill >= 25%) panning forward is rubber-banded so
187
+ // it slows and hard-stops at the cap. The x-axis drag zoom can leave a gap
188
+ // larger than the cap (it zooms in until ~one candle remains), so the cap
189
+ // never forces it back it just shrinks naturally as the user scrolls.
190
+ // Left edge still hard-clamps at the first candle.
163
191
  const int64_t first_time = chart->candles.front().time_ms;
164
192
  const int64_t last_time = chart->candles.back().time_ms;
165
- const int64_t max_future = window_ms / 2;
193
+ const int64_t cur_future = chart->visible_end_ms - last_time;
194
+ const int64_t eff_delta = damp_future_delta(delta_ms, cur_future, window_ms);
195
+
196
+ int64_t new_start = chart->visible_start_ms + eff_delta;
197
+ int64_t new_end = chart->visible_end_ms + eff_delta;
198
+
199
+ const int64_t max_future = max_future_gap(window_ms, cur_future);
166
200
  if (new_end > last_time + max_future) {
167
201
  new_end = last_time + max_future;
168
202
  new_start = new_end - window_ms;
@@ -202,11 +236,18 @@ extern "C" void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_p
202
236
  const int64_t delta_ms = static_cast<int64_t>(
203
237
  (-dx_px / usable_px) * static_cast<float>(window_ms));
204
238
  if (delta_ms != 0) {
205
- int64_t new_start = chart->visible_start_ms + delta_ms;
206
- int64_t new_end = chart->visible_end_ms + delta_ms;
239
+ // See vroom_chart_pan: rubber-band forward scrolling between the
240
+ // 50% and 25%-candles caps, and let an existing larger gap (from
241
+ // the x-axis drag zoom) shrink naturally instead of snapping.
207
242
  const int64_t first_time = chart->candles.front().time_ms;
208
243
  const int64_t last_time = chart->candles.back().time_ms;
209
- const int64_t max_future = window_ms / 2;
244
+ const int64_t cur_future = chart->visible_end_ms - last_time;
245
+ const int64_t eff_delta =
246
+ damp_future_delta(delta_ms, cur_future, window_ms);
247
+ int64_t new_start = chart->visible_start_ms + eff_delta;
248
+ int64_t new_end = chart->visible_end_ms + eff_delta;
249
+ const int64_t max_future =
250
+ max_future_gap(window_ms, cur_future);
210
251
  if (new_end > last_time + max_future) {
211
252
  new_end = last_time + max_future;
212
253
  new_start = new_end - window_ms;
@@ -286,23 +327,51 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
286
327
  int64_t new_window_ms = static_cast<int64_t>(
287
328
  static_cast<double>(window_ms) * scale);
288
329
 
289
- // Clamp window so candle body width stays in [kMinCandleBodyPx,
290
- // kMaxCandleBodyPx]. body_w = usable × (dur / window) × ratio →
330
+ // Candle body width bounds [kMinCandleBodyPx, kMaxCandleBodyPx] map to a
331
+ // window range: body_w = usable × (dur / window) × ratio →
291
332
  // window for a given body_w = usable × dur × ratio / body_w.
292
333
  const auto lay = chart->layout();
293
334
  const double usable =
294
335
  lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
295
336
  const double ratio = chart->theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO];
296
337
  const double dur = static_cast<double>(chart->candle_duration_ms);
338
+ int64_t min_window = 0;
339
+ int64_t max_window = std::numeric_limits<int64_t>::max();
297
340
  if (usable > 0.0 && ratio > 0.0 && dur > 0.0) {
298
- const int64_t min_window = static_cast<int64_t>(
299
- (usable * dur * ratio) / kMaxCandleBodyPx);
300
- const int64_t max_window = static_cast<int64_t>(
301
- (usable * dur * ratio) / kMinCandleBodyPx);
302
- if (new_window_ms < min_window) new_window_ms = min_window;
303
- if (new_window_ms > max_window) new_window_ms = max_window;
341
+ min_window = static_cast<int64_t>((usable * dur * ratio) / kMaxCandleBodyPx);
342
+ max_window = static_cast<int64_t>((usable * dur * ratio) / kMinCandleBodyPx);
304
343
  }
305
344
 
345
+ // The right edge is pinned, so when it sits in the empty "future" space
346
+ // past the last candle (pan/scroll allow it to overshoot by up to half a
347
+ // window), zooming in shrinks the window from the left and slides candles
348
+ // off-screen. Rather than blocking it outright, allow the zoom down to a
349
+ // floor that keeps the last candle on screen, and add rubber-band
350
+ // resistance once the future region passes 50% of the window so the
351
+ // discouraged zoom visibly slows before it stops.
352
+ const int64_t last_time = chart->candles.back().time_ms;
353
+ const int64_t future_ms = chart->visible_end_ms - last_time;
354
+ if (future_ms > 0 && new_window_ms < window_ms) {
355
+ const int64_t floor_window = future_ms + chart->candle_duration_ms;
356
+ const int64_t soft_window = 2 * future_ms; // future occupies 50%
357
+ if (soft_window > floor_window) {
358
+ // depth 0 at the soft point, 1 at the floor; resistance eases the
359
+ // shrink toward zero as the window approaches the floor.
360
+ const double span = static_cast<double>(soft_window - floor_window);
361
+ double depth = (static_cast<double>(soft_window) -
362
+ static_cast<double>(window_ms)) / span;
363
+ depth = std::clamp(depth, 0.0, 1.0);
364
+ const double resist = (1.0 - depth) * (1.0 - depth);
365
+ const int64_t raw_delta = new_window_ms - window_ms; // negative
366
+ new_window_ms = window_ms + static_cast<int64_t>(
367
+ static_cast<double>(raw_delta) * resist);
368
+ }
369
+ if (new_window_ms < floor_window) new_window_ms = floor_window;
370
+ }
371
+
372
+ if (new_window_ms < min_window) new_window_ms = min_window;
373
+ if (new_window_ms > max_window) new_window_ms = max_window;
374
+
306
375
  // Pivot around the right edge — most-recent visible candle stays put.
307
376
  int64_t new_start = chart->visible_end_ms - new_window_ms;
308
377
  const int64_t first_time = chart->candles.front().time_ms;
@@ -387,7 +456,11 @@ extern "C" void vroom_chart_zoom(VroomChart* chart, float scale_x, float scale_y
387
456
  int64_t new_end = new_start + new_window;
388
457
  const int64_t first_time = chart->candles.front().time_ms;
389
458
  const int64_t last_time = chart->candles.back().time_ms;
390
- const int64_t max_future = new_window / 2;
459
+ // Cap the future gap at 3/4 of the (new) window so >= 25% of the
460
+ // chart still shows candles, without snapping back an existing
461
+ // larger gap (e.g. from the x-axis drag zoom).
462
+ const int64_t max_future = max_future_gap(
463
+ new_window, chart->visible_end_ms - last_time);
391
464
  if (new_end > last_time + max_future) {
392
465
  new_end = last_time + max_future;
393
466
  new_start = new_end - new_window;
@@ -453,6 +526,31 @@ extern "C" bool vroom_chart_get_crosshair_candle(VroomChart* chart,
453
526
  return true;
454
527
  }
455
528
 
529
+ extern "C" bool vroom_chart_get_crosshair_info(VroomChart* chart,
530
+ VroomCrosshairInfo* out) {
531
+ if (!chart || !out || !chart->crosshair_active || chart->candles.empty()) {
532
+ return false;
533
+ }
534
+ // Mirror draw_chart's visible-slice derivation so the queried slot is
535
+ // exactly the one the crosshair snaps to on screen.
536
+ const auto lay = chart->layout();
537
+ const auto range = vroom::visible_indices(
538
+ chart->candles.data(), chart->candles.size(),
539
+ chart->visible_start_ms, chart->visible_end_ms);
540
+ const size_t n = range.end - range.start;
541
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
542
+ if (n == 0 || window_ms <= 0) return false;
543
+
544
+ const ::VroomCandle* visible = chart->candles.data() + range.start;
545
+ const vroom::SnapResult snap = vroom::snap_to_slot(
546
+ lay, visible, n, chart->candle_duration_ms,
547
+ chart->visible_start_ms, window_ms, chart->crosshair_x_px);
548
+ out->time_ms = snap.time_ms;
549
+ out->has_candle = snap.has_candle;
550
+ if (snap.has_candle) out->candle = visible[snap.index];
551
+ return true;
552
+ }
553
+
456
554
  // ---- Indicators -----------------------------------------------------------
457
555
 
458
556
  extern "C" void vroom_chart_set_rsi(VroomChart* chart, bool enabled, int period,
@@ -4,13 +4,22 @@
4
4
  #pragma clang diagnostic ignored "-Wdocumentation"
5
5
  #include "include/core/SkCanvas.h"
6
6
  #include "include/core/SkColor.h"
7
+ #include "include/core/SkFont.h"
8
+ #include "include/core/SkFontTypes.h"
7
9
  #include "include/core/SkPaint.h"
10
+ #include "include/core/SkRRect.h"
11
+ #include "include/core/SkRect.h"
12
+ #include "include/core/SkTypeface.h"
8
13
  #include "include/effects/SkDashPathEffect.h"
9
14
  #pragma clang diagnostic pop
10
15
 
11
16
  #include <algorithm>
17
+ #include <cstdio>
18
+ #include <cstring>
19
+ #include <ctime>
12
20
 
13
21
  #include "chart.h"
22
+ #include "fonts.h"
14
23
  #include "theme.h"
15
24
 
16
25
  namespace vroom::crosshair {
@@ -18,14 +27,50 @@ namespace vroom::crosshair {
18
27
  namespace {
19
28
  constexpr float kRingRadius = 3.5f; // hollow dot at the intersection
20
29
  constexpr SkScalar kDash[2] = {2.f, 2.f};
30
+ constexpr float kPadV = 4.f; // badge padding above/below the text
31
+ constexpr float kPadH = 8.f; // badge padding left/right of the text
32
+ constexpr float kCorner = 6.f; // rounded-badge corner radius
33
+
34
+ // Draws a filled rounded-rect badge with `text` centered on (`cx`, `cy`).
35
+ // Matches the current-price indicator's geometry (glyph-bounds centering so the
36
+ // digits sit on the center regardless of the font's cap-height metric).
37
+ void draw_badge(SkCanvas* canvas,
38
+ const SkFont& font,
39
+ const char* text,
40
+ float cx,
41
+ float cy,
42
+ SkColor fill,
43
+ SkColor text_color) {
44
+ const size_t len = std::strlen(text);
45
+ SkRect tb;
46
+ const float text_w = font.measureText(text, len, SkTextEncoding::kUTF8, &tb);
47
+ const float box_w = text_w + 2.f * kPadH;
48
+ const float box_h = tb.height() + 2.f * kPadV;
49
+ const SkRect rect = SkRect::MakeXYWH(cx - box_w * 0.5f, cy - box_h * 0.5f,
50
+ box_w, box_h);
51
+
52
+ SkPaint box;
53
+ box.setAntiAlias(true);
54
+ box.setColor(fill);
55
+ canvas->drawRRect(SkRRect::MakeRectXY(rect, kCorner, kCorner), box);
56
+
57
+ SkPaint text_paint;
58
+ text_paint.setAntiAlias(true);
59
+ text_paint.setColor(text_color);
60
+ canvas->drawString(text, cx - text_w * 0.5f,
61
+ cy - (tb.fTop + tb.fBottom) * 0.5f, font, text_paint);
62
+ }
21
63
  } // namespace
22
64
 
23
65
  void draw(SkCanvas* canvas,
24
66
  const VroomChart& chart,
67
+ const Layout& lay,
68
+ const PriceBounds& bounds,
25
69
  float candle_right,
26
70
  float candle_area_h,
27
71
  float vline_bottom,
28
- float snap_x) {
72
+ float snap_x,
73
+ int64_t snap_time_ms) {
29
74
  if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
30
75
 
31
76
  // Vertical line + ring snap to the nearest candle's center x; the horizontal
@@ -60,6 +105,52 @@ void draw(SkCanvas* canvas,
60
105
  ring.setStyle(SkPaint::kStroke_Style);
61
106
  ring.setStrokeWidth(2.f); // thicker border so the dot reads clearly
62
107
  canvas->drawCircle(cx, cy, kRingRadius, ring);
108
+
109
+ // Axis badges (neutral grey, white text) sit on top of the axis labels and
110
+ // the current-price indicator since the crosshair is the last draw step.
111
+ // They need the axis typeface; if it isn't loaded the lines alone suffice.
112
+ auto tf = vroom::axis_typeface();
113
+ if (!tf) return;
114
+
115
+ SkFont font(tf, chart.theme.floats[VROOM_FLOAT_AXIS_FONT_SIZE_PX]);
116
+ font.setSubpixel(true);
117
+ font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
118
+
119
+ const SkColor badge_fill = chart.theme.colors[VROOM_COLOR_CROSSHAIR_TARGET];
120
+
121
+ // Date/time badge over the x-axis strip, centered on the vertical line and
122
+ // vertically aligned with the time labels (centered in the strip).
123
+ if (lay.x_axis_height_px > 0.f) {
124
+ char buf[32];
125
+ const time_t time_s = static_cast<time_t>(snap_time_ms / 1000);
126
+ struct tm tm_buf;
127
+ localtime_r(&time_s, &tm_buf);
128
+ std::snprintf(buf, sizeof(buf), "%02d/%02d %02d:%02d",
129
+ tm_buf.tm_mon + 1, tm_buf.tm_mday, tm_buf.tm_hour,
130
+ tm_buf.tm_min);
131
+
132
+ const float strip_center_y = vline_bottom + lay.x_axis_height_px * 0.5f;
133
+ // Keep the badge within the candle area so it never slides under the
134
+ // y-axis price column.
135
+ const size_t len = std::strlen(buf);
136
+ const float text_w = font.measureText(buf, len, SkTextEncoding::kUTF8);
137
+ const float half_w = (text_w + 2.f * kPadH) * 0.5f;
138
+ const float badge_cx =
139
+ std::clamp(cx, half_w, std::max(half_w, candle_right - half_w));
140
+ draw_badge(canvas, font, buf, badge_cx, strip_center_y, badge_fill,
141
+ SK_ColorWHITE);
142
+ }
143
+
144
+ // Price badge over the y-axis strip, centered on the horizontal line and
145
+ // sharing the y-axis labels' column.
146
+ if (lay.y_axis_width_px > 0.f) {
147
+ const double price = vroom::y_to_price(lay, bounds, cy);
148
+ char buf[32];
149
+ std::snprintf(buf, sizeof(buf), "%.2f", price);
150
+ const float axis_center_x = lay.width_px - lay.y_axis_width_px * 0.5f;
151
+ draw_badge(canvas, font, buf, axis_center_x, cy, badge_fill,
152
+ SK_ColorWHITE);
153
+ }
63
154
  }
64
155
 
65
156
  } // namespace vroom::crosshair
@@ -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 {
@@ -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) {