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(Box::new(
3425 Array2::from_shape_vec((1, vals.len()), vals.to_vec()).unwrap(),
3426 ))
3427 }
3428
3429 #[test]
3432 fn test_extract_xy_infer_x() {
3433 let y = f64_vec(&[1.0, 4.0, 9.0]);
3434 let (x, yv) = extract_xy("plot", &[y]).unwrap();
3435 assert_eq!(x, vec![1.0, 2.0, 3.0]);
3436 assert_eq!(yv, vec![1.0, 4.0, 9.0]);
3437 }
3438
3439 #[test]
3440 fn test_extract_xy_explicit() {
3441 let x = f64_vec(&[10.0, 20.0]);
3442 let y = f64_vec(&[1.0, 2.0]);
3443 let (xv, yv) = extract_xy("plot", &[x, y]).unwrap();
3444 assert_eq!(xv, vec![10.0, 20.0]);
3445 assert_eq!(yv, vec![1.0, 2.0]);
3446 }
3447
3448 #[test]
3449 fn test_extract_xy_mismatch() {
3450 let x = f64_vec(&[1.0, 2.0]);
3451 let y = f64_vec(&[1.0, 2.0, 3.0]);
3452 assert!(extract_xy("plot", &[x, y]).is_err());
3453 }
3454
3455 #[test]
3456 fn test_extract_xy_scalar_promoted() {
3457 let y = Value::Scalar(5.0);
3458 let (x, yv) = extract_xy("plot", &[y]).unwrap();
3459 assert_eq!(x, vec![1.0]);
3460 assert_eq!(yv, vec![5.0]);
3461 }
3462
3463 #[test]
3466 fn test_xlabel_sets_state() {
3467 let plugin = PlotPlugin;
3468 let env = Env::new();
3469 plugin
3470 .call("xlabel", &[Value::Str("time".into())], &env)
3471 .unwrap();
3472 let label = FIGURE_STATE.with(|f| f.borrow().xlabel.clone());
3473 assert_eq!(label, Some("time".into()));
3474 FIGURE_STATE.with(|f| f.take());
3476 }
3477
3478 #[test]
3479 fn test_title_sets_state() {
3480 let plugin = PlotPlugin;
3481 let env = Env::new();
3482 plugin
3483 .call("title", &[Value::Str("My Chart".into())], &env)
3484 .unwrap();
3485 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
3486 assert_eq!(title, Some("My Chart".into()));
3487 FIGURE_STATE.with(|f| f.take());
3488 }
3489
3490 #[test]
3491 fn test_annotation_requires_string() {
3492 let plugin = PlotPlugin;
3493 let env = Env::new();
3494 let result = plugin.call("xlabel", &[Value::Scalar(1.0)], &env);
3495 assert!(result.is_err());
3496 }
3497
3498 #[test]
3501 fn test_plot_no_feature_returns_error_without_feature() {
3502 #[cfg(not(feature = "plot"))]
3505 {
3506 let plugin = PlotPlugin;
3507 let env = Env::new();
3508 let y = f64_vec(&[1.0, 2.0, 3.0]);
3509 let result = plugin.call("plot", &[y], &env);
3510 assert!(result.is_err());
3511 let msg = result.unwrap_err();
3512 assert!(msg.contains("plot"), "error should mention 'plot'");
3513 }
3514 #[cfg(feature = "plot")]
3516 let _ = ();
3517 }
3518
3519 #[test]
3520 fn test_hist_single_value_no_error() {
3521 let plugin = PlotPlugin;
3522 let env = Env::new();
3523 let result = plugin.call("hist", &[Value::Scalar(1.0)], &env);
3524 assert!(result.is_ok());
3525 }
3526
3527 #[test]
3528 fn test_hist_vector_returns_void() {
3529 let plugin = PlotPlugin;
3530 let env = Env::new();
3531 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3532 let result = plugin.call("hist", &[v], &env).unwrap();
3533 assert_eq!(result, Value::Void);
3534 }
3535
3536 #[test]
3537 fn test_hist_custom_bins_returns_void() {
3538 let plugin = PlotPlugin;
3539 let env = Env::new();
3540 let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3541 let result = plugin.call("hist", &[v, Value::Scalar(3.0)], &env).unwrap();
3542 assert_eq!(result, Value::Void);
3543 }
3544
3545 #[test]
3546 fn test_hist_zero_bins_errors() {
3547 let plugin = PlotPlugin;
3548 let env = Env::new();
3549 let v = f64_vec(&[1.0, 2.0, 3.0]);
3550 let result = plugin.call("hist", &[v, Value::Scalar(0.0)], &env);
3551 assert!(result.is_err());
3552 }
3553
3554 #[test]
3557 fn test_extract_xy_multi_single_series() {
3558 let x = f64_vec(&[1.0, 2.0, 3.0]);
3559 let y = f64_vec(&[1.0, 4.0, 9.0]);
3560 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
3561 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
3562 assert_eq!(ys.len(), 1);
3563 assert_eq!(ys[0], vec![1.0, 4.0, 9.0]);
3564 }
3565
3566 #[test]
3567 fn test_extract_xy_multi_matrix_y() {
3568 let x = f64_vec(&[1.0, 2.0, 3.0]);
3569 let y = Value::Matrix(Box::new(
3571 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
3572 ));
3573 let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
3574 assert_eq!(xv, vec![1.0, 2.0, 3.0]);
3575 assert_eq!(ys.len(), 2);
3576 assert_eq!(ys[0], vec![1.0, 2.0, 3.0]);
3577 assert_eq!(ys[1], vec![4.0, 5.0, 6.0]);
3578 }
3579
3580 #[test]
3581 fn test_extract_xy_multi_column_count_mismatch() {
3582 let x = f64_vec(&[1.0, 2.0]);
3583 let y = Value::Matrix(Box::new(
3584 Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
3585 ));
3586 let result = extract_xy_multi("plot", &[x, y]);
3587 assert!(result.is_err());
3588 }
3589
3590 #[test]
3593 fn test_loglog_non_positive_all_filtered_errors() {
3594 let plugin = PlotPlugin;
3595 let env = Env::new();
3596 let x = f64_vec(&[-1.0, 0.0, -2.0]);
3597 let y = f64_vec(&[1.0, 2.0, 3.0]);
3598 let result = plugin.call("loglog", &[x, y], &env);
3599 assert!(result.is_err());
3600 let msg = result.unwrap_err();
3601 assert!(msg.contains("finite"), "error should mention finite: {msg}");
3602 }
3603
3604 #[test]
3605 fn test_semilogx_valid_data() {
3606 let plugin = PlotPlugin;
3607 let env = Env::new();
3608 let x = f64_vec(&[1.0, 10.0, 100.0]);
3610 let y = f64_vec(&[1.0, 2.0, 3.0]);
3611 let result = plugin.call("semilogx", &[x, y], &env);
3612 if let Err(msg) = &result {
3614 assert!(
3615 !msg.contains("not yet implemented"),
3616 "should not say 'not yet implemented': {msg}"
3617 );
3618 }
3619 }
3620
3621 #[test]
3622 fn test_semilogy_label_annotation() {
3623 FIGURE_STATE.with(|f| f.take());
3627 }
3628
3629 #[test]
3630 fn test_stairs_stub_is_gone() {
3631 let plugin = PlotPlugin;
3633 let env = Env::new();
3634 #[cfg(feature = "plot")]
3637 {
3638 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
3639 let result = plugin.call("stairs", &[y], &env);
3640 assert!(result.is_ok(), "stairs should succeed: {result:?}");
3641 }
3642 #[cfg(not(feature = "plot"))]
3643 {
3644 let y = f64_vec(&[1.0, 4.0, 9.0]);
3645 let result = plugin.call("stairs", &[y], &env);
3646 let msg = result.unwrap_err();
3648 assert!(
3649 !msg.contains("not yet implemented"),
3650 "should not say 'not yet implemented': {msg}"
3651 );
3652 }
3653 }
3654
3655 #[test]
3658 fn test_xlim_sets_state() {
3659 FIGURE_STATE.with(|f| f.take());
3660 let plugin = PlotPlugin;
3661 let env = Env::new();
3662 let lim = Value::Matrix(Box::new(
3663 Array2::from_shape_vec((1, 2), vec![0.0, 10.0]).unwrap(),
3664 ));
3665 plugin.call("xlim", &[lim], &env).unwrap();
3666 let xlim = FIGURE_STATE.with(|f| f.borrow().xlim);
3667 assert_eq!(xlim, Some((0.0, 10.0)));
3668 FIGURE_STATE.with(|f| f.take());
3669 }
3670
3671 #[test]
3672 fn test_ylim_sets_state() {
3673 FIGURE_STATE.with(|f| f.take());
3674 let plugin = PlotPlugin;
3675 let env = Env::new();
3676 let lim = Value::Matrix(Box::new(
3677 Array2::from_shape_vec((1, 2), vec![-1.0, 1.0]).unwrap(),
3678 ));
3679 plugin.call("ylim", &[lim], &env).unwrap();
3680 let ylim = FIGURE_STATE.with(|f| f.borrow().ylim);
3681 assert_eq!(ylim, Some((-1.0, 1.0)));
3682 FIGURE_STATE.with(|f| f.take());
3683 }
3684
3685 #[test]
3686 fn test_legend_sets_state() {
3687 FIGURE_STATE.with(|f| f.take());
3688 let plugin = PlotPlugin;
3689 let env = Env::new();
3690 plugin
3691 .call(
3692 "legend",
3693 &[Value::Str("a".into()), Value::Str("b".into())],
3694 &env,
3695 )
3696 .unwrap();
3697 let legend = FIGURE_STATE.with(|f| f.borrow().legend.clone());
3698 assert_eq!(legend, vec!["a".to_string(), "b".to_string()]);
3699 FIGURE_STATE.with(|f| f.take());
3700 }
3701
3702 #[test]
3703 fn test_legend_requires_strings() {
3704 let plugin = PlotPlugin;
3705 let env = Env::new();
3706 let result = plugin.call("legend", &[Value::Scalar(1.0)], &env);
3707 assert!(result.is_err());
3708 }
3709
3710 #[test]
3711 fn test_legend_requires_at_least_one_arg() {
3712 let plugin = PlotPlugin;
3713 let env = Env::new();
3714 let result = plugin.call("legend", &[], &env);
3715 assert!(result.is_err());
3716 }
3717
3718 #[test]
3719 fn test_grid_toggles_state() {
3720 FIGURE_STATE.with(|f| f.take());
3721 let plugin = PlotPlugin;
3722 let env = Env::new();
3723 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3725 plugin.call("grid", &[], &env).unwrap();
3726 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
3727 plugin.call("grid", &[], &env).unwrap();
3728 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3729 FIGURE_STATE.with(|f| f.take());
3730 }
3731
3732 #[test]
3733 fn test_grid_on_off_string_args() {
3734 FIGURE_STATE.with(|f| f.take());
3735 let plugin = PlotPlugin;
3736 let env = Env::new();
3737 plugin
3738 .call("grid", &[Value::Str("on".into())], &env)
3739 .unwrap();
3740 assert!(FIGURE_STATE.with(|f| f.borrow().grid));
3741 plugin
3742 .call("grid", &[Value::Str("off".into())], &env)
3743 .unwrap();
3744 assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
3745 let result = plugin.call("grid", &[Value::Str("maybe".into())], &env);
3747 assert!(result.is_err());
3748 FIGURE_STATE.with(|f| f.take());
3749 }
3750
3751 #[test]
3752 fn test_zlabel_sets_state() {
3753 FIGURE_STATE.with(|f| f.take());
3754 let plugin = PlotPlugin;
3755 let env = Env::new();
3756 plugin
3757 .call("zlabel", &[Value::Str("depth".into())], &env)
3758 .unwrap();
3759 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
3760 assert_eq!(zlabel, Some("depth".into()));
3761 FIGURE_STATE.with(|f| f.take());
3762 }
3763
3764 #[test]
3765 fn test_xlim_wrong_length() {
3766 let plugin = PlotPlugin;
3767 let env = Env::new();
3768 let v = Value::Matrix(Box::new(
3769 Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0]).unwrap(),
3770 ));
3771 let result = plugin.call("xlim", &[v], &env);
3772 assert!(result.is_err());
3773 }
3774
3775 #[test]
3776 #[cfg(not(feature = "plot-svg"))]
3777 fn test_svg_without_feature() {
3778 let plugin = PlotPlugin;
3779 let env = Env::new();
3780 let y = f64_vec(&[1.0, 2.0, 3.0]);
3781 let path = Value::Str("out.svg".into());
3782 let result = plugin.call("plot", &[y, path], &env);
3783 assert!(result.is_err());
3784 }
3785
3786 #[test]
3789 #[cfg(feature = "plot")]
3790 fn test_plot_ascii_no_error() {
3791 let plugin = PlotPlugin;
3792 let env = Env::new();
3793 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0, 25.0]);
3794 assert!(plugin.call("plot", &[y], &env).is_ok());
3795 }
3796
3797 #[test]
3798 #[cfg(feature = "plot")]
3799 fn test_scatter_ascii_no_error() {
3800 let plugin = PlotPlugin;
3801 let env = Env::new();
3802 let x = f64_vec(&[1.0, 2.0, 3.0, 4.0]);
3803 let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
3804 assert!(plugin.call("scatter", &[x, y], &env).is_ok());
3805 }
3806
3807 #[test]
3808 #[cfg(feature = "plot")]
3809 fn test_figure_state_cleared_after_render() {
3810 let plugin = PlotPlugin;
3811 let env = Env::new();
3812 plugin
3813 .call("title", &[Value::Str("Temp".into())], &env)
3814 .unwrap();
3815 let y = f64_vec(&[1.0, 2.0, 3.0]);
3816 plugin.call("plot", &[y], &env).unwrap();
3817 let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
3819 assert!(
3820 title.is_none(),
3821 "FigureState should be cleared after plot()"
3822 );
3823 }
3824
3825 #[test]
3828 fn test_plot3_length_mismatch_error() {
3829 let plugin = PlotPlugin;
3830 let env = Env::new();
3831 let x = f64_vec(&[1.0, 2.0, 3.0]);
3832 let y = f64_vec(&[1.0, 2.0]);
3833 let z = f64_vec(&[0.0, 0.0, 0.0]);
3834 let result = plugin.call("plot3", &[x, y, z], &env);
3835 assert!(result.is_err());
3836 let msg = result.unwrap_err();
3837 assert!(
3838 msg.contains("same length"),
3839 "error should mention length: {msg}"
3840 );
3841 }
3842
3843 #[test]
3844 fn test_scatter3_wrong_arg_count_error() {
3845 let plugin = PlotPlugin;
3846 let env = Env::new();
3847 let x = f64_vec(&[1.0, 2.0]);
3848 let y = f64_vec(&[1.0, 2.0]);
3849 let result = plugin.call("scatter3", &[x, y], &env);
3851 assert!(result.is_err());
3852 let msg = result.unwrap_err();
3853 assert!(
3854 msg.contains("3 arguments"),
3855 "error should mention 3 args: {msg}"
3856 );
3857 }
3858
3859 #[test]
3860 #[cfg(feature = "plot")]
3861 fn test_plot3_ascii_no_error() {
3862 let plugin = PlotPlugin;
3863 let env = Env::new();
3864 let x = f64_vec(&[0.0, 1.0, 2.0, 3.0]);
3865 let y = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
3866 let z = f64_vec(&[0.0, 0.5, 1.0, 0.5]);
3867 let result = plugin.call("plot3", &[x, y, z], &env);
3868 assert!(result.is_ok(), "plot3 ASCII should succeed: {result:?}");
3869 }
3870
3871 #[test]
3872 #[cfg(feature = "plot")]
3873 fn test_scatter3_ascii_no_error() {
3874 let plugin = PlotPlugin;
3875 let env = Env::new();
3876 let x = f64_vec(&[0.0, 1.0, 2.0]);
3877 let y = f64_vec(&[0.0, 1.0, 0.0]);
3878 let z = f64_vec(&[1.0, 2.0, 3.0]);
3879 let result = plugin.call("scatter3", &[x, y, z], &env);
3880 assert!(result.is_ok(), "scatter3 ASCII should succeed: {result:?}");
3881 }
3882
3883 #[test]
3884 #[cfg(feature = "plot")]
3885 fn test_plot3_state_cleared_after_render() {
3886 FIGURE_STATE.with(|f| f.take());
3887 let plugin = PlotPlugin;
3888 let env = Env::new();
3889 plugin
3890 .call("zlabel", &[Value::Str("depth".into())], &env)
3891 .unwrap();
3892 let x = f64_vec(&[0.0, 1.0, 2.0]);
3893 let y = f64_vec(&[0.0, 1.0, 2.0]);
3894 let z = f64_vec(&[0.0, 1.0, 2.0]);
3895 plugin.call("plot3", &[x, y, z], &env).unwrap();
3896 let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
3897 assert!(
3898 zlabel.is_none(),
3899 "FigureState.zlabel should be cleared after plot3()"
3900 );
3901 }
3902
3903 #[test]
3904 #[cfg(not(feature = "plot-svg"))]
3905 fn test_plot3_svg_without_feature() {
3906 let plugin = PlotPlugin;
3907 let env = Env::new();
3908 let x = f64_vec(&[0.0, 1.0]);
3909 let y = f64_vec(&[0.0, 1.0]);
3910 let z = f64_vec(&[0.0, 1.0]);
3911 let path = Value::Str("out.svg".into());
3912 let result = plugin.call("plot3", &[x, y, z, path], &env);
3913 assert!(result.is_err());
3914 let msg = result.unwrap_err();
3915 assert!(
3916 msg.contains("plot-svg"),
3917 "error should mention plot-svg feature: {msg}"
3918 );
3919 }
3920
3921 #[test]
3924 fn test_colormap_sets_state() {
3925 FIGURE_STATE.with(|f| f.take());
3926 let plugin = PlotPlugin;
3927 let env = Env::new();
3928 plugin
3929 .call("colormap", &[Value::Str("hot".into())], &env)
3930 .unwrap();
3931 let cmap = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
3932 assert_eq!(cmap, Some(colormap::ColormapSpec::Named("hot".to_string())));
3933 FIGURE_STATE.with(|f| f.take());
3934 }
3935
3936 #[test]
3937 fn test_colorbar_sets_state() {
3938 FIGURE_STATE.with(|f| f.take());
3939 let plugin = PlotPlugin;
3940 let env = Env::new();
3941 plugin.call("colorbar", &[], &env).unwrap();
3942 let cb = FIGURE_STATE.with(|f| f.borrow().colorbar);
3943 assert!(cb, "colorbar should set FigureState.colorbar = true");
3944 FIGURE_STATE.with(|f| f.take());
3945 }
3946
3947 #[test]
3950 fn test_style_rgb_matrix_dispatch() {
3951 FIGURE_STATE.with(|f| f.take());
3952 let plugin = PlotPlugin;
3953 let env = Env::new();
3954 plugin
3955 .call("hold", &[Value::Str("on".into())], &env)
3956 .unwrap();
3957 let x = f64_vec(&[1.0, 2.0]);
3958 let y = f64_vec(&[1.0, 2.0]);
3959 let m = Value::Matrix(Box::new(
3960 Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).unwrap(),
3961 ));
3962 plugin.call("plot", &[x, y, m], &env).unwrap();
3963 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3964 assert_eq!(series.len(), 1, "should have one pending series");
3965 if let PendingSeries::Line(_, _, style) = &series[0] {
3966 assert_eq!(
3967 style.as_ref().and_then(|s| s.color),
3968 Some(style::StyleColor(255, 0, 0))
3969 );
3970 } else {
3971 panic!("expected PendingSeries::Line");
3972 }
3973 FIGURE_STATE.with(|f| f.take());
3974 }
3975
3976 #[test]
3977 fn test_style_color_named_arg_bar() {
3978 FIGURE_STATE.with(|f| f.take());
3979 let plugin = PlotPlugin;
3980 let env = Env::new();
3981 plugin
3982 .call("hold", &[Value::Str("on".into())], &env)
3983 .unwrap();
3984 let v = f64_vec(&[1.0, 2.0, 3.0]);
3985 plugin
3986 .call(
3987 "bar",
3988 &[v, Value::Str("color".into()), Value::Str("blue".into())],
3989 &env,
3990 )
3991 .expect("bar with 'color' named arg should succeed");
3992 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3993 assert_eq!(series.len(), 1);
3994 if let PendingSeries::Bar(_, _, style) = &series[0] {
3995 assert_eq!(
3996 style.as_ref().and_then(|s| s.color),
3997 Some(style::StyleColor(0, 0, 255)),
3998 "bar should carry blue style"
3999 );
4000 } else {
4001 panic!("expected PendingSeries::Bar");
4002 }
4003 FIGURE_STATE.with(|f| f.take());
4004 }
4005
4006 #[test]
4007 fn test_style_color_named_arg_hex() {
4008 FIGURE_STATE.with(|f| f.take());
4009 let plugin = PlotPlugin;
4010 let env = Env::new();
4011 plugin
4012 .call("hold", &[Value::Str("on".into())], &env)
4013 .unwrap();
4014 let v = f64_vec(&[1.0, 2.0, 3.0]);
4015 plugin
4016 .call(
4017 "bar",
4018 &[v, Value::Str("color".into()), Value::Str("#FF4400".into())],
4019 &env,
4020 )
4021 .expect("bar with hex color should succeed");
4022 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4023 assert_eq!(series.len(), 1);
4024 if let PendingSeries::Bar(_, _, style) = &series[0] {
4025 assert_eq!(
4026 style.as_ref().and_then(|s| s.color),
4027 Some(style::StyleColor(0xFF, 0x44, 0x00)),
4028 "bar should carry #FF4400 style"
4029 );
4030 } else {
4031 panic!("expected PendingSeries::Bar");
4032 }
4033 FIGURE_STATE.with(|f| f.take());
4034 }
4035
4036 #[test]
4037 fn test_colormap_matrix_dispatch() {
4038 FIGURE_STATE.with(|f| f.take());
4039 let plugin = PlotPlugin;
4040 let env = Env::new();
4041 let m = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap();
4042 let result = plugin.call("colormap", &[Value::Matrix(Box::new(m))], &env);
4043 assert!(
4044 result.is_ok(),
4045 "colormap(N×3 matrix) should succeed: {result:?}"
4046 );
4047 let spec = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
4048 assert!(
4049 matches!(spec, Some(colormap::ColormapSpec::Custom(_))),
4050 "should store ColormapSpec::Custom"
4051 );
4052 FIGURE_STATE.with(|f| f.take());
4053 }
4054
4055 #[test]
4056 fn test_colormap_matrix_wrong_cols() {
4057 let plugin = PlotPlugin;
4058 let env = Env::new();
4059 let m = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).unwrap();
4060 let result = plugin.call("colormap", &[Value::Matrix(Box::new(m))], &env);
4061 assert!(result.is_err());
4062 let msg = result.unwrap_err();
4063 assert!(msg.contains("N×3"), "error should mention N×3: {msg}");
4064 }
4065
4066 #[test]
4069 fn test_bar_accumulates_with_style_red() {
4070 FIGURE_STATE.with(|f| f.take());
4071 let plugin = PlotPlugin;
4072 let env = Env::new();
4073 plugin
4074 .call("hold", &[Value::Str("on".into())], &env)
4075 .unwrap();
4076 let x = f64_vec(&[1.0, 2.0, 3.0]);
4077 let y = f64_vec(&[4.0, 5.0, 6.0]);
4078 plugin
4079 .call("bar", &[x, y, Value::Str("r".into())], &env)
4080 .unwrap();
4081 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4082 assert_eq!(series.len(), 1, "should have one bar series");
4083 if let PendingSeries::Bar(_, _, style) = &series[0] {
4084 assert_eq!(
4085 style.as_ref().and_then(|s| s.color),
4086 Some(style::StyleColor(255, 0, 0)),
4087 "bar should carry red style"
4088 );
4089 } else {
4090 panic!("expected PendingSeries::Bar");
4091 }
4092 FIGURE_STATE.with(|f| f.take());
4093 }
4094
4095 #[test]
4096 fn test_stem_accumulates_with_style_blue() {
4097 FIGURE_STATE.with(|f| f.take());
4098 let plugin = PlotPlugin;
4099 let env = Env::new();
4100 plugin
4101 .call("hold", &[Value::Str("on".into())], &env)
4102 .unwrap();
4103 let x = f64_vec(&[1.0, 2.0, 3.0]);
4104 let y = f64_vec(&[1.0, 2.0, 3.0]);
4105 plugin
4106 .call("stem", &[x, y, Value::Str("blue".into())], &env)
4107 .unwrap();
4108 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4109 assert_eq!(series.len(), 1, "should have one stem series");
4110 if let PendingSeries::Stem(_, _, style) = &series[0] {
4111 assert_eq!(
4112 style.as_ref().and_then(|s| s.color),
4113 Some(style::StyleColor(0, 0, 255)),
4114 "stem should carry blue style"
4115 );
4116 } else {
4117 panic!("expected PendingSeries::Stem");
4118 }
4119 FIGURE_STATE.with(|f| f.take());
4120 }
4121
4122 #[test]
4123 fn test_hist_accumulates_with_style_hex() {
4124 FIGURE_STATE.with(|f| f.take());
4125 let plugin = PlotPlugin;
4126 let env = Env::new();
4127 plugin
4128 .call("hold", &[Value::Str("on".into())], &env)
4129 .unwrap();
4130 let data = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
4131 plugin
4132 .call("hist", &[data, Value::Str("#FF8800".into())], &env)
4133 .unwrap();
4134 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4135 assert_eq!(series.len(), 1, "should have one hist series");
4136 if let PendingSeries::Hist { style, .. } = &series[0] {
4137 assert_eq!(
4138 style.as_ref().and_then(|s| s.color),
4139 Some(style::StyleColor(0xFF, 0x88, 0x00)),
4140 "hist should carry hex colour style"
4141 );
4142 } else {
4143 panic!("expected PendingSeries::Hist");
4144 }
4145 FIGURE_STATE.with(|f| f.take());
4146 }
4147
4148 #[test]
4149 fn test_quiver_accumulates_with_style_green() {
4150 FIGURE_STATE.with(|f| f.take());
4151 let plugin = PlotPlugin;
4152 let env = Env::new();
4153 plugin
4154 .call("hold", &[Value::Str("on".into())], &env)
4155 .unwrap();
4156 let x = f64_vec(&[0.0, 1.0]);
4157 let y = f64_vec(&[0.0, 1.0]);
4158 let u = f64_vec(&[1.0, 0.0]);
4159 let v = f64_vec(&[0.0, 1.0]);
4160 plugin
4161 .call("quiver", &[x, y, u, v, Value::Str("g".into())], &env)
4162 .unwrap();
4163 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4164 assert_eq!(series.len(), 1, "should have one quiver series");
4165 if let PendingSeries::Quiver(_, _, _, _, style) = &series[0] {
4166 assert_eq!(
4167 style.as_ref().and_then(|s| s.color),
4168 Some(style::StyleColor(0, 128, 0)),
4169 "quiver should carry green style"
4170 );
4171 } else {
4172 panic!("expected PendingSeries::Quiver");
4173 }
4174 FIGURE_STATE.with(|f| f.take());
4175 }
4176
4177 #[test]
4178 fn test_bar_no_style_stores_none() {
4179 FIGURE_STATE.with(|f| f.take());
4180 let plugin = PlotPlugin;
4181 let env = Env::new();
4182 plugin
4183 .call("hold", &[Value::Str("on".into())], &env)
4184 .unwrap();
4185 let x = f64_vec(&[1.0, 2.0]);
4186 let y = f64_vec(&[3.0, 4.0]);
4187 plugin.call("bar", &[x, y], &env).unwrap();
4188 let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
4189 if let PendingSeries::Bar(_, _, style) = &series[0] {
4190 assert!(style.is_none(), "unstyled bar should have None style");
4191 } else {
4192 panic!("expected PendingSeries::Bar");
4193 }
4194 FIGURE_STATE.with(|f| f.take());
4195 }
4196
4197 #[test]
4198 #[cfg(feature = "plot-svg")]
4199 fn test_bar_svg_with_red_style() {
4200 FIGURE_STATE.with(|f| f.take());
4201 let plugin = PlotPlugin;
4202 let env = Env::new();
4203 let tmp = std::env::temp_dir().join("bar_red_30_5c.svg");
4204 let path = tmp.to_string_lossy().to_string();
4205 let x = f64_vec(&[1.0, 2.0, 3.0]);
4206 let y = f64_vec(&[4.0, 5.0, 3.0]);
4207 let result = plugin.call(
4208 "bar",
4209 &[x, y, Value::Str("r".into()), Value::Str(path.clone())],
4210 &env,
4211 );
4212 assert!(
4213 result.is_ok(),
4214 "bar with red style to SVG should succeed: {result:?}"
4215 );
4216 assert!(
4217 std::path::Path::new(&path).exists(),
4218 "SVG file should be created"
4219 );
4220 let _ = std::fs::remove_file(&path);
4221 FIGURE_STATE.with(|f| f.take());
4222 }
4223
4224 #[test]
4227 fn test_figure_sets_canvas_size() {
4228 FIGURE_STATE.with(|f| f.take());
4229 let plugin = PlotPlugin;
4230 let env = Env::new();
4231 plugin
4232 .call(
4233 "figure",
4234 &[Value::Scalar(1200.0), Value::Scalar(400.0)],
4235 &env,
4236 )
4237 .unwrap();
4238 let size = FIGURE_STATE.with(|f| f.borrow().figure_size);
4239 assert_eq!(size, Some((1200, 400)));
4240 FIGURE_STATE.with(|f| f.take());
4241 }
4242
4243 #[test]
4244 fn test_figure_default_canvas_size() {
4245 FIGURE_STATE.with(|f| f.take());
4246 let st = FIGURE_STATE.with(|f| f.take());
4247 assert_eq!(st.canvas_size(), (800, 600));
4248 }
4249
4250 #[test]
4251 fn test_figure_wrong_arg_count_errors() {
4252 let plugin = PlotPlugin;
4253 let env = Env::new();
4254 let result = plugin.call("figure", &[Value::Scalar(800.0)], &env);
4255 assert!(result.is_err());
4256 let result = plugin.call("figure", &[], &env);
4257 assert!(result.is_err());
4258 }
4259
4260 #[test]
4261 fn test_figure_invalid_size_errors() {
4262 let plugin = PlotPlugin;
4263 let env = Env::new();
4264 let result = plugin.call("figure", &[Value::Scalar(0.0), Value::Scalar(600.0)], &env);
4265 assert!(result.is_err(), "width 0 should error");
4266 let result = plugin.call(
4267 "figure",
4268 &[Value::Scalar(800.0), Value::Scalar(20000.0)],
4269 &env,
4270 );
4271 assert!(result.is_err(), "height > 16384 should error");
4272 }
4273
4274 #[test]
4275 fn test_figure_in_builtin_names() {
4276 use ccalc_engine::eval::builtin_names;
4277 assert!(
4278 builtin_names().contains(&"figure"),
4279 "figure missing from builtin_names"
4280 );
4281 }
4282
4283 #[test]
4284 fn test_colormap_invalid_name_errors() {
4285 let plugin = PlotPlugin;
4286 let env = Env::new();
4287 let result = plugin.call("colormap", &[Value::Str("notacolormap".into())], &env);
4288 assert!(result.is_err());
4289 let msg = result.unwrap_err();
4290 assert!(
4291 msg.contains("colormap"),
4292 "error should mention colormap: {msg}"
4293 );
4294 }
4295
4296 #[test]
4297 fn test_apply_colormap_gray_extremes() {
4298 let (r, g, b) = colormap::apply_colormap(0.0, "gray");
4299 assert_eq!((r, g, b), (0, 0, 0));
4300 let (r, g, b) = colormap::apply_colormap(1.0, "gray");
4301 assert_eq!((r, g, b), (255, 255, 255));
4302 }
4303
4304 #[test]
4305 fn test_imagesc_non_matrix_errors() {
4306 let plugin = PlotPlugin;
4307 let env = Env::new();
4308 let result = plugin.call("imagesc", &[Value::Str("notamatrix".into())], &env);
4309 assert!(result.is_err());
4310 }
4311
4312 #[test]
4313 fn test_imagesc_no_args_errors() {
4314 let plugin = PlotPlugin;
4315 let env = Env::new();
4316 let result = plugin.call("imagesc", &[], &env);
4317 assert!(result.is_err());
4318 }
4319
4320 #[test]
4321 #[cfg(not(feature = "plot-svg"))]
4322 fn test_imagesc_svg_without_feature_errors() {
4323 let plugin = PlotPlugin;
4324 let env = Env::new();
4325 let z = Value::Matrix(Box::new(
4326 Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap(),
4327 ));
4328 let path = Value::Str("out.svg".into());
4329 let result = plugin.call("imagesc", &[z, path], &env);
4330 assert!(result.is_err());
4331 let msg = result.unwrap_err();
4332 assert!(
4333 msg.contains("plot-svg"),
4334 "error should mention plot-svg feature: {msg}"
4335 );
4336 }
4337
4338 #[test]
4339 #[cfg(feature = "plot")]
4340 fn test_imagesc_ascii_no_error() {
4341 FIGURE_STATE.with(|f| f.take());
4342 let plugin = PlotPlugin;
4343 let env = Env::new();
4344 let z = Value::Matrix(Box::new(
4345 Array2::from_shape_vec((4, 4), (0..16).map(|i| i as f64).collect()).unwrap(),
4346 ));
4347 let result = plugin.call("imagesc", &[z], &env);
4348 assert!(result.is_ok(), "imagesc ASCII should succeed: {result:?}");
4349 }
4350
4351 #[test]
4352 #[cfg(feature = "plot")]
4353 fn test_imagesc_ascii_with_colorbar_no_error() {
4354 FIGURE_STATE.with(|f| f.take());
4355 let plugin = PlotPlugin;
4356 let env = Env::new();
4357 plugin
4358 .call("colormap", &[Value::Str("jet".into())], &env)
4359 .unwrap();
4360 plugin.call("colorbar", &[], &env).unwrap();
4361 let z = Value::Matrix(Box::new(
4362 Array2::from_shape_vec((3, 3), (0..9).map(|i| i as f64).collect()).unwrap(),
4363 ));
4364 let result = plugin.call("imagesc", &[z], &env);
4365 assert!(
4366 result.is_ok(),
4367 "imagesc with colorbar should succeed: {result:?}"
4368 );
4369 }
4370
4371 #[allow(dead_code)]
4374 fn make_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
4375 let x = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(_r, c)| {
4376 c as f64
4377 })));
4378 let y = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(r, _c)| {
4379 r as f64
4380 })));
4381 let z = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(r, c)| {
4382 (r + c) as f64
4383 })));
4384 (x, y, z)
4385 }
4386
4387 #[test]
4388 fn test_surf_dimension_mismatch_error() {
4389 FIGURE_STATE.with(|f| f.take());
4390 let plugin = PlotPlugin;
4391 let env = Env::new();
4392 let x = Value::Matrix(Box::new(
4393 Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap(),
4394 ));
4395 let y = Value::Matrix(Box::new(
4396 Array2::from_shape_vec((3, 2), vec![1.0; 6]).unwrap(),
4397 ));
4398 let z = Value::Matrix(Box::new(
4399 Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap(),
4400 ));
4401 let err = plugin.call("surf", &[x, y, z], &env).unwrap_err();
4402 assert!(
4403 err.contains("same dimensions"),
4404 "error should mention dimensions: {err}"
4405 );
4406 }
4407
4408 #[test]
4409 fn test_mesh_dimension_mismatch_error() {
4410 FIGURE_STATE.with(|f| f.take());
4411 let plugin = PlotPlugin;
4412 let env = Env::new();
4413 let x = Value::Matrix(Box::new(
4414 Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap(),
4415 ));
4416 let y = Value::Matrix(Box::new(
4417 Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap(),
4418 ));
4419 let z = Value::Matrix(Box::new(
4420 Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap(),
4421 ));
4422 let err = plugin.call("mesh", &[x, y, z], &env).unwrap_err();
4423 assert!(
4424 err.contains("same dimensions"),
4425 "error should mention dimensions: {err}"
4426 );
4427 }
4428
4429 #[test]
4430 fn test_surf_missing_args_error() {
4431 FIGURE_STATE.with(|f| f.take());
4432 let plugin = PlotPlugin;
4433 let env = Env::new();
4434 let x = Value::Matrix(Box::new(
4435 Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap(),
4436 ));
4437 let err = plugin.call("surf", &[x], &env).unwrap_err();
4438 assert!(
4439 err.contains("requires"),
4440 "error should mention requires: {err}"
4441 );
4442 }
4443
4444 #[test]
4445 #[cfg(feature = "plot")]
4446 fn test_surf_ascii_no_error() {
4447 FIGURE_STATE.with(|f| f.take());
4448 let plugin = PlotPlugin;
4449 let env = Env::new();
4450 let (x, y, z) = make_xyz(5, 8);
4451 let result = plugin.call("surf", &[x, y, z], &env);
4452 assert!(result.is_ok(), "surf ASCII should succeed: {result:?}");
4453 }
4454
4455 #[test]
4456 #[cfg(feature = "plot")]
4457 fn test_mesh_ascii_no_error() {
4458 FIGURE_STATE.with(|f| f.take());
4459 let plugin = PlotPlugin;
4460 let env = Env::new();
4461 let (x, y, z) = make_xyz(5, 8);
4462 let result = plugin.call("mesh", &[x, y, z], &env);
4463 assert!(result.is_ok(), "mesh ASCII should succeed: {result:?}");
4464 }
4465
4466 #[test]
4467 #[cfg(feature = "plot-svg")]
4468 fn test_surf_svg_creates_file() {
4469 FIGURE_STATE.with(|f| f.take());
4470 let plugin = PlotPlugin;
4471 let env = Env::new();
4472 let (x, y, z) = make_xyz(4, 5);
4473 let path = ".debug/test_surf.svg";
4474 std::fs::create_dir_all(".debug").ok();
4475 let result = plugin.call("surf", &[x, y, z, Value::Str(path.into())], &env);
4476 assert!(result.is_ok(), "surf SVG should succeed: {result:?}");
4477 let content = std::fs::read_to_string(path).unwrap();
4478 assert!(
4479 content.contains("<svg"),
4480 "output should be SVG: starts with {}",
4481 &content[..50.min(content.len())]
4482 );
4483 std::fs::remove_file(path).ok();
4484 }
4485
4486 #[test]
4487 #[cfg(feature = "plot-svg")]
4488 fn test_mesh_png_creates_file() {
4489 FIGURE_STATE.with(|f| f.take());
4490 let plugin = PlotPlugin;
4491 let env = Env::new();
4492 let (x, y, z) = make_xyz(4, 5);
4493 let path = ".debug/test_mesh.png";
4494 std::fs::create_dir_all(".debug").ok();
4495 let result = plugin.call("mesh", &[x, y, z, Value::Str(path.into())], &env);
4496 assert!(result.is_ok(), "mesh PNG should succeed: {result:?}");
4497 let bytes = std::fs::read(path).unwrap();
4498 assert_eq!(
4500 &bytes[0..4],
4501 &[0x89, 0x50, 0x4E, 0x47],
4502 "output should be PNG"
4503 );
4504 std::fs::remove_file(path).ok();
4505 }
4506
4507 #[allow(dead_code)]
4510 fn make_contour_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
4511 let x = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(_r, c)| {
4513 -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64
4514 })));
4515 let y = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(r, _c)| {
4516 -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64
4517 })));
4518 let z = Value::Matrix(Box::new(Array2::from_shape_fn((rows, cols), |(r, c)| {
4519 let xi = -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64;
4520 let yi = -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64;
4521 (-xi * xi - yi * yi).exp()
4522 })));
4523 (x, y, z)
4524 }
4525
4526 #[test]
4527 fn test_contour_non_matrix_x_errors() {
4528 FIGURE_STATE.with(|f| f.take());
4529 let plugin = PlotPlugin;
4530 let env = Env::new();
4531 let x = Value::Str("notamatrix".into());
4532 let y = f64_vec(&[0.0, 1.0]);
4533 let z = f64_vec(&[0.0, 1.0]);
4534 let result = plugin.call("contour", &[x, y, z], &env);
4535 assert!(result.is_err(), "non-matrix X should error");
4536 let msg = result.unwrap_err();
4537 assert!(msg.contains("X"), "error should mention X: {msg}");
4538 }
4539
4540 #[test]
4541 fn test_contour_mismatched_dimensions_errors() {
4542 FIGURE_STATE.with(|f| f.take());
4543 let plugin = PlotPlugin;
4544 let env = Env::new();
4545 let x = Value::Matrix(Box::new(
4546 Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap(),
4547 ));
4548 let y = Value::Matrix(Box::new(
4549 Array2::from_shape_vec((3, 2), vec![0.0; 6]).unwrap(),
4550 ));
4551 let z = Value::Matrix(Box::new(
4552 Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap(),
4553 ));
4554 let result = plugin.call("contour", &[x, y, z], &env);
4555 assert!(result.is_err(), "mismatched dimensions should error");
4556 let msg = result.unwrap_err();
4557 assert!(
4558 msg.contains("same dimensions"),
4559 "error should mention dimensions: {msg}"
4560 );
4561 }
4562
4563 #[test]
4564 fn test_contour_missing_args_errors() {
4565 FIGURE_STATE.with(|f| f.take());
4566 let plugin = PlotPlugin;
4567 let env = Env::new();
4568 let x = Value::Matrix(Box::new(
4569 Array2::from_shape_vec((2, 2), vec![0.0; 4]).unwrap(),
4570 ));
4571 let result = plugin.call("contour", &[x], &env);
4572 assert!(result.is_err());
4573 let msg = result.unwrap_err();
4574 assert!(
4575 msg.contains("requires"),
4576 "error should mention requires: {msg}"
4577 );
4578 }
4579
4580 #[test]
4581 #[cfg(feature = "plot")]
4582 fn test_contour_ascii_no_error() {
4583 FIGURE_STATE.with(|f| f.take());
4584 let plugin = PlotPlugin;
4585 let env = Env::new();
4586 let (x, y, z) = make_contour_xyz(10, 12);
4587 let result = plugin.call("contour", &[x, y, z, Value::Scalar(5.0)], &env);
4588 assert!(result.is_ok(), "contour ASCII should succeed: {result:?}");
4589 }
4590
4591 #[test]
4592 #[cfg(feature = "plot")]
4593 fn test_contourf_ascii_no_error() {
4594 FIGURE_STATE.with(|f| f.take());
4595 let plugin = PlotPlugin;
4596 let env = Env::new();
4597 let (x, y, z) = make_contour_xyz(10, 12);
4598 let result = plugin.call("contourf", &[x, y, z, Value::Scalar(5.0)], &env);
4599 assert!(result.is_ok(), "contourf ASCII should succeed: {result:?}");
4600 }
4601
4602 #[test]
4603 #[cfg(feature = "plot-svg")]
4604 fn test_contour_svg_creates_file() {
4605 FIGURE_STATE.with(|f| f.take());
4606 let plugin = PlotPlugin;
4607 let env = Env::new();
4608 let (x, y, z) = make_contour_xyz(15, 20);
4609 let path = ".debug/test_contour.svg";
4610 std::fs::create_dir_all(".debug").ok();
4611 let result = plugin.call(
4612 "contour",
4613 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
4614 &env,
4615 );
4616 assert!(result.is_ok(), "contour SVG should succeed: {result:?}");
4617 let content = std::fs::read_to_string(path).unwrap();
4618 assert!(
4619 content.contains("<svg"),
4620 "output should be SVG: starts with {}",
4621 &content[..50.min(content.len())]
4622 );
4623 std::fs::remove_file(path).ok();
4624 }
4625
4626 #[test]
4627 #[cfg(feature = "plot-svg")]
4628 fn test_contourf_png_magic_bytes() {
4629 FIGURE_STATE.with(|f| f.take());
4630 let plugin = PlotPlugin;
4631 let env = Env::new();
4632 let (x, y, z) = make_contour_xyz(15, 20);
4633 let path = ".debug/test_contourf.png";
4634 std::fs::create_dir_all(".debug").ok();
4635 let result = plugin.call(
4636 "contourf",
4637 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
4638 &env,
4639 );
4640 assert!(result.is_ok(), "contourf PNG should succeed: {result:?}");
4641 let bytes = std::fs::read(path).unwrap();
4642 assert_eq!(
4643 &bytes[0..4],
4644 &[0x89, 0x50, 0x4E, 0x47],
4645 "output should be PNG"
4646 );
4647 std::fs::remove_file(path).ok();
4648 }
4649
4650 #[test]
4653 fn test_subplot_sets_state() {
4654 FIGURE_STATE.with(|f| f.take());
4655 let plugin = PlotPlugin;
4656 let env = Env::new();
4657 plugin
4658 .call(
4659 "subplot",
4660 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(1.0)],
4661 &env,
4662 )
4663 .unwrap();
4664 let subplot = FIGURE_STATE.with(|f| f.borrow().subplot);
4665 assert_eq!(subplot, Some((2, 2, 1)));
4666 FIGURE_STATE.with(|f| f.take());
4667 }
4668
4669 #[test]
4670 fn test_hold_on_sets_flag() {
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 hold = FIGURE_STATE.with(|f| f.borrow().hold);
4678 assert!(hold, "hold flag should be true after hold('on')");
4679 FIGURE_STATE.with(|f| f.take());
4680 }
4681
4682 #[test]
4683 fn test_hold_off_clears_flag_and_series() {
4684 FIGURE_STATE.with(|f| f.take());
4685 let plugin = PlotPlugin;
4686 let env = Env::new();
4687 FIGURE_STATE.with(|f| {
4689 let mut st = f.borrow_mut();
4690 st.hold = true;
4691 st.pending_series
4692 .push(PendingSeries::Line(vec![1.0, 2.0], vec![1.0, 4.0], None));
4693 });
4694 let _ = plugin.call("hold", &[Value::Str("off".into())], &env);
4697 let (hold, series_empty) = FIGURE_STATE.with(|f| {
4698 let st = f.borrow();
4699 (st.hold, st.pending_series.is_empty())
4700 });
4701 assert!(!hold, "hold should be false after hold('off')");
4702 assert!(
4703 series_empty,
4704 "pending_series should be cleared after hold('off')"
4705 );
4706 FIGURE_STATE.with(|f| f.take());
4707 }
4708
4709 #[test]
4710 fn test_plot_accumulates_under_hold() {
4711 FIGURE_STATE.with(|f| f.take());
4712 let plugin = PlotPlugin;
4713 let env = Env::new();
4714 plugin
4715 .call("hold", &[Value::Str("on".into())], &env)
4716 .unwrap();
4717 let y1 = f64_vec(&[1.0, 2.0, 3.0]);
4718 let y2 = f64_vec(&[3.0, 2.0, 1.0]);
4719 plugin.call("plot", &[y1], &env).unwrap();
4720 plugin.call("plot", &[y2], &env).unwrap();
4721 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
4722 assert_eq!(count, 2, "two plot calls should accumulate 2 series");
4723 FIGURE_STATE.with(|f| f.take());
4724 }
4725
4726 #[test]
4727 fn test_subplot_then_plot_accumulates() {
4728 FIGURE_STATE.with(|f| f.take());
4729 let plugin = PlotPlugin;
4730 let env = Env::new();
4731 plugin
4732 .call(
4733 "subplot",
4734 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4735 &env,
4736 )
4737 .unwrap();
4738 let y = f64_vec(&[1.0, 2.0, 3.0]);
4739 plugin.call("plot", &[y], &env).unwrap();
4740 let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
4741 assert_eq!(
4742 count, 1,
4743 "plot under subplot should accumulate into pending_series"
4744 );
4745 FIGURE_STATE.with(|f| f.take());
4746 }
4747
4748 #[test]
4749 fn test_second_subplot_commits_first_panel() {
4750 FIGURE_STATE.with(|f| f.take());
4751 let plugin = PlotPlugin;
4752 let env = Env::new();
4753 plugin
4754 .call(
4755 "subplot",
4756 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4757 &env,
4758 )
4759 .unwrap();
4760 plugin.call("plot", &[f64_vec(&[1.0, 2.0])], &env).unwrap();
4761 plugin
4763 .call(
4764 "subplot",
4765 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
4766 &env,
4767 )
4768 .unwrap();
4769 let (panels_len, pending_len) = FIGURE_STATE.with(|f| {
4770 let st = f.borrow();
4771 (st.panels.len(), st.pending_series.len())
4772 });
4773 assert_eq!(panels_len, 1, "panel 1 should be committed");
4774 assert_eq!(
4775 pending_len, 0,
4776 "pending_series should be empty after commit"
4777 );
4778 FIGURE_STATE.with(|f| f.take());
4779 }
4780
4781 #[test]
4782 fn test_subplot_invalid_index_errors() {
4783 FIGURE_STATE.with(|f| f.take());
4784 let plugin = PlotPlugin;
4785 let env = Env::new();
4786 let result = plugin.call(
4787 "subplot",
4788 &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(5.0)],
4789 &env,
4790 );
4791 assert!(result.is_err(), "index 5 in a 2×2 grid should error");
4792 FIGURE_STATE.with(|f| f.take());
4793 }
4794
4795 #[test]
4796 fn test_savefig_with_no_panels_errors() {
4797 FIGURE_STATE.with(|f| f.take());
4798 let plugin = PlotPlugin;
4799 let env = Env::new();
4800 let result = plugin.call("savefig", &[Value::Str("out.svg".into())], &env);
4801 assert!(result.is_err(), "savefig with no panels should error");
4802 FIGURE_STATE.with(|f| f.take());
4803 }
4804
4805 #[test]
4808 fn test_quiver_mismatch_error() {
4809 FIGURE_STATE.with(|f| f.take());
4810 let plugin = PlotPlugin;
4811 let env = Env::new();
4812 let x = f64_vec(&[0.0, 1.0, 2.0]);
4813 let y = f64_vec(&[0.0, 1.0, 2.0]);
4814 let u = f64_vec(&[1.0, 0.0]);
4815 let v = f64_vec(&[0.0, 1.0, 0.0]);
4816 let result = plugin.call("quiver", &[x, y, u, v], &env);
4817 assert!(result.is_err(), "length mismatch should produce an error");
4818 let msg = result.unwrap_err();
4819 assert!(
4820 msg.contains("same length"),
4821 "error should mention 'same length': {msg}"
4822 );
4823 }
4824
4825 #[test]
4826 fn test_text_stores_annotation() {
4827 FIGURE_STATE.with(|f| f.take());
4828 let plugin = PlotPlugin;
4829 let env = Env::new();
4830 plugin
4831 .call(
4832 "text",
4833 &[
4834 Value::Scalar(0.0),
4835 Value::Scalar(1.0),
4836 Value::Str("label".into()),
4837 ],
4838 &env,
4839 )
4840 .unwrap();
4841 let ann = FIGURE_STATE.with(|f| f.borrow().annotations.clone());
4842 assert_eq!(ann.len(), 1, "one annotation should be stored");
4843 assert_eq!(ann[0], (0.0, 1.0, "label".to_string()));
4844 FIGURE_STATE.with(|f| f.take());
4845 }
4846
4847 #[test]
4848 #[cfg(feature = "plot-svg")]
4849 fn test_quiver_svg_creates_file() {
4850 FIGURE_STATE.with(|f| f.take());
4851 let plugin = PlotPlugin;
4852 let env = Env::new();
4853 let x = f64_vec(&[0.0, 1.0, 0.0, 1.0]);
4854 let y = f64_vec(&[0.0, 0.0, 1.0, 1.0]);
4855 let u = f64_vec(&[1.0, 0.0, -1.0, 0.0]);
4856 let v = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
4857 let path = ".debug/test_quiver.svg";
4858 std::fs::create_dir_all(".debug").ok();
4859 let result = plugin.call("quiver", &[x, y, u, v, Value::Str(path.into())], &env);
4860 assert!(result.is_ok(), "quiver SVG should succeed: {result:?}");
4861 let content = std::fs::read_to_string(path).unwrap();
4862 assert!(
4863 content.contains("<svg"),
4864 "output should be SVG: starts with {}",
4865 &content[..50.min(content.len())]
4866 );
4867 std::fs::remove_file(path).ok();
4868 }
4869
4870 #[test]
4871 #[cfg(feature = "plot-svg")]
4872 fn test_subplot_savefig_creates_svg() {
4873 FIGURE_STATE.with(|f| f.take());
4874 let plugin = PlotPlugin;
4875 let env = Env::new();
4876 let path = ".debug/test_subplot_grid.svg";
4877 std::fs::create_dir_all(".debug").ok();
4878 plugin
4879 .call(
4880 "subplot",
4881 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
4882 &env,
4883 )
4884 .unwrap();
4885 plugin
4886 .call("plot", &[f64_vec(&[1.0, 2.0, 3.0])], &env)
4887 .unwrap();
4888 plugin
4889 .call(
4890 "subplot",
4891 &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
4892 &env,
4893 )
4894 .unwrap();
4895 plugin
4896 .call("plot", &[f64_vec(&[3.0, 2.0, 1.0])], &env)
4897 .unwrap();
4898 plugin
4899 .call("savefig", &[Value::Str(path.into())], &env)
4900 .unwrap();
4901 let content = std::fs::read_to_string(path).unwrap();
4902 assert!(
4903 content.contains("<svg"),
4904 "savefig should produce an SVG file"
4905 );
4906 std::fs::remove_file(path).ok();
4907 }
4908
4909 #[cfg(feature = "plot-svg")]
4910 #[test]
4911 fn test_figure_size_applied_to_svg() {
4912 FIGURE_STATE.with(|f| f.take());
4913 let plugin = PlotPlugin;
4914 let env = Env::new();
4915 let path = ".debug/test_figure_size.svg";
4916 std::fs::create_dir_all(".debug").ok();
4917 plugin
4918 .call(
4919 "figure",
4920 &[Value::Scalar(1024.0), Value::Scalar(300.0)],
4921 &env,
4922 )
4923 .unwrap();
4924 plugin
4925 .call(
4926 "plot",
4927 &[
4928 f64_vec(&[1.0, 2.0, 3.0]),
4929 f64_vec(&[1.0, 4.0, 9.0]),
4930 Value::Str(path.into()),
4931 ],
4932 &env,
4933 )
4934 .unwrap();
4935 let content = std::fs::read_to_string(path).unwrap();
4936 assert!(
4937 content.contains("1024"),
4938 "SVG should contain requested width"
4939 );
4940 assert!(
4941 content.contains("300"),
4942 "SVG should contain requested height"
4943 );
4944 std::fs::remove_file(path).ok();
4945 }
4946
4947 #[test]
4950 #[cfg(feature = "plot-svg")]
4951 fn test_theme_dark_svg_contains_dark_bg() {
4952 let plugin = PlotPlugin;
4953 let env = Env::new();
4954 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4955
4956 let path = ".debug/test_theme_dark.svg";
4957 plugin
4958 .call("theme", &[Value::Str("dark".into())], &env)
4959 .unwrap();
4960 plugin
4961 .call(
4962 "plot",
4963 &[
4964 f64_vec(&[1.0, 2.0]),
4965 f64_vec(&[1.0, 2.0]),
4966 Value::Str(path.into()),
4967 ],
4968 &env,
4969 )
4970 .unwrap();
4971 let content = std::fs::read_to_string(path).unwrap();
4972 assert!(
4974 content.contains("1E1E2E") || content.contains("1e1e2e"),
4975 "SVG must contain the dark theme background colour"
4976 );
4977 std::fs::remove_file(path).ok();
4978 }
4979
4980 #[test]
4981 fn test_theme_light_is_default() {
4982 let light = style::Theme::light();
4983 let st = FigureState::default();
4985 let resolved = st.resolve_theme();
4986 assert_eq!(resolved.bg, light.bg);
4987 assert_eq!(resolved.text, light.text);
4988 }
4989
4990 #[test]
4991 fn test_theme_unknown_name_errors() {
4992 let plugin = PlotPlugin;
4993 let env = Env::new();
4994 let result = plugin.call("theme", &[Value::Str("rainbow".into())], &env);
4995 assert!(result.is_err());
4996 assert!(result.unwrap_err().contains("unknown theme"));
4997 }
4998
4999 #[test]
5000 #[cfg(feature = "plot-svg")]
5001 fn test_bgcolor_overrides_theme_bg() {
5002 let plugin = PlotPlugin;
5003 let env = Env::new();
5004 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5005
5006 let path = ".debug/test_bgcolor_override.svg";
5007 plugin
5008 .call("theme", &[Value::Str("dark".into())], &env)
5009 .unwrap();
5010 plugin
5012 .call("bgcolor", &[Value::Str("red".into())], &env)
5013 .unwrap();
5014 plugin
5015 .call(
5016 "plot",
5017 &[
5018 f64_vec(&[1.0, 2.0]),
5019 f64_vec(&[1.0, 2.0]),
5020 Value::Str(path.into()),
5021 ],
5022 &env,
5023 )
5024 .unwrap();
5025 let content = std::fs::read_to_string(path).unwrap();
5026 assert!(
5028 !content.contains("1E1E2E") && !content.contains("1e1e2e"),
5029 "Dark theme bg should not appear when bgcolor overrides it"
5030 );
5031 std::fs::remove_file(path).ok();
5032 }
5033
5034 #[test]
5035 fn test_bgcolor_hex_accepted() {
5036 let plugin = PlotPlugin;
5037 let env = Env::new();
5038 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5039 plugin
5040 .call("bgcolor", &[Value::Str("#AABBCC".into())], &env)
5041 .unwrap();
5042 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
5043 assert_eq!(bg, Some(style::StyleColor(0xAA, 0xBB, 0xCC)));
5044 }
5045
5046 #[test]
5047 fn test_bgcolor_rgb_matrix() {
5048 use ndarray::Array2;
5049 let plugin = PlotPlugin;
5050 let env = Env::new();
5051 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5052 let m = Value::Matrix(Box::new(
5054 Array2::from_shape_vec((1, 3), vec![0.0_f64, 0.5, 1.0]).unwrap(),
5055 ));
5056 plugin.call("bgcolor", &[m], &env).unwrap();
5057 let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
5058 assert_eq!(bg, Some(style::StyleColor(0, 128, 255)));
5059 }
5060
5061 #[test]
5064 fn test_linewidth_named_arg_plot() {
5065 let plugin = PlotPlugin;
5066 let env = Env::new();
5067 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5068 plugin
5069 .call("hold", &[Value::Str("on".into())], &env)
5070 .unwrap();
5071 plugin
5072 .call(
5073 "plot",
5074 &[
5075 f64_vec(&[0.0, 1.0]),
5076 f64_vec(&[0.0, 1.0]),
5077 Value::Str("r--".into()),
5078 Value::Str("linewidth".into()),
5079 Value::Scalar(2.5),
5080 ],
5081 &env,
5082 )
5083 .unwrap();
5084 let lw = FIGURE_STATE.with(|f| {
5085 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
5086 sp.line_width
5087 } else {
5088 None
5089 }
5090 });
5091 assert_eq!(lw, Some(2.5_f32));
5092 }
5093
5094 #[test]
5095 fn test_markersize_named_arg_scatter() {
5096 let plugin = PlotPlugin;
5097 let env = Env::new();
5098 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5099 plugin
5100 .call("hold", &[Value::Str("on".into())], &env)
5101 .unwrap();
5102 plugin
5103 .call(
5104 "scatter",
5105 &[
5106 f64_vec(&[1.0, 2.0]),
5107 f64_vec(&[1.0, 2.0]),
5108 Value::Str("markersize".into()),
5109 Value::Scalar(7.0),
5110 ],
5111 &env,
5112 )
5113 .unwrap();
5114 let ms = FIGURE_STATE.with(|f| {
5115 if let Some(PendingSeries::Scatter(_, _, Some(sp))) = f.borrow().pending_series.first()
5116 {
5117 sp.marker_size
5118 } else {
5119 None
5120 }
5121 });
5122 assert_eq!(ms, Some(7_u32));
5123 }
5124
5125 #[test]
5126 fn test_linewidth_and_markersize_combined() {
5127 let plugin = PlotPlugin;
5128 let env = Env::new();
5129 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5130 plugin
5131 .call("hold", &[Value::Str("on".into())], &env)
5132 .unwrap();
5133 plugin
5134 .call(
5135 "plot",
5136 &[
5137 f64_vec(&[0.0, 1.0]),
5138 f64_vec(&[0.0, 1.0]),
5139 Value::Str("b.".into()),
5140 Value::Str("linewidth".into()),
5141 Value::Scalar(1.5),
5142 Value::Str("markersize".into()),
5143 Value::Scalar(8.0),
5144 ],
5145 &env,
5146 )
5147 .unwrap();
5148 let (lw, ms) = FIGURE_STATE.with(|f| {
5149 if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
5150 (sp.line_width, sp.marker_size)
5151 } else {
5152 (None, None)
5153 }
5154 });
5155 assert_eq!(lw, Some(1.5_f32));
5156 assert_eq!(ms, Some(8_u32));
5157 }
5158
5159 #[test]
5160 fn test_fontsize_global_setter() {
5161 let plugin = PlotPlugin;
5162 let env = Env::new();
5163 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5164 plugin
5165 .call("fontsize", &[Value::Scalar(18.0)], &env)
5166 .unwrap();
5167 let fs = FIGURE_STATE.with(|f| f.borrow().font_size);
5168 assert_eq!(fs, Some(18_u32));
5169 }
5170
5171 #[test]
5172 fn test_linewidth_global_setter() {
5173 let plugin = PlotPlugin;
5174 let env = Env::new();
5175 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5176 plugin
5177 .call("linewidth", &[Value::Scalar(3.0)], &env)
5178 .unwrap();
5179 let lw = FIGURE_STATE.with(|f| f.borrow().line_width);
5180 assert_eq!(lw, Some(3.0_f32));
5181 }
5182
5183 #[test]
5184 fn test_markersize_global_setter() {
5185 let plugin = PlotPlugin;
5186 let env = Env::new();
5187 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5188 plugin
5189 .call("markersize", &[Value::Scalar(5.0)], &env)
5190 .unwrap();
5191 let ms = FIGURE_STATE.with(|f| f.borrow().marker_size);
5192 assert_eq!(ms, Some(5_u32));
5193 }
5194
5195 #[test]
5198 fn test_gridcolor_named_color() {
5199 let plugin = PlotPlugin;
5200 let env = Env::new();
5201 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5202 plugin
5203 .call("gridcolor", &[Value::Str("red".into())], &env)
5204 .unwrap();
5205 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
5206 assert_eq!(gc, Some(StyleColor(255, 0, 0)));
5207 }
5208
5209 #[test]
5210 fn test_gridcolor_rgb_matrix() {
5211 let plugin = PlotPlugin;
5212 let env = Env::new();
5213 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5214 use ccalc_engine::env::Value;
5215 use ndarray::arr2;
5216 let m = Value::Matrix(Box::new(arr2(&[[0.0_f64, 1.0, 0.0]])));
5217 plugin.call("gridcolor", &[m], &env).unwrap();
5218 let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
5219 assert_eq!(gc, Some(StyleColor(0, 255, 0)));
5220 }
5221
5222 #[test]
5223 fn test_gridwidth_global_setter() {
5224 let plugin = PlotPlugin;
5225 let env = Env::new();
5226 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5227 plugin
5228 .call("gridwidth", &[Value::Scalar(2.0)], &env)
5229 .unwrap();
5230 let gw = FIGURE_STATE.with(|f| f.borrow().grid_width);
5231 assert_eq!(gw, Some(2.0_f32));
5232 }
5233
5234 #[test]
5237 fn test_axis_equal_sets_state() {
5238 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5239 let plugin = PlotPlugin;
5240 let env = Env::new();
5241 plugin
5242 .call("axis", &[Value::Str("equal".into())], &env)
5243 .unwrap();
5244 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5245 assert_eq!(mode, Some(style::AxisMode::Equal));
5246 FIGURE_STATE.with(|f| f.take());
5247 }
5248
5249 #[test]
5250 fn test_axis_tight_sets_state() {
5251 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5252 let plugin = PlotPlugin;
5253 let env = Env::new();
5254 plugin
5255 .call("axis", &[Value::Str("tight".into())], &env)
5256 .unwrap();
5257 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5258 assert_eq!(mode, Some(style::AxisMode::Tight));
5259 FIGURE_STATE.with(|f| f.take());
5260 }
5261
5262 #[test]
5263 fn test_axis_off_sets_state() {
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("off".into())], &env)
5269 .unwrap();
5270 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5271 assert_eq!(mode, Some(style::AxisMode::Off));
5272 FIGURE_STATE.with(|f| f.take());
5273 }
5274
5275 #[test]
5276 fn test_axis_on_clears_mode() {
5277 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5278 let plugin = PlotPlugin;
5279 let env = Env::new();
5280 plugin
5281 .call("axis", &[Value::Str("equal".into())], &env)
5282 .unwrap();
5283 plugin
5284 .call("axis", &[Value::Str("on".into())], &env)
5285 .unwrap();
5286 let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
5287 assert_eq!(mode, None, "axis('on') should clear the axis mode");
5288 FIGURE_STATE.with(|f| f.take());
5289 }
5290
5291 #[test]
5292 fn test_axis_invalid_arg_errors() {
5293 let plugin = PlotPlugin;
5294 let env = Env::new();
5295 let result = plugin.call("axis", &[Value::Str("square".into())], &env);
5296 assert!(result.is_err());
5297 let msg = result.unwrap_err();
5298 assert!(
5299 msg.contains("expected"),
5300 "error should describe valid options: {msg}"
5301 );
5302 }
5303
5304 #[test]
5305 fn test_axis_mode_carried_into_panel() {
5306 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5307 let plugin = PlotPlugin;
5308 let env = Env::new();
5309 plugin
5310 .call("axis", &[Value::Str("tight".into())], &env)
5311 .unwrap();
5312 plugin
5313 .call("hold", &[Value::Str("on".into())], &env)
5314 .unwrap();
5315 plugin
5316 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
5317 .unwrap();
5318 plugin
5320 .call(
5321 "subplot",
5322 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
5323 &env,
5324 )
5325 .unwrap();
5326 let mode = FIGURE_STATE.with(|f| f.borrow().panels.first().and_then(|p| p.axis_mode));
5327 assert_eq!(
5328 mode,
5329 Some(style::AxisMode::Tight),
5330 "axis_mode should be carried into the committed panel"
5331 );
5332 FIGURE_STATE.with(|f| f.take());
5333 }
5334
5335 #[test]
5336 #[cfg(feature = "plot-svg")]
5337 fn test_axis_off_svg_no_error() {
5338 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5339 let plugin = PlotPlugin;
5340 let env = Env::new();
5341 plugin
5342 .call("axis", &[Value::Str("off".into())], &env)
5343 .unwrap();
5344 let tmp = std::env::temp_dir().join("axis_off_30_6d.svg");
5345 let path = tmp.to_string_lossy().to_string();
5346 let x = f64_vec(&[1.0, 2.0, 3.0]);
5347 let y = f64_vec(&[1.0, 4.0, 9.0]);
5348 let result = plugin.call("plot", &[x, y, Value::Str(path.clone())], &env);
5349 assert!(
5350 result.is_ok(),
5351 "axis('off') + plot to SVG should succeed: {result:?}"
5352 );
5353 let content = std::fs::read_to_string(&path).unwrap_or_default();
5354 assert!(content.contains("<svg"), "output should contain <svg");
5355 let _ = std::fs::remove_file(&path);
5356 FIGURE_STATE.with(|f| f.take());
5357 }
5358
5359 #[test]
5360 fn test_gridcolor_carried_into_panel() {
5361 let plugin = PlotPlugin;
5362 let env = Env::new();
5363 FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
5364 plugin
5365 .call("gridcolor", &[Value::Str("blue".into())], &env)
5366 .unwrap();
5367 plugin
5368 .call("gridwidth", &[Value::Scalar(3.0)], &env)
5369 .unwrap();
5370 plugin
5371 .call("hold", &[Value::Str("on".into())], &env)
5372 .unwrap();
5373 plugin
5374 .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
5375 .unwrap();
5376 plugin
5378 .call(
5379 "subplot",
5380 &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
5381 &env,
5382 )
5383 .unwrap();
5384 let (gc, gw) = FIGURE_STATE.with(|f| {
5385 f.borrow()
5386 .panels
5387 .first()
5388 .map(|p| (p.grid_color, p.grid_width))
5389 .unwrap_or((None, None))
5390 });
5391 assert_eq!(gc, Some(StyleColor(0, 0, 255)));
5392 assert_eq!(gw, Some(3.0_f32));
5393 }
5394
5395 #[test]
5398 fn pie_ascii_sums_100pct() {
5399 let values = vec![25.0_f64, 50.0, 25.0];
5401 let labels: Vec<String> = vec!["A".into(), "B".into(), "C".into()];
5402 let out = format_pie_ascii(&values, &labels, &[]);
5403 let pct_sum: f64 = out
5405 .lines()
5406 .filter_map(|line| {
5407 let pct_part = line.split('%').next()?;
5408 let num = pct_part.rsplit_once(']')?.1.trim();
5409 num.parse::<f64>().ok()
5410 })
5411 .sum();
5412 assert!(
5413 (pct_sum - 100.0).abs() < 0.1,
5414 "percentages should sum to ~100, got {pct_sum}"
5415 );
5416 }
5417
5418 #[test]
5419 fn pie_ascii_contains_labels() {
5420 let values = vec![60.0_f64, 40.0];
5421 let labels: Vec<String> = vec!["Alpha".into(), "Beta".into()];
5422 let out = format_pie_ascii(&values, &labels, &[]);
5423 assert!(out.contains("Alpha"), "output should contain label 'Alpha'");
5424 assert!(out.contains("Beta"), "output should contain label 'Beta'");
5425 }
5426
5427 #[test]
5428 fn pie_ascii_explode_marker() {
5429 let values = vec![50.0_f64, 30.0, 20.0];
5430 let labels: Vec<String> = vec![String::new(); 3];
5431 let explode = vec![0.0_f64, 0.1, 0.0];
5432 let out = format_pie_ascii(&values, &labels, &explode);
5433 let lines: Vec<&str> = out.lines().collect();
5434 assert!(
5436 !lines[0].ends_with('\u{25c4}'),
5437 "non-exploded slice 0 should not have ◄"
5438 );
5439 assert!(
5440 lines[1].ends_with('\u{25c4}'),
5441 "exploded slice 1 should end with ◄"
5442 );
5443 assert!(
5444 !lines[2].ends_with('\u{25c4}'),
5445 "non-exploded slice 2 should not have ◄"
5446 );
5447 }
5448
5449 #[test]
5450 fn pie_dispatch_empty_error() {
5451 FIGURE_STATE.with(|f| f.take());
5452 let plugin = PlotPlugin;
5453 let env = Env::new();
5454 let err = plugin.call("pie", &[f64_vec(&[])], &env).unwrap_err();
5455 assert!(
5456 err.contains("empty") || err.contains("positive") || err.contains("non-negative"),
5457 "expected meaningful error, got: {err}"
5458 );
5459 }
5460
5461 #[test]
5462 fn pie_dispatch_negative_error() {
5463 FIGURE_STATE.with(|f| f.take());
5464 let plugin = PlotPlugin;
5465 let env = Env::new();
5466 let err = plugin
5467 .call("pie", &[f64_vec(&[1.0, -2.0, 3.0])], &env)
5468 .unwrap_err();
5469 assert!(
5470 err.contains("non-negative"),
5471 "expected non-negative error, got: {err}"
5472 );
5473 }
5474
5475 #[test]
5476 fn pie_dispatch_label_length_mismatch_error() {
5477 FIGURE_STATE.with(|f| f.take());
5478 let plugin = PlotPlugin;
5479 let env = Env::new();
5480 let values = f64_vec(&[30.0, 30.0, 40.0]);
5481 let cell = Value::Cell(Box::new(vec![
5483 Value::Str("A".into()),
5484 Value::Str("B".into()),
5485 ]));
5486 let err = plugin.call("pie", &[values, cell], &env).unwrap_err();
5487 assert!(
5488 err.contains("length"),
5489 "expected length mismatch error, got: {err}"
5490 );
5491 }
5492
5493 #[test]
5494 #[cfg(feature = "plot-svg")]
5495 fn pie_svg_polygon_count() {
5496 FIGURE_STATE.with(|f| f.take());
5497 let plugin = PlotPlugin;
5498 let env = Env::new();
5499 let path = ".debug/test_pie_polygon_count.svg".to_string();
5500 let _ = std::fs::remove_file(&path);
5501 let values = f64_vec(&[25.0, 50.0, 25.0]);
5502 let result = plugin.call("pie", &[values, Value::Str(path.clone())], &env);
5503 assert!(result.is_ok(), "pie SVG should succeed: {result:?}");
5504 let content = std::fs::read_to_string(&path).unwrap_or_default();
5505 let count = content.matches("<polygon").count();
5507 assert_eq!(
5508 count, 3,
5509 "expected exactly 3 <polygon> elements for 3 slices, got {count}"
5510 );
5511 let _ = std::fs::remove_file(&path);
5512 FIGURE_STATE.with(|f| f.take());
5513 }
5514
5515 #[test]
5516 #[cfg(feature = "plot-svg")]
5517 fn pie_with_labels_svg() {
5518 FIGURE_STATE.with(|f| f.take());
5519 let plugin = PlotPlugin;
5520 let env = Env::new();
5521 let path = ".debug/test_pie_labels.svg".to_string();
5522 let _ = std::fs::remove_file(&path);
5523 let values = f64_vec(&[30.0, 70.0]);
5524 let cell = Value::Cell(Box::new(vec![
5525 Value::Str("Small".into()),
5526 Value::Str("Large".into()),
5527 ]));
5528 let result = plugin.call("pie", &[values, cell, Value::Str(path.clone())], &env);
5529 assert!(
5530 result.is_ok(),
5531 "pie with labels SVG should succeed: {result:?}"
5532 );
5533 let content = std::fs::read_to_string(&path).unwrap_or_default();
5534 assert!(
5535 content.contains("Small"),
5536 "SVG should contain label 'Small'"
5537 );
5538 assert!(
5539 content.contains("Large"),
5540 "SVG should contain label 'Large'"
5541 );
5542 let _ = std::fs::remove_file(&path);
5543 FIGURE_STATE.with(|f| f.take());
5544 }
5545
5546 #[test]
5547 #[cfg(feature = "plot-svg")]
5548 fn pie_explode_svg() {
5549 FIGURE_STATE.with(|f| f.take());
5550 let plugin = PlotPlugin;
5551 let env = Env::new();
5552 let path = ".debug/test_pie_explode.svg".to_string();
5553 let _ = std::fs::remove_file(&path);
5554 let values = f64_vec(&[40.0, 30.0, 30.0]);
5555 let explode = f64_vec(&[0.1, 0.0, 0.0]);
5556 let result = plugin.call("pie", &[values, explode, Value::Str(path.clone())], &env);
5557 assert!(
5558 result.is_ok(),
5559 "pie with explode SVG should succeed: {result:?}"
5560 );
5561 let content = std::fs::read_to_string(&path).unwrap_or_default();
5562 assert!(content.contains("<polygon"), "SVG should contain polygons");
5563 let _ = std::fs::remove_file(&path);
5564 FIGURE_STATE.with(|f| f.take());
5565 }
5566
5567 #[test]
5568 #[cfg(feature = "plot-svg")]
5569 fn pie_single_slice() {
5570 FIGURE_STATE.with(|f| f.take());
5571 let plugin = PlotPlugin;
5572 let env = Env::new();
5573 let path = ".debug/test_pie_single.svg".to_string();
5574 let _ = std::fs::remove_file(&path);
5575 let values = f64_vec(&[100.0]);
5576 let result = plugin.call("pie", &[values, Value::Str(path.clone())], &env);
5577 assert!(
5578 result.is_ok(),
5579 "pie single-slice SVG should succeed: {result:?}"
5580 );
5581 let content = std::fs::read_to_string(&path).unwrap_or_default();
5582 let count = content.matches("<polygon").count();
5583 assert_eq!(
5584 count, 1,
5585 "single-slice pie should have exactly 1 polygon, got {count}"
5586 );
5587 let _ = std::fs::remove_file(&path);
5588 FIGURE_STATE.with(|f| f.take());
5589 }
5590
5591 #[test]
5594 fn yyaxis_right_sets_active() {
5595 FIGURE_STATE.with(|f| f.take());
5596 let plugin = PlotPlugin;
5597 let env = Env::new();
5598 plugin
5599 .call("yyaxis", &[Value::Str("right".into())], &env)
5600 .unwrap();
5601 FIGURE_STATE.with(|f| {
5602 let st = f.borrow();
5603 assert_eq!(
5604 st.active_yaxis,
5605 style::YAxis::Right,
5606 "active_yaxis should be Right after yyaxis('right')"
5607 );
5608 assert!(st.hold, "yyaxis should enable hold");
5609 });
5610 FIGURE_STATE.with(|f| f.take());
5611 }
5612
5613 #[test]
5614 fn yyaxis_series_routing() {
5615 FIGURE_STATE.with(|f| f.take());
5616 let plugin = PlotPlugin;
5617 let env = Env::new();
5618 plugin
5620 .call("yyaxis", &[Value::Str("left".into())], &env)
5621 .unwrap();
5622 plugin
5623 .call("plot", &[f64_vec(&[1.0, 2.0]), f64_vec(&[1.0, 2.0])], &env)
5624 .unwrap();
5625 plugin
5627 .call("yyaxis", &[Value::Str("right".into())], &env)
5628 .unwrap();
5629 plugin
5630 .call(
5631 "plot",
5632 &[f64_vec(&[1.0, 2.0]), f64_vec(&[10.0, 20.0])],
5633 &env,
5634 )
5635 .unwrap();
5636 FIGURE_STATE.with(|f| {
5637 let st = f.borrow();
5638 assert_eq!(st.pending_series.len(), 1, "one series on the left axis");
5639 assert_eq!(
5640 st.right_pending_series.len(),
5641 1,
5642 "one series on the right axis"
5643 );
5644 });
5645 FIGURE_STATE.with(|f| f.take());
5646 }
5647
5648 #[test]
5649 fn yyaxis_ylabel_routing() {
5650 FIGURE_STATE.with(|f| f.take());
5651 let plugin = PlotPlugin;
5652 let env = Env::new();
5653 plugin
5654 .call("ylabel", &[Value::Str("left label".into())], &env)
5655 .unwrap();
5656 plugin
5657 .call("yyaxis", &[Value::Str("right".into())], &env)
5658 .unwrap();
5659 plugin
5660 .call("ylabel", &[Value::Str("right label".into())], &env)
5661 .unwrap();
5662 FIGURE_STATE.with(|f| {
5663 let st = f.borrow();
5664 assert_eq!(
5665 st.ylabel.as_deref(),
5666 Some("left label"),
5667 "left ylabel must be unchanged"
5668 );
5669 assert_eq!(
5670 st.right_ylabel.as_deref(),
5671 Some("right label"),
5672 "right ylabel must be set"
5673 );
5674 });
5675 FIGURE_STATE.with(|f| f.take());
5676 }
5677
5678 #[test]
5679 #[cfg(feature = "plot-svg")]
5680 fn yyaxis_svg_has_two_axis_labels() {
5681 FIGURE_STATE.with(|f| f.take());
5682 let plugin = PlotPlugin;
5683 let env = Env::new();
5684 let path = ".debug/test_yyaxis.svg";
5685 let _ = std::fs::remove_file(path);
5686
5687 plugin
5689 .call("yyaxis", &[Value::Str("left".into())], &env)
5690 .unwrap();
5691 plugin
5692 .call("ylabel", &[Value::Str("Left Y".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("ylabel", &[Value::Str("Right Y".into())], &env)
5706 .unwrap();
5707 plugin
5708 .call(
5709 "plot",
5710 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[100.0, 200.0, 300.0])],
5711 &env,
5712 )
5713 .unwrap();
5714 plugin
5715 .call("savefig", &[Value::Str(path.into())], &env)
5716 .unwrap();
5717
5718 let content = std::fs::read_to_string(path).unwrap_or_default();
5719 assert!(
5720 content.contains("Left Y"),
5721 "SVG must contain the left y-axis label"
5722 );
5723 assert!(
5724 content.contains("Right Y"),
5725 "SVG must contain the right y-axis label"
5726 );
5727 std::fs::remove_file(path).ok();
5728 FIGURE_STATE.with(|f| f.take());
5729 }
5730
5731 #[test]
5732 #[cfg(feature = "plot")]
5733 fn yyaxis_ascii_combined_state() {
5734 FIGURE_STATE.with(|f| f.take());
5735 let plugin = PlotPlugin;
5736 let env = Env::new();
5737
5738 plugin
5740 .call("yyaxis", &[Value::Str("left".into())], &env)
5741 .unwrap();
5742 plugin
5743 .call(
5744 "plot",
5745 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[1.0, 2.0, 3.0])],
5746 &env,
5747 )
5748 .unwrap();
5749 plugin
5750 .call("yyaxis", &[Value::Str("right".into())], &env)
5751 .unwrap();
5752 plugin
5753 .call(
5754 "plot",
5755 &[f64_vec(&[1.0, 2.0, 3.0]), f64_vec(&[100.0, 200.0, 300.0])],
5756 &env,
5757 )
5758 .unwrap();
5759
5760 FIGURE_STATE.with(|f| {
5761 let st = f.borrow();
5762 assert_eq!(st.pending_series.len(), 1, "one left series");
5764 assert_eq!(st.right_pending_series.len(), 1, "one right series");
5765 });
5766 FIGURE_STATE.with(|f| f.take());
5767 }
5768
5769 #[test]
5770 #[cfg(feature = "plot")]
5771 fn yyaxis_auto_flush_on_new_left() {
5772 FIGURE_STATE.with(|f| f.take());
5775 let plugin = PlotPlugin;
5776 let env = Env::new();
5777
5778 plugin
5779 .call("yyaxis", &[Value::Str("left".into())], &env)
5780 .unwrap();
5781 plugin
5782 .call(
5783 "plot",
5784 &[f64_vec(&[1.0, 2.0]), f64_vec(&[10.0, 20.0])],
5785 &env,
5786 )
5787 .unwrap();
5788 plugin
5789 .call("yyaxis", &[Value::Str("right".into())], &env)
5790 .unwrap();
5791 plugin
5792 .call(
5793 "plot",
5794 &[f64_vec(&[1.0, 2.0]), f64_vec(&[100.0, 200.0])],
5795 &env,
5796 )
5797 .unwrap();
5798
5799 FIGURE_STATE.with(|f| {
5801 let st = f.borrow();
5802 assert_eq!(st.pending_series.len(), 1);
5803 assert_eq!(st.right_pending_series.len(), 1);
5804 });
5805
5806 plugin
5808 .call("yyaxis", &[Value::Str("left".into())], &env)
5809 .unwrap();
5810
5811 FIGURE_STATE.with(|f| {
5812 let st = f.borrow();
5813 assert_eq!(
5814 st.pending_series.len(),
5815 0,
5816 "left queue must be empty after auto-flush"
5817 );
5818 assert_eq!(
5819 st.right_pending_series.len(),
5820 0,
5821 "right queue must be empty after auto-flush"
5822 );
5823 });
5824 FIGURE_STATE.with(|f| f.take());
5825 }
5826
5827 #[test]
5828 #[cfg(feature = "plot")]
5829 fn yyaxis_ascii_combined_no_panic() {
5830 FIGURE_STATE.with(|f| f.take());
5832 let plugin = PlotPlugin;
5833 let env = Env::new();
5834
5835 plugin
5836 .call("yyaxis", &[Value::Str("left".into())], &env)
5837 .unwrap();
5838 plugin
5839 .call("ylabel", &[Value::Str("Left Y".into())], &env)
5840 .unwrap();
5841 plugin
5842 .call(
5843 "plot",
5844 &[
5845 f64_vec(&[0.0, 1.0, 2.0, 3.0]),
5846 f64_vec(&[18.0, 19.0, 21.0, 23.0]),
5847 ],
5848 &env,
5849 )
5850 .unwrap();
5851 plugin
5852 .call("yyaxis", &[Value::Str("right".into())], &env)
5853 .unwrap();
5854 plugin
5855 .call("ylabel", &[Value::Str("Right Y".into())], &env)
5856 .unwrap();
5857 plugin
5858 .call(
5859 "plot",
5860 &[
5861 f64_vec(&[0.0, 1.0, 2.0, 3.0]),
5862 f64_vec(&[60.0, 65.0, 70.0, 68.0]),
5863 ],
5864 &env,
5865 )
5866 .unwrap();
5867 plugin
5868 .call("title", &[Value::Str("Dual".into())], &env)
5869 .unwrap();
5870 plugin
5872 .call("hold", &[Value::Str("off".into())], &env)
5873 .unwrap();
5874 }
5875
5876 #[test]
5879 fn clabel_sets_flag() {
5880 FIGURE_STATE.with(|f| f.take());
5881 let plugin = PlotPlugin;
5882 let env = Env::new();
5883 assert!(!FIGURE_STATE.with(|f| f.borrow().clabel));
5884 plugin.call("clabel", &[], &env).unwrap();
5885 assert!(
5886 FIGURE_STATE.with(|f| f.borrow().clabel),
5887 "clabel() should set FigureState.clabel to true"
5888 );
5889 FIGURE_STATE.with(|f| f.take());
5890 }
5891
5892 #[test]
5893 fn clabel_without_contour_noop() {
5894 FIGURE_STATE.with(|f| f.take());
5895 let plugin = PlotPlugin;
5896 let env = Env::new();
5897 assert!(plugin.call("clabel", &[], &env).is_ok());
5898 FIGURE_STATE.with(|f| f.take());
5899 }
5900
5901 #[test]
5902 #[cfg(feature = "plot-svg")]
5903 fn clabel_svg_has_text_elements() {
5904 FIGURE_STATE.with(|f| f.take());
5905 let plugin = PlotPlugin;
5906 let env = Env::new();
5907 let (x, y, z) = make_contour_xyz(20, 20);
5908 let path = ".debug/test_clabel.svg";
5909 std::fs::create_dir_all(".debug").ok();
5910 plugin.call("clabel", &[], &env).unwrap();
5911 plugin
5912 .call(
5913 "contour",
5914 &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
5915 &env,
5916 )
5917 .unwrap();
5918 let content = std::fs::read_to_string(path).unwrap();
5919 assert!(
5920 content.contains("<text"),
5921 "clabel SVG should contain <text elements"
5922 );
5923 std::fs::remove_file(path).ok();
5924 FIGURE_STATE.with(|f| f.take());
5925 }
5926
5927 #[test]
5928 #[cfg(feature = "plot-svg")]
5929 fn clabel_text_count_matches_levels() {
5930 FIGURE_STATE.with(|f| f.take());
5931 let plugin = PlotPlugin;
5932 let env = Env::new();
5933 let n_levels: usize = 5;
5934 let path_base = ".debug/test_clabel_base.svg";
5935 let path_labeled = ".debug/test_clabel_labeled.svg";
5936 std::fs::create_dir_all(".debug").ok();
5937
5938 let (x0, y0, z0) = make_contour_xyz(20, 20);
5940 plugin
5941 .call(
5942 "contour",
5943 &[
5944 x0,
5945 y0,
5946 z0,
5947 Value::Scalar(n_levels as f64),
5948 Value::Str(path_base.into()),
5949 ],
5950 &env,
5951 )
5952 .unwrap();
5953 let base_count = std::fs::read_to_string(path_base)
5954 .unwrap()
5955 .matches("<text")
5956 .count();
5957
5958 let (x, y, z) = make_contour_xyz(20, 20);
5960 plugin.call("clabel", &[], &env).unwrap();
5961 plugin
5962 .call(
5963 "contour",
5964 &[
5965 x,
5966 y,
5967 z,
5968 Value::Scalar(n_levels as f64),
5969 Value::Str(path_labeled.into()),
5970 ],
5971 &env,
5972 )
5973 .unwrap();
5974 let label_count = std::fs::read_to_string(path_labeled)
5975 .unwrap()
5976 .matches("<text")
5977 .count();
5978
5979 assert!(
5980 label_count >= base_count + n_levels,
5981 "clabel should add at least {n_levels} <text> elements \
5982 (base={base_count}, with labels={label_count})"
5983 );
5984
5985 std::fs::remove_file(path_base).ok();
5986 std::fs::remove_file(path_labeled).ok();
5987 FIGURE_STATE.with(|f| f.take());
5988 }
5989}