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:
Nikita Rakitin
2026-10-14 01:16:09 +03:00
co-authored by Cursor
parent 15daed6ca4
commit b3420a0b04
10 changed files with 394 additions and 14 deletions
+17 -2
View File
@@ -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
View File
@@ -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);
+8
View File
@@ -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);
+4 -1
View File
@@ -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");
}
_ => {}
+37
View File
@@ -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}");
}
}
+2 -1
View File
@@ -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 {
+159 -1
View File
@@ -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>>>,
+26
View File
@@ -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());
}
}
+1 -1
View File
@@ -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
View File
@@ -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).