1use 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
23pub struct GeomWordcloud {
26 pub size_range: (f64, f64),
28 pub rotate_share: f64,
30 pub seed: u64,
32 pub palette: Vec<(u8, u8, u8)>,
34 pub padding: f64,
36 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
62fn 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
72type 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 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}