symplex 0.46.0

Exact symbolic mathematics for Rust: calculus, summation, solving, linear algebra, transforms, compile-time dimensional analysis, and Rust/C code generation
Documentation
//! TikZ/PGFplots rendering backend.
//!
//! Emits self-contained PGFplots code suitable for inclusion in LaTeX documents.
//! PGFplots handles axes, ticks, and legends, so this module focuses on
//! correctly formatting coordinate data and axis options.

/// Render plot data as a TikZ/PGFplots code string.
///
/// Each entry in `series` is a `(points, label)` pair. NaN y-values trigger
/// a break: the current `\addplot` is closed and a new one begins (unlabeled)
/// so that discontinuities appear as gaps rather than wild lines.
///
/// `log_x` / `log_y` switch the corresponding axis to logarithmic mode.
pub(crate) fn tikz_plot(
    series: &[(&[(f64, f64)], &str)],
    title: Option<&str>,
    xlabel: Option<&str>,
    ylabel: Option<&str>,
    log_x: bool,
    log_y: bool,
) -> String {
    let colors = [
        "blue",
        "red",
        "green!60!black",
        "orange",
        "purple",
        "cyan",
        "brown",
        "magenta",
    ];

    let mut out = String::with_capacity(4096);
    out.push_str("\\begin{tikzpicture}\n");
    out.push_str("\\begin{axis}[\n");
    out.push_str("  width=10cm, height=6cm,\n");

    if let Some(t) = title {
        out.push_str(&format!("  title={{{}}},\n", latex_escape(t)));
    }
    if let Some(xl) = xlabel {
        out.push_str(&format!("  xlabel={{{}}},\n", latex_escape(xl)));
    }
    if let Some(yl) = ylabel {
        out.push_str(&format!("  ylabel={{{}}},\n", latex_escape(yl)));
    }

    out.push_str("  grid=major,\n");

    if log_x {
        out.push_str("  xmode=log,\n");
    } else {
        out.push_str("  xmode=normal,\n");
    }
    if log_y {
        out.push_str("  ymode=log,\n");
    } else {
        out.push_str("  ymode=normal,\n");
    }

    if series.len() > 1 {
        out.push_str("  legend pos=north east,\n");
    }

    out.push_str("]\n");

    for (si, &(points, label)) in series.iter().enumerate() {
        let color = colors[si % colors.len()];
        emit_series(&mut out, points, label, color, si == 0 || series.len() > 1);
    }

    out.push_str("\\end{axis}\n");
    out.push_str("\\end{tikzpicture}\n");
    out
}

/// Emit one logical series, splitting on NaN gaps.
fn emit_series(
    out: &mut String,
    points: &[(f64, f64)],
    label: &str,
    color: &str,
    show_legend: bool,
) {
    // Split points into contiguous segments (no NaN).
    let segments = split_on_nan(points);

    for (seg_idx, seg) in segments.iter().enumerate() {
        if seg.is_empty() {
            continue;
        }

        out.push_str(&format!(
            "\\addplot[{}, thick, no markers] coordinates {{\n",
            color
        ));

        for &(x, y) in seg {
            out.push_str(&format!("  ({}, {})\n", fmt_coord(x), fmt_coord(y)));
        }

        out.push_str("};\n");

        // Only add legend entry for the first segment of this series.
        if seg_idx == 0 && show_legend && !label.is_empty() {
            out.push_str(&format!("\\addlegendentry{{{}}}\n", latex_escape(label)));
        }
    }
}

/// Split a point slice into contiguous segments, breaking at non-finite
/// coordinates (`NaN` breaks and, since 0.46, infinities, which were
/// printed as `inf` coordinates).
fn split_on_nan(points: &[(f64, f64)]) -> Vec<Vec<(f64, f64)>> {
    let mut segments: Vec<Vec<(f64, f64)>> = Vec::new();
    let mut current: Vec<(f64, f64)> = Vec::new();

    for &(x, y) in points {
        if !y.is_finite() || !x.is_finite() {
            if !current.is_empty() {
                segments.push(std::mem::take(&mut current));
            }
        } else {
            current.push((x, y));
        }
    }
    if !current.is_empty() {
        segments.push(current);
    }
    segments
}

/// Format a coordinate value with reasonable precision.
///
/// Integers below 10¹² get no decimal point; other values in
/// `[10⁻³, 10¹²)` get up to 6 decimals (6 significant digits below 1);
/// smaller and larger magnitudes get the shortest round-trip scientific
/// notation (`1.5e-9`, `1.0000000000000001e15`), which PGFplots reads.
/// Up to 0.45 every non-integer had 6 fixed decimals:
/// values below `5·10⁻⁷` printed as `0` (`exp(−x)` on `[20, 30]` was
/// a flat line at 0, the x-values of a range `[0, 10⁻⁶]` collapsed to
/// two), and huge ones as 300-digit integers.
fn fmt_coord(v: f64) -> String {
    if v == 0.0 {
        return "0".to_string();
    }
    if !v.is_finite() {
        return format!("{v}");
    }
    let a = v.abs();
    if (v - v.round()).abs() < 1e-10 && (1.0..1e12).contains(&a) {
        return format!("{:.0}", v);
    }
    if !(1e-3..1e12).contains(&a) {
        return format!("{v:e}");
    }
    // Up to 6 significant decimals below 1, 6 decimals above; strip
    // trailing zeros.
    let decimals = if a < 1.0 {
        (5 - a.log10().floor() as i32).clamp(6, 9) as usize
    } else {
        6
    };
    let s = format!("{:.*}", decimals, v);
    let s = s.trim_end_matches('0');
    let s = s.trim_end_matches('.');
    s.to_string()
}

/// Minimal LaTeX escaping for text content in titles/labels.
fn latex_escape(s: &str) -> String {
    s.replace('\\', "\\textbackslash{}")
        .replace('{', "\\{")
        .replace('}', "\\}")
        .replace('&', "\\&")
        .replace('%', "\\%")
        .replace('$', "\\$")
        .replace('#', "\\#")
        .replace('_', "\\_")
        .replace('^', "\\^{}")
        .replace('~', "\\~{}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_tikz_output() {
        let pts: Vec<(f64, f64)> = (0..5).map(|i| (i as f64, (i * i) as f64)).collect();
        let output = tikz_plot(
            &[(&pts, "x^2")],
            Some("Quadratic"),
            Some("x"),
            Some("y"),
            false,
            false,
        );
        assert!(output.contains("\\begin{tikzpicture}"));
        assert!(output.contains("\\end{tikzpicture}"));
        assert!(output.contains("\\begin{axis}"));
        assert!(output.contains("\\end{axis}"));
        assert!(output.contains("\\addplot"));
        assert!(output.contains("title={Quadratic}"));
        assert!(output.contains("xlabel={x}"));
        assert!(output.contains("ylabel={y}"));
        assert!(output.contains("\\addlegendentry{x\\^{}2}"));
    }

    #[test]
    fn nan_breaks_segments() {
        let pts = vec![
            (0.0, 1.0),
            (1.0, 2.0),
            (2.0, f64::NAN),
            (3.0, 4.0),
            (4.0, 5.0),
        ];
        let output = tikz_plot(&[(&pts, "f")], None, None, None, false, false);
        // Should have two \addplot commands (one per segment).
        let count = output.matches("\\addplot[").count();
        assert_eq!(
            count, 2,
            "expected 2 addplot blocks for NaN-split data, got {count}"
        );
    }

    #[test]
    fn log_axes() {
        let pts = vec![(1.0, 10.0), (10.0, 100.0), (100.0, 1000.0)];
        let output = tikz_plot(&[(&pts, "log")], None, None, None, true, true);
        assert!(output.contains("xmode=log"));
        assert!(output.contains("ymode=log"));
    }

    #[test]
    fn multiple_series() {
        let pts1: Vec<(f64, f64)> = (0..3).map(|i| (i as f64, i as f64)).collect();
        let pts2: Vec<(f64, f64)> = (0..3).map(|i| (i as f64, (i * 2) as f64)).collect();
        let output = tikz_plot(
            &[(&pts1, "linear"), (&pts2, "double")],
            None,
            None,
            None,
            false,
            false,
        );
        assert!(output.contains("\\addlegendentry{linear}"));
        assert!(output.contains("\\addlegendentry{double}"));
        // Two series ⇒ at least 2 addplot blocks.
        let count = output.matches("\\addplot[").count();
        assert!(count >= 2);
    }

    #[test]
    fn split_on_nan_basic() {
        let pts = vec![(0.0, 1.0), (1.0, f64::NAN), (2.0, 3.0)];
        let segs = split_on_nan(&pts);
        assert_eq!(segs.len(), 2);
        assert_eq!(segs[0].len(), 1);
        assert_eq!(segs[1].len(), 1);
    }

    #[test]
    fn fmt_coord_integers() {
        assert_eq!(fmt_coord(0.0), "0");
        assert_eq!(fmt_coord(3.0), "3");
        assert_eq!(fmt_coord(-7.0), "-7");
    }

    #[test]
    fn fmt_coord_decimals() {
        let s = fmt_coord(1.5);
        assert_eq!(s, "1.5");
    }

    /// Small and huge magnitudes keep their digits.  Before, 6 fixed
    /// decimals printed `1.96e-9` (`exp(−20.05)`) as `0` and `1e300` as a
    /// 301-digit integer; `0.000123456` lost three digits.
    #[test]
    fn fmt_coord_keeps_small_and_huge_values() {
        assert_eq!(fmt_coord(1.9607927131212156e-9), "1.9607927131212156e-9");
        assert_eq!(fmt_coord(-2.5e-7), "-2.5e-7");
        assert_eq!(fmt_coord(1e300), "1e300");
        assert_eq!(fmt_coord(1e15 + 0.125), "1.0000000000000001e15");
        assert_eq!(fmt_coord(0.000123456), "1.23456e-4");
        assert_eq!(fmt_coord(0.00512345678), "0.00512346");
        assert_eq!(fmt_coord(0.5), "0.5");
        assert_eq!(fmt_coord(-12.25), "-12.25");
        for v in [
            1.9607927131212156e-9,
            3.5e-5,
            0.00512345678,
            1e300,
            123456.75,
        ] {
            let back: f64 = fmt_coord(v).parse().unwrap();
            assert!((back - v).abs() <= 1e-6 * v.abs(), "{v} -> {back}");
        }
    }

    /// An infinite coordinate breaks the curve like `NaN` (it was printed
    /// as `(1, inf)` inside the `\addplot`).
    #[test]
    fn infinite_coordinates_break_segments() {
        let pts = vec![(0.0, 1.0), (1.0, f64::INFINITY), (2.0, 3.0), (3.0, 4.0)];
        let output = tikz_plot(&[(&pts, "f")], None, None, None, false, false);
        assert_eq!(output.matches("\\addplot[").count(), 2);
        assert!(!output.contains("inf"));
    }

    #[test]
    fn empty_series() {
        let pts: Vec<(f64, f64)> = vec![];
        let output = tikz_plot(&[(&pts, "empty")], None, None, None, false, false);
        assert!(output.contains("\\begin{axis}"));
        assert!(output.contains("\\end{axis}"));
    }
}