Skip to main content

egui_charts/widget/pane/indicator/
mod.rs

1//! Separate-pane indicator rendering with direct egui painting.
2//!
3//! This module renders oscillator and multi-line indicators (RSI, MACD, Stochastic,
4//! etc.) in dedicated panels below the main chart. It paints directly through
5//! [`egui::Painter`] using the same horizontal coordinate mapping as the main
6//! candlestick chart.
7//!
8//! # Usage
9//!
10//! Indicator panes are typically drawn by [`crate::widget::Chart::show`], but
11//! they can also be used standalone:
12//!
13//! ```rust
14//! use egui_charts::widget::{
15//!     IndicatorCoordParams, IndicatorPane, IndicatorPaneConfig
16//! };
17//!
18//! # fn example(
19//! #     ui: &mut egui::Ui,
20//! #     indicator: &egui_charts::studies::CustomIndicator,
21//! #     bars: &[egui_charts::model::Bar],
22//! #     visible_range: std::ops::Range<usize>,
23//! #     coords: IndicatorCoordParams,
24//! # ) {
25//! let config = IndicatorPaneConfig::default();
26//! let mut pane = IndicatorPane::with_config(config);
27//! pane.show_aligned(ui, indicator, bars, visible_range, coords);
28//! # }
29//! ```
30//!
31//! # Coordinate Alignment
32//!
33//! The [`IndicatorCoordParams`] struct captures the main chart's bar spacing,
34//! right offset, and base index so that indicator panes draw their data points
35//! at exactly the same x-coordinates as the corresponding candlesticks above.
36
37mod 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
49/// Chart layout constants matching main chart (from ChartConfig defaults)
50mod layout {
51    /// Left padding (matches ChartConfig::padding default)
52    pub const LEFT_PADDING: f32 = crate::styles::sizing::chart::PADDING;
53    /// Right price axis width for standalone panes
54    pub const RIGHT_PADDING: f32 = crate::styles::sizing::chart::RIGHT_AXIS_WIDTH;
55    /// Minimum pixel spacing between vertical grid lines
56    pub const MIN_GRID_SPACING: f32 = 80.0;
57}
58
59/// Indicator panel sizing constants
60mod indicator_sizing {
61    /// Standard panel height for oscillators (RSI, Stochastic)
62    pub const OSCILLATOR_HEIGHT: f32 = 120.0;
63    /// Standard panel height for multi-line indicators (MACD)
64    pub const MULTI_LINE_HEIGHT: f32 = 150.0;
65}
66
67/// A separate-pane indicator panel rendered via direct egui painting.
68///
69/// Each `IndicatorPane` renders one indicator (RSI, MACD, etc.) in its own
70/// rectangular area below the main chart. The pane includes:
71/// - Indicator line(s) with proper colors
72/// - Horizontal zone lines (e.g., RSI 70/30 overbought/oversold)
73/// - Semi-transparent zone fills for overbought/oversold regions
74/// - Y-axis labels on the right side
75/// - A legend showing the indicator name and current value
76/// - Optional vertical grid lines aligned with the main chart
77pub 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    /// Create a new indicator panel with custom configuration
94    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    /// Render the indicator panel with proper x-axis alignment
116    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        // Get chart background and text colors from theme
128        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        // Get live theme colors for zones
134
135        // Allocate the panel area
136        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        // Fill background
147        painter.rect_filled(rect, 0.0, chart_bg);
148
149        // Calculate drawing area to match main chart layout
150        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        // Determine y-axis range
158        let (y_min, y_max) = self.calculate_y_range(indicator, &visible_range);
159
160        // Draw zone fills (colored bands)
161        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        // Draw horizontal grid lines and zone lines
166        self.draw_horizontal_lines(painter, chart_rect, y_min, y_max, grid_color, indicator);
167
168        // Draw indicator lines
169        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        // Draw y-axis labels
180        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        // Draw indicator legend at top-left. The non-interactive path cannot
192        // host a clickable close affordance, so no remove "x" is drawn here.
193        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    /// Render with explicit coordinate parameters for perfect x-axis alignment.
200    ///
201    /// Uses [`IndicatorCoordParams`] from the main chart to ensure that each
202    /// indicator data point is drawn at the exact same x-coordinate as its
203    /// corresponding bar in the price chart above.
204    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        // Get chart background and text colors from theme
217        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        // Get live theme colors for zones
223
224        // Allocate the panel area
225        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        // Fill background
236        painter.rect_filled(rect, 0.0, chart_bg);
237
238        // Calculate drawing area to match main chart layout
239        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        // Determine y-axis range
247        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        // Draw zone fills (colored bands)
253        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        // Draw horizontal grid lines and zone lines
258        self.draw_horizontal_lines(painter, chart_rect, y_min, y_max, grid_color, indicator);
259
260        // Draw vertical grid lines (aligned with main chart)
261        if self.config.show_grid {
262            self.draw_vertical_grid_aligned(painter, chart_rect, &coords, grid_color);
263        }
264
265        // Draw indicator lines with aligned coordinates
266        self.draw_indicator_lines_aligned(
267            painter, chart_rect, indicator, bars, y_min, y_max, &coords,
268        );
269
270        // Draw y-axis labels
271        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        // Draw indicator legend at top-left. The non-interactive path cannot
283        // host a clickable close affordance, so no remove "x" is drawn here.
284        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    /// Render with coordinate alignment and return layout info for hit testing.
291    ///
292    /// Like [`IndicatorPane::show_aligned`], but returns the panel and chart
293    /// rects, y-axis range, and an interactive [`egui::Response`]. This allows
294    /// the caller to implement click-on-line selection, tooltip display, or
295    /// other interactive features on indicator panes.
296    ///
297    /// Returns `None` if the indicator is not visible.
298    ///
299    /// The returned [`PaneInteraction`] also carries the [`egui::Response`] for the
300    /// legend's close "x" (when the pane shows a legend), so the host can offer
301    /// one-click pane removal.
302    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    /// Shows the pane with attached `primitives`. For a bar series, `indicator` supplies only
335    /// its legend; the caller draws its bars and the series primitive pass afterwards.
336    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        // Get chart background and text colors from theme
355        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        // Get live theme colors for zones
361
362        // Allocate the panel area with click sense
363        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        // Fill background
383        if !self.overlay {
384            painter.rect_filled(rect, 0.0, chart_bg);
385        }
386
387        // Calculate drawing area to match main chart layout
388        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        // Determine y-axis range
403        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            // Draw zone fills (colored bands)
465            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            // Draw horizontal grid lines and zone lines
470            self.draw_horizontal_lines(
471                plot_painter,
472                chart_rect,
473                y_min,
474                y_max,
475                grid_color,
476                indicator,
477            );
478
479            // Draw vertical grid lines (aligned with main chart)
480            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            // Draw indicator lines with aligned coordinates, unless a renderer draws them
487            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            // Draw y-axis labels
579            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        // Return panel info for hit testing
628        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        // Auto-scale based on visible indicator values
656        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) // Default fallback
731        } else {
732            // Add 10% padding
733            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}