diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 9f044613d..ce6f0284d 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -224,6 +224,17 @@ fn block(s: &Session, p: &Json) -> Result { Ok(current_region(sh, d.selection.active)) } +fn chart_areas(s: &Session, p: &Json) -> Result> { + if p.get("range").is_some() { + return target_ranges(s, p); + } + let d = s.doc()?; + if d.selection.ranges.len() > 1 { + return Ok(d.selection.ranges.clone()); + } + Ok(vec![block(s, p)?]) +} + // ---------------------------------------------------------------- tables fn insert_table(s: &mut Session, p: &Json) -> Result { @@ -627,6 +638,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec out } +fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec)> { + let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) }; + let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area)); + if areas.len() == 1 { + let by_rows = first.width() > first.height() + 1; + return Ok((bounds, by_rows, series_from_range(sh, first, by_rows))); + } + let valid_shape = first.width() == 1 + && first.height() > 1 + && areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height()) + && first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty()); + if !valid_shape { + return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns")); + } + let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values); + let mut series = Vec::with_capacity(areas.len() - 1); + for area in &areas[1..] { + let mut area_series = series_from_range(sh, *area, false); + let Some(mut item) = area_series.pop() else { + return Err(EngineError::Other("Select data for the chart first.".into())); + }; + if item.name.is_none() { + return Err(bad("insert.chart", "multi-area series columns require headers")); + } + item.categories = category.clone(); + series.push(item); + } + Ok((bounds, false, series)) +} + /// 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) { @@ -647,15 +688,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000; /// without labels → scatter; one short series → column, pie, bar; long series → line; several /// series → clustered and stacked columns and bars, line. pub fn recommend_charts(s: &Session, p: &Json) -> Result> { - let r = block(s, p)?; - if r.count() > MAX_CHART_CELLS { + let areas = chart_areas(s, p)?; + if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { return Err(bad("insert.chart", "range too large for a chart")); } let sheet = target_sheet(s, p)?; let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let by_rows = r.width() > r.height() + 1; - let series = series_from_range(sh, r, by_rows); + let (_, by_rows, series) = series_from_areas(sh, &areas)?; let ranges: Vec = series .iter() .filter_map(|x| match gridcraft_formula::parse(&x.values) { @@ -707,8 +747,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result Result<(usize, Chart)> { - let r = block(s, p)?; - if r.count() > MAX_CHART_CELLS { + let areas = chart_areas(s, p)?; + if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { return Err(bad("insert.chart", "range too large for a chart")); } let mut t = str_param(p, "type").unwrap_or("column").to_string(); @@ -721,18 +761,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { let sheet = target_sheet(s, p)?; let d = s.doc()?; let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - // Excel plots series by the longer dimension (more rows → series in columns). - let data_h = r.height(); - let data_w = r.width(); - let by_rows = data_w > data_h + 1; let kind = chart_kind(&t, &sub); - let mut series = series_from_range(sh, r, by_rows); + let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?; 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 at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2)); let title = str_param(p, "title").map(str::to_string).or_else(|| { if series.len() == 1 { series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display()) @@ -759,7 +795,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { style: 1, x_title: None, y_title: None, - source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())), + source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())), by_rows, pivot: None, }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e7098e664..2b1d53074 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -125,6 +125,31 @@ fn chart_recommendations_follow_the_data_shape() { assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog"); } +#[test] +fn chart_uses_text_first_area_as_categories_for_later_areas() { + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["Mes", "x", "y", "z", "Ventas"], ["Ene", 1, 1, 1, 10], ["Feb", 1, 1, 1, 20], ["Mar", 1, 1, 1, 30], ["Abr", 1, 1, 1, 40]]}), + ) + .unwrap(); + + s.execute("insert.chart", json!({"range": "A1:A5,E1:E5", "type": "pie", "at": "G2"})).unwrap(); + let multi = s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone(); + assert_eq!(multi.series.len(), 1); + assert_eq!(multi.series[0].name.as_deref(), Some("Sheet1!$E$1")); + assert_eq!(multi.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$5")); + assert_eq!(multi.series[0].values, "Sheet1!$E$2:$E$5"); + + s.execute("insert.chart", json!({"range": "D1:E5", "type": "pie", "at": "G20"})).unwrap(); + let contiguous = s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone(); + assert_eq!(contiguous.series.len(), 2); + assert_eq!(contiguous.series[0].name.as_deref(), Some("Sheet1!$D$1")); + assert_eq!(contiguous.series[0].values, "Sheet1!$D$2:$D$5"); + assert_eq!(contiguous.series[1].name.as_deref(), Some("Sheet1!$E$1")); + assert_eq!(contiguous.series[1].values, "Sheet1!$E$2:$E$5"); +} + #[test] fn chart_category_axis_from_blank_corner_and_select_data() { // #287: numeric X values must be the category axis, not a series of their own.