react-native-vroom-chart 0.7.0 → 0.9.0

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.
@@ -29,7 +29,7 @@ ChartHostObject::~ChartHostObject() {
29
29
  std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
30
30
  jsi::Runtime& rt) {
31
31
  std::vector<jsi::PropNameID> out;
32
- out.reserve(29);
32
+ out.reserve(39);
33
33
  out.push_back(jsi::PropNameID::forAscii(rt, "setCandles"));
34
34
  out.push_back(jsi::PropNameID::forAscii(rt, "setSize"));
35
35
  out.push_back(jsi::PropNameID::forAscii(rt, "setColor"));
@@ -38,6 +38,13 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
38
38
  out.push_back(jsi::PropNameID::forAscii(rt, "setDefaultCandleWidth"));
39
39
  out.push_back(jsi::PropNameID::forAscii(rt, "setChartType"));
40
40
  out.push_back(jsi::PropNameID::forAscii(rt, "setMorph"));
41
+ out.push_back(jsi::PropNameID::forAscii(rt, "getVisibleRange"));
42
+ out.push_back(jsi::PropNameID::forAscii(rt, "resetView"));
43
+ out.push_back(jsi::PropNameID::forAscii(rt, "resetPriceScale"));
44
+ out.push_back(jsi::PropNameID::forAscii(rt, "getVisiblePriceEnvelope"));
45
+ out.push_back(jsi::PropNameID::forAscii(rt, "preservePriceEnvelope"));
46
+ out.push_back(jsi::PropNameID::forAscii(rt, "beginIntervalMorph"));
47
+ out.push_back(jsi::PropNameID::forAscii(rt, "setIntervalMorph"));
41
48
  out.push_back(jsi::PropNameID::forAscii(rt, "pan"));
42
49
  out.push_back(jsi::PropNameID::forAscii(rt, "translate"));
43
50
  out.push_back(jsi::PropNameID::forAscii(rt, "zoom"));
@@ -297,6 +304,129 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
297
304
  });
298
305
  }
299
306
 
307
+ if (name == "getVisibleRange") {
308
+ // getVisibleRange() -> { startMs, endMs }. Both 0 while uninitialized.
309
+ return jsi::Function::createFromHostFunction(
310
+ rt,
311
+ jsi::PropNameID::forAscii(rt, "getVisibleRange"),
312
+ 0,
313
+ [this](jsi::Runtime& rt2,
314
+ const jsi::Value& /*thisVal*/,
315
+ const jsi::Value* /*args*/,
316
+ size_t /*count*/) -> jsi::Value {
317
+ int64_t s = 0, e = 0;
318
+ vroom_chart_get_visible_range(chart_, &s, &e);
319
+ jsi::Object obj(rt2);
320
+ obj.setProperty(rt2, "startMs", static_cast<double>(s));
321
+ obj.setProperty(rt2, "endMs", static_cast<double>(e));
322
+ return obj;
323
+ });
324
+ }
325
+
326
+ if (name == "resetView") {
327
+ // resetView() — reframe the most recent candles and re-enable y auto-fit.
328
+ return jsi::Function::createFromHostFunction(
329
+ rt,
330
+ jsi::PropNameID::forAscii(rt, "resetView"),
331
+ 0,
332
+ [this](jsi::Runtime& /*rt2*/,
333
+ const jsi::Value& /*thisVal*/,
334
+ const jsi::Value* /*args*/,
335
+ size_t /*count*/) -> jsi::Value {
336
+ vroom_chart_reset_view(chart_);
337
+ return jsi::Value::undefined();
338
+ });
339
+ }
340
+
341
+ if (name == "resetPriceScale") {
342
+ // resetPriceScale() — re-enable y auto-fit, leaving the time window alone.
343
+ return jsi::Function::createFromHostFunction(
344
+ rt,
345
+ jsi::PropNameID::forAscii(rt, "resetPriceScale"),
346
+ 0,
347
+ [this](jsi::Runtime& /*rt2*/,
348
+ const jsi::Value& /*thisVal*/,
349
+ const jsi::Value* /*args*/,
350
+ size_t /*count*/) -> jsi::Value {
351
+ vroom_chart_reset_price_scale(chart_);
352
+ return jsi::Value::undefined();
353
+ });
354
+ }
355
+
356
+ if (name == "getVisiblePriceEnvelope") {
357
+ // getVisiblePriceEnvelope() -> { low, high } | null. The extent the visible
358
+ // candles occupy, not the (wider) axis range. No rendering.
359
+ return jsi::Function::createFromHostFunction(
360
+ rt,
361
+ jsi::PropNameID::forAscii(rt, "getVisiblePriceEnvelope"),
362
+ 0,
363
+ [this](jsi::Runtime& rt2,
364
+ const jsi::Value& /*thisVal*/,
365
+ const jsi::Value* /*args*/,
366
+ size_t /*count*/) -> jsi::Value {
367
+ double low = 0.0, high = 0.0;
368
+ if (!vroom_chart_get_visible_price_envelope(chart_, &low, &high)) {
369
+ return jsi::Value::null();
370
+ }
371
+ jsi::Object obj(rt2);
372
+ obj.setProperty(rt2, "low", low);
373
+ obj.setProperty(rt2, "high", high);
374
+ return obj;
375
+ });
376
+ }
377
+
378
+ if (name == "preservePriceEnvelope") {
379
+ // preservePriceEnvelope(prevLow, prevHigh) — scale-lock a manual price
380
+ // range across a timeframe switch. No-op in auto-y mode.
381
+ return jsi::Function::createFromHostFunction(
382
+ rt,
383
+ jsi::PropNameID::forAscii(rt, "preservePriceEnvelope"),
384
+ 2,
385
+ [this](jsi::Runtime& /*rt2*/,
386
+ const jsi::Value& /*thisVal*/,
387
+ const jsi::Value* args,
388
+ size_t count) -> jsi::Value {
389
+ if (count < 2) return jsi::Value::undefined();
390
+ vroom_chart_preserve_price_envelope(chart_, args[0].asNumber(),
391
+ args[1].asNumber());
392
+ return jsi::Value::undefined();
393
+ });
394
+ }
395
+
396
+ if (name == "beginIntervalMorph") {
397
+ // beginIntervalMorph() — capture the visible candle geometry so the next
398
+ // setCandles can be animated as a reshape. Call before setCandles.
399
+ return jsi::Function::createFromHostFunction(
400
+ rt,
401
+ jsi::PropNameID::forAscii(rt, "beginIntervalMorph"),
402
+ 0,
403
+ [this](jsi::Runtime& /*rt2*/,
404
+ const jsi::Value& /*thisVal*/,
405
+ const jsi::Value* /*args*/,
406
+ size_t /*count*/) -> jsi::Value {
407
+ vroom_chart_begin_interval_morph(chart_);
408
+ return jsi::Value::undefined();
409
+ });
410
+ }
411
+
412
+ if (name == "setIntervalMorph") {
413
+ // setIntervalMorph(t) — advance the capture toward the new candles. `t` is
414
+ // pre-eased progress: 0 = the captured frame, 1 = settled (capture freed).
415
+ return jsi::Function::createFromHostFunction(
416
+ rt,
417
+ jsi::PropNameID::forAscii(rt, "setIntervalMorph"),
418
+ 1,
419
+ [this](jsi::Runtime& /*rt2*/,
420
+ const jsi::Value& /*thisVal*/,
421
+ const jsi::Value* args,
422
+ size_t count) -> jsi::Value {
423
+ if (count < 1) return jsi::Value::undefined();
424
+ vroom_chart_set_interval_morph(
425
+ chart_, static_cast<float>(args[0].asNumber()));
426
+ return jsi::Value::undefined();
427
+ });
428
+ }
429
+
300
430
  if (name == "pan") {
301
431
  // pan(dx, dy) -> JsiSkPicture
302
432
  // Mutates the visible range and renders in one JSI call so gesture
@@ -172,17 +172,20 @@ typedef struct VroomDrawPoint {
172
172
  // and `b`; the other two corners (a.x,b.y) and (b.x,a.y) derive.
173
173
  // 2 = pencil — a freehand path through `points` (in draw order). `a`/`b` mirror
174
174
  // the first/last point so bounds and handle code stay uniform.
175
+ // 3 = path — straight segments through `points` (in draw order), ending in
176
+ // an arrowhead on the last one. Same storage as a pencil, but
177
+ // rendered unsmoothed and with every vertex a grab handle.
175
178
  //
176
- // `points`/`point_count` are only read for kind 2; line and box leave them
177
- // null/0. Like the rest of this API the points are copied internally, so the
178
- // caller may free them as soon as the call returns.
179
+ // `points`/`point_count` are only read for kinds 2 and 3; line and box leave
180
+ // them null/0. Like the rest of this API the points are copied internally, so
181
+ // the caller may free them as soon as the call returns.
179
182
  typedef struct VroomDrawing {
180
183
  VroomDrawPoint a;
181
184
  VroomDrawPoint b;
182
185
  uint32_t color; // 0xAARRGGBB
183
186
  float width; // stroke width in px
184
- int32_t kind; // 0 = line, 1 = box, 2 = pencil
185
- const VroomDrawPoint* points; // pencil path (kind 2), else null
187
+ int32_t kind; // 0 = line, 1 = box, 2 = pencil, 3 = path
188
+ const VroomDrawPoint* points; // pencil/path points (kind 2/3), else null
186
189
  int32_t point_count; // number of `points`, else 0
187
190
  } VroomDrawing;
188
191
 
@@ -291,6 +294,9 @@ typedef enum {
291
294
  VROOM_FLOAT_VOLUME_RADIUS_PX, // volume bar top-corner radius px (0 = square)
292
295
  VROOM_FLOAT_LINE_WIDTH_PX, // line-chart-mode polyline stroke width px
293
296
  VROOM_FLOAT_LINE_GRADIENT_OPACITY, // fill under the line at its strongest (0 disables)
297
+ VROOM_FLOAT_LINE_TENSION, // 0..1 line-chart corner smoothing (0 = straight)
298
+ VROOM_FLOAT_LINE_TIP_DOT, // 0/1: dot at the line's newest end (default on)
299
+ VROOM_FLOAT_LINE_TIP_PULSE, // 0/1: expanding ring around that dot
294
300
  VROOM_FLOAT_COUNT_
295
301
  } VroomFloatKey;
296
302
 
@@ -551,18 +557,31 @@ void vroom_chart_set_drawings(VroomChart* chart, const VroomDrawing* drawings,
551
557
  size_t count);
552
558
 
553
559
  // Hit-tests pixel (x_px, y_px) against the committed drawings. On a hit, fills
554
- // *out_index with the drawing index, *out_part with 0 (endpoint A), 1
555
- // (endpoint B), or 2 (line body), *out_t with the 0..1 grab position along the
556
- // segment (A→B; 0/1 for handle hits), and returns true. Endpoint hits are only
557
- // reported for the currently selected drawing (whose handles are visible).
558
- // Returns false on a miss (out params untouched). Any out pointer may be null.
560
+ // *out_index with the drawing index, *out_part with the sub-part that was hit,
561
+ // *out_t with the 0..1 grab position along the segment (A→B; 0/1 for handle
562
+ // hits, 0 for the shapes that have no meaningful t), and returns true. Handle
563
+ // hits are only reported for the currently selected drawing (whose handles are
564
+ // visible). Returns false on a miss (out params untouched). Any out pointer may
565
+ // be null.
566
+ //
567
+ // `out_part` is per-kind (see VroomDrawing.kind):
568
+ // line — 0 (endpoint A), 1 (endpoint B), 2 (body).
569
+ // box — 0..3 (corners, in the order a, (b.x,a.y), b, (a.x,b.y)), 4 (body).
570
+ // pencil — 5 (body; a stroke has no handles).
571
+ // path — 6 (body), or VROOM_DRAW_PART_VERTEX + i for vertex i. The offset
572
+ // keeps a vertex index from colliding with the small part numbers
573
+ // above, and is why a path is capped at VROOM_PATH_MAX_POINTS.
574
+ #define VROOM_DRAW_PART_VERTEX 100
575
+ #define VROOM_PATH_MAX_POINTS 64
559
576
  bool vroom_chart_hit_test_drawing(VroomChart* chart, float x_px, float y_px,
560
577
  int32_t* out_index, int32_t* out_part,
561
578
  float* out_t);
562
579
 
563
- // Selects a committed drawing (renders its endpoint handles). `index` -1 clears
564
- // the selection. `grabbed_endpoint` 0/1 renders that handle 50% larger while it's
565
- // being dragged; -1 for none. An out-of-range index clears the selection.
580
+ // Selects a committed drawing (renders its handles). `index` -1 clears the
581
+ // selection. `grabbed_endpoint` renders that handle 50% larger while it's being
582
+ // dragged (0/1 for a line, 0 for the box corner the host normalized to, the
583
+ // vertex index for a path); -1 for none. An out-of-range index clears the
584
+ // selection.
566
585
  void vroom_chart_set_selected_drawing(VroomChart* chart, int32_t index,
567
586
  int32_t grabbed_endpoint);
568
587
 
@@ -572,8 +591,16 @@ void vroom_chart_move_drawing_endpoint(VroomChart* chart, int32_t index,
572
591
  int32_t endpoint, int64_t time_ms,
573
592
  double price);
574
593
 
594
+ // Moves one vertex of a committed path (kind 3) to a new data-space anchor, for
595
+ // live handle dragging. `vertex` indexes `points`; a/b are re-mirrored onto the
596
+ // first/last point afterwards. No-op for an out-of-range drawing/vertex index or
597
+ // a drawing that isn't a path.
598
+ void vroom_chart_move_drawing_vertex(VroomChart* chart, int32_t index,
599
+ int32_t vertex, int64_t time_ms,
600
+ double price);
601
+
575
602
  // Shifts a whole committed drawing by a *relative* data-space delta — `a`, `b`,
576
- // and (for a pencil) every path point. Used for live body dragging of shapes
603
+ // and (for a pencil or path) every point. Used for live body dragging of shapes
577
604
  // whose points can't be restated cheaply. No-op for an out-of-range index.
578
605
  void vroom_chart_translate_drawing(VroomChart* chart, int32_t index,
579
606
  int64_t d_time_ms, double d_price);
@@ -642,6 +669,19 @@ void vroom_chart_start_draft_stroke(VroomChart* chart, uint32_t color,
642
669
  void vroom_chart_append_draft_point(VroomChart* chart, int64_t time_ms,
643
670
  double price);
644
671
 
672
+ // Restates the whole in-progress path (kind 3) draft: `count` vertices placed so
673
+ // far, plus — when `has_cursor` — a rubber-band segment from the last vertex to
674
+ // (cursor_time, cursor_price) carrying the arrow tip. Each placed vertex renders
675
+ // a node dot. A path is placed one deliberate click at a time (so `count` stays
676
+ // small, see VROOM_PATH_MAX_POINTS), which is why this restates rather than
677
+ // appending: the host stays the sole owner of the vertex list, and undoing a
678
+ // point is just a shorter array. Pass count 0 with has_cursor false to show
679
+ // nothing; the points are copied, so the caller may free them on return.
680
+ void vroom_chart_set_draft_path(VroomChart* chart, const VroomDrawPoint* points,
681
+ int32_t count, bool has_cursor,
682
+ int64_t cursor_time, double cursor_price,
683
+ uint32_t color, float width);
684
+
645
685
  // Clears the draft (hides the in-progress node dots / guideline / stroke).
646
686
  void vroom_chart_clear_draft(VroomChart* chart);
647
687
 
@@ -9,6 +9,8 @@
9
9
 
10
10
  #include "chart.h"
11
11
 
12
+ #include <cmath>
13
+
12
14
  #pragma clang diagnostic push
13
15
  #pragma clang diagnostic ignored "-Wdocumentation"
14
16
  #include "include/core/SkCanvas.h"
@@ -34,6 +36,7 @@
34
36
  #include "rsi.h"
35
37
  #include "rsi_pane.h"
36
38
  #include "style_inherit.h"
39
+ #include "tip_pulse.h"
37
40
  #include "volume.h"
38
41
  #include "vwap.h"
39
42
  #include "theme.h"
@@ -191,7 +194,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
191
194
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
192
195
  theme.colors[VROOM_COLOR_LINE],
193
196
  theme.floats[VROOM_FLOAT_LINE_GRADIENT_OPACITY],
194
- fade, morph_src, morph_n, morph_t);
197
+ fade, morph_src, morph_n, morph_t,
198
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
195
199
  }
196
200
 
197
201
  // 4.5. Volume bars — drawn under the candles so candles z-index above.
@@ -239,7 +243,8 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
239
243
  canvas, lay, bounds, visible, n, window_ms,
240
244
  visible_start_ms, candle_duration_ms, candle_right, candle_area_h,
241
245
  theme.colors[VROOM_COLOR_LINE], theme.floats[VROOM_FLOAT_LINE_WIDTH_PX],
242
- fade, morph_src, morph_n, morph_t);
246
+ fade, morph_src, morph_n, morph_t,
247
+ theme.floats[VROOM_FLOAT_LINE_TENSION]);
243
248
  }
244
249
 
245
250
  // 5.5. Moving-average overlay lines (SMA/EMA) on the price pane, over the
@@ -303,6 +308,21 @@ void VroomChart::draw_chart(SkCanvas* canvas) {
303
308
  vroom::drawings::draw(canvas, *this, lay, bounds, candle_right,
304
309
  candle_area_h);
305
310
 
311
+ // 5.8. Line-mode tip marker — the dot (and optional pulse) at the newest
312
+ // close. Above the overlays so it stays the eye's anchor, but before the
313
+ // axis masks, which trim the ring at the price scale.
314
+ if (fade > 0.f && theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f) {
315
+ vroom::ma_overlay::draw_close_tip(
316
+ canvas, lay, bounds, visible, n, window_ms, visible_start_ms,
317
+ candle_duration_ms, candle_right, candle_area_h,
318
+ theme.colors[VROOM_COLOR_LINE],
319
+ theme.colors[VROOM_COLOR_BACKGROUND],
320
+ theme.floats[VROOM_FLOAT_LINE_WIDTH_PX], fade,
321
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f,
322
+ tip_pulse_elapsed_s / vroom::tip_pulse::kPeriodSeconds,
323
+ morph_src, morph_n, morph_t);
324
+ }
325
+
306
326
  // 6. Axis backgrounds (mask any candle overflow). The x-axis separator
307
327
  // line is intentionally omitted for now. The bottom strip anchors at
308
328
  // x_axis_top (below any indicator pane) so it never paints over it.
@@ -422,6 +442,17 @@ void VroomChart::begin_frame() {
422
442
  last_anim_tick = now;
423
443
  anim_started = true;
424
444
  last_dt_seconds = dt;
445
+
446
+ // Wrapped rather than accumulated: the phase is the only thing anyone reads,
447
+ // and a chart left open for hours would otherwise lose float precision on it.
448
+ tip_pulse_elapsed_s =
449
+ std::fmod(tip_pulse_elapsed_s + dt, vroom::tip_pulse::kPeriodSeconds);
450
+ }
451
+
452
+ bool VroomChart::tip_pulse_active() const {
453
+ return morph_fade > 0.f && !candles.empty() &&
454
+ theme.floats[VROOM_FLOAT_LINE_TIP_DOT] > 0.5f &&
455
+ theme.floats[VROOM_FLOAT_LINE_TIP_PULSE] > 0.5f;
425
456
  }
426
457
 
427
458
  void VroomChart::rebuild_chart_picture() {
@@ -433,6 +464,9 @@ void VroomChart::rebuild_chart_picture() {
433
464
  }
434
465
 
435
466
  bool VroomChart::is_animating_now() const {
467
+ // The pulse never finishes on its own, so this is what keeps the host loops
468
+ // requeueing frames for it.
469
+ if (tip_pulse_active()) return true;
436
470
  for (const auto& f : y_fades) {
437
471
  if (f.opacity != f.target) return true;
438
472
  }
@@ -64,6 +64,12 @@ struct VroomChart {
64
64
  float morph_collapse = 0.f;
65
65
  float morph_fade = 0.f;
66
66
 
67
+ // Phase of the line-tip pulse, in seconds into its cycle. Unlike every other
68
+ // animation here this one is core-driven: it loops with no endpoint for the
69
+ // host to ease toward, so begin_frame advances it from the frame clock and
70
+ // is_animating_now keeps the host's redraw loop alive (see tip_pulse.h).
71
+ float tip_pulse_elapsed_s = 0.f;
72
+
67
73
  // Interval morph: the outgoing candle geometry captured when a timeframe
68
74
  // switch begins, indexed from the right of the visible slice (slot 0 =
69
75
  // newest) — the pairing the preserved slot grid guarantees. Stored as
@@ -182,9 +188,10 @@ struct VroomChart {
182
188
  // Committed drawings, anchored in data space so they track the candles on
183
189
  // pan/zoom. Drawn on the price pane above candles/overlays.
184
190
  //
185
- // Mirrors the public VroomDrawing but *owns* its points, so a pencil path
186
- // (kind 2) can carry a variable number of them. For line/box `points` is
187
- // empty and only a/b are used; for pencil a/b mirror the first/last point.
191
+ // Mirrors the public VroomDrawing but *owns* its points, so a pencil stroke
192
+ // (kind 2) or path (kind 3) can carry a variable number of them. For
193
+ // line/box `points` is empty and only a/b are used; for pencil/path a/b
194
+ // mirror the first/last point.
188
195
  struct StoredDrawing {
189
196
  VroomDrawPoint a{};
190
197
  VroomDrawPoint b{};
@@ -199,7 +206,9 @@ struct VroomChart {
199
206
  // draft_a is always drawn (node dot); draft_b is drawn when draft_has_b.
200
207
  // draft_guide draws the live guideline A->B; when false only node dots show
201
208
  // (the committed segment renders via `drawings`). draft_color/draft_width
202
- // style the guideline to match the eventual line.
209
+ // style the guideline to match the eventual line. For a path (draft_kind 3)
210
+ // draft_points holds the vertices placed so far and draft_b is the cursor
211
+ // the rubber-band segment runs to (when draft_has_b).
203
212
  bool draft_active = false;
204
213
  VroomDrawPoint draft_a{};
205
214
  bool draft_has_b = false;
@@ -207,14 +216,16 @@ struct VroomChart {
207
216
  bool draft_guide = false;
208
217
  uint32_t draft_color = 0xff2962ff;
209
218
  float draft_width = 2.f;
210
- int32_t draft_kind = 0; // 0 = line, 1 = box, 2 = pencil (VroomDrawing)
211
- // Freehand stroke in progress (draft_kind 2), grown one point at a time.
219
+ int32_t draft_kind = 0; // 0 = line, 1 = box, 2 = pencil, 3 = path
220
+ // Points of the in-progress stroke (draft_kind 2, grown one at a time) or
221
+ // path (draft_kind 3, restated per click/move).
212
222
  std::vector<VroomDrawPoint> draft_points;
213
223
 
214
224
  // Selection/editing state for committed drawings. selected_drawing indexes
215
- // `drawings` (or -1); its endpoints render as handles. grabbed_endpoint is
216
- // 0 (A) or 1 (B) while that handle is being dragged (rendered 50% larger),
217
- // else -1.
225
+ // `drawings` (or -1); its handles render on top. grabbed_endpoint is the
226
+ // handle being dragged — 0 (A) or 1 (B) for a line, 0 for a box (the host
227
+ // normalizes the grabbed corner to endpoint 0), the vertex index for a path
228
+ // — rendered 50% larger; -1 when none.
218
229
  int32_t selected_drawing = -1;
219
230
  int32_t grabbed_endpoint = -1;
220
231
 
@@ -302,14 +313,20 @@ struct VroomChart {
302
313
  void draw_chart(SkCanvas* canvas);
303
314
 
304
315
  // Per-frame animation bookkeeping: computes `last_dt_seconds`, which paces
305
- // the label fades. Called at the top of draw_chart.
316
+ // the label fades, and advances `tip_pulse_elapsed_s`. Called at the top of
317
+ // draw_chart.
306
318
  void begin_frame();
307
319
 
320
+ // True when the line tip's pulse ring is on screen and looping. Gated on
321
+ // line mode and on having data, so a chart that isn't showing the ring can
322
+ // still go idle.
323
+ bool tip_pulse_active() const;
324
+
308
325
  // Re-records `chart_picture` by invoking draw_chart on a fresh
309
326
  // SkPictureRecorder.
310
327
  void rebuild_chart_picture();
311
328
 
312
- // True if any axis label is mid-fade. Used by the JS-side animation loop
313
- // to know when to keep ticking.
329
+ // True if any axis label is mid-fade, or the tip pulse is running. Used by
330
+ // the JS-side animation loop to know when to keep ticking.
314
331
  bool is_animating_now() const;
315
332
  };
@@ -32,6 +32,17 @@ constexpr double kAxisDragSensitivity = 300.0;
32
32
  constexpr double kMinCandleBodyPx = 1.5;
33
33
  constexpr double kMaxCandleBodyPx = 32.0;
34
34
 
35
+ // Trailing gap the default framing leaves between the newest candle and the
36
+ // price axis. The newest bar flush against the axis reads as cramped, and in line
37
+ // mode the tip marker's pulse needs somewhere to expand into — the axis mask
38
+ // (draw_chart step 6) paints over the gutter, so clearance has to come from the
39
+ // time window rather than from right_padding_px.
40
+ //
41
+ // A gap this small is far under the 3/4-window cap, so it neither triggers the
42
+ // pan rubber-band nor gets clamped away; a timeframe switch carries it over in
43
+ // slots (see timeframeWindow in the hosts), which preserves it in pixels.
44
+ constexpr double kRightGapPx = 12.0;
45
+
35
46
  // Hard cap on the future gap (visible_end - last_candle): 3/4 of the window so
36
47
  // at least 25% of the chart still shows candles. Never forces an existing larger
37
48
  // gap (e.g. from the x-axis drag zoom) to snap back — only limits new growth.
@@ -70,14 +81,29 @@ void apply_default_framing(VroomChart* chart) {
70
81
  const int64_t right_edge_ms =
71
82
  chart->candles.back().time_ms + chart->candle_duration_ms;
72
83
 
84
+ const auto lay = chart->layout();
85
+ const double usable =
86
+ lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
87
+
88
+ // Places a window of the given width with kRightGapPx of air past the newest
89
+ // candle. Shifts the window forward rather than widening it, so the framing
90
+ // keeps whatever candle width it just computed. Falls back to a flush right
91
+ // edge when the layout isn't measured yet and px can't be converted to time.
92
+ const auto frame = [&](int64_t window_ms) {
93
+ const int64_t gap_ms =
94
+ usable > 0.0 && window_ms > 0
95
+ ? static_cast<int64_t>((kRightGapPx * static_cast<double>(window_ms)) /
96
+ usable)
97
+ : 0;
98
+ chart->visible_end_ms = right_edge_ms + gap_ms;
99
+ chart->visible_start_ms = chart->visible_end_ms - window_ms;
100
+ };
101
+
73
102
  // Px-driven framing. Reuses the inversion from vroom_chart_scale_time_axis:
74
103
  // body_w = usable × (dur / window) × ratio → window = usable × dur × ratio / body_w.
75
104
  // Needs a valid layout (width_px); until size is known it falls through to
76
105
  // the candle-count default below.
77
106
  if (chart->default_candle_px > 0.f) {
78
- const auto lay = chart->layout();
79
- const double usable =
80
- lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
81
107
  const double ratio = chart->theme.floats[VROOM_FLOAT_CANDLE_WIDTH_RATIO];
82
108
  const double dur = static_cast<double>(chart->candle_duration_ms);
83
109
  const double body = std::clamp(static_cast<double>(chart->default_candle_px),
@@ -88,8 +114,7 @@ void apply_default_framing(VroomChart* chart) {
88
114
  chart->candles.back().time_ms - chart->candles.front().time_ms;
89
115
  window_ms = std::clamp<int64_t>(window_ms, chart->candle_duration_ms,
90
116
  span > 0 ? span : window_ms);
91
- chart->visible_end_ms = right_edge_ms;
92
- chart->visible_start_ms = chart->visible_end_ms - window_ms;
117
+ frame(window_ms);
93
118
  return;
94
119
  }
95
120
  }
@@ -98,8 +123,7 @@ void apply_default_framing(VroomChart* chart) {
98
123
  const size_t start_idx = chart->candles.size() > kDefaultVisible
99
124
  ? chart->candles.size() - kDefaultVisible
100
125
  : 0;
101
- chart->visible_start_ms = chart->candles[start_idx].time_ms;
102
- chart->visible_end_ms = right_edge_ms;
126
+ frame(right_edge_ms - chart->candles[start_idx].time_ms);
103
127
  }
104
128
 
105
129
  // On the first manual-y gesture, adopt the on-screen auto-fit bounds so the
@@ -883,8 +907,9 @@ extern "C" void vroom_chart_set_drawings(VroomChart* chart,
883
907
  const VroomDrawing* drawings,
884
908
  size_t count) {
885
909
  if (!chart) return;
886
- // Deep-copy into the owning form: a pencil's path lives in the chart, so the
887
- // caller's `points` buffer may be freed as soon as this returns.
910
+ // Deep-copy into the owning form: a pencil stroke's or path's points live in
911
+ // the chart, so the caller's `points` buffer may be freed as soon as this
912
+ // returns.
888
913
  chart->drawings.clear();
889
914
  chart->drawings.reserve(count);
890
915
  for (size_t i = 0; i < count; ++i) {
@@ -895,7 +920,7 @@ extern "C" void vroom_chart_set_drawings(VroomChart* chart,
895
920
  d.color = src.color;
896
921
  d.width = src.width;
897
922
  d.kind = src.kind;
898
- if (src.kind == 2 && src.points && src.point_count > 0) {
923
+ if ((src.kind == 2 || src.kind == 3) && src.points && src.point_count > 0) {
899
924
  d.points.assign(src.points, src.points + src.point_count);
900
925
  // Keep a/b mirroring the path ends so bounds/handle code is uniform.
901
926
  d.a = d.points.front();
@@ -1025,6 +1050,27 @@ extern "C" void vroom_chart_append_draft_point(VroomChart* chart, int64_t time_m
1025
1050
  chart->mark_dirty();
1026
1051
  }
1027
1052
 
1053
+ extern "C" void vroom_chart_set_draft_path(VroomChart* chart,
1054
+ const VroomDrawPoint* points,
1055
+ int32_t count, bool has_cursor,
1056
+ int64_t cursor_time,
1057
+ double cursor_price, uint32_t color,
1058
+ float width) {
1059
+ if (!chart) return;
1060
+ chart->draft_active = true;
1061
+ chart->draft_kind = 3;
1062
+ chart->draft_guide = true;
1063
+ chart->draft_color = color;
1064
+ chart->draft_width = width;
1065
+ chart->draft_points.clear();
1066
+ if (points && count > 0) chart->draft_points.assign(points, points + count);
1067
+ chart->draft_has_b = has_cursor;
1068
+ chart->draft_b = VroomDrawPoint{cursor_time, cursor_price};
1069
+ // draft_a doubles as the first placed vertex for the shared node-dot path.
1070
+ if (!chart->draft_points.empty()) chart->draft_a = chart->draft_points.front();
1071
+ chart->mark_dirty();
1072
+ }
1073
+
1028
1074
  extern "C" void vroom_chart_clear_draft(VroomChart* chart) {
1029
1075
  if (!chart) return;
1030
1076
  if (!chart->draft_active) return;
@@ -1151,6 +1197,21 @@ extern "C" void vroom_chart_move_drawing_endpoint(VroomChart* chart, int32_t ind
1151
1197
  chart->mark_dirty();
1152
1198
  }
1153
1199
 
1200
+ extern "C" void vroom_chart_move_drawing_vertex(VroomChart* chart, int32_t index,
1201
+ int32_t vertex, int64_t time_ms,
1202
+ double price) {
1203
+ if (!chart) return;
1204
+ if (index < 0 || index >= static_cast<int32_t>(chart->drawings.size())) return;
1205
+ auto& d = chart->drawings[index];
1206
+ if (d.kind != 3) return;
1207
+ if (vertex < 0 || vertex >= static_cast<int32_t>(d.points.size())) return;
1208
+ d.points[vertex] = VroomDrawPoint{time_ms, price};
1209
+ // a/b mirror the path ends, so moving a terminal vertex has to restate them.
1210
+ d.a = d.points.front();
1211
+ d.b = d.points.back();
1212
+ chart->mark_dirty();
1213
+ }
1214
+
1154
1215
  extern "C" void vroom_chart_translate_drawing(VroomChart* chart, int32_t index,
1155
1216
  int64_t d_time_ms, double d_price) {
1156
1217
  if (!chart) return;
@@ -1160,6 +1221,7 @@ extern "C" void vroom_chart_translate_drawing(VroomChart* chart, int32_t index,
1160
1221
  p.time_ms += d_time_ms;
1161
1222
  p.price += d_price;
1162
1223
  };
1224
+ // a/b mirror the path/stroke ends, so shifting everything keeps them in sync.
1163
1225
  shift(d.a);
1164
1226
  shift(d.b);
1165
1227
  for (VroomDrawPoint& p : d.points) shift(p);
@@ -0,0 +1,67 @@
1
+ // Monotone cubic interpolation — the math behind the line chart's optional
2
+ // corner smoothing (VROOM_FLOAT_LINE_TENSION).
3
+ //
4
+ // Deliberately monotone (Fritsch-Carlson) rather than a plain Catmull-Rom or
5
+ // cardinal spline. An unconstrained spline through the closes overshoots on a
6
+ // sharp reversal, and on a price chart that isn't just ugly — the curve dips
7
+ // below the period's low and draws a price that never traded, next to candles
8
+ // that show the real one during the candle-to-line crossfade. Limiting the
9
+ // tangents keeps every local extreme on an actual data point.
10
+ //
11
+ // Skia-free and header-only so the unit tests can cover it (see
12
+ // tests/test_curve.cpp); ma_overlay.cpp owns the walk and the path emission.
13
+
14
+ #pragma once
15
+
16
+ #include <algorithm>
17
+ #include <cmath>
18
+
19
+ namespace vroom::curve {
20
+
21
+ // The two interior control points of a cubic Bezier segment, in whatever space
22
+ // the endpoints were given in (pixels, at every call site here).
23
+ struct Controls {
24
+ float c1x, c1y;
25
+ float c2x, c2y;
26
+ };
27
+
28
+ // Slope of the straight line between two points. Non-increasing x yields 0,
29
+ // which keeps a degenerate span (duplicate timestamps, or the compressed
30
+ // spacing an interval morph can pass through) from producing infinities.
31
+ inline float secant(float x0, float y0, float x1, float y1) {
32
+ const float h = x1 - x0;
33
+ return h > 0.f ? (y1 - y0) / h : 0.f;
34
+ }
35
+
36
+ // Tangent at a point with neighboring secants `d_prev` and `d_next`.
37
+ inline float monotone_tangent(float d_prev, float d_next) {
38
+ // Opposite signs (or a flat neighbor) mean this point is a local extreme.
39
+ // A zero tangent is what pins the curve to it instead of sailing past.
40
+ if (d_prev * d_next <= 0.f) return 0.f;
41
+ // Fritsch-Carlson: holding the tangent within three times the smaller
42
+ // neighboring secant is what makes each segment monotone, and is also why
43
+ // the control points below can never leave the endpoints' y range.
44
+ const float limit = 3.f * std::min(std::fabs(d_prev), std::fabs(d_next));
45
+ return std::clamp((d_prev + d_next) * 0.5f, -limit, limit);
46
+ }
47
+
48
+ // Control points for the segment p0 -> p1, given the tangents chosen for each
49
+ // endpoint and how far to lean into them.
50
+ //
51
+ // `tension` 0 puts both tangents back on the segment's own secant, which lands
52
+ // the controls exactly on its thirds — the cubic *is* the straight line. So the
53
+ // knob is continuous at zero rather than snapping into a curve, and callers can
54
+ // interpolate it without a visible discontinuity.
55
+ inline Controls segment_controls(float x0, float y0,
56
+ float x1, float y1,
57
+ float m0, float m1,
58
+ float tension) {
59
+ const float d = secant(x0, y0, x1, y1);
60
+ const float a = d + (m0 - d) * tension;
61
+ const float b = d + (m1 - d) * tension;
62
+ const float third = (x1 - x0) / 3.f;
63
+ return Controls{x0 + third, y0 + a * third,
64
+ x1 - third, y1 - b * third};
65
+ }
66
+
67
+ } // namespace vroom::curve