collective

Browse / github.com/storytold/gridcraft / patch

Charts plot visible cells only; Show data in hidden rows and columns round-trips (#2523)

Charts plot visible cells only; Show data in hidden rows and columns round-trips (#2523) Charts now plot visible cells only, as Excel does by default, and the "Show data in hidden rows and columns" option is read from files and written back (#2523). ## What changed - `Chart` has a `show_hidden_data` field (default off, which is Excel's `plotVisOnly` on). It is the only model change, so it should merge cleanly with the open chart-format PRs. - `chart::resolve` leaves out cells in hidden rows (hand-hidden or filtered) and hidden columns for values, categories, scatter X values and bubble sizes. A series whose cells are all hidden leaves the chart. Multi-level category labels keep t

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2671, open)
Base
11bb67386c9bb19b0717e21e98adc608162ae2e5
Size
+201 −31 in 20 files
Review
pending
Shared
2026-10-28
Id
153bcde085bcdd0e907cf471

How it went

No other operator has reported using it yet.

Diff

raw · crates/chart/src/resolve.rs crates/chart/src/tests.rs crates/engine/src/cmd/extra.rs crates/engine/src/cmd/extra/chart.rs crates/engine/src/cmd/forecast.rs crates/engine/src/cmd/insert/chart.rs crates/engine/src/cmd/pivot/charts.rs crates/engine/src/tests.rs crates/model/src/features.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/charts.rs crates/xlsx/src/chart.rs crates/xlsx/src/chartex.rs crates/xlsx/src/tests/chart_book_names.rs crates/xlsx/src/tests/fixtures.rs crates/xlsx/src/tests/literal_category_roundtrip.rs crates/xlsx/src/tests/multilevel_category_roundtrip.rs crates/xlsx/src/tests/roundtrip.rs crates/xlsx/src/tests/smooth_roundtrip.rs crates/xlsx/src/tests/trendline_roundtrip.rs

diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs--- a/crates/chart/src/resolve.rs+++ b/crates/chart/src/resolve.rs@@ -77,10 +77,22 @@ fn union_parts(f: &str) -> Option<Vec<&str>> { /// Evaluates a series formula to a flat list of values (row-major), capped. A union of /// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part. fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {-    eval_list_in(wb, sheet, formula, 0)+    eval_list_in(wb, sheet, formula, 0, false) } -fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {+/// Whether the cell at flat (row-major) position `i` of `r` on `sh` is plotted: its row and+/// column are not hidden (by hand or by a filter).+fn is_visible(wb: &Workbook, sh: usize, r: &RangeRef, i: usize) -> bool {+    let w = r.width().max(1) as usize;+    let Some(sheet) = wb.sheet(sh) else { return true };+    let at = r.start.offset((i / w) as i64, (i % w) as i64);+    at.is_none_or(|c| !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col))+}++/// `eval_list`, leaving out the cells of hidden rows and columns when `hide` is set (Excel's+/// "Show data in hidden rows and columns" off). Only plain references are filtered: arrays,+/// names and other formulas have no cells to hide.+fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32, hide: bool) -> Vec<Value> {     let f = formula.trim();     if f.is_empty() {         return vec![];@@ -94,13 +106,23 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<V                 break;             }             let rest = MAX_POINTS - out.len();-            out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));+            out.extend(eval_list_in(wb, sheet, p, depth + 1, hide).into_iter().take(rest));         }         return out;     }+    let cells = if hide { parse_ref(wb, sheet, f) } else { None };     match evaluate(wb, sheet, CellRef::default(), f) {-        Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),-        v => vec![v],+        Value::Array(a) => match cells {+            Some((sh, r)) if a.data.len() == (r.width() as usize).saturating_mul(r.height() as usize) => {+                a.iter().enumerate().filter(|(i, _)| is_visible(wb, sh, &r, *i)).map(|(_, v)| v.clone()).take(MAX_POINTS).collect()+            }+            _ => a.iter().take(MAX_POINTS).cloned().collect(),+        },+        // A single cell: hidden, it is not plotted.+        v => match cells {+            Some((sh, r)) if !is_visible(wb, sh, &r, 0) => vec![],+            _ => vec![v],+        },     } } @@ -111,8 +133,8 @@ fn to_num(v: &Value) -> Option<f64> {     } } -fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {-    eval_list(wb, sheet, formula).iter().map(to_num).collect()+fn nums(wb: &Workbook, sheet: usize, formula: &str, hide: bool) -> Vec<Option<f64>> {+    eval_list_in(wb, sheet, formula, 0, hide).iter().map(to_num).collect() }  /// Number format code of the first cell of a reference ("General" when not a reference).@@ -137,9 +159,11 @@ fn value_text(v: &Value) -> String { /// 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);+fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, hide: bool) -> (Vec<String>, Vec<Vec<String>>) {     let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);+    // A plain range is read whole and its hidden rows and columns dropped at the end, so the+    // number formats and multi-level groups still line up with the cells.+    let vals = eval_list_in(wb, sheet, formula, 0, hide && cells.is_none());     let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));     let texts: Vec<String> = vals         .iter()@@ -158,22 +182,32 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (             value_text(v)         })         .collect();-    let Some((_, r)) = src else { return (texts, vec![]) };+    let Some((sheet_ref, r)) = src else { return (texts, vec![]) };+    let visible_row = |row: usize| !(hide && sheet_ref.is_row_hidden(r.start.row.saturating_add(row as u32)));+    let visible_col = |col: usize| !(hide && sheet_ref.is_col_hidden(r.start.col.saturating_add(col as u32)));+    let w = r.width().max(1) as usize;+    let flat = |texts: Vec<String>| -> Vec<String> {+        texts.into_iter().enumerate().filter(|(i, _)| visible_row(i / w) && visible_col(i % w)).map(|(_, t)| t).collect()+    };     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);+    let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (flat(texts), vec![]) };+    let h = 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+        return (flat(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 column = |c: usize| (0..h).filter(|&row| visible_row(row)).map(|row| at(row, c)).collect::<Vec<String>>();+    let row = |rr: usize| (0..w).filter(|&c| visible_col(c)).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())+    // The innermost level is the last visible column (row), the outer levels the ones before it.+    let (lines, list): (Vec<usize>, &dyn Fn(usize) -> Vec<String>) = if levels.by_columns {+        ((0..w).filter(|&c| visible_col(c)).collect(), &column)     } else {-        (row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())-    }+        ((0..h).filter(|&r| visible_row(r)).collect(), &row)+    };+    let Some(&inner) = lines.last() else { return (vec![], vec![]) };+    let outers = (1..=outer).filter_map(|k| lines.len().checked_sub(k + 1)).filter_map(|i| lines.get(i)).map(|&l| list(l)).collect();+    (list(inner), outers) }  /// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on@@ -228,27 +262,34 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {     let mut series = Vec::new();     let mut categories: Option<Vec<String>> = None;     let mut category_levels = Vec::new();+    let hide = !chart.show_hidden_data;     for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {-        let values = nums(wb, sheet, &s.values);+        let values = nums(wb, sheet, &s.values, hide);+        // A series whose cells are all hidden (its column hidden) leaves the chart, legend included.+        if hide && values.is_empty() && !nums(wb, sheet, &s.values, false).is_empty() {+            continue;+        }         let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));         let color =             s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));         let kind = effective_kind(chart.kind, s.kind, i);         let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {             // X values with text in them (labels) are ignored: the points are numbered 1..n.-            (Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),+            (Some(f), true) => {+                Some(eval_list_in(wb, sheet, f, 0, hide)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())+            }             _ => None,         };         if categories.is_none()             && let Some(f) = &s.categories         {-            let (c, outer) = category_table(wb, sheet, f, &s.values);+            let (c, outer) = category_table(wb, sheet, f, &s.values, hide);             if !c.is_empty() {                 categories = Some(c);                 category_levels = outer;             }         }-        let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));+        let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, hide));         let trendlines = s             .trendlines             .iter()diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs--- a/crates/chart/src/tests.rs+++ b/crates/chart/src/tests.rs@@ -50,6 +50,7 @@ fn chart(kind: ChartKind) -> Chart {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     } } @@ -955,6 +956,64 @@ fn histogram_boundary_values_match_interval_labels() {     assert!((h[0] / h[1] - 3.0).abs() < 0.01 && (h[1] - h[2]).abs() < 0.01, "{h:?}"); } +#[test]+fn hidden_rows_and_columns_are_left_out_unless_shown() {+    use gridcraft_model::LineInfo;+    let mut wb = Workbook::new();+    {+        let s = wb.sheet_mut(0).unwrap();+        for r in 0..4u32 {+            s.set_value(CellRef::new(r, 0), Value::from(["a", "b", "c", "d"][r as usize]));+            s.set_value(CellRef::new(r, 1), Value::Number(f64::from(r) + 1.0));+            s.set_value(CellRef::new(r, 2), Value::Number(f64::from(r) * 10.0));+        }+        s.rows.insert(1, LineInfo { hidden: true, ..Default::default() });+    }+    let mut c = chart(ChartKind::ColumnClustered);+    c.series = vec![+        Series { categories: Some("Sheet1!$A$1:$A$4".into()), values: "Sheet1!$B$1:$B$4".into(), ..Series::default() },+        Series { categories: Some("Sheet1!$A$1:$A$4".into()), values: "Sheet1!$C$1:$C$4".into(), ..Series::default() },+    ];+    let d = resolve(&wb, 0, &c);+    assert_eq!(d.series[0].values, vec![Some(1.0), Some(3.0), Some(4.0)]);+    assert_eq!(d.categories, vec!["a", "c", "d"]);+    // A hidden column removes its series, legend entry included.+    wb.sheet_mut(0).unwrap().cols.insert(2, LineInfo { hidden: true, ..Default::default() });+    assert_eq!(resolve(&wb, 0, &c).series.len(), 1);+    c.show_hidden_data = true;+    let d = resolve(&wb, 0, &c);+    assert_eq!(d.series.len(), 2);+    assert_eq!(d.series[0].values.len(), 4);+    assert_eq!(d.categories.len(), 4);+}++#[test]+fn hidden_cells_leave_multi_level_categories() {+    use gridcraft_model::LineInfo;+    let mut wb = Workbook::new();+    {+        let s = wb.sheet_mut(0).unwrap();+        for (r, (y, m)) in [("2023", "jan"), ("", "feb"), ("2024", "jan"), ("", "feb")].iter().enumerate() {+            if !y.is_empty() {+                s.set_value(CellRef::new(r as u32, 0), Value::from(*y));+            }+            s.set_value(CellRef::new(r as u32, 1), Value::from(*m));+            s.set_value(CellRef::new(r as u32, 2), Value::Number(r as f64));+        }+        s.rows.insert(1, LineInfo { hidden: true, ..Default::default() });+    }+    let mut c = chart(ChartKind::Line);+    c.series = vec![Series { categories: Some("Sheet1!$A$1:$B$4".into()), values: "Sheet1!$C$1:$C$4".into(), ..Series::default() }];+    let d = resolve(&wb, 0, &c);+    assert_eq!(d.categories, vec!["jan", "jan", "feb"]);+    assert_eq!(d.category_levels, vec![vec!["2023".to_string(), "2024".into(), String::new()]]);+    // The innermost level is the last visible column.+    wb.sheet_mut(0).unwrap().cols.insert(1, LineInfo { hidden: true, ..Default::default() });+    let d = resolve(&wb, 0, &c);+    assert_eq!(d.categories, vec!["2023", "2024", ""]);+    assert!(d.category_levels.is_empty());+}+ #[test] fn union_series_references() {     let mut wb = Workbook::new();diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs--- a/crates/engine/src/cmd/extra.rs+++ b/crates/engine/src/cmd/extra.rs@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Select Data…",             ["Chart Design", "Data"],             None,-            "{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}",+            "{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…], showHiddenData?: bool (Hidden and Empty Cells ▸ Show data in hidden rows and columns; default off: visible cells only)}",             has_doc,             select_data         ),diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs--- a/crates/engine/src/cmd/extra/chart.rs+++ b/crates/engine/src/cmd/extra/chart.rs@@ -205,8 +205,9 @@ pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {         .unwrap_or_default();     remove.sort_unstable();     remove.dedup();-    if rebuilt.is_none() && categories.is_none() && remove.is_empty() {-        return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));+    let show_hidden = bool_param(p, "showHiddenData");+    if rebuilt.is_none() && categories.is_none() && remove.is_empty() && show_hidden.is_none() {+        return Err(bad("chart.selectData", "missing `range`, `categories`, `removeSeries` or `showHiddenData`"));     }     with_chart(s, p, move |c| {         if let Some((series, source, by_rows)) = rebuilt {@@ -220,6 +221,9 @@ pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {                 c.series.remove(*i);             }         }+        if let Some(v) = show_hidden {+            c.show_hidden_data = v;+        }         if let Some(cats) = categories {             for se in &mut c.series {                 se.categories = cats.clone();diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs--- a/crates/engine/src/cmd/forecast.rs+++ b/crates/engine/src/cmd/forecast.rs@@ -431,6 +431,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {             source: None,             by_rows: false,             pivot: None,+            show_hidden_data: false,         });         cx.wb.sheets.insert(at, Arc::new(sheet));         for nm in cx.wb.names.iter_mut() {diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs--- a/crates/engine/src/cmd/insert/chart.rs+++ b/crates/engine/src/cmd/insert/chart.rs@@ -264,6 +264,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {         source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),         by_rows,         pivot: None,+        show_hidden_data: false,     };     Ok((sheet, chart)) }diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs--- a/crates/engine/src/cmd/pivot/charts.rs+++ b/crates/engine/src/cmd/pivot/charts.rs@@ -69,6 +69,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {         source: None,         by_rows: false,         pivot: Some(link),+        show_hidden_data: false,     };     edit(s, |cx| {         cx.sheet_mut(si)?.charts.push(chart);diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -2974,6 +2974,44 @@ fn chart_data(s: &mut Session) {     .unwrap(); } +/// The values and category labels a chart on the active sheet plots.+fn plotted(s: &Session) -> (Vec<Option<f64>>, Vec<String>) {+    let d = s.doc().unwrap();+    let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &d.wb.active().unwrap().charts[0]);+    (data.series[0].values.clone(), data.categories)+}++/// Excel plots visible cells only: filtered and hand-hidden rows leave the chart, and "Show data+/// in hidden rows and columns" (`c:plotVisOnly` off) brings them back, through a file round trip too.+#[test]+fn chart_plots_visible_cells_only() {+    let mut s = s();+    s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["a", 1], ["b", 2], ["c", 3], ["d", 4], ["e", 5]]})).unwrap();+    s.execute("insert.chart", json!({"type": "column", "range": "A1:B6", "at": "D2"})).unwrap();+    let all = |v: &[f64]| v.iter().map(|n| Some(*n)).collect::<Vec<_>>();+    assert_eq!(plotted(&s).0, all(&[1.0, 2.0, 3.0, 4.0, 5.0]));+    // Rows hidden by a filter.+    s.execute("selection.set", json!({"cell": "B2"})).unwrap();+    s.execute("data.filter", json!({})).unwrap();+    s.execute("data.filterBy", json!({"column": "B", "custom": {"op": ">", "value": "2"}})).unwrap();+    assert_eq!(plotted(&s), (all(&[3.0, 4.0, 5.0]), vec!["c".to_string(), "d".into(), "e".into()]));+    // The option brings them back, and survives the file.+    let id = s.doc().unwrap().wb.active().unwrap().charts[0].id;+    s.execute("chart.selectData", json!({"chart": id, "showHiddenData": true})).unwrap();+    assert_eq!(plotted(&s).0, all(&[1.0, 2.0, 3.0, 4.0, 5.0]));+    reopen(&mut s);+    assert!(s.doc().unwrap().wb.active().unwrap().charts[0].show_hidden_data);+    assert_eq!(plotted(&s).0.len(), 5);+    s.execute("chart.selectData", json!({"chart": id, "showHiddenData": false})).unwrap();+    reopen(&mut s);+    assert!(!s.doc().unwrap().wb.active().unwrap().charts[0].show_hidden_data);+    assert_eq!(plotted(&s).0, all(&[3.0, 4.0, 5.0]));+    // A row hidden by hand, with the filter cleared.+    s.execute("data.filter", json!({})).unwrap();+    s.execute("home.hideRows", json!({"rows": "4:4"})).unwrap();+    assert_eq!(plotted(&s).1, ["a", "b", "d", "e"]);+}+ #[test] fn every_chart_kind_survives_xlsx() {     use gridcraft_model::ChartKind;diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs--- a/crates/model/src/features.rs+++ b/crates/model/src/features.rs@@ -560,6 +560,11 @@ pub struct Chart {     /// whenever the PivotTable is refreshed or its layout changes.     #[serde(default)]     pub pivot: Option<String>,+    /// Select Data ▸ Hidden and Empty Cells ▸ Show data in hidden rows and columns (`c:plotVisOnly`+    /// off). Off (the default, as in Excel) plots visible cells only: rows hidden by hand or by a+    /// filter, and hidden columns, leave the chart.+    #[serde(default)]+    pub show_hidden_data: bool, }  #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -929,12 +929,13 @@ impl Dialog {                     let si = doc.wb.active_sheet();                     let sh = doc.wb.sheet(si)?;                     let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?;-                    let data = gridcraft_chart::resolve(&doc.wb, si, ch);+                    // Hidden series included, so the names line up with `Chart.series` (`removeSeries` indices).+                    let data = gridcraft_chart::resolve(&doc.wb, si, &gridcraft_engine::model::Chart { show_hidden_data: true, ..ch.clone() });                     let names: Vec<String> = data.series.iter().map(|x| x.name.clone()).collect();                     let cats = ch.series.first().and_then(|x| x.categories.clone()).unwrap_or_default();-                    Some((ch.source.clone().unwrap_or_default(), cats, names))+                    Some((ch.source.clone().unwrap_or_default(), cats, names, ch.show_hidden_data))                 });-                let Some((range, cats, names)) = found else {+                let Some((range, cats, names, hidden)) = found else {                     app.message = Some(("Select Data".into(), "Select a chart first.".into()));                     return None;                 };@@ -942,7 +943,7 @@ impl Dialog {                 Dialog::custom(                     "selectData",                     "Select Data Source",-                    json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}),+                    json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": [], "hidden": hidden, "hidden0": hidden}),                 )             }             "journal" => Dialog::custom("journal", "Action Journal", json!({})),diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs--- a/crates/ui-egui/src/dialogs/charts.rs+++ b/crates/ui-egui/src/dialogs/charts.rs@@ -160,6 +160,12 @@ pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog,     d.values.insert("remove".into(), json!(remove));     ui.add_space(6.0);     edit(ui, d, "categories", "Horizontal (Category) Axis Labels:");+    ui.add_space(6.0);+    let flag = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false);+    let mut hidden = flag(d, "hidden");+    if ui.checkbox(&mut hidden, "Show data in hidden rows and columns").changed() {+        d.values.insert("hidden".into(), json!(hidden));+    }     ok_cancel(ui, confirm, open);     if *confirm {         *confirm = false;@@ -175,6 +181,9 @@ pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog,         if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) {             params.insert("categories".into(), json!(text(d, "categories")));         }+        if flag(d, "hidden") != flag(d, "hidden0") {+            params.insert("showHiddenData".into(), json!(flag(d, "hidden")));+        }         if params.len() == 1 {             *open = false;             return;diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs--- a/crates/xlsx/src/chart.rs+++ b/crates/xlsx/src/chart.rs@@ -275,6 +275,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {         source: None,         by_rows: false,         pivot: root.path(&["pivotSource", "name"]).map(|n| strip_book(n.text.trim()).to_string()).filter(|n| !n.is_empty()),+        show_hidden_data: chart.child("plotVisOnly").is_some_and(|e| !e.flag("val", true)),     }) } @@ -877,6 +878,6 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> Str     if let Some(p) = pos {         let _ = write!(s, "<c:legend><c:legendPos val=\"{p}\"/><c:overlay val=\"0\"/></c:legend>");     }-    s.push_str("<c:plotVisOnly val=\"1\"/><c:dispBlanksAs val=\"gap\"/></c:chart></c:chartSpace>");+    let _ = write!(s, "<c:plotVisOnly val=\"{}\"/><c:dispBlanksAs val=\"gap\"/></c:chart></c:chartSpace>", u8::from(!ch.show_hidden_data));     s }diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs--- a/crates/xlsx/src/chartex.rs+++ b/crates/xlsx/src/chartex.rs@@ -137,6 +137,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     }) } diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs--- a/crates/xlsx/src/tests/chart_book_names.rs+++ b/crates/xlsx/src/tests/chart_book_names.rs@@ -82,6 +82,7 @@ fn chart_names_are_written_qualified() {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     wb.sheets = vec![Arc::new(s), Arc::new(Sheet::new("My Data"))];     wb.names = vec![diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs--- a/crates/xlsx/src/tests/fixtures.rs+++ b/crates/xlsx/src/tests/fixtures.rs@@ -543,6 +543,7 @@ fn chartex_parts_as_excel_expects_them() {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     wb.sheets = vec![std::sync::Arc::new(sheet)];     let bytes = crate::write_xlsx(&wb).unwrap();diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs--- a/crates/xlsx/src/tests/literal_category_roundtrip.rs+++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs@@ -48,6 +48,7 @@ fn literal_category_with_separators_survives_save() {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     wb.sheets = vec![Arc::new(s)];     let bytes = write_xlsx(&wb).unwrap();diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs--- a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs+++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs@@ -48,6 +48,7 @@ fn workbook(categories: &str, values: &str) -> Workbook {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() } }diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs--- a/crates/xlsx/src/tests/roundtrip.rs+++ b/crates/xlsx/src/tests/roundtrip.rs@@ -414,6 +414,7 @@ fn sample() -> Workbook {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     };     for kind in [         ChartKind::ColumnClustered,diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs--- a/crates/xlsx/src/tests/smooth_roundtrip.rs+++ b/crates/xlsx/src/tests/smooth_roundtrip.rs@@ -36,6 +36,7 @@ fn smooth_line_setting_round_trips() {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     let mut smooth_series = base_series.clone();     smooth_series.smooth = true;@@ -54,6 +55,7 @@ fn smooth_line_setting_round_trips() {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     });     wb.sheets = vec![Arc::new(s)];     let bytes = write_xlsx(&wb).unwrap();diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs--- a/crates/xlsx/src/tests/trendline_roundtrip.rs+++ b/crates/xlsx/src/tests/trendline_roundtrip.rs@@ -60,6 +60,7 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {         source: None,         by_rows: false,         pivot: None,+        show_hidden_data: false,     } }