@@ -1,20 +1,557 @@ |
| 1 | | -//! 2D plotting (Sprint 3) |
| 1 | +//! 2D plotting engine |
| 2 | +//! |
| 3 | +//! Provides function plotting with: |
| 4 | +//! - Explicit functions: y = f(x) |
| 5 | +//! - Parametric curves: (x(t), y(t)) |
| 6 | +//! - Coordinate system with axes and grid |
| 7 | +//! - Pan/zoom interaction |
| 8 | +//! - Trace mode |
| 2 | 9 | |
| 3 | | -use crate::Viewport2D; |
| 10 | +use cairo::Context; |
| 11 | +use garcalc_cas::{Evaluator, Expr, Symbol}; |
| 4 | 12 | |
| 5 | | -/// 2D graph state |
| 13 | +use crate::{Color, LineStyle, Plottable, Viewport2D}; |
| 14 | + |
| 15 | +/// Configuration for the plot appearance |
| 16 | +#[derive(Debug, Clone)] |
| 17 | +pub struct PlotConfig { |
| 18 | + /// Background color |
| 19 | + pub background: Color, |
| 20 | + /// Axis color |
| 21 | + pub axis_color: Color, |
| 22 | + /// Grid color |
| 23 | + pub grid_color: Color, |
| 24 | + /// Tick label color |
| 25 | + pub label_color: Color, |
| 26 | + /// Whether to show grid |
| 27 | + pub show_grid: bool, |
| 28 | + /// Whether to show axis labels |
| 29 | + pub show_labels: bool, |
| 30 | + /// Line width for curves |
| 31 | + pub curve_width: f64, |
| 32 | + /// Line width for axes |
| 33 | + pub axis_width: f64, |
| 34 | + /// Line width for grid |
| 35 | + pub grid_width: f64, |
| 36 | + /// Number of samples per pixel for function plotting |
| 37 | + pub samples_per_pixel: f64, |
| 38 | +} |
| 39 | + |
| 40 | +impl Default for PlotConfig { |
| 41 | + fn default() -> Self { |
| 42 | + Self { |
| 43 | + background: Color { r: 30, g: 30, b: 46, a: 255 }, |
| 44 | + axis_color: Color { r: 166, g: 173, b: 200, a: 255 }, |
| 45 | + grid_color: Color { r: 69, g: 71, b: 90, a: 255 }, |
| 46 | + label_color: Color { r: 166, g: 173, b: 200, a: 255 }, |
| 47 | + show_grid: true, |
| 48 | + show_labels: true, |
| 49 | + curve_width: 2.0, |
| 50 | + axis_width: 1.5, |
| 51 | + grid_width: 0.5, |
| 52 | + samples_per_pixel: 2.0, |
| 53 | + } |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +/// Default curve colors (catppuccin palette) |
| 58 | +pub const CURVE_COLORS: [Color; 6] = [ |
| 59 | + Color { r: 137, g: 180, b: 250, a: 255 }, // blue |
| 60 | + Color { r: 166, g: 227, b: 161, a: 255 }, // green |
| 61 | + Color { r: 249, g: 226, b: 175, a: 255 }, // yellow |
| 62 | + Color { r: 243, g: 139, b: 168, a: 255 }, // red |
| 63 | + Color { r: 203, g: 166, b: 247, a: 255 }, // mauve |
| 64 | + Color { r: 148, g: 226, b: 213, a: 255 }, // teal |
| 65 | +]; |
| 66 | + |
| 67 | +/// 2D graph state and renderer |
| 6 | 68 | pub struct Graph2D { |
| 69 | + /// Current viewport |
| 7 | 70 | pub viewport: Viewport2D, |
| 8 | | - pub grid_enabled: bool, |
| 9 | | - pub axis_labels: bool, |
| 71 | + /// Plot configuration |
| 72 | + pub config: PlotConfig, |
| 73 | + /// Functions to plot |
| 74 | + pub functions: Vec<Plottable>, |
| 75 | + /// Trace mode: show cursor position |
| 76 | + pub trace_enabled: bool, |
| 77 | + /// Current trace position (screen coords) |
| 78 | + pub trace_pos: Option<(f64, f64)>, |
| 10 | 79 | } |
| 11 | 80 | |
| 12 | 81 | impl Default for Graph2D { |
| 13 | 82 | fn default() -> Self { |
| 14 | 83 | Self { |
| 15 | 84 | viewport: Viewport2D::default(), |
| 16 | | - grid_enabled: true, |
| 17 | | - axis_labels: true, |
| 85 | + config: PlotConfig::default(), |
| 86 | + functions: Vec::new(), |
| 87 | + trace_enabled: false, |
| 88 | + trace_pos: None, |
| 89 | + } |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +impl Graph2D { |
| 94 | + /// Create a new graph with default settings |
| 95 | + pub fn new() -> Self { |
| 96 | + Self::default() |
| 97 | + } |
| 98 | + |
| 99 | + /// Set the viewport |
| 100 | + pub fn set_viewport(&mut self, viewport: Viewport2D) { |
| 101 | + self.viewport = viewport; |
| 102 | + } |
| 103 | + |
| 104 | + /// Add a function to plot |
| 105 | + pub fn add_function(&mut self, func: Plottable) { |
| 106 | + self.functions.push(func); |
| 107 | + } |
| 108 | + |
| 109 | + /// Add an explicit function y = f(x) with auto color |
| 110 | + pub fn add_explicit(&mut self, expr: Expr) { |
| 111 | + let color = CURVE_COLORS[self.functions.len() % CURVE_COLORS.len()]; |
| 112 | + self.functions.push(Plottable::Explicit2D { |
| 113 | + expr, |
| 114 | + x_var: "x".to_string(), |
| 115 | + color, |
| 116 | + style: LineStyle::Solid, |
| 117 | + }); |
| 118 | + } |
| 119 | + |
| 120 | + /// Clear all functions |
| 121 | + pub fn clear_functions(&mut self) { |
| 122 | + self.functions.clear(); |
| 123 | + } |
| 124 | + |
| 125 | + /// Pan the viewport by a screen delta |
| 126 | + pub fn pan(&mut self, dx: f64, dy: f64, width: u32, height: u32) { |
| 127 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 128 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 129 | + |
| 130 | + let math_dx = -dx * x_range / width as f64; |
| 131 | + let math_dy = dy * y_range / height as f64; |
| 132 | + |
| 133 | + self.viewport.x_min += math_dx; |
| 134 | + self.viewport.x_max += math_dx; |
| 135 | + self.viewport.y_min += math_dy; |
| 136 | + self.viewport.y_max += math_dy; |
| 137 | + } |
| 138 | + |
| 139 | + /// Zoom by a factor centered at screen position |
| 140 | + pub fn zoom(&mut self, factor: f64, cx: f64, cy: f64, width: u32, height: u32) { |
| 141 | + // Convert screen position to math coordinates |
| 142 | + let (mx, my) = self.screen_to_math(cx, cy, width, height); |
| 143 | + |
| 144 | + // Calculate new ranges |
| 145 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 146 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 147 | + let new_x_range = x_range / factor; |
| 148 | + let new_y_range = y_range / factor; |
| 149 | + |
| 150 | + // Calculate relative position of zoom center |
| 151 | + let rx = (mx - self.viewport.x_min) / x_range; |
| 152 | + let ry = (my - self.viewport.y_min) / y_range; |
| 153 | + |
| 154 | + // Set new viewport centered on zoom point |
| 155 | + self.viewport.x_min = mx - rx * new_x_range; |
| 156 | + self.viewport.x_max = mx + (1.0 - rx) * new_x_range; |
| 157 | + self.viewport.y_min = my - ry * new_y_range; |
| 158 | + self.viewport.y_max = my + (1.0 - ry) * new_y_range; |
| 159 | + } |
| 160 | + |
| 161 | + /// Reset viewport to default |
| 162 | + pub fn reset_viewport(&mut self) { |
| 163 | + self.viewport = Viewport2D::default(); |
| 164 | + } |
| 165 | + |
| 166 | + /// Convert screen coordinates to math coordinates |
| 167 | + pub fn screen_to_math(&self, sx: f64, sy: f64, width: u32, height: u32) -> (f64, f64) { |
| 168 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 169 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 170 | + |
| 171 | + let mx = self.viewport.x_min + (sx / width as f64) * x_range; |
| 172 | + let my = self.viewport.y_max - (sy / height as f64) * y_range; |
| 173 | + |
| 174 | + (mx, my) |
| 175 | + } |
| 176 | + |
| 177 | + /// Convert math coordinates to screen coordinates |
| 178 | + pub fn math_to_screen(&self, mx: f64, my: f64, width: u32, height: u32) -> (f64, f64) { |
| 179 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 180 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 181 | + |
| 182 | + let sx = ((mx - self.viewport.x_min) / x_range) * width as f64; |
| 183 | + let sy = ((self.viewport.y_max - my) / y_range) * height as f64; |
| 184 | + |
| 185 | + (sx, sy) |
| 186 | + } |
| 187 | + |
| 188 | + /// Update trace position |
| 189 | + pub fn set_trace_pos(&mut self, x: f64, y: f64) { |
| 190 | + self.trace_pos = Some((x, y)); |
| 191 | + } |
| 192 | + |
| 193 | + /// Render the graph to a Cairo context |
| 194 | + pub fn render(&self, ctx: &Context, width: u32, height: u32) { |
| 195 | + let w = width as f64; |
| 196 | + let h = height as f64; |
| 197 | + |
| 198 | + // Background |
| 199 | + set_color(ctx, self.config.background); |
| 200 | + ctx.rectangle(0.0, 0.0, w, h); |
| 201 | + let _ = ctx.fill(); |
| 202 | + |
| 203 | + // Grid |
| 204 | + if self.config.show_grid { |
| 205 | + self.draw_grid(ctx, width, height); |
| 206 | + } |
| 207 | + |
| 208 | + // Axes |
| 209 | + self.draw_axes(ctx, width, height); |
| 210 | + |
| 211 | + // Functions |
| 212 | + for func in &self.functions { |
| 213 | + self.draw_function(ctx, func, width, height); |
| 214 | + } |
| 215 | + |
| 216 | + // Trace |
| 217 | + if self.trace_enabled { |
| 218 | + if let Some((sx, sy)) = self.trace_pos { |
| 219 | + self.draw_trace(ctx, sx, sy, width, height); |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + fn draw_grid(&self, ctx: &Context, width: u32, height: u32) { |
| 225 | + set_color(ctx, self.config.grid_color); |
| 226 | + ctx.set_line_width(self.config.grid_width); |
| 227 | + |
| 228 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 229 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 230 | + |
| 231 | + // Calculate nice grid spacing |
| 232 | + let x_step = nice_step(x_range / 10.0); |
| 233 | + let y_step = nice_step(y_range / 10.0); |
| 234 | + |
| 235 | + // Vertical grid lines |
| 236 | + let x_start = (self.viewport.x_min / x_step).floor() * x_step; |
| 237 | + let mut x = x_start; |
| 238 | + while x <= self.viewport.x_max { |
| 239 | + let (sx, _) = self.math_to_screen(x, 0.0, width, height); |
| 240 | + ctx.move_to(sx, 0.0); |
| 241 | + ctx.line_to(sx, height as f64); |
| 242 | + x += x_step; |
| 243 | + } |
| 244 | + |
| 245 | + // Horizontal grid lines |
| 246 | + let y_start = (self.viewport.y_min / y_step).floor() * y_step; |
| 247 | + let mut y = y_start; |
| 248 | + while y <= self.viewport.y_max { |
| 249 | + let (_, sy) = self.math_to_screen(0.0, y, width, height); |
| 250 | + ctx.move_to(0.0, sy); |
| 251 | + ctx.line_to(width as f64, sy); |
| 252 | + y += y_step; |
| 253 | + } |
| 254 | + |
| 255 | + let _ = ctx.stroke(); |
| 256 | + } |
| 257 | + |
| 258 | + fn draw_axes(&self, ctx: &Context, width: u32, height: u32) { |
| 259 | + set_color(ctx, self.config.axis_color); |
| 260 | + ctx.set_line_width(self.config.axis_width); |
| 261 | + |
| 262 | + // Y-axis (x = 0) |
| 263 | + if self.viewport.x_min <= 0.0 && self.viewport.x_max >= 0.0 { |
| 264 | + let (sx, _) = self.math_to_screen(0.0, 0.0, width, height); |
| 265 | + ctx.move_to(sx, 0.0); |
| 266 | + ctx.line_to(sx, height as f64); |
| 267 | + } |
| 268 | + |
| 269 | + // X-axis (y = 0) |
| 270 | + if self.viewport.y_min <= 0.0 && self.viewport.y_max >= 0.0 { |
| 271 | + let (_, sy) = self.math_to_screen(0.0, 0.0, width, height); |
| 272 | + ctx.move_to(0.0, sy); |
| 273 | + ctx.line_to(width as f64, sy); |
| 274 | + } |
| 275 | + |
| 276 | + let _ = ctx.stroke(); |
| 277 | + |
| 278 | + // Tick labels |
| 279 | + if self.config.show_labels { |
| 280 | + self.draw_tick_labels(ctx, width, height); |
| 281 | + } |
| 282 | + } |
| 283 | + |
| 284 | + fn draw_tick_labels(&self, ctx: &Context, width: u32, height: u32) { |
| 285 | + set_color(ctx, self.config.label_color); |
| 286 | + ctx.set_font_size(11.0); |
| 287 | + |
| 288 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 289 | + let y_range = self.viewport.y_max - self.viewport.y_min; |
| 290 | + |
| 291 | + let x_step = nice_step(x_range / 10.0); |
| 292 | + let y_step = nice_step(y_range / 10.0); |
| 293 | + |
| 294 | + // Get y-axis position for x labels |
| 295 | + let (_, y_axis_sy) = self.math_to_screen(0.0, 0.0, width, height); |
| 296 | + let label_y = y_axis_sy.clamp(12.0, height as f64 - 4.0); |
| 297 | + |
| 298 | + // X-axis labels |
| 299 | + let x_start = (self.viewport.x_min / x_step).ceil() * x_step; |
| 300 | + let mut x = x_start; |
| 301 | + while x <= self.viewport.x_max { |
| 302 | + if x.abs() > x_step * 0.1 { |
| 303 | + let (sx, _) = self.math_to_screen(x, 0.0, width, height); |
| 304 | + let label = format_number(x); |
| 305 | + ctx.move_to(sx - 10.0, label_y + 12.0); |
| 306 | + let _ = ctx.show_text(&label); |
| 307 | + } |
| 308 | + x += x_step; |
| 309 | + } |
| 310 | + |
| 311 | + // Get x-axis position for y labels |
| 312 | + let (x_axis_sx, _) = self.math_to_screen(0.0, 0.0, width, height); |
| 313 | + let label_x = x_axis_sx.clamp(4.0, width as f64 - 40.0); |
| 314 | + |
| 315 | + // Y-axis labels |
| 316 | + let y_start = (self.viewport.y_min / y_step).ceil() * y_step; |
| 317 | + let mut y = y_start; |
| 318 | + while y <= self.viewport.y_max { |
| 319 | + if y.abs() > y_step * 0.1 { |
| 320 | + let (_, sy) = self.math_to_screen(0.0, y, width, height); |
| 321 | + let label = format_number(y); |
| 322 | + ctx.move_to(label_x + 4.0, sy + 4.0); |
| 323 | + let _ = ctx.show_text(&label); |
| 324 | + } |
| 325 | + y += y_step; |
| 326 | + } |
| 327 | + } |
| 328 | + |
| 329 | + fn draw_function(&self, ctx: &Context, func: &Plottable, width: u32, height: u32) { |
| 330 | + match func { |
| 331 | + Plottable::Explicit2D { expr, x_var, color, style } => { |
| 332 | + self.draw_explicit(ctx, expr, x_var, *color, *style, width, height); |
| 333 | + } |
| 334 | + Plottable::Parametric2D { x_expr, y_expr, t_var, t_range, color } => { |
| 335 | + self.draw_parametric(ctx, x_expr, y_expr, t_var, *t_range, *color, width, height); |
| 336 | + } |
| 337 | + _ => {} |
| 338 | + } |
| 339 | + } |
| 340 | + |
| 341 | + fn draw_explicit( |
| 342 | + &self, |
| 343 | + ctx: &Context, |
| 344 | + expr: &Expr, |
| 345 | + x_var: &str, |
| 346 | + color: Color, |
| 347 | + style: LineStyle, |
| 348 | + width: u32, |
| 349 | + height: u32, |
| 350 | + ) { |
| 351 | + set_color(ctx, color); |
| 352 | + ctx.set_line_width(self.config.curve_width); |
| 353 | + |
| 354 | + match style { |
| 355 | + LineStyle::Solid => ctx.set_dash(&[], 0.0), |
| 356 | + LineStyle::Dashed => ctx.set_dash(&[6.0, 4.0], 0.0), |
| 357 | + LineStyle::Dotted => ctx.set_dash(&[2.0, 2.0], 0.0), |
| 358 | + } |
| 359 | + |
| 360 | + let mut evaluator = Evaluator::new(); |
| 361 | + let _var = Symbol::from(x_var); |
| 362 | + |
| 363 | + // Sample the function |
| 364 | + let num_samples = (width as f64 * self.config.samples_per_pixel) as usize; |
| 365 | + let x_range = self.viewport.x_max - self.viewport.x_min; |
| 366 | + let dx = x_range / num_samples as f64; |
| 367 | + |
| 368 | + let mut first = true; |
| 369 | + let mut last_valid = false; |
| 370 | + |
| 371 | + for i in 0..=num_samples { |
| 372 | + let math_x = self.viewport.x_min + i as f64 * dx; |
| 373 | + evaluator.set_var(x_var, Expr::Float(math_x)); |
| 374 | + |
| 375 | + if let Ok(result) = evaluator.eval(expr) { |
| 376 | + if let Ok(math_y) = expr_to_f64(&result) { |
| 377 | + if math_y.is_finite() && math_y >= self.viewport.y_min - x_range |
| 378 | + && math_y <= self.viewport.y_max + x_range |
| 379 | + { |
| 380 | + let (sx, sy) = self.math_to_screen(math_x, math_y, width, height); |
| 381 | + |
| 382 | + if first || !last_valid { |
| 383 | + ctx.move_to(sx, sy); |
| 384 | + first = false; |
| 385 | + } else { |
| 386 | + ctx.line_to(sx, sy); |
| 387 | + } |
| 388 | + last_valid = true; |
| 389 | + continue; |
| 390 | + } |
| 391 | + } |
| 392 | + } |
| 393 | + // Invalid point - break the line |
| 394 | + if last_valid { |
| 395 | + let _ = ctx.stroke(); |
| 396 | + } |
| 397 | + last_valid = false; |
| 398 | + } |
| 399 | + |
| 400 | + let _ = ctx.stroke(); |
| 401 | + ctx.set_dash(&[], 0.0); |
| 402 | + } |
| 403 | + |
| 404 | + fn draw_parametric( |
| 405 | + &self, |
| 406 | + ctx: &Context, |
| 407 | + x_expr: &Expr, |
| 408 | + y_expr: &Expr, |
| 409 | + t_var: &str, |
| 410 | + t_range: (f64, f64), |
| 411 | + color: Color, |
| 412 | + width: u32, |
| 413 | + height: u32, |
| 414 | + ) { |
| 415 | + set_color(ctx, color); |
| 416 | + ctx.set_line_width(self.config.curve_width); |
| 417 | + |
| 418 | + let mut evaluator = Evaluator::new(); |
| 419 | + |
| 420 | + let num_samples = (width as f64 * self.config.samples_per_pixel) as usize; |
| 421 | + let t_span = t_range.1 - t_range.0; |
| 422 | + let dt = t_span / num_samples as f64; |
| 423 | + |
| 424 | + let mut first = true; |
| 425 | + |
| 426 | + for i in 0..=num_samples { |
| 427 | + let t = t_range.0 + i as f64 * dt; |
| 428 | + evaluator.set_var(t_var, Expr::Float(t)); |
| 429 | + |
| 430 | + let x_result = evaluator.eval(x_expr); |
| 431 | + let y_result = evaluator.eval(y_expr); |
| 432 | + |
| 433 | + if let (Ok(x_val), Ok(y_val)) = (x_result, y_result) { |
| 434 | + if let (Ok(math_x), Ok(math_y)) = (expr_to_f64(&x_val), expr_to_f64(&y_val)) { |
| 435 | + if math_x.is_finite() && math_y.is_finite() { |
| 436 | + let (sx, sy) = self.math_to_screen(math_x, math_y, width, height); |
| 437 | + |
| 438 | + if first { |
| 439 | + ctx.move_to(sx, sy); |
| 440 | + first = false; |
| 441 | + } else { |
| 442 | + ctx.line_to(sx, sy); |
| 443 | + } |
| 444 | + } |
| 445 | + } |
| 446 | + } |
| 18 | 447 | } |
| 448 | + |
| 449 | + let _ = ctx.stroke(); |
| 450 | + } |
| 451 | + |
| 452 | + fn draw_trace(&self, ctx: &Context, sx: f64, sy: f64, width: u32, height: u32) { |
| 453 | + let (mx, my) = self.screen_to_math(sx, sy, width, height); |
| 454 | + |
| 455 | + // Crosshair |
| 456 | + set_color(ctx, self.config.axis_color); |
| 457 | + ctx.set_line_width(0.5); |
| 458 | + ctx.set_dash(&[4.0, 4.0], 0.0); |
| 459 | + |
| 460 | + ctx.move_to(sx, 0.0); |
| 461 | + ctx.line_to(sx, height as f64); |
| 462 | + ctx.move_to(0.0, sy); |
| 463 | + ctx.line_to(width as f64, sy); |
| 464 | + let _ = ctx.stroke(); |
| 465 | + ctx.set_dash(&[], 0.0); |
| 466 | + |
| 467 | + // Coordinate display |
| 468 | + set_color(ctx, Color { r: 30, g: 30, b: 46, a: 200 }); |
| 469 | + let label = format!("({:.4}, {:.4})", mx, my); |
| 470 | + let label_w = label.len() as f64 * 7.0 + 8.0; |
| 471 | + let label_h = 18.0; |
| 472 | + let lx = (sx + 10.0).min(width as f64 - label_w - 4.0); |
| 473 | + let ly = (sy - 24.0).max(4.0); |
| 474 | + |
| 475 | + ctx.rectangle(lx, ly, label_w, label_h); |
| 476 | + let _ = ctx.fill(); |
| 477 | + |
| 478 | + set_color(ctx, self.config.label_color); |
| 479 | + ctx.set_font_size(12.0); |
| 480 | + ctx.move_to(lx + 4.0, ly + 13.0); |
| 481 | + let _ = ctx.show_text(&label); |
| 482 | + } |
| 483 | +} |
| 484 | + |
| 485 | +/// Set Cairo source color |
| 486 | +fn set_color(ctx: &Context, color: Color) { |
| 487 | + ctx.set_source_rgba( |
| 488 | + color.r as f64 / 255.0, |
| 489 | + color.g as f64 / 255.0, |
| 490 | + color.b as f64 / 255.0, |
| 491 | + color.a as f64 / 255.0, |
| 492 | + ); |
| 493 | +} |
| 494 | + |
| 495 | +/// Calculate a nice step size for grid/ticks |
| 496 | +fn nice_step(rough: f64) -> f64 { |
| 497 | + let exp = rough.abs().log10().floor(); |
| 498 | + let frac = rough / 10f64.powf(exp); |
| 499 | + |
| 500 | + let nice = if frac < 1.5 { |
| 501 | + 1.0 |
| 502 | + } else if frac < 3.0 { |
| 503 | + 2.0 |
| 504 | + } else if frac < 7.0 { |
| 505 | + 5.0 |
| 506 | + } else { |
| 507 | + 10.0 |
| 508 | + }; |
| 509 | + |
| 510 | + nice * 10f64.powf(exp) |
| 511 | +} |
| 512 | + |
| 513 | +/// Format a number for axis labels |
| 514 | +fn format_number(x: f64) -> String { |
| 515 | + if x == 0.0 { |
| 516 | + return "0".to_string(); |
| 517 | + } |
| 518 | + let abs = x.abs(); |
| 519 | + if abs >= 1000.0 || abs < 0.01 { |
| 520 | + format!("{:.1e}", x) |
| 521 | + } else if abs >= 1.0 { |
| 522 | + format!("{:.1}", x) |
| 523 | + } else { |
| 524 | + format!("{:.2}", x) |
| 525 | + } |
| 526 | +} |
| 527 | + |
| 528 | +/// Convert an Expr to f64 |
| 529 | +fn expr_to_f64(expr: &Expr) -> Result<f64, ()> { |
| 530 | + match expr { |
| 531 | + Expr::Integer(n) => Ok(*n as f64), |
| 532 | + Expr::Float(x) => Ok(*x), |
| 533 | + Expr::Rational(r) => Ok(r.to_f64()), |
| 534 | + _ => Err(()), |
| 535 | + } |
| 536 | +} |
| 537 | + |
| 538 | +#[cfg(test)] |
| 539 | +mod tests { |
| 540 | + use super::*; |
| 541 | + |
| 542 | + #[test] |
| 543 | + fn test_coordinate_transform() { |
| 544 | + let graph = Graph2D::new(); |
| 545 | + let (mx, my) = graph.screen_to_math(250.0, 250.0, 500, 500); |
| 546 | + assert!((mx - 0.0).abs() < 0.01); |
| 547 | + assert!((my - 0.0).abs() < 0.01); |
| 548 | + } |
| 549 | + |
| 550 | + #[test] |
| 551 | + fn test_nice_step() { |
| 552 | + assert_eq!(nice_step(0.03), 0.02); |
| 553 | + assert_eq!(nice_step(0.3), 0.2); |
| 554 | + assert_eq!(nice_step(3.0), 2.0); |
| 555 | + assert_eq!(nice_step(30.0), 20.0); |
| 19 | 556 | } |
| 20 | 557 | } |