OXYGENHUB

Coverage / crates/hub/src/web/charts.rs 99.1%

115 of 116 lines run, 31 of 31 functions · run #501 on main · run missed

1
//! Charts drawn by the hub (oxygenhub#257): inline SVG, coloured by CSS
2
//! classes from the theme's tokens, so they need no script, no library and
3
//! nothing the Content-Security-Policy forbids. Lines for times and levels,
4
//! stacked bars for counts; the x axis is relative (`−24 h` … `now`).
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
16124×
fn esc(s: &str) -> String {
17124×
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
18124×
}
19
20
/// A round top for the y axis: 1, 2 or 5 times a power of ten.
2124×
fn nice(max: f64) -> f64 {
2224×
    if max <= 0.0 {
238×
        return 1.0;
2416×
    }
2516×
    let p = 10f64.powf(max.log10().floor());
2652×
    for m in [1.0, 2.0, 5.0, 10.0] {
2752×
        if max <= m * p {
2816×
            return m * p;
2936×
        }
30
    }
310×
    10.0 * p
3224×
}
33
34
/// The frame: grid lines with the y labels, and the relative x labels.
3522×
fn frame(out: &mut String, top: f64, range: Range, label: &dyn Fn(f64) -> String) {
3622×
    let plot_h = H - TOP - BOTTOM;
3766×
    for i in 0..=2 {
3866×
        let v = top * i as f64 / 2.0;
3966×
        let y = TOP + plot_h - plot_h * i as f64 / 2.0;
4066×
        let _ = write!(out, r#"<line class="grid" x1="{LEFT}" x2="{}" y1="{y:.1}" y2="{y:.1}"/>"#, W - RIGHT);
4166×
        let _ = write!(out, r#"<text class="axis" x="{}" y="{:.1}" text-anchor="end">{}</text>"#, LEFT - 6.0, y + 4.0, esc(&label(v)));
4266×
    }
4322×
    let (first, mid) = match range {
446×
        Range::Hour => ("−60 min", "−30 min"),
4511×
        Range::Day => ("−24 h", "−12 h"),
465×
        Range::Week => ("−7 d", "−3.5 d"),
47
    };
4822×
    let base = H - 6.0;
4922×
    let mid_x = LEFT + (W - LEFT - RIGHT) / 2.0;
5022×
    let _ = write!(out, r#"<text class="axis" x="{LEFT}" y="{base}">{first}</text>"#);
5122×
    let _ = write!(out, r#"<text class="axis" x="{mid_x}" y="{base}" text-anchor="middle">{mid}</text>"#);
5222×
    let _ = write!(out, r#"<text class="axis" x="{}" y="{base}" text-anchor="end">now</text>"#, W - RIGHT);
5322×
}
54
5522×
fn open(out: &mut String, title: &str) {
5622×
    let _ = write!(out, r#"<svg class="chart" viewBox="0 0 {W} {H}" role="img" aria-label="{}">"#, esc(title));
5722×
}
58
59
/// Lines, one per series; a missing value breaks its line.
6017×
pub fn lines(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String {
61655×
    let max = series.iter().flat_map(|s| s.values.iter().flatten()).fold(0f64, |a, b| a.max(*b));
6217×
    let top = nice(max);
6317×
    let mut out = String::new();
6417×
    open(&mut out, title);
6517×
    frame(&mut out, top, range, label);
6617×
    let n = series.first().map_or(0, |s| s.values.len()).max(1);
6717×
    let step = (W - LEFT - RIGHT) / n as f64;
6817×
    let plot_h = H - TOP - BOTTOM;
6929×
    for s in series {
7029×
        let mut d = String::new();
7129×
        let mut pen = false;
721488×
        for (i, v) in s.values.iter().enumerate() {
731488×
            match v {
74655×
                Some(v) => {
75655×
                    let x = LEFT + step * (i as f64 + 0.5);
76655×
                    let y = TOP + plot_h - plot_h * (v / top).min(1.0);
77655×
                    let _ = write!(d, "{}{x:.1},{y:.1} ", if pen { "L" } else { "M" });
78655×
                    pen = true;
79
                }
80833×
                None => pen = false,
81
            }
82
        }
8329×
        if !d.is_empty() {
8429×
            let _ = write!(out, r#"<path class="line {}" d="{}"><title>{}</title></path>"#, s.class, d.trim_end(), esc(s.name));
8529×
        }
86
        // A lone point between gaps would not show as a line: a dot.
871488×
        for (i, v) in s.values.iter().enumerate() {
881488×
            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());
891488×
            if let (true, Some(v)) = (alone, v) {
9017×
                let x = LEFT + step * (i as f64 + 0.5);
9117×
                let y = TOP + plot_h - plot_h * (v / top).min(1.0);
9217×
                let _ = write!(out, r#"<circle class="dot {}" cx="{x:.1}" cy="{y:.1}" r="2.5"/>"#, s.class);
931471×
            }
94
        }
95
    }
9617×
    out.push_str("</svg>");
9717×
    out
9817×
}
99
100
/// Stacked bars: each bucket's series on top of one another.
1015×
pub fn stacked(title: &str, range: Range, series: &[Series], label: &dyn Fn(f64) -> String) -> String {
1025×
    let n = series.first().map_or(0, |s| s.values.len());
103640×
    let totals: Vec<f64> = (0..n).map(|i| series.iter().map(|s| s.values[i].unwrap_or(0.0)).sum()).collect();
104216×
    let top = nice(totals.iter().fold(0f64, |a, b| a.max(*b)));
1055×
    let mut out = String::new();
1065×
    open(&mut out, title);
1075×
    frame(&mut out, top, range, label);
1085×
    let step = (W - LEFT - RIGHT) / n.max(1) as f64;
1095×
    let plot_h = H - TOP - BOTTOM;
110216×
    for i in 0..n {
111216×
        let mut y = TOP + plot_h;
112216×
        let x = LEFT + step * i as f64 + step * 0.1;
113640×
        for s in series {
114640×
            let v = s.values[i].unwrap_or(0.0);
115640×
            if v <= 0.0 {
116633×
                continue;
1177×
            }
1187×
            let h = plot_h * v / top;
1197×
            y -= h;
1207×
            let _ = write!(
1217×
                out,
122
                r#"<rect class="bar {}" x="{x:.1}" y="{y:.1}" width="{:.1}" height="{h:.1}"><title>{} {}</title></rect>"#,
123
                s.class,
1247×
                step * 0.8,
1257×
                label(v),
1267×
                esc(s.name)
127
            );
128
        }
129
    }
1305×
    out.push_str("</svg>");
1315×
    out
1325×
}
133
134
#[cfg(test)]
135
mod tests {
136
    use super::*;
137
138
    #[test]
1391×
    fn charts_are_svg_with_classes_and_no_styles() {
1401×
        let s = vec![Series { name: "median", class: "s-main", values: vec![Some(1.0), None, Some(3.0), Some(4.0)] }];
1413×
        let svg = lines("Pickup", Range::Hour, &s, &|v| format!("{v:.0}"));
1421×
        assert!(svg.starts_with("<svg class=\"chart\"") && svg.ends_with("</svg>"));
1431×
        assert!(svg.contains("class=\"line s-main\"") && svg.contains("class=\"dot s-main\""), "{svg}");
1441×
        assert!(!svg.contains("style="), "no inline styles: the CSP");
1456×
        let bars = stacked("Jobs", Range::Day, &s, &|v| format!("{v:.0}"));
1461×
        assert_eq!(bars.matches("<rect").count(), 3);
1471×
        assert_eq!(nice(7.0), 10.0);
1481×
        assert_eq!(nice(130.0), 200.0);
1491×
    }
150
}