1use super::types::{ColorScheme, HeatmapData, HistogramData, PlotData, VisualizationConfig};
15use std::fmt::Write as _;
16
17const MARGIN: (f64, f64, f64, f64) = (70.0, 25.0, 40.0, 55.0);
19
20const TICKS: usize = 5;
22
23pub(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("<"),
31 '>' => out.push_str(">"),
32 '"' => out.push_str("""),
33 '\'' => out.push_str("'"),
34 _ => out.push(ch),
35 }
36 }
37 out
38}
39
40pub(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
64fn 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 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
108fn 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
156pub(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
178pub(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
199fn 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
209fn 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
221struct 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 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
256fn 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#[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
360pub(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
376pub 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 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
469pub(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 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
490pub 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 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
564pub 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 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 "<a href="x">&</a>"
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 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 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 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 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 assert_eq!(colormap(&ColorScheme::Viridis, 0.0), "#440154");
813 assert_eq!(colormap(&ColorScheme::Viridis, 1.0), "#fde725");
814 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}