//! Charts drawn by the hub (oxygenhub#257): inline SVG, coloured by CSS //! classes from the theme's tokens, so they need no script, no library and //! nothing the Content-Security-Policy forbids. Lines for times and levels, //! stacked bars for counts; the x axis is relative (`−24 h` … `now`). use crate::insights::{Range, Series}; use std::fmt::Write; const W: f64 = 640.0; const H: f64 = 190.0; const LEFT: f64 = 52.0; const RIGHT: f64 = 8.0; const TOP: f64 = 10.0; const BOTTOM: f64 = 22.0; fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } /// A round top for the y axis: 1, 2 or 5 times a power of ten. fn nice(max: f64) -> f64 { if max <= 0.0 { return 1.0; } let p = 10f64.powf(max.log10().floor()); for m in [1.0, 2.0, 5.0, 10.0] { if max <= m * p { return m * p; } } 10.0 * p } /// The frame: grid lines with the y labels, and the relative x labels. fn frame(out: &mut String, top: f64, range: Range, label: &dyn Fn(f64) -> String) { let plot_h = H - TOP - BOTTOM; for i in 0..=2 { let v = top * i as f64 / 2.0; let y = TOP + plot_h - plot_h * i as f64 / 2.0; let _ = write!(out, r#""#, W - RIGHT); let _ = write!(out, r#"{}"#, LEFT - 6.0, y + 4.0, esc(&label(v))); } let (first, mid) = match range { Range::Hour => ("−60 min", "−30 min"), Range::Day => ("−24 h", "−12 h"), Range::Week => ("−7 d", "−3.5 d"), }; let base = H - 6.0; let mid_x = LEFT + (W - LEFT - RIGHT) / 2.0; let _ = write!(out, r#"{first}"#); let _ = write!(out, r#"{mid}"#); let _ = write!(out, r#"now"#, W - RIGHT); } fn open(out: &mut String, title: &str) { let _ = write!(out, r#""#, esc(title)); } /// Lines, one per series; a missing value breaks its line. pub fn lines(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { let max = series.iter().flat_map(|s| s.values.iter().flatten()).fold(0f64, |a, b| a.max(*b)); let top = nice(max); let mut out = String::new(); open(&mut out, title); frame(&mut out, top, range, label); let n = series.first().map_or(0, |s| s.values.len()).max(1); let step = (W - LEFT - RIGHT) / n as f64; let plot_h = H - TOP - BOTTOM; for s in series { let mut d = String::new(); let mut pen = false; for (i, v) in s.values.iter().enumerate() { match v { Some(v) => { let x = LEFT + step * (i as f64 + 0.5); let y = TOP + plot_h - plot_h * (v / top).min(1.0); let _ = write!(d, "{}{x:.1},{y:.1} ", if pen { "L" } else { "M" }); pen = true; } None => pen = false, } } if !d.is_empty() { let _ = write!(out, r#"{}"#, s.class, d.trim_end(), esc(s.name)); } // A lone point between gaps would not show as a line: a dot. for (i, v) in s.values.iter().enumerate() { 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()); if let (true, Some(v)) = (alone, v) { let x = LEFT + step * (i as f64 + 0.5); let y = TOP + plot_h - plot_h * (v / top).min(1.0); let _ = write!(out, r#""#, s.class); } } } out.push_str(""); out } /// Stacked bars: each bucket's series on top of one another. pub fn stacked(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String { let n = series.first().map_or(0, |s| s.values.len()); let totals: Vec = (0..n).map(|i| series.iter().map(|s| s.values[i].unwrap_or(0.0)).sum()).collect(); let top = nice(totals.iter().fold(0f64, |a, b| a.max(*b))); let mut out = String::new(); open(&mut out, title); frame(&mut out, top, range, label); let step = (W - LEFT - RIGHT) / n.max(1) as f64; let plot_h = H - TOP - BOTTOM; for i in 0..n { let mut y = TOP + plot_h; let x = LEFT + step * i as f64 + step * 0.1; for s in series { let v = s.values[i].unwrap_or(0.0); if v <= 0.0 { continue; } let h = plot_h * v / top; y -= h; let _ = write!( out, r#"{} {}"#, s.class, step * 0.8, label(v), esc(s.name) ); } } out.push_str(""); out } #[cfg(test)] mod tests { use super::*; #[test] fn charts_are_svg_with_classes_and_no_styles() { let s = vec![Series { name: "median", class: "s-main", values: vec![Some(1.0), None, Some(3.0), Some(4.0)] }]; let svg = lines("Pickup", Range::Hour, &s, &|v| format!("{v:.0}")); assert!(svg.starts_with("")); assert!(svg.contains("class=\"line s-main\"") && svg.contains("class=\"dot s-main\""), "{svg}"); assert!(!svg.contains("style="), "no inline styles: the CSP"); let bars = stacked("Jobs", Range::Day, &s, &|v| format!("{v:.0}")); assert_eq!(bars.matches("