From b3420a0b04f078d97f56f15e7e8a012d69579a62 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:16:09 +0300 Subject: [PATCH] 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 --- crates/engine/src/cmd/insert.rs | 19 +++- crates/model/src/chart.rs | 140 +++++++++++++++++++++++++++- crates/pptx/src/read/chartex.rs | 8 ++ crates/pptx/src/write/chart.rs | 5 +- crates/pptx/tests/roundtrip.rs | 37 ++++++++ crates/render/src/chart.rs | 3 +- crates/render/src/chartex.rs | 160 +++++++++++++++++++++++++++++++- crates/render/src/tests.rs | 26 ++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 8 +- 10 files changed, 394 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 810a96e2..8a0a7d28 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "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 { "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 { }) .collect() }); + let levels: Option>> = 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 { 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; } diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 583a447b..54e28dcd 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -206,6 +206,13 @@ pub struct Chart { /// the running total, instead of floating. #[serde(skip_serializing_if = "Vec::is_empty")] pub subtotals: Vec, + /// 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>, + /// 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) -> Option { +/// 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) -> Option { + 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, inclusive: bool) -> Option { 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 = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); @@ -780,6 +814,75 @@ pub fn box_stats(mut pts: Vec) -> Option { 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, + pub children: Vec, +} + +/// 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 { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = 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 = vec![String::new(); levels.len()]; + let paths: Vec> = (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 { + let mut out: Vec = 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::>(), ["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::>(), ["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); diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 5c6e2f77..339d26c3 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -14,6 +14,8 @@ const MAX_POINTS: usize = 100_000; #[derive(Default)] struct Data { categories: Vec, + /// Outer category levels, innermost first. + levels: Vec>, numbers: HashMap>, Option)>, } @@ -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::().ok().filter(|v| v.is_finite())).collect(); @@ -109,6 +113,10 @@ pub(crate) fn chartex_from(root: &El) -> Option { ["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); diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 172acf49..e0e3743e 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -546,6 +546,9 @@ pub fn build_chartex(c: &Chart) -> Vec { 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 { 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"); } _ => {} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index addcb39f..ae144dd0 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -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 { + 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}"); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 82d1116d..5cfeb691 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -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 { diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 04d0ab1f..9e988351 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -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 = 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>>, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7b179485..f36adad1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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()); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7d0927f2..2fdebd52 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 253022cd..1d57e9ca 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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).