bland 0.2.0

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
//! Composite-plot helpers: Bode and Q-Q plots.

use crate::figure::{Figure, PaperSize};
use crate::grid::{multi_panel, PanelGridOpts};
use crate::scale::ScaleKind;
use crate::series::Origin;
use crate::stats::normal_quantile;
use crate::strokes::Stroke;
use crate::theme::Theme;

/// Options for [`bode`].
#[derive(Debug, Clone)]
pub struct BodeOpts {
    pub cell_width: f64,
    pub cell_height: f64,
    pub title: Option<String>,
    pub xlabel: String,
    pub mag_label: String,
    pub phase_label: String,
    pub theme: Theme,
}

impl Default for BodeOpts {
    fn default() -> Self {
        Self {
            cell_width: 900.0,
            cell_height: 320.0,
            title: None,
            xlabel: "ω".to_string(),
            mag_label: "|H| [dB]".to_string(),
            phase_label: "∠H [°]".to_string(),
            theme: Theme::report_1972(),
        }
    }
}

impl BodeOpts {
    pub fn title(mut self, s: impl Into<String>) -> Self {
        self.title = Some(s.into());
        self
    }
    pub fn xlabel(mut self, s: impl Into<String>) -> Self {
        self.xlabel = s.into();
        self
    }
    pub fn cell_size(mut self, w: f64, h: f64) -> Self {
        self.cell_width = w;
        self.cell_height = h;
        self
    }
    pub fn theme(mut self, t: Theme) -> Self {
        self.theme = t;
        self
    }
}

/// Render a Bode plot — magnitude (dB, log-x) on top, phase (degrees,
/// log-x) on the bottom — as a stacked two-panel SVG. `omegas` are
/// angular frequencies; `mag_db` and `phase_deg` are pre-computed.
pub fn bode(omegas: &[f64], mag_db: &[f64], phase_deg: &[f64], opts: BodeOpts) -> String {
    let xlim = match (
        omegas.iter().copied().fold(f64::INFINITY, f64::min),
        omegas.iter().copied().fold(f64::NEG_INFINITY, f64::max),
    ) {
        (lo, hi) if lo.is_finite() && hi.is_finite() => (lo, hi),
        _ => (1.0, 10.0),
    };

    let mag_fig = Figure::new()
        .dimensions(opts.cell_width, opts.cell_height)
        .theme(opts.theme.clone())
        .title(opts.title.clone().unwrap_or_default())
        .xlabel(opts.xlabel.clone())
        .ylabel(opts.mag_label.clone())
        .xlog()
        .xlim(xlim.0, xlim.1)
        .line(omegas, mag_db, |s| s);

    let phase_fig = Figure::new()
        .dimensions(opts.cell_width, opts.cell_height)
        .theme(opts.theme)
        .xlabel(opts.xlabel)
        .ylabel(opts.phase_label)
        .xlog()
        .xlim(xlim.0, xlim.1)
        .line(omegas, phase_deg, |s| s);

    multi_panel(
        &[mag_fig, phase_fig],
        PanelGridOpts {
            columns: Some(1),
            cell_width: Some(opts.cell_width),
            cell_height: Some(opts.cell_height),
            ..PanelGridOpts::default()
        },
    )
}

/// Options for [`qq_plot`].
#[derive(Debug, Clone)]
pub struct QqOpts {
    pub label: Option<String>,
    pub reference: bool,
    pub marker: crate::markers::Marker,
}

impl Default for QqOpts {
    fn default() -> Self {
        Self {
            label: None,
            reference: true,
            marker: crate::markers::Marker::CircleOpen,
        }
    }
}

impl QqOpts {
    pub fn label(mut self, s: impl Into<String>) -> Self {
        self.label = Some(s.into());
        self
    }
    pub fn no_reference(mut self) -> Self {
        self.reference = false;
        self
    }
    pub fn marker(mut self, m: crate::markers::Marker) -> Self {
        self.marker = m;
        self
    }
}

/// Returns a Q-Q plot — sample quantiles vs theoretical quantiles of
/// the standard normal — as a fully-built [`Figure`]. With `reference`
/// enabled (default), draws a `y = x` reference line.
pub fn qq_plot(samples: &[f64], opts: QqOpts) -> Figure {
    let mut sorted: Vec<f64> = samples.iter().copied().filter(|v| !v.is_nan()).collect();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let n = sorted.len();
    let theoretical: Vec<f64> = (1..=n)
        .map(|k| {
            let p = (k as f64 - 0.5) / n as f64;
            normal_quantile(p)
        })
        .collect();

    let mut fig = Figure::new()
        .size(PaperSize::A5Landscape)
        .title("Q-Q plot")
        .xlabel("theoretical quantile")
        .ylabel("sample quantile");

    let label = opts.label.clone();
    let marker = opts.marker;
    fig = fig.scatter(&theoretical, &sorted, |s| {
        let s = s.marker(marker);
        if let Some(l) = label {
            s.label(l)
        } else {
            s
        }
    });

    if opts.reference {
        let mut all: Vec<f64> = theoretical.iter().copied().chain(sorted.iter().copied()).collect();
        all.sort_by(|a, b| a.partial_cmp(b).unwrap());
        let lo = all.first().copied().unwrap_or(-1.0);
        let hi = all.last().copied().unwrap_or(1.0);
        fig = fig.line(&[lo, hi], &[lo, hi], |s| s.stroke(Stroke::Dashed));
    }

    // Reference Origin / ScaleKind to keep the imports honest in case
    // we later use them; suppress no-op warnings without #[allow].
    let _ = (Origin::default(), ScaleKind::Linear);
    fig
}