Files
gridcraft/crates/chart/src/resolve.rs
T
neilcawseandClaude Opus 5.5 8ea2342703 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>
2026-10-11 16:39:31 -04:00

244 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
use gridcraft_calc::evaluate;
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, 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];
/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
pub fn series_color(theme: &Theme, i: usize) -> Rgba {
let accent = i % 6;
let cycle = (i / 6) % CYCLE_TINTS.len();
let base = theme.colors.get(4 + accent).copied().unwrap_or(0x156082);
let rgb = [(base >> 16) as u8, (base >> 8) as u8, base as u8];
let tint = CYCLE_TINTS.get(cycle).copied().unwrap_or(0.0);
let [r, g, b] = apply_tint(rgb, tint);
[r, g, b, 0xFF]
}
/// The first `n` series colours for `theme`.
pub fn default_palette(theme: &Theme, n: usize) -> Vec<Rgba> {
(0..n.min(4096)).map(|i| series_color(theme, i)).collect()
}
/// Splits `Sheet!A1:B2` / `'My Sheet'!$A$1` / `A1:B2` into a sheet index and range.
fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, RangeRef)> {
let f = formula.trim().trim_start_matches('=').trim();
let (sh, addr) = match f.rfind('!') {
Some(i) => {
let name = f.get(..i)?.trim();
let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string());
(wb.sheet_index(&name)?, f.get(i + 1..)?)
}
None => (sheet, f),
};
let r = RangeRef::parse(addr)?;
Some((sh, r))
}
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
fn union_parts(f: &str) -> Option<Vec<&str>> {
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
let mut parts = Vec::new();
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
for (i, c) in inner.char_indices() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth -= 1,
',' if !quoted && depth == 0 => {
parts.push(inner.get(start..i)?);
start = i + 1;
}
_ => {}
}
if depth < 0 {
return None;
}
}
parts.push(inner.get(start..)?);
(parts.len() > 1).then_some(parts)
}
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
eval_list_in(wb, sheet, formula, 0)
}
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
let f = formula.trim();
if f.is_empty() {
return vec![];
}
if depth < 4
&& let Some(parts) = union_parts(f)
{
let mut out = Vec::new();
for p in parts {
if out.len() >= MAX_POINTS {
break;
}
let rest = MAX_POINTS - out.len();
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
}
return out;
}
match evaluate(wb, sheet, CellRef::default(), f) {
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
v => vec![v],
}
}
fn to_num(v: &Value) -> Option<f64> {
match v {
Value::Number(n) if n.is_finite() => Some(*n),
_ => None,
}
}
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
eval_list(wb, sheet, formula).iter().map(to_num).collect()
}
/// Number format code of the first cell of a reference ("General" when not a reference).
fn ref_format(wb: &Workbook, sheet: usize, formula: &str) -> String {
parse_ref(wb, sheet, formula)
.and_then(|(sh, r)| wb.sheet(sh).map(|s| wb.styles.get(s.style_id(r.start)).num_fmt.0.to_string()))
.unwrap_or_else(|| "General".into())
}
fn value_text(v: &Value) -> String {
match v {
Value::Empty => String::new(),
Value::Number(n) => number_to_text(*n),
Value::Text(t) => t.to_string(),
Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.into(),
Value::Error(e) => e.as_str().into(),
Value::Array(a) => a.get(0, 0).map(value_text).unwrap_or_default(),
}
}
/// Category labels, formatted with each source cell's number format (dates, currency…).
fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
let vals = eval_list(wb, sheet, formula);
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
vals.iter()
.enumerate()
.map(|(i, v)| {
if let (Some((s, r)), Value::Number(_)) = (src, v) {
let w = r.width().max(1) as usize;
let at = r.start.offset((i / w) as i64, (i % w) as i64);
if let Some(c) = at {
let code = &wb.styles.get(s.style_id(c)).num_fmt.0;
if !code.eq_ignore_ascii_case("general") {
return format_value(v, &NumberFormat::parse(code), wb.date_system).text;
}
}
}
value_text(v)
})
.collect()
}
/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is.
fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
let t = name.trim();
if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) {
return q.replace("\"\"", "\"");
}
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') {
let vals = eval_list(wb, sheet, t);
let parts: Vec<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
return parts.join(" ");
}
t.to_string()
}
fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
match (chart_kind, series_kind) {
(ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k,
(ChartKind::Combo, _) => {
if i == 0 {
ChartKind::ColumnClustered
} else {
ChartKind::Line
}
}
(_, Some(k)) if k != ChartKind::Combo => k,
(k, _) => k,
}
}
/// 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).
pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
let mut series = Vec::new();
let mut categories: Option<Vec<String>> = None;
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
let values = nums(wb, sheet, &s.values);
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
let color =
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
let kind = effective_kind(chart.kind, s.kind, i);
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
// X values with text in them (labels) are ignored: the points are numbered 1..n.
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
_ => None,
};
if categories.is_none()
&& let Some(f) = &s.categories
{
let c = category_texts(wb, sheet, f);
if !c.is_empty() {
categories = Some(c);
}
}
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,
x,
sizes,
color,
kind,
secondary: s.secondary,
smooth: s.smooth,
trendlines,
number_format: ref_format(wb, sheet, &s.values),
});
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
ChartData { categories, series }
}