Media

Heatmaps

Heatmaps as PNG: clicks or sightings blurred into a density map over a screenshot or a plain background, and tables of numbers as coloured grids.

Version 1.0.0 · 2 functions · 1 crate

#images#analytics#charts#maps

Functions

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

heatmap_png(points: list, out: str, width: int, height: int, options: any) → anyor an error

A density map of points ([x, y], [x, y, weight] or { x, y, weight }, in pixels from the top left) saved as a PNG at out, width × height pixels. options (or null): radius of each point's spread (20, up to 100), opacity of the densest spots (0.8), background a picture's path to draw over (a screenshot; with width and height 0 its own size is used) or a "#rrggbb", colors your own scale lightest first, and max to fix the density shown darkest (to compare maps). { width, height, max }, max the highest density found. Up to 50,000 points.

heatmap_grid_png(rows: list, out: str, cell: int, options: any) → anyor an error

A table of numbers (rows, each a list of numbers; null for none) as a grid of coloured cells cell pixels square (4 to 200), darker for more, saved as a PNG at out: orders by weekday and hour, errors by server and day. options: colors as above, gap between cells in pixels (2). { width, height, min, max }.

Get it

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

Or with curl:

$ curl -O https://quillolang.org/plugins/files/heatmap.qwer$ curl -O https://quillolang.org/plugins/files/heatmap.qwe$ curl -O https://quillolang.org/plugins/files/heatmap_sample.png

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

main.qwe
use heatmap.qweruse heatmap_png, heatmap_grid_png from heatmap      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }

Example

heatmap.qwe
// Where visitors click on the order page, over its screenshot; and orders by weekday and hour.////   quillo run heatmap.qwe      then open http://localhost:8080/clicks.pnguse "heatmap.qwer"// Clicks as a page would log them: most on the button, some on the menu, a few anywhere.fn clicks() = flatten([    for i in range(120) => [110 + (i * 7) % 80, 192 + (i * 3) % 26],    for i in range(40) => [30 + (i * 5) % 30, 50 + (i * 11) % 100],    for i in range(15) => [100 + (i * 37) % 240, 50 + (i * 23) % 90],])route GET /clicks.png {    heatmap_png(clicks(), "data/heat/clicks.png", 0, 0, { background: "heatmap_sample.png", radius: 18 })?    file("data/heat/clicks.png")}route GET /week.png {    let orders = for day in range(7) => for hour in range(12) => (day + 1) * (hour < 4 ? hour : 12 - hour)    heatmap_grid_png(orders, "data/heat/week.png", 24, null)?    file("data/heat/week.png")}route GET /made = {    clicks: heatmap_png(clicks(), "data/heat/clicks.png", 0, 0, { background: "heatmap_sample.png", radius: 18 })?,    plain: heatmap_png([[50, 50], [52, 50, 3], { x: 150, y: 80 }], "data/heat/plain.png", 200, 120, null)?,    grid: heatmap_grid_png([[1, 2, 3], [4, null, 6]], "data/heat/grid.png", 20, null)?,}
$ quillo run heatmap.qwe$ curl 'localhost:8080/made'$ curl 'localhost:8080/clicks.png'

Source

heatmap.qwer
//! Heatmaps as PNG: clicks, taps or sightings as points blurred into a density map over a screenshot or a plain background, and tables of numbers as coloured grids.//! version: 1.0.0//!//! [dependencies]//! image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }use image::{DynamicImage, ImageDecoder, Rgba, RgbaImage};use serde_json::{json, Value};const MAX_POINTS: usize = 50_000;// One hue from light to dark: more is darker. A rainbow would suggest steps that are not in the data.const BLUES: [&str; 7] = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"];fn reader(path: &str) -> Result<image::ImageReader<std::io::BufReader<std::fs::File>>, String> {    let mut r = image::ImageReader::open(path).map_err(|e| format!("{}: {}", path, e))?.with_guessed_format().map_err(|e| format!("{}: {}", path, e))?;    // A small file claiming to be enormous is refused before anything is allocated.    let mut l = image::Limits::default();    l.max_image_width = Some(12_000);    l.max_image_height = Some(12_000);    l.max_alloc = Some(512 * 1024 * 1024);    r.limits(l);    Ok(r)}// The picture turned upright by its EXIF orientation, as phones expect.fn open(path: &str) -> Result<DynamicImage, String> {    let mut dec = reader(path)?.into_decoder().map_err(|e| format!("{}: {}", path, e))?;    let o = dec.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);    let mut img = DynamicImage::from_decoder(dec).map_err(|e| format!("{}: {}", path, e))?;    img.apply_orientation(o);    Ok(img)}// Saved in the format its extension names: .jpg (quality 88), .png, .webp (lossless) or .gif; folders are made.fn save(img: &DynamicImage, path: &str) -> Result<(), String> {    if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;    }    let ext = path.rsplit_once('.').map(|(_, e)| e.to_lowercase()).unwrap_or_default();    let err = |e: image::ImageError| format!("{}: {}", path, e);    match ext.as_str() {        "jpg" | "jpeg" => {            let f = std::fs::File::create(path).map_err(|e| format!("{}: {}", path, e))?;            image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::BufWriter::new(f), 88).encode_image(&img.to_rgb8()).map_err(err)        }        "png" => img.save_with_format(path, image::ImageFormat::Png).map_err(err),        "webp" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::WebP).map_err(err),        "gif" => DynamicImage::ImageRgba8(img.to_rgba8()).save_with_format(path, image::ImageFormat::Gif).map_err(err),        _ => Err(format!("{}: save as .jpg, .png, .webp or .gif", path)),    }}fn rgb(h: &str) -> Option<[f32; 3]> {    let h = h.trim_start_matches('#');    if h.len() != 6 {        return None;    }    let v = u32::from_str_radix(h, 16).ok()?;    Some([(v >> 16 & 255) as f32, (v >> 8 & 255) as f32, (v & 255) as f32])}fn ramp(o: &Value) -> Result<Vec<[f32; 3]>, String> {    let given: Option<Vec<String>> = o.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(|c| c.as_str().map(str::to_string)).collect());    let list: Vec<String> = given.unwrap_or_else(|| BLUES.iter().map(|s| s.to_string()).collect());    let r: Vec<[f32; 3]> = list.iter().filter_map(|c| rgb(c)).collect();    if r.len() < 2 || r.len() != list.len() {        return Err("`colors` is a list of two or more \"#rrggbb\", lightest (least) first".into());    }    Ok(r)}fn at(r: &[[f32; 3]], t: f32) -> [f32; 3] {    let t = t.clamp(0.0, 1.0) * (r.len() - 1) as f32;    let i = (t.floor() as usize).min(r.len() - 2);    let f = t - i as f32;    [0, 1, 2].map(|k| r[i][k] + (r[i + 1][k] - r[i][k]) * f)}fn point(v: &Value) -> Option<(f32, f32, f32)> {    match v {        Value::Array(a) => Some((a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32, a.get(2).and_then(Value::as_f64).unwrap_or(1.0) as f32)),        Value::Object(_) => Some((v.get("x")?.as_f64()? as f32, v.get("y")?.as_f64()? as f32, v.get("weight").and_then(Value::as_f64).unwrap_or(1.0) as f32)),        _ => None,    }}/// A density map of `points` (`[x, y]`, `[x, y, weight]` or `{ x, y, weight }`, in pixels from the top left) saved as a PNG at `out`, `width` × `height` pixels./// `options` (or null): `radius` of each point's spread (20, up to 100), `opacity` of the densest spots (0.8), `background` a picture's path to draw over (a screenshot;/// with `width` and `height` 0 its own size is used) or a "#rrggbb", `colors` your own scale lightest first, and `max` to fix the density shown darkest (to compare maps)./// `{ width, height, max }`, `max` the highest density found. Up to 50,000 points.pub fn heatmap_png(points: Vec<serde_json::Value>, out: &str, width: i64, height: i64, options: serde_json::Value) -> Result<Value, String> {    if points.len() > MAX_POINTS {        return Err(format!("up to {} points", MAX_POINTS));    }    let r = ramp(&options)?;    let radius = options.get("radius").and_then(Value::as_f64).unwrap_or(20.0).clamp(1.0, 100.0) as f32;    let opacity = options.get("opacity").and_then(Value::as_f64).unwrap_or(0.8).clamp(0.0, 1.0) as f32;    let bg = options.get("background").and_then(Value::as_str).unwrap_or("");    let mut base: RgbaImage = if !bg.is_empty() && !bg.starts_with('#') {        let img = open(bg)?;        if width > 0 && height > 0 { img.resize_exact(width.min(8000) as u32, height.min(8000) as u32, image::imageops::FilterType::Triangle).to_rgba8() } else { img.to_rgba8() }    } else {        let (w, h) = (width.clamp(1, 8000) as u32, height.clamp(1, 8000) as u32);        let c = rgb(if bg.is_empty() { "#fcfcfb" } else { bg }).unwrap_or([252.0, 252.0, 251.0]);        RgbaImage::from_pixel(w, h, Rgba([c[0] as u8, c[1] as u8, c[2] as u8, 255]))    };    let (w, h) = base.dimensions();    let mut grid = vec![0f32; (w * h) as usize];    let ri = radius.ceil() as i64;    for p in &points {        let Some((px, py, wt)) = point(p) else { return Err(format!("a point is [x, y], [x, y, weight] or {{ x, y, weight }}, not {}", p)) };        if !(px.is_finite() && py.is_finite() && wt.is_finite()) {            continue;        }        let (cx, cy) = (px.round() as i64, py.round() as i64);        for y in (cy - ri).max(0)..(cy + ri + 1).min(h as i64) {            for x in (cx - ri).max(0)..(cx + ri + 1).min(w as i64) {                let d = (((x as f32 - px).powi(2) + (y as f32 - py).powi(2)).sqrt()) / radius;                if d < 1.0 {                    grid[(y as u32 * w + x as u32) as usize] += wt * (1.0 - d) * (1.0 - d);                }            }        }    }    let found = grid.iter().cloned().fold(0.0, f32::max);    let max = options.get("max").and_then(Value::as_f64).map(|m| m as f32).filter(|m| *m > 0.0).unwrap_or(found).max(1e-6);    for (i, px) in base.pixels_mut().enumerate() {        let t = (grid[i] / max).min(1.0);        if t <= 0.0 {            continue;        }        let c = at(&r, t);        // Faint where little happened, so the picture underneath shows through.        let a = opacity * t.sqrt();        for k in 0..3 {            px.0[k] = (c[k] * a + px.0[k] as f32 * (1.0 - a)).round() as u8;        }    }    save(&DynamicImage::ImageRgba8(base), out)?;    Ok(json!({ "width": w, "height": h, "max": (found as f64 * 1000.0).round() / 1000.0 }))}/// A table of numbers (`rows`, each a list of numbers; null for none) as a grid of coloured cells `cell` pixels square (4 to 200), darker for more,/// saved as a PNG at `out`: orders by weekday and hour, errors by server and day. `options`: `colors` as above, `gap` between cells in pixels (2). `{ width, height, min, max }`.pub fn heatmap_grid_png(rows: Vec<Vec<serde_json::Value>>, out: &str, cell: i64, options: serde_json::Value) -> Result<Value, String> {    if rows.is_empty() || rows.len() > 1000 || rows.iter().any(|r| r.len() > 1000) {        return Err("give 1 to 1,000 rows of up to 1,000 numbers".into());    }    let r = ramp(&options)?;    let c = cell.clamp(4, 200) as u32;    let gap = options.get("gap").and_then(Value::as_u64).unwrap_or(2).min(20) as u32;    let cols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1) as u32;    let vals: Vec<f64> = rows.iter().flatten().filter_map(Value::as_f64).collect();    let (lo, hi) = (vals.iter().cloned().fold(f64::INFINITY, f64::min), vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max));    let (w, h) = (cols * (c + gap) - gap, rows.len() as u32 * (c + gap) - gap);    if w > 8000 || h > 8000 {        return Err("the grid would be over 8,000 pixels: use smaller cells".into());    }    let mut img = RgbaImage::from_pixel(w, h, Rgba([252, 252, 251, 255]));    for (ri, row) in rows.iter().enumerate() {        for (ci, v) in row.iter().enumerate() {            let fill = match v.as_f64() {                Some(v) => {                    let t = if hi > lo { ((v - lo) / (hi - lo)) as f32 } else { 1.0 };                    let k = at(&r, t);                    Rgba([k[0] as u8, k[1] as u8, k[2] as u8, 255])                }                None => Rgba([240, 239, 236, 255]),            };            let (x0, y0) = (ci as u32 * (c + gap), ri as u32 * (c + gap));            for y in y0..y0 + c {                for x in x0..x0 + c {                    img.put_pixel(x, y, fill);                }            }        }    }    save(&DynamicImage::ImageRgba8(img), out)?;    Ok(json!({ "width": w, "height": h, "min": if lo.is_finite() { json!(lo) } else { Value::Null }, "max": if hi.is_finite() { json!(hi) } else { Value::Null } }))}
Thread
99Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.