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/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(), ]) 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..7dccc715 100644 --- a/src/sd/cmap.rs +++ b/src/sd/cmap.rs @@ -1,11 +1,281 @@ +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 ColorNoneT { + Color(style::series::Color), + None, + T(T), +} + +impl<'de, T> serde::de::Deserialize<'de> for ColorNoneT +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 = 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(ColorNoneT::Color(color)) + } else { + let t = T::deserialize(value.into_deserializer())?; + Ok(ColorNoneT::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(ColorNoneT::T(t)) + } + + fn visit_seq(self, seq: A) -> Result + where + A: SeqAccess<'de>, + { + let t = T::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?; + Ok(ColorNoneT::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: ColorNoneT = map.next_value()?; + match val { + ColorNoneT::Color(val) => { + cats.insert("method".to_string(), val); + is_cats = true; + } + ColorNoneT::T(val) => { + method = Some(val); + is_lerp = true; + } + ColorNoneT::None => { + method = None; + is_lerp = true; + } + } + } + "cmap" if !is_cats => { + let val: ColorNoneT = map.next_value()?; + match val { + ColorNoneT::Color(val) => { + cats.insert("cmap".to_string(), val); + is_cats = true; + } + ColorNoneT::T(val) => { + cmap = Some(val); + is_lerp = true; + } + ColorNoneT::None => { + cmap = None; + is_lerp = true; + } + } + } + "stops" => { + let val: ColorNoneT = map.next_value()?; + match val { + ColorNoneT::Color(val) => { + cats.insert("stops".to_string(), val); + is_cats = true; + } + ColorNoneT::T(val) => { + stops = Some(val); + is_lerp = true; + } + ColorNoneT::None => { + stops = None; + is_lerp = true; + } + } + } + "scale" => { + let val: ColorNoneT = map.next_value()?; + match val { + ColorNoneT::Color(val) => { + cats.insert("scale".to_string(), val); + is_cats = true; + } + ColorNoneT::T(val) => { + scale = Some(val); + is_lerp = true; + } + ColorNoneT::None => { + scale = None; + 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 +551,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) + } +} + +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) +} - if let Some(scale) = scale { - cmap = cmap.with_scale(scale.unwrap_or_default()); +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 00000000..b41d660f Binary files /dev/null and b/tests/refs/colorbar/cats-nocbar.png differ diff --git a/tests/refs/colorbar/cats-nocbar.svg b/tests/refs/colorbar/cats-nocbar.svg new file mode 100644 index 00000000..1229a600 --- /dev/null +++ b/tests/refs/colorbar/cats-nocbar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ 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 00000000..38d89ac8 Binary files /dev/null and b/tests/refs/colorbar/cats.png differ diff --git a/tests/refs/colorbar/cats.svg b/tests/refs/colorbar/cats.svg new file mode 100644 index 00000000..f3f5d9ff --- /dev/null +++ b/tests/refs/colorbar/cats.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 00000000..36147e04 Binary files /dev/null and b/tests/refs/colorbar/right-axis-right.png differ diff --git a/tests/refs/colorbar/right-axis-right.svg b/tests/refs/colorbar/right-axis-right.svg new file mode 100644 index 00000000..1ec01ae0 --- /dev/null +++ b/tests/refs/colorbar/right-axis-right.svg @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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)