mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
Charts: multi-level treemaps and sunbursts; inclusive box quartiles
Read, keep and write every cx:lvl of a chartEx category dimension, build a category tree from runs of parent labels, and draw treemaps as nested squarified groups with banners and sunbursts as one ring per level. Box & whisker honours quartileMethod="inclusive" (QUARTILE.INC) on import, render, save and the chart.options command. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Data",
|
||||
["Chart Design", "Data"],
|
||||
None,
|
||||
"{categories?: [..], series?: [{name, values}], id?}",
|
||||
"{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}",
|
||||
has_selection,
|
||||
chart_data
|
||||
),
|
||||
@@ -73,7 +73,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?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], id?}",
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -669,10 +669,21 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let levels: Option<Vec<Vec<String>>> = p.get("levels").and_then(Value::as_array).map(|a| {
|
||||
a.iter()
|
||||
.take(deckcraft_model::chart::MAX_LEVELS)
|
||||
.map(|l| {
|
||||
l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
with_chart(s, p, "chart.data", |c| {
|
||||
if let Some(cs) = &cats {
|
||||
c.categories = cs.clone();
|
||||
}
|
||||
if let Some(l) = &levels {
|
||||
c.category_levels = l.clone();
|
||||
}
|
||||
if let Some(se) = &series {
|
||||
// Keep series formatting by position.
|
||||
let old = std::mem::take(&mut c.series);
|
||||
@@ -722,6 +733,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
|
||||
+136
-4
@@ -206,6 +206,13 @@ pub struct Chart {
|
||||
/// the running total, instead of floating.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub subtotals: Vec<usize>,
|
||||
/// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents,
|
||||
/// then theirs…), each with one label per category. A blank label continues the one above it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub category_levels: Vec<Vec<String>>,
|
||||
/// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub inclusive_quartiles: bool,
|
||||
/// 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")]
|
||||
@@ -253,6 +260,8 @@ impl Default for Chart {
|
||||
y2_hidden: false,
|
||||
lang: None,
|
||||
subtotals: vec![],
|
||||
category_levels: vec![],
|
||||
inclusive_quartiles: false,
|
||||
bin_count: None,
|
||||
bin_size: None,
|
||||
raw: None,
|
||||
@@ -266,6 +275,15 @@ 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::Treemap | ChartType::Sunburst) {
|
||||
// A two-level hierarchy: months inside quarters.
|
||||
c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect();
|
||||
c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()];
|
||||
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
|
||||
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
|
||||
c.vary_colors = true;
|
||||
return c;
|
||||
}
|
||||
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.
|
||||
@@ -743,7 +761,7 @@ impl Chart {
|
||||
.filter_map(|(_, v)| *v)
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
box_stats(pts)
|
||||
box_stats_by(pts, self.inclusive_quartiles)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -763,13 +781,29 @@ fn quantile_exc(v: &[f64], p: f64) -> f64 {
|
||||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
/// The summary of one group's points.
|
||||
pub fn box_stats(mut pts: Vec<f64>) -> Option<BoxStats> {
|
||||
/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data.
|
||||
fn quantile_inc(v: &[f64], p: f64) -> f64 {
|
||||
let n = v.len();
|
||||
let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64);
|
||||
let i = pos.floor() as usize;
|
||||
let a = v[i.min(n - 1)];
|
||||
let b = v[(i + 1).min(n - 1)];
|
||||
a + (b - a) * (pos - i as f64)
|
||||
}
|
||||
|
||||
/// The summary of one group's points, with exclusive quartiles.
|
||||
pub fn box_stats(pts: Vec<f64>) -> Option<BoxStats> {
|
||||
box_stats_by(pts, false)
|
||||
}
|
||||
|
||||
/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC.
|
||||
pub fn box_stats_by(mut pts: Vec<f64>, inclusive: bool) -> 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 q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) };
|
||||
let (q1, median, q3) = (q(0.25), q(0.5), q(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();
|
||||
@@ -780,6 +814,75 @@ pub fn box_stats(mut pts: Vec<f64>) -> Option<BoxStats> {
|
||||
Some(BoxStats { low, q1, median, q3, high, mean, outliers })
|
||||
}
|
||||
|
||||
/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and
|
||||
/// its children (none for a leaf, which names its category index).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TreeNode {
|
||||
pub name: String,
|
||||
pub value: f64,
|
||||
pub leaf: Option<usize>,
|
||||
pub children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper).
|
||||
pub const MAX_LEVELS: usize = 16;
|
||||
|
||||
impl Chart {
|
||||
/// The first series as a hierarchy: top-level nodes, each holding the consecutive
|
||||
/// categories that share its label, down to the leaves. Without outer levels every
|
||||
/// category is a top-level leaf. Negative and missing values count as zero.
|
||||
pub fn category_tree(&self) -> Vec<TreeNode> {
|
||||
let Some(s) = self.series.first() else { return vec![] };
|
||||
let n = self.categories.len().max(s.values.len());
|
||||
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
|
||||
// Each leaf's path from the top level down, blanks filled from the row above.
|
||||
let mut last: Vec<String> = vec![String::new(); levels.len()];
|
||||
let paths: Vec<Vec<String>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut path = Vec::with_capacity(levels.len() + 1);
|
||||
for (d, lv) in levels.iter().enumerate().rev() {
|
||||
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
|
||||
if !l.is_empty() {
|
||||
last[d] = l.to_string();
|
||||
// A new outer label starts fresh inner groups.
|
||||
for inner in last.iter_mut().take(d) {
|
||||
inner.clear();
|
||||
}
|
||||
}
|
||||
path.push(last[d].clone());
|
||||
}
|
||||
path.push(self.categories.get(i).cloned().unwrap_or_default());
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
|
||||
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
|
||||
let mut out: Vec<TreeNode> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < rows.len() {
|
||||
let (idx, path) = rows[i];
|
||||
if path.len() <= 1 {
|
||||
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let head = &path[0];
|
||||
let mut j = i;
|
||||
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
|
||||
j += 1;
|
||||
}
|
||||
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
|
||||
let children = build(&sub, value);
|
||||
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
|
||||
build(&rows, &value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chartex_tests {
|
||||
use super::*;
|
||||
@@ -840,6 +943,35 @@ mod chartex_tests {
|
||||
assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inclusive_quartiles_follow_quartile_inc() {
|
||||
let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap();
|
||||
// QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based).
|
||||
assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tree_groups_runs_and_fills_blank_parents() {
|
||||
let mut c = Chart::sample(ChartType::Treemap);
|
||||
let t = c.category_tree();
|
||||
assert_eq!(t.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Q1", "Q2", "Q3"]);
|
||||
assert_eq!(t[0].children.len(), 3);
|
||||
assert_eq!(t[0].children[1].leaf, Some(1));
|
||||
assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9);
|
||||
// Blank parents continue the label above; two outer levels nest.
|
||||
c.category_levels = vec![
|
||||
vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()],
|
||||
vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()],
|
||||
];
|
||||
let t = c.category_tree();
|
||||
assert_eq!(t.len(), 2);
|
||||
assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["a", "b"]);
|
||||
assert_eq!(t[1].children[0].children.len(), 4);
|
||||
// No levels: every category is a leaf.
|
||||
c.category_levels.clear();
|
||||
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_groups_follow_category_labels() {
|
||||
let c = Chart::sample(ChartType::BoxWhisker);
|
||||
|
||||
@@ -14,6 +14,8 @@ const MAX_POINTS: usize = 100_000;
|
||||
#[derive(Default)]
|
||||
struct Data {
|
||||
categories: Vec<String>,
|
||||
/// Outer category levels, innermost first.
|
||||
levels: Vec<Vec<String>>,
|
||||
numbers: HashMap<String, (Vec<Option<f64>>, Option<String>)>,
|
||||
}
|
||||
|
||||
@@ -45,6 +47,8 @@ fn data(d: &El) -> Data {
|
||||
if dim.is("strDim") || (dim.is("numDim") && ty == "cat") {
|
||||
if ty == "cat" || out.categories.is_empty() {
|
||||
out.categories = points(lvl);
|
||||
// Further levels are the hierarchy's parents, innermost first.
|
||||
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect();
|
||||
}
|
||||
} else if dim.is("numDim") {
|
||||
let vals = points(lvl).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
|
||||
@@ -109,6 +113,10 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
|
||||
if c.categories.is_empty() {
|
||||
c.categories = d.categories.clone();
|
||||
c.category_levels = d.levels.clone();
|
||||
}
|
||||
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) {
|
||||
c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive");
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -546,6 +546,9 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
if !c.categories.is_empty() {
|
||||
w.open("cx:strDim", A::new().a("type", "cat"));
|
||||
cx_level(&mut w, &c.categories, None);
|
||||
for lv in &c.category_levels {
|
||||
cx_level(&mut w, lv, None);
|
||||
}
|
||||
w.close("cx:strDim");
|
||||
}
|
||||
let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" };
|
||||
@@ -606,7 +609,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
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.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" }));
|
||||
w.close("cx:layoutPr");
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -1160,3 +1160,40 @@ fn chartex_new_charts_save_as_their_own_type() {
|
||||
assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-level treemap keeps its parent level, and a box chart its inclusive quartiles, through
|
||||
/// a save and reopen.
|
||||
#[test]
|
||||
fn chartex_levels_and_quartiles_round_trip() {
|
||||
use deckcraft_engine::Session;
|
||||
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,
|
||||
})
|
||||
};
|
||||
for ty in ["treemap", "sunburst", "boxwhisker"] {
|
||||
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;
|
||||
if ty == "boxwhisker" {
|
||||
s.execute("chart.options", &serde_json::json!({"id": id, "quartiles": "inclusive"})).unwrap();
|
||||
} else {
|
||||
s.execute(
|
||||
"chart.data",
|
||||
&serde_json::json!({"id": id, "levels": [["Q1", "", "", "Q2", "", "", "Q3", ""], ["H1", "", "", "", "", "", "H2", ""]]}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let before = chart(&s.doc().unwrap().doc).unwrap();
|
||||
let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap();
|
||||
assert_eq!(after.category_levels, before.category_levels, "{ty}");
|
||||
assert_eq!(after.inclusive_quartiles, before.inclusive_quartiles, "{ty}");
|
||||
assert_eq!(after.category_tree(), before.category_tree(), "{ty}");
|
||||
// Unchanged since import, the part is written back as read.
|
||||
let again = deckcraft_pptx::export(&deckcraft_pptx::import(&out).unwrap()).unwrap();
|
||||
assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let legend_items: Vec<(String, Rgba)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect()
|
||||
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
// A hierarchy's legend names its top-level groups.
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect()
|
||||
} else if c.kind == ChartType::Funnel {
|
||||
vec![]
|
||||
} else if pie {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the column chart of their bins).
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::chart::{BoxStats, Chart, ChartType};
|
||||
use deckcraft_model::chart::{BoxStats, Chart, ChartType, TreeNode};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
@@ -23,7 +23,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
let labels = c.data_labels || c.kind == ChartType::Funnel;
|
||||
match c.kind {
|
||||
ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label),
|
||||
ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)),
|
||||
ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)),
|
||||
_ if !c.category_levels.is_empty() => tree_sunburst(ctx, rctx, c, m, area, base),
|
||||
_ => sunburst(ctx, rctx, c, m, area, base, &vals),
|
||||
}
|
||||
}
|
||||
@@ -189,6 +191,162 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec
|
||||
}
|
||||
}
|
||||
|
||||
/// `col` mixed toward white by `k` (0..1): deeper hierarchy levels are lighter.
|
||||
fn lighten(col: Rgba, k: f64) -> Rgba {
|
||||
let k = k.clamp(0.0, 0.8);
|
||||
let f = |v: u8| (f64::from(v) + (255.0 - f64::from(v)) * k).round() as u8;
|
||||
Rgba::rgb(f(col.r), f(col.g), f(col.b))
|
||||
}
|
||||
|
||||
/// A multi-level treemap: top-level groups squarified by their totals, each with its label in
|
||||
/// a banner along the top, and their children squarified inside, down to the leaves. A group
|
||||
/// and everything in it take the group's colour.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) {
|
||||
let tree = c.category_tree();
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
m: Affine,
|
||||
nodes: &[TreeNode],
|
||||
r: Rect,
|
||||
base: f64,
|
||||
col: &dyn Fn(usize) -> Rgba,
|
||||
value_label: Option<&dyn Fn(f64) -> String>,
|
||||
depth: usize,
|
||||
) {
|
||||
let vals: Vec<f64> = nodes.iter().map(|n| n.value).collect();
|
||||
for (i, (n, t)) in nodes.iter().zip(squarify(&vals, r)).enumerate() {
|
||||
if t.area() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let fill = col(i);
|
||||
if n.children.is_empty() {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&t);
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.0));
|
||||
ctx.stroke_path(&t.to_path(0.1));
|
||||
let mut text = n.name.clone();
|
||||
if let Some(f) = value_label {
|
||||
text = format!("{text}\n{}", f(n.value));
|
||||
}
|
||||
let inner = t.inset(-base * 0.3);
|
||||
if inner.width() > base && inner.height() > base {
|
||||
label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A group: its banner, then its children below it.
|
||||
let banner = (base * 1.5).min(t.height() * 0.3);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&t);
|
||||
if t.width() > base * 2.0 && banner > base * 0.8 {
|
||||
let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner);
|
||||
label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true);
|
||||
}
|
||||
let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0);
|
||||
let k = lighten(fill, 0.12 * (depth + 1) as f64);
|
||||
place(ctx, rctx, m, &n.children, inner, base, &|_| k, value_label, depth + 1);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(2.0));
|
||||
ctx.stroke_path(&t.to_path(0.1));
|
||||
}
|
||||
}
|
||||
place(ctx, rctx, m, &tree, area, base, &|i| series_color(rctx, c, i), value_label, 0);
|
||||
}
|
||||
|
||||
/// A multi-level sunburst: the top level as the inner ring, each deeper level in the next ring
|
||||
/// out within its parent's angle, coloured by top-level group and lighter further out.
|
||||
fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) {
|
||||
let tree = c.category_tree();
|
||||
fn depth(n: &[TreeNode]) -> usize {
|
||||
n.iter().map(|c| 1 + depth(&c.children)).max().unwrap_or(0)
|
||||
}
|
||||
let levels = depth(&tree).max(1);
|
||||
let total: f64 = tree.iter().map(|n| n.value).sum();
|
||||
if total <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let r = area.width().min(area.height()) / 2.0 * 0.95;
|
||||
let hole = r * 0.2;
|
||||
let ring = (r - hole) / levels as f64;
|
||||
let cen = area.center();
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ring_of(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
m: Affine,
|
||||
nodes: &[TreeNode],
|
||||
a0: f64,
|
||||
span: f64,
|
||||
d: usize,
|
||||
geo: (Point, f64, f64, f64),
|
||||
base: f64,
|
||||
col: &dyn Fn(usize) -> Rgba,
|
||||
) {
|
||||
let (cen, hole, ring, total) = geo;
|
||||
let sum: f64 = nodes.iter().map(|n| n.value).sum();
|
||||
if sum <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let (ri, ro) = (hole + ring * d as f64, hole + ring * (d + 1) as f64);
|
||||
let mut a = a0;
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let sweep = n.value / sum * span;
|
||||
if sweep <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let fill = col(i);
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(Point::new(cen.x + ri * a.cos(), cen.y + ri * a.sin()));
|
||||
p.line_to(Point::new(cen.x + ro * a.cos(), cen.y + ro * a.sin()));
|
||||
p.extend(kurbo::Arc::new(cen, (ro, ro), a, sweep, 0.0).append_iter(0.1));
|
||||
let a1 = a + sweep;
|
||||
p.line_to(Point::new(cen.x + ri * a1.cos(), cen.y + ri * a1.sin()));
|
||||
p.extend(kurbo::Arc::new(cen, (ri, ri), a1, -sweep, 0.0).append_iter(0.1));
|
||||
p.close_path();
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_path(&p);
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.5));
|
||||
ctx.stroke_path(&p);
|
||||
// Labels where the segment has room for them.
|
||||
let mid = a + sweep / 2.0;
|
||||
let rr = (ri + ro) / 2.0;
|
||||
if sweep * rr > base * 2.5 && !n.name.is_empty() {
|
||||
let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin());
|
||||
let half = (ring * 0.48).max(base);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&n.name,
|
||||
Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base),
|
||||
base * 0.9,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
Rgba::WHITE,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
let k = lighten(fill, 0.15);
|
||||
ring_of(ctx, rctx, m, &n.children, a, sweep, d + 1, (cen, hole, ring, total), base, &|_| k);
|
||||
}
|
||||
a = a1;
|
||||
}
|
||||
}
|
||||
ring_of(ctx, rctx, m, &tree, -std::f64::consts::FRAC_PI_2, std::f64::consts::TAU, 0, (cen, hole, ring, total), base, &|i| {
|
||||
series_color(rctx, c, i)
|
||||
});
|
||||
}
|
||||
|
||||
/// A box chart's summaries: per series, per group.
|
||||
pub struct Boxes {
|
||||
pub stats: Vec<Vec<Option<BoxStats>>>,
|
||||
|
||||
@@ -1777,3 +1777,29 @@ fn histogram_pareto_and_box_charts_draw() {
|
||||
assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-level treemaps and sunbursts draw their groups: the treemap's group banners and the
|
||||
/// sunburst's inner ring of groups, coloured by top-level group.
|
||||
#[test]
|
||||
fn multilevel_treemap_and_sunburst_draw_their_groups() {
|
||||
for kind in [ChartType::Treemap, ChartType::Sunburst] {
|
||||
let c = Chart::sample(kind);
|
||||
let sh = Shape { id: ShapeId(623), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
|
||||
let _ = std::fs::write(format!("{dir}/{kind:?}-levels.png"), img.to_png());
|
||||
}
|
||||
// Distinct colours: one hue per top-level group plus lighter children.
|
||||
let mut seen: Vec<[u8; 3]> = Vec::new();
|
||||
for y in (60..520).step_by(8) {
|
||||
for x in (40..920).step_by(8) {
|
||||
let p = img.pixel(x, y);
|
||||
let c = [p[0] / 16, p[1] / 16, p[2] / 16];
|
||||
if c != [15, 15, 15] && !seen.contains(&c) {
|
||||
seen.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| 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 |
|
||||
| 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. Treemap and sunburst hierarchy levels kept; 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) |
|
||||
|
||||
+4
-4
@@ -148,10 +148,10 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto,
|
||||
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.
|
||||
treemaps (squarified, nested by level with group banners) and sunbursts (one ring per level)
|
||||
have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule),
|
||||
and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. 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