Merge PR #673 from NikitkaRa: Charts: error bars

Fix-ups in the merge: error-bar statistics are computed once per series (was once per point,
quadratic on long series); reading custom amounts uses the chart-wide point budget (#480).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-14 15:14:06 -04:00
co-authored by Claude Opus 5.5
4 changed files with 180 additions and 1 deletions
+87
View File
@@ -242,6 +242,76 @@ pub struct Series {
/// Trendlines (`c:trendline`), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub trendlines: Vec<Trendline>,
/// `c:errBars` on the values (y bars; a bar chart's run along its value axis).
#[serde(skip_serializing_if = "Option::is_none")]
pub error_bars: Option<ErrorBars>,
}
/// Error bars (`c:errBars`): how far each point's bar reaches below and above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ErrorBars {
/// `both`, `plus` or `minus`.
pub bar_type: String,
/// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`.
pub val_type: String,
pub value: f64,
/// `cust`: the per-point plus and minus amounts.
pub plus: Vec<Option<f64>>,
pub minus: Vec<Option<f64>>,
pub end_cap: bool,
}
impl Default for ErrorBars {
fn default() -> Self {
ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true }
}
}
/// Count, mean and sample standard deviation of a series' finite values (for error bars).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SeriesStats {
pub n: f64,
pub mean: f64,
pub sd: f64,
}
impl SeriesStats {
pub fn of(values: &[Option<f64>]) -> Self {
let vals = || values.iter().flatten().copied().filter(|v| v.is_finite());
let n = vals().count() as f64;
let mean = vals().sum::<f64>() / n.max(1.0);
let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt() } else { 0.0 };
SeriesStats { n, mean, sd }
}
}
impl ErrorBars {
/// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units.
/// Standard deviation bars stand around the series' mean, as Excel draws them; the others
/// around the point. `None` for an amount that isn't a finite number.
pub fn range(&self, values: &[Option<f64>], i: usize, v: f64) -> Option<(f64, f64)> {
self.range_in(&SeriesStats::of(values), i, v)
}
/// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so
/// drawing every point of a long series stays linear.
pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> {
let SeriesStats { n, mean, sd } = *stats;
let (centre, minus, plus) = match self.val_type.as_str() {
"percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()),
"stdDev" => (mean, sd * self.value, sd * self.value),
"stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()),
"cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)),
_ => (v, self.value, self.value),
};
let (lo, hi) = match self.bar_type.as_str() {
"plus" => (centre, centre + plus),
"minus" => (centre - minus, centre),
_ => (centre - minus, centre + plus),
};
(lo.is_finite() && hi.is_finite()).then_some((lo, hi))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -732,6 +802,23 @@ mod tests {
assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0)));
}
#[test]
fn error_bar_ranges() {
let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)];
let mut e = ErrorBars::default();
assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0)));
e.val_type = "percentage".into();
e.value = 20.0;
assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0)));
e.bar_type = "plus".into();
assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0)));
e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() };
assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5)));
e = ErrorBars { val_type: "stdDev".into(), ..Default::default() };
let (lo, hi) = e.range(&vals, 0, 3.0).unwrap();
assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9);
}
#[test]
fn nice_scale_covers() {
let (a, b, s) = nice_scale(0.0, 5.1);
+24 -1
View File
@@ -1,6 +1,6 @@
//! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`.
use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType};
use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType};
use deckcraft_model::style::Fill;
use crate::xml::El;
@@ -266,6 +266,21 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
if let Some(v) = s.child("val").or_else(|| s.child("yVal")) {
ser.values = numbers(v, &mut budget);
}
// Error bars on the values: a scatter chart's y bars, any other chart's only ones.
if let Some(e) = s
.children_named("errBars")
.find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble)))
{
let d = ErrorBars::default();
ser.error_bars = Some(ErrorBars {
bar_type: e.child_val("errBarType").unwrap_or("both").to_string(),
val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(),
value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value),
plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(),
minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(),
end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true),
});
}
if let Some(b) = s.child("bubbleSize") {
ser.sizes = numbers(b, &mut budget);
}
@@ -457,6 +472,14 @@ mod tests {
assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0));
}
#[test]
fn error_bars_are_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:errBars><c:errBarType val="plus"/><c:errValType val="percentage"/><c:noEndCap val="1"/><c:val val="20"/></c:errBars><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml).unwrap();
let e = c.series[0].error_bars.clone().unwrap();
assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false));
}
#[test]
fn legend_position_and_overlay_are_read() {
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
+36
View File
@@ -111,6 +111,20 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option<f64>]) {
w.close("c:numRef");
}
/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them.
fn num_lit(w: &mut W, vals: &[Option<f64>]) {
w.open0("c:numLit");
w.val("c:ptCount", vals.len());
for (i, v) in vals.iter().enumerate() {
if let Some(v) = v.filter(|v| v.is_finite()) {
w.open("c:pt", A::new().a("idx", i));
w.elt("c:v", &fmt_num(v));
w.close("c:pt");
}
}
w.close("c:numLit");
}
#[derive(Clone, Copy, PartialEq)]
enum Group {
Bar,
@@ -212,6 +226,28 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
}
let n = c.categories.len().max(s.values.len());
let last = n + 1;
if let Some(e) = &s.error_bars {
let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d);
w.open0("c:errBars");
if matches!(g, Group::Scatter | Group::Bubble) {
w.val("c:errDir", "y");
}
w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both"));
let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal");
w.val("c:errValType", vt);
w.val("c:noEndCap", if e.end_cap { 0 } else { 1 });
if vt == "cust" {
w.open0("c:plus");
num_lit(w, &e.plus);
w.close("c:plus");
w.open0("c:minus");
num_lit(w, &e.minus);
w.close("c:minus");
} else if e.value.is_finite() {
w.val("c:val", fmt_num(e.value));
}
w.close("c:errBars");
}
if matches!(g, Group::Scatter | Group::Bubble) {
let xs: Vec<Option<f64>> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() };
w.open0("c:xVal");
+33
View File
@@ -561,6 +561,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
Some((b, _)) => to_v(b.powf(min)),
None => to_v(clamp_soft(0.0, min, max)),
};
// Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap).
let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![];
if let Some(bk) = bar_kind {
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
@@ -574,6 +576,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
let bar = band / (n - (n - 1.0) * ov + gap);
let step = bar * (1.0 - ov);
let group = bar + step * (n - 1.0);
// Error bars need each series' mean and spread: worked out once, not per point.
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
@@ -633,6 +637,13 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.stroke_path(&j);
}
if let Some(e) = &s.error_bars {
let (end, mid) = if horiz { (if v >= 0.0 { b } else { a }, r.center().y) } else { (if v >= 0.0 { b } else { a }, r.center().x) };
if let Some((lo, hi)) = stats.get(bi).and_then(|st| e.range_in(st, i, end)) {
let (p0, p1) = if horiz { (to_v(lo), to_v(hi)) } else { (to_axis(sec, lo), to_axis(sec, hi)) };
err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap));
}
}
if c.data_labels {
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
let lr = if horiz {
@@ -696,6 +707,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
_ => plot.x0 + band * (slot(i) + 0.5),
};
if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) {
err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap)));
}
pts.push(Point::new(x, to_axis(sec, v)));
}
if pts.is_empty() {
@@ -728,6 +742,25 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
// A category position (0-based, fractional) as a slot, the way `slot` places the bars.
let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back };
draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis);
if !err_marks.is_empty() {
let mut p = BezPath::new();
for &(mid, a, b, cap, end_cap) in &err_marks {
// A horizontal bar chart's error bars run across, its value axis being horizontal.
let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) };
p.move_to(pt(a, mid));
p.line_to(pt(b, mid));
if end_cap {
for e in [a, b] {
p.move_to(pt(e, mid - cap));
p.line_to(pt(e, mid + cap));
}
}
}
ctx.set_transform(m);
ctx.set_paint(color(text_col, 1.0));
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.stroke_path(&p);
}
// Axis line.
let mut ax = BezPath::new();
if horiz {