From 0eb2f1f6f057985c26cacb569f425384b623f1c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 16:32:53 -0700 Subject: [PATCH] Charts: 100% Stacked Area draws each value as a share of its category's total Insert > Line or Area and the All Charts list offer 100% Stacked Area, and insert.chart and chart.set take it as type area with subtype stacked100. Each series' area stacks on the ones before it as its share of the category's total. When every series on the value axis is 100% stacked and no value is negative, the axis runs from 0% to 100%. A blank cell counts as zero, and a category whose total is zero stays on the baseline. An XLSX area chart with percentStacked grouping now opens as 100% stacked and is saved back as percentStacked. It used to open as a stacked area and was saved as stacked. --- ROADMAP.md | 1 + crates/chart/src/render.rs | 44 +++++++++++++------ crates/chart/src/tests.rs | 44 ++++++++++++++++++- crates/engine/src/cmd/insert.rs | 3 +- .../engine/tests/it/chart_area_stacked100.rs | 35 +++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/model/src/features.rs | 1 + crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/tests/it/interaction.rs | 27 ++++++++++++ crates/xlsx/src/chart.rs | 16 +++---- crates/xlsx/src/tests/roundtrip.rs | 1 + docs/chart-parity.md | 7 +-- 20 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 crates/engine/tests/it/chart_area_stacked100.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..6aa2a087e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 0a6fdbe32..e97f576a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -868,6 +868,7 @@ enum Fam { LineStack, Area, AreaStack, + Area100, } fn family(k: ChartKind) -> Fam { @@ -879,10 +880,16 @@ fn family(k: ChartKind) -> Fam { LineStacked => Fam::LineStack, Area => Fam::Area, AreaStacked => Fam::AreaStack, + AreaStacked100 => Fam::Area100, _ => Fam::Col, } } +/// Families stacked to 100%: each category's values are shares of its total. +fn is_100(f: Fam) -> bool { + matches!(f, Fam::Col100 | Fam::Area100) +} + fn is_col(f: Fam) -> bool { matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) } @@ -900,25 +907,38 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let any_primary = data.series.iter().any(|s| !s.secondary); let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - // Totals for 100% stacking, per axis. - let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]]; + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); for (i, s) in data.series.iter().enumerate() { - if fams.get(i) == Some(&Fam::Col100) { - let a = sec.get(i).copied().unwrap_or(false) as usize; + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + if is_100(fam) { + let key = (sec.get(i).copied().unwrap_or(false), fam); + let p = match totals.iter().position(|(k, _)| *k == key) { + Some(p) => p, + None => { + totals.push((key, vec![0.0; ncat])); + totals.len() - 1 + } + }; for (j, v) in s.values.iter().take(ncat).enumerate() { - if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) { + if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { *t += v.abs(); } } } } + // A value's share of its category's total, for the 100% families. + let share = |key: (bool, Fam), j: usize, v: f64| { + let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + }; // Stack accumulators keyed by (axis, family): positive and negative running sums. let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); let mut spans: Vec>> = Vec::with_capacity(data.series.len()); for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let a = sec.get(i).copied().unwrap_or(false); - let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); let key = (a, fam); let si = if stacked { match stacks.iter().position(|(k, _, _)| *k == key) { @@ -936,12 +956,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta let v = s.values.get(j).copied().flatten(); let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { - let v = if fam == Fam::Col100 { - let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0); - if t > 0.0 { v / t } else { 0.0 } - } else { - v - }; + let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; let base = *acc; *acc += v; @@ -949,6 +964,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta }), (Some((_, pos, _)), _) => { let v = v.unwrap_or(0.0); + let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; pos.get_mut(j).map(|acc| { let base = *acc; *acc = clean(*acc + v); @@ -970,7 +986,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta continue; } let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); - let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100)); + let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); let range = value_range(members.iter().flat_map(|i| { let line = fams.get(*i).is_some_and(|f| is_line(*f)); spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) @@ -1044,7 +1060,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta for (i, s) in data.series.iter().enumerate() { let fam = fams.get(i).copied().unwrap_or(Fam::Col); let want = match pass { - 0 => matches!(fam, Fam::Area | Fam::AreaStack), + 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), 1 => is_col(fam), _ => is_line(fam), }; diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index cfe5d5a42..9bddbb270 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl use crate::*; -const ALL_KINDS: [ChartKind; 25] = [ +const ALL_KINDS: [ChartKind; 26] = [ ChartKind::ColumnClustered, ChartKind::ColumnStacked, ChartKind::ColumnStacked100, @@ -19,6 +19,7 @@ const ALL_KINDS: [ChartKind; 25] = [ ChartKind::Doughnut, ChartKind::Area, ChartKind::AreaStacked, + ChartKind::AreaStacked100, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble, @@ -1073,7 +1074,7 @@ fn pie_points_follow_workbook_theme() { #[test] fn area_charts_draw_source_value_labels_only_when_enabled() { - for kind in [ChartKind::Area, ChartKind::AreaStacked] { + for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] { let d = sample(kind); let mut c = chart(kind); c.data_labels = false; @@ -1097,6 +1098,45 @@ fn area_charts_draw_source_value_labels_only_when_enabled() { } } +#[test] +fn hundred_percent_stacked_areas_fill_each_category_to_its_total() { + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100), + series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100), + ], + }; + let mut c = chart(ChartKind::AreaStacked100); + c.data_labels = false; + let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let t = texts(&p); + assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}"); + assert!(!t.contains(&"40"), "{t:?}"); + let polys: Vec<&Vec<[f32; 2]>> = p + .iter() + .filter_map(|p| match p { + Prim::Polygon { pts, .. } => Some(pts), + _ => None, + }) + .collect(); + let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") }; + // Each polygon is its top edge left to right, then its base right to left. + let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN); + let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN); + let (y0, y100) = (base(north, 0), top(south, 0)); + assert!(y0 > y100, "{y0} {y100}"); + let frac = |y: f32| (y0 - y) / (y0 - y100); + for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] { + assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j))); + assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j))); + } + // A category whose total is zero stays on the baseline. + assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2))); +} + #[test] fn two_level_categories_resolve_and_draw() { let years = ["2023", "", "", "2024", "", ""]; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2388d1f25..3c45031af 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Chart Properties", ["Chart Design"], None, - "{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", + "{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ), @@ -582,6 +582,7 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind { ("pie", _) => ChartKind::Pie, ("doughnut", _) => ChartKind::Doughnut, ("area", "stacked") => ChartKind::AreaStacked, + ("area", "stacked100") => ChartKind::AreaStacked100, ("area", _) => ChartKind::Area, ("scatter", "lines") => ChartKind::ScatterLines, ("scatter", _) => ChartKind::Scatter, diff --git a/crates/engine/tests/it/chart_area_stacked100.rs b/crates/engine/tests/it/chart_area_stacked100.rs new file mode 100644 index 000000000..2d0d0c21d --- /dev/null +++ b/crates/engine/tests/it/chart_area_stacked100.rs @@ -0,0 +1,35 @@ +//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype +//! `stacked100`. + +use gridcraft_engine::Session; +use gridcraft_model::ChartKind; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap(); + s +} + +fn kinds(s: &Session) -> Vec { + s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect() +} + +#[test] +fn insert_chart_makes_a_hundred_percent_stacked_area() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); +} + +#[test] +fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() { + let mut s = session(); + s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); + s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked100]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kinds(&s), [ChartKind::AreaStacked]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 385ec76ca..924a95d1b 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -2,6 +2,7 @@ mod autofit_filter_buttons; mod cf_formula_validate; +mod chart_area_stacked100; mod chart_workbook_names; mod command_sequence_fuzz; mod cross_sheet_cut; diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index aa250b9b0..c83b5d1e9 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -410,6 +410,7 @@ pub enum ChartKind { Doughnut, Area, AreaStacked, + AreaStacked100, Scatter, ScatterLines, Bubble, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..76bcff5dc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2497,7 +2497,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu } /// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 20] = [ +const ALL_CHARTS: [(&str, &str, &str); 21] = [ ("column", "", "Clustered Column"), ("column", "stacked", "Stacked Column"), ("column", "stacked100", "100% Stacked Column"), @@ -2511,6 +2511,7 @@ const ALL_CHARTS: [(&str, &str, &str); 20] = [ ("doughnut", "", "Doughnut"), ("area", "", "Area"), ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), ("scatter", "", "Scatter"), ("scatter", "lines", "Scatter with Lines"), ("radar", "", "Radar"), diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..c8431cf36 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -877,6 +877,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Δακτύλιος", "Area" => "Περιοχή", "Stacked Area" => "Σωρευμένη περιοχή", + "100% Stacked Area" => "Σωρευμένη περιοχή 100%", "Scatter" => "Διασπορά", "Scatter with Lines" => "Διασπορά με γραμμές", "Radar" => "Ακτινωτό", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..062b3c1a2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -761,6 +761,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Anello", "Area" => "Area", "Stacked Area" => "Area in pila", + "100% Stacked Area" => "Area in pila 100%", "Scatter" => "Dispersione", "Scatter with Lines" => "Dispersione con linee", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..254ce8d58 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -770,6 +770,7 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Doughnut", "ドーナツ"), ("Area", "面"), ("Stacked Area", "積み上げ面"), + ("100% Stacked Area", "100% 積み上げ面"), ("Scatter", "散布図"), ("Scatter with Lines", "直線付き散布図"), ("Radar", "レーダー"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..e4792fb6a 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -755,6 +755,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Pierścieniowy", "Area" => "Warstwowy", "Stacked Area" => "Warstwowy skumulowany", + "100% Stacked Area" => "Warstwowy 100% skumulowany", "Scatter" => "Punktowy", "Scatter with Lines" => "Punktowy z liniami", "Radar" => "Radarowy", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1b56ff504..a3fbe17bb 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -772,6 +772,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Rosca", "Area" => "Área", "Stacked Area" => "Área Empilhada", + "100% Stacked Area" => "Área 100% Empilhada", "Scatter" => "Dispersão", "Scatter with Lines" => "Dispersão com Linhas", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9e0d92516..b74f98fbf 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -761,6 +761,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Inel", "Area" => "Arie", "Stacked Area" => "Arie stivuită", + "100% Stacked Area" => "Arie 100% stivuită", "Scatter" => "Dispersie", "Scatter with Lines" => "Dispersie cu linii", "Radar" => "Radar", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..c03625a7a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -758,6 +758,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Doughnut" => "Doughnut", "Area" => "Area", "Stacked Area" => "Stacked Area", + "100% Stacked Area" => "100% Stacked Area", "Scatter" => "Scatter", "Scatter with Lines" => "Scatter with Lines", "Radar" => "Radar", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..8468afef3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1611,6 +1611,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { ("Stacked Line", "line", "stacked"), ("Area", "area", ""), ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), ], ), (Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]), diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 10841c7bd..b0ed0a126 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -558,6 +558,33 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() { assert!(h.state().message.is_some()); } +#[test] +fn all_charts_inserts_a_hundred_percent_stacked_area() { + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + s.execute("selection.set", json!({"range": "A1:C3"})).unwrap(); + let mut h = harness(s); + h.state_mut().open_dialog("recommendedCharts", json!({"tab": "All Charts"})); + h.run_steps(4); + // The entry sits below the list's visible height: scroll the list with the wheel first. + let first = h.get_by_label("Pie").rect().center(); + h.input_mut().events.push(Event::PointerMoved(first)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run_steps(20); + h.get_by_label("100% Stacked Area").click(); + h.run_steps(2); + key(&mut h, Key::Enter, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let kinds: Vec<_> = h.state().session.active().unwrap().wb.active().unwrap().charts.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]); +} + #[test] fn recommended_charts_command_without_a_type_opens_the_dialog() { // #1399: the ribbon button and the NR keytip ran the command directly and inserted the first diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index a9c426d8a..37bcf91b0 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -40,7 +40,9 @@ fn group_kind(g: &El) -> Option { "pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie, "doughnutChart" => ChartKind::Doughnut, "areaChart" | "area3DChart" => { - if stacked || pct { + if pct { + ChartKind::AreaStacked100 + } else if stacked { ChartKind::AreaStacked } else { ChartKind::Area @@ -284,7 +286,7 @@ enum G { Line { stacked: bool, markers: bool }, Pie, Doughnut, - Area { stacked: bool }, + Area { grouping: &'static str }, Scatter { lines: bool }, Bubble, Radar, @@ -303,8 +305,9 @@ fn group_for(k: ChartKind) -> G { ChartKind::LineStacked => G::Line { stacked: true, markers: false }, ChartKind::Pie => G::Pie, ChartKind::Doughnut => G::Doughnut, - ChartKind::Area => G::Area { stacked: false }, - ChartKind::AreaStacked => G::Area { stacked: true }, + ChartKind::Area => G::Area { grouping: "standard" }, + ChartKind::AreaStacked => G::Area { grouping: "stacked" }, + ChartKind::AreaStacked100 => G::Area { grouping: "percentStacked" }, ChartKind::Scatter => G::Scatter { lines: false }, ChartKind::ScatterLines => G::Scatter { lines: true }, ChartKind::Bubble => G::Bubble, @@ -671,10 +674,7 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb G::Doughnut => { format!("{sers}{dl}") } - G::Area { stacked } => format!( - "{sers}{dl}{ax}", - if stacked { "stacked" } else { "standard" } - ), + G::Area { grouping } => format!("{sers}{dl}{ax}"), G::Scatter { .. } => format!("{sers}{dl}{ax}"), G::Bubble => { format!("{sers}{dl}{ax}") diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 5b79438db..a68ed44e8 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -429,6 +429,7 @@ fn sample() -> Workbook { ChartKind::Doughnut, ChartKind::Area, ChartKind::AreaStacked, + ChartKind::AreaStacked100, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble, diff --git a/docs/chart-parity.md b/docs/chart-parity.md index ce9f69290..f18283f45 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (stale chart notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip; PRs #158 and #161 merged) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **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). @@ -17,7 +17,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Bar | clustered, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D | | Line | line, stacked, 100% stacked, with markers ×3, 3-D line | 3 of 7 (line, with markers, stacked) | no 100% stacked; smoothed lines landed (#62) | | Pie | pie, 3-D pie, pie of pie, bar of pie, doughnut | 2 of 5 (pie, doughnut) | no exploded, pie of pie, bar of pie | -| Area | area, stacked, 100%, 3-D ×3 | 2 of 6 | no 100%, no 3-D | +| Area | area, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D; 100% stacked drawn as shares of each category's total and kept as `percentStacked` in XLSX | | X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines | | Bubble | bubble, 3-D bubble | 1 of 2 | | | Stock | high-low-close, open-high-low-close, volume-HLC, volume-OHLC | 1 kind | | @@ -31,7 +31,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Waterfall, Funnel | 2 | 2 of 2 | | | Filled map | 1 | 0 | needs geographic boundary data (licensing decision) | | Combo | custom combinations, secondary axis | yes (line on secondary axis) | | -| **Total** | ~60 subtypes | **25 (~42%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate | +| **Total** | ~60 subtypes | **26 (~43%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate | ## Chart elements and formatting @@ -90,6 +90,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) | | 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) |