Skip to main content

ggplot_rs/scale/
bivariate.rs

1//! Bivariate fill scale: two binned variables → an `n × m` palette
2//! (biscale-style; Stevens 1994's bivariate-choropleth method), backing a
3//! `::BIVARIATE` choropleth. Ported from the design in
4//! anofox-visualization's `docs/plans/plan-maps.md` (§15, D10) — DataZooDE/
5//! anofox-visualization#318, #182.
6//!
7//! The *binning itself* (computing each variable's tertile/class index) is
8//! the caller's job, same as [`super::binned::ScaleFillBinned`]'s break
9//! maths — this scale only colours an already-binned composite class.
10//! Follow the `biscale` R package's own convention: a composite class is a
11//! 1-indexed `"{x_class}-{y_class}"` string (e.g. `"3-2"` = x-tertile 3,
12//! y-tertile 2), the value a `bi_class()`-style data-prep step would put in
13//! a `fill` column.
14
15use crate::aes::Aesthetic;
16use crate::data::Value;
17
18use super::Scale;
19
20/// The colour for class `(i, j)` (0-indexed) of an `nx × ny` grid: each
21/// channel of `base` is pulled toward the matching channel of `color_x` by
22/// `tx = i/(nx-1)` and toward `color_y` by `ty = j/(ny-1)`, subtracted
23/// (not averaged) so the two axes compound rather than cancel —
24/// `base − tx·(base − color_x) − ty·(base − color_y)`, clamped to
25/// `0..=255`. At `(0, 0)` this is exactly `base`; at `(nx-1, 0)` exactly
26/// `color_x`; at `(0, ny-1)` exactly `color_y`; at the high-high corner the
27/// two subtractions compound (usually clamping low), the classic
28/// bivariate-choropleth "dark/saturated corner" look. `base` is the
29/// low-low cell's colour — white for a light theme; pass a theme's card
30/// colour for a "lifted over the card colour" dark variant (plan-maps.md
31/// §15's D10) instead of guessing a theme from inside this crate.
32pub fn bivariate_cell_color(
33    base: (u8, u8, u8),
34    color_x: (u8, u8, u8),
35    color_y: (u8, u8, u8),
36    nx: usize,
37    ny: usize,
38    i: usize,
39    j: usize,
40) -> (u8, u8, u8) {
41    let tx = if nx <= 1 {
42        1.0
43    } else {
44        i as f64 / (nx - 1) as f64
45    };
46    let ty = if ny <= 1 {
47        1.0
48    } else {
49        j as f64 / (ny - 1) as f64
50    };
51    let chan = |b: u8, x: u8, y: u8| -> u8 {
52        let v = b as f64 - tx * (b as f64 - x as f64) - ty * (b as f64 - y as f64);
53        v.clamp(0.0, 255.0).round() as u8
54    };
55    (
56        chan(base.0, color_x.0, color_y.0),
57        chan(base.1, color_x.1, color_y.1),
58        chan(base.2, color_x.2, color_y.2),
59    )
60}
61
62/// Parse a biscale-style `"{x}-{y}"` composite class (1-indexed) into a
63/// 0-indexed `(i, j)`, clamped into `0..nx`/`0..ny`.
64fn parse_class(s: &str, nx: usize, ny: usize) -> Option<(usize, usize)> {
65    let (xs, ys) = s.split_once('-')?;
66    let x: i64 = xs.trim().parse().ok()?;
67    let y: i64 = ys.trim().parse().ok()?;
68    let i = (x - 1).clamp(0, nx as i64 - 1) as usize;
69    let j = (y - 1).clamp(0, ny as i64 - 1) as usize;
70    Some((i, j))
71}
72
73/// Bivariate fill scale: `nx × ny` classes, each class `"{i}-{j}"`
74/// (1-indexed) coloured by [`bivariate_cell_color`]. See the
75/// [module docs](self).
76#[derive(Clone, Debug)]
77pub struct ScaleFillBivariate {
78    aesthetic: Aesthetic,
79    nx: usize,
80    ny: usize,
81    base: (u8, u8, u8),
82    color_x: (u8, u8, u8),
83    color_y: (u8, u8, u8),
84    x_name: String,
85    y_name: String,
86    name: String,
87}
88
89impl ScaleFillBivariate {
90    /// `nx × ny` classes (biscale's usual is 3×3), tinted from `base`
91    /// toward `color_x`/`color_y`. `x_name`/`y_name` are the two legend
92    /// axis captions (the mapped variables' names).
93    #[allow(clippy::too_many_arguments)]
94    pub fn new(
95        aesthetic: Aesthetic,
96        nx: usize,
97        ny: usize,
98        base: (u8, u8, u8),
99        color_x: (u8, u8, u8),
100        color_y: (u8, u8, u8),
101        x_name: impl Into<String>,
102        y_name: impl Into<String>,
103    ) -> Self {
104        ScaleFillBivariate {
105            aesthetic,
106            nx: nx.max(1),
107            ny: ny.max(1),
108            base,
109            color_x,
110            color_y,
111            x_name: x_name.into(),
112            y_name: y_name.into(),
113            name: String::new(),
114        }
115    }
116
117    /// The classic biscale 3×3, white base.
118    pub fn tertile(
119        aesthetic: Aesthetic,
120        color_x: (u8, u8, u8),
121        color_y: (u8, u8, u8),
122        x_name: impl Into<String>,
123        y_name: impl Into<String>,
124    ) -> Self {
125        Self::new(
126            aesthetic,
127            3,
128            3,
129            (255, 255, 255),
130            color_x,
131            color_y,
132            x_name,
133            y_name,
134        )
135    }
136
137    pub fn nx(&self) -> usize {
138        self.nx
139    }
140    pub fn ny(&self) -> usize {
141        self.ny
142    }
143    pub fn base(&self) -> (u8, u8, u8) {
144        self.base
145    }
146    pub fn color_x(&self) -> (u8, u8, u8) {
147        self.color_x
148    }
149    pub fn color_y(&self) -> (u8, u8, u8) {
150        self.color_y
151    }
152    pub fn x_name(&self) -> &str {
153        &self.x_name
154    }
155    pub fn y_name(&self) -> &str {
156        &self.y_name
157    }
158
159    fn cell_color(&self, i: usize, j: usize) -> (u8, u8, u8) {
160        bivariate_cell_color(
161            self.base,
162            self.color_x,
163            self.color_y,
164            self.nx,
165            self.ny,
166            i,
167            j,
168        )
169    }
170
171    fn class_label(&self, i: usize, j: usize) -> String {
172        format!("{}-{}", i + 1, j + 1)
173    }
174}
175
176impl Scale for ScaleFillBivariate {
177    fn aesthetic(&self) -> Aesthetic {
178        self.aesthetic.clone()
179    }
180
181    /// No-op: the domain is the fixed `nx × ny` grid, not derived from
182    /// data (same reasoning as [`super::steps::ScaleColorSteps`]/
183    /// [`super::binned::ScaleFillBinned`]).
184    fn train(&mut self, _values: &[Value]) {}
185
186    fn map(&self, _value: &Value) -> f64 {
187        0.0
188    }
189
190    /// Every `(i, j)` cell, in row-major order — the full grid regardless
191    /// of which classes actually appear in the data, so a legend (a
192    /// dedicated grid, not the standard discrete-swatch list — see
193    /// [`crate::geom::bivariate_legend`]) always shows the complete
194    /// palette.
195    fn breaks(&self) -> Vec<(f64, String)> {
196        let mut out = Vec::with_capacity(self.nx * self.ny);
197        for j in 0..self.ny {
198            for i in 0..self.nx {
199                out.push((0.0, self.class_label(i, j)));
200            }
201        }
202        out
203    }
204
205    fn name(&self) -> &str {
206        &self.name
207    }
208
209    fn set_name(&mut self, name: &str) {
210        self.name = name.to_string();
211    }
212
213    fn is_discrete(&self) -> bool {
214        true
215    }
216
217    fn map_to_color(&self, value: &Value) -> Option<(u8, u8, u8)> {
218        let s = match value {
219            Value::Str(s) => s.as_str(),
220            _ => return None,
221        };
222        let (i, j) = parse_class(s, self.nx, self.ny)?;
223        Some(self.cell_color(i, j))
224    }
225
226    fn clone_box(&self) -> Box<dyn Scale> {
227        Box::new(self.clone())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn low_low_is_the_base_colour() {
237        let s = ScaleFillBivariate::tertile(
238            Aesthetic::Fill,
239            (220, 0, 0),
240            (0, 0, 220),
241            "income",
242            "education",
243        );
244        assert_eq!(
245            s.map_to_color(&Value::Str("1-1".into())),
246            Some((255, 255, 255))
247        );
248    }
249
250    #[test]
251    fn high_x_only_tints_toward_color_x() {
252        let s = ScaleFillBivariate::tertile(Aesthetic::Fill, (220, 0, 0), (0, 0, 220), "x", "y");
253        let c = s.map_to_color(&Value::Str("3-1".into())).unwrap();
254        // Multiply-blended with white (255,255,255) in the y channel is a
255        // no-op, so this should equal plain color_x.
256        assert_eq!(c, (220, 0, 0));
257    }
258
259    #[test]
260    fn high_high_is_darker_than_either_axis_alone() {
261        let s = ScaleFillBivariate::tertile(Aesthetic::Fill, (220, 0, 0), (0, 0, 220), "x", "y");
262        let hh = s.map_to_color(&Value::Str("3-3".into())).unwrap();
263        let hx = s.map_to_color(&Value::Str("3-1".into())).unwrap();
264        let hy = s.map_to_color(&Value::Str("1-3".into())).unwrap();
265        let luma = |(r, g, b): (u8, u8, u8)| 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
266        assert!(luma(hh) < luma(hx), "high-high darker than high-x-only");
267        assert!(luma(hh) < luma(hy), "high-high darker than high-y-only");
268    }
269
270    #[test]
271    fn all_nine_cells_are_distinguishable() {
272        let s =
273            ScaleFillBivariate::tertile(Aesthetic::Fill, (217, 95, 2), (117, 112, 179), "x", "y");
274        let mut colors = Vec::new();
275        for j in 1..=3 {
276            for i in 1..=3 {
277                colors.push(s.map_to_color(&Value::Str(format!("{i}-{j}"))).unwrap());
278            }
279        }
280        for a in 0..colors.len() {
281            for b in (a + 1)..colors.len() {
282                assert_ne!(
283                    colors[a], colors[b],
284                    "cells {a} and {b} collide: {colors:?}"
285                );
286            }
287        }
288    }
289
290    /// sRGB → CIE Lab, for the ΔE (CIE76) check below. Standard D65
291    /// conversion (sRGB → linear → XYZ → Lab).
292    fn srgb_to_lab((r, g, b): (u8, u8, u8)) -> (f64, f64, f64) {
293        let to_linear = |c: u8| {
294            let c = c as f64 / 255.0;
295            if c <= 0.04045 {
296                c / 12.92
297            } else {
298                ((c + 0.055) / 1.055).powf(2.4)
299            }
300        };
301        let (rl, gl, bl) = (to_linear(r), to_linear(g), to_linear(b));
302        // sRGB -> XYZ (D65)
303        let x = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375;
304        let y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750;
305        let z = rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041;
306        // D65 white point
307        let (xn, yn, zn) = (0.95047, 1.0, 1.08883);
308        let f = |t: f64| {
309            if t > (6.0f64 / 29.0).powi(3) {
310                t.cbrt()
311            } else {
312                t / (3.0 * (6.0f64 / 29.0).powi(2)) + 4.0 / 29.0
313            }
314        };
315        let (fx, fy, fz) = (f(x / xn), f(y / yn), f(z / zn));
316        (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
317    }
318
319    /// CIE76 ΔE: Euclidean distance in Lab space.
320    fn delta_e(a: (u8, u8, u8), b: (u8, u8, u8)) -> f64 {
321        let (l1, a1, b1) = srgb_to_lab(a);
322        let (l2, a2, b2) = srgb_to_lab(b);
323        ((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt()
324    }
325
326    /// plan-maps.md §15 D10's own acceptance test: the 9 cells stay
327    /// perceptually distinguishable (CIE76 ΔE above a "just noticeable
328    /// difference" floor) in both a light-theme (white base) and a
329    /// dark-theme ("lifted over the card colour") palette.
330    #[test]
331    fn all_nine_cells_pass_a_cie76_delta_e_floor_in_light_and_dark() {
332        const JND: f64 = 5.0;
333        for base in [(255, 255, 255), (45, 45, 48)] {
334            let s = ScaleFillBivariate::new(
335                Aesthetic::Fill,
336                3,
337                3,
338                base,
339                (217, 95, 2),
340                (117, 112, 179),
341                "x",
342                "y",
343            );
344            let mut colors = Vec::new();
345            for j in 1..=3 {
346                for i in 1..=3 {
347                    colors.push(s.map_to_color(&Value::Str(format!("{i}-{j}"))).unwrap());
348                }
349            }
350            for a in 0..colors.len() {
351                for b in (a + 1)..colors.len() {
352                    let de = delta_e(colors[a], colors[b]);
353                    assert!(
354                        de > JND,
355                        "base {base:?}: cells {a} ({:?}) and {b} ({:?}) too close: ΔE={de:.2}",
356                        colors[a],
357                        colors[b]
358                    );
359                }
360            }
361        }
362    }
363
364    #[test]
365    fn class_label_round_trips() {
366        let s = ScaleFillBivariate::tertile(Aesthetic::Fill, (1, 2, 3), (4, 5, 6), "x", "y");
367        for (_, label) in s.breaks() {
368            assert!(
369                s.map_to_color(&Value::Str(label.clone())).is_some(),
370                "{label}"
371            );
372        }
373        assert_eq!(s.breaks().len(), 9);
374    }
375
376    #[test]
377    fn out_of_range_class_clamps_instead_of_panicking() {
378        let s = ScaleFillBivariate::tertile(Aesthetic::Fill, (1, 2, 3), (4, 5, 6), "x", "y");
379        assert_eq!(
380            s.map_to_color(&Value::Str("9-9".into())),
381            s.map_to_color(&Value::Str("3-3".into()))
382        );
383        assert_eq!(
384            s.map_to_color(&Value::Str("0-0".into())),
385            s.map_to_color(&Value::Str("1-1".into()))
386        );
387    }
388
389    #[test]
390    fn malformed_class_string_returns_none() {
391        let s = ScaleFillBivariate::tertile(Aesthetic::Fill, (1, 2, 3), (4, 5, 6), "x", "y");
392        assert_eq!(s.map_to_color(&Value::Str("nope".into())), None);
393        assert_eq!(s.map_to_color(&Value::Float(1.0)), None);
394    }
395
396    #[test]
397    fn custom_grid_shape_and_base() {
398        let s = ScaleFillBivariate::new(
399            Aesthetic::Fill,
400            4,
401            2,
402            (240, 240, 240),
403            (200, 0, 0),
404            (0, 0, 200),
405            "x",
406            "y",
407        );
408        assert_eq!(s.nx(), 4);
409        assert_eq!(s.ny(), 2);
410        assert_eq!(s.breaks().len(), 8);
411        assert_eq!(
412            s.map_to_color(&Value::Str("1-1".into())),
413            Some((240, 240, 240))
414        );
415    }
416
417    /// The boilerplate `Scale` trait methods: `map`/`name`/`set_name`/
418    /// `is_discrete`/`train`/`clone_box`. These aren't reached by a plot
419    /// that suppresses the standard per-aesthetic legend (the usual way to
420    /// use this scale, since the legend is `GeomBivariateLegend` instead —
421    /// `is_discrete`/`breaks`/`name` are legend-routing hooks, not needed
422    /// just to colour the data), so they need their own direct coverage.
423    #[test]
424    fn boilerplate_scale_trait_methods() {
425        let mut s =
426            ScaleFillBivariate::tertile(Aesthetic::Fill, (200, 0, 0), (0, 0, 200), "x", "y");
427        assert_eq!(s.aesthetic(), Aesthetic::Fill);
428        assert_eq!(s.map(&Value::Str("1-1".into())), 0.0);
429        assert_eq!(s.name(), "");
430        s.set_name("bivariate");
431        assert_eq!(s.name(), "bivariate");
432        assert!(s.is_discrete());
433        s.train(&[Value::Str("1-1".into())]); // no-op; must not panic
434        let cloned = s.clone_box();
435        assert_eq!(cloned.name(), "bivariate");
436        assert_eq!(
437            cloned.map_to_color(&Value::Str("3-3".into())),
438            s.map_to_color(&Value::Str("3-3".into()))
439        );
440    }
441
442    /// `nx == 1` or `ny == 1` (a one-dimensional "bivariate" — degenerate):
443    /// `tx`/`ty` fall back to `1.0` (the single column/row is treated as
444    /// the end of that axis's ramp) rather than dividing by zero.
445    #[test]
446    fn single_row_or_column_grid_does_not_panic() {
447        let s = ScaleFillBivariate::new(
448            Aesthetic::Fill,
449            1,
450            3,
451            (255, 255, 255),
452            (200, 0, 0),
453            (0, 0, 200),
454            "x",
455            "y",
456        );
457        for j in 1..=3 {
458            assert!(s.map_to_color(&Value::Str(format!("1-{j}"))).is_some());
459        }
460        // The single column is always tx=1.0 (end of the x ramp): row 1
461        // (ty=0, no y contribution) is exactly color_x.
462        assert_eq!(s.map_to_color(&Value::Str("1-1".into())), Some((200, 0, 0)));
463
464        let s2 = ScaleFillBivariate::new(
465            Aesthetic::Fill,
466            3,
467            1,
468            (255, 255, 255),
469            (200, 0, 0),
470            (0, 0, 200),
471            "x",
472            "y",
473        );
474        for i in 1..=3 {
475            assert!(s2.map_to_color(&Value::Str(format!("{i}-1"))).is_some());
476        }
477        assert_eq!(
478            s2.map_to_color(&Value::Str("1-1".into())),
479            Some((0, 0, 200))
480        );
481    }
482}