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