From 0f01a16d603dc54afc18e1b7e641f2d80b82907f Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Sat, 19 Sep 2026 18:18:31 +0200 Subject: [PATCH] feat: add fractal info and help overlays Co-Authored-By: Claude Sonnet 5 --- src/app.rs | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/app.rs b/src/app.rs index f5c9d05..5c81b4d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,34 @@ fn kind_label(kind: FractalKind) -> &'static str { .unwrap_or("Mandelbrot") } +/// The iteration formula for a kind, in human-readable notation (mirrors the +/// doc comments on `FractalKind`'s variants). `power` is only used by +/// Multibrot. +fn kind_formula(kind: FractalKind, power: u32) -> String { + match kind { + FractalKind::Mandelbrot => "z → z² + c".to_string(), + FractalKind::BurningShip => "z → (|Re z| + i|Im z|)² + c".to_string(), + FractalKind::Tricorn => "z → conj(z)² + c".to_string(), + FractalKind::Multibrot => format!("z → z^{power} + c"), + FractalKind::Celtic => "z → |Re(z²)| + i·Im(z²) + c".to_string(), + FractalKind::Perpendicular => "z → (x² − y²) − 2x|y|i + c".to_string(), + FractalKind::Buffalo => "z → |Re(z²)| − i|Im(z²)| + c".to_string(), + FractalKind::Phoenix => "z → z² + c + p·z_prev".to_string(), + FractalKind::Lambda => "z → λ·z(1 − z)".to_string(), + } +} + +/// Shorten a long decimal string for compact display, keeping the leading +/// (most significant) digits and marking the cut with "…". +fn truncate_digits(s: &str, max_len: usize) -> String { + if s.chars().count() <= max_len { + s.to_string() + } else { + let head: String = s.chars().take(max_len).collect(); + format!("{head}…") + } +} + type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>); /// Nice-looking Julia constants offered as presets. @@ -303,6 +331,11 @@ pub struct FractalApp { /// Whether the app is in fullscreen (browser Fullscreen API on web, viewport /// fullscreen on native). Kept in sync with the real state each frame. fullscreen: bool, + /// Whether the "Fractal Info" popup (formula/constants/zoom for the + /// current view) is open. + info_open: bool, + /// Whether the Help window (about + mouse/touch controls) is open. + help_open: bool, /// Time-based animation of colours / Julia c / Phoenix p / zoom. anim: AnimState, @@ -452,6 +485,8 @@ impl FractalApp { buddha_accumulate: true, controls_open: true, fullscreen: false, + info_open: false, + help_open: false, anim: AnimState::default(), fps: 0.0, fps_frames: 0, @@ -1127,6 +1162,13 @@ impl FractalApp { self.fullscreen = !self.fullscreen; self.apply_fullscreen(ui.ctx()); } + if ui + .button("Help") + .on_hover_text("About this app, and mouse/touch controls") + .clicked() + { + self.help_open = !self.help_open; + } // FPS readout. Monospace + fixed width so the number // changing doesn't jitter the button row. ui.add( @@ -1145,6 +1187,180 @@ impl FractalApp { }); } + /// Floating bottom-left overlay: a single button that toggles the + /// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left) + /// so it stays out of the way of the panel toggle / fullscreen controls, + /// but is still reachable even when the controls panel is collapsed. + fn info_button(&mut self, ui: &mut egui::Ui) { + egui::Area::new(egui::Id::new("info_button")) + .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .shadow(egui::Shadow::NONE) + .show(ui, |ui| { + if ui + .button("ⓘ Info") + .on_hover_text("Show details about the current fractal") + .clicked() + { + self.info_open = !self.info_open; + } + }); + }); + } + + /// Window with details about what's currently on screen: formula, active + /// per-kind constants, zoom depth, iteration count. Reads live state, so + /// it stays correct as the user pans/zooms/switches kinds. + fn info_window(&mut self, ctx: &egui::Context) { + let mut open = self.info_open; + egui::Window::new("Fractal Info") + .id(egui::Id::new("info_window")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -44.0)) + .show(ctx, |ui| { + ui.label( + egui::RichText::new(kind_label(self.kind)) + .strong() + .heading(), + ); + let mode_label = match self.mode { + FractalMode::Mandelbrot => { + "Mandelbrot mode — parameter space (c varies per pixel, z₀ = 0)" + } + FractalMode::Julia => { + "Julia mode — dynamical plane for a fixed c (z₀ varies per pixel)" + } + }; + ui.label(mode_label); + ui.separator(); + + ui.label(format!("formula: {}", kind_formula(self.kind, self.power))); + if self.mode == FractalMode::Julia { + ui.label(format!("c = {:.6} {:+.6}i", self.julia_c.0, self.julia_c.1)); + } + if self.kind == FractalKind::Phoenix { + ui.label(format!( + "p = {:.6} {:+.6}i", + self.phoenix_p.0, self.phoenix_p.1 + )); + } + if self.kind == FractalKind::Lambda { + ui.label(format!( + "λ = {:.6} {:+.6}i", + self.lambda_l.0, self.lambda_l.1 + )); + } + ui.separator(); + + if self.buddhabrot { + ui.label("rendering: Buddhabrot (Monte-Carlo orbit density)"); + ui.label(format!( + "sample caps: R {} / G {} / B {}", + self.buddha_r_cap, self.buddha_g_cap, self.buddha_b_cap + )); + } else { + ui.label(format!( + "magnification: {}×", + format_magnification(self.view.magnification()) + )); + ui.label(format!( + "iterations: {}{}", + self.max_iterations, + if self.auto_iterations { " (auto)" } else { "" } + )); + ui.label(format!("precision: {} bits", self.view.precision_bits())); + ui.label(format!( + "center re: {}", + truncate_digits(&self.center_re_edit, 24) + )); + ui.label(format!( + "center im: {}", + truncate_digits(&self.center_im_edit, 24) + )); + } + }); + self.info_open = open; + } + + /// Help window: what the app does, plus a reference for mouse/touch + /// controls (there are no in-app keyboard shortcuts today). + fn help_window(&mut self, ctx: &egui::Context) { + let mut open = self.help_open; + egui::Window::new("Help") + .id(egui::Id::new("help_window")) + .open(&mut open) + .collapsible(false) + .default_width(360.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(480.0) + .show(ui, |ui| { + ui.heading("About"); + ui.label( + "A deep-zoom fractal explorer. It renders the Mandelbrot set \ + and several related fractals (Burning Ship, Tricorn, \ + Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda), \ + and can zoom in far beyond what ordinary floating-point math \ + allows by using perturbation theory: one very precise \ + reference orbit is computed once, and every pixel on screen \ + is then rendered cheaply as a small offset from that shared \ + orbit.", + ); + ui.add_space(4.0); + ui.label( + "Mandelbrot mode plots one point per pixel in parameter space \ + (does c escape?). Julia mode fixes c and instead varies the \ + starting point z₀ across the plane. Buddhabrot mode switches \ + to a different, Monte-Carlo rendering of orbit density \ + instead of the ordinary escape-time set.", + ); + ui.separator(); + + ui.heading("Mouse & touch"); + egui::Grid::new("help_mouse_grid") + .num_columns(2) + .spacing([12.0, 6.0]) + .show(ui, |ui| { + ui.label("Drag"); + ui.label("Pan the view"); + ui.end_row(); + ui.label("Scroll / trackpad"); + ui.label("Zoom toward the cursor"); + ui.end_row(); + ui.label("Pinch (touch)"); + ui.label("Zoom toward the gesture center"); + ui.end_row(); + ui.label("Two-finger drag (touch)"); + ui.label("Pan the view"); + ui.end_row(); + }); + ui.separator(); + + ui.heading("Keyboard"); + ui.label( + "The view is driven entirely by mouse/touch — there are no \ + in-app keyboard shortcuts. Esc / F11 toggle fullscreen via \ + the browser or OS, as usual.", + ); + ui.separator(); + + ui.heading("Tips"); + ui.label( + "• The controls panel (top-left \"Menu\" button) holds the \ + fractal formula, coloring, animation, and export options.\n\ + • The bottom-left \"Info\" button shows details about what's \ + currently on screen.\n\ + • \"Copy link\" (in the panel) encodes the exact view so it \ + can be reopened later or sent to someone else.", + ); + }); + }); + self.help_open = open; + } + /// Push the desired fullscreen state to the platform. #[cfg(not(target_arch = "wasm32"))] fn apply_fullscreen(&mut self, ctx: &egui::Context) { @@ -1878,6 +2094,9 @@ impl eframe::App for FractalApp { // Floating overlay, always reachable (even when the panel is collapsed): // toggle the panel and toggle fullscreen. Essential on a phone. self.overlay_buttons(ui); + self.info_button(ui); + self.info_window(ui.ctx()); + self.help_window(ui.ctx()); if std::mem::take(&mut self.export_requested) { self.do_export(frame);