Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-plotters/src/palette.rs

//! **Categorical series palette** — a small, *validated* set of hues assigned to
//! chart series in a fixed order, never a random per-string hash. Line/bar/scatter
//! series are identity-coded (which series is which), so this is the **categorical**
//! job from the dataviz method: a fixed order maximising the minimum adjacent
//! colour-vision-deficiency separation, with a light-surface and a dark-surface
//! step of the *same* eight hues (not two unrelated palettes).
//!
//! The set below is the dataviz reference categorical palette (worst adjacent CVD
//! ΔE 24.2 on light, 10.3 on dark — validated as a set, not eyeballed). Which
//! column is used follows the active [`Theme`]: a dark theme surface gets the dark
//! steps, a light one the light steps — so the palette re-skins with the theme
//! without ever baking a colour into a component. Past the eighth series the order
//! *cycles* (a documented floor; real dashboards fold a 9th series into "Other").

use egui::Color32;
use facett_core::Theme;

/// The eight categorical hues, **light-surface** steps, in the validated order
/// (blue · aqua · yellow · green · violet · red · magenta · orange).
const LIGHT: [(u8, u8, u8); 8] = [
    (0x2a, 0x78, 0xd6), // blue
    (0x1b, 0xaf, 0x7a), // aqua
    (0xed, 0xa1, 0x00), // yellow
    (0x00, 0x83, 0x00), // green
    (0x4a, 0x3a, 0xa7), // violet
    (0xe3, 0x49, 0x48), // red
    (0xe8, 0x7b, 0xa4), // magenta
    (0xeb, 0x68, 0x34), // orange
];

/// The same eight hues stepped for a **dark** surface (each is the dark column of
/// the validated set — chosen for the dark lightness band, not an auto-flip).
const DARK: [(u8, u8, u8); 8] = [
    (0x39, 0x87, 0xe5), // blue
    (0x19, 0x9e, 0x70), // aqua
    (0xc9, 0x85, 0x00), // yellow
    (0x00, 0x83, 0x00), // green
    (0x90, 0x85, 0xe9), // violet
    (0xe6, 0x67, 0x67), // red
    (0xd5, 0x51, 0x81), // magenta
    (0xd9, 0x59, 0x26), // orange
];

/// The number of validated categorical slots before the order must cycle.
pub const SLOTS: usize = 8;

/// True when the theme's chart surface is dark (relative luminance below the mid
/// point), so the dark-surface steps are the readable set.
fn surface_is_dark(th: &Theme) -> bool {
    let c = th.bg;
    // Rec. 601 luma in 0..=255; < 128 ⇒ dark surface.
    let luma = 0.299 * c.r() as f32 + 0.587 * c.g() as f32 + 0.114 * c.b() as f32;
    luma < 128.0
}

/// The categorical palette for the active theme, in fixed slot order. Dark themes
/// get [`DARK`], light themes [`LIGHT`] — the same eight hues, stepped per surface.
pub fn series_palette(th: &Theme) -> [Color32; SLOTS] {
    let src = if surface_is_dark(th) { &DARK } else { &LIGHT };
    std::array::from_fn(|i| {
        let (r, g, b) = src[i];
        Color32::from_rgb(r, g, b)
    })
}

/// The colour for series index `i`, assigned in fixed order and cycling past the
/// eighth slot. Identity follows the *slot*, never the value — so a series keeps
/// its colour regardless of how many others are present.
pub fn series_color(th: &Theme, i: usize) -> Color32 {
    series_palette(th)[i % SLOTS]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn palette_picks_dark_steps_for_dark_theme() {
        // The default theme is a near-black surface → the dark steps.
        let p = series_palette(&Theme::default());
        assert_eq!(p[0], Color32::from_rgb(0x39, 0x87, 0xe5), "slot 0 = dark blue");
    }

    #[test]
    fn palette_picks_light_steps_for_light_surface() {
        let th = Theme { bg: Color32::from_rgb(252, 252, 251), ..Default::default() };
        let p = series_palette(&th);
        assert_eq!(p[0], Color32::from_rgb(0x2a, 0x78, 0xd6), "slot 0 = light blue");
    }

    #[test]
    fn colours_are_assigned_in_fixed_order_and_cycle() {
        let th = Theme::default();
        // Same index → same colour, every call (identity is stable).
        assert_eq!(series_color(&th, 0), series_color(&th, 0));
        // Distinct slots → distinct colours.
        assert_ne!(series_color(&th, 0), series_color(&th, 1));
        // Cycles at SLOTS.
        assert_eq!(series_color(&th, 0), series_color(&th, SLOTS));
        assert_eq!(series_color(&th, 3), series_color(&th, SLOTS + 3));
    }

    #[test]
    fn light_and_dark_are_the_same_eight_hues() {
        assert_eq!(LIGHT.len(), SLOTS);
        assert_eq!(DARK.len(), SLOTS);
    }
}