Skip to main content

ccalc_plot/
lib.rs

1//! Plot plugin for ccalc — Phase 32f.
2//!
3//! Provides `plot`, `scatter`, `bar`, `stem`, `hist`, `stairs`, `loglog`,
4//! `semilogx`, `semilogy`, `plot3`, `scatter3`, `imagesc`, `image`, `imshow`,
5//! `surf`, `mesh`, `contour`, `contourf`, `subplot`, `hold`, `savefig`,
6//! `fill`, `area`, `polar`, `quiver`, `text`, `axis`, `line`, `patch`,
7//! `rectangle`, `errorbar`, `pie`, and annotation functions (`xlabel`,
8//! `ylabel`, `zlabel`, `title`, `legend`, `xlim`, `ylim`, `zlim`, `grid`,
9//! `colormap`, `colorbar`).
10//! Rendering requires the `plot` or `plot-svg` feature flags; annotation-only
11//! calls work in every build configuration.
12//!
13//! # Feature flags
14//!
15//! | Flag | Backend | Extra size |
16//! |------|---------|------------|
17//! | `plot` | ASCII via `textplots` | ~100 KB |
18//! | `plot-svg` | SVG + PNG via `plotters` | ~3 MB |
19//! | `plot-all` | Both tiers | combined |
20//!
21//! Build with `--features plot` to enable ASCII rendering.
22
23pub 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// ── PendingSeries / Panel ──────────────────────────────────────────────────
50
51/// A renderable data series stored for deferred rendering under `hold`/`subplot`.
52#[derive(Clone)]
53pub enum PendingSeries {
54    /// Connected line plot, with optional style override.
55    Line(Vec<f64>, Vec<f64>, Option<StyleSpec>),
56    /// Point-cloud scatter, with optional style override.
57    Scatter(Vec<f64>, Vec<f64>, Option<StyleSpec>),
58    /// Vertical bar chart, with optional style override.
59    Bar(Vec<f64>, Vec<f64>, Option<StyleSpec>),
60    /// Stem (lollipop) chart, with optional style override.
61    Stem(Vec<f64>, Vec<f64>, Option<StyleSpec>),
62    /// Histogram — pre-computed counts and bin edges, with optional style override.
63    Hist {
64        counts: Vec<usize>,
65        edges: Vec<f64>,
66        style: Option<StyleSpec>,
67    },
68    /// Filled polygon.
69    Fill(Vec<f64>, Vec<f64>, Option<StyleSpec>),
70    /// Area under a curve (polygon closing along y = 0).
71    Area(Vec<f64>, Vec<f64>, Option<StyleSpec>),
72    /// Vector field: origin coordinates `(x, y)` and displacement vectors `(u, v)`, with optional style override.
73    Quiver(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Option<StyleSpec>),
74    /// Vertical error bars with symmetric or asymmetric half-lengths.
75    ///
76    /// `e_low` and `e_high` store the downward and upward half-extents
77    /// respectively (both are non-negative distances from `y[i]`).
78    ErrorBar {
79        /// X positions.
80        x: Vec<f64>,
81        /// Y centre positions.
82        y: Vec<f64>,
83        /// Downward half-extents (≥ 0).
84        e_low: Vec<f64>,
85        /// Upward half-extents (≥ 0).
86        e_high: Vec<f64>,
87        /// Optional style override.
88        style: Option<StyleSpec>,
89    },
90    /// Per-point color scatter plot mapped through the active colormap.
91    ColorScatter {
92        /// X positions.
93        x: Vec<f64>,
94        /// Y positions.
95        y: Vec<f64>,
96        /// Per-point marker radii in pixels.
97        sz: Vec<f64>,
98        /// Scalar values that drive the colormap lookup.
99        c: Vec<f64>,
100        /// Minimum `c` value (for normalisation).
101        c_min: f64,
102        /// Maximum `c` value (for normalisation).
103        c_max: f64,
104    },
105    /// Pie chart with optional per-slice labels and explode offsets.
106    Pie {
107        /// Slice magnitudes (will be normalised to 100 % internally).
108        values: Vec<f64>,
109        /// Optional per-slice text labels (empty `String` = no label for that slice).
110        labels: Vec<String>,
111        /// Per-slice explode offsets as a fraction of the radius (0.0 = no offset).
112        explode: Vec<f64>,
113    },
114}
115
116/// A committed subplot panel ready for file rendering.
117#[derive(Clone, Default)]
118pub struct Panel {
119    /// Grid position `(rows, cols, index_1based)` inside a subplot layout.
120    pub layout: Option<(u32, u32, u32)>,
121    /// X-axis label.
122    pub xlabel: Option<String>,
123    /// Y-axis label.
124    pub ylabel: Option<String>,
125    /// Chart title.
126    pub title: Option<String>,
127    /// Series labels for legend.
128    pub legend: Vec<String>,
129    /// X-axis range override.
130    pub xlim: Option<(f64, f64)>,
131    /// Y-axis range override.
132    pub ylim: Option<(f64, f64)>,
133    /// Whether to draw grid lines.
134    pub grid: bool,
135    /// Accumulated data series.
136    pub series: Vec<PendingSeries>,
137    /// Text annotations placed on this panel.
138    pub annotations: Vec<(f64, f64, String)>,
139    /// Session-level font size carried into this panel.
140    pub font_size: Option<u32>,
141    /// Session-level line width carried into this panel.
142    pub line_width: Option<f32>,
143    /// Session-level marker size carried into this panel.
144    pub marker_size: Option<u32>,
145    /// Session-level grid colour override carried into this panel.
146    pub grid_color: Option<StyleColor>,
147    /// Session-level grid line width carried into this panel.
148    pub grid_width: Option<f32>,
149    /// Axis display mode override carried into this panel.
150    pub axis_mode: Option<AxisMode>,
151    /// Active colormap specification carried into this panel (for `ColorScatter`).
152    pub colormap: Option<ColormapSpec>,
153    // ── Phase 32d — dual Y axis ────────────────────────────────────────────
154    /// Series on the secondary (right) Y axis.
155    pub right_series: Vec<PendingSeries>,
156    /// Override for the right Y axis range `[min, max]`.
157    pub right_ylim: Option<(f64, f64)>,
158    /// Label for the right Y axis.
159    pub right_ylabel: Option<String>,
160}
161
162// ── FigureState ────────────────────────────────────────────────────────────
163
164/// Per-figure annotation and accumulation state.
165///
166/// Annotations (`xlabel`, `title`, …) are set via their corresponding
167/// functions and consumed at the next render call (or at `hold('off')` /
168/// `savefig` when in accumulating mode).
169#[derive(Default, Clone)]
170pub struct FigureState {
171    /// X-axis label.
172    pub xlabel: Option<String>,
173    /// Y-axis label.
174    pub ylabel: Option<String>,
175    /// Z-axis label (consumed only by `plot3` / `scatter3`).
176    pub zlabel: Option<String>,
177    /// Chart title.
178    pub title: Option<String>,
179    /// Series labels for legend boxes (file export only).
180    pub legend: Vec<String>,
181    /// Override x-axis range `[min, max]`.
182    pub xlim: Option<(f64, f64)>,
183    /// Override y-axis range `[min, max]`.
184    pub ylim: Option<(f64, f64)>,
185    /// Override z-axis range `[min, max]` (3D only).
186    pub zlim: Option<(f64, f64)>,
187    /// Whether to draw grid lines (file export only; ASCII ignores).
188    pub grid: bool,
189    /// Active colormap for `imagesc` (default [`ColormapSpec::Named`]`("viridis")` when `None`).
190    pub colormap: Option<ColormapSpec>,
191    /// Whether to append a colorbar to the next `imagesc` render.
192    pub colorbar: bool,
193
194    // ── Phase 30d — subplot + hold ────────────────────────────────────────
195    /// Active subplot grid position `(rows, cols, index_1based)`.
196    pub subplot: Option<(u32, u32, u32)>,
197    /// When `true`, plot calls accumulate into [`Self::pending_series`].
198    pub hold: bool,
199    /// Series accumulated for the current in-progress panel.
200    pub pending_series: Vec<PendingSeries>,
201    /// Committed panels waiting for `savefig`.
202    pub panels: Vec<Panel>,
203    /// Text annotations accumulated for the current render (flushed at render time).
204    pub annotations: Vec<(f64, f64, String)>,
205
206    // ── Phase 30.6a — theme + background colour ───────────────────────────
207    /// Active colour theme (`None` means use the light default).
208    pub theme: Option<Theme>,
209    /// Per-figure background colour override (beats the theme background).
210    pub bg_color: Option<StyleColor>,
211
212    // ── Phase 30.6b — font / stroke sizes ─────────────────────────────────
213    /// Title and axis-label font size override in points (minimum 8).
214    pub font_size: Option<u32>,
215    /// Stroke width override for all line series (pixels).
216    pub line_width: Option<f32>,
217    /// Marker radius override for scatter / marker series (pixels).
218    pub marker_size: Option<u32>,
219
220    // ── Phase 30.6c — grid style ───────────────────────────────────────────
221    /// Grid line colour override (applied to both bold and light grid lines).
222    pub grid_color: Option<StyleColor>,
223    /// Grid line stroke width override in pixels.
224    pub grid_width: Option<f32>,
225
226    // ── Phase 30.6d — axis mode ────────────────────────────────────────────
227    /// Axis display mode (`axis('equal')`, `axis('tight')`, `axis('off')`).
228    pub axis_mode: Option<AxisMode>,
229
230    // ── Phase 31 — custom canvas size ─────────────────────────────────────
231    /// Output canvas size in pixels `(width, height)` for file export.
232    ///
233    /// `None` falls back to the default `800×600`. Set via `figure(w, h)`.
234    /// Persists across panels; cleared only when the whole state is reset.
235    pub figure_size: Option<(u32, u32)>,
236
237    // ── Phase 32d — dual Y axis ────────────────────────────────────────────
238    /// Which Y axis receives new series and annotation calls.
239    pub active_yaxis: YAxis,
240    /// Series accumulated for the right (secondary) Y axis.
241    pub right_pending_series: Vec<PendingSeries>,
242    /// Override for the right Y axis range.
243    pub right_ylim: Option<(f64, f64)>,
244    /// Label for the right Y axis.
245    pub right_ylabel: Option<String>,
246
247    // ── Phase 32e — contour level labels ──────────────────────────────────
248    /// When `true`, the next contour render places a text label at each level.
249    pub clabel: bool,
250}
251
252impl FigureState {
253    /// Returns the canvas size in pixels, falling back to `800×600` if not set.
254    pub fn canvas_size(&self) -> (u32, u32) {
255        self.figure_size.unwrap_or((800, 600))
256    }
257
258    /// Returns the resolved active [`Theme`]: explicit `theme` field > light default.
259    pub fn resolve_theme(&self) -> style::Theme {
260        self.theme.clone().unwrap_or_else(style::Theme::light)
261    }
262
263    /// Returns the effective background colour as an RGB triple.
264    ///
265    /// Resolution order: explicit `bg_color` override > active theme background.
266    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    /// Pushes `series` to the left or right pending queue based on [`Self::active_yaxis`].
272    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
281// ── Terminal size helpers ───────────────────────────────────────────────────
282
283/// Returns the terminal width in columns, read from `$COLUMNS` (default 80).
284pub(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
291/// Returns the terminal height in rows, read from `$LINES` (default 24).
292pub(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
304// ── Exported names ─────────────────────────────────────────────────────────
305
306const 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    // Phase 32a — drawing primitives
352    "line",
353    "patch",
354    "rectangle",
355    // Phase 32b — statistical extensions
356    "errorbar",
357    // Phase 32c — pie chart
358    "pie",
359    // Phase 32d — dual Y axis
360    "yyaxis",
361    // Phase 32e — contour level labels
362    "clabel",
363    // Phase 32f — image/imshow
364    "image",
365    "imshow",
366];
367
368// ── subplot / hold helpers ─────────────────────────────────────────────────
369
370/// Returns `true` when the figure is in accumulating mode (subplot or hold).
371fn is_accumulating(st: &FigureState) -> bool {
372    st.subplot.is_some() || st.hold
373}
374
375/// Commits the current in-progress panel to `st.panels`.
376///
377/// Only commits when there are pending series (left or right) to avoid empty panels.
378/// Clears annotations and all pending series after committing.
379fn 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
407// ── PlotPlugin ─────────────────────────────────────────────────────────────
408
409/// Plot plugin — registers all 2D/3D plotting functions.
410pub 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            // ── String annotation setters ──────────────────────────────
424            "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 ─────────────────────────────────────────────────
451            "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 toggle ────────────────────────────────────────────
458            "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            // ── Axis limit setters ─────────────────────────────────────
480            "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            // ── Render calls ───────────────────────────────────────────
501            // `line` is a MATLAB alias for `plot` — identical behaviour.
502            "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: 4-arg per-point color form, or regular 2-arg ──
525            "scatter" => {
526                // Check for 4-arg ColorScatter form: scatter(x, y, sz, c)
527                // Use extract_file_arg first to isolate data args, then branch.
528                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                    // Regular scatter(x, y) or scatter(x, y, 'style')
599                    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 ───────────────────────────────────────────────
647            "errorbar" => {
648                // Forms:
649                //   errorbar(x, y, e)             — symmetric bars
650                //   errorbar(x, y, e_low, e_high) — asymmetric bars
651                // Optional trailing style string and/or file path.
652                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 ────────────────────────────────────────────────────
719            "pie" => {
720                // Supported forms:
721                //   pie(v)
722                //   pie(v, path)
723                //   pie(v, {'A','B','C'})
724                //   pie(v, {'A','B','C'}, path)
725                //   pie(v, [0 1 0])
726                //   pie(v, [0 1 0], {'A','B','C'})
727                //   pie(v, [0 1 0], {'A','B','C'}, path)
728                //
729                // Detection order (type-based, not positional):
730                //  1. Trailing string = path (.svg/.png/ascii).
731                //  2. Cell array anywhere after v = labels.
732                //  3. Numeric vector (not the first arg) = explode.
733                let mut rest = args.to_vec();
734
735                // Extract trailing path.
736                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                // First argument is the values vector.
752                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                // Parse remaining optional args (labels Cell, explode vector).
766                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                            // Treat as explode vector.
789                            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                // Default empty labels / zero explode.
808                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            // ── Histogram ──────────────────────────────────────────────
831            "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            // ── Log-scale plots ────────────────────────────────────────
860            "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                // Apply log₁₀ and filter non-finite pairs.
869                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                // Annotate axis labels with log₁₀ notation.
891                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            // ── 3D plots ───────────────────────────────────────────────
912            "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            // ── Canvas size ────────────────────────────────────────────
919            "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 / colorbar setters ────────────────────────────
939            "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 / background colour ──────────────────────────────
972            "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            // ── Font / stroke size setters ─────────────────────────────
1015            "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            // ── Grid colour / width overrides ──────────────────────────
1043            "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 mode ──────────────────────────────────────────────
1082            "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 — dual Y axis ───────────────────────────────────
1100            "yyaxis" => {
1101                let s = require_string("yyaxis", args)?;
1102                match s.as_str() {
1103                    "left" | "right" => {
1104                        let is_right = s == "right";
1105
1106                        // When switching back to 'left' while a dual-axis session is
1107                        // pending (right side has series), auto-flush to ASCII so the
1108                        // caller does not need an explicit hold('off').
1109                        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 — contour level labels ─────────────────────────
1161            "clabel" => {
1162                FIGURE_STATE.with(|f| f.borrow_mut().clabel = true);
1163                Ok(Value::Void)
1164            }
1165
1166            // ── imagesc / image (image is an alias) ───────────────────
1167            "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                // Accepted forms:
1174                //   imagesc(Z)          — ASCII or terminal
1175                //   imagesc(Z, path)    — file export; canvas from figure(w,h) or 800×600
1176                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 ────────────────────────────────────────────────
1192            //
1193            // Forms:
1194            //   imshow(Z)              — grayscale, clamp-to-[0,1]
1195            //   imshow(Z, path)        — grayscale file export
1196            //   imshow(R, G, B)        — RGB channels, values in [0,1]
1197            //   imshow(R, G, B, path)  — RGB file export
1198            "imshow" => {
1199                if args.is_empty() {
1200                    return Err("imshow: at least one argument required".into());
1201                }
1202                // Detect RGB form: first three args are all matrices/scalars and
1203                // (arg count is 3 or 4 with last being a string).
1204                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 ────────────────────────────────────────────
1243            "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                // Unique x values = first row of X; unique y values = first column of Y.
1265                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 ─────────────────────────────────────
1280            "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                // Optional 4th arg: number of contour levels (default 10).
1301                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                // Unique coordinate vectors from meshgrid output.
1311                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 ────────────────────────────────────────────────
1328            "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 ───────────────────────────────────────────────────
1351            "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                        // When not in subplot mode: extract panel for ASCII flush.
1371                        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 ────────────────────────────────────────────────
1410            "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 ───────────────────────────────────────────
1432            // `patch` is a MATLAB alias for `fill` — identical behaviour.
1433            "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 ─────────────────────────────────────────────
1448            "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                // Build closed axis-aligned polygon.
1484                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 ──────────────────────────────────────────────────
1499            "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 ─────────────────────────────────────────────────
1514            "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 ────────────────────────────────────────────────
1527            "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 ──────────────────────────────────────────────────
1566            "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
1593// ── Dispatch helpers ───────────────────────────────────────────────────────
1594
1595fn 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// ── contour / contourf dispatch ────────────────────────────────────────────
1672
1673#[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// ── surf / mesh dispatch ───────────────────────────────────────────────────
1766
1767#[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
1855// ── imagesc dispatch ───────────────────────────────────────────────────────
1856
1857fn 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
1873// ── imshow dispatch ────────────────────────────────────────────────────────
1874
1875fn 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
2072// ── Argument helpers ───────────────────────────────────────────────────────
2073
2074fn 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
2109// ── Stairs helpers ─────────────────────────────────────────────────────────
2110
2111/// Converts (x, y) data into step/staircase pairs for rendering.
2112fn 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        // Horizontal segment at y[i] until the next x position.
2123        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
2131// ── Histogram helpers ──────────────────────────────────────────────────────
2132
2133/// Sturges rule: bins ≈ √n, minimum 1.
2134fn sturges_bins(n: usize) -> usize {
2135    (n as f64).sqrt().round() as usize
2136}
2137
2138/// Parses `hist` arguments: `(data_vec, n_bins)`.
2139/// Parses hist arguments and returns `(counts, edges)` ready for rendering.
2140///
2141/// Accepts `[v]` (Sturges default), `[v, n]` (explicit bin count), or
2142/// `[v, edges]` (explicit bin-edge vector).
2143fn 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
2178/// Computes histogram counts with `n_bins` uniform bins spanning the data range.
2179fn 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
2197/// Computes histogram counts using caller-supplied bin edges.
2198///
2199/// Values below `edges[0]` or above `edges[last]` are ignored.
2200fn 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        // Binary search for the bin: edges[b] <= v < edges[b+1]
2205        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
2214/// Prints a character-art histogram to stdout (no feature flag required).
2215fn 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
2266// ── Multi-series dispatch ──────────────────────────────────────────────────
2267
2268fn 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    // Render first series only; note remaining series.
2290    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
2329// ── Pre-transformed line dispatch (loglog / semilogx / semilogy) ───────────
2330
2331/// Dispatch a pre-processed (x, y) pair to ASCII or file, rendering a line.
2332fn 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
2398// ── fill / area dispatch ───────────────────────────────────────────────────
2399
2400fn 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
2508// ── bar / stem dispatch ────────────────────────────────────────────────────
2509
2510fn 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
2618// ── quiver dispatch ────────────────────────────────────────────────────────
2619
2620fn 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
2678/// ASCII quiver: Unicode directional arrows placed on a character grid.
2679fn 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
2759/// Returns `true` when `v` is a numeric `Value` (Scalar or Matrix).
2760fn is_numeric_value(v: &Value) -> bool {
2761    matches!(v, Value::Scalar(_) | Value::Matrix(_))
2762}
2763
2764// ── errorbar dispatch ──────────────────────────────────────────────────────
2765
2766#[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
2788/// ASCII tier for `errorbar`: prints a compact table with ± notation.
2789fn 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// ── color_scatter dispatch ─────────────────────────────────────────────────
2835
2836#[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
2890// ── pie dispatch ──────────────────────────────────────────────────────────
2891
2892fn 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
2911/// Formats a pie chart as a horizontal bar-art table.
2912///
2913/// Returns the formatted string so callers (including tests) can inspect it.
2914/// Fill characters cycled per slice so each slice is visually distinct.
2915const SLICE_FILLS: [char; 4] = [
2916    '\u{2588}', // █ full block
2917    '\u{2593}', // ▓ dark shade
2918    '\u{2592}', // ▒ medium shade
2919    '\u{2591}', // ░ light shade
2920];
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        // Build bar: filled part uses slice fill char; empty part uses `·`
2937        // with a single `─` (U+2500) exactly at the midpoint.
2938        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}'); // ·
2949            }
2950        }
2951        let explode_marker = if is_exploded { " \u{25c4}" } else { "" }; // ◄ or nothing
2952        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
2986/// Maps an angle in radians to one of 8 Unicode directional arrow characters.
2987fn 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}', // →
2993        1 => '\u{2197}', // ↗
2994        2 => '\u{2191}', // ↑
2995        3 => '\u{2196}', // ↖
2996        4 => '\u{2190}', // ←
2997        5 => '\u{2199}', // ↙
2998        6 => '\u{2193}', // ↓
2999        _ => '\u{2198}', // ↘
3000    }
3001}
3002
3003// ── 3D dispatch ────────────────────────────────────────────────────────────
3004
3005fn 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    // Pass only title and axis limits to the 2D ASCII renderer.
3026    // Labels are printed below as a footer to avoid misleading axis descriptions.
3027    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// ── subplot / hold / savefig render ───────────────────────────────────────
3089
3090/// Renders all series in a panel to ASCII stdout (used by `hold('off')`).
3091///
3092/// Each series is printed sequentially; a `---` divider separates them.
3093/// If the panel has right-axis series, they are printed after a `[right axis]` header.
3094#[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        // Use combined chart when both sides consist only of Line / Scatter series.
3168        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            // Mixed series types: fall back to two-block rendering.
3206            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// ── Argument helpers (continued) ───────────────────────────────────────────
3290
3291/// Extracts three equal-length numeric vectors from `plot3`/`scatter3` args.
3292#[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/// Extracts x and one or more y series from plot arguments.
3344///
3345/// When y is an M×N matrix with M > 1, returns M separate row-series.
3346/// Otherwise behaves identically to `extract_xy`.
3347#[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                    // Each row is one series.
3361                    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// ── Tests ──────────────────────────────────────────────────────────────────
3392
3393#[cfg(test)]
3394mod tests {
3395    use ccalc_engine::env::{Env, Value};
3396    use ndarray::Array2;
3397
3398    use super::*;
3399
3400    // ── term_cols / term_rows ─────────────────────────────────────────
3401
3402    #[test]
3403    fn test_term_cols_default() {
3404        // Without $COLUMNS set, must return the 80-column fallback.
3405        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    // ── extract_xy ────────────────────────────────────────────────────
3430
3431    #[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    // ── Annotation setters ────────────────────────────────────────────
3464
3465    #[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        // Clean up so other tests start fresh.
3475        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    // ── Render dispatch ───────────────────────────────────────────────
3499
3500    #[test]
3501    fn test_plot_no_feature_returns_error_without_feature() {
3502        // When compiled WITHOUT --features plot, calling plot should give a
3503        // helpful error rather than silently doing nothing.
3504        #[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        // With the feature enabled this path is dead code — that's fine.
3515        #[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    // ── Multi-series extract_xy_multi ─────────────────────────────────────
3555
3556    #[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        // 2×3 matrix → 2 series of 3 points each
3570        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    // ── Log-scale plots ───────────────────────────────────────────────────
3591
3592    #[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        // Without plot feature → feature error; with plot feature → ok.
3609        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        // Should not say "not yet implemented" regardless of features.
3613        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        // After calling semilogy, ylabel should be cleared (consumed by render).
3624        // This test verifies that the state is consumed and ylabel is annotated
3625        // before rendering (requires plot feature to actually render).
3626        FIGURE_STATE.with(|f| f.take());
3627    }
3628
3629    #[test]
3630    fn test_stairs_stub_is_gone() {
3631        // stairs should succeed (not stub-error) when called with valid data
3632        let plugin = PlotPlugin;
3633        let env = Env::new();
3634        // Without plot feature this should error about missing feature (not "not implemented").
3635        // With plot feature this should succeed.
3636        #[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            // Should error about missing feature, not "not implemented".
3647            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    // ── 29c.1 annotation setters ──────────────────────────────────────
3656
3657    #[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        // Initially false.
3724        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        // Invalid string arg should still error.
3746        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    // ── ASCII rendering (requires --features plot) ────────────────────
3787
3788    #[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        // State should be cleared after render.
3818        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    // ── 29d: plot3 / scatter3 ─────────────────────────────────────────
3826
3827    #[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        // Only two args — missing z.
3850        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    // ── 30a: colormap / colorbar / imagesc ────────────────────────────
3922
3923    #[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    // ── 30.5b: extended style strings ─────────────────────────────────────
3948
3949    #[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    // ── 30.5c: Option<StyleSpec> for Bar / Stem / Hist / Quiver ─────────────
4067
4068    #[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    // ── figure() tests ───────────────────────────────────────────────────────
4225
4226    #[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    // ── 30b: surf / mesh ───────────────────────────────────────────────────
4372
4373    #[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        // PNG magic bytes: 0x89 P N G
4499        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    // ── 30c: contour / contourf ────────────────────────────────────────────
4508
4509    #[allow(dead_code)]
4510    fn make_contour_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
4511        // X, Y from meshgrid; Z = Gaussian bell centred at (0,0)
4512        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    // ── Phase 30d: subplot + hold + savefig ──────────────────────────
4651
4652    #[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        // Prime hold + a series so hold('off') has something to flush.
4688        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        // State is mutated before ASCII rendering; ignore the render result so
4695        // this test passes regardless of which feature flags are enabled.
4696        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        // Move to panel 2 — should commit panel 1
4762        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    // ── Phase 30f: quiver + text ───────────────────────────────────────────
4806
4807    #[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    // ── Phase 30.6a — Theme + bgcolor ─────────────────────────────────
4948
4949    #[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        // Dark theme background is #1E1E2E.
4973        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        // Default FigureState has no theme → resolve_theme returns light.
4984        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        // Override with a bright red background.
5011        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        // Red = #FF0000; dark theme bg #1E1E2E must NOT be the fill.
5027        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        // [0.0, 0.5, 1.0] as 1×3 matrix → RGB(0, 128, 255).
5053        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    // ── Phase 30.6b tests ──────────────────────────────────────────────────
5062
5063    #[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    // ── Phase 30.6c — grid style ────────────────────────────────────────
5196
5197    #[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    // ── 30.6d: axis mode ─────────────────────────────────────────────────────
5235
5236    #[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        // commit_current_panel via subplot
5319        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        // commit_current_panel via subplot call
5377        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    // ── Phase 32c: pie ─────────────────────────────────────────────────────
5396
5397    #[test]
5398    fn pie_ascii_sums_100pct() {
5399        // Each bar line should show a percentage that adds up to ~100%.
5400        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        // Extract percentages from lines like " [██...] 25.0%  A"
5404        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        // Second slice (index 1) should have ◄ suffix, others should not.
5435        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        // Cell array with wrong number of labels.
5482        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        // One polygon per slice — 3 slices.
5506        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    // ── Phase 32d — yyaxis ────────────────────────────────────────────
5592
5593    #[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        // Activate left axis first (also enables hold so series are not flushed).
5619        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        // Switch to right axis and add another series.
5626        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        // Activate left axis first so the first plot is held instead of flushed.
5688        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        // Activate left axis first so the series is held.
5739        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            // Both series should still be in pending state (hold is on).
5763            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        // A second yyaxis('left') call must flush the previous dual-axis session
5773        // without requiring an explicit hold('off').
5774        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        // State: both sides pending.
5800        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        // Starting a new session via yyaxis('left') must flush the previous one.
5807        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        // hold('off') must flush both sides onto one combined chart without panic.
5831        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        // Flushing via hold('off') must not panic.
5871        plugin
5872            .call("hold", &[Value::Str("off".into())], &env)
5873            .unwrap();
5874    }
5875
5876    // ── Phase 32e — clabel ────────────────────────────────────────────
5877
5878    #[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        // Render without clabel to get baseline <text> count (title/axis labels).
5939        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        // Render with clabel — should add one label per level.
5959        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}