Skip to main content

egui_charts/widget/frame/
mod.rs

1//! Frame rendering: the `show` entry points and the per-frame paint and interaction pass.
2
3mod background;
4mod sessions;
5
6#[cfg(feature = "studies")]
7pub(super) use sessions::session_ranges;
8
9use super::selection::hit_test_main_chart;
10use super::{Chart, PrimitivePass, RightClickTarget, primitives};
11use crate::chart::overlays::FootprintConfig;
12use crate::chart::rendering::{self, ChartMapping, RenderContext, StyleColors};
13use crate::data::Footprint;
14use crate::localization as l10n;
15use crate::model::{ChartType, MarkerZOrder, PriceLine};
16use crate::theme::ThemeExt;
17use egui::{Pos2, Rect, Response, Sense, Ui, Vec2};
18
19impl Chart {
20    /// Prepares the main-series conflation groups for `bar_spacing` in logical points and
21    /// `pixels_per_point` (the egui context's display scale), returning the bars per group.
22    /// Call after supplying data and configuring
23    /// [`conflation_threshold`](crate::config::ChartConfig::conflation_threshold), before showing
24    /// a dense view at that spacing. Rendering also prepares lazily, using the same cache.
25    ///
26    /// Pan and hover reuse the groups. A changed grouping factor, price source or historical
27    /// revision rebuilds them; appends and tail updates rebuild only the last existing group
28    /// and any new groups. Only one factor is retained at a time. This reads the currently
29    /// applied bars, including a replay cutoff; it does not poll a connected feed.
30    /// Disabled conflation and price-based chart types return 1 and clear the groups.
31    pub fn prepare_conflation(&mut self, bar_spacing: f32, pixels_per_point: f32) -> usize {
32        let factor = if self.chart_type.is_time_independent() {
33            1
34        } else {
35            super::merge::factor(
36                bar_spacing,
37                pixels_per_point,
38                self.config.conflation_threshold,
39            )
40        };
41        self.merge_groups.update(
42            &self.state.data().bars,
43            self.state.revision(),
44            factor,
45            self.config.price_source,
46        );
47        factor
48    }
49
50    /// Renders the chart with its studies and drawings, and handles pan, zoom,
51    /// crosshair, keyboard shortcuts, the drawing tools and all visual elements
52    /// configured in [`ChartConfig`](crate::config::ChartConfig).
53    ///
54    /// Overlay studies (moving averages, Bollinger Bands) are drawn on the main
55    /// pane, any other visible study (RSI, MACD) in a pane below it with its
56    /// x-axis aligned to the main pane; [`Chart::get_rendered_indicator_panes`]
57    /// returns their layout. A chart built with a feed first asks it for what it needs and
58    /// applies what it sent. Studies are then brought up to date: an incremental study at once,
59    /// and at most one study per frame in full, the one that has waited longest.
60    ///
61    /// On price-based types the time scale and reported indices count elements, one OHLC bar
62    /// per brick, line, column or range bar. Each carries the last contributing bar's time.
63    /// Studies use element OHLC on price-based types; time annotations follow element slots.
64    /// Source-bar study results remain available through [`Self::indicators`].
65    ///
66    /// Reports the series summary and hovered bar values to accessibility clients. Native
67    /// applications using `eframe` enable its `accesskit` feature to connect to screen readers.
68    ///
69    /// Returns an [`egui::Response`] for additional interaction handling.
70    pub fn show(&mut self, ui: &mut Ui) -> Response {
71        self.show_content(ui, None)
72    }
73
74    /// Keeps a cropped distribution's session and central levels readable at its visible edge.
75    fn paint_profile_context(
76        &self,
77        ui: &Ui,
78        mapping: &ChartMapping,
79        span: std::ops::Range<f32>,
80        session: chrono::DateTime<chrono::Utc>,
81        levels: &[(&str, f64)],
82        top: f32,
83    ) {
84        let left = span.start.max(mapping.rect.left());
85        let right = span.end.min(mapping.rect.right());
86        if right <= left {
87            return;
88        }
89        let theme = ui.active_theme();
90        let mut text = self.format_time(session);
91        for &(name, price) in levels {
92            let y = mapping.price_to_y(price);
93            let direction = if y < mapping.rect.top() {
94                "above"
95            } else if y > mapping.rect.bottom() {
96                "below"
97            } else {
98                ""
99            };
100            let formatter =
101                self.series_label_formatter(super::SeriesId::MAIN, mapping.price.transform().mode);
102            let value = mapping.price.label(
103                mapping.price.transform().to_scale(price),
104                rendering::axes::tag_decimals(&mapping.price, y),
105                formatter.as_deref(),
106            );
107            text.push_str(&format!("\n{name} {value}"));
108            if !direction.is_empty() {
109                text.push_str(&format!(" ({})", l10n::text(ui.ctx(), direction)));
110            }
111        }
112        let target = Rect::from_x_y_ranges(left..=right, top..=mapping.rect.bottom())
113            .intersect(mapping.rect);
114        // Painted rows can own another tooltip, so use the same hit test as the rows.
115        if ui.ctx().pointer_hover_pos().is_some_and(|pos| {
116            target.contains(pos) && ui.ctx().layer_id_at(pos) == Some(ui.layer_id())
117        }) {
118            egui::Tooltip::always_open(
119                ui.ctx().clone(),
120                ui.layer_id(),
121                ui.scope_id().with(("profile_context", session)),
122                target,
123            )
124            .show(|ui| {
125                ui.label(&text);
126            });
127        }
128        let painter = ui.painter().with_clip_rect(mapping.rect);
129        if right - left < 40.0 {
130            painter.with_clip_rect(target).text(
131                Pos2::new((left + right) * 0.5, top),
132                egui::Align2::CENTER_TOP,
133                "i",
134                egui::FontId::proportional(theme.text.label),
135                theme.chart.axis_text,
136            );
137            return;
138        }
139        let galley = painter.layout(
140            text,
141            egui::FontId::proportional(theme.text.label),
142            theme.chart.axis_text,
143            (right - left - 12.0).max(1.0),
144        );
145        let pos = Pos2::new(right - galley.size().x - 6.0, top);
146        painter.rect_filled(
147            Rect::from_min_size(pos, galley.size()).expand(3.0),
148            2.0,
149            theme.chart.bg,
150        );
151        painter.galley(pos, galley, theme.chart.axis_text);
152    }
153
154    /// Draws a footprint in place of the price series, below studies, cursor and legend.
155    /// Supply executions aggregated with the same clock as the chart's source bars.
156    /// Bars without complete executions are drawn as candles. While cells would be too narrow to
157    /// read, every bar is, merged per pixel column when zoomed out.
158    /// Price-based chart types keep their normal series because their x-axis is not time.
159    /// `config.studies` enables execution annotations with the `studies` feature;
160    /// `config.statistics` reserves a per-bar table below the price pane.
161    pub fn show_footprint(
162        &mut self,
163        ui: &mut Ui,
164        flow: &Footprint,
165        config: &FootprintConfig,
166    ) -> Response {
167        self.show_content(ui, Some((flow, config)))
168    }
169
170    fn show_content(
171        &mut self,
172        ui: &mut Ui,
173        footprint: Option<(&Footprint, &FootprintConfig)>,
174    ) -> Response {
175        let footprint = footprint.filter(|_| self.replay.is_none());
176        let theme = self.theme_override.clone();
177        // Axis labels take their size from the theme's label text.
178        let scaled = self.config.axis_font_size.map(|size| {
179            let mut scaled = theme.clone().unwrap_or_else(|| ui.ctx().active_theme());
180            scaled.text.label = crate::styles::typography::font_size(size);
181            scaled
182        });
183        let _theme_scope = scaled
184            .as_ref()
185            .or(theme.as_ref())
186            .map(|theme| crate::theme::ThemeScope::new(ui.ctx(), theme));
187        let size = self.size;
188        let show = |ui: &mut Ui| {
189            if let Some(theme) = &theme {
190                crate::theme::apply_to_style(ui.style_mut(), theme);
191            }
192            self.show_themed_content(ui, footprint)
193        };
194        // Keep the same UI scope when an override is cleared, preserving focus and widget state.
195        match size {
196            Some(size) => ui.allocate_ui(size, show).inner,
197            None => ui.scope(show).inner,
198        }
199    }
200
201    fn show_themed_content(
202        &mut self,
203        ui: &mut Ui,
204        footprint: Option<(&Footprint, &FootprintConfig)>,
205    ) -> Response {
206        self.follow_chart_theme(&ui.active_theme());
207        self.host_events.volume_map = None;
208        self.host_events.target = None;
209        self.apply_config_scale_mode();
210        #[cfg(feature = "studies")]
211        self.sync_volume_study(ui.ctx());
212        #[cfg(feature = "drawings")]
213        self.sync_stored_drawings(ui.ctx());
214        self.pump(ui.ctx());
215        if let Some(wait) = self.refresh_studies(ui.input(|i| i.time)) {
216            ui.ctx()
217                .request_repaint_after(std::time::Duration::from_secs_f64(wait));
218        }
219        self.show_elements();
220        // A crosshair set by time follows its bar when bars are evicted or loaded in front.
221        if let Some(period) = self.synced_crosshair_period {
222            self.set_synced_crosshair(Some(period), self.synced_crosshair_price);
223            if self.synced_crosshair_bar_idx.is_none() {
224                self.last_crosshair = None;
225            }
226        }
227        if let Some(wait) = self.refresh_element_studies(ui.input(|i| i.time)) {
228            ui.ctx()
229                .request_repaint_after(std::time::Duration::from_secs_f64(wait));
230        }
231        self.sync_primitives(ui.ctx());
232        let frame = ui.ctx().cumulative_frame_nr();
233        let width = ui.available_width();
234        let resized = self
235            .last_layout
236            .is_none_or(|(previous, changed)| previous != width || changed == frame);
237        let previous_axis_width = self.measured_axis_width;
238        self.last_rendered_indicator_panes.clear();
239        self.sync_panes();
240        for pane in &mut self.pane_layout.panes {
241            pane.rect = Rect::NOTHING;
242        }
243        // Panes share the available height and the lowest time axis.
244        let has_panes = self.has_panes();
245        let response = if has_panes {
246            self.show_with_panes(ui, footprint)
247        } else {
248            let response = self.show_internal(ui, None, None, footprint);
249            if let Some(main) = self
250                .pane_layout
251                .panes
252                .iter_mut()
253                .find(|pane| pane.id == super::PaneId::MAIN)
254            {
255                main.rect = response.rect;
256            }
257            response
258        };
259        self.menu_open = false;
260        if !self.chart_type.is_time_independent() {
261            let source_marks = self.mark_source.as_ref().map(|source| &source.page);
262            let mut coords = self.get_chart_mapping();
263            coords.start_idx = 0;
264            let baseline = if has_panes {
265                response.rect.bottom() - self.time_axis_height().min(response.rect.height())
266            } else {
267                coords.rect.bottom()
268            };
269            coords.rect.max.y = baseline;
270            // Source pages cover the view; host lists are windowed before cloning or formatting.
271            // Keys keep each badge's interaction stable as the view moves.
272            let (from, to) = super::marks::window(self.state.shown(), &coords, coords.rect);
273            let mut events: Vec<_> = self
274                .events
275                .within(from, to)
276                .into_iter()
277                .map(|index| (index, self.events[index].clone()))
278                .collect();
279            let mut key = self.events.len();
280            if let Some(source) = source_marks {
281                events.extend(
282                    source
283                        .time_scale_marks
284                        .iter()
285                        .cloned()
286                        .enumerate()
287                        .map(|(index, event)| (key + index, event)),
288                );
289                key += source.time_scale_marks.len();
290            }
291            let typed_count = self.time_scale_events.len();
292            let typed = self
293                .time_scale_events
294                .within(from, to)
295                .into_iter()
296                .map(|index| (key + index, &self.time_scale_events[index]))
297                .chain(source_marks.into_iter().flat_map(|page| {
298                    page.events
299                        .iter()
300                        .enumerate()
301                        .map(move |(index, event)| (key + typed_count + index, event))
302                }));
303            let mut breaks = Vec::new();
304            for (key, event) in typed {
305                if event.kind == crate::model::EventKind::Earnings
306                    && self.event_visibility.show_earnings_breaks
307                {
308                    breaks.push(event.time);
309                }
310                if self.event_visibility.shows(event.kind) {
311                    events.push((
312                        key,
313                        crate::model::Marker::event(event.time, event.kind.label())
314                            .with_id(&event.id)
315                            .with_tooltip(format!("{}\n{}", event.title, event.details)),
316                    ));
317                }
318            }
319            rendering::render_event_breaks(ui, &breaks, self.state.shown(), &coords);
320            if let Some(id) = rendering::render_event_badges(
321                ui,
322                events.iter().map(|(key, event)| (*key, event)),
323                self.state.shown(),
324                &coords,
325                baseline,
326            ) {
327                self.event_click = Some(id);
328            }
329        }
330        if self.last_rendered_price_rect.width() > 1.0
331            && self
332                .last_layout
333                .is_none_or(|(previous, _)| previous != width)
334        {
335            self.last_layout = Some((width, frame));
336        }
337        if (self.measured_axis_width - previous_axis_width).abs() > 0.5 {
338            let old_width = self.last_rendered_price_rect.width();
339            let new_width = self
340                .calculate_layout_rects(response.rect)
341                .price_rect
342                .width();
343            // Sizing passes follow the same zoom policy as the viewport resize.
344            if (!resized
345                || self.desired_visible_bars.is_some_and(|count| count > 0)
346                || self
347                    .chart_options
348                    .time_scale
349                    .lock_visible_time_range_on_resize)
350                && previous_axis_width > 0.0
351                && old_width > 0.0
352                && new_width > 0.0
353            {
354                let scale = self.state.time_scale_mut();
355                scale.set_bar_spacing(scale.bar_spacing() * new_width / old_width);
356            }
357            ui.ctx().request_discard("Chart measured its axis labels");
358        }
359        self.record_host_events(ui);
360        let needs_info = response.clicked()
361            || response.double_clicked()
362            || response.triple_clicked()
363            || response.gained_focus()
364            || response.changed()
365            || ui
366                .ctx()
367                .accesskit_node_builder(response.id, |_| ())
368                .is_some();
369        #[cfg(debug_assertions)]
370        let needs_info = needs_info || ui.ctx().global_style().debug.show_interactive_widgets;
371        if needs_info {
372            // Translation callbacks must run outside the context locks held by widget_info.
373            let info = self.accessibility_info(ui.ctx(), response.enabled());
374            response.widget_info(|| info.clone());
375        }
376        // Within the frame of the edit, in case it is the last.
377        #[cfg(feature = "drawings")]
378        self.save_stored_drawings();
379        response
380    }
381
382    /// Pulses at the last price of a line-based chart as its `last_price_animation` asks, and
383    /// keeps repainting while a visible pulse runs.
384    fn paint_last_price_pulse(
385        &self,
386        ui: &Ui,
387        painter: &egui::Painter,
388        price_rect: Rect,
389        coords: &ChartMapping,
390    ) {
391        use crate::model::LastPriceAnimation;
392        let styles = &self.chart_type_params.styles;
393        let animation = styles.last_price_animation;
394        let Some(index) = self
395            .state
396            .shown()
397            .iter()
398            .rposition(|bar| !bar.is_whitespace())
399        else {
400            return;
401        };
402        let last = &self.state.shown()[index];
403        let price = self
404            .config
405            .price_source
406            .compute(last.open, last.high, last.low, last.close);
407        let Some(color) = rendering::candles::line_color_at(
408            self.chart_type,
409            styles,
410            self.chart_type_params.baseline,
411            &coords.price,
412            price_rect,
413            price,
414            last,
415        )
416        .filter(|_| animation != LastPriceAnimation::Disabled) else {
417            return;
418        };
419        let now = ui.input(|input| input.time);
420        let started = match self.last_price_pulse.get() {
421            Some((time, seen, started)) if time == last.time && seen.total_cmp(&price).is_eq() => {
422                started
423            }
424            previous => {
425                // The first price seen is the loaded history, not an update.
426                let started = previous.map_or(f64::NEG_INFINITY, |_| now);
427                self.last_price_pulse.set(Some((last.time, price, started)));
428                started
429            }
430        };
431        let period = rendering::candles::LAST_PRICE_PULSE_SECONDS;
432        let phase = match animation {
433            LastPriceAnimation::Continuous => (now / period).fract(),
434            _ => (now - started) / period,
435        };
436        let point = Pos2::new(coords.idx_to_x(index), coords.price.price_to_y(price));
437        if (0.0..1.0).contains(&phase) && price_rect.contains(point) {
438            rendering::candles::render_last_price_pulse(painter, point, color, phase as f32);
439            ui.ctx().request_repaint();
440        }
441    }
442
443    /// Internal rendering method that orchestrates all modules
444    pub(crate) fn show_internal(
445        &mut self,
446        ui: &mut Ui,
447        shared_time_axis: Option<Rect>,
448        shared_plot: Option<&Response>,
449        footprint: Option<(&Footprint, &FootprintConfig)>,
450    ) -> Response {
451        // Register the icon loader once per context, and the general SVG loader for UI images.
452        #[cfg(feature = "icons")]
453        {
454            let ctx = ui.ctx().clone();
455            let loaders_installed = egui::Id::unique("egui_charts::image_loaders_installed");
456            if !ctx.data(|d| d.get_temp::<bool>(loaders_installed).unwrap_or(false)) {
457                #[cfg(feature = "ui")]
458                egui_extras::install_image_loaders(&ctx);
459                crate::icons::install_loader(&ctx);
460                ctx.data_mut(|d| d.insert_temp(loaders_installed, true));
461            }
462        }
463
464        // Reset zoom_just_applied flag at the start of each frame
465        self.zoom_just_applied = false;
466
467        // A right-click result lives for exactly one frame: clear any stale
468        // capture so a target that is never drained does not reappear later.
469        self.right_click = None;
470
471        let available_size = ui.available_size();
472        let (mut response, mut painter) =
473            ui.allocate_painter(available_size, Sense::click_and_drag());
474        let rect = response.rect;
475
476        if let Some(axis) = shared_time_axis {
477            painter.set_clip_rect(ui.clip_rect().intersect(if rect.height() == 0.0 {
478                axis
479            } else {
480                rect.union(axis)
481            }));
482            response = response.union(ui.interact(
483                axis,
484                response.id.with("shared_time_axis"),
485                Sense::click_and_drag(),
486            ));
487        }
488        let mut layout = self.pane_layout_rects(
489            rect,
490            self.config.show_time_labels && shared_time_axis.is_none(),
491        );
492        let time_axis_top = shared_time_axis.map_or(layout.price_rect.bottom(), |axis| axis.top());
493        let statistics_rect = footprint
494            .filter(|(_, config)| {
495                config.statistics
496                    && !self.chart_type.is_time_independent()
497                    && self.main_series_visible
498            })
499            .map(|_| {
500                let bottom = layout.price_rect.bottom();
501                let height = 112.0_f32.min(layout.price_rect.height().max(0.0) * 0.4);
502                layout.price_rect.max.y -= height;
503                if self.shows_volume() {
504                    layout.volume_rect =
505                        super::volume::overlay_rect(layout.price_rect, &self.config);
506                }
507                Rect::from_min_max(
508                    Pos2::new(layout.price_rect.left(), layout.price_rect.bottom()),
509                    Pos2::new(layout.price_rect.right(), bottom),
510                )
511            });
512        let chart_rect = layout.price_rect;
513
514        self.handle_secondary_axes(ui, rect, chart_rect);
515        let chart_width = chart_rect.width();
516
517        // CRITICAL: Apply TimeScale width configuration BEFORE any zoom operations
518        // This ensures apply_constraints() inside zoom() uses the correct self.width
519        // to calculate constraint bounds. Without this, drawings drift during zoom
520        // because constraints are calculated with stale width values.
521        self.apply_timescale_config(chart_width);
522        self.advance_scroll_animation(ui);
523
524        let navigation_rect = self.navigation_rect(ui, chart_rect);
525        let navigation = navigation_rect
526            .filter(|_| self.navigation_visible(ui, chart_rect))
527            .map(|nav| {
528                (
529                    nav,
530                    self.interact_navigation(ui, nav, chart_rect, response.id),
531                )
532            });
533        let scale = self.state.time_scale();
534        let mut live_scale = scale.clone();
535        live_scale.scroll_to_realtime();
536        let realtime_rect = (self.config.show_realtime_btn
537            && !self.state.data().is_empty()
538            && (scale.right_offset_precise() - live_scale.right_offset_precise()).abs() > 1e-6)
539            .then(|| {
540                rendering::realtime_button_rect(
541                    chart_rect,
542                    self.config.realtime_button_size,
543                    self.config.realtime_button_pos,
544                    navigation_rect.filter(|_| {
545                        self.config.navigation_buttons != crate::config::NavigationButtons::Never
546                    }),
547                )
548            })
549            .flatten();
550        let realtime_interaction = realtime_rect.is_some_and(|button| {
551            ui.input(|input| {
552                (input.pointer.any_down() || input.pointer.any_released())
553                    && input
554                        .pointer
555                        .press_origin()
556                        .or_else(|| input.pointer.hover_pos())
557                        .is_some_and(|pos| button.contains(pos))
558            })
559        });
560        let navigation_interaction = navigation_rect.is_some_and(|nav| {
561            self.config.navigation_buttons != crate::config::NavigationButtons::Never
562                && !self.state.data().is_empty()
563                && ui.input(|i| {
564                    (i.pointer.any_down() || i.pointer.any_released())
565                        && i.pointer
566                            .press_origin()
567                            .or_else(|| i.pointer.hover_pos())
568                            .is_some_and(|p| nav.contains(p))
569                })
570        });
571        // A button owns the whole gesture, including a drag that leaves the row. The chart's
572        // own response goes back to the caller; the gesture handlers see a hover-only one.
573        let shown =
574            shared_plot.map_or_else(|| response.clone(), |plot| response.union(plot.clone()));
575        let axis_corner = Rect::from_min_max(
576            Pos2::new(
577                if self.config.show_right_axis {
578                    chart_rect.right()
579                } else {
580                    rect.left()
581                },
582                time_axis_top,
583            ),
584            Pos2::new(
585                if self.config.show_right_axis {
586                    rect.right()
587                } else {
588                    chart_rect.left()
589                },
590                time_axis_top + crate::styles::sizing::chart::BOTTOM_PADDING_WITH_TIME,
591            ),
592        );
593        #[cfg(feature = "drawings")]
594        let eraser_mode = self.drawings.eraser_mode;
595        #[cfg(not(feature = "drawings"))]
596        let eraser_mode = false;
597        #[cfg(feature = "drawings")]
598        let drawing_tool_active = self.drawings.active_tool.is_some();
599        #[cfg(not(feature = "drawings"))]
600        let drawing_tool_active = false;
601        #[cfg(not(feature = "drawings"))]
602        let is_drawing_interaction = false;
603        // The Magic cursor marks while the button is held, so a drag paints rather than pans.
604        #[cfg(feature = "drawings")]
605        let is_drawing_interaction = self.drawings.active_tool.is_some()
606            || self.chart_options.crosshair.style == crate::config::CrosshairStyle::Magic
607            || self.drawings.dragging_handle.is_some()
608            || self.drawings.text_editor.is_some()
609            || self.drawings.curr_drawing.is_some()
610            || ui
611                .input(|i| i.pointer.press_origin().or_else(|| i.pointer.hover_pos()))
612                .is_some_and(|origin| {
613                    self.drawings
614                        .selected_ids()
615                        .any(|id| self.drawings.hit_test_handle_by_id(origin, id).is_some())
616                        || self.drawings.hit_test(origin).is_some_and(|id| {
617                            self.drawings
618                                .drawings
619                                .iter()
620                                .any(|d| d.id == id && !d.locked)
621                        })
622                });
623        let show_price_button =
624            self.config.show_plus_button && !eraser_mode && !is_drawing_interaction;
625        let show_axis_settings = !self.layout_inactive || ui.rect_contains_pointer(rect);
626        let axis_interaction = cfg!(feature = "icons")
627            && (self.config.show_right_axis || self.config.show_left_axis)
628            && ui.input(|input| {
629                (input.pointer.any_down() || input.pointer.any_released())
630                    && input
631                        .pointer
632                        .press_origin()
633                        .or(input.pointer.hover_pos())
634                        .is_some_and(|pos| {
635                            (show_axis_settings && axis_corner.contains(pos))
636                                || (show_price_button
637                                    && self.axis_hover.is_some_and(|(_, tag)| {
638                                        rendering::axes::crosshair_price_button_rect(tag)
639                                            .contains(pos)
640                                    }))
641                        })
642            });
643        if navigation_interaction || realtime_interaction || axis_interaction {
644            response = ui.interact(
645                Rect::NOTHING,
646                response.id.with("navigation_input"),
647                Sense::hover(),
648            );
649            response.rect = rect;
650        }
651
652        // The visible pane drives time navigation even while the price pane has no height.
653        let mut time_response =
654            shared_plot.map_or_else(|| response.clone(), |plot| response.union(plot.clone()));
655        // Handle tracking mode
656        self.handle_tracking_mode(ui, &time_response);
657
658        // Request focus on hover for keyboard shortcuts
659        self.request_focus_if_needed(&mut time_response);
660
661        // Handle keyboard shortcuts
662        self.handle_keyboard_shortcuts(ui, &time_response, chart_width, chart_rect.min.x);
663
664        // Calculate visible bars
665        let logical_range = self.state.time_scale().visible_logical_range();
666        let visible_bars = logical_range.length().ceil() as usize;
667        self.last_visible_bars = visible_bars;
668
669        // Show grabbing cursor during panning
670        self.set_panning_cursor(ui, &time_response);
671
672        // Define axis rects
673        let price_axis_rect = Rect::from_min_max(
674            Pos2::new(axis_corner.left(), chart_rect.top()),
675            Pos2::new(axis_corner.right(), chart_rect.bottom()),
676        );
677        let time_axis_rect = shared_time_axis.unwrap_or_else(|| {
678            Rect::from_min_max(
679                Pos2::new(chart_rect.min.x, chart_rect.max.y),
680                Pos2::new(chart_rect.max.x, rect.max.y),
681            )
682        });
683        self.time_axis_rect = Rect::from_min_size(
684            time_axis_rect.min,
685            Vec2::new(
686                time_axis_rect.width(),
687                self.time_axis_height().min(time_axis_rect.height()),
688            ),
689        );
690
691        // Handle double-click to reset axes
692        self.handle_double_click(&time_response, price_axis_rect, time_axis_rect);
693
694        // Handle mouse wheel for zoom/scroll
695        // Block pan/zoom when drawing tool is active OR when manipulating a drawing
696        let time_based = !self.chart_type.is_time_independent();
697        let show_volume = self.shows_volume();
698        let pending_price_zoom = self.handle_mouse_wheel(
699            ui,
700            &time_response,
701            chart_width,
702            chart_rect.min.x,
703            price_axis_rect,
704        );
705
706        // Handle pinch-to-zoom for touch/trackpad gestures
707        self.handle_pinch_zoom(ui, &time_response, chart_width, chart_rect.min.x);
708
709        // Handle drag to pan (blocked when interacting with drawings)
710        self.handle_drag_pan(
711            ui,
712            &time_response,
713            price_axis_rect,
714            time_axis_rect,
715            chart_rect.min.x,
716            is_drawing_interaction,
717        );
718
719        // Apply kinetic scrolling
720        self.apply_kinetic_scroll(ui);
721
722        // Handle box zoom (only when zoom mode is active from toolbar)
723        // Right-click is reserved for context menu, zoom uses left-click when mode is active
724        self.zoom_just_applied = self.handle_box_zoom(
725            ui,
726            &response,
727            chart_rect,
728            chart_width,
729            self.zoom_mode_active,
730        );
731        if self.zoom_just_applied {
732            log::info!("Zoom applied - chart will auto-deactivate zoom mode");
733        }
734
735        // Set zoom-in cursor when zoom mode is active
736        if self.zoom_mode_active && response.hovered() {
737            ui.ctx().set_cursor_icon(egui::CursorIcon::ZoomIn);
738        }
739        // Set cursor icon based on crosshair style (cursor type)
740        // This is only for default case - drawing manager may override for eraser mode
741        else if response.hovered() {
742            use crate::config::CrosshairStyle;
743            match self.chart_options.crosshair.style {
744                CrosshairStyle::Full => {
745                    // Cross cursor mode - show crosshair cursor
746                    ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
747                }
748                CrosshairStyle::Dot => {
749                    // Dot mode - default pointer (dot is rendered on chart)
750                    ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
751                }
752                CrosshairStyle::Arrow
753                | CrosshairStyle::Demonstration
754                | CrosshairStyle::Magic
755                | CrosshairStyle::Hidden => {
756                    // Arrow mode - default pointer
757                    ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
758                }
759            }
760        }
761
762        // Draw background (solid or gradient)
763        self.draw_background(&painter, rect);
764        if let Some(axis) = shared_time_axis {
765            self.draw_background(
766                &painter,
767                Rect::from_min_max(
768                    Pos2::new(rect.left(), axis.top()),
769                    Pos2::new(rect.right(), axis.bottom()),
770                ),
771            );
772        }
773
774        self.pump_signals(ui.ctx());
775        self.pump_targets(ui.ctx());
776        #[cfg(feature = "drawings")]
777        self.pump_posts(ui.ctx());
778
779        // Draw watermark overlay (if enabled)
780        self.draw_watermark(ui, &painter, chart_rect);
781        if let Some(watermark) = &self.watermark {
782            rendering::render_watermark(ui, &painter, chart_rect, watermark);
783        }
784
785        if self.state.data().is_empty() {
786            self.last_hover_bar_idx = None;
787            self.last_crosshair = None;
788            self.pump_marks(ui.ctx());
789            painter.text(
790                rect.center(),
791                egui::Align2::CENTER_CENTER,
792                l10n::text(ui.ctx(), "No data available"),
793                egui::FontId::proportional(ui.active_theme().text.title),
794                self.config.text_color,
795            );
796            return response;
797        }
798
799        let price_rect = layout.price_rect;
800        let volume_rect = layout.volume_rect;
801
802        // Get visible range
803        let (start_idx, end_idx) = self.state.visible_range();
804        let cutoff = self.replay_time();
805        let (registry, bars) = if self.chart_type.is_time_independent() {
806            (
807                &mut self.element_studies.studies,
808                &self.element_studies.bars[..],
809            )
810        } else {
811            (&mut self.studies, self.state.data().bars.as_slice())
812        };
813        let mut changed: Vec<_> = registry
814            .indicators_mut()
815            .iter_mut()
816            .enumerate()
817            .filter_map(|(at, study)| study.set_visible_range(start_idx..end_idx).then_some(at))
818            .collect();
819        if !changed.is_empty() {
820            let requests = registry
821                .indicators()
822                .iter()
823                .flat_map(|study| study.data_requests())
824                .collect();
825            let sources = self
826                .study_data
827                .data(requests, &self.symbol, self.timeframe, cutoff);
828            for at in super::studies::order(registry.indicators(), &self.study_ids) {
829                if let Some(crate::studies::StudySource::Study { id, .. }) =
830                    registry.indicators()[at].source()
831                    && let Some(parent) = self
832                        .study_ids
833                        .iter()
834                        .position(|candidate| candidate.0 == id)
835                    && changed.contains(&parent)
836                    && !changed.contains(&at)
837                {
838                    changed.push(at);
839                }
840                if changed.contains(&at) {
841                    super::studies::update(
842                        registry.indicators_mut(),
843                        &self.study_ids,
844                        at,
845                        bars,
846                        0,
847                        &sources,
848                    );
849                }
850            }
851        }
852        self.start_idx = start_idx;
853
854        let factor = self.prepare_conflation(
855            self.state.time_scale().bar_spacing(),
856            ui.ctx().pixels_per_point(),
857        );
858
859        #[cfg(feature = "studies")]
860        let execution_profiles = self
861            .feed_link
862            .as_ref()
863            .and_then(|link| link.footprint.as_ref())
864            .filter(|_| {
865                self.main_series_visible
866                    && footprint.is_none()
867                    && self.replay.is_none()
868                    && self.chart_type == ChartType::SessionVolumeProfile
869            })
870            .and_then(|flow| {
871                let first = flow.complete_from()?;
872                let candles =
873                    self.state.shown()[start_idx..end_idx].partition_point(|bar| bar.time < first);
874                let ranges = self.execution_profile_ranges(start_idx + candles..end_idx)?;
875                let mapping = self.get_chart_mapping().with_rect(price_rect);
876                let bars = &self.state.data().bars;
877                let mut profiles = crate::studies::trade_profiles(flow, &ranges);
878                profiles.retain(|profile| {
879                    let first = bars.partition_point(|b| b.time < profile.range.start);
880                    let end = bars.partition_point(|b| b.time < profile.range.end);
881                    first < end
882                        && mapping.idx_to_x(end - 1) >= price_rect.left()
883                        && mapping.idx_to_x(first) - mapping.bar_spacing * 0.45
884                            <= price_rect.right()
885                });
886                Some((flow.price_step(), flow.tick_size(), profiles))
887            });
888        #[cfg(feature = "studies")]
889        let profile_prices = execution_profiles
890            .iter()
891            .flat_map(|(step, tick, profiles)| {
892                profiles.iter().flat_map(move |profile| {
893                    profile
894                        .volume
895                        .levels
896                        .first_key_value()
897                        .zip(profile.volume.levels.last_key_value())
898                        .into_iter()
899                        .flat_map(move |((&low, _), (&high, _))| {
900                            [
901                                low as f64 * step - tick * 0.5,
902                                (high as f64 + 1.0) * step - tick * 0.5,
903                            ]
904                        })
905                })
906            });
907        #[cfg(not(feature = "studies"))]
908        let profile_prices = std::iter::empty();
909        // Fit the price scale, then apply this frame's price zoom
910        let series_values = self.fit_secondary_series();
911        let primitive_prices = self.main_primitive_prices();
912        let target_prices = self.target_prices();
913        let source_marks = self
914            .mark_source
915            .as_ref()
916            .map(|source| &source.page.bar_marks);
917        let marker_prices = self
918            .marker_prices(
919                &self.marks,
920                source_marks.map_or(&[], |marks| &marks[..]),
921                self.marker_options,
922            )
923            .filter(|_| self.main_series_visible && time_based)
924            .collect::<Vec<_>>();
925        let heikin_ashi = (self.chart_type == crate::model::ChartType::Heikin).then(|| {
926            self.heikin_ashi
927                .bars(&self.state.data().bars, self.state.revision())
928        });
929        let (transform, data_range) = self.state.fit_price_scale_with_series(
930            (if self.chart_type.is_time_independent() {
931                &self.element_studies.studies
932            } else {
933                &self.studies
934            })
935            .indicators()
936            .iter()
937            .zip(&self.study_autoscale)
938            .filter(|(study, autoscale)| {
939                !self.config.scale_price_chart_only && **autoscale && !study.is_volume()
940            })
941            .map(|(study, _)| study.as_ref()),
942            heikin_ashi.as_ref().map(|bars| &bars[start_idx..end_idx]),
943            self.main_series_visible,
944            self.main_series_style.autoscale.as_ref(),
945            (factor > 1
946                && heikin_ashi.is_none()
947                && self.state.price_scale().options().mode
948                    != crate::scales::PriceScaleMode::Logarithmic)
949                .then(|| {
950                    self.merge_groups
951                        .price_range(self.state.shown(), start_idx..end_idx)
952                })
953                .flatten(),
954            marker_prices
955                .into_iter()
956                .chain(primitive_prices)
957                .chain(target_prices)
958                .chain(profile_prices),
959            series_values,
960        );
961        drop(heikin_ashi);
962        if self.main_series_visible
963            && footprint.is_none()
964            && self.chart_type == ChartType::TimePriceOpportunity
965            && !self.state.price_scale().is_manual()
966        {
967            let mut clock = self.bar_clock();
968            if let Some(zone) = self
969                .instrument()
970                .filter(|i| i.session.is_none())
971                .and_then(|i| i.time_zone.as_ref())
972            {
973                clock = clock.with_timezone(zone.clone());
974            }
975            let tick = self
976                .instrument()
977                .and_then(|instrument| instrument.tick_size);
978            let config = self.chart_type_params.tpo_config(tick);
979            // Automatic rows depend on the scale span. Revisit after fitting until the rows
980            // actually drawn, including their outer half-tick edges, fit in the scale. Retain
981            // fitted edges when the grid changes, so non-nested grids cannot alternate ranges.
982            let initial = self.state.price_scale().range();
983            let mut fitted = data_range;
984            loop {
985                let map =
986                    self.state
987                        .price_scale()
988                        .map(transform, price_rect.top(), price_rect.bottom());
989                let range = map.range();
990                let (mut low, mut high) = fitted;
991                let (mut traded_low, mut traded_high) = data_range;
992                let mapping = self.get_chart_mapping().with_rect(price_rect);
993                rendering::candles::tpo::visit_profiles(
994                    &self.state.data().bars,
995                    start_idx..end_idx,
996                    self.state.revision(),
997                    (&config, tick),
998                    &clock,
999                    (&map, price_rect, |i| mapping.idx_to_x(i)),
1000                    |profile, _, anchor, _, _| {
1001                        if anchor >= price_rect.right() {
1002                            return;
1003                        }
1004                        for value in [profile.profile_low, profile.profile_high]
1005                            .map(|p| transform.to_scale(p))
1006                        {
1007                            if value.is_finite() {
1008                                traded_low = traded_low.min(value);
1009                                traded_high = traded_high.max(value);
1010                            }
1011                        }
1012                        for price in [
1013                            profile.row_span(profile.profile_low).0,
1014                            profile.row_span(profile.profile_high).1,
1015                        ] {
1016                            let value = transform.to_scale(price);
1017                            if value.is_finite() {
1018                                low = low.min(value);
1019                                high = high.max(value);
1020                            }
1021                        }
1022                    },
1023                );
1024                if (low, high) == fitted {
1025                    break;
1026                }
1027                self.state.price_scale_mut().autoscale(low, high);
1028                fitted = (low, high);
1029                let next = self.state.price_scale().range();
1030                // A flat session without a tick must keep the scale's minimum span, rather than
1031                // deriving ever smaller rows from its own fitted row height.
1032                if traded_high - traded_low <= 1e-9
1033                    && next.max - next.min < initial.max - initial.min
1034                    && initial.contains(low)
1035                    && initial.contains(high)
1036                {
1037                    self.state
1038                        .price_scale_mut()
1039                        .autoscale(data_range.0, data_range.1);
1040                    break;
1041                }
1042                if next == range {
1043                    break;
1044                }
1045            }
1046        }
1047        self.fit_marker_scales(&painter, price_rect);
1048        self.apply_price_zoom(pending_price_zoom, &response, price_rect, transform);
1049        let bar_spacing = self.state.time_scale().bar_spacing();
1050        if self.config.lock_price_to_bar_ratio != self.last_ratio_lock {
1051            self.state.price_scale_mut().set_bar_ratio_locked(
1052                self.config.lock_price_to_bar_ratio,
1053                bar_spacing,
1054                price_rect.height(),
1055            );
1056        }
1057        self.config.lock_price_to_bar_ratio = self.state.price_scale().bar_ratio_locked();
1058        self.last_ratio_lock = self.config.lock_price_to_bar_ratio;
1059        self.state
1060            .price_scale_mut()
1061            .update_bar_ratio(bar_spacing, price_rect.height());
1062        let price_map = self
1063            .series_display_map(
1064                super::SeriesId::MAIN,
1065                self.state
1066                    .price_scale()
1067                    .map(transform, price_rect.min.y, price_rect.max.y),
1068            )
1069            .with_thousands_sep(self.config.thousands_sep);
1070
1071        self.update_shared_study_maps(price_rect);
1072        let price_formatter = self.series_label_formatter(
1073            self.series_axis_owner(super::SeriesId::MAIN),
1074            price_map.transform().mode,
1075        );
1076
1077        // Store the final price mapping and rects for external use (selection dots, hit testing)
1078        self.last_price_map = price_map;
1079        self.last_rendered_price_rect = price_rect;
1080        self.last_rendered_volume_rect = volume_rect;
1081
1082        self.begin_mark_hits();
1083        self.pump_marks(ui.ctx());
1084
1085        // Get visible data
1086        let visible_data = self.state.visible_data();
1087
1088        let groups = (factor > 1).then(|| {
1089            let covering = self
1090                .merge_groups
1091                .covering(start_idx..start_idx + visible_data.len());
1092            (covering.start, &self.merge_groups.groups[covering])
1093        });
1094        // The first bar of each group, at the group's position, for the time axis and sessions.
1095        let time_bars: Option<Vec<_>> =
1096            groups.map(|(_, groups)| groups.iter().map(|group| group.bar.clone()).collect());
1097
1098        // Largest visible volume: the volume pane and volume candles scale by it
1099        let max_volume = time_bars
1100            .as_deref()
1101            .unwrap_or(visible_data)
1102            .iter()
1103            .map(|c| c.volume)
1104            .filter(|v| v.is_finite())
1105            .max_by(f64::total_cmp)
1106            .unwrap_or(1.0);
1107
1108        // Time and price grid lines sharing a style are painted together.
1109        let horizontal_grid = self.config.show_grid && self.config.show_horizontal_grid;
1110        let time_grid = time_based && self.config.show_grid && self.config.show_vertical_grid;
1111        if horizontal_grid && !time_grid {
1112            rendering::render_grid(
1113                &painter,
1114                price_rect,
1115                &price_map,
1116                self.config.grid_color,
1117                self.config.grid_line_style,
1118            );
1119        }
1120
1121        // Create rendering contexts
1122        let bar_spacing = self.state.time_scale().bar_spacing();
1123        let bar_width = bar_spacing * self.config.candle_width;
1124        let price_ctx = RenderContext::new(&painter, price_rect);
1125        let coords = ChartMapping::new(
1126            price_rect,
1127            bar_spacing,
1128            start_idx,
1129            self.state.time_scale().base_idx(),
1130            self.state.time_scale().right_offset_precise(),
1131            price_map,
1132        );
1133        let (time_data, time_coords) = match (&time_bars, groups) {
1134            (Some(bars), Some((start, _))) => (
1135                bars.as_slice(),
1136                super::merge::group_mapping(&coords, factor, start),
1137            ),
1138            _ => (visible_data, coords),
1139        };
1140        let colors = StyleColors {
1141            bullish: self.config.bullish_color,
1142            bearish: self.config.bearish_color,
1143            text: self.config.text_color,
1144            bullish_border: self.config.bullish_border_color,
1145            bearish_border: self.config.bearish_border_color,
1146            bullish_wick: self.config.bullish_wick_color,
1147            bearish_wick: self.config.bearish_wick_color,
1148            candle_border_width: self.config.candle_border_width,
1149        };
1150
1151        let timezone = self.display_timezone();
1152        let time_scale = self.build_time_formatter();
1153        let host_scale = self.horizontal_scale.clone();
1154        let scale: &dyn crate::scales::HorizontalScale =
1155            host_scale.as_deref().unwrap_or(&time_scale);
1156        // The grid and the labels share one set of marks and the merged bars' mapping.
1157        let (time_marks, time_points, axis_coords) = if time_grid || self.config.show_time_labels {
1158            // Resolved once: a fixed-offset zone allocates on every clone.
1159            let calendar = self.calendar_timezone();
1160            rendering::time_axis_marks(
1161                &RenderContext::new(&painter, chart_rect),
1162                time_data,
1163                &time_coords,
1164                scale,
1165                &self.chart_options.time_scale,
1166                calendar.as_ref(),
1167                |index| {
1168                    crate::model::time_at_with_timeframe(
1169                        self.state.shown(),
1170                        index * factor as f64,
1171                        self.timeframe(),
1172                        calendar.as_ref(),
1173                    )
1174                },
1175            )
1176        } else {
1177            (Vec::new(), Vec::new(), time_coords)
1178        };
1179
1180        if self.config.show_grid && self.config.show_vertical_grid {
1181            let style = self
1182                .config
1183                .vertical_grid_line_style
1184                .unwrap_or(self.config.grid_line_style);
1185            if time_based {
1186                rendering::render_time_grid(
1187                    &RenderContext::new(&painter, chart_rect),
1188                    &time_marks,
1189                    &time_points,
1190                    &axis_coords,
1191                    self.config
1192                        .vertical_grid_color
1193                        .unwrap_or(self.config.grid_color),
1194                    style,
1195                    horizontal_grid
1196                        .then_some((self.config.grid_color, self.config.grid_line_style)),
1197                );
1198            } else {
1199                rendering::render_vertical_grid(
1200                    &painter,
1201                    chart_rect,
1202                    &coords,
1203                    self.config
1204                        .vertical_grid_color
1205                        .unwrap_or(self.config.grid_color),
1206                    style,
1207                );
1208            }
1209        }
1210
1211        let session_tf = self.resolve_session_timeframe(visible_data);
1212        let exchange_session = rendering::exchange_session(
1213            self.instrument(),
1214            self.config.session_config.as_ref(),
1215            &timezone,
1216            session_tf,
1217        );
1218        let session_painter = painter.with_clip_rect(price_rect);
1219        let session_ctx = RenderContext::new(&session_painter, price_rect);
1220        // Sessions and extended hours are periods of time, absent from other horizontal scales.
1221        if self.config.show_session_breaks && host_scale.is_none() {
1222            let provider = self
1223                .session_provider
1224                .as_deref()
1225                .unwrap_or(&exchange_session);
1226            let background =
1227                rendering::SessionBackgroundRenderer::from_background(self.config.background_color);
1228            background.render(
1229                &session_ctx,
1230                time_data,
1231                provider,
1232                &time_coords,
1233                time_coords.start_idx,
1234            );
1235            rendering::SessionBreakRenderer::new(rendering::SessionBreakRenderConfig {
1236                line_color: self.config.session_break_color,
1237                line_width: 1.0,
1238                style: self.config.session_break_style,
1239            })
1240            .render(
1241                &session_ctx,
1242                time_data,
1243                provider,
1244                &time_coords,
1245                time_coords.start_idx,
1246            );
1247        }
1248        if time_based
1249            && host_scale.is_none()
1250            && session_tf.duration_ms() < crate::model::Timeframe::Day1.duration_ms()
1251            && self.config.show_extended_hours
1252        {
1253            let color = if self.config.extended_hours_color == egui::Color32::PLACEHOLDER {
1254                ui.active_theme().ui.accent.linear_multiply(0.08)
1255            } else {
1256                self.config.extended_hours_color
1257            };
1258            rendering::render_extended_hours(
1259                &session_ctx,
1260                time_data,
1261                &exchange_session,
1262                &time_coords,
1263                time_coords.start_idx,
1264                color,
1265            );
1266        }
1267
1268        // Render chart type with clipping to prevent bars from overlapping axes
1269        // CRITICAL: Use chart_rect.width() for consistency with drawing coordinate system
1270        let chart_rect_width = chart_rect.width();
1271        let idx_to_coord = |idx: usize, min_x: f32| -> f32 {
1272            self.state
1273                .time_scale()
1274                .idx_to_coord(idx, min_x, chart_rect_width)
1275        };
1276
1277        // Create clipped painter and contexts to prevent bars from rendering on axes
1278        let clipped_painter = painter.with_clip_rect(chart_rect);
1279        let clipped_price_ctx = RenderContext::new(&clipped_painter, price_rect);
1280        let clipped_volume_ctx = RenderContext::new(&clipped_painter, volume_rect);
1281
1282        let mut bar_clock = self.bar_clock();
1283        // TPO days follow the instrument's zone, as the session breaks do, also without its hours.
1284        if let Some(zone) = self
1285            .instrument()
1286            .filter(|instrument| instrument.session.is_none())
1287            .and_then(|instrument| instrument.time_zone.as_ref())
1288        {
1289            bar_clock = bar_clock.with_timezone(zone.clone());
1290        }
1291        let render_ctx = rendering::CandleDataContext {
1292            price_ctx: &clipped_price_ctx,
1293            volume_ctx: &clipped_volume_ctx,
1294            price_scale: &price_map,
1295            colors: &colors,
1296            visible_data,
1297            // Full dataset so window-independent transforms (Heikin-Ashi) can be
1298            // computed over the complete series, not just the visible slice.
1299            full_data: &self.state.data().bars,
1300            shows_elements: self.state.shows_elements(),
1301            revision: self.state.revision(),
1302            heikin_ashi: &self.heikin_ashi,
1303            start_idx,
1304            clock: &bar_clock,
1305            tick_size: self
1306                .instrument()
1307                .and_then(|instrument| instrument.tick_size),
1308        };
1309
1310        let render_params = rendering::ChartTypeParams::new(
1311            rendering::BarDimensions::new(bar_width, self.config.wick_width),
1312            rendering::VolumeSettings::new(
1313                self.config.show_volume && !cfg!(feature = "studies"),
1314                max_volume,
1315            ),
1316            &self.chart_type_params,
1317            rendering::TradingColors::new(self.config.bullish_color, self.config.bearish_color),
1318            rendering::CoordMapping::new(chart_rect.min.x),
1319            self.config.price_source,
1320        );
1321        // Draw one merged bar, or a column's line points, for each group.
1322        let merged = footprint
1323            .map(|_| false)
1324            .or_else(|| super::merge::line_style(self.chart_type))
1325            .filter(|_| factor > 1)
1326            .map(|line| {
1327                let merge = |line| {
1328                    super::merge::Merged::new(
1329                        self.state.shown(),
1330                        &self.merge_groups,
1331                        start_idx..start_idx + visible_data.len(),
1332                        line,
1333                        self.config.price_source,
1334                    )
1335                };
1336                // Volume columns merge as bars beneath a merged line.
1337                let volume = (line && render_params.show_volume()).then(|| merge(false));
1338                (merge(line), volume)
1339            });
1340        let merged_x = |merged: &super::merge::Merged, index: usize, min_x: f32| {
1341            // Past the last merged bar, as a renderer measuring its pitch asks for.
1342            let past = |last: f32| last + (index + 1 - merged.index.len()) as f32 * factor as f32;
1343            let index = merged
1344                .index
1345                .get(index)
1346                .copied()
1347                .unwrap_or_else(|| merged.index.last().map_or(0.0, |&last| past(last)));
1348            self.state
1349                .time_scale()
1350                .idx_to_coord_precise(index, min_x, chart_rect_width)
1351        };
1352        let merged_params = rendering::ChartTypeParams::new(
1353            rendering::BarDimensions::new(bar_width * factor as f32, self.config.wick_width),
1354            rendering::VolumeSettings::new(
1355                render_params.show_volume() && merged.as_ref().is_some_and(|m| m.1.is_none()),
1356                max_volume,
1357            ),
1358            &self.chart_type_params,
1359            rendering::TradingColors::new(self.config.bullish_color, self.config.bearish_color),
1360            rendering::CoordMapping::new(chart_rect.min.x),
1361            self.config.price_source,
1362        );
1363
1364        let host = footprint.filter(|_| time_based);
1365        let feed = self
1366            .feed_link
1367            .as_ref()
1368            .and_then(|link| link.footprint.as_ref())
1369            .filter(|_| {
1370                time_based
1371                    && self.replay.is_none()
1372                    && (self.chart_type == ChartType::VolumeFootprint
1373                        || (self.chart_type == ChartType::SessionVolumeProfile
1374                            && cfg!(feature = "studies")))
1375            });
1376        let feed_config = feed.map(|_| FootprintConfig {
1377            text_color: colors.text,
1378            neutral_color: colors.text,
1379            ask_color: colors.bullish,
1380            bid_color: colors.bearish,
1381            ..Default::default()
1382        });
1383        let session_profiles = host.is_none() && self.chart_type == ChartType::SessionVolumeProfile;
1384        // Bars from the first execution on are drawn from executions, those before as
1385        // candles, and all of them as candles while their cells would not be readable.
1386        let executions = host
1387            .or_else(|| feed.zip(feed_config.as_ref()))
1388            .filter(|(_, config)| {
1389                session_profiles
1390                    || crate::chart::overlays::footprint_column_width(bar_spacing, config).is_some()
1391            })
1392            .map(|(flow, config)| {
1393                let first = flow.complete_from();
1394                let candles = first.map_or(visible_data.len(), |first| {
1395                    visible_data.partition_point(|bar| bar.time < first)
1396                });
1397                (flow, config, candles)
1398            });
1399
1400        let style = &self.main_series_style;
1401        // Last price line & label, from the newest bar even when it is scrolled out of view, or
1402        // from the newest bar in view
1403        let last = match style.price_line_source {
1404            crate::model::PriceLineSource::LastBar => {
1405                crate::model::last_with_value(&self.state.data().bars)
1406            }
1407            crate::model::PriceLineSource::LastVisible => {
1408                crate::model::last_with_value(visible_data)
1409            }
1410        };
1411        let last_price_line = last.filter(|_| self.main_series_visible).map(|last| {
1412            let color = style
1413                .price_line_color
1414                .or_else(|| last.colors.as_ref().and_then(|own| own.color))
1415                .unwrap_or(if last.close >= last.open {
1416                    self.config.bullish_color
1417                } else {
1418                    self.config.bearish_color
1419                });
1420            PriceLine::new(last.close, color)
1421                .with_width(if self.config.show_price_line && style.price_line_visible {
1422                    style.price_line_width
1423                } else {
1424                    0.0
1425                })
1426                .with_style(style.price_line_style)
1427                .with_axis_label(
1428                    (self.config.show_right_axis || self.config.show_left_axis)
1429                        && self.config.show_symbol_last_val
1430                        && style.last_value_visible,
1431                )
1432        });
1433        // Footprint fills and glyphs must cover the current-price stroke.
1434        let price_line_below = !session_profiles
1435            && executions.is_some_and(|(flow, _, candles)| {
1436                visible_data[candles..]
1437                    .iter()
1438                    .any(|bar| flow.bar_at(bar.time).is_some())
1439            });
1440
1441        self.paint_primitives(&painter, &coords, PrimitivePass::Below);
1442        let studies = if self.chart_type.is_time_independent() {
1443            &self.element_studies.studies
1444        } else {
1445            &self.studies
1446        };
1447        let volume_map = volume_rect.is_positive().then(|| {
1448            crate::scales::PriceMap::new(
1449                crate::scales::PriceTransform::default(),
1450                crate::scales::PriceRange::new(
1451                    0.0,
1452                    studies
1453                        .indicators()
1454                        .iter()
1455                        .find(|study| {
1456                            study.is_volume()
1457                                && study.is_overlay()
1458                                && study.is_visible()
1459                                && !matches!(study.scale(), crate::studies::StudyScale::Shared(_))
1460                        })
1461                        .map_or(max_volume.max(1.0), |study| {
1462                            super::volume::range(
1463                                study.as_ref(),
1464                                &(start_idx..end_idx),
1465                                Some(&self.merge_groups),
1466                            )
1467                            .1
1468                        }),
1469                ),
1470                volume_rect.top(),
1471                volume_rect.bottom(),
1472                false,
1473            )
1474            .with_price_precision(Some(
1475                self.instrument()
1476                    .and_then(|instrument| instrument.volume_decimals)
1477                    .unwrap_or(0),
1478            ))
1479        });
1480        self.host_events.volume_map = volume_map;
1481        // The main pane's visual order: overlays listed before the series are drawn under it.
1482        let series_at = self
1483            .pane_layout
1484            .main
1485            .iter()
1486            .position(|id| *id == super::SeriesId::MAIN)
1487            .unwrap_or(0);
1488        let (behind, front) = self.pane_layout.main.split_at(series_at);
1489        let main_visible_range = start_idx..(start_idx + visible_data.len());
1490        let volume_coords = show_volume.then(|| coords.with_rect(volume_rect));
1491        // Primitives are hit tested at the pointer, before the chart state is borrowed mutably.
1492        let pointer = ui
1493            .input(|input| input.pointer.interact_pos())
1494            .filter(|pos| chart_rect.contains(*pos));
1495        let primitive_hit = pointer.and_then(|pos| {
1496            primitives::primitive_at(
1497                self.main_primitives(&coords)
1498                    .filter(|(item, _)| !item.replaces),
1499                pos,
1500            )
1501        });
1502        let renderer_hit = pointer.and_then(|pos| {
1503            self.pane_layout.main.iter().rev().find_map(|id| {
1504                primitives::primitive_at(
1505                    self.main_primitives(&coords)
1506                        .filter(|(item, _)| item.replaces && item.series == *id),
1507                    pos,
1508                )
1509            })
1510        });
1511        let rendered: Vec<_> = self
1512            .study_ids
1513            .iter()
1514            .copied()
1515            .filter(|id| self.has_renderer(*id))
1516            .collect();
1517        let hit_order: Vec<_> = self
1518            .pane_layout
1519            .main
1520            .iter()
1521            .copied()
1522            .filter(|id| {
1523                !rendered.contains(id) && (*id != super::SeriesId::MAIN || self.main_series_visible)
1524            })
1525            .collect();
1526        let last_bar = self.state.shown().len().saturating_sub(1);
1527        let secondary_hits = [pointer, response.interact_pointer_pos()].map(|pos| {
1528            pos.map(|pos| {
1529                (
1530                    pos,
1531                    hit_order.iter().rev().find_map(|id| {
1532                        let item = self.main_pane_series().find(|item| item.id == *id)?;
1533                        self.hit_price_series(
1534                            item,
1535                            pos,
1536                            price_rect,
1537                            self.series_map(item, price_rect),
1538                        )
1539                        .map(|bar| (super::ChartElementId::Series(item.id), bar))
1540                    }),
1541                )
1542            })
1543        });
1544        let element_at = |pos: Pos2| {
1545            let renderer = renderer_hit
1546                .filter(|_| Some(pos) == pointer)
1547                .and_then(|hit| {
1548                    if let super::PointerTarget::Element(id) = hit.target {
1549                        Some((id, coords.x_to_idx(pos.x).min(last_bar)))
1550                    } else {
1551                        None
1552                    }
1553                });
1554            let hit = hit_test_main_chart(
1555                pos,
1556                studies,
1557                &self.study_ids,
1558                &hit_order,
1559                &coords,
1560                volume_coords.as_ref(),
1561                self.state.shown(),
1562                max_volume,
1563                main_visible_range.clone(),
1564                self.main_series_style.hit_tolerance,
1565                Some(&self.merge_groups),
1566            )
1567            .filter(|(id, _)| match id {
1568                // A renderer hit tests its custom series in place of the study's plots.
1569                crate::widget::ChartElementId::Indicator(study) => !rendered.contains(study),
1570                _ => self.main_series_visible,
1571            });
1572            let secondary = secondary_hits
1573                .iter()
1574                .flatten()
1575                .find_map(|(at, hit)| (*at == pos).then_some(*hit).flatten());
1576            hit.into_iter()
1577                .chain(secondary)
1578                .chain(renderer)
1579                .max_by_key(|(element, _)| {
1580                    let id = match element {
1581                        super::ChartElementId::Indicator(id)
1582                        | super::ChartElementId::Series(id) => id,
1583                    };
1584                    self.pane_layout.main.iter().position(|member| member == id)
1585                })
1586        };
1587        // The element under the pointer goes last when hovered series are drawn on top.
1588        let on_top = ui
1589            .input(|input| input.pointer.hover_pos())
1590            .filter(|pos| self.chart_options.hovered_series_on_top && chart_rect.contains(*pos))
1591            .and_then(element_at)
1592            .map(|(id, _)| id);
1593        let main_on_top = on_top == Some(super::ChartElementId::Series(super::SeriesId::MAIN));
1594        let paint_overlay = |id: &super::SeriesId| {
1595            if let Some(item) = self.main_pane_series().find(|item| item.id == *id) {
1596                self.paint_series(
1597                    &clipped_painter,
1598                    price_rect,
1599                    item,
1600                    self.series_map(item, price_rect),
1601                );
1602                return;
1603            }
1604            let Some(study) = self
1605                .study_ids
1606                .iter()
1607                .position(|study| study == id)
1608                .map(|idx| &studies.indicators()[idx])
1609                .filter(|study| study.is_visible() && study.is_overlay())
1610            else {
1611                return;
1612            };
1613            if study.is_volume()
1614                && !matches!(study.scale(), crate::studies::StudyScale::Shared(_))
1615                && self.main_series_visible
1616            {
1617                let rect = super::volume::overlay_rect(price_rect, &self.config);
1618                let (min, max) = super::volume::range(
1619                    study.as_ref(),
1620                    &(start_idx..end_idx),
1621                    Some(&self.merge_groups),
1622                );
1623                super::volume::paint(
1624                    &painter,
1625                    rect,
1626                    study.as_ref(),
1627                    &(start_idx..end_idx),
1628                    &coords,
1629                    min,
1630                    max,
1631                    Some(&self.merge_groups),
1632                );
1633            }
1634            // TPO studies draw the chart type's profiles beside the bars.
1635            if let Some(config) = study
1636                .tpo()
1637                .filter(|_| !self.chart_type.is_time_independent())
1638            {
1639                rendering::candles::tpo::render_live_tpo(
1640                    &clipped_painter,
1641                    price_rect,
1642                    &self.state.data().bars,
1643                    start_idx..start_idx + visible_data.len(),
1644                    self.state.revision(),
1645                    &price_map,
1646                    config,
1647                    render_ctx.tick_size,
1648                    &bar_clock,
1649                    idx_to_coord,
1650                    chart_rect.min.x,
1651                    self.config.bullish_color,
1652                    self.config.bearish_color,
1653                );
1654            }
1655            if self.has_renderer(*id) {
1656                let view = self.primitive_view(
1657                    *id,
1658                    crate::chart::rendering::study_mapping(study.as_ref(), &coords),
1659                );
1660                for item in self.primitives_of(*id).filter(|item| item.replaces) {
1661                    item.draw(&clipped_painter, &view, PrimitivePass::Series, factor);
1662                }
1663            } else {
1664                rendering::IndicatorRenderer::render(
1665                    &price_ctx,
1666                    std::slice::from_ref(study),
1667                    visible_data,
1668                    &price_map,
1669                    &coords,
1670                );
1671            }
1672        };
1673        let paint_main_series = || {
1674            if self.main_series_visible {
1675                if time_based {
1676                    self.paint_liquidity(&clipped_painter, &self.get_chart_mapping());
1677                }
1678                // Share the renderer instantiation between ordinary and merged bars.
1679                let (mut data, params) = match &merged {
1680                    Some((merged, _)) => (
1681                        rendering::CandleDataContext {
1682                            visible_data: &merged.bars[merged.start..],
1683                            full_data: &merged.bars,
1684                            start_idx: merged.start,
1685                            ..render_ctx
1686                        },
1687                        &merged_params,
1688                    ),
1689                    None => (
1690                        rendering::CandleDataContext { ..render_ctx },
1691                        &render_params,
1692                    ),
1693                };
1694                if let Some((_, _, candles)) = executions {
1695                    data.visible_data = match &merged {
1696                        // Merged candles end before the group of the first execution.
1697                        Some((merged, _)) if candles < visible_data.len() => {
1698                            let end = merged.count_before(start_idx + candles, factor);
1699                            &merged.bars[merged.start..end.max(merged.start)]
1700                        }
1701                        Some(_) => data.visible_data,
1702                        None => &visible_data[..candles],
1703                    };
1704                }
1705                // A host's footprint replaces any chart type, so what it leaves is always candles.
1706                let series_type = if host.is_some() {
1707                    ChartType::Candles
1708                } else {
1709                    self.chart_type
1710                };
1711                let paint_series =
1712                    |data: &rendering::CandleDataContext,
1713                     params: &rendering::ChartTypeParams,
1714                     merged: Option<&super::merge::Merged>| {
1715                        rendering::render_chart_type(series_type, data, params, |index, min_x| {
1716                            match merged {
1717                                Some(merged) => merged_x(merged, index, min_x),
1718                                None => idx_to_coord(index, min_x),
1719                            }
1720                        });
1721                    };
1722                paint_series(&data, params, merged.as_ref().map(|(merged, _)| merged));
1723                if let Some((flow, config, candles)) = executions {
1724                    let has_cells = |bar: &crate::model::Bar| {
1725                        session_profiles || flow.bar_at(bar.time).is_some()
1726                    };
1727                    let mut first = start_idx + candles;
1728                    // A bar nothing traded in shows its candle among the cells.
1729                    for run in visible_data[candles..]
1730                        .chunk_by(|bar, next| has_cells(bar) == has_cells(next))
1731                    {
1732                        if !has_cells(&run[0]) {
1733                            let data = rendering::CandleDataContext {
1734                                visible_data: run,
1735                                start_idx: first,
1736                                ..render_ctx
1737                            };
1738                            paint_series(&data, &render_params, None);
1739                        } else if params.show_volume() {
1740                            rendering::candles::volume::render_volume_bars(
1741                                &clipped_volume_ctx,
1742                                run,
1743                                first,
1744                                max_volume,
1745                                &colors,
1746                                chart_rect.min.x,
1747                                idx_to_coord,
1748                            );
1749                        }
1750                        first += run.len();
1751                    }
1752                    let mapping = self.get_chart_mapping();
1753                    let bars = self.bars(super::SeriesId::MAIN);
1754                    if !session_profiles {
1755                        if price_line_below && let Some(line) = &last_price_line {
1756                            rendering::render_price_line(
1757                                &painter,
1758                                price_rect,
1759                                &price_map,
1760                                line,
1761                                price_rect.left(),
1762                                false,
1763                                price_rect.right(),
1764                            );
1765                        }
1766                        crate::chart::overlays::render_footprint(
1767                            &clipped_painter,
1768                            &mapping,
1769                            bars,
1770                            flow,
1771                            config,
1772                        );
1773                        #[cfg(feature = "studies")]
1774                        crate::chart::overlays::render_footprint_annotations(
1775                            &clipped_painter,
1776                            &mapping,
1777                            bars,
1778                            flow,
1779                            config,
1780                            response.id,
1781                        );
1782                    }
1783                    #[cfg(feature = "studies")]
1784                    if session_profiles && let Some((step, tick, profiles)) = &execution_profiles {
1785                        crate::chart::overlays::render_session_volume_profiles(
1786                            &clipped_painter,
1787                            &mapping,
1788                            bars,
1789                            profiles,
1790                            *step,
1791                            *tick,
1792                            self.trade_profile_settings(),
1793                        );
1794                    }
1795                }
1796                if let Some(table) = statistics_rect
1797                    && let Some((flow, config)) = host
1798                {
1799                    crate::chart::overlays::render_footprint_statistics(
1800                        &painter,
1801                        &self.get_chart_mapping().with_rect(table),
1802                        self.bars(super::SeriesId::MAIN),
1803                        flow,
1804                        config.text_color,
1805                    );
1806                }
1807                if let Some((_, Some(volume))) = &merged {
1808                    rendering::candles::volume::render_volume_bars(
1809                        &clipped_volume_ctx,
1810                        &volume.bars[volume.start..],
1811                        volume.start,
1812                        max_volume,
1813                        &colors,
1814                        chart_rect.min.x,
1815                        |index, min_x| merged_x(volume, index, min_x),
1816                    );
1817                }
1818                self.paint_last_price_pulse(ui, &clipped_painter, price_rect, &coords);
1819            }
1820        };
1821        let paint_unhovered = |id: &super::SeriesId| {
1822            let element = if self.price_series.iter().any(|item| item.id == *id) {
1823                super::ChartElementId::Series(*id)
1824            } else {
1825                super::ChartElementId::Indicator(*id)
1826            };
1827            if on_top != Some(element) {
1828                paint_overlay(id);
1829            }
1830        };
1831        behind.iter().for_each(paint_unhovered);
1832        if !main_on_top {
1833            paint_main_series();
1834        }
1835
1836        self.paint_marks(&clipped_price_ctx, &coords, MarkerZOrder::Normal);
1837
1838        if let Some(bubbles) = self
1839            .feed_link
1840            .as_ref()
1841            .and_then(|link| link.bubbles.as_ref())
1842            .filter(|_| time_based && self.main_series_visible && self.replay.is_none())
1843        {
1844            let x = |i| idx_to_coord(start_idx + i, chart_rect.min.x);
1845            bubbles.render(&clipped_painter, visible_data, coords.bar_spacing, x, |p| {
1846                price_map.price_to_y(p)
1847            });
1848        }
1849
1850        front.iter().for_each(paint_unhovered);
1851        match on_top {
1852            Some(super::ChartElementId::Indicator(id)) => paint_overlay(&id),
1853            _ if main_on_top => paint_main_series(),
1854            Some(super::ChartElementId::Series(id)) => {
1855                if let Some(item) = self.main_pane_series().find(|item| item.id == id) {
1856                    self.paint_series(
1857                        &clipped_painter,
1858                        price_rect,
1859                        item,
1860                        self.series_map(item, price_rect),
1861                    );
1862                }
1863            }
1864            _ => {}
1865        }
1866        for study in studies
1867            .indicators()
1868            .iter()
1869            .filter(|study| study.is_visible() && study.labels_on_price())
1870        {
1871            crate::chart::indicators::plots::paint_labels(
1872                &clipped_painter,
1873                study.as_ref(),
1874                &price_map,
1875                &coords,
1876            );
1877        }
1878        self.paint_signals(ui, &coords);
1879        let target_lines = self
1880            .show_price_targets
1881            .then(|| painter.add(egui::Shape::Noop));
1882
1883        self.paint_primitives(&painter, &coords, PrimitivePass::Series);
1884
1885        // Resolve a click on the main chart area into a selection. Overlay
1886        // indicators take priority over the series beneath them; an empty-area
1887        // click clears the selection. Clicks in a study's pane are resolved
1888        // where the panes are rendered, outside this rect.
1889        if response.clicked()
1890            && let Some(click_pos) = response.interact_pointer_pos()
1891            && self.mark_at(click_pos).is_none()
1892        {
1893            match element_at(click_pos) {
1894                Some((id, bar_idx)) => self.selection.select(id, Some(bar_idx)),
1895                None => self.selection.deselect(),
1896            }
1897        }
1898
1899        // Resolve a right-click into a context-menu target. Mirrors the
1900        // left-click priority (drawing > overlay indicator > series), and
1901        // right-clicking an object also selects it so the menu and the
1902        // selection handles agree. The captured target is
1903        // drained by the host via `take_right_click`, or consumed by
1904        // `ChartMenus`.
1905        if response.secondary_clicked()
1906            && let Some(click_pos) = response.interact_pointer_pos()
1907            && chart_rect.contains(click_pos)
1908        {
1909            let price = coords.y_to_price(click_pos.y);
1910
1911            // Drawings sit on top of the series, so they take priority.
1912            #[cfg(feature = "drawings")]
1913            let drawing_hit = self.drawings.hit_test(click_pos);
1914            #[cfg(not(feature = "drawings"))]
1915            let drawing_hit: Option<usize> = None;
1916
1917            self.right_click = Some(if let Some(drawing_id) = drawing_hit {
1918                // Right-clicking a drawing selects it and
1919                // clears any series/indicator selection so handles don't show
1920                // on two objects at once.
1921                self.selection.deselect();
1922                #[cfg(feature = "drawings")]
1923                {
1924                    if self.drawings.is_selected(drawing_id) {
1925                        let ids: Vec<_> = std::iter::once(drawing_id)
1926                            .chain(self.drawings.selected_ids())
1927                            .collect();
1928                        self.drawings.select_drawings(ids);
1929                    } else {
1930                        self.drawings.select(drawing_id);
1931                    }
1932                }
1933                RightClickTarget::Drawing {
1934                    drawing_id,
1935                    pos: click_pos,
1936                }
1937            } else {
1938                match element_at(click_pos) {
1939                    Some((id, bar_idx)) => {
1940                        self.selection.select(id, Some(bar_idx));
1941                        RightClickTarget::Element {
1942                            id,
1943                            bar_idx,
1944                            pos: click_pos,
1945                            price,
1946                        }
1947                    }
1948                    None => {
1949                        self.selection.deselect();
1950                        RightClickTarget::Background {
1951                            pos: click_pos,
1952                            price,
1953                        }
1954                    }
1955                }
1956            });
1957        }
1958
1959        // What the pointer is over, for host pointer events, in the right-click priority.
1960        if let Some(pos) = pointer {
1961            #[cfg(feature = "drawings")]
1962            let drawing = self.drawings.hit_test(pos);
1963            #[cfg(not(feature = "drawings"))]
1964            let drawing: Option<usize> = None;
1965            if let Some(hit) = primitive_hit
1966                .or(renderer_hit.filter(|hit| {
1967                    element_at(pos).map(|(id, _)| super::PointerTarget::Element(id))
1968                        == Some(hit.target)
1969                }))
1970                .filter(|_| {
1971                    drawing.is_none()
1972                        && response.hovered()
1973                        && !response.dragged()
1974                        && !self.zoom_mode_active
1975                        && !eraser_mode
1976                        && !drawing_tool_active
1977                })
1978            {
1979                ui.ctx().set_cursor_icon(hit.cursor);
1980            }
1981            self.host_events.target = drawing
1982                .map(super::PointerTarget::Drawing)
1983                .or(primitive_hit.map(|hit| hit.target))
1984                .or_else(|| element_at(pos).map(|(id, _)| super::PointerTarget::Element(id)));
1985        }
1986
1987        // Draw selection handles for whichever element is currently selected on
1988        // the main chart (overlay indicator line or series data points).
1989        self.render_main_chart_selection(&painter, &coords, visible_data, main_visible_range);
1990
1991        self.paint_marks(&clipped_price_ctx, &coords, MarkerZOrder::AboveSeries);
1992
1993        if self.config.show_right_axis || self.config.show_left_axis {
1994            let axis_painter =
1995                painter.with_clip_rect(painter.clip_rect().intersect(price_axis_rect));
1996            for (item, view) in self.main_primitives(&coords) {
1997                item.get()
1998                    .draw_price_axis(&axis_painter, price_axis_rect, &view);
1999            }
2000        }
2001
2002        // The axis labels go at these depths, painted once the crosshair's tags are placed
2003        let price_labels = painter.add(egui::Shape::Noop);
2004
2005        let axis_edge = if self.config.show_right_axis {
2006            rect.max.x
2007        } else {
2008            price_rect.left()
2009        };
2010        let show_price_axis = self.config.show_right_axis || self.config.show_left_axis;
2011        let study_axis = crate::config::PriceAxisOptions {
2012            show_study_values: self.config.show_indicator_last_val
2013                && self.config.price_axis.show_study_values,
2014            ..self.config.price_axis
2015        };
2016        let currency = self
2017            .instrument()
2018            .and_then(|instrument| instrument.currency.as_deref())
2019            .filter(|code| self.config.show_currency && !code.is_empty());
2020        let unit = self
2021            .instrument()
2022            .and_then(|instrument| instrument.unit.as_deref())
2023            .filter(|unit| self.config.show_unit && !unit.is_empty());
2024        let has_currency = currency.is_some() || unit.is_some();
2025        let mut tags = Vec::new();
2026        if has_currency {
2027            tags.push(Some(Rect::from_min_size(
2028                price_rect.min,
2029                egui::vec2(price_rect.width(), 24.0),
2030            )));
2031        }
2032        let mut pending_values = Vec::new();
2033        for (_, line) in self.price_lines.iter().filter(|_| self.main_series_visible) {
2034            rendering::render_price_line(
2035                &painter,
2036                price_rect,
2037                &price_map,
2038                line,
2039                price_rect.left(),
2040                false,
2041                axis_edge,
2042            );
2043            if line.axis_label {
2044                pending_values.push((line.price, rendering::axes::axis_label_colors(line), false));
2045            }
2046        }
2047        #[cfg(feature = "ui")]
2048        let trading_outlined = std::mem::take(&mut self.trading_outlined);
2049        #[cfg(feature = "ui")]
2050        pending_values.extend(
2051            std::mem::take(&mut self.trading_tags)
2052                .into_iter()
2053                .enumerate()
2054                .map(|(index, (value, fill))| {
2055                    let outlined = trading_outlined.contains(&index);
2056                    let colors = if outlined {
2057                        (fill, ui.active_theme().chart.bg)
2058                    } else {
2059                        (rendering::axes::tag_text_color(fill), fill)
2060                    };
2061                    (value, colors, outlined)
2062                }),
2063        );
2064
2065        // The previous session's close and the last close, for the previous close line and the
2066        // legend's day change.
2067        let session_closes = (self.config.show_symbol_prev_close
2068            || self.config.show_prev_close_line
2069            || self.config.show_last_day_change)
2070            .then(|| {
2071                let bars = &self.state.data().bars;
2072                let last = crate::model::last_with_value(bars)?;
2073                let index = bars
2074                    .partition_point(|bar| {
2075                        exchange_session.trading_date(bar.time)
2076                            < exchange_session.trading_date(last.time)
2077                    })
2078                    .checked_sub(1)?;
2079                crate::model::last_with_value(&bars[..=index])
2080                    .map(|previous| (previous.close, last.close))
2081            })
2082            .flatten();
2083        if self.main_series_visible
2084            && (self.config.show_symbol_prev_close || self.config.show_prev_close_line)
2085            && let Some((previous_close, _)) = session_closes
2086        {
2087            let line = PriceLine::new(previous_close, ui.active_theme().ui.text_secondary)
2088                .with_style(crate::model::LineStyle::Dotted)
2089                .with_axis_label(self.config.show_symbol_prev_close)
2090                .with_width(if self.config.show_prev_close_line {
2091                    1.0
2092                } else {
2093                    0.0
2094                });
2095            rendering::render_price_line(
2096                &painter,
2097                price_rect,
2098                &price_map,
2099                &line,
2100                price_rect.left(),
2101                false,
2102                axis_edge,
2103            );
2104            if self.config.show_symbol_prev_close {
2105                pending_values.push((
2106                    previous_close,
2107                    rendering::axes::axis_label_colors(&line),
2108                    false,
2109                ));
2110            }
2111        }
2112
2113        let style = &self.main_series_style;
2114        if let Some(line) = super::series::base_line(&price_map)
2115            .filter(|_| self.main_series_visible && style.base_line_visible)
2116        {
2117            rendering::render_price_line(
2118                &painter,
2119                price_rect,
2120                &price_map,
2121                &line,
2122                price_rect.left(),
2123                false,
2124                axis_edge,
2125            );
2126        }
2127
2128        let last_price_tag = if let Some((last, line)) = last.zip(last_price_line) {
2129            let line = if price_line_below {
2130                line.with_width(0.0)
2131            } else {
2132                line
2133            };
2134            // Only a countdown drawn on the axis needs the once-a-second repaint.
2135            let countdown = self.current_bar_countdown().filter(|_| {
2136                line.axis_label
2137                    && price_map
2138                        .range()
2139                        .contains(price_map.transform().to_scale(last.close))
2140            });
2141            if countdown.is_some()
2142                && (self.server_time_offset.is_some() || self.feed_link.is_some())
2143            {
2144                ui.ctx()
2145                    .request_repaint_after(std::time::Duration::from_secs(1));
2146            }
2147            let quote_tags = rendering::axes::render_quote_tags_with_countdown(
2148                &painter,
2149                price_rect,
2150                &price_map,
2151                &line,
2152                self.session_quote
2153                    .as_ref()
2154                    .filter(|_| show_price_axis && self.config.show_extended_session_price)
2155                    .map(|quote| (quote, self.config.extended_session_price_color)),
2156                (show_volume && show_price_axis && !cfg!(feature = "studies")).then_some((
2157                    last.volume,
2158                    // The tag takes the colour of the bar's direction in the applied theme, as
2159                    // its volume bar does.
2160                    if last.is_bullish() {
2161                        ui.active_theme().chart.candle_up
2162                    } else {
2163                        ui.active_theme().chart.candle_down
2164                    },
2165                )),
2166                axis_edge,
2167                countdown.as_deref(),
2168                self.instrument()
2169                    .and_then(|instrument| instrument.volume_decimals),
2170                if has_currency { 24.0 } else { 0.0 },
2171                price_formatter.as_deref(),
2172                self.volume_formatter.as_deref(),
2173                self.config.price_axis.allow_label_overlap,
2174                if self.config.price_axis.show_symbol_name {
2175                    &self.symbol
2176                } else {
2177                    ""
2178                },
2179            );
2180            tags.extend([quote_tags[1], quote_tags[2]]);
2181            quote_tags[0]
2182        } else {
2183            None
2184        };
2185
2186        tags.push(last_price_tag);
2187        if show_price_axis {
2188            // Labels that sit closer than a tag is high stack in the order of their prices.
2189            let mut shown: Vec<_> = pending_values
2190                .iter()
2191                .filter(|(value, ..)| {
2192                    price_map
2193                        .range()
2194                        .contains(price_map.transform().to_scale(*value))
2195                })
2196                .map(|(value, colors, outlined)| {
2197                    (price_map.price_to_y(*value), *value, *colors, *outlined)
2198                })
2199                .collect();
2200            shown.sort_by(|a, b| a.0.total_cmp(&b.0));
2201            let mut ys: Vec<_> = shown.iter().map(|shown| shown.0).collect();
2202            if !self.config.price_axis.allow_label_overlap {
2203                ys = rendering::axes::spread_axis_ys(&painter, &ys);
2204            }
2205            for ((_, value, colors, outlined), y) in shown.into_iter().zip(ys) {
2206                let label = price_map.label(
2207                    price_map.transform().to_scale(value),
2208                    rendering::axes::tag_decimals(&price_map, price_map.price_to_y(value)),
2209                    price_formatter.as_deref(),
2210                );
2211                let tag = rendering::axes::render_axis_value(
2212                    &painter,
2213                    price_rect,
2214                    axis_edge,
2215                    y,
2216                    label,
2217                    colors,
2218                    &tags,
2219                    self.config.price_axis.allow_label_overlap,
2220                );
2221                if outlined && let Some(rect) = tag {
2222                    painter.rect_stroke(
2223                        rect,
2224                        2.0,
2225                        egui::Stroke::new(1.0, colors.0),
2226                        egui::StrokeKind::Inside,
2227                    );
2228                }
2229                tags.push(tag);
2230            }
2231        }
2232        if show_price_axis {
2233            for (value, colors, map, label) in
2234                self.secondary_axis_values(self.main_series_scale, price_rect)
2235            {
2236                tags.push(rendering::axes::render_axis_value(
2237                    &painter,
2238                    price_rect,
2239                    axis_edge,
2240                    map.price_to_y(value),
2241                    label,
2242                    colors,
2243                    &tags,
2244                    self.config.price_axis.allow_label_overlap,
2245                ));
2246            }
2247        }
2248        if show_price_axis {
2249            let labels: Vec<_> = self
2250                .main_primitives(&coords)
2251                .flat_map(|(item, view)| {
2252                    let map = view.mapping.price;
2253                    let labels = item.get().price_axis_labels(&view);
2254                    labels.into_iter().map(move |label| (label, map))
2255                })
2256                .collect();
2257            for (label, map) in labels {
2258                if map.range().contains(map.transform().to_scale(label.price)) {
2259                    tags.push(rendering::axes::render_axis_value(
2260                        &painter,
2261                        price_rect,
2262                        axis_edge,
2263                        map.price_to_y(label.price),
2264                        label.text,
2265                        (rendering::axes::tag_text_color(label.color), label.color),
2266                        &tags,
2267                        self.config.price_axis.allow_label_overlap,
2268                    ));
2269                }
2270            }
2271        }
2272        let mut independent_axis_width =
2273            self.paint_series_axes(ui, &painter, rect, price_rect, &colors);
2274        if show_price_axis {
2275            let axes = studies.indicators().iter().filter(|study| {
2276                study.is_visible()
2277                    && study.is_overlay()
2278                    && !study.is_volume()
2279                    && study.scale() == crate::studies::StudyScale::Independent
2280            });
2281            let mut edge = price_rect.left()
2282                - if self.config.show_left_axis {
2283                    self.config.left_axis_width.max(self.measured_axis_width)
2284                } else {
2285                    0.0
2286                };
2287            let padding = self
2288                .config
2289                .padding
2290                .max(0.0)
2291                .min(rect.width().min(rect.height()).max(0.0) / 2.0);
2292            let series_left = self
2293                .series_axis_keys()
2294                .iter()
2295                .filter(|key| **key != crate::model::SeriesScale::Right)
2296                .count();
2297            let slot_width = (edge - rect.left() - padding).max(0.0)
2298                / (axes.clone().count() + series_left).max(1) as f32;
2299            edge -= series_left as f32 * slot_width;
2300            for study in axes {
2301                let mapping = crate::chart::rendering::study_mapping(study.as_ref(), &coords);
2302                let axis = Rect::from_min_max(
2303                    Pos2::new(edge - slot_width, price_rect.top()),
2304                    Pos2::new(edge, price_rect.bottom()),
2305                );
2306                edge -= slot_width;
2307                independent_axis_width =
2308                    independent_axis_width.max(crate::chart::rendering::render_study_axis(
2309                        &painter,
2310                        &[study.as_ref()],
2311                        &mapping,
2312                        axis,
2313                        &study_axis,
2314                        None,
2315                    ));
2316            }
2317        }
2318        if show_price_axis && study_axis.show_study_values {
2319            for study in studies
2320                .indicators()
2321                .iter()
2322                .filter(|study| study.is_overlay())
2323            {
2324                if study.is_volume()
2325                    && !matches!(study.scale(), crate::studies::StudyScale::Shared(_))
2326                {
2327                    if self.main_series_visible {
2328                        let rect = super::volume::overlay_rect(price_rect, &self.config);
2329                        let mapping = super::volume::mapping(
2330                            &coords,
2331                            rect,
2332                            study.as_ref(),
2333                            &(start_idx..end_idx),
2334                            Some(&self.merge_groups),
2335                        );
2336                        super::volume::tags(
2337                            &painter,
2338                            price_rect,
2339                            &mapping.price,
2340                            study.as_ref(),
2341                            axis_edge,
2342                            &mut tags,
2343                            &study_axis,
2344                        );
2345                    }
2346                    continue;
2347                }
2348                if study.scale() != crate::studies::StudyScale::Main
2349                    && study.scale() != crate::studies::StudyScale::Shared(self.main_series_scale)
2350                {
2351                    continue;
2352                }
2353                rendering::axes::render_study_tags(
2354                    &painter,
2355                    price_rect,
2356                    &price_map,
2357                    study.as_ref(),
2358                    axis_edge,
2359                    &mut tags,
2360                    price_formatter.as_deref(),
2361                    &study_axis,
2362                );
2363            }
2364        }
2365
2366        if show_price_axis && self.main_series_visible {
2367            let mut values = Vec::new();
2368            if self.config.show_high_low {
2369                let high = visible_data
2370                    .iter()
2371                    .map(|bar| bar.high)
2372                    .fold(f64::NEG_INFINITY, f64::max);
2373                let low = visible_data
2374                    .iter()
2375                    .map(|bar| bar.low)
2376                    .fold(f64::INFINITY, f64::min);
2377                values.extend([
2378                    (high, "High", self.config.bullish_color),
2379                    (low, "Low", self.config.bearish_color),
2380                ]);
2381            }
2382            let trading_bid_ask = {
2383                #[cfg(feature = "ui")]
2384                {
2385                    std::mem::take(&mut self.trading_bid_ask)
2386                }
2387                #[cfg(not(feature = "ui"))]
2388                {
2389                    false
2390                }
2391            };
2392            if (self.config.show_bid_ask || trading_bid_ask)
2393                && let Some(quote) = self.quote.as_ref().filter(|quote| {
2394                    quote.bid.is_finite()
2395                        && quote.ask.is_finite()
2396                        && quote.bid <= quote.ask
2397                        && quote.spread().is_finite()
2398                })
2399            {
2400                values.extend([
2401                    (quote.bid, "Bid", ui.active_theme().ui.accent),
2402                    (quote.ask, "Ask", ui.active_theme().chart.candle_down),
2403                ]);
2404            }
2405            for (value, name, color) in values {
2406                let line = PriceLine::new(value, color)
2407                    .with_width(
2408                        if (name == "Bid" || name == "Ask") && !self.config.show_bid_ask {
2409                            0.0
2410                        } else {
2411                            1.0
2412                        },
2413                    )
2414                    .with_style(crate::model::LineStyle::Dotted)
2415                    .with_axis_label(false);
2416                rendering::render_price_line(
2417                    &painter,
2418                    price_rect,
2419                    &price_map,
2420                    &line,
2421                    price_rect.left(),
2422                    false,
2423                    axis_edge,
2424                );
2425                if price_map
2426                    .range()
2427                    .contains(price_map.transform().to_scale(value))
2428                {
2429                    let price = price_map.label(
2430                        price_map.transform().to_scale(value),
2431                        rendering::axes::tag_decimals(&price_map, price_map.price_to_y(value)),
2432                        price_formatter.as_deref(),
2433                    );
2434                    let quote = name == "Bid" || name == "Ask";
2435                    let label = if quote {
2436                        price
2437                    } else {
2438                        format!("{name} {price}")
2439                    };
2440                    let text_color = rendering::axes::tag_text_color(color);
2441                    let tag = rendering::axes::render_axis_value(
2442                        &painter,
2443                        price_rect,
2444                        axis_edge,
2445                        price_map.price_to_y(value),
2446                        label,
2447                        (text_color, color),
2448                        &tags,
2449                        self.config.price_axis.allow_label_overlap,
2450                    );
2451                    if quote && let Some(rect) = tag {
2452                        let badge = painter.layout_no_wrap(
2453                            l10n::text(ui.ctx(), name),
2454                            rendering::axes::axis_font(ui.ctx()),
2455                            text_color,
2456                        );
2457                        let box_rect = Rect::from_min_size(
2458                            egui::pos2(
2459                                if axis_edge <= price_rect.left() {
2460                                    rect.right() + 1.0
2461                                } else {
2462                                    rect.left() - badge.size().x - 8.0
2463                                },
2464                                rect.top(),
2465                            ),
2466                            egui::vec2(badge.size().x + 7.0, rect.height()),
2467                        );
2468                        painter.rect_filled(box_rect, 2.0, color);
2469                        painter.galley(box_rect.center() - badge.size() / 2.0, badge, text_color);
2470                    }
2471                    tags.push(tag);
2472                }
2473            }
2474        }
2475
2476        if self.config.show_time_labels {
2477            let axis = self.time_axis_rect;
2478            let axis_painter = painter.with_clip_rect(painter.clip_rect().intersect(axis));
2479            for (item, view) in self.time_axis_primitives(&coords) {
2480                item.get().draw_time_axis(&axis_painter, axis, &view);
2481            }
2482        }
2483        let time_labels = painter.add(egui::Shape::Noop);
2484        // Kept apart from the price tags, which size the price axis.
2485        let mut time_tags = Vec::new();
2486        if self.config.show_time_labels {
2487            let labels: Vec<_> = self
2488                .time_axis_primitives(&coords)
2489                .flat_map(|(item, view)| {
2490                    let labels = item.get().time_axis_labels(&view);
2491                    labels
2492                        .into_iter()
2493                        .filter_map(|label| Some((view.time_to_x(label.time)?, label)))
2494                        .collect::<Vec<_>>()
2495                })
2496                .collect();
2497            for (x, label) in labels {
2498                if chart_rect.x_range().contains(x) {
2499                    time_tags.push(Some(rendering::axes::paint_crosshair_tag(
2500                        &painter,
2501                        Pos2::new(x, time_axis_top + 1.5),
2502                        egui::Align2::CENTER_TOP,
2503                        chart_rect.left()..=chart_rect.right(),
2504                        label.text,
2505                        (rendering::axes::tag_text_color(label.color), label.color),
2506                        rendering::axes::axis_font(ui.ctx()),
2507                        25.0,
2508                        None,
2509                    )));
2510                }
2511            }
2512        }
2513
2514        let bar_seconds = self.time_label_seconds();
2515
2516        // Handle drawing tools
2517        #[cfg(feature = "drawings")]
2518        {
2519            // Clone/extract values before mutable borrow of self
2520            let timescale = self.state.time_scale().clone();
2521
2522            let drawing_history = self.history.start_drawing_action(&mut self.drawings);
2523            // Temporarily take cursor_modes and the drawings to avoid borrow conflict
2524            let mut cursor_modes = std::mem::take(&mut self.cursor_modes);
2525            let mut drawings = std::mem::take(&mut self.drawings);
2526
2527            self.handle_drawings(
2528                ui,
2529                &mut drawings,
2530                &mut cursor_modes,
2531                &response,
2532                price_rect,
2533                &price_map,
2534                &painter,
2535                &timescale,
2536                (time_scale.as_ref(), bar_seconds),
2537            );
2538
2539            // Render eraser highlight if in eraser mode
2540            self.render_eraser_highlight(&painter, &drawings, &cursor_modes);
2541
2542            // Put them back
2543            self.drawings = drawings;
2544            self.cursor_modes = cursor_modes;
2545            self.history
2546                .finish_drawing_action(&mut self.drawings, drawing_history);
2547        }
2548        self.paint_marks(&clipped_price_ctx, &coords, MarkerZOrder::Top);
2549        self.paint_primitives(&painter, &coords, PrimitivePass::Above);
2550
2551        // Placed after the drawings handled this frame's input, so a dragged line's tags move
2552        // with it in the same frame.
2553        #[cfg(feature = "drawings")]
2554        {
2555            let mut prices = Vec::new();
2556            for tag in self
2557                .drawings
2558                .axis_tags(price_rect, ui.active_theme().chart.axis_text_secondary)
2559            {
2560                match tag {
2561                    crate::drawings::manager::DrawingAxisTag::Price { price, color } => {
2562                        if show_price_axis
2563                            && price.is_finite()
2564                            && price_map
2565                                .range()
2566                                .contains(price_map.transform().to_scale(price))
2567                        {
2568                            prices.push((price_map.price_to_y(price), price, color));
2569                        }
2570                    }
2571                    crate::drawings::manager::DrawingAxisTag::Time { x, anchor, color } => {
2572                        if self.config.show_time_labels
2573                            && let Some(time) = self.drawing_point_time(&anchor)
2574                        {
2575                            time_tags.push(Some(rendering::axes::paint_crosshair_tag(
2576                                &painter,
2577                                Pos2::new(x, time_axis_top + 1.5),
2578                                egui::Align2::CENTER_TOP,
2579                                chart_rect.left()..=chart_rect.right(),
2580                                scale.format_crosshair(time, bar_seconds),
2581                                (rendering::axes::tag_text_color(color), color),
2582                                rendering::axes::axis_font(ui.ctx()),
2583                                25.0,
2584                                None,
2585                            )));
2586                        }
2587                    }
2588                }
2589            }
2590            if !prices.is_empty() {
2591                // As the price lines' tags: tags closer than a tag is high stack in price order.
2592                prices.sort_by(|a, b| a.0.total_cmp(&b.0));
2593                let mut ys: Vec<_> = prices.iter().map(|price| price.0).collect();
2594                if !self.config.price_axis.allow_label_overlap {
2595                    ys = rendering::axes::spread_axis_ys(&painter, &ys);
2596                }
2597                for ((_, price, color), y) in prices.into_iter().zip(ys) {
2598                    let label = price_map.label(
2599                        price_map.transform().to_scale(price),
2600                        rendering::axes::tag_decimals(&price_map, price_map.price_to_y(price)),
2601                        price_formatter.as_deref(),
2602                    );
2603                    tags.push(rendering::axes::render_axis_value(
2604                        &painter,
2605                        price_rect,
2606                        axis_edge,
2607                        y,
2608                        label,
2609                        (rendering::axes::tag_text_color(color), color),
2610                        &tags,
2611                        self.config.price_axis.allow_label_overlap,
2612                    ));
2613                }
2614            }
2615        }
2616
2617        // Draw crosshair with options from chart_options. The pointer still hovers the chart
2618        // while a context menu is open, so the crosshair, its labels and the tooltip wait until
2619        // the menu closes.
2620        #[cfg(feature = "drawings")]
2621        let eraser_mode = self.drawings.eraser_mode;
2622        #[cfg(feature = "drawings")]
2623        let drawing_tool_active = self.drawings.active_tool.is_some();
2624        let menu_open = self.menu_open || response.secondary_clicked();
2625        self.menu_open = menu_open;
2626        let mut crosshair_tags = [None; 2];
2627        #[cfg(feature = "drawings")]
2628        {
2629            self.cursor_modes.update(ui.input(|input| input.stable_dt));
2630            let trail = Some(self.chart_options.crosshair.style)
2631                .filter(|style| {
2632                    matches!(
2633                        style,
2634                        crate::config::CrosshairStyle::Demonstration
2635                            | crate::config::CrosshairStyle::Magic
2636                    )
2637                })
2638                .filter(|_| !eraser_mode);
2639            self.cursor_modes.follow(
2640                trail,
2641                response.hover_pos().filter(|pos| price_rect.contains(*pos)),
2642                ui.input(|input| input.pointer.primary_down()),
2643            );
2644            if trail.is_some() {
2645                self.cursor_modes.render(ui, price_rect);
2646            }
2647        }
2648        let mut table_rect = price_rect;
2649        for (id, study) in self
2650            .study_ids
2651            .iter()
2652            .zip(self.rendered_studies().indicators())
2653            .filter(|(_, study)| study.is_visible() && study.is_overlay())
2654        {
2655            if let Some(table) = study.table() {
2656                table_rect.max.y =
2657                    crate::studies::table::paint(ui, table_rect, table, ui.scope_id().with(id))
2658                        .top()
2659                        - 8.0;
2660            }
2661        }
2662        let hover_rect = shared_plot.map_or_else(
2663            || Rect::from_min_max(price_rect.min, Pos2::new(price_rect.right(), time_axis_top)),
2664            |plot| plot.rect,
2665        );
2666        let hover_pos = self
2667            .axis_hover
2668            .filter(|(_, tag)| {
2669                ui.input(|input| {
2670                    input.pointer.hover_pos().is_some_and(|pos| {
2671                        cfg!(feature = "icons")
2672                            && show_price_button
2673                            && rendering::axes::crosshair_price_button_rect(*tag).contains(pos)
2674                    })
2675                })
2676            })
2677            .map(|(pos, _)| pos)
2678            .or_else(|| {
2679                ui.input(|input| input.pointer.hover_pos())
2680                    .filter(|pos| hover_rect.contains(*pos) && ui.rect_contains_pointer(hover_rect))
2681            })
2682            .and_then(|pos| self.crosshair_position(pos, &coords));
2683        let mut study_action = None;
2684        let mut series_action = None;
2685        let mut legend_bottom = price_rect.top() + 8.0;
2686        {
2687            let visible_data = self.state.visible_data();
2688            let selected = hover_pos
2689                .and_then(|pos| coords.local_idx_at_x(pos.x, visible_data.len()))
2690                .map(|index| start_idx + index)
2691                .unwrap_or_else(|| {
2692                    // The last bar with a value, before any whitespace bars at the end.
2693                    let shown = self.state.shown();
2694                    shown
2695                        .iter()
2696                        .rposition(|bar| !bar.is_whitespace())
2697                        .unwrap_or(shown.len().saturating_sub(1))
2698                });
2699            if let Some(bar) = self.state.shown().get(selected) {
2700                let timeframe = self.timeframe.map(|tf| tf.as_str()).unwrap_or_default();
2701                #[cfg(feature = "ui")]
2702                let order_quantity = self.order_quantity.take();
2703                let mut legend = super::Legend::new(&self.symbol, &timeframe, bar);
2704                let statuses: Vec<_> = self
2705                    .legend_statuses
2706                    .iter()
2707                    .map(|(text, color, background)| super::LegendStatus {
2708                        text,
2709                        color: *color,
2710                        background: *background,
2711                    })
2712                    .collect();
2713                legend.formatters = self.value_formatters();
2714                legend.thousands_sep = price_map.thousands_sep();
2715                legend.instrument = self.instrument();
2716                legend.badge = self.legend_badge.clone();
2717                legend.show_logo = self.config.show_legend_badge;
2718                legend.statuses = &statuses;
2719                legend.quote = self
2720                    .quote
2721                    .as_ref()
2722                    .filter(|_| self.main_series_visible && self.config.show_quote_boxes);
2723                legend.show_title = self.main_series_present && self.config.show_symbol_labels;
2724                legend.show_market_status = self.config.show_market_status;
2725                if legend.show_title && legend.show_market_status {
2726                    // The quote's time is not a clock: it stops when quotes stop.
2727                    let now = self.chart_time().unwrap_or_else(chrono::Utc::now);
2728                    legend.session = rendering::exchange_session(
2729                        self.instrument(),
2730                        self.config.session_config.as_ref(),
2731                        &self.display_timezone(),
2732                        crate::model::Timeframe::Min1,
2733                    )
2734                    .market_session(now);
2735                    if legend.session.is_some() && self.replay_time().is_none() {
2736                        // Sessions change state on whole minutes.
2737                        let seconds = now.timestamp().rem_euclid(60) as u64;
2738                        ui.ctx()
2739                            .request_repaint_after(std::time::Duration::from_secs(60 - seconds));
2740                    }
2741                }
2742                legend.series_visible = self.main_series_visible;
2743                legend.title_as_description = self.config.title_as_description;
2744                // A whitespace bar has no values to show.
2745                let values = self.main_series_visible && !bar.is_whitespace();
2746                legend.show_ohlc = values && self.config.show_ohlc_info;
2747                legend.show_change = values && self.config.show_ohlc_change;
2748                legend.show_change_percent = values && self.config.show_ohlc_change_percent;
2749                legend.show_indicator_titles = self.config.show_indicator_labels;
2750                legend.show_indicator_inputs = self.config.show_indicator_inputs;
2751                legend.show_indicator_values = self.config.show_indicator_last_val;
2752                legend.day_change =
2753                    session_closes.filter(|_| values && self.config.show_last_day_change);
2754                legend.study_backdrop = self.config.indicator_backdrop_opacity;
2755                let inputs: Vec<_> = self.study_inputs.iter().map(String::as_str).collect();
2756                legend.study_inputs = &inputs;
2757                legend.previous_close =
2758                    crate::model::last_with_value(&self.state.shown()[..selected])
2759                        .map(|bar| bar.close);
2760                legend.bar_index = selected;
2761                legend.studies = self.rendered_studies().indicators();
2762                legend.show_volume =
2763                    values && self.chart_type.supports_volume() && self.config.show_legend_volume;
2764                #[cfg(feature = "ui")]
2765                {
2766                    legend.quantity = order_quantity;
2767                }
2768                let (quote_action, study_settings, bottom, quantity, main_action) = ui
2769                    .push_id(shown.id, |ui| {
2770                        legend.show_with_colors(
2771                            ui,
2772                            Rect::from_min_max(
2773                                price_rect.min,
2774                                egui::pos2(
2775                                    (price_rect.right() - 48.0).min(rect.right() - 100.0),
2776                                    price_rect.bottom(),
2777                                ),
2778                            ),
2779                            &colors,
2780                        )
2781                    })
2782                    .inner;
2783                if let Some(action) = quote_action {
2784                    self.quote_action = Some(action);
2785                }
2786                #[cfg(feature = "ui")]
2787                if quantity.is_some() {
2788                    self.quantity_clicked = quantity;
2789                }
2790                #[cfg(not(feature = "ui"))]
2791                let _ = quantity;
2792                study_action = study_settings;
2793                series_action = main_action;
2794                legend_bottom = bottom;
2795            }
2796        }
2797        let legend_bottom = self.paint_secondary_legend(
2798            ui,
2799            &painter,
2800            price_rect,
2801            legend_bottom + 4.0,
2802            self.main_pane_series(),
2803        );
2804        #[cfg(feature = "studies")]
2805        if let Some((step, tick, profiles)) = &execution_profiles {
2806            for profile in profiles {
2807                let levels = &profile.volume.levels;
2808                let Some(((&low, _), (&high, _))) =
2809                    levels.first_key_value().zip(levels.last_key_value())
2810                else {
2811                    continue;
2812                };
2813                if [
2814                    low as f64 * step - tick * 0.5,
2815                    (high as f64 + 1.0) * step - tick * 0.5,
2816                ]
2817                .into_iter()
2818                .all(|p| price_map.range().contains(transform.to_scale(p)))
2819                {
2820                    continue;
2821                }
2822                let mut labels = Vec::new();
2823                if let Some(poc) = profile.volume.poc() {
2824                    labels.push(("POC", poc as f64 * step));
2825                }
2826                if let Some((low, high)) = self
2827                    .trade_profile_settings()
2828                    .value_area
2829                    .and_then(|s| profile.volume.value_area(s))
2830                {
2831                    labels.extend([("VAH", high as f64 * step), ("VAL", low as f64 * step)]);
2832                }
2833                let bars = &self.state.data().bars;
2834                let first = bars.partition_point(|b| b.time < profile.range.start);
2835                let end = bars.partition_point(|b| b.time < profile.range.end);
2836                self.paint_profile_context(
2837                    ui,
2838                    &coords,
2839                    (coords.idx_to_x(first) - coords.bar_spacing * 0.45)
2840                        ..(coords.idx_to_x(end.saturating_sub(1)) + coords.bar_spacing * 0.5),
2841                    profile.range.start,
2842                    &labels,
2843                    legend_bottom + 6.0,
2844                );
2845            }
2846        }
2847        if self.main_series_visible
2848            && footprint.is_none()
2849            && self.chart_type == ChartType::TimePriceOpportunity
2850            && self.state.price_scale().is_manual()
2851        {
2852            rendering::candles::tpo::visit_profiles(
2853                &self.state.data().bars,
2854                start_idx..end_idx,
2855                self.state.revision(),
2856                (
2857                    &self.chart_type_params.tpo_config(render_ctx.tick_size),
2858                    render_ctx.tick_size,
2859                ),
2860                &bar_clock,
2861                (&price_map, price_rect, |i| coords.idx_to_x(i)),
2862                |profile, config, anchor, column, _| {
2863                    let cropped = [
2864                        profile.row_span(profile.profile_low).0,
2865                        profile.row_span(profile.profile_high).1,
2866                    ]
2867                    .into_iter()
2868                    .any(|p| !price_map.range().contains(transform.to_scale(p)));
2869                    if !cropped {
2870                        return;
2871                    }
2872                    let mut labels = Vec::new();
2873                    if config.show_poc {
2874                        labels.push(("POC", profile.poc_price));
2875                    }
2876                    if config.show_value_area {
2877                        labels.extend([
2878                            ("VAH", profile.value_area_high),
2879                            ("VAL", profile.value_area_low),
2880                        ]);
2881                    }
2882                    let widest = profile.price_tpo_count.values().max().copied().unwrap_or(0);
2883                    self.paint_profile_context(
2884                        ui,
2885                        &coords,
2886                        anchor..anchor + column * widest as f32,
2887                        profile.session_date,
2888                        &labels,
2889                        legend_bottom + 6.0,
2890                    );
2891                },
2892            );
2893        }
2894        if let Some(lines) = target_lines {
2895            self.paint_targets(ui, &coords, legend_bottom + 4.0, lines);
2896        }
2897
2898        if self.chart_options.time_scale.ignore_whitespace_indices
2899            || self.chart_options.time_scale.ignore_hidden_indices
2900        {
2901            self.last_crosshair = None;
2902        }
2903        if let Some(hover_pos) = hover_pos {
2904            // Cache the hover bar index for multi-chart sync
2905            self.last_hover_bar_idx = coords
2906                .local_idx_at_x(hover_pos.x, visible_data.len())
2907                .map(|local| (start_idx + local) as f64);
2908
2909            if !menu_open {
2910                let mut tooltip_options = self.chart_options.tooltip.clone();
2911                tooltip_options.enabled &= self.main_series_visible;
2912                tooltip_options.show_volume &= self.chart_type.supports_volume();
2913                let held = ui.input(|input| {
2914                    input.pointer.primary_down()
2915                        && (!input.any_touches()
2916                            || input
2917                                .pointer
2918                                .press_start_time()
2919                                .is_some_and(|start| input.time - start >= 0.5))
2920                });
2921                let held_values = tooltip_options.enabled
2922                    && tooltip_options.on_long_press
2923                    && held
2924                    && !eraser_mode
2925                    && !drawing_tool_active;
2926                let mut crosshair_options = self.chart_options.crosshair;
2927                // Arrow keeps the axis labels, which a hidden crosshair never shows.
2928                if (held_values || eraser_mode)
2929                    && crosshair_options.style != crate::config::CrosshairStyle::Hidden
2930                {
2931                    crosshair_options.style = crate::config::CrosshairStyle::Arrow;
2932                }
2933                let index = coords
2934                    .local_idx_at_x(hover_pos.x, visible_data.len())
2935                    .map(|local| start_idx + local);
2936                let snapped = if price_rect.contains(hover_pos) {
2937                    index.map_or(hover_pos, |index| {
2938                        self.snap_crosshair(hover_pos, &price_map, index)
2939                    })
2940                } else {
2941                    hover_pos
2942                };
2943                self.last_crosshair = index.map(|index| {
2944                    let main = price_rect.contains(hover_pos) && !volume_rect.contains(hover_pos);
2945                    (
2946                        self.state.shown()[index].time,
2947                        main.then(|| price_map.y_to_price(snapped.y)),
2948                    )
2949                });
2950                crosshair_options.mode = crate::config::CrosshairMode::Normal;
2951                let mut main_options = crosshair_options;
2952                if volume_rect.contains(hover_pos) {
2953                    main_options.horz_line_visible = false;
2954                    main_options.horz_label_visible = false;
2955                    if main_options.style == crate::config::CrosshairStyle::Dot {
2956                        main_options.style = crate::config::CrosshairStyle::Arrow;
2957                    }
2958                }
2959                crosshair_tags = rendering::render_crosshair_with_options(
2960                    &price_ctx,
2961                    snapped,
2962                    visible_data,
2963                    &price_map,
2964                    &coords,
2965                    &main_options,
2966                    time_axis_top,
2967                    axis_edge,
2968                    last_price_tag,
2969                    scale,
2970                    bar_seconds,
2971                    price_formatter.as_deref(),
2972                );
2973                if let Some(index) = index
2974                    .filter(|_| crosshair_options.style != crate::config::CrosshairStyle::Hidden)
2975                    .filter(|_| self.main_series_visible)
2976                {
2977                    let bar = &self.state.shown()[index];
2978                    let value = self
2979                        .config
2980                        .price_source
2981                        .compute(bar.open, bar.high, bar.low, bar.close);
2982                    if let Some(color) = rendering::candles::line_color_at(
2983                        self.chart_type,
2984                        &self.chart_type_params.styles,
2985                        self.chart_type_params.baseline,
2986                        &price_map,
2987                        price_rect,
2988                        value,
2989                        bar,
2990                    ) {
2991                        rendering::candles::render_crosshair_marker(
2992                            &painter.with_clip_rect(price_rect),
2993                            Pos2::new(coords.idx_to_x(index), price_map.price_to_y(value)),
2994                            self.chart_type_params.styles.crosshair_marker_radius,
2995                            color,
2996                            self.config.background_color,
2997                        );
2998                    }
2999                }
3000
3001                if volume_rect.contains(hover_pos)
3002                    && let Some(map) = volume_map
3003                {
3004                    let mut pos = hover_pos;
3005                    if let Some(index) = index {
3006                        pos.x = coords.idx_to_x(index);
3007                        if self.chart_options.crosshair.mode.snaps() {
3008                            pos.y = map.price_to_y(self.state.shown()[index].volume);
3009                        }
3010                    }
3011                    let mut options = crosshair_options.resolved(ui.ctx());
3012                    options.vert_line_visible = false;
3013                    let context = RenderContext::new(&painter, volume_rect);
3014                    rendering::render_crosshair_lines(
3015                        &context,
3016                        pos,
3017                        &options,
3018                        volume_rect.bottom(),
3019                    );
3020                    crosshair_tags[0] = rendering::render_crosshair_price_tag(
3021                        &context,
3022                        pos,
3023                        &map,
3024                        &options,
3025                        axis_edge,
3026                        None,
3027                        Some(
3028                            crate::scales::StudyFormat::Volume
3029                                .format(map.y_to_price(pos.y), map.price_precision()),
3030                        ),
3031                    );
3032                }
3033
3034                if tooltip_options.on_long_press
3035                    && ui.input(|input| input.any_touches() && input.pointer.primary_down())
3036                {
3037                    ui.ctx().request_repaint();
3038                }
3039                if tooltip_options.enabled
3040                    && (!tooltip_options.on_long_press || held)
3041                    && !eraser_mode
3042                    && !drawing_tool_active
3043                    && let Some(local_idx) = coords.local_idx_at_x(hover_pos.x, visible_data.len())
3044                    && !visible_data[local_idx].is_whitespace()
3045                {
3046                    let candle = &visible_data[local_idx];
3047                    if tooltip_options.on_long_press {
3048                        let x = coords.idx_to_x(start_idx + local_idx);
3049                        rendering::draw_dashed_line(
3050                            &clipped_painter,
3051                            Pos2::new(x, price_rect.top()),
3052                            Pos2::new(x, price_rect.bottom()),
3053                            egui::Stroke::new(
3054                                1.0,
3055                                self.chart_options
3056                                    .crosshair
3057                                    .resolved(ui.ctx())
3058                                    .vert_line_color,
3059                            ),
3060                            5.0,
3061                            5.0,
3062                        );
3063                        for (index, bar) in visible_data
3064                            .iter()
3065                            .enumerate()
3066                            .step_by(25)
3067                            .filter(|(_, bar)| !bar.is_whitespace())
3068                        {
3069                            let pos = Pos2::new(
3070                                coords.idx_to_x(start_idx + index),
3071                                price_map.price_to_y(bar.close),
3072                            );
3073                            clipped_painter.circle(
3074                                pos,
3075                                3.5,
3076                                ui.active_theme().chart.bg,
3077                                egui::Stroke::new(1.5, ui.active_theme().ui.accent),
3078                            );
3079                        }
3080                    }
3081                    // A symbol without metadata still heads the readout.
3082                    let heading = (self.instrument().is_none() && !self.symbol.is_empty())
3083                        .then(|| crate::model::Instrument::new(self.symbol.as_str()));
3084                    rendering::render_tooltip_with_options(
3085                        &price_ctx,
3086                        hover_pos,
3087                        candle,
3088                        crate::model::last_with_value(&self.state.shown()[..start_idx + local_idx])
3089                            .map(|bar| bar.close),
3090                        &tooltip_options,
3091                        &colors,
3092                        &price_map,
3093                        &coords,
3094                        visible_data,
3095                        scale,
3096                        bar_seconds,
3097                        &self
3098                            .study_rows_at(start_idx + local_idx)
3099                            .into_iter()
3100                            .filter(|(id, _)| {
3101                                !tooltip_options.on_long_press
3102                                    || tooltip_options.show_volume_details
3103                                    || self
3104                                        .study_ids
3105                                        .iter()
3106                                        .zip(self.rendered_studies().indicators())
3107                                        .any(|(study_id, study)| {
3108                                            study_id == id && !study.is_volume()
3109                                        })
3110                            })
3111                            .map(|(_, row)| row)
3112                            .collect::<Vec<_>>(),
3113                        self.instrument().or(heading.as_ref()),
3114                        self.value_formatters(),
3115                    );
3116                }
3117            }
3118        } else {
3119            // Clear hover bar index when not hovering locally
3120            self.last_hover_bar_idx = None;
3121
3122            // Render synced crosshair from other charts (if available)
3123            {
3124                if !menu_open
3125                    && let Some(bar_idx) = self.synced_crosshair_bar_idx.and_then(|index| {
3126                        let options = &self.chart_options.time_scale;
3127                        if !options.ignore_whitespace_indices && !options.ignore_hidden_indices {
3128                            return Some(index);
3129                        }
3130                        self.crosshair_position(
3131                            Pos2::new(
3132                                coords.idx_to_x(index.floor() as usize)
3133                                    + index.fract() as f32 * coords.bar_spacing,
3134                                price_rect.center().y,
3135                            ),
3136                            &coords,
3137                        )
3138                        .map(|pos| coords.x_to_idx_f64(pos.x))
3139                    })
3140                {
3141                    // Convert bar index to screen x coordinate
3142                    let x = coords.idx_to_x(bar_idx.round() as usize);
3143                    if coords.is_x_visible(x) {
3144                        // At the host's price, or a synthetic position at the centre of the
3145                        // price range for a time alone
3146                        let mut options = self.chart_options.crosshair;
3147                        let y = match self.synced_crosshair_price {
3148                            Some(price) => {
3149                                options.mode = crate::config::CrosshairMode::Normal;
3150                                price_map.price_to_y(price)
3151                            }
3152                            None => price_rect.center().y,
3153                        };
3154                        if let Some(bar) = self.state.shown().get(bar_idx.round() as usize) {
3155                            self.last_crosshair = Some((bar.time, self.synced_crosshair_price));
3156                        }
3157                        crosshair_tags = rendering::render_crosshair_with_options(
3158                            &price_ctx,
3159                            Pos2::new(x, y),
3160                            visible_data,
3161                            &price_map,
3162                            &coords,
3163                            &options,
3164                            time_axis_top,
3165                            axis_edge,
3166                            last_price_tag,
3167                            scale,
3168                            bar_seconds,
3169                            price_formatter.as_deref(),
3170                        );
3171                    }
3172                }
3173            }
3174        }
3175
3176        self.axis_hover = hover_pos.zip(crosshair_tags[0]);
3177        #[cfg(feature = "icons")]
3178        if show_price_axis {
3179            if let Some((pos, tag)) = self.axis_hover.filter(|(pos, _)| {
3180                show_price_button && price_rect.contains(*pos) && !volume_rect.contains(*pos)
3181            }) {
3182                let price = if self.chart_options.crosshair.mode.snaps()
3183                    && let Some(index) = coords.local_idx_at_x(pos.x, visible_data.len())
3184                {
3185                    price_map.y_to_price(self.snap_crosshair(pos, &price_map, start_idx + index).y)
3186                } else {
3187                    price_map.y_to_price(pos.y)
3188                };
3189                let options = self.chart_options.crosshair.resolved(ui.ctx());
3190                if let Some(price) = rendering::axes::crosshair_price_action_with_colors(
3191                    ui,
3192                    tag,
3193                    price,
3194                    (
3195                        options.label_text_color,
3196                        options.horz_label_background_color,
3197                    ),
3198                ) {
3199                    self.right_click = Some(RightClickTarget::Background {
3200                        pos: Pos2::new(tag.left(), tag.bottom().min(price_rect.bottom())),
3201                        price,
3202                    });
3203                }
3204            }
3205            if show_axis_settings
3206                && rendering::axes::axis_settings_button(ui, axis_corner).clicked()
3207            {
3208                self.scale_settings_requested = true;
3209            }
3210        }
3211
3212        let current_price = crate::model::last_with_value(&self.state.data().bars)
3213            .filter(|_| self.main_series_visible && self.config.show_symbol_last_val)
3214            .map(|bar| {
3215                price_map.label(
3216                    price_map.transform().to_scale(bar.close),
3217                    rendering::axes::tag_decimals(&price_map, price_map.price_to_y(bar.close)),
3218                    price_formatter.as_deref(),
3219                )
3220            });
3221        self.measured_axis_width = rendering::axes::price_axis_width(
3222            &painter,
3223            &price_map,
3224            &current_price.as_deref().into_iter().collect::<Vec<_>>(),
3225            price_formatter.as_deref(),
3226        );
3227        if cfg!(feature = "icons")
3228            && self.config.show_left_axis
3229            && !self.config.show_right_axis
3230            && self.config.show_plus_button
3231        {
3232            // Reserve the button before hover so entering the plot does not resize its panes.
3233            self.measured_axis_width += 26.0;
3234        }
3235        self.measured_axis_width = self.measured_axis_width.max(independent_axis_width);
3236        for tag in tags.iter().skip(usize::from(has_currency)).flatten() {
3237            self.measured_axis_width = self.measured_axis_width.max(tag.width() + 5.0 + 6.0);
3238        }
3239
3240        if show_price_axis && has_currency {
3241            let currency = self
3242                .instrument()
3243                .and_then(|instrument| instrument.currency.as_deref())
3244                .filter(|_| self.config.show_currency)
3245                .unwrap_or("");
3246            let unit = self
3247                .instrument()
3248                .and_then(|instrument| instrument.unit.as_deref())
3249                .filter(|_| self.config.show_unit);
3250
3251            let header = match (currency.is_empty(), unit.filter(|unit| !unit.is_empty())) {
3252                (false, Some(unit)) => format!("{currency} ยท {unit}"),
3253                (true, Some(unit)) => unit.to_owned(),
3254                _ => currency.to_owned(),
3255            };
3256            let header_width = painter
3257                .layout_no_wrap(header, rendering::axes::axis_font(ui.ctx()), colors.text)
3258                .size()
3259                .x
3260                + if currency.is_empty() { 16.0 } else { 26.0 };
3261            let axis = Rect::from_min_max(
3262                Pos2::new(
3263                    if self.config.show_right_axis {
3264                        price_rect.right()
3265                    } else {
3266                        rect.left()
3267                    },
3268                    price_rect.top(),
3269                ),
3270                Pos2::new(axis_edge, price_rect.bottom()),
3271            );
3272            let (button, choice) =
3273                rendering::render_axis_currency(ui, axis, currency, unit, &self.currency_choices);
3274            self.measured_axis_width = self.measured_axis_width.max(header_width);
3275            if choice.is_some() {
3276                self.currency_choice = choice;
3277            }
3278            tags.push(Some(button.rect));
3279        }
3280
3281        // Draw the axis labels, leaving out those the tags overlap
3282        tags.extend(crosshair_tags);
3283        let time_tags = if time_tags.is_empty() {
3284            &tags
3285        } else {
3286            time_tags.extend_from_slice(&tags);
3287            &time_tags
3288        };
3289        if self.config.show_right_axis {
3290            rendering::render_price_labels(
3291                &price_ctx,
3292                &price_map,
3293                &colors,
3294                &self.config.price_axis,
3295                &tags,
3296                price_labels,
3297                price_formatter.as_deref(),
3298            );
3299        } else if self.config.show_left_axis {
3300            rendering::render_price_labels_at_pos(
3301                &price_ctx,
3302                &price_map,
3303                &colors,
3304                &self.config.price_axis,
3305                crate::scales::PriceScaleId::Left,
3306                &tags,
3307                price_labels,
3308                price_formatter.as_deref(),
3309            );
3310        }
3311        if self.config.show_time_labels {
3312            let axis_context_rect =
3313                Rect::from_min_max(chart_rect.min, Pos2::new(chart_rect.right(), time_axis_top));
3314            let chart_ctx = RenderContext::new(&painter, axis_context_rect);
3315            rendering::render_time_labels(
3316                &chart_ctx,
3317                time_marks,
3318                &time_points,
3319                &axis_coords,
3320                &colors,
3321                time_tags,
3322                &self.chart_options.time_scale,
3323                time_labels,
3324            );
3325        }
3326
3327        // Draw box zoom rect
3328        crate::widget::pan_zoom::render_box_zoom(&painter, &self.box_zoom);
3329
3330        if let Some((nav, buttons)) = navigation {
3331            self.paint_navigation(ui, nav, buttons);
3332        }
3333
3334        self.show_realtime_button(ui, realtime_rect, shown.id);
3335
3336        // Focus ring for keyboard accessibility
3337        crate::styles::focus::draw_focus_ring(ui, &shown, 0.0);
3338
3339        self.show_signal_menu(ui, price_rect);
3340        if let Some(action) = study_action {
3341            self.apply_study_action(action);
3342        }
3343        if let Some(action) = series_action {
3344            self.apply_main_series_action(action);
3345        }
3346        self.main_pane_actions(ui, rect, chart_rect);
3347        self.record_host_click(ui, &response);
3348        shown
3349    }
3350}
3351
3352#[cfg(test)]
3353mod tests;