| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use crate::insights::{Range, Series}; |
| 7 | | use std::fmt::Write; |
| 8 | |
|
| 9 | | const W: f64 = 640.0; |
| 10 | | const H: f64 = 190.0; |
| 11 | | const LEFT: f64 = 52.0; |
| 12 | | const RIGHT: f64 = 8.0; |
| 13 | | const TOP: f64 = 10.0; |
| 14 | | const BOTTOM: f64 = 22.0; |
| 15 | |
|
| 16 | 124× | fn esc(s: &str) -> String { |
| 17 | 124× | s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) |
| 18 | 124× | } |
| 19 | |
|
| 20 | |
|
| 21 | 24× | fn nice(max: f64) -> f64 { |
| 22 | 24× | if max <= 0.0 { |
| 23 | 8× | return 1.0; |
| 24 | 16× | } |
| 25 | 16× | let p = 10f64.powf(max.log10().floor()); |
| 26 | 48× | for m in [1.0, 2.0, 5.0, 10.0] { |
| 27 | 48× | if max <= m * p { |
| 28 | 16× | return m * p; |
| 29 | 32× | } |
| 30 | | } |
| 31 | 0× | 10.0 * p |
| 32 | 24× | } |
| 33 | |
|
| 34 | |
|
| 35 | 22× | fn frame(out: &mut String, top: f64, range: Range, label: &dyn Fn(f64) -> String) { |
| 36 | 22× | let plot_h = H - TOP - BOTTOM; |
| 37 | 66× | for i in 0..=2 { |
| 38 | 66× | let v = top * i as f64 / 2.0; |
| 39 | 66× | let y = TOP + plot_h - plot_h * i as f64 / 2.0; |
| 40 | 66× | let _ = write!(out, r#"<line class="grid" x1="{LEFT}" x2="{}" y1="{y:.1}" y2="{y:.1}"/>"#, W - RIGHT); |
| 41 | 66× | let _ = write!(out, r#"<text class="axis" x="{}" y="{:.1}" text-anchor="end">{}</text>"#, LEFT - 6.0, y + 4.0, esc(&label(v))); |
| 42 | 66× | } |
| 43 | 22× | let (first, mid) = match range { |
| 44 | 6× | Range::Hour => ("−60 min", "−30 min"), |
| 45 | 11× | Range::Day => ("−24 h", "−12 h"), |
| 46 | 5× | Range::Week => ("−7 d", "−3.5 d"), |
| 47 | | }; |
| 48 | 22× | let base = H - 6.0; |
| 49 | 22× | let mid_x = LEFT + (W - LEFT - RIGHT) / 2.0; |
| 50 | 22× | let _ = write!(out, r#"<text class="axis" x="{LEFT}" y="{base}">{first}</text>"#); |
| 51 | 22× | let _ = write!(out, r#"<text class="axis" x="{mid_x}" y="{base}" text-anchor="middle">{mid}</text>"#); |
| 52 | 22× | let _ = write!(out, r#"<text class="axis" x="{}" y="{base}" text-anchor="end">now</text>"#, W - RIGHT); |
| 53 | 22× | } |
| 54 | |
|
| 55 | 22× | fn open(out: &mut String, title: &str) { |
| 56 | 22× | let _ = write!(out, r#"<svg class="chart" viewBox="0 0 {W} {H}" role="img" aria-label="{}">"#, esc(title)); |
| 57 | 22× | } |
| 58 | |
|
| 59 | |
|
| 60 | 17× | pub fn lines(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { |
| 61 | 655× | let max = series.iter().flat_map(|s| s.values.iter().flatten()).fold(0f64, |a, b| a.max(*b)); |
| 62 | 17× | let top = nice(max); |
| 63 | 17× | let mut out = String::new(); |
| 64 | 17× | open(&mut out, title); |
| 65 | 17× | frame(&mut out, top, range, label); |
| 66 | 17× | let n = series.first().map_or(0, |s| s.values.len()).max(1); |
| 67 | 17× | let step = (W - LEFT - RIGHT) / n as f64; |
| 68 | 17× | let plot_h = H - TOP - BOTTOM; |
| 69 | 29× | for s in series { |
| 70 | 29× | let mut d = String::new(); |
| 71 | 29× | let mut pen = false; |
| 72 | 1488× | for (i, v) in s.values.iter().enumerate() { |
| 73 | 1488× | match v { |
| 74 | 655× | Some(v) => { |
| 75 | 655× | let x = LEFT + step * (i as f64 + 0.5); |
| 76 | 655× | let y = TOP + plot_h - plot_h * (v / top).min(1.0); |
| 77 | 655× | let _ = write!(d, "{}{x:.1},{y:.1} ", if pen { "L" } else { "M" }); |
| 78 | 655× | pen = true; |
| 79 | | } |
| 80 | 833× | None => pen = false, |
| 81 | | } |
| 82 | | } |
| 83 | 29× | if !d.is_empty() { |
| 84 | 29× | let _ = write!(out, r#"<path class="line {}" d="{}"><title>{}</title></path>"#, s.class, d.trim_end(), esc(s.name)); |
| 85 | 29× | } |
| 86 | | |
| 87 | 1488× | for (i, v) in s.values.iter().enumerate() { |
| 88 | 1488× | let alone = v.is_some() && (i == 0 || s.values[i - 1].is_none()) && s.values.get(i + 1).is_none_or(|n| n.is_none()); |
| 89 | 1488× | if let (true, Some(v)) = (alone, v) { |
| 90 | 17× | let x = LEFT + step * (i as f64 + 0.5); |
| 91 | 17× | let y = TOP + plot_h - plot_h * (v / top).min(1.0); |
| 92 | 17× | let _ = write!(out, r#"<circle class="dot {}" cx="{x:.1}" cy="{y:.1}" r="2.5"/>"#, s.class); |
| 93 | 1471× | } |
| 94 | | } |
| 95 | | } |
| 96 | 17× | out.push_str("</svg>"); |
| 97 | 17× | out |
| 98 | 17× | } |
| 99 | |
|
| 100 | |
|
| 101 | 5× | pub fn stacked(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { |
| 102 | 5× | let n = series.first().map_or(0, |s| s.values.len()); |
| 103 | 640× | let totals: Vec<f64> = (0..n).map(|i| series.iter().map(|s| s.values[i].unwrap_or(0.0)).sum()).collect(); |
| 104 | 216× | let top = nice(totals.iter().fold(0f64, |a, b| a.max(*b))); |
| 105 | 5× | let mut out = String::new(); |
| 106 | 5× | open(&mut out, title); |
| 107 | 5× | frame(&mut out, top, range, label); |
| 108 | 5× | let step = (W - LEFT - RIGHT) / n.max(1) as f64; |
| 109 | 5× | let plot_h = H - TOP - BOTTOM; |
| 110 | 216× | for i in 0..n { |
| 111 | 216× | let mut y = TOP + plot_h; |
| 112 | 216× | let x = LEFT + step * i as f64 + step * 0.1; |
| 113 | 640× | for s in series { |
| 114 | 640× | let v = s.values[i].unwrap_or(0.0); |
| 115 | 640× | if v <= 0.0 { |
| 116 | 633× | continue; |
| 117 | 7× | } |
| 118 | 7× | let h = plot_h * v / top; |
| 119 | 7× | y -= h; |
| 120 | 7× | let _ = write!( |
| 121 | 7× | out, |
| 122 | | r#"<rect class="bar {}" x="{x:.1}" y="{y:.1}" width="{:.1}" height="{h:.1}"><title>{} {}</title></rect>"#, |
| 123 | | s.class, |
| 124 | 7× | step * 0.8, |
| 125 | 7× | label(v), |
| 126 | 7× | esc(s.name) |
| 127 | | ); |
| 128 | | } |
| 129 | | } |
| 130 | 5× | out.push_str("</svg>"); |
| 131 | 5× | out |
| 132 | 5× | } |
| 133 | |
|
| 134 | | #[cfg(test)] |
| 135 | | mod tests { |
| 136 | | use super::*; |
| 137 | |
|
| 138 | | #[test] |
| 139 | 1× | fn charts_are_svg_with_classes_and_no_styles() { |
| 140 | 1× | let s = vec![Series { name: "median", class: "s-main", values: vec![Some(1.0), None, Some(3.0), Some(4.0)] }]; |
| 141 | 3× | let svg = lines("Pickup", Range::Hour, &s, &|v| format!("{v:.0}")); |
| 142 | 1× | assert!(svg.starts_with("<svg class=\"chart\"") && svg.ends_with("</svg>")); |
| 143 | 1× | assert!(svg.contains("class=\"line s-main\"") && svg.contains("class=\"dot s-main\""), "{svg}"); |
| 144 | 1× | assert!(!svg.contains("style="), "no inline styles: the CSP"); |
| 145 | 6× | let bars = stacked("Jobs", Range::Day, &s, &|v| format!("{v:.0}")); |
| 146 | 1× | assert_eq!(bars.matches("<rect").count(), 3); |
| 147 | 1× | assert_eq!(nice(7.0), 10.0); |
| 148 | 1× | assert_eq!(nice(130.0), 200.0); |
| 149 | 1× | } |
| 150 | | } |