ravenrs_sheet/chart/render/
trend.rs1use 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
8const TREND_STEPS: usize = 200;
12
13pub fn supports_trendline(k: ChartKind) -> bool {
16 use ChartKind::*;
17 matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble)
18}
19
20fn forecast(v: f64) -> f64 {
22 if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
23}
24
25pub(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
32pub(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
37pub(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
44fn 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
72fn 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
113pub(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 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
159pub(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}