egui_charts/chart/series/
hit_test.rs1use crate::chart::coords::ChartMapping;
6use crate::chart::hit_test::{HIT_TOLERANCE, point_to_segment_distance};
7use crate::model::Bar;
8use egui::{Pos2, Rect};
9
10use super::selection::{SeriesHitResult, SeriesId};
11
12pub const BODY_WIDTH_RATIO: f32 = 0.6;
14
15pub struct HitTestConfig {
17 pub line_tolerance: f32,
19 pub include_wicks: bool,
21}
22
23impl Default for HitTestConfig {
24 fn default() -> Self {
25 Self {
26 line_tolerance: HIT_TOLERANCE,
27 include_wicks: true,
28 }
29 }
30}
31
32pub fn hit_test_candles<F>(
36 click_pos: Pos2,
37 bars: &[Bar],
38 visible_range: std::ops::Range<usize>,
39 coords: &ChartMapping,
40 price_to_y: F,
41 config: &HitTestConfig,
42) -> Option<SeriesHitResult>
43where
44 F: Fn(f64) -> f32,
45{
46 if !coords.rect.contains(click_pos) {
48 return None;
49 }
50
51 let bar_width = coords.bar_width();
52
53 for i in visible_range {
55 let Some(bar) = bars.get(i) else { continue };
56
57 let idx_to_x = coords.idx_to_x(i);
58
59 if !coords.is_x_visible(idx_to_x) {
61 continue;
62 }
63
64 let body_left = idx_to_x - bar_width / 2.0;
66 let body_right = idx_to_x + bar_width / 2.0;
67 let (open_y, close_y) = (price_to_y(bar.open), price_to_y(bar.close));
69 let (body_top, body_bottom) = (open_y.min(close_y), open_y.max(close_y));
70
71 let body_top = body_top.min(body_bottom - 2.0);
73 let body_bottom = body_bottom.max(body_top + 2.0);
74
75 let body_rect = Rect::from_min_max(
76 Pos2::new(body_left, body_top),
77 Pos2::new(body_right, body_bottom),
78 );
79
80 if body_rect.contains(click_pos) {
81 return Some(SeriesHitResult {
82 series_id: SeriesId::MAIN,
83 bar_idx: i,
84 position: click_pos,
85 });
86 }
87
88 if config.include_wicks {
90 let (high_y, low_y) = (price_to_y(bar.high), price_to_y(bar.low));
91 let (wick_top, wick_bottom) = (high_y.min(low_y), high_y.max(low_y));
92
93 if (click_pos.x - idx_to_x).abs() <= config.line_tolerance
95 && click_pos.y >= wick_top
96 && click_pos.y <= wick_bottom
97 {
98 return Some(SeriesHitResult {
99 series_id: SeriesId::MAIN,
100 bar_idx: i,
101 position: click_pos,
102 });
103 }
104 }
105 }
106
107 None
108}
109
110pub fn hit_test_volume(
114 click_pos: Pos2,
115 bars: &[Bar],
116 visible_range: std::ops::Range<usize>,
117 coords: &ChartMapping,
118 max_volume: f64,
119) -> Option<SeriesHitResult> {
120 if !coords.rect.contains(click_pos) {
122 return None;
123 }
124
125 let bar_width = coords.bar_spacing * 0.8;
126
127 for i in visible_range {
129 let Some(bar) = bars.get(i) else { continue };
130
131 let idx_to_x = coords.idx_to_x(i);
132
133 if !coords.is_x_visible(idx_to_x) {
135 continue;
136 }
137
138 let bar_left = idx_to_x - bar_width / 2.0;
140 let bar_right = idx_to_x + bar_width / 2.0;
141
142 let norm = bar.volume / max_volume;
145 let bar_height = norm as f32 * coords.rect.height();
146 let bar_top = coords.rect.bottom() - bar_height;
147 let bar_bottom = coords.rect.bottom();
148
149 let bar_rect = Rect::from_min_max(
150 Pos2::new(bar_left, bar_top),
151 Pos2::new(bar_right, bar_bottom),
152 );
153
154 if bar_rect.contains(click_pos) {
155 return Some(SeriesHitResult {
156 series_id: SeriesId::VOLUME,
157 bar_idx: i,
158 position: click_pos,
159 });
160 }
161 }
162
163 None
164}
165
166pub fn hit_test_line<F>(
168 click_pos: Pos2,
169 values: &[f64],
170 visible_range: std::ops::Range<usize>,
171 coords: &ChartMapping,
172 price_to_y: F,
173 tolerance: f32,
174) -> Option<SeriesHitResult>
175where
176 F: Fn(f64) -> f32,
177{
178 if !coords.rect.contains(click_pos) {
179 return None;
180 }
181
182 for i in visible_range.start..visible_range.end.saturating_sub(1) {
184 let Some(&v1) = values.get(i) else { continue };
185 let Some(&v2) = values.get(i + 1) else {
186 continue;
187 };
188
189 let x1 = coords.idx_to_x(i);
190 let x2 = coords.idx_to_x(i + 1);
191
192 let min_x = x1.min(x2) - tolerance;
194 let max_x = x1.max(x2) + tolerance;
195 if click_pos.x < min_x || click_pos.x > max_x {
196 continue;
197 }
198
199 let y1 = price_to_y(v1);
200 let y2 = price_to_y(v2);
201
202 let dist = point_to_segment_distance(click_pos, Pos2::new(x1, y1), Pos2::new(x2, y2));
204
205 if dist <= tolerance {
206 return Some(SeriesHitResult {
207 series_id: SeriesId::MAIN,
208 bar_idx: i,
209 position: click_pos,
210 });
211 }
212 }
213
214 None
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::scales::{PriceMap, PriceRange, PriceTransform};
221 use chrono::{TimeZone, Utc};
222
223 #[test]
228 fn clicking_a_candle_hits_it_on_either_scale_orientation() {
229 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 500.0));
230 let t = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
231 let bars = [Bar::new(t, 100.0, 108.0, 92.0, 104.0, 1.0)];
232 for (inverted, body_y, wick_y) in [(false, 200.0, 100.0), (true, 300.0, 400.0)] {
233 let map = PriceMap::new(
234 PriceTransform::default(),
235 PriceRange::new(90.0, 110.0),
236 0.0,
237 500.0,
238 inverted,
239 );
240 let coords = ChartMapping::new(rect, 10.0, 0, 0, 0.0, map);
241 for (part, y) in [("body", body_y), ("wick", wick_y)] {
242 let hit = hit_test_candles(
243 Pos2::new(794.0, y),
244 &bars,
245 0..1,
246 &coords,
247 |price| map.price_to_y(price),
248 &HitTestConfig::default(),
249 );
250 assert_eq!(
251 hit.map(|hit| hit.bar_idx),
252 Some(0),
253 "{part}, inverted {inverted}"
254 );
255 }
256 }
257 }
258}