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.
This commit is contained in:
justinjohn0306 2026-10-10 00:09:12 +05:30
parent 51b531be3e
commit 69709bd458
13 changed files with 284 additions and 10 deletions

View File

@ -138,6 +138,8 @@ pub struct SeriesData {
pub kind: ChartKind,
/// Plotted against the secondary (right) value axis.
pub secondary: bool,
/// Draw line segments as a smoothed (spline) curve.
pub smooth: bool,
/// Number format code of the source values ("General" by default).
pub number_format: String,
}
@ -152,6 +154,7 @@ impl Default for SeriesData {
color: [0x15, 0x60, 0x82, 0xFF],
kind: ChartKind::ColumnClustered,
secondary: false,
smooth: false,
number_format: "General".into(),
}
}

View File

@ -194,6 +194,7 @@ fn sanitize(d: &ChartData) -> ChartData {
color: s.color,
kind: s.kind,
secondary: s.secondary,
smooth: s.smooth,
number_format: s.number_format.clone(),
})
.collect(),
@ -545,15 +546,20 @@ fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
out
}
/// Draws a polyline split at gaps (`None`), decimated when long.
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
/// Draws a polyline split at gaps (`None`), decimated when long. When `smooth`, each run is
/// interpolated with a Catmull-Rom spline so the line curves through the data points.
fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: bool) {
let mut run: Vec<[f32; 2]> = Vec::new();
for p in pts.iter().chain(std::iter::once(&None)) {
match p {
Some(p) => run.push(*p),
None => {
if run.len() >= 2 {
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
let r = if smooth {
smooth_run(decimate(std::mem::take(&mut run), MAX_LINE_PTS))
} else {
decimate(std::mem::take(&mut run), MAX_LINE_PTS)
};
ctx.line(r, color, width);
} else {
run.clear();
@ -563,6 +569,46 @@ fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32) {
}
}
/// Catmull-Rom spline through `pts`, splitting each segment into `SPLINE_STEPS` sub-segments.
/// Endpoints are duplicated so the curve passes through the first and last points.
fn smooth_run(pts: Vec<[f32; 2]>) -> Vec<[f32; 2]> {
const SPLINE_STEPS: usize = 100;
let n = pts.len();
if n < 3 {
return pts;
}
let cap = n.saturating_mul(SPLINE_STEPS).min(MAX_LINE_PTS * 2);
let mut out: Vec<[f32; 2]> = Vec::with_capacity(cap);
let at = |i: isize| -> [f32; 2] {
let j = i.clamp(0, n as isize - 1) as usize;
pts.get(j).copied().unwrap_or([0.0, 0.0])
};
for i in 0..n.saturating_sub(1) {
let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2));
for s in 0..SPLINE_STEPS {
let t = s as f32 / SPLINE_STEPS as f32;
out.push(catmull(p0, p1, p2, p3, t));
if out.len() >= cap {
break;
}
}
}
out.push(at(n as isize - 1));
out
}
/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5.
fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] {
let t2 = t * t;
let t3 = t2 * t;
let mut o = [0.0f32; 2];
for k in 0..2 {
let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]);
o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3);
}
o
}
fn draw_markers(ctx: &mut Ctx, pts: impl Iterator<Item = [f32; 2]>, r: f32, color: Rgba) {
let mut seen = HashSet::new();
for p in pts {
@ -836,7 +882,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
}
_ => {
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
draw_runs(ctx, &pts, s.color, 2.25);
draw_runs(ctx, &pts, s.color, 2.25, s.smooth);
let k = kinds.get(i).copied().unwrap_or(ChartKind::Line);
if k == ChartKind::LineMarkers && cw >= 3.0 {
draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color);
@ -914,7 +960,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
} else {
let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines;
if lines {
draw_runs(ctx, &px, s.color, 2.0);
draw_runs(ctx, &px, s.color, 2.0, s.smooth);
}
if !lines || px.len() <= 500 {
draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color);

View File

@ -165,7 +165,17 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
}
}
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
series.push(SeriesData { name, values, x, sizes, color, kind, secondary: s.secondary, number_format: ref_format(wb, sheet, &s.values) });
series.push(SeriesData {
name,
values,
x,
sizes,
color,
kind,
secondary: s.secondary,
smooth: s.smooth,
number_format: ref_format(wb, sheet, &s.values),
});
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());

View File

@ -60,6 +60,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat
color,
kind,
secondary: false,
smooth: false,
number_format: "$#,##0".into(),
}
}
@ -223,6 +224,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
},
Series {
name: None,
@ -232,6 +234,7 @@ fn resolve_from_workbook() {
color: Some(Color::rgb(1, 2, 3)),
secondary: true,
kind: None,
smooth: false,
},
Series {
name: Some("Literal".into()),
@ -241,6 +244,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
},
];
let d = resolve(&wb, 0, &c);
@ -269,8 +273,26 @@ fn resolve_from_workbook() {
// Combo kinds and scatter X values.
let mut c = chart(ChartKind::Combo);
c.series = vec![
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: false, kind: None },
Series { name: None, categories: None, values: "Sales!B2:B4".into(), bubble_sizes: None, color: None, secondary: true, kind: None },
Series {
name: None,
categories: None,
values: "Sales!B2:B4".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
},
Series {
name: None,
categories: None,
values: "Sales!B2:B4".into(),
bubble_sizes: None,
color: None,
secondary: true,
kind: None,
smooth: false,
},
];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].kind, ChartKind::ColumnClustered);
@ -284,6 +306,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));
@ -299,6 +322,7 @@ fn resolve_from_workbook() {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
let d = resolve(&wb, 7, &c);
assert_eq!(d.series.len(), 1);
@ -450,8 +474,51 @@ fn thousands_of_points_are_decimated() {
}
}
#[test]
fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let m = ApproxMeasure;
let cats = ["a", "b", "c", "d"].map(String::from).to_vec();
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
Prim::Line { pts, .. } => Some(pts.len()),
_ => None,
})
.max()
.unwrap_or(0)
};
let straight = line_pts(false, &m);
let curved = line_pts(true, &m);
assert_eq!(straight, 4, "straight polyline keeps one point per datum");
assert!(curved > straight, "smooth line interpolates extra points: {curved} vs {straight}");
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
#[test]
fn smooth_line_splits_at_gaps() {
let m = ApproxMeasure;
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
assert!(lines >= 2, "a gap must split the smoothed line into runs, got {lines}");
assert_sane(&prims, 600.0, 400.0);
}
/// Small deterministic PRNG (xorshift) for the fuzz test.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
@ -491,6 +558,7 @@ fn fuzz_random_data_and_sizes() {
color: [(r.next() % 256) as u8, 100, 50, 255],
kind: ALL_KINDS[(r.next() % ALL_KINDS.len() as u64) as usize],
secondary: r.next().is_multiple_of(3),
smooth: r.next().is_multiple_of(4),
number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),
})
.collect();

View File

@ -0,0 +1,70 @@
use gridcraft_engine::Session;
use serde_json::json;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut session = Session::new();
session.new_workbook();
// Add data: months and sales figures with some curves
let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
let sales = [45.0, 52.0, 48.0, 65.0, 58.0, 72.0, 68.0, 75.0, 70.0, 82.0, 78.0, 85.0];
let expenses = [30.0, 35.0, 32.0, 42.0, 38.0, 45.0, 43.0, 48.0, 46.0, 52.0, 50.0, 55.0];
// Headers
session.execute("cell.set", json!({"cell": "A1", "input": "Month"}))?;
session.execute("cell.set", json!({"cell": "B1", "input": "Sales"}))?;
session.execute("cell.set", json!({"cell": "C1", "input": "Expenses"}))?;
// Data
for (i, ((month, sale), expense)) in months.iter().zip(&sales).zip(&expenses).enumerate() {
let row = i + 2;
session.execute("cell.set", json!({"cell": format!("A{}", row), "input": month}))?;
session.execute("cell.set", json!({"cell": format!("B{}", row), "input": sale.to_string()}))?;
session.execute("cell.set", json!({"cell": format!("C{}", row), "input": expense.to_string()}))?;
}
// Create first chart (straight lines)
session.execute(
"insert.chart",
json!({
"type": "line",
"subtype": "markers",
"range": "A1:C13",
"title": "Straight Lines (default)",
"at": "E2"
}),
)?;
// Create second chart (smooth lines)
session.execute(
"insert.chart",
json!({
"type": "line",
"subtype": "markers",
"range": "A1:C13",
"title": "Smooth Curves",
"at": "E18"
}),
)?;
// Enable smoothing on the second chart
session.execute(
"chart.addElement",
json!({
"chart": 2,
"element": "smooth",
"on": true
}),
)?;
// Save the file
session.execute("file.saveAs", json!({"path": "smooth_demo.xlsx"}))?;
println!("Created smooth_demo.xlsx with two charts:");
println!(" - Chart 1: Straight lines (smooth=false)");
println!(" - Chart 2: Smooth curves (smooth=true)");
println!("\nTo view it:");
println!(" cargo run --release -p gridcraft -- smooth_demo.xlsx");
Ok(())
}

View File

@ -291,6 +291,11 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
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;
}
}
other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))),
}
Ok(())

View File

@ -508,6 +508,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
} else {
@ -521,6 +522,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
color: None,
secondary: false,
kind: None,
smooth: false,
});
}
}

View File

@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
color: None,
secondary: false,
kind: None,
smooth: false,
}];
}
}

View File

@ -352,6 +352,9 @@ pub struct Series {
/// Combo charts: this series drawn as a line on the secondary axis.
pub secondary: bool,
pub kind: Option<ChartKind>,
/// Draw line segments as a smoothed (spline) curve.
#[serde(default)]
pub smooth: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]

View File

@ -106,6 +106,7 @@ fn read_series(s: &El) -> Series {
color,
secondary: false,
kind: None,
smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::<u8>().ok()).is_some_and(|v| v != 0),
}
}
@ -349,7 +350,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
x.push_str(&num_data("bubbleSize", s.bubble_sizes.as_deref().or(Some(&s.values)), wb, si));
x.push_str("<c:bubble3D val=\"0\"/>");
} else {
x.push_str("<c:smooth val=\"0\"/>");
let _ = write!(x, "<c:smooth val=\"{}\"/>", s.smooth as u8);
}
}
_ => {
@ -358,7 +359,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String
}
x.push_str(&num_data("val", Some(&s.values), wb, si));
if matches!(g, G::Line { .. }) {
x.push_str("<c:smooth val=\"0\"/>");
let _ = write!(x, "<c:smooth val=\"{}\"/>", s.smooth as u8);
}
}
}

View File

@ -5,6 +5,7 @@ mod fixtures;
mod malformed;
mod pivot_tests;
mod roundtrip;
mod smooth_roundtrip;
use std::io::Write;

View File

@ -287,6 +287,7 @@ fn sample() -> Workbook {
color: Some(Color::Rgb(0xFF0000)),
secondary: false,
kind: None,
smooth: false,
}],
legend: LegendPos::Bottom,
data_labels: true,
@ -324,6 +325,7 @@ fn sample() -> Workbook {
kind: Some(ChartKind::Line),
secondary: true,
color: None,
smooth: false,
..base_chart.series[0].clone()
});
s.charts.push(combo);

View File

@ -0,0 +1,62 @@
//! Test that XLSX smooth line settings round-trip correctly.
use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sheet, Workbook};
use std::sync::Arc;
use crate::{read_xlsx, write_xlsx};
#[test]
fn smooth_line_setting_round_trips() {
let mut wb = Workbook::default();
let mut s = Sheet::new("Data");
let base_series = Series {
name: Some("Sheet1!$A$1".into()),
categories: Some("Sheet1!$A$2:$A$5".into()),
values: "Sheet1!$B$2:$B$5".into(),
bubble_sizes: None,
color: Some(Color::Rgb(0x4472C4)),
secondary: false,
kind: None,
smooth: false,
};
s.charts.push(Chart {
id: 1,
kind: ChartKind::Line,
anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), dx: 0.0, dy: 0.0, width: 480.0, height: 300.0 },
title: Some("Straight Line".into()),
series: vec![base_series.clone()],
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 2,
x_title: None,
y_title: None,
source: None,
by_rows: false,
});
let mut smooth_series = base_series.clone();
smooth_series.smooth = true;
s.charts.push(Chart {
id: 2,
kind: ChartKind::ScatterLines,
anchor: Anchor { cell: gridcraft_core::CellRef::new(15, 3), dx: 0.0, dy: 0.0, width: 480.0, height: 300.0 },
title: Some("Smooth Scatter".into()),
series: vec![smooth_series],
legend: LegendPos::Bottom,
data_labels: false,
gridlines: true,
style: 2,
x_title: None,
y_title: None,
source: None,
by_rows: false,
});
wb.sheets = vec![Arc::new(s)];
let bytes = write_xlsx(&wb).unwrap();
let (back, _rep) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheets.len(), 1);
let sh = &back.sheets[0];
assert_eq!(sh.charts.len(), 2);
assert!(!sh.charts[0].series[0].smooth, "straight line series should have smooth=false");
assert!(sh.charts[1].series[0].smooth, "smooth scatter series should have smooth=true");
}