orbital_charts/engine/
projection.rs1use orbital_data::{ChartFieldBinding, Dataset, ProjectionError};
4
5#[derive(Clone, Debug, PartialEq)]
7pub struct ProjectedSeries {
8 pub id: String,
10 pub label: String,
12 pub data: Vec<f64>,
14}
15
16#[derive(Clone, Debug, PartialEq)]
18pub struct ProjectedChartData {
19 pub categories: Vec<String>,
21 pub series: Vec<ProjectedSeries>,
23}
24
25pub fn project_chart_data(
27 dataset: &Dataset,
28 binding: &ChartFieldBinding,
29) -> Result<ProjectedChartData, ProjectionError> {
30 if binding.series_by_field.is_some() {
31 return Err(ProjectionError::UnsupportedBinding {
32 reason: "series_by_field pivot is not yet supported".into(),
33 });
34 }
35
36 let x_field =
37 binding
38 .x_field
39 .as_deref()
40 .ok_or_else(|| ProjectionError::UnsupportedBinding {
41 reason: "x_field is required".into(),
42 })?;
43
44 if binding.y_fields.is_empty() {
45 return Err(ProjectionError::UnsupportedBinding {
46 reason: "at least one y_field is required".into(),
47 });
48 }
49
50 let categories = dataset.column_as_categories(x_field)?;
51 let row_count = categories.len();
52
53 let mut series = Vec::with_capacity(binding.y_fields.len());
54 for y_field in &binding.y_fields {
55 let data = dataset.column_as_numbers(y_field)?;
56 if data.len() != row_count {
57 return Err(ProjectionError::LengthMismatch {
58 field: y_field.clone(),
59 expected: row_count,
60 got: data.len(),
61 });
62 }
63
64 let label = dataset
65 .schema
66 .fields
67 .iter()
68 .find(|f| f.key == *y_field)
69 .map(|f| f.label.clone())
70 .unwrap_or_else(|| y_field.clone());
71
72 series.push(ProjectedSeries {
73 id: y_field.clone(),
74 label,
75 data,
76 });
77 }
78
79 Ok(ProjectedChartData { categories, series })
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use orbital_data::{DataRecord, DataSchema, DataValue};
86 use std::collections::HashMap;
87
88 fn fixture_dataset() -> Dataset {
89 let schema = DataSchema::from_text_fields([
90 ("quarter".into(), "Quarter".into()),
91 ("revenue".into(), "Revenue".into()),
92 ("cost".into(), "Cost".into()),
93 ]);
94 let records = vec![
95 DataRecord::new(
96 "1",
97 HashMap::from([
98 ("quarter".into(), DataValue::Category("Q1".into())),
99 ("revenue".into(), DataValue::Number(100.0)),
100 ("cost".into(), DataValue::Number(60.0)),
101 ]),
102 ),
103 DataRecord::new(
104 "2",
105 HashMap::from([
106 ("quarter".into(), DataValue::Category("Q2".into())),
107 ("revenue".into(), DataValue::Number(120.0)),
108 ("cost".into(), DataValue::Number(70.0)),
109 ]),
110 ),
111 ];
112 Dataset::from_records(schema, records)
113 }
114
115 #[test]
116 fn project_chart_data_basic() {
117 let dataset = fixture_dataset();
118 let binding = ChartFieldBinding::new("quarter", vec!["revenue".into(), "cost".into()]);
119 let projected = project_chart_data(&dataset, &binding).unwrap();
120
121 assert_eq!(projected.categories, vec!["Q1", "Q2"]);
122 assert_eq!(projected.series.len(), 2);
123 assert_eq!(projected.series[0].id, "revenue");
124 assert_eq!(projected.series[0].label, "Revenue");
125 assert_eq!(projected.series[0].data, vec![100.0, 120.0]);
126 assert_eq!(projected.series[1].data, vec![60.0, 70.0]);
127 }
128
129 #[test]
130 fn project_chart_data_rejects_series_by_field() {
131 let dataset = fixture_dataset();
132 let binding = ChartFieldBinding {
133 x_field: Some("quarter".into()),
134 y_fields: vec!["revenue".into()],
135 series_by_field: Some("region".into()),
136 ..Default::default()
137 };
138 let err = project_chart_data(&dataset, &binding).unwrap_err();
139 assert!(matches!(err, ProjectionError::UnsupportedBinding { .. }));
140 }
141
142 #[test]
143 fn project_chart_data_requires_y_fields() {
144 let dataset = fixture_dataset();
145 let binding = ChartFieldBinding {
146 x_field: Some("quarter".into()),
147 ..Default::default()
148 };
149 let err = project_chart_data(&dataset, &binding).unwrap_err();
150 assert!(matches!(err, ProjectionError::UnsupportedBinding { .. }));
151 }
152}