Media

Word clouds

Word 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.

Version 1.0.0 · 2 functions · 2 crates

#svg#charts#reviews#dashboards

Functions

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

word_cloud_words(text: str, limit: int, stop_words: bool) → listor an error

The most frequent words of text, most first: [{ word, count }], lower case, common English words left out when stop_words.

word_cloud_svg(words: any, options: any) → stror an error

A word cloud as SVG markup: words is a text (counted, common English words left out) or a map of word to count ({ quillo: 12, fast: 5 }). options: width (600), height (400), max_words (80), min_size and max_size (font pixels, 12 and 64), colors (a list of #rrggbb), background ("" for none), font (a CSS font family), vertical (a share of words turned upright, 0.2). The layout is the same each time for the same words.

Get it

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

Or with curl:

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

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

main.qwe
use word_cloud.qweruse word_cloud_words, word_cloud_svg from word_cloud      // Quillo 0.6: only these

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

[dependencies]
unicode-segmentation = "1"serde_json = { version = "1", features = ["preserve_order"] }

Example

word_cloud.qwe
// The answers to a customer survey ("What do you like about us?") as a word cloud on the team's dashboard.////   quillo run word_cloud.qwe      then open http://localhost:8080/cloud.svguse "word_cloud.qwer"let ANSWERS = "Fast delivery and friendly staff. The coffee is great, delivery was fast. Great coffee, great prices. Friendly staff, clean shop, great coffee! Delivery fast and the packaging was great. Fresh beans, fast delivery, fair prices. The staff are friendly and the coffee is fresh. Good coffee and fast service. Love the fresh beans and the friendly service."route GET /cloud.svg = respond(200, word_cloud_svg(ANSWERS, { width: 500, height: 300, background: "#fdfaf5" })?, { "content-type": "image/svg+xml" })route GET /words = word_cloud_words(ANSWERS, 6, true)?// Tags with counts from the database, in the brand's colours.route GET /tags.svg = respond(200, word_cloud_svg({ quillo: 40, rust: 25, web: 22, sqlite: 15, plugins: 12, routes: 9, views: 7, maps: 4, email: 3 }, { width: 400, height: 240, colors: ["#5b3fc4", "#e0533d"], vertical: 0.0 })?, { "content-type": "image/svg+xml" })
$ quillo run word_cloud.qwe$ curl 'localhost:8080/words'$ curl 'localhost:8080/cloud.svg'$ curl 'localhost:8080/tags.svg'

Source

word_cloud.qwer
//! Word clouds as SVG: the words of a text (or your own counts) sized by how often they appear, laid out on a spiral without overlapping, some turned upright, in your colours; for survey answers, reviews, tags and talks.//! version: 1.0.0//!//! [dependencies]//! unicode-segmentation = "1"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Value};use std::collections::HashMap;use unicode_segmentation::UnicodeSegmentation;const STOP: &str = "a about above after again against all also am an and any are as at be because been before being below between both but by can could did do does doing down during each few for from further had has have having he her here hers him his how i if in into is it its just me more most my no nor not now of off on once only or other our out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your yours really get got one";fn counts_of(words: &Value, stop_words: bool) -> Result<Vec<(String, f64)>, String> {    let stop: std::collections::HashSet<&str> = STOP.split_whitespace().collect();    let mut v: Vec<(String, f64)> = match words {        Value::String(text) => {            let mut m: HashMap<String, (f64, usize, String)> = HashMap::new();            for (i, w) in text.unicode_words().enumerate() {                let low = w.to_lowercase();                if w.chars().count() < 2 || low.chars().all(|c| c.is_numeric()) || (stop_words && stop.contains(low.as_str())) { continue }                let e = m.entry(low).or_insert((0.0, i, w.to_string()));                e.0 += 1.0;            }            let mut v: Vec<(String, f64, usize)> = m.into_iter().map(|(_, (c, i, w))| (w.to_lowercase(), c, i)).collect();            v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.2.cmp(&b.2)));            v.into_iter().map(|(w, c, _)| (w, c)).collect()        }        Value::Object(o) => {            let mut v: Vec<(String, f64)> = o.iter().filter_map(|(k, c)| c.as_f64().filter(|c| *c > 0.0).map(|c| (k.clone(), c))).collect();            v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.0.cmp(&b.0)));            v        }        _ => return Err("words are a text or a map of word to count".into()),    };    v.retain(|(w, _)| !w.trim().is_empty());    Ok(v)}// About how wide a word is set at `size` in a sans-serif face.fn text_width(word: &str, size: f64) -> f64 {    word.chars().map(|c| {        let k = if "iljtfr.,;:!'|".contains(c) { 0.34 } else if "mwMW".contains(c) { 0.88 } else if c.is_uppercase() { 0.70 } else if c as u32 > 0x2E80 { 1.0 } else { 0.58 };        k * size    }).sum()}fn esc(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }/// The most frequent words of `text`, most first: `[{ word, count }]`, lower case, common English words left out when `stop_words`.pub fn word_cloud_words(text: &str, limit: i64, stop_words: bool) -> Result<Vec<Value>, String> {    Ok(counts_of(&Value::String(text.to_string()), stop_words)?.into_iter().take(limit.max(0) as usize).map(|(w, c)| json!({ "word": w, "count": c as i64 })).collect())}/// A word cloud as SVG markup: `words` is a text (counted, common English words left out) or a map of word to count (`{ quillo: 12, fast: 5 }`)./// `options`: `width` (600), `height` (400), `max_words` (80), `min_size` and `max_size` (font pixels, 12 and 64), `colors` (a list of `#rrggbb`), `background` (`""` for none), `font` (a CSS font family), `vertical` (a share of words turned upright, 0.2). The layout is the same each time for the same words.pub fn word_cloud_svg(words: serde_json::Value, options: serde_json::Value) -> Result<String, String> {    let n = |k: &str, d: f64| options.get(k).and_then(|v| v.as_f64()).unwrap_or(d);    let (w, h) = (n("width", 600.0), n("height", 400.0));    let (min_s, max_s) = (n("min_size", 12.0), n("max_size", 64.0));    let vertical = n("vertical", 0.2).clamp(0.0, 1.0);    let max_words = n("max_words", 80.0) as usize;    let colors: Vec<String> = options.get("colors").and_then(|c| c.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).filter(|v: &Vec<String>| !v.is_empty())        .unwrap_or_else(|| ["#1f4e79", "#2e75b6", "#c55a11", "#548235", "#7030a0", "#bf9000"].iter().map(|s| s.to_string()).collect());    let font = options.get("font").and_then(|f| f.as_str()).unwrap_or("Verdana, 'DejaVu Sans', sans-serif").to_string();    let bg = options.get("background").and_then(|f| f.as_str()).unwrap_or("").to_string();    let list: Vec<(String, f64)> = counts_of(&words, true)?.into_iter().take(max_words).collect();    if list.is_empty() { return Err("there are no words to draw".into()) }    let (lo, hi) = (list.iter().map(|x| x.1).fold(f64::MAX, f64::min), list.iter().map(|x| x.1).fold(0.0, f64::max));    let mut placed: Vec<[f64; 4]> = Vec::new();    let mut out = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"{}\">", esc(&font));    if !bg.is_empty() { out.push_str(&format!("<rect width=\"100%\" height=\"100%\" fill=\"{}\"/>", esc(&bg))) }    for (i, (word, c)) in list.iter().enumerate() {        let t = if hi > lo { (c - lo) / (hi - lo) } else { 1.0 };        let mut size = (min_s + (max_s - min_s) * t.sqrt()).round();        // Every few words, in a fixed pattern, one turned upright.        let upright = i > 0 && vertical > 0.0 && ((i as f64 * 0.618034) % 1.0) < vertical;        let mut spot = None;        // A word that finds no room is tried smaller, down to the smallest size.        while spot.is_none() {            let (tw, th) = (text_width(word, size) + 2.0, size * 1.05);            let (bw, bh) = if upright { (th, tw) } else { (tw, th) };            let mut a: f64 = 0.0;            while a < 400.0 {                let r = 2.2 * a;                let (cx, cy) = (w / 2.0 + r * a.cos() * (w / h).sqrt(), h / 2.0 + r * a.sin() / (w / h).sqrt());                let b = [cx - bw / 2.0, cy - bh / 2.0, cx + bw / 2.0, cy + bh / 2.0];                if r > w.max(h) { break }                if b[0] >= 0.0 && b[1] >= 0.0 && b[2] <= w && b[3] <= h && !placed.iter().any(|p| b[0] < p[2] && p[0] < b[2] && b[1] < p[3] && p[1] < b[3]) {                    spot = Some((cx, cy, b));                    break;                }                a += 0.1;            }            if spot.is_none() { if size <= min_s { break } size = (size * 0.85).max(min_s).round() }        }        let Some((cx, cy, b)) = spot else { continue };        placed.push(b);        let color = &colors[i % colors.len()];        let transform = if upright { format!(" transform=\"rotate(-90 {:.1} {:.1})\"", cx, cy) } else { String::new() };        out.push_str(&format!("<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"{}\" fill=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\"{}>{}</text>", cx, cy, size, esc(color), transform, esc(word)));    }    out.push_str("</svg>");    Ok(out)}
Thread
87Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.