Skip to main content

runmat_plot/gui/
plot_overlay.rs

1//! GUI overlay system for interactive plot controls and annotations
2//!
3//! This module handles the egui-based UI that sits on top of the WGPU-rendered
4//! plot content, providing controls, axis labels, grid lines, and titles.
5
6use crate::core::{plot_utils, PlotRenderer};
7use crate::plots::TextStyle;
8use crate::styling::{ModernDarkTheme, PlotThemeConfig, ThemeVariant};
9use egui::{Align2, Color32, Context, FontId, Pos2, Rect, Stroke};
10use glam::{Vec3, Vec4};
11
12#[cfg(target_arch = "wasm32")]
13fn transparent_frame() -> egui::Frame {
14    egui::Frame::NONE
15}
16
17#[cfg(not(target_arch = "wasm32"))]
18fn transparent_frame() -> egui::Frame {
19    egui::Frame::none()
20}
21
22/// GUI overlay manager for plot annotations and controls
23pub struct PlotOverlay {
24    /// Current theme
25    theme: PlotThemeConfig,
26
27    /// Cached plot area from last frame
28    plot_area: Option<Rect>,
29    /// Cached per-axes content rectangles from last frame (egui points, snapped)
30    axes_plot_rects: Vec<Rect>,
31    /// Cached toolbar rectangle (egui points)
32    toolbar_rect: Option<Rect>,
33    /// Cached sidebar rectangle (egui points)
34    sidebar_rect: Option<Rect>,
35
36    /// Show debug information
37    show_debug: bool,
38
39    /// Show Dystr information modal
40    show_dystr_modal: bool,
41
42    // Toolbar state
43    want_save_png: bool,
44    want_save_svg: bool,
45    want_reset_view: bool,
46    want_toggle_grid: Option<bool>,
47    want_toggle_legend: Option<bool>,
48}
49
50/// Configuration for the plot overlay
51#[derive(Debug, Clone)]
52pub struct OverlayConfig {
53    /// Whether to show the sidebar with controls
54    pub show_sidebar: bool,
55
56    /// Whether to show the top toolbar (legend/grid/reset/save).
57    pub show_toolbar: bool,
58
59    /// Scale factor applied to overlay font sizes (1.0 = default).
60    pub font_scale: f32,
61
62    /// Whether to show grid lines
63    pub show_grid: bool,
64
65    /// Whether to show axis labels
66    pub show_axes: bool,
67
68    /// Whether to show plot title
69    pub show_title: bool,
70
71    /// Custom plot title (if any)
72    pub title: Option<String>,
73
74    /// Custom axis labels
75    pub x_label: Option<String>,
76    pub y_label: Option<String>,
77
78    /// Sidebar width
79    pub sidebar_width: f32,
80
81    /// Margins around plot area
82    pub plot_margins: PlotMargins,
83}
84
85#[derive(Debug, Clone)]
86pub struct PlotMargins {
87    pub left: f32,
88    pub right: f32,
89    pub top: f32,
90    pub bottom: f32,
91}
92
93#[derive(Debug, Clone, Copy)]
94struct PanelLayout {
95    plot_rect: Rect,
96    frame_rect: Rect,
97    title_rect: Rect,
98    x_label_rect: Rect,
99    y_label_rect: Rect,
100}
101
102impl Default for OverlayConfig {
103    fn default() -> Self {
104        Self {
105            show_sidebar: true,
106            show_toolbar: true,
107            font_scale: 1.0,
108            show_grid: true,
109            show_axes: true,
110            show_title: true,
111            title: Some("Plot".to_string()),
112            x_label: Some("X".to_string()),
113            y_label: Some("Y".to_string()),
114            sidebar_width: 280.0,
115            plot_margins: PlotMargins {
116                left: 60.0,
117                right: 20.0,
118                top: 40.0,
119                bottom: 60.0,
120            },
121        }
122    }
123}
124
125/// Information about the current frame's UI state
126#[derive(Debug)]
127pub struct FrameInfo {
128    /// The plot area where WGPU should render
129    pub plot_area: Option<Rect>,
130
131    /// Whether the UI consumed any input events
132    pub consumed_input: bool,
133
134    /// Performance metrics to display
135    pub metrics: OverlayMetrics,
136}
137
138#[derive(Debug, Default)]
139pub struct OverlayMetrics {
140    pub vertex_count: usize,
141    pub triangle_count: usize,
142    pub render_time_ms: f64,
143    pub fps: f32,
144}
145
146impl Default for PlotOverlay {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl PlotOverlay {
153    const SUBPLOT_GAP_POINTS: f32 = 6.0;
154
155    fn style_color(style: &TextStyle, fallback: Color32) -> Color32 {
156        style
157            .color
158            .map(|c| {
159                Color32::from_rgb(
160                    (c.x.clamp(0.0, 1.0) * 255.0) as u8,
161                    (c.y.clamp(0.0, 1.0) * 255.0) as u8,
162                    (c.z.clamp(0.0, 1.0) * 255.0) as u8,
163                )
164            })
165            .unwrap_or(fallback)
166    }
167
168    fn style_font_size(style: &TextStyle, default_size: f32, scale: f32) -> f32 {
169        style.font_size.unwrap_or(default_size) * scale.max(0.75)
170    }
171
172    fn axes_tick_font_size(
173        plot_renderer: &PlotRenderer,
174        axes_index: Option<usize>,
175        scale: f32,
176    ) -> f32 {
177        let font_size = axes_index
178            .and_then(|idx| plot_renderer.overlay_axes_style_for_axes(idx))
179            .and_then(|style| style.font_size)
180            .unwrap_or(10.0);
181        font_size * scale.max(0.75)
182    }
183
184    fn style_is_bold(style: &TextStyle) -> bool {
185        style
186            .font_weight
187            .as_deref()
188            .map(|weight| weight.eq_ignore_ascii_case("bold"))
189            .unwrap_or(false)
190    }
191
192    #[allow(clippy::too_many_arguments)]
193    fn paint_styled_text(
194        painter: &egui::Painter,
195        pos: Pos2,
196        align: Align2,
197        text: &str,
198        font_size: f32,
199        color: Color32,
200        bold: bool,
201        shadow_alpha: u8,
202    ) {
203        let font = FontId::proportional(font_size);
204        painter.text(
205            pos + egui::vec2(1.0, 1.0),
206            align,
207            text,
208            font.clone(),
209            Color32::from_rgba_premultiplied(0, 0, 0, shadow_alpha),
210        );
211        painter.text(pos, align, text, font.clone(), color);
212        if bold {
213            painter.text(pos + egui::vec2(0.6, 0.0), align, text, font.clone(), color);
214            painter.text(pos + egui::vec2(0.0, 0.6), align, text, font.clone(), color);
215            painter.text(pos + egui::vec2(0.6, 0.6), align, text, font, color);
216        }
217    }
218
219    fn label_stride(labels: &[String], axis_span_px: f32, font_size_px: f32) -> usize {
220        if labels.len() <= 1 || axis_span_px <= 1.0 {
221            return 1;
222        }
223        let max_chars = labels
224            .iter()
225            .map(|label| truncate_label(label, 14).chars().count())
226            .max()
227            .unwrap_or(0) as f32;
228        let estimated_label_width = (max_chars * font_size_px * 0.55).max(font_size_px * 2.0);
229        let slot_width = (axis_span_px / labels.len() as f32).max(1.0);
230        ((estimated_label_width / slot_width).ceil().max(1.0)) as usize
231    }
232
233    #[allow(clippy::too_many_arguments)]
234    fn draw_histogram_axis_ticks(
235        &self,
236        ui: &mut egui::Ui,
237        plot_rect: Rect,
238        ppp: f32,
239        axis_color: Color32,
240        label_color: Color32,
241        tick_length: f32,
242        label_offset: f32,
243        tick_font: FontId,
244        border_bottom: f32,
245        x_min: f64,
246        x_max: f64,
247        edges: &[f64],
248    ) {
249        if edges.len() < 2 || (x_max - x_min).abs() <= f64::EPSILON {
250            return;
251        }
252        let labels: Vec<String> = edges
253            .iter()
254            .map(|value| plot_utils::format_tick_label(*value))
255            .collect();
256        let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
257        let denom = (edges.len() - 1) as f64;
258        for (idx, label) in labels.iter().enumerate() {
259            if idx != 0 && idx != labels.len() - 1 && idx % stride != 0 {
260                continue;
261            }
262            let frac = idx as f64 / denom;
263            let x_val = x_min + frac * (x_max - x_min);
264            let x_screen =
265                plot_rect.min.x + ((x_val - x_min) / (x_max - x_min)) as f32 * plot_rect.width();
266            let x_screen = Self::snap_coord(x_screen, ppp);
267            ui.painter().line_segment(
268                [
269                    Pos2::new(x_screen, border_bottom),
270                    Pos2::new(x_screen, border_bottom + tick_length),
271                ],
272                Stroke::new(1.0, axis_color),
273            );
274            ui.painter().text(
275                Pos2::new(x_screen, border_bottom + label_offset),
276                Align2::CENTER_CENTER,
277                label,
278                tick_font.clone(),
279                label_color,
280            );
281        }
282    }
283
284    /// Create a new plot overlay
285    pub fn new() -> Self {
286        Self {
287            theme: PlotThemeConfig::default(),
288            plot_area: None,
289            axes_plot_rects: Vec::new(),
290            toolbar_rect: None,
291            sidebar_rect: None,
292            show_debug: false,
293            show_dystr_modal: false,
294            want_save_png: false,
295            want_save_svg: false,
296            want_reset_view: false,
297            want_toggle_grid: None,
298            want_toggle_legend: None,
299        }
300    }
301
302    pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
303        self.theme = theme;
304    }
305
306    fn has_visible_text(text: Option<&str>) -> bool {
307        text.map(|s| !s.trim().is_empty()).unwrap_or(false)
308    }
309
310    fn approx_text_width_points(text: &str, font_size: f32) -> f32 {
311        (text.chars().count() as f32) * font_size * 0.56
312    }
313
314    fn estimate_y_axis_band_width(
315        &self,
316        plot_renderer: &PlotRenderer,
317        axes_index: usize,
318        has_y_label: bool,
319        scale: f32,
320    ) -> f32 {
321        let tick_font_size = Self::axes_tick_font_size(plot_renderer, Some(axes_index), scale);
322        let label_offset = 15.0 * scale;
323
324        let y_log = plot_renderer.overlay_y_log_for_axes(axes_index);
325        let categorical = plot_renderer
326            .overlay_categorical_labels_for_axes(axes_index)
327            .filter(|(is_x, _)| !*is_x)
328            .map(|(_, labels)| labels)
329            .or_else(|| {
330                plot_renderer
331                    .overlay_categorical_labels()
332                    .and_then(|(is_x, labels)| if !is_x { Some(labels.clone()) } else { None })
333            });
334
335        let max_label_width = if let Some(labels) = categorical {
336            labels
337                .iter()
338                .map(|label| {
339                    Self::approx_text_width_points(&truncate_label(label, 14), tick_font_size)
340                })
341                .fold(0.0_f32, f32::max)
342        } else if let Some((_x_min, _x_max, y_min, y_max)) =
343            plot_renderer.overlay_display_bounds_for_axes(axes_index)
344        {
345            if y_log && y_min > 0.0 && y_max > 0.0 {
346                let start_decade = y_min.log10().floor() as i32;
347                let end_decade = y_max.log10().ceil() as i32;
348                (start_decade..=end_decade)
349                    .map(|d| Self::approx_text_width_points(&format!("10^{d}"), tick_font_size))
350                    .fold(0.0_f32, f32::max)
351            } else {
352                plot_utils::generate_major_ticks(y_min, y_max)
353                    .into_iter()
354                    .map(plot_utils::format_tick_label)
355                    .map(|label| Self::approx_text_width_points(&label, tick_font_size))
356                    .fold(0.0_f32, f32::max)
357            }
358        } else {
359            Self::approx_text_width_points("-1.00", tick_font_size)
360        };
361
362        let y_tick_zone = label_offset + max_label_width * 0.5 + 4.0 * scale;
363        let y_label_zone = if has_y_label {
364            11.0 * scale
365        } else {
366            4.0 * scale
367        };
368        (y_tick_zone + y_label_zone).max(24.0 * scale)
369    }
370
371    fn estimate_x_axis_edge_padding(
372        &self,
373        plot_renderer: &PlotRenderer,
374        axes_index: usize,
375        scale: f32,
376    ) -> f32 {
377        let tick_font_size = Self::axes_tick_font_size(plot_renderer, Some(axes_index), scale);
378        let x_log = plot_renderer.overlay_x_log_for_axes(axes_index);
379
380        let explicit_tick_labels = plot_renderer
381            .overlay_x_tick_labels_for_axes(axes_index)
382            .filter(|labels| !labels.is_empty());
383        let categorical_tick_labels = plot_renderer
384            .overlay_categorical_labels_for_axes(axes_index)
385            .and_then(|(is_x, labels)| if is_x { Some(labels) } else { None })
386            .or_else(|| {
387                plot_renderer
388                    .overlay_categorical_labels()
389                    .and_then(|(is_x, labels)| if is_x { Some(labels.clone()) } else { None })
390            });
391
392        let labels: Vec<String> = if let Some(labels) = explicit_tick_labels {
393            labels
394                .iter()
395                .map(|label| truncate_label(label, 14))
396                .collect()
397        } else if let Some(labels) = categorical_tick_labels {
398            labels
399                .iter()
400                .map(|label| truncate_label(label, 14))
401                .collect()
402        } else if let Some((x_min, x_max, _y_min, _y_max)) =
403            plot_renderer.overlay_display_bounds_for_axes(axes_index)
404        {
405            if x_log && x_min > 0.0 && x_max > 0.0 {
406                let start_decade = x_min.log10().floor() as i32;
407                let end_decade = x_max.log10().ceil() as i32;
408                (start_decade..=end_decade)
409                    .map(|d| format!("10^{d}"))
410                    .collect()
411            } else {
412                plot_utils::generate_major_ticks(x_min, x_max)
413                    .into_iter()
414                    .map(plot_utils::format_tick_label)
415                    .collect()
416            }
417        } else {
418            vec!["-1.00".to_string(), "1.00".to_string()]
419        };
420
421        let max_edge_label_width = if labels.is_empty() {
422            Self::approx_text_width_points("-1.00", tick_font_size)
423        } else if labels.len() == 1 {
424            Self::approx_text_width_points(&labels[0], tick_font_size)
425        } else {
426            let left = Self::approx_text_width_points(&labels[0], tick_font_size);
427            let right = Self::approx_text_width_points(&labels[labels.len() - 1], tick_font_size);
428            left.max(right)
429        };
430
431        (max_edge_label_width * 0.5 + 3.0 * scale).max(6.0 * scale)
432    }
433
434    #[allow(clippy::too_many_arguments)]
435    fn layout_2d_panel(
436        &self,
437        outer: Rect,
438        plot_renderer: &PlotRenderer,
439        axes_index: usize,
440        title: Option<&str>,
441        x_label: Option<&str>,
442        y_label: Option<&str>,
443        scale: f32,
444    ) -> PanelLayout {
445        let scale = scale.max(0.75);
446        let has_title = Self::has_visible_text(title);
447        let has_x_label = Self::has_visible_text(x_label);
448        let has_y_label = Self::has_visible_text(y_label);
449        let outer_w = outer.width().max(1.0);
450        let outer_h = outer.height().max(1.0);
451        let title_gap = if has_title { 4.0 * scale } else { 1.5 * scale };
452        let x_gap = 4.0 * scale;
453        let x_edge_pad = self.estimate_x_axis_edge_padding(plot_renderer, axes_index, scale);
454        let mut right_pad = (3.0 * scale).max(x_edge_pad + 1.0 * scale);
455
456        let mut title_h = if has_title {
457            (28.0 * scale).min(outer_h * 0.16)
458        } else {
459            0.0
460        };
461        let mut x_h = ((24.0 + if has_x_label { 14.0 } else { 0.0 }) * scale).min(outer_h * 0.28);
462        let y_band_estimate =
463            self.estimate_y_axis_band_width(plot_renderer, axes_index, has_y_label, scale);
464        let mut y_w = y_band_estimate.min(outer_w * 0.30);
465
466        let min_plot_w = (outer_w * 0.56).max(44.0 * scale).min(outer_w);
467        let min_plot_h = (outer_h * 0.54).max(44.0 * scale).min(outer_h);
468
469        if outer_w - y_w - right_pad < min_plot_w {
470            y_w = (outer_w - right_pad - min_plot_w).max(0.0);
471        }
472        if outer_w - y_w - right_pad < min_plot_w {
473            right_pad = (outer_w - y_w - min_plot_w).max(0.0);
474        }
475
476        let available_h = outer_h - title_h - title_gap - x_h - x_gap;
477        if available_h < min_plot_h {
478            let deficit = min_plot_h - available_h;
479            let reducible = title_h + x_h;
480            if reducible > 0.0 {
481                let keep = ((reducible - deficit).max(0.0)) / reducible;
482                title_h *= keep;
483                x_h *= keep;
484            }
485        }
486
487        let plot_rect = Rect::from_min_max(
488            egui::pos2(outer.min.x + y_w, outer.min.y + title_h + title_gap),
489            egui::pos2(
490                (outer.max.x - right_pad).max(outer.min.x + y_w + 1.0),
491                outer.max.y - x_h - x_gap,
492            ),
493        );
494        let frame_rect = plot_rect;
495        let title_rect = Rect::from_min_max(
496            egui::pos2(outer.min.x, outer.min.y),
497            egui::pos2(outer.max.x, plot_rect.min.y),
498        );
499        let x_label_rect = Rect::from_min_max(
500            egui::pos2(outer.min.x, plot_rect.max.y),
501            egui::pos2(outer.max.x, outer.max.y),
502        );
503        let y_label_rect = Rect::from_min_max(
504            egui::pos2(outer.min.x, plot_rect.min.y),
505            egui::pos2(plot_rect.min.x, plot_rect.max.y),
506        );
507        PanelLayout {
508            plot_rect,
509            frame_rect,
510            title_rect,
511            x_label_rect,
512            y_label_rect,
513        }
514    }
515
516    fn layout_3d_panel(&self, outer: Rect, title: Option<&str>, scale: f32) -> PanelLayout {
517        let scale = scale.max(0.75);
518        let title_h = if Self::has_visible_text(title) {
519            (28.0 * scale).min(outer.height().max(1.0) * 0.16)
520        } else {
521            0.0
522        };
523        let plot_rect =
524            Rect::from_min_max(egui::pos2(outer.min.x, outer.min.y + title_h), outer.max);
525        let title_rect = Rect::from_min_max(outer.min, egui::pos2(outer.max.x, plot_rect.min.y));
526        PanelLayout {
527            plot_rect,
528            frame_rect: plot_rect,
529            title_rect,
530            x_label_rect: plot_rect,
531            y_label_rect: plot_rect,
532        }
533    }
534
535    fn axes_is_3d(plot_renderer: &PlotRenderer, axes_index: usize) -> bool {
536        let cam = plot_renderer
537            .axes_camera(axes_index)
538            .unwrap_or_else(|| plot_renderer.camera());
539        matches!(
540            cam.projection,
541            crate::core::camera::ProjectionType::Perspective { .. }
542        )
543    }
544
545    fn panel_layout_for_axes(
546        &self,
547        outer: Rect,
548        plot_renderer: &PlotRenderer,
549        axes_index: usize,
550        scale: f32,
551    ) -> PanelLayout {
552        if Self::axes_is_3d(plot_renderer, axes_index) {
553            self.layout_3d_panel(
554                outer,
555                plot_renderer
556                    .overlay_title_for_axes(axes_index)
557                    .map(|s| s.as_str()),
558                scale,
559            )
560        } else {
561            self.layout_2d_panel(
562                outer,
563                plot_renderer,
564                axes_index,
565                plot_renderer
566                    .overlay_title_for_axes(axes_index)
567                    .map(|s| s.as_str()),
568                plot_renderer
569                    .overlay_x_label_for_axes(axes_index)
570                    .map(|s| s.as_str()),
571                plot_renderer
572                    .overlay_y_label_for_axes(axes_index)
573                    .map(|s| s.as_str()),
574                scale,
575            )
576        }
577    }
578
579    fn reserve_sg_title_band(
580        &self,
581        outer: Rect,
582        plot_renderer: &PlotRenderer,
583        scale: f32,
584    ) -> (Rect, Option<Rect>) {
585        let Some(title) = plot_renderer.overlay_sg_title().map(String::as_str) else {
586            return (outer, None);
587        };
588        if !Self::has_visible_text(Some(title)) {
589            return (outer, None);
590        }
591
592        let scale = scale.max(0.75);
593        let band_h = (30.0 * scale).min(outer.height().max(1.0) * 0.14);
594        let gap = 4.0 * scale;
595        let title_max_y = (outer.min.y + band_h).min(outer.max.y);
596        let content_min_y = (title_max_y + gap).min(outer.max.y);
597        (
598            Rect::from_min_max(egui::pos2(outer.min.x, content_min_y), outer.max),
599            Some(Rect::from_min_max(
600                outer.min,
601                egui::pos2(outer.max.x, title_max_y),
602            )),
603        )
604    }
605
606    fn compute_subplot_plot_rects_impl(
607        &self,
608        outer: Rect,
609        plot_renderer: &PlotRenderer,
610        font_scale: f32,
611        show_title: bool,
612    ) -> Vec<Rect> {
613        let plot_area = Self::outer_plot_area_for_axes(outer, plot_renderer);
614        let (plot_area, _) = if show_title {
615            self.reserve_sg_title_band(plot_area, plot_renderer, font_scale)
616        } else {
617            (plot_area, None)
618        };
619        let (rows, cols) = plot_renderer.figure_axes_grid();
620        if rows * cols <= 1 {
621            vec![
622                self.panel_layout_for_axes(plot_area, plot_renderer, 0, font_scale)
623                    .plot_rect,
624            ]
625        } else {
626            let rects = self.compute_subplot_rects(
627                plot_area,
628                rows,
629                cols,
630                Self::SUBPLOT_GAP_POINTS,
631                Self::SUBPLOT_GAP_POINTS,
632            );
633            rects
634                .into_iter()
635                .enumerate()
636                .map(|(axes_index, rect)| {
637                    self.panel_layout_for_axes(rect, plot_renderer, axes_index, font_scale)
638                        .plot_rect
639                })
640                .collect()
641        }
642    }
643
644    pub fn compute_subplot_plot_rects(
645        &self,
646        outer: Rect,
647        plot_renderer: &PlotRenderer,
648        font_scale: f32,
649    ) -> Vec<Rect> {
650        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
651    }
652
653    /// Like [`compute_subplot_plot_rects`] but lets the caller control whether the
654    /// super-title band is reserved. Pass `show_title: false` when the overlay is
655    /// rendered without titles so that GPU viewports and hit-rects stay aligned.
656    pub fn compute_subplot_plot_rects_explicit(
657        &self,
658        outer: Rect,
659        plot_renderer: &PlotRenderer,
660        font_scale: f32,
661        show_title: bool,
662    ) -> Vec<Rect> {
663        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
664    }
665
666    pub fn snap_rect_to_pixels(rect: Rect, pixels_per_point: f32) -> Rect {
667        let ppp = pixels_per_point.max(0.5);
668        let min_x = (rect.min.x * ppp).round() / ppp;
669        let min_y = (rect.min.y * ppp).round() / ppp;
670        let width = (rect.width() * ppp).round().max(1.0) / ppp;
671        let height = (rect.height() * ppp).round().max(1.0) / ppp;
672        Rect::from_min_size(egui::pos2(min_x, min_y), egui::vec2(width, height))
673    }
674
675    fn snap_coord(value: f32, pixels_per_point: f32) -> f32 {
676        let ppp = pixels_per_point.max(0.5);
677        (value * ppp).round() / ppp
678    }
679
680    fn border_centerline_edges(
681        plot_rect: Rect,
682        pixels_per_point: f32,
683        stroke_width: f32,
684    ) -> (f32, f32, f32, f32) {
685        let offset = stroke_width * 0.5;
686        let left = Self::snap_coord(plot_rect.min.x - offset, pixels_per_point);
687        let right = Self::snap_coord(plot_rect.max.x + offset, pixels_per_point);
688        let top = Self::snap_coord(plot_rect.min.y - offset, pixels_per_point);
689        let bottom = Self::snap_coord(plot_rect.max.y + offset, pixels_per_point);
690        (left, right, top, bottom)
691    }
692
693    fn draw_2d_border(&self, ui: &mut egui::Ui, plot_rect: Rect) {
694        let stroke = Stroke::new(1.5, self.theme_axis_color());
695        let ppp = ui.ctx().pixels_per_point();
696        let (left, right, top, bottom) =
697            Self::border_centerline_edges(plot_rect, ppp, stroke.width);
698        ui.painter()
699            .line_segment([Pos2::new(left, top), Pos2::new(right, top)], stroke);
700        ui.painter()
701            .line_segment([Pos2::new(left, bottom), Pos2::new(right, bottom)], stroke);
702        ui.painter()
703            .line_segment([Pos2::new(left, top), Pos2::new(left, bottom)], stroke);
704        ui.painter()
705            .line_segment([Pos2::new(right, top), Pos2::new(right, bottom)], stroke);
706    }
707
708    fn draw_plot_box_mask(&self, ui: &mut egui::Ui, plot_rect: Rect) {
709        let mask = 2.0;
710        let bg = self.theme_background_color();
711        let top = Rect::from_min_max(
712            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
713            Pos2::new(plot_rect.max.x + mask, plot_rect.min.y),
714        );
715        let bottom = Rect::from_min_max(
716            Pos2::new(plot_rect.min.x - mask, plot_rect.max.y),
717            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
718        );
719        let left = Rect::from_min_max(
720            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
721            Pos2::new(plot_rect.min.x, plot_rect.max.y + mask),
722        );
723        let right = Rect::from_min_max(
724            Pos2::new(plot_rect.max.x, plot_rect.min.y - mask),
725            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
726        );
727        ui.painter().rect_filled(top, 0.0, bg);
728        ui.painter().rect_filled(bottom, 0.0, bg);
729        ui.painter().rect_filled(left, 0.0, bg);
730        ui.painter().rect_filled(right, 0.0, bg);
731    }
732
733    pub fn compute_subplot_plot_rects_snapped(
734        &self,
735        outer: Rect,
736        plot_renderer: &PlotRenderer,
737        font_scale: f32,
738        pixels_per_point: f32,
739    ) -> Vec<Rect> {
740        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
741            .into_iter()
742            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
743            .collect()
744    }
745
746    /// Like [`compute_subplot_plot_rects_snapped`] but lets the caller control whether
747    /// the super-title band is reserved. Pass `show_title: false` when the overlay is
748    /// rendered without titles so that GPU viewports stay aligned with the drawn area.
749    pub fn compute_subplot_plot_rects_snapped_explicit(
750        &self,
751        outer: Rect,
752        plot_renderer: &PlotRenderer,
753        font_scale: f32,
754        pixels_per_point: f32,
755        show_title: bool,
756    ) -> Vec<Rect> {
757        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
758            .into_iter()
759            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
760            .collect()
761    }
762
763    pub fn outer_plot_area_for_axes(available_rect: Rect, plot_renderer: &PlotRenderer) -> Rect {
764        let (rows, cols) = plot_renderer.figure_axes_grid();
765        let single_axes_is_3d = rows * cols <= 1 && Self::axes_is_3d(plot_renderer, 0);
766        if single_axes_is_3d || rows * cols > 1 {
767            available_rect
768        } else {
769            available_rect.shrink2(egui::vec2(8.0, 8.0))
770        }
771    }
772
773    fn theme_text_color(&self) -> Color32 {
774        let text = self.theme.build_theme().get_text_color();
775        Color32::from_rgba_premultiplied(
776            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
777            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
778            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
779            (text.w.clamp(0.0, 1.0) * 255.0) as u8,
780        )
781    }
782
783    fn theme_axis_color(&self) -> Color32 {
784        let axis = self.theme.build_theme().get_axis_color();
785        Color32::from_rgba_premultiplied(
786            (axis.x.clamp(0.0, 1.0) * 255.0) as u8,
787            (axis.y.clamp(0.0, 1.0) * 255.0) as u8,
788            (axis.z.clamp(0.0, 1.0) * 255.0) as u8,
789            (axis.w.clamp(0.0, 1.0) * 255.0) as u8,
790        )
791    }
792
793    fn theme_background_color(&self) -> Color32 {
794        let bg = self.theme.build_theme().get_background_color();
795        Color32::from_rgba_premultiplied(
796            (bg.x.clamp(0.0, 1.0) * 255.0) as u8,
797            (bg.y.clamp(0.0, 1.0) * 255.0) as u8,
798            (bg.z.clamp(0.0, 1.0) * 255.0) as u8,
799            (bg.w.clamp(0.0, 1.0) * 255.0) as u8,
800        )
801    }
802
803    fn themed_grid_colors(&self) -> (Color32, Color32) {
804        let grid = self.theme.build_theme().get_grid_color();
805        let major = Color32::from_rgba_premultiplied(
806            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
807            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
808            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
809            ((grid.w.clamp(0.15, 0.55)) * 255.0) as u8,
810        );
811        let minor = Color32::from_rgba_premultiplied(
812            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
813            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
814            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
815            ((grid.w * 0.6).clamp(0.10, 0.34) * 255.0) as u8,
816        );
817        (major, minor)
818    }
819
820    /// Apply theme to egui context
821    pub fn apply_theme(&self, ctx: &Context) {
822        match self.theme.variant {
823            ThemeVariant::ModernDark => {
824                ModernDarkTheme::default().apply_to_egui(ctx);
825            }
826            ThemeVariant::ClassicLight => {
827                ctx.set_visuals(egui::Visuals::light());
828            }
829            ThemeVariant::HighContrast => {
830                let mut visuals = egui::Visuals::dark();
831                visuals.extreme_bg_color = egui::Color32::BLACK;
832                visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
833                visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
834                ctx.set_visuals(visuals);
835            }
836            ThemeVariant::Custom => {
837                let mut visuals = egui::Visuals::light();
838                let bg = self.theme.build_theme().get_background_color();
839                if bg.x + bg.y + bg.z < 1.5 {
840                    visuals = egui::Visuals::dark();
841                }
842                ctx.set_visuals(visuals);
843            }
844        }
845
846        // Make context transparent so WGPU content shows through
847        let mut visuals = ctx.style().visuals.clone();
848        visuals.window_fill = Color32::TRANSPARENT;
849        visuals.panel_fill = Color32::TRANSPARENT;
850        visuals.extreme_bg_color = Color32::TRANSPARENT;
851        visuals.faint_bg_color = Color32::TRANSPARENT;
852        visuals.widgets.noninteractive.bg_fill = Color32::TRANSPARENT;
853        visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
854        visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
855        visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
856        visuals.widgets.open.bg_fill = Color32::TRANSPARENT;
857        ctx.set_visuals(visuals);
858    }
859
860    /// Render the complete overlay UI
861    pub fn render(
862        &mut self,
863        ctx: &Context,
864        plot_renderer: &PlotRenderer,
865        config: &OverlayConfig,
866        metrics: OverlayMetrics,
867    ) -> FrameInfo {
868        let mut consumed_input = false;
869        let mut plot_area = None;
870
871        // Render sidebar if enabled
872        if config.show_sidebar {
873            consumed_input |= self.render_sidebar(ctx, plot_renderer, config, &metrics);
874        }
875
876        // Render main plot area
877        let central_response = egui::CentralPanel::default()
878            .frame(transparent_frame()) // Transparent frame
879            .show(ctx, |ui| {
880                // Toolbar (top-right)
881                if config.show_toolbar {
882                    egui::TopBottomPanel::top("plot_toolbar")
883                        .frame(transparent_frame())
884                        .show_inside(ui, |ui| {
885                            let padded = ui.max_rect().shrink2(egui::vec2(12.0, 6.0));
886                            self.toolbar_rect = Some(padded);
887                            let add_toolbar = |ui: &mut egui::Ui| {
888                                ui.with_layout(
889                                    egui::Layout::right_to_left(egui::Align::Center),
890                                    |ui| {
891                                        ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
892                                        ui.spacing_mut().button_padding = egui::vec2(8.0, 6.0);
893                                        if ui.button("Save PNG").clicked() {
894                                            self.want_save_png = true;
895                                        }
896                                        if ui.button("Save SVG").clicked() {
897                                            self.want_save_svg = true;
898                                        }
899                                        if ui.button("Reset View").clicked() {
900                                            self.want_reset_view = true;
901                                        }
902                                        let mut grid = plot_renderer.overlay_show_grid();
903                                        if ui.toggle_value(&mut grid, "Grid").changed() {
904                                            self.want_toggle_grid = Some(grid);
905                                        }
906                                        let mut legend = plot_renderer.overlay_show_legend();
907                                        if ui.toggle_value(&mut legend, "Legend").changed() {
908                                            self.want_toggle_legend = Some(legend);
909                                        }
910                                    },
911                                );
912                            };
913                            #[cfg(target_arch = "wasm32")]
914                            ui.allocate_new_ui(
915                                egui::UiBuilder::new().max_rect(padded),
916                                add_toolbar,
917                            );
918                            #[cfg(not(target_arch = "wasm32"))]
919                            ui.allocate_ui_at_rect(padded, add_toolbar);
920                        });
921                } else {
922                    self.toolbar_rect = None;
923                }
924                plot_area = Some(self.render_plot_area(ui, plot_renderer, config));
925            });
926
927        consumed_input |= central_response.response.hovered();
928
929        // Render Dystr modal if needed
930        if self.show_dystr_modal {
931            consumed_input |= self.render_dystr_modal(ctx);
932        }
933
934        // Store plot area for next frame
935        self.plot_area = plot_area;
936
937        FrameInfo {
938            plot_area,
939            consumed_input,
940            metrics,
941        }
942    }
943
944    /// Render the sidebar with controls and information
945    fn render_sidebar(
946        &mut self,
947        ctx: &Context,
948        plot_renderer: &PlotRenderer,
949        config: &OverlayConfig,
950        metrics: &OverlayMetrics,
951    ) -> bool {
952        let mut consumed_input = false;
953
954        let sidebar_response = egui::SidePanel::left("plot_controls")
955            .resizable(true)
956            .default_width(config.sidebar_width)
957            .min_width(200.0)
958            .show(ctx, |ui| {
959                ui.style_mut().visuals.widgets.noninteractive.bg_fill = Color32::from_gray(25);
960                ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::from_gray(35);
961                ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::from_gray(45);
962
963                // Header with Dystr branding
964                ui.horizontal(|ui| {
965                    // Placeholder for Dystr logo (32x32 square)
966                    let logo_size = egui::Vec2::splat(32.0);
967                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::click()).0;
968
969                    // Draw placeholder logo background
970                    ui.painter().rect_filled(
971                        logo_rect,
972                        4.0, // rounded corners
973                        Color32::from_rgb(100, 100, 100),
974                    );
975
976                    // Draw "D" placeholder text in the logo area
977                    ui.painter().text(
978                        logo_rect.center(),
979                        Align2::CENTER_CENTER,
980                        "D",
981                        FontId::proportional(20.0),
982                        Color32::WHITE,
983                    );
984
985                    ui.vertical(|ui| {
986                        ui.heading("RunMat");
987                        ui.horizontal(|ui| {
988                            ui.small("a community project by ");
989                            if ui.small_button("dystr.com").clicked() {
990                                self.show_dystr_modal = true;
991                            }
992                        });
993                    });
994                });
995                ui.separator();
996                ui.label("GC Stats: [not available]");
997
998                // Camera information
999                ui.collapsing("📷 Camera", |ui| {
1000                    let camera = plot_renderer.camera();
1001                    ui.label(format!(
1002                        "Position: {:.2}, {:.2}, {:.2}",
1003                        camera.position.x, camera.position.y, camera.position.z
1004                    ));
1005                    ui.label(format!(
1006                        "Target: {:.2}, {:.2}, {:.2}",
1007                        camera.target.x, camera.target.y, camera.target.z
1008                    ));
1009
1010                    if let Some(vb) = plot_renderer.view_bounds() {
1011                        ui.label(format!("View X: {:.2} to {:.2}", vb.0, vb.1));
1012                        ui.label(format!("View Y: {:.2} to {:.2}", vb.2, vb.3));
1013                    }
1014                    if let Some(db) = plot_renderer.data_bounds() {
1015                        ui.label(format!("Data X: {:.2} to {:.2}", db.0, db.1));
1016                        ui.label(format!("Data Y: {:.2} to {:.2}", db.2, db.3));
1017                    }
1018                });
1019
1020                // Scene information
1021                ui.collapsing("🎬 Scene", |ui| {
1022                    let stats = plot_renderer.scene_statistics();
1023                    ui.label(format!("Nodes: {}", stats.total_nodes));
1024                    ui.label(format!("Visible: {}", stats.visible_nodes));
1025                    ui.label(format!("Vertices: {}", stats.total_vertices));
1026                    ui.label(format!("Triangles: {}", stats.total_triangles));
1027                });
1028
1029                // Performance metrics
1030                ui.collapsing("⚡ Performance", |ui| {
1031                    ui.label(format!("FPS: {:.1}", metrics.fps));
1032                    ui.label(format!("Render: {:.2}ms", metrics.render_time_ms));
1033                    ui.label(format!("Vertices: {}", metrics.vertex_count));
1034                    ui.label(format!("Triangles: {}", metrics.triangle_count));
1035                });
1036
1037                // Theme selection
1038                ui.collapsing("🎨 Theme", |ui| {
1039                    let label = match self.theme.variant {
1040                        ThemeVariant::ModernDark => "Modern Dark",
1041                        ThemeVariant::ClassicLight => "Classic Light",
1042                        ThemeVariant::HighContrast => "High Contrast",
1043                        ThemeVariant::Custom => "Custom",
1044                    };
1045                    ui.label(format!("{label} (Active)"));
1046                    ui.checkbox(&mut self.show_debug, "Show Debug Info");
1047                });
1048
1049                ui.separator();
1050
1051                // Plot controls
1052                ui.collapsing("🔧 Controls", |ui| {
1053                    ui.label("🖱️ Orbit: MMB drag (or RMB drag)");
1054                    ui.label("🖱️ Pan: Shift + MMB drag (or Shift + RMB drag)");
1055                    ui.label("🖱️ Zoom: Scroll wheel (zooms to cursor)");
1056                    ui.label("🖱️ Alt + LMB/MMB/RMB: Orbit/Pan/Zoom");
1057                    ui.label("📱 Touch: Pinch to zoom");
1058                });
1059            });
1060
1061        consumed_input |= sidebar_response.response.hovered();
1062        self.sidebar_rect = Some(sidebar_response.response.rect);
1063        consumed_input
1064    }
1065
1066    /// Render the main plot area with grid, axes, and annotations
1067    fn render_plot_area(
1068        &mut self,
1069        ui: &mut egui::Ui,
1070        plot_renderer: &PlotRenderer,
1071        config: &OverlayConfig,
1072    ) -> Rect {
1073        let available_rect = ui.available_rect_before_wrap();
1074        let mut rendered_axes_rects: Vec<Rect> = Vec::new();
1075
1076        let (rows, cols) = plot_renderer.figure_axes_grid();
1077        let plot_rect = Self::outer_plot_area_for_axes(available_rect, plot_renderer);
1078        let (plot_rect, sg_title_rect) = if config.show_title {
1079            self.reserve_sg_title_band(plot_rect, plot_renderer, config.font_scale)
1080        } else {
1081            (plot_rect, None)
1082        };
1083
1084        // Use full available rectangular plot area (do not force square);
1085        // camera fitting and axis_equal settings will control aspect.
1086        let plot_area_rect = plot_rect;
1087
1088        if let Some(title_rect) = sg_title_rect {
1089            if let Some(title) = plot_renderer.overlay_sg_title() {
1090                self.draw_title_in_rect(
1091                    ui,
1092                    title_rect,
1093                    title,
1094                    Some(plot_renderer.overlay_sg_title_style()),
1095                    config.font_scale,
1096                );
1097            }
1098        }
1099
1100        if rows * cols > 1 {
1101            let rects = self.compute_subplot_rects(
1102                plot_area_rect,
1103                rows,
1104                cols,
1105                Self::SUBPLOT_GAP_POINTS,
1106                Self::SUBPLOT_GAP_POINTS,
1107            );
1108            for (i, cell_rect) in rects.iter().enumerate() {
1109                let cam = plot_renderer
1110                    .axes_camera(i)
1111                    .unwrap_or_else(|| plot_renderer.camera());
1112                let panel_layout =
1113                    self.panel_layout_for_axes(*cell_rect, plot_renderer, i, config.font_scale);
1114                let r =
1115                    Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1116                let frame_rect =
1117                    Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1118                rendered_axes_rects.push(r);
1119                log::debug!(
1120                    target: "runmat_plot.axes_layout",
1121                    "computed axes panel layout axes_index={} rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1122                    i,
1123                    rows,
1124                    cols,
1125                    Self::axes_is_3d(plot_renderer, i),
1126                    cell_rect.min.x,
1127                    cell_rect.min.y,
1128                    cell_rect.max.x,
1129                    cell_rect.max.y,
1130                    frame_rect.min.x,
1131                    frame_rect.min.y,
1132                    frame_rect.max.x,
1133                    frame_rect.max.y,
1134                    r.min.x,
1135                    r.min.y,
1136                    r.max.x,
1137                    r.max.y
1138                );
1139                if matches!(
1140                    cam.projection,
1141                    crate::core::camera::ProjectionType::Perspective { .. }
1142                ) {
1143                    if config.show_title {
1144                        if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1145                            self.draw_title_in_rect(
1146                                ui,
1147                                panel_layout.title_rect,
1148                                title,
1149                                None,
1150                                config.font_scale,
1151                            );
1152                        }
1153                    }
1154                    self.draw_3d_orientation_gizmo(ui, r, plot_renderer, i, config.font_scale);
1155                    self.draw_3d_origin_axis_ticks(ui, r, plot_renderer, i, config.font_scale);
1156                    self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1157                    for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1158                        self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1159                    }
1160                    if plot_renderer.overlay_show_legend_for_axes(i) {
1161                        let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1162                        self.draw_legend(ui, r, &entries, config.font_scale);
1163                    }
1164                    continue;
1165                }
1166                // Frame (2D only; 3D uses the axes cube instead)
1167                if plot_renderer.overlay_show_box_for_axes(i) {
1168                    self.draw_plot_box_mask(ui, r);
1169                    self.draw_2d_border(ui, frame_rect);
1170                }
1171
1172                // Grid (2D)
1173                if config.show_grid
1174                    && (plot_renderer.overlay_show_grid_for_axes(i)
1175                        || plot_renderer.overlay_show_minor_grid_for_axes(i))
1176                {
1177                    let b = plot_renderer.view_bounds_for_axes(i);
1178                    self.draw_grid(ui, r, plot_renderer, b, Some(i));
1179                }
1180                // Axes (2D)
1181                if config.show_axes {
1182                    let b = plot_renderer.view_bounds_for_axes(i);
1183                    self.draw_axes(ui, r, plot_renderer, config, b, Some(i));
1184                }
1185
1186                if config.show_title {
1187                    if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1188                        self.draw_title_in_rect(
1189                            ui,
1190                            panel_layout.title_rect,
1191                            title,
1192                            None,
1193                            config.font_scale,
1194                        );
1195                    }
1196                }
1197                if !matches!(
1198                    cam.projection,
1199                    crate::core::camera::ProjectionType::Perspective { .. }
1200                ) {
1201                    if let Some(x_label) = plot_renderer.overlay_x_label_for_axes(i) {
1202                        self.draw_x_label_in_rect(
1203                            ui,
1204                            panel_layout.x_label_rect,
1205                            x_label,
1206                            config.font_scale,
1207                        );
1208                    }
1209                }
1210                if !matches!(
1211                    cam.projection,
1212                    crate::core::camera::ProjectionType::Perspective { .. }
1213                ) {
1214                    if let Some(y_label) = plot_renderer.overlay_y_label_for_axes(i) {
1215                        self.draw_y_label_in_rect(
1216                            ui,
1217                            panel_layout.y_label_rect,
1218                            y_label,
1219                            config.font_scale,
1220                        );
1221                    }
1222                }
1223                self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1224                for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1225                    self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1226                }
1227                if plot_renderer.overlay_show_legend_for_axes(i) {
1228                    let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1229                    self.draw_legend(ui, r, &entries, config.font_scale);
1230                }
1231            }
1232        } else {
1233            let cam = plot_renderer.camera();
1234            let panel_layout =
1235                self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale);
1236            let centered_plot_rect =
1237                Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1238            let centered_frame_rect =
1239                Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1240            rendered_axes_rects.push(centered_plot_rect);
1241            log::debug!(
1242                target: "runmat_plot.axes_layout",
1243                "computed axes panel layout axes_index=0 rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1244                rows,
1245                cols,
1246                Self::axes_is_3d(plot_renderer, 0),
1247                plot_area_rect.min.x,
1248                plot_area_rect.min.y,
1249                plot_area_rect.max.x,
1250                plot_area_rect.max.y,
1251                centered_frame_rect.min.x,
1252                centered_frame_rect.min.y,
1253                centered_frame_rect.max.x,
1254                centered_frame_rect.max.y,
1255                centered_plot_rect.min.x,
1256                centered_plot_rect.min.y,
1257                centered_plot_rect.max.x,
1258                centered_plot_rect.max.y
1259            );
1260            if config.show_title {
1261                if let Some(title) = plot_renderer
1262                    .overlay_title_for_axes(0)
1263                    .or(config.title.as_ref())
1264                {
1265                    self.draw_title_in_rect(
1266                        ui,
1267                        panel_layout.title_rect,
1268                        title,
1269                        None,
1270                        config.font_scale,
1271                    );
1272                }
1273            }
1274            if matches!(
1275                cam.projection,
1276                crate::core::camera::ProjectionType::Perspective { .. }
1277            ) {
1278                self.draw_3d_orientation_gizmo(
1279                    ui,
1280                    centered_plot_rect,
1281                    plot_renderer,
1282                    0,
1283                    config.font_scale,
1284                );
1285                self.draw_3d_origin_axis_ticks(
1286                    ui,
1287                    centered_plot_rect,
1288                    plot_renderer,
1289                    0,
1290                    config.font_scale,
1291                );
1292                self.draw_projected_world_texts(
1293                    ui,
1294                    centered_plot_rect,
1295                    plot_renderer,
1296                    0,
1297                    config.font_scale,
1298                );
1299            } else {
1300                // Draw plot frame (2D only; 3D uses the axes cube instead)
1301                if plot_renderer.overlay_show_box() {
1302                    self.draw_plot_box_mask(ui, centered_plot_rect);
1303                    self.draw_2d_border(ui, centered_frame_rect);
1304                }
1305                // Draw grid if enabled
1306                if config.show_grid
1307                    && (plot_renderer.overlay_show_grid()
1308                        || plot_renderer.overlay_show_minor_grid())
1309                {
1310                    self.draw_grid(ui, centered_plot_rect, plot_renderer, None, None);
1311                }
1312
1313                // Draw axes if enabled
1314                if config.show_axes {
1315                    self.draw_axes(ui, centered_plot_rect, plot_renderer, config, None, None);
1316                    // Emphasize zero baseline if within data range
1317                    if let Some((x_min, x_max, y_min, y_max)) = plot_renderer
1318                        .view_bounds()
1319                        .or_else(|| plot_renderer.data_bounds())
1320                    {
1321                        let axis_color = self.theme_axis_color();
1322                        let zero_stroke = Stroke::new(1.5, axis_color);
1323                        if y_min < 0.0 && y_max > 0.0 {
1324                            let y_screen = centered_plot_rect.max.y
1325                                - ((0.0 - y_min) / (y_max - y_min)) as f32
1326                                    * centered_plot_rect.height();
1327                            ui.painter().line_segment(
1328                                [
1329                                    Pos2::new(centered_plot_rect.min.x, y_screen),
1330                                    Pos2::new(centered_plot_rect.max.x, y_screen),
1331                                ],
1332                                zero_stroke,
1333                            );
1334                        }
1335                        if x_min < 0.0 && x_max > 0.0 {
1336                            let x_screen = centered_plot_rect.min.x
1337                                + ((0.0 - x_min) / (x_max - x_min)) as f32
1338                                    * centered_plot_rect.width();
1339                            ui.painter().line_segment(
1340                                [
1341                                    Pos2::new(x_screen, centered_plot_rect.min.y),
1342                                    Pos2::new(x_screen, centered_plot_rect.max.y),
1343                                ],
1344                                zero_stroke,
1345                            );
1346                        }
1347                    }
1348                }
1349                if let Some(x_label) = plot_renderer
1350                    .overlay_x_label_for_axes(0)
1351                    .or(config.x_label.as_ref())
1352                {
1353                    self.draw_x_label_in_rect(
1354                        ui,
1355                        panel_layout.x_label_rect,
1356                        x_label,
1357                        config.font_scale,
1358                    );
1359                }
1360                if let Some(y_label) = plot_renderer
1361                    .overlay_y_label_for_axes(0)
1362                    .or(config.y_label.as_ref())
1363                {
1364                    self.draw_y_label_in_rect(
1365                        ui,
1366                        panel_layout.y_label_rect,
1367                        y_label,
1368                        config.font_scale,
1369                    );
1370                }
1371                self.draw_projected_world_texts(
1372                    ui,
1373                    centered_plot_rect,
1374                    plot_renderer,
1375                    0,
1376                    config.font_scale,
1377                );
1378            }
1379        }
1380        let centered_plot_rect = if rows * cols <= 1 {
1381            self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale)
1382                .plot_rect
1383        } else {
1384            plot_area_rect
1385        };
1386        for (label, pos) in if rows * cols <= 1 {
1387            plot_renderer.active_axes_pie_labels()
1388        } else {
1389            Vec::new()
1390        } {
1391            self.draw_pie_label(ui, centered_plot_rect, &label, pos, config.font_scale);
1392        }
1393
1394        // Draw legend if enabled and entries available
1395        if rows * cols <= 1 && plot_renderer.overlay_show_legend() {
1396            self.draw_legend(
1397                ui,
1398                centered_plot_rect,
1399                plot_renderer.overlay_legend_entries(),
1400                config.font_scale,
1401            );
1402        }
1403
1404        // Draw colorbar if enabled
1405        if plot_renderer.overlay_colorbar_enabled() {
1406            // Simple vertical colorbar on the right side inside plot
1407            let bar_width = 12.0;
1408            let pad = 8.0;
1409            let bar_rect = Rect::from_min_max(
1410                egui::pos2(
1411                    centered_plot_rect.max.x - bar_width - pad,
1412                    centered_plot_rect.min.y + pad,
1413                ),
1414                egui::pos2(
1415                    centered_plot_rect.max.x - pad,
1416                    centered_plot_rect.max.y - pad,
1417                ),
1418            );
1419            // Fill with gradient according to colormap
1420            let steps = 64;
1421            for i in 0..steps {
1422                let t0 = i as f32 / steps as f32;
1423                let t1 = (i + 1) as f32 / steps as f32;
1424                let y0 = bar_rect.min.y + (1.0 - t0) * bar_rect.height();
1425                let y1 = bar_rect.min.y + (1.0 - t1) * bar_rect.height();
1426                let cmap = plot_renderer.overlay_colormap();
1427                let c = cmap.map_value(t0);
1428                let col = Color32::from_rgb(
1429                    (c.x * 255.0) as u8,
1430                    (c.y * 255.0) as u8,
1431                    (c.z * 255.0) as u8,
1432                );
1433                ui.painter().rect_filled(
1434                    Rect::from_min_max(
1435                        egui::pos2(bar_rect.min.x, y1),
1436                        egui::pos2(bar_rect.max.x, y0),
1437                    ),
1438                    0.0,
1439                    col,
1440                );
1441            }
1442            let bg = plot_renderer.theme.build_theme().get_background_color();
1443            let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
1444            let border = if bg_luma > 0.62 {
1445                Color32::from_gray(60)
1446            } else {
1447                Color32::WHITE
1448            };
1449            #[cfg(target_arch = "wasm32")]
1450            ui.painter().rect_stroke(
1451                bar_rect,
1452                0.0,
1453                Stroke::new(1.0, border),
1454                egui::StrokeKind::Inside,
1455            );
1456            #[cfg(not(target_arch = "wasm32"))]
1457            ui.painter()
1458                .rect_stroke(bar_rect, 0.0, Stroke::new(1.0, border));
1459        }
1460
1461        self.axes_plot_rects = rendered_axes_rects;
1462        centered_plot_rect
1463    }
1464
1465    /// Compute subplot rectangles within a given plot area for a rows x cols grid (row-major)
1466    pub fn compute_subplot_rects(
1467        &self,
1468        outer: Rect,
1469        rows: usize,
1470        cols: usize,
1471        hgap: f32,
1472        vgap: f32,
1473    ) -> Vec<Rect> {
1474        let rows = rows.max(1) as f32;
1475        let cols = cols.max(1) as f32;
1476        let total_hgap = hgap * (cols - 1.0);
1477        let total_vgap = vgap * (rows - 1.0);
1478        let cell_w = ((outer.width()).max(1.0) - total_hgap).max(1.0) / cols;
1479        let cell_h = ((outer.height()).max(1.0) - total_vgap).max(1.0) / rows;
1480        let mut rects = Vec::new();
1481        for r in 0..rows as i32 {
1482            for c in 0..cols as i32 {
1483                let x = outer.min.x + c as f32 * (cell_w + hgap);
1484                let y = outer.min.y + r as f32 * (cell_h + vgap);
1485                rects.push(Rect::from_min_size(
1486                    egui::pos2(x, y),
1487                    egui::vec2(cell_w, cell_h),
1488                ));
1489            }
1490        }
1491        rects
1492    }
1493
1494    /// Draw grid lines based on data bounds
1495    fn draw_grid(
1496        &self,
1497        ui: &mut egui::Ui,
1498        plot_rect: Rect,
1499        plot_renderer: &PlotRenderer,
1500        view_bounds_override: Option<(f64, f64, f64, f64)>,
1501        axes_index: Option<usize>,
1502    ) {
1503        let ppp = ui.ctx().pixels_per_point();
1504        let edge_eps = 0.51 / ppp.max(0.5);
1505        if let Some(data_bounds) = view_bounds_override
1506            .or_else(|| plot_renderer.view_bounds())
1507            .or_else(|| plot_renderer.data_bounds())
1508        {
1509            let (grid_color_major, grid_color_minor) = self.themed_grid_colors();
1510
1511            let (x_min, x_max, y_min, y_max) = data_bounds;
1512            let x_range = x_max - x_min;
1513            let y_range = y_max - y_min;
1514
1515            // Calculate tick intervals
1516            let x_log = axes_index
1517                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1518                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1519            let y_log = axes_index
1520                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1521                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1522            let show_major_grid = axes_index
1523                .map(|idx| plot_renderer.overlay_show_grid_for_axes(idx))
1524                .unwrap_or_else(|| plot_renderer.overlay_show_grid());
1525            let show_minor_grid = axes_index
1526                .map(|idx| plot_renderer.overlay_show_minor_grid_for_axes(idx))
1527                .unwrap_or_else(|| plot_renderer.overlay_show_minor_grid());
1528
1529            let x_ticks = if x_log {
1530                Vec::new()
1531            } else {
1532                plot_utils::generate_major_ticks(x_min, x_max)
1533            };
1534            let y_ticks = if y_log {
1535                Vec::new()
1536            } else {
1537                plot_utils::generate_major_ticks(y_min, y_max)
1538            };
1539
1540            // Draw vertical grid lines (linear vs log)
1541            if x_log {
1542                // Decades within [x_min, x_max]
1543                let start_decade = x_min.log10().floor() as i32;
1544                let end_decade = x_max.log10().ceil() as i32;
1545                for d in start_decade..=end_decade {
1546                    let decade = 10f64.powi(d);
1547                    for mantissa in 1..=9 {
1548                        let x_val = decade * mantissa as f64;
1549                        if x_val < x_min || x_val > x_max {
1550                            continue;
1551                        }
1552                        let is_major = mantissa == 1;
1553                        if (is_major && !show_major_grid) || (!is_major && !show_minor_grid) {
1554                            continue;
1555                        }
1556                        let x_screen = plot_rect.min.x
1557                            + ((x_val.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1558                                as f32
1559                                * plot_rect.width();
1560                        let x_screen = Self::snap_coord(x_screen, ppp);
1561                        if (x_screen - plot_rect.min.x).abs() <= edge_eps
1562                            || (x_screen - plot_rect.max.x).abs() <= edge_eps
1563                        {
1564                            continue;
1565                        }
1566                        ui.painter().line_segment(
1567                            [
1568                                Pos2::new(x_screen, plot_rect.min.y),
1569                                Pos2::new(x_screen, plot_rect.max.y),
1570                            ],
1571                            Stroke::new(
1572                                if is_major { 0.8 } else { 0.6 },
1573                                if is_major {
1574                                    grid_color_major
1575                                } else {
1576                                    grid_color_minor
1577                                },
1578                            ),
1579                        );
1580                    }
1581                }
1582            } else {
1583                if show_minor_grid {
1584                    for pair in x_ticks.windows(2) {
1585                        let step = (pair[1] - pair[0]) / 5.0;
1586                        if !step.is_finite() || step <= 0.0 {
1587                            continue;
1588                        }
1589                        for n in 1..5 {
1590                            let x_val = pair[0] + step * n as f64;
1591                            if x_val < x_min || x_val > x_max {
1592                                continue;
1593                            }
1594                            let x_screen = plot_rect.min.x
1595                                + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1596                            let x_screen = Self::snap_coord(x_screen, ppp);
1597                            if (x_screen - plot_rect.min.x).abs() <= edge_eps
1598                                || (x_screen - plot_rect.max.x).abs() <= edge_eps
1599                            {
1600                                continue;
1601                            }
1602                            ui.painter().line_segment(
1603                                [
1604                                    Pos2::new(x_screen, plot_rect.min.y),
1605                                    Pos2::new(x_screen, plot_rect.max.y),
1606                                ],
1607                                Stroke::new(0.6, grid_color_minor),
1608                            );
1609                        }
1610                    }
1611                }
1612                if show_major_grid {
1613                    for x_val in x_ticks {
1614                        let x_screen = plot_rect.min.x
1615                            + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1616                        let x_screen = Self::snap_coord(x_screen, ppp);
1617                        if (x_screen - plot_rect.min.x).abs() <= edge_eps
1618                            || (x_screen - plot_rect.max.x).abs() <= edge_eps
1619                        {
1620                            continue;
1621                        }
1622                        ui.painter().line_segment(
1623                            [
1624                                Pos2::new(x_screen, plot_rect.min.y),
1625                                Pos2::new(x_screen, plot_rect.max.y),
1626                            ],
1627                            Stroke::new(0.8, grid_color_major),
1628                        );
1629                    }
1630                }
1631            }
1632
1633            // Draw horizontal grid lines (linear vs log)
1634            if y_log {
1635                let start_decade = y_min.log10().floor() as i32;
1636                let end_decade = y_max.log10().ceil() as i32;
1637                for d in start_decade..=end_decade {
1638                    let decade = 10f64.powi(d);
1639                    for mantissa in 1..=9 {
1640                        let y_val = decade * mantissa as f64;
1641                        if y_val < y_min || y_val > y_max {
1642                            continue;
1643                        }
1644                        let is_major = mantissa == 1;
1645                        if (is_major && !show_major_grid) || (!is_major && !show_minor_grid) {
1646                            continue;
1647                        }
1648                        let y_screen = plot_rect.max.y
1649                            - ((y_val.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1650                                as f32
1651                                * plot_rect.height();
1652                        let y_screen = Self::snap_coord(y_screen, ppp);
1653                        if (y_screen - plot_rect.min.y).abs() <= edge_eps
1654                            || (y_screen - plot_rect.max.y).abs() <= edge_eps
1655                        {
1656                            continue;
1657                        }
1658                        ui.painter().line_segment(
1659                            [
1660                                Pos2::new(plot_rect.min.x, y_screen),
1661                                Pos2::new(plot_rect.max.x, y_screen),
1662                            ],
1663                            Stroke::new(
1664                                if is_major { 0.8 } else { 0.6 },
1665                                if is_major {
1666                                    grid_color_major
1667                                } else {
1668                                    grid_color_minor
1669                                },
1670                            ),
1671                        );
1672                    }
1673                }
1674            } else {
1675                if show_minor_grid {
1676                    for pair in y_ticks.windows(2) {
1677                        let step = (pair[1] - pair[0]) / 5.0;
1678                        if !step.is_finite() || step <= 0.0 {
1679                            continue;
1680                        }
1681                        for n in 1..5 {
1682                            let y_val = pair[0] + step * n as f64;
1683                            if y_val < y_min || y_val > y_max {
1684                                continue;
1685                            }
1686                            let y_screen = plot_rect.max.y
1687                                - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1688                            let y_screen = Self::snap_coord(y_screen, ppp);
1689                            if (y_screen - plot_rect.min.y).abs() <= edge_eps
1690                                || (y_screen - plot_rect.max.y).abs() <= edge_eps
1691                            {
1692                                continue;
1693                            }
1694                            ui.painter().line_segment(
1695                                [
1696                                    Pos2::new(plot_rect.min.x, y_screen),
1697                                    Pos2::new(plot_rect.max.x, y_screen),
1698                                ],
1699                                Stroke::new(0.6, grid_color_minor),
1700                            );
1701                        }
1702                    }
1703                }
1704                if show_major_grid {
1705                    for y_val in y_ticks {
1706                        let y_screen = plot_rect.max.y
1707                            - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1708                        let y_screen = Self::snap_coord(y_screen, ppp);
1709                        if (y_screen - plot_rect.min.y).abs() <= edge_eps
1710                            || (y_screen - plot_rect.max.y).abs() <= edge_eps
1711                        {
1712                            continue;
1713                        }
1714                        ui.painter().line_segment(
1715                            [
1716                                Pos2::new(plot_rect.min.x, y_screen),
1717                                Pos2::new(plot_rect.max.x, y_screen),
1718                            ],
1719                            Stroke::new(0.8, grid_color_major),
1720                        );
1721                    }
1722                }
1723            }
1724        }
1725    }
1726
1727    /// Draw axis ticks and numeric labels
1728    fn draw_axes(
1729        &self,
1730        ui: &mut egui::Ui,
1731        plot_rect: Rect,
1732        plot_renderer: &PlotRenderer,
1733        config: &OverlayConfig,
1734        view_bounds_override: Option<(f64, f64, f64, f64)>,
1735        axes_index: Option<usize>,
1736    ) {
1737        let ppp = ui.ctx().pixels_per_point();
1738        if let Some(data_bounds) = view_bounds_override
1739            .or_else(|| plot_renderer.view_bounds())
1740            .or_else(|| plot_renderer.data_bounds())
1741        {
1742            let (x_min, x_max, y_min, y_max) = data_bounds;
1743            let x_range = x_max - x_min;
1744            let y_range = y_max - y_min;
1745            let scale = config.font_scale.max(0.75);
1746            let tick_length = 6.0 * scale;
1747            let label_offset = 15.0 * scale;
1748            let tick_font_size = Self::axes_tick_font_size(plot_renderer, axes_index, scale);
1749            let tick_font = FontId::proportional(tick_font_size);
1750            let axis_color = self.theme_axis_color();
1751            let label_color = self.theme_text_color();
1752            let border_left = plot_rect.min.x;
1753            let border_bottom = plot_rect.max.y;
1754
1755            let x_log = axes_index
1756                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1757                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1758            let y_log = axes_index
1759                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1760                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1761
1762            // Histogram numeric tick support and categorical axis support
1763            let (mut cat_x, mut cat_y) = (false, false);
1764            let mut custom_hist_x = false;
1765            if let Some((true, edges)) =
1766                axes_index.and_then(|idx| plot_renderer.overlay_histogram_edges_for_axes(idx))
1767            {
1768                custom_hist_x = true;
1769                self.draw_histogram_axis_ticks(
1770                    ui,
1771                    plot_rect,
1772                    ppp,
1773                    axis_color,
1774                    label_color,
1775                    tick_length,
1776                    label_offset,
1777                    tick_font.clone(),
1778                    border_bottom,
1779                    x_min,
1780                    x_max,
1781                    &edges,
1782                );
1783            }
1784            if let Some(labels) = axes_index.and_then(|idx| {
1785                plot_renderer
1786                    .overlay_x_tick_labels_for_axes(idx)
1787                    .filter(|labels| !labels.is_empty())
1788            }) {
1789                cat_x = true;
1790                let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1791                for (label_idx, label) in labels.iter().enumerate() {
1792                    if label_idx != 0 && label_idx != labels.len() - 1 && label_idx % stride != 0 {
1793                        continue;
1794                    }
1795                    let x_val = (label_idx + 1) as f64;
1796                    if x_val < x_min || x_val > x_max {
1797                        continue;
1798                    }
1799                    let x_screen =
1800                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1801                    let x_screen = Self::snap_coord(x_screen, ppp);
1802                    ui.painter().line_segment(
1803                        [
1804                            Pos2::new(x_screen, border_bottom),
1805                            Pos2::new(x_screen, border_bottom + tick_length),
1806                        ],
1807                        Stroke::new(1.0, axis_color),
1808                    );
1809                    let text = truncate_label(label, 14);
1810                    ui.painter().text(
1811                        Pos2::new(x_screen, border_bottom + label_offset),
1812                        Align2::CENTER_CENTER,
1813                        text,
1814                        tick_font.clone(),
1815                        label_color,
1816                    );
1817                }
1818            }
1819            if let Some(labels) = axes_index.and_then(|idx| {
1820                plot_renderer
1821                    .overlay_y_tick_labels_for_axes(idx)
1822                    .filter(|labels| !labels.is_empty())
1823            }) {
1824                cat_y = true;
1825                let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1826                for (label_idx, label) in labels.iter().enumerate() {
1827                    if label_idx != 0 && label_idx != labels.len() - 1 && label_idx % stride != 0 {
1828                        continue;
1829                    }
1830                    let y_val = (label_idx + 1) as f64;
1831                    if y_val < y_min || y_val > y_max {
1832                        continue;
1833                    }
1834                    let y_screen =
1835                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1836                    let y_screen = Self::snap_coord(y_screen, ppp);
1837                    ui.painter().line_segment(
1838                        [
1839                            Pos2::new(border_left - tick_length, y_screen),
1840                            Pos2::new(border_left, y_screen),
1841                        ],
1842                        Stroke::new(1.0, axis_color),
1843                    );
1844                    let text = truncate_label(label, 14);
1845                    ui.painter().text(
1846                        Pos2::new(border_left - label_offset, y_screen),
1847                        Align2::CENTER_CENTER,
1848                        text,
1849                        tick_font.clone(),
1850                        label_color,
1851                    );
1852                }
1853            }
1854            if let Some((is_x, labels)) = axes_index
1855                .and_then(|idx| plot_renderer.overlay_categorical_labels_for_axes(idx))
1856                .or_else(|| {
1857                    plot_renderer
1858                        .overlay_categorical_labels()
1859                        .map(|(is_x, labels)| (is_x, labels.clone()))
1860                })
1861            {
1862                if (is_x && cat_x) || (!is_x && cat_y) {
1863                    // Explicit axes tick labels take precedence over inferred bar labels.
1864                } else if is_x {
1865                    cat_x = true;
1866                    let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1867                    // Draw X categorical labels at integer positions (1..n)
1868                    for (label_idx, label) in labels.iter().enumerate() {
1869                        if label_idx != 0
1870                            && label_idx != labels.len() - 1
1871                            && label_idx % stride != 0
1872                        {
1873                            continue;
1874                        }
1875                        let x_val = (label_idx + 1) as f64;
1876                        if x_val < x_min || x_val > x_max {
1877                            continue;
1878                        }
1879                        let x_screen = plot_rect.min.x
1880                            + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1881                        let x_screen = Self::snap_coord(x_screen, ppp);
1882                        // Tick
1883                        ui.painter().line_segment(
1884                            [
1885                                Pos2::new(x_screen, border_bottom),
1886                                Pos2::new(x_screen, border_bottom + tick_length),
1887                            ],
1888                            Stroke::new(1.0, axis_color),
1889                        );
1890                        // Label
1891                        let text = truncate_label(label, 14);
1892                        ui.painter().text(
1893                            Pos2::new(x_screen, border_bottom + label_offset),
1894                            Align2::CENTER_CENTER,
1895                            text,
1896                            tick_font.clone(),
1897                            label_color,
1898                        );
1899                    }
1900                } else {
1901                    cat_y = true;
1902                    let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1903                    // Draw Y categorical labels at integer positions (1..n)
1904                    for (label_idx, label) in labels.iter().enumerate() {
1905                        if label_idx != 0
1906                            && label_idx != labels.len() - 1
1907                            && label_idx % stride != 0
1908                        {
1909                            continue;
1910                        }
1911                        let y_val = (label_idx + 1) as f64;
1912                        if y_val < y_min || y_val > y_max {
1913                            continue;
1914                        }
1915                        let y_screen = plot_rect.max.y
1916                            - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1917                        let y_screen = Self::snap_coord(y_screen, ppp);
1918                        // Tick
1919                        ui.painter().line_segment(
1920                            [
1921                                Pos2::new(border_left - tick_length, y_screen),
1922                                Pos2::new(border_left, y_screen),
1923                            ],
1924                            Stroke::new(1.0, axis_color),
1925                        );
1926                        // Label
1927                        let text = truncate_label(label, 14);
1928                        ui.painter().text(
1929                            Pos2::new(border_left - label_offset, y_screen),
1930                            Align2::CENTER_CENTER,
1931                            text,
1932                            tick_font.clone(),
1933                            label_color,
1934                        );
1935                    }
1936                }
1937            }
1938
1939            // Draw X-axis ticks and labels (categorical handled above)
1940            if x_log {
1941                let start_decade = x_min.log10().floor() as i32;
1942                let end_decade = x_max.log10().ceil() as i32;
1943                for d in start_decade..=end_decade {
1944                    let decade = 10f64.powi(d);
1945                    let x_screen = plot_rect.min.x
1946                        + ((decade.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1947                            as f32
1948                            * plot_rect.width();
1949                    let x_screen = Self::snap_coord(x_screen, ppp);
1950                    // Tick mark
1951                    ui.painter().line_segment(
1952                        [
1953                            Pos2::new(x_screen, border_bottom),
1954                            Pos2::new(x_screen, border_bottom + tick_length),
1955                        ],
1956                        Stroke::new(1.0, axis_color),
1957                    );
1958                    // Label like 10^d
1959                    ui.painter().text(
1960                        Pos2::new(x_screen, border_bottom + label_offset),
1961                        Align2::CENTER_CENTER,
1962                        format!("10^{}", d),
1963                        tick_font.clone(),
1964                        label_color,
1965                    );
1966                }
1967            } else if !cat_x && !custom_hist_x {
1968                for x_val in plot_utils::generate_major_ticks(x_min, x_max) {
1969                    let x_screen =
1970                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1971                    let x_screen = Self::snap_coord(x_screen, ppp);
1972                    ui.painter().line_segment(
1973                        [
1974                            Pos2::new(x_screen, border_bottom),
1975                            Pos2::new(x_screen, border_bottom + tick_length),
1976                        ],
1977                        Stroke::new(1.0, axis_color),
1978                    );
1979                    ui.painter().text(
1980                        Pos2::new(x_screen, border_bottom + label_offset),
1981                        Align2::CENTER_CENTER,
1982                        plot_utils::format_tick_label(x_val),
1983                        tick_font.clone(),
1984                        label_color,
1985                    );
1986                }
1987            }
1988
1989            // Draw Y-axis ticks and labels (categorical handled above)
1990            if y_log {
1991                let start_decade = y_min.log10().floor() as i32;
1992                let end_decade = y_max.log10().ceil() as i32;
1993                for d in start_decade..=end_decade {
1994                    let decade = 10f64.powi(d);
1995                    let y_screen = plot_rect.max.y
1996                        - ((decade.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1997                            as f32
1998                            * plot_rect.height();
1999                    let y_screen = Self::snap_coord(y_screen, ppp);
2000                    ui.painter().line_segment(
2001                        [
2002                            Pos2::new(border_left - tick_length, y_screen),
2003                            Pos2::new(border_left, y_screen),
2004                        ],
2005                        Stroke::new(1.0, axis_color),
2006                    );
2007                    ui.painter().text(
2008                        Pos2::new(border_left - label_offset, y_screen),
2009                        Align2::CENTER_CENTER,
2010                        format!("10^{}", d),
2011                        tick_font.clone(),
2012                        label_color,
2013                    );
2014                }
2015            } else if !cat_y {
2016                for y_val in plot_utils::generate_major_ticks(y_min, y_max) {
2017                    let y_screen =
2018                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
2019                    let y_screen = Self::snap_coord(y_screen, ppp);
2020                    ui.painter().line_segment(
2021                        [
2022                            Pos2::new(border_left - tick_length, y_screen),
2023                            Pos2::new(border_left, y_screen),
2024                        ],
2025                        Stroke::new(1.0, axis_color),
2026                    );
2027                    ui.painter().text(
2028                        Pos2::new(border_left - label_offset, y_screen),
2029                        Align2::CENTER_CENTER,
2030                        plot_utils::format_tick_label(y_val),
2031                        tick_font.clone(),
2032                        label_color,
2033                    );
2034                }
2035            }
2036        }
2037    }
2038
2039    /// Draw a CAD-style XYZ orientation gizmo in the bottom-left corner of the plot rect.
2040    /// This is drawn in screen-space (overlay) but rotates with the current 3D camera.
2041    fn draw_3d_orientation_gizmo(
2042        &self,
2043        ui: &mut egui::Ui,
2044        plot_rect: Rect,
2045        plot_renderer: &PlotRenderer,
2046        axes_index: usize,
2047        font_scale: f32,
2048    ) {
2049        let cam_ref = plot_renderer
2050            .axes_camera(axes_index)
2051            .unwrap_or_else(|| plot_renderer.camera());
2052        let cam = cam_ref.clone();
2053
2054        let forward = (cam.target - cam.position).normalize_or_zero();
2055        if forward.length_squared() < 1e-9 {
2056            return;
2057        }
2058        let world_up = cam.up.normalize_or_zero();
2059        let right = forward.cross(world_up).normalize_or_zero();
2060        if right.length_squared() < 1e-9 {
2061            return;
2062        }
2063        let up = right.cross(forward).normalize_or_zero();
2064        if up.length_squared() < 1e-9 {
2065            return;
2066        }
2067
2068        let scale = font_scale.max(0.75);
2069        let base = plot_rect.width().min(plot_rect.height()).max(1.0);
2070        let gizmo_size = (base * 0.16).clamp(44.0, 110.0) * scale;
2071        let pad = (30.0 * scale).round();
2072        let origin = Pos2::new(plot_rect.min.x + pad, plot_rect.max.y - pad);
2073
2074        struct AxisItem {
2075            label: &'static str,
2076            dir_world: Vec3,
2077            color: Color32,
2078            z_sort: f32,
2079        }
2080
2081        let mut axes = [
2082            AxisItem {
2083                label: "X",
2084                dir_world: Vec3::X,
2085                color: Color32::from_rgb(235, 80, 80),
2086                z_sort: 0.0,
2087            },
2088            AxisItem {
2089                label: "Y",
2090                dir_world: Vec3::Y,
2091                color: Color32::from_rgb(90, 220, 120),
2092                z_sort: 0.0,
2093            },
2094            AxisItem {
2095                label: "Z",
2096                dir_world: Vec3::Z,
2097                color: Color32::from_rgb(90, 160, 255),
2098                z_sort: 0.0,
2099            },
2100        ];
2101
2102        // Transform world axis directions into camera space and use the camera-space z as a
2103        // painter's-order hint (draw farther axes first so nearer ones sit on top).
2104        for a in axes.iter_mut() {
2105            let x = a.dir_world.dot(right);
2106            let y = a.dir_world.dot(up);
2107            let z = a.dir_world.dot(-forward);
2108            a.z_sort = z;
2109            a.dir_world = Vec3::new(x, y, z);
2110        }
2111        axes.sort_by(|a, b| {
2112            a.z_sort
2113                .partial_cmp(&b.z_sort)
2114                .unwrap_or(std::cmp::Ordering::Equal)
2115        });
2116
2117        let painter = ui.painter();
2118
2119        painter.circle_filled(origin, 2.0 * scale, Color32::from_gray(210));
2120
2121        let axis_len = gizmo_size * 0.65;
2122        let head_len = (8.0 * scale).min(axis_len * 0.35);
2123        let head_w = 5.0 * scale;
2124        let font = FontId::proportional(11.0 * scale);
2125
2126        for a in axes.iter() {
2127            let dir2 = egui::Vec2::new(a.dir_world.x, -a.dir_world.y);
2128            let mag = dir2.length();
2129            if !mag.is_finite() || mag < 1e-4 {
2130                continue;
2131            }
2132            let d = dir2 / mag;
2133
2134            let end = origin + d * axis_len;
2135            let stroke = Stroke::new(2.0 * scale, a.color);
2136            painter.line_segment([origin, end], stroke);
2137
2138            // Arrow head
2139            let base = end - d * head_len;
2140            let perp = egui::Vec2::new(-d.y, d.x);
2141            painter.line_segment([end, base + perp * head_w], stroke);
2142            painter.line_segment([end, base - perp * head_w], stroke);
2143
2144            // Label near arrow tip
2145            let label_pos = end + d * (10.0 * scale);
2146            painter.text(
2147                label_pos,
2148                Align2::CENTER_CENTER,
2149                a.label,
2150                font.clone(),
2151                a.color,
2152            );
2153        }
2154    }
2155
2156    /// Draw dynamic tick labels for the 3D origin triad axes (X/Y/Z).
2157    /// These labels are screen-space (egui) but are positioned by projecting 3D points.
2158    fn draw_3d_origin_axis_ticks(
2159        &self,
2160        ui: &mut egui::Ui,
2161        plot_rect: Rect,
2162        plot_renderer: &PlotRenderer,
2163        axes_index: usize,
2164        font_scale: f32,
2165    ) {
2166        let cam_ref = plot_renderer
2167            .axes_camera(axes_index)
2168            .unwrap_or_else(|| plot_renderer.camera());
2169        let mut cam = cam_ref.clone();
2170        let w = plot_rect.width().max(1.0);
2171        let h = plot_rect.height().max(1.0);
2172        cam.update_aspect_ratio(w / h);
2173        let view_proj = cam.view_proj_matrix();
2174
2175        let project = |p: Vec3| -> Option<Pos2> {
2176            let clip: Vec4 = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
2177            if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
2178                return None;
2179            }
2180            let ndc = clip.truncate() / clip.w;
2181            if !(ndc.x.is_finite() && ndc.y.is_finite()) {
2182                return None;
2183            }
2184            let sx = plot_rect.min.x + ((ndc.x + 1.0) * 0.5) * plot_rect.width();
2185            let sy = plot_rect.min.y + ((1.0 - ndc.y) * 0.5) * plot_rect.height();
2186            Some(Pos2::new(sx, sy))
2187        };
2188
2189        let nice_step = |raw: f64| -> f64 {
2190            if !raw.is_finite() || raw <= 0.0 {
2191                return 1.0;
2192            }
2193            let pow10 = 10.0_f64.powf(raw.log10().floor());
2194            let norm = raw / pow10;
2195            let mult = if norm <= 1.0 {
2196                1.0
2197            } else if norm <= 2.0 {
2198                2.0
2199            } else if norm <= 5.0 {
2200                5.0
2201            } else {
2202                10.0
2203            };
2204            mult * pow10
2205        };
2206
2207        // Use the same basic heuristic as the renderer: choose a major tick spacing based on
2208        // projected pixels per world unit near the origin.
2209        let origin = Vec3::ZERO;
2210        let px_per_world = match (project(origin), project(origin + Vec3::X)) {
2211            (Some(a), Some(b)) => ((b.x - a.x).hypot(b.y - a.y) as f64).max(1e-3),
2212            _ => 1.0,
2213        };
2214        let desired_major_px = 120.0_f64;
2215        let major_step = nice_step((desired_major_px / px_per_world).max(1e-6));
2216        if !(major_step.is_finite() && major_step > 0.0) {
2217            return;
2218        }
2219        let axis_len = (major_step as f32 * 5.0).max(0.5);
2220
2221        let scale = font_scale.max(0.75);
2222        let font = FontId::proportional(Self::axes_tick_font_size(
2223            plot_renderer,
2224            Some(axes_index),
2225            scale,
2226        ));
2227        let painter = ui.painter();
2228        let col_x = Color32::from_rgb(235, 80, 80);
2229        let col_y = Color32::from_rgb(90, 220, 120);
2230        let col_z = Color32::from_rgb(90, 160, 255);
2231        let panel_center = plot_rect.center();
2232
2233        let outward_offset = |pos: Pos2, base: f32| {
2234            let dir = pos - panel_center;
2235            let len = dir.length().max(1.0);
2236            (dir / len) * base
2237        };
2238
2239        if let Some(pos) = project(Vec3::X * axis_len * 1.10) {
2240            if let Some(label) = plot_renderer.overlay_x_label_for_axes(axes_index) {
2241                let style = plot_renderer
2242                    .overlay_x_label_style_for_axes(axes_index)
2243                    .cloned()
2244                    .unwrap_or_default();
2245                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(4.0 * scale, 0.0);
2246                Self::paint_styled_text(
2247                    painter,
2248                    pos + offset,
2249                    Align2::LEFT_CENTER,
2250                    label,
2251                    Self::style_font_size(&style, 12.0, scale),
2252                    Self::style_color(&style, col_x),
2253                    Self::style_is_bold(&style),
2254                    110,
2255                );
2256            }
2257        }
2258        if let Some(pos) = project(Vec3::Y * axis_len * 1.10) {
2259            if let Some(label) = plot_renderer.overlay_y_label_for_axes(axes_index) {
2260                let style = plot_renderer
2261                    .overlay_y_label_style_for_axes(axes_index)
2262                    .cloned()
2263                    .unwrap_or_default();
2264                let offset =
2265                    outward_offset(pos, 12.0 * scale) + egui::vec2(2.0 * scale, -2.0 * scale);
2266                Self::paint_styled_text(
2267                    painter,
2268                    pos + offset,
2269                    Align2::LEFT_CENTER,
2270                    label,
2271                    Self::style_font_size(&style, 12.0, scale),
2272                    Self::style_color(&style, col_y),
2273                    Self::style_is_bold(&style),
2274                    110,
2275                );
2276            }
2277        }
2278        if let Some(pos) = project(Vec3::Z * axis_len * 1.10) {
2279            if let Some(label) = plot_renderer.overlay_z_label_for_axes(axes_index) {
2280                let style = plot_renderer
2281                    .overlay_z_label_style_for_axes(axes_index)
2282                    .cloned()
2283                    .unwrap_or_default();
2284                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(0.0, -4.0 * scale);
2285                Self::paint_styled_text(
2286                    painter,
2287                    pos + offset,
2288                    Align2::LEFT_BOTTOM,
2289                    label,
2290                    Self::style_font_size(&style, 12.0, scale),
2291                    Self::style_color(&style, col_z),
2292                    Self::style_is_bold(&style),
2293                    110,
2294                );
2295            }
2296        }
2297
2298        let draw_axis = |axis: Vec3, color: Color32| {
2299            for i in 1..=6 {
2300                let t = (i as f32) * (major_step as f32);
2301                if t >= axis_len * 0.999 {
2302                    break;
2303                }
2304                let p = origin + axis * t;
2305                let Some(pos) = project(p) else { continue };
2306                // Offset labels slightly away from the axis in screen-space based on camera right/up.
2307                let offset =
2308                    outward_offset(pos, 7.0 * scale) + egui::Vec2::new(3.0 * scale, -3.0 * scale);
2309                painter.text(
2310                    pos + offset + egui::vec2(1.0, 1.0),
2311                    Align2::LEFT_CENTER,
2312                    plot_utils::format_tick_label((i as f64) * major_step),
2313                    font.clone(),
2314                    Color32::from_rgba_premultiplied(0, 0, 0, 90),
2315                );
2316                painter.text(
2317                    pos + offset,
2318                    Align2::LEFT_CENTER,
2319                    plot_utils::format_tick_label((i as f64) * major_step),
2320                    font.clone(),
2321                    color,
2322                );
2323            }
2324        };
2325        draw_axis(Vec3::X, col_x);
2326        draw_axis(Vec3::Y, col_y);
2327        draw_axis(Vec3::Z, col_z);
2328    }
2329
2330    fn draw_title_in_rect(
2331        &self,
2332        ui: &mut egui::Ui,
2333        rect: Rect,
2334        title: &str,
2335        style: Option<&TextStyle>,
2336        scale: f32,
2337    ) {
2338        let scale = scale.max(0.75);
2339        let style = style.cloned().unwrap_or_default();
2340        Self::paint_styled_text(
2341            ui.painter(),
2342            rect.center(),
2343            Align2::CENTER_CENTER,
2344            title,
2345            Self::style_font_size(&style, 16.0, scale),
2346            Self::style_color(&style, self.theme_text_color()),
2347            Self::style_is_bold(&style),
2348            110,
2349        );
2350    }
2351
2352    fn draw_legend(
2353        &self,
2354        ui: &mut egui::Ui,
2355        plot_rect: Rect,
2356        entries: &[crate::plots::figure::LegendEntry],
2357        scale: f32,
2358    ) {
2359        if entries.is_empty() {
2360            return;
2361        }
2362        let scale = scale.max(0.75);
2363        let theme = self.theme.build_theme();
2364        let bg = theme.get_background_color();
2365        let text = theme.get_text_color();
2366        let legend_text = Color32::from_rgb(
2367            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
2368            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
2369            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
2370        );
2371        let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
2372        let legend_bg = if bg_luma > 0.62 {
2373            Color32::from_rgba_premultiplied(255, 255, 255, 170)
2374        } else {
2375            Color32::from_rgba_premultiplied(0, 0, 0, 128)
2376        };
2377        let legend_stroke = if bg_luma > 0.62 {
2378            Color32::from_rgb(55, 55, 55)
2379        } else {
2380            Color32::BLACK
2381        };
2382        let pad = 8.0 * scale;
2383        let row_h = (16.0 * scale).clamp(13.0, 18.0);
2384        let swatch_w = 14.0 * scale;
2385        let text_x_gap = 18.0 * scale;
2386        let legend_w = (plot_rect.width() * 0.30).clamp(92.0, 132.0);
2387        let x = plot_rect.max.x - legend_w - pad;
2388        let mut y = plot_rect.min.y + pad + 4.0 * scale;
2389        let legend_rect = Rect::from_min_max(
2390            egui::pos2(x - pad, plot_rect.min.y + pad),
2391            egui::pos2(x + legend_w, y + entries.len() as f32 * row_h + pad),
2392        );
2393        ui.painter().rect_filled(legend_rect, 4.0, legend_bg);
2394        y += 10.0 * scale;
2395        for e in entries {
2396            let c = Color32::from_rgb(
2397                (e.color.x * 255.0) as u8,
2398                (e.color.y * 255.0) as u8,
2399                (e.color.z * 255.0) as u8,
2400            );
2401            let swatch_rect = Rect::from_min_size(
2402                egui::pos2(x, y - 5.0 * scale),
2403                egui::vec2(swatch_w, 7.0 * scale),
2404            );
2405            match e.plot_type {
2406                crate::plots::figure::PlotType::Line
2407                | crate::plots::figure::PlotType::Line3
2408                | crate::plots::figure::PlotType::Contour
2409                | crate::plots::figure::PlotType::ReferenceLine => {
2410                    let ymid = swatch_rect.center().y;
2411                    ui.painter().line_segment(
2412                        [
2413                            Pos2::new(swatch_rect.min.x, ymid),
2414                            Pos2::new(swatch_rect.max.x, ymid),
2415                        ],
2416                        Stroke::new(2.0, c),
2417                    );
2418                }
2419                crate::plots::figure::PlotType::Scatter
2420                | crate::plots::figure::PlotType::Scatter3 => {
2421                    let center = swatch_rect.center();
2422                    ui.painter().circle_filled(center, 3.5, c);
2423                    ui.painter()
2424                        .circle_stroke(center, 3.5, Stroke::new(1.0, legend_stroke));
2425                }
2426                crate::plots::figure::PlotType::Bar
2427                | crate::plots::figure::PlotType::Area
2428                | crate::plots::figure::PlotType::Surface
2429                | crate::plots::figure::PlotType::Patch
2430                | crate::plots::figure::PlotType::Pie
2431                | crate::plots::figure::PlotType::ContourFill => {
2432                    ui.painter().rect_filled(swatch_rect, 2.0, c);
2433                    #[cfg(target_arch = "wasm32")]
2434                    ui.painter().rect_stroke(
2435                        swatch_rect,
2436                        2.0,
2437                        Stroke::new(1.0, legend_stroke),
2438                        egui::StrokeKind::Inside,
2439                    );
2440                    #[cfg(not(target_arch = "wasm32"))]
2441                    ui.painter()
2442                        .rect_stroke(swatch_rect, 2.0, Stroke::new(1.0, legend_stroke));
2443                }
2444                crate::plots::figure::PlotType::ErrorBar
2445                | crate::plots::figure::PlotType::Stairs
2446                | crate::plots::figure::PlotType::Stem
2447                | crate::plots::figure::PlotType::Quiver => {
2448                    let ymid = swatch_rect.center().y;
2449                    ui.painter().line_segment(
2450                        [
2451                            Pos2::new(swatch_rect.min.x, ymid),
2452                            Pos2::new(swatch_rect.max.x - 4.0, ymid),
2453                        ],
2454                        Stroke::new(1.5, c),
2455                    );
2456                    ui.painter().line_segment(
2457                        [
2458                            Pos2::new(swatch_rect.max.x - 4.0, ymid - 3.0),
2459                            Pos2::new(swatch_rect.max.x, ymid),
2460                        ],
2461                        Stroke::new(1.0, c),
2462                    );
2463                }
2464            }
2465            ui.painter().text(
2466                egui::pos2(x + text_x_gap, y),
2467                Align2::LEFT_CENTER,
2468                &e.label,
2469                FontId::proportional(11.0 * scale),
2470                legend_text,
2471            );
2472            y += row_h;
2473        }
2474    }
2475
2476    fn draw_x_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2477        let scale = scale.max(0.75);
2478        let text_color = self.theme_text_color();
2479        ui.painter().text(
2480            Pos2::new(rect.center().x, rect.max.y - rect.height() * 0.24),
2481            Align2::CENTER_CENTER,
2482            label,
2483            FontId::proportional(14.0 * scale),
2484            text_color,
2485        );
2486    }
2487
2488    fn draw_y_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2489        let scale = scale.max(0.75);
2490        let text_color = self.theme_text_color();
2491        let galley = ui.fonts(|fonts| {
2492            fonts.layout_no_wrap(
2493                label.to_owned(),
2494                FontId::proportional(13.0 * scale),
2495                text_color,
2496            )
2497        });
2498        let size = galley.size();
2499        let center = Pos2::new(rect.min.x + rect.width() * 0.32, rect.center().y);
2500        let pos = Pos2::new(center.x - size.y * 0.5, center.y + size.x * 0.5);
2501        let mut shape = egui::epaint::TextShape::new(pos, galley, text_color);
2502        shape.angle = -std::f32::consts::FRAC_PI_2;
2503        shape.override_text_color = Some(text_color);
2504        ui.painter().add(shape);
2505    }
2506
2507    fn project_world_to_screen(
2508        &self,
2509        plot_rect: Rect,
2510        camera: &crate::core::Camera,
2511        point: glam::Vec3,
2512    ) -> Option<Pos2> {
2513        let mut cam = camera.clone();
2514        let clip = cam.view_proj_matrix() * point.extend(1.0);
2515        if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() || !clip.w.is_finite()
2516        {
2517            return None;
2518        }
2519        if clip.w.abs() < 1.0e-6 {
2520            return None;
2521        }
2522        let ndc = clip.truncate() / clip.w;
2523        if ndc.z < -1.1 || ndc.z > 1.1 {
2524            return None;
2525        }
2526        if clip.w <= 0.0
2527            && matches!(
2528                camera.projection,
2529                crate::core::camera::ProjectionType::Perspective { .. }
2530            )
2531        {
2532            return None;
2533        }
2534        let x = plot_rect.min.x + (ndc.x + 1.0) * 0.5 * plot_rect.width();
2535        let y = plot_rect.min.y + (1.0 - (ndc.y + 1.0) * 0.5) * plot_rect.height();
2536        Some(Pos2::new(x, y))
2537    }
2538
2539    fn draw_projected_world_texts(
2540        &self,
2541        ui: &mut egui::Ui,
2542        plot_rect: Rect,
2543        plot_renderer: &PlotRenderer,
2544        axes_index: usize,
2545        scale: f32,
2546    ) {
2547        let Some(camera) = plot_renderer
2548            .axes_camera(axes_index)
2549            .or_else(|| Some(plot_renderer.camera()))
2550        else {
2551            return;
2552        };
2553        let annotations = plot_renderer.world_text_annotations_for_axes(axes_index);
2554        let is_3d = Self::axes_is_3d(plot_renderer, axes_index);
2555        for (position, text, style) in annotations {
2556            if !style.visible || text.trim().is_empty() {
2557                continue;
2558            }
2559            let Some(screen) = self.project_world_to_screen(plot_rect, camera, position) else {
2560                continue;
2561            };
2562            let color = Self::style_color(&style, self.theme_text_color());
2563            let font_size = Self::style_font_size(&style, 14.0, scale);
2564            let offset = if is_3d {
2565                egui::vec2(0.0, -8.0 * scale.max(0.75))
2566            } else {
2567                egui::vec2(0.0, 6.0 * scale.max(0.75))
2568            };
2569            Self::paint_styled_text(
2570                ui.painter(),
2571                screen + offset,
2572                Align2::CENTER_CENTER,
2573                &text,
2574                font_size,
2575                color,
2576                Self::style_is_bold(&style),
2577                if is_3d { 120 } else { 90 },
2578            );
2579        }
2580    }
2581
2582    fn draw_pie_label(
2583        &self,
2584        ui: &mut egui::Ui,
2585        plot_rect: Rect,
2586        label: &str,
2587        pos: glam::Vec2,
2588        scale: f32,
2589    ) {
2590        let center = plot_rect.center();
2591        let radius = plot_rect.width().min(plot_rect.height()) * 0.4;
2592        let screen = Pos2::new(center.x + pos.x * radius, center.y - pos.y * radius);
2593        ui.painter().text(
2594            screen,
2595            Align2::CENTER_CENTER,
2596            label,
2597            FontId::proportional(12.0 * scale.max(0.75)),
2598            self.theme_text_color(),
2599        );
2600    }
2601
2602    /// Get the plot area from the last frame
2603    pub fn plot_area(&self) -> Option<Rect> {
2604        self.plot_area
2605    }
2606
2607    /// Get per-axes snapped content rectangles from the last frame.
2608    pub fn axes_plot_rects(&self) -> &[Rect] {
2609        &self.axes_plot_rects
2610    }
2611
2612    /// Get toolbar rectangle from last frame
2613    pub fn toolbar_rect(&self) -> Option<Rect> {
2614        self.toolbar_rect
2615    }
2616
2617    /// Get sidebar rectangle from last frame
2618    pub fn sidebar_rect(&self) -> Option<Rect> {
2619        self.sidebar_rect
2620    }
2621
2622    pub fn take_toolbar_actions(&mut self) -> (bool, bool, bool, Option<bool>, Option<bool>) {
2623        let out = (
2624            self.want_save_png,
2625            self.want_save_svg,
2626            self.want_reset_view,
2627            self.want_toggle_grid.take(),
2628            self.want_toggle_legend.take(),
2629        );
2630        self.want_save_png = false;
2631        self.want_save_svg = false;
2632        self.want_reset_view = false;
2633        out
2634    }
2635
2636    /// Render the Dystr information modal
2637    fn render_dystr_modal(&mut self, ctx: &Context) -> bool {
2638        let mut consumed_input = false;
2639
2640        egui::Window::new("About Dystr")
2641            .anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
2642            .collapsible(false)
2643            .resizable(false)
2644            .default_width(400.0)
2645            .show(ctx, |ui| {
2646                consumed_input = true;
2647
2648                ui.vertical_centered(|ui| {
2649                    ui.add_space(10.0);
2650
2651                    // Dystr logo placeholder (larger for modal)
2652                    let logo_size = egui::Vec2::splat(64.0);
2653                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::hover()).0;
2654
2655                    ui.painter().rect_filled(
2656                        logo_rect,
2657                        8.0,                             // rounded corners
2658                        Color32::from_rgb(60, 130, 200), // Dystr brand color placeholder
2659                    );
2660
2661                    ui.painter().text(
2662                        logo_rect.center(),
2663                        Align2::CENTER_CENTER,
2664                        "D",
2665                        FontId::proportional(40.0),
2666                        Color32::WHITE,
2667                    );
2668
2669                    ui.add_space(15.0);
2670
2671                    ui.heading("Welcome to RunMat");
2672                    ui.add_space(10.0);
2673
2674                    ui.label("RunMat is a high-performance MATLAB-compatible");
2675                    ui.label("numerical computing platform, built as part of");
2676                    ui.label("the Dystr computation ecosystem.");
2677
2678                    ui.add_space(15.0);
2679
2680                    ui.label("🚀 V8-inspired JIT compilation");
2681                    ui.label("⚡ BLAS/LAPACK acceleration");
2682                    ui.label("🎯 Full MATLAB compatibility");
2683                    ui.label("🔬 Advanced plotting & visualization");
2684
2685                    ui.add_space(20.0);
2686
2687                    ui.horizontal(|ui| {
2688                        if ui.button("Visit dystr.com").clicked() {
2689                            // Open dystr.com in browser
2690                            if let Err(e) = webbrowser::open("https://dystr.com") {
2691                                eprintln!("Failed to open browser: {e}");
2692                            }
2693                        }
2694
2695                        if ui.button("Close").clicked() {
2696                            self.show_dystr_modal = false;
2697                        }
2698                    });
2699
2700                    ui.add_space(10.0);
2701                });
2702            });
2703
2704        consumed_input
2705    }
2706}
2707
2708fn truncate_label(label: &str, max_len: usize) -> String {
2709    if label.chars().count() <= max_len {
2710        return label.to_string();
2711    }
2712    let mut out = String::new();
2713    for (i, ch) in label.chars().enumerate() {
2714        if i >= max_len - 1 {
2715            break;
2716        }
2717        out.push(ch);
2718    }
2719    out.push('…');
2720    out
2721}