Charts: Select Data edits the category axis labels; blank corner makes column 1 the categories (#287)

A line chart from numeric X values (years) plotted them as a series and
there was no way to set the axis labels. Now:
- a blank top-left cell over a header row makes the first column the
  category labels even when it holds numbers (the usual convention);
- chart.selectData takes categories (Horizontal (Category) Axis Labels,
  "" clears) and removeSeries, and range is optional;
- Chart Design gets a Select Data button opening a Select Data Source
  dialog: chart data range, legend entries with Remove, axis labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:20:29 +02:00
parent 9f896c1249
commit 9262fba5ce
12 changed files with 223 additions and 17 deletions

View File

@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
}
fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
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<usize> = 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(())
})
}

View File

@ -518,7 +518,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
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 {

View File

@ -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>, 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();

View File

@ -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<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))
});
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<String> =
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<u64> =
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();

View File

@ -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", "凡例: なし"),

View File

@ -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" => "범례: 없음",

View File

@ -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",

View File

@ -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" => "Легенда: нет",

View File

@ -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" => "图例:无",

View File

@ -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 [

View File

@ -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());

View File

@ -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 |