1mod config;
38mod paint;
39pub use config::{IndicatorCoordParams, IndicatorPaneConfig, PaneInteraction};
40
41use crate::localization as l10n;
42use crate::model::Bar;
43use crate::scales::PriceMap;
44use crate::studies::Indicator;
45use crate::theme::ThemeExt;
46use egui::{Pos2, Rect, Sense, Ui};
47use std::ops::Range;
48
49mod layout {
51 pub const LEFT_PADDING: f32 = crate::styles::sizing::chart::PADDING;
53 pub const RIGHT_PADDING: f32 = crate::styles::sizing::chart::RIGHT_AXIS_WIDTH;
55 pub const MIN_GRID_SPACING: f32 = 80.0;
57}
58
59mod indicator_sizing {
61 pub const OSCILLATOR_HEIGHT: f32 = 120.0;
63 pub const MULTI_LINE_HEIGHT: f32 = 150.0;
65}
66
67pub struct IndicatorPane {
78 config: IndicatorPaneConfig,
79 pub(super) bar_index: Option<usize>,
80 pub(super) measured_axis_width: f32,
81 pub(super) price_map: Option<PriceMap>,
82 pub(super) price_formatter: Option<std::sync::Arc<dyn crate::scales::PriceFormatter>>,
83 pub(super) overlay: bool,
84 pub(super) legend_offset: f32,
85 pub(super) pane_controls: bool,
86 pub(super) axis_rect: Option<Rect>,
87 pub(super) axis_tags: Vec<Option<Rect>>,
88 pub(super) axis_options: crate::config::PriceAxisOptions,
89 pub(super) legend_backdrop: f32,
90}
91
92impl IndicatorPane {
93 pub fn with_config(config: IndicatorPaneConfig) -> Self {
95 let axis_options = crate::config::PriceAxisOptions {
96 show_study_values: config.show_values,
97 ..Default::default()
98 };
99 Self {
100 config,
101 bar_index: None,
102 measured_axis_width: 0.0,
103 price_map: None,
104 price_formatter: None,
105 overlay: false,
106 legend_offset: 0.0,
107 pane_controls: false,
108 axis_rect: None,
109 axis_tags: Vec::new(),
110 axis_options,
111 legend_backdrop: 0.6,
112 }
113 }
114
115 pub fn show(
117 &mut self,
118 ui: &mut Ui,
119 indicator: &dyn Indicator,
120 bars: &[Bar],
121 visible_range: Range<usize>,
122 ) {
123 if !indicator.is_visible() {
124 return;
125 }
126
127 let colors = ui.active_theme().chart;
129 let chart_bg = colors.bg;
130 let text_color = ui.style().visuals.text_color();
131 let grid_color = colors.grid_line;
132
133 let available_width = ui.available_width();
137 let panel_size = egui::vec2(available_width, self.config.height);
138 let (rect, response) = ui.allocate_exact_size(panel_size, Sense::hover());
139
140 if !ui.is_rect_visible(rect) {
141 return;
142 }
143
144 let painter = ui.painter();
145
146 painter.rect_filled(rect, 0.0, chart_bg);
148
149 let left_padding = layout::LEFT_PADDING;
151 let right_padding = layout::RIGHT_PADDING;
152 let chart_rect = Rect::from_min_max(
153 Pos2::new(rect.min.x + left_padding, rect.min.y),
154 Pos2::new(rect.max.x - right_padding, rect.max.y),
155 );
156
157 let (y_min, y_max) = self.calculate_y_range(indicator, &visible_range);
159
160 if self.config.show_zones && self.config.zones.len() >= 2 {
162 self.draw_zone_fills(painter, chart_rect, y_min, y_max, indicator);
163 }
164
165 self.draw_horizontal_lines(painter, chart_rect, y_min, y_max, grid_color, indicator);
167
168 self.draw_indicator_lines(
170 painter,
171 chart_rect,
172 indicator,
173 bars,
174 &visible_range,
175 y_min,
176 y_max,
177 );
178
179 self.draw_y_axis_labels(
181 painter,
182 rect,
183 chart_rect,
184 y_min,
185 y_max,
186 indicator,
187 Some(crate::scales::PriceScaleId::Right),
188 None,
189 );
190
191 self.draw_legend(painter, chart_rect, indicator, text_color);
194 if let Some(table) = indicator.table() {
195 crate::studies::table::paint(ui, chart_rect, table, response.id);
196 }
197 }
198
199 pub fn show_aligned(
205 &mut self,
206 ui: &mut Ui,
207 indicator: &dyn Indicator,
208 bars: &[Bar],
209 _visible_range: Range<usize>,
210 coords: IndicatorCoordParams,
211 ) {
212 if !indicator.is_visible() {
213 return;
214 }
215
216 let colors = ui.active_theme().chart;
218 let chart_bg = colors.bg;
219 let text_color = ui.style().visuals.text_color();
220 let grid_color = colors.grid_line;
221
222 let available_width = ui.available_width();
226 let panel_size = egui::vec2(available_width, self.config.height);
227 let (rect, response) = ui.allocate_exact_size(panel_size, Sense::hover());
228
229 if !ui.is_rect_visible(rect) {
230 return;
231 }
232
233 let painter = ui.painter();
234
235 painter.rect_filled(rect, 0.0, chart_bg);
237
238 let left_padding = layout::LEFT_PADDING;
240 let right_padding = layout::RIGHT_PADDING;
241 let chart_rect = Rect::from_min_max(
242 Pos2::new(rect.min.x + left_padding, rect.min.y),
243 Pos2::new(rect.max.x - right_padding, rect.max.y),
244 );
245
246 let horizontal = coords.to_mapping(chart_rect, 0.0, 1.0);
248 let displayed_range = horizontal.x_to_idx(chart_rect.left())
249 ..horizontal.x_to_idx(chart_rect.right()).saturating_add(1);
250 let (y_min, y_max) = self.calculate_y_range(indicator, &displayed_range);
251
252 if self.config.show_zones && self.config.zones.len() >= 2 {
254 self.draw_zone_fills(painter, chart_rect, y_min, y_max, indicator);
255 }
256
257 self.draw_horizontal_lines(painter, chart_rect, y_min, y_max, grid_color, indicator);
259
260 if self.config.show_grid {
262 self.draw_vertical_grid_aligned(painter, chart_rect, &coords, grid_color);
263 }
264
265 self.draw_indicator_lines_aligned(
267 painter, chart_rect, indicator, bars, y_min, y_max, &coords,
268 );
269
270 self.draw_y_axis_labels(
272 painter,
273 rect,
274 chart_rect,
275 y_min,
276 y_max,
277 indicator,
278 Some(crate::scales::PriceScaleId::Right),
279 None,
280 );
281
282 self.draw_legend(painter, chart_rect, indicator, text_color);
285 if let Some(table) = indicator.table() {
286 crate::studies::table::paint(ui, chart_rect, table, response.id);
287 }
288 }
289
290 pub fn show_aligned_interactive(
303 &mut self,
304 ui: &mut Ui,
305 indicator: &dyn Indicator,
306 bars: &[Bar],
307 _visible_range: Range<usize>,
308 coords: IndicatorCoordParams,
309 ) -> Option<PaneInteraction> {
310 let interaction = self.show_aligned_interactive_in(
311 ui,
312 indicator,
313 bars,
314 coords,
315 None,
316 false,
317 Some(crate::scales::PriceScaleId::Right),
318 None,
319 &[],
320 false,
321 1,
322 )?;
323 if let Some(table) = indicator.table() {
324 crate::studies::table::paint(
325 ui,
326 interaction.chart_rect,
327 table,
328 interaction.response.id,
329 );
330 }
331 Some(interaction)
332 }
333
334 pub(super) fn show_aligned_interactive_in(
337 &mut self,
338 ui: &mut Ui,
339 indicator: &dyn Indicator,
340 bars: &[Bar],
341 coords: IndicatorCoordParams,
342 plot_x: Option<egui::emath::Rangef>,
343 collapsed: bool,
344 axis: Option<crate::scales::PriceScaleId>,
345 hover: Option<(Pos2, crate::config::CrosshairOptions)>,
346 primitives: &[&crate::widget::primitives::Attached],
347 bar_series: bool,
348 factor: usize,
349 ) -> Option<PaneInteraction> {
350 if !indicator.is_visible() {
351 return None;
352 }
353
354 let colors = ui.active_theme().chart;
356 let chart_bg = colors.bg;
357 let text_color = ui.style().visuals.text_color();
358 let grid_color = colors.grid_line;
359
360 let available_width = ui.available_width();
364 let panel_size = egui::vec2(available_width, self.config.height);
365 let (rect, response) = ui.allocate_exact_size(panel_size, Sense::click());
366 let translated_label =
367 l10n::format_text(ui.ctx(), "{0} pane", &[("0", indicator.name().to_string())]);
368 response.widget_info(|| {
369 egui::WidgetInfo::labeled(
370 egui::Role::GraphicsObject,
371 response.enabled(),
372 &translated_label,
373 )
374 });
375
376 if !ui.is_rect_visible(rect) {
377 return None;
378 }
379
380 let painter = &ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
381
382 if !self.overlay {
384 painter.rect_filled(rect, 0.0, chart_bg);
385 }
386
387 let left_padding = layout::LEFT_PADDING;
389 let right_padding = layout::RIGHT_PADDING;
390 let left_axis = axis == Some(crate::scales::PriceScaleId::Left);
391 let plot_x = plot_x.unwrap_or_else(|| {
392 egui::emath::Rangef::new(
393 rect.left() + left_padding,
394 (rect.right() - right_padding).max(rect.left() + left_padding),
395 )
396 });
397 let chart_rect = Rect::from_min_max(
398 Pos2::new(plot_x.min, rect.top()),
399 Pos2::new(plot_x.max, rect.bottom()),
400 );
401
402 let horizontal = coords.to_mapping(chart_rect, 0.0, 1.0);
404 let displayed_range = horizontal.x_to_idx(chart_rect.left())
405 ..horizontal.x_to_idx(chart_rect.right()).saturating_add(1);
406 let (y_min, y_max) = self.price_map.map_or_else(
407 || self.calculate_y_range(indicator, &displayed_range),
408 |map| {
409 (
410 map.transform().to_price(map.range().min),
411 map.transform().to_price(map.range().max),
412 )
413 },
414 );
415
416 let mut mapping = coords.to_mapping(chart_rect, y_min, y_max);
417 if let Some(map) = self.price_map {
418 mapping.price = map;
419 }
420 let view = primitives
421 .first()
422 .map(|item| crate::chart::primitives::PrimitiveView {
423 series: item.series,
424 study: (!bar_series).then_some(indicator),
425 mapping: mapping.clone(),
426 bars,
427 visible: displayed_range.clone(),
428 });
429 let draw = |painter: &egui::Painter, pass| {
430 if let Some(view) = &view {
431 for item in primitives {
432 item.draw(painter, view, pass, factor);
433 }
434 }
435 };
436 let crosshair = hover.map(|(mut pos, options)| {
437 if let Some(index) = self.bar_index {
438 pos.x = mapping.idx_to_x(index);
439 if options.mode.snaps()
440 && chart_rect.contains(pos)
441 && let Some(value) = crate::chart::rendering::snap_to_values(
442 pos.y,
443 &mapping.price,
444 (0..indicator.line_count())
445 .filter(|line| {
446 let plot = indicator.plot_style(*line);
447 plot.visible
448 && !matches!(
449 plot.plot_type,
450 crate::studies::PlotType::BarColor
451 | crate::studies::PlotType::BackgroundColor
452 )
453 })
454 .filter_map(|line| indicator.plot_value(line, index)),
455 )
456 {
457 pos.y = mapping.price_to_y(value);
458 }
459 }
460 (pos, options)
461 });
462 if !collapsed {
463 let plot_painter = &painter.with_clip_rect(chart_rect.intersect(painter.clip_rect()));
464 if self.config.show_zones && self.config.zones.len() >= 2 {
466 self.draw_zone_fills(plot_painter, chart_rect, y_min, y_max, indicator);
467 }
468
469 self.draw_horizontal_lines(
471 plot_painter,
472 chart_rect,
473 y_min,
474 y_max,
475 grid_color,
476 indicator,
477 );
478
479 if self.config.show_grid {
481 self.draw_vertical_grid_aligned(plot_painter, chart_rect, &coords, grid_color);
482 }
483
484 use crate::chart::primitives::PrimitivePass;
485 draw(plot_painter, PrimitivePass::Below);
486 if !primitives.iter().any(|item| item.replaces) {
488 self.draw_indicator_lines_aligned(
489 plot_painter,
490 chart_rect,
491 indicator,
492 bars,
493 y_min,
494 y_max,
495 &coords,
496 );
497 }
498 if !bar_series {
499 draw(plot_painter, PrimitivePass::Series);
500 }
501
502 if let (Some(view), Some(side)) = (&view, axis)
503 && indicator.scale() != crate::studies::StudyScale::None
504 {
505 let (axis_rect, axis_edge) = if side == crate::scales::PriceScaleId::Left {
506 (
507 Rect::from_x_y_ranges(rect.left()..=chart_rect.left(), rect.y_range()),
508 chart_rect.left(),
509 )
510 } else {
511 (
512 Rect::from_x_y_ranges(chart_rect.right()..=rect.right(), rect.y_range()),
513 rect.right(),
514 )
515 };
516 let axis_rect = self.axis_rect.unwrap_or(axis_rect);
517 let axis_edge = self.axis_rect.map_or(axis_edge, |axis| axis.right());
518 let axis_painter = painter.with_clip_rect(painter.clip_rect().intersect(axis_rect));
519 for item in primitives {
520 let primitive = item.get();
521 primitive.draw_price_axis(&axis_painter, axis_rect, view);
522 for label in primitive.price_axis_labels(view) {
523 let map = mapping.price;
524 if map.range().contains(map.transform().to_scale(label.price)) {
525 let tag = crate::chart::rendering::axes::render_axis_value(
526 painter,
527 chart_rect,
528 axis_edge,
529 map.price_to_y(label.price),
530 label.text,
531 (
532 crate::chart::rendering::axes::tag_text_color(label.color),
533 label.color,
534 ),
535 &self.axis_tags,
536 self.axis_options.allow_label_overlap,
537 );
538 self.axis_tags.push(tag);
539 }
540 }
541 }
542 }
543 let context = crate::chart::rendering::RenderContext::new(painter, chart_rect);
544 let tag = crosshair.and_then(|(pos, options)| {
545 crate::chart::rendering::render_crosshair_lines(
546 &context,
547 pos,
548 &options,
549 chart_rect.bottom(),
550 );
551 axis?;
552 if indicator.scale() == crate::studies::StudyScale::None {
553 return None;
554 }
555 crate::chart::rendering::render_crosshair_price_tag(
556 &context,
557 pos,
558 &mapping.price.with_price_precision(Some(
559 indicator.precision().unwrap_or(2).min(255) as u8,
560 )),
561 &options,
562 if left_axis {
563 chart_rect.left()
564 } else {
565 rect.right()
566 },
567 None,
568 Some(match self.price_formatter.as_deref() {
569 Some(formatter) => mapping.price.transform().label(
570 mapping.price.y_to_scale(pos.y),
571 indicator.precision().unwrap_or(2),
572 Some(formatter),
573 ),
574 None => indicator.format_value(mapping.price.y_to_price(pos.y)),
575 }),
576 )
577 });
578 self.measured_axis_width = self.draw_y_axis_labels(
580 painter, rect, chart_rect, y_min, y_max, indicator, axis, tag,
581 );
582 }
583
584 if collapsed && let Some((pos, mut options)) = crosshair {
585 options.horz_line_visible = false;
586 crate::chart::rendering::render_crosshair_lines(
587 &crate::chart::rendering::RenderContext::new(painter, chart_rect),
588 pos,
589 &options,
590 chart_rect.bottom(),
591 );
592 }
593 let controls = self.legend_controls_rect(painter, chart_rect, indicator);
594 let responses = controls.map(|controls| {
595 let row = Rect::from_min_max(
596 chart_rect.min + egui::vec2(0.0, self.legend_offset),
597 controls.right_bottom(),
598 );
599 crate::chart::rendering::study_controls(
600 ui,
601 row,
602 controls,
603 response.id.with("study_controls"),
604 indicator.is_visible(),
605 )
606 });
607 let legend_rect = Rect::from_min_max(
608 chart_rect.min,
609 Pos2::new(
610 controls.map_or(chart_rect.right(), |rect| rect.left()),
611 chart_rect.bottom(),
612 ),
613 );
614 self.draw_legend(
615 &painter.with_clip_rect(legend_rect.intersect(ui.clip_rect())),
616 chart_rect,
617 indicator,
618 text_color,
619 );
620 let (visibility_response, settings_response, close_response, menu) = match responses {
621 Some(([visibility, settings, close], menu)) => {
622 (Some(visibility), Some(settings), Some(close), menu)
623 }
624 None => (None, None, None, None),
625 };
626
627 Some(PaneInteraction {
629 panel_rect: rect,
630 chart_rect,
631 y_min,
632 y_max,
633 response,
634 close_response,
635 visibility_response,
636 settings_response,
637 menu,
638 })
639 }
640
641 pub(super) fn calculate_y_range(
642 &self,
643 indicator: &dyn Indicator,
644 visible_range: &Range<usize>,
645 ) -> (f64, f64) {
646 if indicator.is_volume()
647 && !matches!(indicator.scale(), crate::studies::StudyScale::Shared(_))
648 {
649 return crate::widget::volume::range(indicator, visible_range, None);
650 }
651 if let Some((min, max)) = self.config.y_range {
652 return (min, max);
653 }
654
655 let mut min_val = f64::MAX;
657 let mut max_val = f64::MIN;
658
659 for line in (0..indicator.line_count()).filter(|line| {
660 let plot = indicator.plot_style(*line);
661 plot.visible
662 && !matches!(
663 plot.plot_type,
664 crate::studies::PlotType::BarColor | crate::studies::PlotType::BackgroundColor
665 )
666 }) {
667 for bar in visible_range.clone() {
668 if let Some(value) = indicator.plot_value(line, bar) {
669 min_val = min_val.min(value);
670 max_val = max_val.max(value);
671 }
672 }
673 }
674
675 for candle in indicator.candles().iter().filter(|candle| {
676 candle.bars.end > visible_range.start
677 && candle.bars.start < visible_range.end
678 && indicator.plot_style(candle.plot).visible
679 }) {
680 min_val = min_val.min(candle.bar.low);
681 max_val = max_val.max(candle.bar.high);
682 }
683
684 for plot in indicator.volume_profiles(visible_range.clone()) {
685 min_val = min_val.min(plot.profile.low());
686 max_val = max_val.max(plot.profile.high());
687 }
688
689 for zone in indicator.zones().iter().filter(|zone| {
690 zone.bars.end > visible_range.start
691 && zone.bars.start < visible_range.end
692 && indicator.plot_style(zone.plot).visible
693 }) {
694 min_val = min_val.min(zone.bottom);
695 max_val = max_val.max(zone.top);
696 }
697
698 for projection in indicator.projections().iter().filter(|projection| {
699 projection.bars.end > visible_range.start as f64
700 && projection.bars.start < visible_range.end as f64
701 && indicator.plot_style(projection.plot).visible
702 }) {
703 min_val = min_val.min(projection.bottom);
704 max_val = max_val.max(projection.top);
705 }
706
707 #[cfg(feature = "drawings")]
708 for price in crate::studies::plots::drawing_prices(indicator, visible_range.clone()) {
709 min_val = min_val.min(price);
710 max_val = max_val.max(price);
711 }
712
713 if min_val != f64::MAX
714 && (0..indicator.line_count()).any(|line| {
715 let plot = indicator.plot_style(line);
716 plot.visible
717 && matches!(
718 plot.plot_type,
719 crate::studies::PlotType::Histogram
720 | crate::studies::PlotType::Columns
721 | crate::studies::PlotType::Area
722 )
723 })
724 {
725 min_val = min_val.min(0.0);
726 max_val = max_val.max(0.0);
727 }
728
729 if min_val == f64::MAX {
730 (0.0, 100.0) } else {
732 let padding = crate::scales::auto_scale_span(min_val, max_val) * 0.1;
734 (min_val - padding, max_val + padding)
735 }
736 }
737}
738
739#[cfg(all(test, feature = "studies"))]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn candle_panes_fit_the_visible_period_extremes() {
745 let mut study = crate::studies::ConfiguredStudy::new(Box::new(
746 crate::studies::HigherIntervalCandles::default(),
747 ));
748 study.set_timeframe(crate::model::Timeframe::Hour1);
749 study.set_overlay(false);
750 let start: chrono::DateTime<chrono::Utc> = "2024-03-11T00:00:00Z".parse().unwrap();
751 let bars: Vec<_> = (0..3)
752 .map(|i| {
753 Bar::new(
754 start + chrono::TimeDelta::hours(i),
755 10.0,
756 20.0,
757 1.0,
758 15.0,
759 1.0,
760 )
761 })
762 .collect();
763 study.calculate(&bars);
764 let pane = IndicatorPane::with_config(IndicatorPaneConfig::from_study(&study));
765 for range in [0..1, 1..2, 2..3] {
766 let (low, high) = pane.calculate_y_range(&study, &range);
767 assert!(low <= 1.0 && high >= 20.0, "{range:?}: {low}..{high}");
768 }
769 let mut settings = study.settings();
770 settings.plots[0].visible = false;
771 study.apply_settings(&[], settings).unwrap();
772 assert_eq!(pane.calculate_y_range(&study, &(0..3)), (0.0, 100.0));
773 }
774}