1use super::super::{ChartMapping, RenderContext};
2use crate::chart::rendering::axes::tick_decimals;
3use crate::chart::rendering::axes::{axis_font, paint_crosshair_tag};
4use crate::config::{CrosshairMode, CrosshairOptions, CrosshairStyle};
5use crate::model::Bar;
6use crate::model::LineStyle;
7use crate::painting::dashed_line::{draw_cached_dashed_segments, draw_dashed_segments};
8use crate::scales::{HorizontalScale, PriceFormatter, PriceMap, PriceScaleMode};
9use crate::theme::ThemeExt;
10use crate::tokens::DESIGN_TOKENS;
11use egui::{Align2, Color32, Pos2, Rect, Stroke};
12
13pub fn render_crosshair_full(
20 context: &RenderContext,
21 hover_pos: Pos2,
22 visible_data: &[Bar],
23 price_scale: &PriceMap,
24 coords: &ChartMapping,
25 mode: CrosshairMode,
26 style: CrosshairStyle,
27 crosshair_color: Color32,
28 line_width: f32,
29 line_style: LineStyle,
30 label_bg: Color32,
31 label_text: Color32,
32 chart_bottom: f32,
33 axis_right: f32,
34 last_price_tag: Option<Rect>,
35 scale: &dyn HorizontalScale,
36 bar_seconds: i64,
37) -> [Option<Rect>; 2] {
38 render_crosshair_with_options(
39 context,
40 hover_pos,
41 visible_data,
42 price_scale,
43 coords,
44 &CrosshairOptions {
45 mode,
46 style,
47 vert_line_color: crosshair_color,
48 horz_line_color: crosshair_color,
49 vert_line_width: line_width,
50 horz_line_width: line_width,
51 vert_line_style: line_style,
52 horz_line_style: line_style,
53 vert_label_background_color: label_bg,
54 horz_label_background_color: label_bg,
55 label_text_color: label_text,
56 ..Default::default()
57 },
58 chart_bottom,
59 axis_right,
60 last_price_tag,
61 scale,
62 bar_seconds,
63 None,
64 )
65}
66
67pub fn render_crosshair_with_options(
69 context: &RenderContext,
70 hover_pos: Pos2,
71 visible_data: &[Bar],
72 price_scale: &PriceMap,
73 coords: &ChartMapping,
74 options: &CrosshairOptions,
75 chart_bottom: f32,
76 axis_right: f32,
77 last_price_tag: Option<Rect>,
78 scale: &dyn HorizontalScale,
79 bar_seconds: i64,
80 price_formatter: Option<&dyn PriceFormatter>,
81) -> [Option<Rect>; 2] {
82 let options = options.resolved(context.painter.ctx());
83 let bar = coords
84 .local_idx_at_x(hover_pos.x, visible_data.len())
85 .map(|idx| (coords.start_idx + idx, &visible_data[idx]));
86 let mut pos = hover_pos;
87 if let Some((idx, candle)) = bar {
88 pos.x = coords.idx_to_x(idx);
89 if options.mode.snaps() && context.rect.contains(hover_pos) && !candle.is_whitespace() {
90 let price = if options.mode == CrosshairMode::MagnetClose {
91 candle.close
92 } else {
93 snap_to_nearest_ohlc(pos.y, price_scale, candle)
94 };
95 pos.y = price_scale.price_to_y(price);
96 }
97 }
98 render_crosshair_lines(context, pos, &options, chart_bottom);
99 let price_tag = render_crosshair_price_tag(
100 context,
101 pos,
102 price_scale,
103 &options,
104 axis_right,
105 last_price_tag,
106 price_formatter.map(|price_formatter| {
107 price_scale
108 .transform()
109 .label(price_scale.y_to_scale(pos.y), 2, Some(price_formatter))
110 }),
111 );
112 let time_tag = bar
113 .filter(|_| {
114 options.label_visible
115 && options.vert_label_visible
116 && options.style != CrosshairStyle::Hidden
117 })
118 .map(|(_, candle)| {
119 paint_crosshair_tag(
120 context.painter,
121 Pos2::new(pos.x, chart_bottom + 1.5),
122 Align2::CENTER_TOP,
123 context.rect.left()..=axis_right.max(context.rect.right()),
124 scale.format_crosshair(candle.time, bar_seconds),
125 (
126 options.label_text_color,
127 options.vert_label_background_color,
128 ),
129 egui::FontId::proportional(context.painter.ctx().active_theme().text.title),
130 23.5,
131 None,
132 )
133 });
134 [price_tag, time_tag]
135}
136
137pub(crate) fn render_crosshair_lines(
138 context: &RenderContext,
139 pos: Pos2,
140 options: &CrosshairOptions,
141 chart_bottom: f32,
142) {
143 let cache_lines = context
145 .painter
146 .ctx()
147 .input(|input| input.pointer.has_pointer());
148 let line = |segments: &[[Pos2; 2]], color, width, style| {
149 let stroke = Stroke::new(width, color);
150 let pattern = match style {
151 LineStyle::Solid => None,
152 LineStyle::Dashed => Some((6.0, 6.0)),
153 LineStyle::Dotted => Some((1.0, 3.0)),
154 LineStyle::LargeDashed => Some((10.0, 10.0)),
155 LineStyle::SparseDotted => Some((1.0, 7.0)),
156 };
157 if let Some((dash, gap)) = pattern {
158 let segments = segments.iter().map(|&[from, to]| (from, to, 0.0));
159 if cache_lines {
160 draw_cached_dashed_segments(context.painter, segments, stroke, dash, gap);
161 } else {
162 draw_dashed_segments(context.painter, segments, stroke, dash, gap);
163 }
164 } else {
165 for &points in segments {
166 context.painter.line_segment(points, stroke);
167 }
168 }
169 };
170 match options.style {
171 CrosshairStyle::Full => {
172 let vertical = [
173 Pos2::new(pos.x, context.rect.top()),
174 Pos2::new(pos.x, chart_bottom),
175 ];
176 let horizontal = [
177 Pos2::new(context.rect.left(), pos.y),
178 Pos2::new(context.rect.right(), pos.y),
179 ];
180 let show_horizontal =
181 options.horz_line_visible && context.rect.y_range().contains(pos.y);
182 if cache_lines
183 && options.vert_line_visible
184 && show_horizontal
185 && options.vert_line_style == options.horz_line_style
186 && options.vert_line_color == options.horz_line_color
187 && options.vert_line_width == options.horz_line_width
188 {
189 line(
191 &[vertical, horizontal],
192 options.vert_line_color,
193 options.vert_line_width,
194 options.vert_line_style,
195 );
196 return;
197 }
198 if options.vert_line_visible {
199 line(
200 &[vertical],
201 options.vert_line_color,
202 options.vert_line_width,
203 options.vert_line_style,
204 );
205 }
206 if show_horizontal {
207 line(
208 &[horizontal],
209 options.horz_line_color,
210 options.horz_line_width,
211 options.horz_line_style,
212 );
213 }
214 }
215 CrosshairStyle::Dot if context.rect.contains(pos) => {
216 context.painter.circle_filled(
217 pos,
218 DESIGN_TOKENS.sizing.crosshair.dot_radius,
219 options.vert_line_color,
220 );
221 }
222 _ => {}
223 }
224}
225
226pub(crate) fn render_crosshair_price_tag(
227 context: &RenderContext,
228 pos: Pos2,
229 price_scale: &PriceMap,
230 options: &CrosshairOptions,
231 axis_right: f32,
232 last_price_tag: Option<Rect>,
233 label: Option<String>,
234) -> Option<Rect> {
235 if !options.label_visible
236 || !options.horz_label_visible
237 || options.style == CrosshairStyle::Hidden
238 || !context.rect.y_range().contains(pos.y)
239 {
240 return None;
241 }
242 let transform = price_scale.transform();
243 let label = if let Some(label) = label {
244 label
245 } else if matches!(
246 transform.mode,
247 PriceScaleMode::Percentage | PriceScaleMode::IndexedTo100
248 ) {
249 price_scale.label(price_scale.y_to_scale(pos.y), 2, None)
250 } else {
251 let decimals = price_scale
252 .price_precision()
253 .unwrap_or_else(|| tick_decimals(price_scale, pos.y));
254 price_scale.label(price_scale.y_to_scale(pos.y), decimals, None)
255 };
256 let left = axis_right <= context.rect.left();
257 let painter = context
258 .painter
259 .with_clip_rect(context.painter.clip_rect().intersect(Rect::from_x_y_ranges(
260 context.painter.clip_rect().x_range(),
261 context.rect.y_range(),
262 )));
263 Some(paint_crosshair_tag(
264 &painter,
265 Pos2::new(
266 if left {
267 context.rect.left()
268 } else {
269 context.rect.right()
270 },
271 pos.y,
272 ),
273 if left {
274 Align2::RIGHT_CENTER
275 } else {
276 Align2::LEFT_CENTER
277 },
278 if left {
279 context.painter.clip_rect().left()..=context.rect.left()
280 } else {
281 context.rect.right()..=axis_right
282 },
283 label,
284 (
285 options.label_text_color,
286 options.horz_label_background_color,
287 ),
288 axis_font(context.painter.ctx()),
289 21.0,
290 last_price_tag,
291 ))
292}
293
294pub(crate) fn snap_to_nearest_ohlc(hover_y: f32, price_scale: &PriceMap, candle: &Bar) -> f64 {
296 snap_to_values(
297 hover_y,
298 price_scale,
299 [candle.open, candle.high, candle.low, candle.close],
300 )
301 .unwrap_or(candle.close)
302}
303
304pub(crate) fn snap_to_values(
305 hover_y: f32,
306 price_scale: &PriceMap,
307 values: impl IntoIterator<Item = f64>,
308) -> Option<f64> {
309 values
310 .into_iter()
311 .filter(|value| value.is_finite())
312 .min_by(|a, b| {
313 (price_scale.price_to_y(*a) - hover_y)
314 .abs()
315 .total_cmp(&(price_scale.price_to_y(*b) - hover_y).abs())
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::scales::{PriceRange, PriceTransform, TimeFormatterBuilder, TimezoneMode};
323 use chrono::{Duration, TimeZone, Utc};
324 use egui::{Context, RawInput, Shape};
325
326 #[test]
328 fn default_crosshair_dashes_read_in_both_presets() {
329 use crate::theme::{Theme, apply_to_egui, contrast};
330 for (theme, ink) in [(Theme::dark(), 119), (Theme::light(), 116)] {
331 let ctx = Context::default();
332 apply_to_egui(&ctx, &theme);
333 let rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(24.0, 24.0));
334 let mut output = ctx.run_ui(RawInput::default(), |ui| {
335 let painter = ui.painter().with_clip_rect(rect);
336 render_crosshair_lines(
337 &RenderContext::new(&painter, rect),
338 Pos2::new(12.0, 12.0),
339 &CrosshairOptions::default().resolved(&ctx),
340 24.0,
341 );
342 });
343 output.textures_delta.clear();
344 let lines: Vec<_> = output
345 .shapes
346 .iter()
347 .filter_map(|shape| match shape.shape {
348 Shape::LineSegment { points, stroke } => Some((points, stroke)),
349 _ => None,
350 })
351 .collect();
352 assert_eq!(
353 lines,
354 [
355 (
356 [Pos2::new(12.0, 0.0), Pos2::new(12.0, 6.0)],
357 Stroke::new(1.0, Color32::from_gray(ink))
358 ),
359 (
360 [Pos2::new(12.0, 12.0), Pos2::new(12.0, 18.0)],
361 Stroke::new(1.0, Color32::from_gray(ink))
362 ),
363 (
364 [Pos2::new(0.0, 12.0), Pos2::new(6.0, 12.0)],
365 Stroke::new(1.0, Color32::from_gray(ink))
366 ),
367 (
368 [Pos2::new(12.0, 12.0), Pos2::new(18.0, 12.0)],
369 Stroke::new(1.0, Color32::from_gray(ink))
370 ),
371 ]
372 );
373 assert!(contrast(theme.chart.crosshair_line, theme.chart.bg) >= 4.5);
374 output.drop_without_applying_deltas();
375 }
376 }
377
378 #[test]
381 fn hover_tags_keep_the_bar_time_and_fit_both_edges() {
382 let time = Utc.with_ymd_and_hms(2024, 10, 1, 0, 30, 0).unwrap();
383 let bars: Vec<_> = (0..30)
384 .map(|i| Bar::new(time + Duration::hours(i), 704.0, 706.0, 703.0, 705.0, 10.0))
385 .collect();
386 let zones = [
387 (TimezoneMode::Utc, "Tue 01 Oct '24 00:30"),
388 #[cfg(feature = "timezones")]
389 (TimezoneMode::nyse(), "Mon 30 Sep '24 20:30"),
390 ];
391 for (zone, first_label) in zones {
392 let formatter = TimeFormatterBuilder::new().with_timezone(zone).build();
393 for (index, hover_y, width, style) in [
394 (0, 100.0, 300.0, CrosshairStyle::Full),
395 (0, 100.0, 300.0, CrosshairStyle::Arrow),
396 (0, 100.0, 300.0, CrosshairStyle::Dot),
397 (29, 100.0, 300.0, CrosshairStyle::Full),
398 (0, 250.0, 300.0, CrosshairStyle::Full),
399 (0, 100.0, 60.0, CrosshairStyle::Full),
400 (0, 1.0, 300.0, CrosshairStyle::Full),
401 (0, 199.0, 300.0, CrosshairStyle::Full),
402 ] {
403 let rect =
404 Rect::from_min_max(Pos2::new(100.0, 0.0), Pos2::new(100.0 + width, 200.0));
405 let axis_right = rect.right() + 60.0;
406 let map = PriceMap::new(
407 PriceTransform::default(),
408 PriceRange::new(700.0, 710.0),
409 0.0,
410 200.0,
411 false,
412 )
413 .with_price_precision(Some(3));
414 let coords = ChartMapping::new(rect, 10.0, 0, 29, 0.0, map);
415 let ctx = Context::default();
416 let mut tags = [None; 2];
417 let mut output = ctx.run_ui(RawInput::default(), |ui| {
418 tags = render_crosshair_full(
419 &RenderContext::new(ui.painter(), rect),
420 Pos2::new(coords.idx_to_x(index), hover_y),
421 &bars,
422 &map,
423 &coords,
424 CrosshairMode::Normal,
425 style,
426 Color32::GRAY,
427 1.0,
428 LineStyle::Dotted,
429 Color32::BLACK,
430 Color32::WHITE,
431 300.0,
432 axis_right,
433 None,
434 &formatter,
435 3600,
436 );
437 });
438 output.textures_delta.clear();
439 let labels: Vec<_> = output
440 .shapes
441 .iter()
442 .filter_map(|shape| match &shape.shape {
443 Shape::Text(text) => Some(text.galley.text().to_owned()),
444 _ => None,
445 })
446 .collect();
447 let date_text = output
448 .shapes
449 .iter()
450 .find_map(|shape| match &shape.shape {
451 Shape::Text(text) if text.galley.text().contains("'24") => Some(text),
452 _ => None,
453 })
454 .unwrap();
455 assert_eq!(date_text.galley.job.sections[0].format.font_id.size, 14.0);
456 output.drop_without_applying_deltas();
457 let date = tags[1].unwrap();
458 assert!(
459 date.left() >= rect.left() && date.right() <= axis_right,
460 "{date:?}"
461 );
462 assert_eq!(date.height(), 23.5);
463 if let Some(tag) = tags[0] {
464 assert_eq!(tag.height(), 21.0);
465 assert!(
466 tag.top() >= rect.top() && tag.bottom() <= rect.bottom(),
467 "{tag:?}"
468 );
469 assert!(
470 tag.left() >= rect.right() && tag.right() <= axis_right,
471 "{tag:?}"
472 );
473 } else {
474 assert!(!rect.y_range().contains(hover_y));
475 }
476 if index == 0 {
477 assert!(
478 labels.iter().any(|label| label == first_label),
479 "{labels:?}"
480 );
481 }
482 if rect.y_range().contains(hover_y) {
483 assert!(
484 labels.contains(&format!("{:.3}", map.y_to_price(hover_y))),
485 "{labels:?}"
486 );
487 }
488 }
489 }
490 }
491 #[test]
492 fn options_control_each_stroke_and_label_at_the_bar_center() {
493 let mut saved = serde_json::to_value(CrosshairOptions::default()).unwrap();
494 let fields = saved.as_object_mut().unwrap();
495 fields.remove("vert_label_visible");
496 fields.remove("horz_label_visible");
497 assert_eq!(
498 serde_json::from_value::<CrosshairOptions>(saved).unwrap(),
499 CrosshairOptions::default()
500 );
501 let rect = Rect::from_min_max(Pos2::new(100.0, 0.0), Pos2::new(400.0, 200.0));
502 let map = PriceMap::new(
503 PriceTransform::default(),
504 PriceRange::new(700.0, 710.0),
505 0.0,
506 200.0,
507 false,
508 );
509 let coords = ChartMapping::new(rect, 10.0, 0, 29, 0.0, map);
510 let bar = Bar::new(
511 Utc.with_ymd_and_hms(2024, 10, 1, 0, 0, 0).unwrap(),
512 704.0,
513 706.0,
514 703.0,
515 705.0,
516 10.0,
517 );
518 let bars = vec![bar; 30];
519 for flags in 0..32 {
520 let vertical = flags & 1 != 0;
521 let horizontal = flags & 2 != 0;
522 let labels = flags & 4 != 0;
523 let time_label = flags & 8 != 0;
524 let price_label = flags & 16 != 0;
525 for style in [
526 CrosshairStyle::Full,
527 CrosshairStyle::Dot,
528 CrosshairStyle::Arrow,
529 CrosshairStyle::Demonstration,
530 CrosshairStyle::Magic,
531 CrosshairStyle::Hidden,
532 ] {
533 let shown = style != CrosshairStyle::Hidden;
534 let (vertical_shown, horizontal_shown) = (
535 vertical && style == CrosshairStyle::Full,
536 horizontal && style == CrosshairStyle::Full,
537 );
538 let options = CrosshairOptions {
539 style,
540 vert_line_visible: vertical,
541 horz_line_visible: horizontal,
542 label_visible: labels,
543 vert_label_visible: time_label,
544 horz_label_visible: price_label,
545 vert_line_color: Color32::RED,
546 horz_line_color: Color32::BLUE,
547 vert_line_width: 2.0,
548 horz_line_width: 3.0,
549 vert_line_style: LineStyle::Solid,
550 horz_line_style: LineStyle::Dotted,
551 vert_label_background_color: Color32::DARK_RED,
552 horz_label_background_color: Color32::DARK_GREEN,
553 ..Default::default()
554 };
555 let ctx = Context::default();
556 let mut tags = [None; 2];
557 let x = coords.idx_to_x(10);
558 let output = ctx.run_ui(RawInput::default(), |ui| {
559 tags = crate::chart::rendering::render_crosshair_with_options(
560 &RenderContext::new(ui.painter(), rect),
561 Pos2::new(x + 3.0, 100.0),
562 &bars,
563 &map,
564 &coords,
565 &options,
566 300.0,
567 480.0,
568 None,
569 &TimeFormatterBuilder::new().build(),
570 3600,
571 None,
572 );
573 });
574 let (lines, dots): (Vec<_>, Vec<_>) = output
575 .shapes
576 .iter()
577 .filter_map(|shape| match shape.shape {
578 Shape::LineSegment { points, stroke } => Some((points, stroke)),
579 _ => None,
580 })
581 .partition(|(_, stroke)| stroke.color == Color32::RED);
582 assert!(dots.iter().all(|([a, b], stroke)| {
584 *stroke == Stroke::new(3.0, Color32::BLUE)
585 && a.y == 100.0
586 && b.y == 100.0
587 && b.x - a.x <= 1.0
588 }));
589 let dots = !dots.is_empty();
590 let fill = |color| {
591 output
592 .shapes
593 .iter()
594 .any(|shape| matches!(&shape.shape, Shape::Rect(r) if r.fill == color))
595 };
596 let (time_fill, price_fill) = (fill(Color32::DARK_RED), fill(Color32::DARK_GREEN));
597 if !shown {
598 assert!(
599 output
600 .shapes
601 .iter()
602 .all(|shape| matches!(shape.shape, Shape::Noop))
603 );
604 }
605 output.drop_without_applying_deltas();
606 assert_eq!(lines.len(), usize::from(vertical_shown));
607 if vertical_shown {
608 assert!(lines.contains(&(
609 [Pos2::new(x, 0.0), Pos2::new(x, 300.0)],
610 Stroke::new(2.0, Color32::RED)
611 )));
612 }
613 assert_eq!(dots, horizontal_shown);
614 assert_eq!(tags[0].is_some(), labels && price_label && shown);
615 assert_eq!(tags[1].is_some(), labels && time_label && shown);
616 assert_eq!(price_fill, tags[0].is_some());
617 assert_eq!(time_fill, tags[1].is_some());
618 }
619 }
620 }
621}