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
}
fn emit_series(
out: &mut String,
points: &[(f64, f64)],
label: &str,
color: &str,
show_legend: bool,
) {
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");
if seg_idx == 0 && show_legend && !label.is_empty() {
out.push_str(&format!("\\addlegendentry{{{}}}\n", latex_escape(label)));
}
}
}
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
}
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}");
}
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()
}
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);
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}"));
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");
}
#[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}");
}
}
#[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}"));
}
}