mod atlas;
mod calendar;
mod categorical;
mod dataset;
mod rangebar;
mod stripes;
mod timechart;
mod topicmap;
use serde::{Deserialize, Serialize};
use serde_path_to_error::Segment;
use crate::{
error::{ChartError, ChartWarning},
time::TimeValue,
};
pub use atlas::{AtlasSpec, PlaceSpec, RegionSpec};
pub(crate) use calendar::calendar_date;
pub use calendar::{CalendarDay, CalendarLayout, CalendarSpec};
pub use categorical::{DataPoint, SeriesSpec};
pub use rangebar::RangeSpec;
pub(crate) use stripes::Diverging;
pub use stripes::StripesSpec;
pub use timechart::{
Curve, Dash, Gaps, LayerSpec, Mark, OhlcPoint, PaneSpec, Shape, Stroke, TimeAxisKind,
TimeAxisSpec, TimePoint, TimePrecision, Tooltips,
};
pub(crate) use timechart::{LayerContext, MAX_TIME_POINTS_PER_LAYER, validate_layer_name};
pub use topicmap::{CartoucheSpec, Corner, TopicLinkSpec, TopicMapSpec, TopicSpec};
const MAX_DATA_POINTS: usize = 100;
pub(crate) const MAX_SERIES: usize = 4;
pub(crate) const MAX_REFERENCES: usize = 4;
pub(crate) const MAX_DECIMALS: u8 = 6;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[allow(
clippy::struct_excessive_bools,
reason = "every bool is a switch of the JSON specification"
)]
pub struct ChartSpec {
pub schema_version: u8,
#[serde(rename = "type")]
pub chart_type: ChartType,
#[serde(default)]
pub orientation: Orientation,
#[serde(default, skip_serializing_if = "is_false")]
pub patterns: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<Stack>,
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 = "Vec::is_empty")]
pub references: Vec<ReferenceSpec>,
#[serde(default, skip_serializing_if = "is_false")]
pub sparkline: bool,
#[serde(default, skip_serializing_if = "Tooltips::is_observations")]
pub tooltips: Tooltips,
#[serde(default, skip_serializing_if = "LegendPlacement::is_top")]
pub legend: LegendPlacement,
#[serde(default, skip_serializing_if = "is_false")]
pub independent_axes: bool,
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<u32>,
}
#[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 {
pub(crate) const fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::De => "de",
}
}
#[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,
pub thousands: bool,
}
impl From<ValueFormat> for NumberStyle {
fn from(format: ValueFormat) -> Self {
Self {
format,
..Self::default()
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ChartType {
Bar,
Line,
Time,
Topicmap,
Atlas,
Stripes,
Calendar,
Rangebar,
Multiples,
}
impl ChartType {
pub const ALL: [Self; 9] = [
Self::Bar,
Self::Line,
Self::Time,
Self::Topicmap,
Self::Atlas,
Self::Stripes,
Self::Calendar,
Self::Rangebar,
Self::Multiples,
];
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|chart_type| type_name(*chart_type) == name)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LegendPlacement {
#[default]
Top,
End,
}
impl LegendPlacement {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_top(&self) -> bool {
matches!(self, Self::Top)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Stack {
Normal,
Percent,
}
#[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, PartialEq, 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>,
#[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, skip_serializing_if = "std::ops::Not::not")]
pub thousands_separator: bool,
#[serde(default, skip_serializing_if = "AxisScale::is_linear")]
pub scale: AxisScale,
#[serde(default, skip_serializing_if = "is_false")]
pub reverse: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
#[serde(default, skip_serializing_if = "is_false")]
pub exact: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AxisScale {
#[default]
Linear,
Log,
}
impl AxisScale {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_linear(&self) -> bool {
matches!(self, Self::Linear)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReferenceSpec {
pub value: f64,
pub label: String,
}
impl ValueAxisSpec {
pub(crate) const fn bounds(&self) -> (Option<f64>, Option<f64>) {
(self.min, self.max)
}
}
#[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(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;
if mobile.columns.is_some() {
spec.columns = mobile.columns;
}
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()?;
self.validate_value_axes()?;
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()?;
self.validate_references()?;
if self.chart_type == ChartType::Line
&& self.series.is_empty()
&& self.data.iter().all(|point| point.value.is_none())
{
return Err(ChartError::new(
"empty_series",
"/data",
"line charts require at least one numeric value",
));
}
Ok(warnings)
}
fn axis_values(&self) -> Vec<(Option<usize>, f64, String)> {
let mut values = Vec::new();
for (index, point) in self.data.iter().enumerate() {
if let Some(value) = point.value {
values.push((None, value, format!("/data/{index}/value")));
}
}
for (series_index, series) in self.series.iter().enumerate() {
for (index, value) in series.values.iter().enumerate() {
if let Some(value) = value {
values.push((
None,
*value,
format!("/series/{series_index}/values/{index}"),
));
}
}
}
for (index, reference) in self.references.iter().enumerate() {
values.push((None, reference.value, format!("/references/{index}/value")));
}
for (index, range) in self.ranges.iter().enumerate() {
for (name, value) in [
("low", Some(range.low)),
("high", Some(range.high)),
("mid", range.mid),
] {
if let Some(value) = value {
values.push((None, value, format!("/ranges/{index}/{name}")));
}
}
}
for (pane_index, pane) in self.panes.iter().enumerate() {
let axis = (self.chart_type != ChartType::Multiples).then_some(pane_index);
for (layer_index, layer) in pane.layers.iter().enumerate() {
let path = format!("/panes/{pane_index}/layers/{layer_index}");
for (index, point) in layer.points.iter().enumerate() {
for (name, value) in [
("value", point.value),
("lower", point.lower),
("upper", point.upper),
] {
if let Some(value) = value {
values.push((axis, value, format!("{path}/points/{index}/{name}")));
}
}
}
for (index, candle) in layer.data.iter().enumerate() {
values.push((axis, candle.low, format!("{path}/data/{index}/low")));
values.push((axis, candle.high, format!("{path}/data/{index}/high")));
}
for (name, value) in [
("value", layer.value),
("bottom", layer.bottom),
("top", layer.top),
] {
if let Some(value) = value {
values.push((axis, value, format!("{path}/{name}")));
}
}
}
}
values
}
fn value_axes(&self) -> Vec<(Option<usize>, &ValueAxisSpec, String)> {
let mut axes = vec![(None, &self.value_axis, "/valueAxis".to_owned())];
if self.chart_type == ChartType::Time {
for (index, pane) in self.panes.iter().enumerate() {
axes.push((
Some(index),
&pane.value_axis,
format!("/panes/{index}/valueAxis"),
));
}
}
axes
}
fn validate_value_axes(&self) -> Result<(), ChartError> {
let values = self.axis_values();
for (axis_index, axis, path) in self.value_axes() {
let log = axis.scale == AxisScale::Log;
let refuse = |field: &str, reason: &str| {
Err(ChartError::new(
"option_not_supported",
format!("{path}/{field}"),
reason.to_owned(),
))
};
if log && self.stack.is_some() {
return refuse(
"scale",
"a stack is measured from zero and takes no logarithmic axis",
);
}
if log && (axis.step.is_some() || axis.exact) {
return refuse("step", "a logarithmic axis ticks at powers of ten");
}
if axis.exact && self.stack.is_some() {
return refuse("exact", "a stack's totals decide its axis");
}
if let Some(step) = axis.step {
validate_number(step, &format!("{path}/step"))?;
if step <= 0.0 {
return Err(ChartError::new(
"invalid_value",
format!("{path}/step"),
"the step between ticks must be above zero",
));
}
}
if let (true, Some(min), Some(max)) = (axis.exact, axis.min, axis.max)
&& self.chart_type == ChartType::Bar
&& !(min..=max).contains(&0.0)
{
return Err(ChartError::new(
"invalid_axis_range",
format!("{path}/min"),
"bars start at zero, so an exact axis of a bar chart includes zero",
));
}
if axis.exact && (axis.min.is_none() || axis.max.is_none()) {
return Err(ChartError::new(
"invalid_axis_range",
format!("{path}/exact"),
"an exact axis needs both min and max",
));
}
if log {
positive_bounds(axis, &path)?;
}
for (_, value, value_path) in values.iter().filter(|(owner, _, _)| *owner == axis_index)
{
if log {
positive(*value, value_path.clone())?;
}
if let (true, Some(min), Some(max)) = (axis.exact, axis.min, axis.max)
&& !(min..=max).contains(value)
{
return Err(ChartError::new(
"value_outside_axis",
value_path.clone(),
format!("the value lies outside the exact axis from {min} to {max}"),
));
}
}
if log {
let panes: Vec<usize> = match axis_index {
Some(pane) => vec![pane],
None if self.chart_type == ChartType::Multiples => {
(0..self.panes.len()).collect()
}
None => Vec::new(),
};
for pane in panes {
reject_log_area(&self.panes[pane], pane)?;
}
}
}
Ok(())
}
fn validate_references(&self) -> Result<(), ChartError> {
if self.references.len() > MAX_REFERENCES {
return Err(ChartError::new(
"too_many_references",
"/references",
format!("use at most {MAX_REFERENCES} reference lines"),
));
}
for (index, reference) in self.references.iter().enumerate() {
validate_number(reference.value, &format!("/references/{index}/value"))?;
validate_text(&reference.label, &format!("/references/{index}/label"), 60)?;
}
Ok(())
}
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 {
let base = path.trim_end_matches("/decimals");
for (name, bound) in [("min", axis.min), ("max", axis.max)] {
if let Some(bound) = bound {
validate_number(bound, &format!("{base}/{name}"))?;
}
}
if let (Some(min), Some(max)) = (axis.min, axis.max)
&& min >= max
{
return Err(ChartError::new(
"invalid_axis_range",
format!("{base}/max"),
"valueAxis.max must be greater than valueAxis.min",
));
}
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 self.sparkline && self.chart_type == ChartType::Time {
for (path, value, range) in [
("/width", self.width, 60..=600),
("/height", self.height, 16..=200),
] {
if !range.contains(&value) {
return Err(ChartError::new(
"invalid_dimension",
path,
format!(
"a sparkline's {} must be between {} and {}",
&path[1..],
range.start(),
range.end()
),
));
}
}
return Ok(());
}
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()?;
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(())
}
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",
));
}
if self.value_axis.min.is_some()
|| self.value_axis.max.is_some()
|| self.value_axis.thousands_separator
|| self.value_axis.scale != AxisScale::Linear
|| self.value_axis.reverse
|| self.value_axis.step.is_some()
|| self.value_axis.exact
{
return Err(ChartError::new(
"option_not_supported",
"/valueAxis",
"a time chart sets the value axis range and number style inside its pane",
));
}
Ok(())
}
fn reject_foreign_blocks(&self) -> Result<(), ChartError> {
let own = self.chart_type;
if own != ChartType::Time
&& let Some(index) = self.panes.iter().position(|pane| pane.stack.is_some())
{
return Err(ChartError::new(
"option_not_supported",
format!("/panes/{index}/stack"),
"stacked areas belong to the pane of a time chart",
));
}
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),
("/references", !self.references.is_empty(), ChartType::Bar),
("/stack", self.stack.is_some(), ChartType::Bar),
(
"/legend",
self.legend == LegendPlacement::End,
ChartType::Time,
),
("/sparkline", self.sparkline, ChartType::Time),
(
"/tooltips",
self.tooltips == Tooltips::Markers,
ChartType::Time,
),
("/patterns", self.patterns, ChartType::Bar),
(
"/independentAxes",
self.independent_axes,
ChartType::Multiples,
),
(
"/mobile/columns",
self.mobile
.as_ref()
.is_some_and(|mobile| mobile.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 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
|| self.value_axis.min.is_some()
|| self.value_axis.max.is_some()
|| self.value_axis.thousands_separator
|| self.value_axis.scale != AxisScale::Linear
|| self.value_axis.reverse
|| self.value_axis.step.is_some()
|| self.value_axis.exact
{
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(())
}
}
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 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
}
}
fn positive(value: f64, path: String) -> Result<(), ChartError> {
if value > 0.0 {
Ok(())
} else {
Err(ChartError::new(
"invalid_value",
path,
"a logarithmic axis takes values above zero only",
))
}
}
fn positive_bounds(axis: &ValueAxisSpec, path: &str) -> Result<(), ChartError> {
for (name, bound) in [("min", axis.min), ("max", axis.max)] {
if let Some(bound) = bound {
positive(bound, format!("{path}/{name}"))?;
}
}
Ok(())
}
fn reject_log_area(pane: &PaneSpec, pane_index: usize) -> Result<(), ChartError> {
for (layer_index, layer) in pane.layers.iter().enumerate() {
if layer.mark == Mark::Area {
return Err(ChartError::new(
"option_not_supported",
format!("/panes/{pane_index}/layers/{layer_index}/mark"),
"an area is filled down to zero and takes no logarithmic axis",
));
}
}
Ok(())
}
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
}
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
}