mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 04:27:06 -06:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)> =
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user