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,
     }
 }
 
