mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 20:26:48 -06:00
refactor(engine): move cmd/whatif.rs items to cmd/whatif/
This commit is contained in:
+12
-713
@@ -16,6 +16,18 @@ use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod advanced_filter;
|
||||
mod consolidate;
|
||||
mod scenarios;
|
||||
|
||||
use advanced_filter::advanced_filter;
|
||||
#[cfg(test)]
|
||||
pub(crate) use advanced_filter::criterion_matches;
|
||||
use consolidate::consolidate;
|
||||
#[cfg(test)]
|
||||
pub(crate) use scenarios::{SCENARIO_NAME, load_scenarios};
|
||||
use scenarios::{scenario_add, scenario_delete, scenario_list, scenario_show, scenario_summary};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -338,297 +350,6 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"value": r.x, "result": value_json(&result), "iterations": r.iterations, "converged": r.converged}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scenarios
|
||||
|
||||
/// Hidden workbook-scope name holding the scenarios as a quoted JSON string.
|
||||
pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios";
|
||||
const MAX_SCENARIO_CELLS: usize = 32;
|
||||
const MAX_SCENARIOS: usize = 1000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct Scenario {
|
||||
pub name: String,
|
||||
pub sheet: String,
|
||||
pub changing: String,
|
||||
pub values: Vec<Json>,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
pub(crate) fn load_scenarios(wb: &Workbook) -> Vec<Scenario> {
|
||||
let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] };
|
||||
serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Concatenates the string literals of a `"a"&"b""c"` formula.
|
||||
fn string_literals(f: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut it = f.chars().peekable();
|
||||
let mut in_str = false;
|
||||
while let Some(c) = it.next() {
|
||||
if !in_str {
|
||||
if c == '"' {
|
||||
in_str = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
if it.peek() == Some(&'"') {
|
||||
it.next();
|
||||
out.push('"');
|
||||
} else {
|
||||
in_str = false;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) {
|
||||
wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)));
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into());
|
||||
wb.names.push(DefinedName {
|
||||
name: SCENARIO_NAME.into(),
|
||||
scope: None,
|
||||
// Excel caps string literals at 255 characters: store `"…"&"…"` chunks.
|
||||
formula: text
|
||||
.chars()
|
||||
.collect::<Vec<char>>()
|
||||
.chunks(250)
|
||||
.map(|c| format!("\"{}\"", c.iter().collect::<String>().replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&"),
|
||||
comment: "GridCraft Scenario Manager data".into(),
|
||||
hidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn scenario_json(sc: &Scenario) -> Json {
|
||||
json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment})
|
||||
}
|
||||
|
||||
fn scenario_list(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default());
|
||||
let list: Vec<Json> =
|
||||
load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect();
|
||||
Ok(json!({"scenarios": list}))
|
||||
}
|
||||
|
||||
fn scenario_add(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioAdd";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
|
||||
if name.chars().count() > 255 {
|
||||
return Err(bad(CMD, "the name is too long (255 characters max)"));
|
||||
}
|
||||
let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string();
|
||||
let d = s.doc()?;
|
||||
let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
if cells.is_empty() {
|
||||
return Err(bad(CMD, "no changing cells"));
|
||||
}
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let values: Vec<Json> = match p.get("values").and_then(Json::as_array) {
|
||||
Some(a) => {
|
||||
if a.len() != cells.len() {
|
||||
return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len())));
|
||||
}
|
||||
a.clone()
|
||||
}
|
||||
None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(),
|
||||
};
|
||||
let changing = cells.iter().map(|c| c.a1()).collect::<Vec<_>>().join(",");
|
||||
let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() };
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let mut list = load_scenarios(&d.wb);
|
||||
match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) {
|
||||
Some(i) if replace => {
|
||||
if let Some(slot) = list.get_mut(i) {
|
||||
*slot = sc.clone();
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))),
|
||||
None => {
|
||||
if list.len() >= MAX_SCENARIOS {
|
||||
return Err(bad(CMD, "too many scenarios"));
|
||||
}
|
||||
list.push(sc.clone());
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
store_scenarios(&mut cx.wb, &list);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(scenario_json(&sc))
|
||||
}
|
||||
|
||||
fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec<Scenario>, usize)> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?;
|
||||
let list = load_scenarios(wb);
|
||||
let active = wb.active().map(|s| s.name.clone()).unwrap_or_default();
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active))
|
||||
.or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name)))
|
||||
.ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?;
|
||||
Ok((list, i))
|
||||
}
|
||||
|
||||
fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioShow";
|
||||
let d = s.doc()?;
|
||||
let (list, i) = find_scenario(&d.wb, p, CMD)?;
|
||||
let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?;
|
||||
let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?;
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
// Stored numbers are values, not typed text: entering them through the parser would
|
||||
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
}
|
||||
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
None => {
|
||||
sh.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
cx.touch(si, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(scenario_json(&sc))
|
||||
}
|
||||
|
||||
fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?;
|
||||
let removed = list.remove(i);
|
||||
edit(s, |cx| {
|
||||
store_scenarios(&mut cx.wb, &list);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"deleted": removed.name}))
|
||||
}
|
||||
|
||||
fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioSummary";
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
|
||||
if scenarios.is_empty() {
|
||||
return Err(EngineError::Other("There are no scenarios on this sheet.".into()));
|
||||
}
|
||||
let results: Vec<CellRef> = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) => {
|
||||
let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?;
|
||||
if rsi != si {
|
||||
return Err(bad(CMD, "result cells must be on the scenarios' sheet"));
|
||||
}
|
||||
cells
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
// Changing cells of every scenario, first-seen order; per scenario its values.
|
||||
let mut changing: Vec<CellRef> = Vec::new();
|
||||
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
|
||||
for sc in &scenarios {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
let mut m = HashMap::new();
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
if !changing.contains(c) {
|
||||
changing.push(*c);
|
||||
}
|
||||
m.insert(*c, json_value(v, &d.wb));
|
||||
}
|
||||
per.push(m);
|
||||
}
|
||||
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
|
||||
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
|
||||
let mut trial = Trial::new(d);
|
||||
let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect();
|
||||
let scenario_results: Vec<Vec<Value>> = per
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect();
|
||||
trial.run(&sets, &reads)
|
||||
})
|
||||
.collect();
|
||||
let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone();
|
||||
let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect();
|
||||
let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect();
|
||||
let name = (1..)
|
||||
.map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") })
|
||||
.find(|n| d.wb.sheet_index(n).is_none())
|
||||
.unwrap_or_else(|| "Scenario Summary".into());
|
||||
let names: Vec<String> = scenarios.iter().map(|x| x.name.clone()).collect();
|
||||
let new_index = edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
for (k, n) in names.iter().enumerate() {
|
||||
put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold);
|
||||
}
|
||||
put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold);
|
||||
let mut row = 4u32;
|
||||
for (i, c) in changing.iter().enumerate() {
|
||||
let fmt = changing_fmts.get(i).cloned().unwrap_or_default();
|
||||
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
|
||||
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
|
||||
let cur = current.get(i).cloned().unwrap_or_default();
|
||||
put(&mut sheet, row, 2, cur.clone(), st);
|
||||
for (k, m) in per.iter().enumerate() {
|
||||
put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st);
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
if !results.is_empty() {
|
||||
put(&mut sheet, row, 1, Value::text("Result Cells:"), bold);
|
||||
row += 1;
|
||||
for (i, c) in results.iter().enumerate() {
|
||||
let fmt = result_fmts.get(i).cloned().unwrap_or_default();
|
||||
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
|
||||
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
|
||||
put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st);
|
||||
for (k, r) in scenario_results.iter().enumerate() {
|
||||
put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st);
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
put(
|
||||
&mut sheet,
|
||||
row + 1,
|
||||
1,
|
||||
Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."),
|
||||
StyleId::DEFAULT,
|
||||
);
|
||||
sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() });
|
||||
sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
|
||||
cx.wb.sheets.push(std::sync::Arc::new(sheet));
|
||||
cx.structural = true;
|
||||
Ok(cx.wb.sheets.len() - 1)
|
||||
})?;
|
||||
s.doc_mut()?.wb_switch_sheet(new_index);
|
||||
Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- data tables
|
||||
|
||||
const MAX_TABLE_CELLS: u64 = 100_000;
|
||||
@@ -709,427 +430,5 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"range": body.a1(), "cells": n, "mode": mode, "static": true}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- advanced filter
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Cond {
|
||||
/// Column offset in the list, comparison operator and operand text.
|
||||
Compare(u32, Value),
|
||||
/// A computed criterion: formula relative to the first data row, evaluated at `at`.
|
||||
Computed(gridcraft_formula::Expr, CellRef),
|
||||
}
|
||||
|
||||
/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with".
|
||||
fn split_op(t: &str) -> (&str, &str) {
|
||||
for op in ["<>", ">=", "<=", "=", ">", "<"] {
|
||||
if let Some(rest) = t.strip_prefix(op) {
|
||||
return (op, rest);
|
||||
}
|
||||
}
|
||||
("begins", t)
|
||||
}
|
||||
|
||||
/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)?
|
||||
pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
|
||||
let crit_text = match crit {
|
||||
Value::Empty => return true,
|
||||
Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)),
|
||||
Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b),
|
||||
Value::Error(e) => return v.as_error() == Some(*e),
|
||||
Value::Text(t) => t.to_string(),
|
||||
Value::Array(_) => return false,
|
||||
};
|
||||
let (op, operand) = split_op(&crit_text);
|
||||
if operand.is_empty() {
|
||||
return match op {
|
||||
"=" => v.is_empty() || text.is_empty(),
|
||||
"<>" => !(v.is_empty() || text.is_empty()),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
let num = gridcraft_core::parse::parse_number_text(operand);
|
||||
let lower_text = text.to_lowercase();
|
||||
let lower_op = operand.to_lowercase();
|
||||
use std::cmp::Ordering;
|
||||
let cmp: Option<Ordering> = match (v, num) {
|
||||
(Value::Number(a), Some(b)) => a.partial_cmp(&b),
|
||||
(Value::Number(_), None) => None,
|
||||
(Value::Empty, _) => None,
|
||||
(_, Some(_)) if op != "begins" => None,
|
||||
_ => Some(gridcraft_core::compare_text(text, operand)),
|
||||
};
|
||||
let wild = |pat: &str| super::edit::wildcard(&lower_text, pat);
|
||||
match op {
|
||||
"=" => {
|
||||
if num.is_none() && operand.contains(['*', '?']) {
|
||||
wild(&lower_op)
|
||||
} else if num.is_none() {
|
||||
lower_text == lower_op
|
||||
} else {
|
||||
cmp == Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
"<>" => {
|
||||
if num.is_none() && operand.contains(['*', '?']) {
|
||||
!wild(&lower_op)
|
||||
} else if num.is_none() {
|
||||
lower_text != lower_op
|
||||
} else {
|
||||
cmp != Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
">" => cmp == Some(Ordering::Greater),
|
||||
"<" => cmp == Some(Ordering::Less),
|
||||
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
|
||||
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
|
||||
_ => {
|
||||
// Plain text criterion: begins with (wildcards allowed); a plain number equals.
|
||||
match (v, num) {
|
||||
(Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0),
|
||||
_ => !v.is_empty() && wild(&format!("{lower_op}*")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.advancedFilter";
|
||||
let d = s.doc()?;
|
||||
let (si, list) = match str_param(p, "range") {
|
||||
Some(t) => parse_range(&d.wb, t, CMD)?,
|
||||
None => {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
if list.height() < 1 || list.count() > 50_000_000 {
|
||||
return Err(bad(CMD, "the list range is too large"));
|
||||
}
|
||||
if let Some((_, crit)) = criteria
|
||||
&& (crit.width() > 1024 || crit.height() > 10_000)
|
||||
{
|
||||
return Err(bad(CMD, "the criteria range is too large"));
|
||||
}
|
||||
let wb = &d.wb;
|
||||
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let headers: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
|
||||
let first_data = list.start.row + 1;
|
||||
// Criteria rows → conditions.
|
||||
let mut rows: Vec<Vec<Cond>> = Vec::new();
|
||||
if let Some((csi, crit)) = criteria {
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
}
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
return true;
|
||||
}
|
||||
rows.iter().any(|conds| {
|
||||
conds.iter().all(|c| match c {
|
||||
Cond::Compare(off, crit) => {
|
||||
let at = CellRef::new(row, list.start.col + off);
|
||||
let v = sh.value(at);
|
||||
let text = crate::display::cell_text(wb, sh, at);
|
||||
criterion_matches(&v, &text, crit)
|
||||
}
|
||||
Cond::Computed(e, at) => {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
|
||||
matches!(v.scalar().to_bool(), Ok(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let unique = bool_param(p, "unique").unwrap_or(false);
|
||||
let mut seen: HashSet<Vec<String>> = HashSet::new();
|
||||
let mut keep: Vec<(u32, bool)> = Vec::new();
|
||||
for row in first_data..=list.end.row {
|
||||
let mut ok = matches_row(row);
|
||||
if ok && unique {
|
||||
let key: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect();
|
||||
ok = seen.insert(key);
|
||||
}
|
||||
keep.push((row, ok));
|
||||
}
|
||||
let matched = keep.iter().filter(|(_, k)| *k).count();
|
||||
if let Some(t) = str_param(p, "copyTo") {
|
||||
let (dsi, dest) = parse_range(wb, t, CMD)?;
|
||||
let dest = dest.start;
|
||||
let mut rows_out: Vec<u32> = vec![list.start.row];
|
||||
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
|
||||
let end =
|
||||
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
|
||||
let out_range = RangeRef::new(dest, end);
|
||||
if dsi == si && out_range.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let cells: Vec<(CellRef, Cell)> = rows_out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(list.start.col..=list.end.col).map(move |c| {
|
||||
let src = CellRef::new(*row, c);
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
for (c, cell) in cells {
|
||||
shm.set_cell(c, cell);
|
||||
}
|
||||
for c in old {
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
for c in out_range.iter() {
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"matched": matched, "range": out_range.a1()}));
|
||||
}
|
||||
let hidden = keep.iter().filter(|(_, k)| !*k).count();
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(si)?;
|
||||
for (row, k) in &keep {
|
||||
if *k {
|
||||
if let Some(e) = shm.rows.get_mut(row) {
|
||||
e.hidden = false;
|
||||
if *e == gridcraft_model::LineInfo::default() {
|
||||
shm.rows.remove(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shm.rows.entry(*row).or_default().hidden = true;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"matched": matched, "hiddenRows": hidden}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- consolidate
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Agg {
|
||||
count: u64,
|
||||
nums: u64,
|
||||
sum: f64,
|
||||
product: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl Agg {
|
||||
fn add(&mut self, v: &Value) {
|
||||
if v.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.count += 1;
|
||||
if let Value::Number(n) = v {
|
||||
if self.nums == 0 {
|
||||
self.min = *n;
|
||||
self.max = *n;
|
||||
self.product = 1.0;
|
||||
}
|
||||
self.nums += 1;
|
||||
self.sum += n;
|
||||
self.product *= n;
|
||||
self.min = self.min.min(*n);
|
||||
self.max = self.max.max(*n);
|
||||
}
|
||||
}
|
||||
fn result(&self, func: &str) -> Value {
|
||||
match func {
|
||||
"count" => Value::number(self.count as f64),
|
||||
"countNums" | "countnums" => Value::number(self.nums as f64),
|
||||
"average" => {
|
||||
if self.nums == 0 {
|
||||
Value::Error(gridcraft_core::CellError::Div0)
|
||||
} else {
|
||||
Value::number(self.sum / self.nums as f64)
|
||||
}
|
||||
}
|
||||
"max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }),
|
||||
"min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }),
|
||||
"product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }),
|
||||
_ => Value::number(self.sum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row/column key: a position or a label (matched case-insensitively).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum Key {
|
||||
Pos(u32),
|
||||
Label(String),
|
||||
}
|
||||
|
||||
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
|
||||
|
||||
fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.consolidate";
|
||||
let func = str_param(p, "function").unwrap_or("sum").to_string();
|
||||
if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) {
|
||||
return Err(bad(CMD, format!("unknown function `{func}`")));
|
||||
}
|
||||
let top = bool_param(p, "topRow").unwrap_or(false);
|
||||
let left = bool_param(p, "leftColumn").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
let srcs: Vec<String> = p
|
||||
.get("sources")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
.or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect()))
|
||||
.unwrap_or_default();
|
||||
if srcs.is_empty() {
|
||||
return Err(bad(CMD, "give at least one source range in `sources`"));
|
||||
}
|
||||
if srcs.len() > 255 {
|
||||
return Err(bad(CMD, "too many sources (255 max)"));
|
||||
}
|
||||
let (dsi, dest) = match str_param(p, "destination") {
|
||||
Some(t) => {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
(si, r.start)
|
||||
}
|
||||
None => (d.wb.active_sheet(), d.selection.active),
|
||||
};
|
||||
let mut row_keys: Vec<Key> = Vec::new();
|
||||
let mut col_keys: Vec<Key> = Vec::new();
|
||||
let mut row_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut col_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
|
||||
let mut total: u64 = 0;
|
||||
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
|
||||
match keys.iter().position(|x| *x == k) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
keys.push(k);
|
||||
keys.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
for t in &srcs {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
total = total.saturating_add(r.count());
|
||||
if total > MAX_CONSOLIDATE_CELLS {
|
||||
return Err(bad(CMD, "the sources are too large"));
|
||||
}
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let r0 = r.start.row + top as u32;
|
||||
let c0 = r.start.col + left as u32;
|
||||
if r0 > r.end.row || c0 > r.end.col {
|
||||
continue;
|
||||
}
|
||||
let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string();
|
||||
// Column keys of this source.
|
||||
let mut cols: Vec<(u32, usize)> = Vec::new();
|
||||
for col in c0..=r.end.col {
|
||||
let k = if top {
|
||||
let l = label(CellRef::new(r.start.row, col));
|
||||
if l.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let k = Key::Label(l.to_lowercase());
|
||||
col_label.entry(k.clone()).or_insert(l);
|
||||
k
|
||||
} else {
|
||||
Key::Pos(col - c0)
|
||||
};
|
||||
cols.push((col, index_of(&mut col_keys, k)));
|
||||
}
|
||||
for row in r0..=r.end.row {
|
||||
let k = if left {
|
||||
let l = label(CellRef::new(row, r.start.col));
|
||||
if l.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let k = Key::Label(l.to_lowercase());
|
||||
row_label.entry(k.clone()).or_insert(l);
|
||||
k
|
||||
} else {
|
||||
Key::Pos(row - r0)
|
||||
};
|
||||
let ri = index_of(&mut row_keys, k);
|
||||
for (col, ci) in &cols {
|
||||
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32);
|
||||
if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64
|
||||
|| out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64
|
||||
{
|
||||
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
|
||||
}
|
||||
let mut cells: Vec<(CellRef, Value)> = Vec::new();
|
||||
if top {
|
||||
for (ci, k) in col_keys.iter().enumerate() {
|
||||
cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
if left {
|
||||
for (ri, k) in row_keys.iter().enumerate() {
|
||||
cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
for ri in 0..row_keys.len() {
|
||||
for ci in 0..col_keys.len() {
|
||||
let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default();
|
||||
cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v));
|
||||
}
|
||||
}
|
||||
let end = CellRef::new(
|
||||
(out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row),
|
||||
(out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col),
|
||||
);
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells {
|
||||
let sh = cx.sheet_mut(dsi)?;
|
||||
let style = sh.style_id(c);
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- advanced filter
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Cond {
|
||||
/// Column offset in the list, comparison operator and operand text.
|
||||
Compare(u32, Value),
|
||||
/// A computed criterion: formula relative to the first data row, evaluated at `at`.
|
||||
Computed(gridcraft_formula::Expr, CellRef),
|
||||
}
|
||||
|
||||
/// Splits `">=10"` into (`">="`, `"10"`); text without an operator means "begins with".
|
||||
fn split_op(t: &str) -> (&str, &str) {
|
||||
for op in ["<>", ">=", "<=", "=", ">", "<"] {
|
||||
if let Some(rest) = t.strip_prefix(op) {
|
||||
return (op, rest);
|
||||
}
|
||||
}
|
||||
("begins", t)
|
||||
}
|
||||
|
||||
/// Does a list value match one criteria cell's value (Excel's advanced-filter semantics)?
|
||||
pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
|
||||
let crit_text = match crit {
|
||||
Value::Empty => return true,
|
||||
Value::Number(n) => return v.as_f64().is_some_and(|x| (x - n).abs() <= 1e-12 * n.abs().max(1.0)),
|
||||
Value::Bool(b) => return matches!(v, Value::Bool(x) if x == b),
|
||||
Value::Error(e) => return v.as_error() == Some(*e),
|
||||
Value::Text(t) => t.to_string(),
|
||||
Value::Array(_) => return false,
|
||||
};
|
||||
let (op, operand) = split_op(&crit_text);
|
||||
if operand.is_empty() {
|
||||
return match op {
|
||||
"=" => v.is_empty() || text.is_empty(),
|
||||
"<>" => !(v.is_empty() || text.is_empty()),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
let num = gridcraft_core::parse::parse_number_text(operand);
|
||||
let lower_text = text.to_lowercase();
|
||||
let lower_op = operand.to_lowercase();
|
||||
use std::cmp::Ordering;
|
||||
let cmp: Option<Ordering> = match (v, num) {
|
||||
(Value::Number(a), Some(b)) => a.partial_cmp(&b),
|
||||
(Value::Number(_), None) => None,
|
||||
(Value::Empty, _) => None,
|
||||
(_, Some(_)) if op != "begins" => None,
|
||||
_ => Some(gridcraft_core::compare_text(text, operand)),
|
||||
};
|
||||
let wild = |pat: &str| crate::cmd::edit::wildcard(&lower_text, pat);
|
||||
match op {
|
||||
"=" => {
|
||||
if num.is_none() && operand.contains(['*', '?']) {
|
||||
wild(&lower_op)
|
||||
} else if num.is_none() {
|
||||
lower_text == lower_op
|
||||
} else {
|
||||
cmp == Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
"<>" => {
|
||||
if num.is_none() && operand.contains(['*', '?']) {
|
||||
!wild(&lower_op)
|
||||
} else if num.is_none() {
|
||||
lower_text != lower_op
|
||||
} else {
|
||||
cmp != Some(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
">" => cmp == Some(Ordering::Greater),
|
||||
"<" => cmp == Some(Ordering::Less),
|
||||
">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)),
|
||||
"<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)),
|
||||
_ => {
|
||||
// Plain text criterion: begins with (wildcards allowed); a plain number equals.
|
||||
match (v, num) {
|
||||
(Value::Number(a), Some(b)) => (a - b).abs() <= 1e-12 * b.abs().max(1.0),
|
||||
_ => !v.is_empty() && wild(&format!("{lower_op}*")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.advancedFilter";
|
||||
let d = s.doc()?;
|
||||
let (si, list) = match str_param(p, "range") {
|
||||
Some(t) => parse_range(&d.wb, t, CMD)?,
|
||||
None => {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
if list.height() < 1 || list.count() > 50_000_000 {
|
||||
return Err(bad(CMD, "the list range is too large"));
|
||||
}
|
||||
if let Some((_, crit)) = criteria
|
||||
&& (crit.width() > 1024 || crit.height() > 10_000)
|
||||
{
|
||||
return Err(bad(CMD, "the criteria range is too large"));
|
||||
}
|
||||
let wb = &d.wb;
|
||||
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let headers: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
|
||||
let first_data = list.start.row + 1;
|
||||
// Criteria rows → conditions.
|
||||
let mut rows: Vec<Vec<Cond>> = Vec::new();
|
||||
if let Some((csi, crit)) = criteria {
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
}
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
return true;
|
||||
}
|
||||
rows.iter().any(|conds| {
|
||||
conds.iter().all(|c| match c {
|
||||
Cond::Compare(off, crit) => {
|
||||
let at = CellRef::new(row, list.start.col + off);
|
||||
let v = sh.value(at);
|
||||
let text = crate::display::cell_text(wb, sh, at);
|
||||
criterion_matches(&v, &text, crit)
|
||||
}
|
||||
Cond::Computed(e, at) => {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
|
||||
matches!(v.scalar().to_bool(), Ok(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
let unique = bool_param(p, "unique").unwrap_or(false);
|
||||
let mut seen: HashSet<Vec<String>> = HashSet::new();
|
||||
let mut keep: Vec<(u32, bool)> = Vec::new();
|
||||
for row in first_data..=list.end.row {
|
||||
let mut ok = matches_row(row);
|
||||
if ok && unique {
|
||||
let key: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(row, c)).to_lowercase()).collect();
|
||||
ok = seen.insert(key);
|
||||
}
|
||||
keep.push((row, ok));
|
||||
}
|
||||
let matched = keep.iter().filter(|(_, k)| *k).count();
|
||||
if let Some(t) = str_param(p, "copyTo") {
|
||||
let (dsi, dest) = parse_range(wb, t, CMD)?;
|
||||
let dest = dest.start;
|
||||
let mut rows_out: Vec<u32> = vec![list.start.row];
|
||||
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
|
||||
let end =
|
||||
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
|
||||
let out_range = RangeRef::new(dest, end);
|
||||
if dsi == si && out_range.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let cells: Vec<(CellRef, Cell)> = rows_out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(list.start.col..=list.end.col).map(move |c| {
|
||||
let src = CellRef::new(*row, c);
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
for (c, cell) in cells {
|
||||
shm.set_cell(c, cell);
|
||||
}
|
||||
for c in old {
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
for c in out_range.iter() {
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(json!({"matched": matched, "range": out_range.a1()}));
|
||||
}
|
||||
let hidden = keep.iter().filter(|(_, k)| !*k).count();
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(si)?;
|
||||
for (row, k) in &keep {
|
||||
if *k {
|
||||
if let Some(e) = shm.rows.get_mut(row) {
|
||||
e.hidden = false;
|
||||
if *e == gridcraft_model::LineInfo::default() {
|
||||
shm.rows.remove(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shm.rows.entry(*row).or_default().hidden = true;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"matched": matched, "hiddenRows": hidden}))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- consolidate
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Agg {
|
||||
count: u64,
|
||||
nums: u64,
|
||||
sum: f64,
|
||||
product: f64,
|
||||
min: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl Agg {
|
||||
fn add(&mut self, v: &Value) {
|
||||
if v.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.count += 1;
|
||||
if let Value::Number(n) = v {
|
||||
if self.nums == 0 {
|
||||
self.min = *n;
|
||||
self.max = *n;
|
||||
self.product = 1.0;
|
||||
}
|
||||
self.nums += 1;
|
||||
self.sum += n;
|
||||
self.product *= n;
|
||||
self.min = self.min.min(*n);
|
||||
self.max = self.max.max(*n);
|
||||
}
|
||||
}
|
||||
fn result(&self, func: &str) -> Value {
|
||||
match func {
|
||||
"count" => Value::number(self.count as f64),
|
||||
"countNums" | "countnums" => Value::number(self.nums as f64),
|
||||
"average" => {
|
||||
if self.nums == 0 {
|
||||
Value::Error(gridcraft_core::CellError::Div0)
|
||||
} else {
|
||||
Value::number(self.sum / self.nums as f64)
|
||||
}
|
||||
}
|
||||
"max" => Value::number(if self.nums == 0 { 0.0 } else { self.max }),
|
||||
"min" => Value::number(if self.nums == 0 { 0.0 } else { self.min }),
|
||||
"product" => Value::number(if self.nums == 0 { 0.0 } else { self.product }),
|
||||
_ => Value::number(self.sum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row/column key: a position or a label (matched case-insensitively).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum Key {
|
||||
Pos(u32),
|
||||
Label(String),
|
||||
}
|
||||
|
||||
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
|
||||
|
||||
pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.consolidate";
|
||||
let func = str_param(p, "function").unwrap_or("sum").to_string();
|
||||
if !["sum", "count", "countNums", "countnums", "average", "max", "min", "product"].contains(&func.as_str()) {
|
||||
return Err(bad(CMD, format!("unknown function `{func}`")));
|
||||
}
|
||||
let top = bool_param(p, "topRow").unwrap_or(false);
|
||||
let left = bool_param(p, "leftColumn").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
let srcs: Vec<String> = p
|
||||
.get("sources")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
.or_else(|| str_param(p, "sources").map(|t| t.split(';').map(str::to_string).collect()))
|
||||
.unwrap_or_default();
|
||||
if srcs.is_empty() {
|
||||
return Err(bad(CMD, "give at least one source range in `sources`"));
|
||||
}
|
||||
if srcs.len() > 255 {
|
||||
return Err(bad(CMD, "too many sources (255 max)"));
|
||||
}
|
||||
let (dsi, dest) = match str_param(p, "destination") {
|
||||
Some(t) => {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
(si, r.start)
|
||||
}
|
||||
None => (d.wb.active_sheet(), d.selection.active),
|
||||
};
|
||||
let mut row_keys: Vec<Key> = Vec::new();
|
||||
let mut col_keys: Vec<Key> = Vec::new();
|
||||
let mut row_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut col_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
|
||||
let mut total: u64 = 0;
|
||||
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
|
||||
match keys.iter().position(|x| *x == k) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
keys.push(k);
|
||||
keys.len() - 1
|
||||
}
|
||||
}
|
||||
};
|
||||
for t in &srcs {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
total = total.saturating_add(r.count());
|
||||
if total > MAX_CONSOLIDATE_CELLS {
|
||||
return Err(bad(CMD, "the sources are too large"));
|
||||
}
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let r0 = r.start.row + top as u32;
|
||||
let c0 = r.start.col + left as u32;
|
||||
if r0 > r.end.row || c0 > r.end.col {
|
||||
continue;
|
||||
}
|
||||
let label = |c: CellRef| crate::display::cell_text(&d.wb, sh, c).trim().to_string();
|
||||
// Column keys of this source.
|
||||
let mut cols: Vec<(u32, usize)> = Vec::new();
|
||||
for col in c0..=r.end.col {
|
||||
let k = if top {
|
||||
let l = label(CellRef::new(r.start.row, col));
|
||||
if l.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let k = Key::Label(l.to_lowercase());
|
||||
col_label.entry(k.clone()).or_insert(l);
|
||||
k
|
||||
} else {
|
||||
Key::Pos(col - c0)
|
||||
};
|
||||
cols.push((col, index_of(&mut col_keys, k)));
|
||||
}
|
||||
for row in r0..=r.end.row {
|
||||
let k = if left {
|
||||
let l = label(CellRef::new(row, r.start.col));
|
||||
if l.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let k = Key::Label(l.to_lowercase());
|
||||
row_label.entry(k.clone()).or_insert(l);
|
||||
k
|
||||
} else {
|
||||
Key::Pos(row - r0)
|
||||
};
|
||||
let ri = index_of(&mut row_keys, k);
|
||||
for (col, ci) in &cols {
|
||||
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (out_r0, out_c0) = (dest.row + top as u32, dest.col + left as u32);
|
||||
if out_r0 as u64 + row_keys.len() as u64 > gridcraft_core::MAX_ROWS as u64
|
||||
|| out_c0 as u64 + col_keys.len() as u64 > gridcraft_core::MAX_COLS as u64
|
||||
{
|
||||
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
|
||||
}
|
||||
let mut cells: Vec<(CellRef, Value)> = Vec::new();
|
||||
if top {
|
||||
for (ci, k) in col_keys.iter().enumerate() {
|
||||
cells.push((CellRef::new(dest.row, out_c0 + ci as u32), Value::text(col_label.get(k).cloned().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
if left {
|
||||
for (ri, k) in row_keys.iter().enumerate() {
|
||||
cells.push((CellRef::new(out_r0 + ri as u32, dest.col), Value::text(row_label.get(k).cloned().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
for ri in 0..row_keys.len() {
|
||||
for ci in 0..col_keys.len() {
|
||||
let v = aggs.get(&(ri, ci)).map(|a| if a.count == 0 { Value::Empty } else { a.result(&func) }).unwrap_or_default();
|
||||
cells.push((CellRef::new(out_r0 + ri as u32, out_c0 + ci as u32), v));
|
||||
}
|
||||
}
|
||||
let end = CellRef::new(
|
||||
(out_r0 + row_keys.len() as u32).saturating_sub(1).max(dest.row),
|
||||
(out_c0 + col_keys.len() as u32).saturating_sub(1).max(dest.col),
|
||||
);
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells {
|
||||
let sh = cx.sheet_mut(dsi)?;
|
||||
let style = sh.style_id(c);
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"range": RangeRef::new(dest, end).a1(), "rows": row_keys.len(), "columns": col_keys.len()}))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------- scenarios
|
||||
|
||||
/// Hidden workbook-scope name holding the scenarios as a quoted JSON string.
|
||||
pub(crate) const SCENARIO_NAME: &str = "_sc_scenarios";
|
||||
|
||||
const MAX_SCENARIO_CELLS: usize = 32;
|
||||
|
||||
const MAX_SCENARIOS: usize = 1000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct Scenario {
|
||||
pub name: String,
|
||||
pub sheet: String,
|
||||
pub changing: String,
|
||||
pub values: Vec<Json>,
|
||||
#[serde(default)]
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
pub(crate) fn load_scenarios(wb: &Workbook) -> Vec<Scenario> {
|
||||
let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)) else { return vec![] };
|
||||
serde_json::from_str(&string_literals(&n.formula)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Concatenates the string literals of a `"a"&"b""c"` formula.
|
||||
fn string_literals(f: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut it = f.chars().peekable();
|
||||
let mut in_str = false;
|
||||
while let Some(c) = it.next() {
|
||||
if !in_str {
|
||||
if c == '"' {
|
||||
in_str = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
if it.peek() == Some(&'"') {
|
||||
it.next();
|
||||
out.push('"');
|
||||
} else {
|
||||
in_str = false;
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn store_scenarios(wb: &mut Workbook, list: &[Scenario]) {
|
||||
wb.names.retain(|n| !(n.scope.is_none() && n.name.eq_ignore_ascii_case(SCENARIO_NAME)));
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = serde_json::to_string(list).unwrap_or_else(|_| "[]".into());
|
||||
wb.names.push(DefinedName {
|
||||
name: SCENARIO_NAME.into(),
|
||||
scope: None,
|
||||
// Excel caps string literals at 255 characters: store `"…"&"…"` chunks.
|
||||
formula: text
|
||||
.chars()
|
||||
.collect::<Vec<char>>()
|
||||
.chunks(250)
|
||||
.map(|c| format!("\"{}\"", c.iter().collect::<String>().replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&"),
|
||||
comment: "GridCraft Scenario Manager data".into(),
|
||||
hidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn scenario_json(sc: &Scenario) -> Json {
|
||||
json!({"name": sc.name, "sheet": sc.sheet, "changing": sc.changing, "values": sc.values, "comment": sc.comment})
|
||||
}
|
||||
|
||||
pub(super) fn scenario_list(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let sheet = str_param(p, "sheet").map(str::to_string).unwrap_or_else(|| d.wb.active().map(|s| s.name.clone()).unwrap_or_default());
|
||||
let list: Vec<Json> =
|
||||
load_scenarios(&d.wb).iter().filter(|sc| sheet == "*" || sc.sheet.eq_ignore_ascii_case(&sheet)).map(scenario_json).collect();
|
||||
Ok(json!({"scenarios": list}))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_add(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioAdd";
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(CMD, "missing `name`"))?.to_string();
|
||||
if name.chars().count() > 255 {
|
||||
return Err(bad(CMD, "the name is too long (255 characters max)"));
|
||||
}
|
||||
let changing = str_param(p, "changing").ok_or_else(|| bad(CMD, "missing `changing`"))?.to_string();
|
||||
let d = s.doc()?;
|
||||
let (si, cells) = cell_list(&d.wb, &changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
if cells.is_empty() {
|
||||
return Err(bad(CMD, "no changing cells"));
|
||||
}
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let values: Vec<Json> = match p.get("values").and_then(Json::as_array) {
|
||||
Some(a) => {
|
||||
if a.len() != cells.len() {
|
||||
return Err(bad(CMD, format!("{} values for {} changing cells", a.len(), cells.len())));
|
||||
}
|
||||
a.clone()
|
||||
}
|
||||
None => cells.iter().map(|c| value_json(&sh.value(*c))).collect(),
|
||||
};
|
||||
let changing = cells.iter().map(|c| c.a1()).collect::<Vec<_>>().join(",");
|
||||
let sc = Scenario { name: name.clone(), sheet: sh.name.clone(), changing, values, comment: str_param(p, "comment").unwrap_or("").to_string() };
|
||||
let replace = bool_param(p, "replace").unwrap_or(false);
|
||||
let mut list = load_scenarios(&d.wb);
|
||||
match list.iter().position(|x| x.name.eq_ignore_ascii_case(&name) && x.sheet.eq_ignore_ascii_case(&sc.sheet)) {
|
||||
Some(i) if replace => {
|
||||
if let Some(slot) = list.get_mut(i) {
|
||||
*slot = sc.clone();
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(EngineError::Other(format!("A scenario named '{name}' already exists on this sheet."))),
|
||||
None => {
|
||||
if list.len() >= MAX_SCENARIOS {
|
||||
return Err(bad(CMD, "too many scenarios"));
|
||||
}
|
||||
list.push(sc.clone());
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
store_scenarios(&mut cx.wb, &list);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(scenario_json(&sc))
|
||||
}
|
||||
|
||||
fn find_scenario(wb: &Workbook, p: &Json, cmd: &str) -> Result<(Vec<Scenario>, usize)> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing `name`"))?;
|
||||
let list = load_scenarios(wb);
|
||||
let active = wb.active().map(|s| s.name.clone()).unwrap_or_default();
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|x| x.name.eq_ignore_ascii_case(name) && x.sheet.eq_ignore_ascii_case(&active))
|
||||
.or_else(|| list.iter().position(|x| x.name.eq_ignore_ascii_case(name)))
|
||||
.ok_or_else(|| bad(cmd, format!("no scenario named `{name}`")))?;
|
||||
Ok((list, i))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioShow";
|
||||
let d = s.doc()?;
|
||||
let (list, i) = find_scenario(&d.wb, p, CMD)?;
|
||||
let sc = list.get(i).cloned().ok_or_else(|| bad(CMD, "no such scenario"))?;
|
||||
let si = d.wb.sheet_index(&sc.sheet).ok_or_else(|| bad(CMD, format!("the scenario's sheet `{}` no longer exists", sc.sheet)))?;
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
// Stored numbers are values, not typed text: entering them through the parser would
|
||||
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
}
|
||||
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
None => {
|
||||
sh.remove_cell(*c);
|
||||
}
|
||||
}
|
||||
cx.touch(si, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(scenario_json(&sc))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let (mut list, i) = find_scenario(&d.wb, p, "data.scenarioDelete")?;
|
||||
let removed = list.remove(i);
|
||||
edit(s, |cx| {
|
||||
store_scenarios(&mut cx.wb, &list);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"deleted": removed.name}))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioSummary";
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
|
||||
if scenarios.is_empty() {
|
||||
return Err(EngineError::Other("There are no scenarios on this sheet.".into()));
|
||||
}
|
||||
let results: Vec<CellRef> = match str_param(p, "resultCells").map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) => {
|
||||
let (rsi, cells) = cell_list(&d.wb, t, CMD, 255)?;
|
||||
if rsi != si {
|
||||
return Err(bad(CMD, "result cells must be on the scenarios' sheet"));
|
||||
}
|
||||
cells
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
// Changing cells of every scenario, first-seen order; per scenario its values.
|
||||
let mut changing: Vec<CellRef> = Vec::new();
|
||||
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
|
||||
for sc in &scenarios {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
let mut m = HashMap::new();
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
if !changing.contains(c) {
|
||||
changing.push(*c);
|
||||
}
|
||||
m.insert(*c, json_value(v, &d.wb));
|
||||
}
|
||||
per.push(m);
|
||||
}
|
||||
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
|
||||
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
|
||||
let mut trial = Trial::new(d);
|
||||
let reads: Vec<(usize, CellRef)> = results.iter().map(|c| (si, *c)).collect();
|
||||
let scenario_results: Vec<Vec<Value>> = per
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let sets: Vec<(usize, CellRef, Value)> = m.iter().map(|(c, v)| (si, *c, v.clone())).collect();
|
||||
trial.run(&sets, &reads)
|
||||
})
|
||||
.collect();
|
||||
let fmt_of = |c: CellRef| d.wb.styles.get(sh.style_id(c)).num_fmt.clone();
|
||||
let changing_fmts: Vec<_> = changing.iter().map(|c| fmt_of(*c)).collect();
|
||||
let result_fmts: Vec<_> = results.iter().map(|c| fmt_of(*c)).collect();
|
||||
let name = (1..)
|
||||
.map(|n| if n == 1 { "Scenario Summary".to_string() } else { format!("Scenario Summary {n}") })
|
||||
.find(|n| d.wb.sheet_index(n).is_none())
|
||||
.unwrap_or_else(|| "Scenario Summary".into());
|
||||
let names: Vec<String> = scenarios.iter().map(|x| x.name.clone()).collect();
|
||||
let new_index = edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
for (k, n) in names.iter().enumerate() {
|
||||
put(&mut sheet, 2, 3 + k as u32, Value::text(n.as_str()), bold);
|
||||
}
|
||||
put(&mut sheet, 3, 1, Value::text("Changing Cells:"), bold);
|
||||
let mut row = 4u32;
|
||||
for (i, c) in changing.iter().enumerate() {
|
||||
let fmt = changing_fmts.get(i).cloned().unwrap_or_default();
|
||||
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
|
||||
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
|
||||
let cur = current.get(i).cloned().unwrap_or_default();
|
||||
put(&mut sheet, row, 2, cur.clone(), st);
|
||||
for (k, m) in per.iter().enumerate() {
|
||||
put(&mut sheet, row, 3 + k as u32, m.get(c).cloned().unwrap_or_else(|| cur.clone()), st);
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
if !results.is_empty() {
|
||||
put(&mut sheet, row, 1, Value::text("Result Cells:"), bold);
|
||||
row += 1;
|
||||
for (i, c) in results.iter().enumerate() {
|
||||
let fmt = result_fmts.get(i).cloned().unwrap_or_default();
|
||||
let st = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = fmt);
|
||||
put(&mut sheet, row, 1, Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1)), StyleId::DEFAULT);
|
||||
put(&mut sheet, row, 2, current_results.get(i).cloned().unwrap_or_default(), st);
|
||||
for (k, r) in scenario_results.iter().enumerate() {
|
||||
put(&mut sheet, row, 3 + k as u32, r.get(i).cloned().unwrap_or_default(), st);
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
put(
|
||||
&mut sheet,
|
||||
row + 1,
|
||||
1,
|
||||
Value::text("Notes: Current Values column represents values of changing cells at time Scenario Summary Report was created."),
|
||||
StyleId::DEFAULT,
|
||||
);
|
||||
sheet.cols.insert(1, gridcraft_model::LineInfo { size: Some(110.0), ..Default::default() });
|
||||
sheet.cols.insert(2, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
|
||||
cx.wb.sheets.push(std::sync::Arc::new(sheet));
|
||||
cx.structural = true;
|
||||
Ok(cx.wb.sheets.len() - 1)
|
||||
})?;
|
||||
s.doc_mut()?.wb_switch_sheet(new_index);
|
||||
Ok(json!({"sheet": name, "scenarios": names.len(), "changingCells": changing.len(), "resultCells": results.len()}))
|
||||
}
|
||||
Reference in New Issue
Block a user