//! Charts: an embedded data grid plus type and element options. 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>, /// X values for scatter/bubble. pub x_values: Vec>, pub sizes: Vec>, pub fill: Option, pub line: Option, /// Per-point fill overrides (pie slices). pub point_fills: Vec<(usize, Fill)>, pub data_labels: Option, pub smooth: bool, pub marker: Option, /// Combo charts: this series' own type. pub kind: Option, pub secondary_axis: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Chart { pub kind: ChartType, pub title: Option, pub categories: Vec, pub series: Vec, pub legend: Option, pub data_labels: bool, pub gridlines: bool, pub axis_titles: (Option, Option), /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). pub style: u32, pub palette: String, pub plot_fill: Option, pub chart_fill: Option, pub text_color: Option, pub hole_size: f64, pub gap_width: f64, pub overlap: f64, pub vary_colors: bool, pub y_min: Option, pub y_max: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, } 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 { /// The sample data a new chart starts with (our own numbers). 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 } /// Value range across series (stacked sums for stacked types). 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)) } } /// "Nice" axis scale: (min, max, major step) covering [lo, 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); } }