From 88df1670ba88c2a7bc49944ed9ea5f3a18459014 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:36:29 +0200 Subject: [PATCH] Scatter charts of two unlabelled columns plot the first as X for one series (#428) --- crates/engine/src/cmd/extra.rs | 1 + crates/engine/src/cmd/insert.rs | 15 ++++++++++++++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 9f2565ac4..9249763f6 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -421,6 +421,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result { with_chart(s, p, move |c| { if let Some((series, source, by_rows)) = rebuilt { c.series = series; + super::insert::scatter_x_from_first(c.kind, &mut c.series); c.source = Some(source); c.by_rows = by_rows; } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 16859c3dc..4e044efcb 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -573,6 +573,17 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec out } +/// A scatter plot of a block without category labels takes its first column (or row) as the X +/// values of the others, as Excel does: two plain columns make one series of points, not two (#428). +pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec) { + if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() { + let x = series.remove(0).values; + for se in series.iter_mut() { + se.categories = Some(x.clone()); + } + } +} + /// Most cells a chart's source block may span (bounds the series it builds). const MAX_CHART_CELLS: u64 = 1_000_000; @@ -661,10 +672,11 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { let data_w = r.width(); let by_rows = data_w > data_h + 1; let kind = chart_kind(&t, &sub); - let series = series_from_range(sh, r, by_rows); + let mut series = series_from_range(sh, r, by_rows); if series.is_empty() { return Err(EngineError::Other("Select data for the chart first.".into())); } + scatter_x_from_first(kind, &mut series); let id = d.wb.next_object_id(); let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2)); let title = str_param(p, "title").map(str::to_string).or_else(|| { @@ -801,6 +813,7 @@ fn chart_switch(s: &mut Session, p: &Json) -> Result { c.source.get_or_insert(src); c.by_rows = by_rows; c.series = series; + scatter_x_from_first(c.kind, &mut c.series); } Ok(Json::Null) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c9f4d0aa3..f5c0b7268 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1554,6 +1554,31 @@ fn every_chart_kind_survives_xlsx() { } } +#[test] +fn scatter_of_two_columns_is_one_series_with_the_first_as_x() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["X", "Y"], [1, 10], [2, 20], [3, 30]]})).unwrap(); + s.execute("insert.chart", json!({"type": "scatter", "range": "A1:B4"})).unwrap(); + let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone(); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].values, "Sheet1!$B$2:$B$4"); + assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4")); + // Select Data on the scatter chart and Switch Row/Column rebuild the series the same way. + s.execute("chart.selectData", json!({"range": "A2:B4"})).unwrap(); + let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone(); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4")); + s.execute("range.setValues", json!({"range": "D1", "values": [[1, 2, 3, 4], [10, 20, 30, 40]]})).unwrap(); + s.execute("chart.selectData", json!({"range": "D1:G2"})).unwrap(); + s.execute("chart.switchRowColumn", json!({})).unwrap(); + s.execute("chart.switchRowColumn", json!({})).unwrap(); + let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone(); + assert!(c.by_rows); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].values, "Sheet1!$D$2:$G$2"); + assert_eq!(c.series[0].categories.as_deref(), Some("Sheet1!$D$1:$G$1")); +} + #[test] fn whole_sheet_selection_stays_within_the_data() { // On an empty sheet with everything selected these filled or scanned every cell of the