1pub mod colormap;
23pub mod dispatch;
24pub mod proj3d;
25pub mod style;
26
27#[cfg(feature = "plot")]
28mod ascii;
29
30#[cfg(feature = "plot-svg")]
31mod file;
32
33mod contour;
34mod surface;
35
36use std::cell::RefCell;
37
38use ccalc_engine::env::{Env, Value};
39use ccalc_engine::plugin::Plugin;
40
41use colormap::ColormapSpec;
42use dispatch::{
43 extract_file_arg, extract_flat, extract_matrix, extract_style_and_file_arg,
44 extract_style_and_file_arg_min, extract_vector,
45};
46use style::{AxisMode, StyleColor, StyleSpec, Theme};
47
48#[derive(Clone)]
52pub enum PendingSeries {
53 Line(Vec<f64>, Vec<f64>, Option<StyleSpec>),
55 Scatter(Vec<f64>, Vec<f64>, Option<StyleSpec>),
57 Bar(Vec<f64>, Vec<f64>, Option<StyleSpec>),
59 Stem(Vec<f64>, Vec<f64>, Option<StyleSpec>),
61 Hist {
63 counts: Vec<usize>,
64 edges: Vec<f64>,
65 style: Option<StyleSpec>,
66 },
67 Fill(Vec<f64>, Vec<f64>, Option<StyleSpec>),
69 Area(Vec<f64>, Vec<f64>, Option<StyleSpec>),
71 Quiver(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Option<StyleSpec>),
73}
74
75#[derive(Clone, Default)]
77pub struct Panel {
78 pub layout: Option<(u32, u32, u32)>,
80 pub xlabel: Option<String>,
82 pub ylabel: Option<String>,
84 pub title: Option<String>,
86 pub legend: Vec<String>,
88 pub xlim: Option<(f64, f64)>,
90 pub ylim: Option<(f64, f64)>,
92 pub grid: bool,
94 pub series: Vec<PendingSeries>,
96 pub annotations: Vec<(f64, f64, String)>,
98 pub font_size: Option<u32>,
100 pub line_width: Option<f32>,
102 pub marker_size: Option<u32>,
104 pub grid_color: Option<StyleColor>,
106 pub grid_width: Option<f32>,
108 pub axis_mode: Option<AxisMode>,
110}
111
112#[derive(Default, Clone)]
120pub struct FigureState {
121 pub xlabel: Option<String>,
123 pub ylabel: Option<String>,
125 pub zlabel: Option<String>,
127 pub title: Option<String>,
129 pub legend: Vec<String>,
131 pub xlim: Option<(f64, f64)>,
133 pub ylim: Option<(f64, f64)>,
135 pub zlim: Option<(f64, f64)>,
137 pub grid: bool,
139 pub colormap: Option<ColormapSpec>,
141 pub colorbar: bool,
143
144 pub subplot: Option<(u32, u32, u32)>,
147 pub hold: bool,
149 pub pending_series: Vec<PendingSeries>,
151 pub panels: Vec<Panel>,
153 pub annotations: Vec<(f64, f64, String)>,
155
156 pub theme: Option<Theme>,
159 pub bg_color: Option<StyleColor>,
161
162 pub font_size: Option<u32>,
165 pub line_width: Option<f32>,
167 pub marker_size: Option<u32>,
169
170 pub grid_color: Option<StyleColor>,
173 pub grid_width: Option<f32>,
175
176 pub axis_mode: Option<AxisMode>,
179
180 pub figure_size: Option<(u32, u32)>,
186}
187
188impl FigureState {
189 pub fn canvas_size(&self) -> (u32, u32) {
191 self.figure_size.unwrap_or((800, 600))
192 }
193
194 pub fn resolve_theme(&self) -> style::Theme {
196 self.theme.clone().unwrap_or_else(style::Theme::light)
197 }
198
199 pub fn effective_bg_rgb(&self) -> (u8, u8, u8) {
203 let c = self.bg_color.unwrap_or_else(|| self.resolve_theme().bg);
204 (c.0, c.1, c.2)
205 }
206}
207
208pub(crate) fn term_cols() -> usize {
212 std::env::var("COLUMNS")
213 .ok()
214 .and_then(|s| s.parse().ok())
215 .unwrap_or(80)
216}
217
218pub(crate) fn term_rows() -> usize {
220 std::env::var("LINES")
221 .ok()
222 .and_then(|s| s.parse().ok())
223 .unwrap_or(24)
224}
225
226thread_local! {
227 static FIGURE_STATE: RefCell<FigureState> =
228 RefCell::new(FigureState::default());
229}
230
231const EXPORTED: &[&str] = &[
234 "plot",
235 "scatter",
236 "bar",
237 "stem",
238 "hist",
239 "stairs",
240 "loglog",
241 "semilogx",
242 "semilogy",
243 "plot3",
244 "scatter3",
245 "xlabel",
246 "ylabel",
247 "zlabel",
248 "title",
249 "legend",
250 "xlim",
251 "ylim",
252 "zlim",
253 "grid",
254 "colormap",
255 "colorbar",
256 "imagesc",
257 "surf",
258 "mesh",
259 "contour",
260 "contourf",
261 "subplot",
262 "hold",
263 "savefig",
264 "fill",
265 "area",
266 "polar",
267 "quiver",
268 "text",
269 "figure",
270 "theme",
271 "bgcolor",
272 "fontsize",
273 "linewidth",
274 "markersize",
275 "gridcolor",
276 "gridwidth",
277 "axis",
278];
279
280fn is_accumulating(st: &FigureState) -> bool {
284 st.subplot.is_some() || st.hold
285}
286
287fn commit_current_panel(st: &mut FigureState) {
292 if !st.pending_series.is_empty() {
293 let panel = Panel {
294 layout: st.subplot,
295 xlabel: st.xlabel.take(),
296 ylabel: st.ylabel.take(),
297 title: st.title.take(),
298 legend: std::mem::take(&mut st.legend),
299 xlim: st.xlim.take(),
300 ylim: st.ylim.take(),
301 grid: std::mem::replace(&mut st.grid, false),
302 series: std::mem::take(&mut st.pending_series),
303 annotations: std::mem::take(&mut st.annotations),
304 font_size: st.font_size,
305 line_width: st.line_width,
306 marker_size: st.marker_size,
307 grid_color: st.grid_color,
308 grid_width: st.grid_width,
309 axis_mode: st.axis_mode,
310 };
311 st.panels.push(panel);
312 }
313}
314
315pub struct PlotPlugin;
319
320impl Plugin for PlotPlugin {
321 fn name(&self) -> &str {
322 "plot"
323 }
324
325 fn exported_names(&self) -> &[&str] {
326 EXPORTED
327 }
328
329 fn call(&self, name: &str, args: &[Value], _env: &Env) -> Result<Value, String> {
330 match name {
331 "xlabel" | "ylabel" | "title" => {
333 let s = require_string(name, args)?;
334 FIGURE_STATE.with(|f| {
335 let mut st = f.borrow_mut();
336 match name {
337 "xlabel" => st.xlabel = Some(s),
338 "ylabel" => st.ylabel = Some(s),
339 "title" => st.title = Some(s),
340 _ => unreachable!(),
341 }
342 });
343 Ok(Value::Void)
344 }
345
346 "zlabel" => {
347 let s = require_string(name, args)?;
348 FIGURE_STATE.with(|f| f.borrow_mut().zlabel = Some(s));
349 Ok(Value::Void)
350 }
351
352 "legend" => {
354 let labels = require_string_list(args)?;
355 FIGURE_STATE.with(|f| f.borrow_mut().legend = labels);
356 Ok(Value::Void)
357 }
358
359 "grid" => {
361 match args {
362 [] => FIGURE_STATE.with(|f| {
363 let mut st = f.borrow_mut();
364 st.grid = !st.grid;
365 }),
366 [Value::Str(s) | Value::StringObj(s)] => {
367 let enable = match s.as_str() {
368 "on" => true,
369 "off" => false,
370 other => {
371 return Err(format!("grid: expected 'on' or 'off', got '{other}'"));
372 }
373 };
374 FIGURE_STATE.with(|f| f.borrow_mut().grid = enable);
375 }
376 _ => return Err("grid: expected no arguments, 'on', or 'off'".into()),
377 }
378 Ok(Value::Void)
379 }
380
381 "xlim" | "ylim" | "zlim" => {
383 let (lo, hi) = extract_lim(name, args)?;
384 FIGURE_STATE.with(|f| {
385 let mut st = f.borrow_mut();
386 match name {
387 "xlim" => st.xlim = Some((lo, hi)),
388 "ylim" => st.ylim = Some((lo, hi)),
389 "zlim" => st.zlim = Some((lo, hi)),
390 _ => unreachable!(),
391 }
392 });
393 Ok(Value::Void)
394 }
395
396 "plot" => {
398 let (data_args, style, path) = extract_style_and_file_arg(args)?;
399 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
400 let (x, ys) = extract_xy_multi("plot", &data_args)?;
401 FIGURE_STATE.with(|f| {
402 let mut st = f.borrow_mut();
403 for y in ys {
404 st.pending_series.push(PendingSeries::Line(
405 x.clone(),
406 y,
407 style.clone(),
408 ));
409 }
410 });
411 Ok(Value::Void)
412 } else {
413 let state = FIGURE_STATE.with(|f| f.take());
414 let (x, ys) = extract_xy_multi("plot", &data_args)?;
415 if ys.len() == 1 {
416 render_line_xy("plot", &x, &ys[0], path.as_deref(), state)
417 } else {
418 render_multi_series(&x, &ys, path.as_deref(), state)
419 }
420 }
421 }
422
423 "scatter" | "bar" | "stem" | "stairs" => {
424 let (data_args, style, path) = extract_style_and_file_arg(args)?;
425 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
426 let (x, y) = extract_xy(name, &data_args)?;
427 let (x, y) = if name == "stairs" {
428 make_step_data(&x, &y)
429 } else {
430 (x, y)
431 };
432 let series = match name {
433 "scatter" => PendingSeries::Scatter(x, y, style),
434 "bar" | "stairs" => PendingSeries::Bar(x, y, style),
435 "stem" => PendingSeries::Stem(x, y, style),
436 _ => unreachable!(),
437 };
438 FIGURE_STATE.with(|f| f.borrow_mut().pending_series.push(series));
439 Ok(Value::Void)
440 } else {
441 let state = FIGURE_STATE.with(|f| f.take());
442 match name {
443 "bar" => {
444 let (x, y) = extract_xy(name, &data_args)?;
445 render_bar_xy(&x, &y, path.as_deref(), style, state)
446 }
447 "stem" => {
448 let (x, y) = extract_xy(name, &data_args)?;
449 render_stem_xy(&x, &y, path.as_deref(), style, state)
450 }
451 _ => render_ascii_or_file(name, &data_args, path.as_deref(), state),
452 }
453 }
454 }
455
456 "hist" => {
458 let (data_args, style, path) = extract_style_and_file_arg(args)?;
459 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
460 let (counts, edges) = parse_and_compute_hist(&data_args)?;
461 FIGURE_STATE.with(|f| {
462 f.borrow_mut().pending_series.push(PendingSeries::Hist {
463 counts,
464 edges,
465 style,
466 });
467 });
468 Ok(Value::Void)
469 } else {
470 let state = FIGURE_STATE.with(|f| f.take());
471 let (counts, edges) = parse_and_compute_hist(&data_args)?;
472 match path.as_deref() {
473 None | Some("ascii") => {
474 render_hist_ascii(&counts, &edges, &state);
475 Ok(Value::Void)
476 }
477 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
478 render_hist_file(&counts, &edges, p, style, state)
479 }
480 Some(p) => Err(format!("hist: unknown output target '{p}'")),
481 }
482 }
483 }
484
485 "loglog" | "semilogx" | "semilogy" => {
487 let (data_args, path) = extract_file_arg(args);
488 let mut state = FIGURE_STATE.with(|f| f.take());
489 let (x_raw, y_raw) = extract_xy(name, &data_args)?;
490
491 let log_x = name == "loglog" || name == "semilogx";
492 let log_y = name == "loglog" || name == "semilogy";
493
494 let (x, y): (Vec<f64>, Vec<f64>) = x_raw
496 .iter()
497 .zip(y_raw.iter())
498 .filter_map(|(&xi, &yi)| {
499 let lx = if log_x { xi.log10() } else { xi };
500 let ly = if log_y { yi.log10() } else { yi };
501 if lx.is_finite() && ly.is_finite() {
502 Some((lx, ly))
503 } else {
504 None
505 }
506 })
507 .unzip();
508
509 if x.is_empty() {
510 return Err(format!(
511 "{name}: no finite values after log₁₀ transform \
512 (check for non-positive values)"
513 ));
514 }
515
516 if log_x {
518 let lbl = state.xlabel.take().unwrap_or_default();
519 state.xlabel = Some(if lbl.is_empty() {
520 "log\u{2081}\u{2080}(x)".into()
521 } else {
522 format!("{lbl} [log\u{2081}\u{2080}]")
523 });
524 }
525 if log_y {
526 let lbl = state.ylabel.take().unwrap_or_default();
527 state.ylabel = Some(if lbl.is_empty() {
528 "log\u{2081}\u{2080}(y)".into()
529 } else {
530 format!("{lbl} [log\u{2081}\u{2080}]")
531 });
532 }
533
534 render_line_xy(name, &x, &y, path.as_deref(), state)
535 }
536
537 "plot3" | "scatter3" => {
539 let (data_args, path) = extract_file_arg(args);
540 let state = FIGURE_STATE.with(|f| f.take());
541 render_3d(name, &data_args, path.as_deref(), state)
542 }
543
544 "figure" => {
546 if args.len() != 2 {
547 return Err(format!(
548 "figure: expected 2 arguments (width, height), got {}",
549 args.len()
550 ));
551 }
552 let w = match &args[0] {
553 Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
554 _ => return Err("figure: width must be a positive integer (1–16384)".into()),
555 };
556 let h = match &args[1] {
557 Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
558 _ => return Err("figure: height must be a positive integer (1–16384)".into()),
559 };
560 FIGURE_STATE.with(|f| f.borrow_mut().figure_size = Some((w, h)));
561 Ok(Value::Void)
562 }
563
564 "colormap" => {
566 if args.is_empty() {
567 return Err("colormap: one argument required".into());
568 }
569 let spec = match &args[0] {
570 Value::Str(name) | Value::StringObj(name) => ColormapSpec::Named(name.clone()),
571 Value::Matrix(m) => {
572 if m.ncols() != 3 {
573 return Err("colormap: matrix argument must be N×3".into());
574 }
575 let lut: Vec<(u8, u8, u8)> = (0..m.nrows())
576 .map(|r| {
577 let clamp = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
578 (clamp(m[[r, 0]]), clamp(m[[r, 1]]), clamp(m[[r, 2]]))
579 })
580 .collect();
581 ColormapSpec::Custom(lut)
582 }
583 _ => {
584 return Err("colormap: argument must be a name string or N×3 matrix".into());
585 }
586 };
587 colormap::validate_colormap_spec(&spec)?;
588 FIGURE_STATE.with(|f| f.borrow_mut().colormap = Some(spec));
589 Ok(Value::Void)
590 }
591
592 "colorbar" => {
593 FIGURE_STATE.with(|f| f.borrow_mut().colorbar = true);
594 Ok(Value::Void)
595 }
596
597 "theme" => {
599 if args.is_empty() {
600 return Err("theme: one argument required (e.g. 'dark' or 'light')".into());
601 }
602 let name = match &args[0] {
603 Value::Str(s) | Value::StringObj(s) => s.clone(),
604 _ => return Err("theme: argument must be a theme name string".into()),
605 };
606 let t = Theme::from_name(&name)?;
607 FIGURE_STATE.with(|f| f.borrow_mut().theme = Some(t));
608 Ok(Value::Void)
609 }
610
611 "bgcolor" => {
612 if args.is_empty() {
613 return Err("bgcolor: one argument required".into());
614 }
615 let sc = match &args[0] {
616 Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
617 .ok_or_else(|| format!("bgcolor: unrecognised color '{s}'"))?,
618 Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
619 let all_unit = (0..3).all(|c| {
620 let v = m[[0, c]];
621 (0.0..=1.0).contains(&v)
622 });
623 if !all_unit {
624 return Err("bgcolor: RGB matrix values must be in [0, 1]".into());
625 }
626 let clamp = |v: f64| (v * 255.0).round() as u8;
627 StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
628 }
629 _ => {
630 return Err(
631 "bgcolor: argument must be a color name string or 1×3 RGB matrix"
632 .into(),
633 );
634 }
635 };
636 FIGURE_STATE.with(|f| f.borrow_mut().bg_color = Some(sc));
637 Ok(Value::Void)
638 }
639
640 "fontsize" => {
642 let val = match args {
643 [Value::Scalar(f)] if *f >= 1.0 => (*f as u32).max(8),
644 _ => return Err("fontsize: expected a positive number".into()),
645 };
646 FIGURE_STATE.with(|f| f.borrow_mut().font_size = Some(val));
647 Ok(Value::Void)
648 }
649
650 "linewidth" => {
651 let val = match args {
652 [Value::Scalar(f)] if *f > 0.0 => *f as f32,
653 _ => return Err("linewidth: expected a positive number".into()),
654 };
655 FIGURE_STATE.with(|f| f.borrow_mut().line_width = Some(val));
656 Ok(Value::Void)
657 }
658
659 "markersize" => {
660 let val = match args {
661 [Value::Scalar(f)] if *f >= 1.0 => *f as u32,
662 _ => return Err("markersize: expected a positive integer".into()),
663 };
664 FIGURE_STATE.with(|f| f.borrow_mut().marker_size = Some(val));
665 Ok(Value::Void)
666 }
667
668 "gridcolor" => {
670 if args.is_empty() {
671 return Err("gridcolor: one argument required".into());
672 }
673 let sc = match &args[0] {
674 Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
675 .ok_or_else(|| format!("gridcolor: unrecognised color '{s}'"))?,
676 Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
677 let all_unit = (0..3).all(|c| {
678 let v = m[[0, c]];
679 (0.0..=1.0).contains(&v)
680 });
681 if !all_unit {
682 return Err("gridcolor: RGB matrix values must be in [0, 1]".into());
683 }
684 let clamp = |v: f64| (v * 255.0).round() as u8;
685 StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
686 }
687 _ => {
688 return Err(
689 "gridcolor: argument must be a color name string or 1×3 RGB matrix"
690 .into(),
691 );
692 }
693 };
694 FIGURE_STATE.with(|f| f.borrow_mut().grid_color = Some(sc));
695 Ok(Value::Void)
696 }
697
698 "gridwidth" => {
699 let val = match args {
700 [Value::Scalar(f)] if *f > 0.0 => *f as f32,
701 _ => return Err("gridwidth: expected a positive number".into()),
702 };
703 FIGURE_STATE.with(|f| f.borrow_mut().grid_width = Some(val));
704 Ok(Value::Void)
705 }
706
707 "axis" => {
709 let s = require_string("axis", args)?;
710 let mode = match s.as_str() {
711 "equal" => Some(AxisMode::Equal),
712 "tight" => Some(AxisMode::Tight),
713 "off" => Some(AxisMode::Off),
714 "on" => None,
715 other => {
716 return Err(format!(
717 "axis: expected 'equal', 'tight', 'off', or 'on', got '{other}'"
718 ));
719 }
720 };
721 FIGURE_STATE.with(|f| f.borrow_mut().axis_mode = mode);
722 Ok(Value::Void)
723 }
724
725 "imagesc" => {
727 if args.is_empty() {
728 return Err("imagesc: at least one argument required".into());
729 }
730 let (z, nrows, ncols) = extract_matrix(&args[0])?;
731 let state = FIGURE_STATE.with(|f| f.take());
732 let path: Option<String> = match args.len() {
736 1 => None,
737 2 => match &args[1] {
738 Value::Str(s) | Value::StringObj(s) => Some(s.clone()),
739 _ => {
740 return Err(
741 "imagesc: second argument must be a file path string".into()
742 );
743 }
744 },
745 n => return Err(format!("imagesc: expected 1 or 2 arguments, got {n}")),
746 };
747 render_imagesc(&z, nrows, ncols, path.as_deref(), state)
748 }
749
750 "surf" | "mesh" => {
752 let (data_args, path) = extract_file_arg(args);
753 if data_args.len() < 3 {
754 return Err(format!(
755 "{name}: requires (X, Y, Z) matrix arguments, got {}",
756 data_args.len()
757 ));
758 }
759 let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
760 .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
761 let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
762 .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
763 let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
764 .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
765 if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
766 return Err(format!(
767 "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
768 Z ({z_rows}×{z_cols}) must have the same dimensions"
769 ));
770 }
771 let state = FIGURE_STATE.with(|f| f.take());
772 let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
774 let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
775 render_surface(
776 name,
777 &x_vals,
778 &y_vals,
779 &z_data,
780 z_rows,
781 z_cols,
782 path.as_deref(),
783 state,
784 )
785 }
786
787 "contour" | "contourf" => {
789 let (data_args, path) = extract_file_arg(args);
790 if data_args.len() < 3 {
791 return Err(format!(
792 "{name}: requires (X, Y, Z) matrix arguments, got {}",
793 data_args.len()
794 ));
795 }
796 let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
797 .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
798 let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
799 .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
800 let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
801 .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
802 if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
803 return Err(format!(
804 "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
805 Z ({z_rows}×{z_cols}) must have the same dimensions"
806 ));
807 }
808 let n_levels: usize = if data_args.len() >= 4 {
810 match &data_args[3] {
811 Value::Scalar(v) if *v >= 1.0 => *v as usize,
812 _ => return Err(format!("{name}: level count must be a positive integer")),
813 }
814 } else {
815 10
816 };
817 let state = FIGURE_STATE.with(|f| f.take());
818 let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
820 let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
821 let filled = name == "contourf";
822 render_contour(
823 filled,
824 &x_vals,
825 &y_vals,
826 &z_data,
827 z_rows,
828 z_cols,
829 n_levels,
830 path.as_deref(),
831 state,
832 )
833 }
834
835 "subplot" => match args {
837 [Value::Scalar(m), Value::Scalar(n), Value::Scalar(k)] => {
838 let m = *m as u32;
839 let n = *n as u32;
840 let k = *k as u32;
841 if m == 0 || n == 0 || k == 0 || k > m * n {
842 return Err(format!(
843 "subplot: invalid layout ({m},{n},{k}) — \
844 index must be in 1..={}",
845 m * n
846 ));
847 }
848 FIGURE_STATE.with(|f| {
849 let mut st = f.borrow_mut();
850 commit_current_panel(&mut st);
851 st.subplot = Some((m, n, k));
852 });
853 Ok(Value::Void)
854 }
855 _ => Err("subplot: expected 3 numeric arguments (rows, cols, index)".into()),
856 },
857
858 "hold" => {
860 let turn_on = match args {
861 [] => !FIGURE_STATE.with(|f| f.borrow().hold),
862 [Value::Str(s) | Value::StringObj(s)] => match s.as_str() {
863 "on" => true,
864 "off" => false,
865 other => {
866 return Err(format!(
867 "hold: expected 'on', 'off', or no argument, got '{other}'"
868 ));
869 }
870 },
871 _ => return Err("hold: expected 'on', 'off', or no argument".into()),
872 };
873
874 if !turn_on {
875 let panel_opt = FIGURE_STATE.with(|f| {
876 let mut st = f.borrow_mut();
877 st.hold = false;
878 if st.subplot.is_none() && !st.pending_series.is_empty() {
880 Some(Panel {
881 layout: None,
882 xlabel: st.xlabel.take(),
883 ylabel: st.ylabel.take(),
884 title: st.title.take(),
885 legend: std::mem::take(&mut st.legend),
886 xlim: st.xlim.take(),
887 ylim: st.ylim.take(),
888 grid: std::mem::replace(&mut st.grid, false),
889 series: std::mem::take(&mut st.pending_series),
890 annotations: std::mem::take(&mut st.annotations),
891 font_size: st.font_size,
892 line_width: st.line_width,
893 marker_size: st.marker_size,
894 grid_color: st.grid_color,
895 grid_width: st.grid_width,
896 axis_mode: st.axis_mode,
897 })
898 } else {
899 None
900 }
901 });
902 if let Some(panel) = panel_opt {
903 return render_panel_ascii(&panel);
904 }
905 } else {
906 FIGURE_STATE.with(|f| f.borrow_mut().hold = true);
907 }
908 Ok(Value::Void)
909 }
910
911 "savefig" => {
913 let path = require_string("savefig", args)?;
914 if !path.ends_with(".svg") && !path.ends_with(".png") {
915 return Err("savefig: path must end with '.svg' or '.png'".into());
916 }
917 let (panels, canvas, theme, bg_override) = FIGURE_STATE.with(|f| {
918 let mut st = f.borrow_mut();
919 commit_current_panel(&mut st);
920 st.hold = false;
921 st.subplot = None;
922 let canvas = st.canvas_size();
923 let theme = st.theme.clone().unwrap_or_else(style::Theme::light);
924 let bg_override = st.bg_color;
925 (std::mem::take(&mut st.panels), canvas, theme, bg_override)
926 });
927 if panels.is_empty() {
928 return Err("savefig: no panels to render".into());
929 }
930 render_panels_file(&panels, &path, canvas, &theme, bg_override)
931 }
932
933 "fill" => {
935 let (data_args, style, path) = extract_style_and_file_arg(args)?;
936 let (x, y) = extract_xy("fill", &data_args)?;
937 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
938 FIGURE_STATE.with(|f| {
939 f.borrow_mut()
940 .pending_series
941 .push(PendingSeries::Fill(x, y, style));
942 });
943 Ok(Value::Void)
944 } else {
945 let state = FIGURE_STATE.with(|f| f.take());
946 render_fill_xy(&x, &y, path.as_deref(), style, state)
947 }
948 }
949
950 "area" => {
952 let (data_args, style, path) = extract_style_and_file_arg(args)?;
953 let (x, y) = extract_xy("area", &data_args)?;
954 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
955 FIGURE_STATE.with(|f| {
956 f.borrow_mut()
957 .pending_series
958 .push(PendingSeries::Area(x, y, style));
959 });
960 Ok(Value::Void)
961 } else {
962 let state = FIGURE_STATE.with(|f| f.take());
963 render_area_xy(&x, &y, path.as_deref(), style, state)
964 }
965 }
966
967 "polar" => {
969 let (data_args, _style, path) = extract_style_and_file_arg(args)?;
970 let (theta, r) = extract_xy("polar", &data_args)?;
971 let (px, py): (Vec<f64>, Vec<f64>) = theta
972 .iter()
973 .zip(r.iter())
974 .map(|(&t, &rv)| (rv * t.cos(), rv * t.sin()))
975 .unzip();
976 let state = FIGURE_STATE.with(|f| f.take());
977 render_line_xy("polar", &px, &py, path.as_deref(), state)
978 }
979
980 "quiver" => {
982 let (data_args, style, path) = extract_style_and_file_arg_min(args, 4)?;
983 if data_args.len() != 4 {
984 return Err(format!(
985 "quiver: expected 4 data arguments (x, y, u, v), got {}",
986 data_args.len()
987 ));
988 }
989 let x = extract_flat(&data_args[0])
990 .map_err(|_| "quiver: x must be a numeric array".to_string())?;
991 let y = extract_flat(&data_args[1])
992 .map_err(|_| "quiver: y must be a numeric array".to_string())?;
993 let u = extract_flat(&data_args[2])
994 .map_err(|_| "quiver: u must be a numeric array".to_string())?;
995 let v = extract_flat(&data_args[3])
996 .map_err(|_| "quiver: v must be a numeric array".to_string())?;
997 if x.len() != y.len() || x.len() != u.len() || x.len() != v.len() {
998 return Err(format!(
999 "quiver: x, y, u, v must have the same length \
1000 ({}, {}, {}, {})",
1001 x.len(),
1002 y.len(),
1003 u.len(),
1004 v.len()
1005 ));
1006 }
1007 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1008 FIGURE_STATE.with(|f| {
1009 f.borrow_mut()
1010 .pending_series
1011 .push(PendingSeries::Quiver(x, y, u, v, style));
1012 });
1013 Ok(Value::Void)
1014 } else {
1015 let state = FIGURE_STATE.with(|f| f.take());
1016 render_quiver(&x, &y, &u, &v, path.as_deref(), style, state)
1017 }
1018 }
1019
1020 "text" => {
1022 let (data_args, _path) = extract_file_arg(args);
1023 match data_args.as_slice() {
1024 [xval, yval, Value::Str(s) | Value::StringObj(s)] => {
1025 let x = match xval {
1026 Value::Scalar(f) => *f,
1027 _ => return Err("text: x must be a scalar".into()),
1028 };
1029 let y = match yval {
1030 Value::Scalar(f) => *f,
1031 _ => return Err("text: y must be a scalar".into()),
1032 };
1033 let label = s.clone();
1034 FIGURE_STATE.with(|f| {
1035 f.borrow_mut().annotations.push((x, y, label));
1036 });
1037 Ok(Value::Void)
1038 }
1039 _ => Err("text: expected text(x, y, 'string')".into()),
1040 }
1041 }
1042
1043 _ => Err(format!("plot plugin: unknown function '{name}'")),
1044 }
1045 }
1046}
1047
1048fn render_ascii_or_file(
1051 name: &str,
1052 data_args: &[Value],
1053 path: Option<&str>,
1054 state: FigureState,
1055) -> Result<Value, String> {
1056 match path {
1057 None | Some("ascii") => render_ascii(name, data_args, state),
1058 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1059 render_file(name, data_args, p, state)
1060 }
1061 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1062 }
1063}
1064
1065#[cfg(feature = "plot-svg")]
1066fn render_file(
1067 name: &str,
1068 data_args: &[Value],
1069 path: &str,
1070 state: FigureState,
1071) -> Result<Value, String> {
1072 let (x, y) = extract_xy(name, data_args)?;
1073 let (x, y) = if name == "stairs" {
1074 make_step_data(&x, &y)
1075 } else {
1076 (x, y)
1077 };
1078 let result = match name {
1079 "plot" | "stairs" => file::render_line(&x, &y, path, state),
1080 "scatter" => file::render_scatter(&x, &y, path, state),
1081 _ => unreachable!(),
1082 };
1083 result.map_err(|e| format!("{name}: {e}"))?;
1084 Ok(Value::Void)
1085}
1086
1087#[cfg(not(feature = "plot-svg"))]
1088fn render_file(
1089 name: &str,
1090 _data_args: &[Value],
1091 _path: &str,
1092 _state: FigureState,
1093) -> Result<Value, String> {
1094 Err(format!(
1095 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
1096 rebuild with: cargo build --features plot-svg"
1097 ))
1098}
1099
1100#[cfg(feature = "plot")]
1101fn render_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
1102 let (x, y) = extract_xy(name, data_args)?;
1103 let (x, y) = if name == "stairs" {
1104 make_step_data(&x, &y)
1105 } else {
1106 (x, y)
1107 };
1108 match name {
1109 "plot" | "stairs" => ascii::render_line(&x, &y, state),
1110 "scatter" => ascii::render_scatter(&x, &y, state),
1111 "bar" => ascii::render_bar(&x, &y, state),
1112 "stem" => ascii::render_stem(&x, &y, state),
1113 _ => unreachable!(),
1114 }
1115 Ok(Value::Void)
1116}
1117
1118#[cfg(not(feature = "plot"))]
1119fn render_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
1120 Err(format!(
1121 "{name}: ASCII rendering requires the 'plot' feature flag. \
1122 Rebuild with: cargo build --features plot"
1123 ))
1124}
1125
1126#[allow(clippy::too_many_arguments)]
1129fn render_contour(
1130 filled: bool,
1131 x_vals: &[f64],
1132 y_vals: &[f64],
1133 z: &[f64],
1134 nrows: usize,
1135 ncols: usize,
1136 n_levels: usize,
1137 path: Option<&str>,
1138 state: FigureState,
1139) -> Result<Value, String> {
1140 match path {
1141 None | Some("ascii") => render_contour_ascii_tier(z, nrows, ncols, n_levels, state),
1142 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1143 render_contour_file_tier(filled, x_vals, y_vals, z, nrows, ncols, n_levels, p, state)
1144 }
1145 Some(p) => Err(format!("contour: unknown output target '{p}'")),
1146 }
1147}
1148
1149#[cfg(feature = "plot")]
1150fn render_contour_ascii_tier(
1151 z: &[f64],
1152 nrows: usize,
1153 ncols: usize,
1154 n_levels: usize,
1155 state: FigureState,
1156) -> Result<Value, String> {
1157 let (z_min, z_max) = colormap::data_range(z);
1158 let levels = contour::compute_levels(z_min, z_max, n_levels);
1159 contour::render_contour_ascii(z, nrows, ncols, &levels, &state);
1160 Ok(Value::Void)
1161}
1162
1163#[cfg(not(feature = "plot"))]
1164fn render_contour_ascii_tier(
1165 _z: &[f64],
1166 _nrows: usize,
1167 _ncols: usize,
1168 _n_levels: usize,
1169 _state: FigureState,
1170) -> Result<Value, String> {
1171 Err(
1172 "contour: ASCII rendering requires the 'plot' feature flag — \
1173 rebuild with: cargo build --features plot"
1174 .into(),
1175 )
1176}
1177
1178#[cfg(feature = "plot-svg")]
1179#[allow(clippy::too_many_arguments)]
1180fn render_contour_file_tier(
1181 filled: bool,
1182 x_vals: &[f64],
1183 y_vals: &[f64],
1184 z: &[f64],
1185 nrows: usize,
1186 ncols: usize,
1187 n_levels: usize,
1188 path: &str,
1189 state: FigureState,
1190) -> Result<Value, String> {
1191 let (z_min, z_max) = colormap::data_range(z);
1192 let levels = contour::compute_levels(z_min, z_max, n_levels);
1193 let result = if filled {
1194 contour::render_contourf_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1195 } else {
1196 contour::render_contour_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1197 };
1198 result.map_err(|e| e.to_string())?;
1199 Ok(Value::Void)
1200}
1201
1202#[cfg(not(feature = "plot-svg"))]
1203#[allow(clippy::too_many_arguments)]
1204fn render_contour_file_tier(
1205 _filled: bool,
1206 _x_vals: &[f64],
1207 _y_vals: &[f64],
1208 _z: &[f64],
1209 _nrows: usize,
1210 _ncols: usize,
1211 _n_levels: usize,
1212 _path: &str,
1213 _state: FigureState,
1214) -> Result<Value, String> {
1215 Err("contour: SVG/PNG export requires the 'plot-svg' feature — \
1216 rebuild with: cargo build --features plot-svg"
1217 .into())
1218}
1219
1220#[allow(clippy::too_many_arguments)]
1223fn render_surface(
1224 name: &str,
1225 x_vals: &[f64],
1226 y_vals: &[f64],
1227 z: &[f64],
1228 nrows: usize,
1229 ncols: usize,
1230 path: Option<&str>,
1231 state: FigureState,
1232) -> Result<Value, String> {
1233 let wireframe = name == "mesh";
1234 match path {
1235 None | Some("ascii") => render_surface_ascii_tier(x_vals, z, nrows, ncols, state),
1236 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1237 render_surface_file_tier(wireframe, x_vals, y_vals, z, nrows, ncols, p, state)
1238 }
1239 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1240 }
1241}
1242
1243#[cfg(feature = "plot")]
1244fn render_surface_ascii_tier(
1245 x_vals: &[f64],
1246 z: &[f64],
1247 nrows: usize,
1248 ncols: usize,
1249 state: FigureState,
1250) -> Result<Value, String> {
1251 surface::render_surf_ascii(x_vals, z, nrows, ncols, &state);
1252 Ok(Value::Void)
1253}
1254
1255#[cfg(not(feature = "plot"))]
1256fn render_surface_ascii_tier(
1257 _x_vals: &[f64],
1258 _z: &[f64],
1259 _nrows: usize,
1260 _ncols: usize,
1261 _state: FigureState,
1262) -> Result<Value, String> {
1263 Err(
1264 "surf/mesh: ASCII rendering requires the 'plot' feature flag — \
1265 rebuild with: cargo build --features plot"
1266 .into(),
1267 )
1268}
1269
1270#[cfg(feature = "plot-svg")]
1271#[allow(clippy::too_many_arguments)]
1272fn render_surface_file_tier(
1273 wireframe: bool,
1274 x_vals: &[f64],
1275 y_vals: &[f64],
1276 z: &[f64],
1277 nrows: usize,
1278 ncols: usize,
1279 path: &str,
1280 state: FigureState,
1281) -> Result<Value, String> {
1282 let result = if wireframe {
1283 surface::render_mesh_file(x_vals, y_vals, z, nrows, ncols, path, state)
1284 } else {
1285 surface::render_surf_file(x_vals, y_vals, z, nrows, ncols, path, state)
1286 };
1287 result.map_err(|e| e.to_string())?;
1288 Ok(Value::Void)
1289}
1290
1291#[cfg(not(feature = "plot-svg"))]
1292#[allow(clippy::too_many_arguments)]
1293fn render_surface_file_tier(
1294 _wireframe: bool,
1295 _x_vals: &[f64],
1296 _y_vals: &[f64],
1297 _z: &[f64],
1298 _nrows: usize,
1299 _ncols: usize,
1300 _path: &str,
1301 _state: FigureState,
1302) -> Result<Value, String> {
1303 Err(
1304 "surf/mesh: SVG/PNG export requires the 'plot-svg' feature — \
1305 rebuild with: cargo build --features plot-svg"
1306 .into(),
1307 )
1308}
1309
1310fn render_imagesc(
1313 z: &[f64],
1314 nrows: usize,
1315 ncols: usize,
1316 path: Option<&str>,
1317 state: FigureState,
1318) -> Result<Value, String> {
1319 match path {
1320 None | Some("ascii") => render_imagesc_ascii_tier(z, nrows, ncols, state),
1321 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1322 render_imagesc_file_tier(z, nrows, ncols, p, state)
1323 }
1324 Some(p) => Err(format!("imagesc: unknown output target '{p}'")),
1325 }
1326}
1327
1328#[cfg(feature = "plot")]
1329fn render_imagesc_ascii_tier(
1330 z: &[f64],
1331 nrows: usize,
1332 ncols: usize,
1333 state: FigureState,
1334) -> Result<Value, String> {
1335 colormap::render_imagesc_ascii(z, nrows, ncols, &state);
1336 Ok(Value::Void)
1337}
1338
1339#[cfg(not(feature = "plot"))]
1340fn render_imagesc_ascii_tier(
1341 _z: &[f64],
1342 _nrows: usize,
1343 _ncols: usize,
1344 _state: FigureState,
1345) -> Result<Value, String> {
1346 Err(
1347 "imagesc: ASCII rendering requires the 'plot' feature flag — \
1348 rebuild with: cargo build --features plot"
1349 .into(),
1350 )
1351}
1352
1353#[cfg(feature = "plot-svg")]
1354fn render_imagesc_file_tier(
1355 z: &[f64],
1356 nrows: usize,
1357 ncols: usize,
1358 path: &str,
1359 state: FigureState,
1360) -> Result<Value, String> {
1361 colormap::render_imagesc_file(z, nrows, ncols, path, state)
1362 .map_err(|e| format!("imagesc: {e}"))?;
1363 Ok(Value::Void)
1364}
1365
1366#[cfg(not(feature = "plot-svg"))]
1367fn render_imagesc_file_tier(
1368 _z: &[f64],
1369 _nrows: usize,
1370 _ncols: usize,
1371 _path: &str,
1372 _state: FigureState,
1373) -> Result<Value, String> {
1374 Err("imagesc: SVG/PNG export requires the 'plot-svg' feature — \
1375 rebuild with: cargo build --features plot-svg"
1376 .into())
1377}
1378
1379fn require_string(name: &str, args: &[Value]) -> Result<String, String> {
1382 match args {
1383 [Value::Str(s)] | [Value::StringObj(s)] => Ok(s.clone()),
1384 [_] => Err(format!("{name}: argument must be a string")),
1385 _ => Err(format!("{name}: expected exactly one string argument")),
1386 }
1387}
1388
1389fn require_string_list(args: &[Value]) -> Result<Vec<String>, String> {
1390 if args.is_empty() {
1391 return Err("legend: at least one string argument required".into());
1392 }
1393 args.iter()
1394 .map(|a| match a {
1395 Value::Str(s) | Value::StringObj(s) => Ok(s.clone()),
1396 _ => Err("legend: all arguments must be strings".into()),
1397 })
1398 .collect()
1399}
1400
1401fn extract_lim(name: &str, args: &[Value]) -> Result<(f64, f64), String> {
1402 let v = match args {
1403 [val] => extract_vector(val)
1404 .map_err(|_| format!("{name}: expected a 2-element vector [lo hi]"))?,
1405 _ => return Err(format!("{name}: expected exactly one argument [lo hi]")),
1406 };
1407 if v.len() != 2 {
1408 return Err(format!(
1409 "{name}: vector must have exactly 2 elements, got {}",
1410 v.len()
1411 ));
1412 }
1413 Ok((v[0], v[1]))
1414}
1415
1416fn make_step_data(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
1420 let n = x.len();
1421 if n == 0 {
1422 return (vec![], vec![]);
1423 }
1424 let mut sx = Vec::with_capacity(2 * n - 1);
1425 let mut sy = Vec::with_capacity(2 * n - 1);
1426 for i in 0..n - 1 {
1427 sx.push(x[i]);
1428 sy.push(y[i]);
1429 sx.push(x[i + 1]);
1431 sy.push(y[i]);
1432 }
1433 sx.push(*x.last().unwrap());
1434 sy.push(*y.last().unwrap());
1435 (sx, sy)
1436}
1437
1438fn sturges_bins(n: usize) -> usize {
1442 (n as f64).sqrt().round() as usize
1443}
1444
1445fn parse_and_compute_hist(args: &[Value]) -> Result<(Vec<usize>, Vec<f64>), String> {
1451 match args.len() {
1452 0 => Err("hist: at least one argument required".into()),
1453 1 => {
1454 let vals = extract_vector(&args[0])
1455 .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
1456 let n = sturges_bins(vals.len()).max(1);
1457 Ok(compute_histogram_uniform(&vals, n))
1458 }
1459 2 => {
1460 let vals = extract_vector(&args[0])
1461 .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
1462 match &args[1] {
1463 Value::Scalar(v) => {
1464 let n = *v as usize;
1465 if n == 0 {
1466 return Err("hist: bin count must be positive".into());
1467 }
1468 Ok(compute_histogram_uniform(&vals, n))
1469 }
1470 Value::Matrix(_) | Value::ComplexMatrix(_) => {
1471 let edges = extract_vector(&args[1])
1472 .map_err(|_| "hist: edge vector must be numeric".to_string())?;
1473 if edges.len() < 2 {
1474 return Err("hist: edge vector must have at least 2 elements".into());
1475 }
1476 Ok(compute_histogram_edges(&vals, &edges))
1477 }
1478 _ => Err("hist: second argument must be a bin count or an edge vector".into()),
1479 }
1480 }
1481 _ => Err("hist: too many arguments".into()),
1482 }
1483}
1484
1485fn compute_histogram_uniform(vals: &[f64], n_bins: usize) -> (Vec<usize>, Vec<f64>) {
1487 if vals.is_empty() {
1488 return (vec![0; n_bins], (0..=n_bins).map(|i| i as f64).collect());
1489 }
1490 let min_v = vals.iter().copied().fold(f64::INFINITY, f64::min);
1491 let max_v = vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1492 let range = if max_v > min_v { max_v - min_v } else { 1.0 };
1493 let mut counts = vec![0usize; n_bins];
1494 for &v in vals {
1495 let b = ((v - min_v) / range * n_bins as f64) as usize;
1496 counts[b.min(n_bins - 1)] += 1;
1497 }
1498 let edges: Vec<f64> = (0..=n_bins)
1499 .map(|i| min_v + range * (i as f64 / n_bins as f64))
1500 .collect();
1501 (counts, edges)
1502}
1503
1504fn compute_histogram_edges(vals: &[f64], edges: &[f64]) -> (Vec<usize>, Vec<f64>) {
1508 let n_bins = edges.len() - 1;
1509 let mut counts = vec![0usize; n_bins];
1510 for &v in vals {
1511 match edges.binary_search_by(|e| e.partial_cmp(&v).unwrap_or(std::cmp::Ordering::Less)) {
1513 Ok(b) => counts[b.min(n_bins - 1)] += 1,
1514 Err(b) if b > 0 && b <= n_bins => counts[b - 1] += 1,
1515 _ => {}
1516 }
1517 }
1518 (counts, edges.to_vec())
1519}
1520
1521fn render_hist_ascii(counts: &[usize], edges: &[f64], state: &FigureState) {
1523 let n_bins = counts.len();
1524 let bar_cols: usize = term_cols().saturating_sub(26).max(10);
1525 let max_count = counts.iter().copied().max().unwrap_or(1).max(1);
1526 if let Some(t) = &state.title {
1527 println!("{t}");
1528 }
1529 for i in 0..n_bins {
1530 let lo = edges[i];
1531 let hi = edges[i + 1];
1532 let bar_len = counts[i] * bar_cols / max_count;
1533 println!(
1534 "{lo:8.4} {hi:8.4} |{bar:<width$}| {c}",
1535 bar = "#".repeat(bar_len),
1536 width = bar_cols,
1537 c = counts[i],
1538 );
1539 }
1540 if let Some(xl) = &state.xlabel {
1541 println!("x: {xl}");
1542 }
1543 if let Some(yl) = &state.ylabel {
1544 println!("y: {yl}");
1545 }
1546}
1547
1548#[cfg(feature = "plot-svg")]
1549fn render_hist_file(
1550 counts: &[usize],
1551 edges: &[f64],
1552 path: &str,
1553 style: Option<StyleSpec>,
1554 state: FigureState,
1555) -> Result<Value, String> {
1556 file::render_hist(counts, edges, path, style, state).map_err(|e| format!("hist: {e}"))?;
1557 Ok(Value::Void)
1558}
1559
1560#[cfg(not(feature = "plot-svg"))]
1561fn render_hist_file(
1562 _counts: &[usize],
1563 _edges: &[f64],
1564 _path: &str,
1565 _style: Option<StyleSpec>,
1566 _state: FigureState,
1567) -> Result<Value, String> {
1568 Err("hist: SVG/PNG export requires the 'plot-svg' feature — \
1569 rebuild with: cargo build --features plot-svg"
1570 .into())
1571}
1572
1573fn render_multi_series(
1576 x: &[f64],
1577 ys: &[Vec<f64>],
1578 path: Option<&str>,
1579 state: FigureState,
1580) -> Result<Value, String> {
1581 match path {
1582 None | Some("ascii") => render_multi_series_ascii(x, ys, &state),
1583 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1584 render_multi_series_file(x, ys, p, state)
1585 }
1586 Some(p) => Err(format!("plot: unknown output target '{p}'")),
1587 }
1588}
1589
1590#[cfg(feature = "plot")]
1591fn render_multi_series_ascii(
1592 x: &[f64],
1593 ys: &[Vec<f64>],
1594 _state: &FigureState,
1595) -> Result<Value, String> {
1596 ascii::render_line(x, &ys[0], FigureState::default());
1598 println!("% {} series total — use file export for all", ys.len());
1599 Ok(Value::Void)
1600}
1601
1602#[cfg(not(feature = "plot"))]
1603fn render_multi_series_ascii(
1604 _x: &[f64],
1605 _ys: &[Vec<f64>],
1606 _state: &FigureState,
1607) -> Result<Value, String> {
1608 Err("plot: ASCII rendering requires the 'plot' feature flag — \
1609 rebuild with: cargo build --features plot"
1610 .into())
1611}
1612
1613#[cfg(feature = "plot-svg")]
1614fn render_multi_series_file(
1615 x: &[f64],
1616 ys: &[Vec<f64>],
1617 path: &str,
1618 state: FigureState,
1619) -> Result<Value, String> {
1620 file::render_multi_line(x, ys, path, state).map_err(|e| format!("plot: {e}"))?;
1621 Ok(Value::Void)
1622}
1623
1624#[cfg(not(feature = "plot-svg"))]
1625fn render_multi_series_file(
1626 _x: &[f64],
1627 _ys: &[Vec<f64>],
1628 _path: &str,
1629 _state: FigureState,
1630) -> Result<Value, String> {
1631 Err("plot: SVG/PNG export requires the 'plot-svg' feature — \
1632 rebuild with: cargo build --features plot-svg"
1633 .into())
1634}
1635
1636fn render_line_xy(
1640 name: &str,
1641 x: &[f64],
1642 y: &[f64],
1643 path: Option<&str>,
1644 state: FigureState,
1645) -> Result<Value, String> {
1646 match path {
1647 None | Some("ascii") => render_line_xy_ascii(name, x, y, state),
1648 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1649 render_line_xy_file(name, x, y, p, state)
1650 }
1651 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1652 }
1653}
1654
1655#[cfg(feature = "plot")]
1656fn render_line_xy_ascii(
1657 _name: &str,
1658 x: &[f64],
1659 y: &[f64],
1660 state: FigureState,
1661) -> Result<Value, String> {
1662 ascii::render_line(x, y, state);
1663 Ok(Value::Void)
1664}
1665
1666#[cfg(not(feature = "plot"))]
1667fn render_line_xy_ascii(
1668 name: &str,
1669 _x: &[f64],
1670 _y: &[f64],
1671 _state: FigureState,
1672) -> Result<Value, String> {
1673 Err(format!(
1674 "{name}: ASCII rendering requires the 'plot' feature flag — \
1675 rebuild with: cargo build --features plot"
1676 ))
1677}
1678
1679#[cfg(feature = "plot-svg")]
1680fn render_line_xy_file(
1681 name: &str,
1682 x: &[f64],
1683 y: &[f64],
1684 path: &str,
1685 state: FigureState,
1686) -> Result<Value, String> {
1687 file::render_line(x, y, path, state).map_err(|e| format!("{name}: {e}"))?;
1688 Ok(Value::Void)
1689}
1690
1691#[cfg(not(feature = "plot-svg"))]
1692fn render_line_xy_file(
1693 name: &str,
1694 _x: &[f64],
1695 _y: &[f64],
1696 _path: &str,
1697 _state: FigureState,
1698) -> Result<Value, String> {
1699 Err(format!(
1700 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
1701 rebuild with: cargo build --features plot-svg"
1702 ))
1703}
1704
1705fn render_fill_xy(
1708 x: &[f64],
1709 y: &[f64],
1710 path: Option<&str>,
1711 style: Option<StyleSpec>,
1712 state: FigureState,
1713) -> Result<Value, String> {
1714 match path {
1715 None | Some("ascii") => render_fill_ascii(x, y, state),
1716 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1717 render_fill_file(x, y, p, style, state)
1718 }
1719 Some(p) => Err(format!("fill: unknown output target '{p}'")),
1720 }
1721}
1722
1723fn render_area_xy(
1724 x: &[f64],
1725 y: &[f64],
1726 path: Option<&str>,
1727 style: Option<StyleSpec>,
1728 state: FigureState,
1729) -> Result<Value, String> {
1730 match path {
1731 None | Some("ascii") => render_area_ascii(x, y, state),
1732 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1733 render_area_file(x, y, p, style, state)
1734 }
1735 Some(p) => Err(format!("area: unknown output target '{p}'")),
1736 }
1737}
1738
1739#[cfg(feature = "plot")]
1740fn render_fill_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1741 ascii::render_fill(x, y, state);
1742 Ok(Value::Void)
1743}
1744
1745#[cfg(not(feature = "plot"))]
1746fn render_fill_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1747 Err("fill: ASCII rendering requires the 'plot' feature flag — \
1748 rebuild with: cargo build --features plot"
1749 .into())
1750}
1751
1752#[cfg(feature = "plot")]
1753fn render_area_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1754 ascii::render_area(x, y, state);
1755 Ok(Value::Void)
1756}
1757
1758#[cfg(not(feature = "plot"))]
1759fn render_area_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1760 Err("area: ASCII rendering requires the 'plot' feature flag — \
1761 rebuild with: cargo build --features plot"
1762 .into())
1763}
1764
1765#[cfg(feature = "plot-svg")]
1766fn render_fill_file(
1767 x: &[f64],
1768 y: &[f64],
1769 path: &str,
1770 style: Option<StyleSpec>,
1771 state: FigureState,
1772) -> Result<Value, String> {
1773 file::render_fill(x, y, path, style, state).map_err(|e| format!("fill: {e}"))?;
1774 Ok(Value::Void)
1775}
1776
1777#[cfg(not(feature = "plot-svg"))]
1778fn render_fill_file(
1779 _x: &[f64],
1780 _y: &[f64],
1781 _path: &str,
1782 _style: Option<StyleSpec>,
1783 _state: FigureState,
1784) -> Result<Value, String> {
1785 Err("fill: SVG/PNG export requires the 'plot-svg' feature — \
1786 rebuild with: cargo build --features plot-svg"
1787 .into())
1788}
1789
1790#[cfg(feature = "plot-svg")]
1791fn render_area_file(
1792 x: &[f64],
1793 y: &[f64],
1794 path: &str,
1795 style: Option<StyleSpec>,
1796 state: FigureState,
1797) -> Result<Value, String> {
1798 file::render_area(x, y, path, style, state).map_err(|e| format!("area: {e}"))?;
1799 Ok(Value::Void)
1800}
1801
1802#[cfg(not(feature = "plot-svg"))]
1803fn render_area_file(
1804 _x: &[f64],
1805 _y: &[f64],
1806 _path: &str,
1807 _style: Option<StyleSpec>,
1808 _state: FigureState,
1809) -> Result<Value, String> {
1810 Err("area: SVG/PNG export requires the 'plot-svg' feature — \
1811 rebuild with: cargo build --features plot-svg"
1812 .into())
1813}
1814
1815fn render_bar_xy(
1818 x: &[f64],
1819 y: &[f64],
1820 path: Option<&str>,
1821 style: Option<StyleSpec>,
1822 state: FigureState,
1823) -> Result<Value, String> {
1824 match path {
1825 None | Some("ascii") => render_bar_ascii(x, y, state),
1826 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1827 render_bar_file(x, y, p, style, state)
1828 }
1829 Some(p) => Err(format!("bar: unknown output target '{p}'")),
1830 }
1831}
1832
1833fn render_stem_xy(
1834 x: &[f64],
1835 y: &[f64],
1836 path: Option<&str>,
1837 style: Option<StyleSpec>,
1838 state: FigureState,
1839) -> Result<Value, String> {
1840 match path {
1841 None | Some("ascii") => render_stem_ascii(x, y, state),
1842 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1843 render_stem_file(x, y, p, style, state)
1844 }
1845 Some(p) => Err(format!("stem: unknown output target '{p}'")),
1846 }
1847}
1848
1849#[cfg(feature = "plot")]
1850fn render_bar_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1851 ascii::render_bar(x, y, state);
1852 Ok(Value::Void)
1853}
1854
1855#[cfg(not(feature = "plot"))]
1856fn render_bar_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1857 Err("bar: ASCII rendering requires the 'plot' feature flag — \
1858 rebuild with: cargo build --features plot"
1859 .into())
1860}
1861
1862#[cfg(feature = "plot")]
1863fn render_stem_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1864 ascii::render_stem(x, y, state);
1865 Ok(Value::Void)
1866}
1867
1868#[cfg(not(feature = "plot"))]
1869fn render_stem_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1870 Err("stem: ASCII rendering requires the 'plot' feature flag — \
1871 rebuild with: cargo build --features plot"
1872 .into())
1873}
1874
1875#[cfg(feature = "plot-svg")]
1876fn render_bar_file(
1877 x: &[f64],
1878 y: &[f64],
1879 path: &str,
1880 style: Option<StyleSpec>,
1881 state: FigureState,
1882) -> Result<Value, String> {
1883 file::render_bar(x, y, path, style, state).map_err(|e| format!("bar: {e}"))?;
1884 Ok(Value::Void)
1885}
1886
1887#[cfg(not(feature = "plot-svg"))]
1888fn render_bar_file(
1889 _x: &[f64],
1890 _y: &[f64],
1891 _path: &str,
1892 _style: Option<StyleSpec>,
1893 _state: FigureState,
1894) -> Result<Value, String> {
1895 Err("bar: SVG/PNG export requires the 'plot-svg' feature — \
1896 rebuild with: cargo build --features plot-svg"
1897 .into())
1898}
1899
1900#[cfg(feature = "plot-svg")]
1901fn render_stem_file(
1902 x: &[f64],
1903 y: &[f64],
1904 path: &str,
1905 style: Option<StyleSpec>,
1906 state: FigureState,
1907) -> Result<Value, String> {
1908 file::render_stem(x, y, path, style, state).map_err(|e| format!("stem: {e}"))?;
1909 Ok(Value::Void)
1910}
1911
1912#[cfg(not(feature = "plot-svg"))]
1913fn render_stem_file(
1914 _x: &[f64],
1915 _y: &[f64],
1916 _path: &str,
1917 _style: Option<StyleSpec>,
1918 _state: FigureState,
1919) -> Result<Value, String> {
1920 Err("stem: SVG/PNG export requires the 'plot-svg' feature — \
1921 rebuild with: cargo build --features plot-svg"
1922 .into())
1923}
1924
1925fn render_quiver(
1928 x: &[f64],
1929 y: &[f64],
1930 u: &[f64],
1931 v: &[f64],
1932 path: Option<&str>,
1933 style: Option<StyleSpec>,
1934 state: FigureState,
1935) -> Result<Value, String> {
1936 match path {
1937 None | Some("ascii") => render_quiver_ascii_tier(x, y, u, v, state),
1938 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1939 render_quiver_file_tier(x, y, u, v, p, style, state)
1940 }
1941 Some(p) => Err(format!("quiver: unknown output target '{p}'")),
1942 }
1943}
1944
1945fn render_quiver_ascii_tier(
1946 x: &[f64],
1947 y: &[f64],
1948 u: &[f64],
1949 v: &[f64],
1950 state: FigureState,
1951) -> Result<Value, String> {
1952 render_quiver_ascii(x, y, u, v, &state);
1953 Ok(Value::Void)
1954}
1955
1956#[cfg(feature = "plot-svg")]
1957fn render_quiver_file_tier(
1958 x: &[f64],
1959 y: &[f64],
1960 u: &[f64],
1961 v: &[f64],
1962 path: &str,
1963 style: Option<StyleSpec>,
1964 state: FigureState,
1965) -> Result<Value, String> {
1966 file::render_quiver(x, y, u, v, path, style, state).map_err(|e| format!("quiver: {e}"))?;
1967 Ok(Value::Void)
1968}
1969
1970#[cfg(not(feature = "plot-svg"))]
1971fn render_quiver_file_tier(
1972 _x: &[f64],
1973 _y: &[f64],
1974 _u: &[f64],
1975 _v: &[f64],
1976 _path: &str,
1977 _style: Option<StyleSpec>,
1978 _state: FigureState,
1979) -> Result<Value, String> {
1980 Err("quiver: SVG/PNG export requires the 'plot-svg' feature — \
1981 rebuild with: cargo build --features plot-svg"
1982 .into())
1983}
1984
1985fn render_quiver_ascii(xs: &[f64], ys: &[f64], us: &[f64], vs: &[f64], state: &FigureState) {
1987 let n = xs.len();
1988 if n == 0 {
1989 return;
1990 }
1991 let w = term_cols().saturating_sub(4).max(20);
1992 let h = (term_rows() / 2).max(10);
1993
1994 let x_min = state
1995 .xlim
1996 .map(|(lo, _)| lo)
1997 .unwrap_or_else(|| xs.iter().copied().fold(f64::INFINITY, f64::min));
1998 let x_max = state
1999 .xlim
2000 .map(|(_, hi)| hi)
2001 .unwrap_or_else(|| xs.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2002 let y_min = state
2003 .ylim
2004 .map(|(lo, _)| lo)
2005 .unwrap_or_else(|| ys.iter().copied().fold(f64::INFINITY, f64::min));
2006 let y_max = state
2007 .ylim
2008 .map(|(_, hi)| hi)
2009 .unwrap_or_else(|| ys.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2010
2011 let x_span = if (x_max - x_min).abs() < f64::EPSILON {
2012 2.0
2013 } else {
2014 x_max - x_min
2015 };
2016 let y_span = if (y_max - y_min).abs() < f64::EPSILON {
2017 2.0
2018 } else {
2019 y_max - y_min
2020 };
2021
2022 let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
2023
2024 for i in 0..n {
2025 let col = ((xs[i] - x_min) / x_span * (w - 1) as f64).round() as isize;
2026 let row = ((y_max - ys[i]) / y_span * (h - 1) as f64).round() as isize;
2027 if col >= 0 && (col as usize) < w && row >= 0 && (row as usize) < h {
2028 let angle = vs[i].atan2(us[i]);
2029 grid[row as usize][col as usize] = arrow_char(angle);
2030 }
2031 }
2032
2033 if let Some(t) = &state.title {
2034 println!("{t}");
2035 }
2036 for row in &grid {
2037 println!("|{}|", row.iter().collect::<String>());
2038 }
2039 if let Some(xl) = &state.xlabel {
2040 println!("x: {xl}");
2041 }
2042 if let Some(yl) = &state.ylabel {
2043 println!("y: {yl}");
2044 }
2045}
2046
2047fn arrow_char(angle: f64) -> char {
2049 use std::f64::consts::PI;
2050 let a = (angle + 2.0 * PI).rem_euclid(2.0 * PI);
2051 let octant = ((a + PI / 8.0) / (PI / 4.0)) as usize % 8;
2052 match octant {
2053 0 => '\u{2192}', 1 => '\u{2197}', 2 => '\u{2191}', 3 => '\u{2196}', 4 => '\u{2190}', 5 => '\u{2199}', 6 => '\u{2193}', _ => '\u{2198}', }
2062}
2063
2064fn render_3d(
2067 name: &str,
2068 data_args: &[Value],
2069 path: Option<&str>,
2070 state: FigureState,
2071) -> Result<Value, String> {
2072 extract_xyz(name, data_args)?;
2073 match path {
2074 None | Some("ascii") => render_3d_ascii(name, data_args, state),
2075 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2076 render_3d_file(name, data_args, p, state)
2077 }
2078 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
2079 }
2080}
2081
2082#[cfg(feature = "plot")]
2083fn render_3d_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
2084 let (x, y, z) = extract_xyz(name, data_args)?;
2085 let (px, py) = proj3d::project_ortho(&x, &y, &z);
2086 let state_2d = FigureState {
2089 title: state.title.clone(),
2090 xlim: state.xlim,
2091 ylim: state.ylim,
2092 ..FigureState::default()
2093 };
2094 match name {
2095 "plot3" => ascii::render_line(&px, &py, state_2d),
2096 "scatter3" => ascii::render_scatter(&px, &py, state_2d),
2097 _ => unreachable!(),
2098 }
2099 if let Some(xl) = &state.xlabel {
2100 println!("x: {xl}");
2101 }
2102 if let Some(yl) = &state.ylabel {
2103 println!("y: {yl}");
2104 }
2105 if let Some(zl) = &state.zlabel {
2106 println!("z: {zl}");
2107 }
2108 Ok(Value::Void)
2109}
2110
2111#[cfg(not(feature = "plot"))]
2112fn render_3d_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
2113 Err(format!(
2114 "{name}: ASCII rendering requires the 'plot' feature flag — \
2115 rebuild with: cargo build --features plot"
2116 ))
2117}
2118
2119#[cfg(feature = "plot-svg")]
2120fn render_3d_file(
2121 name: &str,
2122 data_args: &[Value],
2123 path: &str,
2124 state: FigureState,
2125) -> Result<Value, String> {
2126 let (x, y, z) = extract_xyz(name, data_args)?;
2127 let result = match name {
2128 "plot3" => file::render_plot3(&x, &y, &z, path, state),
2129 "scatter3" => file::render_scatter3(&x, &y, &z, path, state),
2130 _ => unreachable!(),
2131 };
2132 result.map_err(|e| format!("{name}: {e}"))?;
2133 Ok(Value::Void)
2134}
2135
2136#[cfg(not(feature = "plot-svg"))]
2137fn render_3d_file(
2138 name: &str,
2139 _data_args: &[Value],
2140 _path: &str,
2141 _state: FigureState,
2142) -> Result<Value, String> {
2143 Err(format!(
2144 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
2145 rebuild with: cargo build --features plot-svg"
2146 ))
2147}
2148
2149#[cfg(feature = "plot")]
2155fn render_panel_ascii(panel: &Panel) -> Result<Value, String> {
2156 if panel.series.is_empty() {
2157 return Ok(Value::Void);
2158 }
2159 let base_state = FigureState {
2160 xlabel: panel.xlabel.clone(),
2161 ylabel: panel.ylabel.clone(),
2162 title: panel.title.clone(),
2163 xlim: panel.xlim,
2164 ylim: panel.ylim,
2165 ..FigureState::default()
2166 };
2167 for (i, series) in panel.series.iter().enumerate() {
2168 if i > 0 {
2169 println!("---");
2170 }
2171 match series {
2172 PendingSeries::Line(x, y, _style) => ascii::render_line(x, y, base_state.clone()),
2173 PendingSeries::Scatter(x, y, _style) => {
2174 ascii::render_scatter(x, y, base_state.clone());
2175 }
2176 PendingSeries::Bar(x, y, _style) => ascii::render_bar(x, y, base_state.clone()),
2177 PendingSeries::Stem(x, y, _style) => ascii::render_stem(x, y, base_state.clone()),
2178 PendingSeries::Hist {
2179 counts,
2180 edges,
2181 style: _,
2182 } => {
2183 render_hist_ascii(counts, edges, &base_state);
2184 }
2185 PendingSeries::Fill(x, y, _style) => ascii::render_fill(x, y, base_state.clone()),
2186 PendingSeries::Area(x, y, _style) => ascii::render_area(x, y, base_state.clone()),
2187 PendingSeries::Quiver(x, y, u, v, _style) => {
2188 render_quiver_ascii(x, y, u, v, &base_state);
2189 }
2190 }
2191 }
2192 for (ax, ay, label) in &panel.annotations {
2193 println!(" ({ax:.4}, {ay:.4}): {label}");
2194 }
2195 Ok(Value::Void)
2196}
2197
2198#[cfg(not(feature = "plot"))]
2199fn render_panel_ascii(_panel: &Panel) -> Result<Value, String> {
2200 Err("hold: ASCII rendering requires the 'plot' feature flag — \
2201 rebuild with: cargo build --features plot"
2202 .into())
2203}
2204
2205#[cfg(feature = "plot-svg")]
2206fn render_panels_file(
2207 panels: &[Panel],
2208 path: &str,
2209 canvas: (u32, u32),
2210 theme: &style::Theme,
2211 bg_override: Option<style::StyleColor>,
2212) -> Result<Value, String> {
2213 use plotters::style::RGBColor;
2214 let bg = bg_override
2215 .map(|c| RGBColor(c.0, c.1, c.2))
2216 .unwrap_or_else(|| {
2217 let c = theme.bg;
2218 RGBColor(c.0, c.1, c.2)
2219 });
2220 file::render_subplot_panels(panels, path, canvas, theme, bg)
2221 .map_err(|e| format!("savefig: {e}"))?;
2222 Ok(Value::Void)
2223}
2224
2225#[cfg(not(feature = "plot-svg"))]
2226fn render_panels_file(
2227 _panels: &[Panel],
2228 _path: &str,
2229 _canvas: (u32, u32),
2230 _theme: &style::Theme,
2231 _bg_override: Option<style::StyleColor>,
2232) -> Result<Value, String> {
2233 Err("savefig: SVG/PNG export requires the 'plot-svg' feature — \
2234 rebuild with: cargo build --features plot-svg"
2235 .into())
2236}
2237
2238#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2242#[allow(clippy::type_complexity)]
2243fn extract_xyz(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), String> {
2244 match args {
2245 [xv, yv, zv] => {
2246 let x = extract_vector(xv).map_err(|e| format!("{name}: {e}"))?;
2247 let y = extract_vector(yv).map_err(|e| format!("{name}: {e}"))?;
2248 let z = extract_vector(zv).map_err(|e| format!("{name}: {e}"))?;
2249 if x.len() != y.len() || x.len() != z.len() {
2250 return Err(format!(
2251 "{name}: x, y, z must have the same length \
2252 (got {}, {}, {})",
2253 x.len(),
2254 y.len(),
2255 z.len()
2256 ));
2257 }
2258 Ok((x, y, z))
2259 }
2260 _ => Err(format!(
2261 "{name}: expected 3 arguments (x, y, z), got {}",
2262 args.len()
2263 )),
2264 }
2265}
2266
2267#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2268fn extract_xy(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>), String> {
2269 match args.len() {
2270 0 => Err(format!("{name}: at least one argument required")),
2271 1 => {
2272 let y = extract_vector(&args[0])?;
2273 let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
2274 Ok((x, y))
2275 }
2276 2 => {
2277 let x = extract_vector(&args[0])?;
2278 let y = extract_vector(&args[1])?;
2279 if x.len() != y.len() {
2280 return Err(format!(
2281 "{name}: x and y must have the same length ({} vs {})",
2282 x.len(),
2283 y.len()
2284 ));
2285 }
2286 Ok((x, y))
2287 }
2288 _ => Err(format!("{name}: too many arguments")),
2289 }
2290}
2291
2292#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2297fn extract_xy_multi(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<Vec<f64>>), String> {
2298 match args.len() {
2299 0 => Err(format!("{name}: at least one argument required")),
2300 1 => {
2301 let y = extract_vector(&args[0])?;
2302 let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
2303 Ok((x, vec![y]))
2304 }
2305 2 => {
2306 let x = extract_vector(&args[0])?;
2307 match &args[1] {
2308 Value::Matrix(m) if m.nrows() > 1 => {
2309 let n_cols = m.ncols();
2311 if n_cols != x.len() {
2312 return Err(format!(
2313 "{name}: x has {} elements but Y has {} columns",
2314 x.len(),
2315 n_cols
2316 ));
2317 }
2318 let ys = (0..m.nrows())
2319 .map(|r| m.row(r).iter().copied().collect())
2320 .collect();
2321 Ok((x, ys))
2322 }
2323 other => {
2324 let y = extract_vector(other)?;
2325 if x.len() != y.len() {
2326 return Err(format!(
2327 "{name}: x and y must have the same length ({} vs {})",
2328 x.len(),
2329 y.len()
2330 ));
2331 }
2332 Ok((x, vec![y]))
2333 }
2334 }
2335 }
2336 _ => Err(format!("{name}: too many arguments")),
2337 }
2338}
2339
2340#[cfg(test)]
2343mod tests {
2344 use ccalc_engine::env::{Env, Value};
2345 use ndarray::Array2;
2346
2347 use super::*;
2348
2349 #[test]
2352 fn test_term_cols_default() {
2353 unsafe { std::env::remove_var("COLUMNS") };
2355 assert_eq!(term_cols(), 80);
2356 }
2357
2358 #[test]
2359 fn test_term_rows_default() {
2360 unsafe { std::env::remove_var("LINES") };
2361 assert_eq!(term_rows(), 24);
2362 }
2363
2364 #[test]
2365 fn test_term_cols_env_override() {
2366 unsafe { std::env::set_var("COLUMNS", "132") };
2367 let cols = term_cols();
2368 unsafe { std::env::remove_var("COLUMNS") };
2369 assert_eq!(cols, 132);
2370 }
2371
2372 fn f64_vec(vals: &[f64]) -> Value {
2373 Value::Matrix(Array2::from_shape_vec((1, vals.len()), vals.to_vec()).unwrap())
2374 }
2375
2376 #[test]
2379 fn test_extract_xy_infer_x() {
2380 let y = f64_vec(&[1.0, 4.0, 9.0]);
2381 let (x, yv) = extract_xy("plot", &[y]).unwrap();
2382 assert_eq!(x, vec![1.0, 2.0, 3.0]);
2383 assert_eq!(yv, vec![1.0, 4.0, 9.0]);
2384 }
2385
2386 #[test]
2387 fn test_extract_xy_explicit() {
2388 let x = f64_vec(&[10.0, 20.0]);
2389 let y = f64_vec(&[1.0, 2.0]);
2390 let (xv, yv) = extract_xy("plot", &[x, y]).unwrap();
2391 assert_eq!(xv, vec![10.0, 20.0]);
2392 assert_eq!(yv, vec![1.0, 2.0]);
2393 }
2394
2395 #[test]
2396 fn test_extract_xy_mismatch() {
2397 let x = f64_vec(&[1.0, 2.0]);
2398 let y = f64_vec(&[1.0, 2.0, 3.0]);
2399 assert!(extract_xy("plot", &[x, y]).is_err());
2400 }
2401
2402 #[test]
2403 fn test_extract_xy_scalar_promoted() {
2404 let y = Value::Scalar(5.0);
2405 let (x, yv) = extract_xy("plot", &[y]).unwrap();
2406 assert_eq!(x, vec![1.0]);
2407 assert_eq!(yv, vec![5.0]);
2408 }
2409
2410 #[test]
2413 fn test_xlabel_sets_state() {
2414 let plugin = PlotPlugin;
2415 let env = Env::new();
2416 plugin
2417 .call("xlabel", &[Value::Str("time".into())], &env)
2418 .unwrap();
2419 let label = FIGURE_STATE.with(|f| f.borrow().xlabel.clone());
2420 assert_eq!(label, Some("time".into()));
2421 FIGURE_STATE.with(|f| f.take());
2423 }
2424
2425 #[test]
2426 fn test_title_sets_state() {
2427 let plugin = PlotPlugin;
2428 let env = Env::new();
2429 plugin
2430 .call("title", &[Value::Str("My Chart".into())], &env)
2431 .unwrap();
2432 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
2433 assert_eq!(title, Some("My Chart".into()));
2434 FIGURE_STATE.with(|f| f.take());
2435 }
2436
2437 #[test]
2438 fn test_annotation_requires_string() {
2439 let plugin = PlotPlugin;
2440 let env = Env::new();
2441 let result = plugin.call("xlabel", &[Value::Scalar(1.0)], &env);
2442 assert!(result.is_err());
2443 }
2444
2445 #[test]
2448 fn test_plot_no_feature_returns_error_without_feature() {
2449 #[cfg(not(feature = "plot"))]
2452 {
2453 let plugin = PlotPlugin;
2454 let env = Env::new();
2455 let y = f64_vec(&[1.0, 2.0, 3.0]);
2456 let result = plugin.call("plot", &[y], &env);
2457 assert!(result.is_err());
2458 let msg = result.unwrap_err();
2459 assert!(msg.contains("plot"), "error should mention 'plot'");
2460 }
2461 #[cfg(feature = "plot")]
2463 let _ = ();
2464 }
2465
2466 #[test]
2467 fn test_hist_single_value_no_error() {
2468 let plugin = PlotPlugin;
2469 let env = Env::new();
2470 let result = plugin.call("hist", &[Value::Scalar(1.0)], &env);
2471 assert!(result.is_ok());
2472 }
2473
2474 #[test]
2475 fn test_hist_vector_returns_void() {
2476 let plugin = PlotPlugin;
2477 let env = Env::new();
2478 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
2479 let result = plugin.call("hist", &[v], &env).unwrap();
2480 assert_eq!(result, Value::Void);
2481 }
2482
2483 #[test]
2484 fn test_hist_custom_bins_returns_void() {
2485 let plugin = PlotPlugin;
2486 let env = Env::new();
2487 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
2488 let result = plugin.call("hist", &[v, Value::Scalar(3.0)], &env).unwrap();
2489 assert_eq!(result, Value::Void);
2490 }
2491
2492 #[test]
2493 fn test_hist_zero_bins_errors() {
2494 let plugin = PlotPlugin;
2495 let env = Env::new();
2496 let v = f64_vec(&[1.0, 2.0, 3.0]);
2497 let result = plugin.call("hist", &[v, Value::Scalar(0.0)], &env);
2498 assert!(result.is_err());
2499 }
2500
2501 #[test]
2504 fn test_extract_xy_multi_single_series() {
2505 let x = f64_vec(&[1.0, 2.0, 3.0]);
2506 let y = f64_vec(&[1.0, 4.0, 9.0]);
2507 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
2508 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
2509 assert_eq!(ys.len(), 1);
2510 assert_eq!(ys[0], vec![1.0, 4.0, 9.0]);
2511 }
2512
2513 #[test]
2514 fn test_extract_xy_multi_matrix_y() {
2515 let x = f64_vec(&[1.0, 2.0, 3.0]);
2516 let y = Value::Matrix(
2518 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
2519 );
2520 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
2521 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
2522 assert_eq!(ys.len(), 2);
2523 assert_eq!(ys[0], vec![1.0, 2.0, 3.0]);
2524 assert_eq!(ys[1], vec![4.0, 5.0, 6.0]);
2525 }
2526
2527 #[test]
2528 fn test_extract_xy_multi_column_count_mismatch() {
2529 let x = f64_vec(&[1.0, 2.0]);
2530 let y = Value::Matrix(
2531 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
2532 );
2533 let result = extract_xy_multi("plot", &[x, y]);
2534 assert!(result.is_err());
2535 }
2536
2537 #[test]
2540 fn test_loglog_non_positive_all_filtered_errors() {
2541 let plugin = PlotPlugin;
2542 let env = Env::new();
2543 let x = f64_vec(&[-1.0, 0.0, -2.0]);
2544 let y = f64_vec(&[1.0, 2.0, 3.0]);
2545 let result = plugin.call("loglog", &[x, y], &env);
2546 assert!(result.is_err());
2547 let msg = result.unwrap_err();
2548 assert!(msg.contains("finite"), "error should mention finite: {msg}");
2549 }
2550
2551 #[test]
2552 fn test_semilogx_valid_data() {
2553 let plugin = PlotPlugin;
2554 let env = Env::new();
2555 let x = f64_vec(&[1.0, 10.0, 100.0]);
2557 let y = f64_vec(&[1.0, 2.0, 3.0]);
2558 let result = plugin.call("semilogx", &[x, y], &env);
2559 if let Err(msg) = &result {
2561 assert!(
2562 !msg.contains("not yet implemented"),
2563 "should not say 'not yet implemented': {msg}"
2564 );
2565 }
2566 }
2567
2568 #[test]
2569 fn test_semilogy_label_annotation() {
2570 FIGURE_STATE.with(|f| f.take());
2574 }
2575
2576 #[test]
2577 fn test_stairs_stub_is_gone() {
2578 let plugin = PlotPlugin;
2580 let env = Env::new();
2581 #[cfg(feature = "plot")]
2584 {
2585 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
2586 let result = plugin.call("stairs", &[y], &env);
2587 assert!(result.is_ok(), "stairs should succeed: {result:?}");
2588 }
2589 #[cfg(not(feature = "plot"))]
2590 {
2591 let y = f64_vec(&[1.0, 4.0, 9.0]);
2592 let result = plugin.call("stairs", &[y], &env);
2593 let msg = result.unwrap_err();
2595 assert!(
2596 !msg.contains("not yet implemented"),
2597 "should not say 'not yet implemented': {msg}"
2598 );
2599 }
2600 }
2601
2602 #[test]
2605 fn test_xlim_sets_state() {
2606 FIGURE_STATE.with(|f| f.take());
2607 let plugin = PlotPlugin;
2608 let env = Env::new();
2609 let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![0.0, 10.0]).unwrap());
2610 plugin.call("xlim", &[lim], &env).unwrap();
2611 let xlim = FIGURE_STATE.with(|f| f.borrow().xlim);
2612 assert_eq!(xlim, Some((0.0, 10.0)));
2613 FIGURE_STATE.with(|f| f.take());
2614 }
2615
2616 #[test]
2617 fn test_ylim_sets_state() {
2618 FIGURE_STATE.with(|f| f.take());
2619 let plugin = PlotPlugin;
2620 let env = Env::new();
2621 let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![-1.0, 1.0]).unwrap());
2622 plugin.call("ylim", &[lim], &env).unwrap();
2623 let ylim = FIGURE_STATE.with(|f| f.borrow().ylim);
2624 assert_eq!(ylim, Some((-1.0, 1.0)));
2625 FIGURE_STATE.with(|f| f.take());
2626 }
2627
2628 #[test]
2629 fn test_legend_sets_state() {
2630 FIGURE_STATE.with(|f| f.take());
2631 let plugin = PlotPlugin;
2632 let env = Env::new();
2633 plugin
2634 .call(
2635 "legend",
2636 &[Value::Str("a".into()), Value::Str("b".into())],
2637 &env,
2638 )
2639 .unwrap();
2640 let legend = FIGURE_STATE.with(|f| f.borrow().legend.clone());
2641 assert_eq!(legend, vec!["a".to_string(), "b".to_string()]);
2642 FIGURE_STATE.with(|f| f.take());
2643 }
2644
2645 #[test]
2646 fn test_legend_requires_strings() {
2647 let plugin = PlotPlugin;
2648 let env = Env::new();
2649 let result = plugin.call("legend", &[Value::Scalar(1.0)], &env);
2650 assert!(result.is_err());
2651 }
2652
2653 #[test]
2654 fn test_legend_requires_at_least_one_arg() {
2655 let plugin = PlotPlugin;
2656 let env = Env::new();
2657 let result = plugin.call("legend", &[], &env);
2658 assert!(result.is_err());
2659 }
2660
2661 #[test]
2662 fn test_grid_toggles_state() {
2663 FIGURE_STATE.with(|f| f.take());
2664 let plugin = PlotPlugin;
2665 let env = Env::new();
2666 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2668 plugin.call("grid", &[], &env).unwrap();
2669 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
2670 plugin.call("grid", &[], &env).unwrap();
2671 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2672 FIGURE_STATE.with(|f| f.take());
2673 }
2674
2675 #[test]
2676 fn test_grid_on_off_string_args() {
2677 FIGURE_STATE.with(|f| f.take());
2678 let plugin = PlotPlugin;
2679 let env = Env::new();
2680 plugin
2681 .call("grid", &[Value::Str("on".into())], &env)
2682 .unwrap();
2683 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
2684 plugin
2685 .call("grid", &[Value::Str("off".into())], &env)
2686 .unwrap();
2687 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2688 let result = plugin.call("grid", &[Value::Str("maybe".into())], &env);
2690 assert!(result.is_err());
2691 FIGURE_STATE.with(|f| f.take());
2692 }
2693
2694 #[test]
2695 fn test_zlabel_sets_state() {
2696 FIGURE_STATE.with(|f| f.take());
2697 let plugin = PlotPlugin;
2698 let env = Env::new();
2699 plugin
2700 .call("zlabel", &[Value::Str("depth".into())], &env)
2701 .unwrap();
2702 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
2703 assert_eq!(zlabel, Some("depth".into()));
2704 FIGURE_STATE.with(|f| f.take());
2705 }
2706
2707 #[test]
2708 fn test_xlim_wrong_length() {
2709 let plugin = PlotPlugin;
2710 let env = Env::new();
2711 let v = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0]).unwrap());
2712 let result = plugin.call("xlim", &[v], &env);
2713 assert!(result.is_err());
2714 }
2715
2716 #[test]
2717 #[cfg(not(feature = "plot-svg"))]
2718 fn test_svg_without_feature() {
2719 let plugin = PlotPlugin;
2720 let env = Env::new();
2721 let y = f64_vec(&[1.0, 2.0, 3.0]);
2722 let path = Value::Str("out.svg".into());
2723 let result = plugin.call("plot", &[y, path], &env);
2724 assert!(result.is_err());
2725 }
2726
2727 #[test]
2730 #[cfg(feature = "plot")]
2731 fn test_plot_ascii_no_error() {
2732 let plugin = PlotPlugin;
2733 let env = Env::new();
2734 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0, 25.0]);
2735 assert!(plugin.call("plot", &[y], &env).is_ok());
2736 }
2737
2738 #[test]
2739 #[cfg(feature = "plot")]
2740 fn test_scatter_ascii_no_error() {
2741 let plugin = PlotPlugin;
2742 let env = Env::new();
2743 let x = f64_vec(&[1.0, 2.0, 3.0, 4.0]);
2744 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
2745 assert!(plugin.call("scatter", &[x, y], &env).is_ok());
2746 }
2747
2748 #[test]
2749 #[cfg(feature = "plot")]
2750 fn test_figure_state_cleared_after_render() {
2751 let plugin = PlotPlugin;
2752 let env = Env::new();
2753 plugin
2754 .call("title", &[Value::Str("Temp".into())], &env)
2755 .unwrap();
2756 let y = f64_vec(&[1.0, 2.0, 3.0]);
2757 plugin.call("plot", &[y], &env).unwrap();
2758 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
2760 assert!(
2761 title.is_none(),
2762 "FigureState should be cleared after plot()"
2763 );
2764 }
2765
2766 #[test]
2769 fn test_plot3_length_mismatch_error() {
2770 let plugin = PlotPlugin;
2771 let env = Env::new();
2772 let x = f64_vec(&[1.0, 2.0, 3.0]);
2773 let y = f64_vec(&[1.0, 2.0]);
2774 let z = f64_vec(&[0.0, 0.0, 0.0]);
2775 let result = plugin.call("plot3", &[x, y, z], &env);
2776 assert!(result.is_err());
2777 let msg = result.unwrap_err();
2778 assert!(
2779 msg.contains("same length"),
2780 "error should mention length: {msg}"
2781 );
2782 }
2783
2784 #[test]
2785 fn test_scatter3_wrong_arg_count_error() {
2786 let plugin = PlotPlugin;
2787 let env = Env::new();
2788 let x = f64_vec(&[1.0, 2.0]);
2789 let y = f64_vec(&[1.0, 2.0]);
2790 let result = plugin.call("scatter3", &[x, y], &env);
2792 assert!(result.is_err());
2793 let msg = result.unwrap_err();
2794 assert!(
2795 msg.contains("3 arguments"),
2796 "error should mention 3 args: {msg}"
2797 );
2798 }
2799
2800 #[test]
2801 #[cfg(feature = "plot")]
2802 fn test_plot3_ascii_no_error() {
2803 let plugin = PlotPlugin;
2804 let env = Env::new();
2805 let x = f64_vec(&[0.0, 1.0, 2.0, 3.0]);
2806 let y = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
2807 let z = f64_vec(&[0.0, 0.5, 1.0, 0.5]);
2808 let result = plugin.call("plot3", &[x, y, z], &env);
2809 assert!(result.is_ok(), "plot3 ASCII should succeed: {result:?}");
2810 }
2811
2812 #[test]
2813 #[cfg(feature = "plot")]
2814 fn test_scatter3_ascii_no_error() {
2815 let plugin = PlotPlugin;
2816 let env = Env::new();
2817 let x = f64_vec(&[0.0, 1.0, 2.0]);
2818 let y = f64_vec(&[0.0, 1.0, 0.0]);
2819 let z = f64_vec(&[1.0, 2.0, 3.0]);
2820 let result = plugin.call("scatter3", &[x, y, z], &env);
2821 assert!(result.is_ok(), "scatter3 ASCII should succeed: {result:?}");
2822 }
2823
2824 #[test]
2825 #[cfg(feature = "plot")]
2826 fn test_plot3_state_cleared_after_render() {
2827 FIGURE_STATE.with(|f| f.take());
2828 let plugin = PlotPlugin;
2829 let env = Env::new();
2830 plugin
2831 .call("zlabel", &[Value::Str("depth".into())], &env)
2832 .unwrap();
2833 let x = f64_vec(&[0.0, 1.0, 2.0]);
2834 let y = f64_vec(&[0.0, 1.0, 2.0]);
2835 let z = f64_vec(&[0.0, 1.0, 2.0]);
2836 plugin.call("plot3", &[x, y, z], &env).unwrap();
2837 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
2838 assert!(
2839 zlabel.is_none(),
2840 "FigureState.zlabel should be cleared after plot3()"
2841 );
2842 }
2843
2844 #[test]
2845 #[cfg(not(feature = "plot-svg"))]
2846 fn test_plot3_svg_without_feature() {
2847 let plugin = PlotPlugin;
2848 let env = Env::new();
2849 let x = f64_vec(&[0.0, 1.0]);
2850 let y = f64_vec(&[0.0, 1.0]);
2851 let z = f64_vec(&[0.0, 1.0]);
2852 let path = Value::Str("out.svg".into());
2853 let result = plugin.call("plot3", &[x, y, z, path], &env);
2854 assert!(result.is_err());
2855 let msg = result.unwrap_err();
2856 assert!(
2857 msg.contains("plot-svg"),
2858 "error should mention plot-svg feature: {msg}"
2859 );
2860 }
2861
2862 #[test]
2865 fn test_colormap_sets_state() {
2866 FIGURE_STATE.with(|f| f.take());
2867 let plugin = PlotPlugin;
2868 let env = Env::new();
2869 plugin
2870 .call("colormap", &[Value::Str("hot".into())], &env)
2871 .unwrap();
2872 let cmap = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
2873 assert_eq!(cmap, Some(colormap::ColormapSpec::Named("hot".to_string())));
2874 FIGURE_STATE.with(|f| f.take());
2875 }
2876
2877 #[test]
2878 fn test_colorbar_sets_state() {
2879 FIGURE_STATE.with(|f| f.take());
2880 let plugin = PlotPlugin;
2881 let env = Env::new();
2882 plugin.call("colorbar", &[], &env).unwrap();
2883 let cb = FIGURE_STATE.with(|f| f.borrow().colorbar);
2884 assert!(cb, "colorbar should set FigureState.colorbar = true");
2885 FIGURE_STATE.with(|f| f.take());
2886 }
2887
2888 #[test]
2891 fn test_style_rgb_matrix_dispatch() {
2892 FIGURE_STATE.with(|f| f.take());
2893 let plugin = PlotPlugin;
2894 let env = Env::new();
2895 plugin
2896 .call("hold", &[Value::Str("on".into())], &env)
2897 .unwrap();
2898 let x = f64_vec(&[1.0, 2.0]);
2899 let y = f64_vec(&[1.0, 2.0]);
2900 let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).unwrap());
2901 plugin.call("plot", &[x, y, m], &env).unwrap();
2902 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2903 assert_eq!(series.len(), 1, "should have one pending series");
2904 if let PendingSeries::Line(_, _, style) = &series[0] {
2905 assert_eq!(
2906 style.as_ref().and_then(|s| s.color),
2907 Some(style::StyleColor(255, 0, 0))
2908 );
2909 } else {
2910 panic!("expected PendingSeries::Line");
2911 }
2912 FIGURE_STATE.with(|f| f.take());
2913 }
2914
2915 #[test]
2916 fn test_style_color_named_arg_bar() {
2917 FIGURE_STATE.with(|f| f.take());
2918 let plugin = PlotPlugin;
2919 let env = Env::new();
2920 plugin
2921 .call("hold", &[Value::Str("on".into())], &env)
2922 .unwrap();
2923 let v = f64_vec(&[1.0, 2.0, 3.0]);
2924 plugin
2925 .call(
2926 "bar",
2927 &[v, Value::Str("color".into()), Value::Str("blue".into())],
2928 &env,
2929 )
2930 .expect("bar with 'color' named arg should succeed");
2931 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2932 assert_eq!(series.len(), 1);
2933 if let PendingSeries::Bar(_, _, style) = &series[0] {
2934 assert_eq!(
2935 style.as_ref().and_then(|s| s.color),
2936 Some(style::StyleColor(0, 0, 255)),
2937 "bar should carry blue style"
2938 );
2939 } else {
2940 panic!("expected PendingSeries::Bar");
2941 }
2942 FIGURE_STATE.with(|f| f.take());
2943 }
2944
2945 #[test]
2946 fn test_style_color_named_arg_hex() {
2947 FIGURE_STATE.with(|f| f.take());
2948 let plugin = PlotPlugin;
2949 let env = Env::new();
2950 plugin
2951 .call("hold", &[Value::Str("on".into())], &env)
2952 .unwrap();
2953 let v = f64_vec(&[1.0, 2.0, 3.0]);
2954 plugin
2955 .call(
2956 "bar",
2957 &[v, Value::Str("color".into()), Value::Str("#FF4400".into())],
2958 &env,
2959 )
2960 .expect("bar with hex color should succeed");
2961 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2962 assert_eq!(series.len(), 1);
2963 if let PendingSeries::Bar(_, _, style) = &series[0] {
2964 assert_eq!(
2965 style.as_ref().and_then(|s| s.color),
2966 Some(style::StyleColor(0xFF, 0x44, 0x00)),
2967 "bar should carry #FF4400 style"
2968 );
2969 } else {
2970 panic!("expected PendingSeries::Bar");
2971 }
2972 FIGURE_STATE.with(|f| f.take());
2973 }
2974
2975 #[test]
2976 fn test_colormap_matrix_dispatch() {
2977 FIGURE_STATE.with(|f| f.take());
2978 let plugin = PlotPlugin;
2979 let env = Env::new();
2980 let m = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap();
2981 let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
2982 assert!(
2983 result.is_ok(),
2984 "colormap(N×3 matrix) should succeed: {result:?}"
2985 );
2986 let spec = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
2987 assert!(
2988 matches!(spec, Some(colormap::ColormapSpec::Custom(_))),
2989 "should store ColormapSpec::Custom"
2990 );
2991 FIGURE_STATE.with(|f| f.take());
2992 }
2993
2994 #[test]
2995 fn test_colormap_matrix_wrong_cols() {
2996 let plugin = PlotPlugin;
2997 let env = Env::new();
2998 let m = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).unwrap();
2999 let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
3000 assert!(result.is_err());
3001 let msg = result.unwrap_err();
3002 assert!(msg.contains("N×3"), "error should mention N×3: {msg}");
3003 }
3004
3005 #[test]
3008 fn test_bar_accumulates_with_style_red() {
3009 FIGURE_STATE.with(|f| f.take());
3010 let plugin = PlotPlugin;
3011 let env = Env::new();
3012 plugin
3013 .call("hold", &[Value::Str("on".into())], &env)
3014 .unwrap();
3015 let x = f64_vec(&[1.0, 2.0, 3.0]);
3016 let y = f64_vec(&[4.0, 5.0, 6.0]);
3017 plugin
3018 .call("bar", &[x, y, Value::Str("r".into())], &env)
3019 .unwrap();
3020 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3021 assert_eq!(series.len(), 1, "should have one bar series");
3022 if let PendingSeries::Bar(_, _, style) = &series[0] {
3023 assert_eq!(
3024 style.as_ref().and_then(|s| s.color),
3025 Some(style::StyleColor(255, 0, 0)),
3026 "bar should carry red style"
3027 );
3028 } else {
3029 panic!("expected PendingSeries::Bar");
3030 }
3031 FIGURE_STATE.with(|f| f.take());
3032 }
3033
3034 #[test]
3035 fn test_stem_accumulates_with_style_blue() {
3036 FIGURE_STATE.with(|f| f.take());
3037 let plugin = PlotPlugin;
3038 let env = Env::new();
3039 plugin
3040 .call("hold", &[Value::Str("on".into())], &env)
3041 .unwrap();
3042 let x = f64_vec(&[1.0, 2.0, 3.0]);
3043 let y = f64_vec(&[1.0, 2.0, 3.0]);
3044 plugin
3045 .call("stem", &[x, y, Value::Str("blue".into())], &env)
3046 .unwrap();
3047 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3048 assert_eq!(series.len(), 1, "should have one stem series");
3049 if let PendingSeries::Stem(_, _, style) = &series[0] {
3050 assert_eq!(
3051 style.as_ref().and_then(|s| s.color),
3052 Some(style::StyleColor(0, 0, 255)),
3053 "stem should carry blue style"
3054 );
3055 } else {
3056 panic!("expected PendingSeries::Stem");
3057 }
3058 FIGURE_STATE.with(|f| f.take());
3059 }
3060
3061 #[test]
3062 fn test_hist_accumulates_with_style_hex() {
3063 FIGURE_STATE.with(|f| f.take());
3064 let plugin = PlotPlugin;
3065 let env = Env::new();
3066 plugin
3067 .call("hold", &[Value::Str("on".into())], &env)
3068 .unwrap();
3069 let data = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3070 plugin
3071 .call("hist", &[data, Value::Str("#FF8800".into())], &env)
3072 .unwrap();
3073 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3074 assert_eq!(series.len(), 1, "should have one hist series");
3075 if let PendingSeries::Hist { style, .. } = &series[0] {
3076 assert_eq!(
3077 style.as_ref().and_then(|s| s.color),
3078 Some(style::StyleColor(0xFF, 0x88, 0x00)),
3079 "hist should carry hex colour style"
3080 );
3081 } else {
3082 panic!("expected PendingSeries::Hist");
3083 }
3084 FIGURE_STATE.with(|f| f.take());
3085 }
3086
3087 #[test]
3088 fn test_quiver_accumulates_with_style_green() {
3089 FIGURE_STATE.with(|f| f.take());
3090 let plugin = PlotPlugin;
3091 let env = Env::new();
3092 plugin
3093 .call("hold", &[Value::Str("on".into())], &env)
3094 .unwrap();
3095 let x = f64_vec(&[0.0, 1.0]);
3096 let y = f64_vec(&[0.0, 1.0]);
3097 let u = f64_vec(&[1.0, 0.0]);
3098 let v = f64_vec(&[0.0, 1.0]);
3099 plugin
3100 .call("quiver", &[x, y, u, v, Value::Str("g".into())], &env)
3101 .unwrap();
3102 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3103 assert_eq!(series.len(), 1, "should have one quiver series");
3104 if let PendingSeries::Quiver(_, _, _, _, style) = &series[0] {
3105 assert_eq!(
3106 style.as_ref().and_then(|s| s.color),
3107 Some(style::StyleColor(0, 128, 0)),
3108 "quiver should carry green style"
3109 );
3110 } else {
3111 panic!("expected PendingSeries::Quiver");
3112 }
3113 FIGURE_STATE.with(|f| f.take());
3114 }
3115
3116 #[test]
3117 fn test_bar_no_style_stores_none() {
3118 FIGURE_STATE.with(|f| f.take());
3119 let plugin = PlotPlugin;
3120 let env = Env::new();
3121 plugin
3122 .call("hold", &[Value::Str("on".into())], &env)
3123 .unwrap();
3124 let x = f64_vec(&[1.0, 2.0]);
3125 let y = f64_vec(&[3.0, 4.0]);
3126 plugin.call("bar", &[x, y], &env).unwrap();
3127 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3128 if let PendingSeries::Bar(_, _, style) = &series[0] {
3129 assert!(style.is_none(), "unstyled bar should have None style");
3130 } else {
3131 panic!("expected PendingSeries::Bar");
3132 }
3133 FIGURE_STATE.with(|f| f.take());
3134 }
3135
3136 #[test]
3137 #[cfg(feature = "plot-svg")]
3138 fn test_bar_svg_with_red_style() {
3139 FIGURE_STATE.with(|f| f.take());
3140 let plugin = PlotPlugin;
3141 let env = Env::new();
3142 let tmp = std::env::temp_dir().join("bar_red_30_5c.svg");
3143 let path = tmp.to_string_lossy().to_string();
3144 let x = f64_vec(&[1.0, 2.0, 3.0]);
3145 let y = f64_vec(&[4.0, 5.0, 3.0]);
3146 let result = plugin.call(
3147 "bar",
3148 &[x, y, Value::Str("r".into()), Value::Str(path.clone())],
3149 &env,
3150 );
3151 assert!(
3152 result.is_ok(),
3153 "bar with red style to SVG should succeed: {result:?}"
3154 );
3155 assert!(
3156 std::path::Path::new(&path).exists(),
3157 "SVG file should be created"
3158 );
3159 let _ = std::fs::remove_file(&path);
3160 FIGURE_STATE.with(|f| f.take());
3161 }
3162
3163 #[test]
3166 fn test_figure_sets_canvas_size() {
3167 FIGURE_STATE.with(|f| f.take());
3168 let plugin = PlotPlugin;
3169 let env = Env::new();
3170 plugin
3171 .call(
3172 "figure",
3173 &[Value::Scalar(1200.0), Value::Scalar(400.0)],
3174 &env,
3175 )
3176 .unwrap();
3177 let size = FIGURE_STATE.with(|f| f.borrow().figure_size);
3178 assert_eq!(size, Some((1200, 400)));
3179 FIGURE_STATE.with(|f| f.take());
3180 }
3181
3182 #[test]
3183 fn test_figure_default_canvas_size() {
3184 FIGURE_STATE.with(|f| f.take());
3185 let st = FIGURE_STATE.with(|f| f.take());
3186 assert_eq!(st.canvas_size(), (800, 600));
3187 }
3188
3189 #[test]
3190 fn test_figure_wrong_arg_count_errors() {
3191 let plugin = PlotPlugin;
3192 let env = Env::new();
3193 let result = plugin.call("figure", &[Value::Scalar(800.0)], &env);
3194 assert!(result.is_err());
3195 let result = plugin.call("figure", &[], &env);
3196 assert!(result.is_err());
3197 }
3198
3199 #[test]
3200 fn test_figure_invalid_size_errors() {
3201 let plugin = PlotPlugin;
3202 let env = Env::new();
3203 let result = plugin.call("figure", &[Value::Scalar(0.0), Value::Scalar(600.0)], &env);
3204 assert!(result.is_err(), "width 0 should error");
3205 let result = plugin.call(
3206 "figure",
3207 &[Value::Scalar(800.0), Value::Scalar(20000.0)],
3208 &env,
3209 );
3210 assert!(result.is_err(), "height > 16384 should error");
3211 }
3212
3213 #[test]
3214 fn test_figure_in_builtin_names() {
3215 use ccalc_engine::eval::builtin_names;
3216 assert!(
3217 builtin_names().contains(&"figure"),
3218 "figure missing from builtin_names"
3219 );
3220 }
3221
3222 #[test]
3223 fn test_colormap_invalid_name_errors() {
3224 let plugin = PlotPlugin;
3225 let env = Env::new();
3226 let result = plugin.call("colormap", &[Value::Str("notacolormap".into())], &env);
3227 assert!(result.is_err());
3228 let msg = result.unwrap_err();
3229 assert!(
3230 msg.contains("colormap"),
3231 "error should mention colormap: {msg}"
3232 );
3233 }
3234
3235 #[test]
3236 fn test_apply_colormap_gray_extremes() {
3237 let (r, g, b) = colormap::apply_colormap(0.0, "gray");
3238 assert_eq!((r, g, b), (0, 0, 0));
3239 let (r, g, b) = colormap::apply_colormap(1.0, "gray");
3240 assert_eq!((r, g, b), (255, 255, 255));
3241 }
3242
3243 #[test]
3244 fn test_imagesc_non_matrix_errors() {
3245 let plugin = PlotPlugin;
3246 let env = Env::new();
3247 let result = plugin.call("imagesc", &[Value::Str("notamatrix".into())], &env);
3248 assert!(result.is_err());
3249 }
3250
3251 #[test]
3252 fn test_imagesc_no_args_errors() {
3253 let plugin = PlotPlugin;
3254 let env = Env::new();
3255 let result = plugin.call("imagesc", &[], &env);
3256 assert!(result.is_err());
3257 }
3258
3259 #[test]
3260 #[cfg(not(feature = "plot-svg"))]
3261 fn test_imagesc_svg_without_feature_errors() {
3262 let plugin = PlotPlugin;
3263 let env = Env::new();
3264 let z = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap());
3265 let path = Value::Str("out.svg".into());
3266 let result = plugin.call("imagesc", &[z, path], &env);
3267 assert!(result.is_err());
3268 let msg = result.unwrap_err();
3269 assert!(
3270 msg.contains("plot-svg"),
3271 "error should mention plot-svg feature: {msg}"
3272 );
3273 }
3274
3275 #[test]
3276 #[cfg(feature = "plot")]
3277 fn test_imagesc_ascii_no_error() {
3278 FIGURE_STATE.with(|f| f.take());
3279 let plugin = PlotPlugin;
3280 let env = Env::new();
3281 let z = Value::Matrix(
3282 Array2::from_shape_vec((4, 4), (0..16).map(|i| i as f64).collect()).unwrap(),
3283 );
3284 let result = plugin.call("imagesc", &[z], &env);
3285 assert!(result.is_ok(), "imagesc ASCII should succeed: {result:?}");
3286 }
3287
3288 #[test]
3289 #[cfg(feature = "plot")]
3290 fn test_imagesc_ascii_with_colorbar_no_error() {
3291 FIGURE_STATE.with(|f| f.take());
3292 let plugin = PlotPlugin;
3293 let env = Env::new();
3294 plugin
3295 .call("colormap", &[Value::Str("jet".into())], &env)
3296 .unwrap();
3297 plugin.call("colorbar", &[], &env).unwrap();
3298 let z = Value::Matrix(
3299 Array2::from_shape_vec((3, 3), (0..9).map(|i| i as f64).collect()).unwrap(),
3300 );
3301 let result = plugin.call("imagesc", &[z], &env);
3302 assert!(
3303 result.is_ok(),
3304 "imagesc with colorbar should succeed: {result:?}"
3305 );
3306 }
3307
3308 #[allow(dead_code)]
3311 fn make_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
3312 let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| c as f64));
3313 let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| r as f64));
3314 let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| (r + c) as f64));
3315 (x, y, z)
3316 }
3317
3318 #[test]
3319 fn test_surf_dimension_mismatch_error() {
3320 FIGURE_STATE.with(|f| f.take());
3321 let plugin = PlotPlugin;
3322 let env = Env::new();
3323 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
3324 let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![1.0; 6]).unwrap());
3325 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3326 let err = plugin.call("surf", &[x, y, z], &env).unwrap_err();
3327 assert!(
3328 err.contains("same dimensions"),
3329 "error should mention dimensions: {err}"
3330 );
3331 }
3332
3333 #[test]
3334 fn test_mesh_dimension_mismatch_error() {
3335 FIGURE_STATE.with(|f| f.take());
3336 let plugin = PlotPlugin;
3337 let env = Env::new();
3338 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
3339 let y = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
3340 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3341 let err = plugin.call("mesh", &[x, y, z], &env).unwrap_err();
3342 assert!(
3343 err.contains("same dimensions"),
3344 "error should mention dimensions: {err}"
3345 );
3346 }
3347
3348 #[test]
3349 fn test_surf_missing_args_error() {
3350 FIGURE_STATE.with(|f| f.take());
3351 let plugin = PlotPlugin;
3352 let env = Env::new();
3353 let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
3354 let err = plugin.call("surf", &[x], &env).unwrap_err();
3355 assert!(
3356 err.contains("requires"),
3357 "error should mention requires: {err}"
3358 );
3359 }
3360
3361 #[test]
3362 #[cfg(feature = "plot")]
3363 fn test_surf_ascii_no_error() {
3364 FIGURE_STATE.with(|f| f.take());
3365 let plugin = PlotPlugin;
3366 let env = Env::new();
3367 let (x, y, z) = make_xyz(5, 8);
3368 let result = plugin.call("surf", &[x, y, z], &env);
3369 assert!(result.is_ok(), "surf ASCII should succeed: {result:?}");
3370 }
3371
3372 #[test]
3373 #[cfg(feature = "plot")]
3374 fn test_mesh_ascii_no_error() {
3375 FIGURE_STATE.with(|f| f.take());
3376 let plugin = PlotPlugin;
3377 let env = Env::new();
3378 let (x, y, z) = make_xyz(5, 8);
3379 let result = plugin.call("mesh", &[x, y, z], &env);
3380 assert!(result.is_ok(), "mesh ASCII should succeed: {result:?}");
3381 }
3382
3383 #[test]
3384 #[cfg(feature = "plot-svg")]
3385 fn test_surf_svg_creates_file() {
3386 FIGURE_STATE.with(|f| f.take());
3387 let plugin = PlotPlugin;
3388 let env = Env::new();
3389 let (x, y, z) = make_xyz(4, 5);
3390 let path = ".debug/test_surf.svg";
3391 std::fs::create_dir_all(".debug").ok();
3392 let result = plugin.call("surf", &[x, y, z, Value::Str(path.into())], &env);
3393 assert!(result.is_ok(), "surf SVG should succeed: {result:?}");
3394 let content = std::fs::read_to_string(path).unwrap();
3395 assert!(
3396 content.contains("<svg"),
3397 "output should be SVG: starts with {}",
3398 &content[..50.min(content.len())]
3399 );
3400 std::fs::remove_file(path).ok();
3401 }
3402
3403 #[test]
3404 #[cfg(feature = "plot-svg")]
3405 fn test_mesh_png_creates_file() {
3406 FIGURE_STATE.with(|f| f.take());
3407 let plugin = PlotPlugin;
3408 let env = Env::new();
3409 let (x, y, z) = make_xyz(4, 5);
3410 let path = ".debug/test_mesh.png";
3411 std::fs::create_dir_all(".debug").ok();
3412 let result = plugin.call("mesh", &[x, y, z, Value::Str(path.into())], &env);
3413 assert!(result.is_ok(), "mesh PNG should succeed: {result:?}");
3414 let bytes = std::fs::read(path).unwrap();
3415 assert_eq!(
3417 &bytes[0..4],
3418 &[0x89, 0x50, 0x4E, 0x47],
3419 "output should be PNG"
3420 );
3421 std::fs::remove_file(path).ok();
3422 }
3423
3424 #[allow(dead_code)]
3427 fn make_contour_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
3428 let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| {
3430 -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64
3431 }));
3432 let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| {
3433 -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64
3434 }));
3435 let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| {
3436 let xi = -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64;
3437 let yi = -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64;
3438 (-xi * xi - yi * yi).exp()
3439 }));
3440 (x, y, z)
3441 }
3442
3443 #[test]
3444 fn test_contour_non_matrix_x_errors() {
3445 FIGURE_STATE.with(|f| f.take());
3446 let plugin = PlotPlugin;
3447 let env = Env::new();
3448 let x = Value::Str("notamatrix".into());
3449 let y = f64_vec(&[0.0, 1.0]);
3450 let z = f64_vec(&[0.0, 1.0]);
3451 let result = plugin.call("contour", &[x, y, z], &env);
3452 assert!(result.is_err(), "non-matrix X should error");
3453 let msg = result.unwrap_err();
3454 assert!(msg.contains("X"), "error should mention X: {msg}");
3455 }
3456
3457 #[test]
3458 fn test_contour_mismatched_dimensions_errors() {
3459 FIGURE_STATE.with(|f| f.take());
3460 let plugin = PlotPlugin;
3461 let env = Env::new();
3462 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3463 let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![0.0; 6]).unwrap());
3464 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3465 let result = plugin.call("contour", &[x, y, z], &env);
3466 assert!(result.is_err(), "mismatched dimensions should error");
3467 let msg = result.unwrap_err();
3468 assert!(
3469 msg.contains("same dimensions"),
3470 "error should mention dimensions: {msg}"
3471 );
3472 }
3473
3474 #[test]
3475 fn test_contour_missing_args_errors() {
3476 FIGURE_STATE.with(|f| f.take());
3477 let plugin = PlotPlugin;
3478 let env = Env::new();
3479 let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![0.0; 4]).unwrap());
3480 let result = plugin.call("contour", &[x], &env);
3481 assert!(result.is_err());
3482 let msg = result.unwrap_err();
3483 assert!(
3484 msg.contains("requires"),
3485 "error should mention requires: {msg}"
3486 );
3487 }
3488
3489 #[test]
3490 #[cfg(feature = "plot")]
3491 fn test_contour_ascii_no_error() {
3492 FIGURE_STATE.with(|f| f.take());
3493 let plugin = PlotPlugin;
3494 let env = Env::new();
3495 let (x, y, z) = make_contour_xyz(10, 12);
3496 let result = plugin.call("contour", &[x, y, z, Value::Scalar(5.0)], &env);
3497 assert!(result.is_ok(), "contour ASCII should succeed: {result:?}");
3498 }
3499
3500 #[test]
3501 #[cfg(feature = "plot")]
3502 fn test_contourf_ascii_no_error() {
3503 FIGURE_STATE.with(|f| f.take());
3504 let plugin = PlotPlugin;
3505 let env = Env::new();
3506 let (x, y, z) = make_contour_xyz(10, 12);
3507 let result = plugin.call("contourf", &[x, y, z, Value::Scalar(5.0)], &env);
3508 assert!(result.is_ok(), "contourf ASCII should succeed: {result:?}");
3509 }
3510
3511 #[test]
3512 #[cfg(feature = "plot-svg")]
3513 fn test_contour_svg_creates_file() {
3514 FIGURE_STATE.with(|f| f.take());
3515 let plugin = PlotPlugin;
3516 let env = Env::new();
3517 let (x, y, z) = make_contour_xyz(15, 20);
3518 let path = ".debug/test_contour.svg";
3519 std::fs::create_dir_all(".debug").ok();
3520 let result = plugin.call(
3521 "contour",
3522 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
3523 &env,
3524 );
3525 assert!(result.is_ok(), "contour SVG should succeed: {result:?}");
3526 let content = std::fs::read_to_string(path).unwrap();
3527 assert!(
3528 content.contains("<svg"),
3529 "output should be SVG: starts with {}",
3530 &content[..50.min(content.len())]
3531 );
3532 std::fs::remove_file(path).ok();
3533 }
3534
3535 #[test]
3536 #[cfg(feature = "plot-svg")]
3537 fn test_contourf_png_magic_bytes() {
3538 FIGURE_STATE.with(|f| f.take());
3539 let plugin = PlotPlugin;
3540 let env = Env::new();
3541 let (x, y, z) = make_contour_xyz(15, 20);
3542 let path = ".debug/test_contourf.png";
3543 std::fs::create_dir_all(".debug").ok();
3544 let result = plugin.call(
3545 "contourf",
3546 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
3547 &env,
3548 );
3549 assert!(result.is_ok(), "contourf PNG should succeed: {result:?}");
3550 let bytes = std::fs::read(path).unwrap();
3551 assert_eq!(
3552 &bytes[0..4],
3553 &[0x89, 0x50, 0x4E, 0x47],
3554 "output should be PNG"
3555 );
3556 std::fs::remove_file(path).ok();
3557 }
3558
3559 #[test]
3562 fn test_subplot_sets_state() {
3563 FIGURE_STATE.with(|f| f.take());
3564 let plugin = PlotPlugin;
3565 let env = Env::new();
3566 plugin
3567 .call(
3568 "subplot",
3569 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(1.0)],
3570 &env,
3571 )
3572 .unwrap();
3573 let subplot = FIGURE_STATE.with(|f| f.borrow().subplot);
3574 assert_eq!(subplot, Some((2, 2, 1)));
3575 FIGURE_STATE.with(|f| f.take());
3576 }
3577
3578 #[test]
3579 fn test_hold_on_sets_flag() {
3580 FIGURE_STATE.with(|f| f.take());
3581 let plugin = PlotPlugin;
3582 let env = Env::new();
3583 plugin
3584 .call("hold", &[Value::Str("on".into())], &env)
3585 .unwrap();
3586 let hold = FIGURE_STATE.with(|f| f.borrow().hold);
3587 assert!(hold, "hold flag should be true after hold('on')");
3588 FIGURE_STATE.with(|f| f.take());
3589 }
3590
3591 #[test]
3592 fn test_hold_off_clears_flag_and_series() {
3593 FIGURE_STATE.with(|f| f.take());
3594 let plugin = PlotPlugin;
3595 let env = Env::new();
3596 FIGURE_STATE.with(|f| {
3598 let mut st = f.borrow_mut();
3599 st.hold = true;
3600 st.pending_series
3601 .push(PendingSeries::Line(vec![1.0, 2.0], vec![1.0, 4.0], None));
3602 });
3603 let _ = plugin.call("hold", &[Value::Str("off".into())], &env);
3606 let (hold, series_empty) = FIGURE_STATE.with(|f| {
3607 let st = f.borrow();
3608 (st.hold, st.pending_series.is_empty())
3609 });
3610 assert!(!hold, "hold should be false after hold('off')");
3611 assert!(
3612 series_empty,
3613 "pending_series should be cleared after hold('off')"
3614 );
3615 FIGURE_STATE.with(|f| f.take());
3616 }
3617
3618 #[test]
3619 fn test_plot_accumulates_under_hold() {
3620 FIGURE_STATE.with(|f| f.take());
3621 let plugin = PlotPlugin;
3622 let env = Env::new();
3623 plugin
3624 .call("hold", &[Value::Str("on".into())], &env)
3625 .unwrap();
3626 let y1 = f64_vec(&[1.0, 2.0, 3.0]);
3627 let y2 = f64_vec(&[3.0, 2.0, 1.0]);
3628 plugin.call("plot", &[y1], &env).unwrap();
3629 plugin.call("plot", &[y2], &env).unwrap();
3630 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
3631 assert_eq!(count, 2, "two plot calls should accumulate 2 series");
3632 FIGURE_STATE.with(|f| f.take());
3633 }
3634
3635 #[test]
3636 fn test_subplot_then_plot_accumulates() {
3637 FIGURE_STATE.with(|f| f.take());
3638 let plugin = PlotPlugin;
3639 let env = Env::new();
3640 plugin
3641 .call(
3642 "subplot",
3643 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3644 &env,
3645 )
3646 .unwrap();
3647 let y = f64_vec(&[1.0, 2.0, 3.0]);
3648 plugin.call("plot", &[y], &env).unwrap();
3649 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
3650 assert_eq!(
3651 count, 1,
3652 "plot under subplot should accumulate into pending_series"
3653 );
3654 FIGURE_STATE.with(|f| f.take());
3655 }
3656
3657 #[test]
3658 fn test_second_subplot_commits_first_panel() {
3659 FIGURE_STATE.with(|f| f.take());
3660 let plugin = PlotPlugin;
3661 let env = Env::new();
3662 plugin
3663 .call(
3664 "subplot",
3665 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3666 &env,
3667 )
3668 .unwrap();
3669 plugin.call("plot", &[f64_vec(&[1.0, 2.0])], &env).unwrap();
3670 plugin
3672 .call(
3673 "subplot",
3674 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
3675 &env,
3676 )
3677 .unwrap();
3678 let (panels_len, pending_len) = FIGURE_STATE.with(|f| {
3679 let st = f.borrow();
3680 (st.panels.len(), st.pending_series.len())
3681 });
3682 assert_eq!(panels_len, 1, "panel 1 should be committed");
3683 assert_eq!(
3684 pending_len, 0,
3685 "pending_series should be empty after commit"
3686 );
3687 FIGURE_STATE.with(|f| f.take());
3688 }
3689
3690 #[test]
3691 fn test_subplot_invalid_index_errors() {
3692 FIGURE_STATE.with(|f| f.take());
3693 let plugin = PlotPlugin;
3694 let env = Env::new();
3695 let result = plugin.call(
3696 "subplot",
3697 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(5.0)],
3698 &env,
3699 );
3700 assert!(result.is_err(), "index 5 in a 2×2 grid should error");
3701 FIGURE_STATE.with(|f| f.take());
3702 }
3703
3704 #[test]
3705 fn test_savefig_with_no_panels_errors() {
3706 FIGURE_STATE.with(|f| f.take());
3707 let plugin = PlotPlugin;
3708 let env = Env::new();
3709 let result = plugin.call("savefig", &[Value::Str("out.svg".into())], &env);
3710 assert!(result.is_err(), "savefig with no panels should error");
3711 FIGURE_STATE.with(|f| f.take());
3712 }
3713
3714 #[test]
3717 fn test_quiver_mismatch_error() {
3718 FIGURE_STATE.with(|f| f.take());
3719 let plugin = PlotPlugin;
3720 let env = Env::new();
3721 let x = f64_vec(&[0.0, 1.0, 2.0]);
3722 let y = f64_vec(&[0.0, 1.0, 2.0]);
3723 let u = f64_vec(&[1.0, 0.0]);
3724 let v = f64_vec(&[0.0, 1.0, 0.0]);
3725 let result = plugin.call("quiver", &[x, y, u, v], &env);
3726 assert!(result.is_err(), "length mismatch should produce an error");
3727 let msg = result.unwrap_err();
3728 assert!(
3729 msg.contains("same length"),
3730 "error should mention 'same length': {msg}"
3731 );
3732 }
3733
3734 #[test]
3735 fn test_text_stores_annotation() {
3736 FIGURE_STATE.with(|f| f.take());
3737 let plugin = PlotPlugin;
3738 let env = Env::new();
3739 plugin
3740 .call(
3741 "text",
3742 &[
3743 Value::Scalar(0.0),
3744 Value::Scalar(1.0),
3745 Value::Str("label".into()),
3746 ],
3747 &env,
3748 )
3749 .unwrap();
3750 let ann = FIGURE_STATE.with(|f| f.borrow().annotations.clone());
3751 assert_eq!(ann.len(), 1, "one annotation should be stored");
3752 assert_eq!(ann[0], (0.0, 1.0, "label".to_string()));
3753 FIGURE_STATE.with(|f| f.take());
3754 }
3755
3756 #[test]
3757 #[cfg(feature = "plot-svg")]
3758 fn test_quiver_svg_creates_file() {
3759 FIGURE_STATE.with(|f| f.take());
3760 let plugin = PlotPlugin;
3761 let env = Env::new();
3762 let x = f64_vec(&[0.0, 1.0, 0.0, 1.0]);
3763 let y = f64_vec(&[0.0, 0.0, 1.0, 1.0]);
3764 let u = f64_vec(&[1.0, 0.0, -1.0, 0.0]);
3765 let v = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
3766 let path = ".debug/test_quiver.svg";
3767 std::fs::create_dir_all(".debug").ok();
3768 let result = plugin.call("quiver", &[x, y, u, v, Value::Str(path.into())], &env);
3769 assert!(result.is_ok(), "quiver SVG should succeed: {result:?}");
3770 let content = std::fs::read_to_string(path).unwrap();
3771 assert!(
3772 content.contains("<svg"),
3773 "output should be SVG: starts with {}",
3774 &content[..50.min(content.len())]
3775 );
3776 std::fs::remove_file(path).ok();
3777 }
3778
3779 #[test]
3780 #[cfg(feature = "plot-svg")]
3781 fn test_subplot_savefig_creates_svg() {
3782 FIGURE_STATE.with(|f| f.take());
3783 let plugin = PlotPlugin;
3784 let env = Env::new();
3785 let path = ".debug/test_subplot_grid.svg";
3786 std::fs::create_dir_all(".debug").ok();
3787 plugin
3788 .call(
3789 "subplot",
3790 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3791 &env,
3792 )
3793 .unwrap();
3794 plugin
3795 .call("plot", &[f64_vec(&[1.0, 2.0, 3.0])], &env)
3796 .unwrap();
3797 plugin
3798 .call(
3799 "subplot",
3800 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
3801 &env,
3802 )
3803 .unwrap();
3804 plugin
3805 .call("plot", &[f64_vec(&[3.0, 2.0, 1.0])], &env)
3806 .unwrap();
3807 plugin
3808 .call("savefig", &[Value::Str(path.into())], &env)
3809 .unwrap();
3810 let content = std::fs::read_to_string(path).unwrap();
3811 assert!(
3812 content.contains("<svg"),
3813 "savefig should produce an SVG file"
3814 );
3815 std::fs::remove_file(path).ok();
3816 }
3817
3818 #[cfg(feature = "plot-svg")]
3819 #[test]
3820 fn test_figure_size_applied_to_svg() {
3821 FIGURE_STATE.with(|f| f.take());
3822 let plugin = PlotPlugin;
3823 let env = Env::new();
3824 let path = ".debug/test_figure_size.svg";
3825 std::fs::create_dir_all(".debug").ok();
3826 plugin
3827 .call(
3828 "figure",
3829 &[Value::Scalar(1024.0), Value::Scalar(300.0)],
3830 &env,
3831 )
3832 .unwrap();
3833 plugin
3834 .call(
3835 "plot",
3836 &[
3837 f64_vec(&[1.0, 2.0, 3.0]),
3838 f64_vec(&[1.0, 4.0, 9.0]),
3839 Value::Str(path.into()),
3840 ],
3841 &env,
3842 )
3843 .unwrap();
3844 let content = std::fs::read_to_string(path).unwrap();
3845 assert!(
3846 content.contains("1024"),
3847 "SVG should contain requested width"
3848 );
3849 assert!(
3850 content.contains("300"),
3851 "SVG should contain requested height"
3852 );
3853 std::fs::remove_file(path).ok();
3854 }
3855
3856 #[test]
3859 #[cfg(feature = "plot-svg")]
3860 fn test_theme_dark_svg_contains_dark_bg() {
3861 let plugin = PlotPlugin;
3862 let env = Env::new();
3863 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3864
3865 let path = ".debug/test_theme_dark.svg";
3866 plugin
3867 .call("theme", &[Value::Str("dark".into())], &env)
3868 .unwrap();
3869 plugin
3870 .call(
3871 "plot",
3872 &[
3873 f64_vec(&[1.0, 2.0]),
3874 f64_vec(&[1.0, 2.0]),
3875 Value::Str(path.into()),
3876 ],
3877 &env,
3878 )
3879 .unwrap();
3880 let content = std::fs::read_to_string(path).unwrap();
3881 assert!(
3883 content.contains("1E1E2E") || content.contains("1e1e2e"),
3884 "SVG must contain the dark theme background colour"
3885 );
3886 std::fs::remove_file(path).ok();
3887 }
3888
3889 #[test]
3890 fn test_theme_light_is_default() {
3891 let light = style::Theme::light();
3892 let st = FigureState::default();
3894 let resolved = st.resolve_theme();
3895 assert_eq!(resolved.bg, light.bg);
3896 assert_eq!(resolved.text, light.text);
3897 }
3898
3899 #[test]
3900 fn test_theme_unknown_name_errors() {
3901 let plugin = PlotPlugin;
3902 let env = Env::new();
3903 let result = plugin.call("theme", &[Value::Str("rainbow".into())], &env);
3904 assert!(result.is_err());
3905 assert!(result.unwrap_err().contains("unknown theme"));
3906 }
3907
3908 #[test]
3909 #[cfg(feature = "plot-svg")]
3910 fn test_bgcolor_overrides_theme_bg() {
3911 let plugin = PlotPlugin;
3912 let env = Env::new();
3913 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3914
3915 let path = ".debug/test_bgcolor_override.svg";
3916 plugin
3917 .call("theme", &[Value::Str("dark".into())], &env)
3918 .unwrap();
3919 plugin
3921 .call("bgcolor", &[Value::Str("red".into())], &env)
3922 .unwrap();
3923 plugin
3924 .call(
3925 "plot",
3926 &[
3927 f64_vec(&[1.0, 2.0]),
3928 f64_vec(&[1.0, 2.0]),
3929 Value::Str(path.into()),
3930 ],
3931 &env,
3932 )
3933 .unwrap();
3934 let content = std::fs::read_to_string(path).unwrap();
3935 assert!(
3937 !content.contains("1E1E2E") && !content.contains("1e1e2e"),
3938 "Dark theme bg should not appear when bgcolor overrides it"
3939 );
3940 std::fs::remove_file(path).ok();
3941 }
3942
3943 #[test]
3944 fn test_bgcolor_hex_accepted() {
3945 let plugin = PlotPlugin;
3946 let env = Env::new();
3947 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3948 plugin
3949 .call("bgcolor", &[Value::Str("#AABBCC".into())], &env)
3950 .unwrap();
3951 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
3952 assert_eq!(bg, Some(style::StyleColor(0xAA, 0xBB, 0xCC)));
3953 }
3954
3955 #[test]
3956 fn test_bgcolor_rgb_matrix() {
3957 use ndarray::Array2;
3958 let plugin = PlotPlugin;
3959 let env = Env::new();
3960 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3961 let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![0.0_f64, 0.5, 1.0]).unwrap());
3963 plugin.call("bgcolor", &[m], &env).unwrap();
3964 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
3965 assert_eq!(bg, Some(style::StyleColor(0, 128, 255)));
3966 }
3967
3968 #[test]
3971 fn test_linewidth_named_arg_plot() {
3972 let plugin = PlotPlugin;
3973 let env = Env::new();
3974 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3975 plugin
3976 .call("hold", &[Value::Str("on".into())], &env)
3977 .unwrap();
3978 plugin
3979 .call(
3980 "plot",
3981 &[
3982 f64_vec(&[0.0, 1.0]),
3983 f64_vec(&[0.0, 1.0]),
3984 Value::Str("r--".into()),
3985 Value::Str("linewidth".into()),
3986 Value::Scalar(2.5),
3987 ],
3988 &env,
3989 )
3990 .unwrap();
3991 let lw = FIGURE_STATE.with(|f| {
3992 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
3993 sp.line_width
3994 } else {
3995 None
3996 }
3997 });
3998 assert_eq!(lw, Some(2.5_f32));
3999 }
4000
4001 #[test]
4002 fn test_markersize_named_arg_scatter() {
4003 let plugin = PlotPlugin;
4004 let env = Env::new();
4005 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4006 plugin
4007 .call("hold", &[Value::Str("on".into())], &env)
4008 .unwrap();
4009 plugin
4010 .call(
4011 "scatter",
4012 &[
4013 f64_vec(&[1.0, 2.0]),
4014 f64_vec(&[1.0, 2.0]),
4015 Value::Str("markersize".into()),
4016 Value::Scalar(7.0),
4017 ],
4018 &env,
4019 )
4020 .unwrap();
4021 let ms = FIGURE_STATE.with(|f| {
4022 if let Some(PendingSeries::Scatter(_, _, Some(sp))) = f.borrow().pending_series.first()
4023 {
4024 sp.marker_size
4025 } else {
4026 None
4027 }
4028 });
4029 assert_eq!(ms, Some(7_u32));
4030 }
4031
4032 #[test]
4033 fn test_linewidth_and_markersize_combined() {
4034 let plugin = PlotPlugin;
4035 let env = Env::new();
4036 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4037 plugin
4038 .call("hold", &[Value::Str("on".into())], &env)
4039 .unwrap();
4040 plugin
4041 .call(
4042 "plot",
4043 &[
4044 f64_vec(&[0.0, 1.0]),
4045 f64_vec(&[0.0, 1.0]),
4046 Value::Str("b.".into()),
4047 Value::Str("linewidth".into()),
4048 Value::Scalar(1.5),
4049 Value::Str("markersize".into()),
4050 Value::Scalar(8.0),
4051 ],
4052 &env,
4053 )
4054 .unwrap();
4055 let (lw, ms) = FIGURE_STATE.with(|f| {
4056 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
4057 (sp.line_width, sp.marker_size)
4058 } else {
4059 (None, None)
4060 }
4061 });
4062 assert_eq!(lw, Some(1.5_f32));
4063 assert_eq!(ms, Some(8_u32));
4064 }
4065
4066 #[test]
4067 fn test_fontsize_global_setter() {
4068 let plugin = PlotPlugin;
4069 let env = Env::new();
4070 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4071 plugin
4072 .call("fontsize", &[Value::Scalar(18.0)], &env)
4073 .unwrap();
4074 let fs = FIGURE_STATE.with(|f| f.borrow().font_size);
4075 assert_eq!(fs, Some(18_u32));
4076 }
4077
4078 #[test]
4079 fn test_linewidth_global_setter() {
4080 let plugin = PlotPlugin;
4081 let env = Env::new();
4082 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4083 plugin
4084 .call("linewidth", &[Value::Scalar(3.0)], &env)
4085 .unwrap();
4086 let lw = FIGURE_STATE.with(|f| f.borrow().line_width);
4087 assert_eq!(lw, Some(3.0_f32));
4088 }
4089
4090 #[test]
4091 fn test_markersize_global_setter() {
4092 let plugin = PlotPlugin;
4093 let env = Env::new();
4094 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4095 plugin
4096 .call("markersize", &[Value::Scalar(5.0)], &env)
4097 .unwrap();
4098 let ms = FIGURE_STATE.with(|f| f.borrow().marker_size);
4099 assert_eq!(ms, Some(5_u32));
4100 }
4101
4102 #[test]
4105 fn test_gridcolor_named_color() {
4106 let plugin = PlotPlugin;
4107 let env = Env::new();
4108 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4109 plugin
4110 .call("gridcolor", &[Value::Str("red".into())], &env)
4111 .unwrap();
4112 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
4113 assert_eq!(gc, Some(StyleColor(255, 0, 0)));
4114 }
4115
4116 #[test]
4117 fn test_gridcolor_rgb_matrix() {
4118 let plugin = PlotPlugin;
4119 let env = Env::new();
4120 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4121 use ccalc_engine::env::Value;
4122 use ndarray::arr2;
4123 let m = Value::Matrix(arr2(&[[0.0_f64, 1.0, 0.0]]));
4124 plugin.call("gridcolor", &[m], &env).unwrap();
4125 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
4126 assert_eq!(gc, Some(StyleColor(0, 255, 0)));
4127 }
4128
4129 #[test]
4130 fn test_gridwidth_global_setter() {
4131 let plugin = PlotPlugin;
4132 let env = Env::new();
4133 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4134 plugin
4135 .call("gridwidth", &[Value::Scalar(2.0)], &env)
4136 .unwrap();
4137 let gw = FIGURE_STATE.with(|f| f.borrow().grid_width);
4138 assert_eq!(gw, Some(2.0_f32));
4139 }
4140
4141 #[test]
4144 fn test_axis_equal_sets_state() {
4145 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4146 let plugin = PlotPlugin;
4147 let env = Env::new();
4148 plugin
4149 .call("axis", &[Value::Str("equal".into())], &env)
4150 .unwrap();
4151 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4152 assert_eq!(mode, Some(style::AxisMode::Equal));
4153 FIGURE_STATE.with(|f| f.take());
4154 }
4155
4156 #[test]
4157 fn test_axis_tight_sets_state() {
4158 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4159 let plugin = PlotPlugin;
4160 let env = Env::new();
4161 plugin
4162 .call("axis", &[Value::Str("tight".into())], &env)
4163 .unwrap();
4164 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4165 assert_eq!(mode, Some(style::AxisMode::Tight));
4166 FIGURE_STATE.with(|f| f.take());
4167 }
4168
4169 #[test]
4170 fn test_axis_off_sets_state() {
4171 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4172 let plugin = PlotPlugin;
4173 let env = Env::new();
4174 plugin
4175 .call("axis", &[Value::Str("off".into())], &env)
4176 .unwrap();
4177 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4178 assert_eq!(mode, Some(style::AxisMode::Off));
4179 FIGURE_STATE.with(|f| f.take());
4180 }
4181
4182 #[test]
4183 fn test_axis_on_clears_mode() {
4184 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4185 let plugin = PlotPlugin;
4186 let env = Env::new();
4187 plugin
4188 .call("axis", &[Value::Str("equal".into())], &env)
4189 .unwrap();
4190 plugin
4191 .call("axis", &[Value::Str("on".into())], &env)
4192 .unwrap();
4193 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4194 assert_eq!(mode, None, "axis('on') should clear the axis mode");
4195 FIGURE_STATE.with(|f| f.take());
4196 }
4197
4198 #[test]
4199 fn test_axis_invalid_arg_errors() {
4200 let plugin = PlotPlugin;
4201 let env = Env::new();
4202 let result = plugin.call("axis", &[Value::Str("square".into())], &env);
4203 assert!(result.is_err());
4204 let msg = result.unwrap_err();
4205 assert!(
4206 msg.contains("expected"),
4207 "error should describe valid options: {msg}"
4208 );
4209 }
4210
4211 #[test]
4212 fn test_axis_mode_carried_into_panel() {
4213 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4214 let plugin = PlotPlugin;
4215 let env = Env::new();
4216 plugin
4217 .call("axis", &[Value::Str("tight".into())], &env)
4218 .unwrap();
4219 plugin
4220 .call("hold", &[Value::Str("on".into())], &env)
4221 .unwrap();
4222 plugin
4223 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
4224 .unwrap();
4225 plugin
4227 .call(
4228 "subplot",
4229 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
4230 &env,
4231 )
4232 .unwrap();
4233 let mode = FIGURE_STATE.with(|f| f.borrow().panels.first().and_then(|p| p.axis_mode));
4234 assert_eq!(
4235 mode,
4236 Some(style::AxisMode::Tight),
4237 "axis_mode should be carried into the committed panel"
4238 );
4239 FIGURE_STATE.with(|f| f.take());
4240 }
4241
4242 #[test]
4243 #[cfg(feature = "plot-svg")]
4244 fn test_axis_off_svg_no_error() {
4245 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4246 let plugin = PlotPlugin;
4247 let env = Env::new();
4248 plugin
4249 .call("axis", &[Value::Str("off".into())], &env)
4250 .unwrap();
4251 let tmp = std::env::temp_dir().join("axis_off_30_6d.svg");
4252 let path = tmp.to_string_lossy().to_string();
4253 let x = f64_vec(&[1.0, 2.0, 3.0]);
4254 let y = f64_vec(&[1.0, 4.0, 9.0]);
4255 let result = plugin.call("plot", &[x, y, Value::Str(path.clone())], &env);
4256 assert!(
4257 result.is_ok(),
4258 "axis('off') + plot to SVG should succeed: {result:?}"
4259 );
4260 let content = std::fs::read_to_string(&path).unwrap_or_default();
4261 assert!(content.contains("<svg"), "output should contain <svg");
4262 let _ = std::fs::remove_file(&path);
4263 FIGURE_STATE.with(|f| f.take());
4264 }
4265
4266 #[test]
4267 fn test_gridcolor_carried_into_panel() {
4268 let plugin = PlotPlugin;
4269 let env = Env::new();
4270 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4271 plugin
4272 .call("gridcolor", &[Value::Str("blue".into())], &env)
4273 .unwrap();
4274 plugin
4275 .call("gridwidth", &[Value::Scalar(3.0)], &env)
4276 .unwrap();
4277 plugin
4278 .call("hold", &[Value::Str("on".into())], &env)
4279 .unwrap();
4280 plugin
4281 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
4282 .unwrap();
4283 plugin
4285 .call(
4286 "subplot",
4287 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
4288 &env,
4289 )
4290 .unwrap();
4291 let (gc, gw) = FIGURE_STATE.with(|f| {
4292 f.borrow()
4293 .panels
4294 .first()
4295 .map(|p| (p.grid_color, p.grid_width))
4296 .unwrap_or((None, None))
4297 });
4298 assert_eq!(gc, Some(StyleColor(0, 0, 255)));
4299 assert_eq!(gw, Some(3.0_f32));
4300 }
4301}