Skip to main content

ravenrs_sheet/chart/render/
trend.rs

1use super::frame::{Bx, value_range};
2use super::lines::decimate;
3use super::{
4    ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
5    fit_trendline, moving_average,
6};
7
8// ---------------------------------------------------------------- trendlines
9
10/// Samples along a regression trendline.
11const TREND_STEPS: usize = 200;
12
13/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column,
14/// bar, line, area, scatter and bubble charts).
15pub fn supports_trendline(k: ChartKind) -> bool {
16    use ChartKind::*;
17    matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
18}
19
20/// A forecast distance as drawn: finite, non-negative and bounded.
21fn forecast(v: f64) -> f64 {
22    if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
23}
24
25/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
26/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
27/// column to nothing.
28pub(super) fn max_forward_slots(ncat: usize) -> f64 {
29    (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
30}
31
32/// Longest forward forecast among `s`'s regression trendlines.
33pub(super) fn max_forward(s: &SeriesData) -> f64 {
34    s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
35}
36
37/// Longest backward forecast among `s`'s regression trendlines.
38pub(super) fn max_backward(s: &SeriesData) -> f64 {
39    s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max)
40}
41
42type ClippedSegment = ((f64, f64), (f64, f64), bool, bool);
43
44/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
45/// end were cut.
46fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
47    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
48    let (mut t0, mut t1) = (0.0f64, 1.0f64);
49    for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
50        if p == 0.0 {
51            if q < 0.0 {
52                return None;
53            }
54        } else {
55            let r = q / p;
56            if p < 0.0 {
57                if r > t1 {
58                    return None;
59                }
60                t0 = t0.max(r);
61            } else {
62                if r < t0 {
63                    return None;
64                }
65                t1 = t1.min(r);
66            }
67        }
68    }
69    Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0))
70}
71
72/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit
73/// square), returning the visible runs.
74fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec<Vec<(f64, f64)>> {
75    const LIM: f64 = 1e9;
76    let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite();
77    let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM));
78    let mut runs: Vec<Vec<(f64, f64)>> = Vec::new();
79    let mut run: Vec<(f64, f64)> = Vec::new();
80    for w in pts.windows(2) {
81        let seg = match w {
82            [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)),
83            _ => None,
84        };
85        match seg {
86            Some((p, q, cut_start, cut_end)) => {
87                if cut_start || run.is_empty() {
88                    if run.len() >= 2 {
89                        runs.push(std::mem::take(&mut run));
90                    }
91                    run.clear();
92                    run.push(p);
93                }
94                run.push(q);
95                if cut_end {
96                    runs.push(std::mem::take(&mut run));
97                }
98            }
99            None => {
100                if run.len() >= 2 {
101                    runs.push(std::mem::take(&mut run));
102                }
103                run.clear();
104            }
105        }
106    }
107    if run.len() >= 2 {
108        runs.push(run);
109    }
110    runs
111}
112
113/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to
114/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels.
115/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing.
116pub(super) fn draw_trendlines(
117    ctx: &mut Ctx,
118    s: &SeriesData,
119    pts: &[(f64, f64)],
120    frac: &dyn Fn(f64, f64) -> (f64, f64),
121    px: &dyn Fn((f64, f64)) -> [f32; 2],
122    labels: &mut Vec<String>,
123    window: (f64, f64),
124) {
125    let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
126    for t in s.trendlines.iter().take(MAX_TRENDLINES) {
127        let spec = &t.spec;
128        let samples: Vec<Option<(f64, f64)>> = match spec.kind {
129            TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
130            _ => {
131                let Some(fit) = fit_trendline(spec, pts) else { continue };
132                // Sample only the part of the forecast that is on the plot.
133                let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
134                if spec.display_equation {
135                    labels.push(fit.equation());
136                }
137                if spec.display_r_squared {
138                    labels.push(fit.r_squared());
139                }
140                (0..=TREND_STEPS)
141                    .map(|i| {
142                        let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64;
143                        fit.eval(x).map(|y| frac(x, y))
144                    })
145                    .collect()
146            }
147        };
148        let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0);
149        let dash = spec.dash != Some(LineDash::Solid);
150        for run in clip_unit(&samples) {
151            let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS);
152            if line.len() >= 2 {
153                ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash });
154            }
155        }
156    }
157}
158
159/// Trendline equation / R² labels, stacked in the plot's top-right corner.
160pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) {
161    let lh = LABEL_SIZE * 1.3;
162    for (i, t) in labels.iter().enumerate() {
163        let y = plot.y + 4.0 + i as f32 * lh;
164        if y + lh > plot.bottom() {
165            break;
166        }
167        let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0);
168        ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top));
169    }
170}