Skip to main content

egui_charts/widget/
crosshair.rs

1//! Crosshair snapping and study legend actions shared by the chart panes.
2
3use super::{Chart, ChartElementId, PaneId, RightClickTarget, SeriesId};
4use crate::chart::rendering::{ChartMapping, SeriesAction, StudyAction, study_mapping};
5use crate::config::CrosshairMode;
6use crate::scales::PriceMap;
7use crate::studies::Indicator;
8use egui::Pos2;
9
10impl Chart {
11    /// Sets whether the crosshair follows the pointer value or snaps to visible data.
12    pub fn set_crosshair_mode(&mut self, mode: CrosshairMode) {
13        self.chart_options.crosshair.mode = mode;
14    }
15
16    /// Resolves the shared crosshair position without changing the stored time slots.
17    pub(super) fn crosshair_position(&self, mut pos: Pos2, coords: &ChartMapping) -> Option<Pos2> {
18        let options = &self.chart_options.time_scale;
19        if !options.ignore_whitespace_indices && !options.ignore_hidden_indices {
20            return Some(pos);
21        }
22        let bars = self.state.shown();
23        let index = coords.x_to_idx_f64(pos.x);
24        if bars.is_empty() || !index.is_finite() {
25            return None;
26        }
27        let first = coords.x_to_idx_f64(coords.rect.left()).ceil().max(0.0) as isize;
28        let last = coords
29            .x_to_idx_f64(coords.rect.right())
30            .floor()
31            .min(bars.len() as f64 - 1.0) as isize;
32        if first > last {
33            return None;
34        }
35        // Pixel rounding must not bias an equal-distance tie towards an earlier slot.
36        let max_coordinate = coords.rect.left().abs().max(coords.rect.right().abs());
37        let precision = f32::EPSILON * max_coordinate.max(1.0);
38        let mut left = index.floor().clamp(first as f64 - 1.0, last as f64) as isize;
39        let mut right = left + 1;
40        while left >= first || right <= last {
41            let candidate = if right > last
42                || (left >= first
43                    && (pos.x - coords.idx_to_x(left as usize)).abs() + precision
44                        < (coords.idx_to_x(right as usize) - pos.x).abs())
45            {
46                let candidate = left as usize;
47                left -= 1;
48                candidate
49            } else {
50                let candidate = right as usize;
51                right += 1;
52                candidate
53            };
54            let x = coords.idx_to_x(candidate);
55            if self.crosshair_index_eligible(candidate) {
56                pos.x = x;
57                return Some(pos);
58            }
59        }
60        None
61    }
62
63    fn crosshair_index_eligible(&self, index: usize) -> bool {
64        let options = &self.chart_options.time_scale;
65        let bar = &self.state.shown()[index];
66        let series = self.price_series.iter().filter_map(|item| {
67            let at = item
68                .bars
69                .binary_search_by_key(&bar.time, |bar| bar.time)
70                .ok()?;
71            Some((item.visible, !item.bars[at].is_whitespace()))
72        });
73        let studies = self
74            .rendered_studies()
75            .indicators()
76            .iter()
77            .flat_map(|study| {
78                (0..study.line_count()).map(move |line| {
79                    let plot = study.plot_style(line);
80                    let valued = !matches!(
81                        plot.plot_type,
82                        crate::studies::PlotType::BarColor
83                            | crate::studies::PlotType::BackgroundColor
84                    ) && study.plot_value(line, index).is_some_and(f64::is_finite);
85                    (study.is_visible() && plot.visible, valued)
86                })
87            });
88        let mut valued = false;
89        for (visible, has_value) in
90            std::iter::once((self.main_series_visible, !bar.is_whitespace()))
91                .chain(series)
92                .chain(studies)
93        {
94            if has_value {
95                if visible || !options.ignore_hidden_indices {
96                    return true;
97                }
98                valued = true;
99            }
100        }
101        !valued && !options.ignore_whitespace_indices
102    }
103
104    pub(super) fn snap_crosshair(&self, mut pos: Pos2, map: &PriceMap, index: usize) -> Pos2 {
105        let mode = self.chart_options.crosshair.mode;
106        if !mode.snaps() {
107            return pos;
108        }
109        let close_only = mode == CrosshairMode::MagnetClose;
110        // Candidates are pointer-space heights, so each value goes through its own scale.
111        let ohlc = self
112            .state
113            .shown()
114            .get(index)
115            .filter(|bar| self.main_series_visible && !bar.is_whitespace())
116            .into_iter()
117            .flat_map(|bar| {
118                [bar.open, bar.high, bar.low, bar.close]
119                    .into_iter()
120                    .skip(if close_only { 3 } else { 0 })
121            })
122            .map(|value| map.price_to_y(value));
123        let time_based = !self.chart_type.is_time_independent();
124        let coords = ChartMapping {
125            price: *map,
126            ..self.get_chart_mapping()
127        };
128        let studies = self
129            .study_ids
130            .iter()
131            .zip(self.studies.indicators())
132            .filter(|(id, study)| {
133                !close_only
134                    && time_based
135                    && study.is_visible()
136                    && study.is_overlay()
137                    && !study.is_volume()
138                    && self.series_pane(**id) == Some(PaneId::MAIN)
139            })
140            .flat_map(|(_, study)| {
141                let scale = study_mapping(study.as_ref(), &coords).price;
142                (0..study.line_count())
143                    .filter(|line| {
144                        let plot = study.plot_style(*line);
145                        plot.visible
146                            && !matches!(
147                                plot.plot_type,
148                                crate::studies::PlotType::BarColor
149                                    | crate::studies::PlotType::BackgroundColor
150                            )
151                    })
152                    .filter_map(move |line| study.plot_value(line, index))
153                    .map(move |value| scale.price_to_y(value))
154            });
155        let rect = self.last_rendered_price_rect;
156        let series = self
157            .main_pane_series()
158            .filter(|_| time_based || close_only)
159            .filter_map(|item| {
160                let scale = self.series_map(item, rect);
161                self.series_close_at(item, index)
162                    .map(|value| scale.price_to_y(value))
163            });
164        // Equal distances keep the first candidate: the bar, then studies, then series.
165        if let Some(y) = ohlc
166            .chain(studies)
167            .chain(series)
168            .filter(|y| y.is_finite())
169            .min_by(|a, b| (a - pos.y).abs().total_cmp(&(b - pos.y).abs()))
170        {
171            pos.y = y;
172        }
173        pos
174    }
175
176    /// Applies a legend control of the main series to the chart's typed operations. Settings are
177    /// reported to the host, as for a study, with [`SeriesId::MAIN`].
178    pub(super) fn apply_main_series_action(&mut self, action: SeriesAction) {
179        self.user_action(|chart| match action {
180            SeriesAction::Settings => chart.indicator_settings_request = Some(SeriesId::MAIN),
181            SeriesAction::ToggleVisibility => {
182                chart.set_main_series_visible(!chart.main_series_visible);
183            }
184            SeriesAction::Remove => {
185                chart.remove_series(SeriesId::MAIN);
186            }
187        });
188    }
189
190    pub(super) fn apply_study_action(&mut self, action: StudyAction) {
191        let index = match action {
192            StudyAction::Settings(index)
193            | StudyAction::ToggleVisibility(index)
194            | StudyAction::Remove(index)
195            | StudyAction::Menu { index, .. } => index,
196        };
197        let Some(id) = self.study_ids.get(index).copied() else {
198            return;
199        };
200        if let StudyAction::Menu { pos, .. } = action {
201            // The bar the legend shows: the hovered one, or the latest.
202            let bars = self.state.shown().len();
203            let bar_idx = self
204                .last_hover_bar_idx
205                .map(|index| index.round() as usize)
206                .filter(|index| *index < bars)
207                .unwrap_or(bars.saturating_sub(1));
208            let element = ChartElementId::Indicator(id);
209            self.selection.select(element, Some(bar_idx));
210            self.right_click = Some(RightClickTarget::Element {
211                id: element,
212                bar_idx,
213                pos,
214                price: self
215                    .indicator_value_at(id, bar_idx)
216                    .map_or(f64::NAN, |(_, value)| value),
217            });
218            return;
219        }
220        self.user_action(|chart| match action {
221            StudyAction::Settings(_) => chart.indicator_settings_request = Some(id),
222            StudyAction::ToggleVisibility(_) => {
223                chart.set_indicator_visible(id, !chart.studies.indicators()[index].is_visible());
224            }
225            StudyAction::Remove(_) => {
226                chart.remove_series(id);
227            }
228            StudyAction::Menu { .. } => {}
229        });
230    }
231}
232
233/// The output and value a study's menu refers to at `bar`: its first visible plot with a value
234/// there, which for volume is the bar's column.
235pub(crate) fn menu_value(study: &dyn Indicator, bar: usize) -> Option<(usize, f64)> {
236    (0..study.line_count())
237        .filter(|line| {
238            let plot = study.plot_style(*line);
239            plot.visible
240                && !matches!(
241                    plot.plot_type,
242                    crate::studies::PlotType::BarColor | crate::studies::PlotType::BackgroundColor
243                )
244        })
245        .find_map(|line| Some((line, study.plot_value(line, bar)?)))
246}