Files
gridcraft/crates/chart/src/lib.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

162 lines
4.4 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.
//! GridCraft charts (layer L3, toolkit-free).
//!
//! - [`resolve`] pulls series names, categories and values out of a workbook,
//! - [`render`] lays a chart out into a list of drawing primitives ([`Prim`]) that any UI
//! toolkit (or the built-in [`rasterize`]) can paint,
//! - [`render_sparkline`] draws in-cell sparklines,
//! - [`rasterize`] turns primitives into RGBA pixels for PNG export and tests.
//!
//! The look (palette, greys, spacing) is GridCraft's own design.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod axis;
mod raster;
mod render;
mod resolve;
mod sparkline;
#[cfg(test)]
mod tests;
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind};
use serde::{Deserialize, Serialize};
pub use axis::{Scale, format_number, nice_scale};
pub use raster::rasterize;
pub use render::{Swatch, legend_entries, render};
pub use resolve::{default_palette, resolve, series_color};
pub use sparkline::render_sparkline;
/// Straight (non-premultiplied) RGBA colour.
pub type Rgba = [u8; 4];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum HAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VAlign {
#[default]
Top,
Middle,
Bottom,
}
/// A drawing primitive in chart coordinates (origin top-left, y down, units = points/pixels).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Prim {
Rect {
x: f32,
y: f32,
w: f32,
h: f32,
fill: Option<Rgba>,
stroke: Option<(Rgba, f32)>,
radius: f32,
},
Line {
pts: Vec<[f32; 2]>,
color: Rgba,
width: f32,
dash: bool,
},
Polygon {
pts: Vec<[f32; 2]>,
fill: Rgba,
stroke: Option<(Rgba, f32)>,
},
/// Pie/doughnut sector: centre, radii, angles in radians (0 = 12 o'clock, clockwise).
Wedge {
cx: f32,
cy: f32,
r_outer: f32,
r_inner: f32,
a0: f32,
a1: f32,
fill: Rgba,
stroke: Option<(Rgba, f32)>,
},
Circle {
cx: f32,
cy: f32,
r: f32,
fill: Option<Rgba>,
stroke: Option<(Rgba, f32)>,
},
/// Text anchored at `(x, y)` per `align`/`valign`, rotated by `rotation` radians about the anchor.
Text {
x: f32,
y: f32,
text: String,
size: f32,
color: Rgba,
bold: bool,
align: HAlign,
valign: VAlign,
rotation: f32,
},
}
/// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation).
pub trait Measure {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32;
}
/// Fixed-width approximation: `0.55 × size` per character (bold 10% wider).
#[derive(Clone, Copy, Debug, Default)]
pub struct ApproxMeasure;
impl Measure for ApproxMeasure {
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
let n = text.chars().count() as f32;
n * 0.55 * size * if bold { 1.1 } else { 1.0 }
}
}
/// Resolved data: series names, categories and values pulled from the workbook.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartData {
pub categories: Vec<String>,
pub series: Vec<SeriesData>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SeriesData {
pub name: String,
pub values: Vec<Option<f64>>,
/// Numeric X values (scatter, bubble).
pub x: Option<Vec<Option<f64>>>,
/// Bubble sizes.
pub sizes: Option<Vec<Option<f64>>>,
pub color: Rgba,
/// Per-series kind (combo charts).
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,
}
impl Default for SeriesData {
fn default() -> Self {
SeriesData {
name: String::new(),
values: vec![],
x: None,
sizes: None,
color: [0x15, 0x60, 0x82, 0xFF],
kind: ChartKind::ColumnClustered,
secondary: false,
smooth: false,
number_format: "General".into(),
}
}
}