1use crate::aes::Aesthetic;
16use crate::data::Value;
17
18use super::Scale;
19
20pub 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
62fn 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#[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 #[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 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 fn train(&mut self, _values: &[Value]) {}
185
186 fn map(&self, _value: &Value) -> f64 {
187 0.0
188 }
189
190 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 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 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 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 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 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 #[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 #[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())]); 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 #[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 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}