mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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>"#;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user