mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-11 16:37:06 -06:00
Merge PR #1146: Charts: bars draw their series' pattern fill and no fill
Conflicts: ROADMAP.md (union of progress-log rows)
This commit is contained in:
+88
-28
@@ -29,31 +29,70 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba {
|
||||
}
|
||||
}
|
||||
|
||||
/// A series' one colour (lines, areas, markers): its solid fill, a pattern's foreground, else the
|
||||
/// palette's.
|
||||
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),
|
||||
Some(Fill::Pattern(p)) => ctx.color(&p.fg, 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)),
|
||||
/// How a bar, or its legend key, is painted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Paint {
|
||||
Color(Rgba),
|
||||
/// A preset pattern: name, foreground, background.
|
||||
Pattern(String, Rgba, Rgba),
|
||||
/// `a:noFill`: only the outline, if any, shows.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// The paint of fill `f` as a bar or legend key draws it; the palette's `fallback` when the file
|
||||
/// gives none (or one a bar can't show).
|
||||
fn paint_of(ctx: &Ctx, f: Option<&Fill>, fallback: Rgba) -> Paint {
|
||||
match f {
|
||||
Some(Fill::Solid { color: cc }) => Paint::Color(ctx.color(cc, None)),
|
||||
Some(Fill::Pattern(p)) => Paint::Pattern(p.preset.clone(), ctx.color(&p.fg, None), ctx.color(&p.bg, None)),
|
||||
Some(Fill::None) => Paint::Nothing,
|
||||
_ => Paint::Color(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`).
|
||||
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
if s.point_fills.iter().any(|(k, _)| *k == pi) {
|
||||
return point_fill(ctx, c, si, s, pi);
|
||||
/// Fill `r` (in the space `m` maps to the output) with `p`.
|
||||
fn fill_with(ctx: &mut RenderContext, p: &Paint, r: Rect, m: Affine) {
|
||||
match p {
|
||||
Paint::Color(c) => {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*c, 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
Paint::Pattern(name, fg, bg) => crate::pattern::fill(ctx, name, *fg, *bg, 1.0, &r.to_path(0.1), m),
|
||||
Paint::Nothing => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bar series' outline (`c:spPr/a:ln`) where the file gives it a solid colour: colour and width.
|
||||
fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f64)> {
|
||||
let l = s.line.as_ref()?;
|
||||
match l.fill.as_ref()? {
|
||||
Fill::Solid { color: cc } => Some((ctx.color(cc, None), l.width.unwrap_or(0.75).clamp(0.0, 20.0))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
|
||||
/// `a:noFill` leaves the bar empty, as in PowerPoint.
|
||||
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
|
||||
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
|
||||
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return series_color(ctx, c, pi);
|
||||
return Paint::Color(series_color(ctx, c, pi));
|
||||
}
|
||||
series_fill(ctx, c, si, Some(s))
|
||||
paint_of(ctx, s.fill.as_ref(), series_color(ctx, c, si))
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
@@ -235,16 +274,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend placed by `c:legendPos`.
|
||||
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()
|
||||
// Each legend entry: its name, its key's paint and the key's outline.
|
||||
let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall {
|
||||
["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).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()
|
||||
c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).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()
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect()
|
||||
} else {
|
||||
c.series
|
||||
.iter()
|
||||
@@ -252,13 +292,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.map(|(i, s)| {
|
||||
let k = c.series_kind(s);
|
||||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
if line {
|
||||
(s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None)
|
||||
} else if k.is_bar_like() {
|
||||
let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() {
|
||||
Paint::Color(series_color(rctx, c, i))
|
||||
} else {
|
||||
paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i))
|
||||
};
|
||||
(s.name.clone(), paint, bar_outline(rctx, s))
|
||||
} else {
|
||||
(s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let size = if vertical {
|
||||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||||
} else {
|
||||
@@ -267,10 +318,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||||
area = plot_area;
|
||||
let (mut x, mut y) = (origin.x, origin.y);
|
||||
for (name, col) in &legend_items {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*col, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||||
for (name, paint, outline) in &legend_items {
|
||||
let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base);
|
||||
fill_with(ctx, paint, key, m);
|
||||
if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2)));
|
||||
ctx.stroke_path(&key.to_path(0.1));
|
||||
}
|
||||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
if vertical {
|
||||
@@ -625,7 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
.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 paint = if waterfall {
|
||||
let slot = if c.subtotals.contains(&i) {
|
||||
2
|
||||
} else if b < a {
|
||||
@@ -633,13 +689,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
} else {
|
||||
0
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
Paint::Color(series_color(rctx, c, slot))
|
||||
} else {
|
||||
bar_fill(rctx, c, *si, i, s)
|
||||
bar_paint(rctx, c, *si, i, s)
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
fill_with(ctx, &paint, r.abs(), m);
|
||||
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(oc, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(ow));
|
||||
ctx.stroke_path(&r.abs().to_path(0.1));
|
||||
}
|
||||
if waterfall && i + 1 < ncat && !horiz {
|
||||
let y = to_v(b);
|
||||
let mut j = BezPath::new();
|
||||
|
||||
@@ -340,6 +340,43 @@ fn chart_axis_titles_are_drawn() {
|
||||
assert!(ink(&titled, bottom) > ink(&bare, bottom) + 30, "category axis title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_series_draw_their_pattern_fill_and_no_fill_with_their_outline() {
|
||||
// Three column series, as PowerPoint draws them: a red-on-yellow pattern, no fill with a blue
|
||||
// outline, and no fill at all. None takes a palette colour; the legend keys match the bars.
|
||||
use deckcraft_model::style::{Fill, PatternFill};
|
||||
let rgb = |r, g, b| ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b));
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.title = None;
|
||||
c.series.truncate(3);
|
||||
c.categories = vec!["A".into()];
|
||||
for s in &mut c.series {
|
||||
s.values = vec![Some(10.0)];
|
||||
}
|
||||
c.series[0].fill = Some(Fill::Pattern(PatternFill { preset: "dkDnDiag".into(), fg: rgb(255, 0, 0), bg: rgb(255, 255, 0) }));
|
||||
c.series[1].fill = Some(Fill::None);
|
||||
c.series[1].line = Some(deckcraft_model::style::Line { width: Some(3.0), fill: Some(Fill::solid(rgb(0, 0, 255))), ..Default::default() });
|
||||
c.series[2].fill = Some(Fill::None);
|
||||
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() });
|
||||
let count =
|
||||
|f: &dyn Fn([u8; 4]) -> bool| (0..img.width).flat_map(|x| (0..img.height).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count();
|
||||
let red = count(&|p| p[0] > 220 && p[1] < 40 && p[2] < 40);
|
||||
let yellow = count(&|p| p[0] > 220 && p[1] > 220 && p[2] < 40);
|
||||
let blue = count(&|p| p[0] < 40 && p[1] < 40 && p[2] > 220);
|
||||
assert!(red > 1000 && yellow > 1000, "the pattern's two colours: {red} red, {yellow} yellow");
|
||||
assert!(blue > 300, "the empty series' outline: {blue}");
|
||||
// No palette colour: the theme's accents would show for any series drawn the old way.
|
||||
let p = deck_with(vec![]);
|
||||
let ctx = deckcraft_model::resolve::Ctx::for_slide(&p, &p.slides[0]).unwrap();
|
||||
for i in 0..3 {
|
||||
let a = crate::chart::series_color(&ctx, &c, i);
|
||||
let n = count(&|q| q[0].abs_diff(a.r) < 6 && q[1].abs_diff(a.g) < 6 && q[2].abs_diff(a.b) < 6);
|
||||
assert!(n < 50, "palette colour {i} drawn on {n} pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
|
||||
Reference in New Issue
Block a user