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