Media

Sparklines

Tiny inline charts from a list of numbers: SVG line and bar sparklines for table cells and sentences, and the same in text (▁▃▆█).

Version 1.0.0 · 3 functions · 0 crates

#charts#dashboards#svg#email

Functions

What use sparkline.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use sparkline_svg, sparkline_bars_svg from sparkline (Quillo 0.6).

sparkline_svg(values: list, options: any) → stror an error

A line sparkline as SVG, width × height pixels (100 × 24) in options (or null), with color ("#2a78d6", or "currentColor" to take the text's), fill true for a light wash under the line, dot false to leave out the dot on the last value, and min/max to fix the scale (to compare several). Null values leave gaps. Up to 10,000 values.

sparkline_bars_svg(values: list, options: any) → stror an error

A bar sparkline as SVG, the same options but fill and dot; bars rise from 0, those below it in negative ("#e34948"). For wins and losses, daily counts.

sparkline_text(values: list) → stror an error

The values as a line of block characters, lowest ▁ to highest █, a space for null: for plain-text emails, logs and chat messages.

Get it

$ quillo install sparkline               # sparkline.qwer into this folder$ quillo install sparkline --example     # and its example, sparkline.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/sparkline.qwer$ curl -O https://quillolang.org/plugins/files/sparkline.qwe

Then, in your program, all of it or only what you call:

main.qwe
use sparkline.qweruse sparkline_svg, sparkline_bars_svg from sparkline      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]

Example

sparkline.qwe
// A dashboard table with a sparkline in each row, and the same trend in a plain-text email.////   quillo run sparkline.qwe      then open http://localhost:8080/use "sparkline.qwer"let shops = [    { name: "Lyon", week: [120, 135, 128, 150, 172, 165, 190], profit: [12, 8, -3, 15, 20, -6, 18] },    { name: "Nantes", week: [90, 95, 110, 105, null, 130, 138], profit: [5, -2, 7, 9, 3, 4, 11] },]route GET / = <html><body style="font-family: sans-serif">    <table>        <tr><th>Shop</th><th>Orders this week</th><th>Profit by day</th></tr>        {for s in shops => <tr>            <td>{s.name}</td>            <td>{raw(sparkline_svg(s.week, { width: 120, height: 28, fill: true, min: 0, max: 200 })?)} {last(s.week)}</td>            <td>{raw(sparkline_bars_svg(s.profit, { width: 84, height: 28 })?)}</td>        </tr>}    </table></body></html>route GET /email = join(shops | map((s) => `${s.name}: ${sparkline_text(s.week)?}`), "\n")route GET /lyon.svg = respond(200, sparkline_svg(shops[0].week, null)?, { "content-type": "image/svg+xml" })
$ quillo run sparkline.qwe$ curl 'localhost:8080/email'$ curl 'localhost:8080/lyon.svg'$ curl 'localhost:8080/'

Source

sparkline.qwer
//! Tiny inline charts from a list of numbers: SVG sparklines and bar sparklines to sit in a table cell or a sentence, and the same in text (▁▃▆█).//! version: 1.0.0//!//! [dependencies]use serde_json::Value;const MAX_POINTS: usize = 10_000;fn colour(o: &Value, key: &str, d: &str) -> String {    match o.get(key).and_then(Value::as_str) {        Some(c) if (c.starts_with('#') && [4, 7, 9].contains(&c.len()) && c[1..].chars().all(|x| x.is_ascii_hexdigit())) || c == "currentColor" => c.to_string(),        _ => d.to_string(),    }}fn size(o: &Value, key: &str, d: f64) -> f64 {    o.get(key).and_then(Value::as_f64).unwrap_or(d).clamp(4.0, 2000.0)}fn clean(values: &[Value]) -> Result<Vec<Option<f64>>, String> {    if values.len() > MAX_POINTS {        return Err(format!("up to {} values", MAX_POINTS));    }    Ok(values.iter().map(|v| v.as_f64().filter(|x| x.is_finite())).collect())}fn range(v: &[Option<f64>], o: &Value) -> (f64, f64) {    let vals: Vec<f64> = v.iter().flatten().copied().collect();    let lo = o.get("min").and_then(Value::as_f64).unwrap_or_else(|| vals.iter().cloned().fold(f64::INFINITY, f64::min));    let hi = o.get("max").and_then(Value::as_f64).unwrap_or_else(|| vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max));    if !lo.is_finite() || !hi.is_finite() { (0.0, 1.0) } else if hi - lo < 1e-12 { (lo - 1.0, hi + 1.0) } else { (lo, hi) }}/// A line sparkline as SVG, `width` × `height` pixels (100 × 24) in `options` (or null), with `color` ("#2a78d6", or "currentColor" to take the text's),/// `fill` true for a light wash under the line, `dot` false to leave out the dot on the last value, and `min`/`max` to fix the scale (to compare several)./// Null values leave gaps. Up to 10,000 values.pub fn sparkline_svg(values: Vec<serde_json::Value>, options: serde_json::Value) -> Result<String, String> {    let v = clean(&values)?;    let (w, h) = (size(&options, "width", 100.0), size(&options, "height", 24.0));    let col = colour(&options, "color", "#2a78d6");    let (lo, hi) = range(&v, &options);    let pad = 3.0;    let n = v.len().max(2);    let x = |i: usize| pad + (w - 2.0 * pad) * i as f64 / (n - 1) as f64;    let y = |val: f64| h - pad - ((val - lo) / (hi - lo)).clamp(0.0, 1.0) * (h - 2.0 * pad);    let mut d = String::new();    let mut pen = false;    for (i, val) in v.iter().enumerate() {        match val {            Some(val) => {                d += &format!("{}{:.1} {:.1}", if pen { "L" } else { "M" }, x(i), y(*val));                pen = true;            }            None => pen = false,        }    }    let mut out = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" style="vertical-align: middle">"#);    let pts: Vec<(f64, f64)> = v.iter().enumerate().filter_map(|(i, val)| val.map(|val| (x(i), y(val)))).collect();    if options.get("fill").and_then(Value::as_bool) == Some(true) {        if let (Some(a), Some(b)) = (pts.first(), pts.last()) {            let body: String = pts.iter().map(|(px, py)| format!("L{:.1} {:.1}", px, py)).collect();            out += &format!(r#"<path d="M{:.1} {:.1}{}L{:.1} {:.1}Z" fill="{col}" fill-opacity="0.12"/>"#, a.0, h - pad, body, b.0, h - pad);        }    }    out += &format!(r#"<path d="{d}" fill="none" stroke="{col}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>"#);    if options.get("dot").and_then(Value::as_bool) != Some(false) {        if let Some((px, py)) = pts.last() {            out += &format!(r#"<circle cx="{px:.1}" cy="{py:.1}" r="2.5" fill="{col}"/>"#);        }    }    out += "</svg>";    Ok(out)}/// A bar sparkline as SVG, the same `options` but `fill` and `dot`; bars rise from 0, those below it in `negative` ("#e34948"). For wins and losses, daily counts.pub fn sparkline_bars_svg(values: Vec<serde_json::Value>, options: serde_json::Value) -> Result<String, String> {    let v = clean(&values)?;    let (w, h) = (size(&options, "width", 100.0), size(&options, "height", 24.0));    let col = colour(&options, "color", "#2a78d6");    let neg = colour(&options, "negative", "#e34948");    let (lo, hi) = range(&v, &options);    let (lo, hi) = (lo.min(0.0), hi.max(0.0));    let n = v.len().max(1) as f64;    let slot = w / n;    let bw = (slot - 1.0).max(0.5);    let y = |val: f64| h - ((val - lo) / (hi - lo)) * h;    let base = y(0.0);    let mut out = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" style="vertical-align: middle">"#);    for (i, val) in v.iter().enumerate() {        let Some(val) = val else { continue };        let top = y(*val).min(base);        let height = (y(*val) - base).abs().max(if *val == 0.0 { 0.0 } else { 1.0 });        out += &format!(r#"<rect x="{:.1}" y="{top:.1}" width="{bw:.1}" height="{height:.1}" fill="{}"/>"#, i as f64 * slot, if *val < 0.0 { &neg } else { &col });    }    out += "</svg>";    Ok(out)}/// The values as a line of block characters, lowest ▁ to highest █, a space for null: for plain-text emails, logs and chat messages.pub fn sparkline_text(values: Vec<serde_json::Value>) -> Result<String, String> {    let v = clean(&values)?;    let (lo, hi) = range(&v, &Value::Null);    let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];    Ok(v.iter().map(|val| match val {        Some(val) => blocks[(((val - lo) / (hi - lo)) * 7.0).round().clamp(0.0, 7.0) as usize],        None => ' ',    }).collect())}
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On Sparklines: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.