mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
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:
+526
-34
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user