diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 1ae3d1c..083ff8b 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -15,17 +15,18 @@ mod raster; mod render; mod resolve; mod sparkline; +mod trend; #[cfg(test)] mod tests; -pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind}; +pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind}; use serde::{Deserialize, Serialize}; pub use axis::{Scale, format_number, nice_scale}; pub use raster::rasterize; -pub use render::{Swatch, legend_entries, render}; -pub use resolve::{default_palette, resolve, series_color}; +pub use render::{Swatch, legend_entries, render, supports_trendline}; +pub use resolve::{default_palette, resolve, series_color, series_plot_kind}; pub use sparkline::render_sparkline; /// Straight (non-premultiplied) RGBA colour. @@ -140,10 +141,19 @@ pub struct SeriesData { pub secondary: bool, /// Draw line segments as a smoothed (spline) curve. pub smooth: bool, + /// Trendlines to fit and draw over this series. + pub trendlines: Vec, /// Number format code of the source values ("General" by default). pub number_format: String, } +/// A trendline to draw: the model settings with the line colour resolved against the theme. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TrendlineData { + pub spec: Trendline, + pub color: Rgba, +} + impl Default for SeriesData { fn default() -> Self { SeriesData { @@ -155,6 +165,7 @@ impl Default for SeriesData { kind: ChartKind::ColumnClustered, secondary: false, smooth: false, + trendlines: vec![], number_format: "General".into(), } } diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 354820c..63d30d9 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -3,10 +3,11 @@ use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, Theme}; +use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; -use crate::resolve::{MAX_POINTS, MAX_SERIES, series_color}; +use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; +use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; @@ -158,20 +159,28 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] } - _ => data - .series - .iter() - .take(MAX_SERIES) - .map(|s| { - let sw = match plot_kind(chart, s) { - ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, - ChartKind::LineMarkers => Swatch::LineMarker, - ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, - _ => Swatch::Box, - }; - (s.name.clone(), s.color, sw) - }) - .collect(), + _ => { + let series = data.series.iter().take(MAX_SERIES); + let mut out: Vec<(String, Rgba, Swatch)> = series + .clone() + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(); + // Trendlines follow the series, named "Linear (Sales)" unless given a name. + for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); + } + } + out + } } } @@ -195,6 +204,7 @@ fn sanitize(d: &ChartData) -> ChartData { kind: s.kind, secondary: s.secondary, smooth: s.smooth, + trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(), number_format: s.number_format.clone(), }) .collect(), @@ -630,6 +640,160 @@ fn value_range(it: impl Iterator) -> Option<(f64, f64)> { }) } +// ---------------------------------------------------------------- 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 } +} + +/// Longest forward forecast among `s`'s regression trendlines. +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. +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> { + 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. +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, +) { + 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 }; + let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward)); + 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. +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); + } +} + // ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -763,14 +927,23 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let Some((s1, f1)) = axes.first() else { return }; let y1 = ValAxis { scale: *s1, fmt: f1 }; let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + // A forward forecast on a category axis adds empty category slots after the data. + let fwd = data + .series + .iter() + .enumerate() + .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) + .map(|(_, s)| max_forward(s)) + .fold(0.0, f64::max); + let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat)); + let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines); if !plot.ok() { return; } let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; let cat_len = if horizontal { plot.h } else { plot.w }; - let cw = cat_len / ncat as f32; + let cw = cat_len / ncat_slots as f32; let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; @@ -900,6 +1073,25 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta } } } + // Trendlines on top: x is the category number 1..n. + let mut trend_labels = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { + continue; + } + let sc = scale_of(i); + let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); + let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); + let px = |(fx, fy): (f64, f64)| { + if horizontal { + [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] + } else { + [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] + } + }; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + } + draw_trend_labels(ctx, plot, &trend_labels); } // ---------------------------------------------------------------- scatter & bubble @@ -922,7 +1114,14 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { .collect() }; let all: Vec>> = data.series.iter().map(pts_of).collect(); - let Some((xlo, xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + // Forecasts extend the x axis to the trendline's ends. + for (s, pts) in data.series.iter().zip(&all) { + if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { + xlo = xlo.min(lo - max_backward(s)); + xhi = xhi.max(hi + max_forward(s)); + } + } let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; let xs = nice_scale(xlo, xhi, false); let ys = nice_scale(ylo, yhi, false); @@ -976,6 +1175,17 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { } } } + let mut trend_labels = Vec::new(); + for (s, pts) in data.series.iter().zip(&all) { + if s.trendlines.is_empty() { + continue; + } + let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); + let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); + let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels); + } + draw_trend_labels(ctx, plot, &trend_labels); } // ---------------------------------------------------------------- histogram diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 3d808e1..2c17eff 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -5,12 +5,14 @@ use gridcraft_core::{CellRef, RangeRef, Value, number_to_text}; use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint}; use gridcraft_numfmt::{NumberFormat, format_value}; -use crate::{ChartData, Rgba, SeriesData}; +use crate::{ChartData, Rgba, SeriesData, TrendlineData}; /// Cap on points per series (keeps whole-column references from exploding). pub(crate) const MAX_POINTS: usize = 100_000; /// Cap on series per chart. pub(crate) const MAX_SERIES: usize = 255; +/// Cap on trendlines drawn per series. +pub(crate) const MAX_TRENDLINES: usize = 16; /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2]; @@ -181,6 +183,11 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usiz } } +/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's. +pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind { + if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind } +} + /// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X /// values (scatter/bubble), bubble sizes, number formats and colours (explicit, else the /// default palette derived from the workbook theme accents). @@ -208,6 +215,15 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { } } let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f)); + let trendlines = s + .trendlines + .iter() + .take(MAX_TRENDLINES) + .map(|t| TrendlineData { + spec: t.clone(), + color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color), + }) + .collect(); series.push(SeriesData { name, values, @@ -217,6 +233,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData { kind, secondary: s.secondary, smooth: s.smooth, + trendlines, number_format: ref_format(wb, sheet, &s.values), }); } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 24c1fd2..a942677 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -62,6 +62,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat kind, secondary: false, smooth: false, + trendlines: Vec::new(), number_format: "$#,##0".into(), } } @@ -226,6 +227,7 @@ fn resolve_from_workbook() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }, Series { name: None, @@ -236,6 +238,7 @@ fn resolve_from_workbook() { secondary: true, kind: None, smooth: false, + trendlines: Vec::new(), }, Series { name: Some("Literal".into()), @@ -246,6 +249,7 @@ fn resolve_from_workbook() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }, ]; let d = resolve(&wb, 0, &c); @@ -283,6 +287,7 @@ fn resolve_from_workbook() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }, Series { name: None, @@ -293,6 +298,7 @@ fn resolve_from_workbook() { secondary: true, kind: None, smooth: false, + trendlines: Vec::new(), }, ]; let d = resolve(&wb, 0, &c); @@ -308,6 +314,7 @@ fn resolve_from_workbook() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }]; let d = resolve(&wb, 0, &c); assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3)); @@ -324,6 +331,7 @@ fn resolve_from_workbook() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }]; let d = resolve(&wb, 7, &c); assert_eq!(d.series.len(), 1); @@ -583,6 +591,28 @@ fn fuzz_random_data_and_sizes() { kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize], secondary: r.next().is_multiple_of(3), smooth: r.next().is_multiple_of(4), + trendlines: (0..r.next() % 3) + .map(|_| TrendlineData { + spec: Trendline { + kind: [ + TrendlineKind::Linear, + TrendlineKind::Exponential, + TrendlineKind::Logarithmic, + TrendlineKind::Polynomial((r.next() % 8) as u8), + TrendlineKind::Power, + TrendlineKind::MovingAverage((r.next() % 6) as u32), + ][(r.next() % 6) as usize], + forward: [0.0, 1.5, -2.0, f64::NAN, 1e300][(r.next() % 5) as usize], + backward: [0.0, 0.5, f64::INFINITY][(r.next() % 3) as usize], + intercept: [None, Some(0.0), Some(-5.0), Some(f64::NAN)][(r.next() % 4) as usize], + display_equation: r.next().is_multiple_of(2), + display_r_squared: r.next().is_multiple_of(2), + width: [None, Some(0.0), Some(f32::NAN), Some(3.0)][(r.next() % 4) as usize], + ..Trendline::default() + }, + color: [0, 0, 0, 255], + }) + .collect(), number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(), }) .collect(); @@ -631,6 +661,193 @@ fn sparklines() { assert!(render_sparkline(SparklineKind::Line, &[], [0; 4], false, 10.0, 10.0).is_empty()); } +// ---------------------------------------------------------------- trendlines + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * (1.0 + b.abs()) +} + +fn fit(kind: TrendlineKind, pts: &[(f64, f64)]) -> crate::trend::TrendFit { + crate::trend::fit_trendline(&Trendline { kind, ..Trendline::default() }, pts).expect("fits") +} + +#[test] +fn linear_trendline_matches_least_squares() { + // Slope 0.6, intercept 2.2, R² = 1 - 2.4 / 6. + let f = fit(TrendlineKind::Linear, &[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 4.0), (5.0, 5.0)]); + assert!(close(f.coef[0], 2.2) && close(f.coef[1], 0.6), "{:?}", f.coef); + assert!(close(f.r2.unwrap(), 0.6)); + assert_eq!(f.equation(), "y = 0.6x + 2.2"); + assert_eq!(f.r_squared(), "R² = 0.6"); + assert!(close(f.eval(10.0).unwrap(), 8.2)); + let down = fit(TrendlineKind::Linear, &[(0.0, 1.0), (1.0, -1.0), (2.0, -3.0)]); + assert_eq!(down.equation(), "y = -2x + 1"); +} + +#[test] +fn set_intercept_fits_through_it() { + // Through the origin: slope Σxy / Σx² = 30 / 14; R² against the uncentred total. + let pts = [(1.0, 2.0), (2.0, 5.0), (3.0, 6.0)]; + let f = crate::trend::fit_trendline(&Trendline { intercept: Some(0.0), ..Trendline::default() }, &pts).unwrap(); + assert!(close(f.coef[0], 0.0) && close(f.coef[1], 15.0 / 7.0), "{:?}", f.coef); + let ss_res: f64 = pts.iter().map(|(x, y)| (y - 15.0 / 7.0 * x).powi(2)).sum(); + assert!(close(f.r2.unwrap(), 1.0 - ss_res / 65.0)); + let q = crate::trend::fit_trendline( + &Trendline { kind: TrendlineKind::Polynomial(2), intercept: Some(2.0), ..Trendline::default() }, + &[(1.0, 0.0), (2.0, 0.0), (3.0, 2.0), (4.0, 6.0)], + ) + .unwrap(); + assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef); +} + +#[test] +fn transformed_trendlines_recover_their_curves() { + let xs: [f64; 6] = [0.5, 1.0, 2.0, 3.0, 4.5, 6.0]; + let e = fit(TrendlineKind::Exponential, &xs.map(|x| (x, 3.0 * (0.5 * x).exp()))); + assert!(close(e.coef[0].exp(), 3.0) && close(e.coef[1], 0.5)); + assert_eq!(e.equation(), "y = 3e^(0.5x)"); + let l = fit(TrendlineKind::Logarithmic, &xs.map(|x| (x, 2.0 * x.ln() + 1.0))); + assert!(close(l.coef[1], 2.0) && close(l.coef[0], 1.0)); + assert_eq!(l.equation(), "y = 2ln(x) + 1"); + let p = fit(TrendlineKind::Power, &xs.map(|x| (x, 4.0 * x.powf(1.5)))); + assert!(close(p.coef[0].exp(), 4.0) && close(p.coef[1], 1.5)); + assert_eq!(p.equation(), "y = 4x^1.5"); + assert!(close(p.r2.unwrap(), 1.0)); + assert_eq!(p.eval(-1.0), None, "power is undefined left of zero"); + // Points the transform can't take are skipped, as Excel documents. + let skip = fit(TrendlineKind::Exponential, &[(1.0, 2.0f64.exp()), (2.0, 0.0), (3.0, -4.0), (4.0, 8.0f64.exp())]); + assert!(close(skip.coef[1], 2.0) && close(skip.coef[0], 0.0), "{:?}", skip.coef); + assert!( + crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Power, ..Trendline::default() }, &[(-1.0, 1.0), (0.0, 2.0), (3.0, 3.0)]) + .is_none() + ); +} + +#[test] +fn polynomial_trendlines_are_stable() { + let q = fit(TrendlineKind::Polynomial(2), &[0.0, 1.0, 2.0, 3.0, 4.0].map(|x| (x, x * x - 3.0 * x + 2.0))); + assert!(close(q.coef[0], 2.0) && close(q.coef[1], -3.0) && close(q.coef[2], 1.0), "{:?}", q.coef); + assert_eq!(q.equation(), "y = x² - 3x + 2"); + // Year-valued x would make the raw normal equations hopelessly ill-conditioned. + let curve = |x: f64| { + let t = x - 2000.0; + 0.001 * t.powi(6) - 0.02 * t.powi(5) + 0.1 * t.powi(3) - t + 7.0 + }; + let years: Vec<(f64, f64)> = (2001..=2012).map(|x| (f64::from(x), curve(f64::from(x)))).collect(); + let six = fit(TrendlineKind::Polynomial(6), &years); + for x in [2001.0, 2006.5, 2012.0, 2014.0] { + let (got, want) = (six.eval(x).unwrap(), curve(x)); + assert!((got - want).abs() <= 1e-6 * (1.0 + want.abs()), "{x}: {got} vs {want}"); + } + assert!(six.r2.unwrap() > 0.999_999); +} + +#[test] +fn degenerate_trendlines_fit_nothing() { + let lin = |pts: &[(f64, f64)]| crate::trend::fit_trendline(&Trendline::default(), pts); + assert!(lin(&[]).is_none()); + assert!(lin(&[(1.0, 1.0)]).is_none()); + assert!(lin(&[(2.0, 1.0), (2.0, 5.0), (2.0, 3.0)]).is_none(), "all x equal is singular"); + assert!(lin(&[(f64::NAN, 1.0), (1.0, f64::INFINITY), (2.0, 3.0)]).is_none()); + assert!( + crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::Polynomial(3), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)]) + .is_none() + ); + assert!( + crate::trend::fit_trendline(&Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, &[(1.0, 1.0), (2.0, 2.0)]) + .is_none() + ); + let flat = lin(&[(1.0, 4.0), (2.0, 4.0), (3.0, 4.0)]).unwrap(); + assert_eq!((flat.equation(), flat.r2), ("y = 4".to_string(), None)); + assert!(lin(&[(0.0, 1e300), (1.0, -1e300), (2.0, 1e300)]).is_none_or(|f| f.coef.iter().all(|c| c.is_finite()))); +} + +#[test] +fn moving_average_and_labels() { + let pts = [(1.0, 1.0), (2.0, 2.0), (3.0, 6.0), (4.0, f64::NAN), (5.0, 4.0)]; + assert_eq!(crate::trend::moving_average(&pts, 2), vec![(2.0, 1.5), (3.0, 4.0), (5.0, 5.0)]); + assert_eq!(crate::trend::moving_average(&pts, 3), vec![(3.0, 3.0), (5.0, 4.0)]); + assert!(crate::trend::moving_average(&pts, 9).is_empty()); + assert_eq!(crate::trend::num_text(0.000012345), "1.2345E-05"); + assert_eq!(crate::trend::num_text(-0.00001), "-1E-05"); + assert_eq!(crate::trend::num_text(1234.56789), "1234.5679"); + assert_eq!(crate::trend::auto_name(TrendlineKind::MovingAverage(3), "Sales"), "3 per. Mov. Avg. (Sales)"); + assert_eq!(crate::trend::auto_name(TrendlineKind::Exponential, "Sales"), "Expon. (Sales)"); +} + +fn lines_of(prims: &[Prim], color: Rgba) -> Vec<&Vec<[f32; 2]>> { + prims + .iter() + .filter_map(|p| match p { + Prim::Line { pts, color: c, .. } if *c == color => Some(pts), + _ => None, + }) + .collect() +} + +#[test] +fn line_chart_draws_trendline_forecast_and_labels() { + let m = ApproxMeasure; + let tc: Rgba = [1, 2, 3, 255]; + let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line); + s.trendlines = + vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }]; + let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] }; + let mut c = chart(ChartKind::Line); + c.legend = LegendPos::None; // keeps legend swatches out of the line counts + let prims = render(&c, &d, 600.0, 400.0, &m); + assert_sane(&prims, 600.0, 400.0); + let data = lines_of(&prims, [10, 20, 30, 255]); + let trend = lines_of(&prims, tc); + assert_eq!((data.len(), trend.len()), (1, 1)); + let (d0, dn) = (data[0][0], data[0][3]); + let (t0, tn) = (trend[0][0], *trend[0].last().unwrap()); + // Exact data: the trendline starts on the first point and runs through the last, + // then on one more category (the forecast slot). + assert!((t0[0] - d0[0]).abs() < 0.01 && (t0[1] - d0[1]).abs() < 0.01, "{t0:?} vs {d0:?}"); + let step = (dn[0] - d0[0]) / 3.0; + assert!((tn[0] - (dn[0] + step)).abs() < 0.01, "forecast end {tn:?}, last point {dn:?}"); + assert!(tn[1] > dn[1], "keeps falling past the data"); + let t = texts(&prims); + assert!(t.contains(&"y = -2x + 11") && t.contains(&"R² = 1"), "{t:?}"); + assert!(legend_entries(&chart(ChartKind::Line), &d).iter().any(|(n, col, sw)| n == "Linear (Sales)" && *col == tc && *sw == Swatch::Line)); + // Not on chart kinds Excel doesn't offer trendlines on. + let pie = render(&chart(ChartKind::Pie), &d, 600.0, 400.0, &m); + assert!(lines_of(&pie, tc).is_empty()); + // One point: nothing to fit, nothing drawn. + s.values.truncate(1); + let one = ChartData { categories: vec!["a".into()], series: vec![s] }; + assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty()); +} + +#[test] +fn scatter_trendlines_use_x_values_and_stay_in_the_plot() { + let m = ApproxMeasure; + let xs = [1.0, 2.0, 4.0, 8.0]; + let mut s = series("P", &xs.map(|x| 4.0 * x * x), [10, 20, 30, 255], ChartKind::Scatter); + s.x = Some(xs.iter().map(|x| Some(*x)).collect()); + let colors: [Rgba; 3] = [[1, 0, 0, 255], [2, 0, 0, 255], [3, 0, 0, 255]]; + s.trendlines = vec![ + TrendlineData { + spec: Trendline { kind: TrendlineKind::Power, backward: 0.5, display_equation: true, ..Trendline::default() }, + color: colors[0], + }, + // A steep exponential leaves the plot box and is clipped to it. + TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] }, + TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] }, + ]; + let d = ChartData { categories: vec![], series: vec![s] }; + let mut c = chart(ChartKind::Scatter); + c.legend = LegendPos::None; + let prims = render(&c, &d, 600.0, 400.0, &m); + assert_sane(&prims, 600.0, 400.0); + for c in colors { + assert!(!lines_of(&prims, c).is_empty(), "{c:?} not drawn"); + } + assert_eq!(lines_of(&prims, colors[2])[0].len(), 3, "moving average: one point per full window"); + assert!(texts(&prims).contains(&"y = 4x^2")); +} + #[test] fn treemap_and_histogram_shapes() { let d = ChartData { diff --git a/crates/chart/src/trend.rs b/crates/chart/src/trend.rs new file mode 100644 index 0000000..d987ab5 --- /dev/null +++ b/crates/chart/src/trend.rs @@ -0,0 +1,278 @@ +//! Trendline maths: least-squares fits behind chart trendlines and moving averages. +//! +//! Exponential, logarithmic and power trendlines are linear fits of transformed data +//! (`ln y` against `x`, `y` against `ln x`, `ln y` against `ln x`), the way Excel documents them; +//! points where the transform is undefined (non-positive values) are skipped and R² is reported +//! for the transformed fit. Polynomials solve the normal equations on centred, scaled x. + +use gridcraft_model::{Trendline, TrendlineKind}; + +/// A fitted regression trendline. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TrendFit { + kind: TrendlineKind, + /// Coefficients `c0..=cd` of `Y = Σ cₖ·Xᵏ` in the transformed variables, for the equation. + pub coef: Vec, + /// The same polynomial in `u = (X - center) / scale`, for stable evaluation. + center: f64, + scale: f64, + a: Vec, + /// Coefficient of determination; `None` when undefined (all Y equal). + pub r2: Option, +} + +/// Polynomial degree in the transformed variable; `None` for moving averages. +fn degree(k: TrendlineKind) -> Option { + match k { + TrendlineKind::Linear | TrendlineKind::Exponential | TrendlineKind::Logarithmic | TrendlineKind::Power => Some(1), + TrendlineKind::Polynomial(o) => Some(usize::from(o.clamp(2, 6))), + TrendlineKind::MovingAverage(_) => None, + } +} + +fn log_x(k: TrendlineKind) -> bool { + matches!(k, TrendlineKind::Logarithmic | TrendlineKind::Power) +} + +fn log_y(k: TrendlineKind) -> bool { + matches!(k, TrendlineKind::Exponential | TrendlineKind::Power) +} + +/// Fits `t` to the points `(x, y)`. `None` for moving averages and degenerate data (too few +/// usable points, all x equal, a singular system, non-finite results). +pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option { + let kind = t.kind; + let d = degree(kind)?; + let data: Vec<(f64, f64)> = pts + .iter() + .filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0)) + .map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y })) + .collect(); + if data.len() < 2 || data.len() <= d { + return None; + } + // Set Intercept applies to linear, exponential and polynomial trendlines; it fixes Y at x = 0. + let fixed = match kind { + TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept, + TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln), + _ => None, + } + .filter(|b| b.is_finite()); + let n = data.len() as f64; + let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::() / n }; + let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max); + if !(scale.is_finite() && scale > 0.0) { + return None; + } + let first = usize::from(fixed.is_some()); + let b0 = fixed.unwrap_or(0.0); + // Normal equations over the unknown powers `first..=d` of u. + let m = d + 1 - first; + let mut ata = vec![vec![0.0f64; m]; m]; + let mut aty = vec![0.0f64; m]; + for &(x, y) in &data { + let u = (x - center) / scale; + let pows: Vec = (first..=d).map(|k| u.powi(k as i32)).collect(); + for (i, pi) in pows.iter().enumerate() { + if let Some(v) = aty.get_mut(i) { + *v += pi * (y - b0); + } + for (j, pj) in pows.iter().enumerate() { + if let Some(v) = ata.get_mut(i).and_then(|r| r.get_mut(j)) { + *v += pi * pj; + } + } + } + } + let sol = solve(ata, aty)?; + let mut a = vec![b0; d + 1]; + for (k, v) in sol.into_iter().enumerate() { + if let Some(slot) = a.get_mut(k + first) { + *slot = v; + } + } + let coef = expand(&a, center, scale)?; + let fit = TrendFit { kind, coef, center, scale, a, r2: None }; + let mean = data.iter().map(|p| p.1).sum::() / n; + let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64); + for &(x, y) in &data { + ss_res += (y - fit.poly(x)).powi(2); + // A set intercept is LINEST(y - b, x, FALSE): the total sum of squares is not centred. + ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) }; + } + let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot); + Some(TrendFit { r2, ..fit }) +} + +/// Gaussian elimination with partial pivoting; `None` when (nearly) singular. +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + let big = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())); + if !(big.is_finite() && big > 0.0) { + return None; + } + for col in 0..n { + let piv = (col..n).max_by(|&i, &j| { + let vi = a.get(i).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs()); + let vj = a.get(j).and_then(|r| r.get(col)).map_or(0.0, |v| v.abs()); + vi.total_cmp(&vj) + })?; + a.swap(col, piv); + b.swap(col, piv); + let p = *a.get(col)?.get(col)?; + if !p.is_finite() || p.abs() <= big * 1e-13 { + return None; + } + let prow = a.get(col)?.clone(); + let pb = *b.get(col)?; + for r in col + 1..n { + let row = a.get_mut(r)?; + let f = *row.get(col)? / p; + for (v, pv) in row.iter_mut().zip(&prow).skip(col) { + *v -= f * pv; + } + *b.get_mut(r)? -= f * pb; + } + } + let mut x = vec![0.0f64; n]; + for r in (0..n).rev() { + let row = a.get(r)?; + let s: f64 = (r + 1..n).map(|c| row.get(c).copied().unwrap_or(0.0) * x.get(c).copied().unwrap_or(0.0)).sum(); + let v = (b.get(r)? - s) / row.get(r)?; + *x.get_mut(r)? = v; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// Coefficients in X of `Σ aₖ·((X - c) / s)ᵏ` (binomial expansion). +fn expand(a: &[f64], c: f64, s: f64) -> Option> { + let mut out = vec![0.0f64; a.len()]; + for (k, ak) in a.iter().enumerate() { + let lead = ak / s.powi(k as i32); + let mut binom = 1.0f64; + for j in 0..=k { + // C(k, j) · (-c)^(k-j) · X^j + if let Some(o) = out.get_mut(j) { + *o += lead * binom * (-c).powi((k - j) as i32); + } + binom = binom * (k - j) as f64 / (j + 1) as f64; + } + } + out.iter().all(|v| v.is_finite()).then_some(out) +} + +impl TrendFit { + /// The fitted polynomial at transformed `x` (Horner in u). + fn poly(&self, x: f64) -> f64 { + let u = (x - self.center) / self.scale; + self.a.iter().rev().fold(0.0, |acc, c| acc * u + c) + } + + /// The trendline's y at `x`; `None` outside its domain (x ≤ 0 for log/power) or when not finite. + pub(crate) fn eval(&self, x: f64) -> Option { + let xx = if log_x(self.kind) { if x > 0.0 { x.ln() } else { return None } } else { x }; + let p = self.poly(xx); + let y = if log_y(self.kind) { p.exp() } else { p }; + y.is_finite().then_some(y) + } + + /// Equation label text, e.g. `y = 2x + 1`, `y = 0.5x² - 2x + 1`, `y = 3e^(0.2x)`. + pub(crate) fn equation(&self) -> String { + let c0 = self.coef.first().copied().unwrap_or(0.0); + let c1 = self.coef.get(1).copied().unwrap_or(0.0); + match self.kind { + TrendlineKind::Exponential => format!("y = {}e^({}x)", num_text(c0.exp()), num_text(c1)), + TrendlineKind::Power => format!("y = {}x^{}", num_text(c0.exp()), num_text(c1)), + TrendlineKind::Logarithmic => terms(&[(c1, "ln(x)"), (c0, "")]), + _ => { + let parts: Vec<(f64, &str)> = self.coef.iter().enumerate().rev().map(|(k, c)| (*c, POWERS.get(k).copied().unwrap_or("x"))).collect(); + terms(&parts) + } + } + } + + /// R² label text, e.g. `R² = 0.9963`. + pub(crate) fn r_squared(&self) -> String { + match self.r2 { + Some(r) => format!("R² = {}", num_text(r)), + None => "R² = #N/A".into(), + } + } +} + +const POWERS: [&str; 7] = ["", "x", "x²", "x³", "x⁴", "x⁵", "x⁶"]; + +/// `y = a·t1 ± b·t2 …`, skipping terms whose coefficient shows as 0. +fn terms(parts: &[(f64, &str)]) -> String { + let mut s = String::from("y = "); + let mut first = true; + for (c, t) in parts { + let txt = num_text(c.abs()); + if txt == "0" { + continue; + } + let neg = *c < 0.0; + if first { + if neg { + s.push('-'); + } + } else { + s.push_str(if neg { " - " } else { " + " }); + } + // A unit coefficient on a power of x is left out: `y = x² - x + 1`. + if txt != "1" || t.is_empty() { + s.push_str(&txt); + } + s.push_str(t); + first = false; + } + if first { + s.push('0'); + } + s +} + +/// A coefficient as trendline labels show it: up to four decimals, scientific when tiny or huge. +pub(crate) fn num_text(v: f64) -> String { + if !v.is_finite() { + return "#NUM!".into(); + } + let a = v.abs(); + if a == 0.0 { + return "0".into(); + } + if !(1e-4..1e11).contains(&a) { + let s = format!("{v:.4E}"); + let (mant, exp) = s.split_once('E').unwrap_or((s.as_str(), "0")); + let mant = trim_zeros(mant); + let e: i32 = exp.parse().unwrap_or(0); + return format!("{mant}E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs()); + } + let s = trim_zeros(&format!("{v:.4}")); + if s == "-0" { "0".into() } else { s } +} + +fn trim_zeros(s: &str) -> String { + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s.to_string() } +} + +/// Moving average of `period` consecutive points in plotted order: point `i` (from +/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`. +pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> { + let p = period.clamp(2, 255) as usize; + let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::() / p as f64))).collect() +} + +/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`. +pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String { + let k = match kind { + TrendlineKind::Linear => "Linear".to_string(), + TrendlineKind::Exponential => "Expon.".into(), + TrendlineKind::Logarithmic => "Log.".into(), + TrendlineKind::Polynomial(_) => "Poly.".into(), + TrendlineKind::Power => "Power".into(), + TrendlineKind::MovingAverage(p) => format!("{} per. Mov. Avg.", p.clamp(2, 255)), + }; + format!("{k} ({series})") +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 9f2565a..83a809d 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { "Add Chart Element", ["Chart Design", "Chart Layouts"], None, - "{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth, on?: bool, position?: bottom|top|left|right}", + "{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}", has_doc, add_element ), @@ -316,12 +316,68 @@ fn add_element(s: &mut Session, p: &Json) -> Result { s.smooth = on; } } + "trendline" => { + let only = u32_param(p, "series").map(|i| i as usize); + if only.is_some_and(|i| i >= c.series.len()) { + return Err(bad("chart.addElement", "no such series")); + } + let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); + if !on { + for i in targets { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.clear(); + } + } + return Ok(()); + } + let t = trendline_param(p)?; + let eligible: Vec = + targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); + if eligible.is_empty() { + return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); + } + for i in eligible { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.push(t.clone()); + } + } + } other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), } Ok(()) }) } +/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart +/// Element ▸ Trendline choices, plus the Format Trendline options). +fn trendline_param(p: &Json) -> Result { + let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + let mut t = Trendline { + forward: num("forward"), + backward: num("backward"), + display_equation: bool_param(p, "displayEquation").unwrap_or(false), + display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), + ..Trendline::default() + }; + t.kind = match str_param(p, "type").unwrap_or("linear") { + "linear" => TrendlineKind::Linear, + // Excel's Linear Forecast: a linear trendline projected two periods ahead. + "linearForecast" => { + if t.forward == 0.0 { + t.forward = 2.0; + } + TrendlineKind::Linear + } + "exponential" => TrendlineKind::Exponential, + "logarithmic" => TrendlineKind::Logarithmic, + "power" => TrendlineKind::Power, + "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), + "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), + other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), + }; + Ok(t) +} + fn quick_layout(s: &mut Session, p: &Json) -> Result { let n = u32_param(p, "layout").unwrap_or(1); with_chart(s, p, |c| { diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index f53ab66..6e73573 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -371,6 +371,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { secondary: false, kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line), smooth: false, + trendlines: Vec::new(), }) .collect(); sheet.charts.push(Chart { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6a9137a..6319506 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -553,6 +553,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }); } } else { @@ -567,6 +568,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }); } } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 414ab42..f92b48b 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "freeze": sh.freeze, "merges": sh.merges.iter().map(|m| m.a1()).collect::>(), "tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::>()})).collect::>(), - "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len()})).collect::>(), + "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::>()})).collect::>(), "images": sh.images.len(), "shapes": sh.shapes.len(), "conditionalFormats": sh.cond_formats.len(), diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 4468477..c76bf4b 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -1397,6 +1397,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }) }) .collect() diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 5c05acd..4d48224 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -109,6 +109,7 @@ fn sales(s: &mut Session) -> bool { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }]; } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cddff22..93f302b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1595,6 +1595,45 @@ fn switch_row_column_on_a_chart_read_from_a_file() { assert_eq!(chart(&s).series.len(), 4); } +#[test] +fn chart_trendlines_are_added_saved_and_removed() { + use gridcraft_model::TrendlineKind; + let mut s = s(); + chart_data(&mut s); + s.execute("insert.chart", json!({"type": "line", "range": "A1:E7"})).unwrap(); + let chart = |s: &Session| s.doc().unwrap().wb.active().unwrap().charts[0].clone(); + s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "linearForecast", "displayEquation": true})).unwrap(); + s.execute("chart.addElement", json!({"element": "trendline", "series": 1, "type": "polynomial", "order": 9})).unwrap(); + s.execute("chart.addElement", json!({"element": "trendline", "series": 3, "type": "movingAverage", "period": 3})).unwrap(); + let c = chart(&s); + assert!(c.series[0].trendlines.is_empty()); + let t = &c.series[1].trendlines; + assert_eq!((t.len(), t[0].kind, t[0].forward, t[0].display_equation), (2, TrendlineKind::Linear, 2.0, true)); + assert_eq!(t[1].kind, TrendlineKind::Polynomial(6), "order clamps to Excel's 2..6"); + assert_eq!(c.series[3].trendlines[0].kind, TrendlineKind::MovingAverage(3)); + let info = s.execute("document.inspect", json!({})).unwrap(); + assert_eq!(info["sheets"][0]["charts"][0]["trendlines"], json!(["Linear", "Polynomial(6)", "MovingAverage(3)"])); + assert!(s.execute("chart.addElement", json!({"element": "trendline", "series": 9})).is_err()); + assert!(s.execute("chart.addElement", json!({"element": "trendline", "type": "cubic"})).is_err()); + reopen(&mut s); + let after = chart(&s); + assert_eq!(after.series[1].trendlines, c.series[1].trendlines); + assert_eq!(after.series[3].trendlines, c.series[3].trendlines); + s.execute("chart.addElement", json!({"element": "trendline", "on": false})).unwrap(); + assert!(chart(&s).series.iter().all(|se| se.trendlines.is_empty())); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(chart(&s).series[1].trendlines.len(), 2); +} + +#[test] +fn trendlines_are_refused_on_charts_that_cannot_show_them() { + let mut s = s(); + chart_data(&mut s); + s.execute("insert.chart", json!({"type": "pie", "range": "A1:B7"})).unwrap(); + assert!(s.execute("chart.addElement", json!({"element": "trendline"})).is_err()); + assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].trendlines.is_empty()); +} + #[test] fn switch_row_column_with_whole_column_refs_is_bounded() { let mut s = s(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 317476e..97b3e0b 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -388,6 +388,65 @@ pub struct Series { /// Draw line segments as a smoothed (spline) curve. #[serde(default)] pub smooth: bool, + /// Trendlines fitted to this series (Excel allows several per series). + #[serde(default)] + pub trendlines: Vec, +} + +/// Regression a trendline fits (`c:trendlineType`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TrendlineKind { + #[default] + Linear, + /// `y = c·e^(b·x)`. + Exponential, + /// `y = c·ln(x) + b`. + Logarithmic, + /// Polynomial of this order (2..=6). + Polynomial(u8), + /// `y = c·x^b`. + Power, + /// Average of this many consecutive points (2..=255). + MovingAverage(u32), +} + +/// DrawingML preset line dash (`a:prstDash`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum LineDash { + #[default] + Solid, + Dot, + Dash, + LgDash, + DashDot, + LgDashDot, + LgDashDotDot, + SysDash, + SysDot, + SysDashDot, + SysDashDotDot, +} + +/// A chart trendline (Add Chart Element ▸ Trendline, Format Trendline pane). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Trendline { + pub kind: TrendlineKind, + /// Custom name shown in the legend; `None` = automatic ("Linear (Sales)"). + pub name: Option, + /// Forecast past the last point, in x units (categories on a category axis). + pub forward: f64, + /// Forecast before the first point, in x units. + pub backward: f64, + /// Set Intercept: fixed y at x = 0 (linear, exponential, polynomial). + pub intercept: Option, + pub display_equation: bool, + pub display_r_squared: bool, + /// Line colour; `None` = the series colour. + pub color: Option, + /// Line width in points; `None` = default. + pub width: Option, + /// Line dash; `None` = default. + pub dash: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 3ad4035..052d620 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::Value; use gridcraft_formula::{Expr, SheetSel}; -use gridcraft_model::{Chart, ChartKind, Color, LegendPos, Series, Workbook}; +use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; use crate::drawing::{dml_color, srgb}; use crate::xml::{El, esc, num}; @@ -107,9 +107,61 @@ fn read_series(s: &El) -> Series { secondary: false, kind: None, smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::().ok()).is_some_and(|v| v != 0), + trendlines: s.kids("trendline").filter_map(read_trendline).collect(), } } +const DASHES: [(LineDash, &str); 11] = [ + (LineDash::Solid, "solid"), + (LineDash::Dot, "dot"), + (LineDash::Dash, "dash"), + (LineDash::LgDash, "lgDash"), + (LineDash::DashDot, "dashDot"), + (LineDash::LgDashDot, "lgDashDot"), + (LineDash::LgDashDotDot, "lgDashDotDot"), + (LineDash::SysDash, "sysDash"), + (LineDash::SysDot, "sysDot"), + (LineDash::SysDashDot, "sysDashDot"), + (LineDash::SysDashDotDot, "sysDashDotDot"), +]; + +const EMU_PER_PT: f64 = 12_700.0; + +/// A `CT_Boolean` child (`val` defaults to true). +fn bool_val(e: &El, tag: &str) -> bool { + e.child(tag).is_some_and(|c| c.flag("val", true)) +} + +fn f64_val(e: &El, tag: &str) -> Option { + e.child(tag).and_then(|c| c.attr_f64("val")) +} + +/// `` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped. +fn read_trendline(t: &El) -> Option { + let kind = match t.child_val("trendlineType").unwrap_or("linear") { + "linear" => TrendlineKind::Linear, + "exp" => TrendlineKind::Exponential, + "log" => TrendlineKind::Logarithmic, + "power" => TrendlineKind::Power, + "poly" => TrendlineKind::Polynomial(t.child_val("order").and_then(|v| v.trim().parse::().ok()).unwrap_or(2).clamp(2, 6)), + "movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::().ok()).unwrap_or(2).clamp(2, 255)), + _ => return None, + }; + let ln = t.path(&["spPr", "ln"]); + Some(Trendline { + kind, + name: t.child("name").map(|n| n.text.clone()), + forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0), + backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0), + intercept: f64_val(t, "intercept"), + display_equation: bool_val(t, "dispEq"), + display_r_squared: bool_val(t, "dispRSqr"), + color: ln.and_then(|l| l.child("solidFill")).and_then(dml_color), + width: ln.and_then(|l| l.attr_f64("w")).map(|w| (w / EMU_PER_PT) as f32).filter(|w| w.is_finite() && *w >= 0.0), + dash: ln.and_then(|l| l.child("prstDash")).and_then(|d| d.attr("val")).and_then(|v| DASHES.iter().find(|(_, n)| *n == v).map(|(d, _)| *d)), + }) +} + fn rich_title(t: &El) -> Option { let tx = t.child("tx")?; if let Some(rich) = tx.child("rich") { @@ -343,6 +395,13 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String G::Bubble => x.push_str(""), _ => {} } + // Trendlines sit between dLbls and the data in CT_LineSer, CT_BarSer, CT_AreaSer, + // CT_ScatterSer and CT_BubbleSer; pie and radar series have none. + if !matches!(g, G::Pie | G::Doughnut | G::Radar) { + for t in &s.trendlines { + x.push_str(&trendline_xml(t, wb)); + } + } match g { G::Scatter { .. } | G::Bubble => { if let Some(c) = &s.categories { @@ -370,6 +429,63 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String x } +/// `` with children in schema order: name, spPr, trendlineType, order, period, +/// forward, backward, intercept, dispRSqr, dispEq, trendlineLbl. +fn trendline_xml(t: &Trendline, wb: &Workbook) -> String { + let mut x = String::from(""); + if let Some(n) = &t.name { + let _ = write!(x, "{}", esc(n)); + } + let color = t.color.as_ref().and_then(|c| srgb(c, &wb.theme)); + if color.is_some() || t.width.is_some() || t.dash.is_some() { + x.push_str(""); + if let Some(c) = &color { + let _ = write!(x, "{c}"); + } + if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) { + let _ = write!(x, "", d.1); + } + x.push_str(""); + } + let ty = match t.kind { + TrendlineKind::Linear => "linear", + TrendlineKind::Exponential => "exp", + TrendlineKind::Logarithmic => "log", + TrendlineKind::Polynomial(_) => "poly", + TrendlineKind::Power => "power", + TrendlineKind::MovingAverage(_) => "movingAvg", + }; + let _ = write!(x, ""); + match t.kind { + TrendlineKind::Polynomial(o) => { + let _ = write!(x, "", o.clamp(2, 6)); + } + TrendlineKind::MovingAverage(p) => { + let _ = write!(x, "", p.clamp(2, 255)); + } + _ => {} + } + for (tag, v) in [("forward", t.forward), ("backward", t.backward)] { + if v.is_finite() && v > 0.0 { + let _ = write!(x, "", num(v)); + } + } + if let Some(i) = t.intercept.filter(|i| i.is_finite()) { + let _ = write!(x, "", num(i)); + } + let _ = write!(x, "", t.display_r_squared as u8, t.display_equation as u8); + if t.display_r_squared || t.display_equation { + x.push_str(""); + } + x.push_str(""); + x +} + fn dlbls(show: bool) -> String { format!( "", diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index a9ce0bf..f266bfa 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -10,6 +10,7 @@ mod pivot_tests; mod richdata_tests; mod roundtrip; mod smooth_roundtrip; +mod trendline_roundtrip; mod xls_tests; mod xlsb_tests; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 9e14d19..684de66 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -332,6 +332,7 @@ fn sample() -> Workbook { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }], legend: LegendPos::Bottom, data_labels: true, diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs index 1461613..5a4f68f 100644 --- a/crates/xlsx/src/tests/smooth_roundtrip.rs +++ b/crates/xlsx/src/tests/smooth_roundtrip.rs @@ -18,6 +18,7 @@ fn smooth_line_setting_round_trips() { secondary: false, kind: None, smooth: false, + trendlines: Vec::new(), }; s.charts.push(Chart { id: 1, diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs new file mode 100644 index 0000000..d954c9b --- /dev/null +++ b/crates/xlsx/src/tests/trendline_roundtrip.rs @@ -0,0 +1,171 @@ +//! Chart trendlines (``) round-trip through XLSX in every chart group that has them. + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, LineDash, Series, Sheet, Trendline, TrendlineKind, Workbook}; + +use crate::{read_xlsx, write_xlsx}; + +fn all_trendlines() -> Vec { + vec![ + Trendline { + kind: TrendlineKind::Linear, + forward: 2.0, + backward: 0.5, + display_equation: true, + display_r_squared: true, + ..Trendline::default() + }, + Trendline { kind: TrendlineKind::Exponential, name: Some("Growth & decay".into()), intercept: Some(1.5), ..Trendline::default() }, + Trendline { + kind: TrendlineKind::Logarithmic, + color: Some(Color::Rgb(0xC00000)), + width: Some(2.25), + dash: Some(LineDash::SysDot), + ..Trendline::default() + }, + Trendline { kind: TrendlineKind::Polynomial(2), ..Trendline::default() }, + Trendline { kind: TrendlineKind::Polynomial(6), intercept: Some(-3.0), display_equation: true, ..Trendline::default() }, + Trendline { kind: TrendlineKind::Power, display_r_squared: true, dash: Some(LineDash::LgDashDot), ..Trendline::default() }, + Trendline { kind: TrendlineKind::MovingAverage(4), ..Trendline::default() }, + ] +} + +fn chart(id: u32, kind: ChartKind, trendlines: Vec) -> Chart { + Chart { + id, + kind, + anchor: Anchor { cell: CellRef::new(id * 16, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series: vec![Series { + name: Some("Data!$B$1".into()), + categories: Some("Data!$A$2:$A$6".into()), + values: "Data!$B$2:$B$6".into(), + bubble_sizes: None, + color: Some(Color::Rgb(0x4472C4)), + secondary: false, + kind: None, + smooth: false, + trendlines, + }], + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + } +} + +const KINDS: [ChartKind; 8] = [ + ChartKind::Line, + ChartKind::LineMarkers, + ChartKind::ColumnClustered, + ChartKind::BarClustered, + ChartKind::Area, + ChartKind::Scatter, + ChartKind::ScatterLines, + ChartKind::Bubble, +]; + +fn workbook() -> Workbook { + let mut s = Sheet::new("Data"); + s.set_value(CellRef::new(0, 1), Value::Text("Sales".into())); + for r in 1..=5u32 { + s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r))); + s.set_value(CellRef::new(r, 1), Value::Number(f64::from(r * r))); + } + for (i, k) in KINDS.iter().enumerate() { + s.charts.push(chart(i as u32 + 1, *k, all_trendlines())); + } + Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } +} + +#[test] +fn every_trendline_kind_round_trips_in_every_chart_group() { + let bytes = write_xlsx(&workbook()).unwrap(); + let (back, _rep) = read_xlsx(&bytes).unwrap(); + let charts = &back.sheets[0].charts; + assert_eq!(charts.len(), KINDS.len()); + for (c, k) in charts.iter().zip(KINDS) { + assert_eq!(c.kind, k); + assert_eq!(c.series[0].trendlines, all_trendlines(), "{k:?}"); + } + // A second save writes the same parts (nothing drifts on the way round). + let again = write_xlsx(&back).unwrap(); + assert_eq!(chart_parts(&again), chart_parts(&bytes)); +} + +fn chart_parts(bytes: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap(); + let mut names: Vec = + (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).filter(|n| n.starts_with("xl/charts/chart")).collect(); + names.sort(); + names + .iter() + .map(|n| { + let mut s = String::new(); + z.by_name(n).unwrap().read_to_string(&mut s).unwrap(); + s + }) + .collect() +} + +#[test] +fn trendlines_follow_the_series_schema_sequence() { + let parts = chart_parts(&write_xlsx(&workbook()).unwrap()); + assert_eq!(parts.len(), KINDS.len()); + for (xml, k) in parts.iter().zip(KINDS) { + let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{k:?}: no {tag}")); + let first = at(""); + // After spPr / marker / invertIfNegative, before the data references. + let data = if matches!(k, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble) { "" } else { "" }; + assert!(at("") < first && first < at(data), "{k:?}"); + if let Some(m) = xml.find("") { + assert!(m < first, "{k:?}: marker after trendline"); + } + if let Some(m) = xml.find("").count(), all_trendlines().len(), "{k:?}"); + } + // Children in CT_Trendline order. + let xml = &parts[0]; + let lin = &xml[xml.find("").unwrap()..]; + let lin = &lin[..lin.find("").unwrap()]; + let pos: Vec = [ + "", + "", + "", + "", + "", + "", + ] + .iter() + .map(|t| lin.find(t).unwrap_or_else(|| panic!("missing {t}"))) + .collect(); + assert!(pos.windows(2).all(|w| w[0] < w[1]), "{lin}"); + let exp = &xml[xml.find("Growth & decay").expect("escaped name")..]; + assert!(exp.find("").unwrap() < exp.find("").unwrap()); +} + +#[test] +fn excel_style_trendlines_are_read() { + // As Excel writes them: a theme-coloured dashed line, defaults left out, and an unknown + // type that is skipped rather than failing the chart. + let xml = r#"Data!$B$2:$B$6"#; + let root = crate::xml::parse(xml.as_bytes()).unwrap(); + let c = crate::chart::read_chart(&root).unwrap(); + let t = &c.series[0].trendlines; + assert_eq!(t.len(), 2); + assert_eq!(t[0].kind, TrendlineKind::Polynomial(3)); + assert!(t[0].display_equation && !t[0].display_r_squared, "an empty CT_Boolean means true"); + assert_eq!((t[0].width, t[0].dash), (Some(1.5), Some(LineDash::SysDot))); + assert_eq!(t[0].color, Some(Color::Theme(5, 0))); + assert_eq!(t[1].kind, TrendlineKind::MovingAverage(2), "period clamps to 2..255"); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index d21519c..c2404ca 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotCharts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -42,7 +42,7 @@ open PRs #158, #161, and issue #190. | Gridlines | major/minor per axis, formatting | one on/off flag | | Axis options | min/max/units, log scale, reverse order, crossing point, display units, date axis, number format | **missing** (auto only) | | Secondary axis | any series | line series in combo charts | -| Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | **missing** | +| Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept | | Error bars | fixed, %, standard deviation/error, custom | **missing** | | Up/down bars, high-low lines, drop lines, series lines | yes | **missing** | | Data table under the chart | yes | **missing** | @@ -61,7 +61,7 @@ open PRs #158, #161, and issue #190. ## XLSX round-trip Charts are read from and written to DrawingML chart parts, but only the fields the model holds -survive: anything else (axis scaling, label options, trendlines, per-point formats, chartex +survive: anything else (axis scaling, label options, error bars, per-point formats, chartex extensions) is lost when GridCraft saves a workbook it opened. That is the main reason charts are **~30% ready**: a user who opens an Excel workbook with charts and saves it gets simpler charts back. @@ -71,7 +71,7 @@ back. 1. Kinds survive XLSX round-trip (merge PRs #158, #161): 2–4 h. Chart resizing (#190): 1–2 h. 2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h. 3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h. -4. Trendlines and error bars (maths shared with LINEST/LOGEST/GROWTH): 6–10 h. +4. Error bars; Format Trendline pane (trendlines landed): 4–6 h. 5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. 6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. 7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data. @@ -80,6 +80,7 @@ back. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` | | 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) | | 2026-10-11 | minor | Recommended Charts dialog with previews (#215) | | 2026-10-11 | minor | Select Data dialog with category axis labels; a blank top-left cell makes the first column the categories (#287) | diff --git a/docs/gaps.md b/docs/gaps.md index 35a8f87..d7f352d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§4: chart trendlines landed) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -44,9 +44,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 4. Charts lose type and options on round trip; shallow chart model (F, FF) - **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap, - sunburst), axis scaling, label options, trendlines, error bars, per-point formatting. + sunburst), axis scaling, label options, error bars, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles - (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62) and Switch Row/Column (#116) landed. + (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -159,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | | 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | | 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) |