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.
@@ -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.
@@ -62,6 +62,12 @@ typedef enum {
62
62
  VROOM_COLOR_TOOLTIP_BG,
63
63
  VROOM_COLOR_TOOLTIP_TEXT,
64
64
  VROOM_COLOR_CROSSHAIR_TARGET, // the hollow ring/dot at the intersection
65
+ VROOM_COLOR_BORDER_BULL, // bull body 1px outline; 0 alpha => inherit BULL fill
66
+ VROOM_COLOR_BORDER_BEAR, // bear body 1px outline; 0 alpha => inherit BEAR fill
67
+ VROOM_COLOR_WICK_BULL, // bull wick; 0 alpha => inherit BULL fill
68
+ VROOM_COLOR_WICK_BEAR, // bear wick; 0 alpha => inherit BEAR fill
69
+ VROOM_COLOR_ACCENT_BULL, // generic up color: price indicator, volume, MACD
70
+ VROOM_COLOR_ACCENT_BEAR, // generic down color
65
71
  VROOM_COLOR_COUNT_
66
72
  } VroomColorKey;
67
73
 
@@ -126,6 +132,19 @@ void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_px);
126
132
  void vroom_chart_scale_price_axis(VroomChart* chart, float dy_px);
127
133
  void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px);
128
134
 
135
+ // Scale the y-axis of the below-chart indicator pane that contains y_px.
136
+ // dy_px > 0 (drag down) zooms out (widens the visible value range); dy_px < 0
137
+ // zooms in. RSI scales about 50, MACD about its zero line. No-op when y_px is
138
+ // not over an indicator pane.
139
+ void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px, float dy_px);
140
+
141
+ // Drag the separator between the price pane and the below-chart indicator band.
142
+ // dy_px > 0 (drag down) grows the price pane and shrinks the indicator band.
143
+ // Candle pixel scale is preserved: the price range is widened/narrowed in step
144
+ // (anchored at the top price), so the viewport reveals/hides price rather than
145
+ // rescaling candles. No-op when no indicator pane is shown.
146
+ void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px);
147
+
129
148
  // Reads the current y-axis width, x-axis height, and below-chart indicator pane
130
149
  // height in pixels so callers can hit-test gestures against each region on the
131
150
  // JS side. out_indicator_height_px is 0 when no indicator pane is shown.
@@ -145,6 +164,24 @@ void vroom_chart_clear_crosshair(VroomChart* chart);
145
164
  // from the current crosshair x and visible window, matching what's rendered.
146
165
  bool vroom_chart_get_crosshair_candle(VroomChart* chart, VroomCandle* out);
147
166
 
167
+ // What the crosshair currently snaps to, including future candle-aligned slots
168
+ // to the right of the last candle (empty space the chart leaves ahead of the
169
+ // most recent bar). `time_ms` is the snapped slot's period-start time — always
170
+ // valid. `has_candle` is true when a real candle sits at that slot, in which
171
+ // case `candle` holds its OHLCV; in the future region `has_candle` is false and
172
+ // `candle` is left untouched.
173
+ typedef struct VroomCrosshairInfo {
174
+ int64_t time_ms;
175
+ bool has_candle;
176
+ VroomCandle candle;
177
+ } VroomCrosshairInfo;
178
+
179
+ // Fills *out with the snapped slot the crosshair currently sits on and returns
180
+ // true. Returns false (leaving *out untouched) when the crosshair is inactive
181
+ // or there are no visible candles. Stateless — recomputes the snap from the
182
+ // current crosshair x and visible window, matching what's rendered.
183
+ bool vroom_chart_get_crosshair_info(VroomChart* chart, VroomCrosshairInfo* out);
184
+
148
185
  // ---- Indicators -----------------------------------------------------------
149
186
 
150
187
  // Configures the RSI indicator (rendered in a pane below the candles). `period`
@@ -14,6 +14,14 @@
14
14
 
15
15
  namespace vroom::candles {
16
16
 
17
+ namespace {
18
+ // New border/wick colors use a transparent sentinel (alpha == 0) to mean
19
+ // "inherit the body fill color". Resolve to `fill` in that case.
20
+ inline uint32_t resolve_color(uint32_t color, uint32_t fill) {
21
+ return (color >> 24) == 0 ? fill : color;
22
+ }
23
+ } // namespace
24
+
17
25
  void draw(SkCanvas* canvas,
18
26
  const ::VroomCandle* visible,
19
27
  std::size_t n,
@@ -28,22 +36,40 @@ void draw(SkCanvas* canvas,
28
36
  const float body_w = vroom::candle_body_width(
29
37
  lay, window_ms, candle_duration_ms);
30
38
 
39
+ const uint32_t fill_bull = theme.colors[VROOM_COLOR_BULL];
40
+ const uint32_t fill_bear = theme.colors[VROOM_COLOR_BEAR];
41
+
31
42
  SkPaint bull_paint;
32
43
  bull_paint.setAntiAlias(true);
33
- bull_paint.setColor(theme.colors[VROOM_COLOR_BULL]);
44
+ bull_paint.setColor(fill_bull);
34
45
 
35
46
  SkPaint bear_paint;
36
47
  bear_paint.setAntiAlias(true);
37
- bear_paint.setColor(theme.colors[VROOM_COLOR_BEAR]);
48
+ bear_paint.setColor(fill_bear);
38
49
 
39
50
  SkPaint wick_bull;
40
51
  wick_bull.setAntiAlias(true);
41
- wick_bull.setColor(theme.colors[VROOM_COLOR_BULL]);
52
+ wick_bull.setColor(
53
+ resolve_color(theme.colors[VROOM_COLOR_WICK_BULL], fill_bull));
42
54
  wick_bull.setStrokeWidth(theme.floats[VROOM_FLOAT_WICK_WIDTH_PX]);
43
55
  wick_bull.setStyle(SkPaint::kStroke_Style);
44
56
 
45
57
  SkPaint wick_bear = wick_bull;
46
- wick_bear.setColor(theme.colors[VROOM_COLOR_BEAR]);
58
+ wick_bear.setColor(
59
+ resolve_color(theme.colors[VROOM_COLOR_WICK_BEAR], fill_bear));
60
+
61
+ // 1px body outlines. Default sentinel resolves to the fill color, so the
62
+ // border is invisible until a consumer sets borderBull/borderBear.
63
+ SkPaint border_bull;
64
+ border_bull.setAntiAlias(true);
65
+ border_bull.setStyle(SkPaint::kStroke_Style);
66
+ border_bull.setStrokeWidth(1.f);
67
+ border_bull.setColor(
68
+ resolve_color(theme.colors[VROOM_COLOR_BORDER_BULL], fill_bull));
69
+
70
+ SkPaint border_bear = border_bull;
71
+ border_bear.setColor(
72
+ resolve_color(theme.colors[VROOM_COLOR_BORDER_BEAR], fill_bear));
47
73
 
48
74
  const float half_body = body_w * 0.5f;
49
75
 
@@ -65,9 +91,9 @@ void draw(SkCanvas* canvas,
65
91
  const float y_top = std::min(y_open, y_close);
66
92
  const float y_bot = std::max(y_open, y_close);
67
93
  const float h = std::max(1.f, y_bot - y_top);
68
- canvas->drawRect(
69
- SkRect::MakeXYWH(cx - half_body, y_top, body_w, h),
70
- bull ? bull_paint : bear_paint);
94
+ const SkRect body = SkRect::MakeXYWH(cx - half_body, y_top, body_w, h);
95
+ canvas->drawRect(body, bull ? bull_paint : bear_paint);
96
+ canvas->drawRect(body, bull ? border_bull : border_bear);
71
97
  }
72
98
  }
73
99
 
@@ -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.
@@ -78,6 +78,9 @@ struct VroomChart {
78
78
  std::vector<double> rsi_cache; // RSI per candle (NaN where undefined)
79
79
  std::vector<double> rsi_ma_cache; // SMA of rsi_cache (empty if MA off)
80
80
  bool rsi_dirty = true;
81
+ // User y-axis zoom for the RSI pane (drag on its y-axis strip). 1.0 = the
82
+ // default 0..100 fit; >1 zooms in (taller), <1 zooms out. Anchored at 50.
83
+ double rsi_y_scale = 1.0;
81
84
 
82
85
  // MACD, drawn in its own pane. Caches aligned to `candles` (NaN warmup).
83
86
  bool macd_enabled = false;
@@ -88,6 +91,9 @@ struct VroomChart {
88
91
  std::vector<double> macd_signal_cache;
89
92
  std::vector<double> macd_hist_cache;
90
93
  bool macd_dirty = true;
94
+ // User y-axis zoom for the MACD pane (drag on its y-axis strip). 1.0 = the
95
+ // default auto-fit amplitude; >1 zooms in, <1 zooms out. Anchored at zero.
96
+ double macd_y_scale = 1.0;
91
97
 
92
98
  // Stacking order for the indicator panes: each indicator gets the next
93
99
  // sequence number on its off->on transition, so the most recently enabled
@@ -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;
@@ -318,6 +387,102 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
318
387
  chart->mark_dirty();
319
388
  }
320
389
 
390
+ extern "C" void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px) {
391
+ if (!chart || dy_px == 0.f) return;
392
+
393
+ const int pane_count = (chart->rsi_enabled ? 1 : 0) + (chart->macd_enabled ? 1 : 0);
394
+ if (pane_count == 0) return; // nothing below the chart to resize
395
+
396
+ const auto lay = chart->layout();
397
+ const float old_pane_bottom = vroom::price_pane_bottom(lay);
398
+ const float old_band = lay.indicator_area_h;
399
+
400
+ // Drag down (dy > 0) shrinks the indicator band and grows the price pane.
401
+ // Keep both regions usable: each pane >= kMinPanePx, price pane >= a share
402
+ // of the candle+indicator area.
403
+ constexpr float kMinPanePx = 48.f;
404
+ const float content_h = lay.height_px - lay.x_axis_height_px; // candle + indicators
405
+ const float min_band = static_cast<float>(pane_count) * kMinPanePx;
406
+ const float max_band = content_h * 0.70f; // leave the price pane >= 30%
407
+ if (max_band <= min_band) return;
408
+
409
+ float new_band = old_band - dy_px;
410
+ new_band = std::clamp(new_band, min_band, max_band);
411
+ if (new_band == old_band) return;
412
+
413
+ const float per_pane = new_band / static_cast<float>(pane_count);
414
+ chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC] =
415
+ chart->height_px > 0.f ? per_pane / chart->height_px : 0.f;
416
+
417
+ // Preserve candle pixel scale: scale the price range by the price-pane
418
+ // height ratio, anchoring the top (max) price so existing candles stay put
419
+ // and the newly revealed space opens at the bottom.
420
+ if (chart->price_bounds_initialized && old_pane_bottom > 0.f) {
421
+ const float new_pane_bottom = content_h - new_band;
422
+ const double scale = static_cast<double>(new_pane_bottom) /
423
+ static_cast<double>(old_pane_bottom);
424
+ const double range = chart->price_bounds.max - chart->price_bounds.min;
425
+ if (range > 0.0 && scale > 0.0) {
426
+ chart->price_bounds.min =
427
+ chart->price_bounds.max - range * scale;
428
+ vroom::labels::recompute_axis_width(*chart);
429
+ }
430
+ }
431
+
432
+ chart->mark_dirty();
433
+ }
434
+
435
+ extern "C" void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px,
436
+ float dy_px) {
437
+ if (!chart || dy_px == 0.f) return;
438
+
439
+ const auto lay = chart->layout();
440
+ if (lay.indicator_area_h <= 0.f) return;
441
+
442
+ // Rebuild the same ordered pane stack draw_chart uses (most recently
443
+ // enabled pane sorts to the bottom) to find which pane y_px falls in.
444
+ struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
445
+ ActivePane panes[2];
446
+ int count = 0;
447
+ if (chart->rsi_enabled) panes[count++] = {chart->rsi_order, 0};
448
+ if (chart->macd_enabled) panes[count++] = {chart->macd_order, 1};
449
+ if (count == 0) return;
450
+ if (count == 2 && panes[0].order > panes[1].order) {
451
+ const ActivePane tmp = panes[0];
452
+ panes[0] = panes[1];
453
+ panes[1] = tmp;
454
+ }
455
+
456
+ const float pane_h =
457
+ chart->height_px * chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
458
+ if (pane_h <= 0.f) return;
459
+
460
+ int target = -1; // 0 = RSI, 1 = MACD
461
+ float pane_top = vroom::price_pane_bottom(lay);
462
+ for (int i = 0; i < count; ++i) {
463
+ const float pane_bottom = pane_top + pane_h;
464
+ if (y_px >= pane_top && y_px < pane_bottom) {
465
+ target = panes[i].type;
466
+ break;
467
+ }
468
+ pane_top = pane_bottom;
469
+ }
470
+ if (target < 0) return; // not over a pane
471
+
472
+ // Drag down (dy > 0) widens the visible value range (zoom out), matching
473
+ // scale_price_axis's sign. The zoom is the inverse of the range scale.
474
+ double range_scale = 1.0 + static_cast<double>(dy_px) / kAxisDragSensitivity;
475
+ if (range_scale < 0.05) range_scale = 0.05; // never collapse or flip
476
+
477
+ double& zoom = target == 0 ? chart->rsi_y_scale : chart->macd_y_scale;
478
+ double next = zoom / range_scale;
479
+ next = std::clamp(next, 0.1, 10.0);
480
+ if (next == zoom) return;
481
+ zoom = next;
482
+
483
+ chart->mark_dirty();
484
+ }
485
+
321
486
  extern "C" void vroom_chart_get_axis_metrics(VroomChart* chart,
322
487
  float* out_y_axis_width_px,
323
488
  float* out_x_axis_height_px,
@@ -387,7 +552,11 @@ extern "C" void vroom_chart_zoom(VroomChart* chart, float scale_x, float scale_y
387
552
  int64_t new_end = new_start + new_window;
388
553
  const int64_t first_time = chart->candles.front().time_ms;
389
554
  const int64_t last_time = chart->candles.back().time_ms;
390
- const int64_t max_future = new_window / 2;
555
+ // Cap the future gap at 3/4 of the (new) window so >= 25% of the
556
+ // chart still shows candles, without snapping back an existing
557
+ // larger gap (e.g. from the x-axis drag zoom).
558
+ const int64_t max_future = max_future_gap(
559
+ new_window, chart->visible_end_ms - last_time);
391
560
  if (new_end > last_time + max_future) {
392
561
  new_end = last_time + max_future;
393
562
  new_start = new_end - new_window;
@@ -453,6 +622,31 @@ extern "C" bool vroom_chart_get_crosshair_candle(VroomChart* chart,
453
622
  return true;
454
623
  }
455
624
 
625
+ extern "C" bool vroom_chart_get_crosshair_info(VroomChart* chart,
626
+ VroomCrosshairInfo* out) {
627
+ if (!chart || !out || !chart->crosshair_active || chart->candles.empty()) {
628
+ return false;
629
+ }
630
+ // Mirror draw_chart's visible-slice derivation so the queried slot is
631
+ // exactly the one the crosshair snaps to on screen.
632
+ const auto lay = chart->layout();
633
+ const auto range = vroom::visible_indices(
634
+ chart->candles.data(), chart->candles.size(),
635
+ chart->visible_start_ms, chart->visible_end_ms);
636
+ const size_t n = range.end - range.start;
637
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
638
+ if (n == 0 || window_ms <= 0) return false;
639
+
640
+ const ::VroomCandle* visible = chart->candles.data() + range.start;
641
+ const vroom::SnapResult snap = vroom::snap_to_slot(
642
+ lay, visible, n, chart->candle_duration_ms,
643
+ chart->visible_start_ms, window_ms, chart->crosshair_x_px);
644
+ out->time_ms = snap.time_ms;
645
+ out->has_candle = snap.has_candle;
646
+ if (snap.has_candle) out->candle = visible[snap.index];
647
+ return true;
648
+ }
649
+
456
650
  // ---- Indicators -----------------------------------------------------------
457
651
 
458
652
  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