mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #603 from NikitkaRa: Render: draw chart trendlines
# Conflicts: # crates/render/src/chart.rs
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: previous and next comment; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (View › New Window; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (six original starter decks; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -165,6 +166,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
|
||||
| 2026-10-13 | PPTX round trip of text effects, autofit, columns, vertical and RTL text, tab stops, line spacing, theme effects, gradient and pattern fills, 3-D bevels and connectors. A run keeps its inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML is written back. Embedded fonts and #70, #74, #77 stay open |
|
||||
| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) stay open |
|
||||
| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit |
|
||||
| 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp |
|
||||
| 2026-10-14 | Insert › Icons: a gallery of Lucide stroke icons (release 1.56.0) inserted as custom shapes, no fill, round stroke scaled from the 24-unit design |
|
||||
| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu. Breadth stays 79% |
|
||||
@@ -209,6 +211,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
|
||||
| 2026-10-13 | trivial | Progress log: PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors |
|
||||
| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end |
|
||||
| 2026-10-14 | trivial | Progress log: chart trendlines are drawn |
|
||||
| 2026-10-14 | trivial | Progress log: chart trendlines |
|
||||
| 2026-10-14 | trivial | Progress log: Lucide icon gallery |
|
||||
| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
|
||||
|
||||
@@ -487,6 +487,17 @@ impl Chart {
|
||||
lo = lo.min(m);
|
||||
}
|
||||
}
|
||||
if !percent {
|
||||
for s in &ser {
|
||||
if self.series_kind(s).stacked() {
|
||||
continue;
|
||||
}
|
||||
for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) {
|
||||
hi = hi.max(y);
|
||||
lo = lo.min(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
if percent {
|
||||
lo = lo.min(0.0);
|
||||
hi = hi.max(1.0);
|
||||
@@ -579,6 +590,14 @@ impl Chart {
|
||||
if self.kind.percent() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
if !self.kind.stacked() {
|
||||
for s in &self.series {
|
||||
for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) {
|
||||
hi = hi.max(y);
|
||||
lo = lo.min(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi))
|
||||
}
|
||||
}
|
||||
@@ -820,6 +839,17 @@ mod tests {
|
||||
assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forecast_grows_the_value_axis() {
|
||||
let mut c = Chart::sample(ChartType::Line);
|
||||
c.series.truncate(1);
|
||||
c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)];
|
||||
let before = c.value_range().1;
|
||||
c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() });
|
||||
let after = c.value_range().1;
|
||||
assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_labels_use_the_format_and_language() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
mod trendline;
|
||||
pub mod words;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a
|
||||
//! moving average. The fit is what the chart drawer plots and what the axis grows to include.
|
||||
|
||||
use crate::chart::{ChartType, Series, Trendline, TrendlineType};
|
||||
|
||||
/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x,
|
||||
/// an exponential that overflowed).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrendFit {
|
||||
kind: TrendlineType,
|
||||
model: Model,
|
||||
r2: f64,
|
||||
x_min: f64,
|
||||
x_max: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Model {
|
||||
/// `y = c[0] + c[1] x + c[2] x² + …`
|
||||
Poly(Vec<f64>),
|
||||
/// `y = c · e^(b x)`
|
||||
Exp { c: f64, b: f64 },
|
||||
/// `y = b + c · ln(x)`
|
||||
Log { b: f64, c: f64 },
|
||||
/// `y = c · x^b`
|
||||
Power { c: f64, b: f64 },
|
||||
/// Window averages in x order. No equation.
|
||||
Moving(Vec<(f64, f64)>),
|
||||
}
|
||||
|
||||
impl Trendline {
|
||||
/// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more
|
||||
/// points than it was given, or the normal equations have no solution.
|
||||
pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option<TrendFit> {
|
||||
let mut pairs: Vec<(f64, f64)> =
|
||||
xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?);
|
||||
let intercept = self.intercept.filter(|v| v.is_finite());
|
||||
let (model, used_y, yhat) = match self.kind {
|
||||
TrendlineType::Linear => {
|
||||
let (xs, ys) = split(&pairs);
|
||||
let (b, m) = linreg(&xs, &ys, intercept)?;
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| b + m * x).collect();
|
||||
(Model::Poly(vec![b, m]), ys, yhat)
|
||||
}
|
||||
TrendlineType::Poly => {
|
||||
let order = self.order.unwrap_or(2).clamp(2, 6) as usize;
|
||||
if pairs.len() < order + 1 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ys) = split(&pairs);
|
||||
let coeff = polyfit(&xs, &ys, order, intercept)?;
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| poly_y(&coeff, *x)).collect();
|
||||
(Model::Poly(coeff), ys, yhat)
|
||||
}
|
||||
TrendlineType::Exp => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
|
||||
(Model::Exp { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Log => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ys): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip();
|
||||
let (b, c) = linreg(&ln_x, &ys, intercept)?;
|
||||
let yhat: Vec<f64> = ln_x.iter().map(|u| b + c * u).collect();
|
||||
(Model::Log { b, c }, ys, yhat)
|
||||
}
|
||||
TrendlineType::Power => {
|
||||
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
|
||||
if pairs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
|
||||
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
|
||||
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
|
||||
let c = ln_c.exp();
|
||||
if !c.is_finite() || c <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
|
||||
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
|
||||
(Model::Power { c, b }, ys, yhat)
|
||||
}
|
||||
TrendlineType::MovingAvg => {
|
||||
let period = self.period.unwrap_or(2).clamp(2, 255) as usize;
|
||||
if pairs.len() <= period {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
let mut yhat = Vec::new();
|
||||
for i in period - 1..pairs.len() {
|
||||
let window = &pairs[i + 1 - period..=i];
|
||||
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
|
||||
if avg.is_finite() {
|
||||
pts.push((pairs[i].0, avg));
|
||||
ys.push(pairs[i].1);
|
||||
yhat.push(avg);
|
||||
}
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
(Model::Moving(pts), ys, yhat)
|
||||
}
|
||||
};
|
||||
if yhat.iter().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// A moving average starts at the first full window, not at the first data point.
|
||||
let (x_min, x_max) = if let Model::Moving(pts) = &model {
|
||||
match (pts.first(), pts.last()) {
|
||||
(Some(a), Some(b)) => (a.0, b.0),
|
||||
_ => (x_min, x_max),
|
||||
}
|
||||
} else {
|
||||
(x_min, x_max)
|
||||
};
|
||||
Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max })
|
||||
}
|
||||
|
||||
/// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving
|
||||
/// average has no equation, and PowerPoint does not show its R², so that label is left off
|
||||
/// even when the file asks for it.
|
||||
pub fn caption(&self, fit: &TrendFit) -> String {
|
||||
let mut lines = Vec::new();
|
||||
if self.disp_eq
|
||||
&& let Some(eq) = fit.equation()
|
||||
{
|
||||
lines.push(eq);
|
||||
}
|
||||
if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg {
|
||||
lines.push(fit.r2_label());
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl TrendFit {
|
||||
pub fn y(&self, x: f64) -> Option<f64> {
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let y = match &self.model {
|
||||
Model::Poly(c) => poly_y(c, x),
|
||||
Model::Exp { c, b } => c * (b * x).exp(),
|
||||
Model::Log { b, c } => {
|
||||
if x <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
b + c * x.ln()
|
||||
}
|
||||
Model::Power { c, b } => {
|
||||
if x <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
c * x.powf(*b)
|
||||
}
|
||||
Model::Moving(_) => return None,
|
||||
};
|
||||
y.is_finite().then_some(y)
|
||||
}
|
||||
|
||||
/// Samples from `x0` to `x1`, forecast included. A moving average returns its window points
|
||||
/// and ignores the forecast (Excel does not extend one).
|
||||
pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> {
|
||||
let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
|
||||
if !x0.is_finite() || !x1.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Model::Moving(pts) = &self.model {
|
||||
return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect();
|
||||
}
|
||||
if matches!(self.kind, TrendlineType::Linear) {
|
||||
return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect();
|
||||
}
|
||||
let steps = 48;
|
||||
(0..=steps)
|
||||
.filter_map(|i| {
|
||||
let x = x0 + (x1 - x0) * i as f64 / steps as f64;
|
||||
self.y(x).map(|y| (x, y))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile
|
||||
/// value cannot walk off the chart.
|
||||
pub fn span(&self, forward: Option<f64>, backward: Option<f64>) -> (f64, f64) {
|
||||
let pad = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0);
|
||||
if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) }
|
||||
}
|
||||
|
||||
pub fn equation(&self) -> Option<String> {
|
||||
let body = match &self.model {
|
||||
Model::Poly(c) => poly_equation(c),
|
||||
Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))),
|
||||
Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)),
|
||||
Model::Power { c, b } => {
|
||||
let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) };
|
||||
let sign = if *c < 0.0 { "-" } else { "" };
|
||||
Some(format!("y = {sign}{mag}{}", x_pow(*b)))
|
||||
}
|
||||
Model::Moving(_) => None,
|
||||
}?;
|
||||
Some(body)
|
||||
}
|
||||
|
||||
pub fn r2_label(&self) -> String {
|
||||
let r = if self.r2.is_finite() { self.r2 } else { 0.0 };
|
||||
if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") }
|
||||
}
|
||||
}
|
||||
|
||||
impl Series {
|
||||
/// Trendline y values, including a short forecast, that sit near this series. The axis uses
|
||||
/// them so a forecast is not clipped off the plot. A value many times the series' own range
|
||||
/// is left out, so a wild polynomial cannot flatten the chart.
|
||||
pub fn trendline_ys(&self, scatter: bool) -> Vec<f64> {
|
||||
let n = self.values.len().min(10_000);
|
||||
let mut xs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..n {
|
||||
let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
let x = if scatter {
|
||||
let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
x
|
||||
} else {
|
||||
(i + 1) as f64
|
||||
};
|
||||
xs.push(x);
|
||||
ys.push(y);
|
||||
}
|
||||
let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y)));
|
||||
if !data_lo.is_finite() || !data_hi.is_finite() {
|
||||
return Vec::new();
|
||||
}
|
||||
let span = (data_hi - data_lo).abs().max(1.0);
|
||||
let mid = (data_lo + data_hi) / 2.0;
|
||||
let mut out = Vec::new();
|
||||
for t in self.trendlines.iter().take(8) {
|
||||
let Some(fit) = t.fit(&xs, &ys) else { continue };
|
||||
let (x0, x1) = fit.span(t.forward, t.backward);
|
||||
for (_, y) in fit.samples(x0, x1) {
|
||||
if y.is_finite() && (y - mid).abs() <= span * 8.0 {
|
||||
out.push(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series is plotted against numeric x values rather than category positions.
|
||||
pub fn series_is_scatter(kind: ChartType) -> bool {
|
||||
matches!(kind, ChartType::Scatter | ChartType::Bubble)
|
||||
}
|
||||
|
||||
fn split(pairs: &[(f64, f64)]) -> (Vec<f64>, Vec<f64>) {
|
||||
pairs.iter().map(|(x, y)| (*x, *y)).unzip()
|
||||
}
|
||||
|
||||
/// `y = b + m x`. `intercept` fixes `b`.
|
||||
fn linreg(xs: &[f64], ys: &[f64], intercept: Option<f64>) -> Option<(f64, f64)> {
|
||||
if xs.len() != ys.len() || xs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if let Some(b) = intercept {
|
||||
let mut sxx = 0.0;
|
||||
let mut sxy = 0.0;
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
sxx += x * x;
|
||||
sxy += x * (y - b);
|
||||
}
|
||||
if sxx.abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let m = sxy / sxx;
|
||||
return (m.is_finite() && b.is_finite()).then_some((b, m));
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0);
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
sxy += x * y;
|
||||
}
|
||||
let den = n * sxx - sx * sx;
|
||||
if den.abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let m = (n * sxy - sx * sy) / den;
|
||||
let b = (sy - m * sx) / n;
|
||||
(m.is_finite() && b.is_finite()).then_some((b, m))
|
||||
}
|
||||
|
||||
fn poly_y(coeff: &[f64], x: f64) -> f64 {
|
||||
let mut acc = 0.0;
|
||||
let mut xp = 1.0;
|
||||
for c in coeff {
|
||||
acc += c * xp;
|
||||
if !acc.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
xp *= x;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
|
||||
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
|
||||
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
|
||||
fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Option<Vec<f64>> {
|
||||
let n = xs.len();
|
||||
if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 {
|
||||
return None;
|
||||
}
|
||||
if let Some(b0) = intercept {
|
||||
let m = order;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let dy = y - b0;
|
||||
for i in 0..m {
|
||||
let xi = x.powi((i + 1) as i32);
|
||||
atb[i] += dy * xi;
|
||||
for j in i..m {
|
||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let solved = solve(ata, atb)?;
|
||||
let mut coeff = Vec::with_capacity(order + 1);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
return coeff.iter().all(|v| v.is_finite()).then_some(coeff);
|
||||
}
|
||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
||||
let m = order + 1;
|
||||
let mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (u, y) in us.iter().zip(ys) {
|
||||
for i in 0..m {
|
||||
let ui = if i == 0 { 1.0 } else { u.powi(i as i32) };
|
||||
atb[i] += y * ui;
|
||||
for j in i..m {
|
||||
let uj = if j == 0 { 1.0 } else { u.powi(j as i32) };
|
||||
ata[i][j] += ui * uj;
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..m {
|
||||
for j in 0..i {
|
||||
ata[i][j] = ata[j][i];
|
||||
}
|
||||
}
|
||||
let centred = solve(ata, atb)?;
|
||||
let coeff = expand_shift(¢red, xm);
|
||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||
}
|
||||
|
||||
/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`.
|
||||
#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift
|
||||
fn expand_shift(a: &[f64], xm: f64) -> Vec<f64> {
|
||||
let mut c = vec![0.0; a.len()];
|
||||
for (k, ak) in a.iter().enumerate() {
|
||||
let mut binom = 1.0;
|
||||
for j in 0..=k {
|
||||
c[j] += ak * binom * (-xm).powi((k - j) as i32);
|
||||
if j < k {
|
||||
binom *= (k - j) as f64 / (j + 1) as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index
|
||||
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
if n == 0 || n > 8 || a.len() != n {
|
||||
return None;
|
||||
}
|
||||
for col in 0..n {
|
||||
let mut piv = col;
|
||||
for r in col + 1..n {
|
||||
if a[r][col].abs() > a[piv][col].abs() {
|
||||
piv = r;
|
||||
}
|
||||
}
|
||||
if a[piv][col].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
a.swap(col, piv);
|
||||
b.swap(col, piv);
|
||||
let div = a[col][col];
|
||||
for c in col..n {
|
||||
a[col][c] /= div;
|
||||
}
|
||||
b[col] /= div;
|
||||
for r in 0..n {
|
||||
if r == col {
|
||||
continue;
|
||||
}
|
||||
let f = a[r][col];
|
||||
if f == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in col..n {
|
||||
a[r][c] -= f * a[col][c];
|
||||
}
|
||||
b[r] -= f * b[col];
|
||||
}
|
||||
}
|
||||
b.iter().all(|v| v.is_finite()).then_some(b)
|
||||
}
|
||||
|
||||
fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 {
|
||||
if ys.is_empty() || ys.len() != yhat.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean = ys.iter().sum::<f64>() / ys.len() as f64;
|
||||
let mut ss_tot = 0.0;
|
||||
let mut ss_res = 0.0;
|
||||
for (y, h) in ys.iter().zip(yhat) {
|
||||
ss_tot += (y - mean) * (y - mean);
|
||||
ss_res += (y - h) * (y - h);
|
||||
}
|
||||
if ss_tot < 1e-18 {
|
||||
return if ss_res < 1e-12 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let r = 1.0 - ss_res / ss_tot;
|
||||
if r.is_finite() { r } else { 0.0 }
|
||||
}
|
||||
|
||||
fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".to_string();
|
||||
}
|
||||
let a = v.abs();
|
||||
if a == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
if a >= 1.0e6 || a < 1.0e-3 {
|
||||
return format!("{v:.2e}").replace('+', "");
|
||||
}
|
||||
let digits = if a >= 100.0 {
|
||||
1
|
||||
} else if a >= 10.0 {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
let mut s = format!("{v:.prec$}", prec = digits);
|
||||
if let Some(dot) = s.find('.') {
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
if s.len() == dot + 1 {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn x_pow(p: f64) -> String {
|
||||
if (p - 1.0).abs() < 1e-6 {
|
||||
"x".to_string()
|
||||
} else if (p - 2.0).abs() < 1e-6 {
|
||||
"x²".to_string()
|
||||
} else if (p - 3.0).abs() < 1e-6 {
|
||||
"x³".to_string()
|
||||
} else {
|
||||
format!("x^{}", num(p))
|
||||
}
|
||||
}
|
||||
|
||||
fn poly_equation(coeff: &[f64]) -> Option<String> {
|
||||
let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max).max(1.0);
|
||||
let mut body = String::new();
|
||||
let mut first = true;
|
||||
for (k, c) in coeff.iter().enumerate().rev() {
|
||||
if c.abs() < scale * 1e-8 {
|
||||
continue;
|
||||
}
|
||||
let pow = match k {
|
||||
0 => String::new(),
|
||||
1 => "x".to_string(),
|
||||
_ => x_pow(k as f64),
|
||||
};
|
||||
let mag = if pow.is_empty() {
|
||||
num(c.abs())
|
||||
} else if (c.abs() - 1.0).abs() < scale * 1e-8 {
|
||||
pow
|
||||
} else {
|
||||
format!("{}{}", num(c.abs()), pow)
|
||||
};
|
||||
if first {
|
||||
if *c < 0.0 {
|
||||
body.push('-');
|
||||
}
|
||||
body.push_str(&mag);
|
||||
first = false;
|
||||
} else if *c < 0.0 {
|
||||
body.push_str(" - ");
|
||||
body.push_str(&mag);
|
||||
} else {
|
||||
body.push_str(" + ");
|
||||
body.push_str(&mag);
|
||||
}
|
||||
}
|
||||
if body.is_empty() {
|
||||
body.push('0');
|
||||
}
|
||||
Some(format!("y = {body}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn line(kind: TrendlineType) -> Trendline {
|
||||
Trendline { kind, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_recovers_slope_and_a_fixed_intercept() {
|
||||
let xs = [1.0, 2.0, 3.0, 4.0];
|
||||
let ys = [3.0, 5.0, 7.0, 9.0];
|
||||
let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit");
|
||||
assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9);
|
||||
assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9);
|
||||
assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1"));
|
||||
assert_eq!(fit.r2_label(), "R² = 1");
|
||||
let mut forced = line(TrendlineType::Linear);
|
||||
forced.intercept = Some(0.0);
|
||||
let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit");
|
||||
assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6);
|
||||
assert_eq!(fit.equation().as_deref(), Some("y = 2x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_log_power_and_quadratic_round_their_points() {
|
||||
let xs = [1.0, 2.0, 3.0, 4.0];
|
||||
let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp");
|
||||
for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) {
|
||||
assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}");
|
||||
}
|
||||
assert!(exp.equation().unwrap().contains("e^("));
|
||||
let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log");
|
||||
assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6);
|
||||
assert!(log.equation().unwrap().contains("ln(x)"));
|
||||
let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power");
|
||||
assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6);
|
||||
assert_eq!(power.equation().as_deref(), Some("y = x²"));
|
||||
let mut poly = line(TrendlineType::Poly);
|
||||
poly.order = Some(2);
|
||||
let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly");
|
||||
assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6);
|
||||
assert_eq!(quad.equation().as_deref(), Some("y = x²"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_average_and_forecast_span() {
|
||||
let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() }
|
||||
.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0])
|
||||
.expect("ma");
|
||||
let pts = fit.samples(0.0, 10.0);
|
||||
assert_eq!(pts.len(), 3);
|
||||
assert!((pts[0].1 - 5.0).abs() < 1e-9);
|
||||
assert!((pts[1].1 - 6.0).abs() < 1e-9);
|
||||
assert!((pts[2].1 - 7.0).abs() < 1e-9);
|
||||
assert!(fit.equation().is_none());
|
||||
let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line");
|
||||
assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5));
|
||||
assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_average_shows_neither_equation_nor_r2() {
|
||||
let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() };
|
||||
let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma");
|
||||
assert!(fit.r2_label().starts_with("R² ="));
|
||||
assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_few_points_and_non_finite_input_fit_nothing() {
|
||||
assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none());
|
||||
assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none());
|
||||
assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none());
|
||||
assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none());
|
||||
let mut t = line(TrendlineType::Linear);
|
||||
t.disp_eq = true;
|
||||
t.disp_r_sqr = true;
|
||||
let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit");
|
||||
assert_eq!(t.caption(&fit), "y = x\nR² = 1");
|
||||
}
|
||||
}
|
||||
+292
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
|
||||
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
|
||||
@@ -403,11 +403,15 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
}
|
||||
}
|
||||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
|
||||
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
|
||||
// Forecast reserves empty category slots so the line can run past the data. Charts with no
|
||||
// forecast keep the old band.
|
||||
let (back, fwd) = forecast_pad(c);
|
||||
let nspan = ncat as f64 + back + fwd;
|
||||
let band = if horiz { plot.height() / nspan } else { plot.width() / nspan };
|
||||
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
|
||||
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
|
||||
for (i, name) in c.categories.iter().enumerate() {
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + back + 0.5) } else { plot.x0 + band * (i as f64 + back + 0.5) };
|
||||
if horiz {
|
||||
label(
|
||||
ctx,
|
||||
@@ -461,7 +465,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
for (bi, (si, s)) in bars.iter().enumerate() {
|
||||
let sec = c.on_secondary(s);
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let start = band * (i as f64 + back) + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let (a, b) = if waterfall {
|
||||
c.waterfall_bars().get(i).copied().unwrap_or_default()
|
||||
} else if stacked {
|
||||
@@ -566,9 +570,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + back + 0.5) }
|
||||
}
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
_ => plot.x0 + band * (i as f64 + back + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
}
|
||||
@@ -606,6 +610,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis);
|
||||
// Axis line.
|
||||
let mut ax = BezPath::new();
|
||||
if horiz {
|
||||
@@ -628,6 +633,255 @@ pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
if v > hi { hi } else { v }
|
||||
}
|
||||
|
||||
/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline).
|
||||
fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) {
|
||||
let pad = 4.0;
|
||||
let shift = n as f64 * (box_h + 2.0);
|
||||
let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad);
|
||||
let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad);
|
||||
let y_top = (plot.y0 + pad + shift).min(y_bottom);
|
||||
let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)];
|
||||
let mut best = corners[0];
|
||||
let mut best_d = -1.0;
|
||||
for (x, y) in corners {
|
||||
let mut min_d = f64::MAX;
|
||||
for p in pts {
|
||||
let dx = if p.x < x {
|
||||
x - p.x
|
||||
} else if p.x > x + box_w {
|
||||
p.x - (x + box_w)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dy = if p.y < y {
|
||||
y - p.y
|
||||
} else if p.y > y + box_h {
|
||||
p.y - (y + box_h)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
min_d = min_d.min((dx * dx + dy * dy).sqrt());
|
||||
}
|
||||
if pts.is_empty() {
|
||||
min_d = 1.0;
|
||||
}
|
||||
if min_d > best_d {
|
||||
best_d = min_d;
|
||||
best = (x, y);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn pad_units(v: Option<f64>) -> f64 {
|
||||
v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot.
|
||||
fn forecast_pad(c: &Chart) -> (f64, f64) {
|
||||
if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut back = 0.0_f64;
|
||||
let mut fwd = 0.0_f64;
|
||||
for s in &c.series {
|
||||
if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) {
|
||||
continue;
|
||||
}
|
||||
for t in s.trendlines.iter().take(8) {
|
||||
if t.kind == TrendlineType::MovingAvg {
|
||||
continue;
|
||||
}
|
||||
back = back.max(pad_units(t.backward));
|
||||
fwd = fwd.max(pad_units(t.forward));
|
||||
}
|
||||
}
|
||||
(back, fwd)
|
||||
}
|
||||
|
||||
const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)];
|
||||
|
||||
fn map_xy(
|
||||
plot: Rect,
|
||||
band: f64,
|
||||
back: f64,
|
||||
horiz: bool,
|
||||
scatter: bool,
|
||||
sx0: f64,
|
||||
sx1: f64,
|
||||
sec: bool,
|
||||
x: f64,
|
||||
y: f64,
|
||||
to_axis: &impl Fn(bool, f64) -> f64,
|
||||
) -> Option<Point> {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let value = to_axis(sec, y);
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let along = if scatter && sx1 > sx0 {
|
||||
plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width()
|
||||
} else {
|
||||
let origin = if horiz { plot.y0 } else { plot.x0 };
|
||||
origin + band * (x - 1.0 + back + 0.5)
|
||||
};
|
||||
if !along.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) })
|
||||
}
|
||||
|
||||
/// The part of segment `a`–`b` that lies inside `r`.
|
||||
fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
|
||||
let (mut t0, mut t1) = (0.0, 1.0);
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let p = [-dx, dx, -dy, dy];
|
||||
let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y];
|
||||
for i in 0..4 {
|
||||
let (pi, qi) = (p[i], q[i]);
|
||||
if pi.abs() < 1e-12 {
|
||||
if qi < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let t = qi / pi;
|
||||
if pi < 0.0 {
|
||||
if t > t1 {
|
||||
return None;
|
||||
} else if t > t0 {
|
||||
t0 = t;
|
||||
}
|
||||
} else if t < t0 {
|
||||
return None;
|
||||
} else if t < t1 {
|
||||
t1 = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy)))
|
||||
}
|
||||
|
||||
fn draw_trendlines(
|
||||
ctx: &mut RenderContext,
|
||||
rctx: &Ctx,
|
||||
c: &Chart,
|
||||
m: Affine,
|
||||
plot: Rect,
|
||||
band: f64,
|
||||
back: f64,
|
||||
ncat: usize,
|
||||
horiz: bool,
|
||||
base: f64,
|
||||
text_col: Rgba,
|
||||
to_axis: &impl Fn(bool, f64) -> f64,
|
||||
) {
|
||||
let mut label_n = 0usize;
|
||||
for s in &c.series {
|
||||
if s.trendlines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let kind = c.series_kind(s);
|
||||
let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble);
|
||||
let sec = c.on_secondary(s);
|
||||
let mut xs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..ncat {
|
||||
let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
let x = if scatter {
|
||||
let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
x
|
||||
} else {
|
||||
(i + 1) as f64
|
||||
};
|
||||
xs.push(x);
|
||||
ys.push(y);
|
||||
}
|
||||
// The same x extent the scatter marks use, so the fit sits on the points.
|
||||
let (sx0, sx1) = if scatter {
|
||||
let mut lo = f64::MAX;
|
||||
let mut hi = f64::MIN;
|
||||
for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) {
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
(lo, hi)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
for (ti, t) in s.trendlines.iter().take(8).enumerate() {
|
||||
let Some(fit) = t.fit(&xs, &ys) else { continue };
|
||||
let (x0, x1) = fit.span(t.forward, t.backward);
|
||||
let samples = fit.samples(x0, x1);
|
||||
let mut path = BezPath::new();
|
||||
let mut prev: Option<Point> = None;
|
||||
let mut ink_pts = Vec::new();
|
||||
let mut any = false;
|
||||
for w in samples.windows(2) {
|
||||
let (Some(a), Some(b)) = (
|
||||
map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis),
|
||||
map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis),
|
||||
) else {
|
||||
prev = None;
|
||||
continue;
|
||||
};
|
||||
let Some((ca, cb)) = clip_seg(a, b, plot) else {
|
||||
prev = None;
|
||||
continue;
|
||||
};
|
||||
let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6);
|
||||
if cont {
|
||||
path.line_to(cb);
|
||||
} else {
|
||||
path.move_to(ca);
|
||||
path.line_to(cb);
|
||||
}
|
||||
prev = Some(cb);
|
||||
if ink_pts.len() < 64 {
|
||||
ink_pts.push(ca);
|
||||
ink_pts.push(cb);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
if !any {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22));
|
||||
ctx.set_paint(color(ink, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&path);
|
||||
let caption = t.caption(&fit);
|
||||
if caption.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1;
|
||||
let box_h = base * 1.3 * lines as f64 + 2.0;
|
||||
let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64;
|
||||
let box_w = (widest * base * 0.62).clamp(48.0, plot.width() * 0.72);
|
||||
let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n);
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(Rgba::WHITE, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h));
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&caption,
|
||||
Rect::new(x, y, x + box_w, y + box_h),
|
||||
(base * 0.9).clamp(8.0, 13.0),
|
||||
Align::Left,
|
||||
Anchor::Top,
|
||||
text_col,
|
||||
false,
|
||||
);
|
||||
label_n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -663,4 +917,36 @@ mod tests {
|
||||
assert_eq!(plot, AREA);
|
||||
assert_eq!(o.x, 340.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_keeps_the_part_inside_the_plot() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped");
|
||||
assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6);
|
||||
assert!((a.y - 25.0).abs() < 1e-6);
|
||||
assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none());
|
||||
let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside");
|
||||
assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_keeps_clear_of_a_line_into_the_top_right() {
|
||||
let plot = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)];
|
||||
let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0);
|
||||
assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_reserves_at_most_three_category_slots() {
|
||||
use deckcraft_model::chart::Trendline;
|
||||
let mut c = Chart::sample(ChartType::Line);
|
||||
c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() });
|
||||
assert_eq!(forecast_pad(&c), (0.5, 3.0));
|
||||
c.series[0].trendlines[0].kind = TrendlineType::MovingAvg;
|
||||
assert_eq!(forecast_pad(&c), (0.0, 0.0));
|
||||
c.kind = ChartType::Scatter;
|
||||
c.series[0].trendlines[0].kind = TrendlineType::Linear;
|
||||
assert_eq!(forecast_pad(&c), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
+105
-1
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use deckcraft_model::chart::ChartType;
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm};
|
||||
@@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
|
||||
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
|
||||
}
|
||||
|
||||
fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart {
|
||||
let mut c = Chart::sample(kind);
|
||||
c.title = Some(title.into());
|
||||
c.legend = None;
|
||||
c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
|
||||
c.series.truncate(1);
|
||||
c.series[0].name = "Data".into();
|
||||
c.series[0].values = ys.iter().copied().map(Some).collect();
|
||||
c.y_min = Some(0.0);
|
||||
c.y_max = Some(y_max);
|
||||
c.series[0].trendlines = vec![trend];
|
||||
c
|
||||
}
|
||||
|
||||
fn trend_line(kind: TrendlineType) -> Trendline {
|
||||
Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit.
|
||||
fn trend_gallery(draw: bool, forecast: bool) -> Image {
|
||||
let mut linear = trend_line(TrendlineType::Linear);
|
||||
if forecast {
|
||||
linear.forward = Some(1.0);
|
||||
linear.backward = Some(0.5);
|
||||
}
|
||||
let mut poly = trend_line(TrendlineType::Poly);
|
||||
poly.order = Some(2);
|
||||
let mut ma = trend_line(TrendlineType::MovingAvg);
|
||||
ma.period = Some(2);
|
||||
let mut charts = vec![
|
||||
trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0),
|
||||
trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0),
|
||||
trend_chart(
|
||||
"Logarithmic",
|
||||
ChartType::LineMarkers,
|
||||
&[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()],
|
||||
trend_line(TrendlineType::Log),
|
||||
2.0,
|
||||
),
|
||||
trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0),
|
||||
trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0),
|
||||
trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0),
|
||||
];
|
||||
if !draw {
|
||||
for c in &mut charts {
|
||||
c.series[0].trendlines.clear();
|
||||
}
|
||||
}
|
||||
let shapes = charts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| Shape {
|
||||
id: ShapeId(800 + i as u32),
|
||||
xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)),
|
||||
kind: ShapeKind::Chart(Box::new(c)),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trendlines_draw_through_the_points_with_a_label() {
|
||||
let on = trend_gallery(true, false);
|
||||
let off = trend_gallery(false, false);
|
||||
assert_eq!((on.width, on.height), (960, 540));
|
||||
let dark = on
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(off.pixels.as_chunks::<4>().0)
|
||||
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
|
||||
.count();
|
||||
assert!(dark > 40, "dashed trendline ink {dark}");
|
||||
// A scatter fit uses the real x values and still paints the dash.
|
||||
let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0);
|
||||
scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect();
|
||||
let sh = Shape {
|
||||
id: ShapeId(810),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)),
|
||||
kind: ShapeKind::Chart(Box::new(scatter.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
scatter.series[0].trendlines.clear();
|
||||
let sh =
|
||||
Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() };
|
||||
let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let scatter_dark = with
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(without.pixels.as_chunks::<4>().0)
|
||||
.filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20)
|
||||
.count();
|
||||
assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}");
|
||||
if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() {
|
||||
let png = trend_gallery(true, true).to_png();
|
||||
std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_axis_titles_are_drawn() {
|
||||
let draw = |titles: (Option<String>, Option<String>)| {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for text effects, body layout, theme effects, fills, bevels and connectors; a run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (3-D model parts and animation bounce end round-trip; `corpus_media.rs` covers SmartArt and OLE XML, media bookmarks and trims, sections, fields, ink and timing) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previously trivial: groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -88,7 +89,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||
| Tables with styles | yes | yes | Merges and both diagonal borders round-trip |
|
||||
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read and written; they are not drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt |
|
||||
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt |
|
||||
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
|
||||
| SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save |
|
||||
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) |
|
||||
@@ -128,6 +129,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-13 | minor | Owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds (including custom-show return) survive a save |
|
||||
| 2026-10-13 | minor | Owned corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, survive a save |
|
||||
| 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) |
|
||||
| 2026-10-14 | minor | Chart trendlines are drawn (linear, exponential, logarithmic, polynomial, power, moving average, forecast, equation and R²) |
|
||||
| 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice |
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Icons places a Lucide stroke set; 3-D models and stock content remain; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks on File › New; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -219,6 +220,11 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
or lines), stock charts as high-low lines with close dashes or up/down bars and saved as
|
||||
`c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and
|
||||
trendlines are tracked separately.
|
||||
whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear,
|
||||
exponential, logarithmic, polynomial, power and moving average, with forecast and the equation
|
||||
and R² label. A moving average is drawn with neither label. A combo chart's secondary value axis
|
||||
is read (`y2_*`, `Series.secondary_axis`) and
|
||||
is not written back when the chart is rebuilt.
|
||||
- **Impact:** most business decks have charts; editing their data is routine.
|
||||
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
|
||||
|
||||
@@ -373,6 +379,8 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | minor | Benchmark suite (`cargo xtask bench`, `deckcraft-cli bench`): the performance gap is now frame rate and a PowerPoint comparison |
|
||||
| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open |
|
||||
| 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip |
|
||||
| 2026-10-14 | trivial | A moving average trendline is drawn without an equation or an R² label |
|
||||
| 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open |
|
||||
| 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap |
|
||||
| 2026-10-14 | trivial | 3-D contours |
|
||||
| 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side |
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines round-trip; the real-deck corpus stays open; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Review previous/next comment; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (six original starter decks; the large template gallery stays after beta; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
@@ -143,6 +144,7 @@ plan above.
|
||||
| 2026-10-13 | trivial | Owned PPTX corpus covers comments, links and actions, custom shows, rotated groups and inherited placeholders. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 |
|
||||
| 2026-10-13 | trivial | Owned PPTX corpus covers text effects, body layout, theme effects, fills, bevels and connectors. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 |
|
||||
| 2026-10-14 | trivial | Current focus: `corpus_media.rs` keeps 3-D models and animation bounce end; the real-deck corpus stays open |
|
||||
| 2026-10-14 | trivial | Chart trendlines are drawn. The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus (item 2) stays open |
|
||||
| 2026-10-14 | trivial | Chart trendlines round-trip in an owned corpus slice; the real-deck corpus (item 2) stays open |
|
||||
| 2026-10-14 | trivial | Review workflow: previous and next comment across the deck |
|
||||
| 2026-10-14 | minor | After beta: six original starter decks exist; a larger template gallery remains |
|
||||
|
||||
Reference in New Issue
Block a user