From 5887ee41581dd4747a08116e63d5c8fbfd1b8e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 18 Jul 2026 17:42:26 +0200 Subject: [PATCH 1/3] categorical color maps --- base/src/color.rs | 41 + src/des/cmap.rs | 80 +- src/des/series.rs | 30 +- src/drawing/axis/bounds.rs | 4 +- src/drawing/cmap.rs | 420 ++++++++-- src/drawing/colorbar.rs | 761 ++++++++++++------ src/drawing/plot.rs | 52 +- src/drawing/series.rs | 45 +- src/sd/cmap.rs | 388 ++++++++- src/sd/series.rs | 4 +- src/style/series.rs | 4 +- tests/figs/colorbar/cats-nocbar.json | 11 + tests/figs/colorbar/cats.json | 12 + tests/figs/colorbar/right-axis-right.json | 18 + tests/refs/colorbar/cats-nocbar.png | Bin 0 -> 11321 bytes tests/refs/colorbar/cats-nocbar.svg | 16 + tests/refs/colorbar/cats.png | Bin 0 -> 21590 bytes tests/refs/colorbar/cats.svg | 27 + .../colorbar/{default.png => lerp-auto.png} | Bin .../colorbar/{default.svg => lerp-auto.svg} | 0 tests/refs/colorbar/right-axis-right.png | Bin 0 -> 30787 bytes tests/refs/colorbar/right-axis-right.svg | 303 +++++++ tests/src/lib.rs | 15 + tests/src/tests/colorbar.rs | 46 +- tests/src/tests/series.rs | 2 +- 25 files changed, 1863 insertions(+), 416 deletions(-) create mode 100644 tests/figs/colorbar/cats-nocbar.json create mode 100644 tests/figs/colorbar/cats.json create mode 100644 tests/figs/colorbar/right-axis-right.json create mode 100644 tests/refs/colorbar/cats-nocbar.png create mode 100644 tests/refs/colorbar/cats-nocbar.svg create mode 100644 tests/refs/colorbar/cats.png create mode 100644 tests/refs/colorbar/cats.svg rename tests/refs/colorbar/{default.png => lerp-auto.png} (100%) rename tests/refs/colorbar/{default.svg => lerp-auto.svg} (100%) create mode 100644 tests/refs/colorbar/right-axis-right.png create mode 100644 tests/refs/colorbar/right-axis-right.svg diff --git a/base/src/color.rs b/base/src/color.rs index a6f9df9d..288d11a9 100644 --- a/base/src/color.rs +++ b/base/src/color.rs @@ -16,6 +16,14 @@ impl Rgba8 { Self(r, g, b, a) } + pub const fn from_u32(rgba: u32) -> Self { + let r = ((rgba >> 24) & 0xff) as u8; + let g = ((rgba >> 16) & 0xff) as u8; + let b = ((rgba >> 8) & 0xff) as u8; + let a = (rgba & 0xff) as u8; + Self(r, g, b, a) + } + /// Get the red component of the color. pub const fn r(&self) -> u8 { self.0 @@ -36,6 +44,20 @@ impl Rgba8 { self.3 } + /// Pack the color into a single u32 value in RGBA order. + pub const fn to_u32(&self) -> u32 { + ((self.r() as u32) << 24) + | ((self.g() as u32) << 16) + | ((self.b() as u32) << 8) + | (self.a() as u32) + } + + /// Pack the color to a single i64 value in RGBA order. + /// This is useful to send colors in data sources for plotting, as i64 is the only integer type supported in data sources. + pub const fn to_rgba_int(&self) -> i64 { + self.to_u32() as i64 + } + /// Get the HTML hex string representation of the color, e.g. `#ff0000` for red. /// If the alpha channel is not 255, the form "rgba(r, g, b, a)" is used instead (with alpha normalized to [0, 1]). pub fn html(&self) -> String { @@ -137,6 +159,14 @@ impl Rgb8 { Self(r, g, b) } + /// Create a Rgb8 from a u32 value in RGBA order, ignoring the alpha channel. + pub const fn from_u32(rgb: u32) -> Self { + let r = ((rgb >> 24) & 0xff) as u8; + let g = ((rgb >> 16) & 0xff) as u8; + let b = ((rgb >> 8) & 0xff) as u8; + Self(r, g, b) + } + /// Get the red component of the color. pub const fn r(&self) -> u8 { self.0 @@ -152,6 +182,17 @@ impl Rgb8 { self.2 } + /// Pack the color into a single u32 value in RGBA order. + pub const fn to_u32(&self) -> u32 { + ((self.r() as u32) << 24) | ((self.g() as u32) << 16) | ((self.b() as u32) << 8) | 0xff + } + + /// Pack the color to a single i64 value in RGBA order. + /// This is useful to send colors in data sources for plotting, as i64 is the only integer type supported in data sources. + pub const fn to_rgba_int(&self) -> i64 { + self.to_u32() as i64 + } + /// Get the HTML hex string representation of the color, e.g. `#ff0000` for red. pub fn html(&self) -> String { format!("#{:02x}{:02x}{:02x}", self.r(), self.g(), self.b()) diff --git a/src/des/cmap.rs b/src/des/cmap.rs index 4cc0e130..8f15fbcf 100644 --- a/src/des/cmap.rs +++ b/src/des/cmap.rs @@ -1,7 +1,45 @@ //! A module for defining color maps that can be used in the design of plots to map scalar values to colors. +use std::collections::HashMap; + use crate::color::Rgb8; use crate::des::axis; +use crate::style; + +/// A generic color map that can be used to map scalar values to colors in a plot. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum ColorMap { + /// A color map that automatically chooses a color map based on the data range and type. + /// When the data is floating point, it will use the viridis perceptual color map, + /// When the data is is string, it will use a categorical color map. + /// When the data is integer, a literal color map will be used, interpreting the integer values as RGBA 32 bit colors. + #[default] + Auto, + /// A color map that interpolates between colors in a specified color space. + Lerp(LerpColorMap), + /// A color map that uses a predefined set of colors for categorical data. + Cat(CatColorMap), + /// A color map that interprets string values as literal colors using `Rgb8::parse` and integer values as RGBA 32 bit colors. + Literal(LiteralColorMap), +} + +impl From for ColorMap { + fn from(cmap: LerpColorMap) -> Self { + ColorMap::Lerp(cmap) + } +} + +impl From for ColorMap { + fn from(cmap: CatColorMap) -> Self { + ColorMap::Cat(cmap) + } +} + +impl From for ColorMap { + fn from(cmap: LiteralColorMap) -> Self { + ColorMap::Literal(cmap) + } +} /// Describes how to interpolate between colors in a color map, either in linear RGB or perceptual color space. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -177,12 +215,25 @@ impl From<(LerpMethod, &[Rgb8])> for LerpColorMap { /// Returns None if the name is not recognized. pub fn from_name(name: &str) -> Option { match name { - "stellar" => Some(stellar()), "viridis" => Some(viridis()), + "stellar" => Some(stellar()), _ => None, } } +/// The famous "viridis" color map from matplotlib +pub fn viridis() -> LerpColorMap { + const STOPS: &[Rgb8] = &[ + Rgb8::from_hex(b"#440154"), + Rgb8::from_hex(b"#3b518a"), + Rgb8::from_hex(b"#208f8c"), + Rgb8::from_hex(b"#5bc862"), + Rgb8::from_hex(b"#fde724"), + ]; + let cmap: LerpColorMap = (LerpMethod::Perceptual, STOPS).into(); + cmap.with_name("viridis") +} + /// A colormap that maps kelvin temperatures to black body color, with a range from 1000K to 15000K. /// Based on the approximation from Tanner Helland: /// https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html @@ -244,15 +295,20 @@ pub fn stellar() -> LerpColorMap { .with_name("stellar") } -/// The famous "viridis" color map from matplotlib -pub fn viridis() -> LerpColorMap { - const STOPS: &[Rgb8] = &[ - Rgb8::from_hex(b"#440154"), - Rgb8::from_hex(b"#3b518a"), - Rgb8::from_hex(b"#208f8c"), - Rgb8::from_hex(b"#5bc862"), - Rgb8::from_hex(b"#fde724"), - ]; - let cmap: LerpColorMap = (LerpMethod::Perceptual, STOPS).into(); - cmap.with_name("viridis") +/// A categorical color map that maps a set of categories to a set of colors. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum CatColorMap { + /// A categorical color map that pick colors based on the category type. + /// Each distinct category will be assigned a distinct color, in the order they are encoutered. + /// The colors are the one from the active series color palette. + #[default] + Auto, + /// A categorical color map that uses a predefined set of colors indexed by string categories + Strings(HashMap), + /// A categorical color map that uses a predefined set of colors indexed by integer categories + Integers(HashMap), } + +/// A colormap that interpret string values as literal colors using `Rgb8::parse` and integer values as RGBA 32 bit colors. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct LiteralColorMap; diff --git a/src/des/series.rs b/src/des/series.rs index 63e1da63..fd72ad5f 100644 --- a/src/des/series.rs +++ b/src/des/series.rs @@ -1,5 +1,6 @@ //! Data series definitions for plots. -use crate::des::{axis, cmap}; +use crate::des::axis; +use crate::des::cmap::ColorMap; #[cfg(feature = "time")] use crate::time; use crate::{data, style}; @@ -7,7 +8,7 @@ use crate::{data, style}; /// A data column, either inline or a reference to a data source. /// /// Data columns can contain either inline data (vectors of values) or references -/// to columns in a data source. This allows for flexible data handling in series. +/// to columns in a data source. This allows flexible data handling in series. #[derive(Debug, Clone, PartialEq)] pub enum DataCol { /// The data is provided inline, directly in the series @@ -328,6 +329,21 @@ impl Line { /// Marker size is interpreted as an area, so the actual size of the marker will be proportional to the square root of the sizes data value /// (e.g. for circle marker: diameter = sqrt(marker size * size column data)). /// The sizes data column must have the same length as the x and y data columns. +/// +/// Optional color data column can be used to specify the color of each marker, for colored scatter plots. +/// Interpretation of the color data depends on the type of data and on the type of the associated [`ColorMap`] field. +/// | data type | [`ColorMap`] variant | color interpretation | +/// |-------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +/// | f64 | [`ColorMap::Auto`] | Mapped to color by the [`cmap::viridis`] color map | +/// | f64 | [`ColorMap::Lerp`] | Mapped to color by the provided color map | +/// | String | [`ColorMap::Auto`] | Each unique string is interpreted as a category and assigned a color in order of series colors | +/// | String | [`ColorMap::Cat`] | Each unique string is interpreted as a category and mapped to color by the provided color map | +/// | String | [`ColorMap::Literal`] | Each string is parsed as a [`Rgba8`](crate::Rgba8) value | +/// | i64 | [`ColorMap::Auto`] | Cast to f64 and mapped to color by the [`cmap::viridis`] color map | +/// | i64 | [`ColorMap::Lerp`] | Cast to f64 and mapped to color by the provided color map | +/// | i64 | [`ColorMap::Literal`] | Interpreted as a color, where the integer is treated as a 32-bit RGBA value (e.g. 0xRRGGBBAA). See [`Rgba8::to_rgba_int`](crate::Rgba8::to_rgba_int). | +/// Other combinations of data type and color map will result in an error when rendering the plot. +/// If the plot's colorbar is set, the colorbar will be automatically configured based on the color data column and color map. #[derive(Debug, Clone, PartialEq)] pub struct Scatter { x_data: DataCol, @@ -338,7 +354,7 @@ pub struct Scatter { y_axis: axis::Ref, marker: style::series::Marker, size_data: Option, - color_data: Option<(DataCol, cmap::LerpColorMap)>, + color_data: Option<(DataCol, ColorMap)>, } impl Scatter { @@ -392,8 +408,8 @@ impl Scatter { } /// Set the color data column and color map, and return self for chaining - pub fn with_color_data(mut self, color_data: DataCol, color_map: cmap::LerpColorMap) -> Self { - self.color_data = Some((color_data, color_map)); + pub fn with_color_data(mut self, color_data: DataCol, cmap: ColorMap) -> Self { + self.color_data = Some((color_data, cmap)); self } @@ -433,8 +449,8 @@ impl Scatter { } /// Get the color data column and color map, if any - pub fn color_data(&self) -> Option<&(DataCol, cmap::LerpColorMap)> { - self.color_data.as_ref() + pub fn color_data(&self) -> Option<(&DataCol, &ColorMap)> { + self.color_data.as_ref().map(|(data, cmap)| (data, cmap)) } } diff --git a/src/drawing/axis/bounds.rs b/src/drawing/axis/bounds.rs index 0cb61ab0..627d9c25 100644 --- a/src/drawing/axis/bounds.rs +++ b/src/drawing/axis/bounds.rs @@ -63,8 +63,8 @@ impl Bounds { } } - pub fn contains(&self, point: data::SampleRef<'_>) -> bool { - match (self, point) { + pub fn contains(&self, sample: data::SampleRef<'_>) -> bool { + match (self, sample) { (Bounds::Num(nb), data::SampleRef::Num(n)) => nb.contains(n), (Bounds::Cat(c), data::SampleRef::Cat(s)) => c.contains(s), #[cfg(feature = "time")] diff --git a/src/drawing/cmap.rs b/src/drawing/cmap.rs index 142a6fc2..28e949d5 100644 --- a/src/drawing/cmap.rs +++ b/src/drawing/cmap.rs @@ -1,26 +1,202 @@ +use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; -use plotive_base::color::SRgb; +use plotive_base::Rgba8; +use plotive_base::color::{Lerp, LinRgb, OkLab, SRgb, Xyz}; -use crate::color::{Lerp, LinRgb, OkLab, Rgb8, Xyz}; -use crate::des; use crate::des::cmap::{LerpColorMap, LerpMethod}; +use crate::des::{self}; +use crate::drawing::scale::CoordMap; +use crate::drawing::{Categories, axis}; +use crate::{Rgb8, data, style}; -/// A trait for mapping scalar values to colors, used for color scales in heatmaps and similar plots. -pub trait ColorMap { - /// Maps a value in the range [0, 1] to an RGBA color. - fn map_color(&self, value: f32) -> Rgb8; +pub trait ColorMapBuild: std::fmt::Debug { + /// Get a unique hash for the color map that is built by this builder. + fn hash(&self, bounds: axis::BoundsRef<'_>) -> u64; + + /// Build a color map that will be used to map data samples to colors. + fn build(&self, bounds: axis::BoundsRef<'_>) -> Result, super::Error>; + + /// Build a color map that will be used to map normalized values from [0, 1] to colors. + /// This is used by the color bar to create the color bar gradient. + fn build_num( + &self, + _bounds: axis::BoundsRef<'_>, + ) -> Option<(Option, Arc)>; + + /// Build a color map that will be used to map category values to colors. + /// This is used by the color bar to create the color bar category map + fn build_cat(&self, _bounds: axis::BoundsRef<'_>) -> Option>; } -/// A trait for types that can be converted to a `ColorMap` implementation at draw time. -pub trait AsColorMap { +/// A color map to map data samples to colors +pub trait ColorMap: std::fmt::Debug { fn hash(&self) -> u64; + /// Map a data sample to a color + fn map_data_to_color(&self, val: data::SampleRef<'_>) -> Option; +} + +/// A color map to map normalized values from [0, 1] to colors +pub trait NumColorMap: std::fmt::Debug { + /// Map a normalized value in [0, 1] to a color + fn map_num_to_color(&self, val: f32) -> Rgb8; +} + +/// A color map to map category values to colors +pub trait CatColorMap: std::fmt::Debug { + /// Map a category value to a color + fn map_cat_to_color(&self, val: &str) -> Option; +} + +impl ColorMapBuild for des::cmap::ColorMap { + fn hash(&self, bounds: axis::BoundsRef<'_>) -> u64 { + match self { + des::cmap::ColorMap::Auto => match bounds { + axis::BoundsRef::Num(..) => auto_num_hash(), + axis::BoundsRef::Cat(..) => auto_cat_hash(), + #[allow(unreachable_patterns)] + _ => unreachable!("unsupported data type for auto color map"), + }, + des::cmap::ColorMap::Lerp(lerp) => hash_lerp_cmap(lerp), + des::cmap::ColorMap::Cat(cat) => hash_cat_cmap(cat), + des::cmap::ColorMap::Literal(..) => literal_hash(), + } + } + + fn build_num( + &self, + bounds: axis::BoundsRef<'_>, + ) -> Option<(Option, Arc)> { + match (self, bounds) { + (des::cmap::ColorMap::Auto, axis::BoundsRef::Num(_)) => { + let cmap = make_lerp_num_color_map(&des::cmap::LerpColorMap::default()); + Some((Some(des::axis::Scale::Auto), cmap)) + } + (des::cmap::ColorMap::Lerp(lerp), axis::BoundsRef::Num(_)) => { + let cmap = make_lerp_num_color_map(lerp); + Some((Some(lerp.scale().clone()), cmap)) + } + _ => None, + } + } + + fn build_cat(&self, bounds: axis::BoundsRef<'_>) -> Option> { + match (self, bounds) { + (des::cmap::ColorMap::Auto, axis::BoundsRef::Cat(categories)) => { + let map = categories_to_color_map(categories); + Some(Arc::new(CatColorMapImpl { + hash: self.hash(bounds), + map, + })) + } + (des::cmap::ColorMap::Cat(cat), axis::BoundsRef::Cat(categories)) => { + let map = match cat { + des::cmap::CatColorMap::Auto => categories_to_color_map(categories), + des::cmap::CatColorMap::Strings(map) => map + .iter() + .map(|(cat_val, color)| (cat_val.clone(), *color)) + .collect(), + des::cmap::CatColorMap::Integers(..) => { + todo!("Integer category color map is not implemented yet") + } + }; + Some(Arc::new(CatColorMapImpl { + hash: self.hash(bounds), + map, + })) + } + _ => None, + } + } + + fn build(&self, bounds: axis::BoundsRef<'_>) -> Result, super::Error> { + match (self, bounds) { + (des::cmap::ColorMap::Auto, axis::BoundsRef::Num(num_bounds)) => { + let hash = auto_num_hash(); + let lerp = des::cmap::LerpColorMap::default(); + Ok(make_lerp_cmap(&lerp, num_bounds, hash)) + } + (des::cmap::ColorMap::Lerp(lerp), axis::BoundsRef::Num(num_bounds)) => { + let hash = hash_lerp_cmap(lerp); + Ok(make_lerp_cmap(lerp, num_bounds, hash)) + } + (des::cmap::ColorMap::Auto, axis::BoundsRef::Cat(categories)) => { + let hash = auto_cat_hash(); + let map = categories_to_color_map(categories); + Ok(Arc::new(CatColorMapImpl { hash, map })) + } + (des::cmap::ColorMap::Cat(cat), axis::BoundsRef::Cat(categories)) => { + let map = match cat { + des::cmap::CatColorMap::Auto => categories_to_color_map(categories), + des::cmap::CatColorMap::Strings(map) => map + .iter() + .map(|(cat_val, color)| (cat_val.clone(), *color)) + .collect(), + des::cmap::CatColorMap::Integers(..) => { + todo!("Integer category color map is not implemented yet") + } + }; + Ok(Arc::new(CatColorMapImpl { + hash: self.hash(bounds), + map, + })) + } + (des::cmap::ColorMap::Literal(..), axis::BoundsRef::Num(num_bounds)) => { + if num_bounds.start() < 0.0 || num_bounds.end() > u32::MAX as f64 { + return Err(super::Error::InconsistentData(format!( + "literal color data outside of the u32 range" + ))); + } + Ok(Arc::new(LiteralColorMapImpl { + hash: literal_hash(), + })) + } + (des::cmap::ColorMap::Literal(..), axis::BoundsRef::Cat(categories)) => { + // we parse everything upfront, so we can check the bounds here and cache the parsed colors in a cat color map + let map = { + let mut map = HashMap::new(); + for cat in categories.iter() { + if let Ok(col) = cat.parse::() { + map.insert(cat.to_string(), style::series::Color::Fixed(col)); + } else { + return Err(super::Error::InconsistentData(format!( + "literal color data is not a valid color: {}", + cat + ))); + } + } + map + }; + Ok(Arc::new(CatColorMapImpl { + hash: literal_hash(), + map, + })) + } + _ => Err(super::Error::InconsistentData(format!( + "Color map type {:?} is not compatible with bounds type {:?}", + self, bounds + ))), + } + } +} + +fn auto_num_hash() -> u64 { + let mut hasher = DefaultHasher::new(); + "auto-num".hash(&mut hasher); + hasher.finish() +} - fn scale(&self) -> &des::axis::Scale; +fn auto_cat_hash() -> u64 { + let mut hasher = DefaultHasher::new(); + "auto-cat".hash(&mut hasher); + hasher.finish() +} - /// Convert this type to a `ColorMap` implementation that can be used for color mapping. - fn as_color_map(&self) -> Arc; +fn literal_hash() -> u64 { + let mut hasher = DefaultHasher::new(); + "literal".hash(&mut hasher); + hasher.finish() } fn hash_range(rng: &des::axis::Range, hasher: &mut DefaultHasher) { @@ -44,56 +220,91 @@ fn hash_range(rng: &des::axis::Range, hasher: &mut DefaultHasher) { } } -impl AsColorMap for LerpColorMap { - /// Get a unique hash for this color map, used to avoid creating - /// multiple color bars for the same color map configuration. - fn hash(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.method().hash(&mut hasher); - self.start().hash(&mut hasher); - self.end().hash(&mut hasher); - for stop in self.stops() { - // reinterpret the f32 position as u32 for hashing - // it is checked that the position can't be invalid or -0.0 - let pos_bits = stop.0.to_bits(); - pos_bits.hash(&mut hasher); - stop.1.hash(&mut hasher); +fn hash_lerp_cmap(lerp: &LerpColorMap) -> u64 { + let mut hasher = DefaultHasher::new(); + lerp.method().hash(&mut hasher); + lerp.start().hash(&mut hasher); + lerp.end().hash(&mut hasher); + for stop in lerp.stops() { + // reinterpret the f32 position as u32 for hashing + // it is checked that the position can't be invalid or -0.0 + let pos_bits = stop.0.to_bits(); + pos_bits.hash(&mut hasher); + stop.1.hash(&mut hasher); + } + match lerp.scale() { + des::axis::Scale::Auto => "auto".hash(&mut hasher), + des::axis::Scale::Linear(rng) => { + "lin".hash(&mut hasher); + hash_range(rng, &mut hasher); } - match self.scale() { - des::axis::Scale::Auto => "auto".hash(&mut hasher), - des::axis::Scale::Linear(rng) => { - "lin".hash(&mut hasher); - hash_range(rng, &mut hasher); - } - des::axis::Scale::Log(log_scale) => { - "log".hash(&mut hasher); - log_scale.base.to_bits().hash(&mut hasher); - hash_range(&log_scale.range, &mut hasher); - } - _ => unreachable!(), + des::axis::Scale::Log(log_scale) => { + "log".hash(&mut hasher); + log_scale.base.to_bits().hash(&mut hasher); + hash_range(&log_scale.range, &mut hasher); } - // TODO: hash the locator - hasher.finish() + _ => unreachable!(), } + hasher.finish() +} + +fn make_lerp_cmap( + lerp: &LerpColorMap, + num_bounds: axis::NumBounds, + hash: u64, +) -> Arc { + let normalizer = super::scale::map_scale_coord_num(lerp.scale(), 1.0, &num_bounds, (0.0, 0.0)); + let valid_bounds = normalizer.axis_bounds().to_bounds(); + + let cmap = make_lerp_num_color_map(lerp); + Arc::new(LerpColorMapImpl { + hash, + valid_bounds, + normalizer, + cmap, + }) +} + +#[derive(Debug, Clone)] +struct LerpColorMapImpl { + hash: u64, + valid_bounds: axis::Bounds, + normalizer: Arc, + cmap: Arc, +} - fn scale(&self) -> &des::axis::Scale { - self.scale() +impl ColorMap for LerpColorMapImpl { + fn hash(&self) -> u64 { + self.hash } - fn as_color_map(&self) -> Arc { - let start = self.start(); - let end = self.end(); - let stops = self.stops().iter().copied(); - match self.method() { - LerpMethod::Nearest => Arc::new(NearestColorMap::new(start, end, stops)), - LerpMethod::SRgb => Arc::new(SRgbColorMap::new(start, end, stops)), - LerpMethod::LinearRgb => Arc::new(LinearColorMap::new(start, end, stops)), - LerpMethod::Perceptual => Arc::new(PerceptualColorMap::new(start, end, stops)), - LerpMethod::Xyz => Arc::new(XyzColorMap::new(start, end, stops)), + fn map_data_to_color(&self, val: data::SampleRef<'_>) -> Option { + if self.valid_bounds.contains(val) { + let norm = self.normalizer.map_coord(val).unwrap().clamp(0.0, 1.0); + let rgb = self.cmap.map_num_to_color(norm); + Some(style::series::Color::Fixed(rgb.opaque())) + } else { + None } } } +fn make_lerp_num_color_map(lerp: &LerpColorMap) -> Arc { + let start = lerp.start(); + let end = lerp.end(); + let stops = lerp.stops().iter().copied(); + + let cmap: Arc = match lerp.method() { + LerpMethod::Nearest => Arc::new(NearestColorMap::new(start, end, stops)), + LerpMethod::SRgb => Arc::new(SRgbColorMap::new(start, end, stops)), + LerpMethod::LinearRgb => Arc::new(LinearColorMap::new(start, end, stops)), + LerpMethod::Perceptual => Arc::new(PerceptualColorMap::new(start, end, stops)), + LerpMethod::Xyz => Arc::new(XyzColorMap::new(start, end, stops)), + }; + cmap +} + +#[derive(Debug, Clone)] pub struct NearestColorMap { start: Rgb8, end: Rgb8, @@ -113,21 +324,21 @@ impl NearestColorMap { } } -impl ColorMap for NearestColorMap { - fn map_color(&self, value: f32) -> Rgb8 { - if value <= 0.0 { +impl NumColorMap for NearestColorMap { + fn map_num_to_color(&self, val: f32) -> Rgb8 { + if val <= 0.0 { self.start - } else if value >= 1.0 { + } else if val >= 1.0 { self.end } else { let mut nearest = self.start; let mut nearest_pos = 0.0; for stop in &self.stops { - if (stop.0 - value).abs() < (nearest_pos - value).abs() { + if (stop.0 - val).abs() < (nearest_pos - val).abs() { nearest = stop.1; nearest_pos = stop.0; } - if stop.0 > value { + if stop.0 > val { break; } } @@ -173,13 +384,13 @@ impl> GenColorMap { } } -impl> ColorMap for GenColorMap { - fn map_color(&self, value: f32) -> Rgb8 { +impl + std::fmt::Debug> NumColorMap for GenColorMap { + fn map_num_to_color(&self, val: f32) -> Rgb8 { let mut start = (0.0, self.start); let mut end = (1.0, self.end); for stop in &self.stops { - if stop.0 <= value { + if stop.0 <= val { start = *stop; } else { end = *stop; @@ -187,10 +398,95 @@ impl> ColorMap for GenColorMap { } } let t = if end.0 != start.0 { - (value - start.0) / (end.0 - start.0) + (val - start.0) / (end.0 - start.0) } else { 0.0 }; start.1.lerp(end.1, t).into() } } + +fn hash_cat_cmap(cat: &des::cmap::CatColorMap) -> u64 { + match cat { + des::cmap::CatColorMap::Auto => auto_cat_hash(), + des::cmap::CatColorMap::Strings(map) => { + let mut hasher = DefaultHasher::new(); + for (cat_val, color) in map.iter() { + cat_val.hash(&mut hasher); + color.hash(&mut hasher); + } + hasher.finish() + } + des::cmap::CatColorMap::Integers(map) => { + let mut hasher = DefaultHasher::new(); + for (cat_val, color) in map.iter() { + cat_val.hash(&mut hasher); + color.hash(&mut hasher); + } + hasher.finish() + } + } +} + +fn categories_to_color_map(categories: &Categories) -> HashMap { + let mut map = HashMap::new(); + for (idx, cat) in categories.iter().enumerate() { + let color = style::series::IndexColor(idx).into(); + map.insert(cat.to_string(), color); + } + map +} + +#[derive(Debug, Clone)] +struct CatColorMapImpl { + hash: u64, + map: HashMap, +} + +impl CatColorMap for CatColorMapImpl { + fn map_cat_to_color(&self, val: &str) -> Option { + self.map.get(val).copied() + } +} + +impl ColorMap for CatColorMapImpl { + fn hash(&self) -> u64 { + self.hash + } + + fn map_data_to_color(&self, val: data::SampleRef<'_>) -> Option { + match val { + data::SampleRef::Cat(cat) => self.map_cat_to_color(cat), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +struct LiteralColorMapImpl { + hash: u64, +} + +impl ColorMap for LiteralColorMapImpl { + fn hash(&self) -> u64 { + self.hash + } + + fn map_data_to_color(&self, val: data::SampleRef<'_>) -> Option { + match val { + data::SampleRef::Num(num) => { + if num < 0.0 || num > u32::MAX as f64 { + return None; + } + let int = num as i64 as u32; + let col = Rgba8::from_u32(int); + Some(style::series::Color::Fixed(col)) + } + data::SampleRef::Cat(cat) => { + let col: Rgba8 = cat.parse().ok()?; + Some(style::series::Color::Fixed(col)) + } + _ => None, + } + } +} diff --git a/src/drawing/colorbar.rs b/src/drawing/colorbar.rs index 0d755917..881dd7e3 100644 --- a/src/drawing/colorbar.rs +++ b/src/drawing/colorbar.rs @@ -1,47 +1,93 @@ -use std::fmt; use std::sync::Arc; -use plotive_base::Rgb8; +use plotive_base::style::ResolveColor; use crate::des::axis::ticks::Locator; -use crate::des::{self, colorbar}; -use crate::drawing::cmap::{AsColorMap, ColorMap}; +use crate::des::colorbar; +use crate::drawing::axis::AsBoundRef; +use crate::drawing::cmap::{CatColorMap, ColorMap, ColorMapBuild, NumColorMap}; use crate::drawing::scale::CoordMap; use crate::drawing::{Ctx, Text, axis, ticks}; use crate::style::{AsStroke, defaults, theme}; -use crate::{Style, data, geom, missing_params, render, text}; +use crate::{Style, data, des, geom, missing_params, render, text}; /// A colorbar entry, used to populate one colorbar #[derive(Clone)] pub struct Entry<'a> { pub data_col: &'a des::DataCol, - pub cmap: &'a dyn AsColorMap, + pub cmap_build: &'a dyn ColorMapBuild, +} + +/// Implement the scale for a colorbar +#[derive(Debug, Clone)] +enum CbarScale { + /// Numerical colorbar scale + Num(NumScale), + /// Category axis + Cat(CatScale), +} + +#[derive(Debug, Clone)] +struct NumScale { + /// Data Bounds + view_bounds: axis::NumBounds, + /// The normalizer to map data values to a [0, 1] range + normalizer: Arc, + /// The color map to map normalized values to colors + cmap: Arc, + /// The ticks and labels for the axis + ticks: Vec<(f64, Text)>, + /// Style for the tick marks and their size + ticks_mark: (theme::Stroke, f32), +} + +#[derive(Debug, Clone)] +struct CatScale { + /// The categories for the axis + categories: Vec<(String, Text)>, + /// The color map to map category values to colors + cmap: Arc, + /// Style for the tick marks and their size + ticks_mark: (theme::Stroke, f32), +} + +#[derive(Debug, Clone)] +pub struct ColorBar { + side: axis::Side, + des: des::ColorBar, + title: Option, + scale: CbarScale, } #[derive(Clone)] pub struct ColorBarBuilder { hash: u64, cmap: Arc, + num_cmap: Option<(Option, Arc)>, + cat_cmap: Option>, data_bounds: axis::Bounds, - scale: des::axis::Scale, locator: Locator, } impl ColorBarBuilder { pub fn new( + cmap_build: &dyn ColorMapBuild, hash: u64, - cmap: Arc, data_bounds: axis::Bounds, - scale: des::axis::Scale, locator: Locator, - ) -> Self { - Self { + ) -> Result { + let bounds = data_bounds.as_bound_ref(); + let cmap = cmap_build.build(bounds)?; + let num_cmap = cmap_build.build_num(bounds); + let cat_cmap = cmap_build.build_cat(bounds); + Ok(Self { hash, cmap, + num_cmap, + cat_cmap, data_bounds, - scale, locator, - } + }) } pub fn hash(&self) -> u64 { @@ -54,50 +100,39 @@ impl ColorBarBuilder { pub fn build( self, - des: Option, + cbar: Option, ctx: &Ctx<'_, D>, - ) -> Result<(ColorScale, Option), super::Error> + ) -> Result<(Arc, Option), super::Error> where D: data::Source + ?Sized, { - let data_bounds = match &self.data_bounds { - axis::Bounds::Num(nb) => nb, - _ => unimplemented!("time and categories colorbar"), - }; - - let cm = super::scale::map_scale_coord_num(&self.scale, 1.0, data_bounds, (0.0, 0.0)); - let view_bounds = cm.axis_bounds().as_num().unwrap(); + let cmap = self.cmap.clone(); - let scale = ColorScale { - hash: self.hash, - view_bounds: view_bounds.into(), - data_to_coord: cm, - coord_to_color: self.cmap.clone(), + let colorbar = if let Some(cbar) = cbar { + Some(self.build_colorbar(cbar, ctx)?) + } else { + None }; - let cbar = des - .map(|des| self.build_colorbar(des, view_bounds, ctx)) - .transpose()?; - Ok((scale, cbar)) + Ok((cmap, colorbar)) } - fn build_colorbar( + pub fn build_colorbar( self, - des: des::ColorBar, - view_bounds: axis::NumBounds, + cbar: des::ColorBar, ctx: &Ctx<'_, D>, ) -> Result where D: data::Source + ?Sized, { - let side = match des.pos() { + let side = match cbar.pos() { colorbar::Pos::Right => axis::Side::Right, colorbar::Pos::Left => axis::Side::Left, colorbar::Pos::Top => axis::Side::Top, colorbar::Pos::Bottom => axis::Side::Bottom, }; - let title = des + let title = cbar .title() .map(|title| { title.to_rich_text( @@ -110,119 +145,130 @@ impl ColorBarBuilder { .map(|rt| Text::from_rich_text(&rt, ctx.fontdb())) .transpose()?; - let align = side.ticks_labels_align(); - let font_props = des.ticks_font().clone(); - let font = super::resolve_line_font(&font_props, Default::default()); - let font_size = font_props - .size - .unwrap_or(defaults::COLORBAR_TICKS_FONT_SIZE); - let color = font_props - .color - .clone() - .flatten() - .unwrap_or(theme::Col::Foreground.into()); - - let formatter = des::axis::ticks::Formatter::Auto; - let ticks = ticks::locate_num(&self.locator, view_bounds, &self.scale)?; - let formatter = - ticks::num_label_formatter(&self.locator, Some(&formatter), view_bounds, &self.scale); - let ticks = ticks - .into_iter() - .filter(|t| view_bounds.contains(*t)) - .map(|t| -> Result<_, super::Error> { - let text = formatter.format_label(t.into()); - let lt = text::LineText::new(text, align, font_size, font.clone(), ctx.fontdb())?; - let text = Text::from_line_text(<, ctx.fontdb(), color)?; - Ok((data::Sample::Num(t), text)) - }) - .collect::, _>>()?; - - let ticks_mark = ( - theme::Stroke { - color: theme::Col::Foreground.into(), - width: 1.0, - pattern: Default::default(), - opacity: None, - }, - 4.0, - ); - - Ok(ColorBar { - hash: self.hash, - side, - des, - view_bounds: view_bounds.into(), - title, - ticks, - ticks_mark, - }) - } -} - -#[derive(Clone)] -pub struct ColorScale { - hash: u64, - view_bounds: axis::Bounds, - data_to_coord: Arc, - coord_to_color: Arc, -} - -impl fmt::Debug for ColorScale { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ColorScale") - .field("hash", &self.hash) - .field("view_bounds", &self.view_bounds) - .finish() - } -} + match self.data_bounds { + axis::Bounds::Num(num_bounds) => { + let (scale, cmap) = self.num_cmap.ok_or(super::Error::InconsistentData( + "Unable to map colors for numerical data".to_string(), + ))?; + let scale = scale.unwrap_or_default(); + let normalizer = crate::drawing::scale::map_scale_coord_num( + &scale, + 1.0, + &num_bounds, + (0.0, 0.0), + ); + let axis::Bounds::Num(view_bounds) = normalizer.axis_bounds().to_bounds() else { + unreachable!("Normalizer should return numerical bounds"); + }; + + let align = side.ticks_labels_align(); + let font_props = cbar.ticks_font().clone(); + let font = super::resolve_line_font(&font_props, Default::default()); + let font_size = font_props + .size + .unwrap_or(defaults::COLORBAR_TICKS_FONT_SIZE); + let color = font_props + .color + .clone() + .flatten() + .unwrap_or(theme::Col::Foreground.into()); + let formatter = des::axis::ticks::Formatter::Auto; + let ticks = ticks::locate_num(&self.locator, view_bounds, &scale)?; + let formatter = + ticks::num_label_formatter(&self.locator, Some(&formatter), num_bounds, &scale); + + let ticks = ticks + .into_iter() + .filter(|t| view_bounds.contains(*t)) + .map(|t| -> Result<_, super::Error> { + let text = formatter.format_label(t.into()); + let lt = text::LineText::new( + text, + align, + font_size, + font.clone(), + ctx.fontdb(), + )?; + let text = Text::from_line_text(<, ctx.fontdb(), color)?; + Ok((t, text)) + }) + .collect::, _>>()?; + + let ticks_mark = ( + theme::Stroke { + color: theme::Col::Foreground.into(), + width: 1.0, + pattern: Default::default(), + opacity: None, + }, + 4.0, + ); + + Ok(ColorBar { + side, + des: cbar, + title, + scale: CbarScale::Num(NumScale { + normalizer, + view_bounds, + cmap, + ticks, + ticks_mark, + }), + }) + } -impl ColorScale { - pub fn hash(&self) -> u64 { - self.hash - } + axis::Bounds::Cat(categories) => { + let categories = categories + .iter() + .map(|s| -> Result<_, super::Error> { + let text = Text::from_line_text( + &text::LineText::new( + s.to_string(), + side.ticks_labels_align(), + defaults::COLORBAR_TICKS_FONT_SIZE, + super::resolve_line_font(&cbar.ticks_font(), Default::default()), + ctx.fontdb(), + )?, + ctx.fontdb(), + theme::Col::Foreground.into(), + )?; + Ok((s.to_string(), text)) + }) + .collect::, _>>()?; + + let cmap = self.cat_cmap.ok_or(super::Error::InconsistentData( + "Unable to map colors for categorical data".to_string(), + ))?; + + let ticks_mark = ( + theme::Stroke { + color: theme::Col::Foreground.into(), + width: 1.0, + pattern: Default::default(), + opacity: None, + }, + 4.0, + ); + + Ok(ColorBar { + side, + des: cbar, + title, + scale: CbarScale::Cat(CatScale { + categories, + cmap, + ticks_mark, + }), + }) + } - /// Map data to a 0..1 range, according to the scale and bounds of this color scale. - /// Return None if the data is out of bounds. - pub fn map_data_to_coord(&self, data: data::SampleRef<'_>) -> Option { - if self.view_bounds.contains(data) { - Some(self.data_to_coord.map_coord(data).unwrap().clamp(0.0, 1.0)) - } else { - None + #[cfg(feature = "time")] + axis::Bounds::Time(..) => { + panic!("Time bounds are not supported for colorbars"); + } } } - - pub fn map_coord_to_color(&self, t: f32) -> Rgb8 { - self.coord_to_color.map_color(t) - } - - pub fn map_data_to_color(&self, data: data::SampleRef<'_>) -> Option { - self.map_data_to_coord(data) - .map(|t| self.map_coord_to_color(t)) - } -} - -#[derive(Clone)] -pub struct ColorBar { - hash: u64, - side: axis::Side, - des: des::ColorBar, - view_bounds: axis::Bounds, - title: Option, - ticks: Vec<(data::Sample, Text)>, - ticks_mark: (theme::Stroke, f32), -} - -impl fmt::Debug for ColorBar { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ColorBar") - .field("hash", &self.hash) - .field("side", &self.side) - .field("des", &self.des) - .field("title", &self.title) - .field("ticks", &self.ticks) - .field("ticks_mark", &self.ticks_mark) - .finish() - } } impl ColorBar { @@ -242,35 +288,49 @@ impl ColorBar { self.des.border() } - pub fn calc_size_across(&self) -> f32 { + fn cbar_calc_size_across(&self, ticks_text: T, tick_mark_size: f32) -> f32 + where + T: Iterator, + { let mut size = self.width(); - - if !self.ticks.is_empty() { - size += self.ticks_mark.1 + missing_params::TICK_LABEL_MARGIN; - match self.side { - axis::Side::Bottom | axis::Side::Top => { - let max_h = self - .ticks - .iter() - .map(|t| t.1.height()) - .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or(0.0); - size += max_h; - } - axis::Side::Left | axis::Side::Right => { - let max_w = self - .ticks - .iter() - .map(|t| t.1.width()) - .max_by(|a, b| a.partial_cmp(b).unwrap()) - .unwrap_or(0.0); - size += max_w; - } + match self.side { + axis::Side::Bottom | axis::Side::Top => { + let max_h = ticks_text + .map(|t| t.height()) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + size += max_h; + } + axis::Side::Left | axis::Side::Right => { + let max_w = ticks_text + .map(|t| t.width()) + .max_by(|a, b| a.partial_cmp(b).unwrap()) + .unwrap_or(0.0); + size += max_w; } } + if size > self.width() { + size += tick_mark_size + missing_params::TICK_LABEL_MARGIN; + } + size + } + + pub fn calc_size_across(&self) -> f32 { + let mut size = match &self.scale { + CbarScale::Num(NumScale { + ticks, ticks_mark, .. + }) => self.cbar_calc_size_across(ticks.iter().map(|(_, t)| t.clone()), ticks_mark.1), + CbarScale::Cat(CatScale { + categories, + ticks_mark, + .. + }) => { + self.cbar_calc_size_across(categories.iter().map(|(_, t)| t.clone()), ticks_mark.1) + } + }; if let Some(title) = self.title.as_ref() { - // vertical axis rotate the title, therefore we take the height in all cases. + // vertical axis rotate the title, therefore we take the text height in all cases. size += title.height() + missing_params::AXIS_TITLE_MARGIN; } size @@ -282,7 +342,6 @@ impl ColorBar { style: &Style, plot_rect: &geom::Rect, plot_box: &geom::Rect, - scale: &ColorScale, ) where S: render::Surface, { @@ -321,7 +380,7 @@ impl ColorBar { axis::Side::Top | axis::Side::Bottom => (bar_rect.left(), 1.0), }; - self.draw_gradient(surface, &bar_rect, scale); + self.fill_inner_surface(surface, style, &bar_rect); if let Some(border) = self.border() { let path = bar_rect.to_path(); @@ -334,84 +393,14 @@ impl ColorBar { surface.draw_path(&rpath); } - let mut pb = geom::PathBuilder::with_capacity(2, 2); - let mut title_shift: f32 = 0.0; - - let mark_len = self.ticks_mark.1; - for (tick_val, tick_text) in &self.ticks { - if !self.view_bounds.contains(tick_val.as_ref()) { - continue; + let title_shift = match &self.scale { + CbarScale::Num(num_scale) => { + self.draw_num_ticks(surface, &bar_rect, num_scale, style, bar_len, start, sign) } - let Some(t) = scale.map_data_to_coord(tick_val.as_ref()) else { - continue; - }; - let tick_pos = start + sign * t * bar_len; - let (tx1, tx2, ty1, ty2) = match self.side { - axis::Side::Right => ( - bar_rect.right(), - bar_rect.right() + mark_len, - tick_pos, - tick_pos, - ), - axis::Side::Left => ( - bar_rect.left(), - bar_rect.left() - mark_len, - tick_pos, - tick_pos, - ), - axis::Side::Top => ( - tick_pos, - tick_pos, - bar_rect.top(), - bar_rect.top() - mark_len, - ), - axis::Side::Bottom => ( - tick_pos, - tick_pos, - bar_rect.bottom(), - bar_rect.bottom() + mark_len, - ), - }; - - pb.move_to(tx1, ty1); - pb.line_to(tx2, ty2); - let path = pb.finish().expect("path should be valid"); - let rpath = render::Path { - path: &path, - fill: None, - stroke: Some(self.ticks_mark.0.as_stroke(style)), - transform: None, - }; - surface.draw_path(&rpath); - pb = path.clear(); - - let (tx, ty, ts) = match self.side { - axis::Side::Right => ( - tx2 + missing_params::TICK_LABEL_MARGIN, - tick_pos, - tick_text.width(), - ), - axis::Side::Left => ( - tx2 - missing_params::TICK_LABEL_MARGIN, - tick_pos, - tick_text.width(), - ), - axis::Side::Top => ( - tick_pos, - ty2 - missing_params::TICK_LABEL_MARGIN, - tick_text.height(), - ), - axis::Side::Bottom => ( - tick_pos, - ty2 + missing_params::TICK_LABEL_MARGIN, - tick_text.height(), - ), - }; - let transform = geom::Transform::from_translate(tx, ty); - tick_text.draw(surface, style, Some(&transform)); - - title_shift = title_shift.max(ts + missing_params::TICK_LABEL_MARGIN + mark_len); - } + CbarScale::Cat(cat_scale) => { + self.draw_cat_ticks(surface, &bar_rect, cat_scale, style, bar_len, start, sign) + } + }; if let Some(title) = self.title.as_ref() { let (tx, ty, rot) = match self.side { @@ -441,7 +430,23 @@ impl ColorBar { } } - fn draw_gradient(&self, surface: &mut S, bar_rect: &geom::Rect, scale: &ColorScale) + fn fill_inner_surface(&self, surface: &mut S, style: &Style, bar_rect: &geom::Rect) + where + S: render::Surface, + { + match &self.scale { + CbarScale::Num(NumScale { cmap, .. }) => { + self.draw_gradient(surface, bar_rect, &**cmap); + } + CbarScale::Cat(CatScale { + cmap, categories, .. + }) => { + self.draw_cat_colors(surface, style, bar_rect, categories, &**cmap); + } + } + } + + fn draw_gradient(&self, surface: &mut S, bar_rect: &geom::Rect, cmap: &dyn NumColorMap) where S: render::Surface, { @@ -456,9 +461,9 @@ impl ColorBar { let num_pts = bar_len.ceil() as usize; if surface.caps().max_gradient_stops < 256 { - self.draw_fake_gradient(surface, bar_len, num_pts, bar_rect, scale); + self.draw_fake_gradient(surface, bar_len, num_pts, bar_rect, cmap); } else { - self.draw_real_gradient(surface, num_pts.min(256), bar_rect, scale); + self.draw_real_gradient(surface, num_pts.min(256), bar_rect, cmap); } } @@ -468,7 +473,7 @@ impl ColorBar { bar_len: f32, num_stops: usize, bar_rect: &geom::Rect, - scale: &ColorScale, + cmap: &dyn NumColorMap, ) where S: render::Surface, { @@ -484,7 +489,7 @@ impl ColorBar { let mut pb = geom::PathBuilder::with_capacity(5, 4); for i in 0..=num_stops { - let color = scale.coord_to_color.map_color(t); + let color = cmap.map_num_to_color(t); let pi = start + i as f32 * pos_shift; let pos2 = if i == num_stops { pi @@ -524,7 +529,7 @@ impl ColorBar { surface: &mut S, num_stops: usize, bar_rect: &geom::Rect, - scale: &ColorScale, + cmap: &dyn NumColorMap, ) where S: render::Surface, { @@ -559,7 +564,7 @@ impl ColorBar { let mut stops = Vec::with_capacity(num_stops); for i in 0..=num_stops { let t = i as f32 / num_stops as f32; - let color = scale.coord_to_color.map_color(t); + let color = cmap.map_num_to_color(t); stops.push((t, color.opaque())); } let gradient = render::Paint::LinearGradient { @@ -575,4 +580,250 @@ impl ColorBar { }; surface.draw_path(&rpath); } + + fn draw_cat_colors( + &self, + surface: &mut S, + style: &Style, + bar_rect: &geom::Rect, + categories: &[(String, Text)], + cmap: &dyn CatColorMap, + ) where + S: render::Surface, + { + let is_vertical = matches!(self.side, axis::Side::Right | axis::Side::Left); + if is_vertical { + let height = bar_rect.height() / categories.len() as f32; + for (i, (category, _)) in categories.iter().enumerate() { + let rc = (style, i); + let Some(color) = cmap.map_cat_to_color(category) else { + continue; + }; + let color = rc.resolve_color(&color); + + let y1 = bar_rect.bottom() - i as f32 * height; + let y2 = y1 - height; + let rect = geom::Rect::from_trbl(y2, bar_rect.right(), y1, bar_rect.left()); + let rpath = render::Path { + path: &rect.to_path(), + fill: Some(color.into()), + stroke: None, + transform: None, + }; + surface.draw_path(&rpath); + } + } else { + let width = bar_rect.width() / categories.len() as f32; + for (i, (category, _)) in categories.iter().enumerate() { + let rc = (style, i); + let Some(color) = cmap.map_cat_to_color(category) else { + continue; + }; + let color = rc.resolve_color(&color); + let x1 = bar_rect.left() + i as f32 * width; + let x2 = x1 + width; + let rect = geom::Rect::from_trbl(bar_rect.top(), x2, bar_rect.bottom(), x1); + let rpath = render::Path { + path: &rect.to_path(), + fill: Some(color.into()), + stroke: None, + transform: None, + }; + surface.draw_path(&rpath); + } + } + } + + fn draw_num_ticks( + &self, + surface: &mut S, + bar_rect: &geom::Rect, + num_scale: &NumScale, + style: &Style, + bar_len: f32, + start: f32, + sign: f32, + ) -> f32 + where + S: render::Surface, + { + let mut pb = geom::PathBuilder::with_capacity(2, 2); + let mut title_shift: f32 = 0.0; + + let mark_len = num_scale.ticks_mark.1; + for (tick_val, tick_text) in &num_scale.ticks { + if !num_scale.view_bounds.contains(*tick_val) { + continue; + } + let Some(t) = num_scale.normalizer.map_coord((*tick_val).into()) else { + continue; + }; + let tick_pos = start + sign * t * bar_len; + let (tx1, tx2, ty1, ty2) = match self.side { + axis::Side::Right => ( + bar_rect.right(), + bar_rect.right() + mark_len, + tick_pos, + tick_pos, + ), + axis::Side::Left => ( + bar_rect.left(), + bar_rect.left() - mark_len, + tick_pos, + tick_pos, + ), + axis::Side::Top => ( + tick_pos, + tick_pos, + bar_rect.top(), + bar_rect.top() - mark_len, + ), + axis::Side::Bottom => ( + tick_pos, + tick_pos, + bar_rect.bottom(), + bar_rect.bottom() + mark_len, + ), + }; + + pb.move_to(tx1, ty1); + pb.line_to(tx2, ty2); + let path = pb.finish().expect("path should be valid"); + let rpath = render::Path { + path: &path, + fill: None, + stroke: Some(num_scale.ticks_mark.0.as_stroke(style)), + transform: None, + }; + surface.draw_path(&rpath); + pb = path.clear(); + + let (tx, ty, ts) = match self.side { + axis::Side::Right => ( + tx2 + missing_params::TICK_LABEL_MARGIN, + tick_pos, + tick_text.width(), + ), + axis::Side::Left => ( + tx2 - missing_params::TICK_LABEL_MARGIN, + tick_pos, + tick_text.width(), + ), + axis::Side::Top => ( + tick_pos, + ty2 - missing_params::TICK_LABEL_MARGIN, + tick_text.height(), + ), + axis::Side::Bottom => ( + tick_pos, + ty2 + missing_params::TICK_LABEL_MARGIN, + tick_text.height(), + ), + }; + let transform = geom::Transform::from_translate(tx, ty); + tick_text.draw(surface, style, Some(&transform)); + + title_shift = title_shift.max(ts + missing_params::TICK_LABEL_MARGIN + mark_len); + } + + title_shift + } + + fn draw_cat_ticks( + &self, + surface: &mut S, + bar_rect: &geom::Rect, + cat_scale: &CatScale, + style: &Style, + bar_len: f32, + start: f32, + sign: f32, + ) -> f32 + where + S: render::Surface, + { + let mut pb = geom::PathBuilder::with_capacity(2, 2); + let mut title_shift: f32 = 0.0; + + let bin_size = bar_len / cat_scale.categories.len() as f32; + let mark_len = cat_scale.ticks_mark.1; + + let ticks_coord = |tick_pos| match self.side { + axis::Side::Right => ( + bar_rect.left(), + bar_rect.right() + mark_len, + tick_pos, + tick_pos, + ), + axis::Side::Left => ( + bar_rect.right(), + bar_rect.left() - mark_len, + tick_pos, + tick_pos, + ), + axis::Side::Top => ( + tick_pos, + tick_pos, + bar_rect.bottom(), + bar_rect.top() - mark_len, + ), + axis::Side::Bottom => ( + tick_pos, + tick_pos, + bar_rect.top(), + bar_rect.bottom() + mark_len, + ), + }; + + for i in 0..=cat_scale.categories.len() { + let tick_pos = start + sign * i as f32 * bin_size; + let (tx1, tx2, ty1, ty2) = ticks_coord(tick_pos); + pb.move_to(tx1, ty1); + pb.line_to(tx2, ty2); + + let path = pb.finish().expect("path should be valid"); + let rpath = render::Path { + path: &path, + fill: None, + stroke: Some(cat_scale.ticks_mark.0.as_stroke(style)), + transform: None, + }; + + surface.draw_path(&rpath); + pb = path.clear(); + } + + for (i, (_, tick_text)) in cat_scale.categories.iter().enumerate() { + let tick_pos = start + sign * (i as f32 + 0.5) * bin_size; + let (_, tx2, _, ty2) = ticks_coord(tick_pos); + + let (tx, ty, ts) = match self.side { + axis::Side::Right => ( + tx2 + missing_params::TICK_LABEL_MARGIN, + tick_pos, + tick_text.width(), + ), + axis::Side::Left => ( + tx2 - missing_params::TICK_LABEL_MARGIN, + tick_pos, + tick_text.width(), + ), + axis::Side::Top => ( + tick_pos, + ty2 - missing_params::TICK_LABEL_MARGIN, + tick_text.height(), + ), + axis::Side::Bottom => ( + tick_pos, + ty2 + missing_params::TICK_LABEL_MARGIN, + tick_text.height(), + ), + }; + let transform = geom::Transform::from_translate(tx, ty); + tick_text.draw(surface, style, Some(&transform)); + title_shift = title_shift.max(ts + missing_params::TICK_LABEL_MARGIN + mark_len); + } + + title_shift + } } diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 6de0c3a3..fcafa659 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -2,13 +2,15 @@ use std::cell::RefCell; use std::collections::HashMap; use std::f32; use std::rc::Rc; +use std::sync::Arc; use crate::des::{PlotIdx, annot, colorbar}; use crate::drawing::annot::Annot; use crate::drawing::axis::{ AsBoundRef, Axis, AxisCacheKey, AxisCacheMap, AxisScale, Bounds, Orientation, Side, }; -use crate::drawing::colorbar::{ColorBar, ColorBarBuilder, ColorScale}; +use crate::drawing::cmap::ColorMap; +use crate::drawing::colorbar::{ColorBar, ColorBarBuilder}; use crate::drawing::legend::{Legend, LegendBuilder}; use crate::drawing::scale::CoordMapXy; use crate::drawing::series::{self, Series, SeriesExt}; @@ -52,7 +54,7 @@ impl Plots { } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub(super) struct Plot { idx: PlotIdx, rect: geom::Rect, @@ -63,10 +65,29 @@ pub(super) struct Plot { border: Option, series: Vec, legend: Option<(geom::Point, Legend)>, - colorbars: Vec<(ColorScale, Option)>, + colorbars: Vec<(Arc, Option)>, annots: Vec, } +impl std::fmt::Debug for Plot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Plot") + .field("idx", &self.idx) + .field("rect", &self.rect) + .field("axes", &self.axes) + .field("fill", &self.fill) + .field("border", &self.border) + .field("series", &self.series) + .field("legend", &self.legend) + .field( + "colorbars", + &self.colorbars.iter().map(|(_, cb)| cb).collect::>(), + ) + .field("annots", &self.annots) + .finish() + } +} + impl Plot { pub(super) fn idx(&self) -> PlotIdx { self.idx @@ -149,12 +170,12 @@ impl Axes { } } -/// Plot itermediate data during setup phase +/// Plot intermediate data during setup phase #[derive(Debug, Clone)] struct PlotData { series: Vec, legend: Option, - colorbars: Vec<(ColorScale, Option)>, + colorbars: Vec<(Arc, Option)>, insets: geom::Padding, } @@ -589,14 +610,13 @@ where fn setup_plot_colorbars( &self, des_plot: &des::Plot, - ) -> Result)>, Error> { + ) -> Result, Option)>, Error> { let des_colorbar = des_plot.colorbar(); let mut builders: Vec = Vec::new(); for_each_series(des_plot, |s| { if let Some(entry) = s.colorbar_entry() { - let scale = entry.cmap.scale(); let col = get_column(entry.data_col, self.data_source())?; let bounds = col .bounds() @@ -606,18 +626,18 @@ where .map(|cb| cb.ticks_locator()) .cloned() .unwrap_or_default(); - let hash = entry.cmap.hash(); + + let hash = entry.cmap_build.hash(bounds.as_bound_ref()); if let Some(cbb) = builders.iter_mut().find(|b| b.hash() == hash) { cbb.unite_bounds(bounds.as_bound_ref())?; } else { builders.push(ColorBarBuilder::new( + entry.cmap_build, hash, - entry.cmap.as_color_map(), bounds, - scale.clone(), locator, - )); + )?); } } Ok(()) @@ -1081,9 +1101,9 @@ impl Plot { y: &*y_cm, }; let cmap_hash = series.cmap_hash(); - let cmap = self.colorbars.iter().find_map(|(s, _)| { - if Some(s.hash()) == cmap_hash { - return Some(s); + let cmap = self.colorbars.iter().find_map(|(cm, _)| { + if Some(cm.hash()) == cmap_hash { + return Some(&**cm); } None }); @@ -1112,9 +1132,9 @@ impl Plot { let plot_box = axes.draw(surface, style, &self.rect); self.draw_border_box(surface, style); - for (cs, cbar) in &self.colorbars { + for (_, cbar) in &self.colorbars { if let Some(cbar) = cbar { - cbar.draw(surface, style, &self.rect, &plot_box, cs); + cbar.draw(surface, style, &self.rect, &plot_box); } } diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 416a343b..062d1ff7 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -1,11 +1,10 @@ use axis::AsBoundRef; -use plotive_base::Rgb8; use plotive_base::geom::PathSegment; +use plotive_base::style::Color; use scale::{CoordMap, CoordMapXy}; use crate::drawing::axis::{Bounds, Orientation}; -use crate::drawing::cmap::AsColorMap; -use crate::drawing::colorbar::ColorScale; +use crate::drawing::cmap::{ColorMap, ColorMapBuild}; use crate::drawing::{ Categories, ColumnExt, Error, F64ColumnExt, axis, colorbar, get_column, legend, marker, plot_to_fig, scale, @@ -43,7 +42,10 @@ impl SeriesExt for des::series::Scatter { fn colorbar_entry(&self) -> Option> { self.color_data() - .map(|(data_col, cmap)| colorbar::Entry { data_col, cmap }) + .map(|(data_col, cmap_build)| colorbar::Entry { + data_col, + cmap_build, + }) } } @@ -286,7 +288,7 @@ impl Series { data_source: &D, rect: &geom::Rect, cm: &CoordMapXy, - cmap: Option<&ColorScale>, + cmap: Option<&dyn ColorMap>, ) -> Result<(), Error> where D: data::Source + ?Sized, @@ -595,7 +597,7 @@ fn calc_xy_line_path( struct MarkerPoint { pos: geom::Point, scale: f32, - color: Option, + color: Option, } impl Default for MarkerPoint { @@ -646,8 +648,9 @@ impl MarkerData { let fill = self.marker.fill.as_ref().map(|f| { let f = f.as_paint(&rc); - if let Some(rgb) = p.color { - f.with_rgb(rgb) + if let Some(col) = p.color { + let rgb = col.resolve(&rc); + f.with_rgb(rgb.rgb()) } else { f } @@ -655,8 +658,9 @@ impl MarkerData { let stroke = self.marker.stroke.as_ref().map(|s| { let s = s.as_stroke(&rc).with_multiplied_width(1.0 / scale); - if let Some(rgb) = p.color { - s.with_rgb(rgb) + if let Some(col) = p.color { + let rgb = col.resolve(&rc); + s.with_rgb(rgb.rgb()) } else { s } @@ -795,11 +799,20 @@ impl Scatter { { let cols = (des.x_data().clone(), des.y_data().clone()); let size_col = des.size_data().cloned(); - let color_data = des.color_data().map(|(col, cmap)| { - let col = col.clone(); - let hash = cmap.hash(); - (col, hash) - }); + let color_data = des + .color_data() + .map(|(col, cmap)| -> Result<_, Error> { + let col = col.clone(); + let col_bounds = get_column(&col, data_source) + .expect("Should be able to get color column") + .bounds() + .ok_or_else(|| { + Error::InconsistentData(format!("Color column {:?} has no bounds", col)) + })?; + let hash = cmap.hash(col_bounds.as_bound_ref()); + Ok((col, hash)) + }) + .transpose()?; let xy_bounds = calc_xy_bounds(data_source, &cols.0, &cols.1)?; let marker_data = MarkerData::new(des.marker().clone()); Ok(Scatter { @@ -818,7 +831,7 @@ impl Scatter { data_source: &D, rect: &geom::Rect, cm: &CoordMapXy, - cmap: Option<&ColorScale>, + cmap: Option<&dyn ColorMap>, ) where D: data::Source + ?Sized, { diff --git a/src/sd/cmap.rs b/src/sd/cmap.rs index f9df825d..5b36ebb0 100644 --- a/src/sd/cmap.rs +++ b/src/sd/cmap.rs @@ -1,11 +1,257 @@ +use std::collections::HashMap; + use plotive_base::Rgb8; use serde::Deserializer; -use serde::de::{Error, SeqAccess}; +use serde::de::{Error, IntoDeserializer, SeqAccess}; use serde::ser::{SerializeMap, SerializeSeq}; -use crate::des; -use crate::des::cmap; -use crate::des::cmap::{LerpColorMap, LerpMethod}; +use crate::des::cmap::{self, CatColorMap, ColorMap, LerpColorMap, LerpMethod}; +use crate::{des, style}; + +impl serde::Serialize for ColorMap { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + ColorMap::Auto => "auto".serialize(serializer), + ColorMap::Lerp(cmap) => cmap.serialize(serializer), + ColorMap::Cat(cmap) => cmap.serialize(serializer), + ColorMap::Literal(_) => "literal".serialize(serializer), + } + } +} + +/// Helper type to deserialize a value that can be either a color or another type. +/// This is the disambiguation used to differentiate between a categorical color map and a lerp color map when deserializing. +#[derive(Debug)] +enum ColorOr { + Color(style::series::Color), + T(T), +} + +impl<'de, T> serde::de::Deserialize<'de> for ColorOr +where + T: serde::de::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ColorOrVisitor { + marker: std::marker::PhantomData, + } + + impl<'de, T> serde::de::Visitor<'de> for ColorOrVisitor + where + T: serde::de::Deserialize<'de>, + { + type Value = ColorOr; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a color or one of the LerpColorMap fields") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if let Ok(color) = value.parse::() { + Ok(ColorOr::Color(color)) + } else { + let t = T::deserialize(value.into_deserializer())?; + Ok(ColorOr::T(t)) + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let t = T::deserialize(serde::de::value::MapAccessDeserializer::new(map))?; + Ok(ColorOr::T(t)) + } + + fn visit_seq(self, seq: A) -> Result + where + A: SeqAccess<'de>, + { + let t = T::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?; + Ok(ColorOr::T(t)) + } + } + + deserializer.deserialize_any(ColorOrVisitor { + marker: std::marker::PhantomData, + }) + } +} + +/// Helper type deserialize either a string or an integer as a key for a categorical color map. +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum CatKey { + String(String), + Integer(i64), +} + +impl<'de> serde::de::Deserialize<'de> for ColorMap { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ColorMapVisitor; + + impl<'de> serde::de::Visitor<'de> for ColorMapVisitor { + type Value = ColorMap; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a ColorMap") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "auto" => Ok(ColorMap::Auto), + "cat" | "categorical" => Ok(cmap::CatColorMap::Auto.into()), + "literal" => Ok(cmap::LiteralColorMap.into()), + value => { + if let Some(cmap) = cmap::from_name(value) { + Ok(cmap.into()) + } else { + Err(E::custom(format!("unknown ColorMap: {}", value))) + } + } + } + } + + fn visit_seq(self, seq: A) -> Result + where + A: SeqAccess<'de>, + { + let stops = StopsVisitor.visit_seq(seq)?; + + Ok( + LerpColorMap::new(LerpMethod::default(), stops.start, stops.end) + .with_stops(stops.stops) + .into(), + ) + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut is_lerp = false; + let mut method: Option = None; + let mut cmap: Option = None; + let mut stops: Option = None; + let mut scale: Option = None; + + let mut is_cats = false; + let mut cats: HashMap = HashMap::new(); + let mut icats: HashMap = HashMap::new(); + + while let Some(key) = map.next_key::()? { + let str_key = match key { + CatKey::String(s) => s, + CatKey::Integer(i) => { + let val = map.next_value()?; + icats.insert(i, val); + is_cats = true; + continue; + } + }; + match str_key.as_str() { + "method" if !is_cats => { + let val: ColorOr = map.next_value()?; + match val { + ColorOr::Color(val) => { + cats.insert("method".to_string(), val); + is_cats = true; + } + ColorOr::T(val) => { + method = Some(val); + is_lerp = true; + } + } + } + "cmap" if !is_cats => { + let val: ColorOr = map.next_value()?; + match val { + ColorOr::Color(val) => { + cats.insert("cmap".to_string(), val); + is_cats = true; + } + ColorOr::T(val) => { + cmap = Some(val); + is_lerp = true; + } + } + } + "stops" => { + let val: ColorOr = map.next_value()?; + match val { + ColorOr::Color(val) => { + cats.insert("stops".to_string(), val); + is_cats = true; + } + ColorOr::T(val) => { + stops = Some(val); + is_lerp = true; + } + } + } + "scale" => { + let val: ColorOr = map.next_value()?; + match val { + ColorOr::Color(val) => { + cats.insert("scale".to_string(), val); + is_cats = true; + } + ColorOr::T(val) => { + scale = Some(val); + is_lerp = true; + } + } + } + _ => { + let val: style::series::Color = map.next_value()?; + cats.insert(str_key, val); + is_cats = true; + } + } + } + + if is_cats { + if is_lerp { + return Err(A::Error::custom( + "Can't mix categorical and lerp color map fields", + )); + } + if !icats.is_empty() && !cats.is_empty() { + return Err(A::Error::custom( + "Can't mix integer and string keys in categorical color map", + )); + } + if !icats.is_empty() { + return Ok(CatColorMap::Integers(icats).into()); + } else { + return Ok(CatColorMap::Strings(cats).into()); + } + } + if is_lerp { + return Ok( + lerp_color_map_from_fields::(method, cmap, stops, scale)?.into(), + ); + } + Err(A::Error::custom("Missing fields for ColorMap")) + } + } + + deserializer.deserialize_any(ColorMapVisitor) + } +} impl serde::Serialize for LerpMethod { fn serialize(&self, serializer: S) -> Result @@ -281,38 +527,118 @@ impl<'de> serde::Deserialize<'de> for LerpColorMap { "method" => method: Option, "cmap" => cmap: Option, "stops" => stops: Option, - "scale" => scale: Option>, + "scale" => scale: Option, ); - let mut cmap = if let Some(cmap) = cmap { - if stops.is_some() { - return Err(A::Error::custom("Can't specify both cmap and stops")); - } - if method.is_some() { - return Err(A::Error::custom("Can't specify both cmap and method")); - } - let Some(cmap) = cmap::from_name(&cmap) else { - return Err(A::Error::custom(format!("Unknown ColorMap name: {}", cmap))); - }; - cmap - } else { - let Some(stops) = stops else { - return Err(A::Error::missing_field("stops")); - }; - let method = method.unwrap_or(LerpMethod::default()); - let mut cmap = LerpColorMap::new(method, stops.start, stops.end); - if !stops.stops.is_empty() { - cmap = cmap.with_stops(stops.stops); - } - cmap - }; + lerp_color_map_from_fields::(method, cmap, stops, scale) + } + } + deserializer.deserialize_any(LerpColorMapVisitor) + } +} - if let Some(scale) = scale { - cmap = cmap.with_scale(scale.unwrap_or_default()); +fn lerp_color_map_from_fields( + method: Option, + cmap: Option, + stops: Option, + scale: Option, +) -> Result +where + E: serde::de::Error, +{ + let mut cmap = if let Some(cmap) = cmap { + if stops.is_some() { + return Err(E::custom("Can't specify both cmap and stops")); + } + if method.is_some() { + return Err(E::custom("Can't specify both cmap and method")); + } + let Some(cmap) = cmap::from_name(&cmap) else { + return Err(E::custom(format!("Unknown ColorMap name: {}", cmap))); + }; + cmap + } else { + let Some(stops) = stops else { + return Err(E::custom("Missing field: stops")); + }; + let method = method.unwrap_or(LerpMethod::default()); + let mut cmap = LerpColorMap::new(method, stops.start, stops.end); + if !stops.stops.is_empty() { + cmap = cmap.with_stops(stops.stops); + } + cmap + }; + + if let Some(scale) = scale { + cmap = cmap.with_scale(scale); + } + Ok(cmap) +} + +impl serde::Serialize for CatColorMap { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + CatColorMap::Auto => "cat".serialize(serializer), + CatColorMap::Strings(cmap) => { + let mut state = serializer.serialize_map(Some(cmap.len() + 1))?; + for (key, value) in cmap { + state.serialize_entry(key, value)?; } - Ok(cmap) + state.end() + } + CatColorMap::Integers(cmap) => { + let mut state = serializer.serialize_map(Some(cmap.len() + 1))?; + for (key, value) in cmap { + state.serialize_entry(&key.to_string(), value)?; + } + state.end() } } - deserializer.deserialize_any(LerpColorMapVisitor) + } +} + +#[cfg(test)] +mod tests { + use crate::des; + use crate::des::cmap::ColorMap; + + #[test] + fn colormap_scale_seq_deserializes_as_linear_scale() { + let input = r##" +{ + "stops": ["#440154", "#fde724"], + "scale": [0.0, 2.0] +} + "##; + + let cmap: ColorMap = serde_json::from_str(input).unwrap(); + let ColorMap::Lerp(cmap) = cmap else { + panic!("expected lerp colormap"); + }; + + assert_eq!( + cmap.scale(), + &des::axis::Scale::Linear(des::axis::Range(Some(0.0), Some(2.0))) + ); + } + + #[test] + fn colormap_scale_null_deserializes_as_default_scale() { + let input = r##" +{ + "stops": ["#440154", "#fde724"], + "scale": null +} + "##; + + let cmap: ColorMap = serde_json::from_str(input).unwrap(); + let ColorMap::Lerp(cmap) = cmap else { + panic!("expected lerp colormap"); + }; + + assert_eq!(cmap.scale(), &des::axis::Scale::default()); } } diff --git a/src/sd/series.rs b/src/sd/series.rs index 892145c6..59045797 100644 --- a/src/sd/series.rs +++ b/src/sd/series.rs @@ -449,7 +449,7 @@ impl serde::Serialize for series::Scatter { if let Some((colors, cmap)) = self.color_data() { state.serialize_entry("colors", colors)?; - if cmap.name() != Some("viridis") { + if cmap != &cmap::ColorMap::default() { state.serialize_entry("cmap", cmap)?; } } @@ -473,7 +473,7 @@ where "marker" => marker: Option, "sizes" => sizes: Option, "colors" => colors: Option, - "cmap" => cmap: Option, + "cmap" => cmap: Option, "name" => name: Option, "xAxis" => x_axis: Option, diff --git a/src/style/series.rs b/src/style/series.rs index bc1b262c..67836431 100644 --- a/src/style/series.rs +++ b/src/style/series.rs @@ -70,7 +70,7 @@ impl Palette { } /// A series color identified by its index in a palette -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct IndexColor(pub usize); /// An error type for parsing an IndexColor from a string @@ -122,7 +122,7 @@ impl std::fmt::Display for IndexColor { pub struct AutoColor; /// A flexible color for data series -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum Color { /// Automatic color from the palette #[default] diff --git a/tests/figs/colorbar/cats-nocbar.json b/tests/figs/colorbar/cats-nocbar.json new file mode 100644 index 00000000..81ee3128 --- /dev/null +++ b/tests/figs/colorbar/cats-nocbar.json @@ -0,0 +1,11 @@ +{ + "size": [400, 300], + "plot": { + "series": { + "type": "scatter", + "x": [1, 2, 3, 4, 5], + "y": [1, 4, 9, 16, 25], + "colors": ["cat1", "cat2", "cat1", "cat3", "cat2"] + } + } +} \ No newline at end of file diff --git a/tests/figs/colorbar/cats.json b/tests/figs/colorbar/cats.json new file mode 100644 index 00000000..3058f83d --- /dev/null +++ b/tests/figs/colorbar/cats.json @@ -0,0 +1,12 @@ +{ + "size": [400, 300], + "plot": { + "series": { + "type": "scatter", + "x": [1, 2, 3, 4, 5], + "y": [1, 4, 9, 16, 25], + "colors": ["cat1", "cat2", "cat1", "cat3", "cat2"] + }, + "colorbar": "auto" + } +} \ No newline at end of file diff --git a/tests/figs/colorbar/right-axis-right.json b/tests/figs/colorbar/right-axis-right.json new file mode 100644 index 00000000..bf6fa053 --- /dev/null +++ b/tests/figs/colorbar/right-axis-right.json @@ -0,0 +1,18 @@ +{ + "size": [400, 300], + "plot": { + "series": { + "type": "scatter", + "x": [1, 2, 3, 4, 5], + "y": [1, 4, 9, 16, 25], + "colors": [1, 2, 3, 4, 5], + "cmap": "viridis" + }, + "colorbar": "right", + "yAxis": { + "title": "Y axis", + "side": "right", + "ticks": "auto" + } + } +} diff --git a/tests/refs/colorbar/cats-nocbar.png b/tests/refs/colorbar/cats-nocbar.png new file mode 100644 index 0000000000000000000000000000000000000000..b41d660f4585a792b1bbdfca11734247e2e6f251 GIT binary patch literal 11321 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 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/refs/colorbar/cats.png b/tests/refs/colorbar/cats.png new file mode 100644 index 0000000000000000000000000000000000000000..38d89ac888297cb0fe99156c104e4bab063d1449 GIT binary patch literal 21590 zcmeI4eNukhws93}?5L%Ej>gwk5kaC(D`i|QGyURqkfbi#$5_NjsfO^8 zt01M8()jdQtQ6uJ>eyEbX=^no30J;~Rv>;128|?qnFtY*a0$sxa_@cToLD~rqf`?vSr^M@DXW=wlz8jHo65&!(NFR@rbd%@?oAydGWA;t9u z7VDKqB)x|y#DcbRz@^je5Up2JL@-9r)f{z`q}(p`NzMPmp}FNZ-&Vo zinp`=S{R+$k#M5z)x_lO9j_)*rg>V!7nRkg2EVAJx9?5M0N3Cb%*rz-PJ(Oj3+&N1 zwHx3cgD>#=zez)X41NiMuPpbBl5CUbZz=VRQti~N`TsCH89EvzBchPtRKjSr*;yUa z8z$|3&KAb8hPf#{vx@m6zjq-&@8zQ}rsJYqjp%xqH1i&2{bO1K?~F00-DpX|ZAr?p zRNV8J%>HY+_AYll#~p+*c1&H=%cXm{nKli!{rl|&OvuUK8(bN&BaOU$E|saaZjm&n ze?BRgc4axUCf1k{8?4P4OTm5^%H1q*-^&?Y!)cN+q>T8BqX>80r@N7?d*8$x%A)80 zR+xN|db6dEF<9N4U0X_-dEjAW`DwAPV?2+hnRsw_Ixp0DIJ8&IxYgo6qA`V|ON31} z*^X6A*vE<`1Cnq0tQe6=OIM$0!}hAlPu2CyWz-d!#{ifGn3avy2j`v-wi+?osC)vA z8JSfQ+3QTGEoVZ`C6&C(yJB_V;ut0l*osFpZ0-& z(3s(>bK&XK(PTBY`+C*-Q?i?xZhcHSGkPE;et^a-)?F{Ip)XY%c?~0%7j`A=h@pjZ)lAr#6J+QJoZU{tF`nxfGE}FmA5Mqk zaHw-0?N&3NvVn%dnCr|V>ozc(H3Vm)*tBmrB6=# z!Un?UoDa@@0B8eizcs}bBJuoc=35gf{Ytq#-oF1)DO1`)VE25JM&|=N$+`aoc~ebq z@vNc<5>6%1M(q1MhTs{W0~)rnl^1H2VH)hJ%^mk2x>KLI&VJOwwDpm^Ndfct*D;UM zFPGFOU{7qwS$WMBYUxfajKiapBZU?-n&R=9O~)`9?Wm4?Z{yUhF@;I|dv*2_QsErG zDg9gJdP}i_Up5SC6*B@vXf|_eeyeEN4-T zb*;+IJ)JKo!>Y!=v&cuz$);n@s=dYRK@}D>*FES?rN+JYVh4EiI>+^d9>M695{Ck9 z-s4;Gx27rBzIJ)JJdu7NvyW?-5Uq9*B1@TY`lXrQSm$gNzm=&M^yml2zf*daLdb^9 zmDlVmGA)&j75eFX@^_?t0(7xI@Nx{i90M=MU*hb99q8&x{)oeC$7YWI0K^%RGfug9 zphU8nwL>Hlmny{J<@D%LqC1#(FiEI7fDVZ&y-P)gzBm3OY(?3M^t>WA_Z?hM59tlZ z1+L>k4iiqN@{Qa1h2V=3&h&Agrj$!-Yin~_TXLrBsV@DD(}}qqiPj4o>OziPghj$L zcw=)BJ1V{R9-i%GUh{f~;bpCA@=Vu?}+3RK|V9rZoOFpMH!v$wyN%E;C0b>&oF9XfE$Arvs z*1oUWCg(z@2g(aSa$=>tF(vnMigiM!3XVTCJUp^oNG$I}1_2cUKje^}PGc?DSZYiW ziAqW&10{x|nDZ#IReJISJiehmRI*e+bqLy^lbGQ#$qJscf`qzYNt{QqLvnghZctz@ zMbO^eY5cu~G5wF_XhgYeIdxUuhRqxQ0&GAYC69QT*NA*6w*gqa>TPwB3$ zGu3u>bzMhdtN^BhOgL3_IW@=-Wb1wB^bHHqb|8e z6PRaMz;4ZLsrXnb0k0tohQ^D0CZs64#B86^4Mije<@DQp*Kt89yk^()qiQ<2?J5MgO5O6af7ugxql z@pr|QWzN!4%M&TB*N}SbjUj=QhjD>TbBDL)oab}_*g5o|!z=W{b@W&2NUAN;nk#k1 z&b{GmGm&|T%p`u#sl@Ituu`umz@O=GYFwY|oli)J6Z-y>1$pm|Y!X^G#W?#bP7Vsp zUIXH5giMy3%>IN4!O}&2`Sgv)X}3^z+ADvgx1)h8B&LeYHEZkF%fy@Oj4#v`R?;@F zn3gj-CU249C8X5C7DdM1kZuJKcWkP6)+;csr$^U2>Lc9q2Ko!gTDvnEV0M=`IFxf< zi_gVqzJb>uxYp%3%saJ*$7mxgUSTCxW+$)Pd(>tsgI=g7*XhqL+F+ly!6qlXVNl0m z1-6QIt6H($culQ!4fgJX7_CU?_UcA6D+MD*1r4FGJ;Cu2+*Z9xqfhU^bzU)VzK*sz z2>Vk^7~MPkT`+aw1vjaW-Q|@PQzo||4L=)4U5>Nn?anJ%NxYO}F3sVBOooZD!8qCw zHBD8gr?wI+>nt7k-o%>WhiV4(hVk3rQ2Dl=sWH}u2Tu~+FQSD5h2Mb# z`FD(4{_Pz;vIYE6LSPU-(l7#wy)!i>pp##%PL9D)|I;AaeJ#sWh2qvi+iSvRY?2>= zbYJf<-qkp4G>+&1f$(eQraccTJCalnp&&IRDkvb0qWQepe&qc4dKYuAfPg&o6Ihx5 zQRIr4JdI6yhUIFt zR@tVtyu`J=gs_Nz(9Opg1Yj;Um*4NO_Qjm9Qs}-{yIa+0X5d}tMDO?z&d!w8Gf#-O zB8Gz4A0i(S{v_P_*wy&6<2NWX|EsS>nt{U^*GPsmDC6z6a$(xpkG!wWRg0J2rcMwH zGE@B_kG_;(I3jQ!A#X_OKco3|QIPH-%Ye^2*#eghLOnn!_>7$kwq)RtK6&sBByoNd zXJ6~I0O2*Avr2zRBQDbfbwf`(b4Vje<2#QC4%x*r`$mz7t%X~Jhe09+`@6X#59kJ~ zbRQrR_!i&r7W$vLH&i;4DibkLzo4KmzhfVu>ky$mvJ>41m<<#Jeju|#*Zu*Sqo;w& zfolh`H{%$D=fONjy*qeq;H+>r2kXF@GGHeGJNYqt+sDhqt5X_SJAt+H>svciHg)z= z`((>Olc@9ccR}hu-ZI|66!V&ZG9?%)F)BXhe0W{w_8d}+FNiMssIuDWJwMo&ci=5j zr|+GNo4Jk-tftWT+S(1ZPa8{kgUfEoetF!o{|R@7+AX{EcD8b<+4Xj4&(r$euc2Bd zU}%ng6V{l(bd-Jj4EK!T%aKqSv2KEfHjh?et+JS*7-k<%eHr;?%7t`Lx%9|*BVAN# z?rA$6gk#Xu%!gh)TvO9e)w$G}y($;$zrpyI zzZ^MWcD$tQDN1eub!16ibGH2;05-F=lGzq2t<9FIt~b39@vxFQ*VWYimp)LKRyF<* z`WHURrOd=-d1I*R2#CYi94l7Srd0BR%qbqW9}ZsiepS_Cy2PJTxJKNi+4S|vv)JzR zIZ-P+jXj~+v+b_$gzj~GVwqq+bHpESY=&;Q>x$UO1=Ua6)Gx0XW9ysym|?k}HACG) zy0S76{SG~Ux;>^~6J|}0q0RImufg?Xn~y@QfGFO|(*jxvXytB883VQwu$6$V1l~b^ zSM2@keKS+hpek{jT3*D5N!lP6JMb*MtUTy;c<+ZFXOG$ITuVPivujYe6MB zwaiN{6IRi&&@m8N5aEtd_grP?i^z>DB=@1V1>O@6?tW9I!gDWGHsmI-BFpn9IbU!b z#CDSZj+!Hsc}+|SS9aANBiaS+*zTV4quXwB>xx>KE`gVtac88XPq-dFX*QL3_QU1EvI$K?xI-NOr-2~uMVj%WlP6U z{S;sb57|*6key2wCpgqw?|`9OxSzb!y90{hK%YqMkUc z-zgQ(X_W%^8syn{_4IT;b~Co&u~2b{!1k)385T+rd5FsV$d2Ys)y620)?hs!DhZ+= zyra*=t(Cni!aYgdbEu`EmJXSN7r=%iK2o=)2<^i+l|%{X`cPwha*nIW+-t?`8s$<@ zGM3>QHq%?{jM;U;+MC0}?|_1-+X(HX3by+|1zKpDWvri86#lSxHNd@V-_P5-xF0UK zT_@?HVCA5Yx-4u%4EUBbo(3B~6pxi_|LJ^z_AGvSZ{m%R=7q4 zf#xEvRQ^GL&JbOck)OzdP~BRlNg+u^tPdwk@gBgyb8Ly&vfH3_vITbTKnoLP0wge_ z2^si5H6~~_n2)9Zg^%bJM>tiGk5Ilm|D5N&=4|3?!iG~B5tPeax7mx3RN-x$8Brykh+0J_}8px@8 z5;M=lOZq1ZKAE7=`8SVA3L0%q_Z^F>bT=B6VW_8=k1+mSpMTv+%FYyeT45s?_evt& zlc9fqg5>>WweibMV^y6~BWp@zocpKl`8FtI&$r|a4R#NQa!UJApErTYYXY*5lhmDw z>X?)#xhk$3)W}-(`t+$g+4#V04&jt`|7eK-a(L?$7I^6fv;zC>-9=&3U2lk zbhc#ufjH2{(akLY-t}taexSaT%(Jk?$@lX)kC1P#}(TLXW6qs v=Z(a< + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/refs/colorbar/default.png b/tests/refs/colorbar/lerp-auto.png similarity index 100% rename from tests/refs/colorbar/default.png rename to tests/refs/colorbar/lerp-auto.png diff --git a/tests/refs/colorbar/default.svg b/tests/refs/colorbar/lerp-auto.svg similarity index 100% rename from tests/refs/colorbar/default.svg rename to tests/refs/colorbar/lerp-auto.svg diff --git a/tests/refs/colorbar/right-axis-right.png b/tests/refs/colorbar/right-axis-right.png new file mode 100644 index 0000000000000000000000000000000000000000..36147e048e45cfdb486b51cf2a0510c6b156fbe2 GIT binary patch literal 30787 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/lib.rs b/tests/src/lib.rs index e5403011..331326db 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -1,5 +1,7 @@ #![cfg(test)] +use std::path::{Path, PathBuf}; + use plotive::Style; mod harness; @@ -8,6 +10,19 @@ mod tests; use harness::{PxlHarness, SvgHarness, TestHarness}; +fn json_figure(path: &str) -> plotive::des::Figure { + let tests_dir = env!("CARGO_MANIFEST_DIR"); + let path: PathBuf = Path::new(tests_dir) + .join("figs") + .join(path) + .with_extension("json") + .try_into() + .unwrap(); + let fig: plotive::des::Figure = + serde_json::from_reader(std::fs::File::open(path).unwrap()).unwrap(); + fig +} + fn bw_theme() -> Style { Style::black_white() } diff --git a/tests/src/tests/colorbar.rs b/tests/src/tests/colorbar.rs index dc36375d..573e98dd 100644 --- a/tests/src/tests/colorbar.rs +++ b/tests/src/tests/colorbar.rs @@ -27,18 +27,18 @@ fn scatter(x: Vec, y: Vec) -> des::series::Scatter { } #[test] -fn colorbar_default() { +fn colorbar_lerp_auto() { let (x, y, col) = columns(); let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), Default::default()) .into(), ]) .with_colorbar(Default::default()); let fig = fig_small(plot); - assert_fig_eq_ref!(&fig, "colorbar/default"); + assert_fig_eq_ref!(&fig, "colorbar/lerp-auto"); } #[test] @@ -50,7 +50,7 @@ fn colorbar_locator() { scatter(x, y) .with_color_data( des::data_inline(col), - cmap::viridis().with_scale((0.0, 1.0).into()), + cmap::viridis().with_scale((0.0, 1.0).into()).into(), ) .into(), ]) @@ -66,7 +66,7 @@ fn colorbar_default_with_axes() { let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), cmap::viridis().into()) .into(), ]) .with_x_axis( @@ -94,7 +94,7 @@ fn colorbar_auto_range() { let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), cmap::viridis().into()) .into(), ]) .with_colorbar(Default::default()); @@ -111,7 +111,7 @@ fn colorbar_cmap_scale() { scatter(x, y) .with_color_data( des::data_inline(col), - cmap::viridis().with_scale((0.0, 2.0).into()), + cmap::viridis().with_scale((0.0, 2.0).into()).into(), ) .into(), ]) @@ -127,7 +127,7 @@ fn colorbar_left() { let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), cmap::viridis().into()) .into(), ]) .with_colorbar(colorbar::Pos::Left.into()); @@ -142,7 +142,7 @@ fn colorbar_top() { let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), cmap::viridis().into()) .into(), ]) .with_colorbar(colorbar::Pos::Top.into()); @@ -157,7 +157,7 @@ fn colorbar_bottom() { let plot = des::Plot::new(vec![ scatter(x, y) - .with_color_data(des::data_inline(col), cmap::viridis()) + .with_color_data(des::data_inline(col), cmap::viridis().into()) .into(), ]) .with_colorbar(colorbar::Pos::Bottom.into()); @@ -165,3 +165,29 @@ fn colorbar_bottom() { assert_fig_eq_ref!(&fig, "colorbar/bottom"); } + +#[test] +fn colorbar_cats() { + let fig = crate::json_figure("colorbar/cats"); + assert_fig_eq_ref!(&fig, "colorbar/cats", &plotive::style::Style::light()); +} + +#[test] +fn colorbar_cats_nocbar() { + let fig = crate::json_figure("colorbar/cats-nocbar"); + assert_fig_eq_ref!( + &fig, + "colorbar/cats-nocbar", + &plotive::style::Style::light() + ); +} + +#[test] +fn colorbar_right_axis_right() { + let fig = crate::json_figure("colorbar/right-axis-right"); + assert_fig_eq_ref!( + &fig, + "colorbar/right-axis-right", + &plotive::style::Style::light() + ); +} diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index b06944b2..00efb99b 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -120,7 +120,7 @@ fn series_scatter_colors() { let plot = des::Plot::new(vec![ des::series::Scatter::new(des::data_inline(x), des::data_inline(y)) - .with_color_data(des::data_inline(colors), cmap::viridis()) + .with_color_data(des::data_inline(colors), cmap::viridis().into()) .with_marker( style::series::Marker::default() .with_fill_opacity(0.6) From 80f290aecffd611f74fd118e2f6290a27b21aeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 25 Jul 2026 23:14:05 +0200 Subject: [PATCH 2/3] fix example fix --- examples/stars.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/stars.rs b/examples/stars.rs index ea0a9c2e..bd1bc7f9 100644 --- a/examples/stars.rs +++ b/examples/stars.rs @@ -79,7 +79,7 @@ fn main() { des::Plot::new(vec![ des::series::Scatter::new("x".into(), "y".into()) .with_size_data("mag_sizes".into()) - .with_color_data("temp".into(), cmap::stellar()) + .with_color_data("temp".into(), cmap::stellar().into()) .with_marker(style::series::Marker::default().with_fill_opacity(0.85)) .into(), ]) From 604057d6c0a02044c0f5200fbfaeda1868db18f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sun, 26 Jul 2026 08:19:42 +0200 Subject: [PATCH 3/3] fix parsing colormap --- src/sd/cmap.rs | 62 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/src/sd/cmap.rs b/src/sd/cmap.rs index 5b36ebb0..7dccc715 100644 --- a/src/sd/cmap.rs +++ b/src/sd/cmap.rs @@ -25,12 +25,13 @@ impl serde::Serialize for ColorMap { /// Helper type to deserialize a value that can be either a color or another type. /// This is the disambiguation used to differentiate between a categorical color map and a lerp color map when deserializing. #[derive(Debug)] -enum ColorOr { +enum ColorNoneT { Color(style::series::Color), + None, T(T), } -impl<'de, T> serde::de::Deserialize<'de> for ColorOr +impl<'de, T> serde::de::Deserialize<'de> for ColorNoneT where T: serde::de::Deserialize<'de>, { @@ -46,20 +47,27 @@ where where T: serde::de::Deserialize<'de>, { - type Value = ColorOr; + type Value = ColorNoneT; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a color or one of the LerpColorMap fields") } + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(ColorNoneT::None) + } + fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { if let Ok(color) = value.parse::() { - Ok(ColorOr::Color(color)) + Ok(ColorNoneT::Color(color)) } else { let t = T::deserialize(value.into_deserializer())?; - Ok(ColorOr::T(t)) + Ok(ColorNoneT::T(t)) } } @@ -68,7 +76,7 @@ where A: serde::de::MapAccess<'de>, { let t = T::deserialize(serde::de::value::MapAccessDeserializer::new(map))?; - Ok(ColorOr::T(t)) + Ok(ColorNoneT::T(t)) } fn visit_seq(self, seq: A) -> Result @@ -76,7 +84,7 @@ where A: SeqAccess<'de>, { let t = T::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?; - Ok(ColorOr::T(t)) + Ok(ColorNoneT::T(t)) } } @@ -164,55 +172,71 @@ impl<'de> serde::de::Deserialize<'de> for ColorMap { }; match str_key.as_str() { "method" if !is_cats => { - let val: ColorOr = map.next_value()?; + let val: ColorNoneT = map.next_value()?; match val { - ColorOr::Color(val) => { + ColorNoneT::Color(val) => { cats.insert("method".to_string(), val); is_cats = true; } - ColorOr::T(val) => { + ColorNoneT::T(val) => { method = Some(val); is_lerp = true; } + ColorNoneT::None => { + method = None; + is_lerp = true; + } } } "cmap" if !is_cats => { - let val: ColorOr = map.next_value()?; + let val: ColorNoneT = map.next_value()?; match val { - ColorOr::Color(val) => { + ColorNoneT::Color(val) => { cats.insert("cmap".to_string(), val); is_cats = true; } - ColorOr::T(val) => { + ColorNoneT::T(val) => { cmap = Some(val); is_lerp = true; } + ColorNoneT::None => { + cmap = None; + is_lerp = true; + } } } "stops" => { - let val: ColorOr = map.next_value()?; + let val: ColorNoneT = map.next_value()?; match val { - ColorOr::Color(val) => { + ColorNoneT::Color(val) => { cats.insert("stops".to_string(), val); is_cats = true; } - ColorOr::T(val) => { + ColorNoneT::T(val) => { stops = Some(val); is_lerp = true; } + ColorNoneT::None => { + stops = None; + is_lerp = true; + } } } "scale" => { - let val: ColorOr = map.next_value()?; + let val: ColorNoneT = map.next_value()?; match val { - ColorOr::Color(val) => { + ColorNoneT::Color(val) => { cats.insert("scale".to_string(), val); is_cats = true; } - ColorOr::T(val) => { + ColorNoneT::T(val) => { scale = Some(val); is_lerp = true; } + ColorNoneT::None => { + scale = None; + is_lerp = true; + } } } _ => {