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}