From 303d05904c75e734b7d6d8db1fe973885b220860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Thebault?= Date: Fri, 20 Mar 2026 17:07:03 +0100 Subject: [PATCH] handline null in line series --- CHANGELOG.md | 4 + examples/bitcoin.rs | 1 - src/data.rs | 12 +- src/drawing/series.rs | 439 ++++++++++--------- tests/refs/nulls/line-lin-null-end.png | Bin 0 -> 15380 bytes tests/refs/nulls/line-lin-null-end.svg | 11 + tests/refs/nulls/line-spline-null-middle.png | Bin 0 -> 25079 bytes tests/refs/nulls/line-spline-null-middle.svg | 16 + tests/src/tests.rs | 1 + tests/src/tests/nulls.rs | 30 ++ 10 files changed, 311 insertions(+), 203 deletions(-) create mode 100644 tests/refs/nulls/line-lin-null-end.png create mode 100644 tests/refs/nulls/line-lin-null-end.svg create mode 100644 tests/refs/nulls/line-spline-null-middle.png create mode 100644 tests/refs/nulls/line-spline-null-middle.svg create mode 100644 tests/src/tests/nulls.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef9c32..2a349f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Handling nulls in line series + ## [0.3.0] - 2026-02-19 ### Changed diff --git a/examples/bitcoin.rs b/examples/bitcoin.rs index a1b08f2..368ddc5 100644 --- a/examples/bitcoin.rs +++ b/examples/bitcoin.rs @@ -3,7 +3,6 @@ use plotive::{data, des}; mod common; fn main() { - // FIXME: support parsing datetime in CSV let btc_csv = common::example_res("BTC-USD.csv"); let csv_data = std::fs::read_to_string(&btc_csv).unwrap(); let data_source = data::csv::parse_str(&csv_data, Default::default()).unwrap(); diff --git a/src/data.rs b/src/data.rs index 90d4c9f..c4c6d41 100644 --- a/src/data.rs +++ b/src/data.rs @@ -43,7 +43,11 @@ pub enum SampleRef<'a> { impl SampleRef<'_> { /// Check if the sample is null pub fn is_null(&self) -> bool { - matches!(self, SampleRef::Null) + match self { + SampleRef::Null => true, + SampleRef::Num(v) => !v.is_finite(), + _ => false, + } } /// Get the sample as a numeric value, if possible @@ -200,7 +204,11 @@ pub enum Sample { impl Sample { /// Check if the sample is null pub fn is_null(&self) -> bool { - matches!(self, Sample::Null) + match self { + Sample::Null => true, + Sample::Num(v) => !v.is_finite(), + _ => false, + } } /// Get the sample as a numeric value, if possible diff --git a/src/drawing/series.rs b/src/drawing/series.rs index 75e76e5..98e6a9b 100644 --- a/src/drawing/series.rs +++ b/src/drawing/series.rs @@ -290,6 +290,196 @@ struct Line { interpolation: des::series::Interpolation, } +trait Liner { + fn new(pt_len: usize) -> Self + where + Self: Sized; + + fn start_line(&mut self, x: f32, y: f32); + fn cont_line(&mut self, x: f32, y: f32); + fn stop_line(&mut self, x: f32, y: f32) { + self.cont_line(x, y); + } + + fn into_path(self) -> geom::Path; +} + +struct LinearLiner { + pb: geom::PathBuilder, +} + +impl Liner for LinearLiner { + fn new(pt_len: usize) -> Self { + LinearLiner { + pb: geom::PathBuilder::with_capacity(pt_len + 1, pt_len), + } + } + + fn start_line(&mut self, x: f32, y: f32) { + self.pb.move_to(x, y); + } + + fn cont_line(&mut self, x: f32, y: f32) { + self.pb.line_to(x, y); + } + + fn into_path(self) -> geom::Path { + self.pb.finish().expect("Should be a valid path") + } +} + +struct StepLiner { + pb: geom::PathBuilder, + prev_x: Option, + prev_y: Option, + step_type: des::series::Interpolation, +} + +impl Liner for StepLiner { + fn new(_pt_len: usize) -> Self { + StepLiner { + pb: geom::PathBuilder::new(), + prev_x: None, + prev_y: None, + step_type: des::series::Interpolation::StepEarly, // default, will be set properly in prepare + } + } + + fn into_path(self) -> geom::Path { + self.pb.finish().expect("Should be a valid path") + } + + fn start_line(&mut self, x: f32, y: f32) { + self.prev_x = Some(x); + self.prev_y = Some(y); + self.pb.move_to(x, y); + } + + fn cont_line(&mut self, x: f32, y: f32) { + if let (Some(px), Some(py)) = (self.prev_x, self.prev_y) { + match self.step_type { + des::series::Interpolation::StepEarly => { + self.pb.line_to(px, y); + self.pb.line_to(x, y); + } + des::series::Interpolation::StepLate => { + self.pb.line_to(x, py); + self.pb.line_to(x, y); + } + des::series::Interpolation::StepMiddle => { + let mid_x = (px + x) / 2.0; + self.pb.line_to(mid_x, py); + self.pb.line_to(mid_x, y); + self.pb.line_to(x, y); + } + _ => {} + } + } else { + self.pb.move_to(x, y); + } + self.prev_x = Some(x); + self.prev_y = Some(y); + } +} + +struct CubicSplineLiner { + pb: geom::PathBuilder, + buf: [(f32, f32); 4], + buf_idx: usize, +} + +impl CubicSplineLiner { + fn add_point(&mut self, points: &[(f32, f32)]) { + match points.len() { + 2 => { + self.pb.line_to(points[1].0, points[1].1); + } + 3 => { + unreachable!() + // // For the first segment, we can use a quadratic Bezier with control point at p1 + // let cp_x = points[1].0; + // let cp_y = points[1].1; + // self.pb + // .quad_to(cp_x, cp_y, points[2].0, points[2].1); + } + 4 => { + // Calculate control points for cubic Bezier using Catmull-Rom formulation + // The tangent at p1 is (p2 - p0) / 2 + // The tangent at p2 is (p3 - p1) / 2 + + // Tension parameter (0.5 for standard Catmull-Rom) + let tension = 0.5; + + // Control point 1: p1 + tangent_at_p1 / 3 + let tangent1_x = (points[2].0 - points[0].0) * tension; + let tangent1_y = (points[2].1 - points[0].1) * tension; + let cp1_x = points[1].0 + tangent1_x / 3.0; + let cp1_y = points[1].1 + tangent1_y / 3.0; + + // Control point 2: p2 - tangent_at_p2 / 3 + let tangent2_x = (points[3].0 - points[1].0) * tension; + let tangent2_y = (points[3].1 - points[1].1) * tension; + let cp2_x = points[2].0 - tangent2_x / 3.0; + let cp2_y = points[2].1 - tangent2_y / 3.0; + + // Draw cubic Bezier curve + self.pb + .cubic_to(cp1_x, cp1_y, cp2_x, cp2_y, points[2].0, points[2].1); + } + _ => {} + } + } +} + +impl Liner for CubicSplineLiner { + fn new(_pt_len: usize) -> Self { + CubicSplineLiner { + pb: geom::PathBuilder::new(), + buf: [(0.0, 0.0); 4], + buf_idx: 0, + } + } + + fn into_path(self) -> geom::Path { + self.pb.finish().expect("Should be a valid path") + } + + fn start_line(&mut self, x: f32, y: f32) { + self.buf[0] = (x, y); + self.buf_idx = 1; + self.pb.move_to(x, y); + } + + fn cont_line(&mut self, x: f32, y: f32) { + self.buf[self.buf_idx] = (x, y); + self.buf_idx += 1; + if self.buf_idx == 3 { + // For the first segment, we can use a quadratic Bezier with control point at p1 + let [(x0, y0), (x1, y1), (x2, y2), _] = self.buf; + let points = [(x0, y0), (x0, y0), (x1, y1), (x2, y2)]; + self.add_point(&points); + } else if self.buf_idx == 4 { + let points = self.buf; + self.add_point(&points); + // Shift buffer + self.buf[0] = self.buf[1]; + self.buf[1] = self.buf[2]; + self.buf[2] = self.buf[3]; + self.buf_idx = 3; + } + } + + fn stop_line(&mut self, x: f32, y: f32) { + self.cont_line(x, y); + + // we draw the last segment if any + if self.buf_idx == 3 { + let points = [self.buf[0], self.buf[1], self.buf[2], self.buf[2]]; + self.add_point(&points); + } + } +} + impl Line { fn prepare(index: usize, des: &des::series::Line, data_source: &D) -> Result where @@ -319,230 +509,79 @@ impl Line { debug_assert!(x_col.len() == y_col.len()); let path = match self.interpolation { - des::series::Interpolation::Linear => self.make_path_linear(rect, x_col, y_col, cm), - des::series::Interpolation::StepEarly => { - self.make_path_step_early(rect, x_col, y_col, cm) - } - des::series::Interpolation::StepLate => { - self.make_path_step_late(rect, x_col, y_col, cm) + 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::StepMiddle => { - self.make_path_step_middle(rect, x_col, y_col, cm) + 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 => { - self.make_path_cubic_spline(rect, x_col, y_col, cm) + let mut liner = CubicSplineLiner::new(x_col.len()); + self.make_line_path(rect, x_col, y_col, cm, &mut liner); + liner.into_path() } }; self.path = Some(path); } - fn make_path_linear( - &self, - rect: &geom::Rect, - x: &dyn data::Column, - y: &dyn data::Column, - cm: &CoordMapXy, - ) -> geom::Path { - let mut in_a_line = false; - let mut pb = geom::PathBuilder::with_capacity(x.len() + 1, x.len()); - for (x, y) in x.sample_iter().zip(y.sample_iter()) { - if x.is_null() || y.is_null() { - in_a_line = false; - continue; - } - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let x = rect.left() + x; - let y = rect.bottom() - y; - // if x_col.len() == 1024 { - // println!(" adding point {} {}", x, y); - // } - if in_a_line { - pb.line_to(x, y); - } else { - pb.move_to(x, y); - in_a_line = true; - } - } - pb.finish().expect("Should be a valid path") - } - - fn make_path_step_early( + fn make_line_path( &self, rect: &geom::Rect, x: &dyn data::Column, y: &dyn data::Column, cm: &CoordMapXy, - ) -> geom::Path { - let mut pb = geom::PathBuilder::new(); - - let mut prev_x: Option = None; - - for (x, y) in x.sample_iter().zip(y.sample_iter()) { - if x.is_null() || y.is_null() { - prev_x = None; - continue; - } - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let (x, y) = plot_to_fig(rect, x, y); - - if let Some(px) = prev_x { - pb.line_to(px, y); - pb.line_to(x, y); - } else { - pb.move_to(x, y); - } - prev_x = Some(x); - } - - pb.finish().expect("Should be a valid path") - } - - fn make_path_step_late( - &self, - rect: &geom::Rect, - x: &dyn data::Column, - y: &dyn data::Column, - cm: &CoordMapXy, - ) -> geom::Path { - let mut pb = geom::PathBuilder::new(); - - let mut prev_y: Option = None; - - for (x, y) in x.sample_iter().zip(y.sample_iter()) { - if x.is_null() || y.is_null() { - prev_y = None; - continue; - } - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let (x, y) = plot_to_fig(rect, x, y); - - if let Some(py) = prev_y { - pb.line_to(x, py); - pb.line_to(x, y); - } else { - pb.move_to(x, y); - } - prev_y = Some(y); - } - - pb.finish().expect("Should be a valid path") - } - - fn make_path_step_middle( - &self, - rect: &geom::Rect, - x: &dyn data::Column, - y: &dyn data::Column, - cm: &CoordMapXy, - ) -> geom::Path { - let mut pb = geom::PathBuilder::new(); - - let mut prev_x: Option = None; - let mut prev_y: Option = None; + 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()) { - if x.is_null() || y.is_null() { - prev_x = None; - prev_y = None; - continue; - } - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let (x, y) = plot_to_fig(rect, x, y); - - if let (Some(px), Some(py)) = (prev_x, prev_y) { - let mx = (px + x) / 2.0; - pb.line_to(mx, py); - pb.line_to(mx, y); - pb.line_to(x, y); + let next = if x.is_null() || y.is_null() { + None } else { - pb.move_to(x, y); - } - prev_x = Some(x); - prev_y = Some(y); - } - - pb.finish().expect("Should be a valid path") - } - - fn make_path_cubic_spline( - &self, - rect: &geom::Rect, - x: &dyn data::Column, - y: &dyn data::Column, - cm: &CoordMapXy, - ) -> geom::Path { - const NAN: (f32, f32) = (f32::NAN, f32::NAN); - let mut buf: [(f32, f32); 4] = [NAN, NAN, NAN, NAN]; - let mut buf_idx = 0; - - let mut pb = geom::PathBuilder::new(); - - fn add_point(pb: &mut geom::PathBuilder, points: &[(f32, f32); 4]) { - // Calculate control points for cubic Bezier using Catmull-Rom formulation - // The tangent at p1 is (p2 - p0) / 2 - // The tangent at p2 is (p3 - p1) / 2 - - // Tension parameter (0.5 for standard Catmull-Rom) - let tension = 0.5; - - // Control point 1: p1 + tangent_at_p1 / 3 - let tangent1_x = (points[2].0 - points[0].0) * tension; - let tangent1_y = (points[2].1 - points[0].1) * tension; - let cp1_x = points[1].0 + tangent1_x / 3.0; - let cp1_y = points[1].1 + tangent1_y / 3.0; - - // Control point 2: p2 - tangent_at_p2 / 3 - let tangent2_x = (points[3].0 - points[1].0) * tension; - let tangent2_y = (points[3].1 - points[1].1) * tension; - let cp2_x = points[2].0 - tangent2_x / 3.0; - let cp2_y = points[2].1 - tangent2_y / 3.0; - - // Draw cubic Bezier curve - pb.cubic_to(cp1_x, cp1_y, cp2_x, cp2_y, points[2].0, points[2].1); - } + 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)) + }; - for (x, y) in x.sample_iter().zip(y.sample_iter()) { - if x.is_null() || y.is_null() { - if buf_idx == 3 { - // we draw the last segment if any - add_point(&mut pb, &[buf[0], buf[1], buf[2], buf[2]]); + 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); } - buf_idx = 0; - continue; } - let (x, y) = cm.map_coord((x, y)).expect("Should be valid coordinates"); - let (x, y) = plot_to_fig(rect, x, y); + prev = cur; + cur = next; + } - // first point, or after a gap - if buf_idx == 0 { - pb.move_to(x, y); + match (prev, cur) { + (_, None) => {} + (None, Some(_)) => { + // single point, no line to draw } - - buf[buf_idx] = (x, y); - buf_idx += 1; - - if buf_idx == 3 { - // we draw the first segment - add_point(&mut pb, &[buf[0], buf[0], buf[1], buf[2]]); - } else if buf_idx == 4 { - // we draw a regular segment - add_point(&mut pb, &buf); - - // Shift buffer - buf[0] = buf[1]; - buf[1] = buf[2]; - buf[2] = buf[3]; - buf_idx = 3; + (Some(_), Some((x, y))) => { + liner.stop_line(x, y); } } - - // we draw the last segment if any - if buf_idx == 3 { - add_point(&mut pb, &[buf[0], buf[1], buf[2], buf[2]]); - } - - pb.finish().expect("Should be a valid path") } fn draw(&self, surface: &mut S, style: &Style) diff --git a/tests/refs/nulls/line-lin-null-end.png b/tests/refs/nulls/line-lin-null-end.png new file mode 100644 index 0000000000000000000000000000000000000000..00cc0037e99f3975e414315fed90b280411435f1 GIT binary patch literal 15380 zcmeHOe^Autna4|%G#8~e`9Wih+iR~gO`8kXOEggoJKiO=)|+xkOLkHM>y1fi0%Y%M z6}je=;|jNivgV-+kZr`T2Q1&-4EBAAhiJ@$E}*PfJT%y#9MnJe`)dU=RHJ z;BAZGlM(loKc%H*_pEgbNFTWgS_OOQu;Fd$DDp)rT!}gimSS$R{xbo)wlNZ@blq}iB*o! zD*fP!(OXp2T{LdE?ZQg7HfL~i&JMrY;2$__jvS6%Q`+qh@ALN`E*w5Qb=|9#Xw@JY zG{Nyip{|M0Ss&;|Ht=HMMkCotaj zPN`N6C#qc19L7WHs!$!55+?{5Zk@Sf?2s`{d&d(e2xn|BfpaAtGG+pUQ;8Eyr-LG; zJxPa**~aFi6MP}2&x4pWDN+{S7`P5fKyhT6~RfHrBQYCi;R$E`J=u5&WOhw8G9rB@qcq@ z*BpTZX7#|4A!&D#na%49OoC;e4ElxQ*6!l*v&QJzWk%cY;l4?3Gp{xj$q)C11H0Pj z?9V!sXUTs}Q7=Ua-7n|&3fAn`M*M-&zCh`r&N6ATEYxj@c0Z>WeIn0fEyn`I$6mE- zSM7lVg+m9XMuXbRh3WzGUO2N*&XO0-YzTeF&_seS7g`)nS z4L7(yTM1g8R?KV#T-njChbXo@$?@lnPFiqk#INC%`xf;~Pvo*kT+vTloq4Q9q6NBJ#}}cU1qdi>a;($7F>;Q(bJYORaYe>^G|W!NS}^3(Jn;O@pDFV0EcYJt*z< z*+zVUdKMMRTE7{YRF?8|R(md+$PHagH~!YCS%l6gcYvaC*EU*K7*q;L(WaJT2A$NI(x=?mnnNqhZR@0ob!3;cTGz4Zad7}RvbC%X ztRwstx~kjibPwR|SJ|c?GZiS)LL>2Ro)vPX-EhS6PjM~q_`wy|nJnwjmw)sLZhVsfPeY-{yxBlD!>+Gu@B=#=(xA846= zfwXO^>N1UgWQcwQ7*_5UZ7<1mS#+;Modicd294oT;mIWjIzc~T>WVjOtPj{CZmCwc zQ1*<_MoU546&Oiq*`(DqN!kXPNn+=+(oHdn{WI#kz^@c|Cb!9tf}I$IKuA6_Z{R&T zWc)xRt<2tz%F`WV7(P7R#jW>{%Em8sz^U2ygjaq6HH}^gfmconL|eL~S4Ht8>(hC0 z9~@d305h*s5W%#(eBzCCf^ItAEKnT6WD}aqf$T`G>{5R0qU5@Hdg@=G_%Zf~yiT8khKXhwDRoexUt!HThJ!)cIp?ie5XsqA^kHIcx?lRb^KN_TeAFFOYQSRN4g!>k`~qK6P`^i9St5oKrIj3Is?( zspIXpL@~q^!Hc3>rT0+j7=!2qTSSmg{uu8yhNXn(+=kY>$Y6B%YRQwHTJ3Y8NPwm6U)Fa6Pa%aZ-IgO56A+%8BH|b1<$`Q$(R)d5U5iyBb>s;Y)H-)Y?)wxFwnE z!=*W*rMq0=y{>*Fo2V8c>=etYjjD)NjTT=Y9n~`{a(uw`S=!NLVwts`eMPW+W*1r| zu_|+cE>tAL0)72w65U71B35z3(x6~z7Th`+=_a8*0QZ8%gtC3swSoBxwj-w#zxEoz zwFh-RXg*4ApsXllTZRuHJ!2_r7&Nj z^qd=ewQc+(Gbv@UE$mk?nFwqOW9_>#!$=7094BIXe&ax49r4J!EPLx~L^bFw2e1!8 z07fm;FSH zAZ#OKklbeh1Gn(&b*(B@2!f@8laJuRcF;5swbZbTT|Rv=9RpZ_5)^E#AO=FHsviIw z4(MGoK`f6I@`H(a4%khm{2*)C`PNeS^CXg!R3A4~)y|)SD10PUyBCwYk=!rB2D|m_CGLf9=YYQg#m_|*%-ahSa{i{_k#{p#7v{Iyq!jlo7-Nr-BGC0|}aLu>3#ZjNNN z+%P2V21i7RMF6;UHPqd!;Sr&u_6oxnv&-FZm!~(fg5~txgN!aiF8M_JO>69 zC`K-Ir)3c{H-F&?c;tDmtmU7Ao+fc3fa=vbT^0yo#$Fz<&|KlvtREm=e)E8RBPjqS zH@G2o`T4RoksTzuEQo3nwpq&a#gN>4Gu}mxMYphgE+~Df-MaUW1b6cMigi(Rk0{ zCYBR6=B8!#CMFo9DX9pvtADUlUWG<2)>2%qlHEzPF4pPL?!;I>X$wy3qrR+~E9YE2 zSYgnlMi?nO)>Hyv@w*1>0XTEYQ4=im9o*0-ZRb?m^~}ySO#evgh->EhgM8Lk+36em z2pTO!zf-`Rp%_D*ohZ8-aOuELC`k{C|XI*s^| zysZ6PD{=HE8V@+gNsv)*1ES3IJfzR6-#m0H{a5OO8?O8j|Ii;0zDG6)b8MxrtSU3s z>V-%GZxgucveQ^0O}TXJzW)*>y2PbbAK%7)O^rL8D2ih}6AUQ|22T+oeeo{fi+2H^ zkLMF>uC31rcW3y|AoE$R)SFaeEpHgXiJ5CvOFUYT18k0_~Ew;U15Ak z@bVw~7tw^Z{sQRp^fXASAxLJ?0+w>buMqp$m-GipP^92f8a)R=>X-gZf1pIYZg-#c z+p#ut)c6X%R#J@@v2TB`w|o&La0LE-?4mtfH+~19kj%^Y1>EX9&`VhHmt!OuB?dH& zO393gY&n#@=IYvv0UUD2UA*P*JzU=4v;Os{8<1FV`b>~!&*1O|1q2v~Xo9h*5N_bTD8}HAj z#l?%F!UM!6o+4HFf=f>&Y4|E*>H2BBTx@QZRqN{MAidGS<8!SZ(z7x2u#ragH_Nnj zeAZ=jlKN^`z+(nN8-l#~X%MrDJ`Qq=ZV`AQvKD+nLyvd#7Y9x)gWk|Z4T$AYPBdg1 zf3}i!63vw4N8>#xKV(W_7x=E{j>N>DK3j|Q$zw3YEW?KF1lMHZuyjuK7JBwTk|txt zsRqiqkmcj@rpC^-Txm6_iFBV2Kj*noEU)x>6Gj!Vp$nk##uanlFVLE5Dt=efzaN-c zVrc8eUn?3z>r9qPXqtu-sP@6*jL(c{EuhDU?CS}71IbO zJ`n(CTTqUq8!h&#Z+Qps?+ePYV8AaR)@TATR~x1`Qd<#!Xwk*^kCfa(!pGMf5t0;k zY}(E`w4cYAKbB5!D76e^sNj|r_Y(h%5?-_sT!MxGUMLK_F!iQGeW0!fbw^4Vc=t>6 z6F99&j5ke~fXC2NLV5*A0-^o`9xsiELw$k;rZUU1TTM)WUZ|wKP9lNXv?a&MVG~bD z5}G7sphC#hUcqrK{FkPV28-ah$YJBunj7anks_p;9|zv>2s0zrA=J&94`-(T=v5Q` z>dY@MqS%Uv^9V=|J$S4F?(~> + + + + + + + + + + \ No newline at end of file diff --git a/tests/refs/nulls/line-spline-null-middle.png b/tests/refs/nulls/line-spline-null-middle.png new file mode 100644 index 0000000000000000000000000000000000000000..56defe234e0f77100d1cce06f726ee8ec25d029c GIT binary patch literal 25079 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 EKhy0{(f|Me literal 0 HcmV?d00001 diff --git a/tests/refs/nulls/line-spline-null-middle.svg b/tests/refs/nulls/line-spline-null-middle.svg new file mode 100644 index 0000000..6a4b7b1 --- /dev/null +++ b/tests/refs/nulls/line-spline-null-middle.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/src/tests.rs b/tests/src/tests.rs index f6f44b1..af84a3b 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -45,6 +45,7 @@ fn line2(x: &[f64], y: &[f64]) -> des::series::Line { mod axes; mod interp; mod legend; +mod nulls; mod subplots; #[test] diff --git a/tests/src/tests/nulls.rs b/tests/src/tests/nulls.rs new file mode 100644 index 0000000..2a617cf --- /dev/null +++ b/tests/src/tests/nulls.rs @@ -0,0 +1,30 @@ +use plotive::des; + +use super::fig_small; +use crate::{TestHarness, assert_fig_eq_ref}; + +#[test] +fn line_lin_null_end() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![1.0, 2.0, 3.0, 4.0, f64::NAN]; + let series = des::series::Line::new(x.into(), y.into()).into(); + let plot = + des::Plot::new(vec![series]).with_x_axis(des::Axis::default().with_title("x axis".into())); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "nulls/line-lin-null-end"); +} + +#[test] +fn line_spline_null_middle() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; + let y = vec![0.0, 2.0, 3.0, 1.0, 4.0, f64::NAN, 2.0, 3.0, 1.0, 4.0]; + + let plot = des::series::Line::new(x.into(), y.into()) + .with_interpolation(des::series::Interpolation::Spline) + .into_plot() + .with_x_axis(des::Axis::default().with_ticks(Default::default())); + let fig = fig_small(plot); + + assert_fig_eq_ref!(&fig, "nulls/line-spline-null-middle"); +}