react-native-vroom-chart 0.1.2 → 0.1.3

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.
@@ -49,6 +49,27 @@ typedef struct VroomOverlay {
49
49
  float width; // stroke width in px
50
50
  } VroomOverlay;
51
51
 
52
+ // A drawing anchor in data space (so a drawing tracks the candles on pan/zoom).
53
+ typedef struct VroomDrawPoint {
54
+ int64_t time_ms; // epoch milliseconds (not snapped to a candle slot)
55
+ double price;
56
+ } VroomDrawPoint;
57
+
58
+ // A committed line drawing: a two-point trendline on the price pane.
59
+ typedef struct VroomDrawing {
60
+ VroomDrawPoint a;
61
+ VroomDrawPoint b;
62
+ uint32_t color; // 0xAARRGGBB
63
+ float width; // stroke width in px
64
+ } VroomDrawing;
65
+
66
+ // A continuous data coordinate at a pixel position (no candle snapping). Used to
67
+ // translate a drawing-tool click into a data-space anchor.
68
+ typedef struct VroomCoord {
69
+ int64_t time_ms;
70
+ double price;
71
+ } VroomCoord;
72
+
52
73
  // ---- Styling keys ---------------------------------------------------------
53
74
 
54
75
  typedef enum {
@@ -109,6 +130,24 @@ void vroom_chart_set_size(VroomChart* chart, float width_px, float height_px, fl
109
130
  // ---- Viewport -------------------------------------------------------------
110
131
 
111
132
  void vroom_chart_set_visible_range(VroomChart* chart, int64_t start_ms, int64_t end_ms);
133
+
134
+ // Reads the current visible time window. Either out pointer may be null.
135
+ // Both are 0 when the window is still uninitialized.
136
+ void vroom_chart_get_visible_range(VroomChart* chart,
137
+ int64_t* out_start_ms, int64_t* out_end_ms);
138
+
139
+ // Reset to the fresh-mount view: frame the most recent ~80 candles and
140
+ // re-enable continuous y auto-fit (the price range follows the visible candles
141
+ // until the next manual y gesture). Use when the data series is wholesale
142
+ // replaced — e.g. switching assets. With no candles loaded, clears the window
143
+ // to 0/0 so the next set_candles applies the default framing.
144
+ void vroom_chart_reset_view(VroomChart* chart);
145
+
146
+ // Re-enable continuous y auto-fit only; the time window is untouched. Use
147
+ // after repositioning the window for a same-asset data swap (e.g. a timeframe
148
+ // switch) so the price scale re-fits the newly visible candles.
149
+ void vroom_chart_reset_price_scale(VroomChart* chart);
150
+
112
151
  void vroom_chart_pan(VroomChart* chart, float dx_px, float dy_px);
113
152
  // Directional zoom. scale_x scales the time window around focus_x_px (>1 =
114
153
  // narrower window, wider candles); scale_y scales the price range around
@@ -212,6 +251,33 @@ void vroom_chart_set_overlays(VroomChart* chart, const VroomOverlay* overlays,
212
251
  void vroom_chart_set_vwap(VroomChart* chart, bool enabled, int reset_offset_min,
213
252
  uint32_t color, float width);
214
253
 
254
+ // ---- Drawings (line annotations) ------------------------------------------
255
+
256
+ // Replaces the full set of committed line drawings (data-anchored, so they track
257
+ // the candles on pan/zoom). Pass count 0 to clear. Drawings render on the price
258
+ // pane above the candles/overlays and below the axis labels & crosshair.
259
+ void vroom_chart_set_drawings(VroomChart* chart, const VroomDrawing* drawings,
260
+ size_t count);
261
+
262
+ // Sets the transient in-progress "draft" the drawing tool shows while the user
263
+ // places points. Node A is always shown; when `has_b`, node B is shown too.
264
+ // `guide != 0` also draws the guideline A->B (the live line preview); `guide == 0`
265
+ // draws node dots only (the committed segment already renders via set_drawings).
266
+ // `color`/`width` style the guideline to match the eventual line.
267
+ void vroom_chart_set_draft(VroomChart* chart, int64_t a_time, double a_price,
268
+ bool has_b, int64_t b_time, double b_price,
269
+ bool guide, uint32_t color, float width);
270
+
271
+ // Clears the draft (hides the in-progress node dots / guideline).
272
+ void vroom_chart_clear_draft(VroomChart* chart);
273
+
274
+ // Fills *out with the continuous data coordinate (time_ms, price) at pixel
275
+ // (x_px, y_px) using the free (non-snapped) mapping, and returns true. Returns
276
+ // false (leaving *out untouched) when there are no candles or the viewport is
277
+ // degenerate.
278
+ bool vroom_chart_coord_at(VroomChart* chart, float x_px, float y_px,
279
+ VroomCoord* out);
280
+
215
281
  // ---- Rendering ------------------------------------------------------------
216
282
 
217
283
  void vroom_chart_draw(VroomChart* chart, SkCanvas* canvas);
@@ -21,6 +21,7 @@
21
21
  #include "candles.h"
22
22
  #include "chart_internal.h"
23
23
  #include "crosshair.h"
24
+ #include "drawings.h"
24
25
  #include "labels.h"
25
26
  #include "ma.h"
26
27
  #include "ma_overlay.h"
@@ -116,9 +117,9 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
116
117
  if (n == 0) return;
117
118
  const ::VroomCandle* visible = candles.data() + range.start;
118
119
 
119
- const auto bounds = price_bounds_initialized
120
+ const auto bounds = price_bounds_manual
120
121
  ? price_bounds
121
- : vroom::price_bounds(visible, n);
122
+ : vroom::auto_price_bounds(visible, n);
122
123
  const int64_t window_ms = visible_end_ms - visible_start_ms;
123
124
 
124
125
  const float candle_area_h = vroom::price_pane_bottom(lay);
@@ -176,6 +177,11 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
176
177
  }
177
178
  }
178
179
 
180
+ // 5.7. Drawing annotations (committed line tools + the in-progress draft).
181
+ // On the price pane above the candles/overlays, below the axis labels.
182
+ vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
183
+ candle_area_h);
184
+
179
185
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
180
186
  // line is intentionally omitted for now. The bottom strip anchors at
181
187
  // x_axis_top (below any indicator pane) so it never paints over it.
@@ -46,7 +46,7 @@ struct VroomChart {
46
46
  float height_px = 0.f;
47
47
  float px_ratio = 1.f;
48
48
 
49
- // 0/0 = uninitialized; set_candles defaults to last ~60 candles.
49
+ // 0/0 = uninitialized; set_candles defaults to last ~80 candles.
50
50
  int64_t visible_start_ms = 0;
51
51
  int64_t visible_end_ms = 0;
52
52
 
@@ -55,10 +55,13 @@ struct VroomChart {
55
55
  float axis_width_px = 0.f;
56
56
 
57
57
  // --- price bounds (y-axis state) ---------------------------------------
58
- // Persistent across pans (panning preserves the price scale). Mutated
59
- // only by pinch zoom, axis drags, vertical drag-translate.
58
+ // Two modes. Auto (price_bounds_manual == false, the default): the y-range
59
+ // continuously follows the visible candles each frame and `price_bounds`
60
+ // is ignored. Manual: the user has touched the y-axis (pinch zoom, axis
61
+ // drag, vertical drag-translate) and `price_bounds` is frozen until
62
+ // vroom_chart_reset_view / reset_price_scale re-enables auto mode.
60
63
  vroom::PriceBounds price_bounds{0.0, 1.0};
61
- bool price_bounds_initialized = false;
64
+ bool price_bounds_manual = false;
62
65
 
63
66
  // --- crosshair (not yet drawn) -----------------------------------------
64
67
  bool crosshair_active = false;
@@ -119,6 +122,24 @@ struct VroomChart {
119
122
  std::vector<unsigned char> vwap_breaks;
120
123
  bool vwap_dirty = true;
121
124
 
125
+ // --- drawings (line annotations) ---------------------------------------
126
+ // Committed two-point lines, anchored in data space so they track the
127
+ // candles on pan/zoom. Drawn on the price pane above candles/overlays.
128
+ std::vector<VroomDrawing> drawings;
129
+
130
+ // Transient in-progress "draft" the drawing tool shows while placing points.
131
+ // draft_a is always drawn (node dot); draft_b is drawn when draft_has_b.
132
+ // draft_guide draws the live guideline A->B; when false only node dots show
133
+ // (the committed segment renders via `drawings`). draft_color/draft_width
134
+ // style the guideline to match the eventual line.
135
+ bool draft_active = false;
136
+ VroomDrawPoint draft_a{};
137
+ bool draft_has_b = false;
138
+ VroomDrawPoint draft_b{};
139
+ bool draft_guide = false;
140
+ uint32_t draft_color = 0xff2962ff;
141
+ float draft_width = 2.f;
142
+
122
143
  // --- theme --------------------------------------------------------------
123
144
  vroom::Theme theme;
124
145
 
@@ -54,6 +54,33 @@ int64_t damp_future_delta(int64_t delta_ms, int64_t cur_future,
54
54
  return static_cast<int64_t>(static_cast<double>(delta_ms) * resist);
55
55
  }
56
56
 
57
+ // Frame the default view: the most recent ~80 candles — a narrower x-window
58
+ // so candles read wider. No-op when there are no candles.
59
+ void apply_default_framing(VroomChart* chart) {
60
+ if (chart->candles.empty()) return;
61
+ constexpr size_t kDefaultVisible = 80;
62
+ const size_t start_idx = chart->candles.size() > kDefaultVisible
63
+ ? chart->candles.size() - kDefaultVisible
64
+ : 0;
65
+ chart->visible_start_ms = chart->candles[start_idx].time_ms;
66
+ chart->visible_end_ms = chart->candles.back().time_ms;
67
+ }
68
+
69
+ // On the first manual-y gesture, adopt the on-screen auto-fit bounds so the
70
+ // gesture continues from exactly what the user sees. No-op once manual.
71
+ void ensure_manual_price_bounds(VroomChart* chart) {
72
+ if (chart->price_bounds_manual) return;
73
+ const auto idx = vroom::visible_indices(
74
+ chart->candles.data(), chart->candles.size(),
75
+ chart->visible_start_ms, chart->visible_end_ms);
76
+ if (idx.end > idx.start) {
77
+ chart->price_bounds = vroom::auto_price_bounds(
78
+ chart->candles.data() + idx.start, idx.end - idx.start);
79
+ } // else: no visible candles — keep whatever bounds we had
80
+ chart->price_bounds_manual = true;
81
+ vroom::labels::recompute_axis_width(*chart);
82
+ }
83
+
57
84
  } // namespace
58
85
 
59
86
  // ---- Lifecycle -------------------------------------------------------------
@@ -85,40 +112,12 @@ extern "C" void vroom_chart_set_candles(VroomChart* chart, const VroomCandle* da
85
112
  }
86
113
  vroom::labels::recompute_axis_width(*chart);
87
114
 
88
- // Default the visible window to the most recent ~80 candles when the
89
- // consumer hasn't set one a narrower x-window so candles read wider.
90
- if (chart->visible_start_ms == 0 && chart->visible_end_ms == 0 &&
91
- !chart->candles.empty()) {
92
- constexpr size_t kDefaultVisible = 80;
93
- const size_t start_idx = chart->candles.size() > kDefaultVisible
94
- ? chart->candles.size() - kDefaultVisible
95
- : 0;
96
- chart->visible_start_ms = chart->candles[start_idx].time_ms;
97
- chart->visible_end_ms = chart->candles.back().time_ms;
98
- }
99
-
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) {
105
- const auto idx = vroom::visible_indices(
106
- chart->candles.data(), chart->candles.size(),
107
- chart->visible_start_ms, chart->visible_end_ms);
108
- if (idx.end > idx.start) {
109
- auto b = vroom::price_bounds(
110
- chart->candles.data() + idx.start, idx.end - idx.start);
111
- // Widen the default y-window beyond the data's min/max so candles
112
- // fill less vertical space (shorter candles, more headroom). 1.0 =
113
- // snug; larger = wider. Only the default — zoom/axis-drag override.
114
- constexpr double kDefaultYZoom = 1.5;
115
- const double mid = (b.min + b.max) * 0.5;
116
- const double half = (b.max - b.min) * 0.5 * kDefaultYZoom;
117
- b.min = mid - half;
118
- b.max = mid + half;
119
- chart->price_bounds = b;
120
- chart->price_bounds_initialized = true;
121
- }
115
+ // Default the visible window when the consumer hasn't set one. The y-axis
116
+ // needs no equivalent: while the price scale is in auto mode the draw path
117
+ // fits it to the visible candles every frame, and once the user has taken
118
+ // it manual, live data updates must not disturb their pan/scale.
119
+ if (chart->visible_start_ms == 0 && chart->visible_end_ms == 0) {
120
+ apply_default_framing(chart);
122
121
  }
123
122
 
124
123
  chart->mark_dirty();
@@ -168,6 +167,35 @@ extern "C" void vroom_chart_set_visible_range(VroomChart* chart, int64_t start_m
168
167
  chart->mark_dirty();
169
168
  }
170
169
 
170
+ extern "C" void vroom_chart_get_visible_range(VroomChart* chart,
171
+ int64_t* out_start_ms,
172
+ int64_t* out_end_ms) {
173
+ if (!chart) return;
174
+ if (out_start_ms) *out_start_ms = chart->visible_start_ms;
175
+ if (out_end_ms) *out_end_ms = chart->visible_end_ms;
176
+ }
177
+
178
+ extern "C" void vroom_chart_reset_view(VroomChart* chart) {
179
+ if (!chart) return;
180
+ chart->visible_start_ms = 0;
181
+ chart->visible_end_ms = 0;
182
+ chart->price_bounds_manual = false;
183
+ apply_default_framing(chart);
184
+ vroom::labels::recompute_axis_width(*chart);
185
+ if (chart->cb.on_viewport_changed) {
186
+ chart->cb.on_viewport_changed(chart->user_ctx, chart->visible_start_ms,
187
+ chart->visible_end_ms);
188
+ }
189
+ chart->mark_dirty();
190
+ }
191
+
192
+ extern "C" void vroom_chart_reset_price_scale(VroomChart* chart) {
193
+ if (!chart || !chart->price_bounds_manual) return;
194
+ chart->price_bounds_manual = false;
195
+ vroom::labels::recompute_axis_width(*chart);
196
+ chart->mark_dirty();
197
+ }
198
+
171
199
  extern "C" void vroom_chart_pan(VroomChart* chart, float dx_px, float /*dy_px*/) {
172
200
  if (!chart || dx_px == 0.f || chart->candles.empty()) return;
173
201
 
@@ -269,7 +297,8 @@ extern "C" void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_p
269
297
  // Vertical: shift price bounds by the price-equivalent of dy_px. Range
270
298
  // stays constant. We divide by draw_h (the 90% slice of candle area
271
299
  // that's actually used for price → y) so translation feels 1:1.
272
- if (dy_px != 0.f && chart->price_bounds_initialized) {
300
+ if (dy_px != 0.f && !chart->candles.empty()) {
301
+ ensure_manual_price_bounds(chart);
273
302
  const float candle_area_h = vroom::price_pane_bottom(chart->layout());
274
303
  const double draw_h = static_cast<double>(candle_area_h) * 0.9;
275
304
  if (draw_h > 0.0) {
@@ -296,7 +325,7 @@ extern "C" void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_p
296
325
 
297
326
  extern "C" void vroom_chart_scale_price_axis(VroomChart* chart, float dy_px) {
298
327
  if (!chart || dy_px == 0.f) return;
299
- if (!chart->price_bounds_initialized) return;
328
+ ensure_manual_price_bounds(chart);
300
329
 
301
330
  const double range = chart->price_bounds.max - chart->price_bounds.min;
302
331
  if (range <= 0.0) return;
@@ -417,7 +446,7 @@ extern "C" void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px
417
446
  // Preserve candle pixel scale: scale the price range by the price-pane
418
447
  // height ratio, anchoring the top (max) price so existing candles stay put
419
448
  // and the newly revealed space opens at the bottom.
420
- if (chart->price_bounds_initialized && old_pane_bottom > 0.f) {
449
+ if (chart->price_bounds_manual && old_pane_bottom > 0.f) {
421
450
  const float new_pane_bottom = content_h - new_band;
422
451
  const double scale = static_cast<double>(new_pane_bottom) /
423
452
  static_cast<double>(old_pane_bottom);
@@ -502,7 +531,8 @@ extern "C" void vroom_chart_zoom(VroomChart* chart, float scale_x, float scale_y
502
531
 
503
532
  // --- Y (price) zoom around fy ------------------------------------------
504
533
  // scale_y > 1 (vertical pinch out) narrows the price range → taller candles.
505
- if (scale_y > 0.f && scale_y != 1.f && chart->price_bounds_initialized) {
534
+ if (scale_y > 0.f && scale_y != 1.f && !chart->candles.empty()) {
535
+ ensure_manual_price_bounds(chart);
506
536
  const float candle_area_h = vroom::price_pane_bottom(chart->layout());
507
537
  const double range = chart->price_bounds.max - chart->price_bounds.min;
508
538
  if (candle_area_h > 0.f && range > 0.0) {
@@ -647,6 +677,60 @@ extern "C" bool vroom_chart_get_crosshair_info(VroomChart* chart,
647
677
  return true;
648
678
  }
649
679
 
680
+ // ---- Drawings (line annotations) ------------------------------------------
681
+
682
+ extern "C" void vroom_chart_set_drawings(VroomChart* chart,
683
+ const VroomDrawing* drawings,
684
+ size_t count) {
685
+ if (!chart) return;
686
+ chart->drawings.assign(drawings, drawings + count);
687
+ chart->mark_dirty();
688
+ }
689
+
690
+ extern "C" void vroom_chart_set_draft(VroomChart* chart, int64_t a_time,
691
+ double a_price, bool has_b, int64_t b_time,
692
+ double b_price, bool guide, uint32_t color,
693
+ float width) {
694
+ if (!chart) return;
695
+ chart->draft_active = true;
696
+ chart->draft_a = VroomDrawPoint{a_time, a_price};
697
+ chart->draft_has_b = has_b;
698
+ chart->draft_b = VroomDrawPoint{b_time, b_price};
699
+ chart->draft_guide = guide;
700
+ chart->draft_color = color;
701
+ chart->draft_width = width;
702
+ chart->mark_dirty();
703
+ }
704
+
705
+ extern "C" void vroom_chart_clear_draft(VroomChart* chart) {
706
+ if (!chart) return;
707
+ if (!chart->draft_active) return;
708
+ chart->draft_active = false;
709
+ chart->mark_dirty();
710
+ }
711
+
712
+ extern "C" bool vroom_chart_coord_at(VroomChart* chart, float x_px, float y_px,
713
+ VroomCoord* out) {
714
+ if (!chart || !out || chart->candles.empty()) return false;
715
+ const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
716
+ if (window_ms <= 0) return false;
717
+ const auto lay = chart->layout();
718
+ // Mirror draw_chart's bounds: the frozen manual scale, else the auto-fit
719
+ // over the visible slice — so y_to_price matches what's on screen.
720
+ const auto range = vroom::visible_indices(
721
+ chart->candles.data(), chart->candles.size(),
722
+ chart->visible_start_ms, chart->visible_end_ms);
723
+ const size_t n = range.end - range.start;
724
+ const auto bounds =
725
+ chart->price_bounds_manual
726
+ ? chart->price_bounds
727
+ : vroom::auto_price_bounds(chart->candles.data() + range.start, n);
728
+ out->time_ms =
729
+ vroom::time_at_x(lay, chart->visible_start_ms, window_ms, x_px);
730
+ out->price = vroom::y_to_price(lay, bounds, y_px);
731
+ return true;
732
+ }
733
+
650
734
  // ---- Indicators -----------------------------------------------------------
651
735
 
652
736
  extern "C" void vroom_chart_set_rsi(VroomChart* chart, bool enabled, int period,
@@ -0,0 +1,108 @@
1
+ #include "drawings.h"
2
+
3
+ #pragma clang diagnostic push
4
+ #pragma clang diagnostic ignored "-Wdocumentation"
5
+ #include "include/core/SkCanvas.h"
6
+ #include "include/core/SkColor.h"
7
+ #include "include/core/SkPaint.h"
8
+ #include "include/core/SkPoint.h"
9
+ #include "include/core/SkRect.h"
10
+ #pragma clang diagnostic pop
11
+
12
+ #include "chart.h"
13
+
14
+ namespace vroom::drawings {
15
+
16
+ namespace {
17
+ // Node-dot styling (fixed for v1, not themed): a 4px black dot with a 2px blue
18
+ // ring around it. kNodeFillRadius is the black core; the blue stroke is centered
19
+ // at kNodeRingRadius so it sits just outside the core.
20
+ constexpr SkColor kNodeFill = 0xff000000; // black core
21
+ constexpr SkColor kNodeBorder = 0xff2962ff; // blue ring
22
+ constexpr float kNodeFillRadius = 2.f; // 4px diameter
23
+ constexpr float kNodeBorderWidth = 2.f;
24
+ constexpr float kNodeRingRadius = 3.f; // ring centered outside the core
25
+
26
+ // Maps a data-space point to pixels in the price pane.
27
+ SkPoint to_px(const VroomChart& chart, const Layout& lay, const PriceBounds& bounds,
28
+ int64_t window_ms, const VroomDrawPoint& p) {
29
+ const float x = vroom::x_at_time(lay, chart.visible_start_ms, window_ms, p.time_ms);
30
+ const float y = vroom::price_to_y(lay, bounds, p.price);
31
+ return SkPoint{x, y};
32
+ }
33
+
34
+ void draw_node(SkCanvas* canvas, SkPoint pt) {
35
+ SkPaint fill;
36
+ fill.setAntiAlias(true);
37
+ fill.setColor(kNodeFill);
38
+ canvas->drawCircle(pt.fX, pt.fY, kNodeFillRadius, fill);
39
+
40
+ SkPaint border;
41
+ border.setAntiAlias(true);
42
+ border.setColor(kNodeBorder);
43
+ border.setStyle(SkPaint::kStroke_Style);
44
+ border.setStrokeWidth(kNodeBorderWidth);
45
+ canvas->drawCircle(pt.fX, pt.fY, kNodeRingRadius, border);
46
+ }
47
+ } // namespace
48
+
49
+ void draw(SkCanvas* canvas,
50
+ const VroomChart& chart,
51
+ const Layout& lay,
52
+ const PriceBounds& bounds,
53
+ float candle_right,
54
+ float candle_area_h) {
55
+ if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
56
+ const int64_t window_ms = chart.visible_end_ms - chart.visible_start_ms;
57
+ if (window_ms <= 0) return;
58
+
59
+ const SkRect clip = SkRect::MakeLTRB(0.f, 0.f, candle_right, candle_area_h);
60
+
61
+ // 1. Committed line segments, clipped to the candle area.
62
+ if (!chart.drawings.empty()) {
63
+ canvas->save();
64
+ canvas->clipRect(clip);
65
+ for (const VroomDrawing& d : chart.drawings) {
66
+ const SkPoint a = to_px(chart, lay, bounds, window_ms, d.a);
67
+ const SkPoint b = to_px(chart, lay, bounds, window_ms, d.b);
68
+ SkPaint line;
69
+ line.setAntiAlias(true);
70
+ line.setColor(static_cast<SkColor>(d.color));
71
+ line.setStyle(SkPaint::kStroke_Style);
72
+ line.setStrokeWidth(d.width > 0.f ? d.width : 2.f);
73
+ canvas->drawLine(a, b, line);
74
+ }
75
+ canvas->restore();
76
+ }
77
+
78
+ if (!chart.draft_active) return;
79
+
80
+ const SkPoint a = to_px(chart, lay, bounds, window_ms, chart.draft_a);
81
+ const bool has_b = chart.draft_has_b;
82
+ const SkPoint b =
83
+ has_b ? to_px(chart, lay, bounds, window_ms, chart.draft_b) : a;
84
+
85
+ // 2. Guideline preview (A->B), clipped to the candle area. Only while the
86
+ // second point is still being placed (draft_guide); once committed, the
87
+ // solid segment comes from chart.drawings instead.
88
+ if (chart.draft_guide && has_b) {
89
+ canvas->save();
90
+ canvas->clipRect(clip);
91
+ SkPaint guide;
92
+ guide.setAntiAlias(true);
93
+ guide.setColor(static_cast<SkColor>(chart.draft_color));
94
+ guide.setStyle(SkPaint::kStroke_Style);
95
+ guide.setStrokeWidth(chart.draft_width > 0.f ? chart.draft_width : 2.f);
96
+ canvas->drawLine(a, b, guide);
97
+ canvas->restore();
98
+ }
99
+
100
+ // 3. Node dots on top (not clipped, so an edge dot still renders fully).
101
+ // While guiding (placing the second point) only the anchor dot shows; the
102
+ // moving end is conveyed by the guideline. Once selected (committed) both
103
+ // endpoints show dots.
104
+ draw_node(canvas, a);
105
+ if (has_b && !chart.draft_guide) draw_node(canvas, b);
106
+ }
107
+
108
+ } // namespace vroom::drawings
@@ -0,0 +1,34 @@
1
+ // Drawing annotations — user-placed line tools rendered on the price pane.
2
+ //
3
+ // Two kinds of geometry are drawn here:
4
+ // * Committed lines (chart.drawings) — two-point trendlines anchored in data
5
+ // space, so they stay glued to the candles as the user pans/zooms.
6
+ // * The transient "draft" (chart.draft_*) — the in-progress node dots and the
7
+ // live guideline shown while the user is placing a line.
8
+ //
9
+ // Like ma_overlay / crosshair this is its own module so the chart orchestrator
10
+ // stays thin. Drawn after the overlays and before the axis backgrounds.
11
+
12
+ #pragma once
13
+
14
+ #include "viewport.h"
15
+ #include "vroom/vroom_chart.h"
16
+
17
+ class SkCanvas;
18
+ struct VroomChart;
19
+
20
+ namespace vroom::drawings {
21
+
22
+ // Draws the committed line drawings and the in-progress draft. `candle_right` is
23
+ // the x of the y-axis strip and `candle_area_h` the price-pane bottom; geometry
24
+ // is clipped to that rectangle so lines never bleed into the axis strips or the
25
+ // indicator panes below. Node dots are exempt from the clip so a dot placed at
26
+ // the very edge still renders fully.
27
+ void draw(SkCanvas* canvas,
28
+ const VroomChart& chart,
29
+ const vroom::Layout& lay,
30
+ const vroom::PriceBounds& bounds,
31
+ float candle_right,
32
+ float candle_area_h);
33
+
34
+ } // namespace vroom::drawings
@@ -318,7 +318,7 @@ void recompute_axis_width(VroomChart& chart) {
318
318
  return;
319
319
  }
320
320
  double hi, lo;
321
- if (chart.price_bounds_initialized) {
321
+ if (chart.price_bounds_manual) {
322
322
  hi = chart.price_bounds.max;
323
323
  lo = chart.price_bounds.min;
324
324
  } else if (!chart.candles.empty()) {
@@ -35,6 +35,31 @@ float candle_center_x(const Layout& layout,
35
35
  return static_cast<float>(static_cast<double>(usable) * frac);
36
36
  }
37
37
 
38
+ int64_t time_at_x(const Layout& layout,
39
+ int64_t visible_start_ms,
40
+ int64_t window_ms,
41
+ float x_px) {
42
+ const float usable =
43
+ layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
44
+ if (usable <= 0.f || window_ms <= 0) return visible_start_ms;
45
+ const double frac = static_cast<double>(x_px) / static_cast<double>(usable);
46
+ return visible_start_ms +
47
+ static_cast<int64_t>(std::llround(frac * static_cast<double>(window_ms)));
48
+ }
49
+
50
+ float x_at_time(const Layout& layout,
51
+ int64_t visible_start_ms,
52
+ int64_t window_ms,
53
+ int64_t time_ms) {
54
+ if (window_ms <= 0) return 0.f;
55
+ const float usable =
56
+ layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
57
+ const double frac =
58
+ static_cast<double>(time_ms - visible_start_ms) /
59
+ static_cast<double>(window_ms);
60
+ return static_cast<float>(static_cast<double>(usable) * frac);
61
+ }
62
+
38
63
  size_t snap_index_to_candle(const Layout& layout,
39
64
  const ::VroomCandle* candles,
40
65
  size_t count,
@@ -180,6 +205,14 @@ PriceBounds price_bounds(const ::VroomCandle* candles, size_t count) {
180
205
  return b;
181
206
  }
182
207
 
208
+ PriceBounds auto_price_bounds(const ::VroomCandle* candles, size_t count) {
209
+ PriceBounds b = price_bounds(candles, count);
210
+ if (count == 0) return b;
211
+ const double mid = (b.min + b.max) * 0.5;
212
+ const double half = (b.max - b.min) * 0.5 * kAutoYZoom;
213
+ return {mid - half, mid + half};
214
+ }
215
+
183
216
  float price_to_y(const Layout& layout,
184
217
  const PriceBounds& bounds,
185
218
  double price) {
@@ -113,9 +113,33 @@ float snap_x_to_candle(const Layout& layout,
113
113
  int64_t window_ms,
114
114
  float x_px);
115
115
 
116
+ // Free (non-snapped, non-candle-centered) mapping between time and pixel x,
117
+ // used for drawing-tool endpoints that can sit anywhere — not just on a candle
118
+ // slot. `x_at_time` is the exact inverse of `time_at_x`:
119
+ // frac = (time - start) / window; x = usable * frac
120
+ // where `usable` is the candle area width (width - y-axis - right padding).
121
+ // Returns 0 / visible_start_ms respectively for a degenerate window/usable.
122
+ int64_t time_at_x(const Layout& layout,
123
+ int64_t visible_start_ms,
124
+ int64_t window_ms,
125
+ float x_px);
126
+ float x_at_time(const Layout& layout,
127
+ int64_t visible_start_ms,
128
+ int64_t window_ms,
129
+ int64_t time_ms);
130
+
116
131
  // Min/max of (low..high) across the given range.
117
132
  PriceBounds price_bounds(const ::VroomCandle* candles, size_t count);
118
133
 
134
+ // Widening applied to auto-fit price bounds so candles keep some headroom
135
+ // instead of touching the pane edges. 1.0 = snug; larger = wider.
136
+ constexpr double kAutoYZoom = 1.5;
137
+
138
+ // price_bounds() widened about its midpoint by kAutoYZoom. This is the y-range
139
+ // used whenever the price scale is in auto (follow-the-data) mode. Returns the
140
+ // {0, 1} sentinel when count == 0 (callers keep their previous bounds).
141
+ PriceBounds auto_price_bounds(const ::VroomCandle* candles, size_t count);
142
+
119
143
  // Map a price to y in pixels. y=0 is top of the chart.
120
144
  float price_to_y(const Layout& layout, const PriceBounds& bounds, double price);
121
145
 
package/lib/index.d.mts CHANGED
@@ -84,6 +84,38 @@ type VisibleRange = {
84
84
  /** Window end (inclusive), Unix epoch milliseconds. */
85
85
  endMs: number;
86
86
  };
87
+ /**
88
+ * Chart interaction mode.
89
+ * 'pan' — default: drag to pan, pinch/wheel to zoom, hover/long-press crosshair.
90
+ * 'draw' — left-clicks place drawing points; panning/zooming are suppressed.
91
+ */
92
+ type ChartMode = 'pan' | 'draw';
93
+ /** Active drawing tool while in `draw` mode. `null` draws nothing. */
94
+ type DrawTool = null | 'line';
95
+ /** A drawing anchor in data space, so it stays glued to the candles on pan/zoom. */
96
+ type DrawPoint = {
97
+ /** Anchor time as Unix epoch milliseconds (not snapped to a candle slot). */
98
+ timeMs: number;
99
+ /** Anchor price. */
100
+ price: number;
101
+ };
102
+ /**
103
+ * A committed drawing. Pass an array of these via the `drawings` prop to render
104
+ * persisted annotations; the chart appends a new one (via `onDrawingComplete`)
105
+ * each time the user finishes drawing. For now only the `'line'` (two-point
106
+ * trendline) type exists.
107
+ */
108
+ type Drawing = {
109
+ /** Stable unique id (the chart generates one for drawings it creates). */
110
+ id: string;
111
+ type: 'line';
112
+ /** The two endpoints, in data space. */
113
+ points: [DrawPoint, DrawPoint];
114
+ /** Line color (hex string or packed ARGB number). Default solid blue. */
115
+ color?: VroomColor;
116
+ /** Stroke width in px. Default 2. */
117
+ width?: number;
118
+ };
87
119
  /** RSI indicator config. Rendered in a pane below the candles when enabled. */
88
120
  type RSIConfig = {
89
121
  enabled?: boolean;
@@ -147,6 +179,14 @@ type MACDConfig = {
147
179
  type VroomChartCoreProps = {
148
180
  /** OHLCV bars to render. The only required prop. */
149
181
  candles: Candle[];
182
+ /**
183
+ * Identity of the data series (e.g. "BTC-USD"). When it changes between
184
+ * renders the chart resets to the default view (most recent candles + price
185
+ * auto-fit), regardless of what the data heuristics conclude — the escape
186
+ * hatch for ambiguous switches like two assets trading at similar prices.
187
+ * Omit to rely on automatic detection from the candle data alone.
188
+ */
189
+ seriesKey?: string;
150
190
  /**
151
191
  * Explicit size overrides in logical px. When omitted, the chart fills its
152
192
  * parent (measured at runtime). Prefer layout-driven sizing via the
@@ -171,6 +211,27 @@ type VroomChartCoreProps = {
171
211
  * touch x. Default 40.
172
212
  */
173
213
  crosshairOffset?: number;
214
+ /**
215
+ * Interaction mode. Default `'pan'`. Set to `'draw'` (with a `tool`) to let
216
+ * the user place drawing points; panning/zooming are suppressed while drawing.
217
+ */
218
+ mode?: ChartMode;
219
+ /** Active drawing tool while in `draw` mode. Default `null` (draws nothing). */
220
+ tool?: DrawTool;
221
+ /**
222
+ * Committed drawings to render, anchored to data so they track the candles on
223
+ * pan/zoom. This is a controlled prop: append the value the chart hands you in
224
+ * `onDrawingComplete` to persist it.
225
+ */
226
+ drawings?: Drawing[];
227
+ /** Fired with the finished drawing when the user completes one. */
228
+ onDrawingComplete?: (drawing: Drawing) => void;
229
+ /**
230
+ * Fired when the chart wants the mode changed — e.g. it requests `'pan'` after
231
+ * the user clicks away from a just-drawn line. Since `mode` is controlled, the
232
+ * host should apply the requested mode.
233
+ */
234
+ onModeChange?: (mode: ChartMode) => void;
174
235
  onCrosshair?: (e: CrosshairEvent) => void;
175
236
  onViewportChange?: (startMs: number, endMs: number) => void;
176
237
  };
package/lib/index.d.ts CHANGED
@@ -84,6 +84,38 @@ type VisibleRange = {
84
84
  /** Window end (inclusive), Unix epoch milliseconds. */
85
85
  endMs: number;
86
86
  };
87
+ /**
88
+ * Chart interaction mode.
89
+ * 'pan' — default: drag to pan, pinch/wheel to zoom, hover/long-press crosshair.
90
+ * 'draw' — left-clicks place drawing points; panning/zooming are suppressed.
91
+ */
92
+ type ChartMode = 'pan' | 'draw';
93
+ /** Active drawing tool while in `draw` mode. `null` draws nothing. */
94
+ type DrawTool = null | 'line';
95
+ /** A drawing anchor in data space, so it stays glued to the candles on pan/zoom. */
96
+ type DrawPoint = {
97
+ /** Anchor time as Unix epoch milliseconds (not snapped to a candle slot). */
98
+ timeMs: number;
99
+ /** Anchor price. */
100
+ price: number;
101
+ };
102
+ /**
103
+ * A committed drawing. Pass an array of these via the `drawings` prop to render
104
+ * persisted annotations; the chart appends a new one (via `onDrawingComplete`)
105
+ * each time the user finishes drawing. For now only the `'line'` (two-point
106
+ * trendline) type exists.
107
+ */
108
+ type Drawing = {
109
+ /** Stable unique id (the chart generates one for drawings it creates). */
110
+ id: string;
111
+ type: 'line';
112
+ /** The two endpoints, in data space. */
113
+ points: [DrawPoint, DrawPoint];
114
+ /** Line color (hex string or packed ARGB number). Default solid blue. */
115
+ color?: VroomColor;
116
+ /** Stroke width in px. Default 2. */
117
+ width?: number;
118
+ };
87
119
  /** RSI indicator config. Rendered in a pane below the candles when enabled. */
88
120
  type RSIConfig = {
89
121
  enabled?: boolean;
@@ -147,6 +179,14 @@ type MACDConfig = {
147
179
  type VroomChartCoreProps = {
148
180
  /** OHLCV bars to render. The only required prop. */
149
181
  candles: Candle[];
182
+ /**
183
+ * Identity of the data series (e.g. "BTC-USD"). When it changes between
184
+ * renders the chart resets to the default view (most recent candles + price
185
+ * auto-fit), regardless of what the data heuristics conclude — the escape
186
+ * hatch for ambiguous switches like two assets trading at similar prices.
187
+ * Omit to rely on automatic detection from the candle data alone.
188
+ */
189
+ seriesKey?: string;
150
190
  /**
151
191
  * Explicit size overrides in logical px. When omitted, the chart fills its
152
192
  * parent (measured at runtime). Prefer layout-driven sizing via the
@@ -171,6 +211,27 @@ type VroomChartCoreProps = {
171
211
  * touch x. Default 40.
172
212
  */
173
213
  crosshairOffset?: number;
214
+ /**
215
+ * Interaction mode. Default `'pan'`. Set to `'draw'` (with a `tool`) to let
216
+ * the user place drawing points; panning/zooming are suppressed while drawing.
217
+ */
218
+ mode?: ChartMode;
219
+ /** Active drawing tool while in `draw` mode. Default `null` (draws nothing). */
220
+ tool?: DrawTool;
221
+ /**
222
+ * Committed drawings to render, anchored to data so they track the candles on
223
+ * pan/zoom. This is a controlled prop: append the value the chart hands you in
224
+ * `onDrawingComplete` to persist it.
225
+ */
226
+ drawings?: Drawing[];
227
+ /** Fired with the finished drawing when the user completes one. */
228
+ onDrawingComplete?: (drawing: Drawing) => void;
229
+ /**
230
+ * Fired when the chart wants the mode changed — e.g. it requests `'pan'` after
231
+ * the user clicks away from a just-drawn line. Since `mode` is controlled, the
232
+ * host should apply the requested mode.
233
+ */
234
+ onModeChange?: (mode: ChartMode) => void;
174
235
  onCrosshair?: (e: CrosshairEvent) => void;
175
236
  onViewportChange?: (startMs: number, endMs: number) => void;
176
237
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-vroom-chart",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Mobile-first Skia candlestick chart for React Native",
5
5
  "license": "MIT",
6
6
  "author": "Darion Welch",
@@ -36,6 +36,13 @@
36
36
  "!**/*.test.ts",
37
37
  "!**/.*"
38
38
  ],
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "build": "tsup",
43
+ "vendor:core": "node scripts/vendor-core.mjs",
44
+ "prepack": "node scripts/vendor-core.mjs && tsup"
45
+ },
39
46
  "codegenConfig": {
40
47
  "name": "VroomChartSpec",
41
48
  "type": "modules",
@@ -56,6 +63,7 @@
56
63
  },
57
64
  "devDependencies": {
58
65
  "@types/react": "~19.1.0",
66
+ "@vroomchart/types": "workspace:*",
59
67
  "react": "19.1.0",
60
68
  "react-native": "0.81.5",
61
69
  "typescript": "~5.9.2",
@@ -63,13 +71,6 @@
63
71
  "react-native-gesture-handler": "~2.28.0",
64
72
  "react-native-reanimated": "~4.1.1",
65
73
  "tsup": "^8.5.1",
66
- "vitest": "^2.1.9",
67
- "@vroomchart/types": "0.0.1"
68
- },
69
- "scripts": {
70
- "typecheck": "tsc --noEmit",
71
- "test": "vitest run",
72
- "build": "tsup",
73
- "vendor:core": "node scripts/vendor-core.mjs"
74
+ "vitest": "^2.1.9"
74
75
  }
75
- }
76
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Darion Welch
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.