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
|
@@ -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.
|
|
@@ -62,6 +62,12 @@ typedef enum {
|
|
|
62
62
|
VROOM_COLOR_TOOLTIP_BG,
|
|
63
63
|
VROOM_COLOR_TOOLTIP_TEXT,
|
|
64
64
|
VROOM_COLOR_CROSSHAIR_TARGET, // the hollow ring/dot at the intersection
|
|
65
|
+
VROOM_COLOR_BORDER_BULL, // bull body 1px outline; 0 alpha => inherit BULL fill
|
|
66
|
+
VROOM_COLOR_BORDER_BEAR, // bear body 1px outline; 0 alpha => inherit BEAR fill
|
|
67
|
+
VROOM_COLOR_WICK_BULL, // bull wick; 0 alpha => inherit BULL fill
|
|
68
|
+
VROOM_COLOR_WICK_BEAR, // bear wick; 0 alpha => inherit BEAR fill
|
|
69
|
+
VROOM_COLOR_ACCENT_BULL, // generic up color: price indicator, volume, MACD
|
|
70
|
+
VROOM_COLOR_ACCENT_BEAR, // generic down color
|
|
65
71
|
VROOM_COLOR_COUNT_
|
|
66
72
|
} VroomColorKey;
|
|
67
73
|
|
|
@@ -126,6 +132,19 @@ void vroom_chart_translate(VroomChart* chart, float dx_px, float dy_px);
|
|
|
126
132
|
void vroom_chart_scale_price_axis(VroomChart* chart, float dy_px);
|
|
127
133
|
void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px);
|
|
128
134
|
|
|
135
|
+
// Scale the y-axis of the below-chart indicator pane that contains y_px.
|
|
136
|
+
// dy_px > 0 (drag down) zooms out (widens the visible value range); dy_px < 0
|
|
137
|
+
// zooms in. RSI scales about 50, MACD about its zero line. No-op when y_px is
|
|
138
|
+
// not over an indicator pane.
|
|
139
|
+
void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px, float dy_px);
|
|
140
|
+
|
|
141
|
+
// Drag the separator between the price pane and the below-chart indicator band.
|
|
142
|
+
// dy_px > 0 (drag down) grows the price pane and shrinks the indicator band.
|
|
143
|
+
// Candle pixel scale is preserved: the price range is widened/narrowed in step
|
|
144
|
+
// (anchored at the top price), so the viewport reveals/hides price rather than
|
|
145
|
+
// rescaling candles. No-op when no indicator pane is shown.
|
|
146
|
+
void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px);
|
|
147
|
+
|
|
129
148
|
// Reads the current y-axis width, x-axis height, and below-chart indicator pane
|
|
130
149
|
// height in pixels so callers can hit-test gestures against each region on the
|
|
131
150
|
// JS side. out_indicator_height_px is 0 when no indicator pane is shown.
|
|
@@ -145,6 +164,24 @@ void vroom_chart_clear_crosshair(VroomChart* chart);
|
|
|
145
164
|
// from the current crosshair x and visible window, matching what's rendered.
|
|
146
165
|
bool vroom_chart_get_crosshair_candle(VroomChart* chart, VroomCandle* out);
|
|
147
166
|
|
|
167
|
+
// What the crosshair currently snaps to, including future candle-aligned slots
|
|
168
|
+
// to the right of the last candle (empty space the chart leaves ahead of the
|
|
169
|
+
// most recent bar). `time_ms` is the snapped slot's period-start time — always
|
|
170
|
+
// valid. `has_candle` is true when a real candle sits at that slot, in which
|
|
171
|
+
// case `candle` holds its OHLCV; in the future region `has_candle` is false and
|
|
172
|
+
// `candle` is left untouched.
|
|
173
|
+
typedef struct VroomCrosshairInfo {
|
|
174
|
+
int64_t time_ms;
|
|
175
|
+
bool has_candle;
|
|
176
|
+
VroomCandle candle;
|
|
177
|
+
} VroomCrosshairInfo;
|
|
178
|
+
|
|
179
|
+
// Fills *out with the snapped slot the crosshair currently sits on and returns
|
|
180
|
+
// true. Returns false (leaving *out untouched) when the crosshair is inactive
|
|
181
|
+
// or there are no visible candles. Stateless — recomputes the snap from the
|
|
182
|
+
// current crosshair x and visible window, matching what's rendered.
|
|
183
|
+
bool vroom_chart_get_crosshair_info(VroomChart* chart, VroomCrosshairInfo* out);
|
|
184
|
+
|
|
148
185
|
// ---- Indicators -----------------------------------------------------------
|
|
149
186
|
|
|
150
187
|
// Configures the RSI indicator (rendered in a pane below the candles). `period`
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
|
|
15
15
|
namespace vroom::candles {
|
|
16
16
|
|
|
17
|
+
namespace {
|
|
18
|
+
// New border/wick colors use a transparent sentinel (alpha == 0) to mean
|
|
19
|
+
// "inherit the body fill color". Resolve to `fill` in that case.
|
|
20
|
+
inline uint32_t resolve_color(uint32_t color, uint32_t fill) {
|
|
21
|
+
return (color >> 24) == 0 ? fill : color;
|
|
22
|
+
}
|
|
23
|
+
} // namespace
|
|
24
|
+
|
|
17
25
|
void draw(SkCanvas* canvas,
|
|
18
26
|
const ::VroomCandle* visible,
|
|
19
27
|
std::size_t n,
|
|
@@ -28,22 +36,40 @@ void draw(SkCanvas* canvas,
|
|
|
28
36
|
const float body_w = vroom::candle_body_width(
|
|
29
37
|
lay, window_ms, candle_duration_ms);
|
|
30
38
|
|
|
39
|
+
const uint32_t fill_bull = theme.colors[VROOM_COLOR_BULL];
|
|
40
|
+
const uint32_t fill_bear = theme.colors[VROOM_COLOR_BEAR];
|
|
41
|
+
|
|
31
42
|
SkPaint bull_paint;
|
|
32
43
|
bull_paint.setAntiAlias(true);
|
|
33
|
-
bull_paint.setColor(
|
|
44
|
+
bull_paint.setColor(fill_bull);
|
|
34
45
|
|
|
35
46
|
SkPaint bear_paint;
|
|
36
47
|
bear_paint.setAntiAlias(true);
|
|
37
|
-
bear_paint.setColor(
|
|
48
|
+
bear_paint.setColor(fill_bear);
|
|
38
49
|
|
|
39
50
|
SkPaint wick_bull;
|
|
40
51
|
wick_bull.setAntiAlias(true);
|
|
41
|
-
wick_bull.setColor(
|
|
52
|
+
wick_bull.setColor(
|
|
53
|
+
resolve_color(theme.colors[VROOM_COLOR_WICK_BULL], fill_bull));
|
|
42
54
|
wick_bull.setStrokeWidth(theme.floats[VROOM_FLOAT_WICK_WIDTH_PX]);
|
|
43
55
|
wick_bull.setStyle(SkPaint::kStroke_Style);
|
|
44
56
|
|
|
45
57
|
SkPaint wick_bear = wick_bull;
|
|
46
|
-
wick_bear.setColor(
|
|
58
|
+
wick_bear.setColor(
|
|
59
|
+
resolve_color(theme.colors[VROOM_COLOR_WICK_BEAR], fill_bear));
|
|
60
|
+
|
|
61
|
+
// 1px body outlines. Default sentinel resolves to the fill color, so the
|
|
62
|
+
// border is invisible until a consumer sets borderBull/borderBear.
|
|
63
|
+
SkPaint border_bull;
|
|
64
|
+
border_bull.setAntiAlias(true);
|
|
65
|
+
border_bull.setStyle(SkPaint::kStroke_Style);
|
|
66
|
+
border_bull.setStrokeWidth(1.f);
|
|
67
|
+
border_bull.setColor(
|
|
68
|
+
resolve_color(theme.colors[VROOM_COLOR_BORDER_BULL], fill_bull));
|
|
69
|
+
|
|
70
|
+
SkPaint border_bear = border_bull;
|
|
71
|
+
border_bear.setColor(
|
|
72
|
+
resolve_color(theme.colors[VROOM_COLOR_BORDER_BEAR], fill_bear));
|
|
47
73
|
|
|
48
74
|
const float half_body = body_w * 0.5f;
|
|
49
75
|
|
|
@@ -65,9 +91,9 @@ void draw(SkCanvas* canvas,
|
|
|
65
91
|
const float y_top = std::min(y_open, y_close);
|
|
66
92
|
const float y_bot = std::max(y_open, y_close);
|
|
67
93
|
const float h = std::max(1.f, y_bot - y_top);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
94
|
+
const SkRect body = SkRect::MakeXYWH(cx - half_body, y_top, body_w, h);
|
|
95
|
+
canvas->drawRect(body, bull ? bull_paint : bear_paint);
|
|
96
|
+
canvas->drawRect(body, bull ? border_bull : border_bear);
|
|
71
97
|
}
|
|
72
98
|
}
|
|
73
99
|
|
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.
|
package/cpp/_core_src/chart.h
CHANGED
|
@@ -78,6 +78,9 @@ struct VroomChart {
|
|
|
78
78
|
std::vector<double> rsi_cache; // RSI per candle (NaN where undefined)
|
|
79
79
|
std::vector<double> rsi_ma_cache; // SMA of rsi_cache (empty if MA off)
|
|
80
80
|
bool rsi_dirty = true;
|
|
81
|
+
// User y-axis zoom for the RSI pane (drag on its y-axis strip). 1.0 = the
|
|
82
|
+
// default 0..100 fit; >1 zooms in (taller), <1 zooms out. Anchored at 50.
|
|
83
|
+
double rsi_y_scale = 1.0;
|
|
81
84
|
|
|
82
85
|
// MACD, drawn in its own pane. Caches aligned to `candles` (NaN warmup).
|
|
83
86
|
bool macd_enabled = false;
|
|
@@ -88,6 +91,9 @@ struct VroomChart {
|
|
|
88
91
|
std::vector<double> macd_signal_cache;
|
|
89
92
|
std::vector<double> macd_hist_cache;
|
|
90
93
|
bool macd_dirty = true;
|
|
94
|
+
// User y-axis zoom for the MACD pane (drag on its y-axis strip). 1.0 = the
|
|
95
|
+
// default auto-fit amplitude; >1 zooms in, <1 zooms out. Anchored at zero.
|
|
96
|
+
double macd_y_scale = 1.0;
|
|
91
97
|
|
|
92
98
|
// Stacking order for the indicator panes: each indicator gets the next
|
|
93
99
|
// sequence number on its off->on transition, so the most recently enabled
|
|
@@ -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;
|
|
@@ -318,6 +387,102 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
|
|
|
318
387
|
chart->mark_dirty();
|
|
319
388
|
}
|
|
320
389
|
|
|
390
|
+
extern "C" void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px) {
|
|
391
|
+
if (!chart || dy_px == 0.f) return;
|
|
392
|
+
|
|
393
|
+
const int pane_count = (chart->rsi_enabled ? 1 : 0) + (chart->macd_enabled ? 1 : 0);
|
|
394
|
+
if (pane_count == 0) return; // nothing below the chart to resize
|
|
395
|
+
|
|
396
|
+
const auto lay = chart->layout();
|
|
397
|
+
const float old_pane_bottom = vroom::price_pane_bottom(lay);
|
|
398
|
+
const float old_band = lay.indicator_area_h;
|
|
399
|
+
|
|
400
|
+
// Drag down (dy > 0) shrinks the indicator band and grows the price pane.
|
|
401
|
+
// Keep both regions usable: each pane >= kMinPanePx, price pane >= a share
|
|
402
|
+
// of the candle+indicator area.
|
|
403
|
+
constexpr float kMinPanePx = 48.f;
|
|
404
|
+
const float content_h = lay.height_px - lay.x_axis_height_px; // candle + indicators
|
|
405
|
+
const float min_band = static_cast<float>(pane_count) * kMinPanePx;
|
|
406
|
+
const float max_band = content_h * 0.70f; // leave the price pane >= 30%
|
|
407
|
+
if (max_band <= min_band) return;
|
|
408
|
+
|
|
409
|
+
float new_band = old_band - dy_px;
|
|
410
|
+
new_band = std::clamp(new_band, min_band, max_band);
|
|
411
|
+
if (new_band == old_band) return;
|
|
412
|
+
|
|
413
|
+
const float per_pane = new_band / static_cast<float>(pane_count);
|
|
414
|
+
chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC] =
|
|
415
|
+
chart->height_px > 0.f ? per_pane / chart->height_px : 0.f;
|
|
416
|
+
|
|
417
|
+
// Preserve candle pixel scale: scale the price range by the price-pane
|
|
418
|
+
// height ratio, anchoring the top (max) price so existing candles stay put
|
|
419
|
+
// and the newly revealed space opens at the bottom.
|
|
420
|
+
if (chart->price_bounds_initialized && old_pane_bottom > 0.f) {
|
|
421
|
+
const float new_pane_bottom = content_h - new_band;
|
|
422
|
+
const double scale = static_cast<double>(new_pane_bottom) /
|
|
423
|
+
static_cast<double>(old_pane_bottom);
|
|
424
|
+
const double range = chart->price_bounds.max - chart->price_bounds.min;
|
|
425
|
+
if (range > 0.0 && scale > 0.0) {
|
|
426
|
+
chart->price_bounds.min =
|
|
427
|
+
chart->price_bounds.max - range * scale;
|
|
428
|
+
vroom::labels::recompute_axis_width(*chart);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
chart->mark_dirty();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
extern "C" void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px,
|
|
436
|
+
float dy_px) {
|
|
437
|
+
if (!chart || dy_px == 0.f) return;
|
|
438
|
+
|
|
439
|
+
const auto lay = chart->layout();
|
|
440
|
+
if (lay.indicator_area_h <= 0.f) return;
|
|
441
|
+
|
|
442
|
+
// Rebuild the same ordered pane stack draw_chart uses (most recently
|
|
443
|
+
// enabled pane sorts to the bottom) to find which pane y_px falls in.
|
|
444
|
+
struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
|
|
445
|
+
ActivePane panes[2];
|
|
446
|
+
int count = 0;
|
|
447
|
+
if (chart->rsi_enabled) panes[count++] = {chart->rsi_order, 0};
|
|
448
|
+
if (chart->macd_enabled) panes[count++] = {chart->macd_order, 1};
|
|
449
|
+
if (count == 0) return;
|
|
450
|
+
if (count == 2 && panes[0].order > panes[1].order) {
|
|
451
|
+
const ActivePane tmp = panes[0];
|
|
452
|
+
panes[0] = panes[1];
|
|
453
|
+
panes[1] = tmp;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const float pane_h =
|
|
457
|
+
chart->height_px * chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
|
|
458
|
+
if (pane_h <= 0.f) return;
|
|
459
|
+
|
|
460
|
+
int target = -1; // 0 = RSI, 1 = MACD
|
|
461
|
+
float pane_top = vroom::price_pane_bottom(lay);
|
|
462
|
+
for (int i = 0; i < count; ++i) {
|
|
463
|
+
const float pane_bottom = pane_top + pane_h;
|
|
464
|
+
if (y_px >= pane_top && y_px < pane_bottom) {
|
|
465
|
+
target = panes[i].type;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
pane_top = pane_bottom;
|
|
469
|
+
}
|
|
470
|
+
if (target < 0) return; // not over a pane
|
|
471
|
+
|
|
472
|
+
// Drag down (dy > 0) widens the visible value range (zoom out), matching
|
|
473
|
+
// scale_price_axis's sign. The zoom is the inverse of the range scale.
|
|
474
|
+
double range_scale = 1.0 + static_cast<double>(dy_px) / kAxisDragSensitivity;
|
|
475
|
+
if (range_scale < 0.05) range_scale = 0.05; // never collapse or flip
|
|
476
|
+
|
|
477
|
+
double& zoom = target == 0 ? chart->rsi_y_scale : chart->macd_y_scale;
|
|
478
|
+
double next = zoom / range_scale;
|
|
479
|
+
next = std::clamp(next, 0.1, 10.0);
|
|
480
|
+
if (next == zoom) return;
|
|
481
|
+
zoom = next;
|
|
482
|
+
|
|
483
|
+
chart->mark_dirty();
|
|
484
|
+
}
|
|
485
|
+
|
|
321
486
|
extern "C" void vroom_chart_get_axis_metrics(VroomChart* chart,
|
|
322
487
|
float* out_y_axis_width_px,
|
|
323
488
|
float* out_x_axis_height_px,
|
|
@@ -387,7 +552,11 @@ extern "C" void vroom_chart_zoom(VroomChart* chart, float scale_x, float scale_y
|
|
|
387
552
|
int64_t new_end = new_start + new_window;
|
|
388
553
|
const int64_t first_time = chart->candles.front().time_ms;
|
|
389
554
|
const int64_t last_time = chart->candles.back().time_ms;
|
|
390
|
-
|
|
555
|
+
// Cap the future gap at 3/4 of the (new) window so >= 25% of the
|
|
556
|
+
// chart still shows candles, without snapping back an existing
|
|
557
|
+
// larger gap (e.g. from the x-axis drag zoom).
|
|
558
|
+
const int64_t max_future = max_future_gap(
|
|
559
|
+
new_window, chart->visible_end_ms - last_time);
|
|
391
560
|
if (new_end > last_time + max_future) {
|
|
392
561
|
new_end = last_time + max_future;
|
|
393
562
|
new_start = new_end - new_window;
|
|
@@ -453,6 +622,31 @@ extern "C" bool vroom_chart_get_crosshair_candle(VroomChart* chart,
|
|
|
453
622
|
return true;
|
|
454
623
|
}
|
|
455
624
|
|
|
625
|
+
extern "C" bool vroom_chart_get_crosshair_info(VroomChart* chart,
|
|
626
|
+
VroomCrosshairInfo* out) {
|
|
627
|
+
if (!chart || !out || !chart->crosshair_active || chart->candles.empty()) {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
// Mirror draw_chart's visible-slice derivation so the queried slot is
|
|
631
|
+
// exactly the one the crosshair snaps to on screen.
|
|
632
|
+
const auto lay = chart->layout();
|
|
633
|
+
const auto range = vroom::visible_indices(
|
|
634
|
+
chart->candles.data(), chart->candles.size(),
|
|
635
|
+
chart->visible_start_ms, chart->visible_end_ms);
|
|
636
|
+
const size_t n = range.end - range.start;
|
|
637
|
+
const int64_t window_ms = chart->visible_end_ms - chart->visible_start_ms;
|
|
638
|
+
if (n == 0 || window_ms <= 0) return false;
|
|
639
|
+
|
|
640
|
+
const ::VroomCandle* visible = chart->candles.data() + range.start;
|
|
641
|
+
const vroom::SnapResult snap = vroom::snap_to_slot(
|
|
642
|
+
lay, visible, n, chart->candle_duration_ms,
|
|
643
|
+
chart->visible_start_ms, window_ms, chart->crosshair_x_px);
|
|
644
|
+
out->time_ms = snap.time_ms;
|
|
645
|
+
out->has_candle = snap.has_candle;
|
|
646
|
+
if (snap.has_candle) out->candle = visible[snap.index];
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
|
|
456
650
|
// ---- Indicators -----------------------------------------------------------
|
|
457
651
|
|
|
458
652
|
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
|