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
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 errorA 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 errorA 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.qweOr 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.pngThen, in your program, all of it or only what you call:
use heatmap.qweruse heatmap_png, heatmap_grid_png from heatmap // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }Example
// 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
//! 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 } }))}Related
gradient.qwer · 1.0.0Colour scales and gradients (viridis, magma, blues, red-blue and more, or your own stops) as hex lists, a colour per value, CSS or SVG.6 views · 6 downloadsImage tiles image_tiles.qwer · 1.0.0Pictures cut into tiles for zoomable viewers, puzzles or a grid of posts, and many pictures made into one contact sheet of thumbnails.8 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 downloadsAnimated GIF gif.qwer · 1.0.0Animated GIFs made from a list of pictures with the delay and size you choose, and GIFs read back: frames, size and length.6 views · 7 downloadsASCII art ascii_art.qwer · 1.0.0Pictures turned into ASCII art: characters from light to dark, plain for terminals and emails, or coloured as HTML.6 views · 10 downloadsBarcodes barcode.qwer · 1.0.0EAN-13, EAN-8, UPC-A, Code 128 and Code 39 barcodes as SVG or PNG, with the digits underneath and check digits worked out.6 views · 5 downloadsThread 0 posts
On Heatmaps: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.