Skip to main content

egui_charts/widget/
change_marks.rs

1//! Marks where the close of a series rose or fell between updates.
2
3use super::{Chart, SeriesId};
4use crate::data::merge::Origin;
5use crate::model::{Bar, BarClock, ChangeMarkers};
6use chrono::{DateTime, Utc};
7use egui::{Painter, Pos2};
8use std::cell::RefCell;
9use web_time::Instant;
10
11/// The change markers of one bar series and the marks of its recent updates.
12pub(super) struct ChangeMarks {
13    options: ChangeMarkers,
14    marks: RefCell<Vec<Mark>>,
15}
16
17/// The change of one bar.
18struct Mark {
19    time: DateTime<Utc>,
20    close: f64,
21    up: bool,
22    at: Instant,
23}
24
25impl ChangeMarks {
26    /// The slot and close of each stored bar that a live update in `incoming` merges into.
27    pub(super) fn before(
28        bars: &[Bar],
29        clock: &BarClock,
30        incoming: &[(Bar, Origin)],
31    ) -> Vec<(DateTime<Utc>, f64)> {
32        incoming
33            .iter()
34            .filter(|(_, origin)| !matches!(origin, Origin::History))
35            .filter_map(|(bar, _)| {
36                let slot = clock.bar_open(bar.time);
37                close_at(bars, clock, slot).map(|(_, close)| (slot, close))
38            })
39            .collect()
40    }
41
42    /// Marks each slot of `before` whose close in `bars` differs from the close it had.
43    pub(super) fn record(
44        &mut self,
45        bars: &[Bar],
46        clock: &BarClock,
47        before: Vec<(DateTime<Utc>, f64)>,
48    ) {
49        let now = Instant::now();
50        let marks = self.marks.get_mut();
51        marks.retain(|mark| now.duration_since(mark.at) < self.options.duration);
52        for (slot, old) in before {
53            let Some((time, close)) = close_at(bars, clock, slot) else {
54                continue;
55            };
56            if close == old || !close.is_finite() {
57                continue;
58            }
59            marks.retain(|mark| mark.time != time);
60            marks.push(Mark {
61                time,
62                close,
63                up: close > old,
64                at: now,
65            });
66        }
67    }
68
69    /// Drops the marks, for bars that were replaced.
70    pub(super) fn clear(&mut self) {
71        self.marks.get_mut().clear();
72    }
73
74    /// Paints fading marks placed by `position`, removes expired marks and requests another
75    /// frame while a fade is visible.
76    pub(super) fn paint(
77        &self,
78        painter: &Painter,
79        position: impl Fn(DateTime<Utc>, f64) -> Option<Pos2>,
80    ) {
81        let now = Instant::now();
82        self.marks.borrow_mut().retain(|mark| {
83            let elapsed = now.duration_since(mark.at);
84            if elapsed >= self.options.duration {
85                return false;
86            }
87            if let Some(pos) = position(mark.time, mark.close)
88                && painter.clip_rect().expand(12.0).contains(pos)
89            {
90                let color = if mark.up {
91                    self.options.up_color
92                } else {
93                    self.options.down_color
94                };
95                let opacity = 1.0 - elapsed.as_secs_f64() / self.options.duration.as_secs_f64();
96                crate::chart::rendering::render_change_mark(
97                    painter,
98                    pos,
99                    mark.up,
100                    color.gamma_multiply(opacity as f32),
101                );
102                painter.ctx().request_repaint();
103            }
104            true
105        });
106    }
107}
108
109/// The time and close of the bar of `bars` in `slot`.
110fn close_at(bars: &[Bar], clock: &BarClock, slot: DateTime<Utc>) -> Option<(DateTime<Utc>, f64)> {
111    let at = bars.partition_point(|bar| clock.bar_open(bar.time) < slot);
112    bars.get(at)
113        .filter(|bar| clock.bar_open(bar.time) == slot)
114        .map(|bar| (bar.time, bar.close))
115}
116
117impl Chart {
118    /// Live change marker settings of a bar series, or `None` when disabled or absent.
119    pub fn change_markers(&self, series: SeriesId) -> Option<ChangeMarkers> {
120        if let Some(item) = self.price_series.iter().find(|item| item.id == series) {
121            item.change_marks.as_ref().map(|marks| marks.options)
122        } else if series == SeriesId::MAIN && self.main_series_present {
123            self.change_marks.as_ref().map(|marks| marks.options)
124        } else {
125            None
126        }
127    }
128
129    /// Marks, for `options.duration` after each live update, where the close of `series` rose
130    /// or fell against the close of the bar the update replaced: a dot at the new close with a
131    /// chevron above it for a rise and below it for a fall. Each mark fades linearly to
132    /// transparent over that duration, and another changed close of the same bar restarts its
133    /// fade. Updates that leave the close unchanged, new bars and history mark nothing. `None`
134    /// removes the markers.
135    ///
136    /// Bar series accept change markers; unknown ids and study ids are ignored with a warning.
137    pub fn set_change_markers(&mut self, series: SeriesId, options: Option<ChangeMarkers>) {
138        let marks = options.map(|options| ChangeMarks {
139            options,
140            marks: RefCell::default(),
141        });
142        if let Some(item) = self.price_series.iter_mut().find(|item| item.id == series) {
143            item.change_marks = marks;
144        } else if self.main_series_present
145            && super::series::holds_bars(series, "set_change_markers")
146        {
147            self.change_marks = marks;
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::widget::test_frames::chart;
156    use egui::epaint::ColorMode;
157    use egui::{Color32, Shape};
158    use std::time::Duration;
159
160    /// Both directions keep their colour while opacity falls linearly: halfway through
161    /// 1,000 seconds every premultiplied channel is halved. A changed close restarts that
162    /// lifetime; at its end the mark is removed and idle frames request no further repaint.
163    #[test]
164    fn change_markers_fade_restart_and_expire_on_each_series() {
165        let up = Color32::from_rgba_premultiplied(20, 160, 40, 200);
166        let down = Color32::from_rgba_premultiplied(120, 20, 40, 160);
167        for main in [true, false] {
168            let mut chart = chart(&[100.0], &[110.0]);
169            let id = if main {
170                SeriesId::MAIN
171            } else {
172                let id = chart.add_series(crate::widget::SeriesOptions::default());
173                chart.set_data(id, chart.bars(SeriesId::MAIN).to_vec());
174                id
175            };
176            chart.set_change_markers(
177                id,
178                Some(ChangeMarkers {
179                    up_color: up,
180                    down_color: down,
181                    duration: Duration::from_secs(1_000),
182                }),
183            );
184            let time = chart.bars(id).last().unwrap().time;
185            fn marks(chart: &Chart, main: bool) -> &ChangeMarks {
186                if main {
187                    chart.change_marks.as_ref().unwrap()
188                } else {
189                    chart.price_series[0].change_marks.as_ref().unwrap()
190                }
191            }
192            let ctx = egui::Context::default();
193            for (close, color, half) in [
194                (
195                    108.0,
196                    down,
197                    Color32::from_rgba_premultiplied(60, 10, 20, 80),
198                ),
199                (109.0, up, Color32::from_rgba_premultiplied(10, 80, 20, 100)),
200            ] {
201                chart.update(id, Bar::new(time, 100.0, 110.0, 100.0, close, 1.0));
202                assert_eq!(marks(&chart, main).marks.borrow().len(), 1);
203                assert!(
204                    marks(&chart, main).marks.borrow()[0].at.elapsed() < Duration::from_secs(1)
205                );
206                for (age, expected) in [(0, color), (500, half), (1_000, Color32::TRANSPARENT)] {
207                    if age == 1_000 && close == 108.0 {
208                        // Keep the half-faded mark for the next update to replace.
209                        continue;
210                    }
211                    for mark in marks(&chart, main).marks.borrow_mut().iter_mut() {
212                        mark.at = Instant::now() - Duration::from_secs(age);
213                    }
214                    // egui can carry a repaint request from the preceding frame.
215                    for _ in 0..3 {
216                        let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
217                            marks(&chart, main)
218                                .paint(ui.painter(), |_, _| Some(Pos2::new(50.0, 50.0)));
219                        });
220                        let painted: Vec<_> = std::mem::take(&mut output.shapes)
221                            .into_iter()
222                            .filter_map(|shape| match shape.shape {
223                                Shape::Circle(dot) => Some(dot.fill),
224                                Shape::Path(path) => match path.stroke.color {
225                                    ColorMode::Solid(color) => Some(color),
226                                    _ => None,
227                                },
228                                _ => None,
229                            })
230                            .collect();
231                        let delay = output.viewport_output[&egui::ViewportId::ROOT].repaint_delay;
232                        output.drop_without_applying_deltas();
233                        if age < 1_000 {
234                            assert_eq!(painted, vec![expected; 2], "main {main}, age {age}");
235                            assert_eq!(delay, Duration::ZERO, "animate the visible fade");
236                        } else {
237                            assert!(painted.is_empty());
238                        }
239                    }
240                }
241            }
242            assert!(marks(&chart, main).marks.borrow().is_empty());
243            let output = ctx.run_ui(egui::RawInput::default(), |ui| {
244                marks(&chart, main).paint(ui.painter(), |_, _| Some(Pos2::new(50.0, 50.0)));
245            });
246            let delay = output.viewport_output[&egui::ViewportId::ROOT].repaint_delay;
247            output.drop_without_applying_deltas();
248            assert_eq!(
249                delay,
250                Duration::MAX,
251                "expired markers leave no repaint work"
252            );
253        }
254    }
255}