hwpforge_core/
chart.rs

1//! Chart types for OOXML-based chart support.
2//!
3//! Charts in HWPX use the OOXML chart XML format (`xmlns:c`).
4//! This module defines the chart type enum (18 variants covering all 16
5//! OOXML chart types, with Bar/Column direction split) and the data model
6//! for category-based and XY-based chart data.
7//!
8//! # Examples
9//!
10//! ```
11//! use hwpforge_core::chart::{ChartType, ChartData, ChartGrouping, LegendPosition};
12//!
13//! let data = ChartData::category(
14//!     &["Q1", "Q2", "Q3"],
15//!     &[("Sales", &[100.0, 150.0, 200.0])],
16//! );
17//! assert!(matches!(data, ChartData::Category { .. }));
18//! ```
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23/// OOXML chart types supported by ν•œκΈ€ (16 OOXML types β†’ 18 variants).
24///
25/// Bar and Column are both `<c:barChart>` in OOXML, distinguished by
26/// `<c:barDir val="bar|col">`. Similarly for 3D variants.
27///
28/// # Examples
29///
30/// ```
31/// use hwpforge_core::chart::ChartType;
32///
33/// let ct = ChartType::Column;
34/// assert_eq!(format!("{ct:?}"), "Column");
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[non_exhaustive]
38pub enum ChartType {
39    /// Horizontal bar chart (`<c:barChart>` with `barDir=bar`).
40    Bar,
41    /// Vertical bar chart (`<c:barChart>` with `barDir=col`).
42    Column,
43    /// 3D horizontal bar chart (`<c:bar3DChart>` with `barDir=bar`).
44    Bar3D,
45    /// 3D vertical bar chart (`<c:bar3DChart>` with `barDir=col`).
46    Column3D,
47    /// Line chart (`<c:lineChart>`).
48    Line,
49    /// 3D line chart (`<c:line3DChart>`).
50    Line3D,
51    /// Pie chart (`<c:pieChart>`).
52    Pie,
53    /// 3D pie chart (`<c:pie3DChart>`).
54    Pie3D,
55    /// Doughnut chart (`<c:doughnutChart>`).
56    Doughnut,
57    /// Pie-of-pie or bar-of-pie chart (`<c:ofPieChart>`).
58    OfPie,
59    /// Area chart (`<c:areaChart>`).
60    Area,
61    /// 3D area chart (`<c:area3DChart>`).
62    Area3D,
63    /// Scatter (XY) chart (`<c:scatterChart>`).
64    Scatter,
65    /// Bubble chart (`<c:bubbleChart>`).
66    Bubble,
67    /// Radar chart (`<c:radarChart>`).
68    Radar,
69    /// Surface chart (`<c:surfaceChart>`).
70    Surface,
71    /// 3D surface chart (`<c:surface3DChart>`).
72    Surface3D,
73    /// Stock chart (`<c:stockChart>`).
74    Stock,
75}
76
77/// Chart data grouping mode.
78///
79/// Determines how multiple series are arranged visually.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
81pub enum ChartGrouping {
82    /// Side-by-side bars/areas (default).
83    #[default]
84    Clustered,
85    /// Stacked on top of each other.
86    Stacked,
87    /// Stacked to 100%.
88    PercentStacked,
89    /// Standard grouping (used by line/scatter).
90    Standard,
91}
92
93/// Stock chart sub-variant determining series composition.
94///
95/// The basic `ChartType::Stock` maps to HLC (High-Low-Close, 3 series).
96/// Volume variants require a composite `<c:plotArea>` with both a `<c:barChart>`
97/// (volume series) and a `<c:stockChart>` (price series).
98///
99/// # OOXML mapping
100///
101/// | Variant | Series | plotArea layout |
102/// |---------|--------|-----------------|
103/// | `Hlc`   | 3      | stockChart only |
104/// | `Ohlc`  | 4      | stockChart only |
105/// | `Vhlc`  | 4      | barChart + stockChart |
106/// | `Vohlc` | 5      | barChart + stockChart |
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
108pub enum StockVariant {
109    /// High-Low-Close (3 price series, default).
110    Hlc,
111    /// Open-High-Low-Close (4 price series).
112    Ohlc,
113    /// Volume-High-Low-Close (1 volume + 3 price series, composite plotArea).
114    Vhlc,
115    /// Volume-Open-High-Low-Close (1 volume + 4 price series, composite plotArea).
116    Vohlc,
117}
118
119/// Bar/column 3D shape variant.
120///
121/// Controls the visual shape of bars in 3D bar and column charts.
122/// Maps to OOXML `<c:shape val="..."/>` within `<c:bar3DChart>`.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
124pub enum BarShape {
125    /// Standard rectangular box (default).
126    Box,
127    /// Cylindrical column.
128    Cylinder,
129    /// Conical column.
130    Cone,
131    /// Pyramid-shaped column.
132    Pyramid,
133}
134
135/// Scatter chart line/marker style.
136///
137/// Controls how data points are connected and displayed in scatter charts.
138/// Maps to OOXML `<c:scatterStyle val="..."/>` within `<c:scatterChart>`.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
140pub enum ScatterStyle {
141    /// Points only, no lines.
142    Dots,
143    /// Straight lines with markers.
144    LineMarker,
145    /// Smooth curves with markers.
146    SmoothMarker,
147    /// Straight lines without markers.
148    Line,
149    /// Smooth curves without markers.
150    Smooth,
151}
152
153/// Radar chart rendering style.
154///
155/// Controls how the radar chart area is rendered.
156/// Maps to OOXML `<c:radarStyle val="..."/>` within `<c:radarChart>`.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
158pub enum RadarStyle {
159    /// Standard radar (lines only).
160    Standard,
161    /// Radar with data point markers.
162    Marker,
163    /// Filled/shaded radar area.
164    Filled,
165}
166
167/// Pie-of-pie or bar-of-pie sub-type.
168///
169/// Determines whether the secondary chart is a pie or a bar.
170/// Maps to OOXML `<c:ofPieType val="..."/>` within `<c:ofPieChart>`.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
172pub enum OfPieType {
173    /// Pie-of-pie chart.
174    Pie,
175    /// Bar-of-pie chart.
176    Bar,
177}
178
179/// Legend position relative to the chart area.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181pub enum LegendPosition {
182    /// Legend on the right side (default).
183    #[default]
184    Right,
185    /// Legend at the bottom.
186    Bottom,
187    /// Legend at the top.
188    Top,
189    /// Legend on the left side.
190    Left,
191    /// No legend displayed.
192    None,
193}
194
195/// Chart data β€” either category-based or XY-based.
196///
197/// # Examples
198///
199/// ```
200/// use hwpforge_core::chart::ChartData;
201///
202/// let cat = ChartData::category(
203///     &["A", "B"],
204///     &[("Series1", &[10.0, 20.0])],
205/// );
206/// assert!(matches!(cat, ChartData::Category { .. }));
207///
208/// let xy = ChartData::xy(&[("Points", &[1.0, 2.0], &[3.0, 4.0])]);
209/// assert!(matches!(xy, ChartData::Xy { .. }));
210/// ```
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
212pub enum ChartData {
213    /// Category-based data (bar, line, pie, area, radar, etc.).
214    Category {
215        /// Category labels (X-axis).
216        categories: Vec<String>,
217        /// Data series, each with a name and values.
218        series: Vec<ChartSeries>,
219    },
220    /// XY-based data (scatter, bubble).
221    Xy {
222        /// XY series, each with name + x/y value arrays.
223        series: Vec<XySeries>,
224    },
225}
226
227/// A named data series with values aligned to categories.
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
229pub struct ChartSeries {
230    /// Series name (shown in legend).
231    pub name: String,
232    /// Numeric values (one per category).
233    pub values: Vec<f64>,
234}
235
236/// A named XY data series (for scatter/bubble charts).
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
238pub struct XySeries {
239    /// Series name (shown in legend).
240    pub name: String,
241    /// X-axis values.
242    pub x_values: Vec<f64>,
243    /// Y-axis values (must be same length as `x_values`).
244    pub y_values: Vec<f64>,
245}
246
247impl ChartData {
248    /// Creates category-based chart data from slices.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use hwpforge_core::chart::ChartData;
254    ///
255    /// let data = ChartData::category(
256    ///     &["Jan", "Feb", "Mar"],
257    ///     &[("Revenue", &[100.0, 200.0, 300.0])],
258    /// );
259    /// match &data {
260    ///     ChartData::Category { categories, series } => {
261    ///         assert_eq!(categories.len(), 3);
262    ///         assert_eq!(series.len(), 1);
263    ///     }
264    ///     _ => unreachable!(),
265    /// }
266    /// ```
267    pub fn category(cats: &[&str], series: &[(&str, &[f64])]) -> Self {
268        Self::Category {
269            categories: cats.iter().map(|s| (*s).to_string()).collect(),
270            series: series
271                .iter()
272                .map(|(name, vals)| ChartSeries {
273                    name: (*name).to_string(),
274                    values: vals.to_vec(),
275                })
276                .collect(),
277        }
278    }
279
280    /// Creates XY-based chart data from slices.
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use hwpforge_core::chart::ChartData;
286    ///
287    /// let data = ChartData::xy(&[("Points", &[1.0, 2.0], &[3.0, 4.0])]);
288    /// match &data {
289    ///     ChartData::Xy { series } => {
290    ///         assert_eq!(series.len(), 1);
291    ///         assert_eq!(series[0].x_values.len(), 2);
292    ///     }
293    ///     _ => unreachable!(),
294    /// }
295    /// ```
296    pub fn xy(series: &[(&str, &[f64], &[f64])]) -> Self {
297        Self::Xy {
298            series: series
299                .iter()
300                .map(|(name, xs, ys)| XySeries {
301                    name: (*name).to_string(),
302                    x_values: xs.to_vec(),
303                    y_values: ys.to_vec(),
304                })
305                .collect(),
306        }
307    }
308
309    /// Returns `true` if the chart data contains no series.
310    ///
311    /// A chart with zero series cannot be rendered. This is checked during
312    /// document validation (see [`ValidationError::EmptyChartData`](crate::error::ValidationError::EmptyChartData)).
313    pub fn has_no_series(&self) -> bool {
314        match self {
315            Self::Category { series, .. } => series.is_empty(),
316            Self::Xy { series } => series.is_empty(),
317        }
318    }
319
320    /// Returns `true` if the chart data contains no series.
321    #[deprecated(since = "0.2.0", note = "Use `has_no_series()` instead")]
322    pub fn is_empty(&self) -> bool {
323        self.has_no_series()
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn chart_type_all_18_variants() {
333        let variants = [
334            ChartType::Bar,
335            ChartType::Column,
336            ChartType::Bar3D,
337            ChartType::Column3D,
338            ChartType::Line,
339            ChartType::Line3D,
340            ChartType::Pie,
341            ChartType::Pie3D,
342            ChartType::Doughnut,
343            ChartType::OfPie,
344            ChartType::Area,
345            ChartType::Area3D,
346            ChartType::Scatter,
347            ChartType::Bubble,
348            ChartType::Radar,
349            ChartType::Surface,
350            ChartType::Surface3D,
351            ChartType::Stock,
352        ];
353        assert_eq!(variants.len(), 18);
354        // All distinct
355        for (i, a) in variants.iter().enumerate() {
356            for (j, b) in variants.iter().enumerate() {
357                if i != j {
358                    assert_ne!(a, b, "variants {i} and {j} should be distinct");
359                }
360            }
361        }
362    }
363
364    #[test]
365    fn chart_data_category_convenience() {
366        let data = ChartData::category(
367            &["Q1", "Q2", "Q3", "Q4"],
368            &[("Sales", &[100.0, 150.0, 200.0, 250.0]), ("Costs", &[80.0, 90.0, 100.0, 110.0])],
369        );
370        match &data {
371            ChartData::Category { categories, series } => {
372                assert_eq!(categories.len(), 4);
373                assert_eq!(series.len(), 2);
374                assert_eq!(series[0].name, "Sales");
375                assert_eq!(series[1].values, &[80.0, 90.0, 100.0, 110.0]);
376            }
377            _ => panic!("expected Category"),
378        }
379    }
380
381    #[test]
382    fn chart_data_xy_convenience() {
383        let data = ChartData::xy(&[("Points", &[1.0, 2.0, 3.0], &[10.0, 20.0, 30.0])]);
384        match &data {
385            ChartData::Xy { series } => {
386                assert_eq!(series.len(), 1);
387                assert_eq!(series[0].name, "Points");
388                assert_eq!(series[0].x_values, &[1.0, 2.0, 3.0]);
389                assert_eq!(series[0].y_values, &[10.0, 20.0, 30.0]);
390            }
391            _ => panic!("expected Xy"),
392        }
393    }
394
395    #[test]
396    fn chart_data_has_no_series() {
397        let empty_cat = ChartData::category(&["A"], &[]);
398        assert!(empty_cat.has_no_series());
399
400        let non_empty = ChartData::category(&["A"], &[("S", &[1.0])]);
401        assert!(!non_empty.has_no_series());
402
403        let empty_xy = ChartData::Xy { series: vec![] };
404        assert!(empty_xy.has_no_series());
405    }
406
407    #[test]
408    #[allow(deprecated)]
409    fn chart_data_is_empty_deprecated_alias() {
410        let empty = ChartData::category(&["A"], &[]);
411        assert!(empty.is_empty());
412        assert_eq!(empty.is_empty(), empty.has_no_series());
413    }
414
415    #[test]
416    fn serde_roundtrip_chart_data() {
417        let data = ChartData::category(&["A", "B"], &[("S1", &[1.0, 2.0])]);
418        let json = serde_json::to_string(&data).unwrap();
419        let back: ChartData = serde_json::from_str(&json).unwrap();
420        assert_eq!(data, back);
421    }
422
423    #[test]
424    fn serde_roundtrip_xy_data() {
425        let data = ChartData::xy(&[("P", &[1.0, 2.0], &[3.0, 4.0])]);
426        let json = serde_json::to_string(&data).unwrap();
427        let back: ChartData = serde_json::from_str(&json).unwrap();
428        assert_eq!(data, back);
429    }
430
431    #[test]
432    fn chart_grouping_default() {
433        assert_eq!(ChartGrouping::default(), ChartGrouping::Clustered);
434    }
435
436    #[test]
437    fn legend_position_default() {
438        assert_eq!(LegendPosition::default(), LegendPosition::Right);
439    }
440
441    #[test]
442    fn chart_type_copy_clone() {
443        let ct = ChartType::Pie;
444        let ct2 = ct;
445        assert_eq!(ct, ct2);
446    }
447}