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
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 errorA 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 errorA 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 errorThe 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.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/chart_svg.qwer$ curl -O https://quillolang.org/plugins/files/chart_svg.qweThen, in your program, all of it or only what you call:
use chart_svg.qweruse chart_line_svg, chart_bar_svg from chart_svg // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// 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
//! 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('&', "&").replace('<', "<").replace('>', ">").replace('"', """)}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)}Related
sparkline.qwer · 1.0.0Tiny inline charts from a list of numbers: SVG line and bar sparklines for table cells and sentences, and the same in text (▁▃▆█).6 views · 8 downloadsSVG to PNG svg_png.qwer · 1.0.0SVG drawn to PNG at the size you ask, text included with a built-in font: link previews, email images, icons and charts.7 views · 7 downloadsWord clouds word_cloud.qwer · 1.0.0Word clouds as SVG: a text's words, or your own counts, sized by how often they appear and laid out on a spiral without overlapping, some turned upright, in your colours; the same layout each time.8 views · 7 downloadsDMARC reports dmarc_report.qwer · 1.0.0Read DMARC aggregate reports from their XML or their gzip and zip attachments, with a limit on what they unpack to, into records and summaries by source and alignment that show who sends as your domain and what fails.2 views · 6 downloadsText tables text_table.qwer · 1.0.0Rows as aligned plain-text tables for e-mails, logs, terminals and READMEs: box-drawn, ASCII, plain or Markdown, numbers to the right, wide characters measured right, long cells cut; and Markdown tables read back into rows.7 views · 7 downloadsAudio waveform audio_wave.qwer · 1.0.0A WAV file's waveform for an audio player: the loudness of each slice from 0 to 1, or drawn as an SVG of bars.6 views · 7 downloadsThread 0 posts
On SVG charts: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.