Files
gridcraft/crates/chart/src/lib.rs
T
neilcawseandClaude Opus 5.5 8ea2342703 Charts: read, write and draw trendlines
Model: Series.trendlines (Vec<Trendline>, serde default) with kind
(linear, exponential, logarithmic, polynomial 2..6, power, moving
average 2..255), name, forward/backward forecast, set intercept,
equation / R² display and line colour, width and dash.

XLSX: read and write <c:trendline> in CT_Trendline child order, placed
after dLbls and before the data references in line, bar, area, scatter,
bubble and stock series; unknown types are skipped.

Chart: least-squares fits (transformed for exp/log/power, skipping
non-positive values; polynomials via normal equations on centred,
scaled x with partial pivoting), moving averages, forecasts that widen
the category or x axis, clipping to the plot, equation and R² labels and
legend entries ("Linear (Sales)"). Degenerate data draws nothing.

Engine: chart.addElement element="trendline" adds (type, series, order,
period, forward, backward, displayEquation, displayRSquared) or removes
trendlines; document.inspect lists each chart's trendlines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:39:31 -04:00

173 lines
4.8 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;
mod trend;
#[cfg(test)]
mod tests;
pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind};
use serde::{Deserialize, Serialize};
pub use axis::{Scale, format_number, nice_scale};
pub use raster::rasterize;
pub use render::{Swatch, legend_entries, render, supports_trendline};
pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
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,
/// Trendlines to fit and draw over this series.
pub trendlines: Vec<TrendlineData>,
/// Number format code of the source values ("General" by default).
pub number_format: String,
}
/// A trendline to draw: the model settings with the line colour resolved against the theme.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TrendlineData {
pub spec: Trendline,
pub color: Rgba,
}
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,
trendlines: vec![],
number_format: "General".into(),
}
}
}