Merge PR #603 from NikitkaRa: Render: draw chart trendlines

# Conflicts:
#	crates/render/src/chart.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:47:08 -04:00
9 changed files with 1067 additions and 8 deletions
+292 -6
View File
@@ -2,7 +2,7 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
use deckcraft_model::resolve::Ctx;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
@@ -403,11 +403,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
}
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 };
// 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) };
let mid = if horiz { plot.y0 + band * (i as f64 + back + 0.5) } else { plot.x0 + band * (i as f64 + back + 0.5) };
if horiz {
label(
ctx,
@@ -461,7 +465,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
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 start = band * (i as f64 + back) + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
let (a, b) = if waterfall {
c.waterfall_bars().get(i).copied().unwrap_or_default()
} else if stacked {
@@ -566,9 +570,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
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) }
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + back + 0.5) }
}
_ => plot.x0 + band * (i as f64 + 0.5),
_ => plot.x0 + band * (i as f64 + back + 0.5),
};
pts.push(Point::new(x, to_axis(sec, v)));
}
@@ -606,6 +610,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
}
}
}
draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis);
// Axis line.
let mut ax = BezPath::new();
if horiz {
@@ -628,6 +633,255 @@ 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,
back: 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 * (x - 1.0 + back + 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,
back: 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.
let (sx0, sx1) = 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, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis),
map_xy(plot, band, back, 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 = (widest * base * 0.62).clamp(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::*;
@@ -663,4 +917,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));
}
}
+105 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use deckcraft_model::chart::ChartType;
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
use deckcraft_model::text::TextBody;
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
@@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
}
fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart {
let mut c = Chart::sample(kind);
c.title = Some(title.into());
c.legend = None;
c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
c.series.truncate(1);
c.series[0].name = "Data".into();
c.series[0].values = ys.iter().copied().map(Some).collect();
c.y_min = Some(0.0);
c.y_max = Some(y_max);
c.series[0].trendlines = vec![trend];
c
}
fn trend_line(kind: TrendlineType) -> Trendline {
Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() }
}
/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit.
fn trend_gallery(draw: bool, forecast: bool) -> Image {
let mut linear = trend_line(TrendlineType::Linear);
if forecast {
linear.forward = Some(1.0);
linear.backward = Some(0.5);
}
let mut poly = trend_line(TrendlineType::Poly);
poly.order = Some(2);
let mut ma = trend_line(TrendlineType::MovingAvg);
ma.period = Some(2);
let mut charts = vec![
trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0),
trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0),
trend_chart(
"Logarithmic",
ChartType::LineMarkers,
&[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()],
trend_line(TrendlineType::Log),
2.0,
),
trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0),
trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0),
trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0),
];
if !draw {
for c in &mut charts {
c.series[0].trendlines.clear();
}
}
let shapes = charts
.into_iter()
.enumerate()
.map(|(i, c)| Shape {
id: ShapeId(800 + i as u32),
xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
})
.collect();
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
#[test]
fn trendlines_draw_through_the_points_with_a_label() {
let on = trend_gallery(true, false);
let off = trend_gallery(false, false);
assert_eq!((on.width, on.height), (960, 540));
let dark = on
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(off.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
.count();
assert!(dark > 40, "dashed trendline ink {dark}");
// A scatter fit uses the real x values and still paints the dash.
let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0);
scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect();
let sh = Shape {
id: ShapeId(810),
xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)),
kind: ShapeKind::Chart(Box::new(scatter.clone())),
..Default::default()
};
let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
scatter.series[0].trendlines.clear();
let sh =
Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() };
let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
let scatter_dark = with
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(without.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
.count();
assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}");
if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() {
let png = trend_gallery(true, true).to_png();
std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot");
}
}
#[test]
fn chart_axis_titles_are_drawn() {
let draw = |titles: (Option<String>, Option<String>)| {