//! Charts: title, plot area with axes and gridlines, series marks, legend. use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; 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}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; use vello_cpu::RenderContext; use crate::color; const SLOTS: [SchemeSlot; 6] = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; /// Colour of series/point `i`: accents, then darker and lighter rounds. pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { let slot = SLOTS.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1); let base = ctx.color(&ColorRef::scheme(slot), None); let mono = c.palette.starts_with("monochrome"); let base = if mono { ctx.color(&ColorRef::scheme(SchemeSlot::Accent1), None) } else { base }; let round = if mono { i } else { i / 6 }; match round { 0 => base, 1 => deckcraft_color::apply(base, &[ColorTransform::Shade(60000)]), 2 => deckcraft_color::apply(base, &[ColorTransform::Tint(60000)]), n => deckcraft_color::apply(base, &[ColorTransform::LumMod(100000 - (n as i32 % 5) * 12000)]), } } 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)), } } /// Fill of one bar: a `` 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 ``). 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); } if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { return series_color(ctx, c, pi); } series_fill(ctx, c, si, 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. 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)), } } /// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025"). fn split_label(name: &str) -> String { let chars: Vec = name.chars().collect(); let mid = chars.len() / 2; let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid)); match best { Some(i) if i > 0 && i + 1 < chars.len() => { let (a, b) = chars.split_at(i); format!("{}\n{}", a.iter().collect::().trim_end(), b.iter().collect::().trim_start()) } _ => name.to_string(), } } 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); body.body.inset_t = Some(0.0); body.body.inset_b = Some(0.0); body.body.anchor = Some(anchor); body.body.wrap = Some(false); for p in &mut body.paragraphs { p.props.align = Some(align); p.props.bullet = Some(Bullet::None); p.props.margin_left = Some(0.0); p.props.indent = Some(0.0); for run in &mut p.runs { run.props.size = Some(size); run.props.bold = Some(bold); run.props.fill = Some(Fill::solid(ColorRef::rgb(col))); } } let tmp = Shape { text: Some(body.clone()), ..Default::default() }; 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 /// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows. /// An overlaid legend leaves the plot area as it was. fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) { let (lw, lh) = size; let gap = base * 0.5; let cx = (area.x0 + area.x1) / 2.0; let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0); let mut plot = area; let origin = match pos { "b" => { plot.y1 = area.y1 - lh - gap; Point::new(cx - lw / 2.0, area.y1 - lh) } "t" => { plot.y0 = area.y0 + lh + gap; Point::new(cx - lw / 2.0, area.y0) } "l" => { plot.x0 = area.x0 + lw + gap; Point::new(area.x0, cy) } "tr" => { plot.x1 = area.x1 - lw - gap; Point::new(area.x1 - lw, area.y0) } _ => { plot.x1 = area.x1 - lw - gap; Point::new(area.x1 - lw, cy) } }; (origin, if overlay { area } else { plot }) } 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 { crate::paint::fill_path( ctx, rctx, f, None, &Rect::new(0.0, 0.0, w, h).to_path(0.1), Rect::new(0.0, 0.0, w, h), m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres, ); } // The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size // that suits the chart. let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0)); let title_size = c.title_size.unwrap_or(base * 1.4); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false); area.y0 += title_size * 1.7; } 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() } 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 .iter() .enumerate() .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)) }) }) .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 = 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 { (item_ws.iter().sum::().min(w * 0.9), base * 1.4) }; 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)); 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 { y += base * 1.5; } else { x += tw + base * 0.6; } } } if pie { let Some(s) = c.series.first() else { return }; let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); let total: f64 = vals.iter().sum(); if total <= 0.0 { return; } let r = (area.width().min(area.height()) / 2.0) * 0.95; 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; let mut p = BezPath::new(); p.move_to(cen); let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); p.extend(arc.append_iter(0.1)); p.close_path(); ctx.set_transform(m); let col = s .point_fills .iter() .find(|(k, _)| *k == i) .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) .unwrap_or_else(|| series_color(rctx, c, i)); ctx.set_paint(color(col, 1.0)); ctx.fill_path(&p); ctx.set_paint(color(Rgba::WHITE, 1.0)); ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.stroke_path(&p); if c.data_labels { let mid = a0 + sweep / 2.0; let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); label( ctx, rctx, m, &c.data_label(s, *v), Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), base, Align::Center, Anchor::Middle, Rgba::WHITE, false, ); } a0 += sweep; } if c.kind == ChartType::Doughnut { ctx.set_transform(m); ctx.set_paint(color(Rgba::WHITE, 1.0)); ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); } 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()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. // On a horizontal bar chart the category axis is the left one. let (left_title, bottom_title) = if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; let band_h = base * 1.6; if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); area.y1 -= band_h + base * 0.3; } if let Some(t) = left_title.filter(|t| !t.is_empty()) { let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); let half = area.height() / 2.0; label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); area.x0 += band_h + base * 0.3; } // 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; // 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)| { let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50); (0..=n).map(move |k| lo + k as f64 * st) }; // Label gutters as wide as the longest value label. let primary_ticks: Vec = 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 = 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 { base * 0.5 } else { clamp_soft(label_w(false, (min, max, step)), base * 1.5, area.width() * 0.3) }; let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| clamp_soft(label_w(true, sc), base * 1.5, area.width() * 0.3)).unwrap_or(0.0); let plot = if horiz { Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6) } else { Rect::new(area.x0 + gutter, area.y0, area.x1 - gutter_r, area.y1 - base * 1.8) }; if let Some(f) = &c.plot_fill { crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); // 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 = 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. let to_axis = |sec: bool, v: f64| match (sec, second) { (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 &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(); if horiz { g.move_to((pos, plot.y0)); g.line_to((pos, plot.y1)); label( ctx, rctx, m, &text, Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), base, Align::Center, Anchor::Top, text_col, false, ); } else { g.move_to((plot.x0, pos)); g.line_to((plot.x1, pos)); label( ctx, rctx, m, &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), base, Align::Right, Anchor::Middle, text_col, false, ); } if c.gridlines { ctx.set_transform(m); ctx.set_paint(color(grid_col, 1.0)); ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.stroke_path(&g); } } // The secondary axis' labels, on the right, without gridlines of their own. if let Some(sc) = second.filter(|_| !c.y2_hidden) { for v in ticks(sc) { let pos = to_axis(true, v); label( ctx, rctx, m, &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), base, Align::Left, Anchor::Middle, text_col, false, ); } } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); // 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().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, rctx, m, name, Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), base, Align::Right, Anchor::Middle, text_col, false, ); } else { let text = if wraps { split_label(name) } else { name.clone() }; label( ctx, rctx, m, &text, Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), base, Align::Center, Anchor::Top, text_col, false, ); } } 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 stacked = bk.stacked(); 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; let total: f64 = if bk.percent() { bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::().max(1e-9) } else { 1.0 }; 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 * 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; r } else { let r = (neg_acc + v, neg_acc); neg_acc += v; r } } else { (0.0f64.min(v), 0.0f64.max(v)) }; let r = if horiz { 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 { bar_fill(rctx, c, *si, i, s) }; ctx.set_transform(m); 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 { Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1) } else { Rect::new(r.x0 - 10.0, r.y0 - base * 1.4, r.x1 + 10.0, r.y0) }; label( ctx, rctx, m, &t, lr, base * 0.9, if horiz { Align::Left } else { Align::Center }, if horiz { Anchor::Middle } else { Anchor::Bottom }, text_col, false, ); } } } } // 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() || kind == ChartType::Stock { continue; } let sec = c.on_secondary(s); let stacked = kind.stacked(); let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; let mut pts = vec![]; for i in 0..ncat { let Some(v) = s.values.get(i).copied().flatten() else { continue }; let v = if stacked { let base_v = acc.get(i).copied().unwrap_or(0.0); if let Some(a) = acc.get_mut(i) { *a += v; } base_v + v } else { v }; let x = match kind { ChartType::Scatter | ChartType::Bubble => { 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 * (slot(i) + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { continue; } 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(); if let (Some(l), Some(f)) = (pts.last(), pts.first()) { a.line_to((l.x, zero)); a.line_to((f.x, zero)); a.close_path(); } ctx.set_paint(color(col, 0.85)); ctx.fill_path(&a); } else if kind != ChartType::Scatter { 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).with_caps(kurbo::Cap::Round)); ctx.stroke_path(&p); } if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { ctx.set_paint(color(col, 1.0)); for q in &pts { ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); } } } // 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 { ax.move_to((zero, plot.y0)); ax.line_to((zero, plot.y1)); } else { ax.move_to((plot.x0, zero)); ax.line_to((plot.x1, zero)); } ctx.set_transform(m); ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); ctx.set_stroke(kurbo::Stroke::new(0.75)); ctx.stroke_path(&ax); } /// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than /// its label font) or a bound is NaN: the upper bound wins. pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 { let v = if v < lo { lo } else { v }; 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 { 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 { 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 = 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); assert_eq!((o.x, o.y), (340.0, 100.0)); assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0)); } #[test] fn left_top_and_corner_positions() { let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false); assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0)); let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false); assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0)); let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false); assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0)); } #[test] fn bottom_legend_keeps_the_old_row_layout() { let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false); assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0)); } #[test] fn overlay_does_not_shrink_the_plot() { let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true); 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)); } }