OXYGENHUB
◆ v0.6.0 oxygenhub / crates / hub / src / web / charts.rs
5.9 KiB · Rust · last changed Insights: OxygenFlow's standard charts, for the site, an owner and a repository 17 days ago source blame history raw
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
16
fn esc(s: &str) -> String {
17
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
18
}
19
20
/// A round top for the y axis: 1, 2 or 5 times a power of ten.
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
/// The frame: grid lines with the y labels, and the relative x labels.
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
/// Lines, one per series; a missing value breaks its line.
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
        // A lone point between gaps would not show as a line: a dot.
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
/// Stacked bars: each bucket's series on top of one another.
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
}