Merge pull request #313 from vangel76/feat/pivot-chart
Charts: PivotCharts (insert.pivotChart) (#311) # Conflicts: # ROADMAP.md # crates/engine/src/cmd/pivot.rs # docs/gaps.md # docs/parity-checklist.md # docs/target-app-parity.md
This commit is contained in:
commit
97bad77ef4
|
|
@ -147,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
| 2026-10-11 | Cell checkboxes: Insert › Checkbox on the ribbon, Excel's Delete and value rules, XLSX round trip as Excel cell controls (#196) |
|
||||
| 2026-10-11 | Review › Allow Edit Ranges with Excel-compatible password hashes, kept in XLSX (#307) |
|
||||
| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) |
|
||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
|
|
|
|||
|
|
@ -46,12 +46,54 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
|
|||
Some((sh, r))
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped.
|
||||
/// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`.
|
||||
fn union_parts(f: &str) -> Option<Vec<&str>> {
|
||||
let inner = f.strip_prefix('(')?.strip_suffix(')')?;
|
||||
let mut parts = Vec::new();
|
||||
let (mut depth, mut quoted, mut start) = (0i32, false, 0usize);
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
'(' if !quoted => depth += 1,
|
||||
')' if !quoted => depth -= 1,
|
||||
',' if !quoted && depth == 0 => {
|
||||
parts.push(inner.get(start..i)?);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if depth < 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
parts.push(inner.get(start..)?);
|
||||
(parts.len() > 1).then_some(parts)
|
||||
}
|
||||
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
|
||||
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
|
||||
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
|
||||
eval_list_in(wb, sheet, formula, 0)
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
|
||||
let f = formula.trim();
|
||||
if f.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
if depth < 4
|
||||
&& let Some(parts) = union_parts(f)
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for p in parts {
|
||||
if out.len() >= MAX_POINTS {
|
||||
break;
|
||||
}
|
||||
let rest = MAX_POINTS - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match evaluate(wb, sheet, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
|
||||
v => vec![v],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ fn chart(kind: ChartKind) -> Chart {
|
|||
y_title: Some("Revenue".into()),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -658,3 +659,29 @@ fn treemap_and_histogram_shapes() {
|
|||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_series_references() {
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
for (r, (l, v)) in [("a", 1.0), ("b", 2.0), ("Total", 3.0), ("d", 4.0)].iter().enumerate() {
|
||||
s.set_value(CellRef::new(r as u32, 0), Value::from(*l));
|
||||
s.set_value(CellRef::new(r as u32, 1), Value::Number(*v));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("\"Sum of X\"".into()),
|
||||
categories: Some("(Sheet1!$A$1:$A$2,Sheet1!$A$4)".into()),
|
||||
values: "(Sheet1!$B$1:$B$2,Sheet1!$B$4)".into(),
|
||||
..Series::default()
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].values, vec![Some(1.0), Some(2.0), Some(4.0)]);
|
||||
assert_eq!(d.series[0].name, "Sum of X");
|
||||
assert_eq!(d.categories, vec!["a", "b", "d"]);
|
||||
// Deeply nested parentheses from a file are bounded, not a crash.
|
||||
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let _ = resolve(&wb, 0, &c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -606,6 +606,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
calculated_field
|
||||
),
|
||||
cmd!(
|
||||
"insert.pivotChart",
|
||||
"PivotChart",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{pivot?: name | id | a cell of the report (\"Sheet2!B5\"; default: the PivotTable at the active cell), type?: column|bar|line|pie|area|… (default column), subtype?, title?, at?: \"H2\", width?, height?} adds a chart bound to the PivotTable's report: categories are the innermost row items, series the column items × value fields; it follows refreshes and layout changes → {chart, pivot, series}",
|
||||
has_doc,
|
||||
insert_pivot_chart
|
||||
),
|
||||
cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields),
|
||||
cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields),
|
||||
cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list),
|
||||
|
|
@ -1272,6 +1281,18 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
edit(s, |cx| {
|
||||
// Its PivotCharts become ordinary charts.
|
||||
let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone())));
|
||||
if let Some((sheet, name)) = link {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name));
|
||||
if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) {
|
||||
for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) {
|
||||
c.pivot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pv::clear_output(&mut cx.wb, si, pi);
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if pi < sh.pivots.len() {
|
||||
|
|
@ -1282,6 +1303,80 @@ fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotChart
|
||||
|
||||
/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report.
|
||||
fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
if let Some(t) = str_param(p, "pivot") {
|
||||
let d = s.doc()?;
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
};
|
||||
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
|
||||
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
|
||||
return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable")));
|
||||
}
|
||||
}
|
||||
find_pivot(s, p)
|
||||
}
|
||||
|
||||
fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = chart_pivot(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?;
|
||||
let out = pv::compute(&d.wb, pt, si).map_err(other)?;
|
||||
if out.chart_rows.is_empty() || out.chart_cols.is_empty() {
|
||||
return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart."));
|
||||
}
|
||||
let series = pv::chart_series(&sh.name, area.start, &out);
|
||||
let t = str_param(p, "type").unwrap_or("column");
|
||||
let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
let at = cell_param(p, "at").unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let title = match p.get("title") {
|
||||
Some(Json::String(t)) => Some(t.clone()),
|
||||
Some(Json::Null) => None,
|
||||
_ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }),
|
||||
};
|
||||
let id = d.wb.next_object_id();
|
||||
let link = pv::chart_link(&sh.name, &pt.name);
|
||||
let pivot_name = pt.name.clone();
|
||||
let n = series.len();
|
||||
let chart = Chart {
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
cell: at,
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32,
|
||||
height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32,
|
||||
mode: anchor_mode_param(p).unwrap_or_default(),
|
||||
},
|
||||
title,
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: Some(link),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.charts.push(chart);
|
||||
Ok(json!({"chart": id, "pivot": pivot_name, "series": n}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recommended
|
||||
|
||||
fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
|
||||
|
|
@ -1651,6 +1746,76 @@ mod tests {
|
|||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
|
||||
fn pivot_chart(s: &Session) -> gridcraft_model::Chart {
|
||||
s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone()
|
||||
}
|
||||
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
|
||||
let d = s.doc().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
|
||||
data.series.into_iter().map(|x| x.values).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_follows_the_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
// Any cell of the report picks the PivotTable.
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
assert_eq!(ch.title.as_deref(), Some("Total"));
|
||||
assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6");
|
||||
assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6"));
|
||||
assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]);
|
||||
// A column field: one series per item, rebound by the layout change.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]);
|
||||
assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]);
|
||||
// Nested row fields: the innermost items only, as a union around the parent rows.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
let ch = pivot_chart(&s);
|
||||
assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values);
|
||||
let pts = plotted(&s);
|
||||
assert_eq!(pts[0].len(), 9);
|
||||
assert_eq!(pts[0].iter().flatten().sum::<f64>(), TOTAL);
|
||||
// Survives an XLSX round trip as a PivotChart.
|
||||
let wb = (*s.doc().unwrap().wb).clone();
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
let si = back.sheet_index("Sheet2").unwrap();
|
||||
assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1"));
|
||||
// Renaming the sheet keeps the link; a refresh still rebinds.
|
||||
s.execute("sheet.rename", json!({"name": "Report"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1"));
|
||||
s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).series.len(), 3);
|
||||
// Deleting the PivotTable leaves an ordinary chart.
|
||||
s.execute("pivot.delete", json!({})).unwrap();
|
||||
assert_eq!(pivot_chart(&s).pivot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_errors() {
|
||||
let mut s = sales();
|
||||
// No PivotTable yet.
|
||||
assert!(s.execute("insert.pivotChart", json!({})).is_err());
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap();
|
||||
// Not in the report; no values.
|
||||
assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err());
|
||||
assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values"));
|
||||
s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap();
|
||||
let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap();
|
||||
assert_eq!(r["series"], 1);
|
||||
let ch = pivot_chart(&s);
|
||||
assert_eq!(ch.kind, gridcraft_model::ChartKind::Line);
|
||||
assert_eq!(ch.title, None);
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculated_field_on_sums() {
|
||||
let mut s = sales();
|
||||
|
|
|
|||
|
|
@ -542,6 +542,12 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
{
|
||||
ch.pivot = Some(crate::pivot::chart_link(&name, &pivot));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
|
|
|
|||
|
|
@ -505,6 +505,12 @@ pub struct Output {
|
|||
/// Rows above the body (report filters plus a blank row).
|
||||
pub body_offset: u32,
|
||||
pub width: u32,
|
||||
/// Grid rows of the innermost (or collapsed) items: a PivotChart's categories.
|
||||
pub chart_rows: Vec<u32>,
|
||||
/// Grid columns of item (not total) values with their series names: a PivotChart's series.
|
||||
pub chart_cols: Vec<(u32, String)>,
|
||||
/// Grid column holding the innermost row labels (`None` without row fields).
|
||||
pub label_col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
|
|
@ -548,6 +554,8 @@ enum LineKind {
|
|||
|
||||
struct Line {
|
||||
kind: LineKind,
|
||||
/// A category of a PivotChart: an innermost (or collapsed) item.
|
||||
leaf: bool,
|
||||
path: Vec<u32>,
|
||||
values: bool,
|
||||
/// (column, value, indent, number format)
|
||||
|
|
@ -589,14 +597,21 @@ impl Ctx<'_> {
|
|||
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(depth + 1 < n), path: prefix.clone(), values: true, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(depth + 1 < n),
|
||||
leaf: true,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
});
|
||||
} else {
|
||||
let top = self.pt.subtotals && self.pt.subtotals_top;
|
||||
out.push(Line { kind: LineKind::Item(true), path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
self.flatten(&node.children, depth + 1, prefix, out);
|
||||
if self.pt.subtotals && !self.pt.subtotals_top {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
|
||||
|
|
@ -623,12 +638,13 @@ impl Ctx<'_> {
|
|||
labels.push((depth, v, 0, fmt));
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), path: prefix.clone(), values: true, labels });
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
|
||||
} else {
|
||||
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
|
||||
if self.pt.subtotals {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
|
||||
|
|
@ -793,7 +809,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if rows.is_empty() {
|
||||
lines.push(Line { kind: LineKind::Item(false), path: vec![], values: true, labels: vec![] });
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
|
||||
} else {
|
||||
if pt.layout == PivotLayout::Tabular {
|
||||
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
|
||||
|
|
@ -801,7 +817,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
|
||||
}
|
||||
if pt.grand_totals_cols {
|
||||
lines.push(Line { kind: LineKind::Grand, path: vec![], values: true, labels: vec![(0, Value::text("Grand Total"), 0, None)] });
|
||||
lines.push(Line {
|
||||
kind: LineKind::Grand,
|
||||
leaf: false,
|
||||
path: vec![],
|
||||
values: true,
|
||||
labels: vec![(0, Value::text("Grand Total"), 0, None)],
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut specs: Vec<ColSpec> = Vec::new();
|
||||
|
|
@ -867,7 +889,15 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
if empty {
|
||||
let r = g.push_row(RowKind::Placeholder)?;
|
||||
g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None);
|
||||
return Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset });
|
||||
return Ok(Output {
|
||||
width: g.width as u32,
|
||||
grid: g.rows,
|
||||
row_kinds: g.kinds,
|
||||
body_offset,
|
||||
chart_rows: vec![],
|
||||
chart_cols: vec![],
|
||||
label_col: None,
|
||||
});
|
||||
}
|
||||
|
||||
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
|
||||
|
|
@ -1003,6 +1033,33 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut chart_rows: Vec<u32> = Vec::new();
|
||||
let chart_cols: Vec<(u32, String)> = data_cols
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(j, (si, vi))| {
|
||||
let spec = specs.get(*si)?;
|
||||
if spec.kind != ColKind::Leaf {
|
||||
return None;
|
||||
}
|
||||
let mut parts: Vec<String> =
|
||||
spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect();
|
||||
if let Some(vi) = vi
|
||||
&& (k > 1 || parts.is_empty())
|
||||
{
|
||||
parts.push(val_name(*vi));
|
||||
}
|
||||
Some(((l + j) as u32, parts.join(" - ")))
|
||||
})
|
||||
.filter(|_| k > 0)
|
||||
.collect();
|
||||
let label_col = if rows.is_empty() {
|
||||
None
|
||||
} else if compact {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(rows.len().saturating_sub(1) as u32)
|
||||
};
|
||||
for line in &lines {
|
||||
let rk = match line.kind {
|
||||
LineKind::Item(b) => RowKind::Item(b),
|
||||
|
|
@ -1015,6 +1072,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal),
|
||||
};
|
||||
let r = g.push_row(rk)?;
|
||||
if line.leaf && k > 0 {
|
||||
chart_rows.push(r as u32);
|
||||
}
|
||||
for (c, v, indent, fmt) in &line.labels {
|
||||
g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone());
|
||||
}
|
||||
|
|
@ -1069,7 +1129,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
|||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
}
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset })
|
||||
Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- styling and writing
|
||||
|
|
@ -1259,9 +1319,105 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
|||
if let Some(p) = shm.pivots.get_mut(idx) {
|
||||
p.last_range = Some(area);
|
||||
}
|
||||
let sheet_name = shm.name.clone();
|
||||
rebind_charts(wb, &sheet_name, &pt.name, origin, &out);
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PivotCharts
|
||||
|
||||
/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`.
|
||||
pub fn chart_link(sheet: &str, pivot: &str) -> String {
|
||||
format!("{}!{pivot}", quote_sheet(sheet))
|
||||
}
|
||||
|
||||
/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored.
|
||||
pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
let t = link.trim();
|
||||
let t = match t.strip_prefix('[') {
|
||||
Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest),
|
||||
None => t,
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((sheet, name.to_string()))
|
||||
}
|
||||
|
||||
/// Whether a PivotChart link points at `pivot` on `sheet`.
|
||||
pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool {
|
||||
parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot))
|
||||
}
|
||||
|
||||
/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union).
|
||||
fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<String> {
|
||||
let q = quote_sheet(sheet);
|
||||
let c = origin.col.checked_add(col)?;
|
||||
let letters = gridcraft_core::col_to_letters(c);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&start) = rows.get(i) {
|
||||
let mut end = start;
|
||||
while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) {
|
||||
end += 1;
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1);
|
||||
parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") });
|
||||
}
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.pop(),
|
||||
_ => Some(format!("({})", parts.join(","))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most category rows a PivotChart plots.
|
||||
const MAX_CHART_ROWS: usize = 10_000;
|
||||
|
||||
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
|
||||
/// items, one series per item column (column items × value fields); totals are left out.
|
||||
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
let rows: Vec<u32> = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect();
|
||||
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
|
||||
out.chart_cols
|
||||
.iter()
|
||||
.filter_map(|(c, name)| {
|
||||
Some(Series {
|
||||
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
|
||||
categories: categories.clone(),
|
||||
values: rows_ref(sheet, origin, *c, &rows)?,
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuilds the series of every PivotChart of a PivotTable after its report was written.
|
||||
fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {
|
||||
let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));
|
||||
let hits: Vec<usize> = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect();
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
let series = chart_series(sheet, origin, out);
|
||||
for i in hits {
|
||||
let Some(sh) = wb.sheet_mut(i) else { continue };
|
||||
for ch in sh.charts.iter_mut().filter(|c| linked(c)) {
|
||||
ch.series = series.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears a pivot's last output area.
|
||||
pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
let Some(shm) = wb.sheet_mut(sheet) else { return };
|
||||
|
|
|
|||
|
|
@ -406,6 +406,10 @@ pub struct Chart {
|
|||
/// Source range when created from a selection (for Switch Row/Column).
|
||||
pub source: Option<String>,
|
||||
pub by_rows: bool,
|
||||
/// A PivotChart's PivotTable (`Sheet2!PivotTable1`): its series are rebuilt from the report
|
||||
/// whenever the PivotTable is refreshed or its layout changes.
|
||||
#[serde(default)]
|
||||
pub pivot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ pub fn read_chart(root: &El) -> Option<Chart> {
|
|||
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +466,10 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
|
|||
if (1..=48).contains(&ch.style) {
|
||||
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
|
||||
}
|
||||
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
|
||||
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
|
||||
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
|
||||
}
|
||||
s.push_str("<c:chart>");
|
||||
match &ch.title {
|
||||
Some(t) if !t.is_empty() => {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
|
|||
y_title: axis_title("1"),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ fn chartex_parts_as_excel_expects_them() {
|
|||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
wb.sheets = vec![std::sync::Arc::new(sheet)];
|
||||
let bytes = crate::write_xlsx(&wb).unwrap();
|
||||
|
|
|
|||
|
|
@ -340,6 +340,7 @@ fn sample() -> Workbook {
|
|||
y_title: Some("Amount".into()),
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
};
|
||||
for kind in [
|
||||
ChartKind::ColumnClustered,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fn smooth_line_setting_round_trips() {
|
|||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
let mut smooth_series = base_series.clone();
|
||||
smooth_series.smooth = true;
|
||||
|
|
@ -50,6 +51,7 @@ fn smooth_line_setting_round_trips() {
|
|||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
});
|
||||
wb.sheets = vec![Arc::new(s)];
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit of the chart model, renderer and XLSX chart parts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotCharts) · **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).
|
||||
|
|
@ -53,7 +53,7 @@ open PRs #158, #161, and issue #190.
|
|||
| Recommended Charts | yes | partial (Insert ▸ Recommended) |
|
||||
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
|
||||
| Sparklines (line, column, win/loss, markers, axis) | yes | line, column, win/loss |
|
||||
| PivotCharts | yes | **missing** |
|
||||
| PivotCharts | yes | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only |
|
||||
| Animation on data change | yes (Windows) | no |
|
||||
|
||||
## XLSX round-trip
|
||||
|
|
@ -72,11 +72,12 @@ back.
|
|||
4. Trendlines and error bars (maths shared with LINEST/LOGEST/GROWTH): 6–10 h.
|
||||
5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h.
|
||||
6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h.
|
||||
7. Chart sheets, PivotCharts, templates: 6–10 h. Filled map: owner decision on boundary data.
|
||||
7. Chart sheets, templates, PivotChart field buttons and multi-level categories: 6–10 h. Filled map: owner decision on boundary data.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-10 | minor | Smooth lines (#62) and Switch Row/Column on file charts (#116) landed; chart resize bug (#190) |
|
||||
| 2026-10-10 | major | Created from a source audit: 25 kinds (25 of ~60 subtypes), element gaps, round-trip loss |
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
| 2026-10-11 | minor | §15: TRIMRANGE added (21 functions missing; trim-reference operators still missing) |
|
||||
| 2026-10-11 | minor | §16: Allow Edit Ranges landed (#307) |
|
||||
| 2026-10-11 | minor | §8: calculated fields added |
|
||||
| 2026-10-11 | minor | §8: PivotCharts added |
|
||||
| 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks the alpha gate |
|
||||
| 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues |
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
|
|||
|---|---|---|---|
|
||||
| File | 10 | 11 | 91% |
|
||||
| Home | 94 | 94 | 100% |
|
||||
| Insert | 15 | 25 | 60% |
|
||||
| Insert | 16 | 25 | 64% |
|
||||
| Draw | 4 | 4 | 100% |
|
||||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
|
|
@ -142,7 +142,7 @@ Implemented:
|
|||
- `edit.goToSpecial` Go To Special (Editing)
|
||||
- `home.addIns` Add-ins (Add-ins)
|
||||
|
||||
## Insert (15 / 25, 60%)
|
||||
## Insert (16 / 25, 64%)
|
||||
|
||||
Implemented:
|
||||
|
||||
|
|
@ -154,6 +154,7 @@ Implemented:
|
|||
- `insert.icons` Icons (Illustrations)
|
||||
- `insert.recommendedCharts` Recommended Charts (Charts)
|
||||
- `insert.chart` Charts (Charts)
|
||||
- `insert.pivotChart` PivotChart (Charts)
|
||||
- `insert.sparkline` Sparklines (Sparklines)
|
||||
- `insert.link` Link (Links)
|
||||
- `review.newComment` Comment (Comments)
|
||||
|
|
@ -166,7 +167,6 @@ Missing:
|
|||
|
||||
- `insert.smartArt` SmartArt (Illustrations)
|
||||
- `insert.screenshot` Screenshot (Illustrations)
|
||||
- `insert.pivotChart` PivotChart (Charts)
|
||||
- `insert.map` Maps (Charts)
|
||||
- `insert.slicer` Slicer (Filters)
|
||||
- `insert.timeline` Timeline (Filters)
|
||||
|
|
|
|||
|
|
@ -339,6 +339,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
|||
| 2026-10-11 | minor | View › Formula Bar and Zoom 100% are engine commands (CLI/MCP); catalog 258 → 260 / 290 (#289) |
|
||||
| 2026-10-11 | minor | Review › Allow Edit Ranges; catalog 258 → 259 / 290 (#307) |
|
||||
| 2026-10-11 | minor | PivotTables: calculated fields (`pivot.calculatedField`) |
|
||||
| 2026-10-11 | minor | PivotTables: PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
|
||||
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
|
||||
|
|
|
|||
Loading…
Reference in New Issue