mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 02:57:01 -06:00
Read the regionMap chartEx layout as a RegionMap chart (regions and their colour values), write the part back as read while unchanged, and when edited rebuild it with the original geography (projection, culture and cached boundaries) carried over. With no map data in the repo, the renderer draws each region as a value-coloured tile with a colour scale and a note that the map itself is not drawn. Co-authored-by: Cursor <cursoragent@cursor.com>
574 lines
24 KiB
Rust
574 lines
24 KiB
Rust
//! 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));
|
|
}
|
|
}
|