Merge storytold/gridcraft#2522: Charts: 100% Stacked Area draws each value as a share of its category's total

# Conflicts:
#	ROADMAP.md
This commit is contained in:
aiongg
2026-10-25 02:54:23 +00:00
20 changed files with 162 additions and 29 deletions
+1
View File
@@ -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) |
+30 -14
View File
@@ -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<bool> = 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<f64>)> = 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<f64>, Vec<f64>)> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = 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),
};
+42 -2
View File
@@ -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", "", ""];
+2 -1
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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,
@@ -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<ChartKind> {
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]);
}
+1
View File
@@ -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;
+1
View File
@@ -410,6 +410,7 @@ pub enum ChartKind {
Doughnut,
Area,
AreaStacked,
AreaStacked100,
Scatter,
ScatterLines,
Bubble,
+2 -1
View File
@@ -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"),
+1
View File
@@ -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" => "Ακτινωτό",
+1
View File
@@ -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",
+1
View File
@@ -773,6 +773,7 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
("Doughnut", "ドーナツ"),
("Area", "面"),
("Stacked Area", "積み上げ面"),
("100% Stacked Area", "100% 積み上げ面"),
("Scatter", "散布図"),
("Scatter with Lines", "直線付き散布図"),
("Radar", "レーダー"),
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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", "")]),
+27
View File
@@ -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
+8 -8
View File
@@ -40,7 +40,9 @@ fn group_kind(g: &El) -> Option<ChartKind> {
"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!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")
}
G::Area { stacked } => format!(
"<c:areaChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>",
if stacked { "stacked" } else { "standard" }
),
G::Area { grouping } => format!("<c:areaChart><c:grouping val=\"{grouping}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>"),
G::Scatter { .. } => format!("<c:scatterChart><c:scatterStyle val=\"lineMarker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:scatterChart>"),
G::Bubble => {
format!("<c:bubbleChart><c:varyColors val=\"0\"/>{sers}{dl}<c:bubbleScale val=\"100\"/><c:showNegBubbles val=\"0\"/>{ax}</c:bubbleChart>")
+1
View File
@@ -429,6 +429,7 @@ fn sample() -> Workbook {
ChartKind::Doughnut,
ChartKind::Area,
ChartKind::AreaStacked,
ChartKind::AreaStacked100,
ChartKind::Scatter,
ChartKind::ScatterLines,
ChartKind::Bubble,
+4 -3
View File
@@ -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) |