1use 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 pub x_values: Vec<Option<f64>>,
129 pub sizes: Vec<Option<f64>>,
130 pub fill: Option<Fill>,
131 pub line: Option<Line>,
132 pub point_fills: Vec<(usize, Fill)>,
134 pub data_labels: Option<bool>,
135 pub smooth: bool,
136 pub marker: Option<String>,
137 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 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 #[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 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 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
277pub 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}