use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::{AxisScale, ChartSpec, MAX_SERIES, validate_number, validate_text};
use crate::error::{ChartError, ChartWarning};
pub(crate) const MAX_POINTS: usize = 5000;
const MAX_LINES: usize = 8;
const MAX_TICKS: usize = 40;
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x_range: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y_range: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub x_ticks: Vec<ScatterTickSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quadrants: Option<ScatterQuadrantsSpec>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub regression: bool,
#[serde(default, skip_serializing_if = "AxisScale::is_linear")]
pub x_scale: AxisScale,
#[serde(default, skip_serializing_if = "AxisScale::is_linear")]
pub y_scale: AxisScale,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_title: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Fit {
pub slope: f64,
pub intercept: f64,
pub r_squared: f64,
pub count: usize,
pub from: f64,
pub to: f64,
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterTickSpec {
pub at: f64,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterQuadrantsSpec {
pub x: f64,
pub y: f64,
#[serde(default)]
pub labels: ScatterQuadrantLabels,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScatterQuadrantLabels {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_left: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top_right: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bottom_left: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bottom_right: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Quadrant {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Quadrant {
pub(crate) const ALL: [Self; 4] = [
Self::TopLeft,
Self::TopRight,
Self::BottomLeft,
Self::BottomRight,
];
pub(crate) const fn field(self) -> &'static str {
match self {
Self::TopLeft => "topLeft",
Self::TopRight => "topRight",
Self::BottomLeft => "bottomLeft",
Self::BottomRight => "bottomRight",
}
}
pub(crate) const fn is_top(self) -> bool {
matches!(self, Self::TopLeft | Self::TopRight)
}
pub(crate) const fn is_left(self) -> bool {
matches!(self, Self::TopLeft | Self::BottomLeft)
}
}
impl ScatterQuadrantsSpec {
pub(crate) fn label(&self, quadrant: Quadrant) -> Option<&str> {
let labels = &self.labels;
match quadrant {
Quadrant::TopLeft => labels.top_left.as_deref(),
Quadrant::TopRight => labels.top_right.as_deref(),
Quadrant::BottomLeft => labels.bottom_left.as_deref(),
Quadrant::BottomRight => labels.bottom_right.as_deref(),
}
}
pub(crate) fn of(&self, point: &ScatterPointSpec) -> Option<Quadrant> {
use std::cmp::Ordering::{Greater, Less};
match (point.y.partial_cmp(&self.y)?, point.x.partial_cmp(&self.x)?) {
(Greater, Less) => Some(Quadrant::TopLeft),
(Greater, Greater) => Some(Quadrant::TopRight),
(Less, Less) => Some(Quadrant::BottomLeft),
(Less, Greater) => Some(Quadrant::BottomRight),
_ => None,
}
}
}
#[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 scaled(scale: AxisScale, value: f64) -> f64 {
if scale == AxisScale::Log {
value.ln()
} else {
value
}
}
pub(crate) fn fits(&self) -> Vec<(Option<&str>, Fit)> {
let groups = self.groups();
let sets: Vec<Option<&str>> = if groups.is_empty() {
vec![None]
} else {
groups.into_iter().map(Some).collect()
};
sets.into_iter()
.filter_map(|group| {
let points: Vec<(f64, f64)> = self
.points
.iter()
.filter(|point| group.is_none() || point.group.as_deref() == group)
.map(|point| {
(
Self::scaled(self.x_scale, point.x),
Self::scaled(self.y_scale, point.y),
)
})
.collect();
fit(&points).map(|fit| (group, fit))
})
.collect()
}
pub(crate) fn is_sized(&self) -> bool {
self.points.iter().any(|point| point.size.is_some())
}
pub(crate) fn size_range(&self) -> (f64, f64) {
self.points
.iter()
.filter_map(|point| point.size)
.fold((f64::MAX, f64::MIN), |(low, high), size| {
(low.min(size), high.max(size))
})
}
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)?;
validate_log_axes(scatter)?;
validate_ticks(scatter)?;
validate_ranges(scatter)?;
validate_quadrants(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 sized = scatter.is_sized();
if !sized && scatter.size_title.is_some() {
return Err(ChartError::new(
"option_not_supported",
"/scatter/sizeTitle",
"a size title names the sizes of the points; give every point a `size`, or remove it",
));
}
if let Some(title) = &scatter.size_title {
validate_text(title, "/scatter/sizeTitle", 60)?;
}
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.size, sized) {
(Some(size), _) => {
validate_number(size, &format!("{path}/size"))?;
if size <= 0.0 {
return Err(ChartError::new(
"invalid_value",
format!("{path}/size"),
"a size is above zero: it sets the area of the point",
));
}
}
(None, true) => {
return Err(ChartError::new(
"missing_size",
format!("{path}/size"),
"give every point a size, or none",
));
}
(None, false) => {}
}
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(())
}
fn fit(points: &[(f64, f64)]) -> Option<Fit> {
if points.len() < 3 {
return None;
}
#[allow(clippy::cast_precision_loss)]
let n = points.len() as f64;
let (mean_x, mean_y) = (
points.iter().map(|p| p.0).sum::<f64>() / n,
points.iter().map(|p| p.1).sum::<f64>() / n,
);
let sxx: f64 = points.iter().map(|p| (p.0 - mean_x).powi(2)).sum();
let syy: f64 = points.iter().map(|p| (p.1 - mean_y).powi(2)).sum();
let sxy: f64 = points.iter().map(|p| (p.0 - mean_x) * (p.1 - mean_y)).sum();
if sxx <= f64::EPSILON {
return None;
}
let slope = sxy / sxx;
Some(Fit {
slope,
intercept: mean_y - slope * mean_x,
r_squared: if syy <= f64::EPSILON {
1.0
} else {
sxy * sxy / (sxx * syy)
},
count: points.len(),
from: points.iter().map(|p| p.0).fold(f64::MAX, f64::min),
to: points.iter().map(|p| p.0).fold(f64::MIN, f64::max),
})
}
fn validate_log_axes(scatter: &ScatterSpec) -> Result<(), ChartError> {
for (axis, scale, name) in [
(ScatterAxis::X, scatter.x_scale, "x"),
(ScatterAxis::Y, scatter.y_scale, "y"),
] {
if scale != AxisScale::Log {
continue;
}
let refuse = |path: String| {
Err(ChartError::new(
"invalid_value",
path,
format!("a logarithmic {name} axis needs values above zero"),
))
};
for (index, point) in scatter.points.iter().enumerate() {
let value = if axis == ScatterAxis::X {
point.x
} else {
point.y
};
if value <= 0.0 {
return refuse(format!("/scatter/points/{index}/{name}"));
}
}
for (index, line) in scatter.lines.iter().enumerate() {
if line.axis == axis && line.value <= 0.0 {
return refuse(format!("/scatter/lines/{index}/value"));
}
}
}
Ok(())
}
fn validate_ticks(scatter: &ScatterSpec) -> Result<(), ChartError> {
if scatter.x_ticks.len() > MAX_TICKS {
return Err(ChartError::new(
"too_many_references",
"/scatter/xTicks",
format!("at most {MAX_TICKS} tick labels are supported"),
));
}
for (index, tick) in scatter.x_ticks.iter().enumerate() {
let path = format!("/scatter/xTicks/{index}");
validate_number(tick.at, &format!("{path}/at"))?;
validate_text(&tick.label, &format!("{path}/label"), 40)?;
if scatter.x_scale == AxisScale::Log && tick.at <= 0.0 {
return Err(ChartError::new(
"invalid_value",
format!("{path}/at"),
"a logarithmic x axis needs positions above zero",
));
}
}
Ok(())
}
fn validate_ranges(scatter: &ScatterSpec) -> Result<(), ChartError> {
for (name, range, scale, axis) in [
("xRange", scatter.x_range, scatter.x_scale, ScatterAxis::X),
("yRange", scatter.y_range, scatter.y_scale, ScatterAxis::Y),
] {
let Some([min, max]) = range else { continue };
let path = format!("/scatter/{name}");
validate_number(min, &path)?;
validate_number(max, &path)?;
if min >= max || (scale == AxisScale::Log && min <= 0.0) {
return Err(ChartError::new(
"invalid_range",
path,
"a range runs from a smaller to a larger value, above zero on a logarithmic axis",
));
}
let outside = |value: f64| value < min || value > max;
let found = scatter
.points
.iter()
.enumerate()
.find(|(_, point)| {
outside(if axis == ScatterAxis::X {
point.x
} else {
point.y
})
})
.map(|(index, _)| {
format!(
"/scatter/points/{index}/{}",
if axis == ScatterAxis::X { "x" } else { "y" }
)
})
.or_else(|| {
scatter
.lines
.iter()
.enumerate()
.find(|(_, line)| line.axis == axis && outside(line.value))
.map(|(index, _)| format!("/scatter/lines/{index}/value"))
})
.or_else(|| {
scatter
.x_ticks
.iter()
.enumerate()
.find(|(_, tick)| axis == ScatterAxis::X && outside(tick.at))
.map(|(index, _)| format!("/scatter/xTicks/{index}/at"))
});
if let Some(found) = found {
return Err(ChartError::new(
"out_of_range",
found,
format!("this lies outside {name}"),
));
}
}
Ok(())
}
fn validate_quadrants(scatter: &ScatterSpec) -> Result<(), ChartError> {
let Some(quadrants) = &scatter.quadrants else {
return Ok(());
};
for (name, value, scale, range) in [
("x", quadrants.x, scatter.x_scale, scatter.x_range),
("y", quadrants.y, scatter.y_scale, scatter.y_range),
] {
let path = format!("/scatter/quadrants/{name}");
validate_number(value, &path)?;
if scale == AxisScale::Log && value <= 0.0 {
return Err(ChartError::new(
"invalid_value",
path,
format!("a logarithmic {name} axis needs values above zero"),
));
}
if let Some([min, max]) = range
&& (value <= min || value >= max)
{
return Err(ChartError::new(
"out_of_range",
path,
format!(
"the dividing line must lie inside {name}Range, so that all four areas have room"
),
));
}
}
for quadrant in Quadrant::ALL {
if let Some(label) = quadrants.label(quadrant) {
validate_text(
label,
&format!("/scatter/quadrants/labels/{}", quadrant.field()),
40,
)?;
}
}
Ok(())
}