1use super::{Chart, ChartElementId, PointerTarget, SeriesId};
4use crate::chart::coords::ChartMapping;
5use crate::chart::primitives::{Primitive, PrimitiveId, PrimitivePass, PrimitiveView};
6use crate::model::Bar;
7use crate::studies::Indicator;
8use egui::{CursorIcon, Painter, Pos2};
9use std::sync::{Arc, Mutex, MutexGuard};
10
11#[derive(Clone)]
13pub(super) struct Attached {
14 pub(super) id: PrimitiveId,
15 pub(super) series: SeriesId,
16 primitive: Arc<Mutex<dyn Primitive>>,
18 pub(super) replaces: bool,
20 pub(super) seen: Option<Seen>,
22}
23
24pub(super) type Seen = (
27 u64,
28 usize,
29 Option<(chrono::DateTime<chrono::Utc>, [u64; 5])>,
30);
31
32fn seen(revision: u64, bars: &[Bar]) -> Seen {
33 let last = bars.last().map(|bar| {
34 (
35 bar.time,
36 [bar.open, bar.high, bar.low, bar.close, bar.volume].map(f64::to_bits),
37 )
38 });
39 (revision, bars.len(), last)
40}
41
42impl Attached {
43 pub(super) fn draw(
44 &self,
45 painter: &Painter,
46 view: &PrimitiveView<'_>,
47 pass: PrimitivePass,
48 factor: usize,
49 ) {
50 self.get().draw_merged(painter, view, pass, factor);
51 }
52
53 pub(super) fn get(&self) -> MutexGuard<'_, dyn Primitive + 'static> {
54 self.primitive
56 .lock()
57 .unwrap_or_else(|poisoned| poisoned.into_inner())
58 }
59}
60
61#[derive(Clone, Copy)]
63pub(super) struct PrimitiveHit {
64 pub(super) target: PointerTarget,
66 pub(super) cursor: CursorIcon,
67}
68
69impl Chart {
70 pub fn attach_primitive(
76 &mut self,
77 series: SeriesId,
78 primitive: impl Primitive + 'static,
79 ) -> Option<PrimitiveId> {
80 self.attach(series, Arc::new(Mutex::new(primitive)), false)
81 }
82
83 pub fn detach_primitive(&mut self, id: PrimitiveId) -> bool {
86 let Some(at) = self.primitives.iter().position(|item| item.id == id) else {
87 return false;
88 };
89 let item = self.primitives.remove(at);
90 if item.seen.is_some() {
91 item.get().detached();
92 }
93 true
94 }
95
96 pub fn add_custom_series(
102 &mut self,
103 study: Box<dyn Indicator>,
104 renderer: impl Primitive + 'static,
105 ) -> SeriesId {
106 let id = self.add_indicator(study);
107 self.attach(id, Arc::new(Mutex::new(renderer)), true);
108 id
109 }
110
111 fn attach(
112 &mut self,
113 series: SeriesId,
114 primitive: Arc<Mutex<dyn Primitive>>,
115 replaces: bool,
116 ) -> Option<PrimitiveId> {
117 self.primitive_source(series)?;
118 let id = PrimitiveId(self.next_primitive);
119 self.next_primitive += 1;
120 self.primitives.push(Attached {
121 id,
122 series,
123 primitive,
124 replaces,
125 seen: None,
126 });
127 Some(id)
128 }
129
130 fn primitive_source(&self, series: SeriesId) -> Option<(Seen, &[Bar])> {
133 let bars = self.state.data().bars.as_slice();
134 let main = (seen(self.state.revision(), bars), bars);
135 if series == SeriesId::MAIN {
136 self.main_series_present.then_some(main)
137 } else if let Some(item) = self.price_series.iter().find(|item| item.id == series) {
138 Some((seen(item.revision, &item.bars), &item.bars))
139 } else {
140 self.study_ids.contains(&series).then_some(main)
141 }
142 }
143
144 pub(super) fn sync_primitives(&mut self, ctx: &egui::Context) {
147 let mut primitives = std::mem::take(&mut self.primitives);
148 primitives.retain_mut(|item| {
149 let Some((current, bars)) = self.primitive_source(item.series) else {
150 if item.seen.is_some() {
151 item.get().detached();
152 }
153 return false;
154 };
155 if item.seen != Some(current) {
156 let mut primitive = item.get();
157 if item.seen.is_none() {
158 primitive.attached(ctx, item.series);
159 }
160 primitive.data_changed(bars);
161 drop(primitive);
162 item.seen = Some(current);
163 }
164 true
165 });
166 self.primitives = primitives;
168 }
169
170 pub(super) fn settle_primitives(&mut self) {
173 for at in 0..self.primitives.len() {
174 let current = self
175 .primitive_source(self.primitives[at].series)
176 .map(|(current, _)| current);
177 self.primitives[at].seen = current;
178 }
179 }
180
181 pub(super) fn primitive_view(
182 &self,
183 series: SeriesId,
184 mapping: ChartMapping,
185 ) -> PrimitiveView<'_> {
186 let (start, end) = self.state.visible_range();
187 PrimitiveView {
188 series,
189 study: self
190 .study_ids
191 .iter()
192 .position(|id| *id == series)
193 .and_then(|idx| self.rendered_studies().indicators().get(idx))
194 .map(|study| study.as_ref()),
195 mapping,
196 bars: self.state.shown(),
197 visible: start..end,
198 }
199 }
200
201 fn main_view(&self, series: SeriesId, coords: &ChartMapping) -> Option<PrimitiveView<'_>> {
204 if self.in_pane(series) {
205 return None;
206 }
207 let mapping = if series == SeriesId::MAIN {
208 self.main_series_visible.then_some(*coords)?
209 } else if let Some(item) = self.price_series.iter().find(|item| item.id == series) {
210 item.visible.then(|| ChartMapping {
211 price: self.series_map(item, coords.rect),
212 ..*coords
213 })?
214 } else {
215 let idx = self.study_ids.iter().position(|id| *id == series)?;
216 let study = self.rendered_studies().indicators().get(idx)?;
217 (study.is_visible()
218 && study.is_overlay()
219 && (!study.is_volume()
220 || matches!(study.scale(), crate::studies::StudyScale::Shared(_))))
221 .then(|| crate::chart::rendering::study_mapping(study.as_ref(), coords))?
222 };
223 Some(self.primitive_view(series, mapping))
224 }
225
226 pub(super) fn main_primitives<'a>(
228 &'a self,
229 coords: &'a ChartMapping,
230 ) -> impl DoubleEndedIterator<Item = (&'a Attached, PrimitiveView<'a>)> {
231 self.primitives
232 .iter()
233 .filter_map(move |item| Some((item, self.main_view(item.series, coords)?)))
234 }
235
236 pub(super) fn time_axis_primitives<'a>(
239 &'a self,
240 coords: &'a ChartMapping,
241 ) -> impl Iterator<Item = (&'a Attached, PrimitiveView<'a>)> {
242 self.primitives.iter().filter_map(move |item| {
243 let view = self.main_view(item.series, coords).or_else(|| {
244 self.is_series_visible(item.series)
245 .then(|| self.primitive_view(item.series, *coords))
246 })?;
247 Some((item, view))
248 })
249 }
250
251 pub(super) fn primitives_of(
253 &self,
254 series: SeriesId,
255 ) -> impl DoubleEndedIterator<Item = &Attached> {
256 self.primitives
257 .iter()
258 .filter(move |item| item.series == series)
259 }
260
261 pub(super) fn primitive_prices(&self, series: SeriesId) -> impl Iterator<Item = f64> + '_ {
263 let (start, end) = self.state.visible_range();
264 self.primitives_of(series)
265 .filter_map(move |item| item.get().autoscale(self.state.shown(), start..end))
266 .flat_map(|(min, max)| [min, max])
267 }
268
269 pub(super) fn main_primitive_prices(&self) -> Vec<f64> {
272 let studies = self.rendered_studies().indicators();
273 let mut prices = Vec::new();
274 if self.main_series_visible {
275 prices.extend(self.primitive_prices(SeriesId::MAIN));
276 }
277 for (idx, id) in self.study_ids.iter().enumerate() {
278 if studies.get(idx).is_some_and(|study| {
279 study.is_visible()
280 && study.is_overlay()
281 && study.scale() == crate::studies::StudyScale::Main
282 }) && self.study_autoscale[idx]
283 && !self.config.scale_price_chart_only
284 {
285 prices.extend(self.primitive_prices(*id));
286 }
287 }
288 prices
289 }
290
291 pub(super) fn has_renderer(&self, series: SeriesId) -> bool {
293 self.primitives_of(series).any(|item| item.replaces)
294 }
295
296 pub(super) fn paint_primitives(
298 &self,
299 painter: &Painter,
300 coords: &ChartMapping,
301 pass: PrimitivePass,
302 ) {
303 let painter = painter.with_clip_rect(painter.clip_rect().intersect(coords.rect));
304 for (item, view) in self
305 .main_primitives(coords)
306 .filter(|(item, _)| pass != PrimitivePass::Series || !item.replaces)
307 {
308 item.draw(&painter, &view, pass, self.merge_groups.factor());
309 }
310 }
311}
312
313pub(super) fn primitive_at<'a>(
315 primitives: impl DoubleEndedIterator<Item = (&'a Attached, PrimitiveView<'a>)>,
316 pos: Pos2,
317) -> Option<PrimitiveHit> {
318 primitives.rev().find_map(|(item, view)| {
319 let cursor = item.get().hit_test(pos, &view)?;
320 Some(PrimitiveHit {
321 target: if item.replaces {
322 PointerTarget::Element(ChartElementId::Indicator(item.series))
323 } else {
324 PointerTarget::Primitive(item.id)
325 },
326 cursor,
327 })
328 })
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::chart::primitives::{PriceAxisLabel, TimeAxisLabel};
335 use crate::model::Bar;
336 use crate::studies::{CustomIndicator, IndicatorValue};
337 use crate::widget::test_frames::{chart, frame, shapes};
338 use crate::widget::{ChartEvent, SeriesOptions};
339 use egui::{Color32, Event, Shape};
340
341 type Log = Arc<Mutex<Vec<String>>>;
342
343 struct Probe(&'static str, Log);
344
345 impl Primitive for Probe {
346 fn attached(&mut self, _: &egui::Context, _: SeriesId) {
347 self.1.lock().unwrap().push(format!("{} attached", self.0));
348 }
349 fn data_changed(&mut self, bars: &[Bar]) {
350 let line = format!("{} data {}", self.0, bars.len());
351 self.1.lock().unwrap().push(line);
352 }
353 fn detached(&mut self) {
354 self.1.lock().unwrap().push(format!("{} detached", self.0));
355 }
356 }
357
358 #[test]
359 fn hooks_follow_attachment_bar_changes_and_removal() {
360 let ctx = egui::Context::default();
361 let mut chart = chart(&[10.0], &[20.0]);
362 let bars = chart.bars(SeriesId::MAIN).to_vec();
363 let line = chart.add_series(SeriesOptions::default());
364 chart.set_data(line, bars[..50].to_vec());
365 let log = Log::default();
366 let main = chart.attach_primitive(SeriesId::MAIN, Probe("main", log.clone()));
367 chart.attach_primitive(line, Probe("line", log.clone()));
368 assert!(
369 chart
370 .attach_primitive(SeriesId(99), Probe("absent", log.clone()))
371 .is_none()
372 );
373 let take = || std::mem::take(&mut *log.lock().unwrap());
374 frame(&ctx, &mut chart, 0.0, vec![]);
375 frame(&ctx, &mut chart, 0.1, vec![]);
376 assert_eq!(
377 take(),
378 [
379 "main attached",
380 "main data 100",
381 "line attached",
382 "line data 50"
383 ]
384 );
385 chart.update(line, bars[50].clone());
386 frame(&ctx, &mut chart, 0.2, vec![]);
387 assert_eq!(take(), ["line data 51"]);
388 let mut corrected = bars[10].clone();
389 corrected.close = 15.0;
390 chart.update(line, corrected);
391 frame(&ctx, &mut chart, 0.25, vec![]);
392 assert_eq!(take(), ["line data 51"], "a correction before the last bar");
393 let mut last = bars[99].clone();
394 last.close = 15.0;
395 chart.update(SeriesId::MAIN, last);
396 frame(&ctx, &mut chart, 0.3, vec![]);
397 assert_eq!(take(), ["main data 100"]);
398 chart.remove_series(line);
399 frame(&ctx, &mut chart, 0.4, vec![]);
400 assert_eq!(take(), ["line detached"]);
401 assert!(chart.detach_primitive(main.unwrap()));
402 assert!(!chart.detach_primitive(main.unwrap()));
403 assert_eq!(take(), ["main detached"]);
404 }
405
406 const BELOW: Color32 = Color32::from_rgb(1, 2, 3);
407 const SERIES: Color32 = Color32::from_rgb(4, 5, 6);
408 const ABOVE: Color32 = Color32::from_rgb(7, 8, 9);
409
410 struct Layers;
412
413 impl Primitive for Layers {
414 fn draw(&self, painter: &Painter, view: &PrimitiveView<'_>, pass: PrimitivePass) {
415 let fill = match pass {
416 PrimitivePass::Below => BELOW,
417 PrimitivePass::Series => SERIES,
418 PrimitivePass::Above => ABOVE,
419 };
420 let corner = egui::Rect::from_min_size(view.mapping.rect.min, egui::vec2(9.0, 9.0));
421 painter.rect_filled(corner, 0.0, fill);
422 }
423 fn autoscale(&self, _: &[Bar], _: std::ops::Range<usize>) -> Option<(f64, f64)> {
424 Some((0.0, 500.0))
425 }
426 fn price_axis_labels(&self, _: &PrimitiveView<'_>) -> Vec<PriceAxisLabel> {
427 vec![PriceAxisLabel {
428 price: 250.0,
429 text: "Level".into(),
430 color: SERIES,
431 }]
432 }
433 fn time_axis_labels(&self, view: &PrimitiveView<'_>) -> Vec<TimeAxisLabel> {
434 vec![TimeAxisLabel {
435 time: view.bars[90].time,
436 text: "A session label much wider than any price on the axis".into(),
437 color: ABOVE,
438 }]
439 }
440 }
441
442 #[test]
443 fn passes_layer_around_series_and_studies_and_widen_the_scale() {
444 let ctx = egui::Context::default();
445 let mut chart = chart(&[10.0], &[20.0]);
446 let study = Color32::from_rgb(10, 11, 12);
447 let study_id = chart.add_indicator(Box::new(
448 CustomIndicator::new("Mid", |bars| {
449 bars.iter()
450 .map(|bar| IndicatorValue::Single((bar.high + bar.low) / 2.0))
451 .collect()
452 })
453 .with_color(study),
454 ));
455 let primitive = chart.attach_primitive(SeriesId::MAIN, Layers).unwrap();
456 frame(&ctx, &mut chart, 0.0, vec![]);
457 let shapes = shapes(&ctx, &mut chart, 0.1, vec![]);
458 let first = |found: &dyn Fn(&Shape) -> bool| shapes.iter().position(found).unwrap();
459 let fill =
460 |color| move |shape: &Shape| matches!(shape, Shape::Rect(rect) if rect.fill == color);
461 let candle = first(&fill(chart.config.bullish_color));
462 let line = first(&|shape| match shape {
463 Shape::Path(path) => path.stroke.color == egui::epaint::ColorMode::Solid(study),
464 Shape::LineSegment { stroke, .. } => stroke.color == study,
465 _ => false,
466 });
467 let (below, series, above) = (
468 first(&fill(BELOW)),
469 first(&fill(SERIES)),
470 first(&fill(ABOVE)),
471 );
472 assert!(below < candle && candle < line && line < series && series < above);
473 let range = chart.state().price_scale().range();
474 assert!(range.min <= 0.0 && range.max >= 500.0, "{range:?}");
475 let text = |label: &str| {
476 shapes
477 .iter()
478 .any(|shape| matches!(shape, Shape::Text(text) if text.galley.text() == label))
479 };
480 assert!(text("Level") && text("A session label much wider than any price on the axis"));
481 assert!(
483 chart.measured_axis_width < 120.0,
484 "{}",
485 chart.measured_axis_width
486 );
487 chart.detach_primitive(primitive);
488 for (owner, price_only, expected) in [
489 (SeriesId::MAIN, true, (-62.5, 562.5)),
490 (study_id, true, (8.75, 21.25)),
491 (study_id, false, (-62.5, 562.5)),
492 ] {
493 let primitive = chart.attach_primitive(owner, Layers).unwrap();
494 chart.config_mut().scale_price_chart_only = price_only;
495 let painted = super::super::test_frames::shapes(&ctx, &mut chart, 0.2, vec![]);
496 let range = chart.state().price_scale().range();
497 assert_eq!((range.min, range.max), expected);
498 assert!(
499 painted.iter().any(fill(ABOVE)),
500 "the primitive still paints"
501 );
502 chart.detach_primitive(primitive);
503 }
504 }
505
506 #[test]
507 fn bar_series_primitives_follow_pane_moves_and_layer_around_the_bars() {
508 use crate::model::ChartType;
509 use crate::widget::PaneId;
510
511 let ctx = egui::Context::default();
512 let mut chart = chart(&[10.0], &[20.0]);
513 let color = Color32::from_rgb(10, 11, 12);
514 let id = chart.add_series(SeriesOptions {
515 chart_type: ChartType::Candles,
516 up_color: color,
517 ..Default::default()
518 });
519 chart.set_data(id, chart.bars(SeriesId::MAIN).to_vec());
520 chart.attach_primitive(id, Layers);
521 let pane = chart.add_pane();
522 assert!(chart.set_preserve_empty_pane(pane, true));
523 for destination in [pane, PaneId::MAIN, pane] {
524 assert!(chart.move_series_to_pane(id, destination));
525 let painted = shapes(&ctx, &mut chart, 0.0, vec![]);
526 let map = chart.series_price_map(id).unwrap();
527 assert!(map.range().contains(map.transform().to_scale(500.0)));
528 if destination != PaneId::MAIN {
529 assert!(chart.last_price_map.range().max < 100.0);
530 }
531 let rects = |fill| {
532 painted
533 .iter()
534 .enumerate()
535 .filter_map(move |(at, shape)| match shape {
536 Shape::Rect(rect)
537 if rect.fill == fill
538 && (fill == color || rect.rect.size() == egui::vec2(9.0, 9.0)) =>
539 {
540 Some((at, rect.rect))
541 }
542 _ => None,
543 })
544 };
545 let (below, rect) = rects(BELOW).next().unwrap();
546 assert_eq!(rects(BELOW).count(), 1, "drawn only in its own pane");
547 let candle = rects(color).next().unwrap().0;
548 let series = rects(SERIES).next().unwrap().0;
549 let above = rects(ABOVE).next().unwrap().0;
550 assert!(
551 below < candle && candle < series && series < above,
552 "{destination:?}: {below} < {candle} < {series} < {above}"
553 );
554 let pane_rect = chart
555 .pane_layout
556 .panes
557 .iter()
558 .find(|item| item.id == destination)
559 .unwrap()
560 .rect;
561 assert!(pane_rect.contains_rect(rect));
562 }
563 chart.set_series_visible(id, false);
564 assert!(
565 !shapes(&ctx, &mut chart, 0.2, vec![])
566 .iter()
567 .any(|shape| matches!(shape, Shape::Rect(rect) if rect.fill == BELOW))
568 );
569 }
570
571 struct Dots;
574
575 impl Primitive for Dots {
576 fn draw(&self, painter: &Painter, view: &PrimitiveView<'_>, pass: PrimitivePass) {
577 if pass == PrimitivePass::Above {
578 let corner = egui::Rect::from_min_size(view.mapping.rect.min, egui::vec2(9.0, 9.0));
579 painter.rect_filled(corner, 0.0, ABOVE);
580 }
581 for index in view
582 .visible
583 .clone()
584 .filter(|_| pass == PrimitivePass::Series)
585 {
586 if let Some(value) = view.study.and_then(|study| study.plot_value(0, index)) {
587 let centre = Pos2::new(
588 view.index_to_x(index as f64),
589 view.mapping.price_to_y(value),
590 );
591 painter.circle_filled(centre, 2.0, SERIES);
592 }
593 }
594 }
595 fn hit_test(&self, pos: Pos2, view: &PrimitiveView<'_>) -> Option<CursorIcon> {
596 view.mapping
597 .rect
598 .contains(pos)
599 .then_some(CursorIcon::PointingHand)
600 }
601 }
602
603 #[test]
604 fn custom_series_draws_and_hit_tests_in_place_of_its_plots() {
605 let ctx = egui::Context::default();
606 let mut chart = chart(&[10.0], &[20.0]);
607 let color = Color32::from_rgb(10, 11, 12);
608 let id = chart.add_custom_series(
609 Box::new(
610 CustomIndicator::new("Range", |bars| {
611 bars.iter()
612 .map(|bar| IndicatorValue::Single(bar.high - bar.low))
613 .collect()
614 })
615 .with_overlay(false)
616 .with_color(color),
617 ),
618 Dots,
619 );
620 #[cfg(feature = "drawings")]
621 {
622 let mut drawing = crate::drawings::Drawing::with_color(
623 1,
624 crate::drawings::DrawingToolType::HorizontalLine,
625 [255, 0, 0, 255],
626 );
627 drawing.chart_points = vec![crate::drawings::ChartPoint::new(50.0, 10.0)];
628 drawing.pane = chart.series_pane(id).map(|pane| pane.0);
629 chart.drawings_mut().drawings.push(drawing);
630 }
631 frame(&ctx, &mut chart, 0.0, vec![]);
632 let shapes = shapes(&ctx, &mut chart, 0.1, vec![]);
633 #[cfg(feature = "drawings")]
634 {
635 let red = |shape: &Shape| match shape {
637 Shape::Path(path) => {
638 path.stroke.color == egui::epaint::ColorMode::Solid(Color32::RED)
639 }
640 Shape::LineSegment { stroke, .. } => stroke.color == Color32::RED,
641 Shape::Mesh(mesh) => mesh.triangles().any(|triangle| {
643 triangle
644 .into_iter()
645 .all(|index| mesh.vertices[index as usize].color == Color32::RED)
646 }),
647 _ => false,
648 };
649 let above = |shape: &Shape| matches!(shape, Shape::Rect(rect) if rect.fill == ABOVE);
650 let drawing = shapes
651 .iter()
652 .position(red)
653 .expect("the red drawing is painted");
654 let last_pass = shapes
655 .iter()
656 .position(above)
657 .expect("the last pass is painted");
658 assert!(
659 drawing < last_pass,
660 "the last pass must paint above the drawing"
661 );
662 }
663 assert!(
664 shapes
665 .iter()
666 .any(|shape| matches!(shape, Shape::Circle(dot) if dot.fill == SERIES))
667 );
668 assert!(!shapes.iter().any(|shape| match shape {
669 Shape::Path(path) => path.stroke.color == egui::epaint::ColorMode::Solid(color),
670 Shape::LineSegment { stroke, .. } => stroke.color == color,
671 _ => false,
672 }));
673 let pane = chart.last_rendered_indicator_panes[0].chart_rect;
674 let pos = Pos2::new(pane.center().x, pane.bottom() - 2.0);
676 chart.drain_events().for_each(drop);
677 frame(&ctx, &mut chart, 0.2, vec![Event::PointerMoved(pos)]);
678 let events: Vec<_> = chart.drain_events().collect();
679 let Some(ChartEvent::CrosshairMoved(Some(pointer))) = events.last() else {
680 panic!("{events:?}")
681 };
682 assert_eq!(
683 pointer.target,
684 Some(PointerTarget::Element(ChartElementId::Indicator(id)))
685 );
686 let click = |pressed| Event::PointerButton {
687 pos,
688 button: egui::PointerButton::Primary,
689 pressed,
690 modifiers: Default::default(),
691 };
692 frame(&ctx, &mut chart, 0.3, vec![click(true), click(false)]);
693 assert_eq!(
694 chart.selection.selected_id(),
695 Some(ChartElementId::Indicator(id))
696 );
697 let pane = chart.series_pane(id).unwrap();
698 chart.set_preserve_empty_pane(pane, true);
699 #[cfg(feature = "drawings")]
700 chart.drawings_mut().drawings.clear();
701 let cover = chart.add_indicator(Box::new(CustomIndicator::new("Cover", |bars| {
702 vec![IndicatorValue::Single(15.0); bars.len()]
703 })));
704 for (step, destination) in [super::super::PaneId::MAIN, pane].into_iter().enumerate() {
705 chart.move_series_to_pane(id, destination);
706 chart.move_series_to_pane(cover, destination);
707 for (layer, (order, expected)) in [
708 (super::super::VisualOrder::SendToBack, cover),
709 (super::super::VisualOrder::BringToFront, id),
710 ]
711 .into_iter()
712 .enumerate()
713 {
714 chart.drain_events().for_each(drop);
715 chart.reorder_indicator(id, order);
716 let time = 1.0 + step as f64 + layer as f64 * 0.2;
717 frame(&ctx, &mut chart, time, vec![]);
718 let map = if destination == super::super::PaneId::MAIN {
719 chart.get_chart_mapping().price
720 } else {
721 chart.series_price_map(cover).unwrap()
722 };
723 let pos = Pos2::new(chart.get_chart_mapping().idx_to_x(70), map.price_to_y(15.0));
724 frame(&ctx, &mut chart, time + 0.1, vec![Event::PointerMoved(pos)]);
725 assert!(chart.drain_events().any(|event| matches!(event,
726 ChartEvent::CrosshairMoved(Some(pointer))
727 if pointer.target == Some(PointerTarget::Element(ChartElementId::Indicator(expected)))
728 )), "{destination:?} {order:?}");
729 }
730 }
731 }
732}