1use 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
11pub(super) struct ChangeMarks {
13 options: ChangeMarkers,
14 marks: RefCell<Vec<Mark>>,
15}
16
17struct Mark {
19 time: DateTime<Utc>,
20 close: f64,
21 up: bool,
22 at: Instant,
23}
24
25impl ChangeMarks {
26 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 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 pub(super) fn clear(&mut self) {
71 self.marks.get_mut().clear();
72 }
73
74 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
109fn 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 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 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 #[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 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 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}