diff --git a/.vscode/launch.json b/.vscode/launch.json index 48ab93f..289249c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,17 @@ // 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", + ] + }, + "args": ["png", "svg"] + }, { "type": "lldb", "request": "launch", diff --git a/Cargo.toml b/Cargo.toml index 8514905..744918d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ noto-serif-italic = ["plotive-text/noto-serif-italic"] time = [] utils = [] +[[example]] +name = "area" + [[example]] name = "bars" diff --git a/base/src/geom.rs b/base/src/geom.rs index 3b662e3..f322d0d 100644 --- a/base/src/geom.rs +++ b/base/src/geom.rs @@ -6,6 +6,8 @@ * Y low coordinates are at the top. */ +use std::marker::PhantomData; + use strict_num::{FiniteF32, PositiveF32}; pub use tiny_skia_path::{Path, PathBuilder, PathSegment, PathVerb, Point, Transform}; @@ -604,3 +606,281 @@ 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..fec23a3 --- /dev/null +++ b/examples/area.rs @@ -0,0 +1,32 @@ +use plotive::{data, des}; + +mod common; + +fn main() { + 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 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/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..f56f95e 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 diff --git a/tests/refs/series/area-double-legend.png b/tests/refs/series/area-double-legend.png new file mode 100644 index 0000000..01c14ee Binary files /dev/null and b/tests/refs/series/area-double-legend.png differ 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 0000000..d7edab9 Binary files /dev/null and b/tests/refs/series/area-double.png differ 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"); +}