From 6a7cf9cf667956b486a719977106ff1f9152a7c4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:31:27 +0100 Subject: [PATCH] Chart: bound the category axis by the point cap, not the raw cell count Read only the cells the category levels use and cut the category dimension at the per-series cap, so a multi-level axis keeps all its levels. Co-Authored-By: Claude Sonnet 5.5 --- crates/chart/src/resolve.rs | 36 +++++++++++++++++++++---- crates/chart/src/tests/budget.rs | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 1ee635a14..bd6849435 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -189,8 +189,29 @@ fn value_text(v: &Value) -> String { /// 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, limit: usize) -> (Vec, Vec>) { - let vals = eval_list_in(wb, sheet, formula, limit, 0); let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula); + let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r); + // Bound the category dimension by `limit` and read only the cells the levels use (the + // innermost label line and up to three outer ones), so that every kept category keeps its + // cells in every level. Counting raw cells would cut a two-level axis in the middle. + let levels = cells.and_then(|(_, r)| category_levels(&r, value_range.as_ref())); + let (cells, read) = match (cells, levels) { + (Some((sh, r)), Some(lv)) => { + let cut = u32::try_from(limit.saturating_sub(1)).unwrap_or(u32::MAX); + let outer = lv.outer; + let end = if lv.by_columns { CellRef::new(r.end.row.min(r.start.row.saturating_add(cut)), r.end.col) } else { CellRef::new(r.end.row, r.end.col.min(r.start.col.saturating_add(cut))) }; + let start = if lv.by_columns { CellRef::new(r.start.row, r.start.col.max(end.col.saturating_sub(outer))) } else { CellRef::new(r.start.row.max(end.row.saturating_sub(outer)), r.start.col) }; + let r = RangeRef::new(start, end); + let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or(""); + let cap = (r.width() as usize).saturating_mul(r.height() as usize); + (Some((sh, r)), Some((format!("{prefix}{r}"), cap))) + } + (c, _) => (c, None), + }; + let vals = match &read { + Some((f, cap)) => eval_list_in(wb, sheet, f, *cap, 0), + None => eval_list_in(wb, sheet, formula, limit, 0), + }; let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); let texts: Vec = vals .iter() @@ -210,8 +231,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limi }) .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 Some(levels) = levels 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 @@ -252,7 +272,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String { t.to_string() } -fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { +pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { match (chart_kind, series_kind) { (ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k, (ChartKind::Combo, _) => { @@ -267,9 +287,15 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option, i: usiz } } +/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the +/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing. +pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option, i: usize) -> ChartKind { + if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind } +} + /// 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 } + plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } /// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X diff --git a/crates/chart/src/tests/budget.rs b/crates/chart/src/tests/budget.rs index 6c5a10879..fad349ba6 100644 --- a/crates/chart/src/tests/budget.rs +++ b/crates/chart/src/tests/budget.rs @@ -232,3 +232,48 @@ fn resolve_keeps_every_series_within_the_point_budget() { let counts: Vec = resolve(&wb, 0, &c).series.iter().map(|s| s.trendlines.len()).collect(); assert_eq!(counts, vec![9, 7, 0, 0]); } + +/// 100 series lower the per-series cap to 20,000, below the number of category cells of a +/// two-level axis (#1155). Each kept category keeps its inner label and its outer level. +fn two_level_chart(n: u32, vertical: bool) -> ChartData { + let at = |i: u32, level: u32| if vertical { CellRef::new(i, level) } else { CellRef::new(level, i) }; + let mut wb = Workbook::new(); + if let Some(s) = wb.sheet_mut(0) { + for i in 0..n { + s.set_value(at(i, 0), Value::Text(format!("Y{}", i / 3).into())); + s.set_value(at(i, 1), Value::Text(format!("C{i}").into())); + s.set_value(at(i, 2), Value::Number(f64::from(i))); + } + } + let cats = format!("{}:{}", at(0, 0), at(n - 1, 1)); + let values = format!("{}:{}", at(0, 2), at(n - 1, 2)); + let mut c = chart(ChartKind::ColumnClustered); + c.series = (0..100).map(|_| Series { values: values.clone(), categories: Some(cats.clone()), ..Series::default() }).collect(); + resolve(&wb, 0, &c) +} + +#[test] +fn multi_level_categories_keep_their_levels_under_the_point_cap() { + for vertical in [true, false] { + let d = two_level_chart(15_000, vertical); + assert_eq!(d.series[0].values.len(), 15_000, "vertical {vertical}"); + assert_eq!(d.categories.len(), 15_000, "vertical {vertical}"); + assert_eq!(d.categories[0], "C0"); + assert_eq!(d.categories[14_999], "C14999"); + assert_eq!(d.category_levels.len(), 1, "one outer level, vertical {vertical}"); + assert_eq!(d.category_levels[0].len(), 15_000); + assert_eq!(d.category_levels[0][3], "Y1"); + } +} + +#[test] +fn multi_level_categories_longer_than_the_cap_are_cut_together_with_their_levels() { + // 25,000 categories against a cap of 20,000. + let d = two_level_chart(25_000, true); + let cap = points_per_series(100); + assert_eq!(d.categories.len(), cap); + assert_eq!(d.category_levels.len(), 1); + assert_eq!(d.category_levels[0].len(), cap); + assert_eq!(d.categories[cap - 1], format!("C{}", cap - 1)); + assert_eq!(d.category_levels[0][cap - 3], format!("Y{}", (cap - 3) / 3)); +}