mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-01 06:36:47 -06:00
Integration: track t12
# Conflicts: # ROADMAP.md # crates/calc/src/tests.rs # crates/chart/src/tests.rs # crates/engine/src/tests.rs # crates/xlsx/src/tests/mod.rs # docs/chart-parity.md
This commit is contained in:
@@ -411,6 +411,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
|
||||
@@ -494,7 +494,7 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(patch) = p.get("style").cloned() else {
|
||||
let Some(patch) = p.get("style").map(snake_case_keys) else {
|
||||
// Menu invocation without params: the UI opens the dialog.
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
|
||||
return ok();
|
||||
@@ -512,6 +512,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
|
||||
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
|
||||
fn snake_case_keys(v: &Json) -> Json {
|
||||
match v {
|
||||
Json::Object(m) => Json::Object(
|
||||
m.iter()
|
||||
.map(|(k, v)| {
|
||||
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
|
||||
k.chars().fold(String::new(), |mut out, c| {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
out
|
||||
})
|
||||
} else {
|
||||
k.clone()
|
||||
};
|
||||
(key, snake_case_keys(v))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
|
||||
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
|
||||
match (&mut base, patch) {
|
||||
@@ -1210,6 +1236,20 @@ mod tests {
|
||||
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_takes_the_documented_keys() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
|
||||
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
|
||||
assert_eq!(st["num_fmt"], "0.0");
|
||||
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
|
||||
// The style's own snake_case keys keep working.
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_units() {
|
||||
assert_eq!(chars_to_points(8.43), 64.0);
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -226,6 +226,17 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
}
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -608,6 +619,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -623,6 +635,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -631,6 +644,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
out
|
||||
}
|
||||
|
||||
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
|
||||
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
|
||||
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
|
||||
if areas.len() == 1 {
|
||||
let by_rows = first.width() > first.height() + 1;
|
||||
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
|
||||
}
|
||||
let valid_shape = first.width() == 1
|
||||
&& first.height() > 1
|
||||
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
|
||||
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
|
||||
if !valid_shape {
|
||||
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
|
||||
}
|
||||
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
|
||||
let mut series = Vec::with_capacity(areas.len() - 1);
|
||||
for area in &areas[1..] {
|
||||
let mut area_series = series_from_range(sh, *area, false);
|
||||
let Some(mut item) = area_series.pop() else {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
};
|
||||
if item.name.is_none() {
|
||||
return Err(bad("insert.chart", "multi-area series columns require headers"));
|
||||
}
|
||||
item.categories = category.clone();
|
||||
series.push(item);
|
||||
}
|
||||
Ok((bounds, false, series))
|
||||
}
|
||||
|
||||
/// A scatter plot of a block without category labels takes its first column (or row) as the X
|
||||
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
|
||||
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
|
||||
@@ -651,15 +694,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = r.width() > r.height() + 1;
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
@@ -711,8 +753,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'st
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
@@ -725,18 +767,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Excel plots series by the longer dimension (more rows → series in columns).
|
||||
let data_h = r.height();
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let mut series = series_from_range(sh, r, by_rows);
|
||||
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
scatter_x_from_first(kind, &mut series);
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
|
||||
let title = str_param(p, "title").map(str::to_string).or_else(|| {
|
||||
if series.len() == 1 {
|
||||
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
|
||||
@@ -763,7 +801,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
@@ -793,12 +831,25 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", si)?;
|
||||
let quartiles = p
|
||||
.get("quartiles")
|
||||
.map(|v| match v.as_str() {
|
||||
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
|
||||
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
|
||||
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
|
||||
})
|
||||
.transpose()?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
}
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
}
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(str::to_string);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,8 @@ pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
// The source block moves with its cells; one that was deleted is dropped.
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
|
||||
Reference in New Issue
Block a user