Merge pull request #303 from vangel76/fix/chart-category-axis
Charts: Select Data edits the category axis labels; blank corner makes column 1 the categories (#287) # Conflicts: # crates/engine/src/tests.rs # crates/ui-egui/src/dialogs.rs # crates/ui-egui/tests/interaction.rs # docs/chart-parity.md
This commit is contained in:
commit
925ff53122
|
|
@ -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(())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -532,7 +532,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 {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,37 @@ fn chart_recommendations_follow_the_data_shape() {
|
|||
assert!(s.ui_requests.is_empty(), "programmatic calls never open the dialog");
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
|
|
@ -602,6 +602,29 @@ impl Dialog {
|
|||
select_chart(&mut d, 0);
|
||||
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!({}));
|
||||
|
|
@ -777,6 +800,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||||
"manageRules" => manage_rules(app, ui),
|
||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"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),
|
||||
|
|
@ -1785,6 +1809,69 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Add Chart\nElement", "グラフ要素を追加"),
|
||||
("Change\nChart Type", "グラフの種類の変更"),
|
||||
("Switch\nRow/Column", "行/列の切り替え"),
|
||||
("Select\nData", "データの選択"),
|
||||
("Data Labels: None", "データ ラベル: なし"),
|
||||
("Data Labels: Show", "データ ラベル: 表示"),
|
||||
("Legend: None", "凡例: なし"),
|
||||
|
|
|
|||
|
|
@ -210,6 +210,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" => "범례: 없음",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,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",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,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" => "Легенда: нет",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,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" => "图例:无",
|
||||
|
|
|
|||
|
|
@ -1883,6 +1883,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 [
|
||||
|
|
|
|||
|
|
@ -335,6 +335,32 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() {
|
|||
assert!(h.state().message.is_some());
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ open PRs #158, #161, and issue #190.
|
|||
| Chart templates (`.crtx`) | yes | **missing** |
|
||||
| Recommended Charts | yes | dialog with recommendations from the data's shape (own heuristic) and an All Charts list, live previews (#215) |
|
||||
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
|
||||
| Recommended Charts | yes | partial (Insert ▸ Recommended) |
|
||||
| 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 | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only |
|
||||
| Animation on data change | yes (Windows) | no |
|
||||
|
|
@ -80,5 +82,6 @@ back.
|
|||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-11 | minor | Recommended Charts dialog with previews (#215) |
|
||||
| 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue