Skip to main content

orbital_charts/engine/
projection.rs

1//! Dataset projection into render-ready series.
2
3use orbital_data::{ChartFieldBinding, Dataset, ProjectionError};
4
5/// One projected data series ready for scale mapping.
6#[derive(Clone, Debug, PartialEq)]
7pub struct ProjectedSeries {
8    /// Series identifier (field key or derived id).
9    pub id: String,
10    /// Display label from schema or field key.
11    pub label: String,
12    /// Numeric values aligned with projected categories.
13    pub data: Vec<f64>,
14}
15
16/// Result of projecting a dataset through a field binding.
17#[derive(Clone, Debug, PartialEq)]
18pub struct ProjectedChartData {
19    /// Category axis labels.
20    pub categories: Vec<String>,
21    /// One or more value series.
22    pub series: Vec<ProjectedSeries>,
23}
24
25/// Project a [`Dataset`] into chart-ready series using a [`ChartFieldBinding`].
26pub 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}