Skip to main content

trustformers_debug/visualization/
svg_render.rs

1//! Dependency-free SVG rendering for [`crate::visualization::DebugVisualizer`].
2//!
3//! Every function in this module produces a **real** SVG document: axes are drawn
4//! from the actual data range, series are real polylines through the actual points,
5//! histogram bars come from a real binning pass and heatmap cells from a real
6//! colour-mapped normalisation of the supplied matrix.
7//!
8//! This exists because SVG is the one raster-free vector format the crate can emit
9//! with zero extra dependencies (the COOLJAPAN Pure-Rust policy keeps the PNG/GIF
10//! encoders behind the optional `visual` / `image` / `gif` features). Formats this
11//! module cannot honestly produce are rejected by the caller with a structured
12//! error rather than being written out under a misleading file extension.
13
14use super::types::{ColorScheme, HeatmapData, HistogramData, PlotData, VisualizationConfig};
15use std::fmt::Write as _;
16
17/// Pixel margins around the plotting area: (left, right, top, bottom).
18const MARGIN: (f64, f64, f64, f64) = (70.0, 25.0, 40.0, 55.0);
19
20/// Number of gridline/tick divisions on each axis.
21const TICKS: usize = 5;
22
23/// Escape the five XML predefined entities so arbitrary titles/labels cannot
24/// break out of the document.
25pub(crate) fn escape_xml(s: &str) -> String {
26    let mut out = String::with_capacity(s.len());
27    for ch in s.chars() {
28        match ch {
29            '&' => out.push_str("&"),
30            '<' => out.push_str("&lt;"),
31            '>' => out.push_str("&gt;"),
32            '"' => out.push_str("&quot;"),
33            '\'' => out.push_str("&apos;"),
34            _ => out.push(ch),
35        }
36    }
37    out
38}
39
40/// Turn a human title into a filesystem-safe stem.
41///
42/// Keeps ASCII alphanumerics, collapses every other character into `_`, and
43/// falls back to `plot` when nothing usable remains.
44pub(crate) fn slugify(name: &str) -> String {
45    let mut out = String::with_capacity(name.len());
46    let mut last_underscore = false;
47    for ch in name.chars() {
48        if ch.is_ascii_alphanumeric() {
49            out.push(ch.to_ascii_lowercase());
50            last_underscore = false;
51        } else if !last_underscore {
52            out.push('_');
53            last_underscore = true;
54        }
55    }
56    let trimmed = out.trim_matches('_');
57    if trimmed.is_empty() {
58        "plot".to_string()
59    } else {
60        trimmed.to_string()
61    }
62}
63
64/// Palette for a colour scheme: (background, axis/text, series colours).
65fn palette(scheme: &ColorScheme) -> (&'static str, &'static str, &'static [&'static str]) {
66    match scheme {
67        ColorScheme::Default => (
68            "#ffffff",
69            "#333333",
70            &[
71                "#1f77b4", "#d62728", "#2ca02c", "#ff7f0e", "#9467bd", "#8c564b",
72            ],
73        ),
74        ColorScheme::Dark => (
75            "#1e1e1e",
76            "#d4d4d4",
77            &[
78                "#4fc3f7", "#ef5350", "#81c784", "#ffb74d", "#ba68c8", "#a1887f",
79            ],
80        ),
81        // Wong (2011) "Points of view: Color blindness", Nature Methods 8:441 —
82        // an eight-colour palette designed to stay distinguishable under the
83        // common dichromacies.
84        ColorScheme::Colorblind => (
85            "#ffffff",
86            "#000000",
87            &[
88                "#0072b2", "#d55e00", "#009e73", "#e69f00", "#cc79a7", "#56b4e9",
89            ],
90        ),
91        ColorScheme::Viridis => (
92            "#ffffff",
93            "#333333",
94            &[
95                "#440154", "#3b528b", "#21918c", "#5ec962", "#fde725", "#31688e",
96            ],
97        ),
98        ColorScheme::Plasma => (
99            "#ffffff",
100            "#333333",
101            &[
102                "#0d0887", "#6a00a8", "#b12a90", "#e16462", "#fca636", "#f0f921",
103            ],
104        ),
105    }
106}
107
108/// Anchor stops for the continuous colour map used by heatmaps.
109///
110/// `Viridis` and `Plasma` use the five canonical matplotlib anchor colours at
111/// t = 0, 0.25, 0.5, 0.75, 1.0 with piecewise-linear interpolation in sRGB.
112/// That is an *approximation* of the published maps (which are tabulated at 256
113/// points in linear-ish space); the maximum per-channel deviation from the real
114/// tables is a few percent, which is immaterial for a debugging heatmap but is
115/// the reason this is documented as an approximation rather than "viridis".
116fn colormap_stops(scheme: &ColorScheme) -> &'static [(u8, u8, u8)] {
117    match scheme {
118        ColorScheme::Default => &[
119            (5, 48, 97),
120            (146, 197, 222),
121            (247, 247, 247),
122            (244, 165, 130),
123            (103, 0, 31),
124        ],
125        ColorScheme::Dark => &[
126            (0, 0, 0),
127            (69, 24, 79),
128            (152, 47, 45),
129            (222, 133, 25),
130            (252, 255, 164),
131        ],
132        ColorScheme::Colorblind => &[
133            (0, 32, 76),
134            (0, 114, 178),
135            (128, 170, 190),
136            (230, 159, 0),
137            (255, 250, 200),
138        ],
139        ColorScheme::Viridis => &[
140            (68, 1, 84),
141            (59, 82, 139),
142            (33, 145, 140),
143            (94, 201, 98),
144            (253, 231, 37),
145        ],
146        ColorScheme::Plasma => &[
147            (13, 8, 135),
148            (126, 3, 168),
149            (204, 71, 120),
150            (248, 149, 64),
151            (240, 249, 33),
152        ],
153    }
154}
155
156/// Map `t` in [0, 1] onto the scheme's colour ramp by piecewise-linear
157/// interpolation between the anchor stops.
158pub(crate) fn colormap(scheme: &ColorScheme, t: f64) -> String {
159    let stops = colormap_stops(scheme);
160    let t = t.clamp(0.0, 1.0);
161    let segments = stops.len() - 1;
162    let scaled = t * segments as f64;
163    let idx = (scaled.floor() as usize).min(segments - 1);
164    let frac = scaled - idx as f64;
165    let (r0, g0, b0) = stops[idx];
166    let (r1, g1, b1) = stops[idx + 1];
167    let lerp = |a: u8, b: u8| -> u8 {
168        (a as f64 + (b as f64 - a as f64) * frac).round().clamp(0.0, 255.0) as u8
169    };
170    format!(
171        "#{:02x}{:02x}{:02x}",
172        lerp(r0, r1),
173        lerp(g0, g1),
174        lerp(b0, b1)
175    )
176}
177
178/// Finite min/max of a slice, ignoring non-finite entries.
179///
180/// Returns `None` when the slice holds no finite value at all.
181pub(crate) fn finite_bounds(values: &[f64]) -> Option<(f64, f64)> {
182    let mut lo = f64::INFINITY;
183    let mut hi = f64::NEG_INFINITY;
184    let mut any = false;
185    for &v in values {
186        if v.is_finite() {
187            lo = lo.min(v);
188            hi = hi.max(v);
189            any = true;
190        }
191    }
192    if any {
193        Some((lo, hi))
194    } else {
195        None
196    }
197}
198
199/// Widen a degenerate range so a constant series still gets a drawable axis.
200fn pad_range(lo: f64, hi: f64) -> (f64, f64) {
201    if (hi - lo).abs() < f64::EPSILON {
202        let pad = if lo.abs() > f64::EPSILON { lo.abs() * 0.1 } else { 1.0 };
203        (lo - pad, hi + pad)
204    } else {
205        (lo, hi)
206    }
207}
208
209/// Format an axis tick compactly without losing the magnitude.
210fn tick_label(v: f64) -> String {
211    let a = v.abs();
212    if a != 0.0 && !(1e-3..1e6).contains(&a) {
213        format!("{:.2e}", v)
214    } else if a >= 100.0 {
215        format!("{:.0}", v)
216    } else {
217        format!("{:.3}", v)
218    }
219}
220
221/// Geometry of the drawing area shared by every plot kind.
222struct Frame {
223    width: f64,
224    height: f64,
225    plot_x: f64,
226    plot_y: f64,
227    plot_w: f64,
228    plot_h: f64,
229}
230
231impl Frame {
232    fn new(config: &VisualizationConfig) -> Self {
233        // Clamp so a pathological config cannot produce a negative plot area.
234        let width = (config.plot_width as f64).max(240.0);
235        let height = (config.plot_height as f64).max(180.0);
236        let (ml, mr, mt, mb) = MARGIN;
237        Self {
238            width,
239            height,
240            plot_x: ml,
241            plot_y: mt,
242            plot_w: (width - ml - mr).max(10.0),
243            plot_h: (height - mt - mb).max(10.0),
244        }
245    }
246
247    fn sx(&self, v: f64, lo: f64, hi: f64) -> f64 {
248        self.plot_x + (v - lo) / (hi - lo) * self.plot_w
249    }
250
251    fn sy(&self, v: f64, lo: f64, hi: f64) -> f64 {
252        self.plot_y + self.plot_h - (v - lo) / (hi - lo) * self.plot_h
253    }
254}
255
256/// Emit the opening `<svg>` element plus background rectangle and title.
257fn open_svg(out: &mut String, frame: &Frame, bg: &str, fg: &str, font: u32, title: &str) {
258    let _ = writeln!(
259        out,
260        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
261         viewBox=\"0 0 {w} {h}\" font-family=\"sans-serif\">\n\
262         <rect width=\"{w}\" height=\"{h}\" fill=\"{bg}\"/>\n\
263         <text x=\"{tx:.1}\" y=\"{ty:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
264         text-anchor=\"middle\" font-weight=\"bold\">{title}</text>",
265        w = frame.width,
266        h = frame.height,
267        bg = bg,
268        fg = fg,
269        tx = frame.width / 2.0,
270        ty = (font as f64) + 8.0,
271        fs = font + 3,
272        title = escape_xml(title),
273    );
274}
275
276/// Draw the axis box, gridlines, numeric ticks and axis captions.
277#[allow(clippy::too_many_arguments)]
278fn draw_axes(
279    out: &mut String,
280    frame: &Frame,
281    fg: &str,
282    font: u32,
283    x_range: (f64, f64),
284    y_range: (f64, f64),
285    x_label: &str,
286    y_label: &str,
287) {
288    let _ = writeln!(
289        out,
290        "<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" fill=\"none\" \
291         stroke=\"{}\" stroke-width=\"1\"/>",
292        frame.plot_x, frame.plot_y, frame.plot_w, frame.plot_h, fg
293    );
294
295    for i in 0..=TICKS {
296        let t = i as f64 / TICKS as f64;
297
298        let xv = x_range.0 + (x_range.1 - x_range.0) * t;
299        let px = frame.plot_x + frame.plot_w * t;
300        let _ = writeln!(
301            out,
302            "<line x1=\"{px:.1}\" y1=\"{y0:.1}\" x2=\"{px:.1}\" y2=\"{y1:.1}\" \
303             stroke=\"{fg}\" stroke-width=\"0.4\" stroke-opacity=\"0.35\"/>\n\
304             <text x=\"{px:.1}\" y=\"{ty:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
305             text-anchor=\"middle\">{lbl}</text>",
306            px = px,
307            y0 = frame.plot_y,
308            y1 = frame.plot_y + frame.plot_h,
309            fg = fg,
310            ty = frame.plot_y + frame.plot_h + (font as f64) + 4.0,
311            fs = font,
312            lbl = escape_xml(&tick_label(xv)),
313        );
314
315        let yv = y_range.0 + (y_range.1 - y_range.0) * t;
316        let py = frame.plot_y + frame.plot_h - frame.plot_h * t;
317        let _ = writeln!(
318            out,
319            "<line x1=\"{x0:.1}\" y1=\"{py:.1}\" x2=\"{x1:.1}\" y2=\"{py:.1}\" \
320             stroke=\"{fg}\" stroke-width=\"0.4\" stroke-opacity=\"0.35\"/>\n\
321             <text x=\"{tx:.1}\" y=\"{ty:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
322             text-anchor=\"end\">{lbl}</text>",
323            x0 = frame.plot_x,
324            x1 = frame.plot_x + frame.plot_w,
325            py = py,
326            fg = fg,
327            tx = frame.plot_x - 6.0,
328            ty = py + (font as f64) / 3.0,
329            fs = font,
330            lbl = escape_xml(&tick_label(yv)),
331        );
332    }
333
334    if !x_label.is_empty() {
335        let _ = writeln!(
336            out,
337            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
338             text-anchor=\"middle\">{lbl}</text>",
339            x = frame.plot_x + frame.plot_w / 2.0,
340            y = frame.height - 8.0,
341            fs = font,
342            fg = fg,
343            lbl = escape_xml(x_label),
344        );
345    }
346    if !y_label.is_empty() {
347        let cy = frame.plot_y + frame.plot_h / 2.0;
348        let _ = writeln!(
349            out,
350            "<text x=\"14\" y=\"{cy:.1}\" font-size=\"{fs}\" fill=\"{fg}\" text-anchor=\"middle\" \
351             transform=\"rotate(-90 14 {cy:.1})\">{lbl}</text>",
352            cy = cy,
353            fs = font,
354            fg = fg,
355            lbl = escape_xml(y_label),
356        );
357    }
358}
359
360/// Split `y_values` into `series` equal chunks.
361///
362/// The in-tree producers (`DebugVisualizer::plot_training_metrics`) append a
363/// second series onto the same `y_values` vector and push a second entry into
364/// `labels`, so the number of labels is the authoritative series count.
365pub(crate) fn split_series(y_values: &[f64], series: usize) -> Vec<&[f64]> {
366    if series <= 1 {
367        return vec![y_values];
368    }
369    let per = y_values.len() / series;
370    if per == 0 {
371        return vec![y_values];
372    }
373    (0..series).map(|s| &y_values[s * per..(s + 1) * per]).collect()
374}
375
376/// Render a real multi-series line plot.
377pub fn line_plot_svg(data: &PlotData, config: &VisualizationConfig) -> String {
378    let (bg, fg, colors) = palette(&config.color_scheme);
379    let frame = Frame::new(config);
380    let font = config.font_size;
381    let mut out = String::new();
382    open_svg(&mut out, &frame, bg, fg, font, &data.title);
383
384    let series_count = data.labels.len().max(1);
385    let series = split_series(&data.y_values, series_count);
386    let n_points = series.iter().map(|s| s.len()).max().unwrap_or(0);
387
388    let x_bounds = finite_bounds(&data.x_values);
389    let y_bounds = finite_bounds(&data.y_values);
390
391    match (x_bounds, y_bounds, n_points >= 1) {
392        (Some((xlo, xhi)), Some((ylo, yhi)), true) => {
393            let (xlo, xhi) = pad_range(xlo, xhi);
394            let (ylo, yhi) = pad_range(ylo, yhi);
395            draw_axes(
396                &mut out,
397                &frame,
398                fg,
399                font,
400                (xlo, xhi),
401                (ylo, yhi),
402                &data.x_label,
403                &data.y_label,
404            );
405
406            for (si, ys) in series.iter().enumerate() {
407                let color = colors[si % colors.len()];
408                let mut points = String::new();
409                for (i, &y) in ys.iter().enumerate() {
410                    // Reuse the shared x axis; fall back to the index when the
411                    // caller supplied fewer x values than y values.
412                    let x = data.x_values.get(i).copied().unwrap_or(i as f64);
413                    if !x.is_finite() || !y.is_finite() {
414                        continue;
415                    }
416                    let _ = write!(
417                        points,
418                        "{:.2},{:.2} ",
419                        frame.sx(x, xlo, xhi),
420                        frame.sy(y, ylo, yhi)
421                    );
422                }
423                if !points.is_empty() {
424                    let _ = writeln!(
425                        out,
426                        "<polyline fill=\"none\" stroke=\"{color}\" stroke-width=\"1.8\" \
427                         points=\"{points}\"/>",
428                        color = color,
429                        points = points.trim_end(),
430                    );
431                }
432                if let Some(label) = data.labels.get(si) {
433                    let ly = frame.plot_y + 14.0 + si as f64 * ((font as f64) + 4.0);
434                    let _ = writeln!(
435                        out,
436                        "<rect x=\"{lx:.1}\" y=\"{ry:.1}\" width=\"10\" height=\"10\" \
437                         fill=\"{color}\"/>\n\
438                         <text x=\"{tx:.1}\" y=\"{ly:.1}\" font-size=\"{fs}\" fill=\"{fg}\">\
439                         {label}</text>",
440                        lx = frame.plot_x + 8.0,
441                        ry = ly - 9.0,
442                        color = color,
443                        tx = frame.plot_x + 22.0,
444                        ly = ly,
445                        fs = font,
446                        fg = fg,
447                        label = escape_xml(label),
448                    );
449                }
450            }
451        },
452        _ => {
453            let _ = writeln!(
454                out,
455                "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
456                 text-anchor=\"middle\">no finite data points</text>",
457                x = frame.width / 2.0,
458                y = frame.height / 2.0,
459                fs = font,
460                fg = fg,
461            );
462        },
463    }
464
465    out.push_str("</svg>\n");
466    out
467}
468
469/// Bin `values` into `bins` equal-width buckets over their finite range.
470///
471/// Returns `(counts, min, max)`. Non-finite values are skipped. Returns `None`
472/// when there is nothing finite to bin.
473pub(crate) fn histogram_counts(values: &[f64], bins: usize) -> Option<(Vec<u64>, f64, f64)> {
474    let bins = bins.max(1);
475    let (lo, hi) = finite_bounds(values)?;
476    let mut counts = vec![0_u64; bins];
477    if (hi - lo).abs() < f64::EPSILON {
478        // Every finite value is identical: they all land in the first bucket.
479        counts[0] = values.iter().filter(|v| v.is_finite()).count() as u64;
480        return Some((counts, lo, hi));
481    }
482    let width = (hi - lo) / bins as f64;
483    for &v in values.iter().filter(|v| v.is_finite()) {
484        let idx = (((v - lo) / width).floor() as isize).clamp(0, bins as isize - 1) as usize;
485        counts[idx] += 1;
486    }
487    Some((counts, lo, hi))
488}
489
490/// Render a real histogram from the actual binned counts.
491pub fn histogram_svg(data: &HistogramData, config: &VisualizationConfig) -> String {
492    let (bg, fg, colors) = palette(&config.color_scheme);
493    let frame = Frame::new(config);
494    let font = config.font_size;
495    let mut out = String::new();
496    open_svg(&mut out, &frame, bg, fg, font, &data.title);
497
498    let bins = if data.bins == 0 { 10 } else { data.bins };
499    match histogram_counts(&data.values, bins) {
500        Some((counts, lo, hi)) => {
501            let total: u64 = counts.iter().sum();
502            let bin_width =
503                if (hi - lo).abs() < f64::EPSILON { 1.0 } else { (hi - lo) / bins as f64 };
504            // In density mode each bar is count / (total * bin_width) so the
505            // bars integrate to 1 — the standard probability-density scaling.
506            let heights: Vec<f64> = if data.density && total > 0 && bin_width > 0.0 {
507                counts.iter().map(|&c| c as f64 / (total as f64 * bin_width)).collect()
508            } else {
509                counts.iter().map(|&c| c as f64).collect()
510            };
511            let ymax = heights.iter().cloned().fold(0.0_f64, f64::max).max(f64::EPSILON);
512            let (xlo, xhi) = pad_range(lo, hi);
513            draw_axes(
514                &mut out,
515                &frame,
516                fg,
517                font,
518                (xlo, xhi),
519                (0.0, ymax),
520                &data.x_label,
521                &data.y_label,
522            );
523
524            let color = colors[0];
525            let step = frame.plot_w / bins as f64;
526            for (i, &h) in heights.iter().enumerate() {
527                let bar_h = h / ymax * frame.plot_h;
528                if bar_h <= 0.0 {
529                    continue;
530                }
531                let _ = writeln!(
532                    out,
533                    "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" \
534                     fill=\"{color}\" fill-opacity=\"0.85\" stroke=\"{fg}\" \
535                     stroke-width=\"0.3\"><title>bin {i}: {c}</title></rect>",
536                    x = frame.plot_x + i as f64 * step,
537                    y = frame.plot_y + frame.plot_h - bar_h,
538                    w = (step - 1.0).max(0.5),
539                    h = bar_h,
540                    color = color,
541                    fg = fg,
542                    i = i,
543                    c = counts[i],
544                );
545            }
546        },
547        None => {
548            let _ = writeln!(
549                out,
550                "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
551                 text-anchor=\"middle\">no finite values to bin</text>",
552                x = frame.width / 2.0,
553                y = frame.height / 2.0,
554                fs = font,
555                fg = fg,
556            );
557        },
558    }
559
560    out.push_str("</svg>\n");
561    out
562}
563
564/// Render a real heatmap: one `<rect>` per matrix cell, coloured by the cell's
565/// position within the finite value range of the whole matrix.
566pub fn heatmap_svg(data: &HeatmapData, config: &VisualizationConfig) -> String {
567    let (bg, fg, _) = palette(&config.color_scheme);
568    let frame = Frame::new(config);
569    let font = config.font_size;
570    let mut out = String::new();
571    open_svg(&mut out, &frame, bg, fg, font, &data.title);
572
573    let flat: Vec<f64> = data.values.iter().flat_map(|r| r.iter().copied()).collect();
574    let rows = data.values.len();
575    let cols = data.values.iter().map(|r| r.len()).max().unwrap_or(0);
576
577    match finite_bounds(&flat) {
578        Some((lo, hi)) if rows > 0 && cols > 0 => {
579            let span = if (hi - lo).abs() < f64::EPSILON { 1.0 } else { hi - lo };
580            let cell_w = frame.plot_w / cols as f64;
581            let cell_h = frame.plot_h / rows as f64;
582
583            for (r, row) in data.values.iter().enumerate() {
584                for (c, &v) in row.iter().enumerate() {
585                    if !v.is_finite() {
586                        continue;
587                    }
588                    let t = (v - lo) / span;
589                    let _ = writeln!(
590                        out,
591                        "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" \
592                         fill=\"{fill}\"><title>[{r}][{c}] = {v}</title></rect>",
593                        x = frame.plot_x + c as f64 * cell_w,
594                        y = frame.plot_y + r as f64 * cell_h,
595                        w = cell_w,
596                        h = cell_h,
597                        fill = colormap(&config.color_scheme, t),
598                        r = r,
599                        c = c,
600                        v = v,
601                    );
602                }
603            }
604
605            let _ = writeln!(
606                out,
607                "<rect x=\"{:.1}\" y=\"{:.1}\" width=\"{:.1}\" height=\"{:.1}\" fill=\"none\" \
608                 stroke=\"{}\" stroke-width=\"1\"/>",
609                frame.plot_x, frame.plot_y, frame.plot_w, frame.plot_h, fg
610            );
611
612            // Colour bar: real min/max endpoints of the data.
613            let bar_x = frame.plot_x + frame.plot_w + 6.0;
614            let steps = 32;
615            for i in 0..steps {
616                let t = i as f64 / (steps - 1) as f64;
617                let _ = writeln!(
618                    out,
619                    "<rect x=\"{x:.1}\" y=\"{y:.2}\" width=\"10\" height=\"{h:.2}\" \
620                     fill=\"{fill}\"/>",
621                    x = bar_x,
622                    y = frame.plot_y + frame.plot_h - (t + 1.0 / steps as f64) * frame.plot_h,
623                    h = frame.plot_h / steps as f64 + 0.6,
624                    fill = colormap(&config.color_scheme, t),
625                );
626            }
627            let _ = writeln!(
628                out,
629                "<text x=\"{x:.1}\" y=\"{y0:.1}\" font-size=\"{fs}\" fill=\"{fg}\">{hi}</text>\n\
630                 <text x=\"{x:.1}\" y=\"{y1:.1}\" font-size=\"{fs}\" fill=\"{fg}\">{lo}</text>\n\
631                 <text x=\"{x:.1}\" y=\"{y2:.1}\" font-size=\"{fs}\" fill=\"{fg}\">{cb}</text>",
632                x = bar_x - 4.0,
633                y0 = frame.plot_y - 4.0,
634                y1 = frame.plot_y + frame.plot_h + (font as f64) + 2.0,
635                y2 = frame.height - 8.0,
636                fs = font,
637                fg = fg,
638                hi = escape_xml(&tick_label(hi)),
639                lo = escape_xml(&tick_label(lo)),
640                cb = escape_xml(&data.color_bar_label),
641            );
642        },
643        _ => {
644            let _ = writeln!(
645                out,
646                "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{fs}\" fill=\"{fg}\" \
647                 text-anchor=\"middle\">no finite cells to render</text>",
648                x = frame.width / 2.0,
649                y = frame.height / 2.0,
650                fs = font,
651                fg = fg,
652            );
653        },
654    }
655
656    out.push_str("</svg>\n");
657    out
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn cfg() -> VisualizationConfig {
665        VisualizationConfig::default()
666    }
667
668    #[test]
669    fn slugify_makes_a_filesystem_safe_stem() {
670        assert_eq!(slugify("Gradient Flow - layer/0"), "gradient_flow_layer_0");
671        assert_eq!(slugify("!!!"), "plot");
672        assert_eq!(slugify(""), "plot");
673    }
674
675    #[test]
676    fn escape_xml_neutralises_markup() {
677        assert_eq!(
678            escape_xml("<a href=\"x\">&</a>"),
679            "&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;"
680        );
681    }
682
683    #[test]
684    fn histogram_counts_are_real_counts() {
685        let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
686        let (counts, lo, hi) = histogram_counts(&values, 4).expect("finite data must bin");
687        assert_eq!(lo, 0.0);
688        assert_eq!(hi, 99.0);
689        assert_eq!(counts.iter().sum::<u64>(), 100);
690        // Equal-width bins over a uniform ramp must be near-equal.
691        for c in &counts {
692            assert!(
693                (*c as i64 - 25).abs() <= 1,
694                "unbalanced bin {c} in {counts:?}"
695            );
696        }
697    }
698
699    #[test]
700    fn histogram_counts_skips_non_finite_and_handles_constants() {
701        let values = vec![f64::NAN, 3.0, 3.0, f64::INFINITY, 3.0];
702        let (counts, lo, hi) = histogram_counts(&values, 5).expect("has finite values");
703        assert_eq!((lo, hi), (3.0, 3.0));
704        assert_eq!(counts[0], 3, "the three finite 3.0s land in bucket 0");
705        assert_eq!(counts.iter().sum::<u64>(), 3);
706        assert!(histogram_counts(&[f64::NAN], 5).is_none());
707    }
708
709    #[test]
710    fn line_plot_svg_contains_a_real_polyline_through_the_points() {
711        let data = PlotData {
712            x_values: vec![0.0, 1.0, 2.0],
713            y_values: vec![10.0, 20.0, 30.0],
714            labels: vec!["series".to_string()],
715            title: "t".to_string(),
716            x_label: "x".to_string(),
717            y_label: "y".to_string(),
718        };
719        let svg = line_plot_svg(&data, &cfg());
720        assert!(svg.starts_with("<svg"), "must be a real SVG document");
721        assert!(svg.ends_with("</svg>\n"));
722        let polyline = svg
723            .lines()
724            .find(|l| l.contains("<polyline"))
725            .expect("a real polyline must be emitted");
726        // Three input points -> three coordinate pairs.
727        assert_eq!(
728            polyline.matches(',').count(),
729            3,
730            "one pair per data point: {polyline}"
731        );
732    }
733
734    #[test]
735    fn line_plot_svg_splits_multi_series_y_values() {
736        let data = PlotData {
737            x_values: vec![0.0, 1.0],
738            y_values: vec![1.0, 2.0, 30.0, 40.0],
739            labels: vec!["loss".to_string(), "acc".to_string()],
740            title: "t".to_string(),
741            x_label: String::new(),
742            y_label: String::new(),
743        };
744        let svg = line_plot_svg(&data, &cfg());
745        assert_eq!(
746            svg.matches("<polyline").count(),
747            2,
748            "two labels -> two series"
749        );
750        assert!(
751            svg.contains(">loss<") && svg.contains(">acc<"),
752            "legend must name both series"
753        );
754    }
755
756    #[test]
757    fn empty_data_says_so_instead_of_claiming_success() {
758        let data = PlotData {
759            x_values: vec![],
760            y_values: vec![],
761            labels: vec![],
762            title: "empty".to_string(),
763            x_label: String::new(),
764            y_label: String::new(),
765        };
766        let svg = line_plot_svg(&data, &cfg());
767        assert!(svg.contains("no finite data points"));
768        assert!(!svg.contains("<polyline"));
769    }
770
771    #[test]
772    fn histogram_svg_emits_one_bar_per_non_empty_bin() {
773        let data = HistogramData {
774            values: (0..40).map(|i| i as f64).collect(),
775            bins: 4,
776            title: "h".to_string(),
777            x_label: String::new(),
778            y_label: String::new(),
779            density: false,
780        };
781        let svg = histogram_svg(&data, &cfg());
782        // 1 background + 1 axis box + 4 bars.
783        assert_eq!(svg.matches("<rect").count(), 6, "4 bars expected:\n{svg}");
784        assert!(
785            svg.contains("<title>bin 0: 10</title>"),
786            "bar tooltips carry real counts"
787        );
788    }
789
790    #[test]
791    fn heatmap_svg_emits_one_cell_per_matrix_entry() {
792        let data = HeatmapData {
793            values: vec![vec![0.0, 1.0], vec![2.0, 3.0]],
794            x_labels: vec![],
795            y_labels: vec![],
796            title: "hm".to_string(),
797            color_bar_label: "v".to_string(),
798        };
799        let svg = heatmap_svg(&data, &cfg());
800        assert!(svg.contains("<title>[0][0] = 0</title>"));
801        assert!(svg.contains("<title>[1][1] = 3</title>"));
802        // Distinct values must get distinct colours from the ramp.
803        let c_lo = colormap(&ColorScheme::Default, 0.0);
804        let c_hi = colormap(&ColorScheme::Default, 1.0);
805        assert_ne!(c_lo, c_hi);
806        assert!(svg.contains(&c_lo) && svg.contains(&c_hi));
807    }
808
809    #[test]
810    fn colormap_is_monotone_between_anchor_stops() {
811        // Endpoints must be exactly the first/last anchors.
812        assert_eq!(colormap(&ColorScheme::Viridis, 0.0), "#440154");
813        assert_eq!(colormap(&ColorScheme::Viridis, 1.0), "#fde725");
814        // Out-of-range inputs clamp rather than panic or wrap.
815        assert_eq!(colormap(&ColorScheme::Viridis, -5.0), "#440154");
816        assert_eq!(colormap(&ColorScheme::Viridis, 5.0), "#fde725");
817    }
818
819    #[test]
820    fn titles_cannot_inject_markup_into_the_document() {
821        let data = HistogramData {
822            values: vec![1.0, 2.0],
823            bins: 2,
824            title: "</svg><script>x</script>".to_string(),
825            x_label: String::new(),
826            y_label: String::new(),
827            density: false,
828        };
829        let svg = histogram_svg(&data, &cfg());
830        assert!(!svg.contains("<script>"));
831        assert_eq!(svg.matches("</svg>").count(), 1);
832    }
833}