Skip to main content

ravenrs_sheet/chart/
mod.rs

1//! GridCraft charts (layer L3, toolkit-free).
2//!
3//! - [`resolve`] pulls series names, categories and values out of a workbook,
4//! - [`render`] lays a chart out into a list of drawing primitives ([`Prim`]) that any UI
5//!   toolkit (or the built-in [`rasterize`]) can paint,
6//! - [`render_sparkline`] draws in-cell sparklines,
7//! - [`rasterize`] turns primitives into RGBA pixels for PNG export and tests.
8//!
9//! The look (palette, greys, spacing) is GridCraft's own design.
10#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
11#![forbid(unsafe_code)]
12
13mod axis;
14mod raster;
15mod render;
16mod resolve;
17mod sparkline;
18mod trend;
19
20#[cfg(test)]
21mod tests;
22
23pub use crate::model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind};
24use serde::{Deserialize, Serialize};
25
26pub use axis::{Scale, format_number, nice_scale};
27pub use raster::rasterize;
28pub use render::{Swatch, legend_entries, render, supports_trendline};
29pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
30pub use sparkline::render_sparkline;
31
32/// Straight (non-premultiplied) RGBA colour.
33pub type Rgba = [u8; 4];
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub enum HAlign {
37    #[default]
38    Left,
39    Center,
40    Right,
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub enum VAlign {
45    #[default]
46    Top,
47    Middle,
48    Bottom,
49}
50
51/// A drawing primitive in chart coordinates (origin top-left, y down, units = points/pixels).
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53pub enum Prim {
54    Rect {
55        x: f32,
56        y: f32,
57        w: f32,
58        h: f32,
59        fill: Option<Rgba>,
60        stroke: Option<(Rgba, f32)>,
61        radius: f32,
62    },
63    Line {
64        pts: Vec<[f32; 2]>,
65        color: Rgba,
66        width: f32,
67        dash: bool,
68    },
69    Polygon {
70        pts: Vec<[f32; 2]>,
71        fill: Rgba,
72        stroke: Option<(Rgba, f32)>,
73    },
74    /// Pie/doughnut sector: centre, radii, angles in radians (0 = 12 o'clock, clockwise).
75    Wedge {
76        cx: f32,
77        cy: f32,
78        r_outer: f32,
79        r_inner: f32,
80        a0: f32,
81        a1: f32,
82        fill: Rgba,
83        stroke: Option<(Rgba, f32)>,
84    },
85    Circle {
86        cx: f32,
87        cy: f32,
88        r: f32,
89        fill: Option<Rgba>,
90        stroke: Option<(Rgba, f32)>,
91    },
92    /// Text anchored at `(x, y)` per `align`/`valign`, rotated by `rotation` radians about the anchor.
93    Text {
94        x: f32,
95        y: f32,
96        text: String,
97        size: f32,
98        color: Rgba,
99        bold: bool,
100        align: HAlign,
101        valign: VAlign,
102        rotation: f32,
103    },
104}
105
106/// Measures text width (the UI passes real font metrics; tests use a fixed-width approximation).
107pub trait Measure {
108    fn text_width(&self, text: &str, size: f32, bold: bool) -> f32;
109}
110
111/// Fixed-width approximation: `0.55 × size` per character (bold 10% wider).
112#[derive(Clone, Copy, Debug, Default)]
113pub struct ApproxMeasure;
114
115impl Measure for ApproxMeasure {
116    fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
117        let n = text.chars().count() as f32;
118        n * 0.55 * size * if bold { 1.1 } else { 1.0 }
119    }
120}
121
122/// Resolved data: series names, categories and values pulled from the workbook.
123#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
124pub struct ChartData {
125    pub categories: Vec<String>,
126    /// Outer levels of a multi-level category axis (a category range two columns wide beside a
127    /// column of values: year beside month), nearest the labels first. One entry per category,
128    /// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`.
129    #[serde(default)]
130    pub category_levels: Vec<Vec<String>>,
131    pub series: Vec<SeriesData>,
132    /// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
133    #[serde(default)]
134    pub palette: Vec<Rgba>,
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub struct SeriesData {
139    pub name: String,
140    pub values: Vec<Option<f64>>,
141    /// Numeric X values (scatter, bubble).
142    pub x: Option<Vec<Option<f64>>>,
143    /// Bubble sizes.
144    pub sizes: Option<Vec<Option<f64>>>,
145    pub color: Rgba,
146    /// Per-series kind (combo charts).
147    pub kind: ChartKind,
148    /// Plotted against the secondary (right) value axis.
149    pub secondary: bool,
150    /// Draw line segments as a smoothed (spline) curve.
151    pub smooth: bool,
152    /// Trendlines to fit and draw over this series.
153    pub trendlines: Vec<TrendlineData>,
154    /// Number format code of the source values ("General" by default).
155    pub number_format: String,
156}
157
158/// A trendline to draw: the model settings with the line colour resolved against the theme.
159#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
160pub struct TrendlineData {
161    pub spec: Trendline,
162    pub color: Rgba,
163}
164
165impl Default for SeriesData {
166    fn default() -> Self {
167        SeriesData {
168            name: String::new(),
169            values: vec![],
170            x: None,
171            sizes: None,
172            color: [0x15, 0x60, 0x82, 0xFF],
173            kind: ChartKind::ColumnClustered,
174            secondary: false,
175            smooth: false,
176            trendlines: vec![],
177            number_format: "General".into(),
178        }
179    }
180}