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