Charts: multi-level category axes, read, drawn and written back

A category range two or more columns wide beside a column of values (year beside month, Excel's
multiLvlStrRef) was read as one flat list: 36 rows in two columns became 72 categories, the
series sat in the left half of the plot and the years and months shared one row of labels.
Saving wrote the range as a plain strRef, so Excel lost the two-level axis too.

- gridcraft_core::category_levels: the shape of a category range beside its values (levels are
  columns when the points run down the rows, rows when they run along the columns; up to three
  outer levels), shared by the chart and xlsx crates.
- ChartData.category_levels: the outer levels, nearest the labels first, one entry per category,
  non-empty where a group starts. resolve fills it; a single column is unchanged.
- render: the outer levels in rows under the labels, a group's name centred under its categories
  with a separator at each boundary; in bar charts in columns beside the labels.
- xlsx: <c:cat> written as multiLvlStrRef with the level caches (innermost first), so the axis
  survives a round trip.

Tests in core (shapes), chart (resolve and layout for columns, bars and a row of values) and
xlsx (written XML, read-back, stability, a single column stays strRef). chart-parity, gaps § 4
and the ROADMAP progress log updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Lexx143andClaude Fable 5.1 committed 2026-10-13 17:30:01 +03:00
1 parent 84acf6293b
commit 209d2447d9
12 files changed
+439 -41

No files matched your search

+2
View File
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
+5
View File
@@ -123,6 +123,11 @@ impl Measure for ApproxMeasure {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartData {
pub categories: Vec<String>,
/// Outer levels of a multi-level category axis (a category range two columns wide beside a
/// column of values: year beside month), nearest the labels first. One entry per category,
/// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`.
#[serde(default)]
pub category_levels: Vec<Vec<String>>,
pub series: Vec<SeriesData>,
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
#[serde(default)]
+68 -12
View File
@@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(),
series: d
.series
.iter()
@@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos
// ---------------------------------------------------------------- axes frame
enum CatAxis<'a> {
Labels(&'a [String]),
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
struct ValAxis<'a> {
scale: Scale,
fmt: &'a Fmt,
@@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 };
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
return plot;
}
@@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Labels(l) => {
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
@@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
for (i, level) in outer.iter().enumerate() {
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
for (first, end) in level_groups(level, n) {
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
@@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
}
plot
} else {
let labels: Vec<String> = match cat {
CatAxis::Labels(l) => l.to_vec(),
CatAxis::Numeric(..) => vec![],
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
// Outer levels stand in columns left of the labels, each as wide as its longest name.
let level_w: Vec<f32> =
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let plot = Bx { x: area.x + lw, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.0 };
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
@@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
let mut x1 = plot.x - lw;
for (level, w) in outer.iter().zip(&level_w) {
let x0 = x1 - w;
for (first, end) in level_groups(level, n) {
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
}
}
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
x1 = x0;
}
plot
}
}
@@ -938,7 +991,10 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
.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);
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1236,7 +1292,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
let ys = nice_scale(0.0, maxc, true);
let fc = Fmt::new("General");
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1288,7 +1344,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
let ys = nice_scale(lo, hi, true);
let f = Fmt::new(&s.number_format);
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1579,7 +1635,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
@@ -1627,7 +1683,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
let ys = nice_scale(lo, hi, false);
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
if !plot.ok() {
return;
}
+29 -7
View File
@@ -1,7 +1,7 @@
//! 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_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
use gridcraft_numfmt::{NumberFormat, format_value};
@@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String {
}
}
/// Category labels, formatted with each source cell's number format (dates, currency…).
fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
/// Category labels, formatted with each source cell's number format (dates, currency…), and
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
/// has one entry per category, non-empty where a group starts, as the cells are.
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<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()
let texts: Vec<String> = vals
.iter()
.enumerate()
.map(|(i, v)| {
if let (Some((s, r)), Value::Number(_)) = (src, v) {
@@ -153,7 +157,23 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
}
value_text(v)
})
.collect()
.collect();
let Some((_, r)) = src else { return (texts, vec![]) };
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
if texts.len() != w.saturating_mul(h) {
return (texts, vec![]); // truncated or not a plain block: a flat list
}
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
let outer = levels.outer as usize;
if levels.by_columns {
(column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
} else {
(row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
}
}
/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is.
@@ -197,6 +217,7 @@ 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;
let mut category_levels = Vec::new();
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));
@@ -211,9 +232,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
if categories.is_none()
&& let Some(f) = &s.categories
{
let c = category_texts(wb, sheet, f);
let (c, outer) = category_table(wb, sheet, f, &s.values);
if !c.is_empty() {
categories = Some(c);
category_levels = outer;
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
@@ -242,5 +264,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
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());
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, series, palette }
ChartData { categories, category_levels, series, palette }
}
+118 -13
View File
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
b.secondary = kind == ChartKind::Combo;
ChartData {
category_levels: vec![],
palette: vec![],
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
@@ -363,7 +364,8 @@ fn scatter_with_text_x_values_numbers_the_points() {
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x, None, "{kind:?}");
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
let empty =
render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
// Numeric X values are kept.
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
@@ -456,8 +458,12 @@ fn pie_starts_at_twelve_clockwise() {
#[test]
fn negative_values_put_axis_below_zero() {
let c = chart(ChartKind::ColumnClustered);
let d =
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)],
};
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let t = texts(&prims);
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
@@ -469,11 +475,13 @@ fn edge_cases_never_panic() {
let m = ApproxMeasure;
let empty = ChartData::default();
let zeros = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into()],
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
};
let weird = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![String::new(); 3],
series: vec![SeriesData {
@@ -505,7 +513,12 @@ fn thousands_of_points_are_decimated() {
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
let mut c = chart(ChartKind::Line);
c.data_labels = true;
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)],
};
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
let max_line = prims
.iter()
@@ -530,7 +543,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
@@ -547,7 +560,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
@@ -557,7 +570,7 @@ fn smooth_line_splits_at_gaps() {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
@@ -634,7 +647,12 @@ fn fuzz_random_data_and_sizes() {
})
.collect();
let ncat = (r.next() % 50) as usize;
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(),
series,
};
let mut c = chart(kind);
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
c.data_labels = r.next().is_multiple_of(2);
@@ -809,7 +827,8 @@ fn line_chart_draws_trendline_forecast_and_labels() {
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 { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let d =
ChartData { category_levels: vec![], palette: vec![], 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);
@@ -833,7 +852,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
assert!(lines_of(&pie, tc).is_empty());
// One point: nothing to fit, nothing drawn.
s.values.truncate(1);
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] };
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
}
@@ -853,7 +872,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
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 { palette: vec![], categories: vec![], series: vec![s] };
let d = ChartData { category_levels: vec![], palette: vec![], 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);
@@ -868,6 +887,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
#[test]
fn treemap_and_histogram_shapes() {
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
@@ -889,7 +909,12 @@ fn treemap_and_histogram_shapes() {
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)],
};
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
@@ -898,7 +923,12 @@ fn treemap_and_histogram_shapes() {
#[test]
fn histogram_boundary_values_match_interval_labels() {
let heights = |vals: &[f64]| -> Vec<f32> {
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec![],
series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)],
};
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
};
@@ -967,6 +997,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() {
#[test]
fn funnel_value_labels_follow_the_data_labels_flag() {
let data = ChartData {
category_levels: vec![],
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into()],
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
@@ -1057,3 +1088,77 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
}
}
#[test]
fn two_level_categories_resolve_and_draw() {
let years = ["2023", "", "", "2024", "", ""];
let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
let mut wb = Workbook::new();
{
let s = wb.sheet_mut(0).unwrap();
s.name = "Sales".into();
for (i, (y, m)) in years.iter().zip(months).enumerate() {
// Year beside month beside the values, points down the rows…
let r = i as u32 + 1;
if !y.is_empty() {
s.set_value(CellRef::new(r, 0), Value::from(*y));
s.set_value(CellRef::new(10, i as u32), Value::from(*y));
}
s.set_value(CellRef::new(r, 1), Value::from(m));
s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0)));
// …and the same table in rows, points along the columns.
s.set_value(CellRef::new(11, i as u32), Value::from(m));
s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0)));
}
}
let mut c = chart(ChartKind::Line);
c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }];
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> {
prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect()
};
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 600.0, 300.0);
let t = texts(&prims);
let at = |t: &[(String, f32, f32)], s: &str| {
t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}"))
};
// Six evenly spaced month labels across the plot, not twelve slots for a two-column range.
let xs: Vec<f32> = months.iter().map(|m| at(&t, m).0).collect();
let step = xs[1] - xs[0];
assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}");
// The years sit in a row below the months, each under its own group.
let (x23, y23) = at(&t, "2023");
let (x24, y24) = at(&t, "2024");
assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}");
assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}");
// Bars: the levels stand in a column left of the labels.
c.kind = ChartKind::BarClustered;
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
assert_sane(&prims, 600.0, 300.0);
let t = texts(&prims);
let (x23, y23) = at(&t, "2023");
let (x_oct, y_oct) = at(&t, "Oct");
// Categories run up the axis: the group's name sits left of its labels, between Oct and Dec.
assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}");
let (x24, y24) = at(&t, "2024");
assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}");
// A row of values: the levels are rows.
c.series[0].categories = Some("Sales!$A$11:$F$12".into());
c.series[0].values = "Sales!$A$13:$F$13".into();
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
// One column of categories: a plain axis, as before.
c.series[0].categories = Some("Sales!$B$2:$B$7".into());
c.series[0].values = "Sales!$C$2:$C$7".into();
let d = resolve(&wb, 0, &c);
assert_eq!(d.categories, months);
assert!(d.category_levels.is_empty());
}
+49
View File
@@ -293,3 +293,52 @@ mod tests {
assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3));
}
}
/// The shape of a chart's category range beside its values: a plain list, or a multi-level
/// axis (Excel: a category range two or more columns wide beside a column of values is a
/// two-level axis, year beside month; one two or more rows tall above a row of values likewise).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CategoryLevels {
/// The levels are columns and the points run down the rows (else the levels are rows and the
/// points run along the columns).
pub by_columns: bool,
/// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a
/// two-level axis. At most 3 are kept.
pub outer: u32,
}
/// How `categories` lays its labels out beside `values` (`None` when the values aren't a
/// reference: the categories' own shape decides). `None` for a plain list of labels.
pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option<CategoryLevels> {
if categories.is_full_rows() || categories.is_full_cols() {
return None;
}
let (w, h) = (categories.width(), categories.height());
let vertical = match values {
Some(v) => v.height() >= v.width(),
None => h >= w,
};
let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) };
(outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) })
}
#[cfg(test)]
mod category_level_tests {
use super::*;
#[test]
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
let r = |s: &str| RangeRef::parse(s).unwrap();
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
// A row of values: levels are rows.
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
// Literal values: the categories' own shape decides.
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
}
}
+1 -1
View File
@@ -8,6 +8,6 @@ pub mod date;
pub mod parse;
pub mod value;
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
pub use addr::{CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col};
pub use date::DateSystem;
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
+51 -5
View File
@@ -3,7 +3,7 @@
use std::fmt::Write as _;
use gridcraft_core::Value;
use gridcraft_core::{RangeRef, Value, category_levels};
use gridcraft_formula::{Expr, SheetSel};
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook};
@@ -281,8 +281,8 @@ fn group_for(k: ChartKind) -> G {
const MAX_CACHE: u64 = 100_000;
/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major.
pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<Vec<Value>> {
/// The sheet and range a same-workbook reference (`Sheet1!$B$2:$B$9`) names.
fn ref_target(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<(usize, RangeRef)> {
let e = gridcraft_formula::parse(f).ok()?;
let Expr::Ref(r) = e else { return None };
let si = match &r.sheet {
@@ -290,8 +290,13 @@ pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<Vec
SheetSel::Named(n) => wb.sheet_index(n)?,
SheetSel::Span(..) => return None,
};
Some((si, r.range()))
}
/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major.
pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<Vec<Value>> {
let (si, mut range) = ref_target(wb, sheet_idx, f)?;
let sheet = wb.sheet(si)?;
let mut range = r.range();
// Whole columns/rows: limit to the used range.
if let Some(u) = sheet.used_range() {
range = range.intersection(&u)?;
@@ -397,6 +402,47 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String {
s
}
/// `<c:cat>` of a series: a string reference, or a multi-level one (`multiLvlStrRef`) when the
/// category range has more columns than a column of values (or more rows than a row of values:
/// year beside month). Its cache lists the innermost level first, then each outer level with
/// only the cells that start a group, as Excel writes it.
fn cat_data(f: &str, values: &str, wb: &Workbook, si: usize) -> String {
multi_level_cat(f.trim(), values, wb, si).unwrap_or_else(|| str_data("cat", f, wb, si))
}
fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option<String> {
let (sheet, range) = ref_target(wb, si, f)?;
let value_range = ref_target(wb, si, values).map(|(_, r)| r);
let levels = category_levels(&range, value_range.as_ref())?;
if range.count() > MAX_CACHE {
return None;
}
let sheet = wb.sheet(sheet)?;
let (w, h) = (range.width() as usize, range.height() as usize);
let text = |row: usize, col: usize| -> String {
let v = range.start.offset(row as i64, col as i64).map(|c| sheet.value(c));
v.filter(|v| !v.is_empty()).map(|v| v.display()).unwrap_or_default()
};
// The innermost level (nearest the values), then the outer ones.
let (n, lvls): (usize, Vec<Vec<String>>) = if levels.by_columns {
(h, (0..=levels.outer as usize).filter_map(|k| (w - 1).checked_sub(k)).map(|c| (0..h).map(|r| text(r, c)).collect()).collect())
} else {
(w, (0..=levels.outer as usize).filter_map(|k| (h - 1).checked_sub(k)).map(|r| (0..w).map(|c| text(r, c)).collect()).collect())
};
let mut s = format!("<c:cat><c:multiLvlStrRef><c:f>{}</c:f><c:multiLvlStrCache><c:ptCount val=\"{n}\"/>", esc(&crate::fmla::text_to_file(f)));
for lvl in &lvls {
s.push_str("<c:lvl>");
for (i, v) in lvl.iter().enumerate() {
if !v.is_empty() {
let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", esc(v));
}
}
s.push_str("</c:lvl>");
}
s.push_str("</c:multiLvlStrCache></c:multiLvlStrRef></c:cat>");
Some(s)
}
fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String {
let mut x = format!("<c:ser><c:idx val=\"{idx}\"/><c:order val=\"{idx}\"/>");
if let Some(n) = &s.name {
@@ -453,7 +499,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
}
_ => {
if let Some(c) = &s.categories {
x.push_str(&str_data("cat", c, wb, si));
x.push_str(&cat_data(c, &s.values, wb, si));
}
x.push_str(&num_data("val", Some(&s.values), wb, si));
if matches!(g, G::Line { .. }) {
+1
View File
@@ -8,6 +8,7 @@ mod fixtures;
mod ink_roundtrip;
mod literal_category_roundtrip;
mod malformed;
mod multilevel_category_roundtrip;
mod ods_tests;
mod passthrough_tests;
mod pivot_tests;
@@ -0,0 +1,109 @@
//! A two-column category range (year beside month) is written as a multi-level string reference
//! with Excel's level caches, and read back as the same reference.
use std::io::Read as _;
use std::sync::Arc;
use gridcraft_core::{CellRef, Value};
use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Sheet, Workbook};
use crate::{read_xlsx, write_xlsx};
const YEARS: [&str; 6] = ["2023", "", "", "2024", "", ""];
const MONTHS: [&str; 6] = ["окт", "ноя", "дек", "янв", "фев", "мар"];
fn workbook(categories: &str, values: &str) -> Workbook {
let mut s = Sheet::new("Data");
for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() {
let r = i as u32 + 1;
if !y.is_empty() {
s.set_value(CellRef::new(r, 0), Value::from(*y));
}
s.set_value(CellRef::new(r, 1), Value::from(m));
s.set_value(CellRef::new(r, 2), Value::Number(i as f64 + 1.0));
}
s.charts.push(Chart {
id: 1,
kind: ChartKind::Line,
anchor: Anchor { cell: CellRef::new(0, 4), width: 480.0, height: 300.0, ..Anchor::default() },
title: None,
series: vec![Series {
name: None,
categories: Some(categories.into()),
values: values.into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}],
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 2,
x_title: None,
y_title: None,
source: None,
by_rows: false,
pivot: None,
});
Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() }
}
fn chart_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();
let mut s = String::new();
z.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn cat(xml: &str) -> &str {
let a = xml.find("<c:cat>").expect("a <c:cat>");
let b = xml.find("</c:cat>").expect("a </c:cat>");
&xml[a..b]
}
#[test]
fn two_column_categories_are_written_as_levels_and_read_back() {
let bytes = write_xlsx(&workbook("Data!$A$2:$B$7", "Data!$C$2:$C$7")).unwrap();
let xml = chart_xml(&bytes);
let c = cat(&xml);
assert!(c.starts_with("<c:cat><c:multiLvlStrRef><c:f>Data!$A$2:$B$7</c:f><c:multiLvlStrCache><c:ptCount val=\"6\"/><c:lvl>"), "{c}");
let lvls: Vec<&str> = c.split("<c:lvl>").skip(1).collect();
assert_eq!(lvls.len(), 2, "{c}");
// The innermost level first, every month; then the years, only where a group starts.
assert!(lvls[0].contains("<c:pt idx=\"0\"><c:v>окт</c:v></c:pt>") && lvls[0].contains("<c:pt idx=\"5\"><c:v>мар</c:v></c:pt>"), "{c}");
assert!(lvls[1].starts_with("<c:pt idx=\"0\"><c:v>2023</c:v></c:pt><c:pt idx=\"3\"><c:v>2024</c:v></c:pt></c:lvl>"), "{c}");
assert!(!c.contains("<c:strRef>"));
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheets[0].charts[0].series[0].categories.as_deref(), Some("Data!$A$2:$B$7"));
assert_eq!(cat(&chart_xml(&write_xlsx(&back).unwrap())), c, "stable across saves");
}
#[test]
fn a_row_of_values_makes_the_levels_rows() {
let mut wb = workbook("Data!$A$9:$F$10", "Data!$A$11:$F$11");
let s = Arc::make_mut(&mut wb.sheets[0]);
for (i, (y, m)) in YEARS.iter().zip(MONTHS).enumerate() {
if !y.is_empty() {
s.set_value(CellRef::new(8, i as u32), Value::from(*y));
}
s.set_value(CellRef::new(9, i as u32), Value::from(m));
s.set_value(CellRef::new(10, i as u32), Value::Number(i as f64));
}
let xml = chart_xml(&write_xlsx(&wb).unwrap());
let c = cat(&xml);
assert!(c.contains("<c:multiLvlStrRef><c:f>Data!$A$9:$F$10</c:f><c:multiLvlStrCache><c:ptCount val=\"6\"/>"), "{c}");
let lvls: Vec<&str> = c.split("<c:lvl>").skip(1).collect();
assert_eq!(lvls.len(), 2);
assert!(lvls[0].contains("<c:v>окт</c:v>") && lvls[1].contains("<c:pt idx=\"3\"><c:v>2024</c:v></c:pt>"), "{c}");
}
#[test]
fn a_single_column_of_categories_stays_a_plain_reference() {
let xml = chart_xml(&write_xlsx(&workbook("Data!$B$2:$B$7", "Data!$C$2:$C$7")).unwrap());
assert!(xml.contains("<c:cat><c:strRef><c:f>Data!$B$2:$B$7</c:f>"), "{xml}");
assert!(!xml.contains("multiLvlStrRef"));
}
+4 -2
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (multi-level category axes, year beside month, are read, drawn and written back; previously trivial: chart sheets are kept on save, still not editable (#727); previously 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).
@@ -41,6 +41,7 @@ open PRs #158, #161, and issue #190.
| Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag |
| 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) |
| Multi-level category axis (a category range two or more columns wide beside the values: year beside month) | the outer levels grouped under the labels with separators | read (`multiLvlStrRef`), drawn (rows under the labels; columns beside them in bar charts; up to three outer levels), written back with Excel's level caches |
| Secondary axis | any series | line series in combo charts |
| 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** |
@@ -74,12 +75,13 @@ back.
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.
7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches |
| 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) |
| 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`) |
+2 -1
View File
@@ -46,7 +46,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap,
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), Switch Row/Column (#116) and trendlines (read, written, drawn) 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. Multi-level category axes (year beside month) are read, drawn and written back.
- **Impact:** charts are in most business workbooks.
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
@@ -168,6 +168,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | §4: multi-level category axes landed |
| 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) |
| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder |
| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) |