facett_plotters/palette.rs
1//! **Categorical series palette** — a small, *validated* set of hues assigned to
2//! chart series in a fixed order, never a random per-string hash. Line/bar/scatter
3//! series are identity-coded (which series is which), so this is the **categorical**
4//! job from the dataviz method: a fixed order maximising the minimum adjacent
5//! colour-vision-deficiency separation, with a light-surface and a dark-surface
6//! step of the *same* eight hues (not two unrelated palettes).
7//!
8//! The set below is the dataviz reference categorical palette (worst adjacent CVD
9//! ΔE 24.2 on light, 10.3 on dark — validated as a set, not eyeballed). Which
10//! column is used follows the active [`Theme`]: a dark theme surface gets the dark
11//! steps, a light one the light steps — so the palette re-skins with the theme
12//! without ever baking a colour into a component. Past the eighth series the order
13//! *cycles* (a documented floor; real dashboards fold a 9th series into "Other").
14
15use egui::Color32;
16use facett_core::Theme;
17
18/// The eight categorical hues, **light-surface** steps, in the validated order
19/// (blue · aqua · yellow · green · violet · red · magenta · orange).
20const LIGHT: [(u8, u8, u8); 8] = [
21 (0x2a, 0x78, 0xd6), // blue
22 (0x1b, 0xaf, 0x7a), // aqua
23 (0xed, 0xa1, 0x00), // yellow
24 (0x00, 0x83, 0x00), // green
25 (0x4a, 0x3a, 0xa7), // violet
26 (0xe3, 0x49, 0x48), // red
27 (0xe8, 0x7b, 0xa4), // magenta
28 (0xeb, 0x68, 0x34), // orange
29];
30
31/// The same eight hues stepped for a **dark** surface (each is the dark column of
32/// the validated set — chosen for the dark lightness band, not an auto-flip).
33const DARK: [(u8, u8, u8); 8] = [
34 (0x39, 0x87, 0xe5), // blue
35 (0x19, 0x9e, 0x70), // aqua
36 (0xc9, 0x85, 0x00), // yellow
37 (0x00, 0x83, 0x00), // green
38 (0x90, 0x85, 0xe9), // violet
39 (0xe6, 0x67, 0x67), // red
40 (0xd5, 0x51, 0x81), // magenta
41 (0xd9, 0x59, 0x26), // orange
42];
43
44/// The number of validated categorical slots before the order must cycle.
45pub const SLOTS: usize = 8;
46
47/// True when the theme's chart surface is dark (relative luminance below the mid
48/// point), so the dark-surface steps are the readable set.
49fn surface_is_dark(th: &Theme) -> bool {
50 let c = th.bg;
51 // Rec. 601 luma in 0..=255; < 128 ⇒ dark surface.
52 let luma = 0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32;
53 luma < 128.0
54}
55
56/// The categorical palette for the active theme, in fixed slot order. Dark themes
57/// get [`DARK`], light themes [`LIGHT`] — the same eight hues, stepped per surface.
58pub fn series_palette(th: &Theme) -> [Color32; SLOTS] {
59 let src = if surface_is_dark(th) { &DARK } else { &LIGHT };
60 std::array::from_fn(|i| {
61 let (r, g, b) = src[i];
62 Color32::from_rgb(r, g, b)
63 })
64}
65
66/// The colour for series index `i`, assigned in fixed order and cycling past the
67/// eighth slot. Identity follows the *slot*, never the value — so a series keeps
68/// its colour regardless of how many others are present.
69pub fn series_color(th: &Theme, i: usize) -> Color32 {
70 series_palette(th)[i % SLOTS]
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76
77 #[test]
78 fn palette_picks_dark_steps_for_dark_theme() {
79 // The default theme is a near-black surface → the dark steps.
80 let p = series_palette(&Theme::default());
81 assert_eq!(p[0], Color32::from_rgb(0x39, 0x87, 0xe5), "slot 0 = dark blue");
82 }
83
84 #[test]
85 fn palette_picks_light_steps_for_light_surface() {
86 let mut th = Theme::default();
87 th.bg = Color32::from_rgb(252, 252, 251);
88 let p = series_palette(&th);
89 assert_eq!(p[0], Color32::from_rgb(0x2a, 0x78, 0xd6), "slot 0 = light blue");
90 }
91
92 #[test]
93 fn colours_are_assigned_in_fixed_order_and_cycle() {
94 let th = Theme::default();
95 // Same index → same colour, every call (identity is stable).
96 assert_eq!(series_color(&th, 0), series_color(&th, 0));
97 // Distinct slots → distinct colours.
98 assert_ne!(series_color(&th, 0), series_color(&th, 1));
99 // Cycles at SLOTS.
100 assert_eq!(series_color(&th, 0), series_color(&th, SLOTS));
101 assert_eq!(series_color(&th, 3), series_color(&th, SLOTS + 3));
102 }
103
104 #[test]
105 fn light_and_dark_are_the_same_eight_hues() {
106 assert_eq!(LIGHT.len(), SLOTS);
107 assert_eq!(DARK.len(), SLOTS);
108 }
109}