From 209d2447d943372c0142146b9cebc475bc98953b Mon Sep 17 00:00:00 2001 From: Lexx143 Date: Tue, 13 Oct 2026 17:27:49 +0300 Subject: [PATCH] Charts: multi-level category axes, read, drawn and written back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- ROADMAP.md | 2 + crates/chart/src/lib.rs | 5 + crates/chart/src/render.rs | 80 +++++++++-- crates/chart/src/resolve.rs | 36 ++++- crates/chart/src/tests.rs | 131 ++++++++++++++++-- crates/core/src/addr.rs | 49 +++++++ crates/core/src/lib.rs | 2 +- crates/xlsx/src/chart.rs | 56 +++++++- crates/xlsx/src/tests/mod.rs | 1 + .../tests/multilevel_category_roundtrip.rs | 109 +++++++++++++++ docs/chart-parity.md | 6 +- docs/gaps.md | 3 +- 12 files changed, 439 insertions(+), 41 deletions(-) create mode 100644 crates/xlsx/src/tests/multilevel_category_roundtrip.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..9fe4875e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 697e2a7a7..4b68b8d17 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -123,6 +123,11 @@ impl Measure for ApproxMeasure { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ChartData { pub categories: Vec, + /// 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>, pub series: Vec, /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme. #[serde(default)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 037b9099e..3ae40d3bc 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -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]), 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 = (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 = match cat { - CatAxis::Labels(l) => l.to_vec(), - CatAxis::Numeric(..) => vec![], + let (labels, outer): (Vec, &[Vec]) = 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 = + 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 = (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> = + 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; } diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index c007e3e90..c7a8f0b9c 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -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 { +/// 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, Vec>) { 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 = 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 { } 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::>(); + let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::>(); + 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> = 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 } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 01f1aac49..52c7e19d4 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -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 = (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 = (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 { - 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 = 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()); +} diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index 2bdfe797a..5302527b2 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -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 { + 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"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..9e04e8b27 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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}; diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index dee33c83d..182345799 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -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> { +/// 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 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> { + 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 } +/// `` 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 { + 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>) = 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!("{}", esc(&crate::fmla::text_to_file(f))); + for lvl in &lvls { + s.push_str(""); + for (i, v) in lvl.iter().enumerate() { + if !v.is_empty() { + let _ = write!(s, "{}", esc(v)); + } + } + s.push_str(""); + } + s.push_str(""); + Some(s) +} + fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); 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 { .. }) { diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..d4234a832 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -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; diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs new file mode 100644 index 000000000..556a8917a --- /dev/null +++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs @@ -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("").expect("a "); + let b = xml.find("").expect("a "); + &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("Data!$A$2:$B$7"), "{c}"); + let lvls: Vec<&str> = c.split("").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("окт") && lvls[0].contains("мар"), "{c}"); + assert!(lvls[1].starts_with("20232024"), "{c}"); + assert!(!c.contains("")); + + 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("Data!$A$9:$F$10"), "{c}"); + let lvls: Vec<&str> = c.split("").skip(1).collect(); + assert_eq!(lvls.len(), 2); + assert!(lvls[0].contains("окт") && lvls[1].contains("2024"), "{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("Data!$B$2:$B$7"), "{xml}"); + assert!(!xml.contains("multiLvlStrRef")); +} diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 9058b0935..40f6787a9 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-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`) | diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c5..65287115a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |