Skip to main content

kuva/plot/
diceplot.rs

1use crate::plot::colormap::ColorMap;
2use std::collections::BTreeMap;
3
4/// Dice face positions (1-indexed in a 3×3 grid) for 1–6 dots.
5///
6/// ```text
7/// 1 2 3
8/// 4 5 6
9/// 7 8 9
10/// ```
11///
12/// Positions follow row-major ordering so the legend key matches visual layout.
13const DICE_POSITIONS: [&[usize]; 7] = [
14    &[],                 // 0 dots
15    &[5],                // 1 dot  – centre
16    &[1, 9],             // 2 dots – diagonal
17    &[1, 5, 9],          // 3 dots – diagonal + centre
18    &[1, 7, 3, 9],       // 4 dots – corners (row-major so legend matches)
19    &[1, 7, 5, 3, 9],    // 5 dots – corners + centre
20    &[1, 4, 7, 3, 6, 9], // 6 dots – two columns
21];
22
23/// One data point: which grid cell, which categories are present, and visual encodings.
24pub struct DicePoint {
25    /// X-axis category label.
26    pub x_cat: String,
27    /// Y-axis category label.
28    pub y_cat: String,
29    /// Which of the `ndots` categories are present (0-indexed, values in `0..ndots`).
30    pub present: Vec<usize>,
31    /// Continuous value encoded as tile background colour via the colour map.
32    pub fill: Option<f64>,
33    /// Continuous value encoded as dot radius.
34    pub size: Option<f64>,
35    /// Per-position categorical dot colours.  Length should equal `ndots`.
36    pub dot_colors: Vec<Option<String>>,
37    /// Per-position continuous fill values.  Length must equal `ndots`.
38    pub dot_fills: Vec<Option<f64>>,
39    /// Per-position continuous size values.  Length must equal `ndots`.
40    pub dot_sizes: Vec<Option<f64>>,
41}
42
43/// A DicePlot: a grid of cells where each cell shows up to 6 dots arranged like a die face.
44pub struct DicePlot {
45    pub points: Vec<DicePoint>,
46    pub x_categories: Vec<String>,
47    pub y_categories: Vec<String>,
48    pub category_labels: Vec<String>,
49    pub ndots: usize,
50    pub cell_width: f64,
51    pub cell_height: f64,
52    pub pad: f64,
53    pub dot_radius: f64,
54    pub color_map: ColorMap,
55    pub fill_range: Option<(f64, f64)>,
56    pub size_range: Option<(f64, f64)>,
57    pub fill_legend_label: Option<String>,
58    pub size_legend_label: Option<String>,
59    pub dot_legend: Vec<(String, String)>,
60    pub position_legend_label: Option<String>,
61    /// Draw a 3×3 sub-grid inside each die tile, showing the pip slot boundaries.
62    pub grid_lines: bool,
63}
64
65impl Default for DicePlot {
66    fn default() -> Self {
67        Self::new(4)
68    }
69}
70
71impl DicePlot {
72    pub fn new(ndots: usize) -> Self {
73        let ndots = ndots.clamp(1, 6);
74        Self {
75            points: Vec::new(),
76            x_categories: Vec::new(),
77            y_categories: Vec::new(),
78            category_labels: (0..ndots).map(|i| format!("Cat {}", i + 1)).collect(),
79            ndots,
80            cell_width: 0.8,
81            cell_height: 0.8,
82            pad: 0.1,
83            dot_radius: 0.0,
84            color_map: ColorMap::Viridis,
85            fill_range: None,
86            size_range: None,
87            fill_legend_label: None,
88            size_legend_label: None,
89            dot_legend: Vec::new(),
90            position_legend_label: None,
91            grid_lines: false,
92        }
93    }
94
95    pub fn with_points<I, Sx, Sy>(mut self, iter: I) -> Self
96    where
97        I: IntoIterator<Item = (Sx, Sy, Vec<usize>, Option<f64>, Option<f64>)>,
98        Sx: Into<String>,
99        Sy: Into<String>,
100    {
101        for (x_cat, y_cat, present, fill, size) in iter {
102            let x_cat: String = x_cat.into();
103            let y_cat: String = y_cat.into();
104            if !self.x_categories.contains(&x_cat) {
105                self.x_categories.push(x_cat.clone());
106            }
107            if !self.y_categories.contains(&y_cat) {
108                self.y_categories.push(y_cat.clone());
109            }
110            self.points.push(DicePoint {
111                x_cat,
112                y_cat,
113                present,
114                fill,
115                size,
116                dot_colors: Vec::new(),
117                dot_fills: Vec::new(),
118                dot_sizes: Vec::new(),
119            });
120        }
121        self
122    }
123
124    pub fn with_records<I, Sx, Sy, Sd, Sc>(mut self, iter: I) -> Self
125    where
126        I: IntoIterator<Item = (Sx, Sy, Sd, Sc)>,
127        Sx: Into<String>,
128        Sy: Into<String>,
129        Sd: Into<String>,
130        Sc: Into<String>,
131    {
132        let mut cell_map: BTreeMap<(String, String), Vec<(usize, String)>> = BTreeMap::new();
133        for (x_cat, y_cat, dot_cat, color) in iter {
134            let x_cat: String = x_cat.into();
135            let y_cat: String = y_cat.into();
136            let dot_cat: String = dot_cat.into();
137            let color: String = color.into();
138            let dot_idx = self.category_labels.iter().position(|l| l == &dot_cat);
139            if let Some(dot_idx) = dot_idx {
140                if !self.x_categories.contains(&x_cat) {
141                    self.x_categories.push(x_cat.clone());
142                }
143                if !self.y_categories.contains(&y_cat) {
144                    self.y_categories.push(y_cat.clone());
145                }
146                cell_map
147                    .entry((x_cat, y_cat))
148                    .or_default()
149                    .push((dot_idx, color));
150            }
151        }
152        for ((x_cat, y_cat), dot_entries) in cell_map {
153            let mut dot_colors: Vec<Option<String>> = vec![None; self.ndots];
154            for (idx, color) in dot_entries {
155                if idx < self.ndots {
156                    dot_colors[idx] = Some(color);
157                }
158            }
159            self.points.push(DicePoint {
160                x_cat,
161                y_cat,
162                present: Vec::new(),
163                fill: None,
164                size: None,
165                dot_colors,
166                dot_fills: Vec::new(),
167                dot_sizes: Vec::new(),
168            });
169        }
170        self
171    }
172
173    pub fn with_dot_data<I, Sx, Sy>(mut self, iter: I) -> Self
174    where
175        I: IntoIterator<Item = (Sx, Sy, usize, Option<f64>, Option<f64>)>,
176        Sx: Into<String>,
177        Sy: Into<String>,
178    {
179        type DotEntry = (usize, Option<f64>, Option<f64>);
180        let mut cell_map: BTreeMap<(String, String), Vec<DotEntry>> = BTreeMap::new();
181        for (x_cat, y_cat, dot_idx, fill, size) in iter {
182            let x_cat: String = x_cat.into();
183            let y_cat: String = y_cat.into();
184            if !self.x_categories.contains(&x_cat) {
185                self.x_categories.push(x_cat.clone());
186            }
187            if !self.y_categories.contains(&y_cat) {
188                self.y_categories.push(y_cat.clone());
189            }
190            if dot_idx < self.ndots {
191                cell_map
192                    .entry((x_cat, y_cat))
193                    .or_default()
194                    .push((dot_idx, fill, size));
195            }
196        }
197        for ((x_cat, y_cat), dot_entries) in cell_map {
198            let mut dot_fills: Vec<Option<f64>> = vec![None; self.ndots];
199            let mut dot_sizes: Vec<Option<f64>> = vec![None; self.ndots];
200            for (idx, fill, size) in dot_entries {
201                dot_fills[idx] = fill;
202                dot_sizes[idx] = size;
203            }
204            if dot_fills.iter().all(|v| v.is_none()) && dot_sizes.iter().all(|v| v.is_none()) {
205                continue;
206            }
207            self.points.push(DicePoint {
208                x_cat,
209                y_cat,
210                present: Vec::new(),
211                fill: None,
212                size: None,
213                dot_colors: Vec::new(),
214                dot_fills,
215                dot_sizes,
216            });
217        }
218        self
219    }
220
221    pub fn with_x_categories(mut self, cats: Vec<String>) -> Self {
222        self.x_categories = cats;
223        self
224    }
225    pub fn with_y_categories(mut self, cats: Vec<String>) -> Self {
226        self.y_categories = cats;
227        self
228    }
229    pub fn with_category_labels(mut self, labels: Vec<String>) -> Self {
230        self.category_labels = labels;
231        self
232    }
233    pub fn with_color_map(mut self, map: ColorMap) -> Self {
234        self.color_map = map;
235        self
236    }
237    pub fn with_fill_range(mut self, min: f64, max: f64) -> Self {
238        self.fill_range = Some((min, max));
239        self
240    }
241    pub fn with_size_range(mut self, min: f64, max: f64) -> Self {
242        self.size_range = Some((min, max));
243        self
244    }
245    pub fn with_fill_legend<S: Into<String>>(mut self, label: S) -> Self {
246        self.fill_legend_label = Some(label.into());
247        self
248    }
249    pub fn with_size_legend<S: Into<String>>(mut self, label: S) -> Self {
250        self.size_legend_label = Some(label.into());
251        self
252    }
253    pub fn with_dot_legend<I, S1, S2>(mut self, entries: I) -> Self
254    where
255        I: IntoIterator<Item = (S1, S2)>,
256        S1: Into<String>,
257        S2: Into<String>,
258    {
259        self.dot_legend = entries
260            .into_iter()
261            .map(|(l, c)| (l.into(), c.into()))
262            .collect();
263        self
264    }
265    pub fn with_position_legend<S: Into<String>>(mut self, label: S) -> Self {
266        self.position_legend_label = Some(label.into());
267        self
268    }
269    pub fn with_grid_lines(mut self, v: bool) -> Self {
270        self.grid_lines = v;
271        self
272    }
273    pub fn with_dot_radius(mut self, r: f64) -> Self {
274        self.dot_radius = r;
275        self
276    }
277    pub fn with_cell_size(mut self, width: f64, height: f64) -> Self {
278        self.cell_width = width;
279        self.cell_height = height;
280        self
281    }
282    pub fn with_pad(mut self, pad: f64) -> Self {
283        self.pad = pad;
284        self
285    }
286
287    /// Returns (grid_row, grid_col) for each pip position in order, 0-indexed, row-major.
288    /// grid_row: 0=top, 1=middle, 2=bottom; grid_col: 0=left, 1=center, 2=right.
289    pub fn dot_grid_positions(&self) -> Vec<(usize, usize)> {
290        let positions = DICE_POSITIONS.get(self.ndots).copied().unwrap_or(&[]);
291        positions
292            .iter()
293            .map(|&p| ((p - 1) / 3, (p - 1) % 3))
294            .collect()
295    }
296
297    pub fn dot_offsets(&self) -> Vec<(f64, f64)> {
298        let positions = DICE_POSITIONS.get(self.ndots).copied().unwrap_or(&[]);
299        let w = self.cell_width;
300        let h = self.cell_height;
301        let pad = self.pad;
302        let avail_w = w - 2.0 * pad;
303        let avail_h = h - 2.0 * pad;
304        positions
305            .iter()
306            .map(|&p| {
307                let col = ((p - 1) / 3) as f64; // row-major: pos 1-3 → row 0, 4-6 → row 1, 7-9 → row 2
308                let row = ((p - 1) % 3) as f64; // column within row: pos 1,4,7 → col 0 (left)
309                let dx = col / 2.0 * avail_w + pad - w / 2.0;
310                let dy = row / 2.0 * avail_h + pad - h / 2.0;
311                (dx, dy)
312            })
313            .collect()
314    }
315
316    pub fn fill_extent(&self) -> (f64, f64) {
317        let mut min = f64::INFINITY;
318        let mut max = f64::NEG_INFINITY;
319        for p in &self.points {
320            if let Some(v) = p.fill {
321                min = min.min(v);
322                max = max.max(v);
323            }
324            for v in p.dot_fills.iter().flatten() {
325                min = min.min(*v);
326                max = max.max(*v);
327            }
328        }
329        if min.is_infinite() {
330            (0.0, 1.0)
331        } else {
332            (min, max)
333        }
334    }
335
336    pub fn size_extent(&self) -> (f64, f64) {
337        let mut min = f64::INFINITY;
338        let mut max = f64::NEG_INFINITY;
339        for p in &self.points {
340            if let Some(v) = p.size {
341                min = min.min(v);
342                max = max.max(v);
343            }
344            for v in p.dot_sizes.iter().flatten() {
345                min = min.min(*v);
346                max = max.max(*v);
347            }
348        }
349        if min.is_infinite() {
350            (0.0, 1.0)
351        } else {
352            (min, max)
353        }
354    }
355}