Skip to main content

egui_charts/widget/
marks.rs

1//! Visible-range requests for host-owned annotations and events.
2
3use super::{Chart, ChartEvent, SeriesId};
4use crate::chart::coords::ChartMapping;
5use crate::chart::rendering::RenderContext;
6use crate::data::{FeedError, inbox::Inbox};
7use crate::model::{Bar, EventVisibility, Marker, MarkerZOrder, TimeScaleEvent};
8use crate::services::{Marks, MarksPage, MarksRequest};
9use chrono::{DateTime, Utc};
10use std::sync::{Arc, Mutex, PoisonError};
11
12/// Where a bar mark with a host ID was painted.
13pub(super) struct MarkHit {
14    id: String,
15    series: SeriesId,
16    time: DateTime<Utc>,
17    rect: egui::Rect,
18}
19
20pub(super) struct MarkSource {
21    source: Arc<dyn Marks>,
22    inbox: Arc<Inbox>,
23    request: Option<MarksRequest>,
24    wake: Arc<Mutex<egui::ViewportId>>,
25    pub(super) page: MarksPage,
26    error: Option<FeedError>,
27}
28
29/// A time, as markers and time scale events carry.
30pub(crate) trait Timed {
31    fn time(&self) -> DateTime<Utc>;
32}
33
34impl Timed for Marker {
35    fn time(&self) -> DateTime<Utc> {
36        self.time
37    }
38}
39
40impl Timed for TimeScaleEvent {
41    fn time(&self) -> DateTime<Utc> {
42        self.time
43    }
44}
45
46/// Items in supplied painting order, indexed by time so a frame visits only those near the view.
47#[derive(Clone)]
48pub(crate) struct TimeOrdered<T> {
49    items: Vec<T>,
50    /// Indices into `items` by time; equal times keep the supplied order.
51    by_time: Vec<usize>,
52}
53
54impl<T> Default for TimeOrdered<T> {
55    fn default() -> Self {
56        Self {
57            items: Vec::new(),
58            by_time: Vec::new(),
59        }
60    }
61}
62
63impl<T: Timed> From<Vec<T>> for TimeOrdered<T> {
64    fn from(items: Vec<T>) -> Self {
65        let mut by_time: Vec<usize> = (0..items.len()).collect();
66        by_time.sort_by_key(|&index| items[index].time());
67        Self { items, by_time }
68    }
69}
70
71impl<T> std::ops::Deref for TimeOrdered<T> {
72    type Target = [T];
73
74    fn deref(&self) -> &[T] {
75        &self.items
76    }
77}
78
79impl<T: Timed> TimeOrdered<T> {
80    /// Indices timed from `from` through `to`, a side open when `None`, in supplied order.
81    pub(crate) fn within(
82        &self,
83        from: Option<DateTime<Utc>>,
84        to: Option<DateTime<Utc>>,
85    ) -> Vec<usize> {
86        let time = |index: &usize| self.items[*index].time();
87        let start = from.map_or(0, |from| self.by_time.partition_point(|i| time(i) < from));
88        let end = to.map_or(self.by_time.len(), |to| {
89            self.by_time.partition_point(|i| time(i) <= to)
90        });
91        if end.saturating_sub(start) == self.items.len() {
92            return (0..self.items.len()).collect();
93        }
94        let mut indices = self.by_time[start..end.max(start)].to_vec();
95        indices.sort_unstable();
96        indices
97    }
98}
99
100/// Times a point and a bar beyond `rect` in `coords`, open past the data or without an inverse.
101/// This includes every marker or badge drawn in the rect; renderers still test each position.
102pub(super) fn window(
103    bars: &[Bar],
104    coords: &ChartMapping,
105    rect: egui::Rect,
106) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
107    if bars.is_empty() || !(f32::EPSILON..).contains(&coords.bar_spacing) {
108        return (None, None);
109    }
110    let last = (bars.len() - 1) as f64;
111    let left = (coords.x_to_idx_f64(rect.left() - 1.0) - 1.0).floor();
112    let right = (coords.x_to_idx_f64(rect.right() + 1.0) + 1.0).ceil();
113    (
114        (left > 0.0 && left <= last).then(|| bars[left as usize].time),
115        (right >= 0.0 && right < last).then(|| bars[right as usize].time),
116    )
117}
118
119impl Chart {
120    /// Paints the markers of the visible bar series whose `MarkerOptions` place them at
121    /// `layer`: the main series' own and source bar marks, and those of the added series.
122    pub(super) fn paint_marks(
123        &self,
124        ctx: &RenderContext,
125        coords: &ChartMapping,
126        layer: MarkerZOrder,
127    ) {
128        // Normal markers of added series, and all markers in other panes, are painted with
129        // each series.
130        for item in self
131            .main_pane_series()
132            .filter(|item| layer != MarkerZOrder::Normal && item.marker_options.z_order == layer)
133        {
134            let map = self.series_map(item, ctx.rect);
135            self.paint_series_markers(ctx.painter, ctx.rect, item, map);
136        }
137        if !self.main_series_visible {
138            return;
139        }
140        if layer == MarkerZOrder::Normal
141            && let Some(marks) = self
142                .change_marks
143                .as_ref()
144                .filter(|_| !self.chart_type.is_time_independent())
145        {
146            marks.paint(ctx.painter, |time, close| {
147                let index = crate::model::index_at(self.state.shown(), time)?;
148                let x = coords.idx_to_x(0) + index as f32 * coords.bar_spacing;
149                Some(egui::Pos2::new(x, coords.price.price_to_y(close)))
150            });
151        }
152        if self.marker_options.z_order != layer {
153            return;
154        }
155        let source: &[Marker] = self
156            .mark_source
157            .as_ref()
158            .map_or(&[], |source| &source.page.bar_marks);
159        if self.marks.is_empty() && source.is_empty() {
160            return;
161        }
162        // Source pages already cover the view; keep manual then source painting order and keys.
163        let (from, to) = window(self.state.shown(), coords, ctx.rect);
164        let marks = self
165            .marks
166            .within(from, to)
167            .into_iter()
168            .map(|index| (index, &self.marks[index]))
169            .chain(
170                source
171                    .iter()
172                    .enumerate()
173                    .map(|(index, mark)| (self.marks.len() + index, mark)),
174            );
175        let mapping = ChartMapping {
176            start_idx: 0,
177            ..*coords
178        };
179        if self.chart_type.is_time_independent() {
180            let mapped = crate::chart::rendering::candles::price_based::markers(
181                marks,
182                &self.state,
183                self.chart_type,
184                &self.chart_type_params,
185                self.older_bars_drawn,
186            );
187            let hits = crate::chart::rendering::render_markers(
188                ctx,
189                mapped.iter().map(|(index, mark)| (*index, mark)),
190                self.state.shown(),
191                &coords.price,
192                &mapping,
193            );
194            self.record_mark_hits(SeriesId::MAIN, hits);
195        } else {
196            let hits = crate::chart::rendering::render_markers(
197                ctx,
198                marks,
199                self.state.shown(),
200                &coords.price,
201                &mapping,
202            );
203            self.record_mark_hits(SeriesId::MAIN, hits);
204        }
205    }
206
207    /// Remembers where the marks of `series` with host IDs were painted, from the marks and
208    /// areas that `render_markers` returned.
209    pub(super) fn record_mark_hits(&self, series: SeriesId, hits: Vec<(&Marker, egui::Rect)>) {
210        self.mark_hits
211            .borrow_mut()
212            .extend(hits.into_iter().filter_map(|(mark, rect)| {
213                Some(MarkHit {
214                    id: mark.id.clone()?,
215                    series,
216                    time: mark.time,
217                    rect,
218                })
219            }));
220    }
221
222    /// Starts a frame's record of painted marks; clicks resolve against the previous frame's.
223    pub(super) fn begin_mark_hits(&mut self) {
224        self.clickable_marks = std::mem::take(self.mark_hits.get_mut());
225    }
226
227    /// The mark painted on top at `pos`: the last of those painted there. Drawing tools and
228    /// the eraser take the click first.
229    pub(super) fn mark_at(&self, pos: egui::Pos2) -> Option<&MarkHit> {
230        #[cfg(feature = "drawings")]
231        if self.drawings.active_tool.is_some() || self.drawings.eraser_mode {
232            return None;
233        }
234        self.clickable_marks
235            .iter()
236            .rev()
237            .find(|hit| hit.rect.contains(pos))
238    }
239
240    /// Reports a click on a mark with a host ID.
241    pub(super) fn click_mark(&mut self, pos: egui::Pos2) {
242        let Some(hit) = self.mark_at(pos) else {
243            return;
244        };
245        let event = ChartEvent::MarkClick {
246            id: hit.id.clone(),
247            series: hit.series,
248            time: hit.time,
249        };
250        self.host_events.push(event);
251    }
252
253    /// Connects a source of bar marks and time-axis events. Replacing or removing the source
254    /// clears its marks and pending replies; manually supplied annotations are retained.
255    pub fn set_mark_source(&mut self, source: Option<Arc<dyn Marks>>) {
256        self.mark_source = source.map(|source| MarkSource {
257            source,
258            inbox: Arc::default(),
259            request: None,
260            wake: Arc::new(Mutex::new(egui::ViewportId::ROOT)),
261            page: MarksPage::default(),
262            error: None,
263        });
264    }
265
266    /// Requests fresh marks on the next frame, for example after a host-side event update.
267    pub fn reload_marks(&mut self) {
268        if let Some(source) = &mut self.mark_source {
269            source.request = None;
270            source.inbox = Arc::default();
271            source.page = MarksPage::default();
272            source.error = None;
273        }
274    }
275
276    /// Takes the last mark request error. Retry with `reload_marks`; errors never clear manual
277    /// markers. Answers for a previous symbol, interval or viewport are ignored.
278    pub fn take_mark_error(&mut self) -> Option<FeedError> {
279        self.mark_source.as_mut()?.error.take()
280    }
281
282    /// Supplies typed corporate, economic and news events independently of a mark source.
283    /// Replaces the previous list; `set_events` continues to accept generic badges.
284    pub fn set_time_scale_events(&mut self, events: Vec<TimeScaleEvent>) {
285        self.time_scale_events = events.into();
286    }
287
288    /// Visibility of typed time-axis events, shared with the chart settings dialog.
289    pub fn event_visibility(&self) -> &EventVisibility {
290        &self.event_visibility
291    }
292
293    /// Changes event visibility without discarding the supplied events or fetching them again.
294    pub fn set_event_visibility(&mut self, visibility: EventVisibility) {
295        self.event_visibility = visibility;
296    }
297
298    pub(super) fn pump_marks(&mut self, ctx: &egui::Context) {
299        let range = self.visible_time_range();
300        let Some(source) = &mut self.mark_source else {
301            return;
302        };
303        *source.wake.lock().unwrap_or_else(PoisonError::into_inner) = ctx.viewport_id();
304        let request = range
305            .filter(|range| range.from < range.to)
306            .map(|range| MarksRequest {
307                symbol: self.symbol.clone(),
308                timeframe: self.timeframe.unwrap_or_default(),
309                from: range.from,
310                to: range.to,
311            });
312        if request != source.request {
313            // A separate inbox per request prevents abandoned replies accumulating during pans.
314            source.inbox = Arc::default();
315            let (ctx, wake) = (ctx.clone(), Arc::clone(&source.wake));
316            source.inbox.set_waker(move || {
317                let viewport = *wake.lock().unwrap_or_else(PoisonError::into_inner);
318                ctx.request_repaint_of(viewport);
319            });
320            source.page = MarksPage::default();
321            source.error = None;
322            source.request = request;
323            if let Some(request) = &source.request {
324                source.source.marks(request.clone(), source.inbox.reply(0));
325            }
326        }
327        for (_, answer) in source.inbox.drain().answers {
328            match *answer.downcast::<Result<MarksPage, FeedError>>().unwrap() {
329                Ok(page) => source.page = page,
330                Err(error) => source.error = Some(error),
331            }
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::data::Reply;
340    use crate::model::{EventKind, Marker, MarkerPos, Timeframe};
341    use crate::widget::{SeriesId, test_frames};
342    use egui::{Context, Shape};
343    use std::sync::Mutex;
344
345    #[derive(Default)]
346    struct Source(Mutex<Vec<(MarksRequest, Reply<MarksPage>)>>);
347
348    impl Marks for Source {
349        fn marks(&self, request: MarksRequest, reply: Reply<MarksPage>) {
350            self.0.lock().unwrap().push((request, reply));
351        }
352    }
353
354    /// Requests track the view, accept only its latest reply and keep manual annotations.
355    #[test]
356    fn requests_follow_the_view_and_discard_stale_answers() {
357        let ctx = Context::default();
358        let mut chart = test_frames::chart(&[95.0], &[105.0]);
359        chart.config_mut().show_volume = false;
360        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
361        let time = chart.state.shown()[70].time;
362        chart.set_markers(SeriesId::MAIN, vec![Marker::new(time).with_text("Manual")]);
363        let source = Arc::new(Source::default());
364        chart.set_mark_source(Some(source.clone()));
365        test_frames::frame(&ctx, &mut chart, 0.01, vec![]);
366        let (request, stale) = source.0.lock().unwrap().pop().unwrap();
367        let range = chart.visible_time_range().unwrap();
368        assert_eq!((request.from, request.to), (range.from, range.to));
369        chart.pump_marks(&ctx);
370        assert!(
371            source.0.lock().unwrap().is_empty(),
372            "an unchanged view must not refetch"
373        );
374        chart.state.time_scale_mut().set_bar_spacing(20.0);
375        chart.pump_marks(&ctx);
376        let (new_request, reply) = source.0.lock().unwrap().pop().unwrap();
377        assert_ne!(request, new_request);
378        let page = MarksPage {
379            bar_marks: vec![
380                Marker::new(time)
381                    .with_pos(MarkerPos::Top)
382                    .with_text("Source bar"),
383            ],
384            time_scale_marks: vec![Marker::event(time, "Axis").with_id("source-axis")],
385            ..Default::default()
386        };
387        stale.send(Ok(page.clone()));
388        chart.pump_marks(&ctx);
389        assert!(
390            chart
391                .mark_source
392                .as_ref()
393                .unwrap()
394                .page
395                .bar_marks
396                .is_empty()
397        );
398        reply.send(Ok(page));
399        chart.pump_marks(&ctx);
400        let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
401        for label in ["Manual", "Source bar", "Axis"] {
402            assert!(
403                shapes
404                    .iter()
405                    .any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label)),
406                "missing {label}"
407            );
408        }
409        chart.set_visible_bars(50);
410        let shapes = test_frames::shapes(&ctx, &mut chart, 0.2, vec![]);
411        assert!(
412            !shapes
413                .iter()
414                .any(|s| matches!(s, Shape::Text(t) if t.galley.text() == "Source bar")),
415            "a layout change must invalidate source marks before painting"
416        );
417        chart.reload_marks();
418        chart.pump_marks(&ctx);
419        source
420            .0
421            .lock()
422            .unwrap()
423            .pop()
424            .unwrap()
425            .1
426            .send(Err(FeedError::Network("offline".into())));
427        chart.pump_marks(&ctx);
428        assert_eq!(
429            chart.take_mark_error(),
430            Some(FeedError::Network("offline".into()))
431        );
432        chart.reload_marks();
433        chart.pump_marks(&ctx);
434        source
435            .0
436            .lock()
437            .unwrap()
438            .pop()
439            .unwrap()
440            .1
441            .send(Ok(MarksPage::default()));
442        chart.pump_marks(&ctx);
443        assert!(
444            chart
445                .mark_source
446                .as_ref()
447                .unwrap()
448                .page
449                .bar_marks
450                .is_empty()
451        );
452        assert_eq!(chart.marks.len(), 1);
453        for timeframe in [Timeframe::Hour1, Timeframe::Day1] {
454            chart.set_timeframe(timeframe);
455            chart.pump_marks(&ctx);
456            assert_eq!(
457                source.0.lock().unwrap().pop().unwrap().0.timeframe,
458                timeframe
459            );
460        }
461        chart.set_symbol("NEXT");
462        chart.pump_marks(&ctx);
463        assert_eq!(source.0.lock().unwrap().pop().unwrap().0.symbol, "NEXT");
464    }
465
466    /// Windowing keeps sub-bar edge marks, future-margin marks and supplied painting order.
467    #[test]
468    fn markers_near_the_view_are_drawn_and_hit_in_list_order() {
469        let ctx = Context::default();
470        let mut chart = test_frames::chart(&[95.0], &[105.0]);
471        chart.config_mut().show_volume = false;
472        let bars = chart.bars(SeriesId::MAIN).to_vec();
473        let series = chart.add_series(crate::widget::SeriesOptions::default());
474        chart.set_data(series, bars.clone());
475        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
476        chart.apply_synced_time_scale(20.0, 4.0);
477        test_frames::frame(&ctx, &mut chart, 0.1, vec![]);
478        let left = chart
479            .get_chart_mapping()
480            .x_to_idx_f64(chart.get_rendered_price_rect().left());
481        let time = |index| crate::model::time_at(&bars, index).unwrap();
482        let marks: Vec<_> = [
483            ("far-left", 0.0),
484            ("future-in", 102.0),
485            ("edge-in", left + 0.25),
486            ("future-out", 104.0),
487            ("edge-out", left - 0.25),
488            ("middle", 90.0),
489        ]
490        .into_iter()
491        .map(|(id, index)| Marker::new(time(index)).with_id(id))
492        .collect();
493        chart.set_markers(SeriesId::MAIN, marks.clone());
494        chart.set_markers(series, marks);
495        chart.set_time_scale_events(
496            [0.0, 90.0, 102.0, 104.0]
497                .into_iter()
498                .map(|index| TimeScaleEvent {
499                    id: index.to_string(),
500                    time: time(index),
501                    kind: EventKind::News,
502                    title: "Headline".into(),
503                    details: String::new(),
504                })
505                .collect(),
506        );
507        let shapes = test_frames::shapes(&ctx, &mut chart, 0.2, vec![]);
508        for series in [SeriesId::MAIN, series] {
509            let hits = chart.mark_hits.borrow();
510            let ids: Vec<_> = hits
511                .iter()
512                .filter(|hit| hit.series == series)
513                .map(|hit| hit.id.as_str())
514                .collect();
515            assert_eq!(ids, ["future-in", "edge-in", "middle"]);
516        }
517        assert_eq!(
518            shapes
519                .iter()
520                .filter(|shape| matches!(shape,
521            Shape::Circle(circle) if circle.radius == 10.0 && circle.stroke.width == 1.2))
522                .count(),
523            2
524        );
525    }
526
527    /// Every category is retained while hidden and restored by the same chart settings.
528    #[test]
529    fn category_visibility_filters_events_without_discarding_them() {
530        let ctx = Context::default();
531        let mut chart = test_frames::chart(&[95.0], &[105.0]);
532        chart.config_mut().show_volume = false;
533        let kinds = [
534            EventKind::Dividend,
535            EventKind::Split,
536            EventKind::Earnings,
537            EventKind::Economic,
538            EventKind::News,
539            EventKind::Idea,
540        ];
541        chart.set_time_scale_events(
542            kinds
543                .iter()
544                .map(|&kind| TimeScaleEvent {
545                    id: kind.label().into(),
546                    time: chart.state.shown()[70].time,
547                    kind,
548                    title: "Host headline".into(),
549                    details: "Host details".into(),
550                })
551                .collect(),
552        );
553        for separate_pane in [false, true] {
554            if separate_pane {
555                #[cfg(feature = "studies")]
556                chart.add_indicator(Box::new(crate::studies::RSI::new(14)));
557                #[cfg(not(feature = "studies"))]
558                continue;
559            }
560            for selected in [
561                None,
562                Some(0),
563                Some(1),
564                Some(2),
565                Some(3),
566                Some(4),
567                Some(5),
568                None,
569            ] {
570                let visibility = EventVisibility {
571                    show_dividends: selected == Some(0),
572                    show_splits: selected == Some(1),
573                    show_earnings: selected == Some(2),
574                    // Breaks follow their own switch, not the earnings badge.
575                    show_earnings_breaks: selected == Some(1),
576                    show_economic_events: selected == Some(3),
577                    show_news: selected == Some(4),
578                    show_news_notifications: false,
579                    show_ideas: selected == Some(5),
580                };
581                #[cfg(feature = "ui")]
582                {
583                    use crate::widget::settings::ChartSettingsState;
584                    let mut settings = ChartSettingsState::from_chart(&chart);
585                    settings.events = visibility.clone();
586                    settings.apply_to_chart(&mut chart).unwrap();
587                    assert_eq!(ChartSettingsState::from_chart(&chart).events, visibility);
588                }
589                #[cfg(not(feature = "ui"))]
590                chart.set_event_visibility(visibility);
591                let shapes = test_frames::shapes(&ctx, &mut chart, 0.0, vec![]);
592                for (index, kind) in kinds.into_iter().enumerate() {
593                    assert_eq!(
594                        shapes.iter().any(
595                            |s| matches!(s, Shape::Text(t) if t.galley.text() == kind.label())
596                        ),
597                        selected == Some(index),
598                        "{kind:?}"
599                    );
600                }
601                let info = crate::theme::ThemeExt::active_theme(&ctx).ui.info;
602                assert_eq!(
603                    shapes
604                        .iter()
605                        .any(|s| matches!(s, Shape::LineSegment { points, stroke }
606                        if points[0].x == points[1].x && stroke.color == info)),
607                    selected == Some(1),
608                    "earnings break"
609                );
610                assert_eq!(chart.time_scale_events.len(), kinds.len());
611                if separate_pane && selected.is_some() {
612                    let badge = shapes
613                        .iter()
614                        .find_map(|s| match s {
615                            Shape::Circle(circle)
616                                if circle.radius == 10.0 && circle.stroke.width == 1.2 =>
617                            {
618                                Some(circle.center)
619                            }
620                            _ => None,
621                        })
622                        .expect("an event badge above the shared time axis");
623                    assert!(
624                        badge.y > chart.get_rendered_price_rect().bottom(),
625                        "the event rail must follow the lowest pane's time axis"
626                    );
627                }
628            }
629        }
630    }
631
632    /// A primary click on marks reports the host ID of the one painted on top, with its series
633    /// and time, and selects nothing; marks without an ID are not reported.
634    #[test]
635    fn clicking_overlapping_marks_reports_the_top_one_and_selects_nothing() {
636        let ctx = Context::default();
637        let mut chart = test_frames::chart(&[95.0], &[105.0]);
638        chart.config_mut().show_volume = false;
639        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
640        let time = chart.state.shown()[70].time;
641        let mark = |id: &str| Marker::new(time).with_pos(MarkerPos::InBar).with_id(id);
642        chart.set_markers(
643            SeriesId::MAIN,
644            vec![mark("first"), mark("top"), Marker::new(time)],
645        );
646        test_frames::frame(&ctx, &mut chart, 0.1, vec![]);
647        test_frames::frame(&ctx, &mut chart, 0.2, vec![]);
648        let pos = chart.clickable_marks.last().unwrap().rect.center();
649        for pressed in [true, false] {
650            test_frames::frame(
651                &ctx,
652                &mut chart,
653                0.3,
654                vec![egui::Event::PointerButton {
655                    pos,
656                    button: egui::PointerButton::Primary,
657                    pressed,
658                    modifiers: Default::default(),
659                }],
660            );
661        }
662        let clicks: Vec<_> = chart
663            .drain_events()
664            .filter(|event| matches!(event, ChartEvent::MarkClick { .. }))
665            .collect();
666        assert_eq!(
667            clicks,
668            [ChartEvent::MarkClick {
669                id: "top".into(),
670                series: SeriesId::MAIN,
671                time
672            }]
673        );
674        assert_eq!(chart.selected_element(), None);
675    }
676}