From a30642acea6195f548273619a66bb3c37506c96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Tue, 21 Apr 2026 22:15:47 +0200 Subject: [PATCH 1/4] Area series --- .vscode/launch.json | 12 ++ Cargo.toml | 4 + base/src/geom.rs | 281 +++++++++++++++++++++++++ examples/area.rs | 32 +++ src/des/series.rs | 200 ++++++++++++++++++ src/drawing/axis/bounds.rs | 1 + src/drawing/legend.rs | 78 ++++++- src/drawing/plot.rs | 1 + src/drawing/series.rs | 419 +++++++++++++++++++++++++++++-------- 9 files changed, 937 insertions(+), 91 deletions(-) create mode 100644 examples/area.rs diff --git a/.vscode/launch.json b/.vscode/launch.json index 48ab93f..d55c54c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,18 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "area example", + "cargo": { + "args": [ + "build", "--example", "area", + "--features", "utils" + ] + }, + "args": ["png", "svg"] + }, { "type": "lldb", "request": "launch", diff --git a/Cargo.toml b/Cargo.toml index 8514905..4a85858 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,10 @@ noto-serif-italic = ["plotive-text/noto-serif-italic"] time = [] utils = [] +[[example]] +name = "area" +required-features = ["utils"] + [[example]] name = "bars" diff --git a/base/src/geom.rs b/base/src/geom.rs index 3b662e3..d29df52 100644 --- a/base/src/geom.rs +++ b/base/src/geom.rs @@ -7,6 +7,7 @@ */ use strict_num::{FiniteF32, PositiveF32}; +use std::marker::PhantomData; pub use tiny_skia_path::{Path, PathBuilder, PathSegment, PathVerb, Point, Transform}; /// A size in 2D space represented by width and height @@ -604,3 +605,283 @@ impl From<(f32, f32, f32, f32)> for Margin { Margin::Custom { t, r, b, l } } } + +pub fn path_segments_rev_iter<'a>(path: &'a Path) -> PathSegmentsRevIter<'a> { + PathSegmentsRevIter::new(path) +} + +pub fn reverse_path(path: &Path) -> Path { + let mut pb = PathBuilder::new(); + for seg in path_segments_rev_iter(path) { + match seg { + PathSegment::MoveTo(p) => pb.move_to(p.x, p.y), + PathSegment::LineTo(p) => pb.line_to(p.x, p.y), + PathSegment::QuadTo(ctrl, to) => pb.quad_to(ctrl.x, ctrl.y, to.x, to.y), + PathSegment::CubicTo(ctrl1, ctrl2, to) => { + pb.cubic_to(ctrl1.x, ctrl1.y, ctrl2.x, ctrl2.y, to.x, to.y) + } + PathSegment::Close => pb.close(), + } + } + pb.finish().expect("Reversing a valid path should yield a valid path") +} + +pub struct PathSegmentsRevIter<'a> { + segments: Vec, + segment_index: usize, + _path: PhantomData<&'a Path>, +} + +impl PathSegmentsRevIter<'_> { + fn reverse_subpath(subpath: &SubPath, out: &mut Vec) { + let start = subpath.start; + let last = subpath + .segments + .last() + .map(|segment| segment.end()) + .unwrap_or(start); + + out.push(PathSegment::MoveTo(last)); + + for segment in subpath.segments.iter().rev() { + out.push(segment.rev_segment()); + } + + if subpath.closed { + out.push(PathSegment::Close); + } + } + + fn new(path: &Path) -> Self { + let mut subpaths: Vec = Vec::new(); + let mut curr_subpath: Option = None; + let mut points_index = 0; + + for verb in path.verbs() { + match *verb { + PathVerb::Move => { + if let Some(subpath) = curr_subpath.take() { + subpaths.push(subpath); + } + let start = path.points()[points_index]; + points_index += 1; + curr_subpath = Some(SubPath::new(start)); + } + PathVerb::Line => { + let to = path.points()[points_index]; + points_index += 1; + if let Some(subpath) = curr_subpath.as_mut() { + subpath.push_line(to); + } + } + PathVerb::Quad => { + let ctrl = path.points()[points_index]; + let to = path.points()[points_index + 1]; + points_index += 2; + if let Some(subpath) = curr_subpath.as_mut() { + subpath.push_quad(ctrl, to); + } + } + PathVerb::Cubic => { + let ctrl1 = path.points()[points_index]; + let ctrl2 = path.points()[points_index + 1]; + let to = path.points()[points_index + 2]; + points_index += 3; + if let Some(subpath) = curr_subpath.as_mut() { + subpath.push_cubic(ctrl1, ctrl2, to); + } + } + PathVerb::Close => { + if let Some(subpath) = curr_subpath.as_mut() { + subpath.closed = true; + } + } + } + } + + if let Some(subpath) = curr_subpath.take() { + subpaths.push(subpath); + } + + let mut segments: Vec = Vec::new(); + for subpath in subpaths.iter().rev() { + Self::reverse_subpath(subpath, &mut segments); + } + + PathSegmentsRevIter { + segment_index: 0, + segments, + _path: PhantomData, + } + } +} + +impl<'a> Iterator for PathSegmentsRevIter<'a> { + type Item = PathSegment; + + fn next(&mut self) -> Option { + if self.segment_index >= self.segments.len() { + return None; + } + + let segment = self.segments[self.segment_index]; + self.segment_index += 1; + Some(segment) + } +} + +#[derive(Clone, Copy)] +enum SubPathSegment { + Line { + from: Point, + to: Point, + }, + Quad { + from: Point, + ctrl: Point, + to: Point, + }, + Cubic { + from: Point, + ctrl1: Point, + ctrl2: Point, + to: Point, + }, +} + +impl SubPathSegment { + fn end(&self) -> Point { + match self { + SubPathSegment::Line { to, .. } => *to, + SubPathSegment::Quad { to, .. } => *to, + SubPathSegment::Cubic { to, .. } => *to, + } + } + + fn rev_segment(&self) -> PathSegment { + match self { + SubPathSegment::Line { from, .. } => PathSegment::LineTo(*from), + SubPathSegment::Quad { from, ctrl, .. } => PathSegment::QuadTo(*ctrl, *from), + SubPathSegment::Cubic { + from, + ctrl1, + ctrl2, + .. + } => PathSegment::CubicTo(*ctrl2, *ctrl1, *from), + } + } +} + +struct SubPath { + start: Point, + curr: Point, + closed: bool, + segments: Vec, +} + +impl SubPath { + fn new(start: Point) -> Self { + Self { + start, + curr: start, + closed: false, + segments: Vec::new(), + } + } + + fn push_line(&mut self, to: Point) { + self.segments.push(SubPathSegment::Line { + from: self.curr, + to, + }); + self.curr = to; + } + + fn push_quad(&mut self, ctrl: Point, to: Point) { + self.segments.push(SubPathSegment::Quad { + from: self.curr, + ctrl, + to, + }); + self.curr = to; + } + + fn push_cubic(&mut self, ctrl1: Point, ctrl2: Point, to: Point) { + self.segments.push(SubPathSegment::Cubic { + from: self.curr, + ctrl1, + ctrl2, + to, + }); + self.curr = to; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(x: f32, y: f32) -> Point { + Point { x, y } + } + + #[test] + fn reverse_open_path_segments() { + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(1.0, 1.0); + pb.quad_to(2.0, 3.0, 4.0, 5.0); + pb.cubic_to(6.0, 7.0, 8.0, 9.0, 10.0, 11.0); + let path = pb.finish().unwrap(); + + let rev: Vec = path_segments_rev_iter(&path).collect(); + let expected = vec![ + PathSegment::MoveTo(p(10.0, 11.0)), + PathSegment::CubicTo(p(8.0, 9.0), p(6.0, 7.0), p(4.0, 5.0)), + PathSegment::QuadTo(p(2.0, 3.0), p(1.0, 1.0)), + PathSegment::LineTo(p(0.0, 0.0)), + ]; + + assert_eq!(rev, expected); + } + + #[test] + fn reverse_closed_path_segments() { + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(1.0, 0.0); + pb.line_to(1.0, 1.0); + pb.close(); + let path = pb.finish().unwrap(); + + let rev: Vec = path_segments_rev_iter(&path).collect(); + let expected = vec![ + PathSegment::MoveTo(p(1.0, 1.0)), + PathSegment::LineTo(p(1.0, 0.0)), + PathSegment::LineTo(p(0.0, 0.0)), + PathSegment::Close, + ]; + + assert_eq!(rev, expected); + } + + #[test] + fn reverse_multiple_subpaths() { + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(1.0, 0.0); + pb.move_to(5.0, 5.0); + pb.line_to(6.0, 6.0); + let path = pb.finish().unwrap(); + + let rev: Vec = path_segments_rev_iter(&path).collect(); + let expected = vec![ + PathSegment::MoveTo(p(6.0, 6.0)), + PathSegment::LineTo(p(5.0, 5.0)), + PathSegment::MoveTo(p(1.0, 0.0)), + PathSegment::LineTo(p(0.0, 0.0)), + ]; + + assert_eq!(rev, expected); + } +} diff --git a/examples/area.rs b/examples/area.rs new file mode 100644 index 0000000..bef3c11 --- /dev/null +++ b/examples/area.rs @@ -0,0 +1,32 @@ +use plotive::{data, des, utils}; + +mod common; + +fn main() { + let x = utils::linspace(0.0, 5.0, 6); + let y1 = vec![10.0, 15.0, 8.0, 6.0, 12.0, 10.0]; + let y2 = vec![4.0, 9.0, 2.0, 0.0, 6.0, 4.0]; + + let fig = des::Plot::new(vec![ + des::series::Area::new( + des::data_src_ref("x"), + des::data_src_ref("y1"), + des::data_src_ref("y2").into(), + ) + .into(), + des::series::Area::new( + des::data_src_ref("x"), + des::data_src_ref("y2"), + Default::default(), + ) + .into(), + ]) + .into_figure(); + + let data_source = data::TableSource::new() + .with_f64_column("x", x) + .with_f64_column("y1", y1) + .with_f64_column("y2", y2); + + common::save_figure(&fig, &data_source, Default::default(), "area"); +} diff --git a/src/des/series.rs b/src/des/series.rs index 755ad6a..17cfeb6 100644 --- a/src/des/series.rs +++ b/src/des/series.rs @@ -85,6 +85,8 @@ pub enum Series { Line(Line), /// Plots data as scatter points. Scatter(Scatter), + /// Plots data as an area between two lines. + Area(Area), /// Plots data in histograms. Histogram(Histogram), /// Plots data as discrete bars. @@ -99,6 +101,7 @@ impl Series { match self { Series::Line(s) => (s.x_axis(), s.y_axis()), Series::Scatter(s) => (s.x_axis(), s.y_axis()), + Series::Area(s) => (s.x_axis(), s.y_axis()), Series::Histogram(s) => (s.x_axis(), s.y_axis()), Series::Bars(s) => (s.x_axis(), s.y_axis()), Series::BarsGroup(s) => (s.x_axis(), s.y_axis()), @@ -125,6 +128,12 @@ impl From for Series { } } +impl From for Series { + fn from(area: Area) -> Self { + Series::Area(area) + } +} + impl From for Series { fn from(histogram: Histogram) -> Self { Series::Histogram(histogram) @@ -373,6 +382,197 @@ impl Scatter { } } +/// Definition for the `y2_data` field of the Area plot. +#[derive(Debug, Clone)] +pub enum AreaY2 { + /// Y2 is a horizontal baseline (usually Y=0) + Baseline(f64), + /// Y2 is defined by another data column. + /// In that case, it must have the same length as X and Y1, and the area will be filled between Y1 and Y2. + DataCol(DataCol, Interpolation), +} + +impl Default for AreaY2 { + fn default() -> Self { + AreaY2::Baseline(0.0) + } +} + +impl From for AreaY2 { + fn from(value: f64) -> Self { + AreaY2::Baseline(value) + } +} + +impl From for AreaY2 { + fn from(col: DataCol) -> Self { + AreaY2::DataCol(col, Interpolation::default()) + } +} + +impl From<(DataCol, Interpolation)> for AreaY2 { + fn from(value: (DataCol, Interpolation)) -> Self { + AreaY2::DataCol(value.0, value.1) + } +} + +/// An area series structure. +/// +/// Plots data as a filled area between Y1 and Y2 lines over the X axis. +/// This is useful for visualizing cumulative data or kernel density estimates +#[derive(Debug, Clone)] +pub struct Area { + x_data: DataCol, + y1_data: DataCol, + y2_data: AreaY2, + + name: Option, + x_axis: axis::Ref, + y_axis: axis::Ref, + fill: Option, + stroke_y1: Option, + stroke_y2: Option, + interpolation: Interpolation, +} + +impl Area { + /// Create a new area series with the given x, y1, and y2 data columns + pub fn new(x_data: DataCol, y1_data: DataCol, y2_data: AreaY2) -> Self { + Area { + x_data, + y1_data, + y2_data, + + name: None, + x_axis: Default::default(), + y_axis: Default::default(), + fill: Some(style::series::Fill::default()), + stroke_y1: None, + stroke_y2: None, + interpolation: Interpolation::default(), + } + } + + /// Set the name and return self for chaining + pub fn with_name(self, name: impl Into) -> Self { + Self { + name: Some(name.into()), + ..self + } + } + + /// Set a reference to the x axis and return self for chaining + /// Use this to associate the series with a specific x axis in the plot, when a plot has multiple x axes. + 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 + /// Use this to associate the series with a specific y axis in the plot, when a plot has multiple y axes. + 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(mut self, fill: Option) -> 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); + 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); + self + } + + /// Set the interpolation method and return self for chaining + pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self { + self.interpolation = interpolation; + self + } + + /// Get the x data column + pub fn x_data(&self) -> &DataCol { + &self.x_data + } + + /// Get the y1 data column + pub fn y1_data(&self) -> &DataCol { + &self.y1_data + } + + /// Get the y2 data definition + pub fn y2_data(&self) -> &AreaY2 { + &self.y2_data + } + + /// Get the name + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Get a reference to the x axis + pub fn x_axis(&self) -> &axis::Ref { + &self.x_axis + } + + /// Get a reference to the y axis + pub fn y_axis(&self) -> &axis::Ref { + &self.y_axis + } + + /// Get the fill style + pub fn fill(&self) -> Option<&style::series::Fill> { + self.fill.as_ref() + } + + /// Get the stroke style of Y1 line + pub fn stroke_y1(&self) -> Option<&style::series::Stroke> { + self.stroke_y1.as_ref() + } + + /// Get the stroke style of Y2 line + pub fn stroke_y2(&self) -> Option<&style::series::Stroke> { + self.stroke_y2.as_ref() + } + + /// Chaining helper to build a plot from this series + /// This can only be used if your plot contains a single series. + /// This is equivalent to `Plot::new(vec![self.into()])` + /// + /// # Example + /// ``` + /// use plotive::des; + /// use plotive::des::series::{self, data_src_ref}; + /// + /// let fig: des::Figure = series::Area::new(data_src_ref("x_values"), data_src_ref("y_values"), Default::default()) + /// .with_name("Area Series") + /// .into_plot() + /// .with_x_axis(des::Axis::new().with_ticks(Default::default())) + /// .with_y_axis(des::Axis::new().with_ticks(Default::default()).with_grid(Default::default())) + /// .into_figure() + /// .with_title("Area Plot Example".into()); + /// + /// ``` + pub fn into_plot(self) -> super::Plot { + super::Plot::new(vec![self.into()]) + } + + /// Get the interpolation method for Y1. + /// Y2 interpolation is defined separately in the `AreaY2::DataCol` variant, if Y2 is defined by a data column. + pub fn interpolation(&self) -> Interpolation { + self.interpolation + } +} + /// A histogram series structure. /// /// Plots data by grouping values into bins and showing the frequency or density diff --git a/src/drawing/axis/bounds.rs b/src/drawing/axis/bounds.rs index d2c440f..dd958ff 100644 --- a/src/drawing/axis/bounds.rs +++ b/src/drawing/axis/bounds.rs @@ -34,6 +34,7 @@ impl From for Bounds { } impl Bounds { + pub fn unite_with(&mut self, other: &B) -> Result<(), Error> where B: AsBoundRef, diff --git a/src/drawing/legend.rs b/src/drawing/legend.rs index 739da77..43e1b87 100644 --- a/src/drawing/legend.rs +++ b/src/drawing/legend.rs @@ -8,14 +8,27 @@ use crate::{Style, des, drawing, geom, render, style}; pub enum Shape { Line(style::series::Stroke), Marker(style::series::Marker), - Rect(style::series::Fill, Option), + Rect(Option, Option), + AreaRect { + fill: Option, + stroke_y1: Option, + stroke_y2: Option, + }, } #[derive(Debug, Clone, Copy)] pub enum ShapeRef<'a> { Line(&'a style::series::Stroke), Marker(&'a style::series::Marker), - Rect(&'a style::series::Fill, Option<&'a style::series::Stroke>), + Rect( + Option<&'a style::series::Fill>, + Option<&'a style::series::Stroke>, + ), + AreaRect { + fill: Option<&'a style::series::Fill>, + stroke_y1: Option<&'a style::series::Stroke>, + stroke_y2: Option<&'a style::series::Stroke>, + }, } impl ShapeRef<'_> { @@ -23,7 +36,16 @@ impl ShapeRef<'_> { match self { &ShapeRef::Line(line) => Shape::Line(line.clone()), &ShapeRef::Marker(marker) => Shape::Marker(marker.clone()), - &ShapeRef::Rect(fill, line) => Shape::Rect(fill.clone(), line.cloned()), + &ShapeRef::Rect(fill, line) => Shape::Rect(fill.cloned(), line.cloned()), + &ShapeRef::AreaRect { + fill, + stroke_y1, + stroke_y2, + } => Shape::AreaRect { + fill: fill.cloned(), + stroke_y1: stroke_y1.cloned(), + stroke_y2: stroke_y2.cloned(), + }, } } } @@ -281,12 +303,60 @@ impl LegendEntry { ); let rr = render::Rect { rect: r, - fill: Some(fill.as_paint(&rc)), + fill: fill.as_ref().map(|f| f.as_paint(&rc)), stroke: line.as_ref().map(|l| l.as_stroke(&rc)), transform: None, }; surface.draw_rect(&rr); } + Shape::AreaRect { + fill, + stroke_y1, + stroke_y2, + } => { + let r = geom::Rect::from_ps( + geom::Point { + x: rect.left(), + y: rect.center_y() - shape_sz.height() / 2.0, + }, + shape_sz, + ); + if let Some(fill) = fill { + let rr = render::Rect { + rect: r, + fill: Some(fill.as_paint(&rc)), + stroke: None, + transform: None, + }; + surface.draw_rect(&rr); + } + if let Some(stroke) = stroke_y1 { + let mut pb = geom::PathBuilder::new(); + pb.move_to(r.left(), r.top()); + pb.line_to(r.right(), r.top()); + let path = pb.finish().unwrap(); + let rp = render::Path { + path: &path, + fill: None, + stroke: Some(stroke.as_stroke(&rc)), + transform: None, + }; + surface.draw_path(&rp); + } + if let Some(stroke) = stroke_y2 { + let mut pb = geom::PathBuilder::new(); + pb.move_to(r.left(), r.bottom()); + pb.line_to(r.right(), r.bottom()); + let path = pb.finish().unwrap(); + let rp = render::Path { + path: &path, + fill: None, + stroke: Some(stroke.as_stroke(&rc)), + transform: None, + }; + surface.draw_path(&rp); + } + } }; let transform = geom::Transform::from_translate( diff --git a/src/drawing/plot.rs b/src/drawing/plot.rs index 7064a40..8a555b5 100644 --- a/src/drawing/plot.rs +++ b/src/drawing/plot.rs @@ -768,6 +768,7 @@ where match &s { des::Series::Line(line) => f(line)?, des::Series::Scatter(scatter) => f(scatter)?, + des::Series::Area(area) => f(area)?, des::Series::Histogram(hist) => f(hist)?, des::Series::Bars(bars) => f(bars)?, des::Series::BarsGroup(bars_group) => { diff --git a/src/drawing/series.rs b/src/drawing/series.rs index ed4d921..30a1715 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -1,6 +1,8 @@ use axis::AsBoundRef; +use plotive_base::geom::PathSegment; use scale::{CoordMap, CoordMapXy}; +use crate::drawing::axis::Bounds; use crate::drawing::plot::Orientation; use crate::drawing::{ Categories, ColumnExt, Error, F64ColumnExt, axis, legend, marker, plot_to_fig, scale, @@ -33,12 +35,26 @@ impl SeriesExt for des::series::Scatter { } } +impl SeriesExt for des::series::Area { + fn legend_entry(&self) -> Option> { + self.name().map(|n| legend::Entry { + label: n.as_ref(), + font: None, + shape: legend::ShapeRef::AreaRect{ + fill: self.fill(), + stroke_y1: self.stroke_y1(), + stroke_y2: self.stroke_y2(), + }, + }) + } +} + impl SeriesExt for des::series::Histogram { fn legend_entry(&self) -> Option> { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::Rect(&self.fill(), self.line()), + shape: legend::ShapeRef::Rect(Some(self.fill()), self.line()), }) } } @@ -48,7 +64,7 @@ impl SeriesExt for des::series::Bars { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::Rect(self.fill(), self.line()), + shape: legend::ShapeRef::Rect(Some(self.fill()), self.line()), }) } } @@ -58,7 +74,7 @@ impl SeriesExt for des::series::BarSeries { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::Rect(&self.fill(), self.line()), + shape: legend::ShapeRef::Rect(Some(self.fill()), self.line()), }) } } @@ -138,6 +154,7 @@ pub struct Series { enum SeriesPlot { Line(Line), Scatter(Scatter), + Area(Area), Histogram(Histogram), Bars(Bars), BarsGroup(BarsGroup), @@ -153,6 +170,7 @@ impl Series { des::Series::Scatter(des) => { SeriesPlot::Scatter(Scatter::prepare(index, des, data_source)?) } + des::Series::Area(des) => SeriesPlot::Area(Area::prepare(index, des, data_source)?), des::Series::Histogram(des) => { SeriesPlot::Histogram(Histogram::prepare(index, des, data_source)?) } @@ -224,6 +242,10 @@ impl Series { .ab .as_ref() .map(|(x, y)| (x.as_bound_ref(), y.as_bound_ref())), + SeriesPlot::Area(area) => area + .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) => { @@ -236,6 +258,7 @@ impl Series { match &self.plot { SeriesPlot::Line(line) => &line.axes.0, SeriesPlot::Scatter(scatter) => &scatter.axes.0, + SeriesPlot::Area(area) => &area.axes.0, SeriesPlot::Histogram(hist) => &hist.axes.0, SeriesPlot::Bars(bars) => &bars.axes.0, SeriesPlot::BarsGroup(bg) => &bg.axes.0, @@ -246,6 +269,7 @@ impl Series { match &self.plot { SeriesPlot::Line(line) => &line.axes.1, SeriesPlot::Scatter(scatter) => &scatter.axes.1, + SeriesPlot::Area(area) => &area.axes.1, SeriesPlot::Histogram(hist) => &hist.axes.1, SeriesPlot::Bars(bars) => &bars.axes.1, SeriesPlot::BarsGroup(bg) => &bg.axes.1, @@ -266,6 +290,7 @@ impl Series { xy.update_data(data_source, rect, cm); } SeriesPlot::Scatter(sc) => sc.update_data(data_source, rect, cm), + SeriesPlot::Area(area) => area.update_data(data_source, rect, cm), SeriesPlot::Histogram(hist) => { hist.update_data(rect, cm); } @@ -286,6 +311,7 @@ impl Series { match &self.plot { SeriesPlot::Line(xy) => xy.draw(surface, style), SeriesPlot::Scatter(sc) => sc.draw(surface, style), + SeriesPlot::Area(area) => area.draw(surface, style), SeriesPlot::Histogram(hist) => hist.draw(surface, style), SeriesPlot::Bars(bars) => bars.draw(surface, style), SeriesPlot::BarsGroup(bg) => bg.draw(surface, style), @@ -293,18 +319,7 @@ impl Series { } } -#[derive(Debug, Clone)] -struct Line { - index: usize, - cols: (des::DataCol, des::DataCol), - ab: Option<(axis::Bounds, axis::Bounds)>, - axes: (des::axis::Ref, des::axis::Ref), - path: Option, - stroke: style::series::Stroke, - interpolation: des::series::Interpolation, -} - -trait Liner { +trait Liner: Sized { fn new(pt_len: usize) -> Self where Self: Sized; @@ -316,6 +331,56 @@ trait Liner { } fn into_path(self) -> geom::Path; + + fn make_path( + mut self, + x: &dyn data::Column, + y: &dyn data::Column, + cm: &CoordMapXy, + rect: &geom::Rect, + ) -> geom::Path { + let mut prev = None; + let mut cur = None; + + for (x, y) in x.sample_iter().zip(y.sample_iter()) { + let next = if x.is_null() || y.is_null() { + None + } else { + let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); + let (x, y) = plot_to_fig(rect, x, y); + Some((x, y)) + }; + + match (prev, cur, next) { + (_, None, _) => {} + (None, Some(_), None) => { + // single point, no line to draw + } + (None, Some((x, y)), Some(_)) => { + self.start_line(x, y); + } + (Some(_), Some((x, y)), None) => { + self.stop_line(x, y); + } + (Some(_), Some((x, y)), Some(_)) => { + self.cont_line(x, y); + } + } + prev = cur; + cur = next; + } + + match (prev, cur) { + (_, None) => {} + (None, Some(_)) => { + // single point, no line to draw + } + (Some(_), Some((x, y))) => { + self.stop_line(x, y); + } + } + self.into_path() + } } struct LinearLiner { @@ -494,6 +559,43 @@ impl Liner for CubicSplineLiner { } } +fn calc_xy_line_path( + x_col: &dyn data::Column, + y_col: &dyn data::Column, + interpolation: des::series::Interpolation, + rect: &geom::Rect, + cm: &CoordMapXy, +) -> geom::Path { + match interpolation { + des::series::Interpolation::Linear => { + let liner = LinearLiner::new(x_col.len()); + liner.make_path(x_col, y_col, cm, rect) + } + des::series::Interpolation::StepEarly + | des::series::Interpolation::StepLate + | des::series::Interpolation::StepMiddle => { + let mut liner = StepLiner::new(x_col.len()); + liner.step_type = interpolation; + liner.make_path(x_col, y_col, cm, rect) + } + des::series::Interpolation::Spline => { + let liner = CubicSplineLiner::new(x_col.len()); + liner.make_path(x_col, y_col, cm, rect) + } + } +} + +#[derive(Debug, Clone)] +struct Line { + index: usize, + cols: (des::DataCol, des::DataCol), + ab: Option<(axis::Bounds, axis::Bounds)>, + axes: (des::axis::Ref, des::axis::Ref), + path: Option, + stroke: style::series::Stroke, + interpolation: des::series::Interpolation, +} + impl Line { fn prepare(index: usize, des: &des::series::Line, data_source: &D) -> Result where @@ -534,82 +636,11 @@ impl Line { self.ab = Some(xy_bounds); } - let path = match self.interpolation { - des::series::Interpolation::Linear => { - let mut liner = LinearLiner::new(x_col.len()); - self.make_line_path(rect, x_col, y_col, cm, &mut liner); - liner.into_path() - } - des::series::Interpolation::StepEarly - | des::series::Interpolation::StepLate - | des::series::Interpolation::StepMiddle => { - let mut liner = StepLiner::new(x_col.len()); - liner.step_type = self.interpolation; - self.make_line_path(rect, x_col, y_col, cm, &mut liner); - liner.into_path() - } - des::series::Interpolation::Spline => { - let mut liner = CubicSplineLiner::new(x_col.len()); - self.make_line_path(rect, x_col, y_col, cm, &mut liner); - liner.into_path() - } - }; + let path = calc_xy_line_path(x_col, y_col, self.interpolation, rect, cm); self.path = Some(path); } - fn make_line_path( - &self, - rect: &geom::Rect, - x: &dyn data::Column, - y: &dyn data::Column, - cm: &CoordMapXy, - liner: &mut L, - ) where - L: Liner, - { - let mut prev = None; - let mut cur = None; - - for (x, y) in x.sample_iter().zip(y.sample_iter()) { - let next = if x.is_null() || y.is_null() { - None - } else { - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let (x, y) = plot_to_fig(rect, x, y); - Some((x, y)) - }; - - match (prev, cur, next) { - (_, None, _) => {} - (None, Some(_), None) => { - // single point, no line to draw - } - (None, Some((x, y)), Some(_)) => { - liner.start_line(x, y); - } - (Some(_), Some((x, y)), None) => { - liner.stop_line(x, y); - } - (Some(_), Some((x, y)), Some(_)) => { - liner.cont_line(x, y); - } - } - prev = cur; - cur = next; - } - - match (prev, cur) { - (_, None) => {} - (None, Some(_)) => { - // single point, no line to draw - } - (Some(_), Some((x, y))) => { - liner.stop_line(x, y); - } - } - } - fn draw(&self, surface: &mut S, style: &Style) where S: render::Surface, @@ -717,6 +748,220 @@ impl Scatter { } } +#[derive(Debug, Clone)] +struct Area { + index: usize, + x: des::DataCol, + y1: des::DataCol, + y2: des::series::AreaY2, + ab: Option<(axis::Bounds, axis::Bounds)>, + axes: (des::axis::Ref, des::axis::Ref), + path_y1: Option, + path_y2: Option, + path_fill: Option, + fill: Option, + stroke_y1: Option, + stroke_y2: Option, + interpolation: des::series::Interpolation, +} + +impl Area { + fn calc_bounds( + data_source: &D, + x: &des::DataCol, + y1: &des::DataCol, + y2: &des::series::AreaY2, + ) -> Result, Error> + where + D: data::Source + ?Sized, + { + let mut xy_bounds = calc_xy_bounds(data_source, x, y1)?; + if let Some((_, y_bounds)) = &mut xy_bounds { + match y2 { + des::series::AreaY2::Baseline(value) => { + y_bounds.unite_with(&Bounds::Num((*value).into()))?; + } + des::series::AreaY2::DataCol(y2_col, ..) => { + let y2_col = get_column(y2_col, data_source)?; + if let Some(y2_bounds) = y2_col.bounds() { + y_bounds.unite_with(&y2_bounds)?; + } + } + } + } + Ok(xy_bounds) + } + + fn prepare(index: usize, des: &des::series::Area, data_source: &D) -> Result + where + D: data::Source + ?Sized, + { + let x = des.x_data().clone(); + let y1 = des.y1_data().clone(); + let y2 = des.y2_data().clone(); + let xy_bounds = Self::calc_bounds(data_source, &x, &y1, &y2)?; + Ok(Area { + index, + x, + y1, + y2, + ab: xy_bounds, + axes: (des.x_axis().clone(), des.y_axis().clone()), + 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(), + interpolation: des.interpolation(), + }) + } + + fn update_data(&mut self, data_source: &D, rect: &geom::Rect, cm: &CoordMapXy) + where + D: data::Source + ?Sized, + { + // unwraping here as data is checked during setup phase + let x_col = get_column(&self.x, data_source).unwrap(); + let y1_col = get_column(&self.y1, data_source).unwrap(); + + debug_assert!(x_col.len() == y1_col.len()); + + if self.ab.is_none() && x_col.is_empty() { + self.path_y1 = None; + self.path_y2 = None; + self.path_fill = None; + return; + } + + if self.ab.is_none() && !x_col.is_empty() { + let xy_bounds = Self::calc_bounds(data_source, &self.x, &self.y1, &self.y2) + .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 = calc_xy_line_path(x_col, y1_col, self.interpolation, rect, cm); + self.path_y1 = Some(path); + + self.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); + let (_, y1) = plot_to_fig(rect, x1, y); + 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")) + } + 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) + } + }; + + 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 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"); + 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 + }); + } + + fn draw(&self, surface: &mut S, style: &Style) + where + S: render::Surface, + { + 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 path = render::Path { + path: sp, + fill: None, + stroke: Some(stroke.as_stroke(&rc)), + transform: None, + }; + surface.draw_path(&path); + } + if let (Some(sp), Some(stroke)) = (&self.path_y2, &self.stroke_y2) { + let path = render::Path { + path: sp, + fill: None, + stroke: Some(stroke.as_stroke(&rc)), + transform: None, + }; + surface.draw_path(&path); + } + } +} + #[derive(Debug, Clone, Copy)] struct HistBin { /// Start and end of this bin From 268503a2dfe53eef7cab99b03fa7716a3ef0733b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Thu, 23 Apr 2026 22:20:09 +0200 Subject: [PATCH 2/4] cargo +nightly fmt --- base/src/geom.rs | 11 +++++------ src/drawing/axis/bounds.rs | 1 - src/drawing/series.rs | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/base/src/geom.rs b/base/src/geom.rs index d29df52..f322d0d 100644 --- a/base/src/geom.rs +++ b/base/src/geom.rs @@ -6,8 +6,9 @@ * Y low coordinates are at the top. */ -use strict_num::{FiniteF32, PositiveF32}; use std::marker::PhantomData; + +use strict_num::{FiniteF32, PositiveF32}; pub use tiny_skia_path::{Path, PathBuilder, PathSegment, PathVerb, Point, Transform}; /// A size in 2D space represented by width and height @@ -623,7 +624,8 @@ pub fn reverse_path(path: &Path) -> Path { PathSegment::Close => pb.close(), } } - pb.finish().expect("Reversing a valid path should yield a valid path") + pb.finish() + .expect("Reversing a valid path should yield a valid path") } pub struct PathSegmentsRevIter<'a> { @@ -763,10 +765,7 @@ impl SubPathSegment { SubPathSegment::Line { from, .. } => PathSegment::LineTo(*from), SubPathSegment::Quad { from, ctrl, .. } => PathSegment::QuadTo(*ctrl, *from), SubPathSegment::Cubic { - from, - ctrl1, - ctrl2, - .. + from, ctrl1, ctrl2, .. } => PathSegment::CubicTo(*ctrl2, *ctrl1, *from), } } diff --git a/src/drawing/axis/bounds.rs b/src/drawing/axis/bounds.rs index dd958ff..d2c440f 100644 --- a/src/drawing/axis/bounds.rs +++ b/src/drawing/axis/bounds.rs @@ -34,7 +34,6 @@ impl From for Bounds { } impl Bounds { - pub fn unite_with(&mut self, other: &B) -> Result<(), Error> where B: AsBoundRef, diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 30a1715..f56f95e 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -40,7 +40,7 @@ impl SeriesExt for des::series::Area { self.name().map(|n| legend::Entry { label: n.as_ref(), font: None, - shape: legend::ShapeRef::AreaRect{ + shape: legend::ShapeRef::AreaRect { fill: self.fill(), stroke_y1: self.stroke_y1(), stroke_y2: self.stroke_y2(), From 9cc6285a35f1995f144f92beffe501bc456d3d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Thu, 23 Apr 2026 22:21:21 +0200 Subject: [PATCH 3/4] remove necessity of utils feature for area example --- .vscode/launch.json | 1 - Cargo.toml | 1 - examples/area.rs | 4 ++-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index d55c54c..289249c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,6 @@ "cargo": { "args": [ "build", "--example", "area", - "--features", "utils" ] }, "args": ["png", "svg"] diff --git a/Cargo.toml b/Cargo.toml index 4a85858..744918d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,6 @@ utils = [] [[example]] name = "area" -required-features = ["utils"] [[example]] name = "bars" diff --git a/examples/area.rs b/examples/area.rs index bef3c11..fec23a3 100644 --- a/examples/area.rs +++ b/examples/area.rs @@ -1,9 +1,9 @@ -use plotive::{data, des, utils}; +use plotive::{data, des}; mod common; fn main() { - let x = utils::linspace(0.0, 5.0, 6); + let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; let y1 = vec![10.0, 15.0, 8.0, 6.0, 12.0, 10.0]; let y2 = vec![4.0, 9.0, 2.0, 0.0, 6.0, 4.0]; From 57c8b05d91391c0624b0aadedd9b3b0cbe48b231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Thu, 23 Apr 2026 22:22:35 +0200 Subject: [PATCH 4/4] test area series --- tests/refs/series/area-double-legend.png | Bin 0 -> 25259 bytes tests/refs/series/area-double-legend.svg | 24 ++++++++ tests/refs/series/area-double.png | Bin 0 -> 25865 bytes tests/refs/series/area-double.svg | 15 +++++ tests/src/tests/series.rs | 73 ++++++++++++++++++++++- 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/refs/series/area-double-legend.png create mode 100644 tests/refs/series/area-double-legend.svg create mode 100644 tests/refs/series/area-double.png create mode 100644 tests/refs/series/area-double.svg diff --git a/tests/refs/series/area-double-legend.png b/tests/refs/series/area-double-legend.png new file mode 100644 index 0000000000000000000000000000000000000000..01c14ee10ccfacde2513bef781aabd0b0dee1ece GIT binary patch literal 25259 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 T3jQS&f`!j4emegTuW$N43dyB2 literal 0 HcmV?d00001 diff --git a/tests/refs/series/area-double-legend.svg b/tests/refs/series/area-double-legend.svg new file mode 100644 index 0000000..8bbf7ab --- /dev/null +++ b/tests/refs/series/area-double-legend.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/refs/series/area-double.png b/tests/refs/series/area-double.png new file mode 100644 index 0000000000000000000000000000000000000000..d7edab97ef57d50dd4e2cd26ee940bb20e24308d GIT binary patch literal 25865 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 E2h~IqF#rGn literal 0 HcmV?d00001 diff --git a/tests/refs/series/area-double.svg b/tests/refs/series/area-double.svg new file mode 100644 index 0000000..dd76da1 --- /dev/null +++ b/tests/refs/series/area-double.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/tests/series.rs b/tests/src/tests/series.rs index fa3b25c..fe89658 100644 --- a/tests/src/tests/series.rs +++ b/tests/src/tests/series.rs @@ -1,4 +1,4 @@ -use plotive::{data, des}; +use plotive::{data, des, style}; use crate::tests::fig_small; use crate::{TestHarness, assert_fig_eq_ref}; @@ -30,3 +30,74 @@ fn series_scatter_nodata() { assert_fig_eq_ref!(&fig, "series/scatter-nodata"); } + +#[test] +fn series_area_double() { + let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let y1 = vec![10.0, 15.0, 8.0, 6.0, 12.0, 10.0]; + let y2 = vec![4.0, 9.0, 2.0, 0.0, 6.0, 4.0]; + + let fill = plotive::ColorU8::from_html(b"#888").into(); + let stroke: style::series::Stroke = plotive::ColorU8::from_html(b"#000").into(); + + let plot = des::Plot::new(vec![ + des::series::Area::new( + des::data_inline(x.clone()), + des::data_inline(y1.clone()), + des::data_inline(y2.clone()).into(), + ) + .with_fill(Some(fill)) + .with_stroke_y1(stroke.clone()) + .with_stroke_y2(stroke.clone()) + .into(), + des::series::Area::new( + des::data_inline(x.clone()), + des::data_inline(y2.clone()), + Default::default(), + ) + .with_fill(Some(fill)) + .with_stroke_y1(stroke.clone()) + .with_stroke_y2(stroke.clone()) + .into(), + ]); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "series/area-double"); +} + +#[test] +fn series_area_double_legend() { + let x = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]; + let y1 = vec![10.0, 15.0, 8.0, 6.0, 12.0, 10.0]; + let y2 = vec![4.0, 9.0, 2.0, 0.0, 6.0, 4.0]; + + let fill1 = plotive::ColorU8::from_html(b"#888").into(); + let fill2 = plotive::ColorU8::from_html(b"#444").into(); + let stroke: style::series::Stroke = plotive::ColorU8::from_html(b"#000").into(); + + let plot = des::Plot::new(vec![ + des::series::Area::new( + des::data_inline(x.clone()), + des::data_inline(y1.clone()), + des::data_inline(y2.clone()).into(), + ) + .with_name("area1") + .with_fill(Some(fill1)) + .with_stroke_y1(stroke.clone()) + .with_stroke_y2(stroke.clone()) + .into(), + des::series::Area::new( + des::data_inline(x.clone()), + des::data_inline(y2.clone()), + Default::default(), + ) + .with_name("area2") + .with_fill(Some(fill2)) + .with_stroke_y1(stroke.clone()) + .with_stroke_y2(stroke.clone()) + .into(), + ]); + let fig = fig_small(plot).with_legend(Default::default()); + + assert_fig_eq_ref!(&fig, "series/area-double-legend"); +}