use crate::color::Color;
pub const LUT_LEN: usize = 256;
const fn rgb8(r: u8, g: u8, b: u8) -> Color {
Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ColorMap {
#[default]
Viridis,
Magma,
Turbo,
GrayScale,
RdBu,
Custom { stops: Vec<Color> },
}
const VIRIDIS: &[Color] = &[
rgb8(68, 1, 84),
rgb8(72, 40, 120),
rgb8(62, 74, 137),
rgb8(49, 104, 142),
rgb8(38, 130, 142),
rgb8(53, 183, 121),
rgb8(145, 213, 66),
rgb8(253, 231, 37),
];
const MAGMA: &[Color] = &[
rgb8(0, 0, 4),
rgb8(28, 16, 68),
rgb8(79, 18, 123),
rgb8(129, 37, 129),
rgb8(181, 54, 122),
rgb8(229, 80, 100),
rgb8(251, 143, 97),
rgb8(252, 253, 191),
];
const TURBO: &[Color] = &[
rgb8(48, 18, 59),
rgb8(70, 107, 227),
rgb8(40, 187, 236),
rgb8(60, 231, 160),
rgb8(163, 252, 60),
rgb8(248, 202, 47),
rgb8(238, 108, 26),
rgb8(122, 4, 3),
];
const GRAY_SCALE: &[Color] = &[Color::BLACK, Color::WHITE];
const RD_BU: &[Color] = &[
rgb8(103, 0, 31),
rgb8(178, 24, 43),
rgb8(214, 96, 77),
rgb8(244, 165, 130),
rgb8(247, 247, 247),
rgb8(146, 197, 222),
rgb8(67, 147, 195),
rgb8(5, 48, 97),
];
impl ColorMap {
pub fn stops(&self) -> &[Color] {
match self {
Self::Viridis => VIRIDIS,
Self::Magma => MAGMA,
Self::Turbo => TURBO,
Self::GrayScale => GRAY_SCALE,
Self::RdBu => RD_BU,
Self::Custom { stops } => stops,
}
}
pub fn sample(&self, t: f32) -> Color {
sample(self, t)
}
pub fn lut(&self) -> [Color; LUT_LEN] {
lut(self)
}
}
pub fn sample(map: &ColorMap, t: f32) -> Color {
let stops = map.stops();
match stops.len() {
0 => Color::new(0.0, 0.0, 0.0, 0.0),
1 => stops[0],
n => {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
let scaled = t * (n - 1) as f32;
let index = (scaled.floor() as usize).min(n - 2);
let frac = scaled - index as f32;
if frac <= 0.0 {
stops[index]
} else if frac >= 1.0 {
stops[index + 1]
} else {
stops[index].lerp(stops[index + 1], frac)
}
}
}
}
pub fn lut(map: &ColorMap) -> [Color; LUT_LEN] {
let last = (LUT_LEN - 1) as f32;
core::array::from_fn(|i| sample(map, i as f32 / last))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_named_ramp_has_at_least_two_stops() {
for map in [
ColorMap::Viridis,
ColorMap::Magma,
ColorMap::Turbo,
ColorMap::GrayScale,
ColorMap::RdBu,
] {
assert!(
map.stops().len() >= 2,
"{map:?} cannot define a ramp with {} stop(s)",
map.stops().len()
);
}
}
#[test]
fn endpoints_are_the_first_and_last_stop_exactly() {
for map in [ColorMap::Viridis, ColorMap::RdBu, ColorMap::GrayScale] {
let stops = map.stops();
assert_eq!(map.sample(0.0), stops[0]);
assert_eq!(map.sample(1.0), stops[stops.len() - 1]);
}
}
#[test]
fn out_of_range_and_nan_clamp_instead_of_panicking() {
let map = ColorMap::Viridis;
assert_eq!(map.sample(-5.0), map.sample(0.0));
assert_eq!(map.sample(5.0), map.sample(1.0));
assert_eq!(map.sample(f32::NAN), map.sample(0.0));
assert_eq!(map.sample(f32::INFINITY), map.sample(1.0));
assert_eq!(map.sample(f32::NEG_INFINITY), map.sample(0.0));
}
#[test]
fn grayscale_is_a_straight_ramp_from_black_to_white() {
let map = ColorMap::GrayScale;
let mid = map.sample(0.5);
assert!((mid.r - 0.5).abs() < 1e-6, "midpoint is {mid:?}");
assert!((mid.g - 0.5).abs() < 1e-6);
assert!((mid.b - 0.5).abs() < 1e-6);
assert_eq!(mid.a, 1.0);
}
#[test]
fn a_custom_ramp_with_too_few_stops_draws_nothing() {
let empty = ColorMap::Custom { stops: Vec::new() };
assert_eq!(empty.sample(0.5).a, 0.0, "no stops cannot be a ramp");
let single = ColorMap::Custom {
stops: vec![Color::new(0.2, 0.4, 0.6, 1.0)],
};
assert_eq!(single.sample(0.0), single.sample(1.0));
assert_eq!(single.sample(0.5).r, 0.2);
}
#[test]
fn a_custom_ramp_interpolates_its_stops_evenly() {
let map = ColorMap::Custom {
stops: vec![Color::BLACK, Color::WHITE, Color::BLACK],
};
assert_eq!(map.sample(0.0), Color::BLACK);
assert_eq!(map.sample(1.0), Color::BLACK);
let mid = map.sample(0.5);
assert!(
(mid.r - 1.0).abs() < 1e-6,
"the middle stop is white: {mid:?}"
);
let quarter = map.sample(0.25);
assert!((quarter.r - 0.5).abs() < 1e-6, "quarter is {quarter:?}");
}
#[test]
fn the_lut_is_the_ramp_at_lut_len_steps() {
let map = ColorMap::Magma;
let table = map.lut();
assert_eq!(table.len(), LUT_LEN);
assert_eq!(table[0], map.sample(0.0));
assert_eq!(table[LUT_LEN - 1], map.sample(1.0));
let last = (LUT_LEN - 1) as f32;
for (i, entry) in table.iter().enumerate() {
assert_eq!(*entry, map.sample(i as f32 / last), "entry {i}");
}
}
#[test]
fn ramps_are_monotone_in_luminance_where_they_claim_to_be() {
for map in [ColorMap::Viridis, ColorMap::Magma, ColorMap::GrayScale] {
let luminance = |c: Color| 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
let table = map.lut();
for pair in table.windows(2) {
let (a, b) = (luminance(pair[0]), luminance(pair[1]));
assert!(b + 1e-4 >= a, "{map:?} luminance dips: {a} then {b}");
}
}
}
}