Skip to main content

egui_charts/chart/rendering/overlays/
crosshair.rs

1use super::super::{ChartMapping, RenderContext};
2use crate::chart::rendering::axes::tick_decimals;
3use crate::chart::rendering::axes::{axis_font, paint_crosshair_tag};
4use crate::config::{CrosshairMode, CrosshairOptions, CrosshairStyle};
5use crate::model::Bar;
6use crate::model::LineStyle;
7use crate::painting::dashed_line::{draw_cached_dashed_segments, draw_dashed_segments};
8use crate::scales::{HorizontalScale, PriceFormatter, PriceMap, PriceScaleMode};
9use crate::theme::ThemeExt;
10use crate::tokens::DESIGN_TOKENS;
11use egui::{Align2, Color32, Pos2, Rect, Stroke};
12
13/// Renders crosshair with full customization: style, mode, color, line width, line style
14///
15/// The vertical line runs down to `chart_bottom`, the bottom of the volume pane. The price label
16/// sits in the price axis, which ends at `axis_right`, over the `last_price_tag` it overlaps, and
17/// the time label on the time axis below `chart_bottom`, written by `scale`. Returns the rects
18/// of the price and time labels drawn.
19pub fn render_crosshair_full(
20    context: &RenderContext,
21    hover_pos: Pos2,
22    visible_data: &[Bar],
23    price_scale: &PriceMap,
24    coords: &ChartMapping,
25    mode: CrosshairMode,
26    style: CrosshairStyle,
27    crosshair_color: Color32,
28    line_width: f32,
29    line_style: LineStyle,
30    label_bg: Color32,
31    label_text: Color32,
32    chart_bottom: f32,
33    axis_right: f32,
34    last_price_tag: Option<Rect>,
35    scale: &dyn HorizontalScale,
36    bar_seconds: i64,
37) -> [Option<Rect>; 2] {
38    render_crosshair_with_options(
39        context,
40        hover_pos,
41        visible_data,
42        price_scale,
43        coords,
44        &CrosshairOptions {
45            mode,
46            style,
47            vert_line_color: crosshair_color,
48            horz_line_color: crosshair_color,
49            vert_line_width: line_width,
50            horz_line_width: line_width,
51            vert_line_style: line_style,
52            horz_line_style: line_style,
53            vert_label_background_color: label_bg,
54            horz_label_background_color: label_bg,
55            label_text_color: label_text,
56            ..Default::default()
57        },
58        chart_bottom,
59        axis_right,
60        last_price_tag,
61        scale,
62        bar_seconds,
63        None,
64    )
65}
66
67/// Renders both crosshair lines and axis labels with independently configured visibility and strokes.
68pub fn render_crosshair_with_options(
69    context: &RenderContext,
70    hover_pos: Pos2,
71    visible_data: &[Bar],
72    price_scale: &PriceMap,
73    coords: &ChartMapping,
74    options: &CrosshairOptions,
75    chart_bottom: f32,
76    axis_right: f32,
77    last_price_tag: Option<Rect>,
78    scale: &dyn HorizontalScale,
79    bar_seconds: i64,
80    price_formatter: Option<&dyn PriceFormatter>,
81) -> [Option<Rect>; 2] {
82    let options = options.resolved(context.painter.ctx());
83    let bar = coords
84        .local_idx_at_x(hover_pos.x, visible_data.len())
85        .map(|idx| (coords.start_idx + idx, &visible_data[idx]));
86    let mut pos = hover_pos;
87    if let Some((idx, candle)) = bar {
88        pos.x = coords.idx_to_x(idx);
89        if options.mode.snaps() && context.rect.contains(hover_pos) && !candle.is_whitespace() {
90            let price = if options.mode == CrosshairMode::MagnetClose {
91                candle.close
92            } else {
93                snap_to_nearest_ohlc(pos.y, price_scale, candle)
94            };
95            pos.y = price_scale.price_to_y(price);
96        }
97    }
98    render_crosshair_lines(context, pos, &options, chart_bottom);
99    let price_tag = render_crosshair_price_tag(
100        context,
101        pos,
102        price_scale,
103        &options,
104        axis_right,
105        last_price_tag,
106        price_formatter.map(|price_formatter| {
107            price_scale
108                .transform()
109                .label(price_scale.y_to_scale(pos.y), 2, Some(price_formatter))
110        }),
111    );
112    let time_tag = bar
113        .filter(|_| {
114            options.label_visible
115                && options.vert_label_visible
116                && options.style != CrosshairStyle::Hidden
117        })
118        .map(|(_, candle)| {
119            paint_crosshair_tag(
120                context.painter,
121                Pos2::new(pos.x, chart_bottom + 1.5),
122                Align2::CENTER_TOP,
123                context.rect.left()..=axis_right.max(context.rect.right()),
124                scale.format_crosshair(candle.time, bar_seconds),
125                (
126                    options.label_text_color,
127                    options.vert_label_background_color,
128                ),
129                egui::FontId::proportional(context.painter.ctx().active_theme().text.title),
130                23.5,
131                None,
132            )
133        });
134    [price_tag, time_tag]
135}
136
137pub(crate) fn render_crosshair_lines(
138    context: &RenderContext,
139    pos: Pos2,
140    options: &CrosshairOptions,
141    chart_bottom: f32,
142) {
143    // Pointerless image exports keep vector strokes for the vector writer.
144    let cache_lines = context
145        .painter
146        .ctx()
147        .input(|input| input.pointer.has_pointer());
148    let line = |segments: &[[Pos2; 2]], color, width, style| {
149        let stroke = Stroke::new(width, color);
150        let pattern = match style {
151            LineStyle::Solid => None,
152            LineStyle::Dashed => Some((6.0, 6.0)),
153            LineStyle::Dotted => Some((1.0, 3.0)),
154            LineStyle::LargeDashed => Some((10.0, 10.0)),
155            LineStyle::SparseDotted => Some((1.0, 7.0)),
156        };
157        if let Some((dash, gap)) = pattern {
158            let segments = segments.iter().map(|&[from, to]| (from, to, 0.0));
159            if cache_lines {
160                draw_cached_dashed_segments(context.painter, segments, stroke, dash, gap);
161            } else {
162                draw_dashed_segments(context.painter, segments, stroke, dash, gap);
163            }
164        } else {
165            for &points in segments {
166                context.painter.line_segment(points, stroke);
167            }
168        }
169    };
170    match options.style {
171        CrosshairStyle::Full => {
172            let vertical = [
173                Pos2::new(pos.x, context.rect.top()),
174                Pos2::new(pos.x, chart_bottom),
175            ];
176            let horizontal = [
177                Pos2::new(context.rect.left(), pos.y),
178                Pos2::new(context.rect.right(), pos.y),
179            ];
180            let show_horizontal =
181                options.horz_line_visible && context.rect.y_range().contains(pos.y);
182            if cache_lines
183                && options.vert_line_visible
184                && show_horizontal
185                && options.vert_line_style == options.horz_line_style
186                && options.vert_line_color == options.horz_line_color
187                && options.vert_line_width == options.horz_line_width
188            {
189                // One mesh avoids growing and copying the frame mesh again for the other axis.
190                line(
191                    &[vertical, horizontal],
192                    options.vert_line_color,
193                    options.vert_line_width,
194                    options.vert_line_style,
195                );
196                return;
197            }
198            if options.vert_line_visible {
199                line(
200                    &[vertical],
201                    options.vert_line_color,
202                    options.vert_line_width,
203                    options.vert_line_style,
204                );
205            }
206            if show_horizontal {
207                line(
208                    &[horizontal],
209                    options.horz_line_color,
210                    options.horz_line_width,
211                    options.horz_line_style,
212                );
213            }
214        }
215        CrosshairStyle::Dot if context.rect.contains(pos) => {
216            context.painter.circle_filled(
217                pos,
218                DESIGN_TOKENS.sizing.crosshair.dot_radius,
219                options.vert_line_color,
220            );
221        }
222        _ => {}
223    }
224}
225
226pub(crate) fn render_crosshair_price_tag(
227    context: &RenderContext,
228    pos: Pos2,
229    price_scale: &PriceMap,
230    options: &CrosshairOptions,
231    axis_right: f32,
232    last_price_tag: Option<Rect>,
233    label: Option<String>,
234) -> Option<Rect> {
235    if !options.label_visible
236        || !options.horz_label_visible
237        || options.style == CrosshairStyle::Hidden
238        || !context.rect.y_range().contains(pos.y)
239    {
240        return None;
241    }
242    let transform = price_scale.transform();
243    let label = if let Some(label) = label {
244        label
245    } else if matches!(
246        transform.mode,
247        PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100
248    ) {
249        price_scale.label(price_scale.y_to_scale(pos.y), 2, None)
250    } else {
251        let decimals = price_scale
252            .price_precision()
253            .unwrap_or_else(|| tick_decimals(price_scale, pos.y));
254        price_scale.label(price_scale.y_to_scale(pos.y), decimals, None)
255    };
256    let left = axis_right <= context.rect.left();
257    let painter = context
258        .painter
259        .with_clip_rect(context.painter.clip_rect().intersect(Rect::from_x_y_ranges(
260            context.painter.clip_rect().x_range(),
261            context.rect.y_range(),
262        )));
263    Some(paint_crosshair_tag(
264        &painter,
265        Pos2::new(
266            if left {
267                context.rect.left()
268            } else {
269                context.rect.right()
270            },
271            pos.y,
272        ),
273        if left {
274            Align2::RIGHT_CENTER
275        } else {
276            Align2::LEFT_CENTER
277        },
278        if left {
279            context.painter.clip_rect().left()..=context.rect.left()
280        } else {
281            context.rect.right()..=axis_right
282        },
283        label,
284        (
285            options.label_text_color,
286            options.horz_label_background_color,
287        ),
288        axis_font(context.painter.ctx()),
289        21.0,
290        last_price_tag,
291    ))
292}
293
294/// Snap to nearest OHLC price (for Magnet mode)
295pub(crate) fn snap_to_nearest_ohlc(hover_y: f32, price_scale: &PriceMap, candle: &Bar) -> f64 {
296    snap_to_values(
297        hover_y,
298        price_scale,
299        [candle.open, candle.high, candle.low, candle.close],
300    )
301    .unwrap_or(candle.close)
302}
303
304pub(crate) fn snap_to_values(
305    hover_y: f32,
306    price_scale: &PriceMap,
307    values: impl IntoIterator<Item = f64>,
308) -> Option<f64> {
309    values
310        .into_iter()
311        .filter(|value| value.is_finite())
312        .min_by(|a, b| {
313            (price_scale.price_to_y(*a) - hover_y)
314                .abs()
315                .total_cmp(&(price_scale.price_to_y(*b) - hover_y).abs())
316        })
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::scales::{PriceRange, PriceTransform, TimeFormatterBuilder, TimezoneMode};
323    use chrono::{Duration, TimeZone, Utc};
324    use egui::{Context, RawInput, Shape};
325
326    /// Default lines keep six-point dashes and gaps in the preset's contrasting ink.
327    #[test]
328    fn default_crosshair_dashes_read_in_both_presets() {
329        use crate::theme::{Theme, apply_to_egui, contrast};
330        for (theme, ink) in [(Theme::dark(), 119), (Theme::light(), 116)] {
331            let ctx = Context::default();
332            apply_to_egui(&ctx, &theme);
333            let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(24.0, 24.0));
334            let mut output = ctx.run_ui(RawInput::default(), |ui| {
335                let painter = ui.painter().with_clip_rect(rect);
336                render_crosshair_lines(
337                    &RenderContext::new(&painter, rect),
338                    Pos2::new(12.0, 12.0),
339                    &CrosshairOptions::default().resolved(&ctx),
340                    24.0,
341                );
342            });
343            output.textures_delta.clear();
344            let lines: Vec<_> = output
345                .shapes
346                .iter()
347                .filter_map(|shape| match shape.shape {
348                    Shape::LineSegment { points, stroke } => Some((points, stroke)),
349                    _ => None,
350                })
351                .collect();
352            assert_eq!(
353                lines,
354                [
355                    (
356                        [Pos2::new(12.0, 0.0), Pos2::new(12.0, 6.0)],
357                        Stroke::new(1.0, Color32::from_gray(ink))
358                    ),
359                    (
360                        [Pos2::new(12.0, 12.0), Pos2::new(12.0, 18.0)],
361                        Stroke::new(1.0, Color32::from_gray(ink))
362                    ),
363                    (
364                        [Pos2::new(0.0, 12.0), Pos2::new(6.0, 12.0)],
365                        Stroke::new(1.0, Color32::from_gray(ink))
366                    ),
367                    (
368                        [Pos2::new(12.0, 12.0), Pos2::new(18.0, 12.0)],
369                        Stroke::new(1.0, Color32::from_gray(ink))
370                    ),
371                ]
372            );
373            assert!(contrast(theme.chart.crosshair_line, theme.chart.bg) >= 4.5);
374            output.drop_without_applying_deltas();
375        }
376    }
377
378    /// Edge tags remain inside the pane, use supplied precision, and name the nearest bar
379    /// in the selected timezone, including when the pointer is below the price area.
380    #[test]
381    fn hover_tags_keep_the_bar_time_and_fit_both_edges() {
382        let time = Utc.with_ymd_and_hms(2024, 10, 1, 0, 30, 0).unwrap();
383        let bars: Vec<_> = (0..30)
384            .map(|i| Bar::new(time + Duration::hours(i), 704.0, 706.0, 703.0, 705.0, 10.0))
385            .collect();
386        let zones = [
387            (TimezoneMode::Utc, "Tue 01 Oct '24 00:30"),
388            #[cfg(feature = "timezones")]
389            (TimezoneMode::nyse(), "Mon 30 Sep '24 20:30"),
390        ];
391        for (zone, first_label) in zones {
392            let formatter = TimeFormatterBuilder::new().with_timezone(zone).build();
393            for (index, hover_y, width, style) in [
394                (0, 100.0, 300.0, CrosshairStyle::Full),
395                (0, 100.0, 300.0, CrosshairStyle::Arrow),
396                (0, 100.0, 300.0, CrosshairStyle::Dot),
397                (29, 100.0, 300.0, CrosshairStyle::Full),
398                (0, 250.0, 300.0, CrosshairStyle::Full),
399                (0, 100.0, 60.0, CrosshairStyle::Full),
400                (0, 1.0, 300.0, CrosshairStyle::Full),
401                (0, 199.0, 300.0, CrosshairStyle::Full),
402            ] {
403                let rect =
404                    Rect::from_min_max(Pos2::new(100.0, 0.0), Pos2::new(100.0 + width, 200.0));
405                let axis_right = rect.right() + 60.0;
406                let map = PriceMap::new(
407                    PriceTransform::default(),
408                    PriceRange::new(700.0, 710.0),
409                    0.0,
410                    200.0,
411                    false,
412                )
413                .with_price_precision(Some(3));
414                let coords = ChartMapping::new(rect, 10.0, 0, 29, 0.0, map);
415                let ctx = Context::default();
416                let mut tags = [None; 2];
417                let mut output = ctx.run_ui(RawInput::default(), |ui| {
418                    tags = render_crosshair_full(
419                        &RenderContext::new(ui.painter(), rect),
420                        Pos2::new(coords.idx_to_x(index), hover_y),
421                        &bars,
422                        &map,
423                        &coords,
424                        CrosshairMode::Normal,
425                        style,
426                        Color32::GRAY,
427                        1.0,
428                        LineStyle::Dotted,
429                        Color32::BLACK,
430                        Color32::WHITE,
431                        300.0,
432                        axis_right,
433                        None,
434                        &formatter,
435                        3600,
436                    );
437                });
438                output.textures_delta.clear();
439                let labels: Vec<_> = output
440                    .shapes
441                    .iter()
442                    .filter_map(|shape| match &shape.shape {
443                        Shape::Text(text) => Some(text.galley.text().to_owned()),
444                        _ => None,
445                    })
446                    .collect();
447                let date_text = output
448                    .shapes
449                    .iter()
450                    .find_map(|shape| match &shape.shape {
451                        Shape::Text(text) if text.galley.text().contains("'24") => Some(text),
452                        _ => None,
453                    })
454                    .unwrap();
455                assert_eq!(date_text.galley.job.sections[0].format.font_id.size, 14.0);
456                output.drop_without_applying_deltas();
457                let date = tags[1].unwrap();
458                assert!(
459                    date.left() >= rect.left() && date.right() <= axis_right,
460                    "{date:?}"
461                );
462                assert_eq!(date.height(), 23.5);
463                if let Some(tag) = tags[0] {
464                    assert_eq!(tag.height(), 21.0);
465                    assert!(
466                        tag.top() >= rect.top() && tag.bottom() <= rect.bottom(),
467                        "{tag:?}"
468                    );
469                    assert!(
470                        tag.left() >= rect.right() && tag.right() <= axis_right,
471                        "{tag:?}"
472                    );
473                } else {
474                    assert!(!rect.y_range().contains(hover_y));
475                }
476                if index == 0 {
477                    assert!(
478                        labels.iter().any(|label| label == first_label),
479                        "{labels:?}"
480                    );
481                }
482                if rect.y_range().contains(hover_y) {
483                    assert!(
484                        labels.contains(&format!("{:.3}", map.y_to_price(hover_y))),
485                        "{labels:?}"
486                    );
487                }
488            }
489        }
490    }
491    #[test]
492    fn options_control_each_stroke_and_label_at_the_bar_center() {
493        let mut saved = serde_json::to_value(CrosshairOptions::default()).unwrap();
494        let fields = saved.as_object_mut().unwrap();
495        fields.remove("vert_label_visible");
496        fields.remove("horz_label_visible");
497        assert_eq!(
498            serde_json::from_value::<CrosshairOptions>(saved).unwrap(),
499            CrosshairOptions::default()
500        );
501        let rect = Rect::from_min_max(Pos2::new(100.0, 0.0), Pos2::new(400.0, 200.0));
502        let map = PriceMap::new(
503            PriceTransform::default(),
504            PriceRange::new(700.0, 710.0),
505            0.0,
506            200.0,
507            false,
508        );
509        let coords = ChartMapping::new(rect, 10.0, 0, 29, 0.0, map);
510        let bar = Bar::new(
511            Utc.with_ymd_and_hms(2024, 10, 1, 0, 0, 0).unwrap(),
512            704.0,
513            706.0,
514            703.0,
515            705.0,
516            10.0,
517        );
518        let bars = vec![bar; 30];
519        for flags in 0..32 {
520            let vertical = flags & 1 != 0;
521            let horizontal = flags & 2 != 0;
522            let labels = flags & 4 != 0;
523            let time_label = flags & 8 != 0;
524            let price_label = flags & 16 != 0;
525            for style in [
526                CrosshairStyle::Full,
527                CrosshairStyle::Dot,
528                CrosshairStyle::Arrow,
529                CrosshairStyle::Demonstration,
530                CrosshairStyle::Magic,
531                CrosshairStyle::Hidden,
532            ] {
533                let shown = style != CrosshairStyle::Hidden;
534                let (vertical_shown, horizontal_shown) = (
535                    vertical && style == CrosshairStyle::Full,
536                    horizontal && style == CrosshairStyle::Full,
537                );
538                let options = CrosshairOptions {
539                    style,
540                    vert_line_visible: vertical,
541                    horz_line_visible: horizontal,
542                    label_visible: labels,
543                    vert_label_visible: time_label,
544                    horz_label_visible: price_label,
545                    vert_line_color: Color32::RED,
546                    horz_line_color: Color32::BLUE,
547                    vert_line_width: 2.0,
548                    horz_line_width: 3.0,
549                    vert_line_style: LineStyle::Solid,
550                    horz_line_style: LineStyle::Dotted,
551                    vert_label_background_color: Color32::DARK_RED,
552                    horz_label_background_color: Color32::DARK_GREEN,
553                    ..Default::default()
554                };
555                let ctx = Context::default();
556                let mut tags = [None; 2];
557                let x = coords.idx_to_x(10);
558                let output = ctx.run_ui(RawInput::default(), |ui| {
559                    tags = crate::chart::rendering::render_crosshair_with_options(
560                        &RenderContext::new(ui.painter(), rect),
561                        Pos2::new(x + 3.0, 100.0),
562                        &bars,
563                        &map,
564                        &coords,
565                        &options,
566                        300.0,
567                        480.0,
568                        None,
569                        &TimeFormatterBuilder::new().build(),
570                        3600,
571                        None,
572                    );
573                });
574                let (lines, dots): (Vec<_>, Vec<_>) = output
575                    .shapes
576                    .iter()
577                    .filter_map(|shape| match shape.shape {
578                        Shape::LineSegment { points, stroke } => Some((points, stroke)),
579                        _ => None,
580                    })
581                    .partition(|(_, stroke)| stroke.color == Color32::RED);
582                // The dotted horizontal line is a row of one-point dots.
583                assert!(dots.iter().all(|([a, b], stroke)| {
584                    *stroke == Stroke::new(3.0, Color32::BLUE)
585                        && a.y == 100.0
586                        && b.y == 100.0
587                        && b.x - a.x <= 1.0
588                }));
589                let dots = !dots.is_empty();
590                let fill = |color| {
591                    output
592                        .shapes
593                        .iter()
594                        .any(|shape| matches!(&shape.shape, Shape::Rect(r) if r.fill == color))
595                };
596                let (time_fill, price_fill) = (fill(Color32::DARK_RED), fill(Color32::DARK_GREEN));
597                if !shown {
598                    assert!(
599                        output
600                            .shapes
601                            .iter()
602                            .all(|shape| matches!(shape.shape, Shape::Noop))
603                    );
604                }
605                output.drop_without_applying_deltas();
606                assert_eq!(lines.len(), usize::from(vertical_shown));
607                if vertical_shown {
608                    assert!(lines.contains(&(
609                        [Pos2::new(x, 0.0), Pos2::new(x, 300.0)],
610                        Stroke::new(2.0, Color32::RED)
611                    )));
612                }
613                assert_eq!(dots, horizontal_shown);
614                assert_eq!(tags[0].is_some(), labels && price_label && shown);
615                assert_eq!(tags[1].is_some(), labels && time_label && shown);
616                assert_eq!(price_fill, tags[0].is_some());
617                assert_eq!(time_fill, tags[1].is_some());
618            }
619        }
620    }
621}