Skip to main content

deckcraft_model/
chart.rs

1//! Charts: an embedded data grid plus type and element options.
2
3use serde::{Deserialize, Serialize};
4
5use crate::style::{ColorRef, Fill, Line};
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub enum ChartType {
10    #[default]
11    Column,
12    StackedColumn,
13    PercentColumn,
14    Bar,
15    StackedBar,
16    PercentBar,
17    Line,
18    LineMarkers,
19    StackedLine,
20    Area,
21    StackedArea,
22    Pie,
23    Doughnut,
24    Scatter,
25    Bubble,
26    Radar,
27    FilledRadar,
28    Stock,
29    Surface,
30    Histogram,
31    Pareto,
32    BoxWhisker,
33    Waterfall,
34    Funnel,
35    Treemap,
36    Sunburst,
37    Combo,
38}
39
40impl ChartType {
41    pub const MAIN: [ChartType; 12] = [
42        ChartType::Column,
43        ChartType::Bar,
44        ChartType::Line,
45        ChartType::Area,
46        ChartType::Pie,
47        ChartType::Doughnut,
48        ChartType::Scatter,
49        ChartType::Radar,
50        ChartType::Waterfall,
51        ChartType::Funnel,
52        ChartType::Histogram,
53        ChartType::Treemap,
54    ];
55    pub fn label(self) -> &'static str {
56        match self {
57            ChartType::Column => "Clustered Column",
58            ChartType::StackedColumn => "Stacked Column",
59            ChartType::PercentColumn => "100% Stacked Column",
60            ChartType::Bar => "Clustered Bar",
61            ChartType::StackedBar => "Stacked Bar",
62            ChartType::PercentBar => "100% Stacked Bar",
63            ChartType::Line => "Line",
64            ChartType::LineMarkers => "Line with Markers",
65            ChartType::StackedLine => "Stacked Line",
66            ChartType::Area => "Area",
67            ChartType::StackedArea => "Stacked Area",
68            ChartType::Pie => "Pie",
69            ChartType::Doughnut => "Doughnut",
70            ChartType::Scatter => "Scatter",
71            ChartType::Bubble => "Bubble",
72            ChartType::Radar => "Radar",
73            ChartType::FilledRadar => "Filled Radar",
74            ChartType::Stock => "Stock",
75            ChartType::Surface => "Surface",
76            ChartType::Histogram => "Histogram",
77            ChartType::Pareto => "Pareto",
78            ChartType::BoxWhisker => "Box and Whisker",
79            ChartType::Waterfall => "Waterfall",
80            ChartType::Funnel => "Funnel",
81            ChartType::Treemap => "Treemap",
82            ChartType::Sunburst => "Sunburst",
83            ChartType::Combo => "Combo",
84        }
85    }
86    pub fn is_bar_like(self) -> bool {
87        matches!(
88            self,
89            ChartType::Column
90                | ChartType::StackedColumn
91                | ChartType::PercentColumn
92                | ChartType::Bar
93                | ChartType::StackedBar
94                | ChartType::PercentBar
95                | ChartType::Histogram
96                | ChartType::Pareto
97                | ChartType::Waterfall
98        )
99    }
100    pub fn horizontal(self) -> bool {
101        matches!(
102            self,
103            ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel
104        )
105    }
106    pub fn stacked(self) -> bool {
107        matches!(
108            self,
109            ChartType::StackedColumn
110                | ChartType::StackedBar
111                | ChartType::StackedLine
112                | ChartType::StackedArea
113                | ChartType::PercentColumn
114                | ChartType::PercentBar
115        )
116    }
117    pub fn percent(self) -> bool {
118        matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
119    }
120}
121
122#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
123#[serde(default, rename_all = "camelCase")]
124pub struct Series {
125    pub name: String,
126    pub values: Vec<Option<f64>>,
127    /// X values for scatter/bubble.
128    pub x_values: Vec<Option<f64>>,
129    pub sizes: Vec<Option<f64>>,
130    pub fill: Option<Fill>,
131    pub line: Option<Line>,
132    /// Per-point fill overrides (pie slices).
133    pub point_fills: Vec<(usize, Fill)>,
134    pub data_labels: Option<bool>,
135    pub smooth: bool,
136    pub marker: Option<String>,
137    /// Combo charts: this series' own type.
138    pub kind: Option<ChartType>,
139    pub secondary_axis: bool,
140}
141
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143#[serde(default, rename_all = "camelCase")]
144pub struct Chart {
145    pub kind: ChartType,
146    pub title: Option<String>,
147    pub categories: Vec<String>,
148    pub series: Vec<Series>,
149    pub legend: Option<String>,
150    pub data_labels: bool,
151    pub gridlines: bool,
152    pub axis_titles: (Option<String>, Option<String>),
153    /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…).
154    pub style: u32,
155    pub palette: String,
156    pub plot_fill: Option<Fill>,
157    pub chart_fill: Option<Fill>,
158    pub text_color: Option<ColorRef>,
159    pub hole_size: f64,
160    pub gap_width: f64,
161    pub overlap: f64,
162    pub vary_colors: bool,
163    pub y_min: Option<f64>,
164    pub y_max: Option<f64>,
165    /// Original chart XML part kept for round-trip of unmodelled options.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub raw: Option<String>,
168}
169
170impl Default for Chart {
171    fn default() -> Self {
172        Chart {
173            kind: ChartType::Column,
174            title: Some("Chart Title".into()),
175            categories: vec![],
176            series: vec![],
177            legend: Some("b".into()),
178            data_labels: false,
179            gridlines: true,
180            axis_titles: (None, None),
181            style: 2,
182            palette: "colorful1".into(),
183            plot_fill: None,
184            chart_fill: None,
185            text_color: None,
186            hole_size: 0.5,
187            gap_width: 2.19,
188            overlap: -0.27,
189            vary_colors: false,
190            y_min: None,
191            y_max: None,
192            raw: None,
193        }
194    }
195}
196
197impl Chart {
198    /// The sample data a new chart starts with (our own numbers).
199    pub fn sample(kind: ChartType) -> Self {
200        let cats = ["Q1", "Q2", "Q3", "Q4"];
201        let data = [
202            [4.2, 2.6, 2.1],
203            [2.8, 4.1, 2.4],
204            [3.6, 1.9, 3.3],
205            [4.7, 3.0, 5.1],
206        ];
207        let mut c = Chart {
208            kind,
209            categories: cats.iter().map(|s| s.to_string()).collect(),
210            ..Default::default()
211        };
212        let n_series = if matches!(
213            kind,
214            ChartType::Pie
215                | ChartType::Doughnut
216                | ChartType::Funnel
217                | ChartType::Waterfall
218                | ChartType::Treemap
219        ) {
220            1
221        } else {
222            3
223        };
224        for s in 0..n_series {
225            c.series.push(Series {
226                name: format!("Series {}", s + 1),
227                values: data.iter().map(|row| row.get(s).copied()).collect(),
228                x_values: if kind == ChartType::Scatter {
229                    (1..=4).map(|x| Some(x as f64)).collect()
230                } else {
231                    vec![]
232                },
233                ..Default::default()
234            });
235        }
236        c.vary_colors = n_series == 1;
237        c
238    }
239    /// Value range across series (stacked sums for stacked types).
240    pub fn value_range(&self) -> (f64, f64) {
241        let mut lo: f64 = 0.0;
242        let mut hi: f64 = 0.0;
243        let n = self.categories.len().max(
244            self.series
245                .iter()
246                .map(|s| s.values.len())
247                .max()
248                .unwrap_or(0),
249        );
250        if self.kind.stacked() {
251            for i in 0..n {
252                let (mut p, mut m) = (0.0, 0.0);
253                for s in &self.series {
254                    let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
255                    if v >= 0.0 { p += v } else { m += v }
256                }
257                hi = hi.max(p);
258                lo = lo.min(m);
259            }
260        } else {
261            for s in &self.series {
262                for v in s.values.iter().flatten() {
263                    if v.is_finite() {
264                        hi = hi.max(*v);
265                        lo = lo.min(*v);
266                    }
267                }
268            }
269        }
270        if self.kind.percent() {
271            return (0.0, 1.0);
272        }
273        (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi))
274    }
275}
276
277/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
278pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
279    let (lo, hi) = if lo.is_finite() && hi.is_finite() {
280        (lo.min(hi), lo.max(hi))
281    } else {
282        (0.0, 1.0)
283    };
284    let span = if hi - lo < 1e-12 {
285        hi.abs().max(1.0)
286    } else {
287        hi - lo
288    };
289    let raw = span / 5.0;
290    let mag = 10f64.powf(raw.log10().floor());
291    let norm = raw / mag;
292    let step = if norm <= 1.0 {
293        1.0
294    } else if norm <= 2.0 {
295        2.0
296    } else if norm <= 2.5 {
297        2.5
298    } else if norm <= 5.0 {
299        5.0
300    } else {
301        10.0
302    } * mag;
303    let min = (lo / step).floor() * step;
304    let max = (hi / step).ceil() * step;
305    let max = if (max - min).abs() < 1e-12 {
306        min + step
307    } else {
308        max
309    };
310    (min, max, step)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn nice_scale_covers() {
319        let (a, b, s) = nice_scale(0.0, 5.1);
320        assert!(a <= 0.0 && b >= 5.1 && s > 0.0);
321        let (a, b, _) = nice_scale(f64::NAN, 3.0);
322        assert!(a.is_finite() && b.is_finite());
323        let (a, b, _) = nice_scale(7.0, 7.0);
324        assert!(b > a);
325    }
326
327    #[test]
328    fn sample_ranges() {
329        let c = Chart::sample(ChartType::StackedColumn);
330        let (_, hi) = c.value_range();
331        assert!(hi > 10.0);
332        assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1);
333    }
334}