1use super::{ChartMapping, RenderContext};
4use crate::model::{Bar, Marker, MarkerPos, MarkerShape};
5use crate::scales::PriceMap;
6use crate::theme::ThemeExt;
7use crate::tokens::DESIGN_TOKENS;
8use egui::{Color32, FontId, Painter, Pos2, Rect, Shape, Stroke, Vec2, epaint::StrokeKind};
9
10pub fn render_markers<'a>(
16 context: &RenderContext,
17 markers: impl IntoIterator<Item = (usize, &'a Marker)>,
18 visible_data: &[Bar],
19 price_scale: &PriceMap,
20 coords: &ChartMapping,
21) -> Vec<(&'a Marker, Rect)> {
22 let mut hits = Vec::new();
23 let mut tooltip_shown = false;
24 let theme = context.painter.ctx().active_theme().chart;
25 let background = theme.bg;
26 for (marker_index, marker) in markers {
27 if !marker.size.is_finite() || marker.size <= 0.0 {
28 continue;
29 }
30 if let Some(index) = crate::model::index_at(visible_data, marker.time) {
31 let bar_idx = index.round().clamp(0.0, (visible_data.len() - 1) as f64) as usize;
32 let bar = &visible_data[bar_idx];
33 let x = coords.index_to_x(coords.start_idx as f64 + index);
34 if x < context.rect.left() || x > context.rect.right() {
35 continue;
36 }
37
38 let y = match marker_anchor(marker, bar, price_scale) {
39 Some((price, offset)) => price_scale.price_to_y(price) + offset,
40 None if marker.position == MarkerPos::Top => {
41 context.rect.top() + 20.0 * marker.size
42 }
43 None => context.rect.bottom() - 20.0 * marker.size,
44 };
45 if !y.is_finite() {
46 continue;
47 }
48
49 let pos = Pos2::new(x, y);
50 let color = themed(marker.color, theme.axis_text);
51
52 render_marker_shape(context.painter, pos, marker.shape, color, marker.size);
54
55 let mut hit_rect = Rect::from_center_size(pos, Vec2::splat(24.0 * marker.size));
56 let ctx = context.painter.ctx();
57 if let Some(text) = &marker.text {
59 let galley = context.painter.layout_no_wrap(
60 text.clone(),
61 FontId::proportional(10.0 * marker.size),
62 color,
63 );
64 let rect = egui::Align2::CENTER_TOP
65 .anchor_size(Pos2::new(pos.x, pos.y + 12.0 * marker.size), galley.size());
66 context
68 .painter
69 .rect_filled(rect.expand(1.0), 2.0, background);
70 context.painter.galley(rect.min, galley, color);
71 hit_rect = hit_rect.union(rect);
72 }
73
74 let hit_rect = hit_rect
75 .intersect(context.rect)
76 .intersect(context.painter.clip_rect());
77 if hit_rect.is_positive() {
78 hits.push((marker, hit_rect));
79 }
80 if !tooltip_shown
81 && let Some(tooltip) = &marker.tooltip
82 && ctx.pointer_hover_pos().is_some_and(|p| {
83 hit_rect.contains(p) && ctx.layer_id_at(p) == Some(context.painter.layer_id())
84 })
85 {
86 egui::Tooltip::always_open(
87 ctx.clone(),
88 context.painter.layer_id(),
89 egui::Id::unique((
90 "bar-mark",
91 context.rect.min.x.to_bits(),
92 context.rect.min.y.to_bits(),
93 marker_index,
94 )),
95 hit_rect,
96 )
97 .show(|ui| {
98 ui.label(tooltip);
99 });
100 tooltip_shown = true;
101 }
102 }
103 }
104 hits
105}
106
107fn marker_anchor(marker: &Marker, bar: &Bar, map: &PriceMap) -> Option<(f64, f32)> {
110 let (high, low) = (
112 map.transform().to_scale(bar.high),
113 map.transform().to_scale(bar.low),
114 );
115 let reversed = (map.extent().0 > map.extent().1) ^ (map.range().min > map.range().max);
116 let (top, bottom) = if map.is_inverted() ^ reversed {
117 (high.min(low), high.max(low))
118 } else {
119 (high.max(low), high.min(low))
120 };
121 let price = |value| if value == high { bar.high } else { bar.low };
122 let (top, bottom) = (price(top), price(bottom));
123 let (price, offset) = match marker.position {
124 MarkerPos::AboveBar => (top, -20.0),
125 MarkerPos::BelowBar => (bottom, 20.0),
126 MarkerPos::AbsoluteUp => (top, -10.0),
127 MarkerPos::AbsoluteDown => (bottom, 10.0),
128 MarkerPos::AtPriceTop => (marker.price.unwrap_or(bar.close), -8.0),
130 MarkerPos::AtPriceBottom => (marker.price.unwrap_or(bar.close), 8.0),
131 MarkerPos::AtPriceMiddle => (marker.price.unwrap_or(bar.close), 0.0),
132 MarkerPos::InBar | MarkerPos::Left | MarkerPos::Right | MarkerPos::Absolute => {
133 (bar.close, 0.0)
134 }
135 MarkerPos::Top | MarkerPos::Bottom => return None,
136 };
137 Some((price, offset * marker.size))
138}
139
140pub(crate) fn marker_bounds(
143 painter: &Painter,
144 marker: &Marker,
145 bar: &Bar,
146 map: &PriceMap,
147) -> Option<(f64, f32, f32)> {
148 if !marker.size.is_finite() || marker.size <= 0.0 {
149 return None;
150 }
151 let (price, offset) = marker_anchor(marker, bar, map)?;
152 let base = 8.0 * marker.size;
153 let (top, bottom) = match marker.shape {
154 MarkerShape::ArrowUp | MarkerShape::TriangleUp => (-base * 1.2, base * 0.6),
155 MarkerShape::ArrowDown | MarkerShape::TriangleDown => (-base * 0.6, base * 1.2),
156 MarkerShape::Cross => (-base * 1.2, base * 1.2),
157 MarkerShape::Flag => (-base * 1.2, base * 2.0),
158 _ => (-base, base),
159 };
160 let stroke = DESIGN_TOKENS.stroke.thick;
162 let (mut top, mut bottom) = (top - stroke, bottom + stroke);
163 if let Some(text) = &marker.text {
164 let galley = painter.layout_no_wrap(
165 text.clone(),
166 FontId::proportional(10.0 * marker.size),
167 Color32::WHITE,
168 );
169 let y = 12.0 * marker.size;
170 top = top.min(y + galley.mesh_bounds.top().min(-1.0));
171 bottom = bottom.max(y + galley.mesh_bounds.bottom().max(galley.size().y + 1.0));
172 }
173 Some((price, -offset - top, offset + bottom))
174}
175
176fn themed(color: Color32, text: Color32) -> Color32 {
178 if color == Color32::PLACEHOLDER {
179 text
180 } else {
181 color
182 }
183}
184
185pub(crate) fn render_change_mark(painter: &Painter, pos: Pos2, up: bool, color: Color32) {
188 painter.circle_filled(pos, 4.0, color);
189 let sign = if up { 1.0 } else { -1.0 };
190 let y = pos.y - 7.0 * sign;
191 painter.add(Shape::line(
192 vec![
193 Pos2::new(pos.x - 4.7, y),
194 Pos2::new(pos.x, y - 3.5 * sign),
195 Pos2::new(pos.x + 4.7, y),
196 ],
197 Stroke::new(2.0, color),
198 ));
199}
200
201pub(crate) fn render_event_breaks(
204 ui: &egui::Ui,
205 times: &[chrono::DateTime<chrono::Utc>],
206 visible_data: &[Bar],
207 coords: &ChartMapping,
208) {
209 let painter = ui.painter().with_clip_rect(coords.rect);
210 let stroke = Stroke::new(1.0, ui.ctx().active_theme().ui.info);
211 for &time in times {
212 let Some(at) = crate::model::index_at(visible_data, time) else {
213 continue;
214 };
215 let x = coords.index_to_x(coords.start_idx as f64 + at.ceil());
216 painter.extend(Shape::dashed_line(
217 &[
218 Pos2::new(x, coords.rect.top()),
219 Pos2::new(x, coords.rect.bottom()),
220 ],
221 stroke,
222 4.0,
223 4.0,
224 ));
225 }
226}
227
228pub fn render_event_band(
235 ui: &mut egui::Ui,
236 events: &[Marker],
237 visible_data: &[Bar],
238 coords: &ChartMapping,
239 baseline: f32,
240) -> Option<String> {
241 render_event_badges(
242 ui,
243 events.iter().enumerate(),
244 visible_data,
245 coords,
246 baseline,
247 )
248}
249
250pub(crate) fn render_event_badges<'a>(
252 ui: &mut egui::Ui,
253 events: impl IntoIterator<Item = (usize, &'a Marker)>,
254 visible_data: &[Bar],
255 coords: &ChartMapping,
256 baseline: f32,
257) -> Option<String> {
258 let mut clicked = None;
259 visible_data.first()?;
260 let mut placed = Vec::<Rect>::new();
261 let theme = ui.ctx().active_theme().chart;
262 for (index, event) in events {
263 let at = crate::model::index_at(visible_data, event.time)?.ceil();
264 let x = coords.index_to_x(coords.start_idx as f64 + at);
265 let mut rect = Rect::from_center_size(Pos2::new(x, baseline - 11.0), Vec2::splat(22.0));
266 while placed.iter().any(|other| other.intersects(rect)) {
267 rect = rect.translate(Vec2::new(0.0, -24.0));
268 }
269 if rect.left() < coords.rect.left()
270 || rect.right() > coords.rect.right()
271 || rect.top() < coords.rect.top()
272 {
273 continue;
274 }
275 placed.push(rect);
276 let response = ui.interact(
277 rect,
278 ui.scope_id().with(("event", &event.id, index)),
279 egui::Sense::click(),
280 );
281 let color = themed(event.color, theme.axis_text);
282 let painter = ui.painter().with_clip_rect(rect);
283 painter.circle(rect.center(), 10.0, theme.bg, Stroke::new(1.2, color));
284 if let Some(text) = &event.text {
285 painter.text(
286 rect.center(),
287 egui::Align2::CENTER_CENTER,
288 text,
289 FontId::proportional(11.0),
290 color,
291 );
292 }
293 let label = event
294 .tooltip
295 .as_deref()
296 .or(event.text.as_deref())
297 .unwrap_or("Event");
298 response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
299 if response.on_hover_text(label).clicked() {
300 clicked.clone_from(&event.id);
301 }
302 }
303 clicked
304}
305
306fn render_marker_shape(
308 painter: &Painter,
309 pos: Pos2,
310 shape: MarkerShape,
311 color: Color32,
312 size: f32,
313) {
314 let base_size = 8.0 * size;
315 let outline = painter.ctx().active_theme().chart.crosshair_label_text;
316
317 match shape {
318 MarkerShape::Circle => {
319 painter.circle_filled(pos, base_size, color);
320 painter.circle_stroke(
321 pos,
322 base_size,
323 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
324 );
325 }
326 MarkerShape::Square => {
327 painter.rect_filled(
328 Rect::from_center_size(pos, Vec2::splat(base_size * 2.0)),
329 0.0,
330 color,
331 );
332 painter.rect_stroke(
333 Rect::from_center_size(pos, Vec2::splat(base_size * 2.0)),
334 0.0,
335 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
336 StrokeKind::Outside,
337 );
338 }
339 MarkerShape::ArrowUp => {
340 let points = vec![
342 pos + Vec2::new(0.0, -base_size * 1.2), pos + Vec2::new(-base_size * 0.8, base_size * 0.6), pos + Vec2::new(base_size * 0.8, base_size * 0.6), ];
346 painter.add(Shape::convex_polygon(
347 points,
348 color,
349 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
350 ));
351 }
352 MarkerShape::ArrowDown => {
353 let points = vec![
355 pos + Vec2::new(0.0, base_size * 1.2), pos + Vec2::new(-base_size * 0.8, -base_size * 0.6), pos + Vec2::new(base_size * 0.8, -base_size * 0.6), ];
359 painter.add(Shape::convex_polygon(
360 points,
361 color,
362 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
363 ));
364 }
365 MarkerShape::TriangleUp => {
366 let points = vec![
368 pos + Vec2::new(0.0, -base_size * 1.2),
369 pos + Vec2::new(-base_size * 0.8, base_size * 0.6),
370 pos + Vec2::new(base_size * 0.8, base_size * 0.6),
371 ];
372 painter.add(Shape::convex_polygon(
373 points,
374 Color32::TRANSPARENT,
375 Stroke::new(DESIGN_TOKENS.stroke.thick, color),
376 ));
377 }
378 MarkerShape::TriangleDown => {
379 let points = vec![
381 pos + Vec2::new(0.0, base_size * 1.2),
382 pos + Vec2::new(-base_size * 0.8, -base_size * 0.6),
383 pos + Vec2::new(base_size * 0.8, -base_size * 0.6),
384 ];
385 painter.add(Shape::convex_polygon(
386 points,
387 Color32::TRANSPARENT,
388 Stroke::new(DESIGN_TOKENS.stroke.thick, color),
389 ));
390 }
391 MarkerShape::Diamond => {
392 let points = vec![
394 pos + Vec2::new(0.0, -base_size),
395 pos + Vec2::new(-base_size, 0.0),
396 pos + Vec2::new(0.0, base_size),
397 pos + Vec2::new(base_size, 0.0),
398 ];
399 painter.add(Shape::convex_polygon(
400 points,
401 color,
402 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
403 ));
404 }
405 MarkerShape::Star => {
406 let outer = base_size;
408 let inner = base_size * 0.4;
409 let points = vec![
410 pos + Vec2::new(0.0, -outer), pos + Vec2::new(inner * 0.3, -inner * 0.3),
412 pos + Vec2::new(outer, 0.0), pos + Vec2::new(inner * 0.3, inner * 0.3),
414 pos + Vec2::new(0.0, outer), pos + Vec2::new(-inner * 0.3, inner * 0.3),
416 pos + Vec2::new(-outer, 0.0), pos + Vec2::new(-inner * 0.3, -inner * 0.3),
418 ];
419 painter.add(Shape::convex_polygon(
420 points,
421 color,
422 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
423 ));
424 }
425 MarkerShape::Cross => {
426 let size = base_size * 1.2;
428 painter.line_segment(
429 [pos + Vec2::new(-size, -size), pos + Vec2::new(size, size)],
430 Stroke::new(DESIGN_TOKENS.stroke.thick, color),
431 );
432 painter.line_segment(
433 [pos + Vec2::new(-size, size), pos + Vec2::new(size, -size)],
434 Stroke::new(DESIGN_TOKENS.stroke.thick, color),
435 );
436 }
437 MarkerShape::Flag => {
438 painter.line_segment(
440 [pos, pos + Vec2::new(0.0, base_size * 2.0)],
441 Stroke::new(DESIGN_TOKENS.stroke.thick, color),
442 );
443 let points = vec![
444 pos,
445 pos + Vec2::new(base_size * 1.2, -base_size * 0.6),
446 pos + Vec2::new(0.0, -base_size * 1.2),
447 ];
448 painter.add(Shape::convex_polygon(
449 points,
450 color,
451 Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
452 ));
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::scales::{PriceRange, PriceTransform};
461 use chrono::{TimeZone, Utc};
462
463 #[test]
468 fn markers_sit_outside_the_bar_on_either_scale_orientation() {
469 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(160.0, 500.0));
470 let t = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
471 let bars = [0, 2].map(|seconds| {
472 Bar::new(
473 t + chrono::Duration::seconds(seconds),
474 100.0,
475 105.0,
476 95.0,
477 104.0,
478 1.0,
479 )
480 });
481 for inverted in [false, true] {
482 let map = PriceMap::new(
483 PriceTransform::default(),
484 PriceRange::new(90.0, 110.0),
485 0.0,
486 500.0,
487 inverted,
488 );
489 let coords = ChartMapping::new(rect, 10.0, 12, 13, 2.0, map);
490 for (position, want) in [
491 (MarkerPos::AboveBar, 105.0),
492 (MarkerPos::BelowBar, 395.0),
493 (MarkerPos::AbsoluteUp, 115.0),
494 (MarkerPos::AbsoluteDown, 385.0),
495 (MarkerPos::AtPriceTop, 242.0),
496 (MarkerPos::AtPriceBottom, 258.0),
497 (MarkerPos::AtPriceMiddle, 250.0),
498 ] {
499 for seconds in [-2, 0, 1, 3] {
500 let marker = Marker::new(t + chrono::Duration::seconds(seconds))
501 .with_tooltip("Host bar annotation")
502 .with_pos(position)
503 .with_shape(MarkerShape::Circle);
504 let marker = match position {
506 MarkerPos::AtPriceTop
507 | MarkerPos::AtPriceBottom
508 | MarkerPos::AtPriceMiddle => marker.at_price(100.0, position),
509 _ => marker,
510 };
511 let ctx = egui::Context::default();
512 let hover = Pos2::new(coords.idx_to_x(12) + seconds as f32 * 5.0, want);
513 let mut output = egui::FullOutput::default();
514 for _ in 0..3 {
515 output = ctx.run_ui(
516 egui::RawInput {
517 events: vec![egui::Event::PointerMoved(hover)],
518 ..Default::default()
519 },
520 |ui| {
521 let painter = Painter::new(
522 ui.ctx().clone(),
523 egui::LayerId::background(),
524 rect,
525 );
526 render_markers(
527 &RenderContext::new(&painter, rect),
528 [(0, &marker)],
529 &bars,
530 &map,
531 &coords,
532 );
533 },
534 );
535 output.textures_delta.clear();
536 }
537 assert!(
538 output.shapes.iter().any(|shape| matches!(&shape.shape,
539 Shape::Text(text) if text.galley.text() == "Host bar annotation")),
540 "the marker tooltip must appear at its painted position"
541 );
542 let center = output.shapes.iter().find_map(|c| match &c.shape {
543 Shape::Circle(circle) => Some(circle.center),
544 _ => None,
545 });
546 output.drop_without_applying_deltas();
547 let center = center.expect("a marker circle");
548 assert_eq!(center.x, coords.idx_to_x(12) + seconds as f32 * 5.0);
549 let y = center.y;
550 assert!(
551 (y - want).abs() < 1e-3,
552 "{position:?}, inverted {inverted}: y {y}, want {want}"
553 );
554 }
555 }
556 }
557 }
558
559 #[test]
562 fn default_marker_colour_reads_on_the_light_theme() {
563 let ctx = egui::Context::default();
564 let light = crate::theme::Theme::light();
565 crate::theme::apply_to_egui(&ctx, &light);
566 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(160.0, 500.0));
567 let t = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
568 let bars = [0, 2].map(|seconds| {
569 Bar::new(
570 t + chrono::Duration::seconds(seconds),
571 100.0,
572 105.0,
573 95.0,
574 104.0,
575 1.0,
576 )
577 });
578 let map = PriceMap::new(
579 PriceTransform::default(),
580 PriceRange::new(90.0, 110.0),
581 0.0,
582 500.0,
583 false,
584 );
585 let coords = ChartMapping::new(rect, 10.0, 12, 13, 2.0, map);
586 let markers = [
587 Marker::new(t).with_text("default"),
588 Marker::new(t + chrono::Duration::seconds(2))
589 .with_shape(MarkerShape::Circle)
590 .with_color(Color32::RED)
591 .with_text("explicit"),
592 Marker::new(t + chrono::Duration::seconds(4)).with_color(Color32::WHITE),
593 ];
594 let markers: Vec<Marker> =
595 serde_json::from_str(&serde_json::to_string(&markers).unwrap()).unwrap();
596 let output = ctx.run_ui(egui::RawInput::default(), |ui| {
597 let painter = Painter::new(ui.ctx().clone(), egui::LayerId::background(), rect);
598 render_markers(
599 &RenderContext::new(&painter, rect),
600 markers.iter().enumerate(),
601 &bars,
602 &map,
603 &coords,
604 );
605 });
606 let fills: Vec<_> = output
607 .shapes
608 .iter()
609 .filter_map(|shape| match &shape.shape {
610 Shape::Circle(circle) if circle.fill != Color32::TRANSPARENT => Some(circle.fill),
611 _ => None,
612 })
613 .collect();
614 let text_matches = [
615 ("default", light.chart.axis_text),
616 ("explicit", Color32::RED),
617 ]
618 .map(|(label, color)| {
619 output.shapes.iter().any(|shape| {
620 matches!(&shape.shape,
621 Shape::Text(text) if text.galley.text() == label
622 && text.galley.job.sections.iter().all(|s| s.format.color == color))
623 })
624 });
625 output.drop_without_applying_deltas();
626 assert_eq!(fills.len(), 3);
627 assert!(crate::theme::contrast(fills[0], light.chart.bg) >= 3.0);
629 assert_eq!(fills[1], Color32::RED);
630 assert_eq!(fills[2], Color32::WHITE);
631 assert_eq!(markers[0].color, Color32::PLACEHOLDER);
632 assert!(text_matches.into_iter().all(|matches| matches));
633 }
634
635 #[test]
638 fn event_band_follows_time_mapping_and_returns_the_clicked_id() {
639 use egui::{Context, Event, Modifiers, PointerButton, RawInput};
640 let ctx = Context::default();
641 let light = crate::theme::Theme::light();
642 crate::theme::apply_to_egui(&ctx, &light);
643 let time = Utc.with_ymd_and_hms(2024, 10, 1, 14, 30, 0).unwrap();
644 let bars: Vec<_> = (0..30)
645 .map(|i| {
646 Bar::new(
647 time + chrono::Duration::hours(i),
648 100.0,
649 101.0,
650 99.0,
651 100.0,
652 1.0,
653 )
654 })
655 .collect();
656 let event = Marker::new(bars[10].time + chrono::Duration::minutes(15))
657 .with_text("E")
658 .with_id("event-42");
659 let events = [
660 event.clone(),
661 event.with_id("event-43").with_color(Color32::RED),
662 Marker::event(bars[29].time + chrono::Duration::hours(1), "N").with_id("future"),
663 ];
664 for (spacing, offset, x) in [(10.0, 0.0, [254.0, 444.0]), (15.0, 2.0, [101.5, 386.5])] {
665 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 300.0));
666 let map = PriceMap::new(
667 PriceTransform::default(),
668 PriceRange::new(90.0, 110.0),
669 0.0,
670 200.0,
671 false,
672 );
673 let coords = ChartMapping::new(rect, spacing, 0, 35, offset, map);
674 let centres = [
675 Pos2::new(x[0], 289.0),
676 Pos2::new(x[0], 265.0),
677 Pos2::new(x[1], 289.0),
678 ];
679 for (centre, event) in centres.into_iter().zip(&events) {
680 let mut clicked = None;
681 for pressed in [None, Some(true), Some(false)] {
682 let mut input = RawInput::default();
683 input.events.push(Event::PointerMoved(centre));
684 if let Some(pressed) = pressed {
685 input.events.push(Event::PointerButton {
686 pos: centre,
687 button: PointerButton::Primary,
688 pressed,
689 modifiers: Modifiers::NONE,
690 });
691 }
692 let output = ctx.run_ui(input, |ui| {
693 clicked = render_event_band(ui, &events, &bars, &coords, 300.0)
694 .or(clicked.take());
695 });
696 let circles: Vec<_> = output
697 .shapes
698 .iter()
699 .filter_map(|shape| match &shape.shape {
700 Shape::Circle(circle) => Some((circle.center, circle.stroke.color)),
701 _ => None,
702 })
703 .collect();
704 output.drop_without_applying_deltas();
705 assert_eq!(
706 circles,
707 [
708 (centres[0], light.chart.axis_text),
709 (centres[1], Color32::RED),
710 (centres[2], events[2].color),
711 ]
712 );
713 }
714 assert_eq!(clicked.as_deref(), event.id.as_deref());
715 }
716 }
717 }
718}