Skip to main content

egui_charts/chart/series/
hit_test.rs

1//! Hit testing for chart series elements.
2//!
3//! Provides functions to detect user clicks on candlesticks, bars, and lines.
4
5use 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
12/// Body width ratio relative to bar spacing (from chart-rendering.md)
13pub const BODY_WIDTH_RATIO: f32 = 0.6;
14
15/// Configuration for series hit testing (tolerance, wick inclusion).
16pub struct HitTestConfig {
17    /// Tolerance in pixels for line/wick hits
18    pub line_tolerance: f32,
19    /// Whether to include wick hits for candles
20    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
32/// Hit test candles to find which bar was clicked.
33///
34/// Uses the same coordinate system as the chart renderer (right-to-left).
35pub 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    // Ensure click is within chart area
47    if !coords.rect.contains(click_pos) {
48        return None;
49    }
50
51    let bar_width = coords.bar_width();
52
53    // Check each visible bar
54    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        // Skip if bar is outside visible area
60        if !coords.is_x_visible(idx_to_x) {
61            continue;
62        }
63
64        // Hit test body
65        let body_left = idx_to_x - bar_width / 2.0;
66        let body_right = idx_to_x + bar_width / 2.0;
67        // Ordered on screen: the higher price is lower down on an inverted scale.
68        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        // Ensure minimum body height for doji candles
72        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        // Hit test wicks if enabled
89        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            // Check if click is near the wick line
94            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
110/// Hit test volume bars to find which bar was clicked.
111///
112/// Uses the same coordinate system as the chart renderer (right-to-left).
113pub 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    // Ensure click is within volume area
121    if !coords.rect.contains(click_pos) {
122        return None;
123    }
124
125    let bar_width = coords.bar_spacing * 0.8;
126
127    // Check each visible bar
128    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        // Skip if bar is outside visible area
134        if !coords.is_x_visible(idx_to_x) {
135            continue;
136        }
137
138        // Calculate volume bar bounds
139        let bar_left = idx_to_x - bar_width / 2.0;
140        let bar_right = idx_to_x + bar_width / 2.0;
141
142        // Volume bar goes from bottom up, using full panel height
143        // Must match actual volume rendering: bar_height = (vol/max) * height
144        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
166/// Hit test a line series
167pub 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    // Find the segment closest to click position
183    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        // Check if click x is within this segment (with some tolerance)
193        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        // Calculate distance from point to line segment
203        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    /// A candle opening at 100, closing at 104, from 92 to 108, on a 90..110
224    /// scale over 500 px: its body centre (102) is at y 200 upright and 300
225    /// inverted, a point on its upper wick (106) at y 100 upright and 400
226    /// inverted. The bar is the last one, at x 800 - 10 / 2 - 1 = 794.
227    #[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}