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_nan() || x.is_nan() {
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 - v.round()).abs() < 1e-10 && v.abs() < 1e12 {
return format!("{:.0}", v);
}
let s = format!("{:.6}", 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 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}"));
}
}