react-native-vroom-chart 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cpp/VroomChartHostObject.cpp +37 -0
- package/cpp/_core_include/vroom/vroom_chart.h +37 -0
- package/cpp/_core_src/candles.cpp +33 -7
- package/cpp/_core_src/chart.cpp +9 -3
- package/cpp/_core_src/chart.h +6 -0
- package/cpp/_core_src/chart_facade.cpp +216 -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/macd_pane.cpp +6 -3
- package/cpp/_core_src/price_indicator.cpp +1 -1
- package/cpp/_core_src/rsi_pane.cpp +6 -1
- package/cpp/_core_src/theme.cpp +6 -0
- 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/cpp/_core_src/volume.cpp +2 -2
- package/lib/index.d.mts +25 -3
- package/lib/index.d.ts +25 -3
- package/lib/index.js +25 -8
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +25 -8
- 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
- package/src/theme.ts +6 -0
|
@@ -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 {
|
|
@@ -52,7 +52,10 @@ void draw(SkCanvas* canvas,
|
|
|
52
52
|
if (band_h <= 0.f) return;
|
|
53
53
|
|
|
54
54
|
const float mid = (pane_top + pane_bottom) * 0.5f;
|
|
55
|
-
|
|
55
|
+
// User y-zoom scales the amplitude about the zero line (mid). 1.0 = the
|
|
56
|
+
// default auto-fit; >1 zooms in, <1 zooms out.
|
|
57
|
+
const float half =
|
|
58
|
+
band_h * 0.5f * kPadFrac * static_cast<float>(chart.macd_y_scale);
|
|
56
59
|
|
|
57
60
|
// Auto-scale symmetric about zero across all visible finite values.
|
|
58
61
|
double scale = 0.0;
|
|
@@ -100,8 +103,8 @@ void draw(SkCanvas* canvas,
|
|
|
100
103
|
vroom::candle_body_width(lay, window_ms, candle_duration_ms);
|
|
101
104
|
const float half_body = body_w * 0.5f;
|
|
102
105
|
const float zero_y = y_for(0.0);
|
|
103
|
-
const SkColor bull = chart.theme.colors[
|
|
104
|
-
const SkColor bear = chart.theme.colors[
|
|
106
|
+
const SkColor bull = chart.theme.colors[VROOM_COLOR_ACCENT_BULL];
|
|
107
|
+
const SkColor bear = chart.theme.colors[VROOM_COLOR_ACCENT_BEAR];
|
|
105
108
|
for (std::size_t i = 0; i < n; ++i) {
|
|
106
109
|
const double h = hist_visible[i];
|
|
107
110
|
if (!std::isfinite(h)) continue;
|
|
@@ -43,7 +43,7 @@ void draw(SkCanvas* canvas,
|
|
|
43
43
|
const ::VroomCandle& last = chart.candles.back();
|
|
44
44
|
const bool bull = last.close >= last.open;
|
|
45
45
|
const SkColor color =
|
|
46
|
-
chart.theme.colors[bull ?
|
|
46
|
+
chart.theme.colors[bull ? VROOM_COLOR_ACCENT_BULL : VROOM_COLOR_ACCENT_BEAR];
|
|
47
47
|
|
|
48
48
|
const float y = vroom::price_to_y(lay, bounds, last.close);
|
|
49
49
|
if (y < 0.f || y > candle_area_h) return; // price scrolled off-range
|
|
@@ -50,8 +50,13 @@ void draw(SkCanvas* canvas,
|
|
|
50
50
|
const float band_h = pane_bottom - pane_top;
|
|
51
51
|
if (band_h <= 0.f) return;
|
|
52
52
|
|
|
53
|
+
// Map 0..100 about the pane center so the user y-zoom scales symmetrically
|
|
54
|
+
// around 50. z == 1 reproduces the default fit (0 -> pane_bottom, 100 ->
|
|
55
|
+
// pane_top); z > 1 zooms in (taller), z < 1 zooms out (flatter).
|
|
56
|
+
const float center_y = (pane_top + pane_bottom) * 0.5f;
|
|
57
|
+
const float z = static_cast<float>(chart.rsi_y_scale);
|
|
53
58
|
auto y_for = [&](double v) -> float {
|
|
54
|
-
return
|
|
59
|
+
return center_y - static_cast<float>((v - 50.0) / 100.0) * band_h * z;
|
|
55
60
|
};
|
|
56
61
|
|
|
57
62
|
// Mask the band with the background so candles/volume that overflow below
|
package/cpp/_core_src/theme.cpp
CHANGED
|
@@ -16,6 +16,12 @@ constexpr uint32_t kDefaultColors[VROOM_COLOR_COUNT_] = {
|
|
|
16
16
|
0xff161b22, // TOOLTIP_BG
|
|
17
17
|
0xffc9d1d9, // TOOLTIP_TEXT
|
|
18
18
|
0xff3e4855, // CROSSHAIR_TARGET — CROSSHAIR lightened 30% (prior derived ring)
|
|
19
|
+
0x00000000, // BORDER_BULL — transparent sentinel: inherit BULL fill
|
|
20
|
+
0x00000000, // BORDER_BEAR — transparent sentinel: inherit BEAR fill
|
|
21
|
+
0x00000000, // WICK_BULL — transparent sentinel: inherit BULL fill
|
|
22
|
+
0x00000000, // WICK_BEAR — transparent sentinel: inherit BEAR fill
|
|
23
|
+
0xff26a69a, // ACCENT_BULL — classic teal-green (price indicator, volume, MACD)
|
|
24
|
+
0xffef5350, // ACCENT_BEAR — classic red
|
|
19
25
|
};
|
|
20
26
|
|
|
21
27
|
constexpr float kDefaultFloats[VROOM_FLOAT_COUNT_] = {
|
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) {
|
package/cpp/_core_src/ticks.h
CHANGED
|
@@ -17,10 +17,33 @@ inline constexpr float kXLabelMinSpacing = 50.f;
|
|
|
17
17
|
// Target spacing for y-axis labels — used to derive a target count.
|
|
18
18
|
inline constexpr float kYLabelTargetSpacing = 55.f;
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
20
|
+
// X-axis tick cadence. The hierarchy starts with fixed millisecond intervals
|
|
21
|
+
// (1m … 1w) and, beyond a week, switches to calendar-aware month/year steps so
|
|
22
|
+
// labels land on real month and year boundaries instead of drifting epoch
|
|
23
|
+
// multiples.
|
|
24
|
+
enum class TimeUnit { Fixed, Month, Year };
|
|
25
|
+
|
|
26
|
+
// A chosen tick cadence. For Fixed, ticks land on epoch multiples of step_ms.
|
|
27
|
+
// For Month/Year, ticks land on local-calendar boundaries `step` units apart
|
|
28
|
+
// (e.g. {Month, step=3} → quarter starts; {Year, step=1} → Jan 1 each year).
|
|
29
|
+
struct TimeTick {
|
|
30
|
+
TimeUnit unit;
|
|
31
|
+
int64_t step_ms; // valid iff unit == Fixed
|
|
32
|
+
int step; // valid iff unit == Month or Year
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Selects the coarsest-enough cadence whose on-screen spacing meets the minimum.
|
|
36
|
+
// Hierarchy: 1m → 5m → 15m → 30m → 1h → 2h → 4h → 6h → 12h → 1d → 2d → 1w →
|
|
37
|
+
// 1mo → 3mo → 6mo → 1y → 2y → 5y → … (the year step grows as 1/2/5 × 10ⁿ so
|
|
38
|
+
// arbitrarily wide windows never overlap).
|
|
39
|
+
TimeTick pick_time_tick(int64_t window_ms, float candle_area_w);
|
|
40
|
+
|
|
41
|
+
// First tick at or after from_ms for the given cadence. Calendar units align to
|
|
42
|
+
// local month/year boundaries.
|
|
43
|
+
int64_t first_tick_at_or_after(int64_t from_ms, const TimeTick& tick);
|
|
44
|
+
|
|
45
|
+
// The next tick strictly after t for the given cadence.
|
|
46
|
+
int64_t next_tick(int64_t t, const TimeTick& tick);
|
|
24
47
|
|
|
25
48
|
// "Nice number" tick selection for the y-axis: snaps to 1, 2, or 5 × 10ⁿ
|
|
26
49
|
// based on the price range and target spacing. Returns the price interval
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#include "viewport.h"
|
|
2
2
|
|
|
3
3
|
#include <algorithm>
|
|
4
|
+
#include <cmath>
|
|
4
5
|
#include <limits>
|
|
5
6
|
|
|
6
7
|
namespace vroom {
|
|
@@ -66,6 +67,62 @@ size_t snap_index_to_candle(const Layout& layout,
|
|
|
66
67
|
: static_cast<size_t>(it - 1 - candles);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
SnapResult snap_to_slot(const Layout& layout,
|
|
71
|
+
const ::VroomCandle* candles,
|
|
72
|
+
size_t count,
|
|
73
|
+
int64_t candle_duration_ms,
|
|
74
|
+
int64_t visible_start_ms,
|
|
75
|
+
int64_t window_ms,
|
|
76
|
+
float x_px) {
|
|
77
|
+
if (count == 0) return {visible_start_ms, false, 0};
|
|
78
|
+
|
|
79
|
+
const float usable =
|
|
80
|
+
layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
|
|
81
|
+
if (usable <= 0.f || window_ms <= 0 || candle_duration_ms <= 0) {
|
|
82
|
+
return {candles[0].time_ms, true, 0};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clamp to the usable candle width so snapping never runs past the visible
|
|
86
|
+
// right edge into off-screen space.
|
|
87
|
+
const float clamped_x = std::clamp(x_px, 0.f, usable);
|
|
88
|
+
|
|
89
|
+
// Pixel x -> the period start time of the candle that would sit there.
|
|
90
|
+
const double target_center =
|
|
91
|
+
static_cast<double>(visible_start_ms) +
|
|
92
|
+
(static_cast<double>(clamped_x) / static_cast<double>(usable)) *
|
|
93
|
+
static_cast<double>(window_ms);
|
|
94
|
+
const int64_t key =
|
|
95
|
+
static_cast<int64_t>(target_center) - candle_duration_ms / 2;
|
|
96
|
+
|
|
97
|
+
const int64_t last_time = candles[count - 1].time_ms;
|
|
98
|
+
if (key > last_time) {
|
|
99
|
+
// Past the last candle: snap to the nearest candle-aligned grid slot.
|
|
100
|
+
// Within half a period of the last candle we still land on it (steps
|
|
101
|
+
// 0); beyond that we snap to an empty future slot with no candle data.
|
|
102
|
+
const double k = static_cast<double>(key - last_time) /
|
|
103
|
+
static_cast<double>(candle_duration_ms);
|
|
104
|
+
const int64_t steps = std::llround(k);
|
|
105
|
+
if (steps <= 0) return {last_time, true, count - 1};
|
|
106
|
+
return {last_time + steps * candle_duration_ms, false, 0};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Populated region: pick whichever of the lower_bound candle / its
|
|
110
|
+
// predecessor is closer in time.
|
|
111
|
+
auto* it = std::lower_bound(
|
|
112
|
+
candles, candles + count, key,
|
|
113
|
+
[](const ::VroomCandle& c, int64_t t) { return c.time_ms < t; });
|
|
114
|
+
if (it == candles) return {candles[0].time_ms, true, 0};
|
|
115
|
+
if (it == candles + count) {
|
|
116
|
+
return {candles[count - 1].time_ms, true, count - 1};
|
|
117
|
+
}
|
|
118
|
+
const int64_t hi = it->time_ms;
|
|
119
|
+
const int64_t lo = (it - 1)->time_ms;
|
|
120
|
+
const size_t idx = (hi - key < key - lo)
|
|
121
|
+
? static_cast<size_t>(it - candles)
|
|
122
|
+
: static_cast<size_t>(it - 1 - candles);
|
|
123
|
+
return {candles[idx].time_ms, true, idx};
|
|
124
|
+
}
|
|
125
|
+
|
|
69
126
|
float snap_x_to_candle(const Layout& layout,
|
|
70
127
|
const ::VroomCandle* candles,
|
|
71
128
|
size_t count,
|
|
@@ -79,10 +136,10 @@ float snap_x_to_candle(const Layout& layout,
|
|
|
79
136
|
layout.width_px - layout.y_axis_width_px - layout.right_padding_px;
|
|
80
137
|
if (usable <= 0.f) return x_px;
|
|
81
138
|
|
|
82
|
-
const
|
|
139
|
+
const SnapResult snap = snap_to_slot(
|
|
83
140
|
layout, candles, count, candle_duration_ms, visible_start_ms,
|
|
84
141
|
window_ms, x_px);
|
|
85
|
-
return candle_center_x(layout,
|
|
142
|
+
return candle_center_x(layout, snap.time_ms, candle_duration_ms,
|
|
86
143
|
visible_start_ms, window_ms);
|
|
87
144
|
}
|
|
88
145
|
|
|
@@ -138,4 +195,17 @@ float price_to_y(const Layout& layout,
|
|
|
138
195
|
return bot - static_cast<float>(t) * draw_h; // invert: high price → low y
|
|
139
196
|
}
|
|
140
197
|
|
|
198
|
+
double y_to_price(const Layout& layout,
|
|
199
|
+
const PriceBounds& bounds,
|
|
200
|
+
float y) {
|
|
201
|
+
const float candle_area_h = price_pane_bottom(layout);
|
|
202
|
+
const float top = candle_area_h * layout.top_padding_frac;
|
|
203
|
+
const float bot = candle_area_h * (1.f - layout.bottom_padding_frac);
|
|
204
|
+
const float draw_h = bot - top;
|
|
205
|
+
const double range = bounds.max - bounds.min;
|
|
206
|
+
if (draw_h <= 0.f || range <= 0.0) return bounds.min;
|
|
207
|
+
const double t = (bot - y) / draw_h; // 0 at min (bottom), 1 at top
|
|
208
|
+
return bounds.min + t * range;
|
|
209
|
+
}
|
|
210
|
+
|
|
141
211
|
} // namespace vroom
|
package/cpp/_core_src/viewport.h
CHANGED
|
@@ -79,10 +79,32 @@ size_t snap_index_to_candle(const Layout& layout,
|
|
|
79
79
|
int64_t window_ms,
|
|
80
80
|
float x_px);
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
82
|
+
// Result of snapping a pixel x to the candle grid. The grid extends past the
|
|
83
|
+
// last candle into the empty "future" space: slots there sit at
|
|
84
|
+
// last_candle.time_ms + k × candle_duration_ms and carry no candle data.
|
|
85
|
+
struct SnapResult {
|
|
86
|
+
int64_t time_ms; // period-start time of the snapped slot (real or future)
|
|
87
|
+
bool has_candle; // true when a real candle sits at this slot
|
|
88
|
+
size_t index; // index into [candles, candles+count); valid iff has_candle
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Snaps pixel x_px to the nearest candle-grid slot. Over the populated region
|
|
92
|
+
// this returns the nearest real candle (has_candle = true). To the right of the
|
|
93
|
+
// last candle it snaps to future grid slots (has_candle = false). x_px is
|
|
94
|
+
// clamped to the usable candle width so snapping never runs past the visible
|
|
95
|
+
// right edge. Precondition: count > 0 and window_ms > 0 (callers guard).
|
|
96
|
+
SnapResult snap_to_slot(const Layout& layout,
|
|
97
|
+
const ::VroomCandle* candles,
|
|
98
|
+
size_t count,
|
|
99
|
+
int64_t candle_duration_ms,
|
|
100
|
+
int64_t visible_start_ms,
|
|
101
|
+
int64_t window_ms,
|
|
102
|
+
float x_px);
|
|
103
|
+
|
|
104
|
+
// On-screen x of the slot whose center is nearest pixel x_px, searching
|
|
105
|
+
// [candles, candles+count) and the future grid past the last candle. Returns
|
|
106
|
+
// x_px unchanged when there are no candles or the window is degenerate. Used to
|
|
107
|
+
// snap the crosshair to candles (and to future candle-aligned slots).
|
|
86
108
|
float snap_x_to_candle(const Layout& layout,
|
|
87
109
|
const ::VroomCandle* candles,
|
|
88
110
|
size_t count,
|
|
@@ -97,4 +119,8 @@ PriceBounds price_bounds(const ::VroomCandle* candles, size_t count);
|
|
|
97
119
|
// Map a price to y in pixels. y=0 is top of the chart.
|
|
98
120
|
float price_to_y(const Layout& layout, const PriceBounds& bounds, double price);
|
|
99
121
|
|
|
122
|
+
// Inverse of price_to_y: map a pixel y in the price pane back to a price.
|
|
123
|
+
// Returns bounds.min for a degenerate range or draw band.
|
|
124
|
+
double y_to_price(const Layout& layout, const PriceBounds& bounds, float y);
|
|
125
|
+
|
|
100
126
|
} // namespace vroom
|
package/cpp/_core_src/volume.cpp
CHANGED
|
@@ -43,12 +43,12 @@ void draw(SkCanvas* canvas,
|
|
|
43
43
|
const float opacity = theme.floats[VROOM_FLOAT_VOLUME_OPACITY];
|
|
44
44
|
SkPaint bull_paint;
|
|
45
45
|
bull_paint.setAntiAlias(true);
|
|
46
|
-
bull_paint.setColor(theme.colors[
|
|
46
|
+
bull_paint.setColor(theme.colors[VROOM_COLOR_ACCENT_BULL]);
|
|
47
47
|
bull_paint.setAlphaf(opacity);
|
|
48
48
|
|
|
49
49
|
SkPaint bear_paint;
|
|
50
50
|
bear_paint.setAntiAlias(true);
|
|
51
|
-
bear_paint.setColor(theme.colors[
|
|
51
|
+
bear_paint.setColor(theme.colors[VROOM_COLOR_ACCENT_BEAR]);
|
|
52
52
|
bear_paint.setAlphaf(opacity);
|
|
53
53
|
|
|
54
54
|
for (std::size_t i = 0; i < n; ++i) {
|
package/lib/index.d.mts
CHANGED
|
@@ -20,8 +20,18 @@ type Candle = {
|
|
|
20
20
|
type CrosshairEvent = {
|
|
21
21
|
/** True while the crosshair is showing; false when it's dismissed. */
|
|
22
22
|
active: boolean;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* OHLCV of the candle under the crosshair, or null when inactive. Also null
|
|
25
|
+
* when the crosshair is parked on a *future* candle-aligned slot in the empty
|
|
26
|
+
* space ahead of the most recent candle (no candle exists there yet) — use
|
|
27
|
+
* `timeMs` to read the slot's time in that case.
|
|
28
|
+
*/
|
|
24
29
|
candle: Candle | null;
|
|
30
|
+
/**
|
|
31
|
+
* Bar-open time (Unix epoch ms) of the slot the crosshair snaps to, including
|
|
32
|
+
* future candle-aligned slots past the last candle. Null when inactive.
|
|
33
|
+
*/
|
|
34
|
+
timeMs: number | null;
|
|
25
35
|
/**
|
|
26
36
|
* Why this event fired — lets the host react differently (e.g. haptics):
|
|
27
37
|
* 'show' — long-press activated the crosshair
|
|
@@ -42,10 +52,22 @@ type VroomColor = string | number;
|
|
|
42
52
|
type VroomTheme = {
|
|
43
53
|
/** Chart + axis-strip background. */
|
|
44
54
|
background?: VroomColor;
|
|
45
|
-
/** Up
|
|
55
|
+
/** Up candle body fill. Wick and border default to this unless overridden. */
|
|
46
56
|
bull?: VroomColor;
|
|
47
|
-
/** Down
|
|
57
|
+
/** Down candle body fill. Wick and border default to this unless overridden. */
|
|
48
58
|
bear?: VroomColor;
|
|
59
|
+
/** Generic up color for the price indicator, volume bars, and MACD histogram. Defaults to teal-green; independent of `bull`. */
|
|
60
|
+
accentBull?: VroomColor;
|
|
61
|
+
/** Generic down color for the price indicator, volume bars, and MACD histogram. Defaults to red; independent of `bear`. */
|
|
62
|
+
accentBear?: VroomColor;
|
|
63
|
+
/** Up candle body 1px border. Defaults to the bull fill color. */
|
|
64
|
+
borderBull?: VroomColor;
|
|
65
|
+
/** Down candle body 1px border. Defaults to the bear fill color. */
|
|
66
|
+
borderBear?: VroomColor;
|
|
67
|
+
/** Up candle wick color. Defaults to the bull fill color. */
|
|
68
|
+
wickBull?: VroomColor;
|
|
69
|
+
/** Down candle wick color. Defaults to the bear fill color. */
|
|
70
|
+
wickBear?: VroomColor;
|
|
49
71
|
/** Gridlines. */
|
|
50
72
|
grid?: VroomColor;
|
|
51
73
|
/** Axis label text (price + time). */
|
package/lib/index.d.ts
CHANGED
|
@@ -20,8 +20,18 @@ type Candle = {
|
|
|
20
20
|
type CrosshairEvent = {
|
|
21
21
|
/** True while the crosshair is showing; false when it's dismissed. */
|
|
22
22
|
active: boolean;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* OHLCV of the candle under the crosshair, or null when inactive. Also null
|
|
25
|
+
* when the crosshair is parked on a *future* candle-aligned slot in the empty
|
|
26
|
+
* space ahead of the most recent candle (no candle exists there yet) — use
|
|
27
|
+
* `timeMs` to read the slot's time in that case.
|
|
28
|
+
*/
|
|
24
29
|
candle: Candle | null;
|
|
30
|
+
/**
|
|
31
|
+
* Bar-open time (Unix epoch ms) of the slot the crosshair snaps to, including
|
|
32
|
+
* future candle-aligned slots past the last candle. Null when inactive.
|
|
33
|
+
*/
|
|
34
|
+
timeMs: number | null;
|
|
25
35
|
/**
|
|
26
36
|
* Why this event fired — lets the host react differently (e.g. haptics):
|
|
27
37
|
* 'show' — long-press activated the crosshair
|
|
@@ -42,10 +52,22 @@ type VroomColor = string | number;
|
|
|
42
52
|
type VroomTheme = {
|
|
43
53
|
/** Chart + axis-strip background. */
|
|
44
54
|
background?: VroomColor;
|
|
45
|
-
/** Up
|
|
55
|
+
/** Up candle body fill. Wick and border default to this unless overridden. */
|
|
46
56
|
bull?: VroomColor;
|
|
47
|
-
/** Down
|
|
57
|
+
/** Down candle body fill. Wick and border default to this unless overridden. */
|
|
48
58
|
bear?: VroomColor;
|
|
59
|
+
/** Generic up color for the price indicator, volume bars, and MACD histogram. Defaults to teal-green; independent of `bull`. */
|
|
60
|
+
accentBull?: VroomColor;
|
|
61
|
+
/** Generic down color for the price indicator, volume bars, and MACD histogram. Defaults to red; independent of `bear`. */
|
|
62
|
+
accentBear?: VroomColor;
|
|
63
|
+
/** Up candle body 1px border. Defaults to the bull fill color. */
|
|
64
|
+
borderBull?: VroomColor;
|
|
65
|
+
/** Down candle body 1px border. Defaults to the bear fill color. */
|
|
66
|
+
borderBear?: VroomColor;
|
|
67
|
+
/** Up candle wick color. Defaults to the bull fill color. */
|
|
68
|
+
wickBull?: VroomColor;
|
|
69
|
+
/** Down candle wick color. Defaults to the bear fill color. */
|
|
70
|
+
wickBear?: VroomColor;
|
|
49
71
|
/** Gridlines. */
|
|
50
72
|
grid?: VroomColor;
|
|
51
73
|
/** Axis label text (price + time). */
|
package/lib/index.js
CHANGED
|
@@ -80,8 +80,20 @@ var COLOR_KEYS = {
|
|
|
80
80
|
// VROOM_COLOR_AXIS_TEXT
|
|
81
81
|
crosshair: 6,
|
|
82
82
|
// VROOM_COLOR_CROSSHAIR
|
|
83
|
-
crosshairTarget: 9
|
|
83
|
+
crosshairTarget: 9,
|
|
84
84
|
// VROOM_COLOR_CROSSHAIR_TARGET
|
|
85
|
+
borderBull: 10,
|
|
86
|
+
// VROOM_COLOR_BORDER_BULL
|
|
87
|
+
borderBear: 11,
|
|
88
|
+
// VROOM_COLOR_BORDER_BEAR
|
|
89
|
+
wickBull: 12,
|
|
90
|
+
// VROOM_COLOR_WICK_BULL
|
|
91
|
+
wickBear: 13,
|
|
92
|
+
// VROOM_COLOR_WICK_BEAR
|
|
93
|
+
accentBull: 14,
|
|
94
|
+
// VROOM_COLOR_ACCENT_BULL
|
|
95
|
+
accentBear: 15
|
|
96
|
+
// VROOM_COLOR_ACCENT_BEAR
|
|
85
97
|
};
|
|
86
98
|
function parseColor(value) {
|
|
87
99
|
if (typeof value === "number") {
|
|
@@ -297,11 +309,11 @@ function VroomChart(props) {
|
|
|
297
309
|
} else if (crosshairActive.current) {
|
|
298
310
|
const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
|
|
299
311
|
if (ch) pictureSV.value = ch;
|
|
300
|
-
const
|
|
301
|
-
const t =
|
|
312
|
+
const info = handle.getCrosshairInfo();
|
|
313
|
+
const t = info?.timeMs ?? null;
|
|
302
314
|
if (t !== lastCrosshairTime.current) {
|
|
303
315
|
lastCrosshairTime.current = t;
|
|
304
|
-
onCrosshair?.({ active: true, candle:
|
|
316
|
+
onCrosshair?.({ active: true, candle: info?.candle ?? null, timeMs: t, reason: "move" });
|
|
305
317
|
}
|
|
306
318
|
return;
|
|
307
319
|
} else {
|
|
@@ -391,9 +403,14 @@ function VroomChart(props) {
|
|
|
391
403
|
crosshairActive.current = true;
|
|
392
404
|
const ch = handle.setCrosshair(e.x, e.y - crosshairOffset);
|
|
393
405
|
if (ch) pictureSV.value = ch;
|
|
394
|
-
const
|
|
395
|
-
lastCrosshairTime.current =
|
|
396
|
-
onCrosshair?.({
|
|
406
|
+
const info = handle.getCrosshairInfo();
|
|
407
|
+
lastCrosshairTime.current = info?.timeMs ?? null;
|
|
408
|
+
onCrosshair?.({
|
|
409
|
+
active: true,
|
|
410
|
+
candle: info?.candle ?? null,
|
|
411
|
+
timeMs: info?.timeMs ?? null,
|
|
412
|
+
reason: "show"
|
|
413
|
+
});
|
|
397
414
|
});
|
|
398
415
|
const tap = import_react_native_gesture_handler.Gesture.Tap().runOnJS(true).onStart((e) => {
|
|
399
416
|
if (!handle || !crosshairActive.current) return;
|
|
@@ -402,7 +419,7 @@ function VroomChart(props) {
|
|
|
402
419
|
const ch = handle.clearCrosshair();
|
|
403
420
|
if (ch) pictureSV.value = ch;
|
|
404
421
|
lastCrosshairTime.current = null;
|
|
405
|
-
onCrosshair?.({ active: false, candle: null, reason: "hide" });
|
|
422
|
+
onCrosshair?.({ active: false, candle: null, timeMs: null, reason: "hide" });
|
|
406
423
|
});
|
|
407
424
|
const gesture = import_react_native_gesture_handler.Gesture.Simultaneous(pan, pinch, longPress, tap);
|
|
408
425
|
return /* @__PURE__ */ import_react2.default.createElement(
|