Charts: read, write and draw trendlines
Model: Series.trendlines (Vec<Trendline>, serde default) with kind
(linear, exponential, logarithmic, polynomial 2..6, power, moving
average 2..255), name, forward/backward forecast, set intercept,
equation / R² display and line colour, width and dash.
XLSX: read and write <c:trendline> in CT_Trendline child order, placed
after dLbls and before the data references in line, bar, area, scatter,
bubble and stock series; unknown types are skipped.
Chart: least-squares fits (transformed for exp/log/power, skipping
non-positive values; polynomials via normal equations on centred,
scaled x with partial pivoting), moving averages, forecasts that widen
the category or x axis, clipping to the plot, equation and R² labels and
legend entries ("Linear (Sales)"). Degenerate data draws nothing.
Engine: chart.addElement element="trendline" adds (type, series, order,
period, forward, backward, displayEquation, displayRSquared) or removes
trendlines; document.inspect lists each chart's trendlines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4dca02c89b
commit
8ea2342703
|
|
@ -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<TrendlineData>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Item = f64>) -> 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<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.
|
||||
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>,
|
||||
) {
|
||||
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 };
|
||||
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<String> = (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<String> = (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<Vec<Option<(f64, f64)>>> = 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
|
||||
|
|
|
|||
|
|
@ -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<ChartKind>, 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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
/// The same polynomial in `u = (X - center) / scale`, for stable evaluation.
|
||||
center: f64,
|
||||
scale: f64,
|
||||
a: Vec<f64>,
|
||||
/// Coefficient of determination; `None` when undefined (all Y equal).
|
||||
pub r2: Option<f64>,
|
||||
}
|
||||
|
||||
/// Polynomial degree in the transformed variable; `None` for moving averages.
|
||||
fn degree(k: TrendlineKind) -> Option<usize> {
|
||||
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<TrendFit> {
|
||||
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::<f64>() / 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<f64> = (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::<f64>() / 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<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
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<Vec<f64>> {
|
||||
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<f64> {
|
||||
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::<f64>() / 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})")
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<Json> {
|
|||
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<usize> = (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<usize> =
|
||||
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<Trendline> {
|
||||
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<Json> {
|
||||
let n = u32_param(p, "layout").unwrap_or(1);
|
||||
with_chart(s, p, |c| {
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
sheet.charts.push(Chart {
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
|||
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<Series>
|
|||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
|||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"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::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
|
|
|
|||
|
|
@ -1397,6 +1397,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
|||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ fn sales(s: &mut Session) -> bool {
|
|||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Trendline>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<f64>,
|
||||
pub display_equation: bool,
|
||||
pub display_r_squared: bool,
|
||||
/// Line colour; `None` = the series colour.
|
||||
pub color: Option<Color>,
|
||||
/// Line width in points; `None` = default.
|
||||
pub width: Option<f32>,
|
||||
/// Line dash; `None` = default.
|
||||
pub dash: Option<LineDash>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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::<u8>().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<f64> {
|
||||
e.child(tag).and_then(|c| c.attr_f64("val"))
|
||||
}
|
||||
|
||||
/// `<c:trendline>` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped.
|
||||
fn read_trendline(t: &El) -> Option<Trendline> {
|
||||
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::<u8>().ok()).unwrap_or(2).clamp(2, 6)),
|
||||
"movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::<u32>().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<String> {
|
||||
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("<c:invertIfNegative val=\"0\"/>"),
|
||||
_ => {}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
/// `<c:trendline>` 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("<c:trendline>");
|
||||
if let Some(n) = &t.name {
|
||||
let _ = write!(x, "<c:name>{}</c:name>", 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("<c:spPr><a:ln");
|
||||
if let Some(w) = t.width.filter(|w| w.is_finite()) {
|
||||
// ST_LineWidth: 0..=20116800 EMU.
|
||||
let _ = write!(x, " w=\"{}\"", (f64::from(w) * EMU_PER_PT).round().clamp(0.0, 20_116_800.0) as i64);
|
||||
}
|
||||
x.push_str(" cap=\"rnd\">");
|
||||
if let Some(c) = &color {
|
||||
let _ = write!(x, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) {
|
||||
let _ = write!(x, "<a:prstDash val=\"{}\"/>", d.1);
|
||||
}
|
||||
x.push_str("</a:ln></c:spPr>");
|
||||
}
|
||||
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, "<c:trendlineType val=\"{ty}\"/>");
|
||||
match t.kind {
|
||||
TrendlineKind::Polynomial(o) => {
|
||||
let _ = write!(x, "<c:order val=\"{}\"/>", o.clamp(2, 6));
|
||||
}
|
||||
TrendlineKind::MovingAverage(p) => {
|
||||
let _ = write!(x, "<c:period val=\"{}\"/>", p.clamp(2, 255));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (tag, v) in [("forward", t.forward), ("backward", t.backward)] {
|
||||
if v.is_finite() && v > 0.0 {
|
||||
let _ = write!(x, "<c:{tag} val=\"{}\"/>", num(v));
|
||||
}
|
||||
}
|
||||
if let Some(i) = t.intercept.filter(|i| i.is_finite()) {
|
||||
let _ = write!(x, "<c:intercept val=\"{}\"/>", num(i));
|
||||
}
|
||||
let _ = write!(x, "<c:dispRSqr val=\"{}\"/><c:dispEq val=\"{}\"/>", t.display_r_squared as u8, t.display_equation as u8);
|
||||
if t.display_r_squared || t.display_equation {
|
||||
x.push_str("<c:trendlineLbl><c:numFmt formatCode=\"General\" sourceLinked=\"0\"/></c:trendlineLbl>");
|
||||
}
|
||||
x.push_str("</c:trendline>");
|
||||
x
|
||||
}
|
||||
|
||||
fn dlbls(show: bool) -> String {
|
||||
format!(
|
||||
"<c:dLbls><c:showLegendKey val=\"0\"/><c:showVal val=\"{}\"/><c:showCatName val=\"0\"/><c:showSerName val=\"0\"/><c:showPercent val=\"0\"/><c:showBubbleSize val=\"0\"/></c:dLbls>",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mod pivot_tests;
|
|||
mod richdata_tests;
|
||||
mod roundtrip;
|
||||
mod smooth_roundtrip;
|
||||
mod trendline_roundtrip;
|
||||
mod xls_tests;
|
||||
mod xlsb_tests;
|
||||
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ fn sample() -> Workbook {
|
|||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: true,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
//! Chart trendlines (`<c:trendline>`) 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<Trendline> {
|
||||
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<Trendline>) -> 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<String> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();
|
||||
let mut names: Vec<String> =
|
||||
(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("<c:trendline>");
|
||||
// After spPr / marker / invertIfNegative, before the data references.
|
||||
let data = if matches!(k, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble) { "<c:xVal>" } else { "<c:cat>" };
|
||||
assert!(at("<c:spPr>") < first && first < at(data), "{k:?}");
|
||||
if let Some(m) = xml.find("<c:marker>") {
|
||||
assert!(m < first, "{k:?}: marker after trendline");
|
||||
}
|
||||
if let Some(m) = xml.find("<c:invertIfNegative") {
|
||||
assert!(m < first, "{k:?}: invertIfNegative after trendline");
|
||||
}
|
||||
assert_eq!(xml.matches("<c:trendline>").count(), all_trendlines().len(), "{k:?}");
|
||||
}
|
||||
// Children in CT_Trendline order.
|
||||
let xml = &parts[0];
|
||||
let lin = &xml[xml.find("<c:trendline>").unwrap()..];
|
||||
let lin = &lin[..lin.find("</c:trendline>").unwrap()];
|
||||
let pos: Vec<usize> = [
|
||||
"<c:trendlineType val=\"linear\"/>",
|
||||
"<c:forward val=\"2\"/>",
|
||||
"<c:backward val=\"0.5\"/>",
|
||||
"<c:dispRSqr val=\"1\"/>",
|
||||
"<c:dispEq val=\"1\"/>",
|
||||
"<c:trendlineLbl>",
|
||||
]
|
||||
.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("<c:name>Growth & decay</c:name>").expect("escaped name")..];
|
||||
assert!(exp.find("<c:trendlineType val=\"exp\"/>").unwrap() < exp.find("<c:intercept val=\"1.5\"/>").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#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="0"/><c:order val="0"/><c:marker><c:symbol val="none"/></c:marker><c:trendline><c:spPr><a:ln w="19050" cap="rnd"><a:solidFill><a:schemeClr val="accent2"/></a:solidFill><a:prstDash val="sysDot"/></a:ln></c:spPr><c:trendlineType val="poly"/><c:order val="3"/><c:dispRSqr val="0"/><c:dispEq/></c:trendline><c:trendline><c:trendlineType val="movingAvg"/><c:period val="1"/></c:trendline><c:trendline><c:trendlineType val="cubicSpline"/></c:trendline><c:val><c:numRef><c:f>Data!$B$2:$B$6</c:f></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:lineChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
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");
|
||||
}
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue