1pub mod colormap;
24pub mod dispatch;
25pub mod proj3d;
26pub mod style;
27
28#[cfg(feature = "plot")]
29mod ascii;
30
31#[cfg(feature = "plot-svg")]
32mod file;
33
34mod contour;
35mod surface;
36
37use std::cell::RefCell;
38
39use ccalc_engine::env::{Env, Value};
40use ccalc_engine::plugin::Plugin;
41
42use colormap::ColormapSpec;
43use dispatch::{
44 extract_file_arg, extract_flat, extract_matrix, extract_style_and_file_arg,
45 extract_style_and_file_arg_min, extract_vector,
46};
47use style::{AxisMode, StyleColor, StyleSpec, Theme, YAxis};
48
49#[derive(Clone)]
53pub enum PendingSeries {
54 Line(Vec<f64>, Vec<f64>, Option<StyleSpec>),
56 Scatter(Vec<f64>, Vec<f64>, Option<StyleSpec>),
58 Bar(Vec<f64>, Vec<f64>, Option<StyleSpec>),
60 Stem(Vec<f64>, Vec<f64>, Option<StyleSpec>),
62 Hist {
64 counts: Vec<usize>,
65 edges: Vec<f64>,
66 style: Option<StyleSpec>,
67 },
68 Fill(Vec<f64>, Vec<f64>, Option<StyleSpec>),
70 Area(Vec<f64>, Vec<f64>, Option<StyleSpec>),
72 Quiver(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Option<StyleSpec>),
74 ErrorBar {
79 x: Vec<f64>,
81 y: Vec<f64>,
83 e_low: Vec<f64>,
85 e_high: Vec<f64>,
87 style: Option<StyleSpec>,
89 },
90 ColorScatter {
92 x: Vec<f64>,
94 y: Vec<f64>,
96 sz: Vec<f64>,
98 c: Vec<f64>,
100 c_min: f64,
102 c_max: f64,
104 },
105 Pie {
107 values: Vec<f64>,
109 labels: Vec<String>,
111 explode: Vec<f64>,
113 },
114}
115
116#[derive(Clone, Default)]
118pub struct Panel {
119 pub layout: Option<(u32, u32, u32)>,
121 pub xlabel: Option<String>,
123 pub ylabel: Option<String>,
125 pub title: Option<String>,
127 pub legend: Vec<String>,
129 pub xlim: Option<(f64, f64)>,
131 pub ylim: Option<(f64, f64)>,
133 pub grid: bool,
135 pub series: Vec<PendingSeries>,
137 pub annotations: Vec<(f64, f64, String)>,
139 pub font_size: Option<u32>,
141 pub line_width: Option<f32>,
143 pub marker_size: Option<u32>,
145 pub grid_color: Option<StyleColor>,
147 pub grid_width: Option<f32>,
149 pub axis_mode: Option<AxisMode>,
151 pub colormap: Option<ColormapSpec>,
153 pub right_series: Vec<PendingSeries>,
156 pub right_ylim: Option<(f64, f64)>,
158 pub right_ylabel: Option<String>,
160}
161
162#[derive(Default, Clone)]
170pub struct FigureState {
171 pub xlabel: Option<String>,
173 pub ylabel: Option<String>,
175 pub zlabel: Option<String>,
177 pub title: Option<String>,
179 pub legend: Vec<String>,
181 pub xlim: Option<(f64, f64)>,
183 pub ylim: Option<(f64, f64)>,
185 pub zlim: Option<(f64, f64)>,
187 pub grid: bool,
189 pub colormap: Option<ColormapSpec>,
191 pub colorbar: bool,
193
194 pub subplot: Option<(u32, u32, u32)>,
197 pub hold: bool,
199 pub pending_series: Vec<PendingSeries>,
201 pub panels: Vec<Panel>,
203 pub annotations: Vec<(f64, f64, String)>,
205
206 pub theme: Option<Theme>,
209 pub bg_color: Option<StyleColor>,
211
212 pub font_size: Option<u32>,
215 pub line_width: Option<f32>,
217 pub marker_size: Option<u32>,
219
220 pub grid_color: Option<StyleColor>,
223 pub grid_width: Option<f32>,
225
226 pub axis_mode: Option<AxisMode>,
229
230 pub figure_size: Option<(u32, u32)>,
236
237 pub active_yaxis: YAxis,
240 pub right_pending_series: Vec<PendingSeries>,
242 pub right_ylim: Option<(f64, f64)>,
244 pub right_ylabel: Option<String>,
246
247 pub clabel: bool,
250}
251
252impl FigureState {
253 pub fn canvas_size(&self) -> (u32, u32) {
255 self.figure_size.unwrap_or((800, 600))
256 }
257
258 pub fn resolve_theme(&self) -> style::Theme {
260 self.theme.clone().unwrap_or_else(style::Theme::light)
261 }
262
263 pub fn effective_bg_rgb(&self) -> (u8, u8, u8) {
267 let c = self.bg_color.unwrap_or_else(|| self.resolve_theme().bg);
268 (c.0, c.1, c.2)
269 }
270
271 pub fn push_series(&mut self, series: PendingSeries) {
273 if self.active_yaxis == YAxis::Right {
274 self.right_pending_series.push(series);
275 } else {
276 self.pending_series.push(series);
277 }
278 }
279}
280
281pub(crate) fn term_cols() -> usize {
285 std::env::var("COLUMNS")
286 .ok()
287 .and_then(|s| s.parse().ok())
288 .unwrap_or(80)
289}
290
291pub(crate) fn term_rows() -> usize {
293 std::env::var("LINES")
294 .ok()
295 .and_then(|s| s.parse().ok())
296 .unwrap_or(24)
297}
298
299thread_local! {
300 static FIGURE_STATE: RefCell<FigureState> =
301 RefCell::new(FigureState::default());
302}
303
304const EXPORTED: &[&str] = &[
307 "plot",
308 "scatter",
309 "bar",
310 "stem",
311 "hist",
312 "stairs",
313 "loglog",
314 "semilogx",
315 "semilogy",
316 "plot3",
317 "scatter3",
318 "xlabel",
319 "ylabel",
320 "zlabel",
321 "title",
322 "legend",
323 "xlim",
324 "ylim",
325 "zlim",
326 "grid",
327 "colormap",
328 "colorbar",
329 "imagesc",
330 "surf",
331 "mesh",
332 "contour",
333 "contourf",
334 "subplot",
335 "hold",
336 "savefig",
337 "fill",
338 "area",
339 "polar",
340 "quiver",
341 "text",
342 "figure",
343 "theme",
344 "bgcolor",
345 "fontsize",
346 "linewidth",
347 "markersize",
348 "gridcolor",
349 "gridwidth",
350 "axis",
351 "line",
353 "patch",
354 "rectangle",
355 "errorbar",
357 "pie",
359 "yyaxis",
361 "clabel",
363 "image",
365 "imshow",
366];
367
368fn is_accumulating(st: &FigureState) -> bool {
372 st.subplot.is_some() || st.hold
373}
374
375fn commit_current_panel(st: &mut FigureState) {
380 if !st.pending_series.is_empty() || !st.right_pending_series.is_empty() {
381 let panel = Panel {
382 layout: st.subplot,
383 xlabel: st.xlabel.take(),
384 ylabel: st.ylabel.take(),
385 title: st.title.take(),
386 legend: std::mem::take(&mut st.legend),
387 xlim: st.xlim.take(),
388 ylim: st.ylim.take(),
389 grid: std::mem::replace(&mut st.grid, false),
390 series: std::mem::take(&mut st.pending_series),
391 annotations: std::mem::take(&mut st.annotations),
392 font_size: st.font_size,
393 line_width: st.line_width,
394 marker_size: st.marker_size,
395 grid_color: st.grid_color,
396 grid_width: st.grid_width,
397 axis_mode: st.axis_mode,
398 colormap: st.colormap.clone(),
399 right_series: std::mem::take(&mut st.right_pending_series),
400 right_ylim: st.right_ylim.take(),
401 right_ylabel: st.right_ylabel.take(),
402 };
403 st.panels.push(panel);
404 }
405}
406
407pub struct PlotPlugin;
411
412impl Plugin for PlotPlugin {
413 fn name(&self) -> &str {
414 "plot"
415 }
416
417 fn exported_names(&self) -> &[&str] {
418 EXPORTED
419 }
420
421 fn call(&self, name: &str, args: &[Value], _env: &Env) -> Result<Value, String> {
422 match name {
423 "xlabel" | "ylabel" | "title" => {
425 let s = require_string(name, args)?;
426 FIGURE_STATE.with(|f| {
427 let mut st = f.borrow_mut();
428 match name {
429 "xlabel" => st.xlabel = Some(s),
430 "ylabel" => {
431 if st.active_yaxis == YAxis::Right {
432 st.right_ylabel = Some(s);
433 } else {
434 st.ylabel = Some(s);
435 }
436 }
437 "title" => st.title = Some(s),
438 _ => unreachable!(),
439 }
440 });
441 Ok(Value::Void)
442 }
443
444 "zlabel" => {
445 let s = require_string(name, args)?;
446 FIGURE_STATE.with(|f| f.borrow_mut().zlabel = Some(s));
447 Ok(Value::Void)
448 }
449
450 "legend" => {
452 let labels = require_string_list(args)?;
453 FIGURE_STATE.with(|f| f.borrow_mut().legend = labels);
454 Ok(Value::Void)
455 }
456
457 "grid" => {
459 match args {
460 [] => FIGURE_STATE.with(|f| {
461 let mut st = f.borrow_mut();
462 st.grid = !st.grid;
463 }),
464 [Value::Str(s) | Value::StringObj(s)] => {
465 let enable = match s.as_str() {
466 "on" => true,
467 "off" => false,
468 other => {
469 return Err(format!("grid: expected 'on' or 'off', got '{other}'"));
470 }
471 };
472 FIGURE_STATE.with(|f| f.borrow_mut().grid = enable);
473 }
474 _ => return Err("grid: expected no arguments, 'on', or 'off'".into()),
475 }
476 Ok(Value::Void)
477 }
478
479 "xlim" | "ylim" | "zlim" => {
481 let (lo, hi) = extract_lim(name, args)?;
482 FIGURE_STATE.with(|f| {
483 let mut st = f.borrow_mut();
484 match name {
485 "xlim" => st.xlim = Some((lo, hi)),
486 "ylim" => {
487 if st.active_yaxis == YAxis::Right {
488 st.right_ylim = Some((lo, hi));
489 } else {
490 st.ylim = Some((lo, hi));
491 }
492 }
493 "zlim" => st.zlim = Some((lo, hi)),
494 _ => unreachable!(),
495 }
496 });
497 Ok(Value::Void)
498 }
499
500 "plot" | "line" => {
503 let (data_args, style, path) = extract_style_and_file_arg(args)?;
504 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
505 let (x, ys) = extract_xy_multi(name, &data_args)?;
506 FIGURE_STATE.with(|f| {
507 let mut st = f.borrow_mut();
508 for y in ys {
509 st.push_series(PendingSeries::Line(x.clone(), y, style.clone()));
510 }
511 });
512 Ok(Value::Void)
513 } else {
514 let state = FIGURE_STATE.with(|f| f.take());
515 let (x, ys) = extract_xy_multi(name, &data_args)?;
516 if ys.len() == 1 {
517 render_line_xy(name, &x, &ys[0], path.as_deref(), state)
518 } else {
519 render_multi_series(&x, &ys, path.as_deref(), state)
520 }
521 }
522 }
523
524 "scatter" => {
526 let (data_peek, path_peek) = extract_file_arg(args);
529 if data_peek.len() == 4
530 && is_numeric_value(&data_peek[2])
531 && is_numeric_value(&data_peek[3])
532 {
533 let x = extract_flat(&data_peek[0])
534 .map_err(|_| "scatter: x must be a numeric array".to_string())?;
535 let y = extract_flat(&data_peek[1])
536 .map_err(|_| "scatter: y must be a numeric array".to_string())?;
537 let sz_raw = extract_flat(&data_peek[2])
538 .map_err(|_| "scatter: sz must be a numeric scalar or array".to_string())?;
539 let c = extract_flat(&data_peek[3])
540 .map_err(|_| "scatter: c must be a numeric array".to_string())?;
541 if x.len() != y.len() || x.len() != c.len() {
542 return Err(format!(
543 "scatter: x, y, c must have the same length ({}, {}, {})",
544 x.len(),
545 y.len(),
546 c.len()
547 ));
548 }
549 let sz = if sz_raw.len() == 1 {
550 vec![sz_raw[0]; x.len()]
551 } else if sz_raw.len() == x.len() {
552 sz_raw
553 } else {
554 return Err(format!(
555 "scatter: sz must be scalar or same length as x ({} vs {})",
556 sz_raw.len(),
557 x.len()
558 ));
559 };
560 let (c_min, c_max) = colormap::data_range(&c);
561 let series = PendingSeries::ColorScatter {
562 x,
563 y,
564 sz,
565 c,
566 c_min,
567 c_max,
568 };
569 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
570 FIGURE_STATE.with(|f| f.borrow_mut().push_series(series));
571 Ok(Value::Void)
572 } else {
573 let state = FIGURE_STATE.with(|f| f.take());
574 if let PendingSeries::ColorScatter {
575 x,
576 y,
577 sz,
578 c,
579 c_min,
580 c_max,
581 } = series
582 {
583 render_color_scatter(
584 &x,
585 &y,
586 &sz,
587 &c,
588 c_min,
589 c_max,
590 path_peek.as_deref(),
591 state,
592 )
593 } else {
594 unreachable!()
595 }
596 }
597 } else {
598 let (data_args, style, path) = extract_style_and_file_arg(args)?;
600 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
601 let (x, y) = extract_xy("scatter", &data_args)?;
602 FIGURE_STATE.with(|f| {
603 f.borrow_mut()
604 .push_series(PendingSeries::Scatter(x, y, style));
605 });
606 Ok(Value::Void)
607 } else {
608 let state = FIGURE_STATE.with(|f| f.take());
609 render_ascii_or_file("scatter", &data_args, path.as_deref(), state)
610 }
611 }
612 }
613
614 "bar" | "stem" | "stairs" => {
615 let (data_args, style, path) = extract_style_and_file_arg(args)?;
616 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
617 let (x, y) = extract_xy(name, &data_args)?;
618 let (x, y) = if name == "stairs" {
619 make_step_data(&x, &y)
620 } else {
621 (x, y)
622 };
623 let series = match name {
624 "bar" | "stairs" => PendingSeries::Bar(x, y, style),
625 "stem" => PendingSeries::Stem(x, y, style),
626 _ => unreachable!(),
627 };
628 FIGURE_STATE.with(|f| f.borrow_mut().push_series(series));
629 Ok(Value::Void)
630 } else {
631 let state = FIGURE_STATE.with(|f| f.take());
632 match name {
633 "bar" => {
634 let (x, y) = extract_xy(name, &data_args)?;
635 render_bar_xy(&x, &y, path.as_deref(), style, state)
636 }
637 "stem" => {
638 let (x, y) = extract_xy(name, &data_args)?;
639 render_stem_xy(&x, &y, path.as_deref(), style, state)
640 }
641 _ => render_ascii_or_file(name, &data_args, path.as_deref(), state),
642 }
643 }
644 }
645
646 "errorbar" => {
648 let (data_args, style, path) = extract_style_and_file_arg_min(args, 3)?;
653 let (x, y, e_low, e_high) = match data_args.as_slice() {
654 [xv, yv, ev] => {
655 let x = extract_vector(xv)
656 .map_err(|_| "errorbar: x must be a numeric vector".to_string())?;
657 let y = extract_vector(yv)
658 .map_err(|_| "errorbar: y must be a numeric vector".to_string())?;
659 let e = extract_vector(ev)
660 .map_err(|_| "errorbar: e must be a numeric vector".to_string())?;
661 if x.len() != y.len() || x.len() != e.len() {
662 return Err(format!(
663 "errorbar: x, y, e must have the same length \
664 ({}, {}, {})",
665 x.len(),
666 y.len(),
667 e.len()
668 ));
669 }
670 let e2 = e.clone();
671 (x, y, e, e2)
672 }
673 [xv, yv, elv, ehv] => {
674 let x = extract_vector(xv)
675 .map_err(|_| "errorbar: x must be a numeric vector".to_string())?;
676 let y = extract_vector(yv)
677 .map_err(|_| "errorbar: y must be a numeric vector".to_string())?;
678 let el = extract_vector(elv)
679 .map_err(|_| "errorbar: e_low must be a numeric vector".to_string())?;
680 let eh = extract_vector(ehv)
681 .map_err(|_| "errorbar: e_high must be a numeric vector".to_string())?;
682 if x.len() != y.len() || x.len() != el.len() || x.len() != eh.len() {
683 return Err(format!(
684 "errorbar: x, y, e_low, e_high must have the same length \
685 ({}, {}, {}, {})",
686 x.len(),
687 y.len(),
688 el.len(),
689 eh.len()
690 ));
691 }
692 (x, y, el, eh)
693 }
694 other => {
695 return Err(format!(
696 "errorbar: expected 3 or 4 data arguments, got {}",
697 other.len()
698 ));
699 }
700 };
701 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
702 FIGURE_STATE.with(|f| {
703 f.borrow_mut().push_series(PendingSeries::ErrorBar {
704 x,
705 y,
706 e_low,
707 e_high,
708 style,
709 });
710 });
711 Ok(Value::Void)
712 } else {
713 let state = FIGURE_STATE.with(|f| f.take());
714 render_errorbar(&x, &y, &e_low, &e_high, path.as_deref(), style, state)
715 }
716 }
717
718 "pie" => {
720 let mut rest = args.to_vec();
734
735 let path = if let Some(last) = rest.last()
737 && let Value::Str(s) | Value::StringObj(s) = last
738 && (s == "ascii" || s.ends_with(".svg") || s.ends_with(".png"))
739 {
740 let p = s.clone();
741 rest.pop();
742 Some(p)
743 } else {
744 None
745 };
746
747 if rest.is_empty() {
748 return Err("pie: expected at least one argument (values vector)".into());
749 }
750
751 let values = extract_vector(&rest[0])
753 .map_err(|_| "pie: first argument must be a numeric vector".to_string())?;
754 if values.is_empty() {
755 return Err("pie: values vector must not be empty".into());
756 }
757 if values.iter().any(|&v| v < 0.0) {
758 return Err("pie: all values must be non-negative".into());
759 }
760 let total: f64 = values.iter().sum();
761 if total <= 0.0 {
762 return Err("pie: sum of values must be positive".into());
763 }
764
765 let mut labels: Vec<String> = Vec::new();
767 let mut explode: Vec<f64> = Vec::new();
768 for arg in &rest[1..] {
769 match arg {
770 Value::Cell(cells) => {
771 labels = cells
772 .iter()
773 .map(|v| match v {
774 Value::Str(s) | Value::StringObj(s) => s.clone(),
775 _ => String::new(),
776 })
777 .collect();
778 if labels.len() != values.len() {
779 return Err(format!(
780 "pie: labels cell array length ({}) must match \
781 values length ({})",
782 labels.len(),
783 values.len()
784 ));
785 }
786 }
787 _ => {
788 let ex = extract_vector(arg).map_err(|_| {
790 "pie: unrecognised argument — expected labels cell \
791 array or explode vector"
792 .to_string()
793 })?;
794 if ex.len() != values.len() {
795 return Err(format!(
796 "pie: explode vector length ({}) must match \
797 values length ({})",
798 ex.len(),
799 values.len()
800 ));
801 }
802 explode = ex;
803 }
804 }
805 }
806
807 if labels.is_empty() {
809 labels = vec![String::new(); values.len()];
810 }
811 if explode.is_empty() {
812 explode = vec![0.0_f64; values.len()];
813 }
814
815 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
816 FIGURE_STATE.with(|f| {
817 f.borrow_mut().push_series(PendingSeries::Pie {
818 values,
819 labels,
820 explode,
821 });
822 });
823 Ok(Value::Void)
824 } else {
825 let state = FIGURE_STATE.with(|f| f.take());
826 render_pie(&values, &labels, &explode, path.as_deref(), state)
827 }
828 }
829
830 "hist" => {
832 let (data_args, style, path) = extract_style_and_file_arg(args)?;
833 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
834 let (counts, edges) = parse_and_compute_hist(&data_args)?;
835 FIGURE_STATE.with(|f| {
836 f.borrow_mut().push_series(PendingSeries::Hist {
837 counts,
838 edges,
839 style,
840 });
841 });
842 Ok(Value::Void)
843 } else {
844 let state = FIGURE_STATE.with(|f| f.take());
845 let (counts, edges) = parse_and_compute_hist(&data_args)?;
846 match path.as_deref() {
847 None | Some("ascii") => {
848 render_hist_ascii(&counts, &edges, &state);
849 Ok(Value::Void)
850 }
851 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
852 render_hist_file(&counts, &edges, p, style, state)
853 }
854 Some(p) => Err(format!("hist: unknown output target '{p}'")),
855 }
856 }
857 }
858
859 "loglog" | "semilogx" | "semilogy" => {
861 let (data_args, path) = extract_file_arg(args);
862 let mut state = FIGURE_STATE.with(|f| f.take());
863 let (x_raw, y_raw) = extract_xy(name, &data_args)?;
864
865 let log_x = name == "loglog" || name == "semilogx";
866 let log_y = name == "loglog" || name == "semilogy";
867
868 let (x, y): (Vec<f64>, Vec<f64>) = x_raw
870 .iter()
871 .zip(y_raw.iter())
872 .filter_map(|(&xi, &yi)| {
873 let lx = if log_x { xi.log10() } else { xi };
874 let ly = if log_y { yi.log10() } else { yi };
875 if lx.is_finite() && ly.is_finite() {
876 Some((lx, ly))
877 } else {
878 None
879 }
880 })
881 .unzip();
882
883 if x.is_empty() {
884 return Err(format!(
885 "{name}: no finite values after log₁₀ transform \
886 (check for non-positive values)"
887 ));
888 }
889
890 if log_x {
892 let lbl = state.xlabel.take().unwrap_or_default();
893 state.xlabel = Some(if lbl.is_empty() {
894 "log\u{2081}\u{2080}(x)".into()
895 } else {
896 format!("{lbl} [log\u{2081}\u{2080}]")
897 });
898 }
899 if log_y {
900 let lbl = state.ylabel.take().unwrap_or_default();
901 state.ylabel = Some(if lbl.is_empty() {
902 "log\u{2081}\u{2080}(y)".into()
903 } else {
904 format!("{lbl} [log\u{2081}\u{2080}]")
905 });
906 }
907
908 render_line_xy(name, &x, &y, path.as_deref(), state)
909 }
910
911 "plot3" | "scatter3" => {
913 let (data_args, path) = extract_file_arg(args);
914 let state = FIGURE_STATE.with(|f| f.take());
915 render_3d(name, &data_args, path.as_deref(), state)
916 }
917
918 "figure" => {
920 if args.len() != 2 {
921 return Err(format!(
922 "figure: expected 2 arguments (width, height), got {}",
923 args.len()
924 ));
925 }
926 let w = match &args[0] {
927 Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
928 _ => return Err("figure: width must be a positive integer (1–16384)".into()),
929 };
930 let h = match &args[1] {
931 Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
932 _ => return Err("figure: height must be a positive integer (1–16384)".into()),
933 };
934 FIGURE_STATE.with(|f| f.borrow_mut().figure_size = Some((w, h)));
935 Ok(Value::Void)
936 }
937
938 "colormap" => {
940 if args.is_empty() {
941 return Err("colormap: one argument required".into());
942 }
943 let spec = match &args[0] {
944 Value::Str(name) | Value::StringObj(name) => ColormapSpec::Named(name.clone()),
945 Value::Matrix(m) => {
946 if m.ncols() != 3 {
947 return Err("colormap: matrix argument must be N×3".into());
948 }
949 let lut: Vec<(u8, u8, u8)> = (0..m.nrows())
950 .map(|r| {
951 let clamp = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
952 (clamp(m[[r, 0]]), clamp(m[[r, 1]]), clamp(m[[r, 2]]))
953 })
954 .collect();
955 ColormapSpec::Custom(lut)
956 }
957 _ => {
958 return Err("colormap: argument must be a name string or N×3 matrix".into());
959 }
960 };
961 colormap::validate_colormap_spec(&spec)?;
962 FIGURE_STATE.with(|f| f.borrow_mut().colormap = Some(spec));
963 Ok(Value::Void)
964 }
965
966 "colorbar" => {
967 FIGURE_STATE.with(|f| f.borrow_mut().colorbar = true);
968 Ok(Value::Void)
969 }
970
971 "theme" => {
973 if args.is_empty() {
974 return Err("theme: one argument required (e.g. 'dark' or 'light')".into());
975 }
976 let name = match &args[0] {
977 Value::Str(s) | Value::StringObj(s) => s.clone(),
978 _ => return Err("theme: argument must be a theme name string".into()),
979 };
980 let t = Theme::from_name(&name)?;
981 FIGURE_STATE.with(|f| f.borrow_mut().theme = Some(t));
982 Ok(Value::Void)
983 }
984
985 "bgcolor" => {
986 if args.is_empty() {
987 return Err("bgcolor: one argument required".into());
988 }
989 let sc = match &args[0] {
990 Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
991 .ok_or_else(|| format!("bgcolor: unrecognised color '{s}'"))?,
992 Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
993 let all_unit = (0..3).all(|c| {
994 let v = m[[0, c]];
995 (0.0..=1.0).contains(&v)
996 });
997 if !all_unit {
998 return Err("bgcolor: RGB matrix values must be in [0, 1]".into());
999 }
1000 let clamp = |v: f64| (v * 255.0).round() as u8;
1001 StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
1002 }
1003 _ => {
1004 return Err(
1005 "bgcolor: argument must be a color name string or 1×3 RGB matrix"
1006 .into(),
1007 );
1008 }
1009 };
1010 FIGURE_STATE.with(|f| f.borrow_mut().bg_color = Some(sc));
1011 Ok(Value::Void)
1012 }
1013
1014 "fontsize" => {
1016 let val = match args {
1017 [Value::Scalar(f)] if *f >= 1.0 => (*f as u32).max(8),
1018 _ => return Err("fontsize: expected a positive number".into()),
1019 };
1020 FIGURE_STATE.with(|f| f.borrow_mut().font_size = Some(val));
1021 Ok(Value::Void)
1022 }
1023
1024 "linewidth" => {
1025 let val = match args {
1026 [Value::Scalar(f)] if *f > 0.0 => *f as f32,
1027 _ => return Err("linewidth: expected a positive number".into()),
1028 };
1029 FIGURE_STATE.with(|f| f.borrow_mut().line_width = Some(val));
1030 Ok(Value::Void)
1031 }
1032
1033 "markersize" => {
1034 let val = match args {
1035 [Value::Scalar(f)] if *f >= 1.0 => *f as u32,
1036 _ => return Err("markersize: expected a positive integer".into()),
1037 };
1038 FIGURE_STATE.with(|f| f.borrow_mut().marker_size = Some(val));
1039 Ok(Value::Void)
1040 }
1041
1042 "gridcolor" => {
1044 if args.is_empty() {
1045 return Err("gridcolor: one argument required".into());
1046 }
1047 let sc = match &args[0] {
1048 Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
1049 .ok_or_else(|| format!("gridcolor: unrecognised color '{s}'"))?,
1050 Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
1051 let all_unit = (0..3).all(|c| {
1052 let v = m[[0, c]];
1053 (0.0..=1.0).contains(&v)
1054 });
1055 if !all_unit {
1056 return Err("gridcolor: RGB matrix values must be in [0, 1]".into());
1057 }
1058 let clamp = |v: f64| (v * 255.0).round() as u8;
1059 StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
1060 }
1061 _ => {
1062 return Err(
1063 "gridcolor: argument must be a color name string or 1×3 RGB matrix"
1064 .into(),
1065 );
1066 }
1067 };
1068 FIGURE_STATE.with(|f| f.borrow_mut().grid_color = Some(sc));
1069 Ok(Value::Void)
1070 }
1071
1072 "gridwidth" => {
1073 let val = match args {
1074 [Value::Scalar(f)] if *f > 0.0 => *f as f32,
1075 _ => return Err("gridwidth: expected a positive number".into()),
1076 };
1077 FIGURE_STATE.with(|f| f.borrow_mut().grid_width = Some(val));
1078 Ok(Value::Void)
1079 }
1080
1081 "axis" => {
1083 let s = require_string("axis", args)?;
1084 let mode = match s.as_str() {
1085 "equal" => Some(AxisMode::Equal),
1086 "tight" => Some(AxisMode::Tight),
1087 "off" => Some(AxisMode::Off),
1088 "on" => None,
1089 other => {
1090 return Err(format!(
1091 "axis: expected 'equal', 'tight', 'off', or 'on', got '{other}'"
1092 ));
1093 }
1094 };
1095 FIGURE_STATE.with(|f| f.borrow_mut().axis_mode = mode);
1096 Ok(Value::Void)
1097 }
1098
1099 "yyaxis" => {
1101 let s = require_string("yyaxis", args)?;
1102 match s.as_str() {
1103 "left" | "right" => {
1104 let is_right = s == "right";
1105
1106 let panel_to_flush = if !is_right {
1110 FIGURE_STATE.with(|f| {
1111 let mut st = f.borrow_mut();
1112 if !st.right_pending_series.is_empty() && st.subplot.is_none() {
1113 Some(Panel {
1114 layout: None,
1115 xlabel: st.xlabel.take(),
1116 ylabel: st.ylabel.take(),
1117 title: st.title.take(),
1118 legend: std::mem::take(&mut st.legend),
1119 xlim: st.xlim.take(),
1120 ylim: st.ylim.take(),
1121 grid: std::mem::replace(&mut st.grid, false),
1122 series: std::mem::take(&mut st.pending_series),
1123 annotations: std::mem::take(&mut st.annotations),
1124 font_size: st.font_size,
1125 line_width: st.line_width,
1126 marker_size: st.marker_size,
1127 grid_color: st.grid_color,
1128 grid_width: st.grid_width,
1129 axis_mode: st.axis_mode,
1130 colormap: st.colormap.clone(),
1131 right_series: std::mem::take(&mut st.right_pending_series),
1132 right_ylim: st.right_ylim.take(),
1133 right_ylabel: st.right_ylabel.take(),
1134 })
1135 } else {
1136 None
1137 }
1138 })
1139 } else {
1140 None
1141 };
1142
1143 if let Some(panel) = panel_to_flush {
1144 render_panel_ascii(&panel)?;
1145 }
1146
1147 FIGURE_STATE.with(|f| {
1148 let mut st = f.borrow_mut();
1149 st.active_yaxis = if is_right { YAxis::Right } else { YAxis::Left };
1150 st.hold = true;
1151 });
1152 }
1153 other => {
1154 return Err(format!("yyaxis: expected 'left' or 'right', got '{other}'"));
1155 }
1156 }
1157 Ok(Value::Void)
1158 }
1159
1160 "clabel" => {
1162 FIGURE_STATE.with(|f| f.borrow_mut().clabel = true);
1163 Ok(Value::Void)
1164 }
1165
1166 "imagesc" | "image" => {
1168 if args.is_empty() {
1169 return Err(format!("{name}: at least one argument required"));
1170 }
1171 let (z, nrows, ncols) = extract_matrix(&args[0])?;
1172 let state = FIGURE_STATE.with(|f| f.take());
1173 let path: Option<String> = match args.len() {
1177 1 => None,
1178 2 => match &args[1] {
1179 Value::Str(s) | Value::StringObj(s) => Some(s.clone()),
1180 _ => {
1181 return Err(format!(
1182 "{name}: second argument must be a file path string"
1183 ));
1184 }
1185 },
1186 n => return Err(format!("{name}: expected 1 or 2 arguments, got {n}")),
1187 };
1188 render_imagesc(&z, nrows, ncols, path.as_deref(), state)
1189 }
1190
1191 "imshow" => {
1199 if args.is_empty() {
1200 return Err("imshow: at least one argument required".into());
1201 }
1202 let (data_args, path) = extract_file_arg(args);
1205 match data_args.as_slice() {
1206 [zv] => {
1207 let (z, nrows, ncols) = extract_matrix(zv)
1208 .map_err(|_| "imshow: Z must be a numeric matrix".to_string())?;
1209 let state = FIGURE_STATE.with(|f| f.take());
1210 render_imshow_gray(&z, nrows, ncols, path.as_deref(), state)
1211 }
1212 [rv, gv, bv]
1213 if is_numeric_value(rv) && is_numeric_value(gv) && is_numeric_value(bv) =>
1214 {
1215 let (r, r_rows, r_cols) = extract_matrix(rv)
1216 .map_err(|_| "imshow: R must be a numeric matrix".to_string())?;
1217 let (g, g_rows, g_cols) = extract_matrix(gv)
1218 .map_err(|_| "imshow: G must be a numeric matrix".to_string())?;
1219 let (b, b_rows, b_cols) = extract_matrix(bv)
1220 .map_err(|_| "imshow: B must be a numeric matrix".to_string())?;
1221 if r_rows != g_rows
1222 || r_rows != b_rows
1223 || r_cols != g_cols
1224 || r_cols != b_cols
1225 {
1226 return Err(format!(
1227 "imshow: R ({r_rows}×{r_cols}), G ({g_rows}×{g_cols}), \
1228 B ({b_rows}×{b_cols}) must have the same dimensions"
1229 ));
1230 }
1231 let state = FIGURE_STATE.with(|f| f.take());
1232 render_imshow_rgb(&r, &g, &b, r_rows, r_cols, path.as_deref(), state)
1233 }
1234 other => Err(format!(
1235 "imshow: expected imshow(Z), imshow(Z,path), imshow(R,G,B), \
1236 or imshow(R,G,B,path) — got {} data arguments",
1237 other.len()
1238 )),
1239 }
1240 }
1241
1242 "surf" | "mesh" => {
1244 let (data_args, path) = extract_file_arg(args);
1245 if data_args.len() < 3 {
1246 return Err(format!(
1247 "{name}: requires (X, Y, Z) matrix arguments, got {}",
1248 data_args.len()
1249 ));
1250 }
1251 let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
1252 .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
1253 let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
1254 .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
1255 let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
1256 .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
1257 if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
1258 return Err(format!(
1259 "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
1260 Z ({z_rows}×{z_cols}) must have the same dimensions"
1261 ));
1262 }
1263 let state = FIGURE_STATE.with(|f| f.take());
1264 let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
1266 let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
1267 render_surface(
1268 name,
1269 &x_vals,
1270 &y_vals,
1271 &z_data,
1272 z_rows,
1273 z_cols,
1274 path.as_deref(),
1275 state,
1276 )
1277 }
1278
1279 "contour" | "contourf" => {
1281 let (data_args, path) = extract_file_arg(args);
1282 if data_args.len() < 3 {
1283 return Err(format!(
1284 "{name}: requires (X, Y, Z) matrix arguments, got {}",
1285 data_args.len()
1286 ));
1287 }
1288 let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
1289 .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
1290 let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
1291 .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
1292 let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
1293 .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
1294 if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
1295 return Err(format!(
1296 "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
1297 Z ({z_rows}×{z_cols}) must have the same dimensions"
1298 ));
1299 }
1300 let n_levels: usize = if data_args.len() >= 4 {
1302 match &data_args[3] {
1303 Value::Scalar(v) if *v >= 1.0 => *v as usize,
1304 _ => return Err(format!("{name}: level count must be a positive integer")),
1305 }
1306 } else {
1307 10
1308 };
1309 let state = FIGURE_STATE.with(|f| f.take());
1310 let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
1312 let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
1313 let filled = name == "contourf";
1314 render_contour(
1315 filled,
1316 &x_vals,
1317 &y_vals,
1318 &z_data,
1319 z_rows,
1320 z_cols,
1321 n_levels,
1322 path.as_deref(),
1323 state,
1324 )
1325 }
1326
1327 "subplot" => match args {
1329 [Value::Scalar(m), Value::Scalar(n), Value::Scalar(k)] => {
1330 let m = *m as u32;
1331 let n = *n as u32;
1332 let k = *k as u32;
1333 if m == 0 || n == 0 || k == 0 || k > m * n {
1334 return Err(format!(
1335 "subplot: invalid layout ({m},{n},{k}) — \
1336 index must be in 1..={}",
1337 m * n
1338 ));
1339 }
1340 FIGURE_STATE.with(|f| {
1341 let mut st = f.borrow_mut();
1342 commit_current_panel(&mut st);
1343 st.subplot = Some((m, n, k));
1344 });
1345 Ok(Value::Void)
1346 }
1347 _ => Err("subplot: expected 3 numeric arguments (rows, cols, index)".into()),
1348 },
1349
1350 "hold" => {
1352 let turn_on = match args {
1353 [] => !FIGURE_STATE.with(|f| f.borrow().hold),
1354 [Value::Str(s) | Value::StringObj(s)] => match s.as_str() {
1355 "on" => true,
1356 "off" => false,
1357 other => {
1358 return Err(format!(
1359 "hold: expected 'on', 'off', or no argument, got '{other}'"
1360 ));
1361 }
1362 },
1363 _ => return Err("hold: expected 'on', 'off', or no argument".into()),
1364 };
1365
1366 if !turn_on {
1367 let panel_opt = FIGURE_STATE.with(|f| {
1368 let mut st = f.borrow_mut();
1369 st.hold = false;
1370 let has_series =
1372 !st.pending_series.is_empty() || !st.right_pending_series.is_empty();
1373 if st.subplot.is_none() && has_series {
1374 Some(Panel {
1375 layout: None,
1376 xlabel: st.xlabel.take(),
1377 ylabel: st.ylabel.take(),
1378 title: st.title.take(),
1379 legend: std::mem::take(&mut st.legend),
1380 xlim: st.xlim.take(),
1381 ylim: st.ylim.take(),
1382 grid: std::mem::replace(&mut st.grid, false),
1383 series: std::mem::take(&mut st.pending_series),
1384 annotations: std::mem::take(&mut st.annotations),
1385 font_size: st.font_size,
1386 line_width: st.line_width,
1387 marker_size: st.marker_size,
1388 grid_color: st.grid_color,
1389 grid_width: st.grid_width,
1390 axis_mode: st.axis_mode,
1391 colormap: st.colormap.clone(),
1392 right_series: std::mem::take(&mut st.right_pending_series),
1393 right_ylim: st.right_ylim.take(),
1394 right_ylabel: st.right_ylabel.take(),
1395 })
1396 } else {
1397 None
1398 }
1399 });
1400 if let Some(panel) = panel_opt {
1401 return render_panel_ascii(&panel);
1402 }
1403 } else {
1404 FIGURE_STATE.with(|f| f.borrow_mut().hold = true);
1405 }
1406 Ok(Value::Void)
1407 }
1408
1409 "savefig" => {
1411 let path = require_string("savefig", args)?;
1412 if !path.ends_with(".svg") && !path.ends_with(".png") {
1413 return Err("savefig: path must end with '.svg' or '.png'".into());
1414 }
1415 let (panels, canvas, theme, bg_override) = FIGURE_STATE.with(|f| {
1416 let mut st = f.borrow_mut();
1417 commit_current_panel(&mut st);
1418 st.hold = false;
1419 st.subplot = None;
1420 let canvas = st.canvas_size();
1421 let theme = st.theme.clone().unwrap_or_else(style::Theme::light);
1422 let bg_override = st.bg_color;
1423 (std::mem::take(&mut st.panels), canvas, theme, bg_override)
1424 });
1425 if panels.is_empty() {
1426 return Err("savefig: no panels to render".into());
1427 }
1428 render_panels_file(&panels, &path, canvas, &theme, bg_override)
1429 }
1430
1431 "fill" | "patch" => {
1434 let (data_args, style, path) = extract_style_and_file_arg(args)?;
1435 let (x, y) = extract_xy(name, &data_args)?;
1436 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1437 FIGURE_STATE.with(|f| {
1438 f.borrow_mut().push_series(PendingSeries::Fill(x, y, style));
1439 });
1440 Ok(Value::Void)
1441 } else {
1442 let state = FIGURE_STATE.with(|f| f.take());
1443 render_fill_xy(&x, &y, path.as_deref(), style, state)
1444 }
1445 }
1446
1447 "rectangle" => {
1449 let (data_args, style, path) = extract_style_and_file_arg(args)?;
1450 let (rx, ry, rw, rh) = match data_args.as_slice() {
1451 [vec_arg] => {
1452 let v = extract_vector(vec_arg).map_err(|_| {
1453 "rectangle: single argument must be a numeric [x y w h] vector"
1454 .to_string()
1455 })?;
1456 if v.len() != 4 {
1457 return Err(format!(
1458 "rectangle: [x y w h] vector must have 4 elements, got {}",
1459 v.len()
1460 ));
1461 }
1462 (v[0], v[1], v[2], v[3])
1463 }
1464 [xv, yv, wv, hv] => {
1465 let to_scalar = |v: &Value, field: &'static str| match v {
1466 Value::Scalar(f) => Ok(*f),
1467 _ => Err(format!("rectangle: {field} must be a scalar")),
1468 };
1469 (
1470 to_scalar(xv, "x")?,
1471 to_scalar(yv, "y")?,
1472 to_scalar(wv, "w")?,
1473 to_scalar(hv, "h")?,
1474 )
1475 }
1476 other => {
1477 return Err(format!(
1478 "rectangle: expected 1 (vector) or 4 (x,y,w,h) data arguments, got {}",
1479 other.len()
1480 ));
1481 }
1482 };
1483 let x_pts = vec![rx, rx + rw, rx + rw, rx];
1485 let y_pts = vec![ry, ry, ry + rh, ry + rh];
1486 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1487 FIGURE_STATE.with(|f| {
1488 f.borrow_mut()
1489 .push_series(PendingSeries::Fill(x_pts, y_pts, style));
1490 });
1491 Ok(Value::Void)
1492 } else {
1493 let state = FIGURE_STATE.with(|f| f.take());
1494 render_fill_xy(&x_pts, &y_pts, path.as_deref(), style, state)
1495 }
1496 }
1497
1498 "area" => {
1500 let (data_args, style, path) = extract_style_and_file_arg(args)?;
1501 let (x, y) = extract_xy("area", &data_args)?;
1502 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1503 FIGURE_STATE.with(|f| {
1504 f.borrow_mut().push_series(PendingSeries::Area(x, y, style));
1505 });
1506 Ok(Value::Void)
1507 } else {
1508 let state = FIGURE_STATE.with(|f| f.take());
1509 render_area_xy(&x, &y, path.as_deref(), style, state)
1510 }
1511 }
1512
1513 "polar" => {
1515 let (data_args, _style, path) = extract_style_and_file_arg(args)?;
1516 let (theta, r) = extract_xy("polar", &data_args)?;
1517 let (px, py): (Vec<f64>, Vec<f64>) = theta
1518 .iter()
1519 .zip(r.iter())
1520 .map(|(&t, &rv)| (rv * t.cos(), rv * t.sin()))
1521 .unzip();
1522 let state = FIGURE_STATE.with(|f| f.take());
1523 render_line_xy("polar", &px, &py, path.as_deref(), state)
1524 }
1525
1526 "quiver" => {
1528 let (data_args, style, path) = extract_style_and_file_arg_min(args, 4)?;
1529 if data_args.len() != 4 {
1530 return Err(format!(
1531 "quiver: expected 4 data arguments (x, y, u, v), got {}",
1532 data_args.len()
1533 ));
1534 }
1535 let x = extract_flat(&data_args[0])
1536 .map_err(|_| "quiver: x must be a numeric array".to_string())?;
1537 let y = extract_flat(&data_args[1])
1538 .map_err(|_| "quiver: y must be a numeric array".to_string())?;
1539 let u = extract_flat(&data_args[2])
1540 .map_err(|_| "quiver: u must be a numeric array".to_string())?;
1541 let v = extract_flat(&data_args[3])
1542 .map_err(|_| "quiver: v must be a numeric array".to_string())?;
1543 if x.len() != y.len() || x.len() != u.len() || x.len() != v.len() {
1544 return Err(format!(
1545 "quiver: x, y, u, v must have the same length \
1546 ({}, {}, {}, {})",
1547 x.len(),
1548 y.len(),
1549 u.len(),
1550 v.len()
1551 ));
1552 }
1553 if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1554 FIGURE_STATE.with(|f| {
1555 f.borrow_mut()
1556 .push_series(PendingSeries::Quiver(x, y, u, v, style));
1557 });
1558 Ok(Value::Void)
1559 } else {
1560 let state = FIGURE_STATE.with(|f| f.take());
1561 render_quiver(&x, &y, &u, &v, path.as_deref(), style, state)
1562 }
1563 }
1564
1565 "text" => {
1567 let (data_args, _path) = extract_file_arg(args);
1568 match data_args.as_slice() {
1569 [xval, yval, Value::Str(s) | Value::StringObj(s)] => {
1570 let x = match xval {
1571 Value::Scalar(f) => *f,
1572 _ => return Err("text: x must be a scalar".into()),
1573 };
1574 let y = match yval {
1575 Value::Scalar(f) => *f,
1576 _ => return Err("text: y must be a scalar".into()),
1577 };
1578 let label = s.clone();
1579 FIGURE_STATE.with(|f| {
1580 f.borrow_mut().annotations.push((x, y, label));
1581 });
1582 Ok(Value::Void)
1583 }
1584 _ => Err("text: expected text(x, y, 'string')".into()),
1585 }
1586 }
1587
1588 _ => Err(format!("plot plugin: unknown function '{name}'")),
1589 }
1590 }
1591}
1592
1593fn render_ascii_or_file(
1596 name: &str,
1597 data_args: &[Value],
1598 path: Option<&str>,
1599 state: FigureState,
1600) -> Result<Value, String> {
1601 match path {
1602 None | Some("ascii") => render_ascii(name, data_args, state),
1603 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1604 render_file(name, data_args, p, state)
1605 }
1606 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1607 }
1608}
1609
1610#[cfg(feature = "plot-svg")]
1611fn render_file(
1612 name: &str,
1613 data_args: &[Value],
1614 path: &str,
1615 state: FigureState,
1616) -> Result<Value, String> {
1617 let (x, y) = extract_xy(name, data_args)?;
1618 let (x, y) = if name == "stairs" {
1619 make_step_data(&x, &y)
1620 } else {
1621 (x, y)
1622 };
1623 let result = match name {
1624 "plot" | "stairs" => file::render_line(&x, &y, path, state),
1625 "scatter" => file::render_scatter(&x, &y, path, state),
1626 _ => unreachable!(),
1627 };
1628 result.map_err(|e| format!("{name}: {e}"))?;
1629 Ok(Value::Void)
1630}
1631
1632#[cfg(not(feature = "plot-svg"))]
1633fn render_file(
1634 name: &str,
1635 _data_args: &[Value],
1636 _path: &str,
1637 _state: FigureState,
1638) -> Result<Value, String> {
1639 Err(format!(
1640 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
1641 rebuild with: cargo build --features plot-svg"
1642 ))
1643}
1644
1645#[cfg(feature = "plot")]
1646fn render_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
1647 let (x, y) = extract_xy(name, data_args)?;
1648 let (x, y) = if name == "stairs" {
1649 make_step_data(&x, &y)
1650 } else {
1651 (x, y)
1652 };
1653 match name {
1654 "plot" | "stairs" => ascii::render_line(&x, &y, state),
1655 "scatter" => ascii::render_scatter(&x, &y, state),
1656 "bar" => ascii::render_bar(&x, &y, state),
1657 "stem" => ascii::render_stem(&x, &y, state),
1658 _ => unreachable!(),
1659 }
1660 Ok(Value::Void)
1661}
1662
1663#[cfg(not(feature = "plot"))]
1664fn render_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
1665 Err(format!(
1666 "{name}: ASCII rendering requires the 'plot' feature flag. \
1667 Rebuild with: cargo build --features plot"
1668 ))
1669}
1670
1671#[allow(clippy::too_many_arguments)]
1674fn render_contour(
1675 filled: bool,
1676 x_vals: &[f64],
1677 y_vals: &[f64],
1678 z: &[f64],
1679 nrows: usize,
1680 ncols: usize,
1681 n_levels: usize,
1682 path: Option<&str>,
1683 state: FigureState,
1684) -> Result<Value, String> {
1685 match path {
1686 None | Some("ascii") => render_contour_ascii_tier(z, nrows, ncols, n_levels, state),
1687 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1688 render_contour_file_tier(filled, x_vals, y_vals, z, nrows, ncols, n_levels, p, state)
1689 }
1690 Some(p) => Err(format!("contour: unknown output target '{p}'")),
1691 }
1692}
1693
1694#[cfg(feature = "plot")]
1695fn render_contour_ascii_tier(
1696 z: &[f64],
1697 nrows: usize,
1698 ncols: usize,
1699 n_levels: usize,
1700 state: FigureState,
1701) -> Result<Value, String> {
1702 let (z_min, z_max) = colormap::data_range(z);
1703 let levels = contour::compute_levels(z_min, z_max, n_levels);
1704 contour::render_contour_ascii(z, nrows, ncols, &levels, &state);
1705 Ok(Value::Void)
1706}
1707
1708#[cfg(not(feature = "plot"))]
1709fn render_contour_ascii_tier(
1710 _z: &[f64],
1711 _nrows: usize,
1712 _ncols: usize,
1713 _n_levels: usize,
1714 _state: FigureState,
1715) -> Result<Value, String> {
1716 Err(
1717 "contour: ASCII rendering requires the 'plot' feature flag — \
1718 rebuild with: cargo build --features plot"
1719 .into(),
1720 )
1721}
1722
1723#[cfg(feature = "plot-svg")]
1724#[allow(clippy::too_many_arguments)]
1725fn render_contour_file_tier(
1726 filled: bool,
1727 x_vals: &[f64],
1728 y_vals: &[f64],
1729 z: &[f64],
1730 nrows: usize,
1731 ncols: usize,
1732 n_levels: usize,
1733 path: &str,
1734 state: FigureState,
1735) -> Result<Value, String> {
1736 let (z_min, z_max) = colormap::data_range(z);
1737 let levels = contour::compute_levels(z_min, z_max, n_levels);
1738 let result = if filled {
1739 contour::render_contourf_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1740 } else {
1741 contour::render_contour_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1742 };
1743 result.map_err(|e| e.to_string())?;
1744 Ok(Value::Void)
1745}
1746
1747#[cfg(not(feature = "plot-svg"))]
1748#[allow(clippy::too_many_arguments)]
1749fn render_contour_file_tier(
1750 _filled: bool,
1751 _x_vals: &[f64],
1752 _y_vals: &[f64],
1753 _z: &[f64],
1754 _nrows: usize,
1755 _ncols: usize,
1756 _n_levels: usize,
1757 _path: &str,
1758 _state: FigureState,
1759) -> Result<Value, String> {
1760 Err("contour: SVG/PNG export requires the 'plot-svg' feature — \
1761 rebuild with: cargo build --features plot-svg"
1762 .into())
1763}
1764
1765#[allow(clippy::too_many_arguments)]
1768fn render_surface(
1769 name: &str,
1770 x_vals: &[f64],
1771 y_vals: &[f64],
1772 z: &[f64],
1773 nrows: usize,
1774 ncols: usize,
1775 path: Option<&str>,
1776 state: FigureState,
1777) -> Result<Value, String> {
1778 let wireframe = name == "mesh";
1779 match path {
1780 None | Some("ascii") => render_surface_ascii_tier(x_vals, z, nrows, ncols, state),
1781 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1782 render_surface_file_tier(wireframe, x_vals, y_vals, z, nrows, ncols, p, state)
1783 }
1784 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1785 }
1786}
1787
1788#[cfg(feature = "plot")]
1789fn render_surface_ascii_tier(
1790 x_vals: &[f64],
1791 z: &[f64],
1792 nrows: usize,
1793 ncols: usize,
1794 state: FigureState,
1795) -> Result<Value, String> {
1796 surface::render_surf_ascii(x_vals, z, nrows, ncols, &state);
1797 Ok(Value::Void)
1798}
1799
1800#[cfg(not(feature = "plot"))]
1801fn render_surface_ascii_tier(
1802 _x_vals: &[f64],
1803 _z: &[f64],
1804 _nrows: usize,
1805 _ncols: usize,
1806 _state: FigureState,
1807) -> Result<Value, String> {
1808 Err(
1809 "surf/mesh: ASCII rendering requires the 'plot' feature flag — \
1810 rebuild with: cargo build --features plot"
1811 .into(),
1812 )
1813}
1814
1815#[cfg(feature = "plot-svg")]
1816#[allow(clippy::too_many_arguments)]
1817fn render_surface_file_tier(
1818 wireframe: bool,
1819 x_vals: &[f64],
1820 y_vals: &[f64],
1821 z: &[f64],
1822 nrows: usize,
1823 ncols: usize,
1824 path: &str,
1825 state: FigureState,
1826) -> Result<Value, String> {
1827 let result = if wireframe {
1828 surface::render_mesh_file(x_vals, y_vals, z, nrows, ncols, path, state)
1829 } else {
1830 surface::render_surf_file(x_vals, y_vals, z, nrows, ncols, path, state)
1831 };
1832 result.map_err(|e| e.to_string())?;
1833 Ok(Value::Void)
1834}
1835
1836#[cfg(not(feature = "plot-svg"))]
1837#[allow(clippy::too_many_arguments)]
1838fn render_surface_file_tier(
1839 _wireframe: bool,
1840 _x_vals: &[f64],
1841 _y_vals: &[f64],
1842 _z: &[f64],
1843 _nrows: usize,
1844 _ncols: usize,
1845 _path: &str,
1846 _state: FigureState,
1847) -> Result<Value, String> {
1848 Err(
1849 "surf/mesh: SVG/PNG export requires the 'plot-svg' feature — \
1850 rebuild with: cargo build --features plot-svg"
1851 .into(),
1852 )
1853}
1854
1855fn render_imagesc(
1858 z: &[f64],
1859 nrows: usize,
1860 ncols: usize,
1861 path: Option<&str>,
1862 state: FigureState,
1863) -> Result<Value, String> {
1864 match path {
1865 None | Some("ascii") => render_imagesc_ascii_tier(z, nrows, ncols, state),
1866 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1867 render_imagesc_file_tier(z, nrows, ncols, p, state)
1868 }
1869 Some(p) => Err(format!("imagesc: unknown output target '{p}'")),
1870 }
1871}
1872
1873fn render_imshow_gray(
1876 z: &[f64],
1877 nrows: usize,
1878 ncols: usize,
1879 path: Option<&str>,
1880 state: FigureState,
1881) -> Result<Value, String> {
1882 match path {
1883 None | Some("ascii") => render_imshow_gray_ascii_tier(z, nrows, ncols, state),
1884 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1885 render_imshow_gray_file_tier(z, nrows, ncols, p, state)
1886 }
1887 Some(p) => Err(format!("imshow: unknown output target '{p}'")),
1888 }
1889}
1890
1891fn render_imshow_rgb(
1892 r: &[f64],
1893 g: &[f64],
1894 b: &[f64],
1895 nrows: usize,
1896 ncols: usize,
1897 path: Option<&str>,
1898 state: FigureState,
1899) -> Result<Value, String> {
1900 match path {
1901 None | Some("ascii") => render_imshow_rgb_ascii_tier(r, g, b, nrows, ncols, state),
1902 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1903 render_imshow_rgb_file_tier(r, g, b, nrows, ncols, p, state)
1904 }
1905 Some(p) => Err(format!("imshow: unknown output target '{p}'")),
1906 }
1907}
1908
1909#[cfg(feature = "plot")]
1910fn render_imshow_gray_ascii_tier(
1911 z: &[f64],
1912 nrows: usize,
1913 ncols: usize,
1914 state: FigureState,
1915) -> Result<Value, String> {
1916 colormap::render_imshow_gray_ascii(z, nrows, ncols, &state);
1917 Ok(Value::Void)
1918}
1919
1920#[cfg(not(feature = "plot"))]
1921fn render_imshow_gray_ascii_tier(
1922 _z: &[f64],
1923 _nrows: usize,
1924 _ncols: usize,
1925 _state: FigureState,
1926) -> Result<Value, String> {
1927 Err(
1928 "imshow: ASCII rendering requires the 'plot' feature flag — \
1929 rebuild with: cargo build --features plot"
1930 .into(),
1931 )
1932}
1933
1934#[cfg(feature = "plot")]
1935fn render_imshow_rgb_ascii_tier(
1936 r: &[f64],
1937 g: &[f64],
1938 b: &[f64],
1939 nrows: usize,
1940 ncols: usize,
1941 state: FigureState,
1942) -> Result<Value, String> {
1943 colormap::render_imshow_rgb_ascii(r, g, b, nrows, ncols, &state);
1944 Ok(Value::Void)
1945}
1946
1947#[cfg(not(feature = "plot"))]
1948fn render_imshow_rgb_ascii_tier(
1949 _r: &[f64],
1950 _g: &[f64],
1951 _b: &[f64],
1952 _nrows: usize,
1953 _ncols: usize,
1954 _state: FigureState,
1955) -> Result<Value, String> {
1956 Err(
1957 "imshow: ASCII rendering requires the 'plot' feature flag — \
1958 rebuild with: cargo build --features plot"
1959 .into(),
1960 )
1961}
1962
1963#[cfg(feature = "plot-svg")]
1964fn render_imshow_gray_file_tier(
1965 z: &[f64],
1966 nrows: usize,
1967 ncols: usize,
1968 path: &str,
1969 state: FigureState,
1970) -> Result<Value, String> {
1971 colormap::render_imshow_gray_file(z, nrows, ncols, path, state)
1972 .map_err(|e| format!("imshow: {e}"))?;
1973 Ok(Value::Void)
1974}
1975
1976#[cfg(not(feature = "plot-svg"))]
1977fn render_imshow_gray_file_tier(
1978 _z: &[f64],
1979 _nrows: usize,
1980 _ncols: usize,
1981 _path: &str,
1982 _state: FigureState,
1983) -> Result<Value, String> {
1984 Err("imshow: SVG/PNG export requires the 'plot-svg' feature — \
1985 rebuild with: cargo build --features plot-svg"
1986 .into())
1987}
1988
1989#[cfg(feature = "plot-svg")]
1990#[allow(clippy::too_many_arguments)]
1991fn render_imshow_rgb_file_tier(
1992 r: &[f64],
1993 g: &[f64],
1994 b: &[f64],
1995 nrows: usize,
1996 ncols: usize,
1997 path: &str,
1998 state: FigureState,
1999) -> Result<Value, String> {
2000 colormap::render_imshow_rgb_file(r, g, b, nrows, ncols, path, state)
2001 .map_err(|e| format!("imshow: {e}"))?;
2002 Ok(Value::Void)
2003}
2004
2005#[cfg(not(feature = "plot-svg"))]
2006#[allow(clippy::too_many_arguments)]
2007fn render_imshow_rgb_file_tier(
2008 _r: &[f64],
2009 _g: &[f64],
2010 _b: &[f64],
2011 _nrows: usize,
2012 _ncols: usize,
2013 _path: &str,
2014 _state: FigureState,
2015) -> Result<Value, String> {
2016 Err("imshow: SVG/PNG export requires the 'plot-svg' feature — \
2017 rebuild with: cargo build --features plot-svg"
2018 .into())
2019}
2020
2021#[cfg(feature = "plot")]
2022fn render_imagesc_ascii_tier(
2023 z: &[f64],
2024 nrows: usize,
2025 ncols: usize,
2026 state: FigureState,
2027) -> Result<Value, String> {
2028 colormap::render_imagesc_ascii(z, nrows, ncols, &state);
2029 Ok(Value::Void)
2030}
2031
2032#[cfg(not(feature = "plot"))]
2033fn render_imagesc_ascii_tier(
2034 _z: &[f64],
2035 _nrows: usize,
2036 _ncols: usize,
2037 _state: FigureState,
2038) -> Result<Value, String> {
2039 Err(
2040 "imagesc: ASCII rendering requires the 'plot' feature flag — \
2041 rebuild with: cargo build --features plot"
2042 .into(),
2043 )
2044}
2045
2046#[cfg(feature = "plot-svg")]
2047fn render_imagesc_file_tier(
2048 z: &[f64],
2049 nrows: usize,
2050 ncols: usize,
2051 path: &str,
2052 state: FigureState,
2053) -> Result<Value, String> {
2054 colormap::render_imagesc_file(z, nrows, ncols, path, state)
2055 .map_err(|e| format!("imagesc: {e}"))?;
2056 Ok(Value::Void)
2057}
2058
2059#[cfg(not(feature = "plot-svg"))]
2060fn render_imagesc_file_tier(
2061 _z: &[f64],
2062 _nrows: usize,
2063 _ncols: usize,
2064 _path: &str,
2065 _state: FigureState,
2066) -> Result<Value, String> {
2067 Err("imagesc: SVG/PNG export requires the 'plot-svg' feature — \
2068 rebuild with: cargo build --features plot-svg"
2069 .into())
2070}
2071
2072fn require_string(name: &str, args: &[Value]) -> Result<String, String> {
2075 match args {
2076 [Value::Str(s)] | [Value::StringObj(s)] => Ok(s.clone()),
2077 [_] => Err(format!("{name}: argument must be a string")),
2078 _ => Err(format!("{name}: expected exactly one string argument")),
2079 }
2080}
2081
2082fn require_string_list(args: &[Value]) -> Result<Vec<String>, String> {
2083 if args.is_empty() {
2084 return Err("legend: at least one string argument required".into());
2085 }
2086 args.iter()
2087 .map(|a| match a {
2088 Value::Str(s) | Value::StringObj(s) => Ok(s.clone()),
2089 _ => Err("legend: all arguments must be strings".into()),
2090 })
2091 .collect()
2092}
2093
2094fn extract_lim(name: &str, args: &[Value]) -> Result<(f64, f64), String> {
2095 let v = match args {
2096 [val] => extract_vector(val)
2097 .map_err(|_| format!("{name}: expected a 2-element vector [lo hi]"))?,
2098 _ => return Err(format!("{name}: expected exactly one argument [lo hi]")),
2099 };
2100 if v.len() != 2 {
2101 return Err(format!(
2102 "{name}: vector must have exactly 2 elements, got {}",
2103 v.len()
2104 ));
2105 }
2106 Ok((v[0], v[1]))
2107}
2108
2109fn make_step_data(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
2113 let n = x.len();
2114 if n == 0 {
2115 return (vec![], vec![]);
2116 }
2117 let mut sx = Vec::with_capacity(2 * n - 1);
2118 let mut sy = Vec::with_capacity(2 * n - 1);
2119 for i in 0..n - 1 {
2120 sx.push(x[i]);
2121 sy.push(y[i]);
2122 sx.push(x[i + 1]);
2124 sy.push(y[i]);
2125 }
2126 sx.push(*x.last().unwrap());
2127 sy.push(*y.last().unwrap());
2128 (sx, sy)
2129}
2130
2131fn sturges_bins(n: usize) -> usize {
2135 (n as f64).sqrt().round() as usize
2136}
2137
2138fn parse_and_compute_hist(args: &[Value]) -> Result<(Vec<usize>, Vec<f64>), String> {
2144 match args.len() {
2145 0 => Err("hist: at least one argument required".into()),
2146 1 => {
2147 let vals = extract_vector(&args[0])
2148 .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
2149 let n = sturges_bins(vals.len()).max(1);
2150 Ok(compute_histogram_uniform(&vals, n))
2151 }
2152 2 => {
2153 let vals = extract_vector(&args[0])
2154 .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
2155 match &args[1] {
2156 Value::Scalar(v) => {
2157 let n = *v as usize;
2158 if n == 0 {
2159 return Err("hist: bin count must be positive".into());
2160 }
2161 Ok(compute_histogram_uniform(&vals, n))
2162 }
2163 Value::Matrix(_) | Value::ComplexMatrix(_) => {
2164 let edges = extract_vector(&args[1])
2165 .map_err(|_| "hist: edge vector must be numeric".to_string())?;
2166 if edges.len() < 2 {
2167 return Err("hist: edge vector must have at least 2 elements".into());
2168 }
2169 Ok(compute_histogram_edges(&vals, &edges))
2170 }
2171 _ => Err("hist: second argument must be a bin count or an edge vector".into()),
2172 }
2173 }
2174 _ => Err("hist: too many arguments".into()),
2175 }
2176}
2177
2178fn compute_histogram_uniform(vals: &[f64], n_bins: usize) -> (Vec<usize>, Vec<f64>) {
2180 if vals.is_empty() {
2181 return (vec![0; n_bins], (0..=n_bins).map(|i| i as f64).collect());
2182 }
2183 let min_v = vals.iter().copied().fold(f64::INFINITY, f64::min);
2184 let max_v = vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2185 let range = if max_v > min_v { max_v - min_v } else { 1.0 };
2186 let mut counts = vec![0usize; n_bins];
2187 for &v in vals {
2188 let b = ((v - min_v) / range * n_bins as f64) as usize;
2189 counts[b.min(n_bins - 1)] += 1;
2190 }
2191 let edges: Vec<f64> = (0..=n_bins)
2192 .map(|i| min_v + range * (i as f64 / n_bins as f64))
2193 .collect();
2194 (counts, edges)
2195}
2196
2197fn compute_histogram_edges(vals: &[f64], edges: &[f64]) -> (Vec<usize>, Vec<f64>) {
2201 let n_bins = edges.len() - 1;
2202 let mut counts = vec![0usize; n_bins];
2203 for &v in vals {
2204 match edges.binary_search_by(|e| e.partial_cmp(&v).unwrap_or(std::cmp::Ordering::Less)) {
2206 Ok(b) => counts[b.min(n_bins - 1)] += 1,
2207 Err(b) if b > 0 && b <= n_bins => counts[b - 1] += 1,
2208 _ => {}
2209 }
2210 }
2211 (counts, edges.to_vec())
2212}
2213
2214fn render_hist_ascii(counts: &[usize], edges: &[f64], state: &FigureState) {
2216 let n_bins = counts.len();
2217 let bar_cols: usize = term_cols().saturating_sub(26).max(10);
2218 let max_count = counts.iter().copied().max().unwrap_or(1).max(1);
2219 if let Some(t) = &state.title {
2220 println!("{t}");
2221 }
2222 for i in 0..n_bins {
2223 let lo = edges[i];
2224 let hi = edges[i + 1];
2225 let bar_len = counts[i] * bar_cols / max_count;
2226 println!(
2227 "{lo:8.4} {hi:8.4} |{bar:<width$}| {c}",
2228 bar = "#".repeat(bar_len),
2229 width = bar_cols,
2230 c = counts[i],
2231 );
2232 }
2233 if let Some(xl) = &state.xlabel {
2234 println!("x: {xl}");
2235 }
2236 if let Some(yl) = &state.ylabel {
2237 println!("y: {yl}");
2238 }
2239}
2240
2241#[cfg(feature = "plot-svg")]
2242fn render_hist_file(
2243 counts: &[usize],
2244 edges: &[f64],
2245 path: &str,
2246 style: Option<StyleSpec>,
2247 state: FigureState,
2248) -> Result<Value, String> {
2249 file::render_hist(counts, edges, path, style, state).map_err(|e| format!("hist: {e}"))?;
2250 Ok(Value::Void)
2251}
2252
2253#[cfg(not(feature = "plot-svg"))]
2254fn render_hist_file(
2255 _counts: &[usize],
2256 _edges: &[f64],
2257 _path: &str,
2258 _style: Option<StyleSpec>,
2259 _state: FigureState,
2260) -> Result<Value, String> {
2261 Err("hist: SVG/PNG export requires the 'plot-svg' feature — \
2262 rebuild with: cargo build --features plot-svg"
2263 .into())
2264}
2265
2266fn render_multi_series(
2269 x: &[f64],
2270 ys: &[Vec<f64>],
2271 path: Option<&str>,
2272 state: FigureState,
2273) -> Result<Value, String> {
2274 match path {
2275 None | Some("ascii") => render_multi_series_ascii(x, ys, &state),
2276 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2277 render_multi_series_file(x, ys, p, state)
2278 }
2279 Some(p) => Err(format!("plot: unknown output target '{p}'")),
2280 }
2281}
2282
2283#[cfg(feature = "plot")]
2284fn render_multi_series_ascii(
2285 x: &[f64],
2286 ys: &[Vec<f64>],
2287 _state: &FigureState,
2288) -> Result<Value, String> {
2289 ascii::render_line(x, &ys[0], FigureState::default());
2291 println!("% {} series total — use file export for all", ys.len());
2292 Ok(Value::Void)
2293}
2294
2295#[cfg(not(feature = "plot"))]
2296fn render_multi_series_ascii(
2297 _x: &[f64],
2298 _ys: &[Vec<f64>],
2299 _state: &FigureState,
2300) -> Result<Value, String> {
2301 Err("plot: ASCII rendering requires the 'plot' feature flag — \
2302 rebuild with: cargo build --features plot"
2303 .into())
2304}
2305
2306#[cfg(feature = "plot-svg")]
2307fn render_multi_series_file(
2308 x: &[f64],
2309 ys: &[Vec<f64>],
2310 path: &str,
2311 state: FigureState,
2312) -> Result<Value, String> {
2313 file::render_multi_line(x, ys, path, state).map_err(|e| format!("plot: {e}"))?;
2314 Ok(Value::Void)
2315}
2316
2317#[cfg(not(feature = "plot-svg"))]
2318fn render_multi_series_file(
2319 _x: &[f64],
2320 _ys: &[Vec<f64>],
2321 _path: &str,
2322 _state: FigureState,
2323) -> Result<Value, String> {
2324 Err("plot: SVG/PNG export requires the 'plot-svg' feature — \
2325 rebuild with: cargo build --features plot-svg"
2326 .into())
2327}
2328
2329fn render_line_xy(
2333 name: &str,
2334 x: &[f64],
2335 y: &[f64],
2336 path: Option<&str>,
2337 state: FigureState,
2338) -> Result<Value, String> {
2339 match path {
2340 None | Some("ascii") => render_line_xy_ascii(name, x, y, state),
2341 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2342 render_line_xy_file(name, x, y, p, state)
2343 }
2344 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
2345 }
2346}
2347
2348#[cfg(feature = "plot")]
2349fn render_line_xy_ascii(
2350 _name: &str,
2351 x: &[f64],
2352 y: &[f64],
2353 state: FigureState,
2354) -> Result<Value, String> {
2355 ascii::render_line(x, y, state);
2356 Ok(Value::Void)
2357}
2358
2359#[cfg(not(feature = "plot"))]
2360fn render_line_xy_ascii(
2361 name: &str,
2362 _x: &[f64],
2363 _y: &[f64],
2364 _state: FigureState,
2365) -> Result<Value, String> {
2366 Err(format!(
2367 "{name}: ASCII rendering requires the 'plot' feature flag — \
2368 rebuild with: cargo build --features plot"
2369 ))
2370}
2371
2372#[cfg(feature = "plot-svg")]
2373fn render_line_xy_file(
2374 name: &str,
2375 x: &[f64],
2376 y: &[f64],
2377 path: &str,
2378 state: FigureState,
2379) -> Result<Value, String> {
2380 file::render_line(x, y, path, state).map_err(|e| format!("{name}: {e}"))?;
2381 Ok(Value::Void)
2382}
2383
2384#[cfg(not(feature = "plot-svg"))]
2385fn render_line_xy_file(
2386 name: &str,
2387 _x: &[f64],
2388 _y: &[f64],
2389 _path: &str,
2390 _state: FigureState,
2391) -> Result<Value, String> {
2392 Err(format!(
2393 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
2394 rebuild with: cargo build --features plot-svg"
2395 ))
2396}
2397
2398fn render_fill_xy(
2401 x: &[f64],
2402 y: &[f64],
2403 path: Option<&str>,
2404 style: Option<StyleSpec>,
2405 state: FigureState,
2406) -> Result<Value, String> {
2407 match path {
2408 None | Some("ascii") => render_fill_ascii(x, y, state),
2409 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2410 render_fill_file(x, y, p, style, state)
2411 }
2412 Some(p) => Err(format!("fill: unknown output target '{p}'")),
2413 }
2414}
2415
2416fn render_area_xy(
2417 x: &[f64],
2418 y: &[f64],
2419 path: Option<&str>,
2420 style: Option<StyleSpec>,
2421 state: FigureState,
2422) -> Result<Value, String> {
2423 match path {
2424 None | Some("ascii") => render_area_ascii(x, y, state),
2425 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2426 render_area_file(x, y, p, style, state)
2427 }
2428 Some(p) => Err(format!("area: unknown output target '{p}'")),
2429 }
2430}
2431
2432#[cfg(feature = "plot")]
2433fn render_fill_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
2434 ascii::render_fill(x, y, state);
2435 Ok(Value::Void)
2436}
2437
2438#[cfg(not(feature = "plot"))]
2439fn render_fill_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
2440 Err("fill: ASCII rendering requires the 'plot' feature flag — \
2441 rebuild with: cargo build --features plot"
2442 .into())
2443}
2444
2445#[cfg(feature = "plot")]
2446fn render_area_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
2447 ascii::render_area(x, y, state);
2448 Ok(Value::Void)
2449}
2450
2451#[cfg(not(feature = "plot"))]
2452fn render_area_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
2453 Err("area: ASCII rendering requires the 'plot' feature flag — \
2454 rebuild with: cargo build --features plot"
2455 .into())
2456}
2457
2458#[cfg(feature = "plot-svg")]
2459fn render_fill_file(
2460 x: &[f64],
2461 y: &[f64],
2462 path: &str,
2463 style: Option<StyleSpec>,
2464 state: FigureState,
2465) -> Result<Value, String> {
2466 file::render_fill(x, y, path, style, state).map_err(|e| format!("fill: {e}"))?;
2467 Ok(Value::Void)
2468}
2469
2470#[cfg(not(feature = "plot-svg"))]
2471fn render_fill_file(
2472 _x: &[f64],
2473 _y: &[f64],
2474 _path: &str,
2475 _style: Option<StyleSpec>,
2476 _state: FigureState,
2477) -> Result<Value, String> {
2478 Err("fill: SVG/PNG export requires the 'plot-svg' feature — \
2479 rebuild with: cargo build --features plot-svg"
2480 .into())
2481}
2482
2483#[cfg(feature = "plot-svg")]
2484fn render_area_file(
2485 x: &[f64],
2486 y: &[f64],
2487 path: &str,
2488 style: Option<StyleSpec>,
2489 state: FigureState,
2490) -> Result<Value, String> {
2491 file::render_area(x, y, path, style, state).map_err(|e| format!("area: {e}"))?;
2492 Ok(Value::Void)
2493}
2494
2495#[cfg(not(feature = "plot-svg"))]
2496fn render_area_file(
2497 _x: &[f64],
2498 _y: &[f64],
2499 _path: &str,
2500 _style: Option<StyleSpec>,
2501 _state: FigureState,
2502) -> Result<Value, String> {
2503 Err("area: SVG/PNG export requires the 'plot-svg' feature — \
2504 rebuild with: cargo build --features plot-svg"
2505 .into())
2506}
2507
2508fn render_bar_xy(
2511 x: &[f64],
2512 y: &[f64],
2513 path: Option<&str>,
2514 style: Option<StyleSpec>,
2515 state: FigureState,
2516) -> Result<Value, String> {
2517 match path {
2518 None | Some("ascii") => render_bar_ascii(x, y, state),
2519 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2520 render_bar_file(x, y, p, style, state)
2521 }
2522 Some(p) => Err(format!("bar: unknown output target '{p}'")),
2523 }
2524}
2525
2526fn render_stem_xy(
2527 x: &[f64],
2528 y: &[f64],
2529 path: Option<&str>,
2530 style: Option<StyleSpec>,
2531 state: FigureState,
2532) -> Result<Value, String> {
2533 match path {
2534 None | Some("ascii") => render_stem_ascii(x, y, state),
2535 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2536 render_stem_file(x, y, p, style, state)
2537 }
2538 Some(p) => Err(format!("stem: unknown output target '{p}'")),
2539 }
2540}
2541
2542#[cfg(feature = "plot")]
2543fn render_bar_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
2544 ascii::render_bar(x, y, state);
2545 Ok(Value::Void)
2546}
2547
2548#[cfg(not(feature = "plot"))]
2549fn render_bar_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
2550 Err("bar: ASCII rendering requires the 'plot' feature flag — \
2551 rebuild with: cargo build --features plot"
2552 .into())
2553}
2554
2555#[cfg(feature = "plot")]
2556fn render_stem_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
2557 ascii::render_stem(x, y, state);
2558 Ok(Value::Void)
2559}
2560
2561#[cfg(not(feature = "plot"))]
2562fn render_stem_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
2563 Err("stem: ASCII rendering requires the 'plot' feature flag — \
2564 rebuild with: cargo build --features plot"
2565 .into())
2566}
2567
2568#[cfg(feature = "plot-svg")]
2569fn render_bar_file(
2570 x: &[f64],
2571 y: &[f64],
2572 path: &str,
2573 style: Option<StyleSpec>,
2574 state: FigureState,
2575) -> Result<Value, String> {
2576 file::render_bar(x, y, path, style, state).map_err(|e| format!("bar: {e}"))?;
2577 Ok(Value::Void)
2578}
2579
2580#[cfg(not(feature = "plot-svg"))]
2581fn render_bar_file(
2582 _x: &[f64],
2583 _y: &[f64],
2584 _path: &str,
2585 _style: Option<StyleSpec>,
2586 _state: FigureState,
2587) -> Result<Value, String> {
2588 Err("bar: SVG/PNG export requires the 'plot-svg' feature — \
2589 rebuild with: cargo build --features plot-svg"
2590 .into())
2591}
2592
2593#[cfg(feature = "plot-svg")]
2594fn render_stem_file(
2595 x: &[f64],
2596 y: &[f64],
2597 path: &str,
2598 style: Option<StyleSpec>,
2599 state: FigureState,
2600) -> Result<Value, String> {
2601 file::render_stem(x, y, path, style, state).map_err(|e| format!("stem: {e}"))?;
2602 Ok(Value::Void)
2603}
2604
2605#[cfg(not(feature = "plot-svg"))]
2606fn render_stem_file(
2607 _x: &[f64],
2608 _y: &[f64],
2609 _path: &str,
2610 _style: Option<StyleSpec>,
2611 _state: FigureState,
2612) -> Result<Value, String> {
2613 Err("stem: SVG/PNG export requires the 'plot-svg' feature — \
2614 rebuild with: cargo build --features plot-svg"
2615 .into())
2616}
2617
2618fn render_quiver(
2621 x: &[f64],
2622 y: &[f64],
2623 u: &[f64],
2624 v: &[f64],
2625 path: Option<&str>,
2626 style: Option<StyleSpec>,
2627 state: FigureState,
2628) -> Result<Value, String> {
2629 match path {
2630 None | Some("ascii") => render_quiver_ascii_tier(x, y, u, v, state),
2631 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2632 render_quiver_file_tier(x, y, u, v, p, style, state)
2633 }
2634 Some(p) => Err(format!("quiver: unknown output target '{p}'")),
2635 }
2636}
2637
2638fn render_quiver_ascii_tier(
2639 x: &[f64],
2640 y: &[f64],
2641 u: &[f64],
2642 v: &[f64],
2643 state: FigureState,
2644) -> Result<Value, String> {
2645 render_quiver_ascii(x, y, u, v, &state);
2646 Ok(Value::Void)
2647}
2648
2649#[cfg(feature = "plot-svg")]
2650fn render_quiver_file_tier(
2651 x: &[f64],
2652 y: &[f64],
2653 u: &[f64],
2654 v: &[f64],
2655 path: &str,
2656 style: Option<StyleSpec>,
2657 state: FigureState,
2658) -> Result<Value, String> {
2659 file::render_quiver(x, y, u, v, path, style, state).map_err(|e| format!("quiver: {e}"))?;
2660 Ok(Value::Void)
2661}
2662
2663#[cfg(not(feature = "plot-svg"))]
2664fn render_quiver_file_tier(
2665 _x: &[f64],
2666 _y: &[f64],
2667 _u: &[f64],
2668 _v: &[f64],
2669 _path: &str,
2670 _style: Option<StyleSpec>,
2671 _state: FigureState,
2672) -> Result<Value, String> {
2673 Err("quiver: SVG/PNG export requires the 'plot-svg' feature — \
2674 rebuild with: cargo build --features plot-svg"
2675 .into())
2676}
2677
2678fn render_quiver_ascii(xs: &[f64], ys: &[f64], us: &[f64], vs: &[f64], state: &FigureState) {
2680 let n = xs.len();
2681 if n == 0 {
2682 return;
2683 }
2684 let w = term_cols().saturating_sub(4).max(20);
2685 let h = (term_rows() / 2).max(10);
2686
2687 let x_min = state
2688 .xlim
2689 .map(|(lo, _)| lo)
2690 .unwrap_or_else(|| xs.iter().copied().fold(f64::INFINITY, f64::min));
2691 let x_max = state
2692 .xlim
2693 .map(|(_, hi)| hi)
2694 .unwrap_or_else(|| xs.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2695 let y_min = state
2696 .ylim
2697 .map(|(lo, _)| lo)
2698 .unwrap_or_else(|| ys.iter().copied().fold(f64::INFINITY, f64::min));
2699 let y_max = state
2700 .ylim
2701 .map(|(_, hi)| hi)
2702 .unwrap_or_else(|| ys.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2703
2704 let x_span = if (x_max - x_min).abs() < f64::EPSILON {
2705 2.0
2706 } else {
2707 x_max - x_min
2708 };
2709 let y_span = if (y_max - y_min).abs() < f64::EPSILON {
2710 2.0
2711 } else {
2712 y_max - y_min
2713 };
2714
2715 let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
2716
2717 for i in 0..n {
2718 let col = ((xs[i] - x_min) / x_span * (w - 1) as f64).round() as isize;
2719 let row = ((y_max - ys[i]) / y_span * (h - 1) as f64).round() as isize;
2720 if col >= 0 && (col as usize) < w && row >= 0 && (row as usize) < h {
2721 let angle = vs[i].atan2(us[i]);
2722 grid[row as usize][col as usize] = arrow_char(angle);
2723 }
2724 }
2725
2726 if let Some(t) = &state.title {
2727 println!("{t}");
2728 }
2729 for row in &grid {
2730 println!("|{}|", row.iter().collect::<String>());
2731 }
2732 if let Some(xl) = &state.xlabel {
2733 println!("x: {xl}");
2734 }
2735 if let Some(yl) = &state.ylabel {
2736 println!("y: {yl}");
2737 }
2738}
2739
2740#[cfg(feature = "plot")]
2741fn render_color_scatter_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
2742 ascii::render_scatter(x, y, state);
2743 Ok(Value::Void)
2744}
2745
2746#[cfg(not(feature = "plot"))]
2747fn render_color_scatter_ascii(
2748 _x: &[f64],
2749 _y: &[f64],
2750 _state: FigureState,
2751) -> Result<Value, String> {
2752 Err(
2753 "scatter: ASCII rendering requires the 'plot' feature flag — \
2754 rebuild with: cargo build --features plot"
2755 .into(),
2756 )
2757}
2758
2759fn is_numeric_value(v: &Value) -> bool {
2761 matches!(v, Value::Scalar(_) | Value::Matrix(_))
2762}
2763
2764#[allow(clippy::too_many_arguments)]
2767fn render_errorbar(
2768 x: &[f64],
2769 y: &[f64],
2770 e_low: &[f64],
2771 e_high: &[f64],
2772 path: Option<&str>,
2773 style: Option<StyleSpec>,
2774 state: FigureState,
2775) -> Result<Value, String> {
2776 match path {
2777 None | Some("ascii") => {
2778 render_errorbar_ascii(x, y, e_low, e_high);
2779 Ok(Value::Void)
2780 }
2781 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2782 render_errorbar_file(x, y, e_low, e_high, p, style, state)
2783 }
2784 Some(p) => Err(format!("errorbar: unknown output target '{p}'")),
2785 }
2786}
2787
2788fn render_errorbar_ascii(x: &[f64], y: &[f64], e_low: &[f64], e_high: &[f64]) {
2790 println!(" {:>10} {:>12} {:>12}", "x", "y", "error");
2791 println!(" {:->10} {:->12} {:->12}", "", "", "");
2792 for i in 0..x.len() {
2793 let err_str = if (e_low[i] - e_high[i]).abs() < 1e-12 {
2794 format!("±{:.4}", e_low[i])
2795 } else {
2796 format!("-{:.4}/+{:.4}", e_low[i], e_high[i])
2797 };
2798 println!(" {:>10.4} {:>12.4} {:>12}", x[i], y[i], err_str);
2799 }
2800}
2801
2802#[cfg(feature = "plot-svg")]
2803fn render_errorbar_file(
2804 x: &[f64],
2805 y: &[f64],
2806 e_low: &[f64],
2807 e_high: &[f64],
2808 path: &str,
2809 style: Option<StyleSpec>,
2810 state: FigureState,
2811) -> Result<Value, String> {
2812 file::render_errorbar(x, y, e_low, e_high, path, style, state)
2813 .map_err(|e| format!("errorbar: {e}"))?;
2814 Ok(Value::Void)
2815}
2816
2817#[cfg(not(feature = "plot-svg"))]
2818fn render_errorbar_file(
2819 _x: &[f64],
2820 _y: &[f64],
2821 _e_low: &[f64],
2822 _e_high: &[f64],
2823 _path: &str,
2824 _style: Option<StyleSpec>,
2825 _state: FigureState,
2826) -> Result<Value, String> {
2827 Err(
2828 "errorbar: SVG/PNG export requires the 'plot-svg' feature — \
2829 rebuild with: cargo build --features plot-svg"
2830 .into(),
2831 )
2832}
2833
2834#[allow(clippy::too_many_arguments)]
2837fn render_color_scatter(
2838 x: &[f64],
2839 y: &[f64],
2840 sz: &[f64],
2841 c: &[f64],
2842 c_min: f64,
2843 c_max: f64,
2844 path: Option<&str>,
2845 state: FigureState,
2846) -> Result<Value, String> {
2847 match path {
2848 None | Some("ascii") => render_color_scatter_ascii(x, y, state),
2849 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2850 render_color_scatter_file(x, y, sz, c, c_min, c_max, p, state)
2851 }
2852 Some(p) => Err(format!("scatter: unknown output target '{p}'")),
2853 }
2854}
2855
2856#[cfg(feature = "plot-svg")]
2857#[allow(clippy::too_many_arguments)]
2858fn render_color_scatter_file(
2859 x: &[f64],
2860 y: &[f64],
2861 sz: &[f64],
2862 c: &[f64],
2863 c_min: f64,
2864 c_max: f64,
2865 path: &str,
2866 state: FigureState,
2867) -> Result<Value, String> {
2868 file::render_color_scatter(x, y, sz, c, c_min, c_max, path, state)
2869 .map_err(|e| format!("scatter: {e}"))?;
2870 Ok(Value::Void)
2871}
2872
2873#[cfg(not(feature = "plot-svg"))]
2874#[allow(clippy::too_many_arguments)]
2875fn render_color_scatter_file(
2876 _x: &[f64],
2877 _y: &[f64],
2878 _sz: &[f64],
2879 _c: &[f64],
2880 _c_min: f64,
2881 _c_max: f64,
2882 _path: &str,
2883 _state: FigureState,
2884) -> Result<Value, String> {
2885 Err("scatter: SVG/PNG export requires the 'plot-svg' feature — \
2886 rebuild with: cargo build --features plot-svg"
2887 .into())
2888}
2889
2890fn render_pie(
2893 values: &[f64],
2894 labels: &[String],
2895 explode: &[f64],
2896 path: Option<&str>,
2897 state: FigureState,
2898) -> Result<Value, String> {
2899 match path {
2900 None | Some("ascii") => {
2901 print!("{}", format_pie_ascii(values, labels, explode));
2902 Ok(Value::Void)
2903 }
2904 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2905 render_pie_file(values, labels, explode, p, state)
2906 }
2907 Some(p) => Err(format!("pie: unknown output target '{p}'")),
2908 }
2909}
2910
2911const SLICE_FILLS: [char; 4] = [
2916 '\u{2588}', '\u{2593}', '\u{2592}', '\u{2591}', ];
2921
2922pub(crate) fn format_pie_ascii(values: &[f64], labels: &[String], explode: &[f64]) -> String {
2923 use std::fmt::Write;
2924 let total: f64 = values.iter().sum();
2925 let bar_width: usize = 20;
2926 let mut out = String::new();
2927 for (i, &v) in values.iter().enumerate() {
2928 let pct = v / total * 100.0;
2929 let label = if i < labels.len() && !labels[i].is_empty() {
2930 labels[i].as_str()
2931 } else {
2932 ""
2933 };
2934 let is_exploded = explode.get(i).copied().unwrap_or(0.0) > 1e-9;
2935 let fill = SLICE_FILLS[i % SLICE_FILLS.len()];
2936 let filled = (pct / 100.0 * bar_width as f64).round() as usize;
2939 let filled = filled.min(bar_width);
2940 let mid = bar_width / 2;
2941 let mut bar = String::new();
2942 for j in 0..bar_width {
2943 if j < filled {
2944 bar.push(fill);
2945 } else if j == mid && filled <= mid {
2946 bar.push(':');
2947 } else {
2948 bar.push('\u{00b7}'); }
2950 }
2951 let explode_marker = if is_exploded { " \u{25c4}" } else { "" }; if label.is_empty() {
2953 let _ = writeln!(out, " [{bar}] {pct:5.1}%{explode_marker}");
2954 } else {
2955 let _ = writeln!(out, " [{bar}] {pct:5.1}% {label}{explode_marker}");
2956 }
2957 }
2958 out
2959}
2960
2961#[cfg(feature = "plot-svg")]
2962fn render_pie_file(
2963 values: &[f64],
2964 labels: &[String],
2965 explode: &[f64],
2966 path: &str,
2967 state: FigureState,
2968) -> Result<Value, String> {
2969 file::render_pie(values, labels, explode, path, state).map_err(|e| format!("pie: {e}"))?;
2970 Ok(Value::Void)
2971}
2972
2973#[cfg(not(feature = "plot-svg"))]
2974fn render_pie_file(
2975 _values: &[f64],
2976 _labels: &[String],
2977 _explode: &[f64],
2978 _path: &str,
2979 _state: FigureState,
2980) -> Result<Value, String> {
2981 Err("pie: SVG/PNG export requires the 'plot-svg' feature — \
2982 rebuild with: cargo build --features plot-svg"
2983 .into())
2984}
2985
2986fn arrow_char(angle: f64) -> char {
2988 use std::f64::consts::PI;
2989 let a = (angle + 2.0 * PI).rem_euclid(2.0 * PI);
2990 let octant = ((a + PI / 8.0) / (PI / 4.0)) as usize % 8;
2991 match octant {
2992 0 => '\u{2192}', 1 => '\u{2197}', 2 => '\u{2191}', 3 => '\u{2196}', 4 => '\u{2190}', 5 => '\u{2199}', 6 => '\u{2193}', _ => '\u{2198}', }
3001}
3002
3003fn render_3d(
3006 name: &str,
3007 data_args: &[Value],
3008 path: Option<&str>,
3009 state: FigureState,
3010) -> Result<Value, String> {
3011 extract_xyz(name, data_args)?;
3012 match path {
3013 None | Some("ascii") => render_3d_ascii(name, data_args, state),
3014 Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
3015 render_3d_file(name, data_args, p, state)
3016 }
3017 Some(p) => Err(format!("{name}: unknown output target '{p}'")),
3018 }
3019}
3020
3021#[cfg(feature = "plot")]
3022fn render_3d_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
3023 let (x, y, z) = extract_xyz(name, data_args)?;
3024 let (px, py) = proj3d::project_ortho(&x, &y, &z);
3025 let state_2d = FigureState {
3028 title: state.title.clone(),
3029 xlim: state.xlim,
3030 ylim: state.ylim,
3031 ..FigureState::default()
3032 };
3033 match name {
3034 "plot3" => ascii::render_line(&px, &py, state_2d),
3035 "scatter3" => ascii::render_scatter(&px, &py, state_2d),
3036 _ => unreachable!(),
3037 }
3038 if let Some(xl) = &state.xlabel {
3039 println!("x: {xl}");
3040 }
3041 if let Some(yl) = &state.ylabel {
3042 println!("y: {yl}");
3043 }
3044 if let Some(zl) = &state.zlabel {
3045 println!("z: {zl}");
3046 }
3047 Ok(Value::Void)
3048}
3049
3050#[cfg(not(feature = "plot"))]
3051fn render_3d_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
3052 Err(format!(
3053 "{name}: ASCII rendering requires the 'plot' feature flag — \
3054 rebuild with: cargo build --features plot"
3055 ))
3056}
3057
3058#[cfg(feature = "plot-svg")]
3059fn render_3d_file(
3060 name: &str,
3061 data_args: &[Value],
3062 path: &str,
3063 state: FigureState,
3064) -> Result<Value, String> {
3065 let (x, y, z) = extract_xyz(name, data_args)?;
3066 let result = match name {
3067 "plot3" => file::render_plot3(&x, &y, &z, path, state),
3068 "scatter3" => file::render_scatter3(&x, &y, &z, path, state),
3069 _ => unreachable!(),
3070 };
3071 result.map_err(|e| format!("{name}: {e}"))?;
3072 Ok(Value::Void)
3073}
3074
3075#[cfg(not(feature = "plot-svg"))]
3076fn render_3d_file(
3077 name: &str,
3078 _data_args: &[Value],
3079 _path: &str,
3080 _state: FigureState,
3081) -> Result<Value, String> {
3082 Err(format!(
3083 "{name}: SVG/PNG export requires the 'plot-svg' feature — \
3084 rebuild with: cargo build --features plot-svg"
3085 ))
3086}
3087
3088#[cfg(feature = "plot")]
3095fn render_panel_ascii(panel: &Panel) -> Result<Value, String> {
3096 if panel.series.is_empty() && panel.right_series.is_empty() {
3097 return Ok(Value::Void);
3098 }
3099
3100 let render_series = |series_list: &[PendingSeries], base_state: &FigureState| {
3101 for (i, series) in series_list.iter().enumerate() {
3102 if i > 0 {
3103 println!("---");
3104 }
3105 match series {
3106 PendingSeries::Line(x, y, _style) => {
3107 ascii::render_line(x, y, base_state.clone());
3108 }
3109 PendingSeries::Scatter(x, y, _style) => {
3110 ascii::render_scatter(x, y, base_state.clone());
3111 }
3112 PendingSeries::Bar(x, y, _style) => {
3113 ascii::render_bar(x, y, base_state.clone());
3114 }
3115 PendingSeries::Stem(x, y, _style) => {
3116 ascii::render_stem(x, y, base_state.clone());
3117 }
3118 PendingSeries::Hist {
3119 counts,
3120 edges,
3121 style: _,
3122 } => {
3123 render_hist_ascii(counts, edges, base_state);
3124 }
3125 PendingSeries::Fill(x, y, _style) => {
3126 ascii::render_fill(x, y, base_state.clone());
3127 }
3128 PendingSeries::Area(x, y, _style) => {
3129 ascii::render_area(x, y, base_state.clone());
3130 }
3131 PendingSeries::Quiver(x, y, u, v, _style) => {
3132 render_quiver_ascii(x, y, u, v, base_state);
3133 }
3134 PendingSeries::ErrorBar {
3135 x,
3136 y,
3137 e_low,
3138 e_high,
3139 style: _,
3140 } => {
3141 render_errorbar_ascii(x, y, e_low, e_high);
3142 }
3143 PendingSeries::ColorScatter {
3144 x,
3145 y,
3146 sz: _,
3147 c: _,
3148 c_min: _,
3149 c_max: _,
3150 } => {
3151 ascii::render_scatter(x, y, base_state.clone());
3152 }
3153 PendingSeries::Pie {
3154 values,
3155 labels,
3156 explode,
3157 } => {
3158 print!("{}", format_pie_ascii(values, labels, explode));
3159 }
3160 }
3161 }
3162 };
3163
3164 let has_dual = !panel.right_series.is_empty();
3165
3166 if has_dual {
3167 let is_xy =
3169 |s: &PendingSeries| matches!(s, PendingSeries::Line(..) | PendingSeries::Scatter(..));
3170 let can_combine = !panel.series.is_empty()
3171 && panel.series.iter().all(is_xy)
3172 && panel.right_series.iter().all(is_xy);
3173
3174 if can_combine {
3175 let to_f32 = |series: &[PendingSeries]| -> Vec<(Vec<f32>, Vec<f32>, bool)> {
3176 series
3177 .iter()
3178 .map(|s| match s {
3179 PendingSeries::Line(x, y, _) => (
3180 x.iter().map(|&v| v as f32).collect(),
3181 y.iter().map(|&v| v as f32).collect(),
3182 true,
3183 ),
3184 PendingSeries::Scatter(x, y, _) => (
3185 x.iter().map(|&v| v as f32).collect(),
3186 y.iter().map(|&v| v as f32).collect(),
3187 false,
3188 ),
3189 _ => unreachable!(),
3190 })
3191 .collect()
3192 };
3193 ascii::render_dual_axis(
3194 &to_f32(&panel.series),
3195 &to_f32(&panel.right_series),
3196 panel.ylim.map(|(lo, hi)| (lo as f32, hi as f32)),
3197 panel.right_ylim.map(|(lo, hi)| (lo as f32, hi as f32)),
3198 panel.xlim.map(|(lo, hi)| (lo as f32, hi as f32)),
3199 panel.title.as_deref(),
3200 panel.xlabel.as_deref(),
3201 panel.ylabel.as_deref(),
3202 panel.right_ylabel.as_deref(),
3203 );
3204 } else {
3205 println!("[left axis]");
3207 let left_state = FigureState {
3208 xlabel: panel.xlabel.clone(),
3209 ylabel: panel.ylabel.clone(),
3210 title: panel.title.clone(),
3211 xlim: panel.xlim,
3212 ylim: panel.ylim,
3213 ..FigureState::default()
3214 };
3215 render_series(&panel.series, &left_state);
3216 println!("\n[right axis]");
3217 let right_state = FigureState {
3218 xlabel: panel.xlabel.clone(),
3219 ylabel: panel.right_ylabel.clone(),
3220 xlim: panel.xlim,
3221 ylim: panel.right_ylim,
3222 ..FigureState::default()
3223 };
3224 render_series(&panel.right_series, &right_state);
3225 }
3226
3227 for (ax, ay, label) in &panel.annotations {
3228 println!(" ({ax:.4}, {ay:.4}): {label}");
3229 }
3230 } else {
3231 let left_state = FigureState {
3232 xlabel: panel.xlabel.clone(),
3233 ylabel: panel.ylabel.clone(),
3234 title: panel.title.clone(),
3235 xlim: panel.xlim,
3236 ylim: panel.ylim,
3237 ..FigureState::default()
3238 };
3239 render_series(&panel.series, &left_state);
3240
3241 for (ax, ay, label) in &panel.annotations {
3242 println!(" ({ax:.4}, {ay:.4}): {label}");
3243 }
3244 }
3245
3246 Ok(Value::Void)
3247}
3248
3249#[cfg(not(feature = "plot"))]
3250fn render_panel_ascii(_panel: &Panel) -> Result<Value, String> {
3251 Err("hold: ASCII rendering requires the 'plot' feature flag — \
3252 rebuild with: cargo build --features plot"
3253 .into())
3254}
3255
3256#[cfg(feature = "plot-svg")]
3257fn render_panels_file(
3258 panels: &[Panel],
3259 path: &str,
3260 canvas: (u32, u32),
3261 theme: &style::Theme,
3262 bg_override: Option<style::StyleColor>,
3263) -> Result<Value, String> {
3264 use plotters::style::RGBColor;
3265 let bg = bg_override
3266 .map(|c| RGBColor(c.0, c.1, c.2))
3267 .unwrap_or_else(|| {
3268 let c = theme.bg;
3269 RGBColor(c.0, c.1, c.2)
3270 });
3271 file::render_subplot_panels(panels, path, canvas, theme, bg)
3272 .map_err(|e| format!("savefig: {e}"))?;
3273 Ok(Value::Void)
3274}
3275
3276#[cfg(not(feature = "plot-svg"))]
3277fn render_panels_file(
3278 _panels: &[Panel],
3279 _path: &str,
3280 _canvas: (u32, u32),
3281 _theme: &style::Theme,
3282 _bg_override: Option<style::StyleColor>,
3283) -> Result<Value, String> {
3284 Err("savefig: SVG/PNG export requires the 'plot-svg' feature — \
3285 rebuild with: cargo build --features plot-svg"
3286 .into())
3287}
3288
3289#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
3293#[allow(clippy::type_complexity)]
3294fn extract_xyz(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), String> {
3295 match args {
3296 [xv, yv, zv] => {
3297 let x = extract_vector(xv).map_err(|e| format!("{name}: {e}"))?;
3298 let y = extract_vector(yv).map_err(|e| format!("{name}: {e}"))?;
3299 let z = extract_vector(zv).map_err(|e| format!("{name}: {e}"))?;
3300 if x.len() != y.len() || x.len() != z.len() {
3301 return Err(format!(
3302 "{name}: x, y, z must have the same length \
3303 (got {}, {}, {})",
3304 x.len(),
3305 y.len(),
3306 z.len()
3307 ));
3308 }
3309 Ok((x, y, z))
3310 }
3311 _ => Err(format!(
3312 "{name}: expected 3 arguments (x, y, z), got {}",
3313 args.len()
3314 )),
3315 }
3316}
3317
3318#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
3319fn extract_xy(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>), String> {
3320 match args.len() {
3321 0 => Err(format!("{name}: at least one argument required")),
3322 1 => {
3323 let y = extract_vector(&args[0])?;
3324 let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
3325 Ok((x, y))
3326 }
3327 2 => {
3328 let x = extract_vector(&args[0])?;
3329 let y = extract_vector(&args[1])?;
3330 if x.len() != y.len() {
3331 return Err(format!(
3332 "{name}: x and y must have the same length ({} vs {})",
3333 x.len(),
3334 y.len()
3335 ));
3336 }
3337 Ok((x, y))
3338 }
3339 _ => Err(format!("{name}: too many arguments")),
3340 }
3341}
3342
3343#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
3348fn extract_xy_multi(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<Vec<f64>>), String> {
3349 match args.len() {
3350 0 => Err(format!("{name}: at least one argument required")),
3351 1 => {
3352 let y = extract_vector(&args[0])?;
3353 let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
3354 Ok((x, vec![y]))
3355 }
3356 2 => {
3357 let x = extract_vector(&args[0])?;
3358 match &args[1] {
3359 Value::Matrix(m) if m.nrows() > 1 => {
3360 let n_cols = m.ncols();
3362 if n_cols != x.len() {
3363 return Err(format!(
3364 "{name}: x has {} elements but Y has {} columns",
3365 x.len(),
3366 n_cols
3367 ));
3368 }
3369 let ys = (0..m.nrows())
3370 .map(|r| m.row(r).iter().copied().collect())
3371 .collect();
3372 Ok((x, ys))
3373 }
3374 other => {
3375 let y = extract_vector(other)?;
3376 if x.len() != y.len() {
3377 return Err(format!(
3378 "{name}: x and y must have the same length ({} vs {})",
3379 x.len(),
3380 y.len()
3381 ));
3382 }
3383 Ok((x, vec![y]))
3384 }
3385 }
3386 }
3387 _ => Err(format!("{name}: too many arguments")),
3388 }
3389}
3390
3391#[cfg(test)]
3394mod tests {
3395 use ccalc_engine::env::{Env, Value};
3396 use ndarray::Array2;
3397
3398 use super::*;
3399
3400 #[test]
3403 fn test_term_cols_default() {
3404 unsafe { std::env::remove_var("COLUMNS") };
3406 assert_eq!(term_cols(), 80);
3407 }
3408
3409 #[test]
3410 fn test_term_rows_default() {
3411 unsafe { std::env::remove_var("LINES") };
3412 assert_eq!(term_rows(), 24);
3413 }
3414
3415 #[test]
3416 fn test_term_cols_env_override() {
3417 unsafe { std::env::set_var("COLUMNS", "132") };
3418 let cols = term_cols();
3419 unsafe { std::env::remove_var("COLUMNS") };
3420 assert_eq!(cols, 132);
3421 }
3422
3423 fn f64_vec(vals: &[f64]) -> Value {
3424 Value::Matrix(Array2::from_shape_vec((1, vals.len()), vals.to_vec()).unwrap())
3425 }
3426
3427 #[test]
3430 fn test_extract_xy_infer_x() {
3431 let y = f64_vec(&[1.0, 4.0, 9.0]);
3432 let (x, yv) = extract_xy("plot", &[y]).unwrap();
3433 assert_eq!(x, vec![1.0, 2.0, 3.0]);
3434 assert_eq!(yv, vec![1.0, 4.0, 9.0]);
3435 }
3436
3437 #[test]
3438 fn test_extract_xy_explicit() {
3439 let x = f64_vec(&[10.0, 20.0]);
3440 let y = f64_vec(&[1.0, 2.0]);
3441 let (xv, yv) = extract_xy("plot", &[x, y]).unwrap();
3442 assert_eq!(xv, vec![10.0, 20.0]);
3443 assert_eq!(yv, vec![1.0, 2.0]);
3444 }
3445
3446 #[test]
3447 fn test_extract_xy_mismatch() {
3448 let x = f64_vec(&[1.0, 2.0]);
3449 let y = f64_vec(&[1.0, 2.0, 3.0]);
3450 assert!(extract_xy("plot", &[x, y]).is_err());
3451 }
3452
3453 #[test]
3454 fn test_extract_xy_scalar_promoted() {
3455 let y = Value::Scalar(5.0);
3456 let (x, yv) = extract_xy("plot", &[y]).unwrap();
3457 assert_eq!(x, vec![1.0]);
3458 assert_eq!(yv, vec![5.0]);
3459 }
3460
3461 #[test]
3464 fn test_xlabel_sets_state() {
3465 let plugin = PlotPlugin;
3466 let env = Env::new();
3467 plugin
3468 .call("xlabel", &[Value::Str("time".into())], &env)
3469 .unwrap();
3470 let label = FIGURE_STATE.with(|f| f.borrow().xlabel.clone());
3471 assert_eq!(label, Some("time".into()));
3472 FIGURE_STATE.with(|f| f.take());
3474 }
3475
3476 #[test]
3477 fn test_title_sets_state() {
3478 let plugin = PlotPlugin;
3479 let env = Env::new();
3480 plugin
3481 .call("title", &[Value::Str("My Chart".into())], &env)
3482 .unwrap();
3483 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
3484 assert_eq!(title, Some("My Chart".into()));
3485 FIGURE_STATE.with(|f| f.take());
3486 }
3487
3488 #[test]
3489 fn test_annotation_requires_string() {
3490 let plugin = PlotPlugin;
3491 let env = Env::new();
3492 let result = plugin.call("xlabel", &[Value::Scalar(1.0)], &env);
3493 assert!(result.is_err());
3494 }
3495
3496 #[test]
3499 fn test_plot_no_feature_returns_error_without_feature() {
3500 #[cfg(not(feature = "plot"))]
3503 {
3504 let plugin = PlotPlugin;
3505 let env = Env::new();
3506 let y = f64_vec(&[1.0, 2.0, 3.0]);
3507 let result = plugin.call("plot", &[y], &env);
3508 assert!(result.is_err());
3509 let msg = result.unwrap_err();
3510 assert!(msg.contains("plot"), "error should mention 'plot'");
3511 }
3512 #[cfg(feature = "plot")]
3514 let _ = ();
3515 }
3516
3517 #[test]
3518 fn test_hist_single_value_no_error() {
3519 let plugin = PlotPlugin;
3520 let env = Env::new();
3521 let result = plugin.call("hist", &[Value::Scalar(1.0)], &env);
3522 assert!(result.is_ok());
3523 }
3524
3525 #[test]
3526 fn test_hist_vector_returns_void() {
3527 let plugin = PlotPlugin;
3528 let env = Env::new();
3529 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3530 let result = plugin.call("hist", &[v], &env).unwrap();
3531 assert_eq!(result, Value::Void);
3532 }
3533
3534 #[test]
3535 fn test_hist_custom_bins_returns_void() {
3536 let plugin = PlotPlugin;
3537 let env = Env::new();
3538 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3539 let result = plugin.call("hist", &[v, Value::Scalar(3.0)], &env).unwrap();
3540 assert_eq!(result, Value::Void);
3541 }
3542
3543 #[test]
3544 fn test_hist_zero_bins_errors() {
3545 let plugin = PlotPlugin;
3546 let env = Env::new();
3547 let v = f64_vec(&[1.0, 2.0, 3.0]);
3548 let result = plugin.call("hist", &[v, Value::Scalar(0.0)], &env);
3549 assert!(result.is_err());
3550 }
3551
3552 #[test]
3555 fn test_extract_xy_multi_single_series() {
3556 let x = f64_vec(&[1.0, 2.0, 3.0]);
3557 let y = f64_vec(&[1.0, 4.0, 9.0]);
3558 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
3559 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
3560 assert_eq!(ys.len(), 1);
3561 assert_eq!(ys[0], vec![1.0, 4.0, 9.0]);
3562 }
3563
3564 #[test]
3565 fn test_extract_xy_multi_matrix_y() {
3566 let x = f64_vec(&[1.0, 2.0, 3.0]);
3567 let y = Value::Matrix(
3569 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
3570 );
3571 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
3572 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
3573 assert_eq!(ys.len(), 2);
3574 assert_eq!(ys[0], vec![1.0, 2.0, 3.0]);
3575 assert_eq!(ys[1], vec![4.0, 5.0, 6.0]);
3576 }
3577
3578 #[test]
3579 fn test_extract_xy_multi_column_count_mismatch() {
3580 let x = f64_vec(&[1.0, 2.0]);
3581 let y = Value::Matrix(
3582 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
3583 );
3584 let result = extract_xy_multi("plot", &[x, y]);
3585 assert!(result.is_err());
3586 }
3587
3588 #[test]
3591 fn test_loglog_non_positive_all_filtered_errors() {
3592 let plugin = PlotPlugin;
3593 let env = Env::new();
3594 let x = f64_vec(&[-1.0, 0.0, -2.0]);
3595 let y = f64_vec(&[1.0, 2.0, 3.0]);
3596 let result = plugin.call("loglog", &[x, y], &env);
3597 assert!(result.is_err());
3598 let msg = result.unwrap_err();
3599 assert!(msg.contains("finite"), "error should mention finite: {msg}");
3600 }
3601
3602 #[test]
3603 fn test_semilogx_valid_data() {
3604 let plugin = PlotPlugin;
3605 let env = Env::new();
3606 let x = f64_vec(&[1.0, 10.0, 100.0]);
3608 let y = f64_vec(&[1.0, 2.0, 3.0]);
3609 let result = plugin.call("semilogx", &[x, y], &env);
3610 if let Err(msg) = &result {
3612 assert!(
3613 !msg.contains("not yet implemented"),
3614 "should not say 'not yet implemented': {msg}"
3615 );
3616 }
3617 }
3618
3619 #[test]
3620 fn test_semilogy_label_annotation() {
3621 FIGURE_STATE.with(|f| f.take());
3625 }
3626
3627 #[test]
3628 fn test_stairs_stub_is_gone() {
3629 let plugin = PlotPlugin;
3631 let env = Env::new();
3632 #[cfg(feature = "plot")]
3635 {
3636 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
3637 let result = plugin.call("stairs", &[y], &env);
3638 assert!(result.is_ok(), "stairs should succeed: {result:?}");
3639 }
3640 #[cfg(not(feature = "plot"))]
3641 {
3642 let y = f64_vec(&[1.0, 4.0, 9.0]);
3643 let result = plugin.call("stairs", &[y], &env);
3644 let msg = result.unwrap_err();
3646 assert!(
3647 !msg.contains("not yet implemented"),
3648 "should not say 'not yet implemented': {msg}"
3649 );
3650 }
3651 }
3652
3653 #[test]
3656 fn test_xlim_sets_state() {
3657 FIGURE_STATE.with(|f| f.take());
3658 let plugin = PlotPlugin;
3659 let env = Env::new();
3660 let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![0.0, 10.0]).unwrap());
3661 plugin.call("xlim", &[lim], &env).unwrap();
3662 let xlim = FIGURE_STATE.with(|f| f.borrow().xlim);
3663 assert_eq!(xlim, Some((0.0, 10.0)));
3664 FIGURE_STATE.with(|f| f.take());
3665 }
3666
3667 #[test]
3668 fn test_ylim_sets_state() {
3669 FIGURE_STATE.with(|f| f.take());
3670 let plugin = PlotPlugin;
3671 let env = Env::new();
3672 let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![-1.0, 1.0]).unwrap());
3673 plugin.call("ylim", &[lim], &env).unwrap();
3674 let ylim = FIGURE_STATE.with(|f| f.borrow().ylim);
3675 assert_eq!(ylim, Some((-1.0, 1.0)));
3676 FIGURE_STATE.with(|f| f.take());
3677 }
3678
3679 #[test]
3680 fn test_legend_sets_state() {
3681 FIGURE_STATE.with(|f| f.take());
3682 let plugin = PlotPlugin;
3683 let env = Env::new();
3684 plugin
3685 .call(
3686 "legend",
3687 &[Value::Str("a".into()), Value::Str("b".into())],
3688 &env,
3689 )
3690 .unwrap();
3691 let legend = FIGURE_STATE.with(|f| f.borrow().legend.clone());
3692 assert_eq!(legend, vec!["a".to_string(), "b".to_string()]);
3693 FIGURE_STATE.with(|f| f.take());
3694 }
3695
3696 #[test]
3697 fn test_legend_requires_strings() {
3698 let plugin = PlotPlugin;
3699 let env = Env::new();
3700 let result = plugin.call("legend", &[Value::Scalar(1.0)], &env);
3701 assert!(result.is_err());
3702 }
3703
3704 #[test]
3705 fn test_legend_requires_at_least_one_arg() {
3706 let plugin = PlotPlugin;
3707 let env = Env::new();
3708 let result = plugin.call("legend", &[], &env);
3709 assert!(result.is_err());
3710 }
3711
3712 #[test]
3713 fn test_grid_toggles_state() {
3714 FIGURE_STATE.with(|f| f.take());
3715 let plugin = PlotPlugin;
3716 let env = Env::new();
3717 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3719 plugin.call("grid", &[], &env).unwrap();
3720 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
3721 plugin.call("grid", &[], &env).unwrap();
3722 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3723 FIGURE_STATE.with(|f| f.take());
3724 }
3725
3726 #[test]
3727 fn test_grid_on_off_string_args() {
3728 FIGURE_STATE.with(|f| f.take());
3729 let plugin = PlotPlugin;
3730 let env = Env::new();
3731 plugin
3732 .call("grid", &[Value::Str("on".into())], &env)
3733 .unwrap();
3734 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
3735 plugin
3736 .call("grid", &[Value::Str("off".into())], &env)
3737 .unwrap();
3738 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3739 let result = plugin.call("grid", &[Value::Str("maybe".into())], &env);
3741 assert!(result.is_err());
3742 FIGURE_STATE.with(|f| f.take());
3743 }
3744
3745 #[test]
3746 fn test_zlabel_sets_state() {
3747 FIGURE_STATE.with(|f| f.take());
3748 let plugin = PlotPlugin;
3749 let env = Env::new();
3750 plugin
3751 .call("zlabel", &[Value::Str("depth".into())], &env)
3752 .unwrap();
3753 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
3754 assert_eq!(zlabel, Some("depth".into()));
3755 FIGURE_STATE.with(|f| f.take());
3756 }
3757
3758 #[test]
3759 fn test_xlim_wrong_length() {
3760 let plugin = PlotPlugin;
3761 let env = Env::new();
3762 let v = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0]).unwrap());
3763 let result = plugin.call("xlim", &[v], &env);
3764 assert!(result.is_err());
3765 }
3766
3767 #[test]
3768 #[cfg(not(feature = "plot-svg"))]
3769 fn test_svg_without_feature() {
3770 let plugin = PlotPlugin;
3771 let env = Env::new();
3772 let y = f64_vec(&[1.0, 2.0, 3.0]);
3773 let path = Value::Str("out.svg".into());
3774 let result = plugin.call("plot", &[y, path], &env);
3775 assert!(result.is_err());
3776 }
3777
3778 #[test]
3781 #[cfg(feature = "plot")]
3782 fn test_plot_ascii_no_error() {
3783 let plugin = PlotPlugin;
3784 let env = Env::new();
3785 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0, 25.0]);
3786 assert!(plugin.call("plot", &[y], &env).is_ok());
3787 }
3788
3789 #[test]
3790 #[cfg(feature = "plot")]
3791 fn test_scatter_ascii_no_error() {
3792 let plugin = PlotPlugin;
3793 let env = Env::new();
3794 let x = f64_vec(&[1.0, 2.0, 3.0, 4.0]);
3795 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
3796 assert!(plugin.call("scatter", &[x, y], &env).is_ok());
3797 }
3798
3799 #[test]
3800 #[cfg(feature = "plot")]
3801 fn test_figure_state_cleared_after_render() {
3802 let plugin = PlotPlugin;
3803 let env = Env::new();
3804 plugin
3805 .call("title", &[Value::Str("Temp".into())], &env)
3806 .unwrap();
3807 let y = f64_vec(&[1.0, 2.0, 3.0]);
3808 plugin.call("plot", &[y], &env).unwrap();
3809 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
3811 assert!(
3812 title.is_none(),
3813 "FigureState should be cleared after plot()"
3814 );
3815 }
3816
3817 #[test]
3820 fn test_plot3_length_mismatch_error() {
3821 let plugin = PlotPlugin;
3822 let env = Env::new();
3823 let x = f64_vec(&[1.0, 2.0, 3.0]);
3824 let y = f64_vec(&[1.0, 2.0]);
3825 let z = f64_vec(&[0.0, 0.0, 0.0]);
3826 let result = plugin.call("plot3", &[x, y, z], &env);
3827 assert!(result.is_err());
3828 let msg = result.unwrap_err();
3829 assert!(
3830 msg.contains("same length"),
3831 "error should mention length: {msg}"
3832 );
3833 }
3834
3835 #[test]
3836 fn test_scatter3_wrong_arg_count_error() {
3837 let plugin = PlotPlugin;
3838 let env = Env::new();
3839 let x = f64_vec(&[1.0, 2.0]);
3840 let y = f64_vec(&[1.0, 2.0]);
3841 let result = plugin.call("scatter3", &[x, y], &env);
3843 assert!(result.is_err());
3844 let msg = result.unwrap_err();
3845 assert!(
3846 msg.contains("3 arguments"),
3847 "error should mention 3 args: {msg}"
3848 );
3849 }
3850
3851 #[test]
3852 #[cfg(feature = "plot")]
3853 fn test_plot3_ascii_no_error() {
3854 let plugin = PlotPlugin;
3855 let env = Env::new();
3856 let x = f64_vec(&[0.0, 1.0, 2.0, 3.0]);
3857 let y = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
3858 let z = f64_vec(&[0.0, 0.5, 1.0, 0.5]);
3859 let result = plugin.call("plot3", &[x, y, z], &env);
3860 assert!(result.is_ok(), "plot3 ASCII should succeed: {result:?}");
3861 }
3862
3863 #[test]
3864 #[cfg(feature = "plot")]
3865 fn test_scatter3_ascii_no_error() {
3866 let plugin = PlotPlugin;
3867 let env = Env::new();
3868 let x = f64_vec(&[0.0, 1.0, 2.0]);
3869 let y = f64_vec(&[0.0, 1.0, 0.0]);
3870 let z = f64_vec(&[1.0, 2.0, 3.0]);
3871 let result = plugin.call("scatter3", &[x, y, z], &env);
3872 assert!(result.is_ok(), "scatter3 ASCII should succeed: {result:?}");
3873 }
3874
3875 #[test]
3876 #[cfg(feature = "plot")]
3877 fn test_plot3_state_cleared_after_render() {
3878 FIGURE_STATE.with(|f| f.take());
3879 let plugin = PlotPlugin;
3880 let env = Env::new();
3881 plugin
3882 .call("zlabel", &[Value::Str("depth".into())], &env)
3883 .unwrap();
3884 let x = f64_vec(&[0.0, 1.0, 2.0]);
3885 let y = f64_vec(&[0.0, 1.0, 2.0]);
3886 let z = f64_vec(&[0.0, 1.0, 2.0]);
3887 plugin.call("plot3", &[x, y, z], &env).unwrap();
3888 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
3889 assert!(
3890 zlabel.is_none(),
3891 "FigureState.zlabel should be cleared after plot3()"
3892 );
3893 }
3894
3895 #[test]
3896 #[cfg(not(feature = "plot-svg"))]
3897 fn test_plot3_svg_without_feature() {
3898 let plugin = PlotPlugin;
3899 let env = Env::new();
3900 let x = f64_vec(&[0.0, 1.0]);
3901 let y = f64_vec(&[0.0, 1.0]);
3902 let z = f64_vec(&[0.0, 1.0]);
3903 let path = Value::Str("out.svg".into());
3904 let result = plugin.call("plot3", &[x, y, z, path], &env);
3905 assert!(result.is_err());
3906 let msg = result.unwrap_err();
3907 assert!(
3908 msg.contains("plot-svg"),
3909 "error should mention plot-svg feature: {msg}"
3910 );
3911 }
3912
3913 #[test]
3916 fn test_colormap_sets_state() {
3917 FIGURE_STATE.with(|f| f.take());
3918 let plugin = PlotPlugin;
3919 let env = Env::new();
3920 plugin
3921 .call("colormap", &[Value::Str("hot".into())], &env)
3922 .unwrap();
3923 let cmap = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
3924 assert_eq!(cmap, Some(colormap::ColormapSpec::Named("hot".to_string())));
3925 FIGURE_STATE.with(|f| f.take());
3926 }
3927
3928 #[test]
3929 fn test_colorbar_sets_state() {
3930 FIGURE_STATE.with(|f| f.take());
3931 let plugin = PlotPlugin;
3932 let env = Env::new();
3933 plugin.call("colorbar", &[], &env).unwrap();
3934 let cb = FIGURE_STATE.with(|f| f.borrow().colorbar);
3935 assert!(cb, "colorbar should set FigureState.colorbar = true");
3936 FIGURE_STATE.with(|f| f.take());
3937 }
3938
3939 #[test]
3942 fn test_style_rgb_matrix_dispatch() {
3943 FIGURE_STATE.with(|f| f.take());
3944 let plugin = PlotPlugin;
3945 let env = Env::new();
3946 plugin
3947 .call("hold", &[Value::Str("on".into())], &env)
3948 .unwrap();
3949 let x = f64_vec(&[1.0, 2.0]);
3950 let y = f64_vec(&[1.0, 2.0]);
3951 let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).unwrap());
3952 plugin.call("plot", &[x, y, m], &env).unwrap();
3953 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3954 assert_eq!(series.len(), 1, "should have one pending series");
3955 if let PendingSeries::Line(_, _, style) = &series[0] {
3956 assert_eq!(
3957 style.as_ref().and_then(|s| s.color),
3958 Some(style::StyleColor(255, 0, 0))
3959 );
3960 } else {
3961 panic!("expected PendingSeries::Line");
3962 }
3963 FIGURE_STATE.with(|f| f.take());
3964 }
3965
3966 #[test]
3967 fn test_style_color_named_arg_bar() {
3968 FIGURE_STATE.with(|f| f.take());
3969 let plugin = PlotPlugin;
3970 let env = Env::new();
3971 plugin
3972 .call("hold", &[Value::Str("on".into())], &env)
3973 .unwrap();
3974 let v = f64_vec(&[1.0, 2.0, 3.0]);
3975 plugin
3976 .call(
3977 "bar",
3978 &[v, Value::Str("color".into()), Value::Str("blue".into())],
3979 &env,
3980 )
3981 .expect("bar with 'color' named arg should succeed");
3982 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3983 assert_eq!(series.len(), 1);
3984 if let PendingSeries::Bar(_, _, style) = &series[0] {
3985 assert_eq!(
3986 style.as_ref().and_then(|s| s.color),
3987 Some(style::StyleColor(0, 0, 255)),
3988 "bar should carry blue style"
3989 );
3990 } else {
3991 panic!("expected PendingSeries::Bar");
3992 }
3993 FIGURE_STATE.with(|f| f.take());
3994 }
3995
3996 #[test]
3997 fn test_style_color_named_arg_hex() {
3998 FIGURE_STATE.with(|f| f.take());
3999 let plugin = PlotPlugin;
4000 let env = Env::new();
4001 plugin
4002 .call("hold", &[Value::Str("on".into())], &env)
4003 .unwrap();
4004 let v = f64_vec(&[1.0, 2.0, 3.0]);
4005 plugin
4006 .call(
4007 "bar",
4008 &[v, Value::Str("color".into()), Value::Str("#FF4400".into())],
4009 &env,
4010 )
4011 .expect("bar with hex color should succeed");
4012 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4013 assert_eq!(series.len(), 1);
4014 if let PendingSeries::Bar(_, _, style) = &series[0] {
4015 assert_eq!(
4016 style.as_ref().and_then(|s| s.color),
4017 Some(style::StyleColor(0xFF, 0x44, 0x00)),
4018 "bar should carry #FF4400 style"
4019 );
4020 } else {
4021 panic!("expected PendingSeries::Bar");
4022 }
4023 FIGURE_STATE.with(|f| f.take());
4024 }
4025
4026 #[test]
4027 fn test_colormap_matrix_dispatch() {
4028 FIGURE_STATE.with(|f| f.take());
4029 let plugin = PlotPlugin;
4030 let env = Env::new();
4031 let m = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap();
4032 let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
4033 assert!(
4034 result.is_ok(),
4035 "colormap(N×3 matrix) should succeed: {result:?}"
4036 );
4037 let spec = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
4038 assert!(
4039 matches!(spec, Some(colormap::ColormapSpec::Custom(_))),
4040 "should store ColormapSpec::Custom"
4041 );
4042 FIGURE_STATE.with(|f| f.take());
4043 }
4044
4045 #[test]
4046 fn test_colormap_matrix_wrong_cols() {
4047 let plugin = PlotPlugin;
4048 let env = Env::new();
4049 let m = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).unwrap();
4050 let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
4051 assert!(result.is_err());
4052 let msg = result.unwrap_err();
4053 assert!(msg.contains("N×3"), "error should mention N×3: {msg}");
4054 }
4055
4056 #[test]
4059 fn test_bar_accumulates_with_style_red() {
4060 FIGURE_STATE.with(|f| f.take());
4061 let plugin = PlotPlugin;
4062 let env = Env::new();
4063 plugin
4064 .call("hold", &[Value::Str("on".into())], &env)
4065 .unwrap();
4066 let x = f64_vec(&[1.0, 2.0, 3.0]);
4067 let y = f64_vec(&[4.0, 5.0, 6.0]);
4068 plugin
4069 .call("bar", &[x, y, Value::Str("r".into())], &env)
4070 .unwrap();
4071 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4072 assert_eq!(series.len(), 1, "should have one bar series");
4073 if let PendingSeries::Bar(_, _, style) = &series[0] {
4074 assert_eq!(
4075 style.as_ref().and_then(|s| s.color),
4076 Some(style::StyleColor(255, 0, 0)),
4077 "bar should carry red style"
4078 );
4079 } else {
4080 panic!("expected PendingSeries::Bar");
4081 }
4082 FIGURE_STATE.with(|f| f.take());
4083 }
4084
4085 #[test]
4086 fn test_stem_accumulates_with_style_blue() {
4087 FIGURE_STATE.with(|f| f.take());
4088 let plugin = PlotPlugin;
4089 let env = Env::new();
4090 plugin
4091 .call("hold", &[Value::Str("on".into())], &env)
4092 .unwrap();
4093 let x = f64_vec(&[1.0, 2.0, 3.0]);
4094 let y = f64_vec(&[1.0, 2.0, 3.0]);
4095 plugin
4096 .call("stem", &[x, y, Value::Str("blue".into())], &env)
4097 .unwrap();
4098 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4099 assert_eq!(series.len(), 1, "should have one stem series");
4100 if let PendingSeries::Stem(_, _, style) = &series[0] {
4101 assert_eq!(
4102 style.as_ref().and_then(|s| s.color),
4103 Some(style::StyleColor(0, 0, 255)),
4104 "stem should carry blue style"
4105 );
4106 } else {
4107 panic!("expected PendingSeries::Stem");
4108 }
4109 FIGURE_STATE.with(|f| f.take());
4110 }
4111
4112 #[test]
4113 fn test_hist_accumulates_with_style_hex() {
4114 FIGURE_STATE.with(|f| f.take());
4115 let plugin = PlotPlugin;
4116 let env = Env::new();
4117 plugin
4118 .call("hold", &[Value::Str("on".into())], &env)
4119 .unwrap();
4120 let data = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
4121 plugin
4122 .call("hist", &[data, Value::Str("#FF8800".into())], &env)
4123 .unwrap();
4124 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4125 assert_eq!(series.len(), 1, "should have one hist series");
4126 if let PendingSeries::Hist { style, .. } = &series[0] {
4127 assert_eq!(
4128 style.as_ref().and_then(|s| s.color),
4129 Some(style::StyleColor(0xFF, 0x88, 0x00)),
4130 "hist should carry hex colour style"
4131 );
4132 } else {
4133 panic!("expected PendingSeries::Hist");
4134 }
4135 FIGURE_STATE.with(|f| f.take());
4136 }
4137
4138 #[test]
4139 fn test_quiver_accumulates_with_style_green() {
4140 FIGURE_STATE.with(|f| f.take());
4141 let plugin = PlotPlugin;
4142 let env = Env::new();
4143 plugin
4144 .call("hold", &[Value::Str("on".into())], &env)
4145 .unwrap();
4146 let x = f64_vec(&[0.0, 1.0]);
4147 let y = f64_vec(&[0.0, 1.0]);
4148 let u = f64_vec(&[1.0, 0.0]);
4149 let v = f64_vec(&[0.0, 1.0]);
4150 plugin
4151 .call("quiver", &[x, y, u, v, Value::Str("g".into())], &env)
4152 .unwrap();
4153 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4154 assert_eq!(series.len(), 1, "should have one quiver series");
4155 if let PendingSeries::Quiver(_, _, _, _, style) = &series[0] {
4156 assert_eq!(
4157 style.as_ref().and_then(|s| s.color),
4158 Some(style::StyleColor(0, 128, 0)),
4159 "quiver should carry green style"
4160 );
4161 } else {
4162 panic!("expected PendingSeries::Quiver");
4163 }
4164 FIGURE_STATE.with(|f| f.take());
4165 }
4166
4167 #[test]
4168 fn test_bar_no_style_stores_none() {
4169 FIGURE_STATE.with(|f| f.take());
4170 let plugin = PlotPlugin;
4171 let env = Env::new();
4172 plugin
4173 .call("hold", &[Value::Str("on".into())], &env)
4174 .unwrap();
4175 let x = f64_vec(&[1.0, 2.0]);
4176 let y = f64_vec(&[3.0, 4.0]);
4177 plugin.call("bar", &[x, y], &env).unwrap();
4178 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4179 if let PendingSeries::Bar(_, _, style) = &series[0] {
4180 assert!(style.is_none(), "unstyled bar should have None style");
4181 } else {
4182 panic!("expected PendingSeries::Bar");
4183 }
4184 FIGURE_STATE.with(|f| f.take());
4185 }
4186
4187 #[test]
4188 #[cfg(feature = "plot-svg")]
4189 fn test_bar_svg_with_red_style() {
4190 FIGURE_STATE.with(|f| f.take());
4191 let plugin = PlotPlugin;
4192 let env = Env::new();
4193 let tmp = std::env::temp_dir().join("bar_red_30_5c.svg");
4194 let path = tmp.to_string_lossy().to_string();
4195 let x = f64_vec(&[1.0, 2.0, 3.0]);
4196 let y = f64_vec(&[4.0, 5.0, 3.0]);
4197 let result = plugin.call(
4198 "bar",
4199 &[x, y, Value::Str("r".into()), Value::Str(path.clone())],
4200 &env,
4201 );
4202 assert!(
4203 result.is_ok(),
4204 "bar with red style to SVG should succeed: {result:?}"
4205 );
4206 assert!(
4207 std::path::Path::new(&path).exists(),
4208 "SVG file should be created"
4209 );
4210 let _ = std::fs::remove_file(&path);
4211 FIGURE_STATE.with(|f| f.take());
4212 }
4213
4214 #[test]
4217 fn test_figure_sets_canvas_size() {
4218 FIGURE_STATE.with(|f| f.take());
4219 let plugin = PlotPlugin;
4220 let env = Env::new();
4221 plugin
4222 .call(
4223 "figure",
4224 &[Value::Scalar(1200.0), Value::Scalar(400.0)],
4225 &env,
4226 )
4227 .unwrap();
4228 let size = FIGURE_STATE.with(|f| f.borrow().figure_size);
4229 assert_eq!(size, Some((1200, 400)));
4230 FIGURE_STATE.with(|f| f.take());
4231 }
4232
4233 #[test]
4234 fn test_figure_default_canvas_size() {
4235 FIGURE_STATE.with(|f| f.take());
4236 let st = FIGURE_STATE.with(|f| f.take());
4237 assert_eq!(st.canvas_size(), (800, 600));
4238 }
4239
4240 #[test]
4241 fn test_figure_wrong_arg_count_errors() {
4242 let plugin = PlotPlugin;
4243 let env = Env::new();
4244 let result = plugin.call("figure", &[Value::Scalar(800.0)], &env);
4245 assert!(result.is_err());
4246 let result = plugin.call("figure", &[], &env);
4247 assert!(result.is_err());
4248 }
4249
4250 #[test]
4251 fn test_figure_invalid_size_errors() {
4252 let plugin = PlotPlugin;
4253 let env = Env::new();
4254 let result = plugin.call("figure", &[Value::Scalar(0.0), Value::Scalar(600.0)], &env);
4255 assert!(result.is_err(), "width 0 should error");
4256 let result = plugin.call(
4257 "figure",
4258 &[Value::Scalar(800.0), Value::Scalar(20000.0)],
4259 &env,
4260 );
4261 assert!(result.is_err(), "height > 16384 should error");
4262 }
4263
4264 #[test]
4265 fn test_figure_in_builtin_names() {
4266 use ccalc_engine::eval::builtin_names;
4267 assert!(
4268 builtin_names().contains(&"figure"),
4269 "figure missing from builtin_names"
4270 );
4271 }
4272
4273 #[test]
4274 fn test_colormap_invalid_name_errors() {
4275 let plugin = PlotPlugin;
4276 let env = Env::new();
4277 let result = plugin.call("colormap", &[Value::Str("notacolormap".into())], &env);
4278 assert!(result.is_err());
4279 let msg = result.unwrap_err();
4280 assert!(
4281 msg.contains("colormap"),
4282 "error should mention colormap: {msg}"
4283 );
4284 }
4285
4286 #[test]
4287 fn test_apply_colormap_gray_extremes() {
4288 let (r, g, b) = colormap::apply_colormap(0.0, "gray");
4289 assert_eq!((r, g, b), (0, 0, 0));
4290 let (r, g, b) = colormap::apply_colormap(1.0, "gray");
4291 assert_eq!((r, g, b), (255, 255, 255));
4292 }
4293
4294 #[test]
4295 fn test_imagesc_non_matrix_errors() {
4296 let plugin = PlotPlugin;
4297 let env = Env::new();
4298 let result = plugin.call("imagesc", &[Value::Str("notamatrix".into())], &env);
4299 assert!(result.is_err());
4300 }
4301
4302 #[test]
4303 fn test_imagesc_no_args_errors() {
4304 let plugin = PlotPlugin;
4305 let env = Env::new();
4306 let result = plugin.call("imagesc", &[], &env);
4307 assert!(result.is_err());
4308 }
4309
4310 #[test]
4311 #[cfg(not(feature = "plot-svg"))]
4312 fn test_imagesc_svg_without_feature_errors() {
4313 let plugin = PlotPlugin;
4314 let env = Env::new();
4315 let z = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap());
4316 let path = Value::Str("out.svg".into());
4317 let result = plugin.call("imagesc", &[z, path], &env);
4318 assert!(result.is_err());
4319 let msg = result.unwrap_err();
4320 assert!(
4321 msg.contains("plot-svg"),
4322 "error should mention plot-svg feature: {msg}"
4323 );
4324 }
4325
4326 #[test]
4327 #[cfg(feature = "plot")]
4328 fn test_imagesc_ascii_no_error() {
4329 FIGURE_STATE.with(|f| f.take());
4330 let plugin = PlotPlugin;
4331 let env = Env::new();
4332 let z = Value::Matrix(
4333 Array2::from_shape_vec((4, 4), (0..16).map(|i| i as f64).collect()).unwrap(),
4334 );
4335 let result = plugin.call("imagesc", &[z], &env);
4336 assert!(result.is_ok(), "imagesc ASCII should succeed: {result:?}");
4337 }
4338
4339 #[test]
4340 #[cfg(feature = "plot")]
4341 fn test_imagesc_ascii_with_colorbar_no_error() {
4342 FIGURE_STATE.with(|f| f.take());
4343 let plugin = PlotPlugin;
4344 let env = Env::new();
4345 plugin
4346 .call("colormap", &[Value::Str("jet".into())], &env)
4347 .unwrap();
4348 plugin.call("colorbar", &[], &env).unwrap();
4349 let z = Value::Matrix(
4350 Array2::from_shape_vec((3, 3), (0..9).map(|i| i as f64).collect()).unwrap(),
4351 );
4352 let result = plugin.call("imagesc", &[z], &env);
4353 assert!(
4354 result.is_ok(),
4355 "imagesc with colorbar should succeed: {result:?}"
4356 );
4357 }
4358
4359 #[allow(dead_code)]
4362 fn make_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
4363 let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| c as f64));
4364 let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| r as f64));
4365 let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| (r + c) as f64));
4366 (x, y, z)
4367 }
4368
4369 #[test]
4370 fn test_surf_dimension_mismatch_error() {
4371 FIGURE_STATE.with(|f| f.take());
4372 let plugin = PlotPlugin;
4373 let env = Env::new();
4374 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
4375 let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![1.0; 6]).unwrap());
4376 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
4377 let err = plugin.call("surf", &[x, y, z], &env).unwrap_err();
4378 assert!(
4379 err.contains("same dimensions"),
4380 "error should mention dimensions: {err}"
4381 );
4382 }
4383
4384 #[test]
4385 fn test_mesh_dimension_mismatch_error() {
4386 FIGURE_STATE.with(|f| f.take());
4387 let plugin = PlotPlugin;
4388 let env = Env::new();
4389 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
4390 let y = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
4391 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
4392 let err = plugin.call("mesh", &[x, y, z], &env).unwrap_err();
4393 assert!(
4394 err.contains("same dimensions"),
4395 "error should mention dimensions: {err}"
4396 );
4397 }
4398
4399 #[test]
4400 fn test_surf_missing_args_error() {
4401 FIGURE_STATE.with(|f| f.take());
4402 let plugin = PlotPlugin;
4403 let env = Env::new();
4404 let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
4405 let err = plugin.call("surf", &[x], &env).unwrap_err();
4406 assert!(
4407 err.contains("requires"),
4408 "error should mention requires: {err}"
4409 );
4410 }
4411
4412 #[test]
4413 #[cfg(feature = "plot")]
4414 fn test_surf_ascii_no_error() {
4415 FIGURE_STATE.with(|f| f.take());
4416 let plugin = PlotPlugin;
4417 let env = Env::new();
4418 let (x, y, z) = make_xyz(5, 8);
4419 let result = plugin.call("surf", &[x, y, z], &env);
4420 assert!(result.is_ok(), "surf ASCII should succeed: {result:?}");
4421 }
4422
4423 #[test]
4424 #[cfg(feature = "plot")]
4425 fn test_mesh_ascii_no_error() {
4426 FIGURE_STATE.with(|f| f.take());
4427 let plugin = PlotPlugin;
4428 let env = Env::new();
4429 let (x, y, z) = make_xyz(5, 8);
4430 let result = plugin.call("mesh", &[x, y, z], &env);
4431 assert!(result.is_ok(), "mesh ASCII should succeed: {result:?}");
4432 }
4433
4434 #[test]
4435 #[cfg(feature = "plot-svg")]
4436 fn test_surf_svg_creates_file() {
4437 FIGURE_STATE.with(|f| f.take());
4438 let plugin = PlotPlugin;
4439 let env = Env::new();
4440 let (x, y, z) = make_xyz(4, 5);
4441 let path = ".debug/test_surf.svg";
4442 std::fs::create_dir_all(".debug").ok();
4443 let result = plugin.call("surf", &[x, y, z, Value::Str(path.into())], &env);
4444 assert!(result.is_ok(), "surf SVG should succeed: {result:?}");
4445 let content = std::fs::read_to_string(path).unwrap();
4446 assert!(
4447 content.contains("<svg"),
4448 "output should be SVG: starts with {}",
4449 &content[..50.min(content.len())]
4450 );
4451 std::fs::remove_file(path).ok();
4452 }
4453
4454 #[test]
4455 #[cfg(feature = "plot-svg")]
4456 fn test_mesh_png_creates_file() {
4457 FIGURE_STATE.with(|f| f.take());
4458 let plugin = PlotPlugin;
4459 let env = Env::new();
4460 let (x, y, z) = make_xyz(4, 5);
4461 let path = ".debug/test_mesh.png";
4462 std::fs::create_dir_all(".debug").ok();
4463 let result = plugin.call("mesh", &[x, y, z, Value::Str(path.into())], &env);
4464 assert!(result.is_ok(), "mesh PNG should succeed: {result:?}");
4465 let bytes = std::fs::read(path).unwrap();
4466 assert_eq!(
4468 &bytes[0..4],
4469 &[0x89, 0x50, 0x4E, 0x47],
4470 "output should be PNG"
4471 );
4472 std::fs::remove_file(path).ok();
4473 }
4474
4475 #[allow(dead_code)]
4478 fn make_contour_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
4479 let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| {
4481 -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64
4482 }));
4483 let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| {
4484 -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64
4485 }));
4486 let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| {
4487 let xi = -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64;
4488 let yi = -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64;
4489 (-xi * xi - yi * yi).exp()
4490 }));
4491 (x, y, z)
4492 }
4493
4494 #[test]
4495 fn test_contour_non_matrix_x_errors() {
4496 FIGURE_STATE.with(|f| f.take());
4497 let plugin = PlotPlugin;
4498 let env = Env::new();
4499 let x = Value::Str("notamatrix".into());
4500 let y = f64_vec(&[0.0, 1.0]);
4501 let z = f64_vec(&[0.0, 1.0]);
4502 let result = plugin.call("contour", &[x, y, z], &env);
4503 assert!(result.is_err(), "non-matrix X should error");
4504 let msg = result.unwrap_err();
4505 assert!(msg.contains("X"), "error should mention X: {msg}");
4506 }
4507
4508 #[test]
4509 fn test_contour_mismatched_dimensions_errors() {
4510 FIGURE_STATE.with(|f| f.take());
4511 let plugin = PlotPlugin;
4512 let env = Env::new();
4513 let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
4514 let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![0.0; 6]).unwrap());
4515 let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
4516 let result = plugin.call("contour", &[x, y, z], &env);
4517 assert!(result.is_err(), "mismatched dimensions should error");
4518 let msg = result.unwrap_err();
4519 assert!(
4520 msg.contains("same dimensions"),
4521 "error should mention dimensions: {msg}"
4522 );
4523 }
4524
4525 #[test]
4526 fn test_contour_missing_args_errors() {
4527 FIGURE_STATE.with(|f| f.take());
4528 let plugin = PlotPlugin;
4529 let env = Env::new();
4530 let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![0.0; 4]).unwrap());
4531 let result = plugin.call("contour", &[x], &env);
4532 assert!(result.is_err());
4533 let msg = result.unwrap_err();
4534 assert!(
4535 msg.contains("requires"),
4536 "error should mention requires: {msg}"
4537 );
4538 }
4539
4540 #[test]
4541 #[cfg(feature = "plot")]
4542 fn test_contour_ascii_no_error() {
4543 FIGURE_STATE.with(|f| f.take());
4544 let plugin = PlotPlugin;
4545 let env = Env::new();
4546 let (x, y, z) = make_contour_xyz(10, 12);
4547 let result = plugin.call("contour", &[x, y, z, Value::Scalar(5.0)], &env);
4548 assert!(result.is_ok(), "contour ASCII should succeed: {result:?}");
4549 }
4550
4551 #[test]
4552 #[cfg(feature = "plot")]
4553 fn test_contourf_ascii_no_error() {
4554 FIGURE_STATE.with(|f| f.take());
4555 let plugin = PlotPlugin;
4556 let env = Env::new();
4557 let (x, y, z) = make_contour_xyz(10, 12);
4558 let result = plugin.call("contourf", &[x, y, z, Value::Scalar(5.0)], &env);
4559 assert!(result.is_ok(), "contourf ASCII should succeed: {result:?}");
4560 }
4561
4562 #[test]
4563 #[cfg(feature = "plot-svg")]
4564 fn test_contour_svg_creates_file() {
4565 FIGURE_STATE.with(|f| f.take());
4566 let plugin = PlotPlugin;
4567 let env = Env::new();
4568 let (x, y, z) = make_contour_xyz(15, 20);
4569 let path = ".debug/test_contour.svg";
4570 std::fs::create_dir_all(".debug").ok();
4571 let result = plugin.call(
4572 "contour",
4573 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
4574 &env,
4575 );
4576 assert!(result.is_ok(), "contour SVG should succeed: {result:?}");
4577 let content = std::fs::read_to_string(path).unwrap();
4578 assert!(
4579 content.contains("<svg"),
4580 "output should be SVG: starts with {}",
4581 &content[..50.min(content.len())]
4582 );
4583 std::fs::remove_file(path).ok();
4584 }
4585
4586 #[test]
4587 #[cfg(feature = "plot-svg")]
4588 fn test_contourf_png_magic_bytes() {
4589 FIGURE_STATE.with(|f| f.take());
4590 let plugin = PlotPlugin;
4591 let env = Env::new();
4592 let (x, y, z) = make_contour_xyz(15, 20);
4593 let path = ".debug/test_contourf.png";
4594 std::fs::create_dir_all(".debug").ok();
4595 let result = plugin.call(
4596 "contourf",
4597 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
4598 &env,
4599 );
4600 assert!(result.is_ok(), "contourf PNG should succeed: {result:?}");
4601 let bytes = std::fs::read(path).unwrap();
4602 assert_eq!(
4603 &bytes[0..4],
4604 &[0x89, 0x50, 0x4E, 0x47],
4605 "output should be PNG"
4606 );
4607 std::fs::remove_file(path).ok();
4608 }
4609
4610 #[test]
4613 fn test_subplot_sets_state() {
4614 FIGURE_STATE.with(|f| f.take());
4615 let plugin = PlotPlugin;
4616 let env = Env::new();
4617 plugin
4618 .call(
4619 "subplot",
4620 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(1.0)],
4621 &env,
4622 )
4623 .unwrap();
4624 let subplot = FIGURE_STATE.with(|f| f.borrow().subplot);
4625 assert_eq!(subplot, Some((2, 2, 1)));
4626 FIGURE_STATE.with(|f| f.take());
4627 }
4628
4629 #[test]
4630 fn test_hold_on_sets_flag() {
4631 FIGURE_STATE.with(|f| f.take());
4632 let plugin = PlotPlugin;
4633 let env = Env::new();
4634 plugin
4635 .call("hold", &[Value::Str("on".into())], &env)
4636 .unwrap();
4637 let hold = FIGURE_STATE.with(|f| f.borrow().hold);
4638 assert!(hold, "hold flag should be true after hold('on')");
4639 FIGURE_STATE.with(|f| f.take());
4640 }
4641
4642 #[test]
4643 fn test_hold_off_clears_flag_and_series() {
4644 FIGURE_STATE.with(|f| f.take());
4645 let plugin = PlotPlugin;
4646 let env = Env::new();
4647 FIGURE_STATE.with(|f| {
4649 let mut st = f.borrow_mut();
4650 st.hold = true;
4651 st.pending_series
4652 .push(PendingSeries::Line(vec![1.0, 2.0], vec![1.0, 4.0], None));
4653 });
4654 let _ = plugin.call("hold", &[Value::Str("off".into())], &env);
4657 let (hold, series_empty) = FIGURE_STATE.with(|f| {
4658 let st = f.borrow();
4659 (st.hold, st.pending_series.is_empty())
4660 });
4661 assert!(!hold, "hold should be false after hold('off')");
4662 assert!(
4663 series_empty,
4664 "pending_series should be cleared after hold('off')"
4665 );
4666 FIGURE_STATE.with(|f| f.take());
4667 }
4668
4669 #[test]
4670 fn test_plot_accumulates_under_hold() {
4671 FIGURE_STATE.with(|f| f.take());
4672 let plugin = PlotPlugin;
4673 let env = Env::new();
4674 plugin
4675 .call("hold", &[Value::Str("on".into())], &env)
4676 .unwrap();
4677 let y1 = f64_vec(&[1.0, 2.0, 3.0]);
4678 let y2 = f64_vec(&[3.0, 2.0, 1.0]);
4679 plugin.call("plot", &[y1], &env).unwrap();
4680 plugin.call("plot", &[y2], &env).unwrap();
4681 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
4682 assert_eq!(count, 2, "two plot calls should accumulate 2 series");
4683 FIGURE_STATE.with(|f| f.take());
4684 }
4685
4686 #[test]
4687 fn test_subplot_then_plot_accumulates() {
4688 FIGURE_STATE.with(|f| f.take());
4689 let plugin = PlotPlugin;
4690 let env = Env::new();
4691 plugin
4692 .call(
4693 "subplot",
4694 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4695 &env,
4696 )
4697 .unwrap();
4698 let y = f64_vec(&[1.0, 2.0, 3.0]);
4699 plugin.call("plot", &[y], &env).unwrap();
4700 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
4701 assert_eq!(
4702 count, 1,
4703 "plot under subplot should accumulate into pending_series"
4704 );
4705 FIGURE_STATE.with(|f| f.take());
4706 }
4707
4708 #[test]
4709 fn test_second_subplot_commits_first_panel() {
4710 FIGURE_STATE.with(|f| f.take());
4711 let plugin = PlotPlugin;
4712 let env = Env::new();
4713 plugin
4714 .call(
4715 "subplot",
4716 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4717 &env,
4718 )
4719 .unwrap();
4720 plugin.call("plot", &[f64_vec(&[1.0, 2.0])], &env).unwrap();
4721 plugin
4723 .call(
4724 "subplot",
4725 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
4726 &env,
4727 )
4728 .unwrap();
4729 let (panels_len, pending_len) = FIGURE_STATE.with(|f| {
4730 let st = f.borrow();
4731 (st.panels.len(), st.pending_series.len())
4732 });
4733 assert_eq!(panels_len, 1, "panel 1 should be committed");
4734 assert_eq!(
4735 pending_len, 0,
4736 "pending_series should be empty after commit"
4737 );
4738 FIGURE_STATE.with(|f| f.take());
4739 }
4740
4741 #[test]
4742 fn test_subplot_invalid_index_errors() {
4743 FIGURE_STATE.with(|f| f.take());
4744 let plugin = PlotPlugin;
4745 let env = Env::new();
4746 let result = plugin.call(
4747 "subplot",
4748 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(5.0)],
4749 &env,
4750 );
4751 assert!(result.is_err(), "index 5 in a 2×2 grid should error");
4752 FIGURE_STATE.with(|f| f.take());
4753 }
4754
4755 #[test]
4756 fn test_savefig_with_no_panels_errors() {
4757 FIGURE_STATE.with(|f| f.take());
4758 let plugin = PlotPlugin;
4759 let env = Env::new();
4760 let result = plugin.call("savefig", &[Value::Str("out.svg".into())], &env);
4761 assert!(result.is_err(), "savefig with no panels should error");
4762 FIGURE_STATE.with(|f| f.take());
4763 }
4764
4765 #[test]
4768 fn test_quiver_mismatch_error() {
4769 FIGURE_STATE.with(|f| f.take());
4770 let plugin = PlotPlugin;
4771 let env = Env::new();
4772 let x = f64_vec(&[0.0, 1.0, 2.0]);
4773 let y = f64_vec(&[0.0, 1.0, 2.0]);
4774 let u = f64_vec(&[1.0, 0.0]);
4775 let v = f64_vec(&[0.0, 1.0, 0.0]);
4776 let result = plugin.call("quiver", &[x, y, u, v], &env);
4777 assert!(result.is_err(), "length mismatch should produce an error");
4778 let msg = result.unwrap_err();
4779 assert!(
4780 msg.contains("same length"),
4781 "error should mention 'same length': {msg}"
4782 );
4783 }
4784
4785 #[test]
4786 fn test_text_stores_annotation() {
4787 FIGURE_STATE.with(|f| f.take());
4788 let plugin = PlotPlugin;
4789 let env = Env::new();
4790 plugin
4791 .call(
4792 "text",
4793 &[
4794 Value::Scalar(0.0),
4795 Value::Scalar(1.0),
4796 Value::Str("label".into()),
4797 ],
4798 &env,
4799 )
4800 .unwrap();
4801 let ann = FIGURE_STATE.with(|f| f.borrow().annotations.clone());
4802 assert_eq!(ann.len(), 1, "one annotation should be stored");
4803 assert_eq!(ann[0], (0.0, 1.0, "label".to_string()));
4804 FIGURE_STATE.with(|f| f.take());
4805 }
4806
4807 #[test]
4808 #[cfg(feature = "plot-svg")]
4809 fn test_quiver_svg_creates_file() {
4810 FIGURE_STATE.with(|f| f.take());
4811 let plugin = PlotPlugin;
4812 let env = Env::new();
4813 let x = f64_vec(&[0.0, 1.0, 0.0, 1.0]);
4814 let y = f64_vec(&[0.0, 0.0, 1.0, 1.0]);
4815 let u = f64_vec(&[1.0, 0.0, -1.0, 0.0]);
4816 let v = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
4817 let path = ".debug/test_quiver.svg";
4818 std::fs::create_dir_all(".debug").ok();
4819 let result = plugin.call("quiver", &[x, y, u, v, Value::Str(path.into())], &env);
4820 assert!(result.is_ok(), "quiver SVG should succeed: {result:?}");
4821 let content = std::fs::read_to_string(path).unwrap();
4822 assert!(
4823 content.contains("<svg"),
4824 "output should be SVG: starts with {}",
4825 &content[..50.min(content.len())]
4826 );
4827 std::fs::remove_file(path).ok();
4828 }
4829
4830 #[test]
4831 #[cfg(feature = "plot-svg")]
4832 fn test_subplot_savefig_creates_svg() {
4833 FIGURE_STATE.with(|f| f.take());
4834 let plugin = PlotPlugin;
4835 let env = Env::new();
4836 let path = ".debug/test_subplot_grid.svg";
4837 std::fs::create_dir_all(".debug").ok();
4838 plugin
4839 .call(
4840 "subplot",
4841 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4842 &env,
4843 )
4844 .unwrap();
4845 plugin
4846 .call("plot", &[f64_vec(&[1.0, 2.0, 3.0])], &env)
4847 .unwrap();
4848 plugin
4849 .call(
4850 "subplot",
4851 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
4852 &env,
4853 )
4854 .unwrap();
4855 plugin
4856 .call("plot", &[f64_vec(&[3.0, 2.0, 1.0])], &env)
4857 .unwrap();
4858 plugin
4859 .call("savefig", &[Value::Str(path.into())], &env)
4860 .unwrap();
4861 let content = std::fs::read_to_string(path).unwrap();
4862 assert!(
4863 content.contains("<svg"),
4864 "savefig should produce an SVG file"
4865 );
4866 std::fs::remove_file(path).ok();
4867 }
4868
4869 #[cfg(feature = "plot-svg")]
4870 #[test]
4871 fn test_figure_size_applied_to_svg() {
4872 FIGURE_STATE.with(|f| f.take());
4873 let plugin = PlotPlugin;
4874 let env = Env::new();
4875 let path = ".debug/test_figure_size.svg";
4876 std::fs::create_dir_all(".debug").ok();
4877 plugin
4878 .call(
4879 "figure",
4880 &[Value::Scalar(1024.0), Value::Scalar(300.0)],
4881 &env,
4882 )
4883 .unwrap();
4884 plugin
4885 .call(
4886 "plot",
4887 &[
4888 f64_vec(&[1.0, 2.0, 3.0]),
4889 f64_vec(&[1.0, 4.0, 9.0]),
4890 Value::Str(path.into()),
4891 ],
4892 &env,
4893 )
4894 .unwrap();
4895 let content = std::fs::read_to_string(path).unwrap();
4896 assert!(
4897 content.contains("1024"),
4898 "SVG should contain requested width"
4899 );
4900 assert!(
4901 content.contains("300"),
4902 "SVG should contain requested height"
4903 );
4904 std::fs::remove_file(path).ok();
4905 }
4906
4907 #[test]
4910 #[cfg(feature = "plot-svg")]
4911 fn test_theme_dark_svg_contains_dark_bg() {
4912 let plugin = PlotPlugin;
4913 let env = Env::new();
4914 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4915
4916 let path = ".debug/test_theme_dark.svg";
4917 plugin
4918 .call("theme", &[Value::Str("dark".into())], &env)
4919 .unwrap();
4920 plugin
4921 .call(
4922 "plot",
4923 &[
4924 f64_vec(&[1.0, 2.0]),
4925 f64_vec(&[1.0, 2.0]),
4926 Value::Str(path.into()),
4927 ],
4928 &env,
4929 )
4930 .unwrap();
4931 let content = std::fs::read_to_string(path).unwrap();
4932 assert!(
4934 content.contains("1E1E2E") || content.contains("1e1e2e"),
4935 "SVG must contain the dark theme background colour"
4936 );
4937 std::fs::remove_file(path).ok();
4938 }
4939
4940 #[test]
4941 fn test_theme_light_is_default() {
4942 let light = style::Theme::light();
4943 let st = FigureState::default();
4945 let resolved = st.resolve_theme();
4946 assert_eq!(resolved.bg, light.bg);
4947 assert_eq!(resolved.text, light.text);
4948 }
4949
4950 #[test]
4951 fn test_theme_unknown_name_errors() {
4952 let plugin = PlotPlugin;
4953 let env = Env::new();
4954 let result = plugin.call("theme", &[Value::Str("rainbow".into())], &env);
4955 assert!(result.is_err());
4956 assert!(result.unwrap_err().contains("unknown theme"));
4957 }
4958
4959 #[test]
4960 #[cfg(feature = "plot-svg")]
4961 fn test_bgcolor_overrides_theme_bg() {
4962 let plugin = PlotPlugin;
4963 let env = Env::new();
4964 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4965
4966 let path = ".debug/test_bgcolor_override.svg";
4967 plugin
4968 .call("theme", &[Value::Str("dark".into())], &env)
4969 .unwrap();
4970 plugin
4972 .call("bgcolor", &[Value::Str("red".into())], &env)
4973 .unwrap();
4974 plugin
4975 .call(
4976 "plot",
4977 &[
4978 f64_vec(&[1.0, 2.0]),
4979 f64_vec(&[1.0, 2.0]),
4980 Value::Str(path.into()),
4981 ],
4982 &env,
4983 )
4984 .unwrap();
4985 let content = std::fs::read_to_string(path).unwrap();
4986 assert!(
4988 !content.contains("1E1E2E") && !content.contains("1e1e2e"),
4989 "Dark theme bg should not appear when bgcolor overrides it"
4990 );
4991 std::fs::remove_file(path).ok();
4992 }
4993
4994 #[test]
4995 fn test_bgcolor_hex_accepted() {
4996 let plugin = PlotPlugin;
4997 let env = Env::new();
4998 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4999 plugin
5000 .call("bgcolor", &[Value::Str("#AABBCC".into())], &env)
5001 .unwrap();
5002 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
5003 assert_eq!(bg, Some(style::StyleColor(0xAA, 0xBB, 0xCC)));
5004 }
5005
5006 #[test]
5007 fn test_bgcolor_rgb_matrix() {
5008 use ndarray::Array2;
5009 let plugin = PlotPlugin;
5010 let env = Env::new();
5011 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5012 let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![0.0_f64, 0.5, 1.0]).unwrap());
5014 plugin.call("bgcolor", &[m], &env).unwrap();
5015 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
5016 assert_eq!(bg, Some(style::StyleColor(0, 128, 255)));
5017 }
5018
5019 #[test]
5022 fn test_linewidth_named_arg_plot() {
5023 let plugin = PlotPlugin;
5024 let env = Env::new();
5025 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5026 plugin
5027 .call("hold", &[Value::Str("on".into())], &env)
5028 .unwrap();
5029 plugin
5030 .call(
5031 "plot",
5032 &[
5033 f64_vec(&[0.0, 1.0]),
5034 f64_vec(&[0.0, 1.0]),
5035 Value::Str("r--".into()),
5036 Value::Str("linewidth".into()),
5037 Value::Scalar(2.5),
5038 ],
5039 &env,
5040 )
5041 .unwrap();
5042 let lw = FIGURE_STATE.with(|f| {
5043 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
5044 sp.line_width
5045 } else {
5046 None
5047 }
5048 });
5049 assert_eq!(lw, Some(2.5_f32));
5050 }
5051
5052 #[test]
5053 fn test_markersize_named_arg_scatter() {
5054 let plugin = PlotPlugin;
5055 let env = Env::new();
5056 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5057 plugin
5058 .call("hold", &[Value::Str("on".into())], &env)
5059 .unwrap();
5060 plugin
5061 .call(
5062 "scatter",
5063 &[
5064 f64_vec(&[1.0, 2.0]),
5065 f64_vec(&[1.0, 2.0]),
5066 Value::Str("markersize".into()),
5067 Value::Scalar(7.0),
5068 ],
5069 &env,
5070 )
5071 .unwrap();
5072 let ms = FIGURE_STATE.with(|f| {
5073 if let Some(PendingSeries::Scatter(_, _, Some(sp))) = f.borrow().pending_series.first()
5074 {
5075 sp.marker_size
5076 } else {
5077 None
5078 }
5079 });
5080 assert_eq!(ms, Some(7_u32));
5081 }
5082
5083 #[test]
5084 fn test_linewidth_and_markersize_combined() {
5085 let plugin = PlotPlugin;
5086 let env = Env::new();
5087 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5088 plugin
5089 .call("hold", &[Value::Str("on".into())], &env)
5090 .unwrap();
5091 plugin
5092 .call(
5093 "plot",
5094 &[
5095 f64_vec(&[0.0, 1.0]),
5096 f64_vec(&[0.0, 1.0]),
5097 Value::Str("b.".into()),
5098 Value::Str("linewidth".into()),
5099 Value::Scalar(1.5),
5100 Value::Str("markersize".into()),
5101 Value::Scalar(8.0),
5102 ],
5103 &env,
5104 )
5105 .unwrap();
5106 let (lw, ms) = FIGURE_STATE.with(|f| {
5107 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
5108 (sp.line_width, sp.marker_size)
5109 } else {
5110 (None, None)
5111 }
5112 });
5113 assert_eq!(lw, Some(1.5_f32));
5114 assert_eq!(ms, Some(8_u32));
5115 }
5116
5117 #[test]
5118 fn test_fontsize_global_setter() {
5119 let plugin = PlotPlugin;
5120 let env = Env::new();
5121 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5122 plugin
5123 .call("fontsize", &[Value::Scalar(18.0)], &env)
5124 .unwrap();
5125 let fs = FIGURE_STATE.with(|f| f.borrow().font_size);
5126 assert_eq!(fs, Some(18_u32));
5127 }
5128
5129 #[test]
5130 fn test_linewidth_global_setter() {
5131 let plugin = PlotPlugin;
5132 let env = Env::new();
5133 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5134 plugin
5135 .call("linewidth", &[Value::Scalar(3.0)], &env)
5136 .unwrap();
5137 let lw = FIGURE_STATE.with(|f| f.borrow().line_width);
5138 assert_eq!(lw, Some(3.0_f32));
5139 }
5140
5141 #[test]
5142 fn test_markersize_global_setter() {
5143 let plugin = PlotPlugin;
5144 let env = Env::new();
5145 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5146 plugin
5147 .call("markersize", &[Value::Scalar(5.0)], &env)
5148 .unwrap();
5149 let ms = FIGURE_STATE.with(|f| f.borrow().marker_size);
5150 assert_eq!(ms, Some(5_u32));
5151 }
5152
5153 #[test]
5156 fn test_gridcolor_named_color() {
5157 let plugin = PlotPlugin;
5158 let env = Env::new();
5159 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5160 plugin
5161 .call("gridcolor", &[Value::Str("red".into())], &env)
5162 .unwrap();
5163 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
5164 assert_eq!(gc, Some(StyleColor(255, 0, 0)));
5165 }
5166
5167 #[test]
5168 fn test_gridcolor_rgb_matrix() {
5169 let plugin = PlotPlugin;
5170 let env = Env::new();
5171 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5172 use ccalc_engine::env::Value;
5173 use ndarray::arr2;
5174 let m = Value::Matrix(arr2(&[[0.0_f64, 1.0, 0.0]]));
5175 plugin.call("gridcolor", &[m], &env).unwrap();
5176 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
5177 assert_eq!(gc, Some(StyleColor(0, 255, 0)));
5178 }
5179
5180 #[test]
5181 fn test_gridwidth_global_setter() {
5182 let plugin = PlotPlugin;
5183 let env = Env::new();
5184 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5185 plugin
5186 .call("gridwidth", &[Value::Scalar(2.0)], &env)
5187 .unwrap();
5188 let gw = FIGURE_STATE.with(|f| f.borrow().grid_width);
5189 assert_eq!(gw, Some(2.0_f32));
5190 }
5191
5192 #[test]
5195 fn test_axis_equal_sets_state() {
5196 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5197 let plugin = PlotPlugin;
5198 let env = Env::new();
5199 plugin
5200 .call("axis", &[Value::Str("equal".into())], &env)
5201 .unwrap();
5202 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5203 assert_eq!(mode, Some(style::AxisMode::Equal));
5204 FIGURE_STATE.with(|f| f.take());
5205 }
5206
5207 #[test]
5208 fn test_axis_tight_sets_state() {
5209 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5210 let plugin = PlotPlugin;
5211 let env = Env::new();
5212 plugin
5213 .call("axis", &[Value::Str("tight".into())], &env)
5214 .unwrap();
5215 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5216 assert_eq!(mode, Some(style::AxisMode::Tight));
5217 FIGURE_STATE.with(|f| f.take());
5218 }
5219
5220 #[test]
5221 fn test_axis_off_sets_state() {
5222 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5223 let plugin = PlotPlugin;
5224 let env = Env::new();
5225 plugin
5226 .call("axis", &[Value::Str("off".into())], &env)
5227 .unwrap();
5228 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5229 assert_eq!(mode, Some(style::AxisMode::Off));
5230 FIGURE_STATE.with(|f| f.take());
5231 }
5232
5233 #[test]
5234 fn test_axis_on_clears_mode() {
5235 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5236 let plugin = PlotPlugin;
5237 let env = Env::new();
5238 plugin
5239 .call("axis", &[Value::Str("equal".into())], &env)
5240 .unwrap();
5241 plugin
5242 .call("axis", &[Value::Str("on".into())], &env)
5243 .unwrap();
5244 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5245 assert_eq!(mode, None, "axis('on') should clear the axis mode");
5246 FIGURE_STATE.with(|f| f.take());
5247 }
5248
5249 #[test]
5250 fn test_axis_invalid_arg_errors() {
5251 let plugin = PlotPlugin;
5252 let env = Env::new();
5253 let result = plugin.call("axis", &[Value::Str("square".into())], &env);
5254 assert!(result.is_err());
5255 let msg = result.unwrap_err();
5256 assert!(
5257 msg.contains("expected"),
5258 "error should describe valid options: {msg}"
5259 );
5260 }
5261
5262 #[test]
5263 fn test_axis_mode_carried_into_panel() {
5264 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5265 let plugin = PlotPlugin;
5266 let env = Env::new();
5267 plugin
5268 .call("axis", &[Value::Str("tight".into())], &env)
5269 .unwrap();
5270 plugin
5271 .call("hold", &[Value::Str("on".into())], &env)
5272 .unwrap();
5273 plugin
5274 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
5275 .unwrap();
5276 plugin
5278 .call(
5279 "subplot",
5280 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
5281 &env,
5282 )
5283 .unwrap();
5284 let mode = FIGURE_STATE.with(|f| f.borrow().panels.first().and_then(|p| p.axis_mode));
5285 assert_eq!(
5286 mode,
5287 Some(style::AxisMode::Tight),
5288 "axis_mode should be carried into the committed panel"
5289 );
5290 FIGURE_STATE.with(|f| f.take());
5291 }
5292
5293 #[test]
5294 #[cfg(feature = "plot-svg")]
5295 fn test_axis_off_svg_no_error() {
5296 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5297 let plugin = PlotPlugin;
5298 let env = Env::new();
5299 plugin
5300 .call("axis", &[Value::Str("off".into())], &env)
5301 .unwrap();
5302 let tmp = std::env::temp_dir().join("axis_off_30_6d.svg");
5303 let path = tmp.to_string_lossy().to_string();
5304 let x = f64_vec(&[1.0, 2.0, 3.0]);
5305 let y = f64_vec(&[1.0, 4.0, 9.0]);
5306 let result = plugin.call("plot", &[x, y, Value::Str(path.clone())], &env);
5307 assert!(
5308 result.is_ok(),
5309 "axis('off') + plot to SVG should succeed: {result:?}"
5310 );
5311 let content = std::fs::read_to_string(&path).unwrap_or_default();
5312 assert!(content.contains("<svg"), "output should contain <svg");
5313 let _ = std::fs::remove_file(&path);
5314 FIGURE_STATE.with(|f| f.take());
5315 }
5316
5317 #[test]
5318 fn test_gridcolor_carried_into_panel() {
5319 let plugin = PlotPlugin;
5320 let env = Env::new();
5321 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5322 plugin
5323 .call("gridcolor", &[Value::Str("blue".into())], &env)
5324 .unwrap();
5325 plugin
5326 .call("gridwidth", &[Value::Scalar(3.0)], &env)
5327 .unwrap();
5328 plugin
5329 .call("hold", &[Value::Str("on".into())], &env)
5330 .unwrap();
5331 plugin
5332 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
5333 .unwrap();
5334 plugin
5336 .call(
5337 "subplot",
5338 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
5339 &env,
5340 )
5341 .unwrap();
5342 let (gc, gw) = FIGURE_STATE.with(|f| {
5343 f.borrow()
5344 .panels
5345 .first()
5346 .map(|p| (p.grid_color, p.grid_width))
5347 .unwrap_or((None, None))
5348 });
5349 assert_eq!(gc, Some(StyleColor(0, 0, 255)));
5350 assert_eq!(gw, Some(3.0_f32));
5351 }
5352
5353 #[test]
5356 fn pie_ascii_sums_100pct() {
5357 let values = vec![25.0_f64, 50.0, 25.0];
5359 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
5360 let out = format_pie_ascii(&values, &labels, &[]);
5361 let pct_sum: f64 = out
5363 .lines()
5364 .filter_map(|line| {
5365 let pct_part = line.split('%').next()?;
5366 let num = pct_part.rsplit_once(']')?.1.trim();
5367 num.parse::<f64>().ok()
5368 })
5369 .sum();
5370 assert!(
5371 (pct_sum - 100.0).abs() < 0.1,
5372 "percentages should sum to ~100, got {pct_sum}"
5373 );
5374 }
5375
5376 #[test]
5377 fn pie_ascii_contains_labels() {
5378 let values = vec![60.0_f64, 40.0];
5379 let labels: Vec<String> = vec!["Alpha".into(), "Beta".into()];
5380 let out = format_pie_ascii(&values, &labels, &[]);
5381 assert!(out.contains("Alpha"), "output should contain label 'Alpha'");
5382 assert!(out.contains("Beta"), "output should contain label 'Beta'");
5383 }
5384
5385 #[test]
5386 fn pie_ascii_explode_marker() {
5387 let values = vec![50.0_f64, 30.0, 20.0];
5388 let labels: Vec<String> = vec![String::new(); 3];
5389 let explode = vec![0.0_f64, 0.1, 0.0];
5390 let out = format_pie_ascii(&values, &labels, &explode);
5391 let lines: Vec<&str> = out.lines().collect();
5392 assert!(
5394 !lines[0].ends_with('\u{25c4}'),
5395 "non-exploded slice 0 should not have ◄"
5396 );
5397 assert!(
5398 lines[1].ends_with('\u{25c4}'),
5399 "exploded slice 1 should end with ◄"
5400 );
5401 assert!(
5402 !lines[2].ends_with('\u{25c4}'),
5403 "non-exploded slice 2 should not have ◄"
5404 );
5405 }
5406
5407 #[test]
5408 fn pie_dispatch_empty_error() {
5409 FIGURE_STATE.with(|f| f.take());
5410 let plugin = PlotPlugin;
5411 let env = Env::new();
5412 let err = plugin.call("pie", &[f64_vec(&[])], &env).unwrap_err();
5413 assert!(
5414 err.contains("empty") || err.contains("positive") || err.contains("non-negative"),
5415 "expected meaningful error, got: {err}"
5416 );
5417 }
5418
5419 #[test]
5420 fn pie_dispatch_negative_error() {
5421 FIGURE_STATE.with(|f| f.take());
5422 let plugin = PlotPlugin;
5423 let env = Env::new();
5424 let err = plugin
5425 .call("pie", &[f64_vec(&[1.0, -2.0, 3.0])], &env)
5426 .unwrap_err();
5427 assert!(
5428 err.contains("non-negative"),
5429 "expected non-negative error, got: {err}"
5430 );
5431 }
5432
5433 #[test]
5434 fn pie_dispatch_label_length_mismatch_error() {
5435 FIGURE_STATE.with(|f| f.take());
5436 let plugin = PlotPlugin;
5437 let env = Env::new();
5438 let values = f64_vec(&[30.0, 30.0, 40.0]);
5439 let cell = Value::Cell(vec![Value::Str("A".into()), Value::Str("B".into())]);
5441 let err = plugin.call("pie", &[values, cell], &env).unwrap_err();
5442 assert!(
5443 err.contains("length"),
5444 "expected length mismatch error, got: {err}"
5445 );
5446 }
5447
5448 #[test]
5449 #[cfg(feature = "plot-svg")]
5450 fn pie_svg_polygon_count() {
5451 FIGURE_STATE.with(|f| f.take());
5452 let plugin = PlotPlugin;
5453 let env = Env::new();
5454 let path = ".debug/test_pie_polygon_count.svg".to_string();
5455 let _ = std::fs::remove_file(&path);
5456 let values = f64_vec(&[25.0, 50.0, 25.0]);
5457 let result = plugin.call("pie", &[values, Value::Str(path.clone())], &env);
5458 assert!(result.is_ok(), "pie SVG should succeed: {result:?}");
5459 let content = std::fs::read_to_string(&path).unwrap_or_default();
5460 let count = content.matches("<polygon").count();
5462 assert_eq!(
5463 count, 3,
5464 "expected exactly 3 <polygon> elements for 3 slices, got {count}"
5465 );
5466 let _ = std::fs::remove_file(&path);
5467 FIGURE_STATE.with(|f| f.take());
5468 }
5469
5470 #[test]
5471 #[cfg(feature = "plot-svg")]
5472 fn pie_with_labels_svg() {
5473 FIGURE_STATE.with(|f| f.take());
5474 let plugin = PlotPlugin;
5475 let env = Env::new();
5476 let path = ".debug/test_pie_labels.svg".to_string();
5477 let _ = std::fs::remove_file(&path);
5478 let values = f64_vec(&[30.0, 70.0]);
5479 let cell = Value::Cell(vec![Value::Str("Small".into()), Value::Str("Large".into())]);
5480 let result = plugin.call("pie", &[values, cell, Value::Str(path.clone())], &env);
5481 assert!(
5482 result.is_ok(),
5483 "pie with labels SVG should succeed: {result:?}"
5484 );
5485 let content = std::fs::read_to_string(&path).unwrap_or_default();
5486 assert!(
5487 content.contains("Small"),
5488 "SVG should contain label 'Small'"
5489 );
5490 assert!(
5491 content.contains("Large"),
5492 "SVG should contain label 'Large'"
5493 );
5494 let _ = std::fs::remove_file(&path);
5495 FIGURE_STATE.with(|f| f.take());
5496 }
5497
5498 #[test]
5499 #[cfg(feature = "plot-svg")]
5500 fn pie_explode_svg() {
5501 FIGURE_STATE.with(|f| f.take());
5502 let plugin = PlotPlugin;
5503 let env = Env::new();
5504 let path = ".debug/test_pie_explode.svg".to_string();
5505 let _ = std::fs::remove_file(&path);
5506 let values = f64_vec(&[40.0, 30.0, 30.0]);
5507 let explode = f64_vec(&[0.1, 0.0, 0.0]);
5508 let result = plugin.call("pie", &[values, explode, Value::Str(path.clone())], &env);
5509 assert!(
5510 result.is_ok(),
5511 "pie with explode SVG should succeed: {result:?}"
5512 );
5513 let content = std::fs::read_to_string(&path).unwrap_or_default();
5514 assert!(content.contains("<polygon"), "SVG should contain polygons");
5515 let _ = std::fs::remove_file(&path);
5516 FIGURE_STATE.with(|f| f.take());
5517 }
5518
5519 #[test]
5520 #[cfg(feature = "plot-svg")]
5521 fn pie_single_slice() {
5522 FIGURE_STATE.with(|f| f.take());
5523 let plugin = PlotPlugin;
5524 let env = Env::new();
5525 let path = ".debug/test_pie_single.svg".to_string();
5526 let _ = std::fs::remove_file(&path);
5527 let values = f64_vec(&[100.0]);
5528 let result = plugin.call("pie", &[values, Value::Str(path.clone())], &env);
5529 assert!(
5530 result.is_ok(),
5531 "pie single-slice SVG should succeed: {result:?}"
5532 );
5533 let content = std::fs::read_to_string(&path).unwrap_or_default();
5534 let count = content.matches("<polygon").count();
5535 assert_eq!(
5536 count, 1,
5537 "single-slice pie should have exactly 1 polygon, got {count}"
5538 );
5539 let _ = std::fs::remove_file(&path);
5540 FIGURE_STATE.with(|f| f.take());
5541 }
5542
5543 #[test]
5546 fn yyaxis_right_sets_active() {
5547 FIGURE_STATE.with(|f| f.take());
5548 let plugin = PlotPlugin;
5549 let env = Env::new();
5550 plugin
5551 .call("yyaxis", &[Value::Str("right".into())], &env)
5552 .unwrap();
5553 FIGURE_STATE.with(|f| {
5554 let st = f.borrow();
5555 assert_eq!(
5556 st.active_yaxis,
5557 style::YAxis::Right,
5558 "active_yaxis should be Right after yyaxis('right')"
5559 );
5560 assert!(st.hold, "yyaxis should enable hold");
5561 });
5562 FIGURE_STATE.with(|f| f.take());
5563 }
5564
5565 #[test]
5566 fn yyaxis_series_routing() {
5567 FIGURE_STATE.with(|f| f.take());
5568 let plugin = PlotPlugin;
5569 let env = Env::new();
5570 plugin
5572 .call("yyaxis", &[Value::Str("left".into())], &env)
5573 .unwrap();
5574 plugin
5575 .call("plot", &[f64_vec(&[1.0, 2.0]), f64_vec(&[1.0, 2.0])], &env)
5576 .unwrap();
5577 plugin
5579 .call("yyaxis", &[Value::Str("right".into())], &env)
5580 .unwrap();
5581 plugin
5582 .call(
5583 "plot",
5584 &[f64_vec(&[1.0, 2.0]), f64_vec(&[10.0, 20.0])],
5585 &env,
5586 )
5587 .unwrap();
5588 FIGURE_STATE.with(|f| {
5589 let st = f.borrow();
5590 assert_eq!(st.pending_series.len(), 1, "one series on the left axis");
5591 assert_eq!(
5592 st.right_pending_series.len(),
5593 1,
5594 "one series on the right axis"
5595 );
5596 });
5597 FIGURE_STATE.with(|f| f.take());
5598 }
5599
5600 #[test]
5601 fn yyaxis_ylabel_routing() {
5602 FIGURE_STATE.with(|f| f.take());
5603 let plugin = PlotPlugin;
5604 let env = Env::new();
5605 plugin
5606 .call("ylabel", &[Value::Str("left label".into())], &env)
5607 .unwrap();
5608 plugin
5609 .call("yyaxis", &[Value::Str("right".into())], &env)
5610 .unwrap();
5611 plugin
5612 .call("ylabel", &[Value::Str("right label".into())], &env)
5613 .unwrap();
5614 FIGURE_STATE.with(|f| {
5615 let st = f.borrow();
5616 assert_eq!(
5617 st.ylabel.as_deref(),
5618 Some("left label"),
5619 "left ylabel must be unchanged"
5620 );
5621 assert_eq!(
5622 st.right_ylabel.as_deref(),
5623 Some("right label"),
5624 "right ylabel must be set"
5625 );
5626 });
5627 FIGURE_STATE.with(|f| f.take());
5628 }
5629
5630 #[test]
5631 #[cfg(feature = "plot-svg")]
5632 fn yyaxis_svg_has_two_axis_labels() {
5633 FIGURE_STATE.with(|f| f.take());
5634 let plugin = PlotPlugin;
5635 let env = Env::new();
5636 let path = ".debug/test_yyaxis.svg";
5637 let _ = std::fs::remove_file(path);
5638
5639 plugin
5641 .call("yyaxis", &[Value::Str("left".into())], &env)
5642 .unwrap();
5643 plugin
5644 .call("ylabel", &[Value::Str("Left Y".into())], &env)
5645 .unwrap();
5646 plugin
5647 .call(
5648 "plot",
5649 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[1.0, 2.0, 3.0])],
5650 &env,
5651 )
5652 .unwrap();
5653 plugin
5654 .call("yyaxis", &[Value::Str("right".into())], &env)
5655 .unwrap();
5656 plugin
5657 .call("ylabel", &[Value::Str("Right Y".into())], &env)
5658 .unwrap();
5659 plugin
5660 .call(
5661 "plot",
5662 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[100.0, 200.0, 300.0])],
5663 &env,
5664 )
5665 .unwrap();
5666 plugin
5667 .call("savefig", &[Value::Str(path.into())], &env)
5668 .unwrap();
5669
5670 let content = std::fs::read_to_string(path).unwrap_or_default();
5671 assert!(
5672 content.contains("Left Y"),
5673 "SVG must contain the left y-axis label"
5674 );
5675 assert!(
5676 content.contains("Right Y"),
5677 "SVG must contain the right y-axis label"
5678 );
5679 std::fs::remove_file(path).ok();
5680 FIGURE_STATE.with(|f| f.take());
5681 }
5682
5683 #[test]
5684 #[cfg(feature = "plot")]
5685 fn yyaxis_ascii_combined_state() {
5686 FIGURE_STATE.with(|f| f.take());
5687 let plugin = PlotPlugin;
5688 let env = Env::new();
5689
5690 plugin
5692 .call("yyaxis", &[Value::Str("left".into())], &env)
5693 .unwrap();
5694 plugin
5695 .call(
5696 "plot",
5697 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[1.0, 2.0, 3.0])],
5698 &env,
5699 )
5700 .unwrap();
5701 plugin
5702 .call("yyaxis", &[Value::Str("right".into())], &env)
5703 .unwrap();
5704 plugin
5705 .call(
5706 "plot",
5707 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[100.0, 200.0, 300.0])],
5708 &env,
5709 )
5710 .unwrap();
5711
5712 FIGURE_STATE.with(|f| {
5713 let st = f.borrow();
5714 assert_eq!(st.pending_series.len(), 1, "one left series");
5716 assert_eq!(st.right_pending_series.len(), 1, "one right series");
5717 });
5718 FIGURE_STATE.with(|f| f.take());
5719 }
5720
5721 #[test]
5722 #[cfg(feature = "plot")]
5723 fn yyaxis_auto_flush_on_new_left() {
5724 FIGURE_STATE.with(|f| f.take());
5727 let plugin = PlotPlugin;
5728 let env = Env::new();
5729
5730 plugin
5731 .call("yyaxis", &[Value::Str("left".into())], &env)
5732 .unwrap();
5733 plugin
5734 .call(
5735 "plot",
5736 &[f64_vec(&[1.0, 2.0]), f64_vec(&[10.0, 20.0])],
5737 &env,
5738 )
5739 .unwrap();
5740 plugin
5741 .call("yyaxis", &[Value::Str("right".into())], &env)
5742 .unwrap();
5743 plugin
5744 .call(
5745 "plot",
5746 &[f64_vec(&[1.0, 2.0]), f64_vec(&[100.0, 200.0])],
5747 &env,
5748 )
5749 .unwrap();
5750
5751 FIGURE_STATE.with(|f| {
5753 let st = f.borrow();
5754 assert_eq!(st.pending_series.len(), 1);
5755 assert_eq!(st.right_pending_series.len(), 1);
5756 });
5757
5758 plugin
5760 .call("yyaxis", &[Value::Str("left".into())], &env)
5761 .unwrap();
5762
5763 FIGURE_STATE.with(|f| {
5764 let st = f.borrow();
5765 assert_eq!(
5766 st.pending_series.len(),
5767 0,
5768 "left queue must be empty after auto-flush"
5769 );
5770 assert_eq!(
5771 st.right_pending_series.len(),
5772 0,
5773 "right queue must be empty after auto-flush"
5774 );
5775 });
5776 FIGURE_STATE.with(|f| f.take());
5777 }
5778
5779 #[test]
5780 #[cfg(feature = "plot")]
5781 fn yyaxis_ascii_combined_no_panic() {
5782 FIGURE_STATE.with(|f| f.take());
5784 let plugin = PlotPlugin;
5785 let env = Env::new();
5786
5787 plugin
5788 .call("yyaxis", &[Value::Str("left".into())], &env)
5789 .unwrap();
5790 plugin
5791 .call("ylabel", &[Value::Str("Left Y".into())], &env)
5792 .unwrap();
5793 plugin
5794 .call(
5795 "plot",
5796 &[
5797 f64_vec(&[0.0, 1.0, 2.0, 3.0]),
5798 f64_vec(&[18.0, 19.0, 21.0, 23.0]),
5799 ],
5800 &env,
5801 )
5802 .unwrap();
5803 plugin
5804 .call("yyaxis", &[Value::Str("right".into())], &env)
5805 .unwrap();
5806 plugin
5807 .call("ylabel", &[Value::Str("Right Y".into())], &env)
5808 .unwrap();
5809 plugin
5810 .call(
5811 "plot",
5812 &[
5813 f64_vec(&[0.0, 1.0, 2.0, 3.0]),
5814 f64_vec(&[60.0, 65.0, 70.0, 68.0]),
5815 ],
5816 &env,
5817 )
5818 .unwrap();
5819 plugin
5820 .call("title", &[Value::Str("Dual".into())], &env)
5821 .unwrap();
5822 plugin
5824 .call("hold", &[Value::Str("off".into())], &env)
5825 .unwrap();
5826 }
5827
5828 #[test]
5831 fn clabel_sets_flag() {
5832 FIGURE_STATE.with(|f| f.take());
5833 let plugin = PlotPlugin;
5834 let env = Env::new();
5835 assert!(!FIGURE_STATE.with(|f| f.borrow().clabel));
5836 plugin.call("clabel", &[], &env).unwrap();
5837 assert!(
5838 FIGURE_STATE.with(|f| f.borrow().clabel),
5839 "clabel() should set FigureState.clabel to true"
5840 );
5841 FIGURE_STATE.with(|f| f.take());
5842 }
5843
5844 #[test]
5845 fn clabel_without_contour_noop() {
5846 FIGURE_STATE.with(|f| f.take());
5847 let plugin = PlotPlugin;
5848 let env = Env::new();
5849 assert!(plugin.call("clabel", &[], &env).is_ok());
5850 FIGURE_STATE.with(|f| f.take());
5851 }
5852
5853 #[test]
5854 #[cfg(feature = "plot-svg")]
5855 fn clabel_svg_has_text_elements() {
5856 FIGURE_STATE.with(|f| f.take());
5857 let plugin = PlotPlugin;
5858 let env = Env::new();
5859 let (x, y, z) = make_contour_xyz(20, 20);
5860 let path = ".debug/test_clabel.svg";
5861 std::fs::create_dir_all(".debug").ok();
5862 plugin.call("clabel", &[], &env).unwrap();
5863 plugin
5864 .call(
5865 "contour",
5866 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
5867 &env,
5868 )
5869 .unwrap();
5870 let content = std::fs::read_to_string(path).unwrap();
5871 assert!(
5872 content.contains("<text"),
5873 "clabel SVG should contain <text elements"
5874 );
5875 std::fs::remove_file(path).ok();
5876 FIGURE_STATE.with(|f| f.take());
5877 }
5878
5879 #[test]
5880 #[cfg(feature = "plot-svg")]
5881 fn clabel_text_count_matches_levels() {
5882 FIGURE_STATE.with(|f| f.take());
5883 let plugin = PlotPlugin;
5884 let env = Env::new();
5885 let n_levels: usize = 5;
5886 let path_base = ".debug/test_clabel_base.svg";
5887 let path_labeled = ".debug/test_clabel_labeled.svg";
5888 std::fs::create_dir_all(".debug").ok();
5889
5890 let (x0, y0, z0) = make_contour_xyz(20, 20);
5892 plugin
5893 .call(
5894 "contour",
5895 &[
5896 x0,
5897 y0,
5898 z0,
5899 Value::Scalar(n_levels as f64),
5900 Value::Str(path_base.into()),
5901 ],
5902 &env,
5903 )
5904 .unwrap();
5905 let base_count = std::fs::read_to_string(path_base)
5906 .unwrap()
5907 .matches("<text")
5908 .count();
5909
5910 let (x, y, z) = make_contour_xyz(20, 20);
5912 plugin.call("clabel", &[], &env).unwrap();
5913 plugin
5914 .call(
5915 "contour",
5916 &[
5917 x,
5918 y,
5919 z,
5920 Value::Scalar(n_levels as f64),
5921 Value::Str(path_labeled.into()),
5922 ],
5923 &env,
5924 )
5925 .unwrap();
5926 let label_count = std::fs::read_to_string(path_labeled)
5927 .unwrap()
5928 .matches("<text")
5929 .count();
5930
5931 assert!(
5932 label_count >= base_count + n_levels,
5933 "clabel should add at least {n_levels} <text> elements \
5934 (base={base_count}, with labels={label_count})"
5935 );
5936
5937 std::fs::remove_file(path_base).ok();
5938 std::fs::remove_file(path_labeled).ok();
5939 FIGURE_STATE.with(|f| f.take());
5940 }
5941}