Files
gridcraft/crates/engine/src/sample.rs
T
justinjohn0306 69709bd458 Line charts: smooth (spline) curves (issue #52)
Add support for Excel's "smoothed line" chart style. The smooth flag flows
model -> resolve -> chart render, persists through XLSX (<c:smooth val="1"/>),
and is toggled via the chart.addElement "smooth" element.

Rendering interpolates each gap-free run with a uniform Catmull-Rom spline
(0.5 tangent scale, 100 sub-segments per data segment), so the curve passes
through every data point and rounds the vertices — matching Excel's observed
behaviour. Long runs are still decimated before smoothing.

- model: Series.smooth (#[serde(default)], so old workbooks load unchanged)
- chart: SeriesData.smooth; smooth_run + catmull in render; wired through
  resolve and the sanitize() copy; draw_runs takes the flag (line + scatter)
- engine: chart.addElement element="smooth"
- xlsx: read/write <c:smooth val> instead of hard-coding val="0"
- tests: smooth_line_adds_interpolated_points_and_keeps_endpoints,
  smooth_line_splits_at_gaps, smooth_line_setting_round_trips
- examples/smooth_demo.rs: generates a straight+smooth side-by-side workbook

cargo xtask ci: all 7 steps pass.
2026-10-10 00:09:12 +05:30

212 lines
10 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.
//! Sample workbooks built in code (original content), for the start screen, demos, README
//! screenshots and tests.
use std::sync::Arc;
use gridcraft_model::{Sheet, Workbook};
use serde_json::json;
use crate::{DocState, Session};
pub const SAMPLES: &[(&str, &str)] = &[("budget", "Household Budget"), ("sales", "Quarterly Sales Dashboard"), ("grades", "Gradebook")];
pub fn title(name: &str) -> String {
SAMPLES.iter().find(|(n, _)| *n == name).map(|(_, t)| t.to_string()).unwrap_or_else(|| "Sample".into())
}
/// Builds a sample by replaying commands (so samples also exercise the command layer).
pub fn build(name: &str) -> Option<Workbook> {
let mut s = Session::new();
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.name = match name {
"budget" => "Budget".into(),
"sales" => "Sales".into(),
"grades" => "Grades".into(),
_ => return None,
};
}
s.add_document(DocState::new(wb, None, title(name)));
let ok = match name {
"budget" => budget(&mut s),
"sales" => sales(&mut s),
"grades" => grades(&mut s),
_ => false,
};
if !ok {
return None;
}
let mut wb = (*s.doc().ok()?.wb).clone();
wb.active_sheet = 0;
if let Some(sh) = wb.sheet_mut(0) {
sh.view_active = gridcraft_core::CellRef::default();
}
Some(wb)
}
fn run(s: &mut Session, id: &str, p: serde_json::Value) -> bool {
match s.execute(id, p) {
Ok(_) => true,
Err(e) => {
log::warn!("sample: {id} failed: {e}");
false
}
}
}
fn sales(s: &mut Session) -> bool {
let rows = json!([
["Region", "Q1", "Q2", "Q3", "Q4", "Total", "Growth", "Trend"],
["North", 48200, 51340, 56010, 61870],
["South", 39150, 37980, 42400, 45120],
["East", 55300, 58910, 57230, 63480],
["West", 61020, 64400, 70150, 76390],
["Central", 28740, 31260, 33980, 36510],
["Online", 72410, 81630, 95220, 112840],
]);
let mut ok = run(s, "range.setValues", json!({"range": "B4", "values": rows}));
ok &= run(s, "range.setValues", json!({"range": "B2", "values": [["Quarterly Sales Dashboard"]]}));
ok &= run(s, "home.cellStyle", json!({"range": "B2", "name": "Title"}));
ok &= run(s, "range.setValues", json!({"range": "B3", "values": [["Fiscal year 2026 · all figures in USD"]]}));
ok &= run(s, "home.cellStyle", json!({"range": "B3", "name": "Explanatory Text"}));
for r in 5..=10 {
ok &= run(s, "cell.set", json!({"cell": format!("G{r}"), "input": format!("=SUM(C{r}:F{r})")}));
ok &= run(s, "cell.set", json!({"cell": format!("H{r}"), "input": format!("=F{r}/C{r}-1")}));
ok &= run(s, "insert.sparkline", json!({"range": format!("C{r}:F{r}"), "location": format!("I{r}"), "type": "line", "markers": true}));
}
ok &= run(s, "insert.table", json!({"range": "B4:I10", "style": "TableStyleMedium2", "name": "Sales"}));
ok &= run(s, "table.totalRow", json!({"table": "Sales", "on": true}));
for col in ["Q1", "Q2", "Q3", "Q4", "Total"] {
ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": col, "function": "sum"}));
}
ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Growth", "function": "average"}));
ok &= run(s, "table.totalFunction", json!({"table": "Sales", "column": "Trend", "function": "none"}));
ok &= run(s, "home.numberFormat", json!({"range": "C5:G11", "code": "\"$\"#,##0"}));
ok &= run(s, "home.numberFormat", json!({"range": "H5:H11", "code": "0.0%"}));
ok &= run(s, "home.conditionalFormat", json!({"range": "G5:G10", "rule": {"type": "dataBar", "color": "#5B9BD5"}}));
ok &= run(s, "home.conditionalFormat", json!({"range": "H5:H10", "rule": {"type": "iconSet", "set": "3Arrows"}}));
ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 20}));
ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 132}));
ok &= run(s, "home.columnWidth", json!({"cols": "C:G", "width": 84}));
ok &= run(s, "home.columnWidth", json!({"cols": "H:I", "width": 76}));
ok &= run(s, "selection.set", json!({"range": "B4:F10"}));
ok &= run(s, "insert.chart", json!({"type": "column", "title": "Revenue by Region", "at": "K3", "width": 460, "height": 270}));
ok &= run(s, "selection.set", json!({"range": "B4:B10"}));
// Pie of totals.
ok &=
run(s, "insert.chart", json!({"range": "B4:B10", "type": "pie", "title": "Share of Annual Total", "at": "K19", "width": 460, "height": 260}));
if let Ok(d) = s.doc_mut() {
let wb = Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(0)
&& let Some(c) = sh.charts.last_mut()
{
c.series = vec![gridcraft_model::Series {
name: Some("Sales!$G$4".into()),
categories: Some("Sales!$B$5:$B$10".into()),
values: "Sales!$G$5:$G$10".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
}];
}
}
ok &= run(s, "range.setValues", json!({"range": "B14", "values": [["Best region"], ["Average quarter"], ["Online share"], ["Year-over-year"]]}));
ok &= run(s, "cell.set", json!({"cell": "C14", "input": "=INDEX(Sales[Region],MATCH(MAX(Sales[Total]),Sales[Total],0))"}));
ok &= run(s, "cell.set", json!({"cell": "C15", "input": "=AVERAGE(C5:F10)"}));
ok &= run(s, "cell.set", json!({"cell": "C16", "input": "=G10/SUM(G5:G10)"}));
ok &= run(s, "cell.set", json!({"cell": "C17", "input": "=SUM(F5:F10)/SUM(C5:C10)-1"}));
ok &= run(s, "home.numberFormat", json!({"range": "C15", "code": "\"$\"#,##0"}));
ok &= run(s, "home.numberFormat", json!({"range": "C16:C17", "code": "0.0%"}));
ok &= run(s, "home.bold", json!({"range": "B14:B17", "on": true}));
ok &= run(s, "home.borders", json!({"range": "B14:C17", "preset": "outside"}));
ok &= run(s, "home.fillColor", json!({"range": "B14:C17", "color": "#F2F7FC"}));
ok &= run(s, "review.newNote", json!({"cell": "H4", "text": "Q4 vs Q1 growth for each region."}));
ok &= run(s, "selection.set", json!({"cell": "B2"}));
ok
}
fn budget(s: &mut Session) -> bool {
let rows = json!([
["Category", "Budget", "Actual", "Difference", "% Used"],
["Rent", 1850, 1850],
["Groceries", 620, 684.35],
["Utilities", 240, 212.8],
["Transport", 180, 203.5],
["Internet & phone", 95, 95],
["Insurance", 210, 210],
["Dining out", 200, 265.9],
["Entertainment", 120, 88.25],
["Savings", 600, 600],
["Art supplies", 150, 172.4],
]);
let mut ok = run(s, "range.setValues", json!({"range": "B2", "values": [["Monthly Budget — October 2026"]]}));
ok &= run(s, "home.cellStyle", json!({"range": "B2", "name": "Title"}));
ok &= run(s, "range.setValues", json!({"range": "B4", "values": rows}));
for r in 5..=14 {
ok &= run(s, "cell.set", json!({"cell": format!("E{r}"), "input": format!("=C{r}-D{r}")}));
ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=D{r}/C{r}")}));
}
ok &= run(s, "range.setValues", json!({"range": "B15", "values": [["Total"]]}));
for c in ["C", "D", "E"] {
ok &= run(s, "cell.set", json!({"cell": format!("{c}15"), "input": format!("=SUM({c}5:{c}14)")}));
}
ok &= run(s, "cell.set", json!({"cell": "F15", "input": "=D15/C15"}));
ok &= run(s, "home.cellStyle", json!({"range": "B4:F4", "name": "Heading 3"}));
ok &= run(s, "home.cellStyle", json!({"range": "B15:F15", "name": "Total"}));
ok &= run(s, "home.numberFormat", json!({"range": "C5:E15", "format": "Accounting"}));
ok &= run(s, "home.numberFormat", json!({"range": "F5:F15", "code": "0%"}));
ok &= run(
s,
"home.conditionalFormat",
json!({"range": "E5:E14", "rule": {"type": "cellIs", "operator": "less", "value": 0, "preset": "lightRedFillDarkRedText"}}),
);
ok &= run(s, "home.conditionalFormat", json!({"range": "F5:F14", "rule": {"type": "colorScale", "colors": ["#63BE7B", "#FFEB84", "#F8696B"]}}));
ok &= run(s, "home.columnWidth", json!({"cols": "B:B", "width": 130}));
ok &= run(s, "home.columnWidth", json!({"cols": "C:E", "width": 90}));
ok &= run(s, "selection.set", json!({"range": "B4:D14"}));
ok &= run(s, "insert.chart", json!({"type": "bar", "title": "Budget vs Actual", "at": "H3", "width": 440, "height": 300}));
ok &= run(s, "view.freezePanes", json!({"cell": "A5"}));
ok &= run(s, "selection.set", json!({"cell": "B2"}));
ok
}
fn grades(s: &mut Session) -> bool {
let rows = json!([
["Student", "Essay", "Midterm", "Project", "Final", "Average", "Grade"],
["Amara Okafor", 92, 88, 95, 91],
["Bruno Silva", 78, 82, 74, 80],
["Chen Wei", 85, 91, 89, 94],
["Dana Kowalski", 67, 72, 80, 70],
["Elif Yılmaz", 98, 95, 97, 99],
["Farid Haddad", 74, 69, 71, 77],
["Grace Mensah", 88, 84, 90, 86],
["Hiro Tanaka", 59, 64, 70, 62],
]);
let mut ok = run(s, "range.setValues", json!({"range": "A1", "values": rows}));
for r in 2..=9 {
ok &= run(s, "cell.set", json!({"cell": format!("F{r}"), "input": format!("=AVERAGE(B{r}:E{r})")}));
ok &= run(
s,
"cell.set",
json!({"cell": format!("G{r}"), "input": format!("=IFS(F{r}>=90,\"A\",F{r}>=80,\"B\",F{r}>=70,\"C\",F{r}>=60,\"D\",TRUE,\"F\")")}),
);
}
ok &= run(s, "home.numberFormat", json!({"range": "F2:F9", "code": "0.0"}));
ok &= run(s, "insert.table", json!({"range": "A1:G9", "style": "TableStyleLight9", "name": "Grades"}));
ok &= run(s, "home.columnWidth", json!({"cols": "A:A", "width": 120}));
ok &= run(
s,
"home.conditionalFormat",
json!({"range": "G2:G9", "rule": {"type": "cellIs", "operator": "equal", "value": "A", "preset": "greenFillDarkGreenText"}}),
);
ok &= run(s, "home.conditionalFormat", json!({"range": "F2:F9", "rule": {"type": "dataBar", "color": "#63BE7B"}}));
ok
}
/// A blank sheet (for callers that need one without a session).
pub fn blank_sheet(name: &str) -> Sheet {
Sheet::new(name)
}