Skip to main content

ggplot_rs/geom/
wordcloud.rs

1//! Word cloud: `geom_wordcloud`. Words sized by `weight` are placed — the
2//! largest first — along an Archimedean spiral from the panel centre at the
3//! first spot where their bounding box overlaps no placed word
4//! (ggwordcloud / wordcloud2). Layout happens at draw time in pixels, since
5//! collisions depend on the rendered text size; it is deterministic (no
6//! randomness: rotations come from a hash of the word and `seed`). Words
7//! that don't fit are left out.
8
9use crate::aes::Aesthetic;
10use crate::coord::Coord;
11use crate::data::{DataFrame, Value};
12use crate::position::identity::PositionIdentity;
13use crate::position::Position;
14use crate::render::backend::{DrawBackend, FontFace, TextAnchor, TextStyle};
15use crate::render::RenderError;
16use crate::scale::ScaleSet;
17use crate::stat::identity::StatIdentity;
18use crate::stat::Stat;
19use crate::theme::Theme;
20
21use super::{Geom, GeomParams};
22
23/// Word cloud. Map `label` (word) and `weight` (frequency); optionally
24/// `color`.
25pub struct GeomWordcloud {
26    /// Font size range in pixels (by √ of the frequency share).
27    pub size_range: (f64, f64),
28    /// Share of words drawn vertically (0–1).
29    pub rotate_share: f64,
30    /// Seed for the rotation choice.
31    pub seed: u64,
32    /// Colours cycled by rank when `color` is unmapped.
33    pub palette: Vec<(u8, u8, u8)>,
34    /// Padding around each word's box, px.
35    pub padding: f64,
36    /// Bold words.
37    pub bold: bool,
38}
39
40impl Default for GeomWordcloud {
41    fn default() -> Self {
42        GeomWordcloud {
43            size_range: (9.0, 54.0),
44            rotate_share: 0.2,
45            seed: 1,
46            palette: vec![
47                (31, 119, 180),
48                (255, 127, 14),
49                (44, 160, 44),
50                (214, 39, 40),
51                (148, 103, 189),
52                (140, 86, 75),
53                (227, 119, 194),
54                (23, 190, 207),
55            ],
56            padding: 1.5,
57            bold: true,
58        }
59    }
60}
61
62/// FNV-1a hash of `s` mixed with `seed` → [0, 1).
63fn unit_hash(s: &str, seed: u64) -> f64 {
64    let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
65    for b in s.bytes() {
66        h ^= b as u64;
67        h = h.wrapping_mul(0x0000_0100_0000_01B3);
68    }
69    (h >> 11) as f64 / (1u64 << 53) as f64
70}
71
72/// Axis-aligned box `(x0, y0, x1, y1)`.
73type Box2 = (f64, f64, f64, f64);
74
75fn overlaps(a: &Box2, b: &Box2) -> bool {
76    a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
77}
78
79impl Geom for GeomWordcloud {
80    fn draw(
81        &self,
82        data: &DataFrame,
83        _coord: &dyn Coord,
84        scales: &ScaleSet,
85        theme: &Theme,
86        backend: &mut dyn DrawBackend,
87    ) -> Result<(), RenderError> {
88        let label = data
89            .column("label")
90            .ok_or(RenderError::MissingAesthetic("label".into()))?;
91        let weight = data.column("weight");
92        let color_col = data.column("color");
93        let area = backend.plot_area();
94        let mut words: Vec<(usize, f64)> = (0..data.nrows())
95            .filter(|&i| !label[i].is_na() && !super::tip_value(&label[i]).is_empty())
96            .map(|i| (i, weight.and_then(|w| w[i].as_f64()).unwrap_or(1.0)))
97            .filter(|w| w.1.is_finite() && w.1 > 0.0)
98            .collect();
99        words.sort_by(|a, b| b.1.total_cmp(&a.1));
100        let max_w = words.first().map(|w| w.1).unwrap_or(1.0);
101        let (cx, cy) = (area.x + area.width / 2.0, area.y + area.height / 2.0);
102        let aspect = (area.width / area.height.max(1.0)).max(0.1);
103        let family = super::resolve_font_family("", theme);
104        let face = if self.bold {
105            FontFace::Bold
106        } else {
107            FontFace::Plain
108        };
109        let mut placed: Vec<Box2> = Vec::new();
110        for (rank, &(i, w)) in words.iter().enumerate() {
111            let word = super::tip_value(&label[i]);
112            let (lo, hi) = self.size_range;
113            let size = lo + (hi - lo) * (w / max_w).sqrt();
114            let vertical = unit_hash(&word, self.seed) < self.rotate_share;
115            let (tw, th) = (
116                crate::render::text::text_width(&word, size) * if self.bold { 1.08 } else { 1.0 },
117                size * 1.05,
118            );
119            let (bw, bh) = if vertical { (th, tw) } else { (tw, th) };
120            let (bw, bh) = (bw + 2.0 * self.padding, bh + 2.0 * self.padding);
121            if bw > area.width || bh > area.height {
122                continue;
123            }
124            // Archimedean spiral r = k·θ (k px per radian), walked in ~3 px
125            // steps along the arc and stretched to the panel's aspect.
126            let mut spot = None;
127            let (k, mut theta) = (1.0f64, 0.0f64);
128            let r_max = area.width.max(area.height);
129            loop {
130                let r = k * theta;
131                if r > r_max {
132                    break;
133                }
134                let (x, y) = (cx + r * theta.cos() * aspect, cy + r * theta.sin());
135                let b = (x - bw / 2.0, y - bh / 2.0, x + bw / 2.0, y + bh / 2.0);
136                let inside = b.0 >= area.x
137                    && b.1 >= area.y
138                    && b.2 <= area.x + area.width
139                    && b.3 <= area.y + area.height;
140                if inside && !placed.iter().any(|p| overlaps(p, &b)) {
141                    spot = Some((x, y, b));
142                    break;
143                }
144                theta += 3.0 / r.max(3.0);
145            }
146            let Some((x, y, b)) = spot else { continue };
147            placed.push(b);
148            let color = color_col
149                .and_then(|c| scales.map_color(&Aesthetic::Color, &c[i]))
150                .unwrap_or_else(|| self.palette[rank % self.palette.len().max(1)]);
151            super::set_mark(
152                backend,
153                Some(format!("{word}: {}", super::tip_value(&Value::Float(w)))),
154                Some(word.clone()),
155                super::series_key(data, i),
156                super::raw_value(&Value::Float(w)),
157            );
158            super::set_row_extras(backend, data, i);
159            backend.draw_text(
160                &word,
161                (x, y),
162                &TextStyle {
163                    color,
164                    size,
165                    anchor: TextAnchor::Middle,
166                    angle: if vertical { 90.0 } else { 0.0 },
167                    family: family.clone(),
168                    face,
169                    bg_colour: None,
170                    bg_r: 0.0,
171                },
172            )?;
173        }
174        super::clear_mark(backend);
175        super::clear_row_extras(backend);
176        Ok(())
177    }
178
179    fn required_aes(&self) -> Vec<Aesthetic> {
180        vec![Aesthetic::Label]
181    }
182    fn default_stat(&self) -> Box<dyn Stat> {
183        Box::new(StatIdentity)
184    }
185    fn default_position(&self) -> Box<dyn Position> {
186        Box::new(PositionIdentity)
187    }
188    fn default_params(&self) -> GeomParams {
189        GeomParams::default()
190    }
191    fn name(&self) -> &str {
192        "wordcloud"
193    }
194    fn consumes_tooltips(&self) -> bool {
195        true
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn hash_is_stable_and_seeded() {
205        assert_eq!(unit_hash("rust", 1), unit_hash("rust", 1));
206        assert_ne!(unit_hash("rust", 1), unit_hash("rust", 2));
207        assert!((0.0..1.0).contains(&unit_hash("x", 7)));
208        assert!(overlaps(&(0.0, 0.0, 2.0, 2.0), &(1.0, 1.0, 3.0, 3.0)));
209        assert!(!overlaps(&(0.0, 0.0, 1.0, 1.0), &(1.0, 0.0, 2.0, 1.0)));
210    }
211}