use serde::{Deserialize, Serialize};
use crate::style::{ColorRef, Fill, Line};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChartType {
#[default]
Column,
StackedColumn,
PercentColumn,
Bar,
StackedBar,
PercentBar,
Line,
LineMarkers,
StackedLine,
Area,
StackedArea,
Pie,
Doughnut,
Scatter,
Bubble,
Radar,
FilledRadar,
Stock,
Surface,
Histogram,
Pareto,
BoxWhisker,
Waterfall,
Funnel,
Treemap,
Sunburst,
Combo,
}
impl ChartType {
pub const MAIN: [ChartType; 12] = [
ChartType::Column,
ChartType::Bar,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Scatter,
ChartType::Radar,
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Histogram,
ChartType::Treemap,
];
pub fn label(self) -> &'static str {
match self {
ChartType::Column => "Clustered Column",
ChartType::StackedColumn => "Stacked Column",
ChartType::PercentColumn => "100% Stacked Column",
ChartType::Bar => "Clustered Bar",
ChartType::StackedBar => "Stacked Bar",
ChartType::PercentBar => "100% Stacked Bar",
ChartType::Line => "Line",
ChartType::LineMarkers => "Line with Markers",
ChartType::StackedLine => "Stacked Line",
ChartType::Area => "Area",
ChartType::StackedArea => "Stacked Area",
ChartType::Pie => "Pie",
ChartType::Doughnut => "Doughnut",
ChartType::Scatter => "Scatter",
ChartType::Bubble => "Bubble",
ChartType::Radar => "Radar",
ChartType::FilledRadar => "Filled Radar",
ChartType::Stock => "Stock",
ChartType::Surface => "Surface",
ChartType::Histogram => "Histogram",
ChartType::Pareto => "Pareto",
ChartType::BoxWhisker => "Box and Whisker",
ChartType::Waterfall => "Waterfall",
ChartType::Funnel => "Funnel",
ChartType::Treemap => "Treemap",
ChartType::Sunburst => "Sunburst",
ChartType::Combo => "Combo",
}
}
pub fn is_bar_like(self) -> bool {
matches!(
self,
ChartType::Column
| ChartType::StackedColumn
| ChartType::PercentColumn
| ChartType::Bar
| ChartType::StackedBar
| ChartType::PercentBar
| ChartType::Histogram
| ChartType::Pareto
| ChartType::Waterfall
)
}
pub fn horizontal(self) -> bool {
matches!(
self,
ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel
)
}
pub fn stacked(self) -> bool {
matches!(
self,
ChartType::StackedColumn
| ChartType::StackedBar
| ChartType::StackedLine
| ChartType::StackedArea
| ChartType::PercentColumn
| ChartType::PercentBar
)
}
pub fn percent(self) -> bool {
matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Series {
pub name: String,
pub values: Vec<Option<f64>>,
pub x_values: Vec<Option<f64>>,
pub sizes: Vec<Option<f64>>,
pub fill: Option<Fill>,
pub line: Option<Line>,
pub point_fills: Vec<(usize, Fill)>,
pub data_labels: Option<bool>,
pub smooth: bool,
pub marker: Option<String>,
pub kind: Option<ChartType>,
pub secondary_axis: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Chart {
pub kind: ChartType,
pub title: Option<String>,
pub categories: Vec<String>,
pub series: Vec<Series>,
pub legend: Option<String>,
pub data_labels: bool,
pub gridlines: bool,
pub axis_titles: (Option<String>, Option<String>),
pub style: u32,
pub palette: String,
pub plot_fill: Option<Fill>,
pub chart_fill: Option<Fill>,
pub text_color: Option<ColorRef>,
pub hole_size: f64,
pub gap_width: f64,
pub overlap: f64,
pub vary_colors: bool,
pub y_min: Option<f64>,
pub y_max: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
}
impl Default for Chart {
fn default() -> Self {
Chart {
kind: ChartType::Column,
title: Some("Chart Title".into()),
categories: vec![],
series: vec![],
legend: Some("b".into()),
data_labels: false,
gridlines: true,
axis_titles: (None, None),
style: 2,
palette: "colorful1".into(),
plot_fill: None,
chart_fill: None,
text_color: None,
hole_size: 0.5,
gap_width: 2.19,
overlap: -0.27,
vary_colors: false,
y_min: None,
y_max: None,
raw: None,
}
}
}
impl Chart {
pub fn sample(kind: ChartType) -> Self {
let cats = ["Q1", "Q2", "Q3", "Q4"];
let data = [
[4.2, 2.6, 2.1],
[2.8, 4.1, 2.4],
[3.6, 1.9, 3.3],
[4.7, 3.0, 5.1],
];
let mut c = Chart {
kind,
categories: cats.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
let n_series = if matches!(
kind,
ChartType::Pie
| ChartType::Doughnut
| ChartType::Funnel
| ChartType::Waterfall
| ChartType::Treemap
) {
1
} else {
3
};
for s in 0..n_series {
c.series.push(Series {
name: format!("Series {}", s + 1),
values: data.iter().map(|row| row.get(s).copied()).collect(),
x_values: if kind == ChartType::Scatter {
(1..=4).map(|x| Some(x as f64)).collect()
} else {
vec![]
},
..Default::default()
});
}
c.vary_colors = n_series == 1;
c
}
pub fn value_range(&self) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
let n = self.categories.len().max(
self.series
.iter()
.map(|s| s.values.len())
.max()
.unwrap_or(0),
);
if self.kind.stacked() {
for i in 0..n {
let (mut p, mut m) = (0.0, 0.0);
for s in &self.series {
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
if v >= 0.0 { p += v } else { m += v }
}
hi = hi.max(p);
lo = lo.min(m);
}
} else {
for s in &self.series {
for v in s.values.iter().flatten() {
if v.is_finite() {
hi = hi.max(*v);
lo = lo.min(*v);
}
}
}
}
if self.kind.percent() {
return (0.0, 1.0);
}
(self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi))
}
}
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
let (lo, hi) = if lo.is_finite() && hi.is_finite() {
(lo.min(hi), lo.max(hi))
} else {
(0.0, 1.0)
};
let span = if hi - lo < 1e-12 {
hi.abs().max(1.0)
} else {
hi - lo
};
let raw = span / 5.0;
let mag = 10f64.powf(raw.log10().floor());
let norm = raw / mag;
let step = if norm <= 1.0 {
1.0
} else if norm <= 2.0 {
2.0
} else if norm <= 2.5 {
2.5
} else if norm <= 5.0 {
5.0
} else {
10.0
} * mag;
let min = (lo / step).floor() * step;
let max = (hi / step).ceil() * step;
let max = if (max - min).abs() < 1e-12 {
min + step
} else {
max
};
(min, max, step)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nice_scale_covers() {
let (a, b, s) = nice_scale(0.0, 5.1);
assert!(a <= 0.0 && b >= 5.1 && s > 0.0);
let (a, b, _) = nice_scale(f64::NAN, 3.0);
assert!(a.is_finite() && b.is_finite());
let (a, b, _) = nice_scale(7.0, 7.0);
assert!(b > a);
}
#[test]
fn sample_ranges() {
let c = Chart::sample(ChartType::StackedColumn);
let (_, hi) = c.value_range();
assert!(hi > 10.0);
assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1);
}
}