plotters-statistical 0.1.0

Statistical chart primitives (box, violin, ROC, PR, regularization-path, residual) as native plotters series
Documentation
//! Normal quantile–quantile plot: sample quantiles vs theoretical standard-normal
//! quantiles, with a robust reference line through the first and third quartiles.

use plotters::element::{Drawable, PointCollection};
use plotters::style::RGBColor;
use plotters_backend::{BackendCoord, DrawingBackend, DrawingErrorKind};

use crate::stats::{norm_ppf, quartiles, sorted_finite, StatsError};
use crate::style::{stroke_style, translucent_fill};

const DEFAULT_COLOR: RGBColor = RGBColor(0, 114, 178); // Okabe–Ito blue
const LINE_GRAY: RGBColor = RGBColor(120, 120, 120);

/// A normal Q–Q plot as a drawable series (coordinate space `(f64, f64)` =
/// `(theoretical quantile, sample quantile)`).
///
/// Points that fall on the reference line indicate the sample is consistent with
/// a normal distribution; systematic departures (curvature, S-shapes) reveal
/// skew or heavy tails.
#[derive(Debug, Clone)]
pub struct QqPlot {
    // [scatter points (n)] then [2 reference-line endpoints].
    points: Vec<(f64, f64)>,
    n: usize,
    marker_radius: u32,
    color: RGBColor,
    line_width: u32,
    show_line: bool,
}

impl QqPlot {
    /// Build from a raw `data` sample. Uses Blom plotting positions
    /// `((i - 3/8) / (n + 1/4))` for the theoretical quantiles and a reference
    /// line through the sample/theoretical first and third quartiles (like R's
    /// `qqline`).
    ///
    /// # Errors
    /// * [`StatsError::EmptyInput`] if no finite values remain.
    pub fn from_data(data: &[f64]) -> Result<Self, StatsError> {
        let sorted = sorted_finite(data);
        if sorted.is_empty() {
            return Err(StatsError::EmptyInput);
        }
        let n = sorted.len();
        let nf = n as f64;
        let mut pts: Vec<(f64, f64)> = Vec::with_capacity(n);
        for (i, &s) in sorted.iter().enumerate() {
            let p = ((i as f64 + 1.0) - 0.375) / (nf + 0.25);
            pts.push((norm_ppf(p), s));
        }

        // Robust reference line through the quartiles.
        let q = quartiles(&sorted)?;
        let tx1 = norm_ppf(0.25);
        let tx3 = norm_ppf(0.75);
        let (slope, intercept) = if tx3 > tx1 {
            let m = (q.q3 - q.q1) / (tx3 - tx1);
            (m, q.q1 - m * tx1)
        } else {
            (1.0, 0.0)
        };
        let x_lo = pts.first().map(|p| p.0).unwrap_or(-3.0);
        let x_hi = pts.last().map(|p| p.0).unwrap_or(3.0);
        pts.push((x_lo, slope * x_lo + intercept));
        pts.push((x_hi, slope * x_hi + intercept));

        Ok(Self {
            points: pts,
            n,
            marker_radius: 3,
            color: DEFAULT_COLOR,
            line_width: 1,
            show_line: true,
        })
    }

    /// Show/hide the reference line.
    pub fn reference_line(mut self, show: bool) -> Self {
        self.show_line = show;
        self
    }

    /// Set the marker color.
    pub fn color(mut self, color: RGBColor) -> Self {
        self.color = color;
        self
    }

    /// Set the marker radius in pixels.
    pub fn marker_radius(mut self, radius: u32) -> Self {
        self.marker_radius = radius;
        self
    }

    /// Set the reference-line width in pixels.
    pub fn line_width(mut self, width: u32) -> Self {
        self.line_width = width;
        self
    }
}

impl<'a> PointCollection<'a, (f64, f64)> for &'a QqPlot {
    type Point = &'a (f64, f64);
    type IntoIter = &'a [(f64, f64)];
    fn point_iter(self) -> &'a [(f64, f64)] {
        &self.points
    }
}

impl<DB: DrawingBackend> Drawable<DB> for QqPlot {
    fn draw<I: Iterator<Item = BackendCoord>>(
        &self,
        points: I,
        backend: &mut DB,
        _parent_dim: (u32, u32),
    ) -> Result<(), DrawingErrorKind<DB::ErrorType>> {
        let pix: Vec<BackendCoord> = points.collect();
        if pix.len() < self.n + 2 {
            return Ok(());
        }
        if self.show_line {
            backend.draw_line(
                pix[self.n],
                pix[self.n + 1],
                &stroke_style(&LINE_GRAY, self.line_width),
            )?;
        }
        let fill = translucent_fill(&self.color, 0.7);
        for p in &pix[..self.n] {
            backend.draw_circle(*p, self.marker_radius, &fill, true)?;
        }
        Ok(())
    }
}