1use super::{Chart, RightClickTarget};
4use crate::chart::indicators::{
5 SelectionDotConfig, hit_test_indicator, render_indicator_selection_dots,
6};
7use crate::chart::rendering::ChartMapping;
8use crate::chart::selection::{ChartElementId, SeriesId};
9use crate::chart::series::{
10 SelectionHandleConfig, calculate_dot_interval, hit_test_candles, hit_test_volume,
11 render_candle_selection_dots,
12};
13use crate::studies::IndicatorRegistry;
14use egui::Pos2;
15
16impl Chart {
17 pub fn selected_element(&self) -> Option<ChartElementId> {
28 self.selection.selected_id()
29 }
30
31 pub fn selected_bar(&self) -> Option<usize> {
37 self.selection.selected_bar()
38 }
39
40 pub fn clear_selection(&mut self) {
45 self.selection.deselect();
46 }
47
48 pub fn take_right_click(&mut self) -> Option<RightClickTarget> {
63 self.right_click.take()
64 }
65
66 pub(super) fn render_main_chart_selection(
72 &self,
73 painter: &egui::Painter,
74 coords: &ChartMapping,
75 visible_data: &[crate::model::Bar],
76 visible_range: std::ops::Range<usize>,
77 ) {
78 let Some(selected) = self.selection.selected_id() else {
79 return;
80 };
81
82 match selected {
83 ChartElementId::Indicator(id) => {
84 let Some(indicator) = self
85 .study_ids
86 .iter()
87 .position(|study| *study == id)
88 .and_then(|at| self.rendered_studies().indicators().get(at))
89 .map(|study| study.as_ref())
90 else {
91 return;
92 };
93 if !indicator.is_visible()
94 || !indicator.is_overlay()
95 || (indicator.is_volume() && !self.shows_volume())
96 {
97 return;
98 }
99 let volume_coords = (indicator.is_volume()
100 && !matches!(indicator.scale(), crate::studies::StudyScale::Shared(_)))
101 .then(|| {
102 super::volume::mapping(
103 coords,
104 super::volume::overlay_rect(coords.rect, &self.config),
105 indicator,
106 &visible_range,
107 Some(&self.merge_groups),
108 )
109 });
110 let study_coords = crate::chart::rendering::study_mapping(indicator, coords);
111 let coords = volume_coords.as_ref().unwrap_or(&study_coords);
112 let config = SelectionDotConfig {
113 dot_interval: calculate_dot_interval(coords.bar_spacing),
114 ..Default::default()
115 };
116 for line_idx in 0..indicator.line_count() {
119 render_indicator_selection_dots(
120 painter,
121 indicator,
122 line_idx,
123 visible_range.clone(),
124 coords,
125 |price| coords.price_to_y(price),
126 &config,
127 );
128 }
129 }
130 ChartElementId::Series(series_id) => {
131 let config = SelectionHandleConfig {
132 dot_interval: calculate_dot_interval(coords.bar_spacing),
133 ..Default::default()
134 };
135 let values: Vec<f64> = visible_data
136 .iter()
137 .map(|b| {
138 if series_id == SeriesId::VOLUME {
139 b.volume
140 } else {
141 b.close
142 }
143 })
144 .collect();
145 match series_id {
146 SeriesId::VOLUME => {
147 let volume_coords = coords.with_rect(self.last_rendered_volume_rect);
150 let max_volume = visible_data
151 .iter()
152 .map(|b| b.volume)
153 .fold(0.0_f64, f64::max);
154 if max_volume <= 0.0 {
155 return;
156 }
157 render_candle_selection_dots(
158 painter,
159 visible_range,
160 &volume_coords,
161 &values,
162 |volume| {
163 let norm = (volume / max_volume) as f32;
164 volume_coords.rect.bottom() - norm * volume_coords.rect.height()
165 },
166 &config,
167 );
168 }
169 _ => {
170 render_candle_selection_dots(
171 painter,
172 visible_range,
173 coords,
174 &values,
175 |price| coords.price_to_y(price),
176 &config,
177 );
178 }
179 }
180 }
181 }
182 }
183}
184
185pub(super) fn hit_test_main_chart(
193 click_pos: Pos2,
194 studies: &IndicatorRegistry,
195 study_ids: &[SeriesId],
196 order: &[SeriesId],
197 coords: &ChartMapping,
198 volume_coords: Option<&ChartMapping>,
199 bars: &[crate::model::Bar],
200 max_volume: f64,
201 visible_range: std::ops::Range<usize>,
202 hit_tolerance: f32,
203 groups: Option<&super::merge::Groups>,
204) -> Option<(ChartElementId, usize)> {
205 if bars.is_empty() {
206 return None;
207 }
208
209 let reach = hit_tolerance.max(crate::chart::hit_test::HIT_TOLERANCE) + coords.bar_spacing;
211 let near = visible_range
212 .start
213 .max(coords.x_to_idx(click_pos.x - reach).saturating_sub(1))
214 ..visible_range
215 .end
216 .min(coords.x_to_idx(click_pos.x + reach).saturating_add(2));
217
218 let main = order
219 .iter()
220 .position(|id| *id == SeriesId::MAIN)
221 .unwrap_or(0);
222 let overlay_hit = |under_series: bool| {
223 for (at, id) in order.iter().enumerate().rev() {
224 if (at < main) != under_series {
225 continue;
226 }
227 let Some(idx) = study_ids.iter().position(|study| study == id) else {
228 continue;
229 };
230 let indicator = &studies.indicators()[idx];
231 let volume_mapping = if indicator.is_volume()
232 && !matches!(indicator.scale(), crate::studies::StudyScale::Shared(_))
233 {
234 if !indicator.is_overlay() || !indicator.is_visible() {
235 continue;
236 }
237 let Some(volume) = volume_coords else {
238 continue;
239 };
240 Some(super::volume::mapping(
241 coords,
242 volume.rect,
243 indicator.as_ref(),
244 &visible_range,
245 groups,
246 ))
247 } else {
248 None
249 };
250 let study_coords = crate::chart::rendering::study_mapping(indicator.as_ref(), coords);
251 let coords = volume_mapping.as_ref().unwrap_or(&study_coords);
252 if let Some(hit) = hit_test_indicator(
253 click_pos,
254 indicator.as_ref(),
255 idx,
256 visible_range.clone(),
257 coords,
258 |price| coords.price_to_y(price),
259 ) {
260 return Some((
261 ChartElementId::Indicator(study_ids[hit.indicator_idx]),
262 hit.bar_idx,
263 ));
264 }
265 }
266 None
267 };
268
269 if let Some(hit) = overlay_hit(false) {
271 return Some(hit);
272 }
273
274 if order.contains(&SeriesId::MAIN)
276 && !studies
277 .indicators()
278 .iter()
279 .any(|study| study.is_volume() && study.is_overlay())
280 && let Some(volume_coords) = volume_coords
281 && let Some(hit) = hit_test_volume(click_pos, bars, near.clone(), volume_coords, max_volume)
282 {
283 return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
284 }
285
286 if order.contains(&SeriesId::MAIN)
288 && let Some(hit) = hit_test_candles(
289 click_pos,
290 bars,
291 near,
292 coords,
293 |price| coords.price_to_y(price),
294 &crate::chart::series::HitTestConfig {
295 line_tolerance: hit_tolerance,
296 ..Default::default()
297 },
298 )
299 {
300 return Some((ChartElementId::Series(hit.series_id), hit.bar_idx));
301 }
302
303 overlay_hit(true)
305}
306
307#[cfg(test)]
308mod selection_tests {
309 use super::*;
310 use crate::model::Bar;
311 use crate::scales::{PriceMap, PriceRange, PriceTransform};
312 use crate::studies::{CustomIndicator, IndicatorValue};
313 use chrono::{TimeZone, Utc};
314 use egui::{Pos2, Rect, Vec2};
315
316 fn fixture() -> (ChartMapping, Vec<Bar>) {
321 let rect = Rect::from_min_size(Pos2::new(0.0, 0.0), Vec2::new(500.0, 400.0));
322 let bars: Vec<Bar> = (0..5)
323 .map(|i| {
324 let close = 10.0 + i as f64;
325 Bar::new(
326 Utc.timestamp_opt(i as i64, 0).unwrap(),
327 close - 0.4,
328 close + 0.5,
329 close - 0.5,
330 close + 0.4,
331 100.0,
332 )
333 })
334 .collect();
335 let range = PriceRange::new(8.0, 16.0);
337 let price = PriceMap::new(
338 PriceTransform::default(),
339 range,
340 rect.min.y,
341 rect.max.y,
342 false,
343 );
344 let mapping = ChartMapping::new(rect, 40.0, 0, bars.len() - 1, 0.0, price);
345 (mapping, bars)
346 }
347
348 fn overlay_at(values: Vec<IndicatorValue>) -> IndicatorRegistry {
349 let mut registry = IndicatorRegistry::new();
350 let captured = values.clone();
351 registry.add(Box::new(
352 CustomIndicator::new("TestLine", move |_| captured.clone()).with_overlay(true),
353 ));
354 registry.calculate_all(&[]);
356 registry
357 }
358
359 #[test]
360 fn click_on_candle_or_near_its_wick_selects_main_series() {
361 let (mapping, bars) = fixture();
362 let bar = &bars[2];
365 let mid_price = (bar.open + bar.close) / 2.0;
366 let at = |dx, price| Pos2::new(mapping.idx_to_x(2) + dx, mapping.price_to_y(price));
367 let tolerance = crate::chart::hit_test::HIT_TOLERANCE;
368 let selected = Some((ChartElementId::Series(SeriesId::MAIN), 2));
369 for (bars, pos, tolerance, expected) in [
370 (bars.as_slice(), at(0.0, mid_price), tolerance, selected),
371 (bars.as_slice(), at(8.0, 12.45), tolerance, None),
372 (bars.as_slice(), at(8.0, 12.45), 10.0, selected),
373 (
374 bars.as_slice(),
375 Pos2::new(mapping.idx_to_x(2), mapping.rect.min.y + 1.0),
376 tolerance,
377 None,
378 ),
379 (&[][..], mapping.rect.center(), tolerance, None),
380 ] {
381 let hit = hit_test_main_chart(
382 pos,
383 &IndicatorRegistry::new(),
384 &[],
385 &[SeriesId::MAIN],
386 &mapping,
387 None,
388 bars,
389 100.0,
390 0..bars.len(),
391 tolerance,
392 None,
393 );
394 assert_eq!(hit, expected, "{pos:?}, {tolerance}, {} bars", bars.len());
395 }
396 }
397
398 #[test]
399 fn selection_follows_overlay_volume_and_price_paint_order() {
400 let (mapping, bars) = fixture();
401 let mut values = vec![IndicatorValue::None; bars.len()];
405 values[2] = IndicatorValue::Single(bars[2].close);
406 values[3] = IndicatorValue::Single(bars[3].close);
407 let registry = overlay_at(values);
408
409 let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(bars[2].close));
410 let volume = mapping.with_rect(egui::Rect::from_min_max(
411 Pos2::new(mapping.rect.left(), pos.y - 10.0),
412 Pos2::new(mapping.rect.right(), pos.y + 10.0),
413 ));
414 let empty = IndicatorRegistry::new();
415 for (studies, order, volume, expected) in [
416 (
417 ®istry,
418 &[SeriesId::MAIN, SeriesId(2)][..],
419 Some(&volume),
420 ChartElementId::Indicator(SeriesId(2)),
421 ),
422 (
423 &empty,
424 &[SeriesId::MAIN],
425 Some(&volume),
426 ChartElementId::Series(SeriesId::VOLUME),
427 ),
428 (
429 &empty,
430 &[SeriesId::MAIN][..],
431 None,
432 ChartElementId::Series(SeriesId::MAIN),
433 ),
434 (
436 ®istry,
437 &[SeriesId(2), SeriesId::MAIN],
438 None,
439 ChartElementId::Series(SeriesId::MAIN),
440 ),
441 ] {
442 let hit = hit_test_main_chart(
443 pos,
444 studies,
445 &[SeriesId(2)],
446 order,
447 &mapping,
448 volume,
449 &bars,
450 100.0,
451 0..bars.len(),
452 crate::chart::hit_test::HIT_TOLERANCE,
453 None,
454 );
455 assert_eq!(hit, Some((expected, 2)));
456 }
457 }
458
459 #[test]
460 fn pane_indicator_does_not_steal_overlay_priority() {
461 let (mapping, bars) = fixture();
462 let mut values = vec![IndicatorValue::None; bars.len()];
465 values[2] = IndicatorValue::Single(bars[2].close);
466 let mut registry = IndicatorRegistry::new();
467 let captured = values.clone();
468 registry.add(Box::new(
469 CustomIndicator::new("Pane", move |_| captured.clone()).with_overlay(false),
470 ));
471 registry.calculate_all(&[]);
472
473 let bar = &bars[2];
474 let mid_price = (bar.open + bar.close) / 2.0;
475 let pos = Pos2::new(mapping.idx_to_x(2), mapping.price_to_y(mid_price));
476 let hit = hit_test_main_chart(
477 pos,
478 ®istry,
479 &[SeriesId(2)],
480 &[SeriesId::MAIN, SeriesId(2)],
481 &mapping,
482 None,
483 &bars,
484 100.0,
485 0..bars.len(),
486 crate::chart::hit_test::HIT_TOLERANCE,
487 None,
488 );
489 assert_eq!(hit, Some((ChartElementId::Series(SeriesId::MAIN), 2)));
491 }
492
493 #[test]
497 fn removing_a_study_keeps_the_selection_on_its_id() {
498 use crate::model::BarData;
499
500 let mut chart = Chart::new(BarData::default());
501 let ids: Vec<SeriesId> = ["A", "B", "C"]
502 .into_iter()
503 .map(|name| {
504 let study =
505 CustomIndicator::new(name, |bars| vec![IndicatorValue::None; bars.len()]);
506 chart.add_indicator(Box::new(study.with_overlay(false)))
507 })
508 .collect();
509 chart
510 .selection
511 .select(ChartElementId::Indicator(ids[2]), None);
512 assert!(chart.remove_series(ids[0]));
513 assert_eq!(
514 chart.selected_element(),
515 Some(ChartElementId::Indicator(ids[2]))
516 );
517 assert!(chart.remove_series(ids[2]));
518 assert_eq!(chart.selected_element(), None);
519 }
520}