Skip to main content

egui_charts/chart/rendering/
markers.rs

1//! Marker renderer for chart annotations
2
3use 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
10/// Render markers on the chart. Times between bars interpolate; whitespace extends the
11/// most common positive spacing among the outermost 16 bars (shorter spacing breaks ties,
12/// one second if none). Vertical placement uses the nearest bar.
13/// `markers` come numbered by the caller, which names each tooltip. Returns each marker drawn
14/// with its visible hit area, in painting order.
15pub 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 the marker shape
53            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            // Render text label if present
58            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                // The chart's background keeps the text readable over the bars behind it.
67                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
107/// The price anchor and screen offset shared by painting and fitting. Pane-edge positions
108/// do not move with the price range.
109fn marker_anchor(marker: &Marker, bar: &Bar, map: &PriceMap) -> Option<(f64, f32)> {
110    // A flat range puts both ends at the same y, so choose the anchor in scale units.
111    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        // Offset by the shape's half size, 8 points at size 1.
129        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
140/// A marker's price anchor and its painted extent above and below it, in points, including
141/// strokes and the text background. Text uses the same font layout as the renderer.
142pub(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    // Allow the outside square outline, polygon joins and antialiasing at the pane edge.
161    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
176/// A marker colour with `PLACEHOLDER` resolved to the theme's text colour.
177fn themed(color: Color32, text: Color32) -> Color32 {
178    if color == Color32::PLACEHOLDER {
179        text
180    } else {
181        color
182    }
183}
184
185/// Draws a change mark at `pos`: a dot with a chevron above it for a rise or below it for a
186/// fall.
187pub(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
201/// Draws a dashed vertical rule across `coords` at each time, placed as [`render_event_band`]
202/// places its badges, in the badge colour.
203pub(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
228/// Draws host-supplied event badges just above `baseline`, using the chart's time mapping.
229/// An event between bars uses the first bar at or after its time; whitespace extrapolates the
230/// most common positive spacing among the outermost 16 bars. Colliding badges stack upward.
231/// Returns the clicked event ID; callers handle that action before starting a chart drag.
232/// The badges use the event's colour, text and tooltip, without assigning any market meaning
233/// to them.
234pub 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
250/// [`render_event_band`] for events with their own keys, which name the badges' interactions.
251pub(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
306/// Render a single marker shape
307fn 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            // Draw triangle pointing up
341            let points = vec![
342                pos + Vec2::new(0.0, -base_size * 1.2),             // top
343                pos + Vec2::new(-base_size * 0.8, base_size * 0.6), // bottom left
344                pos + Vec2::new(base_size * 0.8, base_size * 0.6),  // bottom right
345            ];
346            painter.add(Shape::convex_polygon(
347                points,
348                color,
349                Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
350            ));
351        }
352        MarkerShape::ArrowDown => {
353            // Draw triangle pointing down
354            let points = vec![
355                pos + Vec2::new(0.0, base_size * 1.2),               // bottom
356                pos + Vec2::new(-base_size * 0.8, -base_size * 0.6), // top left
357                pos + Vec2::new(base_size * 0.8, -base_size * 0.6),  // top right
358            ];
359            painter.add(Shape::convex_polygon(
360                points,
361                color,
362                Stroke::new(DESIGN_TOKENS.stroke.hairline, outline),
363            ));
364        }
365        MarkerShape::TriangleUp => {
366            // Hollow triangle pointing up
367            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            // Hollow triangle pointing down
380            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            // Draw diamond (rotated square)
393            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            // Draw simple 4-pointed star
407            let outer = base_size;
408            let inner = base_size * 0.4;
409            let points = vec![
410                pos + Vec2::new(0.0, -outer), // top
411                pos + Vec2::new(inner * 0.3, -inner * 0.3),
412                pos + Vec2::new(outer, 0.0), // right
413                pos + Vec2::new(inner * 0.3, inner * 0.3),
414                pos + Vec2::new(0.0, outer), // bottom
415                pos + Vec2::new(-inner * 0.3, inner * 0.3),
416                pos + Vec2::new(-outer, 0.0), // left
417                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            // Draw X
427            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            // Draw flag pole and triangle
439            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    /// A bar from 95 to 105 on a 90..110 scale over 500 px spans y 125..375 in
464    /// either orientation, so each marker lands at the same y: above positions
465    /// offset up from 125, below positions down from 375. Price 100, not the close
466    /// of 104, is at y 250, with a size-1 shape's 8 point half size above, below or across it.
467    #[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                    // Price 100 is the scale's middle in either orientation.
505                    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    /// A marker without a colour follows the theme's text colour, so it reads on the light
560    /// chart; one with a colour keeps it.
561    #[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        // 3:1 is the minimum contrast for graphical objects.
628        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    /// x = 500 - (35 + offset - index + 0.5) * spacing - 1.
636    /// Events at slots 11 and 30 keep distinct click targets, including a stacked duplicate.
637    #[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}