Files
gridcraft/crates/engine/src/cmd/extra/chart.rs
T

263 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit,
f64_param, json, split_sheet, str_param, u32_param,
};
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
let si = d.wb.active_sheet();
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
let idx = match u32_param(p, "chart") {
Some(id) => sh.charts.iter().position(|c| c.id == id),
None => sh.charts.len().checked_sub(1),
};
idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet"))
}
fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
edit(s, |cx| {
let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?;
f(c)?;
Ok(json!({"chart": c.id}))
})
}
pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
let el = str_param(p, "element").unwrap_or("title").to_string();
let on = bool_param(p, "on").unwrap_or(true);
let pos = str_param(p, "position").map(str::to_string);
with_chart(s, p, |c| {
match el.as_str() {
"title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) },
"legend" => {
c.legend = if !on {
LegendPos::None
} else {
match pos.as_deref() {
Some("top") => LegendPos::Top,
Some("left") => LegendPos::Left,
Some("right") => LegendPos::Right,
_ => LegendPos::Bottom,
}
}
}
"dataLabels" => c.data_labels = on,
"gridlines" => c.gridlines = on,
"axisTitles" => {
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into()));
}
"smooth" => {
for s in &mut c.series {
s.smooth = on;
}
}
"trendline" => {
let only = u32_param(p, "series").map(|i| i as usize);
if only.is_some_and(|i| i >= c.series.len()) {
return Err(bad("chart.addElement", "no such series"));
}
let targets: Vec<usize> = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect();
if !on {
for i in targets {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.clear();
}
}
return Ok(());
}
let t = trendline_param(p)?;
let eligible: Vec<usize> =
targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect();
if eligible.is_empty() {
return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series"));
}
for i in eligible {
if let Some(se) = c.series.get_mut(i) {
se.trendlines.push(t.clone());
}
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())
})
}
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
/// Element ▸ Trendline choices, plus the Format Trendline options).
fn trendline_param(p: &Json) -> Result<Trendline> {
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
let mut t = Trendline {
forward: num("forward"),
backward: num("backward"),
display_equation: bool_param(p, "displayEquation").unwrap_or(false),
display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false),
..Trendline::default()
};
t.kind = match str_param(p, "type").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
// Excel's Linear Forecast: a linear trendline projected two periods ahead.
"linearForecast" => {
if t.forward == 0.0 {
t.forward = 2.0;
}
TrendlineKind::Linear
}
"exponential" => TrendlineKind::Exponential,
"logarithmic" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8),
"movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)),
other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))),
};
Ok(t)
}
pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result<Json> {
let n = u32_param(p, "layout").unwrap_or(1);
with_chart(s, p, |c| {
let (legend, labels, grid, axes) = match n {
2 => (LegendPos::Top, true, false, false),
3 => (LegendPos::Bottom, false, true, false),
4 => (LegendPos::Bottom, true, false, false),
5 => (LegendPos::None, false, true, true),
6 => (LegendPos::Right, false, true, true),
_ => (LegendPos::Right, false, true, false),
};
c.legend = legend;
c.data_labels = labels;
c.gridlines = grid;
if axes {
c.x_title.get_or_insert_with(|| "Axis Title".into());
c.y_title.get_or_insert_with(|| "Axis Title".into());
} else {
c.x_title = None;
c.y_title = None;
}
Ok(())
})
}
pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result<Json> {
let explicit: Option<Vec<Color>> =
p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect());
let palette = str_param(p, "palette").unwrap_or("colorful").to_string();
with_chart(s, p, |c| {
for (i, se) in c.series.iter_mut().enumerate() {
se.color = match &explicit {
Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(),
_ => match palette.as_str() {
"monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))),
p if p.starts_with("accent") => {
let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1);
Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800)))
}
_ => None,
},
};
}
Ok(())
})
}
pub(super) fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
// A new data range rebuilds the series, as in Excel's Select Data › Chart data range.
let rebuilt = match str_param(p, "range") {
Some(range) => {
let range = range.trim_start_matches('=');
let (sheet_name, body) = split_sheet(range);
let src_si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
if r.count() > 1_000_000 {
return Err(bad("chart.selectData", "range too large for a chart"));
}
let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?;
let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1);
let series = crate::cmd::insert::series_from_range(src, r, by_rows);
Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows))
}
None => None,
};
// Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears).
let categories = match str_param(p, "categories") {
None => None,
Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None),
Some(t) => {
let t = t.trim().trim_start_matches('=');
let (sheet_name, body) = split_sheet(t);
let si = match sheet_name {
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
None => d.wb.active_sheet(),
};
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end))))
}
};
let mut remove: Vec<usize> = p
.get("removeSeries")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect())
.unwrap_or_default();
remove.sort_unstable();
remove.dedup();
if rebuilt.is_none() && categories.is_none() && remove.is_empty() {
return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`"));
}
with_chart(s, p, move |c| {
if let Some((series, source, by_rows)) = rebuilt {
c.series = series;
crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series);
c.source = Some(source);
c.by_rows = by_rows;
}
for i in remove.iter().rev() {
if *i < c.series.len() && c.series.len() > 1 {
c.series.remove(*i);
}
}
if let Some(cats) = categories {
for se in &mut c.series {
se.categories = cats.clone();
}
}
Ok(())
})
}
pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result<Json> {
let (si, ci) = find_chart(s, p)?;
let target = match p.get("sheet").and_then(Json::as_str) {
Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?),
None => None,
};
let at = cell_param_on(s, p, "at", target.unwrap_or(si))?;
edit(s, |cx| {
let mut chart = cx.sheet_mut(si)?.charts.remove(ci);
if let Some(a) = at {
chart.anchor.cell = a;
}
let dest = target.unwrap_or(si);
cx.sheet_mut(dest)?.charts.push(chart);
Ok(Json::Null)
})
}
pub(super) fn format_series(s: &mut Session, p: &Json) -> Result<Json> {
let idx = u32_param(p, "series").unwrap_or(0) as usize;
let color = str_param(p, "color").and_then(Color::from_hex);
with_chart(s, p, |c| {
if let Some(se) = c.series.get_mut(idx) {
se.color = color;
}
Ok(())
})
}