use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_path_to_error::Segment;
use crate::error::{ChartError, ChartWarning};
use crate::layout::plot_pixels;
use crate::time::TimeValue;
const MAX_DATA_POINTS: usize = 100;
pub(crate) const MAX_SERIES: usize = 4;
pub(crate) const MAX_TIME_POINTS_PER_LAYER: usize = 2_000;
pub(crate) const MAX_TIME_LAYERS: usize = 6;
pub(crate) const MAX_TIME_PANES: usize = 4;
pub(crate) const MAX_HEIGHT_RATIO: u32 = 10;
pub(crate) const MAX_ANNOTATIONS: usize = 6;
pub(crate) const MIN_PANELS: usize = 2;
pub(crate) const MAX_PANELS: usize = 12;
pub(crate) const MAX_COLUMNS: u32 = 6;
pub(crate) const MAX_STRIPES: usize = 500;
pub(crate) const MAX_DECIMALS: u8 = 6;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChartSpec {
pub schema_version: u8,
#[serde(rename = "type")]
pub chart_type: ChartType,
#[serde(default)]
pub orientation: Orientation,
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub category_axis: CategoryAxisSpec,
#[serde(default)]
pub value_axis: ValueAxisSpec,
#[serde(default = "default_width")]
pub width: u32,
#[serde(default = "default_height")]
pub height: u32,
#[serde(default = "default_show_values")]
pub show_values: bool,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub show_title: bool,
#[serde(default)]
pub data: Vec<DataPoint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categories: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub series: Vec<SeriesSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub zoom_steps: Vec<ZoomStep>,
#[serde(default, skip_serializing_if = "Theme::is_light")]
pub theme: Theme,
#[serde(default, skip_serializing_if = "TimeAxisSpec::is_default")]
pub time_axis: TimeAxisSpec,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub panes: Vec<PaneSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topicmap: Option<TopicMapSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub atlas: Option<AtlasSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stripes: Option<StripesSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calendar: Option<CalendarSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ranges: Vec<RangeSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<u32>,
#[serde(default, skip_serializing_if = "Locale::is_en")]
pub locale: Locale,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mobile: Option<MobileSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MobileSpec {
pub width: u32,
#[serde(default = "default_mobile_height")]
pub height: u32,
#[serde(default = "default_breakpoint")]
pub breakpoint: u32,
}
pub(crate) struct Diverging {
pub reference: f64,
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StripesSpec {
pub first_year: i32,
pub values: Vec<Option<f64>>,
#[serde(default)]
pub reference: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default = "default_true")]
pub year_labels: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CalendarLayout {
#[default]
Months,
Weeks,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CalendarDay {
pub date: String,
pub value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CalendarSpec {
pub year: i32,
#[serde(default)]
pub layout: CalendarLayout,
pub days: Vec<CalendarDay>,
#[serde(default)]
pub reference: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RangeSpec {
pub label: String,
pub low: f64,
pub high: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mid: Option<f64>,
#[serde(default, skip_serializing_if = "is_false")]
pub modeled: bool,
}
impl StripesSpec {
pub(crate) fn years(&self) -> impl Iterator<Item = i32> + '_ {
(0..self.values.len()).map(|offset| {
self.first_year + i32::try_from(offset).expect("stripes are limited to 500 values")
})
}
pub(crate) fn diverging(&self) -> Diverging {
Diverging::new(
self.reference,
self.min,
self.max,
self.values.iter().flatten().copied(),
)
}
}
impl CalendarSpec {
pub(crate) fn sorted_days(&self) -> Vec<&CalendarDay> {
let mut days: Vec<&CalendarDay> = self.days.iter().collect();
days.sort_by(|a, b| a.date.cmp(&b.date));
days
}
pub(crate) fn diverging(&self) -> Diverging {
Diverging::new(
self.reference,
self.min,
self.max,
self.days.iter().filter_map(|day| day.value),
)
}
}
impl Diverging {
pub(crate) const STEPS: usize = 8;
fn new(
reference: f64,
min: Option<f64>,
max: Option<f64>,
values: impl Iterator<Item = f64>,
) -> Self {
let reach = values
.map(|value| (value - reference).abs())
.fold(0.0, f64::max);
let reach = if reach > 0.0 { reach } else { 1.0 };
Self {
reference,
min: min.unwrap_or(reference - reach),
max: max.unwrap_or(reference + reach),
}
}
pub(crate) fn step(&self, value: f64) -> usize {
let (distance, reach) = if value >= self.reference {
(value - self.reference, self.max - self.reference)
} else {
(self.reference - value, self.reference - self.min)
};
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let arm = ((distance / reach * Self::STEPS as f64).round() as usize).min(Self::STEPS);
if value >= self.reference {
Self::STEPS + arm
} else {
Self::STEPS - arm
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Light,
Dark,
}
impl Theme {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_light(&self) -> bool {
matches!(self, Self::Light)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Locale {
#[default]
En,
De,
}
impl Locale {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_en(&self) -> bool {
matches!(self, Self::En)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct NumberStyle {
pub format: ValueFormat,
pub decimals: Option<u8>,
pub locale: Locale,
}
impl From<ValueFormat> for NumberStyle {
fn from(format: ValueFormat) -> Self {
Self {
format,
..Self::default()
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Gaps {
#[default]
Show,
Collapse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TimeAxisSpec {
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default)]
pub gaps: Gaps,
#[serde(default)]
pub title: Option<String>,
}
impl TimeAxisSpec {
fn is_default(&self) -> bool {
self.timezone == default_timezone() && self.gaps == Gaps::Show && self.title.is_none()
}
}
impl Default for TimeAxisSpec {
fn default() -> Self {
Self {
timezone: default_timezone(),
gaps: Gaps::default(),
title: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PaneSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default = "default_height_ratio")]
pub height_ratio: u32,
#[serde(default)]
pub value_axis: ValueAxisSpec,
#[serde(default)]
pub layers: Vec<LayerSpec>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mark {
Line,
Area,
Ohlc,
Band,
Annotation,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Shape {
TriangleUp,
TriangleDown,
Square,
Circle,
Diamond,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LayerSpec {
pub mark: Mark,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub points: Vec<TimePoint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub data: Vec<OhlcPoint>,
#[serde(default)]
pub from: Option<TimeValue>,
#[serde(default)]
pub to: Option<TimeValue>,
#[serde(default)]
pub top: Option<f64>,
#[serde(default)]
pub bottom: Option<f64>,
#[serde(default)]
pub time: Option<TimeValue>,
#[serde(default)]
pub value: Option<f64>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub shape: Option<Shape>,
#[serde(default, skip_serializing_if = "is_false")]
pub modeled: bool,
#[serde(default, skip_serializing_if = "Stroke::is_regular")]
pub stroke: Stroke,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dash: Option<Dash>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Stroke {
#[default]
Regular,
Thin,
Bold,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Dash {
Solid,
Dashed,
Dotted,
}
impl Stroke {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_regular(&self) -> bool {
matches!(self, Self::Regular)
}
}
impl LayerSpec {
pub(crate) fn resolved_color(&self) -> Option<&str> {
self.color
.as_deref()
.map(|color| crate::color::sanitize(color).unwrap_or(crate::color::NEUTRAL))
}
pub(crate) fn resolved_points(&self, zone: crate::time::TimeZone) -> Vec<(i64, f64)> {
self.points
.iter()
.filter_map(|point| {
let epoch = point.time.resolve(zone).ok()?;
Some((epoch, point.value?))
})
.collect()
}
pub(crate) fn resolved_band(&self, zone: crate::time::TimeZone) -> Vec<(i64, f64, f64)> {
self.points
.iter()
.filter_map(|point| {
let epoch = point.time.resolve(zone).ok()?;
Some((epoch, point.lower?, point.upper?))
})
.collect()
}
pub(crate) fn resolved_segments(&self, zone: crate::time::TimeZone) -> Vec<Vec<(i64, f64)>> {
let mut segments = vec![Vec::new()];
for point in &self.points {
let Ok(epoch) = point.time.resolve(zone) else {
continue;
};
match point.value {
Some(value) => segments
.last_mut()
.expect("there is always a current segment")
.push((epoch, value)),
None => segments.push(Vec::new()),
}
}
segments.retain(|segment| !segment.is_empty());
segments
}
pub(crate) fn resolved_times(&self, zone: crate::time::TimeZone) -> Vec<i64> {
self.points
.iter()
.map(|point| &point.time)
.chain(self.data.iter().map(|candle| &candle.time))
.filter_map(|time| time.resolve(zone).ok())
.collect()
}
pub(crate) fn resolved_candles(&self, zone: crate::time::TimeZone) -> Vec<(i64, [f64; 4])> {
self.data
.iter()
.filter_map(|candle| {
let epoch = candle.time.resolve(zone).ok()?;
Some((epoch, [candle.open, candle.high, candle.low, candle.close]))
})
.collect()
}
fn observed_times(&self, zone: crate::time::TimeZone) -> impl Iterator<Item = i64> {
self.resolved_points(zone)
.into_iter()
.map(|(epoch, _)| epoch)
.chain(
self.resolved_candles(zone)
.into_iter()
.map(|(epoch, _)| epoch),
)
}
pub(crate) fn missing_values(&self) -> usize {
self.points
.iter()
.filter(|point| point.value.is_none())
.count()
}
pub(crate) fn has_band(&self) -> bool {
self.points.iter().any(|point| point.lower.is_some())
}
pub(crate) fn effective_dash(&self) -> Dash {
self.dash.unwrap_or(if self.modeled {
Dash::Dashed
} else {
Dash::Solid
})
}
pub(crate) fn is_data(&self) -> bool {
!matches!(self.mark, Mark::Annotation | Mark::Band)
}
pub(crate) fn takes_palette(&self) -> bool {
self.is_data() && self.mark != Mark::Ohlc && self.color.is_none()
}
pub(crate) fn is_rule(&self) -> bool {
self.mark == Mark::Annotation && (self.time.is_some() != self.value.is_some())
}
pub(crate) fn is_marker(&self) -> bool {
self.mark == Mark::Annotation && self.time.is_some() && self.value.is_some()
}
pub(crate) fn marker_shape(&self) -> Shape {
self.shape.unwrap_or(Shape::Circle)
}
fn has_two_points(&self, zone: crate::time::TimeZone) -> bool {
self.resolved_points(zone).len() >= 2
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TimePoint {
pub time: TimeValue,
pub value: Option<f64>,
#[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 OhlcPoint {
pub time: TimeValue,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ChartType {
Bar,
Line,
Time,
Topicmap,
Atlas,
Stripes,
Calendar,
Rangebar,
Multiples,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopicSpec {
pub label: String,
pub value: f64,
#[serde(default)]
pub points: u32,
#[serde(default)]
pub tooltip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopicLinkSpec {
pub from: String,
pub to: String,
#[serde(default = "one")]
pub weight: f64,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum Corner {
#[default]
BottomRight,
BottomLeft,
TopRight,
TopLeft,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CartoucheSpec {
pub heading: String,
pub meta: String,
#[serde(default)]
pub corner: Corner,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TopicMapSpec {
pub topics: Vec<TopicSpec>,
#[serde(default)]
pub links: Vec<TopicLinkSpec>,
#[serde(default)]
pub islands: Vec<TopicSpec>,
#[serde(default)]
pub seed: u64,
#[serde(default)]
pub cartouche: Option<CartoucheSpec>,
#[serde(default = "default_true")]
pub graticule: bool,
#[serde(default = "default_true")]
pub compass: bool,
#[serde(default = "default_depth_bands")]
pub depth_bands: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PlaceSpec {
pub label: String,
#[serde(default = "one")]
pub weight: f64,
#[serde(default)]
pub tooltip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RegionSpec {
pub label: String,
pub value: f64,
#[serde(default)]
pub places: Vec<PlaceSpec>,
#[serde(default)]
pub tooltip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RealmSpec {
pub label: String,
pub regions: Vec<RegionSpec>,
#[serde(default)]
pub tooltip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AtlasSpec {
pub realms: Vec<RealmSpec>,
#[serde(default)]
pub links: Vec<TopicLinkSpec>,
#[serde(default)]
pub seed: u64,
#[serde(default = "default_area_damping")]
pub area_damping: f64,
#[serde(default = "default_true")]
pub contours: bool,
}
const fn default_area_damping() -> f64 {
0.5
}
const MAX_REALMS: usize = 8;
const MAX_REGIONS: usize = 60;
const MAX_PLACES: usize = 4000;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Orientation {
#[default]
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CategoryAxisSpec {
#[serde(default)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ValueAxisSpec {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub format: ValueFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decimals: Option<u8>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ValueFormat {
#[default]
Number,
Percent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DataPoint {
pub label: String,
pub value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesSpec {
pub name: String,
pub values: Vec<Option<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ZoomStep {
pub label: String,
pub from: ZoomBound,
pub to: ZoomBound,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZoomBound {
Index(usize),
Time(TimeValue),
}
impl ZoomBound {
pub(crate) const fn index(&self) -> Option<usize> {
match self {
Self::Index(index) => Some(*index),
Self::Time(_) => None,
}
}
pub(crate) fn resolve(&self, zone: crate::time::TimeZone) -> Result<i64, &'static str> {
match self {
Self::Index(seconds) => {
#[allow(clippy::cast_precision_loss)]
let seconds = *seconds as f64;
TimeValue::Number(seconds).resolve(zone)
}
Self::Time(time) => time.resolve(zone),
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct LayerRef<'a> {
pub global: usize,
pub pane: usize,
pub local: usize,
pub layer: &'a LayerSpec,
}
pub(crate) struct Dataset {
pub categories: Vec<String>,
pub series: Vec<Series>,
}
pub(crate) struct Series {
pub name: Option<String>,
pub values: Vec<Option<f64>>,
pub style: Option<NumberStyle>,
}
impl Dataset {
pub(crate) fn values(&self) -> impl Iterator<Item = f64> + '_ {
self.series
.iter()
.flat_map(|series| series.values.iter().flatten().copied())
}
pub(crate) fn category_path(&self, index: usize) -> String {
if self.series[0].name.is_none() {
format!("/data/{index}/label")
} else {
format!("/categories/{index}")
}
}
}
impl ChartSpec {
pub fn from_json(input: &str) -> Result<Self, ChartError> {
let mut deserializer = serde_json::Deserializer::from_str(input);
serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
let source = error.inner();
let (code, path) = match source.classify() {
serde_json::error::Category::Syntax | serde_json::error::Category::Eof => {
("invalid_json", "/".to_owned())
}
serde_json::error::Category::Data | serde_json::error::Category::Io => {
("invalid_spec", json_pointer(error.path()))
}
};
ChartError::new(code, path, source.to_string())
})
}
pub(crate) fn validate(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_sized(true)
}
pub(crate) fn mobile_variant(&self) -> Option<ChartSpec> {
let mobile = self.mobile.as_ref()?;
let mut spec = self.clone();
spec.width = mobile.width;
spec.height = mobile.height;
spec.mobile = None;
Some(spec)
}
pub(crate) fn validate_mobile_variant(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_sized(false)
}
fn validate_sized(&self, check_size: bool) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_metadata(check_size)?;
self.reject_foreign_blocks()?;
match self.chart_type {
ChartType::Time => return self.validate_time(),
ChartType::Multiples => return self.validate_multiples(),
ChartType::Topicmap => return self.validate_topicmap(),
ChartType::Atlas => return self.validate_atlas(),
ChartType::Stripes => return self.validate_stripes(),
ChartType::Calendar => return self.validate_calendar(),
ChartType::Rangebar => return self.validate_rangebar(),
ChartType::Bar | ChartType::Line => {}
}
let warnings = self.validate_data()?;
if self.chart_type == ChartType::Line && 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)
}
fn validate_decimals(&self) -> Result<(), ChartError> {
let axes = std::iter::once(("/valueAxis/decimals".to_owned(), &self.value_axis)).chain(
self.panes.iter().enumerate().map(|(index, pane)| {
(
format!("/panes/{index}/valueAxis/decimals"),
&pane.value_axis,
)
}),
);
for (path, axis) in axes {
if axis
.decimals
.is_some_and(|decimals| decimals > MAX_DECIMALS)
{
return Err(ChartError::new(
"invalid_decimals",
path,
format!("use 0 to {MAX_DECIMALS} decimal places"),
));
}
}
Ok(())
}
fn validate_size(&self) -> Result<(), ChartError> {
if !(320..=2_400).contains(&self.width) {
return Err(ChartError::new(
"invalid_dimension",
"/width",
"width must be between 320 and 2400",
));
}
if !(240..=1_600).contains(&self.height) {
return Err(ChartError::new(
"invalid_dimension",
"/height",
"height must be between 240 and 1600",
));
}
if let Some(mobile) = &self.mobile {
for (path, value, range) in [
("/mobile/width", mobile.width, 280..=600),
("/mobile/height", mobile.height, 240..=1_600),
("/mobile/breakpoint", mobile.breakpoint, 320..=1_600),
] {
if !range.contains(&value) {
return Err(ChartError::new(
"invalid_dimension",
path,
format!(
"{} must be between {} and {}",
&path[1..],
range.start(),
range.end()
),
));
}
}
}
Ok(())
}
fn validate_metadata(&self, check_size: bool) -> Result<(), ChartError> {
if self.schema_version != 1 {
return Err(ChartError::new(
"unsupported_schema_version",
"/schemaVersion",
"expected schemaVersion 1",
));
}
if self.chart_type == ChartType::Line && self.orientation != Orientation::Vertical {
return Err(ChartError::new(
"option_not_supported",
"/orientation",
"orientation is only available for bar charts",
));
}
self.validate_decimals()?;
if !self.show_title && self.chart_type != ChartType::Time {
return Err(ChartError::new(
"option_not_supported",
"/showTitle",
"leaving the drawn title out is available for time charts so far",
));
}
validate_text(&self.title, "/title", 200)?;
if let Some(description) = &self.description {
validate_text(description, "/description", 1_000)?;
}
if let Some(source) = &self.source {
validate_text(source, "/source", 300)?;
}
validate_optional_text(
self.category_axis.title.as_ref(),
"/categoryAxis/title",
100,
)?;
validate_optional_text(self.value_axis.title.as_ref(), "/valueAxis/title", 100)?;
if check_size {
self.validate_size()?;
}
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(crate) fn dataset(&self) -> Dataset {
if self.series.is_empty() {
Dataset {
categories: self.data.iter().map(|point| point.label.clone()).collect(),
series: vec![Series {
name: None,
values: self.data.iter().map(|point| point.value).collect(),
style: None,
}],
}
} else {
Dataset {
categories: self.categories.clone(),
series: self
.series
.iter()
.map(|series| Series {
name: Some(series.name.clone()),
values: series.values.clone(),
style: None,
})
.collect(),
}
}
}
pub(crate) fn table_dataset(&self) -> Dataset {
match self.chart_type {
ChartType::Time | ChartType::Multiples => {
let zone = self.time_zone().unwrap_or_default();
self.time_dataset(zone, self.time_precision(zone), true)
}
ChartType::Topicmap => self.topicmap_dataset(),
ChartType::Atlas => self.atlas_dataset(),
ChartType::Stripes => self.stripes_dataset(),
ChartType::Calendar => self.calendar_dataset(),
ChartType::Rangebar => self.rangebar_dataset(),
ChartType::Bar | ChartType::Line => self.dataset(),
}
}
fn stripes_dataset(&self) -> Dataset {
let stripes = self
.stripes
.as_ref()
.expect("validated stripes charts carry a stripes block");
Dataset {
categories: stripes.years().map(|year| year.to_string()).collect(),
series: vec![Series {
name: None,
values: stripes.values.clone(),
style: None,
}],
}
}
fn calendar_dataset(&self) -> Dataset {
let calendar = self
.calendar
.as_ref()
.expect("validated calendar charts carry a calendar block");
let days = calendar.sorted_days();
Dataset {
categories: days.iter().map(|day| day.date.clone()).collect(),
series: vec![Series {
name: None,
values: days.iter().map(|day| day.value).collect(),
style: None,
}],
}
}
fn rangebar_dataset(&self) -> Dataset {
let words = self.locale.words();
let mut series = vec![
Series {
name: Some(words.range_low.to_owned()),
values: self.ranges.iter().map(|range| Some(range.low)).collect(),
style: None,
},
Series {
name: Some(words.range_high.to_owned()),
values: self.ranges.iter().map(|range| Some(range.high)).collect(),
style: None,
},
];
if self.ranges.iter().any(|range| range.mid.is_some()) {
series.insert(
1,
Series {
name: Some(words.range_mid.to_owned()),
values: self.ranges.iter().map(|range| range.mid).collect(),
style: None,
},
);
}
Dataset {
categories: self
.ranges
.iter()
.map(|range| {
if range.modeled {
format!("{} ({})", range.label, words.modeled)
} else {
range.label.clone()
}
})
.collect(),
series,
}
}
fn atlas_dataset(&self) -> Dataset {
let atlas = self
.atlas
.as_ref()
.expect("validated atlas charts carry an atlas block");
let regions = || {
atlas
.realms
.iter()
.flat_map(|realm| realm.regions.iter().map(move |region| (realm, region)))
};
Dataset {
categories: regions()
.map(|(realm, region)| format!("{} · {}", realm.label, region.label))
.collect(),
series: vec![
Series {
name: Some(self.locale.words().entries.to_owned()),
values: regions().map(|(_, region)| Some(region.value)).collect(),
style: None,
},
Series {
name: Some(self.locale.words().places.to_owned()),
values: regions()
.map(|(_, region)| Some(place_count(region.places.len())))
.collect(),
style: None,
},
],
}
}
fn topicmap_dataset(&self) -> Dataset {
let topicmap = self
.topicmap
.as_ref()
.expect("validated topicmap charts carry a topicmap block");
let all = || topicmap.topics.iter().chain(topicmap.islands.iter());
let total: f64 = all().map(|topic| topic.value).sum();
Dataset {
categories: all().map(|topic| topic.label.clone()).collect(),
series: vec![
Series {
name: Some(self.locale.words().entries.to_owned()),
values: all().map(|topic| Some(topic.value)).collect(),
style: None,
},
Series {
name: Some(self.locale.words().paths.to_owned()),
values: all().map(|topic| Some(f64::from(topic.points))).collect(),
style: None,
},
Series {
name: Some(self.locale.words().share.to_owned()),
values: all()
.map(|topic| Some(((topic.value / total) * 10_000.0).round() / 10_000.0))
.collect(),
style: Some(NumberStyle {
format: ValueFormat::Percent,
decimals: None,
locale: self.locale,
}),
},
],
}
}
pub(crate) fn time_zone(&self) -> Result<crate::time::TimeZone, ChartError> {
crate::time::TimeZone::parse(&self.time_axis.timezone).ok_or_else(|| {
ChartError::new(
"invalid_timezone",
"/timeAxis/timezone",
"expected UTC or a fixed offset such as +02:00",
)
})
}
pub(crate) fn layers(&self) -> impl Iterator<Item = &LayerSpec> {
self.panes.iter().flat_map(|pane| pane.layers.iter())
}
pub(crate) fn indexed_layers(&self) -> impl Iterator<Item = LayerRef<'_>> {
self.panes
.iter()
.enumerate()
.flat_map(|(pane, spec)| {
spec.layers
.iter()
.enumerate()
.map(move |(local, layer)| (pane, local, layer))
})
.enumerate()
.map(|(global, (pane, local, layer))| LayerRef {
global,
pane,
local,
layer,
})
}
pub(crate) fn data_layers(&self) -> impl Iterator<Item = LayerRef<'_>> {
self.indexed_layers().filter(|entry| entry.layer.is_data())
}
pub(crate) fn series_names(&self) -> Vec<Option<String>> {
let mut names: Vec<Option<String>> = Vec::new();
for entry in self.data_layers() {
if !names.contains(&entry.layer.name) {
names.push(entry.layer.name.clone());
}
}
names
}
pub(crate) fn palette_index(&self, layer: &LayerSpec) -> usize {
let index = if self.chart_type == ChartType::Multiples {
self.series_names()
.iter()
.position(|name| *name == layer.name)
.unwrap_or(0)
} else {
let data = || {
self.layers()
.filter(|candidate| candidate.is_data() && candidate.mark != Mark::Ohlc)
.enumerate()
};
let mut taken = [false; MAX_SERIES];
for (position, candidate) in data() {
if position < MAX_SERIES && candidate.color.is_none() {
taken[position] = true;
}
}
let mut index = 0;
for (position, candidate) in data() {
let slot = if position < MAX_SERIES {
position
} else if candidate.color.is_none() {
let free = taken.iter().position(|taken| !taken).unwrap_or(0);
taken[free] = true;
free
} else {
0
};
if std::ptr::eq(candidate, layer) {
index = slot;
break;
}
}
index
};
index.min(MAX_SERIES - 1)
}
pub(crate) fn time_precision(&self, zone: crate::time::TimeZone) -> crate::time::Precision {
let epochs: Vec<i64> = self
.data_layers()
.flat_map(|entry| entry.layer.resolved_times(zone))
.collect();
crate::time::Precision::of(epochs.into_iter(), zone)
}
pub(crate) fn number_style(&self) -> NumberStyle {
let decimals = match self.chart_type {
ChartType::Time => self.panes.first().and_then(|pane| pane.value_axis.decimals),
ChartType::Bar | ChartType::Line | ChartType::Rangebar | ChartType::Multiples => {
self.value_axis.decimals
}
ChartType::Topicmap | ChartType::Atlas | ChartType::Stripes | ChartType::Calendar => {
None
}
};
NumberStyle {
format: self.value_format(),
decimals,
locale: self.locale,
}
}
pub(crate) fn pane_style(&self, pane_index: usize) -> NumberStyle {
match self.chart_type {
ChartType::Time => NumberStyle {
format: self.panes[pane_index].value_axis.format,
decimals: self.panes[pane_index].value_axis.decimals,
locale: self.locale,
},
_ => self.number_style(),
}
}
pub(crate) fn value_format(&self) -> ValueFormat {
match self.chart_type {
ChartType::Time => self
.panes
.first()
.map_or(ValueFormat::Number, |pane| pane.value_axis.format),
ChartType::Topicmap | ChartType::Atlas | ChartType::Stripes | ChartType::Calendar => {
ValueFormat::Number
}
ChartType::Bar | ChartType::Line | ChartType::Rangebar | ChartType::Multiples => {
self.value_axis.format
}
}
}
pub(crate) fn time_dataset(
&self,
zone: crate::time::TimeZone,
precision: crate::time::Precision,
bounds: bool,
) -> Dataset {
let mut categories: Vec<i64> = self
.data_layers()
.flat_map(|entry| entry.layer.resolved_times(zone))
.collect();
categories.sort_unstable();
categories.dedup();
let column = |pairs: Vec<(i64, f64)>| -> Vec<Option<f64>> {
categories
.iter()
.map(|epoch| {
pairs
.iter()
.find_map(|(point, value)| (point == epoch).then_some(*value))
})
.collect()
};
let part_name = |name: &Option<String>, part: &str| {
if let Some(name) = name {
format!("{name} ({part})")
} else {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
};
let words = self.locale.words();
let mut series = Vec::new();
for (entry, name) in self.data_layers().zip(self.layer_names()) {
let layer = entry.layer;
let style = (self.chart_type == ChartType::Time).then(|| self.pane_style(entry.pane));
if layer.mark == Mark::Ohlc {
let candles = layer.resolved_candles(zone);
for (index, part) in [words.open, words.high, words.low, words.close]
.into_iter()
.enumerate()
{
series.push(Series {
name: Some(part_name(&name, part)),
values: column(
candles
.iter()
.map(|(epoch, values)| (*epoch, values[index]))
.collect(),
),
style,
});
}
continue;
}
series.push(Series {
name: name.clone(),
values: column(layer.resolved_points(zone)),
style,
});
if bounds && layer.has_band() {
let band = layer.resolved_band(zone);
for (edge, pick) in [(words.lower, 0), (words.upper, 1)] {
series.push(Series {
name: Some(part_name(&name, edge)),
values: column(
band.iter()
.map(|(epoch, lower, upper)| {
(*epoch, if pick == 0 { *lower } else { *upper })
})
.collect(),
),
style,
});
}
}
}
Dataset {
categories: categories
.iter()
.map(|epoch| precision.format(*epoch, zone))
.collect(),
series,
}
}
pub(crate) fn layer_names(&self) -> impl Iterator<Item = Option<String>> + '_ {
let named = self.data_layers().count() > 1;
self.data_layers().map(move |entry| {
let (pane_index, layer) = (entry.pane, entry.layer);
let name = layer
.name
.clone()
.or_else(|| named.then(|| self.locale.words().value.to_owned()));
match (&self.panes[pane_index].title, name) {
(Some(title), Some(name)) if self.chart_type == ChartType::Multiples => {
Some(if layer.name.is_some() {
format!("{title} · {name}")
} else {
title.clone()
})
}
(_, name) => name,
}
})
}
pub(crate) fn windowed(&self, from: i64, to: i64) -> ChartSpec {
let zone = self.time_zone().unwrap_or_default();
let inside = |time: &TimeValue| {
time.resolve(zone)
.is_ok_and(|epoch| (from..=to).contains(&epoch))
};
let mut spec = self.clone();
for pane in &mut spec.panes {
pane.layers
.retain(|layer| layer.time.as_ref().is_none_or(&inside));
pane.layers.retain(|layer| {
let resolve = |time: &Option<TimeValue>| {
time.as_ref().and_then(|time| time.resolve(zone).ok())
};
layer.mark != Mark::Band
|| (resolve(&layer.from).is_none_or(|start| start < to)
&& resolve(&layer.to).is_none_or(|end| end > from))
});
for layer in &mut pane.layers {
layer.points.retain(|point| inside(&point.time));
layer.data.retain(|candle| inside(&candle.time));
if layer.mark == Mark::Band {
#[allow(clippy::cast_precision_loss)]
let clamp = |time: &mut Option<TimeValue>| {
if let Some(epoch) = time.as_ref().and_then(|time| time.resolve(zone).ok())
&& !(from..=to).contains(&epoch)
{
*time = Some(TimeValue::Number(epoch.clamp(from, to) as f64));
}
};
clamp(&mut layer.from);
clamp(&mut layer.to);
}
}
}
spec
}
pub(crate) fn sliced(&self, from: usize, to: usize) -> ChartSpec {
let mut spec = self.clone();
if self.data.is_empty() {
spec.categories = self.categories[from..=to].to_vec();
spec.series = self
.series
.iter()
.map(|s| {
let mut series = s.clone();
series.values = s.values[from..=to].to_vec();
series
})
.collect();
} else {
spec.data = self.data[from..=to].to_vec();
}
spec
}
fn validate_data(&self) -> Result<Vec<ChartWarning>, ChartError> {
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.chart_type == ChartType::Line {
return Err(ChartError::new(
"option_not_supported",
"/series",
"series are only available for bar charts in this alpha; use data for a line chart",
));
}
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_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}"),
)?;
}
}
}
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",
));
}
}
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)
}
fn reject_bar_and_line_options(&self, noun: &str) -> Result<(), 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() && self.chart_type == ChartType::Multiples,
),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
field,
format!("{noun} is drawn from panes and layers; remove this field"),
));
}
}
if self.orientation != Orientation::Vertical {
return Err(ChartError::new(
"option_not_supported",
"/orientation",
"orientation is only available for bar charts",
));
}
if self.category_axis.title.is_some() {
return Err(ChartError::new(
"option_not_supported",
"/categoryAxis/title",
format!("{noun} labels its time axis with timeAxis.title"),
));
}
if self.chart_type == ChartType::Multiples {
return Ok(());
}
if self.value_axis.title.is_some() {
return Err(ChartError::new(
"option_not_supported",
"/valueAxis/title",
"a time chart sets the value axis title inside its pane",
));
}
if self.value_axis.format != ValueFormat::Number {
return Err(ChartError::new(
"option_not_supported",
"/valueAxis/format",
"a time chart sets the value format inside its pane",
));
}
Ok(())
}
fn reject_foreign_blocks(&self) -> Result<(), ChartError> {
let own = self.chart_type;
for (field, present, owner) in [
("/stripes", self.stripes.is_some(), ChartType::Stripes),
("/calendar", self.calendar.is_some(), ChartType::Calendar),
("/ranges", !self.ranges.is_empty(), ChartType::Rangebar),
("/columns", self.columns.is_some(), ChartType::Multiples),
] {
if present && own != owner {
return Err(ChartError::new(
"option_not_supported",
field,
format!(
"{field} belongs to a {} chart; remove it or change the type",
type_name(owner)
),
));
}
}
if matches!(
own,
ChartType::Stripes | ChartType::Calendar | ChartType::Rangebar | ChartType::Multiples
) {
for (field, present, owner) in [
("/topicmap", self.topicmap.is_some(), ChartType::Topicmap),
("/atlas", self.atlas.is_some(), ChartType::Atlas),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
field,
format!(
"{field} belongs to a {} chart; remove it or change the type",
type_name(owner)
),
));
}
}
}
Ok(())
}
fn validate_time_axis(&self) -> Result<crate::time::TimeZone, ChartError> {
let zone = self.time_zone()?;
validate_optional_text(self.time_axis.title.as_ref(), "/timeAxis/title", 100)?;
if self.time_axis.gaps == Gaps::Collapse {
return Err(ChartError::new(
"option_not_supported",
"/timeAxis/gaps",
"collapsing gaps is not supported yet; omit gaps to keep the distances",
));
}
Ok(zone)
}
fn validate_time(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_bar_and_line_options("a time chart")?;
let zone = self.validate_time_axis()?;
if self.panes.is_empty() {
return Err(ChartError::new(
"empty_data",
"/panes",
"provide at least one pane",
));
}
if self.panes.len() > MAX_TIME_PANES {
return Err(ChartError::new(
"too_many_panes",
"/panes",
format!("at most {MAX_TIME_PANES} panes are supported"),
));
}
let mut warnings = Vec::new();
let plot_pixels =
usize::try_from(plot_pixels(self.width)).expect("a usize is at least 32 bits wide");
let data_layers = self.data_layers().count();
let mut names = BTreeSet::new();
for (pane_index, pane) in self.panes.iter().enumerate() {
let pane_path = format!("/panes/{pane_index}");
if pane.title.is_some() {
return Err(ChartError::new(
"option_not_supported",
format!("{pane_path}/title"),
"pane titles head the panels of small multiples; a time chart uses its title, and a pane is named by its valueAxis title",
));
}
if !(1..=MAX_HEIGHT_RATIO).contains(&pane.height_ratio) {
return Err(ChartError::new(
"invalid_height_ratio",
format!("{pane_path}/heightRatio"),
format!("heightRatio must be between 1 and {MAX_HEIGHT_RATIO}"),
));
}
validate_optional_text(
pane.value_axis.title.as_ref(),
&format!("{pane_path}/valueAxis/title"),
100,
)?;
let context = LayerContext {
zone,
plot_pixels,
named: data_layers > 1,
};
validate_pane_layers(pane, &pane_path, &context, &mut names, &mut warnings)?;
}
if let Some(entry) = self
.data_layers()
.filter(|entry| entry.layer.takes_palette())
.nth(MAX_SERIES)
{
return Err(ChartError::new(
"too_many_layers",
format!("/panes/{}/layers/{}/color", entry.pane, entry.local),
format!(
"at most {MAX_SERIES} data layers of a chart take a palette color; give this layer a color of its own"
),
));
}
self.validate_time_zoom(zone)?;
Ok(warnings)
}
fn validate_time_zoom(&self, zone: crate::time::TimeZone) -> Result<(), ChartError> {
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 resolve = |field: &str, bound: &ZoomBound| {
bound.resolve(zone).map_err(|message| {
ChartError::new("invalid_time", format!("/zoomSteps/{i}/{field}"), message)
})
};
let from = resolve("from", &step.from)?;
let to = resolve("to", &step.to)?;
if from >= to {
return Err(ChartError::new(
"invalid_zoom_step",
format!("/zoomSteps/{i}/from"),
"from must lie before to",
));
}
let covered = self.panes.iter().all(|pane| {
pane.layers
.iter()
.filter(|layer| layer.is_data())
.any(|layer| {
layer
.observed_times(zone)
.filter(|epoch| (from..=to).contains(epoch))
.count()
>= 2
})
});
if !covered {
return Err(ChartError::new(
"zoom_out_of_range",
format!("/zoomSteps/{i}"),
"the window must hold at least two observations of one layer in every pane",
));
}
}
if self.zoom_steps.len() > 4 {
return Err(ChartError::new(
"too_many_zoom_steps",
"/zoomSteps",
"at most 4 zoom steps are supported",
));
}
Ok(())
}
fn validate_multiples(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_bar_and_line_options("a small-multiples chart")?;
let zone = self.validate_time_axis()?;
if self.panes.len() < MIN_PANELS {
return Err(ChartError::new(
"not_enough_panes",
"/panes",
format!("provide at least {MIN_PANELS} panes; a single one is a time chart"),
));
}
if self.panes.len() > MAX_PANELS {
return Err(ChartError::new(
"too_many_panes",
"/panes",
format!("at most {MAX_PANELS} panes are supported"),
));
}
let columns = self.multiples_columns();
if !(1..=MAX_COLUMNS).contains(&columns) {
return Err(ChartError::new(
"invalid_columns",
"/columns",
format!("columns must be between 1 and {MAX_COLUMNS}"),
));
}
let mut warnings = Vec::new();
let plot_pixels = usize::try_from(crate::layout::panel_plot_pixels(self.width, columns))
.expect("a usize is at least 32 bits wide");
let mut titles = BTreeSet::new();
for (pane_index, pane) in self.panes.iter().enumerate() {
let pane_path = format!("/panes/{pane_index}");
let Some(title) = &pane.title else {
return Err(ChartError::new(
"missing_title",
format!("{pane_path}/title"),
"every panel of small multiples needs a title",
));
};
validate_text(title, &format!("{pane_path}/title"), 100)?;
if !titles.insert(title.as_str()) {
return Err(ChartError::new(
"duplicate_title",
format!("{pane_path}/title"),
"panel titles must be unique",
));
}
if pane.value_axis.title.is_some()
|| pane.value_axis.format != ValueFormat::Number
|| pane.value_axis.decimals.is_some()
{
return Err(ChartError::new(
"option_not_supported",
format!("{pane_path}/valueAxis"),
"small multiples share one value axis; set valueAxis at the top level",
));
}
if pane.height_ratio != default_height_ratio() {
return Err(ChartError::new(
"option_not_supported",
format!("{pane_path}/heightRatio"),
"the panels of small multiples all have the same size",
));
}
if let Some(layer_index) = pane
.layers
.iter()
.position(|layer| layer.mark == Mark::Ohlc)
{
return Err(ChartError::new(
"option_not_supported",
format!("{pane_path}/layers/{layer_index}/mark"),
"candles are drawn on a time chart; small multiples take line and area layers",
));
}
let context = LayerContext {
zone,
plot_pixels,
named: pane.layers.iter().filter(|layer| layer.is_data()).count() > 1,
};
validate_pane_layers(
pane,
&pane_path,
&context,
&mut BTreeSet::new(),
&mut warnings,
)?;
}
if self.series_names().len() > MAX_SERIES {
return Err(ChartError::new(
"too_many_series",
"/panes",
format!(
"at most {MAX_SERIES} distinct layer names are supported across all panels"
),
));
}
Ok(warnings)
}
pub(crate) fn multiples_columns(&self) -> u32 {
self.columns.unwrap_or_else(|| {
u32::try_from(self.panes.len().min(3)).expect("at most three columns")
})
}
fn validate_stripes(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("a stripes chart", "stripes.values")?;
let Some(stripes) = &self.stripes else {
return Err(ChartError::new(
"missing_stripes",
"/stripes",
"a stripes chart requires a stripes block",
));
};
if stripes.values.is_empty() {
return Err(ChartError::new(
"empty_data",
"/stripes/values",
"provide at least one value",
));
}
if stripes.values.len() > MAX_STRIPES {
return Err(ChartError::new(
"too_many_data_points",
"/stripes/values",
format!("at most {MAX_STRIPES} yearly values are supported"),
));
}
let last_year = i64::from(stripes.first_year) + count_i64(stripes.values.len()) - 1;
if stripes.first_year < 1 || last_year > 9_999 {
return Err(ChartError::new(
"invalid_year",
"/stripes/firstYear",
"the years must lie between 1 and 9999",
));
}
for (index, value) in stripes.values.iter().enumerate() {
if let Some(value) = value {
validate_number(*value, &format!("/stripes/values/{index}"))?;
}
}
if stripes.values.iter().all(Option::is_none) {
return Err(ChartError::new(
"empty_series",
"/stripes/values",
"provide at least one numeric value",
));
}
validate_diverging(stripes.reference, stripes.min, stripes.max, "/stripes")?;
Ok(Vec::new())
}
fn validate_calendar(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("a calendar chart", "calendar.days")?;
let Some(calendar) = &self.calendar else {
return Err(ChartError::new(
"missing_calendar",
"/calendar",
"a calendar chart requires a calendar block",
));
};
if !(1_700..=2_199).contains(&calendar.year) {
return Err(ChartError::new(
"invalid_year",
"/calendar/year",
"year must lie between 1700 and 2199",
));
}
if calendar.days.is_empty() {
return Err(ChartError::new(
"empty_data",
"/calendar/days",
"provide at least one day",
));
}
let mut dates = BTreeSet::new();
for (index, day) in calendar.days.iter().enumerate() {
let path = format!("/calendar/days/{index}");
let Some((year, _, _)) = calendar_date(&day.date) else {
return Err(ChartError::new(
"invalid_date",
format!("{path}/date"),
"expected an ISO 8601 date such as 2024-03-01",
));
};
if year != i64::from(calendar.year) {
return Err(ChartError::new(
"date_outside_year",
format!("{path}/date"),
format!("the date must lie in {}", calendar.year),
));
}
if !dates.insert(day.date.as_str()) {
return Err(ChartError::new(
"duplicate_date",
format!("{path}/date"),
"every day may appear only once",
));
}
if let Some(value) = day.value {
validate_number(value, &format!("{path}/value"))?;
}
}
if calendar.days.iter().all(|day| day.value.is_none()) {
return Err(ChartError::new(
"empty_series",
"/calendar/days",
"provide at least one numeric value",
));
}
validate_diverging(calendar.reference, calendar.min, calendar.max, "/calendar")?;
Ok(Vec::new())
}
fn validate_rangebar(&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()),
("/timeAxis", !self.time_axis.is_default()),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
field,
"a rangebar chart is drawn from ranges; remove this field",
));
}
}
if self.ranges.is_empty() {
return Err(ChartError::new(
"empty_data",
"/ranges",
"provide at least one range",
));
}
if self.ranges.len() > MAX_DATA_POINTS {
return Err(ChartError::new(
"too_many_data_points",
"/ranges",
format!("at most {MAX_DATA_POINTS} ranges are supported"),
));
}
let mut labels = BTreeSet::new();
for (index, range) in self.ranges.iter().enumerate() {
let path = format!("/ranges/{index}");
validate_text(&range.label, &format!("{path}/label"), 200)?;
if !labels.insert(range.label.as_str()) {
return Err(ChartError::new(
"duplicate_label",
format!("{path}/label"),
"range labels must be unique",
));
}
validate_number(range.low, &format!("{path}/low"))?;
validate_number(range.high, &format!("{path}/high"))?;
if range.low > range.high {
return Err(ChartError::new(
"invalid_range",
format!("{path}/high"),
"high must not be below low",
));
}
if let Some(mid) = range.mid {
validate_number(mid, &format!("{path}/mid"))?;
if !(range.low..=range.high).contains(&mid) {
return Err(ChartError::new(
"mid_outside_range",
format!("{path}/mid"),
"mid must lie between low and high",
));
}
}
}
let mut warnings = Vec::new();
if self.ranges.len() > 16 {
warnings.push(ChartWarning::new(
"dense_chart",
"/ranges",
"more than 16 categories can be difficult to read at the configured size",
));
}
Ok(warnings)
}
fn reject_map_options(&self, noun: &str, source: &str) -> Result<(), 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()),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
field,
format!("{noun} is drawn from {source}; remove this field"),
));
}
}
if self.orientation != Orientation::Vertical {
return Err(ChartError::new(
"option_not_supported",
"/orientation",
"orientation is only available for bar charts",
));
}
if self.category_axis.title.is_some() {
return Err(ChartError::new(
"option_not_supported",
"/categoryAxis/title",
format!("{noun} has no category axis"),
));
}
if self.value_axis.title.is_some() || self.value_axis.format != ValueFormat::Number {
return Err(ChartError::new(
"option_not_supported",
"/valueAxis",
format!("{noun} has no value axis"),
));
}
if !self.time_axis.is_default() {
return Err(ChartError::new(
"option_not_supported",
"/timeAxis",
format!("{noun} has no time axis"),
));
}
Ok(())
}
fn validate_topicmap(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("a topicmap chart", "topicmap.topics")?;
let Some(topicmap) = &self.topicmap else {
return Err(ChartError::new(
"missing_topicmap",
"/topicmap",
"a topicmap chart requires a topicmap block",
));
};
if topicmap.topics.is_empty() {
return Err(ChartError::new(
"empty_data",
"/topicmap/topics",
"provide at least one topic",
));
}
if topicmap.topics.len() > 40 {
return Err(ChartError::new(
"too_many_topics",
"/topicmap/topics",
"at most 40 topics are supported",
));
}
if topicmap.islands.len() > 6 {
return Err(ChartError::new(
"too_many_islands",
"/topicmap/islands",
"at most 6 islands are supported",
));
}
if !(0..=3).contains(&topicmap.depth_bands) {
return Err(ChartError::new(
"invalid_depth_bands",
"/topicmap/depthBands",
"depthBands must be between 0 and 3",
));
}
let mut labels = BTreeSet::new();
let mut warnings = Vec::new();
for (index, topic) in topicmap.topics.iter().enumerate() {
validate_topic(
topic,
&format!("/topicmap/topics/{index}"),
true,
&mut labels,
&mut warnings,
)?;
}
for (index, island) in topicmap.islands.iter().enumerate() {
validate_topic(
island,
&format!("/topicmap/islands/{index}"),
false,
&mut labels,
&mut warnings,
)?;
}
for (index, link) in topicmap.links.iter().enumerate() {
let path = format!("/topicmap/links/{index}");
if !labels.contains(link.from.as_str()) {
return Err(ChartError::new(
"unknown_topic_link",
format!("{path}/from"),
format!("{:?} is not a declared topic or island label", link.from),
));
}
if !labels.contains(link.to.as_str()) {
return Err(ChartError::new(
"unknown_topic_link",
format!("{path}/to"),
format!("{:?} is not a declared topic or island label", link.to),
));
}
if !(0.0..=1.0).contains(&link.weight) {
return Err(ChartError::new(
"invalid_link_weight",
format!("{path}/weight"),
"weight must be between 0 and 1",
));
}
}
if let Some(cartouche) = &topicmap.cartouche {
validate_text(&cartouche.heading, "/topicmap/cartouche/heading", 100)?;
validate_text(&cartouche.meta, "/topicmap/cartouche/meta", 200)?;
}
Ok(warnings)
}
fn validate_atlas(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("an atlas chart", "atlas.realms")?;
let Some(atlas) = &self.atlas else {
return Err(ChartError::new(
"missing_atlas",
"/atlas",
"an atlas chart requires an atlas block",
));
};
if atlas.realms.is_empty() {
return Err(ChartError::new(
"empty_data",
"/atlas/realms",
"provide at least one realm",
));
}
if atlas.realms.len() > MAX_REALMS {
return Err(ChartError::new(
"too_many_realms",
"/atlas/realms",
format!("at most {MAX_REALMS} realms are supported"),
));
}
if !(0.2..=1.0).contains(&atlas.area_damping) {
return Err(ChartError::new(
"invalid_area_damping",
"/atlas/areaDamping",
"areaDamping must be between 0.2 and 1",
));
}
let mut labels = BTreeSet::new();
let mut regions = BTreeSet::new();
let mut warnings = Vec::new();
let mut places = 0usize;
for (index, realm) in atlas.realms.iter().enumerate() {
places += validate_realm(
realm,
&format!("/atlas/realms/{index}"),
&mut labels,
&mut regions,
&mut warnings,
)?;
}
if regions.len() > MAX_REGIONS {
return Err(ChartError::new(
"too_many_regions",
"/atlas/realms",
format!("at most {MAX_REGIONS} regions are supported across all realms"),
));
}
if places > MAX_PLACES {
return Err(ChartError::new(
"too_many_places",
"/atlas/realms",
format!("at most {MAX_PLACES} places are supported across all regions"),
));
}
for (index, link) in atlas.links.iter().enumerate() {
let path = format!("/atlas/links/{index}");
for (side, label) in [("from", &link.from), ("to", &link.to)] {
if !regions.contains(label.as_str()) {
return Err(ChartError::new(
"unknown_region_link",
format!("{path}/{side}"),
format!("{label:?} is not a declared region label"),
));
}
}
if !(0.0..=1.0).contains(&link.weight) {
return Err(ChartError::new(
"invalid_link_weight",
format!("{path}/weight"),
"weight must be between 0 and 1",
));
}
}
Ok(warnings)
}
}
fn validate_realm(
realm: &RealmSpec,
path: &str,
labels: &mut BTreeSet<String>,
regions: &mut BTreeSet<String>,
warnings: &mut Vec<ChartWarning>,
) -> Result<usize, ChartError> {
validate_text(&realm.label, &format!("{path}/label"), 200)?;
validate_optional_text(realm.tooltip.as_ref(), &format!("{path}/tooltip"), 300)?;
if !labels.insert(realm.label.clone()) {
return Err(ChartError::new(
"duplicate_atlas_label",
format!("{path}/label"),
"realm and region labels must be unique",
));
}
if realm.regions.is_empty() {
return Err(ChartError::new(
"empty_realm",
format!("{path}/regions"),
"a realm needs at least one region",
));
}
if realm.regions.len() == 1 {
warnings.push(ChartWarning::new(
"realm_without_structure",
format!("{path}/regions"),
"a realm with a single region has no inner structure to show; it is drawn as one area",
));
}
let mut places = 0usize;
for (index, region) in realm.regions.iter().enumerate() {
let region_path = format!("{path}/regions/{index}");
validate_text(®ion.label, &format!("{region_path}/label"), 200)?;
validate_number(region.value, &format!("{region_path}/value"))?;
if region.value < 1.0 {
return Err(ChartError::new(
"region_value_out_of_range",
format!("{region_path}/value"),
"value must be at least 1",
));
}
validate_optional_text(
region.tooltip.as_ref(),
&format!("{region_path}/tooltip"),
300,
)?;
if !labels.insert(region.label.clone()) {
return Err(ChartError::new(
"duplicate_atlas_label",
format!("{region_path}/label"),
"realm and region labels must be unique",
));
}
regions.insert(region.label.clone());
places += region.places.len();
if places_exceed_value(region.places.len(), region.value) {
warnings.push(ChartWarning::new(
"more_places_than_value",
format!("{region_path}/places"),
"the region lists more places than its value; the label will not match what is drawn",
));
}
for (place_index, place) in region.places.iter().enumerate() {
validate_place(place, &format!("{region_path}/places/{place_index}"))?;
}
}
Ok(places)
}
#[allow(clippy::cast_precision_loss)]
fn place_count(places: usize) -> f64 {
places as f64
}
#[allow(clippy::cast_precision_loss)]
fn places_exceed_value(places: usize, value: f64) -> bool {
places as f64 > value
}
fn validate_place(place: &PlaceSpec, path: &str) -> Result<(), ChartError> {
validate_text(&place.label, &format!("{path}/label"), 200)?;
validate_number(place.weight, &format!("{path}/weight"))?;
if !(0.25..=4.0).contains(&place.weight) {
return Err(ChartError::new(
"place_weight_out_of_range",
format!("{path}/weight"),
"weight must be between 0.25 and 4",
));
}
validate_optional_text(place.tooltip.as_ref(), &format!("{path}/tooltip"), 300)?;
Ok(())
}
fn validate_topic(
topic: &TopicSpec,
path: &str,
warn_if_small: bool,
labels: &mut BTreeSet<String>,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
validate_text(&topic.label, &format!("{path}/label"), 200)?;
validate_number(topic.value, &format!("{path}/value"))?;
if topic.value < 1.0 {
return Err(ChartError::new(
"topic_value_out_of_range",
format!("{path}/value"),
"value must be at least 1",
));
}
if let Some(tooltip) = &topic.tooltip {
validate_text(tooltip, &format!("{path}/tooltip"), 300)?;
}
if !labels.insert(topic.label.clone()) {
return Err(ChartError::new(
"duplicate_topic_label",
format!("{path}/label"),
"topic and island labels must be unique",
));
}
if warn_if_small && topic.value < 12.0 {
warnings.push(ChartWarning::new(
"topic_too_small_for_label",
format!("{path}/value"),
"below 12, an area is too small to hold its own label at this scale; consider listing it as an island instead",
));
}
Ok(())
}
pub(crate) struct LayerContext {
pub zone: crate::time::TimeZone,
pub plot_pixels: usize,
pub named: bool,
}
fn check_layer_color(layer: &LayerSpec, path: &str, warnings: &mut Vec<ChartWarning>) {
if layer
.color
.as_deref()
.is_some_and(|color| crate::color::sanitize(color).is_none())
{
let color = layer.color.as_deref().unwrap_or_default();
warnings.push(ChartWarning::new(
"color_not_supported",
format!("{path}/color"),
format!(
"{color:?} is not #rgb, #rrggbb, #rrggbbaa or var(--name); the layer is painted in the neutral tone"
),
));
}
}
fn validate_points(
layer: &LayerSpec,
path: &str,
zone: crate::time::TimeZone,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
let banded = layer.has_band();
let mut previous: Option<i64> = None;
for (point_index, point) in layer.points.iter().enumerate() {
let point_path = format!("{path}/points/{point_index}");
let epoch = point.time.resolve(zone).map_err(|message| {
ChartError::new("invalid_time", format!("{point_path}/time"), message)
})?;
if let Some(value) = point.value {
validate_number(value, &format!("{point_path}/value"))?;
validate_band_point(point, &point_path, banded, value, warnings)?;
} else if point.lower.is_some() || point.upper.is_some() {
let edge = if point.lower.is_some() {
"lower"
} else {
"upper"
};
return Err(ChartError::new(
"invalid_band",
format!("{point_path}/{edge}"),
"a missing value has no band; leave out lower and upper where value is null",
));
}
if previous.is_some_and(|previous| epoch <= previous) {
return Err(ChartError::new(
"unordered_time",
format!("{point_path}/time"),
"timestamps must increase from point to point",
));
}
previous = Some(epoch);
}
Ok(())
}
fn validate_band_point(
point: &TimePoint,
point_path: &str,
banded: bool,
value: f64,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
for (edge, present) in [
("lower", point.lower.is_some()),
("upper", point.upper.is_some()),
] {
if present != banded {
return Err(ChartError::new(
"incomplete_band",
format!("{point_path}/{edge}"),
"give every point of a layer both lower and upper, or none of them",
));
}
}
let (Some(lower), Some(upper)) = (point.lower, point.upper) else {
return Ok(());
};
validate_number(lower, &format!("{point_path}/lower"))?;
validate_number(upper, &format!("{point_path}/upper"))?;
if lower > upper {
return Err(ChartError::new(
"invalid_band",
format!("{point_path}/upper"),
"upper must not be below lower",
));
}
if !(lower..=upper).contains(&value) {
warnings.push(ChartWarning::new(
"value_outside_band",
format!("{point_path}/value"),
"the value lies outside its own band; check that line and band describe the same quantity",
));
}
Ok(())
}
fn validate_pane_layers(
pane: &PaneSpec,
pane_path: &str,
context: &LayerContext,
names: &mut BTreeSet<String>,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
let zone = context.zone;
let data_layers = pane.layers.iter().filter(|layer| layer.is_data()).count();
let annotations = pane.layers.len() - data_layers;
if data_layers == 0 {
return Err(ChartError::new(
"empty_data",
format!("{pane_path}/layers"),
"provide at least one line layer; a reference line alone has nothing to refer to",
));
}
if data_layers > MAX_TIME_LAYERS {
return Err(ChartError::new(
"too_many_layers",
format!("{pane_path}/layers"),
format!("at most {MAX_TIME_LAYERS} data layers are supported"),
));
}
if let Some((layer_index, _)) = pane
.layers
.iter()
.enumerate()
.filter(|(_, layer)| layer.takes_palette())
.nth(MAX_SERIES)
{
return Err(ChartError::new(
"too_many_layers",
format!("{pane_path}/layers/{layer_index}/color"),
format!(
"at most {MAX_SERIES} data layers take a palette color; give this layer a color of its own"
),
));
}
if annotations > MAX_ANNOTATIONS {
return Err(ChartError::new(
"too_many_annotations",
format!("{pane_path}/layers"),
format!("at most {MAX_ANNOTATIONS} annotation layers are supported per pane"),
));
}
for (layer_index, layer) in pane.layers.iter().enumerate() {
let layer_path = format!("{pane_path}/layers/{layer_index}");
match layer.mark {
Mark::Annotation => validate_annotation(layer, &layer_path, zone, warnings)?,
Mark::Band => validate_zone(layer, &layer_path, zone, warnings)?,
Mark::Ohlc => crate::ohlc::validate(layer, &layer_path, context, names, warnings)?,
Mark::Line | Mark::Area => {
validate_layer(layer, &layer_path, context, names, warnings)?;
}
}
}
Ok(())
}
fn validate_annotation(
layer: &LayerSpec,
path: &str,
zone: crate::time::TimeZone,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
check_layer_color(layer, path, warnings);
for (field, present, reason) in [
(
"points",
!layer.points.is_empty(),
"belongs to a line layer",
),
("data", !layer.data.is_empty(), "belongs to an ohlc layer"),
("from", layer.from.is_some(), "belongs to a band layer"),
("to", layer.to.is_some(), "belongs to a band layer"),
("top", layer.top.is_some(), "belongs to a band layer"),
("bottom", layer.bottom.is_some(), "belongs to a band layer"),
("modeled", layer.modeled, "belongs to a line layer"),
(
"stroke",
layer.stroke != Stroke::Regular,
"belongs to a line layer",
),
("dash", layer.dash.is_some(), "belongs to a line layer"),
(
"name",
layer.name.is_some(),
"is not used; an annotation is named by its label",
),
(
"shape",
layer.shape.is_some() && !layer.is_marker(),
"belongs to a point marker with both time and value; a reference line has no shape",
),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/{field}"),
format!("{field} {reason}"),
));
}
}
if layer.time.is_none() && layer.value.is_none() {
return Err(ChartError::new(
"missing_position",
path,
"give time for a vertical reference line, value for a horizontal one, or both for a point marker",
));
}
if let Some(time) = &layer.time {
time.resolve(zone)
.map_err(|message| ChartError::new("invalid_time", format!("{path}/time"), message))?;
}
if let Some(value) = layer.value {
validate_number(value, &format!("{path}/value"))?;
}
let Some(label) = &layer.label else {
return Err(ChartError::new(
"missing_label",
format!("{path}/label"),
if layer.is_marker() {
"a point marker needs a label that says what it marks"
} else {
"a reference line needs a label that says what it marks"
},
));
};
validate_text(label, &format!("{path}/label"), 100)
}
fn validate_zone(
layer: &LayerSpec,
path: &str,
zone: crate::time::TimeZone,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
check_layer_color(layer, path, warnings);
for (field, present, reason) in [
(
"points",
!layer.points.is_empty(),
"belongs to a line layer; for an uncertainty band around a line, give its points lower and upper",
),
("data", !layer.data.is_empty(), "belongs to an ohlc layer"),
(
"time",
layer.time.is_some(),
"belongs to an annotation; a zone spans from and to",
),
(
"value",
layer.value.is_some(),
"belongs to an annotation; a zone spans bottom and top",
),
("shape", layer.shape.is_some(), "belongs to a point marker"),
("modeled", layer.modeled, "belongs to a line layer"),
(
"stroke",
layer.stroke != Stroke::Regular,
"belongs to a line layer",
),
("dash", layer.dash.is_some(), "belongs to a line layer"),
(
"name",
layer.name.is_some(),
"is not used; a zone is named by its label",
),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/{field}"),
format!("{field} {reason}"),
));
}
}
let resolve = |field: &str, time: Option<&TimeValue>| {
time.map(|time| {
time.resolve(zone).map_err(|message| {
ChartError::new("invalid_time", format!("{path}/{field}"), message)
})
})
.transpose()
};
let from = resolve("from", layer.from.as_ref())?;
let to = resolve("to", layer.to.as_ref())?;
if let Some(bottom) = layer.bottom {
validate_number(bottom, &format!("{path}/bottom"))?;
}
if let Some(top) = layer.top {
validate_number(top, &format!("{path}/top"))?;
}
let values = layer.bottom.is_some() && layer.top.is_some();
let times = from.is_some() && to.is_some();
if !values && !times {
return Err(ChartError::new(
"missing_position",
path,
"give bottom and top for a range of values, from and to for a span of time, or both",
));
}
if let (Some(bottom), Some(top)) = (layer.bottom, layer.top)
&& bottom >= top
{
return Err(ChartError::new(
"invalid_band",
format!("{path}/top"),
"top must lie above bottom",
));
}
if let (Some(from), Some(to)) = (from, to)
&& from >= to
{
return Err(ChartError::new(
"invalid_band",
format!("{path}/to"),
"to must lie after from",
));
}
let Some(label) = &layer.label else {
return Err(ChartError::new(
"missing_label",
format!("{path}/label"),
"a zone needs a label that says what it marks",
));
};
validate_text(label, &format!("{path}/label"), 100)
}
pub(crate) fn validate_layer_name(
layer: &LayerSpec,
path: &str,
required: bool,
names: &mut BTreeSet<String>,
) -> Result<(), ChartError> {
match layer.name.as_deref() {
Some(name) => {
validate_text(name, &format!("{path}/name"), 100)?;
if !names.insert(name.to_owned()) {
return Err(ChartError::new(
"duplicate_series",
format!("{path}/name"),
"layer names must be unique within a pane, and across the panes of a time chart",
));
}
}
None if required => {
return Err(ChartError::new(
"missing_name",
format!("{path}/name"),
"name every layer when a pane or a chart has more than one",
));
}
None => {}
}
Ok(())
}
fn validate_layer(
layer: &LayerSpec,
path: &str,
context: &LayerContext,
names: &mut BTreeSet<String>,
warnings: &mut Vec<ChartWarning>,
) -> Result<(), ChartError> {
let (zone, plot_pixels) = (context.zone, context.plot_pixels);
let mark = if layer.mark == Mark::Area {
"an area"
} else {
"a line"
};
check_layer_color(layer, path, warnings);
for (field, present, owner) in [
("data", !layer.data.is_empty(), "ohlc"),
("from", layer.from.is_some(), "band"),
("to", layer.to.is_some(), "band"),
("top", layer.top.is_some(), "band"),
("bottom", layer.bottom.is_some(), "band"),
("time", layer.time.is_some(), "annotation"),
("value", layer.value.is_some(), "annotation"),
("label", layer.label.is_some(), "annotation"),
("shape", layer.shape.is_some(), "annotation"),
] {
if present {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/{field}"),
format!("{field} belongs to a {owner} layer, not to {mark} layer"),
));
}
}
validate_layer_name(layer, path, context.named, names)?;
if layer.points.is_empty() {
return Err(ChartError::new(
"empty_series",
format!("{path}/points"),
"provide at least one observation",
));
}
if layer.points.len() > MAX_TIME_POINTS_PER_LAYER {
return Err(ChartError::new(
"too_many_data_points",
format!("{path}/points"),
format!("at most {MAX_TIME_POINTS_PER_LAYER} observations are supported per layer"),
));
}
validate_points(layer, path, zone, warnings)?;
if !layer.has_two_points(zone) {
return Err(ChartError::new(
"empty_series",
format!("{path}/points"),
"a line needs at least two observations",
));
}
if layer.points.len() > plot_pixels {
warnings.push(ChartWarning::new(
"dense_chart",
format!("{path}/points"),
format!(
"{} observations exceed the {plot_pixels} horizontal pixels of the plot; the line cannot show every point at this width",
layer.points.len()
),
));
}
Ok(())
}
fn validate_diverging(
reference: f64,
min: Option<f64>,
max: Option<f64>,
path: &str,
) -> Result<(), ChartError> {
validate_number(reference, &format!("{path}/reference"))?;
if let Some(min) = min {
validate_number(min, &format!("{path}/min"))?;
if min >= reference {
return Err(ChartError::new(
"invalid_scale",
format!("{path}/min"),
"min must lie below reference",
));
}
}
if let Some(max) = max {
validate_number(max, &format!("{path}/max"))?;
if max <= reference {
return Err(ChartError::new(
"invalid_scale",
format!("{path}/max"),
"max must lie above reference",
));
}
}
Ok(())
}
pub(crate) fn calendar_date(text: &str) -> Option<(i64, u32, u32)> {
if text.len() != 10 {
return None;
}
let epoch = crate::time::parse_iso(text, crate::time::TimeZone::utc())?;
Some(crate::time::civil_from_days(epoch.div_euclid(86_400)))
}
pub(crate) const fn type_name(chart_type: ChartType) -> &'static str {
match chart_type {
ChartType::Bar => "bar",
ChartType::Line => "line",
ChartType::Time => "time",
ChartType::Topicmap => "topicmap",
ChartType::Atlas => "atlas",
ChartType::Stripes => "stripes",
ChartType::Calendar => "calendar",
ChartType::Rangebar => "rangebar",
ChartType::Multiples => "multiples",
}
}
fn count_i64(value: usize) -> i64 {
i64::try_from(value).expect("counts are limited by validation")
}
fn json_pointer(path: &serde_path_to_error::Path) -> String {
let pointer: String = path
.iter()
.filter_map(|segment| match segment {
Segment::Seq { index } => Some(format!("/{index}")),
Segment::Map { key } => Some(format!("/{}", key.replace('~', "~0").replace('/', "~1"))),
Segment::Enum { variant } => Some(format!("/{variant}")),
Segment::Unknown => None,
})
.collect();
if pointer.is_empty() {
"/".to_owned()
} else {
pointer
}
}
pub(crate) fn validate_number(value: f64, path: &str) -> Result<(), ChartError> {
if !value.is_finite() {
return Err(ChartError::new(
"non_finite_value",
path,
"value must be finite",
));
}
let magnitude = value.abs();
if magnitude > 1e100 || (magnitude > 0.0 && magnitude < 1e-100) {
return Err(ChartError::new(
"unsupported_numeric_range",
path,
"value magnitude must be zero or between 1e-100 and 1e100",
));
}
Ok(())
}
fn validate_optional_text(
value: Option<&String>,
path: &str,
max_length: usize,
) -> Result<(), ChartError> {
if let Some(value) = value {
validate_text(value, path, max_length)?;
}
Ok(())
}
fn validate_text(value: &str, path: &str, max_length: usize) -> Result<(), ChartError> {
let length = value.chars().count();
if value.trim().is_empty() {
return Err(ChartError::new(
"empty_text",
path,
"value must contain visible text",
));
}
if length > max_length {
return Err(ChartError::new(
"text_too_long",
path,
format!("value must not exceed {max_length} characters"),
));
}
if value.chars().any(|character| !is_xml_character(character)) {
return Err(ChartError::new(
"invalid_xml_character",
path,
"text contains a control character that XML 1.0 cannot represent",
));
}
Ok(())
}
fn is_xml_character(character: char) -> bool {
matches!(character, '\u{9}' | '\u{A}' | '\u{D}')
|| ('\u{20}'..='\u{D7FF}').contains(&character)
|| ('\u{E000}'..='\u{FFFD}').contains(&character)
|| ('\u{10000}'..='\u{10FFFF}').contains(&character)
}
const fn default_width() -> u32 {
800
}
const fn default_height() -> u32 {
450
}
const fn default_mobile_height() -> u32 {
360
}
const fn default_breakpoint() -> u32 {
640
}
const fn default_show_values() -> bool {
true
}
fn default_timezone() -> String {
"UTC".to_owned()
}
const fn default_height_ratio() -> u32 {
1
}
const fn one() -> f64 {
1.0
}
const fn default_true() -> bool {
true
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_false(value: &bool) -> bool {
!*value
}
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_true(value: &bool) -> bool {
*value
}
const fn default_depth_bands() -> u8 {
2
}