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