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