1use super::{DrawingManager, PaintedDrawing};
4use crate::drawings::domain::{ChartPoint, Drawing, DrawingToolType};
5use crate::drawings::rendering::ChartView;
6use crate::drawings::services::{DrawingInteraction, HandleService, in_z_order};
7use egui::epaint::{ClippedShape, Mesh, TessellationOptions, Tessellator, emath::TSTransform};
8use egui::{Color32, Pos2, Rect, Shape};
9use std::borrow::Cow;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13pub(super) struct Tessellated {
18 pixels_per_point: f32,
19 options: TessellationOptions,
20 shapes: Vec<Option<Shape>>,
21}
22
23impl Tessellated {
24 fn new(shapes: &[ClippedShape], pixels_per_point: f32, options: TessellationOptions) -> Self {
25 let mut tessellator = Tessellator::new(pixels_per_point, options, [1, 1], Vec::new());
27 let mut out: Vec<Option<Shape>> = vec![None; shapes.len()];
28 let mut run: Option<(usize, Mesh)> = None;
29 for (i, clipped) in shapes.iter().enumerate() {
30 let plain = is_plain(&clipped.shape)
31 && clipped.transform_after_tessellation == TSTransform::IDENTITY;
32 if (!plain
33 || run
34 .as_ref()
35 .is_some_and(|(first, _)| shapes[*first].clip_rect != clipped.clip_rect))
36 && let Some((first, mesh)) = run.take()
37 {
38 out[first] = Some(Shape::mesh(mesh));
39 }
40 if !plain {
41 continue;
42 }
43 let (_, mesh) = run.get_or_insert_with(|| (i, Mesh::default()));
44 tessellator.set_clip_rect(clipped.clip_rect);
45 tessellator.tessellate_shape(clipped.shape.clone(), mesh);
46 out[i] = Some(Shape::Noop);
47 }
48 if let Some((first, mesh)) = run {
49 out[first] = Some(Shape::mesh(mesh));
50 }
51 Self {
52 pixels_per_point,
53 options,
54 shapes: out,
55 }
56 }
57}
58
59fn same_geometry(a: &[ClippedShape], b: &[ClippedShape]) -> bool {
62 a.len() == b.len()
63 && a.iter()
64 .zip(b)
65 .all(|(a, b)| match (is_plain(&a.shape), is_plain(&b.shape)) {
66 (true, true) => a == b,
67 (plain_a, plain_b) => !plain_a && !plain_b,
68 })
69}
70
71fn is_plain(shape: &Shape) -> bool {
73 match shape {
74 Shape::Vec(shapes) => shapes.iter().all(is_plain),
75 Shape::Rect(rect) => rect.fill_texture_id() == egui::TextureId::default(),
76 Shape::Noop
77 | Shape::Ellipse(_)
78 | Shape::LineSegment { .. }
79 | Shape::Path(_)
80 | Shape::Band(_)
81 | Shape::QuadraticBezier(_)
82 | Shape::CubicBezier(_) => true,
83 Shape::Circle(_) | Shape::Text(_) | Shape::Mesh(_) | Shape::Callback(_) => false,
84 }
85}
86
87impl DrawingManager {
88 pub(super) fn handle_drawing<'a>(&self, drawing: &'a Drawing) -> Cow<'a, Drawing> {
90 if drawing.tool_type != DrawingToolType::RegressionTrend || drawing.custom_tool.is_some() {
91 return Cow::Borrowed(drawing);
92 }
93 let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
94 let ends = painted
95 .iter()
96 .find(|p| p.id == drawing.id && p.points == drawing.points)
97 .and_then(|p| p.regression_ends);
98 let mut projected = drawing.clone();
99 projected.points = ends.map(Vec::from).unwrap_or_default();
100 Cow::Owned(projected)
101 }
102
103 pub fn render_handles(&self, painter: &egui::Painter, drawing: &Drawing) {
106 let dragging = self
107 .dragging_handle
108 .filter(|(id, _)| *id == drawing.id)
109 .map(|(_, h)| h);
110 HandleService::render_handles(
111 painter,
112 &self.handle_drawing(drawing),
113 &self.options.handle_config,
114 dragging,
115 );
116 }
117
118 pub fn hit_test(&self, point: Pos2) -> Option<usize> {
131 let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
132 in_z_order(&self.drawings)
133 .into_iter()
134 .rev()
135 .filter(|d| d.selectable && d.is_visible_on_timeframe(&self.curr_timeframe))
136 .filter(|d| {
137 self.pane_clips.is_empty()
138 || self
139 .pane_clips
140 .get(&d.pane)
141 .is_some_and(|rect| rect.contains(point))
142 })
143 .find(|drawing| {
144 if let Some(id) = &drawing.custom_tool {
145 return self
146 .registry
147 .get(id)
148 .is_some_and(|tool| tool.implementation.hit_test(drawing, point, 6.0));
149 }
150 let paint = painted
151 .iter()
152 .find(|p| p.id == drawing.id && p.points == drawing.points);
153 if paint.is_some_and(|p| !p.shapes.iter().any(|s| s.clip_rect.contains(point))) {
154 return false;
155 }
156 paint.is_some_and(|p| DrawingInteraction::hit_test_painted(point, &p.shapes))
157 || (drawing.tool_type != DrawingToolType::RegressionTrend
158 && DrawingInteraction::new()
159 .hit_test(point, std::slice::from_ref(*drawing), &self.curr_timeframe)
160 .is_some())
161 })
162 .map(|d| d.id)
163 }
164
165 pub(crate) fn painted_text(&self, id: usize, text: &str) -> Option<(Pos2, Arc<egui::Galley>)> {
167 fn find(shape: &Shape, text: &str) -> Option<(Pos2, Arc<egui::Galley>)> {
168 match shape {
169 Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, text)),
170 Shape::Text(t) if t.angle == 0.0 && t.galley.text() == text => {
171 Some((t.pos, Arc::clone(&t.galley)))
172 }
173 _ => None,
174 }
175 }
176 let painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
177 painted
178 .iter()
179 .find(|p| p.id == id)?
180 .shapes
181 .iter()
182 .find_map(|s| find(&s.shape, text))
183 }
184
185 fn render_drawing(
186 &self,
187 drawing: &Drawing,
188 painter: &egui::Painter,
189 rect: Rect,
190 view: &ChartView,
191 ) {
192 if let Some(id) = &drawing.custom_tool {
193 if let Some(tool) = self.registry.get(id) {
194 tool.implementation.render(drawing, painter, rect, view);
195 }
196 } else {
197 drawing.render_with_selection(
198 painter,
199 rect,
200 view,
201 self.selection.is_selected(drawing.id),
202 );
203 }
204 }
205
206 pub fn render_all(&self, painter: &egui::Painter, price_rect: Rect, view: &ChartView) {
208 let painter = &painter.with_clip_rect(price_rect);
209 let mut painted = self.painted.lock().unwrap_or_else(|e| e.into_inner());
210 let others: HashSet<usize> = (self.drawings.iter())
212 .filter(|d| !self.owns_pane(d))
213 .map(|d| d.id)
214 .collect();
215 let mut last: HashMap<usize, PaintedDrawing> = HashMap::new();
216 for paint in std::mem::take(&mut *painted) {
217 if others.contains(&paint.id) {
218 painted.push(paint);
219 } else {
220 last.insert(paint.id, paint);
221 }
222 }
223 let pixels_per_point = painter.pixels_per_point();
224 let options = painter.ctx().tessellation_options(|o| *o);
225 let untransformed = (painter.ctx())
228 .layer_transform_to_global(painter.layer_id())
229 .is_none();
230 for drawing in in_z_order(&self.drawings) {
232 let preview = self
233 .text_editor
234 .as_ref()
235 .filter(|editor| editor.inline && editor.id == drawing.id)
236 .map(|editor| editor.preview(drawing, painter.ctx()));
237 let drawing = preview.as_ref().unwrap_or(drawing);
238 let off_pane = drawing.custom_tool.is_none()
239 && drawing
240 .paint_bounds()
241 .is_some_and(|bounds| !bounds.intersects(price_rect));
242 if self.owns_pane(drawing)
243 && drawing.is_visible_on_timeframe(&self.curr_timeframe)
244 && !off_pane
245 {
246 let start = painter.ctx().graphics(|g| {
247 g.get(painter.layer_id())
248 .map_or(0, |list| list.next_idx().0)
249 });
250 self.render_drawing(drawing, painter, price_rect, view);
251 let shapes: Vec<ClippedShape> = painter.ctx().graphics(|g| {
252 g.get(painter.layer_id())
253 .map(|list| list.all_entries().skip(start).cloned().collect())
254 .unwrap_or_default()
255 });
256 let tessellated = last
259 .remove(&drawing.id)
260 .filter(|paint| untransformed && same_geometry(&paint.shapes, &shapes))
261 .map(|paint| {
262 paint
263 .tessellated
264 .filter(|t| {
265 t.pixels_per_point == pixels_per_point && t.options == options
266 })
267 .unwrap_or_else(|| Tessellated::new(&shapes, pixels_per_point, options))
268 });
269 if let Some(tessellated) = &tessellated {
270 painter.ctx().graphics_mut(|g| {
271 let list = g.entry(painter.layer_id());
272 for (i, shape) in tessellated.shapes.iter().enumerate() {
273 if let Some(shape) = shape {
274 list.set(
275 egui::layers::ShapeIdx(start + i),
276 shapes[i].clip_rect,
277 shape.clone(),
278 );
279 }
280 }
281 });
282 }
283 painted.push(PaintedDrawing {
284 id: drawing.id,
285 points: drawing.points.clone(),
286 regression_ends: (drawing.tool_type == DrawingToolType::RegressionTrend)
287 .then(|| drawing.regression_ends(view))
288 .flatten(),
289 shapes,
290 tessellated,
291 });
292 }
293 }
294
295 drop(painted);
296
297 if let Some(ref drawing) = self.curr_drawing
299 && self.owns_pane(drawing)
300 {
301 self.render_drawing(drawing, painter, price_rect, view);
302 if drawing.tool_type == DrawingToolType::Rect {
303 drawing.render_rect_guides(painter, view.price_formatter);
304 self.render_handles(painter, drawing);
305 }
306 }
307
308 if let Some(drawing) = &self.measurement
309 && self.owns_pane(drawing)
310 {
311 self.render_drawing(drawing, painter, price_rect, view);
312 }
313
314 for drawing in self.drawings.iter().filter(|d| {
316 d.selectable
317 && self.selection.is_selected(d.id)
318 && self.owns_pane(d)
319 && d.is_visible_on_timeframe(&self.curr_timeframe)
320 }) {
321 if drawing.tool_type == DrawingToolType::Rect {
322 drawing.render_rect_guides(painter, view.price_formatter);
323 }
324 self.render_handles(painter, drawing);
325 }
326 }
327}
328
329#[derive(Clone, Copy)]
331pub(crate) enum DrawingAxisTag {
332 Price { price: f64, color: Color32 },
334 Time {
336 x: f32,
337 anchor: ChartPoint,
338 color: Color32,
339 },
340}
341
342impl DrawingManager {
343 pub(crate) fn axis_tags(
348 &self,
349 plot: Rect,
350 entry_color: Color32,
351 ) -> impl Iterator<Item = DrawingAxisTag> + '_ {
352 self.drawings
353 .iter()
354 .filter(|drawing| {
355 self.owns_pane(drawing) && drawing.is_visible_on_timeframe(&self.curr_timeframe)
356 })
357 .flat_map(move |drawing| {
358 let (price, time) = Drawing::axis_labels_of(drawing.tool_type);
359 let (price, time) = (
360 price && drawing.price_axis_label,
361 time && drawing.time_axis_label,
362 );
363 let [r, g, b, _] = drawing.color;
364 let color = Color32::from_rgb(r, g, b);
365 let at = drawing.chart_points.first().zip(drawing.points.first());
366 let price = at
368 .filter(|(_, point)| {
369 price
370 && (drawing.tool_type != DrawingToolType::HorizontalRay
371 || point.x <= plot.right())
372 })
373 .map(|(anchor, _)| DrawingAxisTag::Price {
374 price: anchor.price,
375 color,
376 });
377 let time = at
378 .filter(|(_, point)| time && plot.x_range().contains(point.x))
379 .map(|(anchor, point)| DrawingAxisTag::Time {
380 x: point.x,
381 anchor: *anchor,
382 color,
383 });
384 drawing
385 .position_price_tags(entry_color)
386 .into_iter()
387 .map(|(price, color)| DrawingAxisTag::Price { price, color })
388 .chain([price, time].into_iter().flatten())
389 })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::drawings::domain::ChartPoint;
397
398 #[test]
401 fn regression_handles_follow_the_fit_and_refit_after_dragging() {
402 use crate::drawings::{
403 HandlePos,
404 persistence::{drawing_to_stored, stored_to_drawing},
405 };
406 use crate::model::Bar;
407 let bars: Vec<_> = [10.0, 14.0, 12.0]
408 .into_iter()
409 .map(|close| Bar::from_value(chrono::DateTime::UNIX_EPOCH, close))
410 .collect();
411 let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 500.0));
412 let ctx = egui::Context::default();
413 for (start, reverse) in [(0.0, false), (0.0, true), (-1.0, false), (0.4, true)] {
414 let mut manager = DrawingManager::new();
415 let mut drawing = Drawing::new(7, DrawingToolType::RegressionTrend);
416 drawing.chart_points = vec![ChartPoint::new(start, 30.0), ChartPoint::new(2.0, 5.0)];
417 if reverse {
418 drawing.chart_points.reverse();
419 }
420 let saved = drawing_to_stored(&drawing, |b| 1_700_000_000_000 + (b * 60_000.0) as i64);
421 let mut drawing =
422 stored_to_drawing(&saved, |p| (p - 1_700_000_000_000) as f32 / 60_000.0, 7);
423 assert_eq!(
424 drawing
425 .chart_points
426 .iter()
427 .map(|p| p.bar_idx)
428 .collect::<Vec<_>>(),
429 if reverse {
430 vec![2.0, start]
431 } else {
432 vec![start, 2.0]
433 }
434 );
435 drawing.extend_right = true;
436 manager.drawings.push(drawing);
437 manager.select(7);
438 let render = |manager: &mut DrawingManager, bars: &[Bar]| {
439 manager
440 .update_all_screen_coords(|b| 100.0 + b * 100.0, |p| 500.0 - p as f32 * 10.0);
441 ctx.run_ui(
442 egui::RawInput {
443 screen_rect: Some(pane),
444 ..Default::default()
445 },
446 |ui| {
447 manager.render_all(
448 ui.painter(),
449 pane,
450 &ChartView {
451 price_formatter: None,
452 time_formatter: None,
453 bar_seconds: 0,
454 instrument: None,
455 bars,
456 revision: 0,
457 bar_to_x: &|b| 100.0 + b * 100.0,
458 price_to_y: &|p| 500.0 - p as f32 * 10.0,
459 y_to_price: &|y| f64::from((500.0 - y) / 10.0),
460 time_axis: true,
461 executions: None,
462 timeframe: None,
463 finer: &[],
464 },
465 );
466 },
467 )
468 .drop_without_applying_deltas();
469 };
470 render(&mut manager, &bars);
471 let (first, last) = if reverse { (1, 0) } else { (0, 1) };
472 let handles = manager.get_handles(&manager.drawings[0]);
473 assert_eq!(handles.len(), 2);
474 for point in &manager.drawings[0].points {
475 assert_eq!(manager.hit_test_handle(&manager.drawings[0], *point), None);
476 assert_eq!(manager.hit_test(*point), None);
477 }
478 for (index, point) in [
479 (first, Pos2::new(100.0, 390.0)),
480 (last, Pos2::new(300.0, 370.0)),
481 ] {
482 assert!(handles.contains(&(HandlePos::Point(index), point)));
483 assert_eq!(
484 manager.hit_test_handle(&manager.drawings[0], point),
485 Some(HandlePos::Point(index))
486 );
487 }
488 let before = manager.drawings[0].chart_points.clone();
489 manager.start_drag_handle(7, HandlePos::Point(first));
490 manager.update_drag_handle(
491 Pos2::new(100.0, 50.0),
492 |x| (x - 100.0) / 100.0,
493 |y| f64::from(500.0 - y) / 10.0,
494 |p, from, to| p - f64::from(to - from) / 10.0,
495 );
496 assert_eq!(
497 manager.drawings[0].chart_points, before,
498 "vertical drag is a no-op"
499 );
500 manager.update_drag_handle(
501 Pos2::new(200.0, 50.0),
502 |x| (x - 100.0) / 100.0,
503 |y| f64::from(500.0 - y) / 10.0,
504 |p, from, to| p - f64::from(to - from) / 10.0,
505 );
506 manager.end_drag_handle();
507 render(&mut manager, &bars);
508 let handles = manager.get_handles(&manager.drawings[0]);
509 assert!(handles.contains(&(HandlePos::Point(first), Pos2::new(200.0, 360.0))));
510 assert!(handles.contains(&(HandlePos::Point(last), Pos2::new(300.0, 380.0))));
511 assert!(
512 manager.drawings[0]
513 .chart_points
514 .iter()
515 .zip(before)
516 .all(|(p, old)| p.price == old.price)
517 );
518 assert!(manager.undo());
519 render(&mut manager, &bars);
520 assert_eq!(manager.get_handles(&manager.drawings[0]).len(), 2);
521 let mut live = bars.clone();
522 live[2].close = 18.0;
523 render(&mut manager, &live);
524 assert!(
526 manager
527 .get_handles(&manager.drawings[0])
528 .contains(&(HandlePos::Point(last), Pos2::new(300.0, 320.0)))
529 );
530 render(&mut manager, &[]);
531 assert!(manager.get_handles(&manager.drawings[0]).is_empty());
532 }
533 }
534
535 #[test]
538 fn painted_bodies_are_hit_away_from_anchors() {
539 use egui::{Context, RawInput};
540 let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 400.0));
541 for (tool, anchors, hit, miss) in [
542 (
543 DrawingToolType::Sector,
544 vec![(100., 100.), (200., 100.), (100., 150.)],
545 (170.71068, 170.71068),
546 (220., 220.),
547 ),
548 (
549 DrawingToolType::Sector,
550 vec![(100., 100.), (200., 100.), (100., 50.)],
551 (150., 50.),
552 (50., 150.),
553 ),
554 (
555 DrawingToolType::ArrowMarkLeft,
556 vec![(100., 100.)],
557 (110., 100.),
558 (80., 100.),
559 ),
560 (
561 DrawingToolType::ArrowMarkRight,
562 vec![(100., 100.)],
563 (90., 100.),
564 (120., 100.),
565 ),
566 (
567 DrawingToolType::TrendLine,
568 vec![(50., 100.), (550., 100.)],
569 (200., 100.),
570 (510., 100.),
571 ),
572 (
573 DrawingToolType::TextLabel,
574 vec![(50., 100.)],
575 (180., 105.),
576 (180., 160.),
577 ),
578 (
579 DrawingToolType::LongPos,
580 vec![(50., 150.), (250., 50.), (250., 250.)],
581 (180., 100.),
582 (350., 300.),
583 ),
584 (
585 DrawingToolType::FibonacciCircles,
586 vec![(100., 100.), (200., 200.)],
587 (150., 200.),
588 (580., 390.),
589 ),
590 (
591 DrawingToolType::CyclicLines,
592 vec![(50., 100.), (150., 100.)],
593 (250., 300.),
594 (480., 390.),
595 ),
596 ] {
597 let ctx = Context::default();
598 let mut manager = DrawingManager::new();
599 let mut drawing = Drawing::new(7, tool);
600 drawing.points = anchors.into_iter().map(|(x, y)| Pos2::new(x, y)).collect();
601 drawing.chart_points = drawing
602 .points
603 .iter()
604 .map(|p| ChartPoint::new(p.x / 10.0, 500.0 - p.y as f64))
605 .collect();
606 if tool == DrawingToolType::Sector {
607 drawing.fill_color = Some([30, 150, 180, 60]);
608 }
609 drawing.text = Some("A long text annotation".into());
610 manager.drawings.push(drawing);
611 let render = |manager: &DrawingManager| {
612 ctx.run_ui(RawInput::default(), |ui| {
613 let view = ChartView {
614 price_formatter: None,
615 time_formatter: None,
616 bar_seconds: 0,
617 instrument: None,
618 bars: &[],
619 revision: 0,
620 bar_to_x: &|b| b * 10.0,
621 price_to_y: &|p| 500.0 - p as f32,
622 y_to_price: &|y| 500.0 - f64::from(y),
623 time_axis: true,
624 executions: None,
625 timeframe: None,
626 finer: &[],
627 };
628 manager.render_all(ui.painter(), pane, &view);
629 })
630 .drop_without_applying_deltas();
631 };
632 render(&manager);
633 assert_eq!(
634 manager.hit_test(Pos2::new(hit.0, hit.1)),
635 Some(7),
636 "{tool:?}"
637 );
638 assert_eq!(
639 manager.hit_test(Pos2::new(miss.0, miss.1)),
640 None,
641 "{tool:?}"
642 );
643 if tool == DrawingToolType::TextLabel {
644 manager.edit_drawing(7, |d| d.text = Some("x".into()));
645 assert_eq!(manager.hit_test(Pos2::new(hit.0, hit.1)), None);
646 }
647 manager.drawings[0].visible = false;
648 assert_eq!(manager.hit_test(Pos2::new(hit.0, hit.1)), None);
649 manager.delete_drawing(7);
650 render(&manager);
651 assert!(
652 manager.painted.lock().unwrap().is_empty(),
653 "deleted geometry is released"
654 );
655 }
656 }
657
658 #[test]
663 fn cached_tessellation_matches_the_painted_shapes() {
664 use egui::{Context, RawInput, epaint::Primitive};
665 let ctx = Context::default();
666 let pane = Rect::from_min_max(Pos2::ZERO, Pos2::new(500.0, 400.0));
667 let mut manager = DrawingManager::new();
668 let mut drawing = Drawing::new(7, DrawingToolType::ElliottImpulse);
669 drawing.points = [
670 (50., 300.),
671 (100., 200.),
672 (150., 250.),
673 (250., 80.),
674 (300., 150.),
675 (350., 60.),
676 ]
677 .map(|(x, y)| Pos2::new(x, y))
678 .to_vec();
679 manager.drawings.push(drawing);
680 let meshes = |shapes: Vec<ClippedShape>| -> Vec<(Rect, Mesh)> {
681 (ctx.tessellate(shapes, 1.0).into_iter())
682 .filter_map(|p| match p.primitive {
683 Primitive::Mesh(mesh) => Some((p.clip_rect, mesh)),
684 Primitive::Callback(_) => None,
685 })
686 .collect()
687 };
688 for frame in 0..5 {
689 if frame == 3 {
690 manager.drawings[0].points[3].y = 40.0;
691 }
692 let layer = if frame == 4 {
693 TSTransform::from_scaling(2.0)
694 } else {
695 TSTransform::IDENTITY
696 };
697 let mut output = ctx.run_ui(RawInput::default(), |ui| {
698 ctx.set_transform_layer(ui.layer_id(), layer);
699 let view = ChartView {
700 price_formatter: None,
701 time_formatter: None,
702 bar_seconds: 0,
703 instrument: None,
704 bars: &[],
705 revision: 0,
706 bar_to_x: &|b| b * 10.0,
707 price_to_y: &|p| 500.0 - p as f32,
708 y_to_price: &|y| 500.0 - f64::from(y),
709 time_axis: true,
710 executions: None,
711 timeframe: None,
712 finer: &[],
713 };
714 manager.render_all(ui.painter(), pane, &view);
715 });
716 let shapes = std::mem::take(&mut output.shapes);
717 output.drop_without_applying_deltas();
718 if frame == 2 {
719 assert!(
720 (shapes.iter()).any(|s| matches!(s.shape, Shape::Mesh(_))),
721 "an unchanged drawing paints its kept tessellation"
722 );
723 }
724 let mut painted = manager.painted.lock().unwrap()[0].shapes.clone();
725 painted.iter_mut().for_each(|s| s.transform(layer));
726 assert!(meshes(shapes) == meshes(painted), "frame {frame}");
727 }
728 }
729}