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);
}
}