Merge PR #591 from NikitkaRa: Charts: histograms, Pareto and box and whisker; Office 2016 charts save as themselves

# Conflicts:
#	crates/render/src/chart.rs
#	crates/ui-egui/src/ribbon.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:42:41 -04:00
11 changed files with 731 additions and 17 deletions
+38 -2
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Chart Elements",
["Chart Design", "Chart Layouts"],
None,
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}",
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], id?}",
has_selection,
chart_options
),
@@ -696,7 +696,12 @@ fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let k = chart_type(str_param(p, "type").unwrap_or("column"));
with_chart(s, p, "chart.type", |c| {
c.kind = k;
c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors;
c.vary_colors =
matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall)
|| c.series.len() == 1 && c.vary_colors;
if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
c.gridlines = true;
}
c.raw = None;
Ok(())
})
@@ -740,7 +745,38 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
}
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0));
if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() {
return Err(bad("chart.options", "binSize must be a positive number"));
}
// Waterfall total points.
let subtotals: Option<Vec<usize>> =
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
with_chart(s, p, "chart.options", |c| {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
if let Some(t) = &subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
c.raw = None;
}
if let Some(t) = p.get("title") {
c.title = t.as_str().map(String::from);
}
+305
View File
@@ -38,6 +38,22 @@ pub enum ChartType {
}
impl ChartType {
/// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus.
pub const OFFICE_2016: [ChartType; 7] = [
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Histogram,
ChartType::Pareto,
ChartType::BoxWhisker,
ChartType::Treemap,
ChartType::Sunburst,
];
/// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback).
pub fn is_chartex(self) -> bool {
Self::OFFICE_2016.contains(&self)
}
pub const MAIN: [ChartType; 12] = [
ChartType::Column,
ChartType::Bar,
@@ -274,6 +290,13 @@ pub struct Chart {
/// the running total, instead of floating.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub subtotals: Vec<usize>,
/// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are
/// automatic (Scott's rule, as Excel does).
#[serde(skip_serializing_if = "Option::is_none")]
pub bin_count: Option<u32>,
/// A histogram's bin width (`cx:binSize`).
#[serde(skip_serializing_if = "Option::is_none")]
pub bin_size: Option<f64>,
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
@@ -314,6 +337,8 @@ impl Default for Chart {
y2_hidden: false,
lang: None,
subtotals: vec![],
bin_count: None,
bin_size: None,
raw: None,
}
}
@@ -325,6 +350,27 @@ impl Chart {
let cats = ["Q1", "Q2", "Q3", "Q4"];
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() };
if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
// These plot raw observations: a histogram bins one series, a box chart summarises
// each category's points.
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
c.categories = if kind == ChartType::BoxWhisker {
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
} else {
vec![]
};
for s in 0..n_series {
c.series.push(Series {
name: format!("Series {}", s + 1),
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
..Default::default()
});
}
c.gridlines = true;
c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string());
return c;
}
let n_series =
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
for s in 0..n_series {
@@ -790,3 +836,262 @@ mod tests {
assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1);
}
}
/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the
/// left, as Excel bins) and how many observations fall in it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bin {
pub lo: f64,
pub hi: f64,
pub count: usize,
}
/// Bins a histogram has at most (a hostile bin size cannot make millions).
pub const MAX_BINS: usize = 1000;
/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at
/// the furthest points within 1.5 IQR of the box, and the points beyond them.
#[derive(Clone, Debug, PartialEq)]
pub struct BoxStats {
pub low: f64,
pub q1: f64,
pub median: f64,
pub q3: f64,
pub high: f64,
pub mean: f64,
pub outliers: Vec<f64>,
}
impl Chart {
/// The first series' finite observations.
fn observations(&self) -> Vec<f64> {
self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default()
}
/// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else
/// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations.
pub fn histogram_bins(&self) -> Vec<Bin> {
let mut v = self.observations();
if v.is_empty() {
return vec![];
}
v.sort_by(f64::total_cmp);
let (lo, hi) = (v[0], v[v.len() - 1]);
let span = hi - lo;
let n = v.len() as f64;
let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) {
w
} else if let Some(k) = self.bin_count.filter(|k| *k > 0) {
span / f64::from(k)
} else {
let mean = v.iter().sum::<f64>() / n;
let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0).max(1.0)).sqrt();
3.49 * sd / n.cbrt()
};
let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) };
let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) };
let mut bins: Vec<Bin> = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect();
for x in v {
// (lo, hi] bins, the first one also holding the minimum.
let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
if let Some(b) = bins.get_mut(i) {
b.count += 1;
}
}
bins
}
/// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with
/// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the
/// cumulative percentage as a line on a 0–100 % secondary axis.
pub fn histogram_view(&self) -> Chart {
let mut bins = self.histogram_bins();
let pareto = self.kind == ChartType::Pareto;
if pareto {
bins.sort_by_key(|b| std::cmp::Reverse(b.count));
}
let fmt = |v: f64| {
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
};
let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min);
let mut v = Chart {
kind: if pareto { ChartType::Combo } else { ChartType::Column },
categories: bins
.iter()
.map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) })
.collect(),
series: vec![],
gap_width: 0.0,
raw: None,
subtotals: vec![],
..self.clone()
};
let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default();
let base = self.series.first().cloned().unwrap_or_default();
v.series.push(Series {
name,
values: bins.iter().map(|b| Some(b.count as f64)).collect(),
kind: pareto.then_some(ChartType::Column),
number_format: None,
..base
});
if pareto {
let total = bins.iter().map(|b| b.count).sum::<usize>().max(1) as f64;
let mut run = 0.0;
v.series.push(Series {
name: "Cumulative %".into(),
values: bins
.iter()
.map(|b| {
run += b.count as f64;
Some(run / total)
})
.collect(),
kind: Some(ChartType::Line),
secondary_axis: true,
..Default::default()
});
v.y2_min = Some(0.0);
v.y2_max = Some(1.0);
v.y2_major = Some(0.2);
v.y2_format = Some("0%".into());
}
v.vary_colors = false;
v
}
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no
/// points).
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let mut groups: Vec<String> = Vec::new();
for c in &self.categories {
if !groups.contains(c) {
groups.push(c.clone());
}
}
if groups.is_empty() {
groups.push(String::new());
}
let stats = self
.series
.iter()
.map(|s| {
groups
.iter()
.map(|g| {
let pts: Vec<f64> = s
.values
.iter()
.enumerate()
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
.filter_map(|(_, v)| *v)
.filter(|v| v.is_finite())
.collect();
box_stats(pts)
})
.collect()
})
.collect();
(groups, stats)
}
}
/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends.
fn quantile_exc(v: &[f64], p: f64) -> f64 {
let n = v.len();
let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64);
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = v[i.saturating_sub(1).min(n - 1)];
let b = v[i.min(n - 1)];
a + (b - a) * frac
}
/// The summary of one group's points.
pub fn box_stats(mut pts: Vec<f64>) -> Option<BoxStats> {
if pts.is_empty() {
return None;
}
pts.sort_by(f64::total_cmp);
let (q1, median, q3) = (quantile_exc(&pts, 0.25), quantile_exc(&pts, 0.5), quantile_exc(&pts, 0.75));
let iqr = q3 - q1;
let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let inside: Vec<f64> = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect();
let low = inside.first().copied().unwrap_or(q1).min(q1);
let high = inside.last().copied().unwrap_or(q3).max(q3);
let mean = pts.iter().sum::<f64>() / pts.len() as f64;
let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect();
Some(BoxStats { low, q1, median, q3, high, mean, outliers })
}
#[cfg(test)]
mod chartex_tests {
use super::*;
fn obs(v: &[f64]) -> Chart {
Chart {
kind: ChartType::Histogram,
series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }],
..Default::default()
}
}
#[test]
fn histogram_bins_by_count_size_and_scott() {
let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]);
c.bin_count = Some(4);
let b = c.histogram_bins();
assert_eq!(b.len(), 4);
assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0));
// (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9.
assert_eq!(b.iter().map(|b| b.count).collect::<Vec<_>>(), vec![4, 2, 0, 1]);
c.bin_count = None;
c.bin_size = Some(5.0);
assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::<Vec<_>>(), vec![6, 1]);
c.bin_size = None;
let auto = c.histogram_bins();
assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::<usize>() == 7);
// A tiny bin size is capped; one value gives one bin.
c.bin_size = Some(1e-12);
assert!(c.histogram_bins().len() <= MAX_BINS);
assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]);
assert!(obs(&[]).histogram_bins().is_empty());
}
#[test]
fn pareto_view_sorts_bins_and_adds_the_cumulative_line() {
let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]);
c.kind = ChartType::Pareto;
c.bin_count = Some(3);
let v = c.histogram_view();
assert_eq!(v.kind, ChartType::Combo);
assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]);
let cum = &v.series[1];
assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line));
assert_eq!(cum.values.last(), Some(&Some(1.0)));
assert_eq!(v.categories[0], "(3.67, 6.33]");
}
#[test]
fn box_stats_use_exclusive_quartiles_and_fence_outliers() {
let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap();
// QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75.
assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75));
assert_eq!(b.outliers, vec![100.0]);
assert_eq!((b.low, b.high), (1.0, 7.0));
assert!(box_stats(vec![]).is_none());
let one = box_stats(vec![5.0]).unwrap();
assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0));
}
#[test]
fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker);
let (groups, stats) = c.box_groups();
assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]);
assert_eq!(stats.len(), 2);
assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some)));
}
}
+4
View File
@@ -110,6 +110,10 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
if c.categories.is_empty() {
c.categories = d.categories.clone();
}
if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) {
c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0);
c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0);
}
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) {
c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect();
}
+149 -2
View File
@@ -419,9 +419,14 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
w.close(tag);
}
/// The kept `chartEx` part of an Office 2016 chart (waterfall, funnel…) that is unchanged since
/// it was read, without its workbook link; `None` when it was edited or isn't one.
/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart
/// is unchanged since it was read (without its workbook link), else one written from the model.
/// `None` for other chart types.
pub fn chartex_xml(c: &Chart) -> Option<Vec<u8>> {
kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c)))
}
fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
let raw = c.raw.as_ref()?;
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
let mut root = doc.root.clone();
@@ -556,3 +561,145 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
w.close("c:chartSpace");
w.finish()
}
/// A `cx:tx` holding plain text.
fn cx_text(w: &mut W, t: &str) {
w.open0("cx:tx");
w.open0("cx:txData");
w.open0("cx:v");
w.text(t);
w.close("cx:v");
w.close("cx:txData");
w.close("cx:tx");
}
/// A `cx:lvl` of points.
fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
let mut a = A::new().a("ptCount", pts.len());
if let Some(f) = format {
a = a.a("formatCode", f);
}
w.open("cx:lvl", a);
for (i, p) in pts.iter().enumerate() {
w.open("cx:pt", A::new().a("idx", i));
w.text(p);
w.close("cx:pt");
}
w.close("cx:lvl");
}
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
/// per series (categories and values), then the plot area with each series' layout, the axes
/// the layout uses, the title and the legend.
pub fn build_chartex(c: &Chart) -> Vec<u8> {
let mut w = W::new();
w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX));
w.open0("cx:chartData");
for (i, s) in c.series.iter().enumerate() {
w.open("cx:data", A::new().a("id", i));
if !c.categories.is_empty() {
w.open("cx:strDim", A::new().a("type", "cat"));
cx_level(&mut w, &c.categories, None);
w.close("cx:strDim");
}
let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" };
w.open("cx:numDim", A::new().a("type", ty));
let vals: Vec<String> = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect();
cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General")));
w.close("cx:numDim");
w.close("cx:data");
}
w.close("cx:chartData");
w.open0("cx:chart");
if let Some(t) = &c.title {
w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0"));
cx_text(&mut w, t);
w.close("cx:title");
}
w.open0("cx:plotArea");
w.open0("cx:plotAreaRegion");
let layout = match c.kind {
ChartType::Waterfall => "waterfall",
ChartType::Funnel => "funnel",
ChartType::Treemap => "treemap",
ChartType::Sunburst => "sunburst",
ChartType::BoxWhisker => "boxWhisker",
_ => "clusteredColumn",
};
let one = !matches!(c.kind, ChartType::BoxWhisker);
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
cx_text(&mut w, &s.name);
if s.data_labels.unwrap_or(c.data_labels) {
w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" }));
w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1"));
w.close("cx:dataLabels");
}
w.empty("cx:dataId", A::new().a("val", i));
match c.kind {
ChartType::Waterfall if !c.subtotals.is_empty() => {
w.open0("cx:layoutPr");
w.open0("cx:subtotals");
for t in &c.subtotals {
w.empty("cx:idx", A::new().a("val", t));
}
w.close("cx:subtotals");
w.close("cx:layoutPr");
}
ChartType::Histogram | ChartType::Pareto => {
w.open0("cx:layoutPr");
w.open("cx:binning", A::new().a("intervalClosed", "r"));
if let Some(size) = c.bin_size {
w.empty("cx:binSize", A::new().a("val", fmt_num(size)));
} else if let Some(n) = c.bin_count {
w.empty("cx:binCount", A::new().a("val", n));
}
w.close("cx:binning");
w.close("cx:layoutPr");
}
ChartType::BoxWhisker => {
w.open0("cx:layoutPr");
w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1"));
w.empty("cx:statistics", A::new().a("quartileMethod", "exclusive"));
w.close("cx:layoutPr");
}
_ => {}
}
w.close("cx:series");
}
if c.kind == ChartType::Pareto {
w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}"));
w.empty("cx:axisId", A::new().a("val", "2"));
w.close("cx:series");
}
w.close("cx:plotAreaRegion");
if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
w.open("cx:axis", A::new().a("id", "0"));
w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" }));
w.empty0("cx:tickLabels");
w.close("cx:axis");
if c.kind != ChartType::Funnel {
w.open("cx:axis", A::new().a("id", "1"));
w.empty0("cx:valScaling");
if c.gridlines {
w.empty0("cx:majorGridlines");
}
w.empty0("cx:tickLabels");
w.close("cx:axis");
}
if c.kind == ChartType::Pareto {
w.open("cx:axis", A::new().a("id", "2"));
w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0"));
w.empty0("cx:units");
w.empty0("cx:tickLabels");
w.close("cx:axis");
}
}
w.close("cx:plotArea");
if let Some(pos) = &c.legend {
w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0"));
}
w.close("cx:chart");
w.close("cx:chartSpace");
w.finish()
}
+55 -3
View File
@@ -1479,7 +1479,8 @@ fn chartex_waterfall_reads_and_round_trips() {
let again = deckcraft_pptx::import(&out).unwrap();
assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1)));
// Edited: the kept part no longer matches, so only a plain chart is written.
// Edited: the kept part no longer matches, so a chartEx part is written from the model,
// still a waterfall with the new value.
let mut edited = doc.clone();
for sl in &mut edited.slides {
for sh in &mut Arc::make_mut(sl).shapes {
@@ -1489,6 +1490,57 @@ fn chartex_waterfall_reads_and_round_trips() {
}
}
let out = deckcraft_pptx::export(&edited).unwrap();
assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_none());
assert!(!String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1"));
let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap();
assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}");
assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1"));
let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(again.kind, ChartType::Waterfall);
assert_eq!(again.series[0].values[1], Some(55.0));
assert_eq!(again.subtotals, c.subtotals);
}
/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the
/// same type, data and options.
#[test]
fn chartex_new_charts_save_as_their_own_type() {
use deckcraft_engine::Session;
for (ty, kind) in [
("waterfall", ChartType::Waterfall),
("funnel", ChartType::Funnel),
("treemap", ChartType::Treemap),
("sunburst", ChartType::Sunburst),
("histogram", ChartType::Histogram),
("pareto", ChartType::Pareto),
("boxwhisker", ChartType::BoxWhisker),
] {
let chart = |d: &Presentation| -> Option<Chart> {
d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind {
ShapeKind::Chart(c) => Some((**c).clone()),
_ => None,
})
};
let mut s = Session::with_new();
s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap();
s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap();
let id = s.doc().unwrap().selection.shapes[0].0;
let opts = match kind {
ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}),
ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}),
_ => serde_json::json!({"id": id, "title": "T"}),
};
s.execute("chart.options", &opts).unwrap();
let doc = &s.doc().unwrap().doc;
let before = chart(doc).unwrap();
let out = deckcraft_pptx::export(doc).unwrap();
assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part");
assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart");
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
assert_eq!(after.kind, kind, "{ty}");
assert_eq!(after.title.as_deref(), Some("T"), "{ty}");
assert_eq!(after.categories, before.categories, "{ty}");
let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 };
assert_eq!(after.series.len(), n, "{ty}");
assert_eq!(after.series[0].values, before.series[0].values, "{ty}");
assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}");
}
}
+17
View File
@@ -130,6 +130,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo
}
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
match c.kind {
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None),
ChartType::BoxWhisker => {
let (view, boxes) = crate::chartex::box_view(c);
draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes));
}
_ => draw_inner(ctx, rctx, c, m, w, h, None),
}
}
#[allow(clippy::too_many_arguments)]
fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) {
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
if let Some(f) = &c.chart_fill {
@@ -418,6 +431,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
);
}
}
if let Some(b) = boxes {
crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v);
return;
}
let zero = to_v(clamp_soft(0.0, min, max));
if let Some(bk) = bar_kind {
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
+100 -3
View File
@@ -1,8 +1,9 @@
//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst
//! (waterfall bars are drawn by [`crate::chart`]).
//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the
//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as
//! the column chart of their bins).
use deckcraft_color::Rgba;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::chart::{BoxStats, Chart, ChartType};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::text::{Align, Anchor};
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
@@ -188,6 +189,102 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
}
}
/// A box chart's summaries: per series, per group.
pub struct Boxes {
pub stats: Vec<Vec<Option<BoxStats>>>,
}
/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one
/// category per group, legend by series), and the boxes to draw in it. The frame's values are
/// each group's extremes, so the value axis covers every whisker and outlier.
pub fn box_view(c: &Chart) -> (Chart, Boxes) {
let (groups, stats) = c.box_groups();
let mut v = c.clone();
v.kind = ChartType::Column;
v.categories = groups;
v.raw = None;
let ext = |b: &BoxStats, hi: bool| {
let o = b.outliers.iter().copied();
if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) }
};
let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min);
for (s, st) in v.series.iter_mut().zip(&stats) {
s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect();
}
if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() {
// The frame's values are the highs; below zero the axis must reach the lowest point too.
let mag = 10f64.powf(lowest.abs().log10().floor());
v.y_min = Some((lowest / mag).floor() * mag);
}
(v, Boxes { stats })
}
/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers
/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each
/// group's band.
#[allow(clippy::too_many_arguments)]
pub fn draw_boxes(
ctx: &mut RenderContext,
rctx: &Ctx,
c: &Chart,
b: &Boxes,
m: Affine,
plot: Rect,
band: f64,
horiz: bool,
to_v: &dyn Fn(f64) -> f64,
) {
let ns = b.stats.len().max(1);
let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0));
let w = group / ns as f64;
let line = Rgba::rgb(0x40, 0x40, 0x40);
for (si, per) in b.stats.iter().enumerate() {
let fill = series_color(rctx, c, si);
for (gi, st) in per.iter().enumerate() {
let Some(st) = st else { continue };
let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64;
let (a, z) = (c0 + w * 0.1, c0 + w * 0.9);
let mid = (a + z) / 2.0;
// Along the category axis `a..z`, along the value axis `to_v`.
let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) };
let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) };
ctx.set_transform(m);
ctx.set_paint(color(fill, 1.0));
let bx = rect(a, z, to_v(st.q1), to_v(st.q3));
ctx.fill_rect(&bx);
let mut p = BezPath::new();
// Whiskers with caps, and the median.
for (from, to) in [(st.q3, st.high), (st.q1, st.low)] {
p.move_to(pt(mid, to_v(from)));
p.line_to(pt(mid, to_v(to)));
p.move_to(pt(mid - w * 0.2, to_v(to)));
p.line_to(pt(mid + w * 0.2, to_v(to)));
}
p.move_to(pt(a, to_v(st.median)));
p.line_to(pt(z, to_v(st.median)));
ctx.set_paint(color(line, 1.0));
ctx.set_stroke(kurbo::Stroke::new(1.25));
ctx.stroke_path(&p);
ctx.stroke_path(&bx.to_path(0.1));
// The mean as an x.
let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0));
let mut x = BezPath::new();
x.move_to((mx.x - r, mx.y - r));
x.line_to((mx.x + r, mx.y + r));
x.move_to((mx.x - r, mx.y + r));
x.line_to((mx.x + r, mx.y - r));
ctx.stroke_path(&x);
for o in &st.outliers {
let q = pt(mid, to_v(*o));
ctx.set_paint(color(fill, 1.0));
ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1));
ctx.set_paint(color(line, 1.0));
ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+31
View File
@@ -1991,3 +1991,34 @@ fn office_2016_charts_draw_their_own_layouts() {
assert!(covered > 1000, "{kind:?}: {covered} samples inked");
}
}
/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and
/// box and whisker charts draw a box per group and series.
#[test]
fn histogram_pareto_and_box_charts_draw() {
let shot = |c: Chart| {
let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
};
let inked = |img: &crate::Image| {
(0..540)
.step_by(6)
.flat_map(|y| (0..960).step_by(6).map(move |x| (x, y)))
.filter(|&(x, y)| {
let p = img.pixel(x, y);
p[0] < 200 || p[1] < 200 || p[2] < 200
})
.count()
};
let mut empty = Chart::sample(ChartType::Histogram);
empty.series[0].values.clear();
let blank = inked(&shot(empty));
for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] {
let img = shot(Chart::sample(kind));
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png());
}
let n = inked(&img);
assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})");
}
}
+22 -3
View File
@@ -1072,8 +1072,13 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) {
big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"}));
let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart"));
egui::Popup::menu(&r).show(|ui| {
for k in deckcraft_model::chart::ChartType::MAIN {
if ui.button(k.label()).clicked() {
for k in chart_menu_types() {
if k.is_none() {
ui.separator();
}
if let Some(k) = k
&& ui.button(k.label()).clicked()
{
run(app, "insert.chart", json!({"type": format!("{k:?}")}));
}
}
@@ -2565,7 +2570,11 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true);
egui::Popup::menu(&r).show(|ui| {
for k in deckcraft_model::chart::ChartType::MAIN {
for k in chart_menu_types() {
let Some(k) = k else {
ui.separator();
continue;
};
if ui.button(k.label()).clicked() {
// Replace the chart's type keeping its data.
if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) {
@@ -2682,6 +2691,16 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink)));
}
/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator
/// (`None`), then the Office 2016 types not already listed.
fn chart_menu_types() -> Vec<Option<deckcraft_model::chart::ChartType>> {
use deckcraft_model::chart::ChartType;
let mut v: Vec<Option<ChartType>> = ChartType::MAIN.iter().copied().map(Some).collect();
v.push(None);
v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some));
v
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -92,6 +92,10 @@ the round trip even though DeckCraft doesn't model it.
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
| SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Only the innermost hierarchy level; no region maps |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save |
| Equations (OMML, `a14:m`) | no | no | PR #55 open |
| Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) |
| Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink |
+6 -4
View File
@@ -209,10 +209,12 @@ Detail in [file-format-parity.md](file-format-parity.md).
chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back
when the chart is rebuilt.
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto,
box & whisker) are read for their data and written back unchanged. Waterfalls (floating bars,
totals, connectors), funnels, treemaps (squarified) and sunbursts (innermost ring) are drawn;
histograms and box & whisker still use the column renderer, an edited one is saved as a plain
column chart, and region maps aren't read.
box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else
written from the model, with a plain chart fallback). Waterfalls float and total, funnels,
treemaps (squarified) and sunbursts (innermost ring only) have their own layouts, histograms and
Paretos bin their data (count, width or Scott's rule), and box & whisker charts use exclusive
quartiles with 1.5 IQR whiskers. Multi-level treemap and sunburst hierarchies, inclusive
quartiles and region maps remain.
- **Impact:** most business decks have charts; editing their data is routine.
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).