Skip to main content

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 th = Theme { bg: Color32::from_rgb(252, 252, 251), ..Default::default() };
87        let p = series_palette(&th);
88        assert_eq!(p[0], Color32::from_rgb(0x2a, 0x78, 0xd6), "slot 0 = light blue");
89    }
90
91    #[test]
92    fn colours_are_assigned_in_fixed_order_and_cycle() {
93        let th = Theme::default();
94        // Same index → same colour, every call (identity is stable).
95        assert_eq!(series_color(&th, 0), series_color(&th, 0));
96        // Distinct slots → distinct colours.
97        assert_ne!(series_color(&th, 0), series_color(&th, 1));
98        // Cycles at SLOTS.
99        assert_eq!(series_color(&th, 0), series_color(&th, SLOTS));
100        assert_eq!(series_color(&th, 3), series_color(&th, SLOTS + 3));
101    }
102
103    #[test]
104    fn light_and_dark_are_the_same_eight_hues() {
105        assert_eq!(LIGHT.len(), SLOTS);
106        assert_eq!(DARK.len(), SLOTS);
107    }
108}