use crate::core::{plot_utils, PlotRenderer};
use crate::styling::{ModernDarkTheme, PlotThemeConfig, ThemeVariant};
use egui::{Align2, Color32, Context, FontId, Pos2, Rect, Stroke};
use glam::{Vec3, Vec4};
pub struct PlotOverlay {
theme: PlotThemeConfig,
plot_area: Option<Rect>,
toolbar_rect: Option<Rect>,
sidebar_rect: Option<Rect>,
show_debug: bool,
show_dystr_modal: bool,
want_save_png: bool,
want_save_svg: bool,
want_reset_view: bool,
want_toggle_grid: Option<bool>,
want_toggle_legend: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct OverlayConfig {
pub show_sidebar: bool,
pub show_toolbar: bool,
pub font_scale: f32,
pub show_grid: bool,
pub show_axes: bool,
pub show_title: bool,
pub title: Option<String>,
pub x_label: Option<String>,
pub y_label: Option<String>,
pub sidebar_width: f32,
pub plot_margins: PlotMargins,
}
#[derive(Debug, Clone)]
pub struct PlotMargins {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
impl Default for OverlayConfig {
fn default() -> Self {
Self {
show_sidebar: true,
show_toolbar: true,
font_scale: 1.0,
show_grid: true,
show_axes: true,
show_title: true,
title: Some("Plot".to_string()),
x_label: Some("X".to_string()),
y_label: Some("Y".to_string()),
sidebar_width: 280.0,
plot_margins: PlotMargins {
left: 60.0,
right: 20.0,
top: 40.0,
bottom: 60.0,
},
}
}
}
#[derive(Debug)]
pub struct FrameInfo {
pub plot_area: Option<Rect>,
pub consumed_input: bool,
pub metrics: OverlayMetrics,
}
#[derive(Debug, Default)]
pub struct OverlayMetrics {
pub vertex_count: usize,
pub triangle_count: usize,
pub render_time_ms: f64,
pub fps: f32,
}
impl Default for PlotOverlay {
fn default() -> Self {
Self::new()
}
}
impl PlotOverlay {
pub fn new() -> Self {
Self {
theme: PlotThemeConfig::default(),
plot_area: None,
toolbar_rect: None,
sidebar_rect: None,
show_debug: false,
show_dystr_modal: false,
want_save_png: false,
want_save_svg: false,
want_reset_view: false,
want_toggle_grid: None,
want_toggle_legend: None,
}
}
pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
self.theme = theme;
}
fn theme_text_color(&self) -> Color32 {
let text = self.theme.build_theme().get_text_color();
Color32::from_rgba_premultiplied(
(text.x.clamp(0.0, 1.0) * 255.0) as u8,
(text.y.clamp(0.0, 1.0) * 255.0) as u8,
(text.z.clamp(0.0, 1.0) * 255.0) as u8,
(text.w.clamp(0.0, 1.0) * 255.0) as u8,
)
}
fn theme_axis_color(&self) -> Color32 {
let axis = self.theme.build_theme().get_axis_color();
Color32::from_rgba_premultiplied(
(axis.x.clamp(0.0, 1.0) * 255.0) as u8,
(axis.y.clamp(0.0, 1.0) * 255.0) as u8,
(axis.z.clamp(0.0, 1.0) * 255.0) as u8,
(axis.w.clamp(0.0, 1.0) * 255.0) as u8,
)
}
fn themed_grid_colors(&self) -> (Color32, Color32) {
let grid = self.theme.build_theme().get_grid_color();
let major = Color32::from_rgba_premultiplied(
(grid.x.clamp(0.0, 1.0) * 255.0) as u8,
(grid.y.clamp(0.0, 1.0) * 255.0) as u8,
(grid.z.clamp(0.0, 1.0) * 255.0) as u8,
((grid.w.clamp(0.15, 0.55)) * 255.0) as u8,
);
let minor = Color32::from_rgba_premultiplied(
(grid.x.clamp(0.0, 1.0) * 255.0) as u8,
(grid.y.clamp(0.0, 1.0) * 255.0) as u8,
(grid.z.clamp(0.0, 1.0) * 255.0) as u8,
((grid.w * 0.6).clamp(0.10, 0.34) * 255.0) as u8,
);
(major, minor)
}
pub fn apply_theme(&self, ctx: &Context) {
match self.theme.variant {
ThemeVariant::ModernDark => {
ModernDarkTheme::default().apply_to_egui(ctx);
}
ThemeVariant::ClassicLight => {
ctx.set_visuals(egui::Visuals::light());
}
ThemeVariant::HighContrast => {
let mut visuals = egui::Visuals::dark();
visuals.extreme_bg_color = egui::Color32::BLACK;
visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
ctx.set_visuals(visuals);
}
ThemeVariant::Custom => {
let mut visuals = egui::Visuals::light();
let bg = self.theme.build_theme().get_background_color();
if bg.x + bg.y + bg.z < 1.5 {
visuals = egui::Visuals::dark();
}
ctx.set_visuals(visuals);
}
}
let mut visuals = ctx.style().visuals.clone();
visuals.window_fill = Color32::TRANSPARENT;
visuals.panel_fill = Color32::TRANSPARENT;
visuals.extreme_bg_color = Color32::TRANSPARENT;
visuals.faint_bg_color = Color32::TRANSPARENT;
visuals.widgets.noninteractive.bg_fill = Color32::TRANSPARENT;
visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
visuals.widgets.open.bg_fill = Color32::TRANSPARENT;
ctx.set_visuals(visuals);
}
pub fn render(
&mut self,
ctx: &Context,
plot_renderer: &PlotRenderer,
config: &OverlayConfig,
metrics: OverlayMetrics,
) -> FrameInfo {
let mut consumed_input = false;
let mut plot_area = None;
if config.show_sidebar {
consumed_input |= self.render_sidebar(ctx, plot_renderer, config, &metrics);
}
let central_response = egui::CentralPanel::default()
.frame(egui::Frame::none()) .show(ctx, |ui| {
if config.show_toolbar {
egui::TopBottomPanel::top("plot_toolbar")
.frame(egui::Frame::none())
.show_inside(ui, |ui| {
let padded = ui.max_rect().shrink2(egui::vec2(12.0, 6.0));
self.toolbar_rect = Some(padded);
ui.allocate_ui_at_rect(padded, |ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
ui.spacing_mut().button_padding = egui::vec2(8.0, 6.0);
if ui.button("Save PNG").clicked() {
self.want_save_png = true;
}
if ui.button("Save SVG").clicked() {
self.want_save_svg = true;
}
if ui.button("Reset View").clicked() {
self.want_reset_view = true;
}
let mut grid = plot_renderer.overlay_show_grid();
if ui.toggle_value(&mut grid, "Grid").changed() {
self.want_toggle_grid = Some(grid);
}
let mut legend = plot_renderer.overlay_show_legend();
if ui.toggle_value(&mut legend, "Legend").changed() {
self.want_toggle_legend = Some(legend);
}
},
);
});
});
} else {
self.toolbar_rect = None;
}
plot_area = Some(self.render_plot_area(ui, plot_renderer, config));
});
consumed_input |= central_response.response.hovered();
if self.show_dystr_modal {
consumed_input |= self.render_dystr_modal(ctx);
}
self.plot_area = plot_area;
FrameInfo {
plot_area,
consumed_input,
metrics,
}
}
fn render_sidebar(
&mut self,
ctx: &Context,
plot_renderer: &PlotRenderer,
config: &OverlayConfig,
metrics: &OverlayMetrics,
) -> bool {
let mut consumed_input = false;
let sidebar_response = egui::SidePanel::left("plot_controls")
.resizable(true)
.default_width(config.sidebar_width)
.min_width(200.0)
.show(ctx, |ui| {
ui.style_mut().visuals.widgets.noninteractive.bg_fill = Color32::from_gray(25);
ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::from_gray(35);
ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::from_gray(45);
ui.horizontal(|ui| {
let logo_size = egui::Vec2::splat(32.0);
let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::click()).0;
ui.painter().rect_filled(
logo_rect,
4.0, Color32::from_rgb(100, 100, 100),
);
ui.painter().text(
logo_rect.center(),
Align2::CENTER_CENTER,
"D",
FontId::proportional(20.0),
Color32::WHITE,
);
ui.vertical(|ui| {
ui.heading("RunMat");
ui.horizontal(|ui| {
ui.small("a community project by ");
if ui.small_button("dystr.com").clicked() {
self.show_dystr_modal = true;
}
});
});
});
ui.separator();
ui.label("GC Stats: [not available]");
ui.collapsing("📷 Camera", |ui| {
let camera = plot_renderer.camera();
ui.label(format!(
"Position: {:.2}, {:.2}, {:.2}",
camera.position.x, camera.position.y, camera.position.z
));
ui.label(format!(
"Target: {:.2}, {:.2}, {:.2}",
camera.target.x, camera.target.y, camera.target.z
));
if let Some(vb) = plot_renderer.view_bounds() {
ui.label(format!("View X: {:.2} to {:.2}", vb.0, vb.1));
ui.label(format!("View Y: {:.2} to {:.2}", vb.2, vb.3));
}
if let Some(db) = plot_renderer.data_bounds() {
ui.label(format!("Data X: {:.2} to {:.2}", db.0, db.1));
ui.label(format!("Data Y: {:.2} to {:.2}", db.2, db.3));
}
});
ui.collapsing("🎬 Scene", |ui| {
let stats = plot_renderer.scene_statistics();
ui.label(format!("Nodes: {}", stats.total_nodes));
ui.label(format!("Visible: {}", stats.visible_nodes));
ui.label(format!("Vertices: {}", stats.total_vertices));
ui.label(format!("Triangles: {}", stats.total_triangles));
});
ui.collapsing("⚡ Performance", |ui| {
ui.label(format!("FPS: {:.1}", metrics.fps));
ui.label(format!("Render: {:.2}ms", metrics.render_time_ms));
ui.label(format!("Vertices: {}", metrics.vertex_count));
ui.label(format!("Triangles: {}", metrics.triangle_count));
});
ui.collapsing("🎨 Theme", |ui| {
let label = match self.theme.variant {
ThemeVariant::ModernDark => "Modern Dark",
ThemeVariant::ClassicLight => "Classic Light",
ThemeVariant::HighContrast => "High Contrast",
ThemeVariant::Custom => "Custom",
};
ui.label(format!("{label} (Active)"));
ui.checkbox(&mut self.show_debug, "Show Debug Info");
});
ui.separator();
ui.collapsing("🔧 Controls", |ui| {
ui.label("🖱️ Orbit: MMB drag (or RMB drag)");
ui.label("🖱️ Pan: Shift + MMB drag (or Shift + RMB drag)");
ui.label("🖱️ Zoom: Scroll wheel (zooms to cursor)");
ui.label("🖱️ Alt + LMB/MMB/RMB: Orbit/Pan/Zoom");
ui.label("📱 Touch: Pinch to zoom");
});
});
consumed_input |= sidebar_response.response.hovered();
self.sidebar_rect = Some(sidebar_response.response.rect);
consumed_input
}
fn render_plot_area(
&mut self,
ui: &mut egui::Ui,
plot_renderer: &PlotRenderer,
config: &OverlayConfig,
) -> Rect {
let available_rect = ui.available_rect_before_wrap();
let (rows, cols) = plot_renderer.figure_axes_grid();
let has_3d_axes = (0..(rows.max(1) * cols.max(1))).any(|axes_index| {
let cam = plot_renderer
.axes_camera(axes_index)
.unwrap_or_else(|| plot_renderer.camera());
matches!(
cam.projection,
crate::core::camera::ProjectionType::Perspective { .. }
)
});
let plot_rect = if has_3d_axes {
available_rect
} else {
Rect::from_min_size(
available_rect.min
+ egui::Vec2::new(config.plot_margins.left, config.plot_margins.top),
available_rect.size()
- egui::Vec2::new(
config.plot_margins.left + config.plot_margins.right,
config.plot_margins.top + config.plot_margins.bottom,
),
)
};
let centered_plot_rect = plot_rect;
if rows * cols > 1 {
let rects = self.compute_subplot_rects(centered_plot_rect, rows, cols, 8.0, 8.0);
for (i, r) in rects.iter().enumerate() {
let cam = plot_renderer
.axes_camera(i)
.unwrap_or_else(|| plot_renderer.camera());
if matches!(
cam.projection,
crate::core::camera::ProjectionType::Perspective { .. }
) {
self.draw_3d_orientation_gizmo(ui, *r, plot_renderer, i, config.font_scale);
self.draw_3d_origin_axis_ticks(ui, *r, plot_renderer, i, config.font_scale);
continue;
}
if plot_renderer.overlay_show_box() {
let axis_color = self.theme_axis_color();
ui.painter()
.rect_stroke(*r, 0.0, Stroke::new(1.5, axis_color));
}
if config.show_grid {
let b = plot_renderer.view_bounds_for_axes(i);
self.draw_grid(ui, *r, plot_renderer, b);
}
if config.show_axes {
let b = plot_renderer.view_bounds_for_axes(i);
self.draw_axes(ui, *r, plot_renderer, config, b);
}
}
} else {
let cam = plot_renderer.camera();
if matches!(
cam.projection,
crate::core::camera::ProjectionType::Perspective { .. }
) {
self.draw_3d_orientation_gizmo(
ui,
centered_plot_rect,
plot_renderer,
0,
config.font_scale,
);
self.draw_3d_origin_axis_ticks(
ui,
centered_plot_rect,
plot_renderer,
0,
config.font_scale,
);
} else {
if plot_renderer.overlay_show_box() {
let axis_color = self.theme_axis_color();
ui.painter()
.rect_stroke(centered_plot_rect, 0.0, Stroke::new(1.5, axis_color));
}
if config.show_grid {
self.draw_grid(ui, centered_plot_rect, plot_renderer, None);
}
if config.show_axes {
self.draw_axes(ui, centered_plot_rect, plot_renderer, config, None);
if let Some((x_min, x_max, y_min, y_max)) = plot_renderer
.view_bounds()
.or_else(|| plot_renderer.data_bounds())
{
let axis_color = self.theme_axis_color();
let zero_stroke = Stroke::new(1.5, axis_color);
if y_min < 0.0 && y_max > 0.0 {
let y_screen = centered_plot_rect.max.y
- ((0.0 - y_min) / (y_max - y_min)) as f32
* centered_plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(centered_plot_rect.min.x, y_screen),
Pos2::new(centered_plot_rect.max.x, y_screen),
],
zero_stroke,
);
}
if x_min < 0.0 && x_max > 0.0 {
let x_screen = centered_plot_rect.min.x
+ ((0.0 - x_min) / (x_max - x_min)) as f32
* centered_plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, centered_plot_rect.min.y),
Pos2::new(x_screen, centered_plot_rect.max.y),
],
zero_stroke,
);
}
}
}
}
}
if config.show_title {
if let Some(title) = &config.title {
self.draw_title(ui, centered_plot_rect, title, config.font_scale);
}
}
if !has_3d_axes {
if let Some(x_label) = &config.x_label {
self.draw_x_label(ui, centered_plot_rect, x_label, config.font_scale);
}
if let Some(y_label) = &config.y_label {
self.draw_y_label(ui, centered_plot_rect, y_label, config.font_scale);
}
}
if plot_renderer.overlay_show_legend() {
let theme = plot_renderer.theme.build_theme();
let bg = theme.get_background_color();
let text = theme.get_text_color();
let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
let legend_bg = if bg_luma > 0.62 {
Color32::from_rgba_premultiplied(255, 255, 255, 170)
} else {
Color32::from_rgba_premultiplied(0, 0, 0, 128)
};
let legend_text = Color32::from_rgb(
(text.x.clamp(0.0, 1.0) * 255.0) as u8,
(text.y.clamp(0.0, 1.0) * 255.0) as u8,
(text.z.clamp(0.0, 1.0) * 255.0) as u8,
);
let legend_stroke = if bg_luma > 0.62 {
Color32::from_rgb(55, 55, 55)
} else {
Color32::BLACK
};
let entries = plot_renderer.overlay_legend_entries();
if !entries.is_empty() {
let pad = 6.0;
let mut y = centered_plot_rect.min.y + pad + 4.0;
let x = centered_plot_rect.max.x - 140.0; let legend_rect = Rect::from_min_max(
egui::pos2(x - pad, centered_plot_rect.min.y + pad),
egui::pos2(
centered_plot_rect.max.x - pad,
y + entries.len() as f32 * 18.0 + pad,
),
);
ui.painter().rect_filled(legend_rect, 4.0, legend_bg);
y += 12.0;
for e in entries {
let c = Color32::from_rgb(
(e.color.x * 255.0) as u8,
(e.color.y * 255.0) as u8,
(e.color.z * 255.0) as u8,
);
let swatch_rect =
Rect::from_min_size(egui::pos2(x, y - 6.0), egui::vec2(16.0, 8.0));
match e.plot_type {
crate::plots::figure::PlotType::Line
| crate::plots::figure::PlotType::Contour => {
let ymid = swatch_rect.center().y;
ui.painter().line_segment(
[
Pos2::new(swatch_rect.min.x, ymid),
Pos2::new(swatch_rect.max.x, ymid),
],
Stroke::new(2.0, c),
);
}
crate::plots::figure::PlotType::Scatter
| crate::plots::figure::PlotType::Scatter3 => {
let center = swatch_rect.center();
ui.painter().circle_filled(center, 3.5, c);
ui.painter().circle_stroke(
center,
3.5,
Stroke::new(1.0, legend_stroke),
);
}
crate::plots::figure::PlotType::Bar
| crate::plots::figure::PlotType::Area
| crate::plots::figure::PlotType::Surface
| crate::plots::figure::PlotType::Pie
| crate::plots::figure::PlotType::Image
| crate::plots::figure::PlotType::ContourFill => {
ui.painter().rect_filled(swatch_rect, 2.0, c);
ui.painter().rect_stroke(
swatch_rect,
2.0,
Stroke::new(1.0, legend_stroke),
);
}
crate::plots::figure::PlotType::ErrorBar
| crate::plots::figure::PlotType::Stairs
| crate::plots::figure::PlotType::Stem
| crate::plots::figure::PlotType::Quiver => {
let ymid = swatch_rect.center().y;
ui.painter().line_segment(
[
Pos2::new(swatch_rect.min.x, ymid),
Pos2::new(swatch_rect.max.x - 4.0, ymid),
],
Stroke::new(1.5, c),
);
ui.painter().line_segment(
[
Pos2::new(swatch_rect.max.x - 4.0, ymid - 3.0),
Pos2::new(swatch_rect.max.x, ymid),
],
Stroke::new(1.0, c),
);
}
}
ui.painter().text(
egui::pos2(x + 22.0, y),
Align2::LEFT_CENTER,
&e.label,
FontId::proportional(12.0),
legend_text,
);
y += 18.0;
}
}
}
if plot_renderer.overlay_colorbar_enabled() {
let bar_width = 12.0;
let pad = 8.0;
let bar_rect = Rect::from_min_max(
egui::pos2(
centered_plot_rect.max.x - bar_width - pad,
centered_plot_rect.min.y + pad,
),
egui::pos2(
centered_plot_rect.max.x - pad,
centered_plot_rect.max.y - pad,
),
);
let steps = 64;
for i in 0..steps {
let t0 = i as f32 / steps as f32;
let t1 = (i + 1) as f32 / steps as f32;
let y0 = bar_rect.min.y + (1.0 - t0) * bar_rect.height();
let y1 = bar_rect.min.y + (1.0 - t1) * bar_rect.height();
let cmap = plot_renderer.overlay_colormap();
let c = cmap.map_value(t0);
let col = Color32::from_rgb(
(c.x * 255.0) as u8,
(c.y * 255.0) as u8,
(c.z * 255.0) as u8,
);
ui.painter().rect_filled(
Rect::from_min_max(
egui::pos2(bar_rect.min.x, y1),
egui::pos2(bar_rect.max.x, y0),
),
0.0,
col,
);
}
let bg = plot_renderer.theme.build_theme().get_background_color();
let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
let border = if bg_luma > 0.62 {
Color32::from_gray(60)
} else {
Color32::WHITE
};
ui.painter()
.rect_stroke(bar_rect, 0.0, Stroke::new(1.0, border));
}
centered_plot_rect
}
pub fn compute_subplot_rects(
&self,
outer: Rect,
rows: usize,
cols: usize,
hgap: f32,
vgap: f32,
) -> Vec<Rect> {
let rows = rows.max(1) as f32;
let cols = cols.max(1) as f32;
let total_hgap = hgap * (cols - 1.0);
let total_vgap = vgap * (rows - 1.0);
let cell_w = ((outer.width()).max(1.0) - total_hgap).max(1.0) / cols;
let cell_h = ((outer.height()).max(1.0) - total_vgap).max(1.0) / rows;
let mut rects = Vec::new();
for r in 0..rows as i32 {
for c in 0..cols as i32 {
let x = outer.min.x + c as f32 * (cell_w + hgap);
let y = outer.min.y + r as f32 * (cell_h + vgap);
rects.push(Rect::from_min_size(
egui::pos2(x, y),
egui::vec2(cell_w, cell_h),
));
}
}
rects
}
fn draw_grid(
&self,
ui: &mut egui::Ui,
plot_rect: Rect,
plot_renderer: &PlotRenderer,
view_bounds_override: Option<(f64, f64, f64, f64)>,
) {
if let Some(data_bounds) = view_bounds_override
.or_else(|| plot_renderer.view_bounds())
.or_else(|| plot_renderer.data_bounds())
{
let (grid_color_major, _grid_color_minor) = self.themed_grid_colors();
let (x_min, x_max, y_min, y_max) = data_bounds;
let x_range = x_max - x_min;
let y_range = y_max - y_min;
let x_tick_interval = if plot_renderer.overlay_x_log() {
0.0
} else {
plot_utils::calculate_tick_interval(x_range)
};
let y_tick_interval = if plot_renderer.overlay_y_log() {
0.0
} else {
plot_utils::calculate_tick_interval(y_range)
};
if plot_renderer.overlay_x_log() {
let start_decade = x_min.log10().floor() as i32;
let end_decade = x_max.log10().ceil() as i32;
for d in start_decade..=end_decade {
let decade = 10f64.powi(d);
for m in [1.0, 2.0, 5.0].iter() {
let x_val = decade * m;
if x_val < x_min || x_val > x_max {
continue;
}
let x_screen = plot_rect.min.x
+ ((x_val.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
as f32
* plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, plot_rect.min.y),
Pos2::new(x_screen, plot_rect.max.y),
],
Stroke::new(0.8, grid_color_major),
);
}
}
} else {
let mut x_val = (x_min / x_tick_interval).ceil() * x_tick_interval;
while x_val <= x_max {
let x_screen =
plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, plot_rect.min.y),
Pos2::new(x_screen, plot_rect.max.y),
],
Stroke::new(0.8, grid_color_major),
);
x_val += x_tick_interval;
}
}
if plot_renderer.overlay_y_log() {
let start_decade = y_min.log10().floor() as i32;
let end_decade = y_max.log10().ceil() as i32;
for d in start_decade..=end_decade {
let decade = 10f64.powi(d);
for m in [1.0, 2.0, 5.0].iter() {
let y_val = decade * m;
if y_val < y_min || y_val > y_max {
continue;
}
let y_screen = plot_rect.max.y
- ((y_val.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
as f32
* plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(plot_rect.min.x, y_screen),
Pos2::new(plot_rect.max.x, y_screen),
],
Stroke::new(0.8, grid_color_major),
);
}
}
} else {
let mut y_val = (y_min / y_tick_interval).ceil() * y_tick_interval;
while y_val <= y_max {
let y_screen =
plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(plot_rect.min.x, y_screen),
Pos2::new(plot_rect.max.x, y_screen),
],
Stroke::new(0.8, grid_color_major),
);
y_val += y_tick_interval;
}
}
}
}
fn draw_axes(
&self,
ui: &mut egui::Ui,
plot_rect: Rect,
plot_renderer: &PlotRenderer,
config: &OverlayConfig,
view_bounds_override: Option<(f64, f64, f64, f64)>,
) {
if let Some(data_bounds) = view_bounds_override
.or_else(|| plot_renderer.view_bounds())
.or_else(|| plot_renderer.data_bounds())
{
let (x_min, x_max, y_min, y_max) = data_bounds;
let x_range = x_max - x_min;
let y_range = y_max - y_min;
let scale = config.font_scale.max(0.75);
let tick_length = 6.0 * scale;
let label_offset = 15.0 * scale;
let tick_font = FontId::proportional(10.0 * scale);
let axis_color = self.theme_axis_color();
let label_color = self.theme_text_color();
let x_log = plot_renderer.overlay_x_log();
let y_log = plot_renderer.overlay_y_log();
let (mut cat_x, mut cat_y) = (false, false);
if let Some((is_x, labels)) = plot_renderer.overlay_categorical_labels() {
if is_x {
cat_x = true;
} else {
cat_y = true;
}
if is_x {
for (idx, label) in labels.iter().enumerate() {
let x_val = (idx + 1) as f64;
if x_val < x_min || x_val > x_max {
continue;
}
let x_screen = plot_rect.min.x
+ ((x_val - x_min) / x_range) as f32 * plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, plot_rect.max.y),
Pos2::new(x_screen, plot_rect.max.y + tick_length),
],
Stroke::new(1.0, axis_color),
);
let text = truncate_label(label, 14);
ui.painter().text(
Pos2::new(x_screen, plot_rect.max.y + label_offset),
Align2::CENTER_CENTER,
text,
tick_font.clone(),
label_color,
);
}
} else {
for (idx, label) in labels.iter().enumerate() {
let y_val = (idx + 1) as f64;
if y_val < y_min || y_val > y_max {
continue;
}
let y_screen = plot_rect.max.y
- ((y_val - y_min) / y_range) as f32 * plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(plot_rect.min.x - tick_length, y_screen),
Pos2::new(plot_rect.min.x, y_screen),
],
Stroke::new(1.0, axis_color),
);
let text = truncate_label(label, 14);
ui.painter().text(
Pos2::new(plot_rect.min.x - label_offset, y_screen),
Align2::CENTER_CENTER,
text,
tick_font.clone(),
label_color,
);
}
}
}
if x_log {
let start_decade = x_min.log10().floor() as i32;
let end_decade = x_max.log10().ceil() as i32;
for d in start_decade..=end_decade {
let decade = 10f64.powi(d);
let x_screen = plot_rect.min.x
+ ((decade.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
as f32
* plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, plot_rect.max.y),
Pos2::new(x_screen, plot_rect.max.y + tick_length),
],
Stroke::new(1.0, axis_color),
);
ui.painter().text(
Pos2::new(x_screen, plot_rect.max.y + label_offset),
Align2::CENTER_CENTER,
format!("10^{}", d),
tick_font.clone(),
label_color,
);
}
} else if !cat_x {
let x_tick_interval = plot_utils::calculate_tick_interval(x_range);
let mut x_val = (x_min / x_tick_interval).ceil() * x_tick_interval;
while x_val <= x_max {
let x_screen =
plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
ui.painter().line_segment(
[
Pos2::new(x_screen, plot_rect.max.y),
Pos2::new(x_screen, plot_rect.max.y + tick_length),
],
Stroke::new(1.0, axis_color),
);
ui.painter().text(
Pos2::new(x_screen, plot_rect.max.y + label_offset),
Align2::CENTER_CENTER,
plot_utils::format_tick_label(x_val),
tick_font.clone(),
label_color,
);
x_val += x_tick_interval;
}
}
if y_log {
let start_decade = y_min.log10().floor() as i32;
let end_decade = y_max.log10().ceil() as i32;
for d in start_decade..=end_decade {
let decade = 10f64.powi(d);
let y_screen = plot_rect.max.y
- ((decade.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
as f32
* plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(plot_rect.min.x - tick_length, y_screen),
Pos2::new(plot_rect.min.x, y_screen),
],
Stroke::new(1.0, axis_color),
);
ui.painter().text(
Pos2::new(plot_rect.min.x - label_offset, y_screen),
Align2::CENTER_CENTER,
format!("10^{}", d),
tick_font.clone(),
label_color,
);
}
} else if !cat_y {
let y_tick_interval = plot_utils::calculate_tick_interval(y_range);
let mut y_val = (y_min / y_tick_interval).ceil() * y_tick_interval;
while y_val <= y_max {
let y_screen =
plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
ui.painter().line_segment(
[
Pos2::new(plot_rect.min.x - tick_length, y_screen),
Pos2::new(plot_rect.min.x, y_screen),
],
Stroke::new(1.0, axis_color),
);
ui.painter().text(
Pos2::new(plot_rect.min.x - label_offset, y_screen),
Align2::CENTER_CENTER,
plot_utils::format_tick_label(y_val),
tick_font.clone(),
label_color,
);
y_val += y_tick_interval;
}
}
}
}
fn draw_3d_orientation_gizmo(
&self,
ui: &mut egui::Ui,
plot_rect: Rect,
plot_renderer: &PlotRenderer,
axes_index: usize,
font_scale: f32,
) {
let cam_ref = plot_renderer
.axes_camera(axes_index)
.unwrap_or_else(|| plot_renderer.camera());
let cam = cam_ref.clone();
let forward = (cam.target - cam.position).normalize_or_zero();
if forward.length_squared() < 1e-9 {
return;
}
let world_up = cam.up.normalize_or_zero();
let right = forward.cross(world_up).normalize_or_zero();
if right.length_squared() < 1e-9 {
return;
}
let up = right.cross(forward).normalize_or_zero();
if up.length_squared() < 1e-9 {
return;
}
let scale = font_scale.max(0.75);
let base = plot_rect.width().min(plot_rect.height()).max(1.0);
let gizmo_size = (base * 0.16).clamp(44.0, 110.0) * scale;
let pad = (30.0 * scale).round();
let origin = Pos2::new(plot_rect.min.x + pad, plot_rect.max.y - pad);
struct AxisItem {
label: &'static str,
dir_world: Vec3,
color: Color32,
z_sort: f32,
}
let mut axes = [
AxisItem {
label: "X",
dir_world: Vec3::X,
color: Color32::from_rgb(235, 80, 80),
z_sort: 0.0,
},
AxisItem {
label: "Y",
dir_world: Vec3::Y,
color: Color32::from_rgb(90, 220, 120),
z_sort: 0.0,
},
AxisItem {
label: "Z",
dir_world: Vec3::Z,
color: Color32::from_rgb(90, 160, 255),
z_sort: 0.0,
},
];
for a in axes.iter_mut() {
let x = a.dir_world.dot(right);
let y = a.dir_world.dot(up);
let z = a.dir_world.dot(-forward);
a.z_sort = z;
a.dir_world = Vec3::new(x, y, z);
}
axes.sort_by(|a, b| {
a.z_sort
.partial_cmp(&b.z_sort)
.unwrap_or(std::cmp::Ordering::Equal)
});
let painter = ui.painter();
painter.circle_filled(origin, 2.0 * scale, Color32::from_gray(210));
let axis_len = gizmo_size * 0.65;
let head_len = (8.0 * scale).min(axis_len * 0.35);
let head_w = 5.0 * scale;
let font = FontId::proportional(11.0 * scale);
for a in axes.iter() {
let dir2 = egui::Vec2::new(a.dir_world.x, -a.dir_world.y);
let mag = dir2.length();
if !mag.is_finite() || mag < 1e-4 {
continue;
}
let d = dir2 / mag;
let end = origin + d * axis_len;
let stroke = Stroke::new(2.0 * scale, a.color);
painter.line_segment([origin, end], stroke);
let base = end - d * head_len;
let perp = egui::Vec2::new(-d.y, d.x);
painter.line_segment([end, base + perp * head_w], stroke);
painter.line_segment([end, base - perp * head_w], stroke);
let label_pos = end + d * (10.0 * scale);
painter.text(
label_pos,
Align2::CENTER_CENTER,
a.label,
font.clone(),
a.color,
);
}
}
fn draw_3d_origin_axis_ticks(
&self,
ui: &mut egui::Ui,
plot_rect: Rect,
plot_renderer: &PlotRenderer,
axes_index: usize,
font_scale: f32,
) {
let cam_ref = plot_renderer
.axes_camera(axes_index)
.unwrap_or_else(|| plot_renderer.camera());
let mut cam = cam_ref.clone();
let w = plot_rect.width().max(1.0);
let h = plot_rect.height().max(1.0);
cam.update_aspect_ratio(w / h);
let view_proj = cam.view_proj_matrix();
let project = |p: Vec3| -> Option<Pos2> {
let clip: Vec4 = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
return None;
}
let ndc = clip.truncate() / clip.w;
if !(ndc.x.is_finite() && ndc.y.is_finite()) {
return None;
}
let sx = plot_rect.min.x + ((ndc.x + 1.0) * 0.5) * plot_rect.width();
let sy = plot_rect.min.y + ((1.0 - ndc.y) * 0.5) * plot_rect.height();
Some(Pos2::new(sx, sy))
};
let nice_step = |raw: f64| -> f64 {
if !raw.is_finite() || raw <= 0.0 {
return 1.0;
}
let pow10 = 10.0_f64.powf(raw.log10().floor());
let norm = raw / pow10;
let mult = if norm <= 1.0 {
1.0
} else if norm <= 2.0 {
2.0
} else if norm <= 5.0 {
5.0
} else {
10.0
};
mult * pow10
};
let origin = Vec3::ZERO;
let px_per_world = match (project(origin), project(origin + Vec3::X)) {
(Some(a), Some(b)) => ((b.x - a.x).hypot(b.y - a.y) as f64).max(1e-3),
_ => 1.0,
};
let desired_major_px = 120.0_f64;
let major_step = nice_step((desired_major_px / px_per_world).max(1e-6));
if !(major_step.is_finite() && major_step > 0.0) {
return;
}
let axis_len = (major_step as f32 * 5.0).max(0.5);
let scale = font_scale.max(0.75);
let font = FontId::proportional(11.0 * scale);
let painter = ui.painter();
let col_x = Color32::from_rgb(235, 80, 80);
let col_y = Color32::from_rgb(90, 220, 120);
let col_z = Color32::from_rgb(90, 160, 255);
let draw_axis = |axis: Vec3, color: Color32| {
for i in 1..=6 {
let t = (i as f32) * (major_step as f32);
if t >= axis_len * 0.999 {
break;
}
let p = origin + axis * t;
let Some(pos) = project(p) else { continue };
let offset = egui::Vec2::new(6.0 * scale, -6.0 * scale);
painter.text(
pos + offset,
Align2::LEFT_CENTER,
plot_utils::format_tick_label((i as f64) * major_step),
font.clone(),
color,
);
}
};
draw_axis(Vec3::X, col_x);
draw_axis(Vec3::Y, col_y);
draw_axis(Vec3::Z, col_z);
}
fn draw_title(&self, ui: &mut egui::Ui, plot_rect: Rect, title: &str, scale: f32) {
let scale = scale.max(0.75);
let text_color = self.theme_text_color();
ui.painter().text(
Pos2::new(plot_rect.center().x, plot_rect.min.y - 20.0 * scale),
Align2::CENTER_CENTER,
title,
FontId::proportional(16.0 * scale),
text_color,
);
}
fn draw_x_label(&self, ui: &mut egui::Ui, plot_rect: Rect, label: &str, scale: f32) {
let scale = scale.max(0.75);
let text_color = self.theme_text_color();
ui.painter().text(
Pos2::new(plot_rect.center().x, plot_rect.max.y + 40.0 * scale),
Align2::CENTER_CENTER,
label,
FontId::proportional(14.0 * scale),
text_color,
);
}
fn draw_y_label(&self, ui: &mut egui::Ui, plot_rect: Rect, label: &str, scale: f32) {
let scale = scale.max(0.75);
let text_color = self.theme_text_color();
ui.painter().text(
Pos2::new(plot_rect.min.x - 40.0 * scale, plot_rect.center().y),
Align2::CENTER_CENTER,
label,
FontId::proportional(14.0 * scale),
text_color,
);
}
pub fn plot_area(&self) -> Option<Rect> {
self.plot_area
}
pub fn toolbar_rect(&self) -> Option<Rect> {
self.toolbar_rect
}
pub fn sidebar_rect(&self) -> Option<Rect> {
self.sidebar_rect
}
pub fn take_toolbar_actions(&mut self) -> (bool, bool, bool, Option<bool>, Option<bool>) {
let out = (
self.want_save_png,
self.want_save_svg,
self.want_reset_view,
self.want_toggle_grid.take(),
self.want_toggle_legend.take(),
);
self.want_save_png = false;
self.want_save_svg = false;
self.want_reset_view = false;
out
}
fn render_dystr_modal(&mut self, ctx: &Context) -> bool {
let mut consumed_input = false;
egui::Window::new("About Dystr")
.anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
.collapsible(false)
.resizable(false)
.default_width(400.0)
.show(ctx, |ui| {
consumed_input = true;
ui.vertical_centered(|ui| {
ui.add_space(10.0);
let logo_size = egui::Vec2::splat(64.0);
let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::hover()).0;
ui.painter().rect_filled(
logo_rect,
8.0, Color32::from_rgb(60, 130, 200), );
ui.painter().text(
logo_rect.center(),
Align2::CENTER_CENTER,
"D",
FontId::proportional(40.0),
Color32::WHITE,
);
ui.add_space(15.0);
ui.heading("Welcome to RunMat");
ui.add_space(10.0);
ui.label("RunMat is a high-performance MATLAB-compatible");
ui.label("numerical computing platform, built as part of");
ui.label("the Dystr computation ecosystem.");
ui.add_space(15.0);
ui.label("🚀 V8-inspired JIT compilation");
ui.label("⚡ BLAS/LAPACK acceleration");
ui.label("🎯 Full MATLAB compatibility");
ui.label("🔬 Advanced plotting & visualization");
ui.add_space(20.0);
ui.horizontal(|ui| {
if ui.button("Visit dystr.com").clicked() {
if let Err(e) = webbrowser::open("https://dystr.com") {
eprintln!("Failed to open browser: {e}");
}
}
if ui.button("Close").clicked() {
self.show_dystr_modal = false;
}
});
ui.add_space(10.0);
});
});
consumed_input
}
}
fn truncate_label(label: &str, max_len: usize) -> String {
if label.chars().count() <= max_len {
return label.to_string();
}
let mut out = String::new();
for (i, ch) in label.chars().enumerate() {
if i >= max_len - 1 {
break;
}
out.push(ch);
}
out.push('…');
out
}