mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 04:27:06 -06:00
Charts: draw waterfall, funnel, treemap and sunburst layouts
These Office 2016 charts were drawn as plain columns. Now: - waterfall: bars float from the running total, total points (cx:subtotals, now read into Chart.subtotals) stand on the axis and reset it, increases/decreases/totals take accents 1-3 with a matching legend, and connectors join each bar to the next; - funnel: centred bars as wide as their value, category labels on the left and values inside; - treemap: squarified tiles in proportion to their value, labelled; - sunburst: the innermost level as a ring with an open centre. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -186,6 +186,10 @@ pub struct Chart {
|
||||
pub y2_hidden: bool,
|
||||
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
|
||||
pub lang: Option<String>,
|
||||
/// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart
|
||||
/// the running total, instead of floating.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub subtotals: Vec<usize>,
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
@@ -225,6 +229,7 @@ impl Default for Chart {
|
||||
y2_format: None,
|
||||
y2_hidden: false,
|
||||
lang: None,
|
||||
subtotals: vec![],
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
@@ -304,6 +309,28 @@ impl Chart {
|
||||
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
|
||||
/// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 %
|
||||
/// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis.
|
||||
/// A waterfall's bars as (from, to) on the value axis: each point floats from the running
|
||||
/// total, a total point stands on zero and sets it.
|
||||
pub fn waterfall_bars(&self) -> Vec<(f64, f64)> {
|
||||
let Some(s) = self.series.first() else { return vec![] };
|
||||
let mut run = 0.0;
|
||||
s.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let v = v.unwrap_or(0.0);
|
||||
if self.subtotals.contains(&i) {
|
||||
run = v;
|
||||
(0.0, v)
|
||||
} else {
|
||||
let from = run;
|
||||
run += v;
|
||||
(from, run)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> {
|
||||
let (lo, hi) = self.axis_range(secondary)?;
|
||||
let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) };
|
||||
@@ -328,6 +355,13 @@ impl Chart {
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
if self.kind == ChartType::Waterfall {
|
||||
for (a, b) in self.waterfall_bars() {
|
||||
lo = lo.min(a.min(b));
|
||||
hi = hi.max(a.max(b));
|
||||
}
|
||||
return (lo, hi);
|
||||
}
|
||||
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
if self.kind.stacked() {
|
||||
for i in 0..n {
|
||||
|
||||
@@ -110,6 +110,9 @@ pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
|
||||
if c.categories.is_empty() {
|
||||
c.categories = d.categories.clone();
|
||||
}
|
||||
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) {
|
||||
c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect();
|
||||
}
|
||||
let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default();
|
||||
c.series.push(Series {
|
||||
name,
|
||||
|
||||
@@ -59,7 +59,18 @@ fn split_label(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) {
|
||||
pub(crate) fn label(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
m: Affine,
|
||||
text: &str,
|
||||
r: Rect,
|
||||
size: f64,
|
||||
align: Align,
|
||||
anchor: Anchor,
|
||||
col: Rgba,
|
||||
bold: bool,
|
||||
) {
|
||||
let mut body = TextBody::from_text(text);
|
||||
body.body.inset_l = Some(0.0);
|
||||
body.body.inset_r = Some(0.0);
|
||||
@@ -150,7 +161,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend placed by `c:legendPos`.
|
||||
let legend_items: Vec<(String, Rgba)> = if pie {
|
||||
let waterfall = c.kind == ChartType::Waterfall;
|
||||
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()
|
||||
} else if c.kind == ChartType::Funnel {
|
||||
vec![]
|
||||
} else if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
} else {
|
||||
c.series
|
||||
@@ -242,6 +260,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst) {
|
||||
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
|
||||
return;
|
||||
}
|
||||
let combo = c.kind == ChartType::Combo;
|
||||
// Bars are drawn first and lines over them; a combo chart has both.
|
||||
let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like());
|
||||
@@ -399,7 +421,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
let sec = c.on_secondary(s);
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let (a, b) = if stacked {
|
||||
let (a, b) = if waterfall {
|
||||
c.waterfall_bars().get(i).copied().unwrap_or_default()
|
||||
} else if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
pos_acc += v;
|
||||
@@ -416,10 +440,34 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar)
|
||||
} else {
|
||||
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
|
||||
}
|
||||
.abs();
|
||||
// A waterfall colours increases, decreases and totals, and joins each bar's end
|
||||
// to the next bar's start.
|
||||
let fill = if waterfall {
|
||||
let slot = if c.subtotals.contains(&i) {
|
||||
2
|
||||
} else if b < a {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
} else {
|
||||
series_fill(rctx, c, *si, Some(s))
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0));
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
if waterfall && i + 1 < ncat && !horiz {
|
||||
let y = to_v(b);
|
||||
let mut j = BezPath::new();
|
||||
j.move_to((r.x1, y));
|
||||
j.line_to((r.x1 + (band - bar), y));
|
||||
ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.stroke_path(&j);
|
||||
}
|
||||
if c.data_labels {
|
||||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let lr = if horiz {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst
|
||||
//! (waterfall bars are drawn by [`crate::chart`]).
|
||||
|
||||
use deckcraft_color::Rgba;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Shape as _};
|
||||
use vello_cpu::RenderContext;
|
||||
|
||||
use crate::chart::{label, series_color};
|
||||
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 };
|
||||
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;
|
||||
}
|
||||
let value_label = |v: f64| c.data_label(s, v);
|
||||
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 => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)),
|
||||
_ => sunburst(ctx, rctx, c, m, area, base, &vals),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bars centred on one axis, as wide as their value, widest first as given: category labels on
|
||||
/// the left, values inside.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn funnel(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
c: &Chart,
|
||||
m: Affine,
|
||||
area: Rect,
|
||||
base: f64,
|
||||
text_col: Rgba,
|
||||
vals: &[f64],
|
||||
value_label: &dyn Fn(f64) -> String,
|
||||
) {
|
||||
let n = vals.len().max(1);
|
||||
let gutter = c.categories.iter().map(|n| n.chars().count()).max().unwrap_or(0) as f64 * base * 0.55 + base;
|
||||
let plot = Rect::new(area.x0 + gutter.min(area.width() * 0.4), area.y0, area.x1, area.y1);
|
||||
let max = vals.iter().copied().fold(0.0, f64::max).max(1e-9);
|
||||
let band = plot.height() / n as f64;
|
||||
let cx = plot.center().x;
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
let w = v / max * plot.width();
|
||||
let y = plot.y0 + band * i as f64;
|
||||
let r = Rect::new(cx - w / 2.0, y + band * 0.05, cx + w / 2.0, y + band * 0.95);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_color(rctx, c, 0), 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
if let Some(name) = c.categories.get(i) {
|
||||
label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false);
|
||||
}
|
||||
label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, 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> {
|
||||
let total: f64 = vals.iter().sum();
|
||||
let mut out = vec![Rect::ZERO; vals.len()];
|
||||
if total <= 0.0 || r.area() <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
let scale = r.area() / total;
|
||||
let mut order: Vec<usize> = (0..vals.len()).filter(|&i| vals.get(i).is_some_and(|v| *v > 0.0)).collect();
|
||||
order.sort_by(|a, b| vals[*b].total_cmp(&vals[*a]));
|
||||
let mut free = r;
|
||||
let mut i = 0;
|
||||
while i < order.len() {
|
||||
let short = free.width().min(free.height());
|
||||
let worst = |row: &[usize]| {
|
||||
let areas: Vec<f64> = row.iter().map(|&k| vals[k] * scale).collect();
|
||||
let sum: f64 = areas.iter().sum();
|
||||
let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min));
|
||||
((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn))
|
||||
};
|
||||
let mut end = i + 1;
|
||||
while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) {
|
||||
end += 1;
|
||||
}
|
||||
let row = &order[i..end];
|
||||
let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum();
|
||||
let thick = sum / short;
|
||||
let mut at = 0.0;
|
||||
for &k in row {
|
||||
let len = vals[k] * scale / thick;
|
||||
out[k] = if free.width() >= free.height() {
|
||||
Rect::new(free.x0, free.y0 + at, free.x0 + thick, free.y0 + at + len)
|
||||
} else {
|
||||
Rect::new(free.x0 + at, free.y0, free.x0 + at + len, free.y0 + thick)
|
||||
};
|
||||
at += len;
|
||||
}
|
||||
free = if free.width() >= free.height() {
|
||||
Rect::new(free.x0 + thick, free.y0, free.x1, free.y1)
|
||||
} else {
|
||||
Rect::new(free.x0, free.y0 + thick, free.x1, free.y1)
|
||||
};
|
||||
i = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn treemap(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
c: &Chart,
|
||||
m: Affine,
|
||||
area: Rect,
|
||||
base: f64,
|
||||
vals: &[f64],
|
||||
value_label: Option<&dyn Fn(f64) -> String>,
|
||||
) {
|
||||
for (i, t) in squarify(vals, area).into_iter().enumerate() {
|
||||
if t.area() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_color(rctx, c, i), 1.0));
|
||||
ctx.fill_rect(&t);
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.5));
|
||||
ctx.stroke_path(&t.to_path(0.1));
|
||||
let mut text = c.categories.get(i).cloned().unwrap_or_default();
|
||||
if let (Some(f), Some(v)) = (value_label, vals.get(i)) {
|
||||
text = format!("{text}\n{}", f(*v));
|
||||
}
|
||||
let inner = t.inset(-base * 0.3);
|
||||
if inner.width() > base && inner.height() > base {
|
||||
label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One ring of the innermost level, with the centre left open.
|
||||
fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) {
|
||||
let total: f64 = vals.iter().sum();
|
||||
let r = area.width().min(area.height()) / 2.0 * 0.95;
|
||||
let hole = r * 0.35;
|
||||
let cen = area.center();
|
||||
let mut a0 = -std::f64::consts::FRAC_PI_2;
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
let sweep = v / total * std::f64::consts::TAU;
|
||||
if sweep <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(Point::new(cen.x + hole * a0.cos(), cen.y + hole * a0.sin()));
|
||||
p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin()));
|
||||
p.extend(kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0).append_iter(0.1));
|
||||
let a1 = a0 + sweep;
|
||||
p.line_to(Point::new(cen.x + hole * a1.cos(), cen.y + hole * a1.sin()));
|
||||
p.extend(kurbo::Arc::new(cen, (hole, hole), a1, -sweep, 0.0).append_iter(0.1));
|
||||
p.close_path();
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_color(rctx, c, i), 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);
|
||||
if let Some(name) = c.categories.get(i).filter(|_| sweep > 0.25) {
|
||||
let mid = a0 + sweep / 2.0;
|
||||
let rr = (r + hole) / 2.0;
|
||||
let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin());
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
name,
|
||||
Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base),
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Middle,
|
||||
Rgba::WHITE,
|
||||
false,
|
||||
);
|
||||
}
|
||||
a0 = a1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn squarify_tiles_the_area_in_proportion() {
|
||||
let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0];
|
||||
let r = Rect::new(0.0, 0.0, 600.0, 400.0);
|
||||
let tiles = squarify(&vals, r);
|
||||
let total: f64 = vals.iter().sum();
|
||||
for (t, v) in tiles.iter().zip(vals) {
|
||||
assert!((t.area() - v / total * r.area()).abs() < 1e-6, "{t:?} for {v}");
|
||||
assert!(t.x0 >= -1e-9 && t.y0 >= -1e-9 && t.x1 <= 600.0 + 1e-6 && t.y1 <= 400.0 + 1e-6, "{t:?}");
|
||||
// Squarified: no sliver tiles.
|
||||
let aspect = t.width().max(t.height()) / t.width().min(t.height());
|
||||
assert!(aspect < 4.0, "{t:?}");
|
||||
}
|
||||
for (i, a) in tiles.iter().enumerate() {
|
||||
for b in &tiles[i + 1..] {
|
||||
assert!(a.intersect(*b).area() < 1e-6, "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
assert!(squarify(&[0.0, 0.0], r).iter().all(|t| t.area() == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waterfall_bars_float_and_totals_stand() {
|
||||
let c = Chart {
|
||||
kind: ChartType::Waterfall,
|
||||
subtotals: vec![3],
|
||||
series: vec![deckcraft_model::chart::Series { values: vec![Some(100.0), Some(40.0), Some(-25.0), Some(115.0)], ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]);
|
||||
assert_eq!(c.value_range(), (0.0, 140.0));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
mod bevel;
|
||||
mod camera;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod colr;
|
||||
|
||||
mod compound;
|
||||
|
||||
@@ -1711,3 +1711,38 @@ fn east_asian_vertical_text_stands_ideographs_upright() {
|
||||
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
|
||||
assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_2016_charts_draw_their_own_layouts() {
|
||||
let chart = |kind: ChartType, vals: &[f64], subtotals: Vec<usize>| {
|
||||
let c = Chart {
|
||||
kind,
|
||||
title: None,
|
||||
legend: None,
|
||||
gridlines: false,
|
||||
subtotals,
|
||||
categories: (0..vals.len()).map(|i| format!("C{i}")).collect(),
|
||||
series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let ink = |img: &crate::Image, x: u32, y: u32| {
|
||||
let p = img.pixel(x, y);
|
||||
p[0] < 235 || p[1] < 235 || p[2] < 235
|
||||
};
|
||||
// Waterfall: the decrease floats (nothing under it near the axis), the total stands on it.
|
||||
let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]);
|
||||
let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500);
|
||||
let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32;
|
||||
assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis");
|
||||
assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis");
|
||||
assert!(ink(&img, band(1.0), 100), "the increase bar is missing");
|
||||
// Funnel, treemap and sunburst fill the middle of the chart.
|
||||
for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] {
|
||||
let img = chart(kind, &[50.0, 30.0, 20.0], vec![]);
|
||||
let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count();
|
||||
assert!(covered > 1000, "{kind:?}: {covered} samples inked");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -146,8 +146,10 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
### Charts: data grid, elements and the newer chart types
|
||||
- **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other
|
||||
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto,
|
||||
box & whisker) are read for their data and written back unchanged, but they're drawn with the
|
||||
bar renderer and an edited one is saved as a plain column chart; region maps aren't read.
|
||||
box & whisker) are read for their data and written back unchanged. Waterfalls (floating bars,
|
||||
totals, connectors), funnels, treemaps (squarified) and sunbursts (innermost ring) are drawn;
|
||||
histograms and box & whisker still use the column renderer, an edited one is saved as a plain
|
||||
column chart, and region maps aren't read.
|
||||
- **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