From f629174916451931096cb9d641a56fc09d272af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Fri, 8 May 2026 22:58:46 +0200 Subject: [PATCH 01/11] export Rgb8 with Rgba8 --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index f7f9cef..e85843d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -170,7 +170,7 @@ pub mod color { pub use plotive_base::color::*; } -pub use color::{Color, ResolveColor, Rgba8}; +pub use color::{Color, ResolveColor, Rgba8, Rgb8}; /// Rexports of [`plotive_base::geom`]` items pub mod geom { From 65d81a5813eff65834c6425dd1b051a4e7236934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Fri, 8 May 2026 18:32:42 +0200 Subject: [PATCH 02/11] interp tests now in series --- .../line-interp-linear.png} | Bin .../line-interp-linear.svg} | 0 .../line-interp-spline.png} | Bin .../line-interp-spline.svg} | 0 .../line-interp-step-early.png} | Bin .../line-interp-step-early.svg} | 0 .../line-interp-step-late.png} | Bin .../line-interp-step-late.svg} | 0 .../line-interp-step-middle.png} | Bin .../line-interp-step-middle.svg} | 0 tests/src/tests.rs | 1 - tests/src/tests/interp.rs | 60 ------------------ tests/src/tests/series.rs | 56 ++++++++++++++++ 13 files changed, 56 insertions(+), 61 deletions(-) rename tests/refs/{interp/linear.png => series/line-interp-linear.png} (100%) rename tests/refs/{interp/linear.svg => series/line-interp-linear.svg} (100%) rename tests/refs/{interp/spline.png => series/line-interp-spline.png} (100%) rename tests/refs/{interp/spline.svg => series/line-interp-spline.svg} (100%) rename tests/refs/{interp/step-early.png => series/line-interp-step-early.png} (100%) rename tests/refs/{interp/step-early.svg => series/line-interp-step-early.svg} (100%) rename tests/refs/{interp/step-late.png => series/line-interp-step-late.png} (100%) rename tests/refs/{interp/step-late.svg => series/line-interp-step-late.svg} (100%) rename tests/refs/{interp/step-middle.png => series/line-interp-step-middle.png} (100%) rename tests/refs/{interp/step-middle.svg => series/line-interp-step-middle.svg} (100%) delete mode 100644 tests/src/tests/interp.rs diff --git a/tests/refs/interp/linear.png b/tests/refs/series/line-interp-linear.png similarity index 100% rename from tests/refs/interp/linear.png rename to tests/refs/series/line-interp-linear.png diff --git a/tests/refs/interp/linear.svg b/tests/refs/series/line-interp-linear.svg similarity index 100% rename from tests/refs/interp/linear.svg rename to tests/refs/series/line-interp-linear.svg diff --git a/tests/refs/interp/spline.png b/tests/refs/series/line-interp-spline.png similarity index 100% rename from tests/refs/interp/spline.png rename to tests/refs/series/line-interp-spline.png diff --git a/tests/refs/interp/spline.svg b/tests/refs/series/line-interp-spline.svg similarity index 100% rename from tests/refs/interp/spline.svg rename to tests/refs/series/line-interp-spline.svg diff --git a/tests/refs/interp/step-early.png b/tests/refs/series/line-interp-step-early.png similarity index 100% rename from tests/refs/interp/step-early.png rename to tests/refs/series/line-interp-step-early.png diff --git a/tests/refs/interp/step-early.svg b/tests/refs/series/line-interp-step-early.svg similarity index 100% rename from tests/refs/interp/step-early.svg rename to tests/refs/series/line-interp-step-early.svg diff --git a/tests/refs/interp/step-late.png b/tests/refs/series/line-interp-step-late.png similarity index 100% rename from tests/refs/interp/step-late.png rename to tests/refs/series/line-interp-step-late.png diff --git a/tests/refs/interp/step-late.svg b/tests/refs/series/line-interp-step-late.svg similarity index 100% rename from tests/refs/interp/step-late.svg rename to tests/refs/series/line-interp-step-late.svg diff --git a/tests/refs/interp/step-middle.png b/tests/refs/series/line-interp-step-middle.png similarity index 100% rename from tests/refs/interp/step-middle.png rename to tests/refs/series/line-interp-step-middle.png diff --git a/tests/refs/interp/step-middle.svg b/tests/refs/series/line-interp-step-middle.svg similarity index 100% rename from tests/refs/interp/step-middle.svg rename to tests/refs/series/line-interp-step-middle.svg diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 04aa504..9a5d292 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -74,7 +74,6 @@ impl NotRandom { mod axes; mod colorbar; -mod interp; mod legend; mod nulls; mod series; diff --git a/tests/src/tests/interp.rs b/tests/src/tests/interp.rs deleted file mode 100644 index 2949c9d..0000000 --- a/tests/src/tests/interp.rs +++ /dev/null @@ -1,60 +0,0 @@ -use plotive::des; - -use crate::tests::fig_small; -use crate::{TestHarness, assert_fig_eq_ref}; - -fn line() -> des::series::Line { - let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; - let y = vec![0.0, 2.0, 3.0, 1.0, 4.0, 4.0]; - des::series::Line::new(des::data_inline(x), des::data_inline(y)).into() -} - -#[test] -fn interp_linear() { - let plot = line() - .with_interpolation(des::series::Interpolation::Linear) - .into_plot(); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "interp/linear"); -} - -#[test] -fn interp_step_early() { - let plot = line() - .with_interpolation(des::series::Interpolation::StepEarly) - .into_plot(); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "interp/step-early"); -} - -#[test] -fn interp_step_middle() { - let plot = line() - .with_interpolation(des::series::Interpolation::StepMiddle) - .into_plot(); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "interp/step-middle"); -} - -#[test] -fn interp_step_late() { - let plot = line() - .with_interpolation(des::series::Interpolation::StepLate) - .into_plot(); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "interp/step-late"); -} - -#[test] -fn interp_spline() { - let plot = line() - .with_interpolation(des::series::Interpolation::Spline) - .into_plot(); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "interp/spline"); -} diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index fd7efa1..ee302b4 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -3,6 +3,12 @@ use plotive::{data, des, style}; use crate::tests::fig_small; use crate::{TestHarness, assert_fig_eq_ref}; +fn line() -> des::series::Line { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + let y = vec![0.0, 2.0, 3.0, 1.0, 4.0, 4.0]; + des::series::Line::new(des::data_inline(x), des::data_inline(y)).into() +} + #[test] fn series_line_nodata() { let plot = des::Plot::new(vec![ @@ -17,6 +23,56 @@ fn series_line_nodata() { assert_fig_eq_ref!(&fig, "series/line-nodata"); } +#[test] +fn series_line_interp_linear() { + let plot = line() + .with_interpolation(des::series::Interpolation::Linear) + .into_plot(); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-interp-linear"); +} + +#[test] +fn series_line_interp_step_early() { + let plot = line() + .with_interpolation(des::series::Interpolation::StepEarly) + .into_plot(); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-interp-step-early"); +} + +#[test] +fn series_line_interp_step_middle() { + let plot = line() + .with_interpolation(des::series::Interpolation::StepMiddle) + .into_plot(); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-interp-step-middle"); +} + +#[test] +fn series_line_interp_step_late() { + let plot = line() + .with_interpolation(des::series::Interpolation::StepLate) + .into_plot(); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-interp-step-late"); +} + +#[test] +fn series_line_interp_spline() { + let plot = line() + .with_interpolation(des::series::Interpolation::Spline) + .into_plot(); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-interp-spline"); +} + #[test] fn series_scatter_nodata() { let plot = des::Plot::new(vec![ From a49658ecec324f714e51db8b003d3aeeb3ea9e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 9 May 2026 15:34:01 +0200 Subject: [PATCH 03/11] typo --- pxl/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pxl/src/lib.rs b/pxl/src/lib.rs index 41ff497..769a74e 100644 --- a/pxl/src/lib.rs +++ b/pxl/src/lib.rs @@ -154,11 +154,11 @@ impl ToPixmap for drawing::PreparedFigure { D: plotive::data::Source + ?Sized, { let size = self.size(); - let witdth = (size.width() * params.scale) as u32; + let width = (size.width() * params.scale) as u32; let height = (size.height() * params.scale) as u32; let mut surface = - PxlSurface::new(witdth, height).ok_or(Error::InvalidSurfaceSize(witdth, height))?; + PxlSurface::new(width, height).ok_or(Error::InvalidSurfaceSize(width, height))?; self.draw(&mut surface, ¶ms.style); From 3b30f0edb875206d3b193890d064a130d53e5be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 9 May 2026 15:33:18 +0200 Subject: [PATCH 04/11] time epoch is now same as unix --- src/time.rs | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/src/time.rs b/src/time.rs index 434f63c..04e2181 100644 --- a/src/time.rs +++ b/src/time.rs @@ -3,12 +3,12 @@ //! for use in time series plots. //! //! The [`DateTime`] type represents a date and time as a floating-point. -//! The value is the number of seconds elapsed since Jan 1, 2030 (Plotive Epoch). +//! The value is the number of seconds elapsed since Jan 1, 1970 (Unix Epoch). use core::{cmp, fmt, ops}; use std::iter::Peekable; use std::str::{Chars, FromStr}; -const EPOCH_YEAR: i32 = 2030; +const EPOCH_YEAR: i32 = 1970; /// An error indicating that a field has an invalid value #[derive(Debug, Copy, Clone)] @@ -79,7 +79,7 @@ const fn days_in_year(year: i32) -> i32 { } /// A type representing a date and time. -/// It is represented by a `f64`, that is the seconds elapsed since Jan. 1, 2030, which is Plotive Epoch. +/// It is represented by a `f64`, that is the seconds elapsed since Jan. 1, 1970 (Unix Epoch). /// Timezone is not supported. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct DateTime(f64); @@ -91,19 +91,13 @@ impl DateTime { date.try_into() } - /// The date time of the Plotive Epoch, 2030-01-01 00:00:00. + /// The date time of the Unix Epoch, 1970-01-01 00:00:00. pub const fn epoch() -> Self { DateTime(0.0) } - /// The datetime of the Unix Epoch, 1970-01-01 00:00:00. - pub const fn unix_epoch() -> Self { - // seconds from Jan 1, 2030 to Jan 1, 1970 - DateTime(-1893456000.0) - } - /// Build a new datetime from a float timestamp. - /// The value is in seconds elapsed since Jan 1, 2030. + /// The value is in seconds elapsed since Jan 1, 1970 (Unix Epoch). /// Returns None if the value is not a valid timestamp. /// (invalid timestamps are eg. NaN or Infinity) pub const fn from_timestamp(timestamp: f64) -> Option { @@ -115,8 +109,8 @@ impl DateTime { } /// Get the internal representation as a float timestamp - /// The value is in seconds elapsed since Jan 1, 2030 ([Self::epoch()]). - /// (values before [Self::epoch()] are negative). + /// The value is in seconds elapsed since Jan 1, 1970 ([Self::epoch()]). + /// (values before Jan 1, 1970 are negative). /// /// The value is guaranteed to be a valid timestamp pub const fn timestamp(&self) -> f64 { @@ -303,7 +297,7 @@ pub struct DateTimeComps { } impl DateTimeComps { - /// The date time of the Plotive Epoch, 2030-01-01 00:00:00. + /// The date time of the Unix Epoch, 1970-01-01 00:00:00. pub const fn epoch() -> Self { DateTimeComps { year: EPOCH_YEAR, @@ -316,19 +310,6 @@ impl DateTimeComps { } } - /// The datetime of the Unix Epoch, 1970-01-01 00:00:00. - pub const fn unix_epoch() -> Self { - DateTimeComps { - year: 1970, - month: 1, - day: 1, - hour: 0, - minute: 0, - second: 0, - micro: 0, - } - } - /// Parse a string with the given format string. /// See [DateTime::fmt_parse] for supported formats. pub fn fmt_parse(input: &str, fmt: &str) -> Result { From 005ab7837fc4334b6f4b0c3932184e5a369bc982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 9 May 2026 15:32:49 +0200 Subject: [PATCH 05/11] add missing transparent css4 color --- base/src/color/css4.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/base/src/color/css4.rs b/base/src/color/css4.rs index d7552b0..4ed312e 100644 --- a/base/src/color/css4.rs +++ b/base/src/color/css4.rs @@ -307,6 +307,8 @@ pub const YELLOWGREEN: Rgba8 = Rgba8::from_hex(b"#9acd32"); /// Return Some(Rgba8) if the name is known pub(super) fn lookup_name(name: &str) -> Option { match name.trim().to_ascii_lowercase().as_str() { + "transparent" => Some(TRANSPARENT), + "black" => Some(BLACK), "silver" => Some(SILVER), "gray" | "grey" => Some(GRAY), From 78855e2e3077a9f0c5ec959a8a951bcf46cfcd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 9 May 2026 15:32:31 +0200 Subject: [PATCH 06/11] add missing setters on Bars --- src/des/series.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/des/series.rs b/src/des/series.rs index 4fb17e5..f234c77 100644 --- a/src/des/series.rs +++ b/src/des/series.rs @@ -811,6 +811,18 @@ impl Bars { } } + /// Set a reference to the x axis and return self for chaining + pub fn with_x_axis(mut self, axis: axis::Ref) -> Self { + self.x_axis = axis; + self + } + + /// Set a reference to the y axis and return self for chaining + pub fn with_y_axis(mut self, axis: axis::Ref) -> Self { + self.y_axis = axis; + self + } + /// Set the fill style and return self for chaining pub fn with_fill(self, fill: style::series::Fill) -> Self { Self { fill, ..self } From fd9df253eb6775d061d9f9d90f636e4de3640e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sun, 10 May 2026 12:38:12 +0200 Subject: [PATCH 07/11] colorbar scale --- src/des/axis.rs | 24 +++++++++++++++++++ src/des/cmap.rs | 32 ++++++++++++-------------- src/drawing/axis/bounds.rs | 7 ++++++ src/drawing/cmap.rs | 46 +++++++++++++++++++++++++++++-------- src/drawing/colorbar.rs | 43 +++++++++++++++------------------- src/drawing/plot.rs | 24 ++++++------------- tests/src/tests/colorbar.rs | 6 ++--- tests/src/tests/series.rs | 16 ++++++------- 8 files changed, 119 insertions(+), 79 deletions(-) diff --git a/src/des/axis.rs b/src/des/axis.rs index 8b8ccad..0745592 100644 --- a/src/des/axis.rs +++ b/src/des/axis.rs @@ -340,6 +340,30 @@ pub enum Scale { Shared(Ref), } +impl From<(Option, Option)> for Scale { + fn from(bounds: (Option, Option)) -> Self { + Range(bounds.0, bounds.1).into() + } +} + +impl From<(f64, f64)> for Scale { + fn from(bounds: (f64, f64)) -> Self { + Range(Some(bounds.0), Some(bounds.1)).into() + } +} + +impl From<((), f64)> for Scale { + fn from(bounds: ((), f64)) -> Self { + Range(None, Some(bounds.1)).into() + } +} + +impl From<(f64, ())> for Scale { + fn from(bounds: (f64, ())) -> Self { + Range(Some(bounds.0), None).into() + } +} + impl From for Scale { fn from(range: Range) -> Self { Scale::Linear(range) diff --git a/src/des/cmap.rs b/src/des/cmap.rs index 56f3e06..c880fe5 100644 --- a/src/des/cmap.rs +++ b/src/des/cmap.rs @@ -21,8 +21,9 @@ pub struct LerpColorMap { start: Rgb8, end: Rgb8, stops: Vec<(f32, Rgb8)>, - data_range: Option<(f64, f64)>, + scale: axis::Scale, locator: Option, + } impl LerpColorMap { @@ -32,7 +33,7 @@ impl LerpColorMap { method, start, end, - data_range: None, + scale: Default::default(), locator: None, stops: Vec::new(), } @@ -48,20 +49,15 @@ impl LerpColorMap { self } - /// Force the range of scalar data values that this color map maps to, as (min, max). + /// Assign a scale to this colormap. /// - /// By default, the colormap will map the range of data values in the plot, but this can be overridden with this method. - /// Use this if only a specific range of data are meaningful to map to colors. - pub fn force_data_range(mut self, range: (f64, f64)) -> Self { - assert!( - range.0.is_finite() && range.1.is_finite(), - "Color map data range must be finite" - ); + /// By default, the colormap will map linearly the full range of data values in the plot, but this can be overridden with this method. + pub fn with_scale(mut self, scale: axis::Scale) -> Self { assert!( - range.0 < range.1, - "Color map data range must have min < max" + !scale.is_shared(), + "Color map scale cannot be shared" ); - self.data_range = Some(range); + self.scale = scale; self } @@ -93,10 +89,12 @@ impl LerpColorMap { &self.stops } - /// Get the range of scalar values that this colormap is forced to map to, if it has one. + /// Get the scale of this colormap. + /// The scale is used to map data values to a 0 to 1 range that correspond to the + /// full range of colors /// If None, the color map is assumed to map the range of data values in the plot. - pub fn forced_data_range(&self) -> Option<(f64, f64)> { - self.data_range + pub fn scale(&self) -> &axis::Scale { + &self.scale } /// Get the ticks locator that this colormap is forced to use for its colorbar, if it has one. @@ -174,7 +172,7 @@ pub fn stellar() -> LerpColorMap { .with_stop(stop_for_temp(9000.0)) .with_stop(stop_for_temp(10000.0)) .with_stop(stop_for_temp(12500.0)) - .force_data_range((MIN_TEMP, MAX_TEMP)) + .with_scale((MIN_TEMP, MAX_TEMP).into()) .force_ticks_locator(axis::ticks::Locator::List( vec![ 1000.0, 2000.0, 3000.0, 4000.0, 5000.0, 6500.0, 8000.0, 10000.0, 12500.0, 15000.0, diff --git a/src/drawing/axis/bounds.rs b/src/drawing/axis/bounds.rs index b8b45bb..0cb61ab 100644 --- a/src/drawing/axis/bounds.rs +++ b/src/drawing/axis/bounds.rs @@ -72,6 +72,13 @@ impl Bounds { _ => false, } } + + pub(crate) fn _as_num(&self) -> Option { + match self { + Bounds::Num(nb) => Some(*nb), + _ => None, + } + } } /// Bounds of an axis, borrowing internal its data diff --git a/src/drawing/cmap.rs b/src/drawing/cmap.rs index 988d900..31236bd 100644 --- a/src/drawing/cmap.rs +++ b/src/drawing/cmap.rs @@ -1,9 +1,9 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; use crate::color::{Lerp, LinRgb, OkLab, Rgb8, Xyz}; use crate::des; use crate::des::cmap::{LerpColorMap, LerpMethod}; -use crate::drawing::axis; /// A trait for mapping scalar values to colors, used for color scales in heatmaps and similar plots. pub trait ColorMap { @@ -15,18 +15,38 @@ pub trait ColorMap { pub trait AsColorMap { fn hash(&self) -> u64; - fn forced_data_range(&self) -> Option; + fn scale(&self) -> &des::axis::Scale; fn forced_ticks_locator(&self) -> Option<&des::axis::ticks::Locator>; /// Convert this type to a `ColorMap` implementation that can be used for color mapping. fn as_color_map(&self) -> Arc; } +fn hash_range(rng: &des::axis::Range, hasher: &mut DefaultHasher) { + match rng { + des::axis::Range(Some(val1), Some(val2)) => { + val1.to_bits().hash(hasher); + val2.to_bits().hash(hasher); + } + des::axis::Range(Some(val1), None) => { + val1.to_bits().hash(hasher); + "none".hash(hasher); + } + des::axis::Range(None, Some(val2)) => { + "none".hash(hasher); + val2.to_bits().hash(hasher); + } + des::axis::Range(None, None) => { + "none".hash(hasher); + "none".hash(hasher); + } + } +} + 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 { - use std::hash::{DefaultHasher, Hash, Hasher}; let mut hasher = DefaultHasher::new(); self.method().hash(&mut hasher); self.start().hash(&mut hasher); @@ -38,17 +58,25 @@ impl AsColorMap for LerpColorMap { pos_bits.hash(&mut hasher); stop.1.hash(&mut hasher); } - if let Some(range) = self.forced_data_range() { - range.0.to_bits().hash(&mut hasher); - range.1.to_bits().hash(&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!(), } // TODO: hash the locator hasher.finish() } - fn forced_data_range(&self) -> Option { - self.forced_data_range() - .map(|rng| axis::NumBounds::from(rng).into()) + fn scale(&self) -> &des::axis::Scale { + self.scale() } fn forced_ticks_locator(&self) -> Option<&des::axis::ticks::Locator> { diff --git a/src/drawing/colorbar.rs b/src/drawing/colorbar.rs index a8b380e..8002c12 100644 --- a/src/drawing/colorbar.rs +++ b/src/drawing/colorbar.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use crate::des::axis::ticks::Locator; use crate::des::{self, colorbar}; -use crate::drawing::axis::{self, AsBoundRef}; +use crate::drawing::axis; use crate::drawing::cmap::{AsColorMap, ColorMap}; +use crate::drawing::scale::CoordMap; use crate::drawing::{Ctx, Text, ticks}; use crate::style::theme; use crate::{Style, data, geom, missing_params, render, text}; @@ -28,6 +29,7 @@ pub struct ColorBarBuilder { hash: u64, cmap: Arc, data_bounds: axis::Bounds, + scale: des::axis::Scale, locator: Locator, } @@ -36,12 +38,14 @@ impl ColorBarBuilder { hash: u64, cmap: Arc, data_bounds: axis::Bounds, + scale: des::axis::Scale, locator: Locator, ) -> Self { Self { hash, cmap, data_bounds, + scale, locator, } } @@ -50,10 +54,6 @@ impl ColorBarBuilder { self.hash } - pub fn data_bounds(&self) -> axis::BoundsRef<'_> { - self.data_bounds.as_bound_ref() - } - pub fn unite_bounds(&mut self, data_bounds: axis::BoundsRef<'_>) -> Result<(), super::Error> { self.data_bounds.unite_with(&data_bounds) } @@ -76,17 +76,17 @@ impl ColorBarBuilder { .map(|rt| Text::from_rich_text(&rt, ctx.fontdb())) .transpose()?; - let ticks = match &self.data_bounds { + let (scale, data_bounds, ticks) = match &self.data_bounds { axis::Bounds::Num(nb) => { + let cm = super::scale::map_scale_coord_num(&self.scale, 1.0, nb, (0.0, 0.0)); + let nb = cm.axis_bounds().as_num().unwrap(); let align = side.ticks_labels_align(); let font = des.ticks_font().clone(); - let scale: des::axis::Scale = - des::axis::Range::new(Some(nb.start()), Some(nb.end())).into(); let formatter = des::axis::ticks::Formatter::Auto; - let ticks = ticks::locate_num(&self.locator, *nb, &scale)?; + let ticks = ticks::locate_num(&self.locator, nb, &self.scale)?; let formatter = - ticks::num_label_formatter(&self.locator, Some(&formatter), *nb, &scale); - ticks + ticks::num_label_formatter(&self.locator, Some(&formatter), nb, &self.scale); + let ticks = ticks .into_iter() .map(|t| -> Result<_, super::Error> { let text = formatter.format_label(t.into()); @@ -100,9 +100,10 @@ impl ColorBarBuilder { let text = Text::from_line_text(<, ctx.fontdb(), font.color)?; Ok((data::Sample::Num(t), text)) }) - .collect::, _>>()? + .collect::, _>>()?; + (cm, nb.into(), ticks) } - _ => vec![], + _ => todo!("time and categories colorbar"), }; let ticks_mark = ( @@ -119,7 +120,8 @@ impl ColorBarBuilder { hash: self.hash, side, des, - data_bounds: self.data_bounds, + scale, + data_bounds, cmap: self.cmap, title, ticks, @@ -134,6 +136,7 @@ pub struct ColorBar { side: axis::Side, des: des::ColorBar, data_bounds: axis::Bounds, + scale: Arc, cmap: Arc, title: Option, ticks: Vec<(data::Sample, Text)>, @@ -146,7 +149,6 @@ impl fmt::Debug for ColorBar { .field("hash", &self.hash) .field("side", &self.side) .field("des", &self.des) - .field("data_bounds", &self.data_bounds) .field("title", &self.title) .field("ticks", &self.ticks) .field("ticks_mark", &self.ticks_mark) @@ -160,16 +162,7 @@ impl ColorDataMap for ColorBar { } fn map_color_data(&self, data: data::SampleRef<'_>) -> Option { - let bounds = self.data_bounds.as_bound_ref().as_num()?; - let val = data.as_num()?; - let min = bounds.start(); - let max = bounds.end(); - - if val.is_finite() && min.is_finite() && max.is_finite() && max > min { - Some(((val - min) / (max - min)).clamp(0.0, 1.0) as f32) - } else { - None - } + self.scale.map_coord(data) } } diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 35f4737..2ac13b2 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -515,15 +515,11 @@ where for_each_series(des_plot, |s| { if let Some(entry) = s.colorbar_entry() { - let forced_bounds = entry.cmap.forced_data_range(); - let has_forced_bounds = forced_bounds.is_some(); - let bounds = if let Some(range) = forced_bounds { - range - } else { - let col = get_column(entry.data_col, self.data_source())?; - col.bounds() - .expect("Should get bounds for colormap data column") - }; + let scale = entry.cmap.scale(); + let col = get_column(entry.data_col, self.data_source())?; + let bounds = col + .bounds() + .expect("Should get bounds for colormap data column"); let locator = entry .cmap @@ -532,19 +528,13 @@ where let hash = entry.cmap.hash(); if let Some(cbb) = builders.iter_mut().find(|b| b.hash() == hash) { - if !has_forced_bounds { - cbb.unite_bounds(bounds.as_bound_ref())?; - } else { - debug_assert!( - cbb.data_bounds() == bounds.as_bound_ref(), - "Two color bars with same color map but different forced data range, this should not happen since the color map should be different if the data range is different" - ); - } + cbb.unite_bounds(bounds.as_bound_ref())?; } else { builders.push(ColorBarBuilder::new( hash, entry.cmap.as_color_map(), bounds, + scale.clone(), locator.clone(), )); } diff --git a/tests/src/tests/colorbar.rs b/tests/src/tests/colorbar.rs index 9c3dc89..7c1319d 100644 --- a/tests/src/tests/colorbar.rs +++ b/tests/src/tests/colorbar.rs @@ -50,7 +50,7 @@ fn colorbar_locator() { scatter(x, y) .with_color_data( des::data_inline(col), - cmap::viridis().force_data_range((0.0, 1.0)), + cmap::viridis().with_scale((0.0, 1.0).into()), ) .into(), ]) @@ -70,7 +70,7 @@ fn colorbar_cmap_locator() { .with_color_data( des::data_inline(col), cmap::viridis() - .force_data_range((0.0, 1.0)) + .with_scale((0.0, 1.0).into()) .force_ticks_locator(ticks.into()), ) .into(), @@ -132,7 +132,7 @@ fn colorbar_forced_range() { scatter(x, y) .with_color_data( des::data_inline(col), - cmap::viridis().force_data_range((0.0, 2.0)), + cmap::viridis().with_scale((0.0, 2.0).into()), ) .into(), ]) diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index ee302b4..bbd682d 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -127,8 +127,8 @@ fn series_area_double() { des::data_inline(y2.clone()).into(), ) .with_fill(Some(fill)) - .with_stroke_y1(stroke.clone()) - .with_stroke_y2(stroke.clone()) + .with_y1_line(stroke.clone()) + .with_y2_line(stroke.clone()) .into(), des::series::Area::new( des::data_inline(x.clone()), @@ -136,8 +136,8 @@ fn series_area_double() { Default::default(), ) .with_fill(Some(fill)) - .with_stroke_y1(stroke.clone()) - .with_stroke_y2(stroke.clone()) + .with_y1_line(stroke.clone()) + .with_y2_line(stroke.clone()) .into(), ]); let fig = fig_small(plot); @@ -163,8 +163,8 @@ fn series_area_double_legend() { ) .with_name("area1") .with_fill(Some(fill1)) - .with_stroke_y1(stroke.clone()) - .with_stroke_y2(stroke.clone()) + .with_y1_line(stroke.clone()) + .with_y2_line(stroke.clone()) .into(), des::series::Area::new( des::data_inline(x.clone()), @@ -173,8 +173,8 @@ fn series_area_double_legend() { ) .with_name("area2") .with_fill(Some(fill2)) - .with_stroke_y1(stroke.clone()) - .with_stroke_y2(stroke.clone()) + .with_y1_line(stroke.clone()) + .with_y2_line(stroke.clone()) .into(), ]); let fig = fig_small(plot).with_legend(Default::default()); From 275ad09b154f55facf2e24803c6eaad22d33c54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sun, 10 May 2026 15:11:27 +0200 Subject: [PATCH 08/11] handle colormap without colorbar --- base/src/color.rs | 14 +++ src/des/cmap.rs | 17 +++ src/drawing/cmap.rs | 49 ++++++++ src/drawing/colorbar.rs | 162 +++++++++++++++++---------- src/drawing/plot.rs | 45 +++++--- src/drawing/series.rs | 63 ++++------- tests/refs/series/scatter-colors.png | Bin 0 -> 11852 bytes tests/refs/series/scatter-colors.svg | 14 +++ tests/src/tests/series.rs | 22 ++++ 9 files changed, 269 insertions(+), 117 deletions(-) create mode 100644 tests/refs/series/scatter-colors.png create mode 100644 tests/refs/series/scatter-colors.svg diff --git a/base/src/color.rs b/base/src/color.rs index c5751df..eb072a1 100644 --- a/base/src/color.rs +++ b/base/src/color.rs @@ -498,6 +498,20 @@ impl SRgb { // It is checked that the components of the color are valid, so we can safely implement Eq. impl Eq for SRgb {} +impl Lerp for SRgb { + fn lerp(self, other: Self, t: f32) -> Self { + debug_assert!( + t >= 0.0 && t <= 1.0, + "t must be in the range [0.0, 1.0] (got {})", + t + ); + let r = self.0 * (1.0 - t) + other.0 * t; + let g = self.1 * (1.0 - t) + other.1 * t; + let b = self.2 * (1.0 - t) + other.2 * t; + Self(r, g, b) + } +} + /// A linear RGB color with f32 components in the range [0.0, 1.0]. /// This is a linear colorspace where the components are proportional to the actual light intensity. #[derive(Debug, Clone, Copy, PartialEq)] diff --git a/src/des/cmap.rs b/src/des/cmap.rs index c880fe5..c4f9435 100644 --- a/src/des/cmap.rs +++ b/src/des/cmap.rs @@ -6,6 +6,13 @@ use crate::des::axis; /// 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)] pub enum LerpMethod { + /// Do not interpolate colors, only pick the nearest one + Nearest, + /// Interpolate colors in the standard RGB color space, which is fast but not perceptually uniform. + /// It tends to produce darker gradients, especially when interpolating between bright colors, + /// and can result in less visually appealing color maps. + /// Use this if you have significant amount of stops in the colormap gradient or significant performance constraints. + SRgb, /// Interpolate colors in the linear RGB color space. LinearRgb, /// Interpolate colors in a perceptual color space (OkLab). @@ -118,6 +125,16 @@ impl From<(LerpMethod, &[Rgb8])> for LerpColorMap { } } +/// Build one of the builtin color maps from its name. +/// Returns None if the name is not recognized. +pub fn from_name(name: &str) -> Option { + match name { + "stellar" => Some(stellar()), + "viridis" => Some(viridis()), + _ => None, + } +} + /// 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 diff --git a/src/drawing/cmap.rs b/src/drawing/cmap.rs index 31236bd..759da15 100644 --- a/src/drawing/cmap.rs +++ b/src/drawing/cmap.rs @@ -1,6 +1,8 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; +use plotive_base::color::SRgb; + use crate::color::{Lerp, LinRgb, OkLab, Rgb8, Xyz}; use crate::des; use crate::des::cmap::{LerpColorMap, LerpMethod}; @@ -88,6 +90,8 @@ impl AsColorMap for LerpColorMap { 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)), @@ -95,6 +99,51 @@ impl AsColorMap for LerpColorMap { } } +pub struct NearestColorMap { + start: Rgb8, + end: Rgb8, + stops: Vec<(f32, Rgb8)>, +} + +impl NearestColorMap { + pub fn new(start: Rgb8, end: Rgb8, stops: S) -> Self + where + S: IntoIterator, + { + Self { + start, + end, + stops: stops.into_iter().collect(), + } + } +} + +impl ColorMap for NearestColorMap { + fn map_color(&self, value: f32) -> Rgb8 { + if value <= 0.0 { + self.start + } else if value >= 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() { + nearest = stop.1; + nearest_pos = stop.0; + } + if stop.0 > value { + break; + } + } + nearest + } + } +} + +/// A color map that interpolates between two colors and optional stops in the linear RGB color space +pub type SRgbColorMap = GenColorMap; + /// A color map that interpolates between two colors and optional stops in the linear RGB color space pub type LinearColorMap = GenColorMap; diff --git a/src/drawing/colorbar.rs b/src/drawing/colorbar.rs index 8002c12..055f0fa 100644 --- a/src/drawing/colorbar.rs +++ b/src/drawing/colorbar.rs @@ -1,12 +1,13 @@ use std::fmt; use std::sync::Arc; +use plotive_base::Rgb8; + use crate::des::axis::ticks::Locator; use crate::des::{self, colorbar}; -use crate::drawing::axis; use crate::drawing::cmap::{AsColorMap, ColorMap}; use crate::drawing::scale::CoordMap; -use crate::drawing::{Ctx, Text, ticks}; +use crate::drawing::{Ctx, Text, axis, ticks}; use crate::style::theme; use crate::{Style, data, geom, missing_params, render, text}; @@ -17,13 +18,6 @@ pub struct Entry<'a> { pub cmap: &'a dyn AsColorMap, } -/// A trait that maps data to a 0..1 range, used for color bars and similar features. -pub trait ColorDataMap { - // identifies the right colorbar when multiple color bars are present - fn hash(&self) -> u64; - fn map_color_data(&self, data: data::SampleRef<'_>) -> Option; -} - #[derive(Clone)] pub struct ColorBarBuilder { hash: u64, @@ -58,7 +52,44 @@ impl ColorBarBuilder { self.data_bounds.unite_with(&data_bounds) } - pub fn build(self, des: des::ColorBar, ctx: &Ctx<'_, D>) -> Result + pub fn build( + self, + des: Option, + ctx: &Ctx<'_, D>, + ) -> Result<(ColorScale, 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"), + }; + + println!("colorbar data bounds: {:?}", data_bounds); + + 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(); + println!("colorbar view bounds: {:?}", view_bounds); + + let scale = ColorScale { + hash: self.hash, + view_bounds: view_bounds.into(), + data_to_coord: cm, + coord_to_color: self.cmap.clone(), + }; + + let cbar = des + .map(|des| self.build_colorbar(des, view_bounds, ctx)) + .transpose()?; + Ok((scale, cbar)) + } + + fn build_colorbar( + self, + des: des::ColorBar, + view_bounds: axis::NumBounds, + ctx: &Ctx<'_, D>, + ) -> Result where D: data::Source + ?Sized, { @@ -76,35 +107,22 @@ impl ColorBarBuilder { .map(|rt| Text::from_rich_text(&rt, ctx.fontdb())) .transpose()?; - let (scale, data_bounds, ticks) = match &self.data_bounds { - axis::Bounds::Num(nb) => { - let cm = super::scale::map_scale_coord_num(&self.scale, 1.0, nb, (0.0, 0.0)); - let nb = cm.axis_bounds().as_num().unwrap(); - let align = side.ticks_labels_align(); - let font = des.ticks_font().clone(); - let formatter = des::axis::ticks::Formatter::Auto; - let ticks = ticks::locate_num(&self.locator, nb, &self.scale)?; - let formatter = - ticks::num_label_formatter(&self.locator, Some(&formatter), nb, &self.scale); - let ticks = ticks - .into_iter() - .map(|t| -> Result<_, super::Error> { - let text = formatter.format_label(t.into()); - let lt = text::LineText::new( - text, - align, - font.size, - font.font.clone(), - ctx.fontdb(), - )?; - let text = Text::from_line_text(<, ctx.fontdb(), font.color)?; - Ok((data::Sample::Num(t), text)) - }) - .collect::, _>>()?; - (cm, nb.into(), ticks) - } - _ => todo!("time and categories colorbar"), - }; + let align = side.ticks_labels_align(); + let font = des.ticks_font().clone(); + 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() + .map(|t| -> Result<_, super::Error> { + let text = formatter.format_label(t.into()); + let lt = + text::LineText::new(text, align, font.size, font.font.clone(), ctx.fontdb())?; + let text = Text::from_line_text(<, ctx.fontdb(), font.color)?; + Ok((data::Sample::Num(t), text)) + }) + .collect::, _>>()?; let ticks_mark = ( theme::Stroke { @@ -120,9 +138,7 @@ impl ColorBarBuilder { hash: self.hash, side, des, - scale, - data_bounds, - cmap: self.cmap, + view_bounds: view_bounds.into(), title, ticks, ticks_mark, @@ -130,14 +146,53 @@ impl ColorBarBuilder { } } +#[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() + } +} + +impl ColorScale { + pub fn hash(&self) -> u64 { + self.hash + } + + /// 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 + } + } + + 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, - data_bounds: axis::Bounds, - scale: Arc, - cmap: Arc, + view_bounds: axis::Bounds, title: Option, ticks: Vec<(data::Sample, Text)>, ticks_mark: (theme::Stroke, f32), @@ -156,16 +211,6 @@ impl fmt::Debug for ColorBar { } } -impl ColorDataMap for ColorBar { - fn hash(&self) -> u64 { - self.hash - } - - fn map_color_data(&self, data: data::SampleRef<'_>) -> Option { - self.scale.map_coord(data) - } -} - impl ColorBar { pub fn pos(&self) -> colorbar::Pos { self.des.pos() @@ -223,6 +268,7 @@ impl ColorBar { style: &Style, plot_rect: &geom::Rect, plot_box: &geom::Rect, + scale: &ColorScale, ) where S: render::Surface, { @@ -268,7 +314,7 @@ impl ColorBar { let mut pb = geom::PathBuilder::with_capacity(5, 4); for i in 0..=num_pts { - let color = self.cmap.map_color(t); + let color = scale.coord_to_color.map_color(t); let pi = start + i as f32 * pos_shift; let pos2 = if i == num_pts { pi @@ -317,10 +363,10 @@ impl ColorBar { let mark_len = self.ticks_mark.1; for (tick_val, tick_text) in &self.ticks { - if !self.data_bounds.contains(tick_val.as_ref()) { + if !self.view_bounds.contains(tick_val.as_ref()) { continue; } - let Some(t) = self.map_color_data(tick_val.as_ref()) else { + let Some(t) = scale.map_data_to_coord(tick_val.as_ref()) else { continue; }; let tick_pos = start + sign * t * bar_len; diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 2ac13b2..3657941 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use crate::des::{PlotIdx, annot, colorbar}; use crate::drawing::annot::Annot; use crate::drawing::axis::{AsBoundRef, Axis, AxisScale, Bounds, Side}; -use crate::drawing::colorbar::{ColorBar, ColorBarBuilder}; +use crate::drawing::colorbar::{ColorBar, ColorBarBuilder, ColorScale}; use crate::drawing::legend::{Legend, LegendBuilder}; use crate::drawing::scale::CoordMapXy; use crate::drawing::series::{self, Series, SeriesExt}; @@ -60,7 +60,7 @@ pub(super) struct Plot { border: Option, series: Vec, legend: Option<(geom::Point, Legend)>, - colorbars: Vec, + colorbars: Vec<(ColorScale, Option)>, annots: Vec, } @@ -158,7 +158,7 @@ impl Axes { struct PlotData { series: Vec, legend: Option, - colorbars: Vec, + colorbars: Vec<(ColorScale, Option)>, insets: geom::Padding, } @@ -507,10 +507,12 @@ where Ok(builder.layout()) } - fn setup_plot_colorbars(&self, des_plot: &des::Plot) -> Result, Error> { - let Some(des_colorbar) = des_plot.colorbar() else { - return Ok(vec![]); - }; + fn setup_plot_colorbars( + &self, + des_plot: &des::Plot, + ) -> Result)>, Error> { + let des_colorbar = des_plot.colorbar(); + let mut builders: Vec = Vec::new(); for_each_series(des_plot, |s| { @@ -524,7 +526,9 @@ where let locator = entry .cmap .forced_ticks_locator() - .unwrap_or(des_colorbar.ticks_locator()); + .or(des_colorbar.map(|cb| cb.ticks_locator())) + .cloned() + .unwrap_or_default(); let hash = entry.cmap.hash(); if let Some(cbb) = builders.iter_mut().find(|b| b.hash() == hash) { @@ -535,7 +539,7 @@ where entry.cmap.as_color_map(), bounds, scale.clone(), - locator.clone(), + locator, )); } } @@ -544,7 +548,7 @@ where Ok(builders .into_iter() - .map(|b| b.build(des_colorbar.clone(), self)) + .map(|b| b.build(des_colorbar.cloned(), self)) .collect::, _>>()?) } @@ -569,7 +573,7 @@ where } } - for cbar in &data.colorbars { + for cbar in data.colorbars.iter().filter_map(|(_, cbar)| cbar.as_ref()) { if x_side_matches_colorbar_pos(side, cbar.pos()) { height += cbar.calc_size_across() + cbar.margin(); } @@ -611,7 +615,7 @@ where } } - for cbar in &data.colorbars { + for cbar in data.colorbars.iter().filter_map(|(_, cbar)| cbar.as_ref()) { if x_side_matches_colorbar_pos(side, cbar.pos()) { height += cbar.calc_size_across() + cbar.margin(); } @@ -650,7 +654,7 @@ where } } - for cbar in &data.colorbars { + for cbar in data.colorbars.iter().filter_map(|(_, cbar)| cbar.as_ref()) { if y_side_matches_colorbar_pos(side, cbar.pos()) { width += cbar.calc_size_across() + cbar.margin(); } @@ -969,8 +973,15 @@ impl Plot { x: &*x_cm, 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); + } + None + }); - series.update_data(data_source, &self.rect, &cm, &self.colorbars)?; + series.update_data(data_source, &self.rect, &cm, cmap)?; } Ok(()) } @@ -994,8 +1005,10 @@ impl Plot { let plot_box = axes.draw(surface, style, &self.rect); self.draw_border_box(surface, style); - for cbar in &self.colorbars { - cbar.draw(surface, style, &self.rect, &plot_box); + for (cs, cbar) in &self.colorbars { + if let Some(cbar) = cbar { + cbar.draw(surface, style, &self.rect, &plot_box, cs); + } } if let Some((top_left, leg)) = self.legend.as_ref() { diff --git a/src/drawing/series.rs b/src/drawing/series.rs index f74afe8..5da5a40 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -1,14 +1,11 @@ -use std::fmt; -use std::sync::Arc; - use axis::AsBoundRef; use plotive_base::Rgb8; use plotive_base::geom::PathSegment; use scale::{CoordMap, CoordMapXy}; use crate::drawing::axis::Bounds; -use crate::drawing::cmap::{AsColorMap, ColorMap}; -use crate::drawing::colorbar::{ColorBar, ColorDataMap}; +use crate::drawing::cmap::AsColorMap; +use crate::drawing::colorbar::ColorScale; use crate::drawing::plot::Orientation; use crate::drawing::{ Categories, ColumnExt, Error, F64ColumnExt, axis, colorbar, get_column, legend, marker, @@ -193,6 +190,15 @@ impl Series { (&self.x_axis, &self.y_axis) } + /// Hash of the colormap used by this series, if any. + /// Used to match with the right colorbar when multiple colorbars are present. + pub fn cmap_hash(&self) -> Option { + match &self.plot { + SeriesPlot::Scatter(scatter) => scatter.color_data.as_ref().map(|(_, hash)| *hash), + _ => None, + } + } + /// Unites bounds for series whose axis matches with `matcher` pub fn unite_bounds<'a, S>( or: Orientation, @@ -281,7 +287,7 @@ impl Series { data_source: &D, rect: &geom::Rect, cm: &CoordMapXy, - cbs: &[ColorBar], + cmap: Option<&ColorScale>, ) -> Result<(), Error> where D: data::Source + ?Sized, @@ -290,7 +296,7 @@ impl Series { SeriesPlot::Line(xy) => { xy.update_data(data_source, rect, cm); } - SeriesPlot::Scatter(sc) => sc.update_data(data_source, rect, cm, cbs), + SeriesPlot::Scatter(sc) => sc.update_data(data_source, rect, cm, cmap), SeriesPlot::Area(area) => area.update_data(data_source, rect, cm), SeriesPlot::Histogram(hist) => { hist.update_data(data_source, rect, cm); @@ -767,33 +773,17 @@ impl Line { } } -#[derive(Clone)] +#[derive(Clone, Debug)] struct Scatter { index: usize, cols: (des::DataCol, des::DataCol), size_col: Option, - color_data: Option<(des::DataCol, u64, Arc)>, + color_data: Option<(des::DataCol, u64)>, ab: Option<(axis::Bounds, axis::Bounds)>, axes: (des::axis::Ref, des::axis::Ref), marker_data: MarkerData, } -impl fmt::Debug for Scatter { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Scatter") - .field("index", &self.index) - .field("cols", &self.cols) - .field("size_col", &self.size_col) - .field( - "color_data", - &(self.color_data.as_ref().map(|(col, hash, _)| (col, hash))), - ) - .field("ab", &self.ab) - .field("axes", &self.axes) - .finish() - } -} - impl Scatter { fn prepare(index: usize, des: &des::series::Scatter, data_source: &D) -> Result where @@ -804,8 +794,7 @@ impl Scatter { let color_data = des.color_data().map(|(col, cmap)| { let col = col.clone(); let hash = cmap.hash(); - let cmap = cmap.as_color_map(); - (col, hash, cmap) + (col, hash) }); let xy_bounds = calc_xy_bounds(data_source, &cols.0, &cols.1)?; let marker_data = MarkerData::new(des.marker().clone()); @@ -825,7 +814,7 @@ impl Scatter { data_source: &D, rect: &geom::Rect, cm: &CoordMapXy, - cbs: &[ColorBar], + cmap: Option<&ColorScale>, ) where D: data::Source + ?Sized, { @@ -857,13 +846,7 @@ impl Scatter { let mut color_iter = self .color_data .as_ref() - .map(|(col, _, _)| get_column(col, data_source).unwrap().sample_iter()); - let cbar = self - .color_data - .as_ref() - .map(|(_, hash, _)| cbs.iter().find(|cb| cb.hash() == *hash)) - .flatten(); - let cmap = self.color_data.as_ref().map(|(_, _, cmap)| cmap.clone()); + .map(|(col, _)| get_column(col, data_source).unwrap().sample_iter()); for (x, y) in x_col.sample_iter().zip(y_col.sample_iter()) { if x.is_null() || y.is_null() { @@ -883,14 +866,8 @@ impl Scatter { let color_sample = color_iter.as_mut().and_then(|iter| iter.next()); let color = color_sample - .zip(cmap.as_ref()) - .zip(cbar) - .map(|((v, cmap), cbar)| { - let mapped_value = cbar - .map_color_data(v) - .expect("TODO: handle invalid color data"); - cmap.map_color(mapped_value) - }); + .zip(cmap) + .and_then(|(v, cmap)| cmap.map_data_to_color(v)); points.push(MarkerPoint { pos: geom::Point { x, y }, diff --git a/tests/refs/series/scatter-colors.png b/tests/refs/series/scatter-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..c6ae622ea1634a9eddc39569510aecd61fbc6205 GIT binary patch literal 11852 zcmeHN4Nw#58D5ZH)Ei0FJ5hwh(|WYS%~7=nQbP8;lcSz9(kq9Iq6CX4RKN&SNg!cC zT2VoQXM0=)5_?V$e+s#Z5YQy60>X(9e~J7C5`N?_A%qZ;Y<9Z~u`+XBlFr<8Zfs@< zJNY*Ieb4uN?|$$5e(!Vgw>v_YEcRRs0Kk%MzuEdb0B|Y9KkqDj4R0A24crERcb9M5 zy5&=r%lOR&=YO{Ai#u8@O?|rQURlca*ovgiYU|%0^7&tUEaY$8v{r3ADms|{b#73? zld$TRh{(N1oUcORsedO{L^hjY+D&9uiz<%9SU5mF7Hz4o*oqLEM<2PXU zt4Xsz#{V%$?f!GU?9~f%5{u`0sW8d6{2017lBWU%y1+T$dQcX}ZHv=V7qZw1>e}(9=fE51ApjR%O+s^UL>%{OBLQ-5DNF*@3j{i<+w(bE zL0VT@rhS3HNIsT`hezi-(YJ~DVcG00|jp!Rj3JN864*bG0hzs%_m*czSof#H3x zP>b)D5+F-_A{Wf=3W=g-SJOX-^5!kn_gT|d%^4JztU;z*^0A>p5A8@KaAK5y4{mT^ z@TI4D1n~OgUqP{asxWg^O3`M0<^AB}P`LvGul1yc@uBS}mdB$X(ZFcS;{FijW7!9W zm1va%uT$Q;8nzp74qjTni2q@9{S}7Gnu?5#*a`>O4jIZ3Hjo&kWLLd#VX`YQxt4;y zF}b$GwD6k#3=-vlB}1h&!-`)9y8uczpg#n-$a{MS=7sHaU~Gnr#K!8#96*#cHI%$HOG&y5VXK3yW2%H&={kRD$s~k;5dTPP` zRk;>$Q?MSsCy8(4{cXO>i5Rmq+r{|H9x^72cdI$R109m$`>=mfqDkdxO z&tNt`ljR!*a$X~M)co0xbyB9hjHTO)(@|{m!B9@G0CJ1?tDL4gNCV-FY4VpeZMh$I zB=IjxDojGXy~G*gHEM#-__2_p6md{%GoSSfz5o`_1dJ8j;B>^&&oqwFPF+D0xGxg- zm!{1oOm)#)n+>5$KaGgi-MgYi5Oq5kGg&W^BJzKI;$JfYx7o|oFa}+(p%-@+92)u# zEuzG(dmhqy{Ez~Y*b6E!n5KH>PaoINNm-jJAMF9*6_|Tt z($D}aqxdFoud4OrYoDy(+^_556$NQG?$O&%h_JBG@e>TGSu;us}A(cm;z!h zB;sQW?9Cx^uihCLIRhi-VVv{K5_1Md&cMiiV1ySzoHYIKXhKCDQ9?G-UWsVEMGGeG z26W$xZ7tas=dZ#SLS(XCim))JYAOxdMX<@2rXJ(c?#IgZ)D=nkR%@Sbk{`xPSgY;C zTd1fumdOjSWNSWyBnK)-*-a;x12l^=#Jw?lygi@65gGcwF7?K1EKsqXPy_SVOsc@U zR7G+SIWYNHq;&WIVeHYQ#cdR_*4SrNvh7<`Lic@q$K z$1?JzZ&2dnPYYOGATTk-LpM8wYp%qvK}9LavBWbhx-;nCsked84K?`a^Yt$||VPs7GSa@^b+1&-Zi1 zlzvPo7mw)!CN4fIW1sio2Q@N!xE=xOu?e%r?1C4%Fn>EyI+m2FzZBM&lc|maSSJ&N z19wWj0d-fWN~4x|WHH%!BIOe5^kIhx`iNm^gqq}tma0WKkA30ARLfoAU4oI>g5g|= z?&XY(B*$q~LD+~NyQ|#{u*UwDz)}>kcTqA1J&B>O*s&74eC7;DN1OqvGaz*ar2p3e zDQfQGD%NKXi_mNzMLHGWu_oRVtBPT-sNzldMtT_&*gpM%cnb4bAKgzF*mf_k3ikQ( zmQ{l3KIZKN@xygBgpvib?Uyh;ADJ?<1UxeZsJJn>Qq*Enq|E7r&Y9(eRA#lVVsd*n)Z@m@Mlzwa z!E8HRG{GC6ipnsv`}%IySvfbEFJPEj;J6Y`7~xXCZ5zxdBpMjcrjOAxMujMPl&;(a zeHrdk&8muI;qIS`w1)>m+AN{DD{b(zXym{w$DkdK%X8}9V~Jo0yQXx5(mlbVriTlWF)EuK?g(+{ULTHTuE% zxss_It70$2X7&b^m!bM{4na~-SvC8#C}TZepfH~V_u;4%ND<>sU9{BeKV^xyb~BJRgilB?x&BD4hW^Q zz6tlcM{Hu!yud`Bq6qZv;-NUnI;%W!)MmC0-VVo1JLk;JYOFVG8HQ6m`HG56CpuwE zr+7L6wn~pJGVN?jTY_Z8JRz-VzB+b@5ZxNdfBLaWl&wqj!wIZsLQjP(K36J7ig1-U Ouq|ZA)`|~ej{O^8Yn+Gx literal 0 HcmV?d00001 diff --git a/tests/refs/series/scatter-colors.svg b/tests/refs/series/scatter-colors.svg new file mode 100644 index 0000000..916e71b --- /dev/null +++ b/tests/refs/series/scatter-colors.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index bbd682d..37b5363 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -1,3 +1,4 @@ +use plotive::des::cmap; use plotive::{data, des, style}; use crate::tests::fig_small; @@ -111,6 +112,27 @@ fn series_scatter_sizes() { assert_fig_eq_ref!(&fig, "series/scatter-sizes"); } +#[test] +fn series_scatter_colors() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![1.0, 4.0, 9.0, 16.0, 25.0]; + let colors = vec![0.0, 0.25, 0.5, 0.75, 1.0]; + + 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_marker( + style::series::Marker::default() + .with_fill_opacity(0.6) + .with_stroke_width(2.0), + ) + .into(), + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/scatter-colors"); +} + #[test] fn series_area_double() { let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; From 5650d3bda7c877e29e4c4524eb0c88723d89403f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sun, 10 May 2026 21:36:17 +0200 Subject: [PATCH 09/11] rm cmap forced ticks --- examples/stars.rs | 8 +++-- src/des/cmap.rs | 30 +++--------------- src/des/colorbar.rs | 10 ++++++ src/drawing/cmap.rs | 5 --- src/drawing/colorbar.rs | 7 ++-- src/drawing/plot.rs | 14 ++++---- src/lib.rs | 2 +- .../{forced-range.png => cmap-scale.png} | Bin .../{forced-range.svg => cmap-scale.svg} | 0 tests/src/tests/colorbar.rs | 25 ++------------- 10 files changed, 32 insertions(+), 69 deletions(-) rename tests/refs/colorbar/{forced-range.png => cmap-scale.png} (100%) rename tests/refs/colorbar/{forced-range.svg => cmap-scale.svg} (100%) diff --git a/examples/stars.rs b/examples/stars.rs index 06683d8..6ce2cda 100644 --- a/examples/stars.rs +++ b/examples/stars.rs @@ -1,7 +1,7 @@ use std::{fs, path}; use plotive::data::Source; -use plotive::des::cmap; +use plotive::des::{cmap, colorbar}; use plotive::{data, des, style}; mod common; @@ -83,7 +83,11 @@ fn main() { .with_marker(style::series::Marker::default().with_fill_opacity(0.85)) .into(), ]) - .with_colorbar(des::ColorBar::default().with_title("Surface Temperature [K]".into())) + .with_colorbar( + des::ColorBar::default() + .with_title("Surface Temperature [K]".into()) + .with_ticks_locator(colorbar::stellar_ticks_locator()), + ) .into(), ) .with_title("45 bright stars".into()); diff --git a/src/des/cmap.rs b/src/des/cmap.rs index c4f9435..5a858dc 100644 --- a/src/des/cmap.rs +++ b/src/des/cmap.rs @@ -29,8 +29,6 @@ pub struct LerpColorMap { end: Rgb8, stops: Vec<(f32, Rgb8)>, scale: axis::Scale, - locator: Option, - } impl LerpColorMap { @@ -41,7 +39,6 @@ impl LerpColorMap { start, end, scale: Default::default(), - locator: None, stops: Vec::new(), } } @@ -60,22 +57,11 @@ impl LerpColorMap { /// /// By default, the colormap will map linearly the full range of data values in the plot, but this can be overridden with this method. pub fn with_scale(mut self, scale: axis::Scale) -> Self { - assert!( - !scale.is_shared(), - "Color map scale cannot be shared" - ); + assert!(!scale.is_shared(), "Color map scale cannot be shared"); self.scale = scale; self } - /// Force the ticks of colorbar mapping this colormap to be located according to the given locator. - /// By default, the locator is automatic, but this can be overridden with this method. - /// Use this if you want to have specific control over the ticks of the colorbar, for example to place them at specific data values. - pub fn force_ticks_locator(mut self, locator: axis::ticks::Locator) -> Self { - self.locator = Some(locator); - self - } - /// Get the interpolation method used by this color map. pub fn method(&self) -> LerpMethod { self.method @@ -103,11 +89,6 @@ impl LerpColorMap { pub fn scale(&self) -> &axis::Scale { &self.scale } - - /// Get the ticks locator that this colormap is forced to use for its colorbar, if it has one. - pub fn forced_ticks_locator(&self) -> Option<&axis::ticks::Locator> { - self.locator.as_ref() - } } impl From<(LerpMethod, &[Rgb8])> for LerpColorMap { @@ -138,6 +119,9 @@ pub fn from_name(name: &str) -> Option { /// 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 +/// +/// By default, this colormap is assigned a linear scale between 1000 and 15000 (so that to map directly to Kelvins). +/// If a regular colormap behavior is needed, you can use `stellar().with_scale(axis::Scale::Auto)` pub fn stellar() -> LerpColorMap { const MIN_TEMP: f64 = 1000.0; const MAX_TEMP: f64 = 15000.0; @@ -190,12 +174,6 @@ pub fn stellar() -> LerpColorMap { .with_stop(stop_for_temp(10000.0)) .with_stop(stop_for_temp(12500.0)) .with_scale((MIN_TEMP, MAX_TEMP).into()) - .force_ticks_locator(axis::ticks::Locator::List( - vec![ - 1000.0, 2000.0, 3000.0, 4000.0, 5000.0, 6500.0, 8000.0, 10000.0, 12500.0, 15000.0, - ] - .into(), - )) } /// The famous "viridis" color map from matplotlib diff --git a/src/des/colorbar.rs b/src/des/colorbar.rs index e23ee98..0277be2 100644 --- a/src/des/colorbar.rs +++ b/src/des/colorbar.rs @@ -163,3 +163,13 @@ impl From for ColorBar { Self::new(pos) } } + +/// A tick locator that fits well with the stellar colormap with data in K +pub fn stellar_ticks_locator() -> axis::ticks::Locator { + axis::ticks::Locator::List( + vec![ + 1000.0, 2000.0, 3000.0, 4000.0, 5000.0, 6500.0, 8000.0, 10000.0, 12500.0, 15000.0, + ] + .into(), + ) +} diff --git a/src/drawing/cmap.rs b/src/drawing/cmap.rs index 759da15..142a6fc 100644 --- a/src/drawing/cmap.rs +++ b/src/drawing/cmap.rs @@ -18,7 +18,6 @@ pub trait AsColorMap { fn hash(&self) -> u64; fn scale(&self) -> &des::axis::Scale; - fn forced_ticks_locator(&self) -> Option<&des::axis::ticks::Locator>; /// Convert this type to a `ColorMap` implementation that can be used for color mapping. fn as_color_map(&self) -> Arc; @@ -81,10 +80,6 @@ impl AsColorMap for LerpColorMap { self.scale() } - fn forced_ticks_locator(&self) -> Option<&des::axis::ticks::Locator> { - self.forced_ticks_locator() - } - fn as_color_map(&self) -> Arc { let start = self.start(); let end = self.end(); diff --git a/src/drawing/colorbar.rs b/src/drawing/colorbar.rs index 055f0fa..2e82a84 100644 --- a/src/drawing/colorbar.rs +++ b/src/drawing/colorbar.rs @@ -65,11 +65,8 @@ impl ColorBarBuilder { _ => unimplemented!("time and categories colorbar"), }; - println!("colorbar data bounds: {:?}", data_bounds); - 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(); - println!("colorbar view bounds: {:?}", view_bounds); let scale = ColorScale { hash: self.hash, @@ -115,6 +112,7 @@ impl ColorBarBuilder { 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 = @@ -183,7 +181,8 @@ impl ColorScale { } 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)) + self.map_data_to_coord(data) + .map(|t| self.map_coord_to_color(t)) } } diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 3657941..0f99b4c 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -523,10 +523,8 @@ where .bounds() .expect("Should get bounds for colormap data column"); - let locator = entry - .cmap - .forced_ticks_locator() - .or(des_colorbar.map(|cb| cb.ticks_locator())) + let locator = des_colorbar + .map(|cb| cb.ticks_locator()) .cloned() .unwrap_or_default(); let hash = entry.cmap.hash(); @@ -1110,10 +1108,10 @@ impl Axes { let l = self.draw_side(surface, style, &self.y, Side::Left, plot_rect); plot_rect - .shifted_top_side(t) - .shifted_right_side(-r) - .shifted_bottom_side(-b) - .shifted_left_side(l) + .shifted_top_side(-t) + .shifted_right_side(r) + .shifted_bottom_side(b) + .shifted_left_side(-l) } fn draw_side( diff --git a/src/lib.rs b/src/lib.rs index e85843d..6ddbb14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -170,7 +170,7 @@ pub mod color { pub use plotive_base::color::*; } -pub use color::{Color, ResolveColor, Rgba8, Rgb8}; +pub use color::{Color, ResolveColor, Rgb8, Rgba8}; /// Rexports of [`plotive_base::geom`]` items pub mod geom { diff --git a/tests/refs/colorbar/forced-range.png b/tests/refs/colorbar/cmap-scale.png similarity index 100% rename from tests/refs/colorbar/forced-range.png rename to tests/refs/colorbar/cmap-scale.png diff --git a/tests/refs/colorbar/forced-range.svg b/tests/refs/colorbar/cmap-scale.svg similarity index 100% rename from tests/refs/colorbar/forced-range.svg rename to tests/refs/colorbar/cmap-scale.svg diff --git a/tests/src/tests/colorbar.rs b/tests/src/tests/colorbar.rs index 7c1319d..dc36375 100644 --- a/tests/src/tests/colorbar.rs +++ b/tests/src/tests/colorbar.rs @@ -60,27 +60,6 @@ fn colorbar_locator() { assert_fig_eq_ref!(&fig, "colorbar/locator"); } -#[test] -fn colorbar_cmap_locator() { - let (x, y, col) = columns(); - let ticks = vec![0.0, 0.25, 0.5, 0.75, 1.0]; - - let plot = des::Plot::new(vec![ - scatter(x, y) - .with_color_data( - des::data_inline(col), - cmap::viridis() - .with_scale((0.0, 1.0).into()) - .force_ticks_locator(ticks.into()), - ) - .into(), - ]) - .with_colorbar(ColorBar::default()); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "colorbar/locator"); -} - #[test] fn colorbar_default_with_axes() { let (x, y, col) = columns(); @@ -125,7 +104,7 @@ fn colorbar_auto_range() { } #[test] -fn colorbar_forced_range() { +fn colorbar_cmap_scale() { let (x, y, col) = columns(); let plot = des::Plot::new(vec![ @@ -139,7 +118,7 @@ fn colorbar_forced_range() { .with_colorbar(Default::default()); let fig = fig_small(plot); - assert_fig_eq_ref!(&fig, "colorbar/forced-range"); + assert_fig_eq_ref!(&fig, "colorbar/cmap-scale"); } #[test] From 8fd17148054b2bf9639a109407b70277a774c65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Wed, 13 May 2026 18:42:23 +0200 Subject: [PATCH 10/11] update stroke vs outline and Some(fill) --- src/des/plot.rs | 6 +- src/des/series.rs | 58 ++++++------- src/drawing/legend.rs | 24 +++--- src/drawing/series.rs | 170 ++++++++++++++++++-------------------- src/style.rs | 20 ++--- tests/src/tests/axes.rs | 2 +- tests/src/tests/series.rs | 24 +++--- 7 files changed, 148 insertions(+), 156 deletions(-) diff --git a/src/des/plot.rs b/src/des/plot.rs index 289280f..077dde3 100644 --- a/src/des/plot.rs +++ b/src/des/plot.rs @@ -7,7 +7,7 @@ use crate::style::{defaults, theme}; #[derive(Debug, Clone)] pub struct AxisArrow { /// Line style for the border and arrow - pub line: theme::Stroke, + pub stroke: theme::Stroke, /// Size of the arrow head pub size: f32, /// Extra length of the axis beyond the plot area @@ -21,7 +21,7 @@ pub struct AxisArrow { impl Default for AxisArrow { fn default() -> Self { AxisArrow { - line: theme::Col::Foreground.into(), + stroke: theme::Col::Foreground.into(), size: defaults::PLOT_AXIS_ARROW_SIZE, overflow: defaults::PLOT_AXIS_ARROW_OVERFLOW, } @@ -45,7 +45,7 @@ impl Border { match self { Border::Box(line) => line, Border::Axis(line) => line, - Border::AxisArrow(arrow) => &arrow.line, + Border::AxisArrow(arrow) => &arrow.stroke, } } } diff --git a/src/des/series.rs b/src/des/series.rs index f234c77..900c142 100644 --- a/src/des/series.rs +++ b/src/des/series.rs @@ -486,9 +486,9 @@ pub struct Area { name: Option, x_axis: axis::Ref, y_axis: axis::Ref, - fill: Option, - stroke_y1: Option, - stroke_y2: Option, + fill: style::series::Fill, + y1_stroke: Option, + y2_stroke: Option, interpolation: Interpolation, } @@ -503,9 +503,9 @@ impl Area { name: None, x_axis: Default::default(), y_axis: Default::default(), - fill: Some(style::series::Fill::default()), - stroke_y1: None, - stroke_y2: None, + fill: style::series::Fill::default(), + y1_stroke: None, + y2_stroke: None, interpolation: Interpolation::default(), } } @@ -533,20 +533,20 @@ impl Area { } /// Set the fill style and return self for chaining - pub fn with_fill(mut self, fill: Option) -> Self { + pub fn with_fill(mut self, fill: style::series::Fill) -> Self { self.fill = fill; self } /// Set the stroke style of the Y1 line and return self for chaining - pub fn with_stroke_y1(mut self, stroke: style::series::Stroke) -> Self { - self.stroke_y1 = Some(stroke); + pub fn with_y1_stroke(mut self, stroke: style::series::Stroke) -> Self { + self.y1_stroke = Some(stroke); self } /// Set the stroke style of the Y2 line and return self for chaining - pub fn with_stroke_y2(mut self, stroke: style::series::Stroke) -> Self { - self.stroke_y2 = Some(stroke); + pub fn with_y2_stroke(mut self, stroke: style::series::Stroke) -> Self { + self.y2_stroke = Some(stroke); self } @@ -587,18 +587,18 @@ impl Area { } /// Get the fill style - pub fn fill(&self) -> Option<&style::series::Fill> { - self.fill.as_ref() + pub fn fill(&self) -> &style::series::Fill { + &self.fill } /// Get the stroke style of Y1 line - pub fn stroke_y1(&self) -> Option<&style::series::Stroke> { - self.stroke_y1.as_ref() + pub fn y1_stroke(&self) -> Option<&style::series::Stroke> { + self.y1_stroke.as_ref() } /// Get the stroke style of Y2 line - pub fn stroke_y2(&self) -> Option<&style::series::Stroke> { - self.stroke_y2.as_ref() + pub fn y2_stroke(&self) -> Option<&style::series::Stroke> { + self.y2_stroke.as_ref() } /// Chaining helper to build a plot from this series @@ -636,7 +636,7 @@ impl Area { /// of values in each bin. Useful for visualizing distributions of continuous data. #[derive(Debug, Clone)] pub struct Histogram { - data: DataCol, + x_data: DataCol, name: Option, x_axis: axis::Ref, @@ -649,9 +649,9 @@ pub struct Histogram { impl Histogram { /// Create a new histogram series with the given data column - pub fn new(data: DataCol) -> Self { + pub fn new(x_data: DataCol) -> Self { Histogram { - data, + x_data, name: None, x_axis: Default::default(), @@ -689,7 +689,7 @@ impl Histogram { } /// Set the stroke style for the histogram outline and return self for chaining - pub fn with_outline(mut self, stroke: style::series::Stroke) -> Self { + pub fn with_stroke(mut self, stroke: style::series::Stroke) -> Self { self.stroke = Some(stroke); self } @@ -706,9 +706,9 @@ impl Histogram { self } - /// Get the data column - pub fn data(&self) -> &DataCol { - &self.data + /// Get the x data column + pub fn x_data(&self) -> &DataCol { + &self.x_data } /// Get the name @@ -731,8 +731,8 @@ impl Histogram { &self.fill } - /// Get the outline style, if any - pub fn outline(&self) -> Option<&style::series::Stroke> { + /// Get the stroke style for the histogram outline, if any + pub fn stroke(&self) -> Option<&style::series::Stroke> { self.stroke.as_ref() } @@ -829,7 +829,7 @@ impl Bars { } /// Set the stroke style for the bar outline and return self for chaining - pub fn with_outline(self, stroke: style::series::Stroke) -> Self { + pub fn with_stroke(self, stroke: style::series::Stroke) -> Self { Self { stroke: Some(stroke), ..self @@ -871,8 +871,8 @@ impl Bars { &self.fill } - /// Get the outline style, if any - pub fn outline(&self) -> Option<&style::series::Stroke> { + /// Get the stroke style for the bar outline, if any + pub fn stroke(&self) -> Option<&style::series::Stroke> { self.stroke.as_ref() } diff --git a/src/drawing/legend.rs b/src/drawing/legend.rs index 7dd3567..587f09d 100644 --- a/src/drawing/legend.rs +++ b/src/drawing/legend.rs @@ -11,8 +11,8 @@ pub enum Shape { Rect(Option, Option), AreaRect { fill: Option, - stroke_y1: Option, - stroke_y2: Option, + y1_stroke: Option, + y2_stroke: Option, }, } @@ -26,8 +26,8 @@ pub enum ShapeRef<'a> { ), AreaRect { fill: Option<&'a style::series::Fill>, - stroke_y1: Option<&'a style::series::Stroke>, - stroke_y2: Option<&'a style::series::Stroke>, + y1_stroke: Option<&'a style::series::Stroke>, + y2_stroke: Option<&'a style::series::Stroke>, }, } @@ -39,12 +39,12 @@ impl ShapeRef<'_> { &ShapeRef::Rect(fill, line) => Shape::Rect(fill.cloned(), line.cloned()), &ShapeRef::AreaRect { fill, - stroke_y1, - stroke_y2, + y1_stroke, + y2_stroke, } => Shape::AreaRect { fill: fill.cloned(), - stroke_y1: stroke_y1.cloned(), - stroke_y2: stroke_y2.cloned(), + y1_stroke: y1_stroke.cloned(), + y2_stroke: y2_stroke.cloned(), }, } } @@ -316,8 +316,8 @@ impl LegendEntry { } Shape::AreaRect { fill, - stroke_y1, - stroke_y2, + y1_stroke, + y2_stroke, } => { let r = geom::Rect::from_ps( geom::Point { @@ -335,7 +335,7 @@ impl LegendEntry { }; surface.draw_rect(&rr); } - if let Some(stroke) = stroke_y1 { + if let Some(stroke) = y1_stroke { let mut pb = geom::PathBuilder::new(); pb.move_to(r.left(), r.top()); pb.line_to(r.right(), r.top()); @@ -348,7 +348,7 @@ impl LegendEntry { }; surface.draw_path(&rp); } - if let Some(stroke) = stroke_y2 { + if let Some(stroke) = y2_stroke { let mut pb = geom::PathBuilder::new(); pb.move_to(r.left(), r.bottom()); pb.line_to(r.right(), r.bottom()); diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 5da5a40..19c61b7 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -53,9 +53,9 @@ impl SeriesExt for des::series::Area { label: n.as_ref(), font: None, shape: legend::ShapeRef::AreaRect { - fill: self.fill(), - stroke_y1: self.stroke_y1(), - stroke_y2: self.stroke_y2(), + fill: Some(self.fill()), + y1_stroke: self.y1_stroke(), + y2_stroke: self.y2_stroke(), }, }) } @@ -66,7 +66,7 @@ impl SeriesExt for des::series::Histogram { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::Rect(Some(self.fill()), self.outline()), + shape: legend::ShapeRef::Rect(Some(self.fill()), self.stroke()), }) } } @@ -76,7 +76,7 @@ impl SeriesExt for des::series::Bars { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::Rect(Some(self.fill()), self.outline()), + shape: legend::ShapeRef::Rect(Some(self.fill()), self.stroke()), }) } } @@ -897,9 +897,9 @@ struct Area { path_y1: Option, path_y2: Option, path_fill: Option, - fill: Option, - stroke_y1: Option, - stroke_y2: Option, + fill: style::series::Fill, + y1_stroke: Option, + y2_stroke: Option, interpolation: des::series::Interpolation, } @@ -948,9 +948,9 @@ impl Area { path_y1: None, path_y2: None, path_fill: None, - fill: des.fill().cloned(), - stroke_y1: des.stroke_y1().cloned(), - stroke_y2: des.stroke_y2().cloned(), + fill: des.fill().clone(), + y1_stroke: des.y1_stroke().cloned(), + y2_stroke: des.y2_stroke().cloned(), interpolation: des.interpolation(), }) } @@ -979,13 +979,11 @@ impl Area { self.ab = Some(xy_bounds); } - let path = calc_xy_line_path(x_col, y1_col, self.interpolation, rect, cm); - self.path_y1 = Some(path); + let path_y1 = calc_xy_line_path(x_col, y1_col, self.interpolation, rect, cm); - self.path_y2 = match &self.y2 { + let path_y2 = match &self.y2 { des::series::AreaY2::Baseline(value) => { let mut pb = geom::PathBuilder::new(); - let path_y1 = self.path_y1.as_ref().unwrap(); let x1 = path_y1.points().first().unwrap().x; let x2 = path_y1.points().last().unwrap().x; let y = cm.y.map_coord_num(*value); @@ -993,75 +991,70 @@ impl Area { let (_, y2) = plot_to_fig(rect, x2, y); pb.move_to(x1, y1); pb.line_to(x2, y2); - Some(pb.finish().expect("Should be a valid path")) + pb.finish().expect("Should be a valid path") } des::series::AreaY2::DataCol(y2_col, interpolation) => { let y2_col = get_column(y2_col, data_source).unwrap(); - let path = calc_xy_line_path(x_col, y2_col, *interpolation, rect, cm); - Some(path) + calc_xy_line_path(x_col, y2_col, *interpolation, rect, cm) } }; - self.path_fill = self.fill.as_ref().map(|_| { - let path_y1 = self.path_y1.as_ref().unwrap(); - let path_y2 = self.path_y2.as_ref().unwrap(); - - let mut pb = geom::PathBuilder::new(); - // For some reason, pb.push_path doesn't work (it inserts a line back to the beginning) - for seg in path_y1.segments() { - match seg { - PathSegment::MoveTo(p) => { - pb.move_to(p.x, p.y); - } - PathSegment::LineTo(p) => { - pb.line_to(p.x, p.y); - } - PathSegment::QuadTo(p1, p) => { - pb.quad_to(p1.x, p1.y, p.x, p.y); - } - PathSegment::CubicTo(p1, p2, p) => { - pb.cubic_to(p1.x, p1.y, p2.x, p2.y, p.x, p.y); - } - PathSegment::Close => { - pb.close(); - } + let mut pb = geom::PathBuilder::new(); + // For some reason, pb.push_path doesn't work (it inserts a line back to the beginning) + for seg in path_y1.segments() { + match seg { + PathSegment::MoveTo(p) => { + pb.move_to(p.x, p.y); + } + PathSegment::LineTo(p) => { + pb.line_to(p.x, p.y); + } + PathSegment::QuadTo(p1, p) => { + pb.quad_to(p1.x, p1.y, p.x, p.y); + } + PathSegment::CubicTo(p1, p2, p) => { + pb.cubic_to(p1.x, p1.y, p2.x, p2.y, p.x, p.y); + } + PathSegment::Close => { + pb.close(); } } + } - let mut linked = false; - for seg in geom::path_segments_rev_iter(path_y2) { - match seg { - PathSegment::MoveTo(p) => { - debug_assert!(!linked); - if !linked { - pb.line_to(p.x, p.y); - linked = true; - } else { - pb.move_to(p.x, p.y); - } - } - PathSegment::LineTo(p) => { - debug_assert!(linked, "Should have made linked already"); + let mut linked = false; + for seg in geom::path_segments_rev_iter(&path_y2) { + match seg { + PathSegment::MoveTo(p) => { + debug_assert!(!linked); + if !linked { pb.line_to(p.x, p.y); - } - PathSegment::QuadTo(p1, p) => { - debug_assert!(linked, "Should have made linked already"); - pb.quad_to(p1.x, p1.y, p.x, p.y); - } - PathSegment::CubicTo(p1, p2, p) => { - debug_assert!(linked, "Should have made linked already"); - pb.cubic_to(p1.x, p1.y, p2.x, p2.y, p.x, p.y); - } - PathSegment::Close => { - println!("Z"); - pb.close(); + linked = true; + } else { + pb.move_to(p.x, p.y); } } + PathSegment::LineTo(p) => { + debug_assert!(linked, "Should have made linked already"); + pb.line_to(p.x, p.y); + } + PathSegment::QuadTo(p1, p) => { + debug_assert!(linked, "Should have made linked already"); + pb.quad_to(p1.x, p1.y, p.x, p.y); + } + PathSegment::CubicTo(p1, p2, p) => { + debug_assert!(linked, "Should have made linked already"); + pb.cubic_to(p1.x, p1.y, p2.x, p2.y, p.x, p.y); + } + PathSegment::Close => { + println!("Z"); + pb.close(); + } } - pb.close(); - let p = pb.finish().expect("Should be a valid path"); - p - }); + } + pb.close(); + self.path_fill = Some(pb.finish().expect("Should be a valid path")); + self.path_y1 = Some(path_y1); + self.path_y2 = Some(path_y2); } fn draw(&self, surface: &mut S, style: &Style) @@ -1070,16 +1063,15 @@ impl Area { { let rc = (style, self.index); - if let (Some(fp), Some(fill)) = (&self.path_fill, &self.fill) { - let path = render::Path { - path: fp, - fill: Some(fill.as_paint(&rc)), - stroke: None, - transform: None, - }; - surface.draw_path(&path); - } - if let (Some(sp), Some(stroke)) = (&self.path_y1, &self.stroke_y1) { + let rpath = render::Path { + path: self.path_fill.as_ref().unwrap(), + fill: Some(self.fill.as_paint(&rc)), + stroke: None, + transform: None, + }; + surface.draw_path(&rpath); + + if let (Some(sp), Some(stroke)) = (&self.path_y1, &self.y1_stroke) { let path = render::Path { path: sp, fill: None, @@ -1088,7 +1080,7 @@ impl Area { }; surface.draw_path(&path); } - if let (Some(sp), Some(stroke)) = (&self.path_y2, &self.stroke_y2) { + if let (Some(sp), Some(stroke)) = (&self.path_y2, &self.y2_stroke) { let path = render::Path { path: sp, fill: None, @@ -1111,7 +1103,7 @@ struct HistBin { #[derive(Debug, Clone)] struct Histogram { index: usize, - data_col: des::DataCol, + x_col: des::DataCol, bin_count: u32, density: bool, ab: (axis::NumBounds, axis::NumBounds), @@ -1132,8 +1124,8 @@ impl Histogram { where D: data::Source + ?Sized, { - let data_col = hist.data().clone(); - let col = get_column(&data_col, data_source)?; + let x_col = hist.x_data().clone(); + let col = get_column(&x_col, data_source)?; let col = col.f64().ok_or(Error::InconsistentData( "Histogram data must be numeric".into(), ))?; @@ -1148,7 +1140,7 @@ impl Histogram { Ok(Histogram { index, - data_col, + x_col, bin_count: hist.bins(), density: hist.density(), ab: (x_bounds, y_bounds), @@ -1156,7 +1148,7 @@ impl Histogram { bins, path: None, fill: hist.fill().clone(), - line: hist.outline().cloned(), + line: hist.stroke().cloned(), updated_once: false, }) } @@ -1203,7 +1195,7 @@ impl Histogram { // no need to recalculate bins, as first call is made with the same data_source as prepare } else { let x_bounds = self.ab.0; - let col = get_column(&self.data_col, data_source).expect("TODO: error handling"); + let col = get_column(&self.x_col, data_source).expect("TODO: error handling"); let col = col.f64().expect("TODO: error handling"); let bins = Self::calc_bins(col, x_bounds, self.bin_count, self.density) .expect("TODO: error handling"); @@ -1306,7 +1298,7 @@ impl Bars { position: des.position().clone(), path: None, fill: des.fill().clone(), - line: des.outline().cloned(), + line: des.stroke().cloned(), }) } diff --git a/src/style.rs b/src/style.rs index d7d8955..f6a7271 100644 --- a/src/style.rs +++ b/src/style.rs @@ -146,7 +146,7 @@ impl ResolveColor for (&Style, usize) { } fn add_opacity(c: Rgba8, opacity: Option) -> Rgba8 { - debug_assert!(opacity.map_or(true, |t| t >= 0.0 && t <= 1.0)); + debug_assert!(opacity.is_none_or(|t| (0.0..=1.0).contains(&t))); match opacity { Some(opacity) => Rgba8::new(c.r(), c.g(), c.b(), (c.a() as f32 * opacity).round() as u8), @@ -170,23 +170,21 @@ impl Default for Dash { } /// Line pattern defines how the line is drawn -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum LinePattern { /// Solid line + #[default] Solid, - /// Dashed line. The pattern is relative to the line width. - Dash(Dash), + /// Dashed line. Equivalent to Dash(vec![5.0, 5.0]) + Dashed, /// Dotted line. Equivalent to Dash(vec![1.0, 1.0]) Dot, /// Dash-dot line. Equivalent to Dash(vec![5.0, 5.0, 1.0, 5.0]) DashDot, + /// Dashed line. The pattern is relative to the line width. + Dash(Dash), } -impl Default for LinePattern { - fn default() -> Self { - LinePattern::Solid - } -} impl From for LinePattern { fn from(dash: Dash) -> Self { @@ -210,6 +208,7 @@ pub struct Stroke { pub opacity: Option, } +const DASHED_DASH: &[f32] = &[5.0, 5.0]; const DOT_DASH: &[f32] = &[1.0, 1.0]; const DASH_DOT_DASH: &[f32] = &[5.0, 5.0, 1.0, 5.0]; @@ -241,9 +240,10 @@ impl Stroke { let pattern = match &self.pattern { LinePattern::Solid => render::LinePattern::Solid, - LinePattern::Dash(Dash(a)) => render::LinePattern::Dash(a.as_slice()), + LinePattern::Dashed => render::LinePattern::Dash(DASHED_DASH), LinePattern::Dot => render::LinePattern::Dash(DOT_DASH), LinePattern::DashDot => render::LinePattern::Dash(DASH_DOT_DASH), + LinePattern::Dash(Dash(a)) => render::LinePattern::Dash(a.as_slice()), }; render::Stroke { diff --git a/tests/src/tests/axes.rs b/tests/src/tests/axes.rs index 76d1160..334f160 100644 --- a/tests/src/tests/axes.rs +++ b/tests/src/tests/axes.rs @@ -212,7 +212,7 @@ fn axes_categories() { let y = vec![1.0, 1.4, 3.0]; let series = des::series::Bars::new(x.into(), y.into()) .with_fill(color::TRANSPARENT.into()) - .with_outline(Default::default()); + .with_stroke(Default::default()); let plot = des::Plot::new(vec![series.into()]) .with_x_axis(des::Axis::new().with_ticks(Default::default())); diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index 37b5363..a4ba174 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -148,18 +148,18 @@ fn series_area_double() { des::data_inline(y1.clone()), des::data_inline(y2.clone()).into(), ) - .with_fill(Some(fill)) - .with_y1_line(stroke.clone()) - .with_y2_line(stroke.clone()) + .with_fill(fill) + .with_y1_stroke(stroke.clone()) + .with_y2_stroke(stroke.clone()) .into(), des::series::Area::new( des::data_inline(x.clone()), des::data_inline(y2.clone()), Default::default(), ) - .with_fill(Some(fill)) - .with_y1_line(stroke.clone()) - .with_y2_line(stroke.clone()) + .with_fill(fill) + .with_y1_stroke(stroke.clone()) + .with_y2_stroke(stroke.clone()) .into(), ]); let fig = fig_small(plot); @@ -184,9 +184,9 @@ fn series_area_double_legend() { des::data_inline(y2.clone()).into(), ) .with_name("area1") - .with_fill(Some(fill1)) - .with_y1_line(stroke.clone()) - .with_y2_line(stroke.clone()) + .with_fill(fill1) + .with_y1_stroke(stroke.clone()) + .with_y2_stroke(stroke.clone()) .into(), des::series::Area::new( des::data_inline(x.clone()), @@ -194,9 +194,9 @@ fn series_area_double_legend() { Default::default(), ) .with_name("area2") - .with_fill(Some(fill2)) - .with_y1_line(stroke.clone()) - .with_y2_line(stroke.clone()) + .with_fill(fill2) + .with_y1_stroke(stroke.clone()) + .with_y2_stroke(stroke.clone()) .into(), ]); let fig = fig_small(plot).with_legend(Default::default()); From 447b5e4ae8bf6aecacadeb0cdc59beb4d44b7426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Sat, 16 May 2026 01:11:06 +0200 Subject: [PATCH 11/11] PxlRender --- Cargo.lock | 7 +-- examples/common/mod.rs | 2 +- iced/src/show.rs | 4 +- pxl/Cargo.toml | 1 + pxl/src/lib.rs | 104 +++++++++++++++++++++-------------------- 5 files changed, 62 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7ae1a6..8214a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,7 +2377,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png 0.18.0", + "png 0.18.1", "qoi", "ravif", "rayon", @@ -3808,6 +3808,7 @@ name = "plotive-pxl" version = "0.4.0" dependencies = [ "plotive", + "png 0.17.16", "rustybuzz", "tiny-skia", "tiny-skia-path", @@ -3869,9 +3870,9 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.10.0", "crc32fast", diff --git a/examples/common/mod.rs b/examples/common/mod.rs index eeddafe..a882bb1 100644 --- a/examples/common/mod.rs +++ b/examples/common/mod.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use plotive::{Prepare, Style, data, des, fontdb}; use plotive_iced::Show; -use plotive_pxl::SavePng; +use plotive_pxl::PxlRender; use plotive_svg::SaveSvg; use rand::SeedableRng; diff --git a/iced/src/show.rs b/iced/src/show.rs index 7ff62c8..327f94a 100644 --- a/iced/src/show.rs +++ b/iced/src/show.rs @@ -519,7 +519,7 @@ where self.fig_scale = scale; } Message::ExportPng => { - use plotive_pxl::SavePng; + use plotive_pxl::PxlRender; let filename = rfd::FileDialog::new() .set_title("Save figure as PNG") .add_filter("PNG Image", &["png"]) @@ -576,7 +576,7 @@ where Message::ExportClipboard => { use std::borrow::Cow; - use plotive_pxl::ToPixmap; + use plotive_pxl::PxlRender; let style = if let Some(style) = &self.style { style.clone() diff --git a/pxl/Cargo.toml b/pxl/Cargo.toml index 0354bc4..cce79cc 100644 --- a/pxl/Cargo.toml +++ b/pxl/Cargo.toml @@ -15,3 +15,4 @@ rustybuzz.workspace = true tiny-skia.workspace = true tiny-skia-path.workspace = true ttf-parser.workspace = true +png = "0.17.16" diff --git a/pxl/src/lib.rs b/pxl/src/lib.rs index 769a74e..d21184e 100644 --- a/pxl/src/lib.rs +++ b/pxl/src/lib.rs @@ -9,6 +9,7 @@ pub enum Error { Io(io::Error), Drawing(drawing::Error), InvalidSurfaceSize(u32, u32), + PngEncoding(png::EncodingError), } impl From for Error { @@ -23,22 +24,34 @@ impl From for Error { } } +impl From for Error { + fn from(err: png::EncodingError) -> Self { + Error::PngEncoding(err) + } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::Io(err) => write!(f, "IO error: {}", err), Error::Drawing(err) => write!(f, "Drawing error: {}", err), Error::InvalidSurfaceSize(w, h) => write!(f, "Invalid surface size: {}x{}", w, h), + Error::PngEncoding(err) => write!(f, "PNG encoding error: {}", err), } } } impl std::error::Error for Error {} -/// Parameters needed for saving a figure as PNG +/// Parameters needed for rendering a figure on a pixel surface #[derive(Debug, Clone)] pub struct Params<'a> { + /// Styling palette pub style: Style, + /// Scale factor between figure point size and pixel surface dimensions + /// + /// e.g. a scale of 2.0 and figure size of 800x600 pts will result in an image of 1600x1200 + /// pixels, thus doubling the resolution pub scale: f32, /// Optional font database to use for text rendering /// This parameter is ignored when saving a prepared figure, @@ -57,20 +70,24 @@ impl Default for Params<'_> { } } -/// Trait for saving a figure as PNG file -pub trait SavePng { - /// Save the figure as a PNG file at the given path. +pub trait PxlRender { + /// Rasterizes the figure on a `tiny_skia::Pixmap` /// /// The data source parameter is ignored when saving a prepared figure, /// as the data has already been resolved. /// Therefore, this parameter can be left to `&()` when saving a prepared figure. + fn to_pixmap(&self, data_src: &D, params: Params) -> Result + where + D: plotive::data::Source + ?Sized; + + /// Render the figure and return PNG data. /// /// # Example /// /// ```rust /// use plotive::des; /// use plotive::Prepare; - /// use plotive_pxl::{SavePng, Params}; + /// use plotive_pxl::{PxlRender, Params}; /// /// // Create your figure design (this one has inline data for simplicity) /// let fig = des::series::Line::new( @@ -80,62 +97,49 @@ pub trait SavePng { /// .into_figure(); /// /// // data source is not needed for inline data - /// fig.save_png("figure.png", &(), Default::default()).unwrap(); - /// # std::fs::remove_file("figure.png").unwrap(); + /// let data = fig.to_png_data(&(), Default::default()).unwrap(); /// ``` - fn save_png(&self, path: P, data_src: &D, params: Params) -> Result<(), Error> + fn to_png_data(&self, data_src: &D, params: Params) -> Result, Error> where - P: AsRef, - D: plotive::data::Source + ?Sized; -} - -impl SavePng for plotive::des::Figure { - fn save_png(&self, path: P, data_src: &D, params: Params) -> Result<(), Error> - where - P: AsRef, D: plotive::data::Source + ?Sized, { - use plotive::Prepare; - - let prepared = self.prepare(data_src, params.fontdb)?; - - prepared.save_png(path, &(), params) + let pixmap = self.to_pixmap(data_src, params)?; + let png_data = pixmap.encode_png()?; + Ok(png_data) } -} -impl SavePng for drawing::PreparedFigure { - fn save_png(&self, path: P, _data_src: &D, params: Params) -> Result<(), Error> + /// Save the figure as a PNG file at the given path. + /// + /// # Example + /// + /// ```rust + /// use plotive::des; + /// use plotive::Prepare; + /// use plotive_pxl::{PxlRender, Params}; + /// + /// // Create your figure design (this one has inline data for simplicity) + /// let fig = des::series::Line::new( + /// des::data_inline(vec![0.0, 1.0, 2.0]), + /// des::data_inline(vec![0.0, 1.0, 0.0]), + /// ).into_plot() + /// .into_figure(); + /// + /// // data source is not needed for inline data + /// fig.save_png("figure.png", &(), Default::default()).unwrap(); + /// # std::fs::remove_file("figure.png").unwrap(); + /// ``` + fn save_png(&self, path: P, data_src: &D, params: Params) -> Result<(), Error> where P: AsRef, D: plotive::data::Source + ?Sized, { - let size = self.size(); - let witdth = (size.width() * params.scale) as u32; - let height = (size.height() * params.scale) as u32; - - let mut surface = - PxlSurface::new(witdth, height).ok_or(Error::InvalidSurfaceSize(witdth, height))?; - - self.draw(&mut surface, ¶ms.style); - - surface.save_png(path)?; + let pixmap = self.to_pixmap(data_src, params)?; + pixmap.save_png(path)?; Ok(()) } } -/// Trait for rasterizing a figure to a `tiny_skia::Pixmap` -pub trait ToPixmap { - /// Rasterizes the figure on a `tiny_skia::Pixmap` - /// - /// The data source parameter is ignored when saving a prepared figure, - /// as the data has already been resolved. - /// Therefore, this parameter can be left to `&()` when saving a prepared figure. - fn to_pixmap(&self, data_src: &D, params: Params) -> Result - where - D: plotive::data::Source + ?Sized; -} - -impl ToPixmap for plotive::des::Figure { +impl PxlRender for plotive::des::Figure { fn to_pixmap(&self, data_src: &D, params: Params) -> Result where D: plotive::data::Source + ?Sized, @@ -148,14 +152,14 @@ impl ToPixmap for plotive::des::Figure { } } -impl ToPixmap for drawing::PreparedFigure { +impl PxlRender for drawing::PreparedFigure { fn to_pixmap(&self, _data_src: &D, params: Params) -> Result where D: plotive::data::Source + ?Sized, { let size = self.size(); - let width = (size.width() * params.scale) as u32; - let height = (size.height() * params.scale) as u32; + let width = (size.width() * params.scale).round() as u32; + let height = (size.height() * params.scale).round() as u32; let mut surface = PxlSurface::new(width, height).ok_or(Error::InvalidSurfaceSize(width, height))?;