use starsight_layer_1::backends::DrawBackend;
use starsight_layer_1::errors::Result;
use starsight_layer_1::paths::{Path, PathStyle};
use starsight_layer_1::primitives::{Color, Point};
use starsight_layer_2::coords::Coord;
use crate::marks::{DataExtent, LegendGlyph, Mark, require_cartesian};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum ErrorBarOrientation {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone, Debug)]
pub struct ErrorBarMark {
pub xs: Vec<f64>,
pub ys: Vec<f64>,
pub errors_low: Vec<f64>,
pub errors_high: Vec<f64>,
pub orientation: ErrorBarOrientation,
pub cap_width: f32,
pub color: Color,
pub width: f32,
pub label: Option<String>,
}
impl ErrorBarMark {
#[must_use]
pub fn new(xs: Vec<f64>, ys: Vec<f64>, errors: Vec<f64>) -> Self {
let n = xs.len().min(ys.len()).min(errors.len());
let mut xs = xs;
let mut ys = ys;
let errors_low = errors.clone();
let mut errors_high = errors;
xs.truncate(n);
ys.truncate(n);
let mut errors_low_truncated = errors_low;
errors_low_truncated.truncate(n);
errors_high.truncate(n);
Self {
xs,
ys,
errors_low: errors_low_truncated,
errors_high,
orientation: ErrorBarOrientation::default(),
cap_width: 6.0,
color: Color::from_hex(0x0040_4040),
width: 1.5,
label: None,
}
}
#[must_use]
pub fn errors_pair(mut self, pairs: Vec<(f64, f64)>) -> Self {
let n = self.xs.len().min(pairs.len());
self.errors_low.clear();
self.errors_high.clear();
self.errors_low.reserve(n);
self.errors_high.reserve(n);
for (lo, hi) in pairs.into_iter().take(n) {
self.errors_low.push(lo);
self.errors_high.push(hi);
}
self.xs.truncate(n);
self.ys.truncate(n);
self
}
#[must_use]
pub fn horizontal(mut self) -> Self {
self.orientation = ErrorBarOrientation::Horizontal;
self
}
#[must_use]
pub fn cap_width(mut self, w: f32) -> Self {
self.cap_width = w;
self
}
#[must_use]
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
#[must_use]
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
impl Mark for ErrorBarMark {
fn render(&self, coord: &dyn Coord, backend: &mut dyn DrawBackend) -> Result<()> {
let coord = require_cartesian(coord)?;
if self.xs.is_empty() {
return Ok(());
}
let style = PathStyle::stroke(self.color, self.width);
let cap = self.cap_width;
for i in 0..self.xs.len() {
let x = self.xs[i];
let y = self.ys[i];
let lo = self.errors_low.get(i).copied().unwrap_or(0.0);
let hi = self.errors_high.get(i).copied().unwrap_or(0.0);
if !x.is_finite() || !y.is_finite() || !lo.is_finite() || !hi.is_finite() {
continue;
}
match self.orientation {
ErrorBarOrientation::Vertical => {
let p_low = coord.data_to_pixel(x, y - lo);
let p_high = coord.data_to_pixel(x, y + hi);
backend.draw_path(&Path::new().move_to(p_low).line_to(p_high), &style)?;
if cap > 0.0 {
let half = cap * 0.5;
backend.draw_path(
&Path::new()
.move_to(Point::new(p_low.x - half, p_low.y))
.line_to(Point::new(p_low.x + half, p_low.y)),
&style,
)?;
backend.draw_path(
&Path::new()
.move_to(Point::new(p_high.x - half, p_high.y))
.line_to(Point::new(p_high.x + half, p_high.y)),
&style,
)?;
}
}
ErrorBarOrientation::Horizontal => {
let p_low = coord.data_to_pixel(x - lo, y);
let p_high = coord.data_to_pixel(x + hi, y);
backend.draw_path(&Path::new().move_to(p_low).line_to(p_high), &style)?;
if cap > 0.0 {
let half = cap * 0.5;
backend.draw_path(
&Path::new()
.move_to(Point::new(p_low.x, p_low.y - half))
.line_to(Point::new(p_low.x, p_low.y + half)),
&style,
)?;
backend.draw_path(
&Path::new()
.move_to(Point::new(p_high.x, p_high.y - half))
.line_to(Point::new(p_high.x, p_high.y + half)),
&style,
)?;
}
}
}
}
Ok(())
}
fn data_extent(&self) -> Option<DataExtent> {
if self.xs.is_empty() {
return None;
}
let mut x_min = f64::INFINITY;
let mut x_max = f64::NEG_INFINITY;
let mut y_min = f64::INFINITY;
let mut y_max = f64::NEG_INFINITY;
for i in 0..self.xs.len() {
let x = self.xs[i];
let y = self.ys[i];
let lo = self.errors_low.get(i).copied().unwrap_or(0.0);
let hi = self.errors_high.get(i).copied().unwrap_or(0.0);
if !x.is_finite() || !y.is_finite() {
continue;
}
match self.orientation {
ErrorBarOrientation::Vertical => {
x_min = x_min.min(x);
x_max = x_max.max(x);
y_min = y_min.min(y - lo);
y_max = y_max.max(y + hi);
}
ErrorBarOrientation::Horizontal => {
x_min = x_min.min(x - lo);
x_max = x_max.max(x + hi);
y_min = y_min.min(y);
y_max = y_max.max(y);
}
}
}
if !x_min.is_finite() || !y_min.is_finite() {
return None;
}
Some(DataExtent {
x_min,
x_max,
y_min,
y_max,
})
}
fn legend_color(&self) -> Option<Color> {
self.label.as_ref()?;
Some(self.color)
}
fn legend_label(&self) -> Option<&str> {
self.label.as_deref()
}
fn legend_glyph(&self) -> LegendGlyph {
LegendGlyph::Line
}
}
#[cfg(test)]
mod tests {
use super::{ErrorBarMark, ErrorBarOrientation};
use crate::marks::{LegendGlyph, Mark};
use starsight_layer_1::primitives::Color;
#[test]
fn new_truncates_to_shortest_input() {
let mark = ErrorBarMark::new(vec![0.0, 1.0, 2.0], vec![1.0, 2.0], vec![0.1, 0.2, 0.3]);
assert_eq!(mark.xs.len(), 2);
assert_eq!(mark.ys.len(), 2);
assert_eq!(mark.errors_low.len(), 2);
assert_eq!(mark.errors_high.len(), 2);
}
#[test]
fn defaults_match_spec() {
let mark = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1]);
assert!((mark.cap_width - 6.0).abs() < f32::EPSILON);
assert!((mark.width - 1.5).abs() < f32::EPSILON);
assert_eq!(mark.orientation, ErrorBarOrientation::Vertical);
}
#[test]
fn symmetric_errors_assigned_low_and_high() {
let mark = ErrorBarMark::new(vec![0.0, 1.0], vec![10.0, 20.0], vec![0.5, 1.0]);
assert_eq!(mark.errors_low, vec![0.5, 1.0]);
assert_eq!(mark.errors_high, vec![0.5, 1.0]);
}
#[test]
fn asymmetric_errors_overwrite_pairs() {
let mark = ErrorBarMark::new(vec![0.0, 1.0], vec![10.0, 20.0], vec![0.5, 0.5])
.errors_pair(vec![(0.2, 0.8), (0.4, 0.6)]);
assert_eq!(mark.errors_low, vec![0.2, 0.4]);
assert_eq!(mark.errors_high, vec![0.8, 0.6]);
}
#[test]
fn horizontal_builder_flips_orientation() {
let mark = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1]).horizontal();
assert_eq!(mark.orientation, ErrorBarOrientation::Horizontal);
}
#[test]
fn cap_width_zero_disables_caps() {
let mark = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1]).cap_width(0.0);
assert_eq!(mark.cap_width, 0.0);
}
#[test]
fn data_extent_vertical_widens_y_only() {
let mark = ErrorBarMark::new(vec![0.0, 5.0], vec![10.0, 20.0], vec![1.0, 2.0]);
let extent = mark.data_extent().expect("non-empty");
assert!((extent.x_min - 0.0).abs() < f64::EPSILON);
assert!((extent.x_max - 5.0).abs() < f64::EPSILON);
assert!((extent.y_min - 9.0).abs() < f64::EPSILON);
assert!((extent.y_max - 22.0).abs() < f64::EPSILON);
}
#[test]
fn data_extent_horizontal_widens_x_only() {
let mark = ErrorBarMark::new(vec![10.0, 20.0], vec![0.0, 5.0], vec![1.0, 2.0]).horizontal();
let extent = mark.data_extent().expect("non-empty");
assert!((extent.x_min - 9.0).abs() < f64::EPSILON);
assert!((extent.x_max - 22.0).abs() < f64::EPSILON);
assert!((extent.y_min - 0.0).abs() < f64::EPSILON);
assert!((extent.y_max - 5.0).abs() < f64::EPSILON);
}
#[test]
fn data_extent_asymmetric_uses_separate_low_high() {
let mark =
ErrorBarMark::new(vec![0.0], vec![10.0], vec![1.0]).errors_pair(vec![(2.0, 5.0)]);
let extent = mark.data_extent().expect("non-empty");
assert!((extent.y_min - 8.0).abs() < f64::EPSILON);
assert!((extent.y_max - 15.0).abs() < f64::EPSILON);
}
#[test]
fn data_extent_skips_nonfinite() {
let mark = ErrorBarMark::new(
vec![f64::NAN, 1.0, 2.0],
vec![10.0, 20.0, 30.0],
vec![0.5; 3],
);
let extent = mark.data_extent().expect("two finite points");
assert!((extent.x_min - 1.0).abs() < f64::EPSILON);
assert!((extent.x_max - 2.0).abs() < f64::EPSILON);
}
#[test]
fn legend_glyph_is_line() {
let mark = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1]).label("ci");
assert_eq!(mark.legend_glyph(), LegendGlyph::Line);
}
#[test]
fn legend_color_only_when_labeled() {
let labeled = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1])
.color(Color::RED)
.label("ci");
assert_eq!(labeled.legend_color(), Some(Color::RED));
let unlabeled = ErrorBarMark::new(vec![0.0], vec![1.0], vec![0.1]);
assert!(unlabeled.legend_color().is_none());
}
}