diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b6a362b..c8ab990 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Select Data…", ["Chart Design", "Data"], None, - "{chart?, range: \"Sheet1!A1:D7\", byRows?: bool}", + "{chart?, range?: \"Sheet1!A1:D7\", byRows?: bool, categories?: \"Sheet1!A2:A7\" (Horizontal (Category) Axis Labels for every series; \"\" clears), removeSeries?: [index…]}", has_doc, select_data ), @@ -350,22 +350,70 @@ fn change_colors(s: &mut Session, p: &Json) -> Result { } fn select_data(s: &mut Session, p: &Json) -> Result { - let range = str_param(p, "range").ok_or_else(|| bad("chart.selectData", "missing `range`"))?.to_string(); - let (sheet_name, body) = split_sheet(&range); let d = s.doc()?; - let src_si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet, + // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. + let rebuilt = match str_param(p, "range") { + Some(range) => { + let range = range.trim_start_matches('='); + let (sheet_name, body) = split_sheet(range); + let src_si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet, + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; + if r.count() > 1_000_000 { + return Err(bad("chart.selectData", "range too large for a chart")); + } + let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; + let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); + let series = super::insert::series_from_range(src, r, by_rows); + Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) + } + None => None, }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; - let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; - let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); - let series = super::insert::series_from_range(src, r, by_rows); - let source = format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()); + // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). + let categories = match str_param(p, "categories") { + None => None, + Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), + Some(t) => { + let t = t.trim().trim_start_matches('='); + let (sheet_name, body) = split_sheet(t); + let si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet, + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; + let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); + Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) + } + }; + let mut remove: Vec = p + .get("removeSeries") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) + .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`")); + } with_chart(s, p, move |c| { - c.series = series; - c.source = Some(source); - c.by_rows = by_rows; + if let Some((series, source, by_rows)) = rebuilt { + c.series = series; + c.source = Some(source); + c.by_rows = by_rows; + } + for i in remove.iter().rev() { + if *i < c.series.len() && c.series.len() > 1 { + c.series.remove(*i); + } + } + if let Some(cats) = categories { + for se in &mut c.series { + se.categories = cats.clone(); + } + } Ok(()) }) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 4c96005..359adc7 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -518,7 +518,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec let header_row = r.height() > 1 && (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c))); - let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))); + // A blank top-left cell above a header row marks the first column as the category labels even + // when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't + // plotted as a series of its own (#287). + let blank_corner = header_row && sh.value(r.start).is_empty(); + let label_col = + r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)))); let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end); let mut out = Vec::new(); if !by_rows { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..1b4239a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -52,6 +52,37 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() { } } +#[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. + let mut s = s(); + let series = |s: &Session| -> Vec<(Option, String)> { + let d = s.doc().unwrap(); + d.wb.active().unwrap().charts.last().unwrap().series.iter().map(|x| (x.categories.clone(), x.values.clone())).collect() + }; + // A blank top-left cell marks the first column as category labels, numbers or not. + s.execute("range.setValues", json!({"range": "A1", "values": [[null, "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap(); + assert_eq!(series(&s), [(Some("Sheet1!$A$2:$A$4".to_string()), "Sheet1!$B$2:$B$4".to_string())]); + + // With a "Year" header the year column is a series, as before; Select Data fixes it: + // Horizontal (Category) Axis Labels and Remove on the year series. + s.execute("range.setValues", json!({"range": "D1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap(); + s.execute("insert.chart", json!({"range": "D1:E4", "type": "line"})).unwrap(); + assert_eq!(series(&s).len(), 2); + assert!(s.execute("chart.selectData", json!({})).is_err(), "nothing to change"); + assert!(s.execute("chart.selectData", json!({"categories": "not a range"})).is_err()); + s.execute("chart.selectData", json!({"categories": "=D2:D4", "removeSeries": [0, 7]})).unwrap(); + assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap()); + assert_eq!(data.categories, ["2020", "2021", "2022"]); + // The last series can't be removed; "" clears the labels. + s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap(); + assert_eq!(series(&s), [(None, "Sheet1!$E$2:$E$4".to_string())]); +} + #[test] fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb..85f10ce 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -517,6 +517,29 @@ impl Dialog { }; d } + "selectData" => { + // Chart Design › Select Data: data range, series to keep, category axis labels. + let id = p("chart").as_u64(); + let found = app.session.active().and_then(|doc| { + 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); + let names: Vec = 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)) + }); + let Some((range, cats, names)) = found else { + app.message = Some(("Select Data".into(), "Select a chart first.".into())); + return None; + }; + let eq = |t: &str| if t.is_empty() { String::new() } else { format!("={t}") }; + Dialog::custom( + "selectData", + "Select Data Source", + json!({"chart": id, "range": eq(&range), "range0": eq(&range), "categories": eq(&cats), "categories0": eq(&cats), "names": names, "remove": []}), + ) + } "journal" => Dialog::custom("journal", "Action Journal", json!({})), "statistics" => { let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({})); @@ -679,6 +702,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "sort" => sort(app, ui, &mut d, &mut confirm), "nameManager" => name_manager(app, ui), "manageRules" => manage_rules(app, ui), + "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "spelling" => spelling(app, ui, &mut d, &mut open), @@ -1511,6 +1535,69 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui) { } } +fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + ui.label(label); + let mut v = text(d, key); + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() { + d.values.insert(key.into(), json!(v)); + } + }; + edit(ui, d, "range", "Chart data range:"); + ui.add_space(6.0); + ui.strong("Legend Entries (Series)"); + let names: Vec = + d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); + let mut remove: Vec = + d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + let kept = names.len().saturating_sub(remove.len()); + egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + let i = i as u64; + let removed = remove.contains(&i); + ui.horizontal(|ui| { + let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; + ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); + if removed { + if ui.small_button("Keep").clicked() { + remove.retain(|x| *x != i); + } + } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { + remove.push(i); + } + }); + } + }); + d.values.insert("remove".into(), json!(remove)); + ui.add_space(6.0); + edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let mut params = serde_json::Map::new(); + params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); + let range_changed = text(d, "range") != text(d, "range0"); + if range_changed { + params.insert("range".into(), json!(text(d, "range"))); + } else if !remove.is_empty() { + // A new range rebuilds the series; removals only apply to the current ones. + params.insert("removeSeries".into(), json!(remove)); + } + if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { + params.insert("categories".into(), json!(text(d, "categories"))); + } + if params.len() == 1 { + *open = false; + return; + } + match app.run("chart.selectData", Json::Object(params)) { + Ok(_) => *open = false, + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} + fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); let rules = list["rules"].as_array().cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b212..be201f3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -353,6 +353,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add Chart\nElement", "グラフ要素を追加"), ("Change\nChart Type", "グラフの種類の変更"), ("Switch\nRow/Column", "行/列の切り替え"), + ("Select\nData", "データの選択"), ("Data Labels: None", "データ ラベル: なし"), ("Data Labels: Show", "データ ラベル: 表示"), ("Legend: None", "凡例: なし"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3c..2b5e537 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -200,6 +200,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Add Chart\nElement" => "차트\n요소 추가", "Change\nChart Type" => "차트 종류\n변경", "Switch\nRow/Column" => "행/열\n전환", + "Select\nData" => "데이터\n선택", "Data Labels: None" => "데이터 레이블: 없음", "Data Labels: Show" => "데이터 레이블: 표시", "Legend: None" => "범례: 없음", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3..3477e1d 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -201,6 +201,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Add Chart\nElement" => "Adicionar\nElemento de Gráfico", "Change\nChart Type" => "Alterar\nTipo de Gráfico", "Switch\nRow/Column" => "Alternar\nLinha/Coluna", + "Select\nData" => "Selecionar\nDados", "Data Labels: None" => "Rótulos de Dados: Nenhum", "Data Labels: Show" => "Rótulos de Dados: Exibir", "Legend: None" => "Legenda: Nenhuma", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d54856..b160221 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -200,6 +200,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Add Chart\nElement" => "Добавить элемент\nдиаграммы", "Change\nChart Type" => "Изменить тип\nдиаграммы", "Switch\nRow/Column" => "Поменять\nстроки/столбцы", + "Select\nData" => "Выбрать\nданные", "Data Labels: None" => "Подписи данных: нет", "Data Labels: Show" => "Подписи данных: показать", "Legend: None" => "Легенда: нет", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada..31c1a40 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -200,6 +200,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Add Chart\nElement" => "添加图表\n元素", "Change\nChart Type" => "更改\n图表类型", "Switch\nRow/Column" => "切换\n行/列", + "Select\nData" => "选择\n数据", "Data Labels: None" => "数据标签:无", "Data Labels: Show" => "数据标签:显示", "Legend: None" => "图例:无", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a..0304e1a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1799,6 +1799,9 @@ fn chart_design(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false).clicked() { act(app, "chart.switchRowColumn", json!({"chart": id})); } + if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { + app.open_dialog("selectData", json!({"chart": id})); + } let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); egui::Popup::menu(&ct).show(|ui| { for (label, k, s) in [ diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a6..576c7a1 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -213,6 +213,32 @@ fn language_switch_translates_the_ribbon_and_persists() { assert_eq!(h.state().ui.language, Language::En); } +#[test] +fn select_data_dialog_sets_the_category_axis_labels() { + // #287: Chart Design › Select Data edits the Horizontal (Category) Axis Labels and removes the + // series the X values were plotted as. + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Year", "Sales"], [2020, 5], [2021, 7], [2022, 9]]})).unwrap(); + let id = s.execute("insert.chart", json!({"range": "A1:B4", "type": "line"})).unwrap()["chart"].as_u64().unwrap(); + let mut h = harness(s); + h.state_mut().selected_chart = Some(id as u32); + h.state_mut().open_dialog("selectData", json!({"chart": id})); + h.run_steps(2); + let d = h.state_mut().dialog.as_mut().unwrap(); + assert_eq!(d.title, "Select Data Source"); + assert_eq!(d.values["names"], json!(["Year", "Sales"])); + d.values.insert("categories".into(), json!("=A2:A4")); + d.values.insert("remove".into(), json!([0])); + h.run_steps(1); + key(&mut h, Key::Enter, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let chart = h.state().session.active().unwrap().wb.active().unwrap().charts[0].clone(); + assert_eq!(chart.series.len(), 1); + assert_eq!(chart.series[0].categories.as_deref(), Some("Sheet1!$A$2:$A$4")); + assert_eq!(chart.series[0].values, "Sheet1!$B$2:$B$4"); +} + #[test] fn dialogs_open_and_close() { let mut h = harness(blank()); diff --git a/docs/chart-parity.md b/docs/chart-parity.md index 0577929..7d51021 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit of the chart model, renderer and XLSX chart parts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Select Data dialog, blank-corner category labels, #287) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated). @@ -51,7 +51,7 @@ open PRs #158, #161, and issue #190. | Chart sheets | yes | **missing** (skipped with a warning on open) | | Chart templates (`.crtx`) | yes | **missing** | | Recommended Charts | yes | partial (Insert ▸ Recommended) | -| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) | +| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116); Select Data dialog: data range, remove series, Horizontal (Category) Axis Labels (#287). No series add/edit or hidden/empty cell options | | Sparklines (line, column, win/loss, markers, axis) | yes | line, column, win/loss | | PivotCharts | yes | **missing** | | Animation on data change | yes (Windows) | no | @@ -78,5 +78,6 @@ back. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Select Data dialog with category axis labels; a blank top-left cell makes the first column the categories (#287) | | 2026-10-10 | minor | Smooth lines (#62) and Switch Row/Column on file charts (#116) landed; chart resize bug (#190) | | 2026-10-10 | major | Created from a source audit: 25 kinds (25 of ~60 subtypes), element gaps, round-trip loss |