mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 20:56:54 -06:00
167 lines
6.4 KiB
Rust
167 lines
6.4 KiB
Rust
use super::frame::{Bx, value_range};
|
||
use super::lines::decimate;
|
||
use super::*;
|
||
|
||
// ---------------------------------------------------------------- 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)
|
||
}
|
||
|
||
/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and
|
||
/// end were cut.
|
||
#[allow(clippy::type_complexity)]
|
||
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
|
||
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<Vec<(f64, f64)>> {
|
||
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<(f64, f64)>> = 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<String>,
|
||
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<Option<(f64, f64)>> = 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);
|
||
}
|
||
}
|