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
@@ -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`) |@@ -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 {@@ -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", "", ""];@@ -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,new file mode 100644@@ -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]);+}@@ -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;@@ -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)"), }),@@ -406,6 +406,8 @@ pub enum ChartKind { Line, LineMarkers, LineStacked,+ LineStacked100,+ LineStacked100Markers, Pie, Doughnut, Area,@@ -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"),@@ -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" => "Περιοχή",@@ -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",@@ -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", "面"),@@ -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",@@ -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",@@ -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",@@ -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",@@ -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"),@@ -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 first@@ -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();new file mode 100644@@ -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}");+}@@ -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;@@ -425,6 +425,8 @@ fn sample() -> Workbook { ChartKind::Line, ChartKind::LineMarkers, ChartKind::LineStacked,+ ChartKind::LineStacked100,+ ChartKind::LineStacked100Markers, ChartKind::Pie, ChartKind::Doughnut, ChartKind::Area,@@ -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) |