From 69709bd4587a2b3435ae69a34f55f59965f1e55f Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Sat, 10 Oct 2026 00:09:12 +0530 Subject: [PATCH] Line charts: smooth (spline) curves (issue #52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for Excel's "smoothed line" chart style. The smooth flag flows model -> resolve -> chart render, persists through XLSX (), 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 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. --- crates/chart/src/lib.rs | 3 + crates/chart/src/render.rs | 56 ++++++++++++++++-- crates/chart/src/resolve.rs | 12 +++- crates/chart/src/tests.rs | 72 ++++++++++++++++++++++- crates/engine/examples/smooth_demo.rs | 70 ++++++++++++++++++++++ crates/engine/src/cmd/extra.rs | 5 ++ crates/engine/src/cmd/insert.rs | 2 + crates/engine/src/sample.rs | 1 + crates/model/src/features.rs | 3 + crates/xlsx/src/chart.rs | 5 +- crates/xlsx/src/tests/mod.rs | 1 + crates/xlsx/src/tests/roundtrip.rs | 2 + crates/xlsx/src/tests/smooth_roundtrip.rs | 62 +++++++++++++++++++ 13 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 crates/engine/examples/smooth_demo.rs create mode 100644 crates/xlsx/src/tests/smooth_roundtrip.rs diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 93e40fb..1ae3d1c 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -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(), } } diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index c67e9b7..2041d21 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -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, 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> = 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); diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 77f7a87..830296e 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -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()); diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 441085d..51777c8 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -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(); diff --git a/crates/engine/examples/smooth_demo.rs b/crates/engine/examples/smooth_demo.rs new file mode 100644 index 0000000..6c49d7f --- /dev/null +++ b/crates/engine/examples/smooth_demo.rs @@ -0,0 +1,70 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn main() -> Result<(), Box> { + 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(()) +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 5ebc8b8..3182182 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -291,6 +291,11 @@ fn add_element(s: &mut Session, p: &Json) -> Result { 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(()) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index e4b5bc9..dc076b6 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -508,6 +508,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec 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 color: None, secondary: false, kind: None, + smooth: false, }); } } diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index c2ef603..5c05acd 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool { color: None, secondary: false, kind: None, + smooth: false, }]; } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index cd358a4..a9a879a 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -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, + /// Draw line segments as a smoothed (spline) curve. + #[serde(default)] + pub smooth: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index eac9ef2..86527e0 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -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::().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(""); } else { - x.push_str(""); + let _ = write!(x, "", 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(""); + let _ = write!(x, "", s.smooth as u8); } } } diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997..558bb79 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -5,6 +5,7 @@ mod fixtures; mod malformed; mod pivot_tests; mod roundtrip; +mod smooth_roundtrip; use std::io::Write; diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index bfc83b9..7dfd28b 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -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); diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs new file mode 100644 index 0000000..52ed772 --- /dev/null +++ b/crates/xlsx/src/tests/smooth_roundtrip.rs @@ -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"); +}