gardesk/garcalc / ace3ed3

Browse files

add viewport presets, zoom-to-fit, and extended color palette

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ace3ed35ebe599c964b041bbbf06e4f0aa19ff76
Parents
5b07250
Tree
caa9cd6

2 changed files

StatusFile+-
M garcalc-graph/src/lib.rs 92 2
M garcalc-graph/src/plot2d.rs 553 21
garcalc-graph/src/lib.rsmodified
@@ -6,8 +6,8 @@
66
 pub mod plot2d;
77
 pub mod plot3d;
88
 
9
-pub use plot2d::{CURVE_COLORS, Graph2D, PlotConfig};
10
-pub use plot3d::{Camera3D, Colormap, Graph3D, Plot3DConfig, RenderMode, Viewport3D};
9
+pub use plot2d::{CURVE_COLORS, Graph2D, PlotConfig, PlottedFunction, ViewportPreset};
10
+pub use plot3d::{Camera3D, CameraPreset, Colormap, Graph3D, Plot3DConfig, RenderMode, Surface3D, Viewport3D};
1111
 
1212
 use garcalc_cas::Expr;
1313
 use serde::{Deserialize, Serialize};
@@ -56,6 +56,39 @@ pub enum LineStyle {
5656
     Dotted,
5757
 }
5858
 
59
+/// A single piece of a piecewise function
60
+#[derive(Debug, Clone, Serialize, Deserialize)]
61
+pub struct PiecewisePiece {
62
+    pub expr: Expr,
63
+    pub condition: PiecewiseCondition,
64
+}
65
+
66
+/// Condition for a piecewise segment
67
+#[derive(Debug, Clone, Serialize, Deserialize)]
68
+pub enum PiecewiseCondition {
69
+    LessThan(f64),
70
+    LessEqual(f64),
71
+    GreaterThan(f64),
72
+    GreaterEqual(f64),
73
+    Between(f64, f64),
74
+    BetweenInclusive(f64, f64),
75
+    Always,
76
+}
77
+
78
+impl PiecewiseCondition {
79
+    pub fn matches(&self, x: f64) -> bool {
80
+        match self {
81
+            Self::LessThan(v) => x < *v,
82
+            Self::LessEqual(v) => x <= *v,
83
+            Self::GreaterThan(v) => x > *v,
84
+            Self::GreaterEqual(v) => x >= *v,
85
+            Self::Between(a, b) => x > *a && x < *b,
86
+            Self::BetweenInclusive(a, b) => x >= *a && x <= *b,
87
+            Self::Always => true,
88
+        }
89
+    }
90
+}
91
+
5992
 /// A plottable function or relation
6093
 #[derive(Debug, Clone, Serialize, Deserialize)]
6194
 pub enum Plottable {
@@ -81,6 +114,21 @@ pub enum Plottable {
81114
         t_range: (f64, f64),
82115
         color: Color,
83116
     },
117
+    /// r = f(theta)
118
+    Polar2D {
119
+        expr: Expr,
120
+        theta_var: String,
121
+        theta_range: (f64, f64),
122
+        color: Color,
123
+        style: LineStyle,
124
+    },
125
+    /// Piecewise-defined function
126
+    Piecewise2D {
127
+        pieces: Vec<PiecewisePiece>,
128
+        x_var: String,
129
+        color: Color,
130
+        style: LineStyle,
131
+    },
84132
     /// z = f(x, y)
85133
     Explicit3D {
86134
         expr: Expr,
@@ -89,6 +137,48 @@ pub enum Plottable {
89137
     },
90138
 }
91139
 
140
+/// Extended color palette (6 base catppuccin + 6 additional)
141
+pub const COLOR_PALETTE: [Color; 12] = [
142
+    // Original 6 from CURVE_COLORS
143
+    Color { r: 137, g: 180, b: 250, a: 255 }, // blue
144
+    Color { r: 166, g: 227, b: 161, a: 255 }, // green
145
+    Color { r: 249, g: 226, b: 175, a: 255 }, // yellow
146
+    Color { r: 243, g: 139, b: 168, a: 255 }, // red
147
+    Color { r: 203, g: 166, b: 247, a: 255 }, // mauve
148
+    Color { r: 148, g: 226, b: 213, a: 255 }, // teal
149
+    // Additional 6
150
+    Color { r: 245, g: 224, b: 220, a: 255 }, // rosewater
151
+    Color { r: 242, g: 205, b: 205, a: 255 }, // flamingo
152
+    Color { r: 245, g: 194, b: 231, a: 255 }, // pink
153
+    Color { r: 250, g: 179, b: 135, a: 255 }, // peach
154
+    Color { r: 180, g: 190, b: 254, a: 255 }, // lavender
155
+    Color { r: 137, g: 220, b: 235, a: 255 }, // sky
156
+];
157
+
158
+impl Plottable {
159
+    pub fn color(&self) -> Option<Color> {
160
+        match self {
161
+            Self::Explicit2D { color, .. }
162
+            | Self::Implicit2D { color, .. }
163
+            | Self::Parametric2D { color, .. }
164
+            | Self::Polar2D { color, .. }
165
+            | Self::Piecewise2D { color, .. } => Some(*color),
166
+            Self::Explicit3D { .. } => None,
167
+        }
168
+    }
169
+
170
+    pub fn set_color(&mut self, new_color: Color) {
171
+        match self {
172
+            Self::Explicit2D { color, .. }
173
+            | Self::Implicit2D { color, .. }
174
+            | Self::Parametric2D { color, .. }
175
+            | Self::Polar2D { color, .. }
176
+            | Self::Piecewise2D { color, .. } => *color = new_color,
177
+            Self::Explicit3D { .. } => {}
178
+        }
179
+    }
180
+}
181
+
92182
 /// 2D viewport bounds
93183
 #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
94184
 pub struct Viewport2D {
garcalc-graph/src/plot2d.rsmodified
@@ -114,6 +114,22 @@ pub const CURVE_COLORS: [Color; 6] = [
114114
     }, // teal
115115
 ];
116116
 
117
+/// Viewport preset configurations
118
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119
+pub enum ViewportPreset {
120
+    Standard,
121
+    Trig,
122
+    ZoomFit,
123
+}
124
+
125
+/// A plotted function with visibility and label
126
+#[derive(Debug, Clone)]
127
+pub struct PlottedFunction {
128
+    pub func: Plottable,
129
+    pub visible: bool,
130
+    pub label: String,
131
+}
132
+
117133
 /// 2D graph state and renderer
118134
 pub struct Graph2D {
119135
     /// Current viewport
@@ -121,7 +137,7 @@ pub struct Graph2D {
121137
     /// Plot configuration
122138
     pub config: PlotConfig,
123139
     /// Functions to plot
124
-    pub functions: Vec<Plottable>,
140
+    pub functions: Vec<PlottedFunction>,
125141
     /// Trace mode: show cursor position
126142
     pub trace_enabled: bool,
127143
     /// Current trace position (screen coords)
@@ -152,44 +168,166 @@ impl Graph2D {
152168
     }
153169
 
154170
     /// Add a function to plot
155
-    pub fn add_function(&mut self, func: Plottable) {
156
-        self.functions.push(func);
171
+    pub fn add_function(&mut self, func: Plottable, label: String) {
172
+        self.functions.push(PlottedFunction {
173
+            func,
174
+            visible: true,
175
+            label,
176
+        });
157177
     }
158178
 
159179
     /// Add an explicit function y = f(x) with auto color
160180
     pub fn add_explicit(&mut self, expr: Expr) {
161181
         let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
162
-        self.functions.push(Plottable::Explicit2D {
163
-            expr,
164
-            x_var: "x".to_string(),
165
-            color,
166
-            style: LineStyle::Solid,
182
+        let label = format!("y = {}", expr);
183
+        self.functions.push(PlottedFunction {
184
+            func: Plottable::Explicit2D {
185
+                expr,
186
+                x_var: "x".to_string(),
187
+                color,
188
+                style: LineStyle::Solid,
189
+            },
190
+            visible: true,
191
+            label,
167192
         });
168193
     }
169194
 
170195
     /// Add an implicit curve F(x,y) = 0
171196
     pub fn add_implicit(&mut self, expr: Expr) {
172197
         let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
173
-        self.functions.push(Plottable::Implicit2D {
174
-            expr,
175
-            x_var: "x".to_string(),
176
-            y_var: "y".to_string(),
177
-            color,
198
+        let label = format!("{} = 0", expr);
199
+        self.functions.push(PlottedFunction {
200
+            func: Plottable::Implicit2D {
201
+                expr,
202
+                x_var: "x".to_string(),
203
+                y_var: "y".to_string(),
204
+                color,
205
+            },
206
+            visible: true,
207
+            label,
178208
         });
179209
     }
180210
 
181211
     /// Add a parametric curve (x(t), y(t))
182212
     pub fn add_parametric(&mut self, x_expr: Expr, y_expr: Expr, t_range: (f64, f64)) {
183213
         let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
184
-        self.functions.push(Plottable::Parametric2D {
185
-            x_expr,
186
-            y_expr,
187
-            t_var: "t".to_string(),
188
-            t_range,
189
-            color,
214
+        let label = format!("({}, {})", x_expr, y_expr);
215
+        self.functions.push(PlottedFunction {
216
+            func: Plottable::Parametric2D {
217
+                x_expr,
218
+                y_expr,
219
+                t_var: "t".to_string(),
220
+                t_range,
221
+                color,
222
+            },
223
+            visible: true,
224
+            label,
225
+        });
226
+    }
227
+
228
+    /// Add a polar curve r = f(theta)
229
+    pub fn add_polar(&mut self, expr: Expr, theta_range: (f64, f64)) {
230
+        let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
231
+        let label = format!("r = {}", expr);
232
+        self.functions.push(PlottedFunction {
233
+            func: Plottable::Polar2D {
234
+                expr,
235
+                theta_var: "theta".to_string(),
236
+                theta_range,
237
+                color,
238
+                style: LineStyle::Solid,
239
+            },
240
+            visible: true,
241
+            label,
190242
         });
191243
     }
192244
 
245
+    /// Add a piecewise function
246
+    pub fn add_piecewise(&mut self, pieces: Vec<crate::PiecewisePiece>) {
247
+        let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()];
248
+        let label = format!("piecewise ({} pieces)", pieces.len());
249
+        self.functions.push(PlottedFunction {
250
+            func: Plottable::Piecewise2D {
251
+                pieces,
252
+                x_var: "x".to_string(),
253
+                color,
254
+                style: LineStyle::Solid,
255
+            },
256
+            visible: true,
257
+            label,
258
+        });
259
+    }
260
+
261
+    /// Apply a viewport preset
262
+    pub fn apply_viewport_preset(&mut self, preset: ViewportPreset) {
263
+        match preset {
264
+            ViewportPreset::Standard => {
265
+                self.viewport = Viewport2D::default();
266
+            }
267
+            ViewportPreset::Trig => {
268
+                self.viewport = Viewport2D {
269
+                    x_min: -2.0 * std::f64::consts::PI,
270
+                    x_max: 2.0 * std::f64::consts::PI,
271
+                    y_min: -1.5,
272
+                    y_max: 1.5,
273
+                };
274
+            }
275
+            ViewportPreset::ZoomFit => {
276
+                self.zoom_to_fit();
277
+            }
278
+        }
279
+    }
280
+
281
+    /// Auto-zoom to fit visible function outputs
282
+    pub fn zoom_to_fit(&mut self) {
283
+        let mut y_min = f64::INFINITY;
284
+        let mut y_max = f64::NEG_INFINITY;
285
+        let mut evaluator = Evaluator::new();
286
+        let num_samples = 500;
287
+        let x_range = self.viewport.x_max - self.viewport.x_min;
288
+        let dx = x_range / num_samples as f64;
289
+
290
+        for pf in &self.functions {
291
+            if !pf.visible {
292
+                continue;
293
+            }
294
+            if let Plottable::Explicit2D { ref expr, ref x_var, .. } = pf.func {
295
+                for i in 0..=num_samples {
296
+                    let x = self.viewport.x_min + i as f64 * dx;
297
+                    evaluator.set_var(x_var, Expr::Float(x));
298
+                    if let Ok(result) = evaluator.eval(expr) {
299
+                        if let Ok(y) = expr_to_f64(&result) {
300
+                            if y.is_finite() {
301
+                                y_min = y_min.min(y);
302
+                                y_max = y_max.max(y);
303
+                            }
304
+                        }
305
+                    }
306
+                }
307
+            }
308
+        }
309
+
310
+        if y_min.is_finite() && y_max.is_finite() && (y_max - y_min).abs() > 1e-10 {
311
+            let margin = (y_max - y_min) * 0.1;
312
+            self.viewport.y_min = y_min - margin;
313
+            self.viewport.y_max = y_max + margin;
314
+        }
315
+    }
316
+
317
+    /// Set the color of a function at the given index
318
+    pub fn set_function_color(&mut self, index: usize, color: crate::Color) {
319
+        if let Some(pf) = self.functions.get_mut(index) {
320
+            pf.func.set_color(color);
321
+        }
322
+    }
323
+
324
+    /// Toggle visibility of function at index
325
+    pub fn toggle_visibility(&mut self, index: usize) {
326
+        if let Some(f) = self.functions.get_mut(index) {
327
+            f.visible = !f.visible;
328
+        }
329
+    }
330
+
193331
     /// Clear all functions
194332
     pub fn clear_functions(&mut self) {
195333
         self.functions.clear();
@@ -282,8 +420,10 @@ impl Graph2D {
282420
         self.draw_axes(ctx, width, height);
283421
 
284422
         // Functions
285
-        for func in &self.functions {
286
-            self.draw_function(ctx, func, width, height);
423
+        for pf in &self.functions {
424
+            if pf.visible {
425
+                self.draw_function(ctx, &pf.func, width, height);
426
+            }
287427
         }
288428
 
289429
         // Trace
@@ -426,6 +566,23 @@ impl Graph2D {
426566
             } => {
427567
                 self.draw_parametric(ctx, x_expr, y_expr, t_var, *t_range, *color, width, height);
428568
             }
569
+            Plottable::Polar2D {
570
+                expr,
571
+                theta_var,
572
+                theta_range,
573
+                color,
574
+                style,
575
+            } => {
576
+                self.draw_polar(ctx, expr, theta_var, *theta_range, *color, *style, width, height);
577
+            }
578
+            Plottable::Piecewise2D {
579
+                pieces,
580
+                x_var,
581
+                color,
582
+                style,
583
+            } => {
584
+                self.draw_piecewise(ctx, pieces, x_var, *color, *style, width, height);
585
+            }
429586
             _ => {}
430587
         }
431588
     }
@@ -671,6 +828,381 @@ impl Graph2D {
671828
         let _ = ctx.stroke();
672829
     }
673830
 
831
+    fn draw_polar(
832
+        &self,
833
+        ctx: &Context,
834
+        expr: &Expr,
835
+        theta_var: &str,
836
+        theta_range: (f64, f64),
837
+        color: Color,
838
+        style: LineStyle,
839
+        width: u32,
840
+        height: u32,
841
+    ) {
842
+        set_color(ctx, color);
843
+        ctx.set_line_width(self.config.curve_width);
844
+        match style {
845
+            LineStyle::Solid => ctx.set_dash(&[], 0.0),
846
+            LineStyle::Dashed => ctx.set_dash(&[6.0, 4.0], 0.0),
847
+            LineStyle::Dotted => ctx.set_dash(&[2.0, 2.0], 0.0),
848
+        }
849
+
850
+        let mut evaluator = Evaluator::new();
851
+        let num_samples = (width as f64 * self.config.samples_per_pixel) as usize;
852
+        let theta_span = theta_range.1 - theta_range.0;
853
+        let dtheta = theta_span / num_samples as f64;
854
+        let mut first = true;
855
+
856
+        for i in 0..=num_samples {
857
+            let theta = theta_range.0 + i as f64 * dtheta;
858
+            evaluator.set_var(theta_var, Expr::Float(theta));
859
+            if let Ok(result) = evaluator.eval(expr) {
860
+                if let Ok(r) = expr_to_f64(&result) {
861
+                    if r.is_finite() {
862
+                        let math_x = r * theta.cos();
863
+                        let math_y = r * theta.sin();
864
+                        let (sx, sy) = self.math_to_screen(math_x, math_y, width, height);
865
+                        if first {
866
+                            ctx.move_to(sx, sy);
867
+                            first = false;
868
+                        } else {
869
+                            ctx.line_to(sx, sy);
870
+                        }
871
+                        continue;
872
+                    }
873
+                }
874
+            }
875
+            if !first {
876
+                let _ = ctx.stroke();
877
+                first = true;
878
+            }
879
+        }
880
+        let _ = ctx.stroke();
881
+        ctx.set_dash(&[], 0.0);
882
+    }
883
+
884
+    fn draw_piecewise(
885
+        &self,
886
+        ctx: &Context,
887
+        pieces: &[crate::PiecewisePiece],
888
+        x_var: &str,
889
+        color: Color,
890
+        style: LineStyle,
891
+        width: u32,
892
+        height: u32,
893
+    ) {
894
+        set_color(ctx, color);
895
+        ctx.set_line_width(self.config.curve_width);
896
+        match style {
897
+            LineStyle::Solid => ctx.set_dash(&[], 0.0),
898
+            LineStyle::Dashed => ctx.set_dash(&[6.0, 4.0], 0.0),
899
+            LineStyle::Dotted => ctx.set_dash(&[2.0, 2.0], 0.0),
900
+        }
901
+
902
+        let mut evaluator = Evaluator::new();
903
+        let num_samples = (width as f64 * self.config.samples_per_pixel) as usize;
904
+        let x_range = self.viewport.x_max - self.viewport.x_min;
905
+        let dx = x_range / num_samples as f64;
906
+        let mut first = true;
907
+        let mut last_valid = false;
908
+        let mut last_piece_idx: Option<usize> = None;
909
+
910
+        for i in 0..=num_samples {
911
+            let math_x = self.viewport.x_min + i as f64 * dx;
912
+            let piece_idx = pieces.iter().position(|p| p.condition.matches(math_x));
913
+
914
+            if piece_idx != last_piece_idx && last_valid {
915
+                let _ = ctx.stroke();
916
+                first = true;
917
+                last_valid = false;
918
+            }
919
+            last_piece_idx = piece_idx;
920
+
921
+            if let Some(idx) = piece_idx {
922
+                evaluator.set_var(x_var, Expr::Float(math_x));
923
+                if let Ok(result) = evaluator.eval(&pieces[idx].expr) {
924
+                    if let Ok(math_y) = expr_to_f64(&result) {
925
+                        if math_y.is_finite()
926
+                            && math_y >= self.viewport.y_min - x_range
927
+                            && math_y <= self.viewport.y_max + x_range
928
+                        {
929
+                            let (sx, sy) = self.math_to_screen(math_x, math_y, width, height);
930
+                            if first || !last_valid {
931
+                                ctx.move_to(sx, sy);
932
+                                first = false;
933
+                            } else {
934
+                                ctx.line_to(sx, sy);
935
+                            }
936
+                            last_valid = true;
937
+                            continue;
938
+                        }
939
+                    }
940
+                }
941
+            }
942
+            if last_valid {
943
+                let _ = ctx.stroke();
944
+            }
945
+            last_valid = false;
946
+        }
947
+        let _ = ctx.stroke();
948
+        ctx.set_dash(&[], 0.0);
949
+    }
950
+
951
+    /// Find zeros (x-intercepts) of all visible Explicit2D functions
952
+    pub fn find_zeros(&self) -> Vec<(usize, Vec<(f64, f64)>)> {
953
+        let mut result = Vec::new();
954
+        for (idx, pf) in self.functions.iter().enumerate() {
955
+            if !pf.visible {
956
+                continue;
957
+            }
958
+            if let Plottable::Explicit2D { ref expr, ref x_var, .. } = pf.func {
959
+                let zeros = self.find_zeros_for_expr(expr, x_var);
960
+                if !zeros.is_empty() {
961
+                    result.push((idx, zeros));
962
+                }
963
+            }
964
+        }
965
+        result
966
+    }
967
+
968
+    fn find_zeros_for_expr(&self, expr: &Expr, x_var: &str) -> Vec<(f64, f64)> {
969
+        let mut evaluator = Evaluator::new();
970
+        let mut zeros = Vec::new();
971
+        let num_samples = 500;
972
+        let x_range = self.viewport.x_max - self.viewport.x_min;
973
+        let dx = x_range / num_samples as f64;
974
+
975
+        let eval_at = |eval: &mut Evaluator, x: f64| -> Option<f64> {
976
+            eval.set_var(x_var, Expr::Float(x));
977
+            eval.eval(expr)
978
+                .ok()
979
+                .and_then(|r| expr_to_f64(&r).ok())
980
+                .filter(|y| y.is_finite())
981
+        };
982
+
983
+        let mut prev: Option<f64> = None;
984
+        let mut prev_x = self.viewport.x_min;
985
+
986
+        for i in 0..=num_samples {
987
+            let x = self.viewport.x_min + i as f64 * dx;
988
+            if let Some(y) = eval_at(&mut evaluator, x) {
989
+                if let Some(py) = prev {
990
+                    if py * y < 0.0 {
991
+                        if let Some(zx) = self.bisect(expr, x_var, prev_x, x, 50) {
992
+                            if zeros
993
+                                .last()
994
+                                .map_or(true, |&(lx, _): &(f64, f64)| (lx - zx).abs() > dx * 0.1)
995
+                            {
996
+                                zeros.push((zx, 0.0));
997
+                            }
998
+                        }
999
+                    }
1000
+                }
1001
+                prev = Some(y);
1002
+                prev_x = x;
1003
+            } else {
1004
+                prev = None;
1005
+            }
1006
+        }
1007
+        zeros
1008
+    }
1009
+
1010
+    fn bisect(
1011
+        &self,
1012
+        expr: &Expr,
1013
+        x_var: &str,
1014
+        mut a: f64,
1015
+        mut b: f64,
1016
+        max_iter: usize,
1017
+    ) -> Option<f64> {
1018
+        let mut evaluator = Evaluator::new();
1019
+        let eval_at = |eval: &mut Evaluator, x: f64| -> Option<f64> {
1020
+            eval.set_var(x_var, Expr::Float(x));
1021
+            eval.eval(expr)
1022
+                .ok()
1023
+                .and_then(|r| expr_to_f64(&r).ok())
1024
+                .filter(|y| y.is_finite())
1025
+        };
1026
+
1027
+        let fa = eval_at(&mut evaluator, a)?;
1028
+        if fa.abs() < 1e-12 {
1029
+            return Some(a);
1030
+        }
1031
+
1032
+        for _ in 0..max_iter {
1033
+            let mid = (a + b) / 2.0;
1034
+            let fm = eval_at(&mut evaluator, mid)?;
1035
+            if fm.abs() < 1e-12 || (b - a).abs() < 1e-12 {
1036
+                return Some(mid);
1037
+            }
1038
+            if fa * fm < 0.0 {
1039
+                b = mid;
1040
+            } else {
1041
+                a = mid;
1042
+            }
1043
+        }
1044
+        Some((a + b) / 2.0)
1045
+    }
1046
+
1047
+    /// Find intersections between pairs of visible Explicit2D functions
1048
+    pub fn find_intersections(&self) -> Vec<((usize, usize), Vec<(f64, f64)>)> {
1049
+        let mut result = Vec::new();
1050
+        let explicit_indices: Vec<usize> = self
1051
+            .functions
1052
+            .iter()
1053
+            .enumerate()
1054
+            .filter(|(_, pf)| pf.visible && matches!(pf.func, Plottable::Explicit2D { .. }))
1055
+            .map(|(i, _)| i)
1056
+            .collect();
1057
+
1058
+        for i in 0..explicit_indices.len() {
1059
+            for j in (i + 1)..explicit_indices.len() {
1060
+                let idx_i = explicit_indices[i];
1061
+                let idx_j = explicit_indices[j];
1062
+                let points = self.find_intersection_points(idx_i, idx_j);
1063
+                if !points.is_empty() {
1064
+                    result.push(((idx_i, idx_j), points));
1065
+                }
1066
+            }
1067
+        }
1068
+        result
1069
+    }
1070
+
1071
+    fn find_intersection_points(&self, idx_i: usize, idx_j: usize) -> Vec<(f64, f64)> {
1072
+        let (expr_i, var_i) = match &self.functions[idx_i].func {
1073
+            Plottable::Explicit2D { expr, x_var, .. } => (expr, x_var.as_str()),
1074
+            _ => return Vec::new(),
1075
+        };
1076
+        let (expr_j, _) = match &self.functions[idx_j].func {
1077
+            Plottable::Explicit2D { expr, x_var, .. } => (expr, x_var.as_str()),
1078
+            _ => return Vec::new(),
1079
+        };
1080
+
1081
+        let mut eval_i = Evaluator::new();
1082
+        let mut eval_j = Evaluator::new();
1083
+        let mut points = Vec::new();
1084
+        let num_samples = 500;
1085
+        let x_range = self.viewport.x_max - self.viewport.x_min;
1086
+        let dx = x_range / num_samples as f64;
1087
+
1088
+        let eval_diff = |ei: &mut Evaluator, ej: &mut Evaluator, x: f64| -> Option<f64> {
1089
+            ei.set_var(var_i, Expr::Float(x));
1090
+            ej.set_var(var_i, Expr::Float(x));
1091
+            let yi = ei.eval(expr_i).ok().and_then(|r| expr_to_f64(&r).ok())?;
1092
+            let yj = ej.eval(expr_j).ok().and_then(|r| expr_to_f64(&r).ok())?;
1093
+            if yi.is_finite() && yj.is_finite() {
1094
+                Some(yi - yj)
1095
+            } else {
1096
+                None
1097
+            }
1098
+        };
1099
+
1100
+        let mut prev_diff: Option<f64> = None;
1101
+        let mut prev_x = self.viewport.x_min;
1102
+
1103
+        for i in 0..=num_samples {
1104
+            let x = self.viewport.x_min + i as f64 * dx;
1105
+            if let Some(diff) = eval_diff(&mut eval_i, &mut eval_j, x) {
1106
+                if let Some(pd) = prev_diff {
1107
+                    if pd * diff < 0.0 {
1108
+                        let mut a = prev_x;
1109
+                        let mut b = x;
1110
+                        for _ in 0..50 {
1111
+                            let mid = (a + b) / 2.0;
1112
+                            if let Some(dm) = eval_diff(&mut eval_i, &mut eval_j, mid) {
1113
+                                if dm.abs() < 1e-12 || (b - a).abs() < 1e-12 {
1114
+                                    a = mid;
1115
+                                    b = mid;
1116
+                                    break;
1117
+                                }
1118
+                                if pd * dm < 0.0 {
1119
+                                    b = mid;
1120
+                                } else {
1121
+                                    a = mid;
1122
+                                }
1123
+                            } else {
1124
+                                break;
1125
+                            }
1126
+                        }
1127
+                        let zx = (a + b) / 2.0;
1128
+                        eval_i.set_var(var_i, Expr::Float(zx));
1129
+                        if let Ok(r) = eval_i.eval(expr_i) {
1130
+                            if let Ok(zy) = expr_to_f64(&r) {
1131
+                                if zy.is_finite()
1132
+                                    && points.last().map_or(true, |&(lx, _): &(f64, f64)| {
1133
+                                        (lx - zx).abs() > dx * 0.1
1134
+                                    })
1135
+                                {
1136
+                                    points.push((zx, zy));
1137
+                                }
1138
+                            }
1139
+                        }
1140
+                    }
1141
+                }
1142
+                prev_diff = Some(diff);
1143
+                prev_x = x;
1144
+            } else {
1145
+                prev_diff = None;
1146
+            }
1147
+        }
1148
+        points
1149
+    }
1150
+
1151
+    /// Draw marker circles at given points
1152
+    pub fn draw_markers(
1153
+        &self,
1154
+        ctx: &Context,
1155
+        points: &[(f64, f64)],
1156
+        color: Color,
1157
+        width: u32,
1158
+        height: u32,
1159
+    ) {
1160
+        set_color(ctx, color);
1161
+        for &(mx, my) in points {
1162
+            let (sx, sy) = self.math_to_screen(mx, my, width, height);
1163
+            ctx.arc(sx, sy, 4.0, 0.0, std::f64::consts::TAU);
1164
+            let _ = ctx.fill();
1165
+
1166
+            let label = format!("({:.3}, {:.3})", mx, my);
1167
+            ctx.set_font_size(10.0);
1168
+            ctx.move_to(sx + 6.0, sy - 6.0);
1169
+            let _ = ctx.show_text(&label);
1170
+        }
1171
+    }
1172
+
1173
+    /// Generate a table of (x, f(x)) values for an Explicit2D function
1174
+    pub fn generate_table(
1175
+        &self,
1176
+        func_index: usize,
1177
+        x_min: f64,
1178
+        x_max: f64,
1179
+        step: f64,
1180
+    ) -> Vec<(f64, Option<f64>)> {
1181
+        let pf = match self.functions.get(func_index) {
1182
+            Some(f) => f,
1183
+            None => return Vec::new(),
1184
+        };
1185
+        let (expr, x_var) = match &pf.func {
1186
+            Plottable::Explicit2D { expr, x_var, .. } => (expr, x_var.as_str()),
1187
+            _ => return Vec::new(),
1188
+        };
1189
+
1190
+        let mut evaluator = Evaluator::new();
1191
+        let mut table = Vec::new();
1192
+        let mut x = x_min;
1193
+        while x <= x_max + step * 0.01 {
1194
+            evaluator.set_var(x_var, Expr::Float(x));
1195
+            let y = evaluator
1196
+                .eval(expr)
1197
+                .ok()
1198
+                .and_then(|r| expr_to_f64(&r).ok())
1199
+                .filter(|v| v.is_finite());
1200
+            table.push((x, y));
1201
+            x += step;
1202
+        }
1203
+        table
1204
+    }
1205
+
6741206
     fn draw_trace(&self, ctx: &Context, sx: f64, sy: f64, width: u32, height: u32) {
6751207
         let (mx, my) = self.screen_to_math(sx, sy, width, height);
6761208