From 267fa1451cc8d55de38f153f2dd166ff3913d81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Thu, 16 Apr 2026 05:40:14 +0200 Subject: [PATCH 1/3] Detect too large tick labels creating negative fig. size --- src/drawing.rs | 3 +++ src/drawing/plot.rs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/drawing.rs b/src/drawing.rs index ce83dd1..a41a8a3 100644 --- a/src/drawing.rs +++ b/src/drawing.rs @@ -46,6 +46,8 @@ pub enum Error { InconsistentData(String), /// Font or text related error, e.g. missing glyphs or font not found FontOrText(text::Error), + /// Not enough space to draw the figure, e.g. due to too many ticks or too long tick labels + NotEnoughSpace, } impl From for Error { @@ -73,6 +75,7 @@ impl fmt::Display for Error { } Error::InconsistentData(reason) => write!(f, "Inconsistent data: {}", reason), Error::FontOrText(err) => err.fmt(f), + Error::NotEnoughSpace => write!(f, "Not enough space to draw the figure"), } } } diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 980b1c7..7064a40 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -319,6 +319,9 @@ where // Now we can determine width of horizontal axes and set them all up let subplot_rect_width = (rect.width() - vert_space_width) / des_plots.cols() as f32; + if subplot_rect_width <= 0.0 || subplot_rect_height <= 0.0 { + return Err(Error::NotEnoughSpace); + } let x_axes = self.setup_orientation_axes(Orientation::X, des_plots, &plot_data, subplot_rect_width)?; From c55f137d968b99b48351582c71d524a1fabaecb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Thu, 16 Apr 2026 05:43:48 +0200 Subject: [PATCH 2/3] handling of empty datasets --- src/data.rs | 5 + src/drawing/series.rs | 153 ++++++++++++++++++++------- tests/refs/series/line-nodata.png | Bin 0 -> 10218 bytes tests/refs/series/line-nodata.svg | 4 + tests/refs/series/scatter-nodata.png | Bin 0 -> 10218 bytes tests/refs/series/scatter-nodata.svg | 4 + tests/src/tests.rs | 1 + tests/src/tests/axes.rs | 10 ++ tests/src/tests/series.rs | 31 ++++++ 9 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 tests/refs/series/line-nodata.png create mode 100644 tests/refs/series/line-nodata.svg create mode 100644 tests/refs/series/scatter-nodata.png create mode 100644 tests/refs/series/scatter-nodata.svg create mode 100644 tests/src/tests/series.rs diff --git a/src/data.rs b/src/data.rs index c4c6d41..5a6bafc 100644 --- a/src/data.rs +++ b/src/data.rs @@ -365,6 +365,11 @@ pub trait Column: std::fmt::Debug { /// Get the number of non-null values in the column fn len_some(&self) -> usize; + /// Check if the column is empty (i.e. has no samples) + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Get an iterator over the samples in the column fn sample_iter(&self) -> Box> + '_> { #[cfg(feature = "time")] diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 98e6a9b..81b0127 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -82,7 +82,7 @@ fn calc_xy_bounds( data_source: &D, x_data: &des::series::DataCol, y_data: &des::series::DataCol, -) -> Result<(axis::Bounds, axis::Bounds), Error> +) -> Result, Error> where D: data::Source + ?Sized, { @@ -95,10 +95,14 @@ where )); } + if x_col.is_empty() { + return Ok(None); + } + let x_bounds = x_col.bounds().ok_or(Error::UnboundedAxis)?; let y_bounds = y_col.bounds().ok_or(Error::UnboundedAxis)?; - Ok((x_bounds, y_bounds)) + Ok(Some((x_bounds, y_bounds))) } #[derive(Debug, Clone)] @@ -192,9 +196,13 @@ impl Series { continue; } + let Some(sb) = s.bounds() else { + continue; + }; + let b = match or { - Orientation::X => &s.bounds().0, - Orientation::Y => &s.bounds().1, + Orientation::X => &sb.0, + Orientation::Y => &sb.1, }; if let Some(a) = &mut a { @@ -206,15 +214,21 @@ impl Series { Ok(a) } - fn bounds(&self) -> (axis::BoundsRef<'_>, axis::BoundsRef<'_>) { + fn bounds(&self) -> Option<(axis::BoundsRef<'_>, axis::BoundsRef<'_>)> { match &self.plot { - SeriesPlot::Line(line) => (line.ab.0.as_bound_ref(), line.ab.1.as_bound_ref()), - SeriesPlot::Scatter(scatter) => { - (scatter.ab.0.as_bound_ref(), scatter.ab.1.as_bound_ref()) - } - SeriesPlot::Histogram(hist) => (hist.ab.0.into(), hist.ab.1.into()), + SeriesPlot::Line(line) => line + .ab + .as_ref() + .map(|(x, y)| (x.as_bound_ref(), y.as_bound_ref())), + SeriesPlot::Scatter(scatter) => scatter + .ab + .as_ref() + .map(|(x, y)| (x.as_bound_ref(), y.as_bound_ref())), + SeriesPlot::Histogram(hist) => Some((hist.ab.0.into(), hist.ab.1.into())), SeriesPlot::Bars(bars) => bars.bounds(), - SeriesPlot::BarsGroup(bg) => (bg.bounds.0.as_bound_ref(), bg.bounds.1.as_bound_ref()), + SeriesPlot::BarsGroup(bg) => { + Some((bg.bounds.0.as_bound_ref(), bg.bounds.1.as_bound_ref())) + } } } @@ -283,7 +297,7 @@ impl Series { struct Line { index: usize, cols: (des::DataCol, des::DataCol), - ab: (axis::Bounds, axis::Bounds), + ab: Option<(axis::Bounds, axis::Bounds)>, axes: (des::axis::Ref, des::axis::Ref), path: Option, stroke: style::series::Stroke, @@ -486,11 +500,11 @@ impl Line { D: data::Source + ?Sized, { let cols = (des.x_data().clone(), des.y_data().clone()); - let (x_bounds, y_bounds) = calc_xy_bounds(data_source, &cols.0, &cols.1)?; + let xy_bounds = calc_xy_bounds(data_source, &cols.0, &cols.1)?; Ok(Line { index, cols, - ab: (x_bounds, y_bounds), + ab: xy_bounds, axes: (des.x_axis().clone(), des.y_axis().clone()), path: None, stroke: des.stroke().clone(), @@ -508,6 +522,18 @@ impl Line { debug_assert!(x_col.len() == y_col.len()); + if self.ab.is_none() && x_col.is_empty() { + self.path = None; + return; + } + + if self.ab.is_none() && !x_col.is_empty() { + let xy_bounds = calc_xy_bounds(data_source, &self.cols.0, &self.cols.1) + .expect("Should be able to calculate bounds for non-empty data") + .expect("Should be able to calculate bounds for non-empty data"); + self.ab = Some(xy_bounds); + } + let path = match self.interpolation { des::series::Interpolation::Linear => { let mut liner = LinearLiner::new(x_col.len()); @@ -588,6 +614,10 @@ impl Line { where S: render::Surface, { + if self.path.is_none() { + return; + } + let rc = (style, self.index); let path = render::Path { @@ -604,7 +634,7 @@ impl Line { struct Scatter { index: usize, cols: (des::DataCol, des::DataCol), - ab: (axis::Bounds, axis::Bounds), + ab: Option<(axis::Bounds, axis::Bounds)>, axes: (des::axis::Ref, des::axis::Ref), path: geom::Path, points: Vec, @@ -617,12 +647,12 @@ impl Scatter { D: data::Source + ?Sized, { let cols = (des.x_data().clone(), des.y_data().clone()); - let (x_bounds, y_bounds) = calc_xy_bounds(data_source, &cols.0, &cols.1)?; + let xy_bounds = calc_xy_bounds(data_source, &cols.0, &cols.1)?; let path = marker::marker_path(des.marker()); Ok(Scatter { index, cols, - ab: (x_bounds, y_bounds), + ab: xy_bounds, axes: (des.x_axis().clone(), des.y_axis().clone()), path, points: Vec::new(), @@ -638,6 +668,18 @@ impl Scatter { let y_col = get_column(&self.cols.1, data_source).unwrap(); debug_assert!(x_col.len() == y_col.len()); + if self.ab.is_none() && x_col.is_empty() { + self.points.clear(); + return; + } + + if self.ab.is_none() && !x_col.is_empty() { + let xy_bounds = calc_xy_bounds(data_source, &self.cols.0, &self.cols.1) + .expect("Should be able to calculate bounds for non-empty data") + .expect("Should be able to calculate bounds for non-empty data"); + self.ab = Some(xy_bounds); + } + let mut points = Vec::with_capacity(x_col.len()); for (x, y) in x_col.sample_iter().zip(y_col.sample_iter()) { @@ -656,6 +698,10 @@ impl Scatter { where S: render::Surface, { + if self.points.is_empty() { + return; + } + let rc = (style, self.index); for p in &self.points { @@ -788,11 +834,31 @@ enum BarsBounds { Horizontal(axis::NumBounds, Categories), } +impl BarsBounds { + fn calc(x_bounds: axis::Bounds, y_bounds: axis::Bounds) -> Result { + match (x_bounds, y_bounds) { + (axis::Bounds::Num(mut x_bounds), axis::Bounds::Cat(y_bounds)) => { + x_bounds.add_sample(0.0); + Ok(BarsBounds::Horizontal(x_bounds, y_bounds)) + } + (axis::Bounds::Cat(x_bounds), axis::Bounds::Num(mut y_bounds)) => { + y_bounds.add_sample(0.0); + Ok(BarsBounds::Vertical(x_bounds, y_bounds)) + } + _ => { + return Err(Error::InconsistentData( + "One of X and Y data must be numeric and the other categorical".to_string(), + )); + } + } + } +} + #[derive(Debug, Clone)] struct Bars { index: usize, cols: (des::DataCol, des::DataCol), - bounds: BarsBounds, + bounds: Option, axes: (des::axis::Ref, des::axis::Ref), position: des::series::BarsPosition, path: Option, @@ -806,22 +872,12 @@ impl Bars { D: data::Source + ?Sized, { let cols = (des.x_data().clone(), des.y_data().clone()); - let (x_bounds, y_bounds) = calc_xy_bounds(data_source, &cols.0, &cols.1)?; + let xy_bounds = calc_xy_bounds(data_source, &cols.0, &cols.1)?; - let bounds = match (x_bounds, y_bounds) { - (axis::Bounds::Num(mut x_bounds), axis::Bounds::Cat(y_bounds)) => { - x_bounds.add_sample(0.0); - BarsBounds::Horizontal(x_bounds, y_bounds) - } - (axis::Bounds::Cat(x_bounds), axis::Bounds::Num(mut y_bounds)) => { - y_bounds.add_sample(0.0); - BarsBounds::Vertical(x_bounds, y_bounds) - } - _ => { - return Err(Error::InconsistentData( - "One of X and Y data must be numeric and the other categorical".to_string(), - )); - } + let bounds = if let Some((x_bounds, y_bounds)) = xy_bounds { + Some(BarsBounds::calc(x_bounds, y_bounds)?) + } else { + None }; Ok(Bars { @@ -836,10 +892,14 @@ impl Bars { }) } - fn bounds(&self) -> (axis::BoundsRef<'_>, axis::BoundsRef<'_>) { - match &self.bounds { - &BarsBounds::Vertical(ref x_bounds, y_bounds) => (x_bounds.into(), y_bounds.into()), - &BarsBounds::Horizontal(x_bounds, ref y_bounds) => (x_bounds.into(), y_bounds.into()), + fn bounds(&self) -> Option<(axis::BoundsRef<'_>, axis::BoundsRef<'_>)> { + match self.bounds.as_ref()? { + &BarsBounds::Vertical(ref x_bounds, y_bounds) => { + Some((x_bounds.into(), y_bounds.into())) + } + &BarsBounds::Horizontal(x_bounds, ref y_bounds) => { + Some((x_bounds.into(), y_bounds.into())) + } } } @@ -852,9 +912,22 @@ impl Bars { let y_col = get_column(&self.cols.1, data_source).unwrap(); debug_assert!(x_col.len() == y_col.len()); + if self.bounds.is_none() && x_col.is_empty() && y_col.is_empty() { + // no valid data, so no bars to draw + self.path = None; + return; + } + + if self.bounds.is_none() { + let xy_bounds = calc_xy_bounds(data_source, &self.cols.0, &self.cols.1) + .expect("Should be able to calculate bounds for non-empty data") + .expect("Should be able to calculate bounds for non-empty data"); + self.bounds = Some(BarsBounds::calc(xy_bounds.0, xy_bounds.1).expect("Should be able to calculate bars bounds")); + } + let mut pb = geom::PathBuilder::new(); - match &self.bounds { + match self.bounds.as_ref().unwrap() { BarsBounds::Vertical(..) => { let cat_bin_width = cm.x.cat_bin_size(); let y_start = rect.bottom() - cm.y.map_coord_num(0.0); @@ -903,6 +976,10 @@ impl Bars { where S: render::Surface, { + if self.path.is_none() { + return; + } + let rc = (style, self.index); let path = render::Path { diff --git a/tests/refs/series/line-nodata.png b/tests/refs/series/line-nodata.png new file mode 100644 index 0000000000000000000000000000000000000000..ea81b360256260b6d36be3ea87ce20eb916a3432 GIT binary patch literal 10218 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvi|3k+<8Q9%5in-{|S$7*fIb_5kw&mB?ww zKK?I%*L&j3@*?l#b-A~5 zDY0MIH|NZTn6mEIb;rSA{7?Qbmh(SbLLC0p{(O4vwe*kj95>DWJK8-jwrjutYwi6< zTjPIkg?h8T@BYuZ_aAPJd%g9?oAdv2{xt(b#{R_fnsd(+W}pA}<~%f*{#$~yv)``y zmR=7uYeMd4`6tCdp$%u#>%XN#!|%Tg(CYckxpu!d+t2>5aAMm}{!bv++<3Ox{`Y3M zjpu*PIe*Y>{@pe}Zah XcXPA#C%*@-;bZW0^>bP0l+XkKXbbEY literal 0 HcmV?d00001 diff --git a/tests/refs/series/line-nodata.svg b/tests/refs/series/line-nodata.svg new file mode 100644 index 0000000..0522d6d --- /dev/null +++ b/tests/refs/series/line-nodata.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/refs/series/scatter-nodata.png b/tests/refs/series/scatter-nodata.png new file mode 100644 index 0000000000000000000000000000000000000000..ea81b360256260b6d36be3ea87ce20eb916a3432 GIT binary patch literal 10218 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvi|3k+<8Q9%5in-{|S$7*fIb_5kw&mB?ww zKK?I%*L&j3@*?l#b-A~5 zDY0MIH|NZTn6mEIb;rSA{7?Qbmh(SbLLC0p{(O4vwe*kj95>DWJK8-jwrjutYwi6< zTjPIkg?h8T@BYuZ_aAPJd%g9?oAdv2{xt(b#{R_fnsd(+W}pA}<~%f*{#$~yv)``y zmR=7uYeMd4`6tCdp$%u#>%XN#!|%Tg(CYckxpu!d+t2>5aAMm}{!bv++<3Ox{`Y3M zjpu*PIe*Y>{@pe}Zah XcXPA#C%*@-;bZW0^>bP0l+XkKXbbEY literal 0 HcmV?d00001 diff --git a/tests/refs/series/scatter-nodata.svg b/tests/refs/series/scatter-nodata.svg new file mode 100644 index 0000000..0522d6d --- /dev/null +++ b/tests/refs/series/scatter-nodata.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/src/tests.rs b/tests/src/tests.rs index af84a3b..80e9ae7 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -46,6 +46,7 @@ mod axes; mod interp; mod legend; mod nulls; +mod series; mod subplots; #[test] diff --git a/tests/src/tests/axes.rs b/tests/src/tests/axes.rs index 3d10a34..94d4161 100644 --- a/tests/src/tests/axes.rs +++ b/tests/src/tests/axes.rs @@ -13,6 +13,16 @@ fn axes_default() { assert_fig_eq_ref!(&fig, "axes/default"); } +// #[test] +// fn axes_ticks_empty() { +// let plot = des::Plot::new(vec![]) +// .with_x_axis(des::Axis::new().with_ticks(Default::default())) +// .with_y_axis(des::Axis::new().with_ticks(Default::default())); +// let fig = fig_small(plot); + +// assert_fig_eq_ref!(&fig, "axes/ticks-empty"); +// } + #[test] fn axes_x_title() { let series = line().into(); diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs new file mode 100644 index 0000000..c26e4b5 --- /dev/null +++ b/tests/src/tests/series.rs @@ -0,0 +1,31 @@ +use plotive::{data, des}; + +use crate::{assert_fig_eq_ref, tests::fig_small}; +use crate::TestHarness; + + +#[test] +fn series_line_nodata() { + let plot = des::Plot::new(vec![ + des::series::Line::new( + des::DataCol::Inline(data::VecColumn::F64(vec![])), + des::DataCol::Inline(data::VecColumn::F64(vec![])), + ).into() + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-nodata"); +} + +#[test] +fn series_scatter_nodata() { + let plot = des::Plot::new(vec![ + des::series::Scatter::new( + des::DataCol::Inline(data::VecColumn::F64(vec![])), + des::DataCol::Inline(data::VecColumn::F64(vec![])), + ).into() + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/scatter-nodata"); +} From d7621e52be4264eb05fc51c4688a5a40f3687101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Fri, 17 Apr 2026 22:38:45 +0200 Subject: [PATCH 3/3] cargo +nightly fmt --- src/drawing/series.rs | 5 +++- tests/src/tests/series.rs | 63 ++++++++++++++++++++------------------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 81b0127..ed4d921 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -922,7 +922,10 @@ impl Bars { let xy_bounds = calc_xy_bounds(data_source, &self.cols.0, &self.cols.1) .expect("Should be able to calculate bounds for non-empty data") .expect("Should be able to calculate bounds for non-empty data"); - self.bounds = Some(BarsBounds::calc(xy_bounds.0, xy_bounds.1).expect("Should be able to calculate bars bounds")); + self.bounds = Some( + BarsBounds::calc(xy_bounds.0, xy_bounds.1) + .expect("Should be able to calculate bars bounds"), + ); } let mut pb = geom::PathBuilder::new(); diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index c26e4b5..fa3b25c 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -1,31 +1,32 @@ -use plotive::{data, des}; - -use crate::{assert_fig_eq_ref, tests::fig_small}; -use crate::TestHarness; - - -#[test] -fn series_line_nodata() { - let plot = des::Plot::new(vec![ - des::series::Line::new( - des::DataCol::Inline(data::VecColumn::F64(vec![])), - des::DataCol::Inline(data::VecColumn::F64(vec![])), - ).into() - ]); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "series/line-nodata"); -} - -#[test] -fn series_scatter_nodata() { - let plot = des::Plot::new(vec![ - des::series::Scatter::new( - des::DataCol::Inline(data::VecColumn::F64(vec![])), - des::DataCol::Inline(data::VecColumn::F64(vec![])), - ).into() - ]); - let fig = fig_small(plot); - - assert_fig_eq_ref!(&fig, "series/scatter-nodata"); -} +use plotive::{data, des}; + +use crate::tests::fig_small; +use crate::{TestHarness, assert_fig_eq_ref}; + +#[test] +fn series_line_nodata() { + let plot = des::Plot::new(vec![ + des::series::Line::new( + des::DataCol::Inline(data::VecColumn::F64(vec![])), + des::DataCol::Inline(data::VecColumn::F64(vec![])), + ) + .into(), + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/line-nodata"); +} + +#[test] +fn series_scatter_nodata() { + let plot = des::Plot::new(vec![ + des::series::Scatter::new( + des::DataCol::Inline(data::VecColumn::F64(vec![])), + des::DataCol::Inline(data::VecColumn::F64(vec![])), + ) + .into(), + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/scatter-nodata"); +}