Skip to main content

sheetkit_core/
sparkline.rs

1//! Sparkline creation and management.
2
3use crate::error::{Error, Result};
4
5/// Sparkline type.
6#[derive(Debug, Clone, PartialEq, Default)]
7pub enum SparklineType {
8    /// Line sparkline (default).
9    #[default]
10    Line,
11    /// Column (bar) sparkline.
12    Column,
13    /// Win/Loss sparkline.
14    WinLoss,
15}
16
17impl SparklineType {
18    /// Return the OOXML string representation.
19    pub fn as_str(&self) -> &str {
20        match self {
21            SparklineType::Line => "line",
22            SparklineType::Column => "column",
23            SparklineType::WinLoss => "stacked",
24        }
25    }
26
27    /// Parse from OOXML string representation.
28    pub fn parse(s: &str) -> Option<Self> {
29        match s {
30            "line" => Some(SparklineType::Line),
31            "column" => Some(SparklineType::Column),
32            "stacked" | "winloss" => Some(SparklineType::WinLoss),
33            _ => None,
34        }
35    }
36}
37
38/// Configuration for creating a sparkline group.
39#[derive(Debug, Clone)]
40pub struct SparklineConfig {
41    /// Data source range (e.g., "Sheet1!A1:A10").
42    pub data_range: String,
43    /// Cell where the sparkline is rendered (e.g., "B1").
44    pub location: String,
45    /// Sparkline type.
46    pub sparkline_type: SparklineType,
47    /// Show data markers.
48    pub markers: bool,
49    /// Highlight high point.
50    pub high_point: bool,
51    /// Highlight low point.
52    pub low_point: bool,
53    /// Highlight first point.
54    pub first_point: bool,
55    /// Highlight last point.
56    pub last_point: bool,
57    /// Highlight negative values.
58    pub negative_points: bool,
59    /// Show horizontal axis.
60    pub show_axis: bool,
61    /// Line weight in points (for line sparklines).
62    pub line_weight: Option<f64>,
63    /// Style preset index (0-35).
64    pub style: Option<u32>,
65}
66
67impl SparklineConfig {
68    /// Create a simple sparkline config.
69    pub fn new(data_range: &str, location: &str) -> Self {
70        Self {
71            data_range: data_range.to_string(),
72            location: location.to_string(),
73            sparkline_type: SparklineType::Line,
74            markers: false,
75            high_point: false,
76            low_point: false,
77            first_point: false,
78            last_point: false,
79            negative_points: false,
80            show_axis: false,
81            line_weight: None,
82            style: None,
83        }
84    }
85}
86
87/// A sparkline group configuration for adding to worksheets.
88#[derive(Debug, Clone)]
89pub struct SparklineGroupConfig {
90    /// Sparklines in this group (all share the same settings).
91    pub sparklines: Vec<SparklineConfig>,
92    /// Sparkline type for the group.
93    pub sparkline_type: SparklineType,
94    /// Show data markers.
95    pub markers: bool,
96    /// Highlight high point.
97    pub high_point: bool,
98    /// Highlight low point.
99    pub low_point: bool,
100    /// Line weight in points.
101    pub line_weight: Option<f64>,
102}
103
104/// Convert a SparklineConfig to the XML representation.
105#[allow(dead_code)]
106pub(crate) fn config_to_xml_group(
107    config: &SparklineConfig,
108) -> sheetkit_xml::sparkline::SparklineGroup {
109    use sheetkit_xml::sparkline::*;
110
111    SparklineGroup {
112        sparkline_type: match config.sparkline_type {
113            SparklineType::Line => None,
114            _ => Some(config.sparkline_type.as_str().to_string()),
115        },
116        markers: if config.markers { Some(true) } else { None },
117        high: if config.high_point { Some(true) } else { None },
118        low: if config.low_point { Some(true) } else { None },
119        first: if config.first_point { Some(true) } else { None },
120        last: if config.last_point { Some(true) } else { None },
121        negative: if config.negative_points {
122            Some(true)
123        } else {
124            None
125        },
126        display_x_axis: if config.show_axis { Some(true) } else { None },
127        line_weight: config.line_weight,
128        min_axis_type: None,
129        max_axis_type: None,
130        sparklines: SparklineList {
131            items: vec![Sparkline {
132                formula: config.data_range.clone(),
133                sqref: config.location.clone(),
134            }],
135        },
136    }
137}
138
139/// Validate a sparkline configuration.
140pub fn validate_sparkline_config(config: &SparklineConfig) -> Result<()> {
141    if config.data_range.is_empty() {
142        return Err(Error::Internal("sparkline data range is empty".to_string()));
143    }
144    if config.location.is_empty() {
145        return Err(Error::Internal("sparkline location is empty".to_string()));
146    }
147    if let Some(weight) = config.line_weight {
148        if weight <= 0.0 {
149            return Err(Error::Internal(
150                "sparkline line weight must be positive".to_string(),
151            ));
152        }
153    }
154    if let Some(style) = config.style {
155        if style > 35 {
156            return Err(Error::Internal(format!(
157                "sparkline style {} out of range (0-35)",
158                style
159            )));
160        }
161    }
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_sparkline_type_default() {
171        assert_eq!(SparklineType::default(), SparklineType::Line);
172    }
173
174    #[test]
175    fn test_sparkline_type_as_str() {
176        assert_eq!(SparklineType::Line.as_str(), "line");
177        assert_eq!(SparklineType::Column.as_str(), "column");
178        assert_eq!(SparklineType::WinLoss.as_str(), "stacked");
179    }
180
181    #[test]
182    fn test_sparkline_type_parse() {
183        assert_eq!(SparklineType::parse("line"), Some(SparklineType::Line));
184        assert_eq!(SparklineType::parse("column"), Some(SparklineType::Column));
185        assert_eq!(
186            SparklineType::parse("stacked"),
187            Some(SparklineType::WinLoss)
188        );
189        assert_eq!(
190            SparklineType::parse("winloss"),
191            Some(SparklineType::WinLoss)
192        );
193        assert_eq!(SparklineType::parse("invalid"), None);
194    }
195
196    #[test]
197    fn test_sparkline_config_new() {
198        let config = SparklineConfig::new("Sheet1!A1:A10", "B1");
199        assert_eq!(config.data_range, "Sheet1!A1:A10");
200        assert_eq!(config.location, "B1");
201        assert_eq!(config.sparkline_type, SparklineType::Line);
202        assert!(!config.markers);
203    }
204
205    #[test]
206    fn test_validate_sparkline_config_ok() {
207        let config = SparklineConfig::new("Sheet1!A1:A10", "B1");
208        assert!(validate_sparkline_config(&config).is_ok());
209    }
210
211    #[test]
212    fn test_validate_sparkline_empty_range() {
213        let config = SparklineConfig {
214            data_range: String::new(),
215            ..SparklineConfig::new("", "B1")
216        };
217        assert!(validate_sparkline_config(&config).is_err());
218    }
219
220    #[test]
221    fn test_validate_sparkline_empty_location() {
222        let config = SparklineConfig {
223            location: String::new(),
224            ..SparklineConfig::new("Sheet1!A1:A10", "")
225        };
226        assert!(validate_sparkline_config(&config).is_err());
227    }
228
229    #[test]
230    fn test_validate_sparkline_invalid_weight() {
231        let mut config = SparklineConfig::new("Sheet1!A1:A10", "B1");
232        config.line_weight = Some(-1.0);
233        assert!(validate_sparkline_config(&config).is_err());
234    }
235
236    #[test]
237    fn test_validate_sparkline_invalid_style() {
238        let mut config = SparklineConfig::new("Sheet1!A1:A10", "B1");
239        config.style = Some(36);
240        assert!(validate_sparkline_config(&config).is_err());
241    }
242
243    #[test]
244    fn test_config_to_xml_group_line() {
245        let config = SparklineConfig::new("Sheet1!A1:A10", "B1");
246        let group = config_to_xml_group(&config);
247        assert!(group.sparkline_type.is_none());
248        assert_eq!(group.sparklines.items.len(), 1);
249    }
250
251    #[test]
252    fn test_config_to_xml_group_column() {
253        let mut config = SparklineConfig::new("Sheet1!A1:A10", "B1");
254        config.sparkline_type = SparklineType::Column;
255        config.markers = true;
256        config.high_point = true;
257        let group = config_to_xml_group(&config);
258        assert_eq!(group.sparkline_type, Some("column".to_string()));
259        assert_eq!(group.markers, Some(true));
260        assert_eq!(group.high, Some(true));
261    }
262
263    #[test]
264    fn test_config_to_xml_group_winloss() {
265        let mut config = SparklineConfig::new("Sheet1!A1:A10", "B1");
266        config.sparkline_type = SparklineType::WinLoss;
267        let group = config_to_xml_group(&config);
268        assert_eq!(group.sparkline_type, Some("stacked".to_string()));
269    }
270}