use super::frame::{Bx, value_range}; use super::lines::decimate; use super::{ ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign, fit_trendline, moving_average, }; // ---------------------------------------------------------------- trendlines /// Samples along a regression trendline. const TREND_STEPS: usize = 200; /// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, /// bar, line, area, scatter and bubble charts). pub fn supports_trendline(k: ChartKind) -> bool { use ChartKind::*; matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) } /// A forecast distance as drawn: finite, non-negative and bounded. fn forecast(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } } /// Most empty category slots a forward forecast may add after `ncat` categories: four times the /// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every /// column to nothing. pub(super) fn max_forward_slots(ncat: usize) -> f64 { (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 } /// Longest forward forecast among `s`'s regression trendlines. pub(super) fn max_forward(s: &SeriesData) -> f64 { s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) } /// Longest backward forecast among `s`'s regression trendlines. pub(super) fn max_backward(s: &SeriesData) -> f64 { s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) } type ClippedSegment = ((f64, f64), (f64, f64), bool, bool); /// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and /// end were cut. fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64); for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { if p == 0.0 { if q < 0.0 { return None; } } else { let r = q / p; if p < 0.0 { if r > t1 { return None; } t0 = t0.max(r); } else { if r < t0 { return None; } t1 = t1.min(r); } } } Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) } /// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit /// square), returning the visible runs. fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { const LIM: f64 = 1e9; let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); let mut runs: Vec> = Vec::new(); let mut run: Vec<(f64, f64)> = Vec::new(); for w in pts.windows(2) { let seg = match w { [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), _ => None, }; match seg { Some((p, q, cut_start, cut_end)) => { if cut_start || run.is_empty() { if run.len() >= 2 { runs.push(std::mem::take(&mut run)); } run.clear(); run.push(p); } run.push(q); if cut_end { runs.push(std::mem::take(&mut run)); } } None => { if run.len() >= 2 { runs.push(std::mem::take(&mut run)); } run.clear(); } } } if run.len() >= 2 { runs.push(run); } runs } /// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to /// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. /// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. pub(super) fn draw_trendlines( ctx: &mut Ctx, s: &SeriesData, pts: &[(f64, f64)], frac: &dyn Fn(f64, f64) -> (f64, f64), px: &dyn Fn((f64, f64)) -> [f32; 2], labels: &mut Vec, window: (f64, f64), ) { let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; for t in s.trendlines.iter().take(MAX_TRENDLINES) { let spec = &t.spec; let samples: Vec> = match spec.kind { TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), _ => { let Some(fit) = fit_trendline(spec, pts) else { continue }; // Sample only the part of the forecast that is on the plot. let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); if spec.display_equation { labels.push(fit.equation()); } if spec.display_r_squared { labels.push(fit.r_squared()); } (0..=TREND_STEPS) .map(|i| { let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; fit.eval(x).map(|y| frac(x, y)) }) .collect() } }; let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); let dash = spec.dash != Some(LineDash::Solid); for run in clip_unit(&samples) { let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); if line.len() >= 2 { ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); } } } } /// Trendline equation / R² labels, stacked in the plot's top-right corner. pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { let lh = LABEL_SIZE * 1.3; for (i, t) in labels.iter().enumerate() { let y = plot.y + 4.0 + i as f32 * lh; if y + lh > plot.bottom() { break; } let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top)); } }