use crate::core::{Affine2D, Bbox, Path, PathCode, PathSegment, color::Rgba};
use crate::render::{CapStyle, GraphicsContext, JoinStyle, Renderer};
use crate::artist::marker::Snap;
use crate::artist::{Artist, MarkerStyle};
#[derive(Debug, Clone, PartialEq)]
pub struct Line2D {
xdata: Vec<f64>,
ydata: Vec<f64>,
color: Rgba,
linewidth: f64,
dashes: Option<(f64, Vec<f64>)>,
cap: Option<CapStyle>,
join: Option<JoinStyle>,
dash_cap: Option<CapStyle>,
dash_join: Option<JoinStyle>,
visible: bool,
zorder: f64,
label: Option<String>,
marker: Option<LineMarker>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LineMarker {
pub(crate) code: char,
pub(crate) size: f64,
pub(crate) face: Option<Rgba>,
pub(crate) edge: Option<Rgba>,
pub(crate) edge_width: f64,
pub(crate) every: usize,
}
const DEFAULT_MARKER_SIZE: f64 = 6.0;
const DEFAULT_MARKER_EDGE_WIDTH: f64 = 1.0;
impl LineMarker {
fn new(code: char) -> Option<Self> {
MarkerStyle::from_char(code)?;
Some(Self {
code,
size: DEFAULT_MARKER_SIZE,
face: None,
edge: None,
edge_width: DEFAULT_MARKER_EDGE_WIDTH,
every: 1,
})
}
#[cfg(feature = "portable")]
pub(crate) fn validated(self) -> Result<Self, crate::portable::PortableError> {
let drawable = MarkerStyle::from_char(self.code).is_some()
&& self.size.is_finite()
&& self.size >= 0.0
&& self.edge_width.is_finite()
&& self.edge_width >= 0.0
&& self.every >= 1;
if drawable {
Ok(self)
} else {
Err(crate::portable::PortableError::Malformed(format!(
"line marker {:?} is not drawable",
self.code
)))
}
}
pub(crate) fn draw(&self, renderer: &mut dyn Renderer, points: &[[f64; 2]], color: Rgba) {
let Some(style) = MarkerStyle::from_char(self.code) else {
return;
};
let pixel = self.code == ',';
let size = if pixel {
1.0
} else {
renderer.points_to_pixels(self.size)
};
let edge_width = if pixel { 0.0 } else { self.edge_width };
let marker = style.scaled(size);
let points: Vec<[f64; 2]> = points
.iter()
.copied()
.filter(|[x, y]| x.is_finite() && y.is_finite())
.collect();
if points.is_empty() {
return;
}
let curved = marker
.iter_segments()
.any(|s| matches!(s, PathSegment::Quad(..) | PathSegment::Cubic(..)));
let corners = style.join() == JoinStyle::Miter && !curved;
let gc = GraphicsContext {
line_width: edge_width,
stroke: Some(self.edge.unwrap_or(color)),
join: style.join(),
miter_limit: corners.then(|| renderer.points_to_pixels(edge_width)),
snap: match style.snap() {
Snap::From(threshold) => Some(size >= threshold),
Snap::Auto => None,
},
..GraphicsContext::new()
};
let face = style.is_filled().then(|| self.face.unwrap_or(color));
renderer.draw_markers(
&gc,
&marker,
&Affine2D::identity(),
&Path::from_polyline(&points),
&Affine2D::identity(),
face,
);
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct LineStyles {
pub(crate) cap: CapStyle,
pub(crate) join: JoinStyle,
pub(crate) dash_cap: CapStyle,
pub(crate) dash_join: JoinStyle,
}
impl LineStyles {
pub(crate) fn from_rc(rc: &crate::core::RcParams) -> Self {
Self {
cap: rc.lines_solid_capstyle,
join: rc.lines_solid_joinstyle,
dash_cap: rc.lines_dash_capstyle,
dash_join: rc.lines_dash_joinstyle,
}
}
}
impl Default for LineStyles {
fn default() -> Self {
Self {
cap: CapStyle::Projecting,
join: JoinStyle::Round,
dash_cap: CapStyle::Butt,
dash_join: JoinStyle::Round,
}
}
}
impl Line2D {
#[must_use]
pub fn new(xdata: Vec<f64>, ydata: Vec<f64>) -> Self {
Self {
xdata,
ydata,
color: Rgba::BLACK,
linewidth: 1.5,
dashes: None,
cap: None,
join: None,
dash_cap: None,
dash_join: None,
visible: true,
zorder: 2.0,
label: None,
marker: None,
}
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
#[must_use]
pub fn color(&self) -> Rgba {
self.color
}
pub fn set_data(&mut self, x: &[f64], y: &[f64]) -> &mut Self {
self.xdata = x.to_vec();
self.ydata = y.to_vec();
self
}
pub fn set_color(&mut self, color: Rgba) -> &mut Self {
self.color = color;
self
}
pub fn set_linewidth(&mut self, linewidth: f64) -> &mut Self {
self.linewidth = linewidth;
self
}
pub fn set_dashes(&mut self, dashes: Option<(f64, Vec<f64>)>) -> &mut Self {
self.dashes = dashes;
self
}
pub fn set_cap(&mut self, cap: CapStyle) -> &mut Self {
self.cap = Some(cap);
self.dash_cap = Some(cap);
self
}
pub fn set_join(&mut self, join: JoinStyle) -> &mut Self {
self.join = Some(join);
self.dash_join = Some(join);
self
}
pub fn set_solid_capstyle(&mut self, cap: CapStyle) -> &mut Self {
self.cap = Some(cap);
self
}
pub fn set_solid_joinstyle(&mut self, join: JoinStyle) -> &mut Self {
self.join = Some(join);
self
}
pub fn set_dash_capstyle(&mut self, cap: CapStyle) -> &mut Self {
self.dash_cap = Some(cap);
self
}
pub fn set_dash_joinstyle(&mut self, join: JoinStyle) -> &mut Self {
self.dash_join = Some(join);
self
}
#[cfg(feature = "portable")]
pub(crate) fn dash_like_solid(&mut self) {
self.dash_cap = self.cap;
self.dash_join = self.join;
}
pub(crate) fn resolve_styles(&mut self, styles: &LineStyles) {
self.cap.get_or_insert(styles.cap);
self.join.get_or_insert(styles.join);
self.dash_cap.get_or_insert(styles.dash_cap);
self.dash_join.get_or_insert(styles.dash_join);
}
fn cap_join(&self) -> (CapStyle, JoinStyle) {
let defaults = LineStyles::default();
if self.dashes.as_ref().is_some_and(|(_, p)| !p.is_empty()) {
(
self.dash_cap.unwrap_or(defaults.dash_cap),
self.dash_join.unwrap_or(defaults.dash_join),
)
} else {
(
self.cap.unwrap_or(defaults.cap),
self.join.unwrap_or(defaults.join),
)
}
}
pub fn set_visible(&mut self, visible: bool) -> &mut Self {
self.visible = visible;
self
}
pub fn set_zorder(&mut self, zorder: f64) -> &mut Self {
self.zorder = zorder;
self
}
pub fn set_label(&mut self, label: impl Into<String>) -> &mut Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn with_color(mut self, color: Rgba) -> Self {
self.set_color(color);
self
}
#[must_use]
pub fn with_linewidth(mut self, linewidth: f64) -> Self {
self.set_linewidth(linewidth);
self
}
#[must_use]
pub fn with_dashes(mut self, dashes: Option<(f64, Vec<f64>)>) -> Self {
self.set_dashes(dashes);
self
}
#[must_use]
pub fn with_cap(mut self, cap: CapStyle) -> Self {
self.set_cap(cap);
self
}
#[must_use]
pub fn with_join(mut self, join: JoinStyle) -> Self {
self.set_join(join);
self
}
#[must_use]
pub fn with_visible(mut self, visible: bool) -> Self {
self.set_visible(visible);
self
}
#[must_use]
pub fn with_zorder(mut self, zorder: f64) -> Self {
self.set_zorder(zorder);
self
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.set_label(label);
self
}
pub fn set_marker(&mut self, code: &str) -> &mut Self {
let mut chars = code.chars();
self.marker = match (chars.next(), chars.next()) {
(Some(c), None) => LineMarker::new(c).map(|new| match self.marker.take() {
Some(old) => LineMarker { code: c, ..old },
None => new,
}),
_ => None,
};
self
}
#[must_use]
pub fn with_marker(mut self, code: &str) -> Self {
self.set_marker(code);
self
}
#[must_use]
pub fn marker(&self) -> Option<char> {
self.marker.as_ref().map(|m| m.code)
}
pub fn set_markersize(&mut self, size: f64) -> &mut Self {
if let Some(marker) = &mut self.marker {
marker.size = size;
}
self
}
pub fn set_markerfacecolor(&mut self, color: Rgba) -> &mut Self {
if let Some(marker) = &mut self.marker {
marker.face = Some(color);
}
self
}
pub fn set_markeredgecolor(&mut self, color: Rgba) -> &mut Self {
if let Some(marker) = &mut self.marker {
marker.edge = Some(color);
}
self
}
pub fn set_markeredgewidth(&mut self, width: f64) -> &mut Self {
if let Some(marker) = &mut self.marker {
marker.edge_width = width;
}
self
}
pub fn set_markevery(&mut self, n: usize) -> &mut Self {
if let Some(marker) = &mut self.marker {
marker.every = n.max(1);
}
self
}
pub(crate) fn line_marker(&self) -> Option<&LineMarker> {
self.marker.as_ref()
}
pub(crate) fn draw_markers_mapped(
&self,
renderer: &mut dyn Renderer,
transform: &Affine2D,
map: &dyn Fn(f64, f64) -> [f64; 2],
) {
let Some(marker) = self.marker.as_ref().filter(|_| self.visible) else {
return;
};
let points: Vec<[f64; 2]> = self
.xdata
.iter()
.zip(&self.ydata)
.step_by(marker.every)
.map(|(&x, &y)| {
let [sx, sy] = map(x, y);
let (dx, dy) = transform.transform_point((sx, sy));
[dx, dy]
})
.collect();
marker.draw(renderer, &points, self.color);
}
#[must_use]
pub fn points(&self) -> Vec<[f64; 2]> {
self.xdata
.iter()
.zip(self.ydata.iter())
.map(|(&x, &y)| [x, y])
.collect()
}
#[must_use]
pub fn path(&self) -> Path {
let points = self.points();
if points.iter().all(|[x, y]| x.is_finite() && y.is_finite()) {
return Path::from_polyline(&points);
}
let mut vertices: Vec<[f64; 2]> = Vec::with_capacity(points.len());
let mut codes: Vec<PathCode> = Vec::with_capacity(points.len());
for run in points
.split(|[x, y]| !x.is_finite() || !y.is_finite())
.filter(|run| run.len() >= 2)
{
vertices.extend_from_slice(run);
codes.push(PathCode::MoveTo);
codes.extend(std::iter::repeat_n(PathCode::LineTo, run.len() - 1));
}
Path::new(vertices, Some(codes))
}
pub fn draw_path(&self, renderer: &mut dyn Renderer, path: &Path, transform: &Affine2D) {
if !self.visible || path.vertices().len() < 2 {
return;
}
let (cap, join) = self.cap_join();
let gc = GraphicsContext {
line_width: self.linewidth,
dashes: self.dashes.clone(),
cap,
join,
stroke: Some(self.color),
..GraphicsContext::new()
};
renderer.draw_path(&gc, path, transform, None);
}
}
impl Artist for Line2D {
fn draw(&self, renderer: &mut dyn Renderer, transform: &Affine2D) {
if !self.visible {
return;
}
self.draw_path(renderer, &self.path(), transform);
self.draw_markers_mapped(renderer, transform, &|x, y| [x, y]);
}
fn zorder(&self) -> f64 {
self.zorder
}
fn visible(&self) -> bool {
self.visible
}
fn data_extents(&self) -> Option<Bbox> {
let mut xmin = f64::INFINITY;
let mut ymin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
let mut ymax = f64::NEG_INFINITY;
let mut any = false;
for (&x, &y) in self.xdata.iter().zip(self.ydata.iter()) {
if !x.is_finite() || !y.is_finite() {
continue;
}
xmin = xmin.min(x);
ymin = ymin.min(y);
xmax = xmax.max(x);
ymax = ymax.max(y);
any = true;
}
if any {
Some(Bbox::from_extents(xmin, ymin, xmax, ymax))
} else {
None
}
}
}
#[cfg(feature = "portable")]
impl Line2D {
pub(crate) fn to_portable(
&self,
bank: &mut crate::portable::data::BankWriter,
) -> crate::portable::spec::LineSpec {
let defaults = LineStyles::default();
let cap = self.cap.unwrap_or(defaults.cap);
let join = self.join.unwrap_or(defaults.join);
let dash_cap = self.dash_cap.unwrap_or(defaults.dash_cap);
let dash_join = self.dash_join.unwrap_or(defaults.dash_join);
crate::portable::spec::LineSpec {
x: bank.push_f64(&self.xdata),
y: bank.push_f64(&self.ydata),
color: self.color,
linewidth: self.linewidth,
dashes: self.dashes.clone(),
cap,
join,
dash_cap: (dash_cap != defaults.dash_cap).then_some(dash_cap),
dash_join: (dash_join != defaults.dash_join).then_some(dash_join),
visible: self.visible,
zorder: self.zorder,
label: self.label.clone(),
marker: self.marker.clone(),
}
}
pub(crate) fn from_portable(
spec: &crate::portable::spec::LineSpec,
reader: &crate::portable::data::BankReader<'_>,
) -> Result<Line2D, crate::portable::PortableError> {
Ok(Line2D {
xdata: reader.f64s(&spec.x)?,
ydata: reader.f64s(&spec.y)?,
color: spec.color,
linewidth: spec.linewidth,
dashes: spec.dashes.clone(),
cap: Some(spec.cap),
join: Some(spec.join),
dash_cap: Some(spec.dash_cap.unwrap_or(LineStyles::default().dash_cap)),
dash_join: Some(spec.dash_join.unwrap_or(LineStyles::default().dash_join)),
visible: spec.visible,
zorder: spec.zorder,
label: spec.label.clone(),
marker: spec.marker.clone().map(LineMarker::validated).transpose()?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_without_gaps_is_the_implicit_polyline() {
let line = Line2D::new(vec![0.0, 1.0, 2.0], vec![0.0, 1.0, 0.5]);
let path = line.path();
assert_eq!(path.vertices(), &[[0.0, 0.0], [1.0, 1.0], [2.0, 0.5]]);
assert!(path.codes().is_none());
}
#[test]
fn nan_splits_the_stroke_into_subpaths() {
let line = Line2D::new(
vec![0.0, 1.0, 2.0, 3.0, 4.0],
vec![0.0, 1.0, f64::NAN, 3.0, 4.0],
);
let path = line.path();
assert_eq!(
path.vertices(),
&[[0.0, 0.0], [1.0, 1.0], [3.0, 3.0], [4.0, 4.0]]
);
assert_eq!(
path.codes(),
Some(
[
PathCode::MoveTo,
PathCode::LineTo,
PathCode::MoveTo,
PathCode::LineTo,
]
.as_slice()
)
);
let subpaths = path.flatten(0.1);
assert_eq!(subpaths.len(), 2, "one flattened polyline per finite run");
}
#[test]
fn non_finite_x_and_isolated_points_are_gaps_too() {
let line = Line2D::new(
vec![0.0, 1.0, f64::INFINITY, 3.0, f64::NAN, 5.0, 6.0],
vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
);
let path = line.path();
assert_eq!(
path.vertices(),
&[[0.0, 0.0], [1.0, 1.0], [5.0, 5.0], [6.0, 6.0]]
);
let e = line.data_extents().unwrap();
assert_eq!((e.xmin(), e.xmax()), (0.0, 6.0));
}
#[test]
fn all_gaps_draws_nothing() {
let line = Line2D::new(vec![0.0, 1.0], vec![f64::NAN, f64::NAN]);
assert!(line.path().vertices().is_empty());
let mut r = MockRenderer::default();
line.draw(&mut r, &Affine2D::identity());
assert!(r.calls.is_empty());
}
#[test]
fn gapped_line_strokes_only_finite_vertices() {
let line = Line2D::new(
vec![0.0, 1.0, 2.0, 3.0, 4.0],
vec![0.0, 1.0, f64::NAN, 3.0, 4.0],
);
let mut r = MockRenderer::default();
line.draw(&mut r, &Affine2D::identity());
assert_eq!(r.calls.len(), 1);
assert_eq!(r.calls[0].0, 4);
}
#[test]
fn set_and_with_setters_agree() {
let built = Line2D::new(vec![0.0, 1.0], vec![0.0, 1.0])
.with_color(Rgba::RED)
.with_linewidth(3.0)
.with_dashes(Some((0.0, vec![4.0, 2.0])))
.with_cap(CapStyle::Round)
.with_join(JoinStyle::Round)
.with_visible(false)
.with_zorder(7.0)
.with_label("p50");
let mut set = Line2D::new(vec![0.0, 1.0], vec![0.0, 1.0]);
set.set_color(Rgba::RED)
.set_linewidth(3.0)
.set_dashes(Some((0.0, vec![4.0, 2.0])))
.set_cap(CapStyle::Round)
.set_join(JoinStyle::Round)
.set_visible(false)
.set_zorder(7.0)
.set_label("p50");
assert_eq!(built, set);
assert_eq!(set.color(), Rgba::RED);
assert!(!set.visible());
assert_eq!(set.label(), Some("p50"));
assert_eq!(Line2D::new(vec![], vec![]).label(), None);
}
#[derive(Default)]
struct MockRenderer {
calls: Vec<(usize, Option<Rgba>)>,
}
impl Renderer for MockRenderer {
fn draw_path(
&mut self,
gc: &GraphicsContext,
path: &Path,
_transform: &Affine2D,
_fill: Option<Rgba>,
) {
self.calls.push((path.vertices().len(), gc.stroke));
}
fn canvas_size(&self) -> (f64, f64) {
(100.0, 100.0)
}
}
#[test]
fn three_point_line_draws_once_with_color() {
let line = Line2D::new(vec![0.0, 1.0, 2.0], vec![0.0, 1.0, 0.0]).with_color(Rgba::RED);
let mut r = MockRenderer::default();
line.draw(&mut r, &Affine2D::identity());
assert_eq!(r.calls, vec![(3, Some(Rgba::RED))]);
}
#[test]
fn invisible_line_draws_nothing() {
let line = Line2D::new(vec![0.0, 1.0], vec![0.0, 1.0]).with_visible(false);
let mut r = MockRenderer::default();
line.draw(&mut r, &Affine2D::identity());
assert!(r.calls.is_empty());
}
#[test]
fn single_point_line_draws_nothing() {
let line = Line2D::new(vec![0.0], vec![0.0]);
let mut r = MockRenderer::default();
line.draw(&mut r, &Affine2D::identity());
assert!(r.calls.is_empty());
}
#[test]
fn data_extents_bounds_finite_points() {
let line = Line2D::new(vec![1.0, 3.0, 2.0], vec![-1.0, 4.0, 0.0]);
let e = line.data_extents().expect("non-empty");
assert_eq!(e.xmin(), 1.0);
assert_eq!(e.xmax(), 3.0);
assert_eq!(e.ymin(), -1.0);
assert_eq!(e.ymax(), 4.0);
}
#[test]
fn data_extents_ignores_nan_point() {
let line = Line2D::new(vec![1.0, f64::NAN, 2.0], vec![1.0, 5.0, 2.0]);
let e = line.data_extents().expect("non-empty");
assert_eq!(e.xmin(), 1.0);
assert_eq!(e.xmax(), 2.0);
assert_eq!(e.ymin(), 1.0);
assert_eq!(e.ymax(), 2.0);
}
#[test]
fn data_extents_empty_is_none() {
let line = Line2D::new(vec![], vec![]);
assert!(line.data_extents().is_none());
}
#[test]
fn default_zorder_is_two() {
let line = Line2D::new(vec![0.0, 1.0], vec![0.0, 1.0]);
assert_eq!(line.zorder(), 2.0);
}
}