Media

SVG charts

Line and bar charts as SVG from your data, with axes, a legend and labels, light or dark, for emails, PDFs and reports.

Version 1.0.0 · 3 functions · 0 crates

#charts#reports#email#svg

Functions

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

chart_line_svg(data: any, options: any) → stror an error

A line chart as SVG. data is { labels: ["Jan", "Feb", ...], series: [{ name, values: [...] }, ...] } (1 to 8 series; null for a missing value leaves a gap). options (or null): title, width and height in pixels (640 × 320), prefix and suffix for values ("€", "%"), zero true to start the axis at 0, area true to shade under a single series, and theme "light", "dark" or "auto" (follows the page's dark mode). Up to four series are named at their ends. Hovering a point in a browser shows its value.

chart_bar_svg(data: any, options: any) → stror an error

A bar chart as SVG, the same data (several series make groups of bars, side by side) and options (but area); the axis always holds 0, and bars go below it for negative values. A single series of up to 12 bars has its values written at their tips.

chart_table_html(data: any, options: any) → stror an error

The same data as an HTML table, a row per label and a column per series: to put beside a chart for screen readers, or under it in an email.

Get it

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

Or with curl:

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

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

main.qwe
use chart_svg.qweruse chart_line_svg, chart_bar_svg from chart_svg      // Quillo 0.6: only these

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

[dependencies]

Example

chart_svg.qwe
// A weekly sales email's charts, drawn on the server: a line chart of two shops and a bar chart of one.////   quillo run chart_svg.qwe      then open http://localhost:8080/use "chart_svg.qwer"let weeks = {    labels: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"],    series: [        { name: "Lyon", values: [1200, 1350, 1280, 1500, 1720, 1650, 1900, 2100] },        { name: "Nantes", values: [900, 950, 1100, 1050, null, 1300, 1380, 1420] },    ],}let drinks = { labels: ["Flat white", "Espresso", "Tea", "Hot chocolate"], series: [{ name: "Cups sold", values: [412, 380, 129, 64] }] }route GET /sales.svg = respond(200, chart_line_svg(weeks, { title: "Weekly sales", prefix: "€" })?, { "content-type": "image/svg+xml" })route GET /drinks.svg = respond(200, chart_bar_svg(drinks, { title: "Cups sold this week", width: 480, height: 280 })?, { "content-type": "image/svg+xml" })route GET / = <html><body style="font-family: sans-serif; max-width: 680px">    <h1>Your week</h1>    {raw(chart_line_svg(weeks, { title: "Weekly sales", prefix: "€", theme: "auto" })?)}    {raw(chart_bar_svg(drinks, { title: "Cups sold this week", width: 480, height: 280, theme: "auto" })?)}    <details><summary>The numbers</summary>{raw(chart_table_html(weeks, { prefix: "€" })?)}</details></body></html>
$ quillo run chart_svg.qwe$ curl 'localhost:8080/sales.svg'$ curl 'localhost:8080/drinks.svg'$ curl 'localhost:8080/'

Source

chart_svg.qwer
//! Line and bar charts as SVG from your data, with axes, a legend and labels, light or dark: for emails, PDFs, reports and pages that run no JavaScript.//! version: 1.0.0//!//! [dependencies]use serde_json::Value;// A colour-blind-safe categorical order (light and dark steps), and the text, grid and surface colours.const LIGHT: [&str; 8] = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"];const DARK: [&str; 8] = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];struct Theme {    bg: &'static str,    t1: &'static str,    t2: &'static str,    grid: &'static str,    series: [&'static str; 8],}const LIGHT_THEME: Theme = Theme { bg: "#fcfcfb", t1: "#0b0b0b", t2: "#52514e", grid: "#e4e3df", series: LIGHT };const DARK_THEME: Theme = Theme { bg: "#1a1a19", t1: "#f0efec", t2: "#c3c2b7", grid: "#383835", series: DARK };struct Series {    name: String,    values: Vec<Option<f64>>,}struct Chart {    labels: Vec<String>,    series: Vec<Series>,    title: String,    width: f64,    height: f64,    prefix: String,    suffix: String,    theme: &'static Theme,    auto: bool,    area: bool,    zero: bool,}fn esc(s: &str) -> String {    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")}fn text_of(v: &Value) -> String {    match v {        Value::String(s) => s.clone(),        Value::Null => String::new(),        v => v.to_string(),    }}fn parse(data: &Value, o: &Value, bars: bool) -> Result<Chart, String> {    let labels: Vec<String> = data.get("labels").and_then(Value::as_array).ok_or("the data is { labels: [...], series: [{ name, values }] }")?.iter().map(text_of).collect();    if labels.is_empty() || labels.len() > 1000 {        return Err("give 1 to 1,000 labels".into());    }    let raw = data.get("series").and_then(Value::as_array).ok_or("the data has no `series` list")?;    if raw.is_empty() || raw.len() > 8 {        return Err("give 1 to 8 series: fold the smallest into an \"Other\" series, or draw several charts".into());    }    let mut series = vec![];    for (i, s) in raw.iter().enumerate() {        let vals = s.get("values").and_then(Value::as_array).ok_or_else(|| format!("series {} has no `values` list", i + 1))?;        let values: Vec<Option<f64>> = (0..labels.len()).map(|k| vals.get(k).and_then(Value::as_f64).filter(|x| x.is_finite())).collect();        series.push(Series { name: s.get("name").map(text_of).unwrap_or_else(|| format!("Series {}", i + 1)), values });    }    let theme = o.get("theme").and_then(Value::as_str).unwrap_or("light");    let num = |k: &str, d: f64, lo: f64, hi: f64| o.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(lo, hi);    Ok(Chart {        labels,        series,        title: o.get("title").map(text_of).unwrap_or_default(),        width: num("width", 640.0, 160.0, 4000.0),        height: num("height", 320.0, 120.0, 4000.0),        prefix: o.get("prefix").map(text_of).unwrap_or_default(),        suffix: o.get("suffix").map(text_of).unwrap_or_default(),        theme: if theme == "dark" { &DARK_THEME } else { &LIGHT_THEME },        auto: theme == "auto",        area: o.get("area").and_then(Value::as_bool).unwrap_or(false),        zero: bars || o.get("zero").and_then(Value::as_bool).unwrap_or(false),    })}// Clean tick values covering lo..hi: steps of 1, 2, 2.5 or 5 times a power of ten, about five of them.fn ticks(lo: f64, hi: f64) -> Vec<f64> {    let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo - 1.0, hi + 1.0) } else { (lo, hi) };    let raw = (hi - lo) / 5.0;    let p = 10f64.powf(raw.log10().floor());    let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * p).find(|s| *s >= raw).unwrap_or(10.0 * p);    let start = (lo / step).floor() * step;    let mut out = vec![];    let mut v = start;    while v < hi + step * 0.999 && out.len() < 20 {        out.push((v / step).round() * step);        v += step;    }    out}// 1234 as "1.2K", 2500000 as "2.5M", small numbers with up to two decimals.fn compact(v: f64) -> String {    compact_step(v, 0.0)}// The same with as many decimals as ticks `step` apart need: 1250 is "1.25K" on an axis in steps of 250.fn compact_step(v: f64, step: f64) -> String {    let a = v.abs().max(step.abs());    let (div, unit) = if a >= 1e9 { (1e9, "B") } else if a >= 1e6 { (1e6, "M") } else if a >= 1e3 { (1e3, "K") } else { (1.0, "") };    let x = v / div;    let places = if step > 0.0 {        (0..=4).find(|p| {            let m = step / div * 10f64.powi(*p as i32);            (m - m.round()).abs() < 1e-6        }).unwrap_or(4)    } else if unit.is_empty() { 2 } else { 1 };    let s = format!("{:.*}", places, x);    let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };    format!("{}{}", if s == "-0" { "0" } else { &s }, unit)}fn width_of(s: &str, size: f64) -> f64 {    s.chars().count() as f64 * size * 0.58}struct Frame {    out: String,    left: f64,    right: f64,    top: f64,    bottom: f64,    lo: f64,    hi: f64,}impl Frame {    fn y(&self, v: f64) -> f64 {        self.bottom - (v - self.lo) / (self.hi - self.lo) * (self.bottom - self.top)    }}// The surface, title, legend, grid and y axis; the plot area is left for the marks.fn frame(c: &Chart, end_space: f64) -> Frame {    let t = c.theme;    let all: Vec<f64> = c.series.iter().flat_map(|s| s.values.iter().flatten().copied()).collect();    let (mut lo, mut hi) = (all.iter().cloned().fold(f64::INFINITY, f64::min), all.iter().cloned().fold(f64::NEG_INFINITY, f64::max));    if !lo.is_finite() {        lo = 0.0;        hi = 1.0;    }    if c.zero {        lo = lo.min(0.0);        hi = hi.max(0.0);    }    let tk = ticks(lo, hi);    let (lo, hi) = (*tk.first().unwrap_or(&lo), *tk.last().unwrap_or(&hi));    let step = if tk.len() > 1 { tk[1] - tk[0] } else { 0.0 };    let fmt = |v: f64| format!("{}{}{}", c.prefix, compact_step(v, step), c.suffix);    let tick_w = tk.iter().map(|v| width_of(&fmt(*v), 11.0)).fold(0.0, f64::max);    let (w, h) = (c.width, c.height);    let mut top = 16.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" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">"#);    out += &format!("<title>{}</title><desc>{}</desc>", esc(if c.title.is_empty() { "Chart" } else { &c.title }), esc(&describe(c)));    if c.auto {        // In a page, the chart follows the reader's dark mode; elsewhere it stays light.        let mut css = format!("@media (prefers-color-scheme: dark){{.bg{{fill:{}}}.t1{{fill:{}}}.t2{{fill:{}}}.grid{{stroke:{}}}", DARK_THEME.bg, DARK_THEME.t1, DARK_THEME.t2, DARK_THEME.grid);        for (i, s) in DARK.iter().enumerate() {            css += &format!(".s{i}{{stroke:{s}}}.f{i}{{fill:{s}}}");        }        out += &format!("<style>{}.ring{{stroke:{}}}}}</style>", css, DARK_THEME.bg);    }    out += &format!(r#"<rect class="bg" width="{w}" height="{h}" fill="{}"/>"#, t.bg);    if !c.title.is_empty() {        out += &format!(r#"<text class="t1" x="16" y="{}" font-size="15" font-weight="600" fill="{}">{}</text>"#, top + 12.0, t.t1, esc(&c.title));        top += 26.0;    }    if c.series.len() >= 2 {        let mut x = 16.0;        for (i, s) in c.series.iter().enumerate() {            out += &format!(r#"<rect class="f{i}" x="{x}" y="{}" width="10" height="10" rx="2" fill="{}"/><text class="t2" x="{}" y="{}" font-size="12" fill="{}">{}</text>"#, top + 2.0, t.series[i], x + 14.0, top + 11.0, t.t2, esc(&s.name));            x += 14.0 + width_of(&s.name, 12.0) + 18.0;        }        top += 22.0;    }    let f = Frame { out, left: 16.0 + tick_w + 8.0, right: w - 16.0 - end_space, top: top + 8.0, bottom: h - 16.0 - 18.0, lo, hi };    let mut out = f.out.clone();    for v in &tk {        let y = f.y(*v);        out += &format!(r#"<line class="grid" x1="{}" x2="{}" y1="{y:.1}" y2="{y:.1}" stroke="{}" stroke-width="1"/>"#, f.left, f.right, t.grid);        out += &format!(r#"<text class="t2" x="{}" y="{:.1}" font-size="11" text-anchor="end" fill="{}">{}</text>"#, f.left - 8.0, y + 4.0, t.t2, esc(&fmt(*v)));    }    Frame { out, ..f }}fn describe(c: &Chart) -> String {    let parts: Vec<String> = c.series.iter().map(|s| {        let vals: Vec<f64> = s.values.iter().flatten().copied().collect();        let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);        let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);        if vals.is_empty() { format!("{}: no values", s.name) } else { format!("{}: from {} to {}", s.name, compact(lo), compact(hi)) }    }).collect();    format!("{} from {} to {}. {}.", if c.series.len() == 1 { "One series" } else { "Several series" }, c.labels[0], c.labels[c.labels.len() - 1], parts.join("; "))}// X-axis labels, every one when they fit, else every k-th.fn x_labels(c: &Chart, f: &Frame, xs: &[f64]) -> String {    let slot = if xs.len() > 1 { (xs[1] - xs[0]).abs() } else { f.right - f.left };    let widest = c.labels.iter().map(|l| width_of(l, 11.0)).fold(0.0, f64::max) + 8.0;    let every = (widest / slot.max(1.0)).ceil().max(1.0) as usize;    let mut out = String::new();    for (i, (l, x)) in c.labels.iter().zip(xs).enumerate() {        if i % every == 0 {            out += &format!(r#"<text class="t2" x="{x:.1}" y="{:.1}" font-size="11" text-anchor="middle" fill="{}">{}</text>"#, f.bottom + 16.0, c.theme.t2, esc(l));        }    }    out}/// A line chart as SVG. `data` is `{ labels: ["Jan", "Feb", ...], series: [{ name, values: [...] }, ...] }` (1 to 8 series; null for a missing value leaves a gap)./// `options` (or null): `title`, `width` and `height` in pixels (640 × 320), `prefix` and `suffix` for values ("€", "%"), `zero` true to start the axis at 0,/// `area` true to shade under a single series, and `theme` "light", "dark" or "auto" (follows the page's dark mode). Up to four series are named at their ends./// Hovering a point in a browser shows its value.pub fn chart_line_svg(data: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    let c = parse(&data, &options, false)?;    let named = c.series.len() <= 4;    let end_space = if named { c.series.iter().map(|s| width_of(&s.name, 11.0).max(width_of("00.0K", 11.0))).fold(0.0, f64::max) + 14.0 } else { 8.0 };    let f = frame(&c, end_space);    let mut out = f.out.clone();    let t = c.theme;    let n = c.labels.len();    let xs: Vec<f64> = (0..n).map(|i| if n == 1 { (f.left + f.right) / 2.0 } else { f.left + (f.right - f.left) * i as f64 / (n - 1) as f64 }).collect();    let fmt = |v: f64| format!("{}{}{}", c.prefix, compact(v), c.suffix);    let mut ends: Vec<(f64, usize, String)> = vec![];    for (si, s) in c.series.iter().enumerate() {        let col = t.series[si];        let mut d = String::new();        let mut pen = false;        for (i, v) in s.values.iter().enumerate() {            match v {                Some(v) => {                    d += &format!("{}{:.1} {:.1}", if pen { "L" } else { "M" }, xs[i], f.y(*v));                    pen = true;                }                None => pen = false,            }        }        if c.area && c.series.len() == 1 {            let pts: Vec<(f64, f64)> = s.values.iter().enumerate().filter_map(|(i, v)| v.map(|v| (xs[i], f.y(v)))).collect();            if let (Some(a), Some(b)) = (pts.first(), pts.last()) {                let base = f.y(f.lo.max(0.0).min(f.hi));                let body: String = pts.iter().map(|(x, y)| format!("L{:.1} {:.1}", x, y)).collect();                out += &format!(r#"<path class="f{si}" d="M{:.1} {:.1}{}L{:.1} {:.1}Z" fill="{col}" fill-opacity="0.1"/>"#, a.0, base, body, b.0, base);            }        }        out += &format!(r#"<path class="s{si}" d="{d}" fill="none" stroke="{col}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>"#);        // An invisible target on each point carries its tooltip.        for (i, v) in s.values.iter().enumerate() {            if let Some(v) = v {                out += &format!(r#"<circle cx="{:.1}" cy="{:.1}" r="8" fill="transparent"><title>{} · {}: {}</title></circle>"#, xs[i], f.y(*v), esc(&c.labels[i]), esc(&s.name), esc(&fmt(*v)));            }        }        if let Some((i, v)) = s.values.iter().enumerate().rev().find_map(|(i, v)| v.map(|v| (i, v))) {            out += &format!(r#"<circle class="f{si} ring" cx="{:.1}" cy="{:.1}" r="4" fill="{col}" stroke="{}" stroke-width="2"/>"#, xs[i], f.y(v), t.bg);            if named && i == n - 1 {                ends.push((f.y(v), si, if c.series.len() == 1 { fmt(v) } else { s.name.clone() }));            }        }    }    // End labels pushed apart so they never sit on one another.    ends.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));    for i in 1..ends.len() {        if ends[i].0 - ends[i - 1].0 < 14.0 {            ends[i].0 = ends[i - 1].0 + 14.0;        }    }    for (y, _, label) in &ends {        out += &format!(r#"<text class="t2" x="{:.1}" y="{:.1}" font-size="11" fill="{}">{}</text>"#, f.right + 10.0, y + 4.0, t.t2, esc(label));    }    out += &x_labels(&c, &f, &xs);    out += "</svg>";    Ok(out)}/// A bar chart as SVG, the same `data` (several series make groups of bars, side by side) and `options` (but `area`); the axis always holds 0, and bars go below it for negative values./// A single series of up to 12 bars has its values written at their tips.pub fn chart_bar_svg(data: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    let c = parse(&data, &options, true)?;    let f = frame(&c, 8.0);    let mut out = f.out.clone();    let t = c.theme;    let (n, k) = (c.labels.len(), c.series.len());    let band = (f.right - f.left) / n as f64;    let thick = ((band * 0.7 - 2.0 * (k as f64 - 1.0)) / k as f64).clamp(1.0, 24.0);    let group = thick * k as f64 + 2.0 * (k as f64 - 1.0);    let base = f.y(0.0);    let fmt = |v: f64| format!("{}{}{}", c.prefix, compact(v), c.suffix);    let label_tips = k == 1 && n <= 12;    let mut xs = vec![];    for i in 0..n {        let cx = f.left + band * (i as f64 + 0.5);        xs.push(cx);        for (si, s) in c.series.iter().enumerate() {            let Some(v) = s.values[i] else { continue };            let x = cx - group / 2.0 + si as f64 * (thick + 2.0);            let y = f.y(v);            let hgt = (base - y).abs();            let r = 4f64.min(hgt).min(thick / 2.0);            // Rounded at the value's end, square at the baseline.            let d = if v >= 0.0 {                format!("M{x:.1} {base:.1}V{:.1}Q{x:.1} {y:.1} {:.1} {y:.1}H{:.1}Q{:.1} {y:.1} {:.1} {:.1}V{base:.1}Z", y + r, x + r, x + thick - r, x + thick, x + thick, y + r)            } else {                format!("M{x:.1} {base:.1}V{:.1}Q{x:.1} {y:.1} {:.1} {y:.1}H{:.1}Q{:.1} {y:.1} {:.1} {:.1}V{base:.1}Z", y - r, x + r, x + thick - r, x + thick, x + thick, y - r)            };            out += &format!(r#"<path class="f{si}" d="{d}" fill="{}"><title>{} · {}: {}</title></path>"#, t.series[si], esc(&c.labels[i]), esc(&s.name), esc(&fmt(v)));            if label_tips {                let ty = if v >= 0.0 { y - 6.0 } else { y + 14.0 };                out += &format!(r#"<text class="t1" x="{:.1}" y="{ty:.1}" font-size="11" text-anchor="middle" fill="{}">{}</text>"#, x + thick / 2.0, t.t1, esc(&fmt(v)));            }        }    }    out += &format!(r#"<line class="grid" x1="{}" x2="{}" y1="{base:.1}" y2="{base:.1}" stroke="{}" stroke-width="1"/>"#, f.left, f.right, t.t2);    out += &x_labels(&c, &f, &xs);    out += "</svg>";    Ok(out)}/// The same data as an HTML table, a row per label and a column per series: to put beside a chart for screen readers, or under it in an email.pub fn chart_table_html(data: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    let c = parse(&data, &options, false)?;    let mut h = String::from("<table><thead><tr><th></th>");    for s in &c.series {        h += &format!("<th scope=\"col\">{}</th>", esc(&s.name));    }    h += "</tr></thead><tbody>";    for (i, l) in c.labels.iter().enumerate() {        h += &format!("<tr><th scope=\"row\">{}</th>", esc(l));        for s in &c.series {            let v = s.values[i].map(|v| {                let t = if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{}", (v * 100.0).round() / 100.0) };                format!("{}{}{}", c.prefix, t, c.suffix)            });            h += &format!("<td>{}</td>", esc(&v.unwrap_or_default()));        }        h += "</tr>";    }    h += "</tbody></table>";    Ok(h)}
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.