mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-31 22:26:57 -06:00
Merge branch 'land' into fix-206
This commit is contained in:
+119
-20
@@ -67,10 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
insert_chart
|
||||
),
|
||||
cmd!("insert.recommendedCharts", "Recommended Charts", ["Insert", "Charts"], None, "{range?}", has_doc, |s, p| insert_chart(
|
||||
s,
|
||||
&with(p, "type", json!("auto"))
|
||||
)),
|
||||
cmd!(
|
||||
"insert.recommendedCharts",
|
||||
"Recommended Charts",
|
||||
["Insert", "Charts"],
|
||||
None,
|
||||
"{range?, type?, subtype?} (no type: the first of insert.chartRecommendations; the ribbon button opens the dialog)",
|
||||
has_doc,
|
||||
|s, p| insert_chart(s, &if p.get("type").is_some() { p.clone() } else { with(p, "type", json!("auto")) })
|
||||
),
|
||||
cmd!(
|
||||
query "insert.chartRecommendations",
|
||||
"Chart Recommendations",
|
||||
[],
|
||||
None,
|
||||
"{range?} → [{type, subtype, label}] chart types that suit the data's shape, best first",
|
||||
has_doc,
|
||||
|s, p| Ok(Json::Array(recommend_charts(s, p)?.into_iter().map(|(t, sub, label)| json!({"type": t, "subtype": sub, "label": label})).collect()))
|
||||
),
|
||||
cmd!(
|
||||
"chart.set",
|
||||
"Chart Properties",
|
||||
@@ -143,8 +157,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], None, "{cell?, text, author?}", has_doc, |s, p| comment(s, p, true)),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
|
||||
s, p, true
|
||||
)),
|
||||
cmd!("review.replyComment", "Reply", [], None, "{cell?, text, author?}", has_doc, reply_comment),
|
||||
cmd!("review.resolveComment", "Resolve Thread", [], None, "{cell?, resolved?: true}", has_doc, resolve_comment),
|
||||
cmd!("review.deleteComment", "Delete Comment", ["Review", "Comments"], None, "{cell?}", has_doc, delete_comment),
|
||||
@@ -518,7 +534,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
let header_row = r.height() > 1
|
||||
&& (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text())
|
||||
&& (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c)));
|
||||
let label_col = r.width() > 1 && (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)));
|
||||
// A blank top-left cell above a header row marks the first column as the category labels even
|
||||
// when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't
|
||||
// plotted as a series of its own (#287).
|
||||
let blank_corner = header_row && sh.value(r.start).is_empty();
|
||||
let label_col =
|
||||
r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col))));
|
||||
let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end);
|
||||
let mut out = Vec::new();
|
||||
if !by_rows {
|
||||
@@ -555,7 +576,12 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
/// Most cells a chart's source block may span (bounds the series it builds).
|
||||
const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for
|
||||
/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per
|
||||
/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns
|
||||
/// 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 {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
@@ -563,15 +589,78 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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 ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let points = ranges.first().map_or(0, RangeRef::count);
|
||||
let (mut any_number, mut non_negative) = (false, true);
|
||||
for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) {
|
||||
any_number = true;
|
||||
non_negative &= n >= 0.0;
|
||||
}
|
||||
if series.is_empty() || !any_number {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
let labelled = series.first().is_some_and(|x| x.categories.is_some());
|
||||
const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column");
|
||||
const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar");
|
||||
const LINE: (&str, &str, &str) = ("line", "", "Line");
|
||||
const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers");
|
||||
const PIE: (&str, &str, &str) = ("pie", "", "Pie");
|
||||
const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut");
|
||||
const AREA: (&str, &str, &str) = ("area", "", "Area");
|
||||
const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter");
|
||||
const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column");
|
||||
const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar");
|
||||
let mut out = Vec::new();
|
||||
if series.len() == 2 && !labelled && !by_rows {
|
||||
out.extend([SCATTER, LINE_MARKERS, COLUMN]);
|
||||
} else if series.len() == 1 {
|
||||
if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR]);
|
||||
if non_negative && points >= 2 {
|
||||
out.extend([PIE, DOUGHNUT]);
|
||||
}
|
||||
out.push(LINE_MARKERS);
|
||||
}
|
||||
} else if points > 12 {
|
||||
out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]);
|
||||
} else {
|
||||
out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
let mut sub = str_param(p, "subtype").unwrap_or("").to_string();
|
||||
if t == "auto" {
|
||||
let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", ""));
|
||||
t = first.0.into();
|
||||
sub = first.1.into();
|
||||
}
|
||||
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;
|
||||
if t == "auto" {
|
||||
t = if data_h > 12 && data_w <= 3 { "line".into() } else { "column".into() };
|
||||
}
|
||||
let kind = chart_kind(&t, str_param(p, "subtype").unwrap_or(""));
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
@@ -606,7 +695,14 @@ 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,
|
||||
};
|
||||
Ok((sheet, chart))
|
||||
}
|
||||
|
||||
fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, chart) = build_chart(s, p)?;
|
||||
let id = chart.id;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.charts.push(chart);
|
||||
Ok(json!({"chart": id}))
|
||||
@@ -783,7 +879,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at);
|
||||
let style = sh.style_id(at);
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked {
|
||||
if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() {
|
||||
return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() };
|
||||
@@ -1038,9 +1134,10 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?
|
||||
.comments
|
||||
.insert(at, Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false });
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
}
|
||||
@@ -1159,15 +1256,17 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let rule = match kind.as_str() {
|
||||
"cellIs" => {
|
||||
// Excel's file and VBA names (`lessThan`, …) are accepted too.
|
||||
let op = match g("operator").as_deref().unwrap_or("greater") {
|
||||
"between" => CfOperator::Between,
|
||||
"notBetween" => CfOperator::NotBetween,
|
||||
"equal" => CfOperator::Equal,
|
||||
"notEqual" => CfOperator::NotEqual,
|
||||
"less" => CfOperator::Less,
|
||||
"greaterOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" => CfOperator::LessOrEqual,
|
||||
_ => CfOperator::Greater,
|
||||
"less" | "lessThan" => CfOperator::Less,
|
||||
"greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual,
|
||||
"lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual,
|
||||
"greater" | "greaterThan" => CfOperator::Greater,
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))),
|
||||
};
|
||||
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(super::edit::json_to_input);
|
||||
|
||||
Reference in New Issue
Block a user