diff --git a/ROADMAP.md b/ROADMAP.md index d64980706..ad14859af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) | | 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does | | 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share | +| 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 e7114b802..00db5e879 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 39c33519e..00901f289 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2606,7 +2606,7 @@ fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &m } /// 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"), @@ -2620,6 +2620,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 1a6291bce..ae8320c59 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -880,6 +880,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 3b7d0d23c..c881ef1e4 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -764,6 +764,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 42d392723..84b3866ad 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -773,6 +773,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 2cfeacb8e..c63206800 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -758,6 +758,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 35413d6d5..3d26f2a29 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -775,6 +775,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 dbd9b7389..f9e732cf8 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -764,6 +764,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 a7c6d30b5..466795365 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -761,6 +761,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 551e00acf..046d88ed2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1628,6 +1628,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 71779aa26..d29eecd56 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) |