mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 03:06:53 -06:00
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.
162 lines
4.4 KiB
Rust
162 lines
4.4 KiB
Rust
//! 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(),
|
||
}
|
||
}
|
||
}
|