mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #598 from NikitkaRa: Charts: keep and draw region map charts
This commit is contained in:
@@ -34,6 +34,9 @@ pub enum ChartType {
|
||||
Funnel,
|
||||
Treemap,
|
||||
Sunburst,
|
||||
/// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it
|
||||
/// is not offered in the chart menus (PowerPoint needs an online map service to make one).
|
||||
RegionMap,
|
||||
Combo,
|
||||
}
|
||||
|
||||
@@ -51,7 +54,7 @@ impl ChartType {
|
||||
|
||||
/// 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)
|
||||
Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap
|
||||
}
|
||||
|
||||
pub const MAIN: [ChartType; 12] = [
|
||||
@@ -96,6 +99,7 @@ impl ChartType {
|
||||
ChartType::Funnel => "Funnel",
|
||||
ChartType::Treemap => "Treemap",
|
||||
ChartType::Sunburst => "Sunburst",
|
||||
ChartType::RegionMap => "Map",
|
||||
ChartType::Combo => "Combo",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,12 @@ fn kind_of(layout: &str) -> Option<ChartType> {
|
||||
"sunburst" => ChartType::Sunburst,
|
||||
"clusteredColumn" => ChartType::Histogram,
|
||||
"boxWhisker" => ChartType::BoxWhisker,
|
||||
"regionMap" => ChartType::RegionMap,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type
|
||||
/// for (region maps).
|
||||
/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type for.
|
||||
pub fn read_chartex_xml(bytes: &[u8]) -> Option<Chart> {
|
||||
let doc = crate::xml::parse(bytes).ok()?;
|
||||
let mut root = doc.root;
|
||||
@@ -110,7 +110,7 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
});
|
||||
let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?;
|
||||
let (values, format) =
|
||||
["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
|
||||
["val", "size", "colorVal"].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();
|
||||
|
||||
@@ -588,6 +588,18 @@ fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
|
||||
w.close("cx:lvl");
|
||||
}
|
||||
|
||||
/// The `cx:layoutPr` of the region map series in a kept `chartEx` part.
|
||||
fn kept_region_layout(raw: &str) -> Option<String> {
|
||||
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
|
||||
let mut root = doc.root;
|
||||
crate::read::resolve_mc(&mut root, 0);
|
||||
let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?;
|
||||
let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?;
|
||||
let lp = s.child("layoutPr")?;
|
||||
// Written under our `cx` prefix; a part that bound chartex to another prefix is not copied.
|
||||
lp.name.starts_with("cx:").then(|| lp.to_xml())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -605,7 +617,11 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
}
|
||||
w.close("cx:strDim");
|
||||
}
|
||||
let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" };
|
||||
let ty = match c.kind {
|
||||
ChartType::Treemap | ChartType::Sunburst => "size",
|
||||
ChartType::RegionMap => "colorVal",
|
||||
_ => "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")));
|
||||
@@ -627,6 +643,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
ChartType::Treemap => "treemap",
|
||||
ChartType::Sunburst => "sunburst",
|
||||
ChartType::BoxWhisker => "boxWhisker",
|
||||
ChartType::RegionMap => "regionMap",
|
||||
_ => "clusteredColumn",
|
||||
};
|
||||
let one = !matches!(c.kind, ChartType::BoxWhisker);
|
||||
@@ -666,6 +683,13 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" }));
|
||||
w.close("cx:layoutPr");
|
||||
}
|
||||
ChartType::RegionMap => {
|
||||
// The map's geography (projection, region level and the cached boundaries
|
||||
// PowerPoint downloaded) is not modelled: carry it over from the part we read.
|
||||
if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) {
|
||||
w.raw(&lp);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
w.close("cx:series");
|
||||
@@ -676,7 +700,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
w.close("cx:series");
|
||||
}
|
||||
w.close("cx:plotAreaRegion");
|
||||
if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) {
|
||||
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");
|
||||
@@ -706,3 +730,34 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
|
||||
w.close("cx:chartSpace");
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod region_map_tests {
|
||||
use super::*;
|
||||
|
||||
/// A region map part as PowerPoint writes it (our own numbers; the cached geography is a
|
||||
/// stand-in blob).
|
||||
const MAP: &str = r#"<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$4</cx:f><cx:lvl ptCount="3"><cx:pt idx="0">Minsk</cx:pt><cx:pt idx="1">Brest</cx:pt><cx:pt idx="2">Gomel</cx:pt></cx:lvl></cx:strDim><cx:numDim type="colorVal"><cx:f>Sheet1!$B$2:$B$4</cx:f><cx:lvl ptCount="3" formatCode="General"><cx:pt idx="0">12</cx:pt><cx:pt idx="1">5</cx:pt><cx:pt idx="2">8</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"/><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="regionMap" uniqueId="{00000000-0000-4000-8000-000000000001}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Sales</cx:v></cx:txData></cx:tx><cx:dataId val="0"/><cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="BY" attribution="Powered by Bing"><cx:geoCache provider="{E9337A44-BEBE-4D9F-B70C-5C5E7DAFC167}"><cx:binary>ZGVja2NyYWZ0LXRlc3Q=</cx:binary></cx:geoCache></cx:geography></cx:layoutPr></cx:series></cx:plotAreaRegion></cx:plotArea><cx:legend pos="r" align="min" overlay="0"/></cx:chart></cx:chartSpace>"#;
|
||||
|
||||
#[test]
|
||||
fn region_maps_keep_their_geography_through_save_and_edit() {
|
||||
let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read");
|
||||
assert_eq!(c.kind, ChartType::RegionMap);
|
||||
assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]);
|
||||
assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)]));
|
||||
// Unchanged: written back as read.
|
||||
let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default();
|
||||
assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}");
|
||||
// Edited: rebuilt from the model, with the geography carried over.
|
||||
let mut e = c.clone();
|
||||
if let Some(s) = e.series.first_mut() {
|
||||
s.values[1] = Some(20.0);
|
||||
}
|
||||
let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default();
|
||||
assert!(built.contains("<cx:geoCache") && built.contains("ZGVja2NyYWZ0LXRlc3Q=") && built.contains("colorVal"), "{built}");
|
||||
assert!(!built.contains("<cx:axis"), "{built}");
|
||||
let back = crate::read::read_chartex_xml(built.as_bytes()).expect("the rebuilt part reads");
|
||||
assert_eq!(back.kind, ChartType::RegionMap);
|
||||
assert_eq!(back.series.first().and_then(|s| s.values.get(1).copied().flatten()), Some(20.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
} else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) {
|
||||
// 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 {
|
||||
} else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) {
|
||||
// A map draws its own colour scale.
|
||||
vec![]
|
||||
} else if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
@@ -270,7 +271,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst) {
|
||||
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) {
|
||||
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use crate::color;
|
||||
/// Draw `c` inside `area` (the space left by the title and legend).
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
if c.kind == ChartType::RegionMap {
|
||||
region_tiles(ctx, rctx, c, m, area, base, text_col);
|
||||
return;
|
||||
}
|
||||
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
|
||||
if vals.iter().all(|v| *v <= 0.0) {
|
||||
return;
|
||||
@@ -64,6 +68,93 @@ fn funnel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix `a` towards `b` by `t` (0..1).
|
||||
fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let f = |x: u8, y: u8| (x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8;
|
||||
Rgba::rgb(f(a.r, b.r), f(a.g, b.g), f(a.b, b.b))
|
||||
}
|
||||
|
||||
/// A region map without its geography: the boundaries PowerPoint caches come from an online
|
||||
/// map service in an undocumented format and DeckCraft ships no map data, so each region is a
|
||||
/// tile in a grid, coloured on the map's value scale, with the scale below and a note saying
|
||||
/// the map itself is not drawn.
|
||||
fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
let regions: Vec<(String, Option<f64>)> =
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect();
|
||||
let note_h = base * 1.4;
|
||||
let scale_h = base * 2.6;
|
||||
let grid = Rect::new(area.x0, area.y0, area.x1, (area.y1 - note_h - scale_h).max(area.y0));
|
||||
let hi = series_color(rctx, c, 0);
|
||||
let lo = mix(hi, Rgba::WHITE, 0.85);
|
||||
let (min, max) = regions.iter().filter_map(|(_, v)| *v).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(v), b.max(v)));
|
||||
let span = if max > min { max - min } else { 1.0 };
|
||||
let n = regions.len();
|
||||
if n > 0 && grid.area() > 0.0 {
|
||||
// Columns so that tiles come out about twice as wide as tall.
|
||||
let aspect = grid.width() / grid.height().max(1.0);
|
||||
let cols = ((n as f64 * aspect / 2.0).sqrt().ceil() as usize).clamp(1, n);
|
||||
let rows = n.div_ceil(cols).max(1);
|
||||
let (tw, th) = (grid.width() / cols as f64, grid.height() / rows as f64);
|
||||
for (i, (name, v)) in regions.iter().enumerate() {
|
||||
let (col, row) = (i % cols, i / cols);
|
||||
let t = Rect::new(grid.x0 + tw * col as f64, grid.y0 + th * row as f64, grid.x0 + tw * (col + 1) as f64, grid.y0 + th * (row + 1) as f64)
|
||||
.inset(-1.5);
|
||||
let fill = v.map_or(Rgba::rgb(0xD9, 0xD9, 0xD9), |v| mix(lo, hi, (v - min) / span));
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&t);
|
||||
let dark = v.is_some_and(|v| (v - min) / span > 0.55);
|
||||
let mut text = name.clone();
|
||||
if c.data_labels
|
||||
&& let Some(v) = v
|
||||
{
|
||||
text = format!(
|
||||
"{text}
|
||||
{}",
|
||||
c.data_label(s, *v)
|
||||
);
|
||||
}
|
||||
let inner = t.inset(-base * 0.25);
|
||||
if inner.width() > base && inner.height() > base * 0.8 {
|
||||
label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The colour scale: a gradient bar from the lowest to the highest value.
|
||||
if min <= max {
|
||||
let bar_w = (area.width() * 0.5).min(base * 24.0);
|
||||
let y = grid.y1 + base * 0.6;
|
||||
let bar = Rect::new(area.center().x - bar_w / 2.0, y, area.center().x + bar_w / 2.0, y + base * 0.8);
|
||||
let steps = 48;
|
||||
for k in 0..steps {
|
||||
let x0 = bar.x0 + bar.width() * k as f64 / steps as f64;
|
||||
let x1 = bar.x0 + bar.width() * (k + 1) as f64 / steps as f64 + 0.5;
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(mix(lo, hi, k as f64 / (steps - 1) as f64), 1.0));
|
||||
ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1));
|
||||
}
|
||||
let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2);
|
||||
label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false);
|
||||
let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2);
|
||||
label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false);
|
||||
}
|
||||
let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
"Map preview unavailable: regions shown as tiles",
|
||||
note,
|
||||
base * 0.85,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
mix(text_col, Rgba::WHITE, 0.4),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side,
|
||||
/// each row kept while it improves the worst aspect ratio.
|
||||
pub fn squarify(vals: &[f64], r: Rect) -> Vec<Rect> {
|
||||
|
||||
@@ -2048,3 +2048,34 @@ fn multilevel_treemap_and_sunburst_draw_their_groups() {
|
||||
assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// A region map without drawable geography falls back to coloured tiles on the value scale.
|
||||
#[test]
|
||||
fn region_map_draws_tiles_on_its_colour_scale() {
|
||||
let names = ["Brest", "Vitebsk", "Gomel", "Grodno", "Minsk", "Mogilev", "Minsk City"];
|
||||
let vals = [3.1, 2.2, 2.9, 4.8, 6.0, 1.4, 9.5];
|
||||
let c = Chart {
|
||||
kind: ChartType::RegionMap,
|
||||
title: Some("Sales by region".into()),
|
||||
categories: names.iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![deckcraft_model::chart::Series { name: "Sales".into(), values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }],
|
||||
data_labels: true,
|
||||
..Default::default()
|
||||
};
|
||||
let sh = Shape { id: ShapeId(624), 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}/RegionMap-tiles.png"), img.to_png());
|
||||
}
|
||||
let mut seen: Vec<[u8; 3]> = Vec::new();
|
||||
for y in (60..520).step_by(6) {
|
||||
for x in (40..920).step_by(6) {
|
||||
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() >= 5, "{} colours", seen.len());
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 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. Treemap and sunburst hierarchy levels kept; 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; region maps kept, drawn as tiles (no map geometry) |
|
||||
| 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) |
|
||||
|
||||
+3
-2
@@ -213,8 +213,9 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
written from the model, with a plain chart fallback). Waterfalls float and total, funnels,
|
||||
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.
|
||||
and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept
|
||||
(their cached geography too, also through edits) and drawn as value-coloured tiles with a
|
||||
colour scale, since DeckCraft ships no map boundaries.
|
||||
- **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