//! 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#"");
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("