use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::{ChartSpec, MAX_SERIES, validate_number, validate_text};
use crate::error::{ChartError, ChartWarning};
pub(crate) const MAX_POINTS: usize = 5000;
const MAX_LINES: usize = 8;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterSpec {
pub points: Vec<ScatterPointSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<ScatterLineSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterPointSpec {
pub x: f64,
pub y: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ScatterAxis {
X,
Y,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterLineSpec {
pub axis: ScatterAxis,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl ScatterSpec {
pub(crate) fn groups(&self) -> Vec<&str> {
let mut groups: Vec<&str> = Vec::new();
for group in self
.points
.iter()
.filter_map(|point| point.group.as_deref())
{
if !groups.contains(&group) {
groups.push(group);
}
}
groups
}
pub(crate) fn color(&self, point: &ScatterPointSpec) -> usize {
point.group.as_deref().map_or(1, |group| {
self.groups()
.iter()
.position(|known| *known == group)
.map_or(1, |position| position + 1)
})
}
}
impl ChartSpec {
pub(super) fn validate_scatter(&self) -> Result<Vec<ChartWarning>, ChartError> {
for (field, present) in [
("/data", !self.data.is_empty()),
("/categories", !self.categories.is_empty()),
("/series", !self.series.is_empty()),
("/zoomSteps", !self.zoom_steps.is_empty()),
("/panes", !self.panes.is_empty()),
("/references", !self.references.is_empty()),
("/timeAxis", !self.time_axis.is_default()),
(
"/valueAxis",
self.value_axis != super::ValueAxisSpec::default(),
),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
field,
"a scatter plot is drawn from points; remove this field, and name the axes with xTitle and yTitle",
));
}
}
let Some(scatter) = &self.scatter else {
return Err(ChartError::new(
"missing_scatter",
"/scatter",
"a scatter plot requires a scatter block",
));
};
if scatter.points.is_empty() {
return Err(ChartError::new(
"empty_data",
"/scatter/points",
"provide at least one point",
));
}
if scatter.points.len() > MAX_POINTS {
return Err(ChartError::new(
"too_many_data_points",
"/scatter/points",
format!("at most {MAX_POINTS} points are supported"),
));
}
for (path, title) in [("xTitle", &scatter.x_title), ("yTitle", &scatter.y_title)] {
if let Some(title) = title {
validate_text(title, &format!("/scatter/{path}"), 100)?;
}
}
validate_points(scatter)?;
if scatter.lines.len() > MAX_LINES {
return Err(ChartError::new(
"too_many_references",
"/scatter/lines",
format!("at most {MAX_LINES} lines are supported"),
));
}
for (index, line) in scatter.lines.iter().enumerate() {
let path = format!("/scatter/lines/{index}");
validate_number(line.value, &format!("{path}/value"))?;
if let Some(label) = &line.label {
validate_text(label, &format!("{path}/label"), 100)?;
}
}
Ok(Vec::new())
}
}
fn validate_points(scatter: &ScatterSpec) -> Result<(), ChartError> {
let grouped = scatter.points.iter().any(|point| point.group.is_some());
let mut groups = BTreeSet::new();
for (index, point) in scatter.points.iter().enumerate() {
let path = format!("/scatter/points/{index}");
validate_number(point.x, &format!("{path}/x"))?;
validate_number(point.y, &format!("{path}/y"))?;
if let Some(label) = &point.label {
validate_text(label, &format!("{path}/label"), 60)?;
}
match (&point.group, grouped) {
(Some(group), _) => {
validate_text(group, &format!("{path}/group"), 60)?;
groups.insert(group.as_str());
if groups.len() > MAX_SERIES {
return Err(ChartError::new(
"too_many_series",
format!("{path}/group"),
format!("at most {MAX_SERIES} groups are supported, one per palette color"),
));
}
}
(None, true) => {
return Err(ChartError::new(
"missing_group",
format!("{path}/group"),
"give every point a group, or none",
));
}
(None, false) => {}
}
}
Ok(())
}