collective

Browse / github.com/storytold/gridcraft / patch

Charts: 100% Stacked Line, with or without markers, draws values as shares of each category's total

Charts: 100% Stacked Line, with or without markers, draws values as shares of each category's total ## Summary GridCraft had no 100% stacked line chart: `docs/chart-parity.md` listed Line as "3 of 7 (line, with markers, stacked)" with "no 100% stacked". Microsoft's "Available chart types in Office" page describes the two subtypes: "Shown with or without markers to indicate individual data values, 100% stacked line charts can show the trend of the percentage each value contributes over time or evenly spaced categories." https://web.archive.org/web/20260417051336/https://support.microsoft.com/en-us/office/available-chart-types-in-office-a6187218-807e-4103-9e0a-27cdb19afb90 An XLSX line chart

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #2602, open)
Base
ad10881cc3347c3a07bc5f04a4e876999647e839
Size
+264 −25 in 23 files
Review
pending
Shared
2026-10-28
Id
9ee5a7f5a56155451593c986

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/chart/src/render.rs crates/chart/src/tests.rs crates/engine/src/cmd/insert.rs crates/engine/tests/it/chart_line_stacked100.rs crates/engine/tests/it/main.rs crates/mcp/src/tools.rs crates/model/src/features.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/el.rs crates/ui-egui/src/i18n/it.rs crates/ui-egui/src/i18n/ja.rs crates/ui-egui/src/i18n/pl.rs crates/ui-egui/src/i18n/pt.rs crates/ui-egui/src/i18n/ro.rs crates/ui-egui/src/i18n/uk.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/tests/it/interaction.rs crates/xlsx/src/chart.rs crates/xlsx/src/tests/chart_line_percent_stacked.rs crates/xlsx/src/tests/mod.rs crates/xlsx/src/tests/roundtrip.rs docs/chart-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- 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-25 | Charts: 100% Stacked Line and 100% Stacked Line with Markers (`insert.chart` `{type: "line", subtype: "stacked100"}` and `"stacked100Markers"`), drawn as each value's share of its category's total; XLSX `percentStacked` line charts open as 100% stacked instead of stacked | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs--- a/crates/chart/src/render.rs+++ b/crates/chart/src/render.rs@@ -165,8 +165,10 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa                 .clone()                 .map(|s| {                     let sw = match plot_kind(chart, s) {-                        ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,-                        ChartKind::LineMarkers => Swatch::LineMarker,+                        ChartKind::Line | ChartKind::LineStacked | ChartKind::LineStacked100 | ChartKind::Radar | ChartKind::ScatterLines => {+                            Swatch::Line+                        }+                        ChartKind::LineMarkers | ChartKind::LineStacked100Markers => Swatch::LineMarker,                         ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,                         _ => Swatch::Box,                     };@@ -866,6 +868,7 @@ enum Fam {     Col100,     Line,     LineStack,+    Line100,     Area,     AreaStack,     Area100,@@ -878,6 +881,7 @@ fn family(k: ChartKind) -> Fam {         ColumnStacked100 | BarStacked100 => Fam::Col100,         Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line,         LineStacked => Fam::LineStack,+        LineStacked100 | LineStacked100Markers => Fam::Line100,         Area => Fam::Area,         AreaStacked => Fam::AreaStack,         AreaStacked100 => Fam::Area100,@@ -887,14 +891,14 @@ fn family(k: ChartKind) -> Fam {  /// 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)+    matches!(f, Fam::Col100 | Fam::Line100 | Fam::Area100) }  fn is_col(f: Fam) -> bool {     matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) } fn is_line(f: Fam) -> bool {-    matches!(f, Fam::Line | Fam::LineStack)+    matches!(f, Fam::Line | Fam::LineStack | Fam::Line100) }  fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) {@@ -907,7 +911,7 @@ 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<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); -    // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).+    // Totals for 100% stacking, per axis and family (100% columns, lines and areas stack apart).     let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();     for (i, s) in data.series.iter().enumerate() {         let fam = fams.get(i).copied().unwrap_or(Fam::Col);@@ -938,7 +942,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 a = sec.get(i).copied().unwrap_or(false);-        let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);+        let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::Line100 | Fam::AreaStack | Fam::Area100);         let key = (a, fam);         let si = if stacked {             match stacks.iter().position(|(k, _, _)| *k == key) {@@ -964,7 +968,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 };+                    let v = if is_100(fam) { share(key, j, v) } else { v };                     pos.get_mut(j).map(|acc| {                         let base = *acc;                         *acc = clean(*acc + v);@@ -1150,7 +1154,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta                     let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();                     draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot));                     let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);-                    if k == ChartKind::LineMarkers && cw >= 3.0 {+                    if matches!(k, ChartKind::LineMarkers | ChartKind::LineStacked100Markers) && cw >= 3.0 {                         draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);                     }                     if labels_on {diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs--- 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; 26] = [+const ALL_KINDS: [ChartKind; 28] = [     ChartKind::ColumnClustered,     ChartKind::ColumnStacked,     ChartKind::ColumnStacked100,@@ -15,6 +15,8 @@ const ALL_KINDS: [ChartKind; 26] = [     ChartKind::Line,     ChartKind::LineMarkers,     ChartKind::LineStacked,+    ChartKind::LineStacked100,+    ChartKind::LineStacked100Markers,     ChartKind::Pie,     ChartKind::Doughnut,     ChartKind::Area,@@ -1137,6 +1139,62 @@ fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {     assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2))); } +#[test]+fn hundred_percent_stacked_lines_end_each_category_at_its_total() {+    let (north_color, south_color) = ([20, 96, 130, 255], [25, 107, 36, 255]);+    for kind in [ChartKind::LineStacked100, ChartKind::LineStacked100Markers] {+        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], north_color, kind), series("South", &[30.0, 10.0, 0.0, 15.0], south_color, kind)],+        };+        let mut c = chart(kind);+        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%"), "{kind:?}: {t:?}");+        assert!(!t.contains(&"40"), "{kind:?}: {t:?}");+        // The value axis labels sit on their gridlines.+        let label_y = |s: &str| {+            p.iter()+                .find_map(|p| match p {+                    Prim::Text { text, y, .. } if text == s => Some(*y),+                    _ => None,+                })+                .unwrap_or(f32::NAN)+        };+        let (y0, y100) = (label_y("0%"), label_y("100%"));+        assert!(y0 > y100, "{kind:?}: {y0} {y100}");+        let frac = |y: f32| (y0 - y) / (y0 - y100);+        let line = |color: Rgba| {+            p.iter()+                .find_map(|p| match p {+                    // The series line is 2.25 wide; its legend swatch is 2.0.+                    Prim::Line { pts, color: c, width, .. } if *c == color && *width > 2.1 => {+                        Some(pts.iter().map(|q| frac(q[1])).collect::<Vec<f32>>())+                    }+                    _ => None,+                })+                .unwrap_or_default()+        };+        let (north, south) = (line(north_color), line(south_color));+        assert_eq!((north.len(), south.len()), (4, 4), "{kind:?}: {north:?} {south:?}");+        // A category whose total is zero stays on the baseline.+        for (j, (n, s)) in [(0.25, 1.0), (0.75, 1.0), (0.0, 0.0), (0.25, 1.0)].into_iter().enumerate() {+            assert!((north[j] - n).abs() < 1e-3 && (south[j] - s).abs() < 1e-3, "{kind:?} Q{}: {} {}", j + 1, north[j], south[j]);+        }+        // Point markers have a white outline; the legend's marker swatches have none.+        let circles = |outlined: bool| {+            p.iter()+                .filter(|p| matches!(p, Prim::Circle { fill: Some(f), stroke, .. } if (*f == north_color || *f == south_color) && stroke.is_some() == outlined))+                .count()+        };+        let with_markers = kind == ChartKind::LineStacked100Markers;+        assert_eq!((circles(true), circles(false)), if with_markers { (8, 2) } else { (0, 0) }, "{kind:?}");+    }+}+ #[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--- a/crates/engine/src/cmd/insert.rs+++ b/crates/engine/src/cmd/insert.rs@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Insert Chart",             ["Insert", "Charts"],             None,-            "{range?, type?: column|bar|line|pie|doughnut|area|scatter|bubble|radar|combo|histogram|waterfall|funnel|treemap|sunburst|boxWhisker|stock, subtype?: clustered|stacked|stacked100|markers, title?, at?: \"H2\", width?, height?}",+            "{range?, type?: column|bar|line|pie|doughnut|area|scatter|bubble|radar|combo|histogram|waterfall|funnel|treemap|sunburst|boxWhisker|stock, subtype?: clustered|stacked|stacked100|markers|stacked100Markers, title?, at?: \"H2\", width?, height?}",             has_doc,             insert_chart         ),@@ -578,6 +578,8 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {         ("bar", _) => ChartKind::BarClustered,         ("line", "markers") => ChartKind::LineMarkers,         ("line", "stacked") => ChartKind::LineStacked,+        ("line", "stacked100") => ChartKind::LineStacked100,+        ("line", "stacked100Markers") => ChartKind::LineStacked100Markers,         ("line", _) => ChartKind::Line,         ("pie", _) => ChartKind::Pie,         ("doughnut", _) => ChartKind::Doughnut,diff --git a/crates/engine/tests/it/chart_line_stacked100.rs b/crates/engine/tests/it/chart_line_stacked100.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/chart_line_stacked100.rs@@ -0,0 +1,38 @@+//! The 100% stacked line charts: `insert.chart` and `chart.set` take type `line` with subtype+//! `stacked100` or `stacked100Markers`.++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<ChartKind> {+    s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()+}++#[test]+fn insert_chart_makes_hundred_percent_stacked_lines() {+    let mut s = session();+    s.execute("insert.chart", json!({"range": "A1:C3", "type": "line", "subtype": "stacked100"})).unwrap();+    s.execute("insert.chart", json!({"range": "A1:C3", "type": "line", "subtype": "stacked100Markers"})).unwrap();+    assert_eq!(kinds(&s), [ChartKind::LineStacked100, ChartKind::LineStacked100Markers]);+}++#[test]+fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_line_and_back() {+    let mut s = session();+    s.execute("insert.chart", json!({"range": "A1:C3", "type": "line", "subtype": "stacked"})).unwrap();+    assert_eq!(kinds(&s), [ChartKind::LineStacked]);+    s.execute("chart.set", json!({"type": "line", "subtype": "stacked100Markers"})).unwrap();+    assert_eq!(kinds(&s), [ChartKind::LineStacked100Markers]);+    s.execute("chart.set", json!({"type": "line", "subtype": "stacked100"})).unwrap();+    assert_eq!(kinds(&s), [ChartKind::LineStacked100]);+    s.execute("edit.undo", json!({})).unwrap();+    assert_eq!(kinds(&s), [ChartKind::LineStacked100Markers]);+}diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -3,6 +3,7 @@ mod autofit_filter_buttons; mod cf_formula_validate; mod chart_area_stacked100;+mod chart_line_stacked100; mod chart_workbook_names; mod command_sequence_fuzz; mod cross_sheet_cut;diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs--- a/crates/mcp/src/tools.rs+++ b/crates/mcp/src/tools.rs@@ -260,7 +260,7 @@ pub fn tool_definitions() -> Vec<Value> {                 json!({                     "range": string("Source data range including headers, e.g. \"A1:C6\""),                     "type": enumeration("Chart type", CHART_TYPES),-                    "subtype": enumeration("Variant (optional)", &["clustered", "stacked", "stacked100", "markers"]),+                    "subtype": enumeration("Variant (optional)", &["clustered", "stacked", "stacked100", "markers", "stacked100Markers"]),                     "title": string("Chart title"),                     "at": string("Top-left anchor cell (default: right of the data)"),                 }),diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs--- a/crates/model/src/features.rs+++ b/crates/model/src/features.rs@@ -406,6 +406,8 @@ pub enum ChartKind {     Line,     LineMarkers,     LineStacked,+    LineStacked100,+    LineStacked100Markers,     Pie,     Doughnut,     Area,diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -2646,7 +2646,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); 21] = [+const ALL_CHARTS: [(&str, &str, &str); 23] = [     ("column", "", "Clustered Column"),     ("column", "stacked", "Stacked Column"),     ("column", "stacked100", "100% Stacked Column"),@@ -2656,6 +2656,8 @@ const ALL_CHARTS: [(&str, &str, &str); 21] = [     ("line", "", "Line"),     ("line", "markers", "Line with Markers"),     ("line", "stacked", "Stacked Line"),+    ("line", "stacked100", "100% Stacked Line"),+    ("line", "stacked100Markers", "100% Stacked Line with Markers"),     ("pie", "", "Pie"),     ("doughnut", "", "Doughnut"),     ("area", "", "Area"),diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs--- a/crates/ui-egui/src/i18n/el.rs+++ b/crates/ui-egui/src/i18n/el.rs@@ -881,6 +881,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Γραμμή",         "Line with Markers" => "Γραμμή με δείκτες",         "Stacked Line" => "Σωρευμένες γραμμές",+        "100% Stacked Line" => "Σωρευμένες γραμμές 100%",+        "100% Stacked Line with Markers" => "Σωρευμένες γραμμές 100% με δείκτες",         "Pie" => "Πίτα",         "Doughnut" => "Δακτύλιος",         "Area" => "Περιοχή",diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs--- a/crates/ui-egui/src/i18n/it.rs+++ b/crates/ui-egui/src/i18n/it.rs@@ -765,6 +765,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Linee",         "Line with Markers" => "Linee con indicatori",         "Stacked Line" => "Linee in pila",+        "100% Stacked Line" => "Linee in pila 100%",+        "100% Stacked Line with Markers" => "Linee in pila 100% con indicatori",         "Pie" => "Torta",         "Doughnut" => "Anello",         "Area" => "Area",diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs--- a/crates/ui-egui/src/i18n/ja.rs+++ b/crates/ui-egui/src/i18n/ja.rs@@ -777,6 +777,8 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[     ("Line", "折れ線"),     ("Line with Markers", "マーカー付き折れ線"),     ("Stacked Line", "積み上げ折れ線"),+    ("100% Stacked Line", "100% 積み上げ折れ線"),+    ("100% Stacked Line with Markers", "マーカー付き 100% 積み上げ折れ線"),     ("Pie", "円"),     ("Doughnut", "ドーナツ"),     ("Area", "面"),diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs--- a/crates/ui-egui/src/i18n/pl.rs+++ b/crates/ui-egui/src/i18n/pl.rs@@ -759,6 +759,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Liniowy",         "Line with Markers" => "Liniowy ze znacznikami",         "Stacked Line" => "Liniowy skumulowany",+        "100% Stacked Line" => "Liniowy 100% skumulowany",+        "100% Stacked Line with Markers" => "Liniowy 100% skumulowany ze znacznikami",         "Pie" => "Kołowy",         "Doughnut" => "Pierścieniowy",         "Area" => "Warstwowy",diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs--- a/crates/ui-egui/src/i18n/pt.rs+++ b/crates/ui-egui/src/i18n/pt.rs@@ -780,6 +780,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Linhas",         "Line with Markers" => "Linhas com Marcadores",         "Stacked Line" => "Linhas Empilhadas",+        "100% Stacked Line" => "Linhas 100% Empilhadas",+        "100% Stacked Line with Markers" => "Linhas 100% Empilhadas com Marcadores",         "Pie" => "Pizza",         "Doughnut" => "Rosca",         "Area" => "Área",diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs--- a/crates/ui-egui/src/i18n/ro.rs+++ b/crates/ui-egui/src/i18n/ro.rs@@ -765,6 +765,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Linie",         "Line with Markers" => "Linii cu marcaje",         "Stacked Line" => "Linii stivuite",+        "100% Stacked Line" => "Linii 100% stivuite",+        "100% Stacked Line with Markers" => "Linii 100% stivuite cu marcaje",         "Pie" => "Radială",         "Doughnut" => "Inel",         "Area" => "Arie",diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs--- a/crates/ui-egui/src/i18n/uk.rs+++ b/crates/ui-egui/src/i18n/uk.rs@@ -762,6 +762,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {         "Line" => "Line",         "Line with Markers" => "Line with Markers",         "Stacked Line" => "Stacked Line",+        "100% Stacked Line" => "100% Stacked Line",+        "100% Stacked Line with Markers" => "100% Stacked Line with Markers",         "Pie" => "Pie",         "Doughnut" => "Doughnut",         "Area" => "Area",diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs--- a/crates/ui-egui/src/ribbon.rs+++ b/crates/ui-egui/src/ribbon.rs@@ -1739,6 +1739,8 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {                         ("Line", "line", ""),                         ("Line with Markers", "line", "markers"),                         ("Stacked Line", "line", "stacked"),+                        ("100% Stacked Line", "line", "stacked100"),+                        ("100% Stacked Line with Markers", "line", "stacked100Markers"),                         ("Area", "area", ""),                         ("Stacked Area", "area", "stacked"),                         ("100% Stacked Area", "area", "stacked100"),diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs--- a/crates/ui-egui/tests/it/interaction.rs+++ b/crates/ui-egui/tests/it/interaction.rs@@ -407,6 +407,29 @@ fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() {     assert_eq!(kinds, [gridcraft_engine::model::ChartKind::BarStacked100]); } +#[test]+fn insert_line_or_area_menu_inserts_hundred_percent_stacked_lines() {+    // Insert › Line or Area (keytip N, N1) lists both 100% stacked line types.+    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);+    for label in ["100% Stacked Line", "100% Stacked Line with Markers"] {+        key(&mut h, Key::F10, Modifiers::NONE);+        for (k, t) in [(Key::N, "n"), (Key::N, "n"), (Key::Num1, "1")] {+            h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });+            h.input_mut().events.push(Event::Text(t.into()));+            h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });+            h.run_steps(2);+        }+        h.run_steps(2);+        h.get_by_role_and_label(egui::accesskit::Role::Button, label).click();+        h.run_steps(2);+    }+    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::LineStacked100, gridcraft_engine::model::ChartKind::LineStacked100Markers]);+}+ #[test] /// Every main ribbon tab and dark mode render with accessibility checks. fn renders_every_ribbon_tab_without_panicking() {@@ -622,6 +645,24 @@ fn all_charts_inserts_a_hundred_percent_stacked_area() {     assert_eq!(kinds, [gridcraft_engine::model::ChartKind::AreaStacked100]); } +#[test]+fn all_charts_inserts_a_hundred_percent_stacked_line_with_markers() {+    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);+    assert!(h.query_by_label("100% Stacked Line").is_some(), "the list has 100% Stacked Line");+    h.get_by_label("100% Stacked Line with Markers").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::LineStacked100Markers]);+}+ #[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 firstdiff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs--- a/crates/xlsx/src/chart.rs+++ b/crates/xlsx/src/chart.rs@@ -29,12 +29,15 @@ fn group_kind(g: &El) -> Option<ChartKind> {             }         }         "lineChart" | "line3DChart" => {-            if stacked || pct {-                ChartKind::LineStacked-            } else {-                let group_markers = g.child_val("marker").is_none_or(|v| v == "1" || v == "true");-                let all_none = g.kids("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) == Some("none"));-                if !group_markers || (all_none && g.kids("ser").next().is_some()) { ChartKind::Line } else { ChartKind::LineMarkers }+            let group_markers = g.child_val("marker").is_none_or(|v| v == "1" || v == "true");+            let all_none = g.kids("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) == Some("none"));+            let markers = group_markers && !(all_none && g.kids("ser").next().is_some());+            match (stacked, pct, markers) {+                (true, _, _) => ChartKind::LineStacked,+                (_, true, true) => ChartKind::LineStacked100Markers,+                (_, true, false) => ChartKind::LineStacked100,+                (_, _, true) => ChartKind::LineMarkers,+                (_, _, false) => ChartKind::Line,             }         }         "pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie,@@ -284,6 +287,7 @@ pub fn read_chart(root: &El) -> Option<Chart> { enum G {     Bar { bar: bool, grouping: &'static str },     Line { stacked: bool, markers: bool },+    Line100 { markers: bool },     Pie,     Doughnut,     Area { grouping: &'static str },@@ -303,6 +307,8 @@ fn group_for(k: ChartKind) -> G {         ChartKind::Line => G::Line { stacked: false, markers: false },         ChartKind::LineMarkers => G::Line { stacked: false, markers: true },         ChartKind::LineStacked => G::Line { stacked: true, markers: false },+        ChartKind::LineStacked100 => G::Line100 { markers: false },+        ChartKind::LineStacked100Markers => G::Line100 { markers: true },         ChartKind::Pie => G::Pie,         ChartKind::Doughnut => G::Doughnut,         ChartKind::Area => G::Area { grouping: "standard" },@@ -533,7 +539,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String         }     }     let color = s.color.as_ref().and_then(|c: &Color| srgb(c, &wb.theme));-    let line_like = matches!(g, G::Line { .. } | G::Radar | G::Scatter { lines: true });+    let line_like = matches!(g, G::Line { .. } | G::Line100 { .. } | G::Radar | G::Scatter { lines: true });     match (&color, line_like) {         (Some(c), true) => {             let _ = write!(x, "<c:spPr><a:ln w=\"28575\" cap=\"rnd\"><a:solidFill>{c}</a:solidFill><a:round/></a:ln></c:spPr>");@@ -552,7 +558,9 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String     }     match g {         G::Bar { .. } => x.push_str("<c:invertIfNegative val=\"0\"/>"),-        G::Line { markers: false, .. } | G::Scatter { lines: true } | G::Stock => x.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>"),+        G::Line { markers: false, .. } | G::Line100 { markers: false } | G::Scatter { lines: true } | G::Stock => {+            x.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>")+        }         G::Bubble => x.push_str("<c:invertIfNegative val=\"0\"/>"),         _ => {}     }@@ -581,7 +589,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String                 x.push_str(&cat_data(c, &s.values, wb, si));             }             x.push_str(&num_data("val", Some(&s.values), wb, si));-            if matches!(g, G::Line { .. }) {+            if matches!(g, G::Line { .. } | G::Line100 { .. }) {                 let _ = write!(x, "<c:smooth val=\"{}\"/>", s.smooth as u8);             }         }@@ -670,6 +678,9 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb             "<c:lineChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}<c:marker val=\"1\"/>{ax}</c:lineChart>",             if stacked { "stacked" } else { "standard" }         ),+        G::Line100 { .. } => {+            format!("<c:lineChart><c:grouping val=\"percentStacked\"/><c:varyColors val=\"0\"/>{sers}{dl}<c:marker val=\"1\"/>{ax}</c:lineChart>")+        }         G::Pie => format!("<c:pieChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/></c:pieChart>"),         G::Doughnut => {             format!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")@@ -766,7 +777,9 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> Str     if ch.kind == ChartKind::Combo {         // Series without a kind of their own: the first is a column, the others lines (as drawn).         let kind = |i: usize, x: &Series| x.kind.filter(|k| *k != ChartKind::Combo).or((i > 0).then_some(ChartKind::Line));-        let is_line = |k: Option<ChartKind>| matches!(k.map(group_for), Some(G::Line { .. } | G::Area { .. } | G::Radar | G::Scatter { .. }));+        let is_line = |k: Option<ChartKind>| {+            matches!(k.map(group_for), Some(G::Line { .. } | G::Line100 { .. } | G::Area { .. } | G::Radar | G::Scatter { .. }))+        };         let bars: Vec<(usize, &Series)> = all.iter().filter(|(i, x)| !x.secondary && !is_line(kind(*i, x))).copied().collect();         let lines: Vec<(usize, &Series)> = all.iter().filter(|(i, x)| !x.secondary && is_line(kind(*i, x))).copied().collect();         let sec: Vec<(usize, &Series)> = all.iter().filter(|(_, x)| x.secondary).copied().collect();diff --git a/crates/xlsx/src/tests/chart_line_percent_stacked.rs b/crates/xlsx/src/tests/chart_line_percent_stacked.rsnew file mode 100644--- /dev/null+++ b/crates/xlsx/src/tests/chart_line_percent_stacked.rs@@ -0,0 +1,57 @@+//! Line chart groups by `grouping` and markers: `percentStacked` opens as a 100% stacked line.++use gridcraft_model::{ChartKind, Workbook};++use crate::chart::{read_chart, write_chart};+use crate::xml::parse;++/// Reads a line chart whose one group has `grouping` and whose one series holds `marker`.+fn kind(grouping: &str, marker: &str) -> ChartKind {+    let xml = format!(+        "<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\"><c:chart><c:plotArea><c:lineChart>\+         <c:grouping val=\"{grouping}\"/><c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>{marker}\+         <c:val><c:numRef><c:f>Data!$A$1:$A$5</c:f></c:numRef></c:val></c:ser><c:marker val=\"1\"/>\+         <c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart><c:catAx><c:axId val=\"1\"/></c:catAx><c:valAx><c:axId val=\"2\"/></c:valAx>\+         </c:plotArea></c:chart></c:chartSpace>"+    );+    let root = parse(xml.as_bytes()).unwrap();+    read_chart(&root).unwrap().kind+}++const NO_MARKER: &str = "<c:marker><c:symbol val=\"none\"/></c:marker>";++#[test]+fn percent_stacked_lines_open_as_hundred_percent_stacked() {+    assert_eq!(kind("percentStacked", NO_MARKER), ChartKind::LineStacked100);+    assert_eq!(kind("percentStacked", ""), ChartKind::LineStacked100Markers);+}++#[test]+fn standard_and_stacked_lines_keep_their_kinds() {+    assert_eq!(kind("standard", NO_MARKER), ChartKind::Line);+    assert_eq!(kind("standard", ""), ChartKind::LineMarkers);+    assert_eq!(kind("stacked", NO_MARKER), ChartKind::LineStacked);+    assert_eq!(kind("stacked", ""), ChartKind::LineStacked);+}++#[test]+fn a_hundred_percent_stacked_line_series_in_a_combo_chart_is_saved_as_a_line() {+    let ser =+        |i: usize| format!("<c:ser><c:idx val=\"{i}\"/><c:order val=\"{i}\"/><c:val><c:numRef><c:f>Data!$A$1:$A$5</c:f></c:numRef></c:val></c:ser>");+    let xml = format!(+        "<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\"><c:chart><c:plotArea>\+         <c:barChart><c:barDir val=\"col\"/><c:grouping val=\"clustered\"/>{}<c:axId val=\"1\"/><c:axId val=\"2\"/></c:barChart>\+         <c:lineChart><c:grouping val=\"percentStacked\"/>{}<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>\+         </c:plotArea></c:chart></c:chartSpace>",+        ser(0),+        ser(1)+    );+    let ch = read_chart(&parse(xml.as_bytes()).unwrap()).unwrap();+    assert_eq!(ch.kind, ChartKind::Combo);+    assert_eq!(ch.series.iter().map(|s| s.kind).collect::<Vec<_>>(), [Some(ChartKind::ColumnClustered), Some(ChartKind::LineStacked100Markers)]);+    let saved = write_chart(&Workbook::default(), 0, &ch, "Book1");+    let again = read_chart(&parse(saved.as_bytes()).unwrap()).unwrap();+    let kinds: Vec<Option<ChartKind>> = again.series.iter().map(|s| s.kind).collect();+    assert_eq!(kinds.first(), Some(&Some(ChartKind::ColumnClustered)), "{saved}");+    assert!(kinds.get(1).is_some_and(|k| !matches!(k, Some(ChartKind::ColumnClustered))), "the line series stays out of the column group: {saved}");+}diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs--- a/crates/xlsx/src/tests/mod.rs+++ b/crates/xlsx/src/tests/mod.rs@@ -5,6 +5,7 @@ mod cellmetadata; mod chart_book_names; mod chart_bool_default; mod chart_combo_kinds;+mod chart_line_percent_stacked; mod chart_ref_offsets; mod csv_tests; mod encrypted_tests;diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs--- a/crates/xlsx/src/tests/roundtrip.rs+++ b/crates/xlsx/src/tests/roundtrip.rs@@ -425,6 +425,8 @@ fn sample() -> Workbook {         ChartKind::Line,         ChartKind::LineMarkers,         ChartKind::LineStacked,+        ChartKind::LineStacked100,+        ChartKind::LineStacked100Markers,         ChartKind::Pie,         ChartKind::Doughnut,         ChartKind::Area,diff --git a/docs/chart-parity.md b/docs/chart-parity.md--- 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-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-25 · **Change:** minor (100% stacked line 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).@@ -15,7 +15,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate |---|---|---|---| | Column | clustered, stacked, 100% stacked, 3-D clustered/stacked/100%, 3-D column | 3 of 7 | no 3-D | | 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) |+| Line | line, stacked, 100% stacked, with markers ×3, 3-D line | 5 of 7 (line, with markers, stacked, 100% stacked, 100% stacked with markers) | no stacked with markers, no 3-D; 100% stacked drawn as shares of each category's total and kept as `percentStacked` in XLSX; 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 | 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 |@@ -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 | **26 (~43%)** | usage-weighted breadth higher (~65%): column, bar, line, pie and scatter dominate |+| **Total** | ~60 subtypes | **28 (~47%)** | 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-25 | minor | 100% Stacked Line and 100% Stacked Line with Markers: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 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) |