use crate::error::DotmaxError;
use crate::grid::Color;
#[derive(Debug, Clone)]
pub struct ColorScheme {
name: String,
colors: Vec<Color>,
}
impl ColorScheme {
pub fn new(name: impl Into<String>, colors: Vec<Color>) -> Result<Self, DotmaxError> {
if colors.is_empty() {
return Err(DotmaxError::EmptyColorScheme);
}
Ok(Self {
name: name.into(),
colors,
})
}
#[inline]
#[must_use]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn sample(&self, intensity: f32) -> Color {
let intensity = intensity.clamp(0.0, 1.0);
let n = self.colors.len();
if n == 1 {
return self.colors[0];
}
let scaled = intensity * (n - 1) as f32;
let lower_idx = (scaled.floor() as usize).min(n - 1);
let upper_idx = (lower_idx + 1).min(n - 1);
let frac = scaled.fract();
let c1 = &self.colors[lower_idx];
let c2 = &self.colors[upper_idx];
Color::rgb(
lerp_u8(c1.r, c2.r, frac),
lerp_u8(c1.g, c2.g, frac),
lerp_u8(c1.b, c2.b, frac),
)
}
#[inline]
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[inline]
#[must_use]
pub fn colors(&self) -> &[Color] {
&self.colors
}
pub fn from_colors(name: impl Into<String>, colors: Vec<Color>) -> Result<Self, DotmaxError> {
if colors.len() < 2 {
return Err(DotmaxError::InvalidColorScheme(
"at least 2 colors required".into(),
));
}
Ok(Self {
name: name.into(),
colors,
})
}
#[must_use]
pub fn rainbow() -> Self {
rainbow()
}
#[must_use]
pub fn heat_map() -> Self {
heat_map()
}
#[must_use]
pub fn blue_purple() -> Self {
blue_purple()
}
#[must_use]
pub fn green_yellow() -> Self {
green_yellow()
}
#[must_use]
pub fn cyan_magenta() -> Self {
cyan_magenta()
}
#[must_use]
pub fn grayscale() -> Self {
grayscale()
}
#[must_use]
pub fn monochrome() -> Self {
monochrome()
}
}
#[inline]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
let a_f = f32::from(a);
let b_f = f32::from(b);
(b_f - a_f).mul_add(t, a_f).round() as u8
}
#[inline]
#[allow(
clippy::many_single_char_names,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn hsv_to_rgb(hue: f32, sat: f32, val: f32) -> Color {
let chroma = val * sat;
let h_prime = hue / 60.0;
let secondary = chroma * (1.0 - ((h_prime % 2.0) - 1.0).abs());
let match_val = val - chroma;
let (red, green, blue) = if h_prime < 1.0 {
(chroma, secondary, 0.0)
} else if h_prime < 2.0 {
(secondary, chroma, 0.0)
} else if h_prime < 3.0 {
(0.0, chroma, secondary)
} else if h_prime < 4.0 {
(0.0, secondary, chroma)
} else if h_prime < 5.0 {
(secondary, 0.0, chroma)
} else {
(chroma, 0.0, secondary)
};
Color::rgb(
((red + match_val) * 255.0) as u8,
((green + match_val) * 255.0) as u8,
((blue + match_val) * 255.0) as u8,
)
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn rainbow() -> ColorScheme {
let colors: Vec<Color> = (0..=6)
.map(|i| {
let hue = (i as f32 / 6.0) * 300.0;
hsv_to_rgb(hue, 1.0, 1.0)
})
.collect();
ColorScheme {
name: "rainbow".to_string(),
colors,
}
}
#[must_use]
pub fn heat_map() -> ColorScheme {
let colors = vec![
Color::rgb(0, 0, 0), Color::rgb(255, 0, 0), Color::rgb(255, 165, 0), Color::rgb(255, 255, 0), Color::rgb(255, 255, 255), ];
ColorScheme {
name: "heat_map".to_string(),
colors,
}
}
#[must_use]
pub fn blue_purple() -> ColorScheme {
let colors = vec![
Color::rgb(0, 0, 255), Color::rgb(128, 0, 127), ];
ColorScheme {
name: "blue_purple".to_string(),
colors,
}
}
#[must_use]
pub fn green_yellow() -> ColorScheme {
let colors = vec![
Color::rgb(0, 255, 0), Color::rgb(255, 255, 0), ];
ColorScheme {
name: "green_yellow".to_string(),
colors,
}
}
#[must_use]
pub fn cyan_magenta() -> ColorScheme {
let colors = vec![
Color::rgb(0, 255, 255), Color::rgb(255, 0, 255), ];
ColorScheme {
name: "cyan_magenta".to_string(),
colors,
}
}
#[must_use]
pub fn grayscale() -> ColorScheme {
let colors = vec![
Color::rgb(0, 0, 0), Color::rgb(255, 255, 255), ];
ColorScheme {
name: "grayscale".to_string(),
colors,
}
}
#[must_use]
pub fn monochrome() -> ColorScheme {
ColorScheme {
name: "monochrome".to_string(),
colors: vec![Color::rgb(255, 255, 255)],
}
}
#[must_use]
pub fn list_schemes() -> Vec<String> {
vec![
"rainbow".to_string(),
"heat_map".to_string(),
"blue_purple".to_string(),
"green_yellow".to_string(),
"cyan_magenta".to_string(),
"grayscale".to_string(),
"monochrome".to_string(),
]
}
#[must_use]
pub fn get_scheme(name: &str) -> Option<ColorScheme> {
match name.to_lowercase().as_str() {
"rainbow" => Some(rainbow()),
"heat_map" | "heatmap" => Some(heat_map()),
"blue_purple" | "bluepurple" => Some(blue_purple()),
"green_yellow" | "greenyellow" => Some(green_yellow()),
"cyan_magenta" | "cyanmagenta" => Some(cyan_magenta()),
"grayscale" | "greyscale" => Some(grayscale()),
"monochrome" => Some(monochrome()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_colorscheme_new_valid() {
let scheme = ColorScheme::new("test", vec![Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)]);
assert!(scheme.is_ok());
let scheme = scheme.unwrap();
assert_eq!(scheme.name(), "test");
assert_eq!(scheme.colors().len(), 2);
}
#[test]
fn test_colorscheme_new_single_color() {
let scheme = ColorScheme::new("single", vec![Color::rgb(128, 128, 128)]);
assert!(scheme.is_ok());
assert_eq!(scheme.unwrap().colors().len(), 1);
}
#[test]
fn test_colorscheme_new_empty_returns_error() {
let result = ColorScheme::new("empty", vec![]);
assert!(matches!(result, Err(DotmaxError::EmptyColorScheme)));
}
#[test]
fn test_colorscheme_clone_and_debug() {
let scheme = rainbow();
let cloned = scheme.clone();
assert_eq!(scheme.name(), cloned.name());
let debug_str = format!("{:?}", scheme);
assert!(debug_str.contains("ColorScheme"));
assert!(debug_str.contains("rainbow"));
}
#[test]
fn test_sample_boundary_0() {
let scheme = grayscale();
let color = scheme.sample(0.0);
assert_eq!(color, Color::black());
}
#[test]
fn test_sample_boundary_1() {
let scheme = grayscale();
let color = scheme.sample(1.0);
assert_eq!(color, Color::white());
}
#[test]
fn test_sample_midpoint() {
let scheme = grayscale();
let color = scheme.sample(0.5);
assert!(color.r >= 127 && color.r <= 128);
assert!(color.g >= 127 && color.g <= 128);
assert!(color.b >= 127 && color.b <= 128);
}
#[test]
fn test_sample_clamps_negative() {
let scheme = grayscale();
let color = scheme.sample(-0.5);
assert_eq!(color, Color::black());
}
#[test]
fn test_sample_clamps_above_one() {
let scheme = grayscale();
let color = scheme.sample(1.5);
assert_eq!(color, Color::white());
}
#[test]
fn test_sample_single_color_scheme() {
let scheme = monochrome();
assert_eq!(scheme.sample(0.0), Color::white());
assert_eq!(scheme.sample(0.5), Color::white());
assert_eq!(scheme.sample(1.0), Color::white());
}
#[test]
fn test_rainbow_red_at_0() {
let scheme = rainbow();
let color = scheme.sample(0.0);
assert_eq!(color.r, 255);
assert_eq!(color.g, 0);
assert_eq!(color.b, 0);
}
#[test]
fn test_rainbow_purple_at_1() {
let scheme = rainbow();
let color = scheme.sample(1.0);
assert!(color.r > 200);
assert!(color.b > 200);
}
#[test]
fn test_heat_map_black_at_0() {
let scheme = heat_map();
let color = scheme.sample(0.0);
assert_eq!(color, Color::black());
}
#[test]
fn test_heat_map_white_at_1() {
let scheme = heat_map();
let color = scheme.sample(1.0);
assert_eq!(color, Color::white());
}
#[test]
fn test_blue_purple_endpoints() {
let scheme = blue_purple();
let blue = scheme.sample(0.0);
let purple = scheme.sample(1.0);
assert_eq!(blue, Color::rgb(0, 0, 255));
assert_eq!(purple, Color::rgb(128, 0, 127));
}
#[test]
fn test_green_yellow_endpoints() {
let scheme = green_yellow();
let green = scheme.sample(0.0);
let yellow = scheme.sample(1.0);
assert_eq!(green, Color::rgb(0, 255, 0));
assert_eq!(yellow, Color::rgb(255, 255, 0));
}
#[test]
fn test_cyan_magenta_endpoints() {
let scheme = cyan_magenta();
let cyan = scheme.sample(0.0);
let magenta = scheme.sample(1.0);
assert_eq!(cyan, Color::rgb(0, 255, 255));
assert_eq!(magenta, Color::rgb(255, 0, 255));
}
#[test]
fn test_grayscale_endpoints() {
let scheme = grayscale();
assert_eq!(scheme.sample(0.0), Color::black());
assert_eq!(scheme.sample(1.0), Color::white());
}
#[test]
fn test_list_schemes_returns_7() {
let schemes = list_schemes();
assert_eq!(schemes.len(), 7);
}
#[test]
fn test_list_schemes_contains_all() {
let schemes = list_schemes();
assert!(schemes.contains(&"rainbow".to_string()));
assert!(schemes.contains(&"heat_map".to_string()));
assert!(schemes.contains(&"blue_purple".to_string()));
assert!(schemes.contains(&"green_yellow".to_string()));
assert!(schemes.contains(&"cyan_magenta".to_string()));
assert!(schemes.contains(&"grayscale".to_string()));
assert!(schemes.contains(&"monochrome".to_string()));
}
#[test]
fn test_get_scheme_valid() {
assert!(get_scheme("rainbow").is_some());
assert!(get_scheme("heat_map").is_some());
assert!(get_scheme("blue_purple").is_some());
assert!(get_scheme("green_yellow").is_some());
assert!(get_scheme("cyan_magenta").is_some());
assert!(get_scheme("grayscale").is_some());
assert!(get_scheme("monochrome").is_some());
}
#[test]
fn test_get_scheme_case_insensitive() {
assert!(get_scheme("RAINBOW").is_some());
assert!(get_scheme("Rainbow").is_some());
assert!(get_scheme("rAiNbOw").is_some());
assert!(get_scheme("HEAT_MAP").is_some());
assert!(get_scheme("HeatMap").is_some());
}
#[test]
fn test_get_scheme_alternate_names() {
assert!(get_scheme("heatmap").is_some());
assert!(get_scheme("bluepurple").is_some());
assert!(get_scheme("greenyellow").is_some());
assert!(get_scheme("cyanmagenta").is_some());
assert!(get_scheme("greyscale").is_some()); }
#[test]
fn test_get_scheme_invalid_returns_none() {
assert!(get_scheme("nonexistent").is_none());
assert!(get_scheme("fire").is_none());
assert!(get_scheme("ocean").is_none());
assert!(get_scheme("").is_none());
}
#[test]
fn test_monochrome_always_white() {
let scheme = monochrome();
for i in 0..=100 {
let intensity = i as f32 / 100.0;
let color = scheme.sample(intensity);
assert_eq!(color, Color::white(), "Failed at intensity {}", intensity);
}
}
#[test]
fn test_monochrome_name() {
let scheme = monochrome();
assert_eq!(scheme.name(), "monochrome");
}
#[test]
fn test_hsv_to_rgb_red() {
let color = hsv_to_rgb(0.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(255, 0, 0));
}
#[test]
fn test_hsv_to_rgb_green() {
let color = hsv_to_rgb(120.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(0, 255, 0));
}
#[test]
fn test_hsv_to_rgb_blue() {
let color = hsv_to_rgb(240.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(0, 0, 255));
}
#[test]
fn test_hsv_to_rgb_yellow() {
let color = hsv_to_rgb(60.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(255, 255, 0));
}
#[test]
fn test_hsv_to_rgb_cyan() {
let color = hsv_to_rgb(180.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(0, 255, 255));
}
#[test]
fn test_hsv_to_rgb_magenta() {
let color = hsv_to_rgb(300.0, 1.0, 1.0);
assert_eq!(color, Color::rgb(255, 0, 255));
}
#[test]
fn test_hsv_to_rgb_white() {
let color = hsv_to_rgb(0.0, 0.0, 1.0);
assert_eq!(color, Color::white());
}
#[test]
fn test_hsv_to_rgb_black() {
let color = hsv_to_rgb(0.0, 1.0, 0.0);
assert_eq!(color, Color::black());
}
#[test]
fn test_all_schemes_boundary_coverage() {
let schemes = vec![
rainbow(),
heat_map(),
blue_purple(),
green_yellow(),
cyan_magenta(),
grayscale(),
monochrome(),
];
for scheme in schemes {
let c0 = scheme.sample(0.0);
let c5 = scheme.sample(0.5);
let c1 = scheme.sample(1.0);
let _ = (c0.r, c0.g, c0.b);
let _ = (c5.r, c5.g, c5.b);
let _ = (c1.r, c1.g, c1.b);
}
}
#[test]
fn test_interpolation_smoothness() {
let scheme = grayscale();
let mut prev_r = 0u8;
for i in 0..=100 {
let intensity = i as f32 / 100.0;
let color = scheme.sample(intensity);
assert!(
color.r >= prev_r,
"Non-monotonic at intensity {}",
intensity
);
prev_r = color.r;
}
}
#[test]
fn test_custom_scheme_creation() {
let colors = vec![
Color::rgb(255, 0, 0), Color::rgb(0, 255, 0), Color::rgb(0, 0, 255), ];
let scheme = ColorScheme::new("rgb", colors).unwrap();
assert_eq!(scheme.sample(0.0), Color::rgb(255, 0, 0));
assert_eq!(scheme.sample(1.0), Color::rgb(0, 0, 255));
let mid = scheme.sample(0.25);
assert!(mid.r > 0);
assert!(mid.g > 0);
}
#[test]
fn test_lerp_u8_edge_cases() {
assert_eq!(lerp_u8(0, 255, 0.0), 0);
assert_eq!(lerp_u8(0, 255, 1.0), 255);
assert_eq!(lerp_u8(0, 255, 0.5), 128);
assert_eq!(lerp_u8(255, 0, 0.5), 128);
assert_eq!(lerp_u8(100, 100, 0.5), 100); }
#[test]
fn test_associated_methods() {
assert_eq!(ColorScheme::rainbow().name(), rainbow().name());
assert_eq!(ColorScheme::heat_map().name(), heat_map().name());
assert_eq!(ColorScheme::blue_purple().name(), blue_purple().name());
assert_eq!(ColorScheme::green_yellow().name(), green_yellow().name());
assert_eq!(ColorScheme::cyan_magenta().name(), cyan_magenta().name());
assert_eq!(ColorScheme::grayscale().name(), grayscale().name());
assert_eq!(ColorScheme::monochrome().name(), monochrome().name());
}
#[test]
fn test_from_colors_creates_valid_scheme() {
let colors = vec![Color::black(), Color::white()];
let scheme = ColorScheme::from_colors("test", colors).unwrap();
assert_eq!(scheme.name(), "test");
assert_eq!(scheme.colors().len(), 2);
}
#[test]
fn test_from_colors_with_four_colors() {
let colors = vec![
Color::rgb(0, 0, 0),
Color::rgb(85, 85, 85),
Color::rgb(170, 170, 170),
Color::rgb(255, 255, 255),
];
let scheme = ColorScheme::from_colors("four", colors).unwrap();
assert_eq!(scheme.colors().len(), 4);
}
#[test]
fn test_from_colors_validates_minimum_colors() {
let result = ColorScheme::from_colors("empty", vec![]);
assert!(matches!(result, Err(DotmaxError::InvalidColorScheme(_))));
let result = ColorScheme::from_colors("single", vec![Color::white()]);
assert!(matches!(result, Err(DotmaxError::InvalidColorScheme(_))));
}
#[test]
fn test_from_colors_sample_boundaries() {
let scheme =
ColorScheme::from_colors("gradient", vec![Color::black(), Color::white()]).unwrap();
let black = scheme.sample(0.0);
let white = scheme.sample(1.0);
assert_eq!(black, Color::black());
assert_eq!(white, Color::white());
}
#[test]
fn test_from_colors_sample_midpoint() {
let scheme =
ColorScheme::from_colors("gradient", vec![Color::black(), Color::white()]).unwrap();
let mid = scheme.sample(0.5);
assert!(mid.r >= 127 && mid.r <= 128);
}
#[test]
fn test_from_colors_evenly_spaced_four_colors() {
let scheme = ColorScheme::from_colors(
"four",
vec![
Color::rgb(0, 0, 0), Color::rgb(85, 0, 0), Color::rgb(170, 0, 0), Color::rgb(255, 0, 0), ],
)
.unwrap();
let c0 = scheme.sample(0.0);
let c1 = scheme.sample(1.0);
assert_eq!(c0.r, 0);
assert_eq!(c1.r, 255);
let mid = scheme.sample(0.5);
assert!(mid.r > 85 && mid.r < 170);
}
}