Data: Analysis ToolPak with 13 tools and the Data Analysis dialog (#312)

data.analysisToolPak runs Descriptive Statistics, Histogram, Correlation,
Covariance, Regression, Moving Average, Exponential Smoothing, Rank and
Percentile, the three t-Tests, F-Test and Anova: Single Factor, writing a
static report to an output range or a new sheet. Non-empty output cells are
refused unless overwrite is set; the dialog asks instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:44:47 +02:00
parent 9f896c1249
commit 0dfd29efde
15 changed files with 1593 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(spelling::specs());
v.extend(pivot::specs());
v.extend(whatif::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
})

View File

@ -108,7 +108,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
/// A range text (`"A1:C9"`, `"Sheet2!A:C"`) resolved against the workbook. Whole rows/columns
/// are trimmed to the used range.
fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
let (sh, body) = split_sheet(t);
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;

View File

@ -317,6 +317,16 @@ impl Dialog {
json!({"header": true}),
),
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"analysisToolPak" => {
// Opened with a known `tool`, it goes straight to that tool's form.
let tool = params.get("tool").and_then(Json::as_str).unwrap_or("");
let mut d = Dialog::custom("analysisToolPak", "Data Analysis", json!({"tool": "", "pick": tool}));
if gridcraft_engine::cmd::analysis::TOOLS.iter().any(|(id, _)| *id == tool) {
d.values = toolpak_defaults(&sel, tool);
}
d.command = Some("data.analysisToolPak");
d
}
"subtotal" => Dialog::form(
"subtotal",
"Subtotal",
@ -659,6 +669,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"analysisToolPak" => 420.0,
_ => 380.0,
};
let window = egui::Window::new(d.title.clone())
@ -681,6 +692,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"manageRules" => manage_rules(app, ui),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
@ -878,6 +890,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let params = build_params(&d);
match app.run(cmd, params) {
Ok(_) => open = false,
// Data Analysis asks before replacing data in its output range.
Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => {
d.values.insert("askOverwrite".into(), json!(true));
}
Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))),
}
} else if confirm {
@ -967,6 +983,12 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
}
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
form_fields(ui, d);
ok_cancel(ui, confirm, open);
}
/// Draws a dialog's `fields`, editing its values.
fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
for f in d.fields.clone() {
match f {
@ -1022,7 +1044,6 @@ fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
ui.end_row();
}
});
ok_cancel(ui, confirm, open);
}
/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits
@ -1607,6 +1628,144 @@ fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mu
}
}
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
fn toolpak_fields(tool: &str) -> Vec<Field> {
use Field::*;
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
let labels = || Check { key: "labels", label: "Labels in first row" };
let range = || Text { key: "range", label: "Input Range:" };
let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }];
match tool {
"descriptive" => vec![
range(),
grouped(),
labels(),
Check { key: "summary", label: "Summary statistics" },
Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" },
Text { key: "kthLargest", label: "Kth Largest (blank for none):" },
Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" },
],
"histogram" => vec![
range(),
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
labels(),
Check { key: "pareto", label: "Pareto (sorted histogram)" },
Check { key: "cumulative", label: "Cumulative Percentage" },
],
"correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()],
"anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }],
"regression" => vec![
Text { key: "inputY", label: "Input Y Range:" },
Text { key: "inputX", label: "Input X Range:" },
labels(),
Check { key: "constantZero", label: "Constant is Zero" },
Text { key: "confidence", label: "Confidence Level (%):" },
Check { key: "residuals", label: "Residuals" },
Check { key: "standardizedResiduals", label: "Standardized Residuals" },
Check { key: "normalProbability", label: "Normal Probability" },
],
"movingAverage" => {
vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"exponentialSmoothing" => {
vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }]
}
"fTest" => {
let mut v = two();
v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]);
v
}
_ => {
let mut v = two();
v.extend([
Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" },
labels(),
Text { key: "alpha", label: "Alpha:" },
]);
v
}
}
}
/// The starting values of a tool's form: the selection as its input, a new sheet as output.
fn toolpak_defaults(sel: &str, tool: &str) -> Map<String, Json> {
let mut v = json!({
"tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "",
"groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "",
"interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0",
"outputTo": "newSheet", "output": "", "newSheet": "",
});
if tool == "regression" {
v["confidence"] = json!("95");
v["inputY"] = json!(sel);
}
v.as_object().cloned().unwrap_or_default()
}
/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak`
/// (through the dialog's command); a report that would replace data asks first.
fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
use gridcraft_engine::cmd::analysis::TOOLS;
if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) {
ui.label("The output range already holds data. Replace it with the report?");
ui.horizontal(|ui| {
if ui.button("Replace").clicked() {
d.values.remove("askOverwrite");
d.values.insert("overwrite".into(), json!(true));
*confirm = true;
}
if ui.button("Cancel").clicked() {
d.values.remove("askOverwrite");
}
});
return;
}
let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string();
let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else {
ui.label(egui::RichText::new("Analysis Tools").strong());
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
let mut chosen = false;
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (id, label) in TOOLS {
let r = ui.selectable_label(pick == *id, *label);
if r.clicked() {
d.values.insert("pick".into(), json!(id));
}
if r.double_clicked() {
chosen = true;
}
}
});
let mut ok = false;
ok_cancel(ui, &mut ok, open);
let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string();
if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) {
let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default();
d.values = toolpak_defaults(&sel, &pick);
}
return;
};
d.title = label.to_string();
d.fields = toolpak_fields(&tool);
form_fields(ui, d);
ui.separator();
ui.label(egui::RichText::new("Output options").strong());
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
ui.horizontal(|ui| {
if ui.radio(to == choice, text_label).clicked() {
d.values.insert("outputTo".into(), json!(choice));
}
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
d.values.insert(key.into(), json!(t));
d.values.insert("outputTo".into(), json!(choice));
}
});
}
ok_cancel(ui, confirm, open);
}
fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
let r = d.result.clone().unwrap_or(Json::Null);
if let Some(e) = r.get("error").and_then(Json::as_str) {

View File

@ -313,6 +313,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Group", "グループ化"),
("Ungroup", "グループ解除"),
("Subtotal", "小計"),
("Data\nAnalysis", "データ分析"),
("Data Analysis", "データ分析"),
("What-If\nAnalysis", "What-If 分析"),
("Data Table…", "データ テーブル…"),
("Scenario Manager…", "シナリオ マネージャー…"),

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "그룹",
"Ungroup" => "그룹 해제",
"Subtotal" => "부분합",
"Data\nAnalysis" => "데이터 분석",
"Data Analysis" => "데이터 분석",
"What-If\nAnalysis" => "가상 분석",
"Data Table…" => "데이터 표…",
"Scenario Manager…" => "시나리오 관리자…",

View File

@ -165,6 +165,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Agrupar",
"Ungroup" => "Desagrupar",
"Subtotal" => "Subtotal",
"Data\nAnalysis" => "Análise de Dados",
"Data Analysis" => "Análise de Dados",
"What-If\nAnalysis" => "Análise de\nHipóteses",
"Data Table…" => "Tabela de Dados…",
"Scenario Manager…" => "Gerenciador de Cenários…",

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "Группировать",
"Ungroup" => "Разгруппировать",
"Subtotal" => "Промежуточный итог",
"Data\nAnalysis" => "Анализ данных",
"Data Analysis" => "Анализ данных",
"What-If\nAnalysis" => "Анализ «что\nесли»",
"Data Table…" => "Таблица данных…",
"Scenario Manager…" => "Диспетчер сценариев…",

View File

@ -164,6 +164,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Group" => "组合",
"Ungroup" => "取消组合",
"Subtotal" => "分类汇总",
"Data\nAnalysis" => "数据分析",
"Data Analysis" => "数据分析",
"What-If\nAnalysis" => "假设分析",
"Data Table…" => "数据表…",
"Scenario Manager…" => "方案管理器…",

View File

@ -1580,6 +1580,10 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
app.open_dialog("subtotal", json!({}));
}
});
sep(ui);
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
app.open_dialog("analysisToolPak", json!({}));
}
}
fn review(app: &mut SheetApp, ui: &mut Ui) {

View File

@ -228,6 +228,7 @@ fn dialogs_open_and_close() {
"spelling",
"goTo",
"about",
"analysisToolPak",
] {
h.state_mut().open_dialog(name, json!({}));
h.run_steps(2);
@ -237,6 +238,39 @@ fn dialogs_open_and_close() {
}
}
#[test]
fn data_analysis_dialog_runs_a_tool_and_asks_before_overwriting() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("analysisToolPak", json!({}));
h.state_mut().dialog.as_mut().unwrap().values.insert("pick".into(), json!("descriptive"));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.clone()).as_deref(), Some("Descriptive Statistics"));
let fill = |h: &mut egui_kittest::Harness<'static, SheetApp>| {
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("range".into(), json!("A1:A5"));
d.values.insert("outputTo".into(), json!("range"));
d.values.insert("output".into(), json!("C1"));
};
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().dialog.is_none(), "OK runs the tool and closes the dialog");
assert_eq!(value(&h, "C3"), Value::from("Mean"));
assert_eq!(value(&h, "D3"), Value::Number(3.0));
// A second report into the same cells asks before replacing them (opened with a tool, the
// dialog starts at that tool's form).
h.state_mut().open_dialog("analysisToolPak", json!({"tool": "descriptive"}));
fill(&mut h);
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
let d = h.state().dialog.as_ref().expect("the dialog stays open to ask");
assert_eq!(d.values.get("askOverwrite"), Some(&json!(true)));
assert!(h.state().message.is_none());
}
#[test]
fn about_renders_every_tab() {
let mut h = harness(blank());

View File

@ -98,8 +98,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h.
### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F)
- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing; FORECAST.ETS family missing.
**Estimate:** 22–35 h.
- `data.solver`, `data.forecastSheet` missing; FORECAST.ETS family missing. Analysis ToolPak
(`data.analysisToolPak`, #312) has 13 tools; Anova two-factor, z-Test, Sampling, Random Number
Generation, Fourier Analysis and the tools' charts remain. **Estimate:** 18–30 h.
### 14. Page Layout view and print polish (U, F)
- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR

View File

@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 258 / 290 commands implemented (89.0%).**
**Overall: 259 / 290 commands implemented (89.3%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Draw | 4 | 4 | 100% |
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
| Data | 20 | 28 | 71% |
| Data | 21 | 28 | 75% |
| Review | 9 | 18 | 50% |
| View | 18 | 20 | 90% |
| Automate | 3 | 3 | 100% |
@ -240,7 +240,7 @@ Implemented:
- `formulas.calculateNow` Calculate Now (Calculation)
- `formulas.calculateSheet` Calculate Sheet (Calculation)
## Data (20 / 28, 71%)
## Data (21 / 28, 75%)
Implemented:
@ -264,6 +264,7 @@ Implemented:
- `data.subtotal` Subtotal (Outline)
- `data.showDetail` Show Detail (Outline)
- `data.hideDetail` Hide Detail (Outline)
- `data.analysisToolPak` Data Analysis (Analysis)
Missing:
@ -274,7 +275,6 @@ Missing:
- `data.forecastSheet` Forecast Sheet (Forecast)
- `data.analyzeData` Analyze Data (Analysis)
- `data.solver` Solver (Analysis)
- `data.analysisToolPak` Data Analysis (Analysis)
## Review (9 / 18, 50%)

View File

@ -87,7 +87,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts). Missing: Solver, Forecast Sheet, Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection. Missing: comment navigation, Show Changes, Allow Edit Ranges, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |

View File

@ -65,7 +65,7 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h.
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis,
Seek, Scenario Manager, Data Analysis and others exist. Missing or partial: Options, Solver,
Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker.