1use 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
12pub(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
29pub(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#[derive(Clone)]
48pub(crate) struct TimeOrdered<T> {
49 items: Vec<T>,
50 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 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
100pub(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 pub(super) fn paint_marks(
123 &self,
124 ctx: &RenderContext,
125 coords: &ChartMapping,
126 layer: MarkerZOrder,
127 ) {
128 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 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 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 pub(super) fn begin_mark_hits(&mut self) {
224 self.clickable_marks = std::mem::take(self.mark_hits.get_mut());
225 }
226
227 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 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 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 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 pub fn take_mark_error(&mut self) -> Option<FeedError> {
279 self.mark_source.as_mut()?.error.take()
280 }
281
282 pub fn set_time_scale_events(&mut self, events: Vec<TimeScaleEvent>) {
285 self.time_scale_events = events.into();
286 }
287
288 pub fn event_visibility(&self) -> &EventVisibility {
290 &self.event_visibility
291 }
292
293 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 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 #[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 #[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 #[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 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 #[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}