use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::{
ChartSpec, ChartType, MAX_DATA_POINTS, MAX_REFERENCES, MAX_SERIES, Stack, ZoomBound,
validate_number, validate_text,
};
use crate::error::{ChartError, ChartWarning};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataPoint {
pub label: String,
pub value: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lower: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upper: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesSpec {
pub name: String,
pub values: Vec<Option<f64>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lower: Vec<Option<f64>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upper: Vec<Option<f64>>,
}
impl ChartSpec {
pub(crate) fn data_groups(&self) -> Vec<&str> {
let mut groups: Vec<&str> = Vec::new();
for group in self.data.iter().filter_map(|point| point.group.as_deref()) {
if !groups.contains(&group) {
groups.push(group);
}
}
groups
}
pub(crate) fn group_view(&self) -> Option<Self> {
let groups = self.data_groups();
if groups.len() < 2 {
return None;
}
let mut view = self.clone();
view.categories = self.data.iter().map(|point| point.label.clone()).collect();
view.series = groups
.iter()
.map(|group| SeriesSpec {
lower: Vec::new(),
upper: Vec::new(),
name: (*group).to_owned(),
values: self
.data
.iter()
.map(|point| {
if point.group.as_deref() == Some(*group) {
point.value
} else {
None
}
})
.collect(),
})
.collect();
view.data.clear();
view.stack = Some(Stack::Normal);
Some(view)
}
pub(crate) fn has_error_bars(&self) -> bool {
self.data.iter().any(|point| point.lower.is_some())
|| self.series.iter().any(|series| !series.lower.is_empty())
}
pub(crate) fn error_bounds(&self, index: usize, series_index: usize) -> Option<(f64, f64)> {
if self.series.is_empty() {
return self
.data
.get(index)
.and_then(|point| point.lower.zip(point.upper));
}
let series = self.series.get(series_index)?;
(*series.lower.get(index)?).zip(*series.upper.get(index)?)
}
pub(crate) fn error_limits(&self) -> impl Iterator<Item = f64> + '_ {
let single = self
.data
.iter()
.flat_map(|point| [point.lower, point.upper].into_iter().flatten());
let several = self
.series
.iter()
.flat_map(|series| series.lower.iter().chain(&series.upper).flatten().copied());
single.chain(several)
}
fn validate_series_bounds(&self) -> Result<(), ChartError> {
for (series_index, series) in self.series.iter().enumerate() {
if series.lower.is_empty() && series.upper.is_empty() {
continue;
}
let path = format!("/series/{series_index}");
if !matches!(self.chart_type, ChartType::Bar | ChartType::Line) || self.stack.is_some()
{
return Err(ChartError::new(
"option_not_supported",
format!("{path}/lower"),
"error bars belong to a bar chart or a line chart without a stack",
));
}
for (name, bounds) in [("lower", &series.lower), ("upper", &series.upper)] {
if bounds.len() != self.categories.len() {
return Err(ChartError::new(
if bounds.is_empty() {
"missing_bounds"
} else {
"series_length_mismatch"
},
format!("{path}/{name}"),
format!(
"give lower and upper together, {} entries each, one per category",
self.categories.len()
),
));
}
}
for (index, value) in series.values.iter().enumerate() {
let bounds = (series.lower[index], series.upper[index]);
let (Some(value), (Some(lower), Some(upper))) = (value, bounds) else {
if value.is_some() {
return Err(ChartError::new(
"missing_bounds",
format!("{path}/lower/{index}"),
"give every value both lower and upper, or null where the value is null",
));
}
continue;
};
validate_number(lower, &format!("{path}/lower/{index}"))?;
validate_number(upper, &format!("{path}/upper/{index}"))?;
if !(lower..=upper).contains(value) {
return Err(ChartError::new(
"invalid_bounds",
format!("{path}/lower/{index}"),
"the value must lie between lower and upper",
));
}
}
}
Ok(())
}
fn validate_error_bars(&self) -> Result<(), ChartError> {
self.validate_series_bounds()?;
let used = self
.data
.iter()
.any(|point| point.lower.is_some() || point.upper.is_some());
if !used {
return Ok(());
}
for (index, point) in self.data.iter().enumerate() {
let path = format!("/data/{index}");
if !matches!(self.chart_type, ChartType::Bar | ChartType::Line) {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/lower"),
"error bars belong to a bar chart or a line chart",
));
}
if point.group.is_some() {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/group"),
"a bar chart takes error bars or groups, not both",
));
}
let (Some(lower), Some(upper)) = (point.lower, point.upper) else {
return Err(ChartError::new(
"missing_bounds",
path,
"give every point both lower and upper, or none",
));
};
validate_number(lower, &format!("{path}/lower"))?;
validate_number(upper, &format!("{path}/upper"))?;
let Some(value) = point.value else {
return Err(ChartError::new(
"invalid_bounds",
format!("{path}/value"),
"an error bar needs a value between lower and upper; give the value or remove lower and upper",
));
};
if !(lower..=upper).contains(&value) {
return Err(ChartError::new(
"invalid_bounds",
format!("{path}/lower"),
"the value must lie between lower and upper",
));
}
}
Ok(())
}
fn validate_data_groups(&self) -> Result<(), ChartError> {
let grouped = self.data.iter().any(|point| point.group.is_some());
let mut groups: Vec<&str> = Vec::new();
for (index, point) in self.data.iter().enumerate() {
let path = format!("/data/{index}/group");
let Some(group) = point.group.as_deref() else {
if grouped {
return Err(ChartError::new(
"missing_group",
path,
"give every point a group, or none",
));
}
continue;
};
if self.chart_type != ChartType::Bar {
return Err(ChartError::new(
"option_not_supported",
path,
"groups color the bars of a bar chart",
));
}
validate_text(group, &path, 60)?;
if !groups.contains(&group) {
groups.push(group);
}
if groups.len() > MAX_SERIES {
return Err(ChartError::new(
"too_many_series",
path,
format!("at most {MAX_SERIES} groups are supported, one per palette color"),
));
}
}
Ok(())
}
pub(super) fn validate_bar_and_line(&self) -> Result<Vec<ChartWarning>, ChartError> {
let warnings = self.validate_data()?;
self.validate_references()?;
if self.chart_type == ChartType::Line
&& self.series.is_empty()
&& self.data.iter().all(|point| point.value.is_none())
{
return Err(ChartError::new(
"empty_series",
"/data",
"line charts require at least one numeric value",
));
}
Ok(warnings)
}
pub(super) fn validate_references(&self) -> Result<(), ChartError> {
if self.references.len() > MAX_REFERENCES {
return Err(ChartError::new(
"too_many_references",
"/references",
format!("use at most {MAX_REFERENCES} reference lines"),
));
}
for (index, reference) in self.references.iter().enumerate() {
validate_number(reference.value, &format!("/references/{index}/value"))?;
validate_text(&reference.label, &format!("/references/{index}/label"), 60)?;
}
Ok(())
}
pub(super) fn validate_zoom_steps(&self) -> Result<(), ChartError> {
if !matches!(self.chart_type, ChartType::Bar | ChartType::Line) {
return Ok(());
}
if self.zoom_steps.len() == 1 {
return Err(ChartError::new(
"not_enough_zoom_steps",
"/zoomSteps",
"provide at least 2 zoom steps so the view can be switched",
));
}
for (i, step) in self.zoom_steps.iter().enumerate() {
validate_text(&step.label, &format!("/zoomSteps/{i}/label"), 40)?;
let index = |field: &str, bound: &ZoomBound| {
bound.index().ok_or_else(|| {
ChartError::new(
"invalid_spec",
format!("/zoomSteps/{i}/{field}"),
"expected a category index, a whole number from 0",
)
})
};
let from = index("from", &step.from)?;
let to = index("to", &step.to)?;
if from > to {
return Err(ChartError::new(
"invalid_zoom_step",
format!("/zoomSteps/{i}/from"),
"from must not be greater than to",
));
}
let count = if self.data.is_empty() {
self.categories.len()
} else {
self.data.len()
};
if to >= count {
return Err(ChartError::new(
"zoom_out_of_range",
format!("/zoomSteps/{i}/to"),
format!("to must be less than the number of categories ({count})"),
));
}
}
if self.zoom_steps.len() > 4 {
return Err(ChartError::new(
"too_many_zoom_steps",
"/zoomSteps",
"at most 4 zoom steps are supported",
));
}
Ok(())
}
pub(super) fn validate_data(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_stack()?;
if self.categories.is_empty() && self.series.is_empty() {
self.validate_points()
} else {
self.validate_series()
}
}
fn validate_series_shape(&self) -> Result<(), ChartError> {
if !self.data.is_empty() {
return Err(ChartError::new(
"conflicting_data_shape",
"/data",
"use either data or categories with series, not both",
));
}
if self.categories.is_empty() {
return Err(ChartError::new(
"empty_data",
"/categories",
"provide at least one category",
));
}
if self.categories.len() > MAX_DATA_POINTS {
return Err(ChartError::new(
"too_many_data_points",
"/categories",
format!("at most {MAX_DATA_POINTS} categories are supported"),
));
}
if self.series.is_empty() {
return Err(ChartError::new(
"empty_data",
"/series",
"provide at least one series",
));
}
if self.series.len() > MAX_SERIES {
return Err(ChartError::new(
"too_many_series",
"/series",
format!("at most {MAX_SERIES} series are supported"),
));
}
Ok(())
}
fn validate_stack(&self) -> Result<(), ChartError> {
if self.stack.is_none() {
return Ok(());
}
if self.series.len() < 2 {
return Err(ChartError::new(
"option_not_supported",
"/stack",
"stacking needs categories with at least two series",
));
}
if self.stack == Some(Stack::Percent) {
for (series_index, series) in self.series.iter().enumerate() {
if let Some(value_index) = series
.values
.iter()
.position(|value| value.is_some_and(|value| value < 0.0))
{
return Err(ChartError::new(
"invalid_value",
format!("/series/{series_index}/values/{value_index}"),
"a percent stack shows shares of a total and takes no negative values",
));
}
}
}
Ok(())
}
fn validate_series(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_series_shape()?;
let mut labels = BTreeSet::new();
for (index, category) in self.categories.iter().enumerate() {
let path = format!("/categories/{index}");
validate_text(category, &path, 200)?;
if !labels.insert(category.as_str()) {
return Err(ChartError::new(
"duplicate_label",
path,
"categories must be unique",
));
}
}
let mut names = BTreeSet::new();
for (series_index, series) in self.series.iter().enumerate() {
let name_path = format!("/series/{series_index}/name");
validate_text(&series.name, &name_path, 100)?;
if !names.insert(series.name.as_str()) {
return Err(ChartError::new(
"duplicate_series",
name_path,
"series names must be unique",
));
}
if series.values.len() != self.categories.len() {
return Err(ChartError::new(
"series_length_mismatch",
format!("/series/{series_index}/values"),
format!(
"expected {} values, one per category; use null for a missing value",
self.categories.len()
),
));
}
for (value_index, value) in series.values.iter().enumerate() {
if let Some(value) = value {
validate_number(
*value,
&format!("/series/{series_index}/values/{value_index}"),
)?;
}
}
}
self.validate_series_bounds()?;
if self
.series
.iter()
.flat_map(|series| &series.values)
.all(Option::is_none)
{
return Err(ChartError::new(
"empty_series",
"/series",
"provide at least one numeric value",
));
}
let mut warnings = Vec::new();
if self.categories.len() > 16 {
warnings.push(ChartWarning::new(
"dense_chart",
"/categories",
"more than 16 categories can be difficult to read at the configured size",
));
}
Ok(warnings)
}
fn validate_points(&self) -> Result<Vec<ChartWarning>, ChartError> {
if self.data.is_empty() {
return Err(ChartError::new(
"empty_data",
"/data",
"provide at least one data point",
));
}
if self.data.len() > MAX_DATA_POINTS {
return Err(ChartError::new(
"too_many_data_points",
"/data",
format!("at most {MAX_DATA_POINTS} data points are supported"),
));
}
let mut labels = BTreeSet::new();
let mut warnings = Vec::new();
for (index, point) in self.data.iter().enumerate() {
let label_path = format!("/data/{index}/label");
validate_text(&point.label, &label_path, 200)?;
if let Some(value) = point.value {
validate_number(value, &format!("/data/{index}/value"))?;
} else if self.chart_type == ChartType::Bar {
return Err(ChartError::new(
"missing_bar_value",
format!("/data/{index}/value"),
"bar charts require a numeric value for every category",
));
}
if !labels.insert(point.label.as_str()) {
return Err(ChartError::new(
"duplicate_label",
label_path,
"labels must be unique in a single-series chart",
));
}
}
self.validate_data_groups()?;
self.validate_error_bars()?;
if self.data.len() > 16 {
warnings.push(ChartWarning::new(
"dense_chart",
"/data",
"more than 16 categories can be difficult to read at the configured size",
));
}
Ok(warnings)
}
}