Merge the chart and EMF+ metafile PRs integrated on land-r2 (#532 #537 #591 #594 #598 #602 #603 #638 #640 #662 #666 #669 #690 #582 #588 #633 #635)

# Conflicts:
#	crates/pptx/src/write/mod.rs
#	crates/pptx/tests/roundtrip.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:17:35 -04:00
30 changed files with 5690 additions and 196 deletions
+526 -34
View File
@@ -2,7 +2,7 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
@@ -29,16 +29,53 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
}
}
fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba {
match s.and_then(|s| s.fill.as_ref()) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
_ => series_color(ctx, c, i),
}
}
/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'.
fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba {
match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
_ => series_fill(ctx, c, i, Some(s)),
}
}
fn polyline(pts: &[Point]) -> BezPath {
let mut p = BezPath::new();
for (k, q) in pts.iter().enumerate() {
if k == 0 {
p.move_to(*q);
} else {
p.line_to(*q);
}
}
p
}
/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve
/// PowerPoint draws, passing through every point.
pub(crate) fn smooth_path(pts: &[Point]) -> BezPath {
let mut p = BezPath::new();
let Some(first) = pts.first() else { return p };
p.move_to(*first);
for k in 0..pts.len() - 1 {
let p0 = pts[k.saturating_sub(1)];
let (p1, p2) = (pts[k], pts[k + 1]);
let p3 = pts[(k + 2).min(pts.len() - 1)];
let c1 = p1 + (p2 - p0) / 6.0;
let c2 = p2 - (p3 - p1) / 6.0;
p.curve_to(c1, c2, p2);
}
p
}
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
/// palette's.
fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
_ => series_fill(ctx, c, i, Some(s)),
@@ -59,7 +96,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);
@@ -82,6 +130,21 @@ fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, si
crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None);
}
fn fmt_num(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
}
/// A scatter or bubble chart's value x axis (min, max, major unit): one scale over every series'
/// x values, from zero when they're all positive. `None` for other charts and when there are no x values.
fn value_x_scale(c: &Chart, max_intervals: usize) -> Option<(f64, f64, f64)> {
if c.series.is_empty() || !c.series.iter().all(|s| matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble)) {
return None;
}
let xs = c.series.iter().flat_map(|s| s.x_values.iter().flatten().copied()).filter(|v| v.is_finite());
let (lo, hi) = xs.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, b)| (a.min(v), b.max(v)))))?;
Some(axis_scale(lo.min(0.0), hi, None, None, None, max_intervals, false))
}
/// Where the legend goes for `c:legendPos`, and the plot area left over.
/// `size` is the legend block (width, height); the result is its top-left corner and the plot area.
/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred
@@ -119,6 +182,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo
}
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
match c.kind {
// Histograms and Pareto charts are drawn as the column (and line) chart of their bins.
ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None),
ChartType::BoxWhisker => {
let (view, boxes) = crate::chartex::box_view(c);
draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes));
}
_ => draw_inner(ctx, rctx, c, m, w, h, None),
}
}
#[allow(clippy::too_many_arguments)]
fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) {
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
if let Some(f) = &c.chart_fill {
@@ -146,7 +222,16 @@ 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) {
// 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 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()
} else {
c.series
@@ -238,6 +323,14 @@ 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 | ChartType::RegionMap) {
crate::chartex::draw(ctx, rctx, c, m, area, base, text_col);
return;
}
if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) {
crate::radar::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());
@@ -262,7 +355,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
// As many major units as the value axis has room for labels (PowerPoint's automatic unit).
let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 };
let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize;
let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true));
// A logarithmic axis is scaled in exponents of its base.
let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc));
let (min, max, step) = match log {
Some((_, sc)) => sc,
None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)),
};
let to_e = |v: f64| match log {
Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0),
None => v,
};
// A combo chart's secondary value axis, on the right (vertical value axes only).
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
let ticks = |(lo, hi, st): (f64, f64, f64)| {
@@ -270,8 +372,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
(0..=n).map(move |k| lo + k as f64 * st)
};
// Label gutters as wide as the longest value label.
let label_w =
|sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6;
let primary_ticks: Vec<f64> = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect();
let label_w = |sec: bool, sc: (f64, f64, f64)| {
let vals: Vec<f64> = if sec { ticks(sc).collect() } else { primary_ticks.clone() };
vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6
};
let gutter = if horiz {
base * 4.5
} else if c.y_hidden {
@@ -292,7 +397,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
// Values past the axis limits (fixed ones, or zero on an axis that starts above it) stop at
// the plot's edge.
let to_v = |v: f64| {
let v = v.max(min).min(max);
let v = to_e(v).max(min).min(max);
if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }
};
// A series' value as a position on its own axis.
@@ -300,8 +405,30 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
(true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / (max2 - min2).max(1e-9) * plot.height(),
_ => to_v(v),
};
// Minor gridlines, under the major ones: five to a major unit.
if c.minor_gridlines {
let minor = step / 5.0;
let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500);
let mut g = BezPath::new();
for k in (0..=n).filter(|k| k % 5 != 0) {
// On a logarithmic axis `min`, `max` and `step` are exponents of the base.
let e = min + k as f64 * minor;
let pos = to_v(log.map_or(e, |(b, _)| b.powf(e)));
if horiz {
g.move_to((pos, plot.y0));
g.line_to((pos, plot.y1));
} else {
g.move_to((plot.x0, pos));
g.line_to((plot.x1, pos));
}
}
ctx.set_transform(m);
ctx.set_paint(color(Rgba::rgb(0xF2, 0xF2, 0xF2), 1.0));
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.stroke_path(&g);
}
// Gridlines and value labels.
for v in ticks((min, max, step)) {
for &v in &primary_ticks {
let pos = to_v(v);
let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) };
let mut g = BezPath::new();
@@ -362,11 +489,41 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
}
}
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
// Forecast reserves empty category slots so the line can run past the data. Charts with no
// forecast keep the old band.
let (back, fwd) = forecast_pad(c);
let nspan = ncat as f64 + back + fwd;
let band = if horiz { plot.height() / nspan } else { plot.width() / nspan };
// Where category `i`'s band starts along its axis: left to right, or bottom to top on a
// horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on
// their own side of the data.
let backwards = horiz != c.cat_reversed;
let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back };
// Scatter and bubble charts have a value x axis: one scale over every series' x values, with its
// own tick labels in place of category names.
let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) };
let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width());
if let Some(sc) = xscale {
for v in ticks(sc) {
let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width();
label(
ctx,
rctx,
m,
&fmt_num(v),
Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6),
base,
Align::Center,
Anchor::Top,
text_col,
false,
);
}
}
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
for (i, name) in c.categories.iter().enumerate() {
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) {
let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) };
if horiz {
label(
ctx,
@@ -396,15 +553,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
);
}
}
let zero = to_v(clamp_soft(0.0, min, max));
if let Some(b) = boxes {
crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v);
return;
}
let zero = match log {
Some((b, _)) => to_v(b.powf(min)),
None => to_v(clamp_soft(0.0, min, max)),
};
if let Some(bk) = bar_kind {
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
let ns = bars.len().max(1);
// Gap width and overlap are fractions of one bar's width: a category's band holds its bars
// (overlapping by `overlap` of a bar between neighbours) plus one gap.
let gap = c.gap_width.clamp(0.0, 5.0);
let group = band / (1.0 + gap);
let stacked = bk.stacked();
let bar = if stacked { group } else { group / ns as f64 };
let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) };
let n = ns as f64;
let bar = band / (n - (n - 1.0) * ov + gap);
let step = bar * (1.0 - ov);
let group = bar + step * (n - 1.0);
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
@@ -416,8 +585,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
for (bi, (si, s)) in bars.iter().enumerate() {
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 start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
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;
@@ -434,10 +605,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 {
point_fill(rctx, c, *si, s, i)
};
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 {
@@ -461,11 +656,17 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
}
}
}
// Lines, areas, scatter, radar (drawn as lines): the series that aren't bars.
// A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series.
if c.kind == ChartType::Stock {
for i in 0..ncat {
crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v));
}
}
// Lines, areas, scatter: the series that aren't bars.
let mut acc = vec![0.0; ncat];
for (si, s) in c.series.iter().enumerate() {
let kind = c.series_kind(s);
if kind.is_bar_like() {
if kind.is_bar_like() || kind == ChartType::Stock {
continue;
}
let sec = c.on_secondary(s);
@@ -486,26 +687,21 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
};
let x = match kind {
ChartType::Scatter | ChartType::Bubble => {
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) {
(Some(x), _) => x,
// No x values at all: the points are numbered 1, 2, 3...
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, Some(_)) => continue,
}
}
_ => plot.x0 + band * (i as f64 + 0.5),
_ => plot.x0 + band * (slot(i) + 0.5),
};
pts.push(Point::new(x, to_axis(sec, v)));
}
if pts.is_empty() {
continue;
}
let mut p = BezPath::new();
for (k, q) in pts.iter().enumerate() {
if k == 0 {
p.move_to(*q);
} else {
p.line_to(*q);
}
}
let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) };
ctx.set_transform(m);
if area_kind {
let mut a = p.clone();
@@ -529,6 +725,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
}
}
}
// A category position (0-based, fractional) as a slot, the way `slot` places the bars.
let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back };
draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis);
// Axis line.
let mut ax = BezPath::new();
if horiz {
@@ -551,12 +750,273 @@ pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 {
if v > hi { hi } else { v }
}
/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline).
fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) {
let pad = 4.0;
let shift = n as f64 * (box_h + 2.0);
let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad);
let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad);
let y_top = (plot.y0 + pad + shift).min(y_bottom);
let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)];
let mut best = corners[0];
let mut best_d = -1.0;
for (x, y) in corners {
let mut min_d = f64::MAX;
for p in pts {
let dx = if p.x < x {
x - p.x
} else if p.x > x + box_w {
p.x - (x + box_w)
} else {
0.0
};
let dy = if p.y < y {
y - p.y
} else if p.y > y + box_h {
p.y - (y + box_h)
} else {
0.0
};
min_d = min_d.min((dx * dx + dy * dy).sqrt());
}
if pts.is_empty() {
min_d = 1.0;
}
if min_d > best_d {
best_d = min_d;
best = (x, y);
}
}
best
}
fn pad_units(v: Option<f64>) -> f64 {
v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0)
}
/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot.
fn forecast_pad(c: &Chart) -> (f64, f64) {
if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) {
return (0.0, 0.0);
}
let mut back = 0.0_f64;
let mut fwd = 0.0_f64;
for s in &c.series {
if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) {
continue;
}
for t in s.trendlines.iter().take(8) {
if t.kind == TrendlineType::MovingAvg {
continue;
}
back = back.max(pad_units(t.backward));
fwd = fwd.max(pad_units(t.forward));
}
}
(back, fwd)
}
const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)];
fn map_xy(
plot: Rect,
band: f64,
slot: &impl Fn(f64) -> f64,
horiz: bool,
scatter: bool,
sx0: f64,
sx1: f64,
sec: bool,
x: f64,
y: f64,
to_axis: &impl Fn(bool, f64) -> f64,
) -> Option<Point> {
if !x.is_finite() || !y.is_finite() {
return None;
}
let value = to_axis(sec, y);
if !value.is_finite() {
return None;
}
let along = if scatter && sx1 > sx0 {
plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width()
} else {
let origin = if horiz { plot.y0 } else { plot.x0 };
origin + band * (slot(x - 1.0) + 0.5)
};
if !along.is_finite() {
return None;
}
Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) })
}
/// The part of segment `a`–`b` that lies inside `r`.
fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
let (mut t0, mut t1) = (0.0, 1.0);
let dx = b.x - a.x;
let dy = b.y - a.y;
let p = [-dx, dx, -dy, dy];
let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y];
for i in 0..4 {
let (pi, qi) = (p[i], q[i]);
if pi.abs() < 1e-12 {
if qi < 0.0 {
return None;
}
} else {
let t = qi / pi;
if pi < 0.0 {
if t > t1 {
return None;
} else if t > t0 {
t0 = t;
}
} else if t < t0 {
return None;
} else if t < t1 {
t1 = t;
}
}
}
Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy)))
}
fn draw_trendlines(
ctx: &mut RenderContext,
rctx: &Ctx,
c: &Chart,
m: Affine,
plot: Rect,
band: f64,
slot: &impl Fn(f64) -> f64,
xscale: Option<(f64, f64, f64)>,
ncat: usize,
horiz: bool,
base: f64,
text_col: Rgba,
to_axis: &impl Fn(bool, f64) -> f64,
) {
let mut label_n = 0usize;
for s in &c.series {
if s.trendlines.is_empty() {
continue;
}
let kind = c.series_kind(s);
let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble);
let sec = c.on_secondary(s);
let mut xs = Vec::new();
let mut ys = Vec::new();
for i in 0..ncat {
let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
let x = if scatter {
let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
x
} else {
(i + 1) as f64
};
xs.push(x);
ys.push(y);
}
// The same x extent the scatter marks use, so the fit sits on the points: the chart's
// value x axis where it has one, else the series' own x values.
let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) {
(lo, hi)
} else if scatter {
let mut lo = f64::MAX;
let mut hi = f64::MIN;
for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) {
lo = lo.min(v);
hi = hi.max(v);
}
(lo, hi)
} else {
(0.0, 0.0)
};
for (ti, t) in s.trendlines.iter().take(8).enumerate() {
let Some(fit) = t.fit(&xs, &ys) else { continue };
let (x0, x1) = fit.span(t.forward, t.backward);
let samples = fit.samples(x0, x1);
let mut path = BezPath::new();
let mut prev: Option<Point> = None;
let mut ink_pts = Vec::new();
let mut any = false;
for w in samples.windows(2) {
let (Some(a), Some(b)) = (
map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis),
map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis),
) else {
prev = None;
continue;
};
let Some((ca, cb)) = clip_seg(a, b, plot) else {
prev = None;
continue;
};
let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6);
if cont {
path.line_to(cb);
} else {
path.move_to(ca);
path.line_to(cb);
}
prev = Some(cb);
if ink_pts.len() < 64 {
ink_pts.push(ca);
ink_pts.push(cb);
}
any = true;
}
if !any {
continue;
}
ctx.set_transform(m);
let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22));
ctx.set_paint(color(ink, 1.0));
ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round));
ctx.stroke_path(&path);
let caption = t.caption(&fit);
if caption.is_empty() {
continue;
}
let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1;
let box_h = base * 1.3 * lines as f64 + 2.0;
let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64;
let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72);
let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n);
ctx.set_transform(m);
ctx.set_paint(color(Rgba::WHITE, 1.0));
ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h));
label(
ctx,
rctx,
m,
&caption,
Rect::new(x, y, x + box_w, y + box_h),
(base * 0.9).clamp(8.0, 13.0),
Align::Left,
Anchor::Top,
text_col,
false,
);
label_n += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
#[test]
fn scatter_x_is_a_value_scale_over_all_series() {
let mut c = Chart::sample(ChartType::Scatter);
c.series[0].x_values = vec![Some(0.5), Some(7.2), None];
assert_eq!(value_x_scale(&c, 8), Some((0.0, 8.0, 1.0)));
assert_eq!(value_x_scale(&Chart::sample(ChartType::Column), 8), None);
}
#[test]
fn right_legend_is_a_centred_column_and_narrows_the_plot() {
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false);
@@ -586,4 +1046,36 @@ mod tests {
assert_eq!(plot, AREA);
assert_eq!(o.x, 340.0);
}
#[test]
fn clip_keeps_the_part_inside_the_plot() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped");
assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6);
assert!((a.y - 25.0).abs() < 1e-6);
assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none());
let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside");
assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6);
}
#[test]
fn caption_keeps_clear_of_a_line_into_the_top_right() {
let plot = Rect::new(0.0, 0.0, 200.0, 100.0);
let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)];
let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0);
assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}");
}
#[test]
fn forecast_reserves_at_most_three_category_slots() {
use deckcraft_model::chart::Trendline;
let mut c = Chart::sample(ChartType::Line);
c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() });
assert_eq!(forecast_pad(&c), (0.5, 3.0));
c.series[0].trendlines[0].kind = TrendlineType::MovingAvg;
assert_eq!(forecast_pad(&c), (0.0, 0.0));
c.kind = ChartType::Scatter;
c.series[0].trendlines[0].kind = TrendlineType::Linear;
assert_eq!(forecast_pad(&c), (0.0, 0.0));
}
}
+573
View File
@@ -0,0 +1,573 @@
//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the
//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as
//! the column chart of their bins).
use deckcraft_color::Rgba;
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 _};
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 };
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;
}
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 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),
}
}
/// 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);
}
}
/// 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> {
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;
}
}
/// `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>>>,
}
/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one
/// category per group, legend by series), and the boxes to draw in it. The frame's values are
/// each group's extremes, so the value axis covers every whisker and outlier.
pub fn box_view(c: &Chart) -> (Chart, Boxes) {
let (groups, stats) = c.box_groups();
let mut v = c.clone();
v.kind = ChartType::Column;
v.categories = groups;
v.raw = None;
let ext = |b: &BoxStats, hi: bool| {
let o = b.outliers.iter().copied();
if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) }
};
let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min);
for (s, st) in v.series.iter_mut().zip(&stats) {
s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect();
}
if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() {
// The frame's values are the highs; below zero the axis must reach the lowest point too.
let mag = 10f64.powf(lowest.abs().log10().floor());
v.y_min = Some((lowest / mag).floor() * mag);
}
(v, Boxes { stats })
}
/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers
/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each
/// group's band.
#[allow(clippy::too_many_arguments)]
pub fn draw_boxes(
ctx: &mut RenderContext,
rctx: &Ctx,
c: &Chart,
b: &Boxes,
m: Affine,
plot: Rect,
band: f64,
horiz: bool,
to_v: &dyn Fn(f64) -> f64,
) {
let ns = b.stats.len().max(1);
let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0));
let w = group / ns as f64;
let line = Rgba::rgb(0x40, 0x40, 0x40);
for (si, per) in b.stats.iter().enumerate() {
let fill = series_color(rctx, c, si);
for (gi, st) in per.iter().enumerate() {
let Some(st) = st else { continue };
let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64;
let (a, z) = (c0 + w * 0.1, c0 + w * 0.9);
let mid = (a + z) / 2.0;
// Along the category axis `a..z`, along the value axis `to_v`.
let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) };
let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) };
ctx.set_transform(m);
ctx.set_paint(color(fill, 1.0));
let bx = rect(a, z, to_v(st.q1), to_v(st.q3));
ctx.fill_rect(&bx);
let mut p = BezPath::new();
// Whiskers with caps, and the median.
for (from, to) in [(st.q3, st.high), (st.q1, st.low)] {
p.move_to(pt(mid, to_v(from)));
p.line_to(pt(mid, to_v(to)));
p.move_to(pt(mid - w * 0.2, to_v(to)));
p.line_to(pt(mid + w * 0.2, to_v(to)));
}
p.move_to(pt(a, to_v(st.median)));
p.line_to(pt(z, to_v(st.median)));
ctx.set_paint(color(line, 1.0));
ctx.set_stroke(kurbo::Stroke::new(1.25));
ctx.stroke_path(&p);
ctx.stroke_path(&bx.to_path(0.1));
// The mean as an x.
let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0));
let mut x = BezPath::new();
x.move_to((mx.x - r, mx.y - r));
x.line_to((mx.x + r, mx.y + r));
x.move_to((mx.x - r, mx.y + r));
x.line_to((mx.x + r, mx.y - r));
ctx.stroke_path(&x);
for o in &st.outliers {
let q = pt(mid, to_v(*o));
ctx.set_paint(color(fill, 1.0));
ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1));
ctx.set_paint(color(line, 1.0));
ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1));
}
}
}
}
#[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));
}
}
+2
View File
@@ -10,6 +10,7 @@
mod bevel;
mod camera;
mod chart;
mod chartex;
mod colr;
mod extrude;
mod light;
@@ -25,6 +26,7 @@ mod pattern;
mod pathgrad;
mod placed;
mod radar;
mod ramp;
mod table;
mod transition;
+187
View File
@@ -0,0 +1,187 @@
//! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon
//! of gridlines per major unit and each series a closed polygon (filled for a filled radar).
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_fill, series_line};
use crate::color;
/// The point at `frac` of the radius along spoke `i` of `n` (the first spoke points up).
fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point {
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n.max(1) as f64;
Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin())
}
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) {
let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
if n < 3 {
// Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing.
return;
}
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
// Room for the category labels around the outside.
let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base);
let cen = area.center();
let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2));
let span = (max - min).max(1e-9);
let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20);
ctx.set_transform(m);
ctx.set_stroke(kurbo::Stroke::new(0.75));
ctx.set_paint(color(grid_col, 1.0));
if c.gridlines {
for k in 1..=ticks {
let frac = k as f64 / ticks as f64;
let mut p = BezPath::new();
for i in 0..n {
let q = spoke(cen, r, i, n, frac);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
ctx.stroke_path(&p);
}
}
for i in 0..n {
let mut p = BezPath::new();
p.move_to(cen);
p.line_to(spoke(cen, r, i, n, 1.0));
ctx.set_transform(m);
ctx.set_paint(color(grid_col, 1.0));
ctx.stroke_path(&p);
if let Some(name) = c.categories.get(i) {
let q = spoke(cen, r + base * 1.3, i, n, 1.0);
let w = name.chars().count() as f64 * base * 0.55 + base;
let align = if (q.x - cen.x).abs() < base {
Align::Center
} else if q.x > cen.x {
Align::Left
} else {
Align::Right
};
let x0 = match align {
Align::Left => q.x - base * 0.3,
Align::Right => q.x - w + base * 0.3,
_ => q.x - w / 2.0,
};
label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false);
}
}
// Value labels up the first spoke.
if !c.y_hidden {
for k in 0..=ticks {
let v = min + step * k as f64;
let q = spoke(cen, r, 0, n, k as f64 / ticks as f64);
let t = c.axis_label(false, v);
let w = t.chars().count() as f64 * base * 0.55 + base * 0.4;
label(
ctx,
rctx,
m,
&t,
Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6),
base * 0.9,
Align::Right,
Anchor::Middle,
text_col,
false,
);
}
}
let filled = c.kind == ChartType::FilledRadar;
for (si, s) in c.series.iter().enumerate() {
let pts: Vec<Point> = (0..n)
.map(|i| {
let v = s.values.get(i).copied().flatten().unwrap_or(min);
spoke(cen, r, i, n, ((v - min) / span).clamp(0.0, 1.2))
})
.collect();
let mut p = BezPath::new();
for (i, q) in pts.iter().enumerate() {
if i == 0 { p.move_to(*q) } else { p.line_to(*q) }
}
p.close_path();
ctx.set_transform(m);
if filled {
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 0.75));
ctx.fill_path(&p);
} else {
let col = series_line(rctx, c, si, s);
let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0);
ctx.set_paint(color(col, 1.0));
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round));
ctx.stroke_path(&p);
if s.marker.is_some() {
for q in &pts {
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
}
}
}
if c.data_labels || s.data_labels == Some(true) {
for (i, q) in pts.iter().enumerate() {
if let Some(v) = s.values.get(i).copied().flatten() {
let t = c.data_label(s, v);
label(
ctx,
rctx,
m,
&t,
Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2),
base * 0.9,
Align::Center,
Anchor::Bottom,
text_col,
false,
);
}
}
}
}
}
/// A stock chart's marks at one category: the high-low line, then the up/down bar between the
/// first (open) and last (close) series, else a dash at the close.
#[allow(clippy::too_many_arguments)]
pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) {
let vals: Vec<f64> = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect();
if vals.len() < 2 {
return;
}
let hi = vals.iter().copied().fold(f64::MIN, f64::max);
let lo = vals.iter().copied().fold(f64::MAX, f64::min);
let ink = Rgba::rgb(0x40, 0x40, 0x40);
ctx.set_transform(m);
ctx.set_paint(color(ink, 1.0));
ctx.set_stroke(kurbo::Stroke::new(1.0));
let mut p = BezPath::new();
p.move_to((x, to_y(hi)));
p.line_to((x, to_y(lo)));
ctx.stroke_path(&p);
let open = c.series.first().and_then(|s| s.values.get(i).copied().flatten());
let close = c.series.last().and_then(|s| s.values.get(i).copied().flatten());
if c.up_down_bars && c.series.len() >= 4 {
let (Some(o), Some(cl)) = (open, close) else { return };
let w = band * 0.4;
let (y0, y1) = (to_y(o.max(cl)), to_y(o.min(cl)));
let r = Rect::new(x - w / 2.0, y0, x + w / 2.0, y1.max(y0 + 1.0));
// PowerPoint's default: white when the price rose, dark when it fell.
ctx.set_paint(color(if cl >= o { Rgba::WHITE } else { ink }, 1.0));
ctx.fill_rect(&r);
ctx.set_paint(color(ink, 1.0));
ctx.stroke_path(&r.to_path(0.1));
return;
}
if let Some(cl) = close {
let col = c.series.last().map(|s| series_line(rctx, c, c.series.len() - 1, s)).unwrap_or(ink);
let y = to_y(cl);
let mut d = BezPath::new();
d.move_to((x - band * 0.12, y));
d.line_to((x + band * 0.12, y));
ctx.set_paint(color(col, 1.0));
ctx.set_stroke(kurbo::Stroke::new(2.5));
ctx.stroke_path(&d);
}
}
+403 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use deckcraft_model::chart::ChartType;
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
use deckcraft_model::text::TextBody;
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
@@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
}
fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart {
let mut c = Chart::sample(kind);
c.title = Some(title.into());
c.legend = None;
c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
c.series.truncate(1);
c.series[0].name = "Data".into();
c.series[0].values = ys.iter().copied().map(Some).collect();
c.y_min = Some(0.0);
c.y_max = Some(y_max);
c.series[0].trendlines = vec![trend];
c
}
fn trend_line(kind: TrendlineType) -> Trendline {
Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() }
}
/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit.
fn trend_gallery(draw: bool, forecast: bool) -> Image {
let mut linear = trend_line(TrendlineType::Linear);
if forecast {
linear.forward = Some(1.0);
linear.backward = Some(0.5);
}
let mut poly = trend_line(TrendlineType::Poly);
poly.order = Some(2);
let mut ma = trend_line(TrendlineType::MovingAvg);
ma.period = Some(2);
let mut charts = vec![
trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0),
trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0),
trend_chart(
"Logarithmic",
ChartType::LineMarkers,
&[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()],
trend_line(TrendlineType::Log),
2.0,
),
trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0),
trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0),
trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0),
];
if !draw {
for c in &mut charts {
c.series[0].trendlines.clear();
}
}
let shapes = charts
.into_iter()
.enumerate()
.map(|(i, c)| Shape {
id: ShapeId(800 + i as u32),
xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
})
.collect();
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
#[test]
fn trendlines_draw_through_the_points_with_a_label() {
let on = trend_gallery(true, false);
let off = trend_gallery(false, false);
assert_eq!((on.width, on.height), (960, 540));
let dark = on
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(off.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
.count();
assert!(dark > 40, "dashed trendline ink {dark}");
// A scatter fit uses the real x values and still paints the dash.
let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0);
scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect();
let sh = Shape {
id: ShapeId(810),
xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)),
kind: ShapeKind::Chart(Box::new(scatter.clone())),
..Default::default()
};
let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
scatter.series[0].trendlines.clear();
let sh =
Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() };
let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let scatter_dark = with
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(without.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
.count();
assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}");
if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() {
let png = trend_gallery(true, true).to_png();
std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot");
}
}
#[test]
fn chart_axis_titles_are_drawn() {
let draw = |titles: (Option<String>, Option<String>)| {
@@ -177,6 +281,69 @@ fn chart_axis_titles_are_drawn() {
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
}
#[test]
fn column_points_take_their_own_fill_and_smooth_lines_curve() {
// One column series where only the middle point is given a red c:dPt fill.
let mut c = Chart::sample(ChartType::Column);
c.title = None;
c.legend = None;
c.series.truncate(1);
c.categories = vec!["A".into(), "B".into(), "C".into()];
c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)];
c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
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() };
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]);
assert!(red(480), "the middle column takes its point's fill");
assert!(!red(200) && !red(760), "the others keep the series colour");
// Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and
// LibreOffice's geometry), not band / 2.5 split in two.
let mut c = Chart::sample(ChartType::Column);
c.title = None;
c.legend = None;
c.series.truncate(2);
c.categories = vec!["A".into()];
c.series[0].values = vec![Some(10.0)];
c.series[1].values = vec![Some(10.0)];
c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
(c.gap_width, c.overlap) = (1.5, 0.0);
let sh = Shape { id: ShapeId(622), 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() });
let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5;
let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64;
// The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band
// was split as 1 + gap and then shared between the series).
assert!((200.0..280.0).contains(&reds), "bar {reds} px");
// A smoothed line through a peak overshoots no point but leaves the straight chord.
let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)];
let p = crate::chart::smooth_path(&pts);
assert_eq!(p.elements().len(), 3, "a cubic per segment");
let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5);
assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}");
}
#[test]
fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() {
let draw = |reversed: bool| {
let mut c = Chart::sample(ChartType::Bar);
c.title = None;
c.legend = None;
c.series.truncate(1);
c.categories = vec!["A".into(), "B".into(), "C".into()];
c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)];
c.series[0].point_fills = vec![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))];
c.cat_reversed = reversed;
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() });
let rows: Vec<u32> = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect();
(rows[0] + rows[rows.len() - 1]) / 2
};
assert!(draw(false) > 360, "first category at the bottom: {}", draw(false));
assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true));
}
#[test]
fn placeholder_prompts_only_in_edit_view() {
let p = Presentation::default();
@@ -1975,3 +2142,238 @@ fn table_rows_below_the_slide_are_not_laid_out_and_the_slide_looks_the_same() {
let diff = (0..big.height).flat_map(|y| (0..big.width).map(move |x| (x, y))).filter(|&(x, y)| big.pixel(x, y) != small.pixel(x, y)).count();
assert_eq!(diff, 0);
}
#[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");
}
}
/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and
/// box and whisker charts draw a box per group and series.
#[test]
fn histogram_pareto_and_box_charts_draw() {
let shot = |c: Chart| {
let sh = Shape { id: ShapeId(622), 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 inked = |img: &crate::Image| {
(0..540)
.step_by(6)
.flat_map(|y| (0..960).step_by(6).map(move |x| (x, y)))
.filter(|&(x, y)| {
let p = img.pixel(x, y);
p[0] < 200 || p[1] < 200 || p[2] < 200
})
.count()
};
let mut empty = Chart::sample(ChartType::Histogram);
empty.series[0].values.clear();
let blank = inked(&shot(empty));
for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] {
let img = shot(Chart::sample(kind));
if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") {
let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png());
}
let n = inked(&img);
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());
}
}
/// 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());
}
/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo
/// chart draws its line series over its columns.
#[test]
fn radar_stock_and_combo_draw_their_own_layouts() {
let mut ohlc = Chart::sample(ChartType::Stock);
ohlc.series.insert(
0,
deckcraft_model::chart::Series {
name: "Open".into(),
values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(),
..Default::default()
},
);
ohlc.up_down_bars = true;
let charts = [
("Radar", Chart::sample(ChartType::Radar)),
("FilledRadar", Chart::sample(ChartType::FilledRadar)),
("Stock", Chart::sample(ChartType::Stock)),
("StockOHLC", ohlc),
("Combo", Chart::sample(ChartType::Combo)),
];
for (name, c) in charts {
let sh = Shape {
id: ShapeId(625),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Chart(Box::new(c.clone())),
..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}/{name}.png"), img.to_png());
}
let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255];
match name {
// The radar's top spoke runs straight up from the centre; a line chart has nothing there.
"Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"),
// No series lines: well right of the first category there is only gridline grey.
"Stock" | "StockOHLC" => {
let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| {
let p = img.pixel(x, y);
p[2] > p[0].saturating_add(60)
});
assert!(!coloured || name == "Stock", "{name}: series drawn as lines");
}
_ => {}
}
}
}
#[test]
fn charts_in_degenerate_frames_draw_without_panicking() {
// A chart frame squashed to no width (cx="0" in a generated file) made the value labels'
// gutter clamp panic (its upper bound, a share of the width, fell below its lower bound).
// Every chart type and legend position must survive any frame size.
let p = Presentation::default();
let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap();
let kinds = [
ChartType::Column,
ChartType::StackedColumn,
ChartType::PercentColumn,
ChartType::Bar,
ChartType::StackedBar,
ChartType::PercentBar,
ChartType::Line,
ChartType::LineMarkers,
ChartType::StackedLine,
ChartType::Area,
ChartType::StackedArea,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Scatter,
ChartType::Bubble,
ChartType::Radar,
ChartType::FilledRadar,
ChartType::Stock,
ChartType::Surface,
ChartType::Histogram,
ChartType::Pareto,
ChartType::BoxWhisker,
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Treemap,
ChartType::Sunburst,
ChartType::Combo,
];
let mut ctx = vello_cpu::RenderContext::new(64, 64);
for k in kinds {
for (w, h) in [(0.0, 403.0), (403.0, 0.0), (0.0, 0.0), (2.0, 400.0), (400.0, 2.0), (12.0, 12.0)] {
for legend in [None, Some("b"), Some("t"), Some("r"), Some("l"), Some("tr")] {
let mut c = Chart::sample(k);
c.legend = legend.map(String::from);
crate::chart::draw(&mut ctx, &rctx, &c, kurbo::Affine::IDENTITY, w, h);
}
}
}
}
/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing
/// without them).
#[test]
fn render_an_emf_file_to_look_at() {
let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return };
let Ok(bytes) = std::fs::read(&src) else { return };
let mut p = Presentation::default();
let media = p.add_media("image1.emf", "image/x-emf", bytes);
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
let _ = std::fs::write(out, img.to_png());
}