react-native-vroom-chart 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cpp/_core_include/vroom/vroom_chart.h +5 -1
- package/cpp/_core_src/chart.cpp +36 -2
- package/cpp/_core_src/chart.h +15 -3
- package/cpp/_core_src/chart_facade.cpp +91 -61
- package/cpp/_core_src/crosshair.cpp +5 -1
- package/cpp/_core_src/curve.h +67 -0
- package/cpp/_core_src/labels.cpp +8 -3
- package/cpp/_core_src/ma_overlay.cpp +200 -34
- package/cpp/_core_src/ma_overlay.h +41 -4
- package/cpp/_core_src/price_indicator.cpp +4 -1
- package/cpp/_core_src/price_lines.cpp +7 -3
- package/cpp/_core_src/theme.cpp +3 -0
- package/cpp/_core_src/ticks.cpp +14 -0
- package/cpp/_core_src/ticks.h +8 -0
- package/cpp/_core_src/tip_pulse.h +74 -0
- package/cpp/_core_src/viewport.cpp +45 -0
- package/cpp/_core_src/viewport.h +30 -0
- package/lib/index.d.mts +29 -1
- package/lib/index.d.ts +29 -1
- package/lib/index.js +15 -5
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +15 -5
- package/lib/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/VroomChart.tsx +13 -7
- package/src/theme.ts +7 -0
- package/src/useChartCore.ts +8 -1
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
#include <algorithm>
|
|
16
16
|
#include <cmath>
|
|
17
|
+
#include <vector>
|
|
17
18
|
|
|
19
|
+
#include "curve.h"
|
|
18
20
|
#include "gradient.h"
|
|
21
|
+
#include "tip_pulse.h"
|
|
19
22
|
#include "viewport.h"
|
|
20
23
|
|
|
21
24
|
namespace vroom::ma_overlay {
|
|
@@ -48,6 +51,48 @@ void stroke_path(SkCanvas* canvas,
|
|
|
48
51
|
|
|
49
52
|
inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
|
|
50
53
|
|
|
54
|
+
// Width of the background-colored ring that separates the tip dot from the line
|
|
55
|
+
// and from the pulse expanding out behind it.
|
|
56
|
+
constexpr float kTipBorderPx = 2.f;
|
|
57
|
+
|
|
58
|
+
// One vertex of the close polyline, in screen space. Slot `k` counts back from
|
|
59
|
+
// the right edge (0 = the newest close), the pairing a timeframe switch
|
|
60
|
+
// preserves.
|
|
61
|
+
//
|
|
62
|
+
// Shared by the path builder and the tip marker: the dot has to sit on the exact
|
|
63
|
+
// pixel the line ends at, including mid-interval-morph where both coordinates
|
|
64
|
+
// are interpolated against the outgoing capture.
|
|
65
|
+
SkPoint close_vertex(const Layout& lay,
|
|
66
|
+
const PriceBounds& bounds,
|
|
67
|
+
const ::VroomCandle* visible,
|
|
68
|
+
std::size_t n,
|
|
69
|
+
int64_t window_ms,
|
|
70
|
+
int64_t visible_start_ms,
|
|
71
|
+
int64_t candle_duration_ms,
|
|
72
|
+
const CandleSnapshot* from,
|
|
73
|
+
std::size_t from_count,
|
|
74
|
+
float morph_t,
|
|
75
|
+
std::size_t k) {
|
|
76
|
+
const ::VroomCandle* to = (k < n) ? &visible[n - 1 - k] : nullptr;
|
|
77
|
+
const CandleSnapshot* frm = (k < from_count) ? &from[k] : nullptr;
|
|
78
|
+
if (to) {
|
|
79
|
+
const float tx = vroom::candle_center_x(
|
|
80
|
+
lay, to->time_ms, candle_duration_ms, visible_start_ms, window_ms);
|
|
81
|
+
const float ty = vroom::price_to_y(lay, bounds, to->close);
|
|
82
|
+
if (!frm) return SkPoint{tx, ty};
|
|
83
|
+
// The capture is in band fractions, so it lands on the same pixels it
|
|
84
|
+
// occupied pre-switch even though the bounds changed.
|
|
85
|
+
const float area_w = vroom::candle_area_width(lay);
|
|
86
|
+
return SkPoint{
|
|
87
|
+
lerp(frm->x * area_w, tx, morph_t),
|
|
88
|
+
lerp(vroom::y_at_fraction(lay, frm->close), ty, morph_t)};
|
|
89
|
+
}
|
|
90
|
+
// A slot only the outgoing data had. Counts match in practice, so this just
|
|
91
|
+
// keeps gappy or short-history data from breaking the line.
|
|
92
|
+
return SkPoint{frm->x * vroom::candle_area_width(lay),
|
|
93
|
+
vroom::y_at_fraction(lay, frm->close)};
|
|
94
|
+
}
|
|
95
|
+
|
|
51
96
|
// The close-price polyline, walked left to right. Shared by the stroke and the
|
|
52
97
|
// gradient fill so both derive from identical geometry, mid-morph included.
|
|
53
98
|
//
|
|
@@ -55,6 +100,10 @@ inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
|
|
|
55
100
|
// timeframe switch preserves — which walks the line left to right, so the vertex
|
|
56
101
|
// order matches draw()'s at morph_t == 1. Every candle has a close, so there are
|
|
57
102
|
// no gaps to break the subpath on.
|
|
103
|
+
//
|
|
104
|
+
// `tension` above 0 rounds the corners (see curve.h). It smooths the *screen*
|
|
105
|
+
// positions rather than the closes, which is what keeps the line glued to the
|
|
106
|
+
// candles mid-interval-morph, where those positions are themselves interpolated.
|
|
58
107
|
SkPath build_close_path(const Layout& lay,
|
|
59
108
|
const PriceBounds& bounds,
|
|
60
109
|
const ::VroomCandle* visible,
|
|
@@ -64,45 +113,72 @@ SkPath build_close_path(const Layout& lay,
|
|
|
64
113
|
int64_t candle_duration_ms,
|
|
65
114
|
const CandleSnapshot* from,
|
|
66
115
|
std::size_t from_n,
|
|
67
|
-
float morph_t
|
|
116
|
+
float morph_t,
|
|
117
|
+
float tension) {
|
|
68
118
|
const std::size_t from_count = vroom::morph_from_count(from, from_n, morph_t);
|
|
69
119
|
const std::size_t slots = std::max(n, from_count);
|
|
70
120
|
if (slots == 0) return SkPath();
|
|
71
|
-
const float area_w = vroom::candle_area_width(lay);
|
|
72
121
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
122
|
+
// Vertex `i` of the left-to-right walk, so the smoothing pass below can look
|
|
123
|
+
// ahead without a second copy of the morph interpolation.
|
|
124
|
+
const auto vertex = [&](std::size_t i) -> SkPoint {
|
|
125
|
+
return close_vertex(lay, bounds, visible, n, window_ms, visible_start_ms,
|
|
126
|
+
candle_duration_ms, from, from_count, morph_t,
|
|
127
|
+
slots - 1 - i);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Straight segments by default, and below three points there's no interior
|
|
131
|
+
// vertex to round. Kept as its own pass so the common case emits exactly the
|
|
132
|
+
// path it always has.
|
|
133
|
+
if (!(tension > 0.f) || slots < 3) {
|
|
134
|
+
SkPathBuilder path;
|
|
135
|
+
for (std::size_t i = 0; i < slots; ++i) {
|
|
136
|
+
const SkPoint p = vertex(i);
|
|
137
|
+
if (i == 0) {
|
|
138
|
+
path.moveTo(p);
|
|
89
139
|
} else {
|
|
90
|
-
|
|
91
|
-
y = ty;
|
|
140
|
+
path.lineTo(p);
|
|
92
141
|
}
|
|
93
|
-
} else {
|
|
94
|
-
// A slot only the outgoing data had. Counts match in practice, so this
|
|
95
|
-
// just keeps gappy or short-history data from breaking the line.
|
|
96
|
-
x = frm->x * area_w;
|
|
97
|
-
y = vroom::y_at_fraction(lay, frm->close);
|
|
98
142
|
}
|
|
143
|
+
return path.detach();
|
|
144
|
+
}
|
|
99
145
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
146
|
+
// A tangent needs a neighbor on both sides, so smoothing can't stay
|
|
147
|
+
// streaming. Reused across frames — rendering is single-threaded per chart
|
|
148
|
+
// and the buffer is only live for this call — so it stops allocating after
|
|
149
|
+
// the first frame at a given zoom.
|
|
150
|
+
static thread_local std::vector<SkPoint> pts;
|
|
151
|
+
pts.clear();
|
|
152
|
+
pts.reserve(slots);
|
|
153
|
+
for (std::size_t i = 0; i < slots; ++i) pts.push_back(vertex(i));
|
|
154
|
+
|
|
155
|
+
const auto secant_at = [&](std::size_t i) {
|
|
156
|
+
return vroom::curve::secant(pts[i].fX, pts[i].fY,
|
|
157
|
+
pts[i + 1].fX, pts[i + 1].fY);
|
|
158
|
+
};
|
|
159
|
+
// Endpoints have only one neighboring secant to lean on; interiors get the
|
|
160
|
+
// monotone-limited blend of both. Recomputing a secant or two beats carrying
|
|
161
|
+
// a second buffer.
|
|
162
|
+
const auto tangent_at = [&](std::size_t i) {
|
|
163
|
+
if (i == 0) return secant_at(0);
|
|
164
|
+
if (i + 1 == pts.size()) return secant_at(pts.size() - 2);
|
|
165
|
+
return vroom::curve::monotone_tangent(secant_at(i - 1), secant_at(i));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
SkPathBuilder path;
|
|
169
|
+
path.moveTo(pts[0]);
|
|
170
|
+
for (std::size_t i = 0; i + 1 < pts.size(); ++i) {
|
|
171
|
+
// Flat or backwards spacing — duplicate timestamps, or the compressed x
|
|
172
|
+
// a morph can pass through — has no meaningful tangent, and curving
|
|
173
|
+
// through it would fold the line back on itself.
|
|
174
|
+
if (!(pts[i + 1].fX > pts[i].fX)) {
|
|
175
|
+
path.lineTo(pts[i + 1]);
|
|
176
|
+
continue;
|
|
105
177
|
}
|
|
178
|
+
const vroom::curve::Controls c = vroom::curve::segment_controls(
|
|
179
|
+
pts[i].fX, pts[i].fY, pts[i + 1].fX, pts[i + 1].fY,
|
|
180
|
+
tangent_at(i), tangent_at(i + 1), tension);
|
|
181
|
+
path.cubicTo(c.c1x, c.c1y, c.c2x, c.c2y, pts[i + 1].fX, pts[i + 1].fY);
|
|
106
182
|
}
|
|
107
183
|
return path.detach();
|
|
108
184
|
}
|
|
@@ -170,15 +246,17 @@ void draw_close_line(SkCanvas* canvas,
|
|
|
170
246
|
float opacity,
|
|
171
247
|
const CandleSnapshot* from,
|
|
172
248
|
std::size_t from_n,
|
|
173
|
-
float morph_t
|
|
249
|
+
float morph_t,
|
|
250
|
+
float tension) {
|
|
174
251
|
if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
|
|
175
252
|
opacity = std::clamp(opacity, 0.f, 1.f);
|
|
176
253
|
if (opacity <= 0.f) return;
|
|
177
254
|
morph_t = std::clamp(morph_t, 0.f, 1.f);
|
|
255
|
+
tension = std::clamp(tension, 0.f, 1.f);
|
|
178
256
|
|
|
179
257
|
const SkPath path = build_close_path(lay, bounds, visible, n, window_ms,
|
|
180
258
|
visible_start_ms, candle_duration_ms,
|
|
181
|
-
from, from_n, morph_t);
|
|
259
|
+
from, from_n, morph_t, tension);
|
|
182
260
|
if (path.isEmpty()) return;
|
|
183
261
|
|
|
184
262
|
stroke_path(canvas, path, candle_right, candle_area_h, color, width, opacity);
|
|
@@ -199,17 +277,19 @@ void draw_close_gradient(SkCanvas* canvas,
|
|
|
199
277
|
float opacity,
|
|
200
278
|
const CandleSnapshot* from,
|
|
201
279
|
std::size_t from_n,
|
|
202
|
-
float morph_t
|
|
280
|
+
float morph_t,
|
|
281
|
+
float tension) {
|
|
203
282
|
if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
|
|
204
283
|
gradient_opacity = std::clamp(gradient_opacity, 0.f, 1.f);
|
|
205
284
|
opacity = std::clamp(opacity, 0.f, 1.f);
|
|
206
285
|
const float alpha = gradient_opacity * opacity;
|
|
207
286
|
if (alpha <= 0.f) return;
|
|
208
287
|
morph_t = std::clamp(morph_t, 0.f, 1.f);
|
|
288
|
+
tension = std::clamp(tension, 0.f, 1.f);
|
|
209
289
|
|
|
210
290
|
const SkPath line = build_close_path(lay, bounds, visible, n, window_ms,
|
|
211
291
|
visible_start_ms, candle_duration_ms,
|
|
212
|
-
from, from_n, morph_t);
|
|
292
|
+
from, from_n, morph_t, tension);
|
|
213
293
|
if (line.isEmpty()) return;
|
|
214
294
|
|
|
215
295
|
// The polyline runs strictly left to right, so its bounds give the first and
|
|
@@ -240,6 +320,92 @@ void draw_close_gradient(SkCanvas* canvas,
|
|
|
240
320
|
canvas->restore();
|
|
241
321
|
}
|
|
242
322
|
|
|
323
|
+
void draw_close_tip(SkCanvas* canvas,
|
|
324
|
+
const Layout& lay,
|
|
325
|
+
const PriceBounds& bounds,
|
|
326
|
+
const ::VroomCandle* visible,
|
|
327
|
+
std::size_t n,
|
|
328
|
+
int64_t window_ms,
|
|
329
|
+
int64_t visible_start_ms,
|
|
330
|
+
int64_t candle_duration_ms,
|
|
331
|
+
float candle_right,
|
|
332
|
+
float candle_area_h,
|
|
333
|
+
uint32_t line_color,
|
|
334
|
+
uint32_t bg_color,
|
|
335
|
+
float line_width,
|
|
336
|
+
float opacity,
|
|
337
|
+
bool pulse,
|
|
338
|
+
float pulse_phase,
|
|
339
|
+
const CandleSnapshot* from,
|
|
340
|
+
std::size_t from_n,
|
|
341
|
+
float morph_t) {
|
|
342
|
+
if (!canvas || candle_right <= 0.f || candle_area_h <= 0.f) return;
|
|
343
|
+
opacity = std::clamp(opacity, 0.f, 1.f);
|
|
344
|
+
if (opacity <= 0.f) return;
|
|
345
|
+
morph_t = std::clamp(morph_t, 0.f, 1.f);
|
|
346
|
+
|
|
347
|
+
const std::size_t from_count = vroom::morph_from_count(from, from_n, morph_t);
|
|
348
|
+
const std::size_t slots = std::max(n, from_count);
|
|
349
|
+
if (slots == 0) return;
|
|
350
|
+
|
|
351
|
+
const SkPoint tip =
|
|
352
|
+
close_vertex(lay, bounds, visible, n, window_ms, visible_start_ms,
|
|
353
|
+
candle_duration_ms, from, from_count, morph_t, 0);
|
|
354
|
+
// Panned or scaled out of the pane: the line's end isn't on screen, so
|
|
355
|
+
// there's nothing to mark. The clip below would hide it anyway, but the ring
|
|
356
|
+
// is wide enough that a tip just off-pane would still bleed in.
|
|
357
|
+
if (tip.fY < 0.f || tip.fY > candle_area_h) return;
|
|
358
|
+
if (tip.fX < 0.f || tip.fX > candle_right) return;
|
|
359
|
+
|
|
360
|
+
// Scaling off the stroke keeps the marker proportionate at any line width;
|
|
361
|
+
// the floor stops a hairline chart from getting an invisible dot.
|
|
362
|
+
const float w = line_width > 0.f ? line_width : 1.5f;
|
|
363
|
+
const float dot_r = std::max(2.f, w * 1.5f);
|
|
364
|
+
const float border_r = dot_r + kTipBorderPx;
|
|
365
|
+
|
|
366
|
+
canvas->save();
|
|
367
|
+
canvas->clipRect(SkRect::MakeLTRB(0.f, 0.f, candle_right, candle_area_h));
|
|
368
|
+
|
|
369
|
+
// Ring first: the border paints over its inner edge, so it reads as
|
|
370
|
+
// expanding out from underneath the dot rather than around it.
|
|
371
|
+
if (pulse) {
|
|
372
|
+
const vroom::tip_pulse::Frame f = vroom::tip_pulse::at(pulse_phase);
|
|
373
|
+
const float r = border_r * f.radius_mul;
|
|
374
|
+
if (f.fill_alpha > 0.f) {
|
|
375
|
+
SkPaint fill;
|
|
376
|
+
fill.setAntiAlias(true);
|
|
377
|
+
fill.setColor(static_cast<SkColor>(line_color));
|
|
378
|
+
fill.setAlphaf(fill.getAlphaf() * f.fill_alpha * opacity);
|
|
379
|
+
canvas->drawCircle(tip.fX, tip.fY, r, fill);
|
|
380
|
+
}
|
|
381
|
+
if (f.stroke_alpha > 0.f) {
|
|
382
|
+
SkPaint edge;
|
|
383
|
+
edge.setAntiAlias(true);
|
|
384
|
+
edge.setColor(static_cast<SkColor>(line_color));
|
|
385
|
+
edge.setAlphaf(edge.getAlphaf() * f.stroke_alpha * opacity);
|
|
386
|
+
edge.setStyle(SkPaint::kStroke_Style);
|
|
387
|
+
edge.setStrokeWidth(1.f);
|
|
388
|
+
canvas->drawCircle(tip.fX, tip.fY, r, edge);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Filled rather than stroked: an annulus stroked around the dot leaves an
|
|
393
|
+
// antialiased seam where the two meet, which shows up as a dark hairline.
|
|
394
|
+
SkPaint border;
|
|
395
|
+
border.setAntiAlias(true);
|
|
396
|
+
border.setColor(static_cast<SkColor>(bg_color));
|
|
397
|
+
border.setAlphaf(border.getAlphaf() * opacity);
|
|
398
|
+
canvas->drawCircle(tip.fX, tip.fY, border_r, border);
|
|
399
|
+
|
|
400
|
+
SkPaint dot;
|
|
401
|
+
dot.setAntiAlias(true);
|
|
402
|
+
dot.setColor(static_cast<SkColor>(line_color));
|
|
403
|
+
dot.setAlphaf(dot.getAlphaf() * opacity);
|
|
404
|
+
canvas->drawCircle(tip.fX, tip.fY, dot_r, dot);
|
|
405
|
+
|
|
406
|
+
canvas->restore();
|
|
407
|
+
}
|
|
408
|
+
|
|
243
409
|
void fill_between(SkCanvas* canvas,
|
|
244
410
|
const Layout& lay,
|
|
245
411
|
const PriceBounds& bounds,
|
|
@@ -48,6 +48,9 @@ void draw(SkCanvas* canvas,
|
|
|
48
48
|
// had before the timeframe switch to its new one. `morph_t == 1` (the default)
|
|
49
49
|
// draws `visible` alone.
|
|
50
50
|
//
|
|
51
|
+
// `tension` (0..1) rounds the corners, monotone-limited so the curve can't
|
|
52
|
+
// overshoot into a price that never traded (see curve.h). 0 is straight segments.
|
|
53
|
+
//
|
|
51
54
|
// Separate from draw() because the capture holds candle closes — it can't stand
|
|
52
55
|
// in for an indicator's values.
|
|
53
56
|
void draw_close_line(SkCanvas* canvas,
|
|
@@ -65,12 +68,14 @@ void draw_close_line(SkCanvas* canvas,
|
|
|
65
68
|
float opacity = 1.f,
|
|
66
69
|
const CandleSnapshot* from = nullptr,
|
|
67
70
|
std::size_t from_n = 0,
|
|
68
|
-
float morph_t = 1.f
|
|
71
|
+
float morph_t = 1.f,
|
|
72
|
+
float tension = 0.f);
|
|
69
73
|
|
|
70
74
|
// The area beneath the close-price polyline, filled with `color` ramping from
|
|
71
75
|
// `gradient_opacity` at the line's peak to fully transparent at the pane bottom.
|
|
72
|
-
// Takes the same geometry and
|
|
73
|
-
// identical
|
|
76
|
+
// Takes the same geometry, morph and tension arguments as draw_close_line and
|
|
77
|
+
// builds the identical path, so the fill tracks the line through a timeframe
|
|
78
|
+
// switch and follows the same rounded corners.
|
|
74
79
|
//
|
|
75
80
|
// `opacity` multiplies the ramp, which is what fades the fill in alongside the
|
|
76
81
|
// line during the candle→line morph. Draws nothing when either opacity is 0.
|
|
@@ -91,7 +96,39 @@ void draw_close_gradient(SkCanvas* canvas,
|
|
|
91
96
|
float opacity = 1.f,
|
|
92
97
|
const CandleSnapshot* from = nullptr,
|
|
93
98
|
std::size_t from_n = 0,
|
|
94
|
-
float morph_t = 1.f
|
|
99
|
+
float morph_t = 1.f,
|
|
100
|
+
float tension = 0.f);
|
|
101
|
+
|
|
102
|
+
// The marker at the line's newest end: a dot in `line_color` sized off
|
|
103
|
+
// `line_width`, ringed by a 2px disc of `bg_color` that separates it from the
|
|
104
|
+
// line's stroke cap, and optionally a pulse expanding out from behind it.
|
|
105
|
+
//
|
|
106
|
+
// Takes the same geometry and morph arguments as draw_close_line and reads the
|
|
107
|
+
// same vertex, so the dot stays on the line's end pixel through a timeframe
|
|
108
|
+
// switch. `opacity` scales everything, fading the marker in with the line during
|
|
109
|
+
// the candle→line morph.
|
|
110
|
+
//
|
|
111
|
+
// `pulse_phase` is in cycles and wraps, so the caller can hand over elapsed time
|
|
112
|
+
// divided by tip_pulse::kPeriodSeconds. Ignored unless `pulse`.
|
|
113
|
+
void draw_close_tip(SkCanvas* canvas,
|
|
114
|
+
const Layout& lay,
|
|
115
|
+
const PriceBounds& bounds,
|
|
116
|
+
const ::VroomCandle* visible,
|
|
117
|
+
std::size_t n,
|
|
118
|
+
int64_t window_ms,
|
|
119
|
+
int64_t visible_start_ms,
|
|
120
|
+
int64_t candle_duration_ms,
|
|
121
|
+
float candle_right,
|
|
122
|
+
float candle_area_h,
|
|
123
|
+
uint32_t line_color,
|
|
124
|
+
uint32_t bg_color,
|
|
125
|
+
float line_width,
|
|
126
|
+
float opacity = 1.f,
|
|
127
|
+
bool pulse = false,
|
|
128
|
+
float pulse_phase = 0.f,
|
|
129
|
+
const CandleSnapshot* from = nullptr,
|
|
130
|
+
std::size_t from_n = 0,
|
|
131
|
+
float morph_t = 1.f);
|
|
95
132
|
|
|
96
133
|
// Fills the closed region between two aligned series (NaN where undefined)
|
|
97
134
|
// with `color` at its alpha × `opacity` — used for the Bollinger Band fill.
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
#include "chart.h"
|
|
20
20
|
#include "fonts.h"
|
|
21
21
|
#include "theme.h"
|
|
22
|
+
#include "ticks.h"
|
|
22
23
|
#include "viewport.h"
|
|
23
24
|
|
|
24
25
|
namespace vroom::price_indicator {
|
|
@@ -66,7 +67,9 @@ void draw(SkCanvas* canvas,
|
|
|
66
67
|
font.setEdging(SkFont::Edging::kSubpixelAntiAlias);
|
|
67
68
|
|
|
68
69
|
char buf[32];
|
|
69
|
-
|
|
70
|
+
const int decimals = vroom::price_decimals(
|
|
71
|
+
vroom::pick_price_interval(bounds.max - bounds.min, candle_area_h));
|
|
72
|
+
vroom::format_price(buf, sizeof(buf), last.close, decimals);
|
|
70
73
|
const size_t len = std::strlen(buf);
|
|
71
74
|
|
|
72
75
|
// Measure the tight glyph bounds (origin at the baseline) so we can center
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
#include "fonts.h"
|
|
24
24
|
#include "price_line_layout.h"
|
|
25
25
|
#include "theme.h"
|
|
26
|
+
#include "ticks.h"
|
|
26
27
|
#include "viewport.h"
|
|
27
28
|
|
|
28
29
|
namespace vroom::price_lines {
|
|
@@ -212,11 +213,12 @@ void draw_axis_badge(SkCanvas* canvas,
|
|
|
212
213
|
const Layout& lay,
|
|
213
214
|
double price,
|
|
214
215
|
float y,
|
|
215
|
-
SkColor fill
|
|
216
|
+
SkColor fill,
|
|
217
|
+
int decimals) {
|
|
216
218
|
if (lay.y_axis_width_px <= 0.f) return;
|
|
217
219
|
|
|
218
220
|
char buf[32];
|
|
219
|
-
|
|
221
|
+
vroom::format_price(buf, sizeof(buf), price, decimals);
|
|
220
222
|
const size_t len = std::strlen(buf);
|
|
221
223
|
|
|
222
224
|
SkRect tb;
|
|
@@ -253,6 +255,8 @@ void draw(SkCanvas* canvas,
|
|
|
253
255
|
const VroomPriceLineStyle& style = chart.price_line_style;
|
|
254
256
|
SkFont font;
|
|
255
257
|
const bool has_font = label_font(chart, &font);
|
|
258
|
+
const int decimals = vroom::price_decimals(
|
|
259
|
+
vroom::pick_price_interval(bounds.max - bounds.min, candle_area_h));
|
|
256
260
|
|
|
257
261
|
for (size_t i = 0; i < chart.price_lines.size(); ++i) {
|
|
258
262
|
const VroomChart::StoredPriceLine& pl = chart.price_lines[i];
|
|
@@ -351,7 +355,7 @@ void draw(SkCanvas* canvas,
|
|
|
351
355
|
|
|
352
356
|
if (has_font && (pl.flags & VROOM_PRICE_LINE_AXIS_LABEL) != 0) {
|
|
353
357
|
draw_axis_badge(canvas, font, lay, render_price(chart, i), y,
|
|
354
|
-
line_color);
|
|
358
|
+
line_color, decimals);
|
|
355
359
|
}
|
|
356
360
|
}
|
|
357
361
|
}
|
package/cpp/_core_src/theme.cpp
CHANGED
|
@@ -39,6 +39,9 @@ constexpr float kDefaultFloats[VROOM_FLOAT_COUNT_] = {
|
|
|
39
39
|
0.f, // VOLUME_RADIUS_PX — square by default
|
|
40
40
|
1.5f, // LINE_WIDTH_PX — line-chart polyline stroke width
|
|
41
41
|
0.28f, // LINE_GRADIENT_OPACITY — fill under the line, ramped to 0 at the pane bottom
|
|
42
|
+
0.f, // LINE_TENSION — straight segments by default
|
|
43
|
+
1.f, // LINE_TIP_DOT — the line's leading end is marked by default
|
|
44
|
+
0.f, // LINE_TIP_PULSE — the ring is opt-in; it never lets the chart idle
|
|
42
45
|
};
|
|
43
46
|
|
|
44
47
|
} // namespace
|
package/cpp/_core_src/ticks.cpp
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
#include <algorithm>
|
|
4
4
|
#include <cmath>
|
|
5
|
+
#include <cstdio>
|
|
5
6
|
#include <ctime>
|
|
6
7
|
|
|
7
8
|
namespace vroom {
|
|
@@ -145,4 +146,17 @@ double pick_price_interval(double range, float candle_area_h) {
|
|
|
145
146
|
return nice * magnitude;
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
int price_decimals(double interval) {
|
|
150
|
+
if (!(interval > 0.0) || !std::isfinite(interval)) return 2;
|
|
151
|
+
const double d = std::ceil(-std::log10(interval) - 1e-12);
|
|
152
|
+
if (d < 0.0) return 0;
|
|
153
|
+
if (d > 12.0) return 12;
|
|
154
|
+
return static_cast<int>(d);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
void format_price(char* buf, size_t buf_size, double price, int decimals) {
|
|
158
|
+
if (!buf || buf_size == 0) return;
|
|
159
|
+
std::snprintf(buf, buf_size, "%.*f", decimals, price);
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
} // namespace vroom
|
package/cpp/_core_src/ticks.h
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
#pragma once
|
|
8
8
|
|
|
9
|
+
#include <cstddef>
|
|
9
10
|
#include <cstdint>
|
|
10
11
|
|
|
11
12
|
namespace vroom {
|
|
@@ -50,4 +51,11 @@ int64_t next_tick(int64_t t, const TimeTick& tick);
|
|
|
50
51
|
// in the same units as the data (e.g. dollars).
|
|
51
52
|
double pick_price_interval(double range, float candle_area_h);
|
|
52
53
|
|
|
54
|
+
// Decimal places needed so adjacent ticks `interval` apart don't collapse to
|
|
55
|
+
// the same string. 0.01 → 2, 0.005 → 3, 1e-8 → 8. Clamped to [0, 12].
|
|
56
|
+
int price_decimals(double interval);
|
|
57
|
+
|
|
58
|
+
// Writes `price` into `buf` using `decimals` (from price_decimals).
|
|
59
|
+
void format_price(char* buf, size_t buf_size, double price, int decimals);
|
|
60
|
+
|
|
53
61
|
} // namespace vroom
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The pulsing ring at the line chart's tip — a phase in, a radius and two
|
|
2
|
+
// alphas out (VROOM_FLOAT_LINE_TIP_PULSE).
|
|
3
|
+
//
|
|
4
|
+
// Shape and timing follow TradingView's last-price animation, whose numbers are
|
|
5
|
+
// well-tuned: expand while the fill washes out and the edge sharpens, keep
|
|
6
|
+
// expanding while the edge fades, then rest. That rest is nearly half the period
|
|
7
|
+
// and it is what makes the ring read as a heartbeat instead of a strobe.
|
|
8
|
+
//
|
|
9
|
+
// Radii come out as multiples of the ring's start radius rather than pixels,
|
|
10
|
+
// because the tip dot scales with the line width and the ring has to scale with
|
|
11
|
+
// it (see draw_close_tip in ma_overlay.cpp).
|
|
12
|
+
//
|
|
13
|
+
// Skia-free and header-only so the unit tests can cover it; see
|
|
14
|
+
// tests/test_tip_pulse.cpp.
|
|
15
|
+
|
|
16
|
+
#pragma once
|
|
17
|
+
|
|
18
|
+
#include <cmath>
|
|
19
|
+
|
|
20
|
+
namespace vroom::tip_pulse {
|
|
21
|
+
|
|
22
|
+
// One full expand-and-rest cycle.
|
|
23
|
+
constexpr float kPeriodSeconds = 2.6f;
|
|
24
|
+
|
|
25
|
+
// The ring at one instant. Alphas already account for the rest stage, so a
|
|
26
|
+
// caller can paint unconditionally and simply draw nothing visible.
|
|
27
|
+
struct Frame {
|
|
28
|
+
float radius_mul; // multiple of the ring's start radius
|
|
29
|
+
float fill_alpha;
|
|
30
|
+
float stroke_alpha;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
namespace detail {
|
|
34
|
+
|
|
35
|
+
struct Stage {
|
|
36
|
+
float end; // phase this stage runs until
|
|
37
|
+
float start_radius, end_radius;
|
|
38
|
+
float start_fill, end_fill;
|
|
39
|
+
float start_stroke, end_stroke;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The fill fades as the ring grows, while the edge first *gains* alpha — that
|
|
43
|
+
// crossover is what gives the bloom its snap — before fading out in stage two.
|
|
44
|
+
constexpr Stage kStages[] = {
|
|
45
|
+
{0.25f, 1.0f, 2.5f, 0.25f, 0.f, 0.40f, 0.80f},
|
|
46
|
+
{0.525f, 2.5f, 3.5f, 0.f, 0.f, 0.80f, 0.f},
|
|
47
|
+
{1.0f, 3.5f, 3.5f, 0.f, 0.f, 0.f, 0.f},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
inline float lerp(float a, float b, float t) { return a + (b - a) * t; }
|
|
51
|
+
|
|
52
|
+
} // namespace detail
|
|
53
|
+
|
|
54
|
+
// Ring state at `phase`, in cycles. Values outside [0,1) wrap, so a caller can
|
|
55
|
+
// hand over raw elapsed time divided by the period without normalizing.
|
|
56
|
+
inline Frame at(float phase) {
|
|
57
|
+
const float p = phase - std::floor(phase);
|
|
58
|
+
float start = 0.f;
|
|
59
|
+
for (const detail::Stage& s : detail::kStages) {
|
|
60
|
+
// The last stage takes anything left over, which also catches a `p` that
|
|
61
|
+
// rounded up to 1 on the way in.
|
|
62
|
+
if (p < s.end || s.end >= 1.f) {
|
|
63
|
+
const float span = s.end - start;
|
|
64
|
+
const float t = span > 0.f ? (p - start) / span : 0.f;
|
|
65
|
+
return Frame{detail::lerp(s.start_radius, s.end_radius, t),
|
|
66
|
+
detail::lerp(s.start_fill, s.end_fill, t),
|
|
67
|
+
detail::lerp(s.start_stroke, s.end_stroke, t)};
|
|
68
|
+
}
|
|
69
|
+
start = s.end;
|
|
70
|
+
}
|
|
71
|
+
return Frame{1.f, 0.f, 0.f}; // unreachable: the table ends at 1
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
} // namespace vroom::tip_pulse
|
|
@@ -17,6 +17,16 @@ float candle_body_width(const Layout& layout,
|
|
|
17
17
|
return static_cast<float>(slot * layout.candle_width_ratio);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
int64_t window_for_body_width(const Layout& layout,
|
|
21
|
+
int64_t candle_duration_ms,
|
|
22
|
+
double body_px) {
|
|
23
|
+
const double usable = candle_area_width(layout);
|
|
24
|
+
const double ratio = static_cast<double>(layout.candle_width_ratio);
|
|
25
|
+
const double dur = static_cast<double>(candle_duration_ms);
|
|
26
|
+
if (usable <= 0.0 || ratio <= 0.0 || dur <= 0.0 || body_px <= 0.0) return 0;
|
|
27
|
+
return static_cast<int64_t>((usable * dur * ratio) / body_px);
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
float candle_center_x(const Layout& layout,
|
|
21
31
|
int64_t time_ms,
|
|
22
32
|
int64_t candle_duration_ms,
|
|
@@ -265,4 +275,39 @@ double y_to_price(const Layout& layout,
|
|
|
265
275
|
return bounds.min + t * range;
|
|
266
276
|
}
|
|
267
277
|
|
|
278
|
+
TimeWindow clamp_shifted_time_window(int64_t start_ms, int64_t end_ms,
|
|
279
|
+
int64_t first_time, int64_t last_time,
|
|
280
|
+
int64_t candle_duration_ms,
|
|
281
|
+
int64_t max_future) {
|
|
282
|
+
const int64_t window = end_ms - start_ms;
|
|
283
|
+
if (window <= 0) return {start_ms, end_ms};
|
|
284
|
+
|
|
285
|
+
const int64_t dur = candle_duration_ms > 0 ? candle_duration_ms : 0;
|
|
286
|
+
const int64_t last_slot_end = last_time + dur;
|
|
287
|
+
const int64_t data_extent = last_slot_end - first_time;
|
|
288
|
+
|
|
289
|
+
if (max_future >= 0 && end_ms > last_time + max_future) {
|
|
290
|
+
end_ms = last_time + max_future;
|
|
291
|
+
start_ms = end_ms - window;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Window longer than the series: empty past is how width wins. Floor the
|
|
295
|
+
// right edge at last_time so a pan into the past is a no-op when the
|
|
296
|
+
// candles are already right-aligned. Do not pin start to first_time — that
|
|
297
|
+
// throws the extra length into the future and the two caps latch.
|
|
298
|
+
if (data_extent >= 0 && window > data_extent) {
|
|
299
|
+
if (end_ms < last_time) {
|
|
300
|
+
end_ms = last_time;
|
|
301
|
+
start_ms = end_ms - window;
|
|
302
|
+
}
|
|
303
|
+
return {start_ms, end_ms};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (start_ms < first_time) {
|
|
307
|
+
start_ms = first_time;
|
|
308
|
+
end_ms = start_ms + window;
|
|
309
|
+
}
|
|
310
|
+
return {start_ms, end_ms};
|
|
311
|
+
}
|
|
312
|
+
|
|
268
313
|
} // namespace vroom
|
package/cpp/_core_src/viewport.h
CHANGED
|
@@ -86,6 +86,36 @@ float candle_body_width(const Layout& layout,
|
|
|
86
86
|
int64_t window_ms,
|
|
87
87
|
int64_t candle_duration_ms);
|
|
88
88
|
|
|
89
|
+
// Inverse of candle_body_width: the time window that makes each candle body
|
|
90
|
+
// `body_px` wide. Returns 0 if any input is degenerate. Does not consult the
|
|
91
|
+
// data span — a short series leaves empty space on the left (width wins).
|
|
92
|
+
int64_t window_for_body_width(const Layout& layout,
|
|
93
|
+
int64_t candle_duration_ms,
|
|
94
|
+
double body_px);
|
|
95
|
+
|
|
96
|
+
// A [start, end] time window of fixed length, used by pan/zoom clamping.
|
|
97
|
+
struct TimeWindow {
|
|
98
|
+
int64_t start_ms;
|
|
99
|
+
int64_t end_ms;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Clamp a same-length time window after a pan or pinch-zoom shift.
|
|
103
|
+
//
|
|
104
|
+
// The future cap (`max_future`, typically 3/4 of the window) is unchanged: the
|
|
105
|
+
// right edge cannot run past last_time + max_future.
|
|
106
|
+
//
|
|
107
|
+
// The past cap depends on whether the window fits in the data:
|
|
108
|
+
// - Window ≤ data extent: start cannot precede first_time (no empty past).
|
|
109
|
+
// - Window > data extent: empty past is intentional (width wins). start MAY
|
|
110
|
+
// precede first_time. Instead, the last candle cannot leave the right edge
|
|
111
|
+
// (end >= last_time). Pinning start to first_time in this case would shove
|
|
112
|
+
// the extra length into the future and latch, because the future cap then
|
|
113
|
+
// fights the past cap on every subsequent pan.
|
|
114
|
+
TimeWindow clamp_shifted_time_window(int64_t start_ms, int64_t end_ms,
|
|
115
|
+
int64_t first_time, int64_t last_time,
|
|
116
|
+
int64_t candle_duration_ms,
|
|
117
|
+
int64_t max_future);
|
|
118
|
+
|
|
89
119
|
// Center-x of a candle whose period starts at time_ms and lasts
|
|
90
120
|
// candle_duration_ms, given the current visible time window.
|
|
91
121
|
float candle_center_x(const Layout& layout,
|