use crate::core::Result;
use crate::core::plot::ErrorValuesRef;
use crate::render::{Color, LineStyle};
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Whisker {
pub lower: f32,
pub upper: f32,
pub lower_cap: bool,
pub upper_cap: bool,
}
impl Whisker {
fn resolve(
raw_lower: Option<f32>,
raw_upper: Option<f32>,
px_at_data_min: f32,
px_at_data_max: f32,
) -> Option<Self> {
let (clip_lo, clip_hi) = if px_at_data_min <= px_at_data_max {
(px_at_data_min, px_at_data_max)
} else {
(px_at_data_max, px_at_data_min)
};
if !clip_lo.is_finite() || !clip_hi.is_finite() {
return None;
}
let visible = |raw: Option<f32>| raw.is_some_and(|px| px >= clip_lo && px <= clip_hi);
let place = |raw: Option<f32>| raw.unwrap_or(px_at_data_min).clamp(clip_lo, clip_hi);
let lower = place(raw_lower);
let upper = place(raw_upper);
if (upper - lower).abs() <= 0.5 {
return None;
}
Some(Self {
lower,
upper,
lower_cap: visible(raw_lower),
upper_cap: visible(raw_upper),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct ErrorBarPixels {
pub x: f32,
pub y: f32,
pub vertical: Option<Whisker>,
pub horizontal: Option<Whisker>,
}
#[derive(Clone, Copy)]
pub(super) struct ErrorBarFrame<'a> {
pub plot_area: tiny_skia::Rect,
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
pub x_scale: &'a crate::axes::AxisScale,
pub y_scale: &'a crate::axes::AxisScale,
}
impl ErrorBarFrame<'_> {
fn project(&self, x: f64, y: f64) -> Option<(f32, f32)> {
crate::render::skia::try_map_data_to_pixels_scaled(
x,
y,
self.x_min,
self.x_max,
self.y_min,
self.y_max,
self.plot_area,
self.x_scale,
self.y_scale,
)
.filter(|(px, py)| px.is_finite() && py.is_finite())
}
}
impl ErrorBarPixels {
pub(super) fn new(
x_value: f64,
y_value: f64,
y_error: Option<(f64, f64)>,
x_error: Option<(f64, f64)>,
frame: ErrorBarFrame<'_>,
) -> Option<Self> {
let (x, y) = frame.project(x_value, y_value)?;
let plot_top = frame.plot_area.top();
let plot_bottom = frame.plot_area.bottom();
let plot_left = frame.plot_area.left();
let plot_right = frame.plot_area.right();
let vertical = y_error.and_then(|(lower, upper)| {
Whisker::resolve(
frame.project(x_value, y_value - lower).map(|(_, py)| py),
frame.project(x_value, y_value + upper).map(|(_, py)| py),
plot_bottom,
plot_top,
)
});
let horizontal = x_error.and_then(|(lower, upper)| {
Whisker::resolve(
frame.project(x_value - lower, y_value).map(|(px, _)| px),
frame.project(x_value + upper, y_value).map(|(px, _)| px),
plot_left,
plot_right,
)
});
Some(Self {
x,
y,
vertical,
horizontal,
})
}
}
pub(super) fn error_extent_at(
errors: Option<ErrorValuesRef<'_>>,
index: usize,
) -> Option<(f64, f64)> {
let (lower, upper) = errors?.bounds_at(index)?;
let (lower, upper) = (lower.abs(), upper.abs());
if !lower.is_finite() || !upper.is_finite() || (lower <= 0.0 && upper <= 0.0) {
return None;
}
Some((lower, upper))
}
pub(super) fn error_bar_pixels_for_series<'a>(
x_data: &'a [f64],
y_data: &'a [f64],
y_errors: Option<ErrorValuesRef<'a>>,
x_errors: Option<ErrorValuesRef<'a>>,
frame: ErrorBarFrame<'a>,
) -> impl Iterator<Item = ErrorBarPixels> + 'a {
x_data
.iter()
.zip(y_data)
.enumerate()
.filter_map(move |(index, (&x_value, &y_value))| {
ErrorBarPixels::new(
x_value,
y_value,
error_extent_at(y_errors, index),
error_extent_at(x_errors, index),
frame,
)
})
}
#[derive(Debug, Clone, Copy)]
pub(super) struct AttachedErrorBarStyle {
pub color: Color,
pub line_width: f32,
pub half_cap: f32,
}
impl AttachedErrorBarStyle {
pub(super) fn resolve(
error_config: Option<&crate::plots::error::errorbar::ErrorBarConfig>,
series_color: Color,
default_line_width: f32,
render_scale: crate::core::units::RenderScale,
) -> Self {
let config = error_config.cloned().unwrap_or_default();
let color = config.color.unwrap_or(series_color);
let color = color.with_alpha((f32::from(color.a) / 255.0) * config.alpha);
Self {
color,
line_width: render_scale
.logical_pixels_to_pixels(config.line_width)
.max(default_line_width * 0.75),
half_cap: render_scale.logical_pixels_to_pixels(config.cap_size) * 0.5,
}
}
}
pub(super) fn stroke_error_bar_series<C: ErrorBarCanvas>(
canvas: &mut C,
x_data: &[f64],
y_data: &[f64],
y_errors: Option<ErrorValuesRef<'_>>,
x_errors: Option<ErrorValuesRef<'_>>,
frame: ErrorBarFrame<'_>,
style: AttachedErrorBarStyle,
) -> Result<()> {
for bars in error_bar_pixels_for_series(x_data, y_data, y_errors, x_errors, frame) {
draw_error_bars(
canvas,
&bars,
frame.plot_area,
style.color,
style.line_width,
style.half_cap,
)?;
}
Ok(())
}
pub(super) trait ErrorBarCanvas {
fn stroke(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
) -> Result<()>;
}
impl ErrorBarCanvas for crate::render::skia::SkiaRenderer {
fn stroke(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
) -> Result<()> {
self.draw_line(x1, y1, x2, y2, color, width, LineStyle::Solid)
}
}
impl ErrorBarCanvas for crate::export::SvgRenderer {
fn stroke(
&mut self,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: Color,
width: f32,
) -> Result<()> {
self.draw_line(x1, y1, x2, y2, color, width, LineStyle::Solid);
Ok(())
}
}
pub(super) fn draw_error_bars<C: ErrorBarCanvas>(
canvas: &mut C,
bars: &ErrorBarPixels,
plot_area: tiny_skia::Rect,
color: Color,
line_width: f32,
half_cap: f32,
) -> Result<()> {
if let Some(whisker) = bars.vertical {
canvas.stroke(
bars.x,
whisker.lower,
bars.x,
whisker.upper,
color,
line_width,
)?;
let left = (bars.x - half_cap).max(plot_area.left());
let right = (bars.x + half_cap).min(plot_area.right());
for (py, draw) in [
(whisker.lower, whisker.lower_cap),
(whisker.upper, whisker.upper_cap),
] {
if draw {
canvas.stroke(left, py, right, py, color, line_width)?;
}
}
}
if let Some(whisker) = bars.horizontal {
canvas.stroke(
whisker.lower,
bars.y,
whisker.upper,
bars.y,
color,
line_width,
)?;
let top = (bars.y - half_cap).max(plot_area.top());
let bottom = (bars.y + half_cap).min(plot_area.bottom());
for (px, draw) in [
(whisker.lower, whisker.lower_cap),
(whisker.upper, whisker.upper_cap),
] {
if draw {
canvas.stroke(px, top, px, bottom, color, line_width)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::axes::AxisScale;
fn rect() -> tiny_skia::Rect {
tiny_skia::Rect::from_xywh(100.0, 50.0, 400.0, 300.0).unwrap()
}
fn linear_frame() -> ErrorBarFrame<'static> {
ErrorBarFrame {
plot_area: rect(),
x_min: 0.0,
x_max: 10.0,
y_min: 0.0,
y_max: 100.0,
x_scale: &AxisScale::Linear,
y_scale: &AxisScale::Linear,
}
}
fn log_y_frame() -> ErrorBarFrame<'static> {
ErrorBarFrame {
plot_area: rect(),
x_min: 0.0,
x_max: 10.0,
y_min: 1.0,
y_max: 200.0,
x_scale: &AxisScale::Linear,
y_scale: &AxisScale::Log,
}
}
#[test]
fn linear_whisker_spans_its_error_extent() {
let bars = ErrorBarPixels::new(5.0, 50.0, Some((10.0, 10.0)), None, linear_frame())
.expect("sample is on the axes");
let whisker = bars.vertical.expect("y error present");
assert!((bars.y - 200.0).abs() < 0.01, "anchor y {}", bars.y);
assert!(
(whisker.lower - 230.0).abs() < 0.01,
"lower {}",
whisker.lower
);
assert!(
(whisker.upper - 170.0).abs() < 0.01,
"upper {}",
whisker.upper
);
assert!(whisker.lower_cap && whisker.upper_cap);
}
#[test]
fn sample_the_log_axis_cannot_represent_is_dropped_whole() {
assert!(
ErrorBarPixels::new(2.0, 0.0, Some((2.0, 2.0)), None, log_y_frame()).is_none(),
"a non-positive sample has no place on a log axis"
);
assert!(ErrorBarPixels::new(4.0, -5.0, Some((2.0, 2.0)), None, log_y_frame()).is_none());
}
#[test]
fn whisker_running_off_a_log_axis_falls_to_the_floor_without_a_cap() {
let bars = ErrorBarPixels::new(5.0, 10.0, Some((20.0, 5.0)), None, log_y_frame())
.expect("the sample itself is positive");
let whisker = bars.vertical.expect("y error present");
let area = rect();
assert!(
(whisker.lower - area.bottom()).abs() < 0.01,
"unrepresentable lower end must clamp to the frame bottom, got {}",
whisker.lower
);
assert!(
!whisker.lower_cap,
"no cap on an end that is not really there"
);
assert!(
whisker.upper < whisker.lower,
"upper end is higher on screen"
);
assert!(
whisker.upper_cap,
"the upper end is representable and visible"
);
}
#[test]
fn cap_is_suppressed_when_an_end_leaves_the_visible_frame() {
let bars = ErrorBarPixels::new(5.0, 90.0, Some((5.0, 500.0)), None, linear_frame())
.expect("sample is on the axes");
let whisker = bars.vertical.expect("y error present");
let area = rect();
assert!((whisker.upper - area.top()).abs() < 0.01);
assert!(!whisker.upper_cap);
assert!(whisker.lower_cap);
}
#[test]
fn horizontal_whisker_uses_the_x_axis_orientation() {
let bars = ErrorBarPixels::new(5.0, 50.0, None, Some((1.0, 1.0)), linear_frame())
.expect("sample is on the axes");
let whisker = bars.horizontal.expect("x error present");
assert!(
(whisker.lower - 260.0).abs() < 0.01,
"lower {}",
whisker.lower
);
assert!(
(whisker.upper - 340.0).abs() < 0.01,
"upper {}",
whisker.upper
);
assert!(
whisker.lower < whisker.upper,
"data-lower is LEFT in pixel-x, unlike y"
);
}
#[test]
fn degenerate_error_extents_draw_nothing() {
assert_eq!(error_extent_at(None, 0), None);
assert_eq!(
error_extent_at(Some(ErrorValuesRef::Symmetric(&[0.0])), 0),
None
);
assert_eq!(
error_extent_at(Some(ErrorValuesRef::Symmetric(&[f64::NAN])), 0),
None
);
assert_eq!(
error_extent_at(Some(ErrorValuesRef::Symmetric(&[1.0])), 5),
None
);
assert_eq!(
error_extent_at(Some(ErrorValuesRef::Symmetric(&[-3.0])), 0),
Some((3.0, 3.0))
);
}
#[test]
fn sub_pixel_whiskers_are_dropped_rather_than_stubbed() {
let bars = ErrorBarPixels::new(5.0, 50.0, Some((0.001, 0.001)), None, linear_frame())
.expect("sample is on the axes");
assert!(bars.vertical.is_none());
}
}