mod architecture;
mod atlas;
mod boxplot;
mod calendar;
mod categorical;
mod classes;
mod dataset;
mod flow;
mod parliament;
mod rangebar;
mod sankey;
mod scatter;
mod sequence;
mod state;
mod stripes;
mod survival;
mod timechart;
mod timeline;
mod topicmap;
mod tree;
mod treemap;
mod waffle;
mod waterfall;
use serde::{Deserialize, Serialize};
use serde_path_to_error::Segment;
use crate::{
error::{ChartError, ChartWarning},
time::TimeValue,
};
pub use architecture::{
ArchitectureSpec, BoundarySpec, ComponentKind, ComponentSpec, ConnectionSpec,
};
pub use atlas::{AtlasSpec, PlaceSpec, RegionSpec};
pub(crate) use boxplot::BoxSummary;
pub use boxplot::{BoxDisplay, BoxSpec, ViolinScale};
pub(crate) use calendar::calendar_date;
pub use calendar::{CalendarDay, CalendarLayout, CalendarSpec};
pub use categorical::{DataPoint, SeriesSpec};
pub use classes::{ClassKind, ClassSpec, ClassesSpec, RelationKind, RelationSpec};
pub use flow::{FlowEdgeSpec, FlowNodeSpec, FlowSpec, GroupSpec, LaneSpec, NodeKind};
pub use parliament::{ParliamentSpec, PartySpec};
pub use rangebar::RangeSpec;
pub(crate) use sankey::SankeyGraph;
pub use sankey::{SankeyLinkSpec, SankeyNodeSpec, SankeyOrder, SankeySpec};
pub(crate) use scatter::Quadrant;
pub use scatter::{
ScatterAxis, ScatterLineSpec, ScatterPointSpec, ScatterQuadrantLabels, ScatterQuadrantsSpec,
ScatterSpec, ScatterTickSpec,
};
pub use sequence::{
BranchSpec, DiagramOrientation, FragmentKind, FragmentSpec, MessageKind, MessageSpec,
ParticipantKind, ParticipantSpec, SequenceSpec,
};
pub use state::{StateKind, StateNodeSpec, StateSpec, TransitionSpec};
pub(crate) use stripes::Diverging;
pub use stripes::StripesSpec;
pub(crate) use survival::{KmCurve, KmStep};
pub use survival::{SurvivalGroupSpec, SurvivalObservationSpec, SurvivalSpec};
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(crate) use timeline::Span;
pub use timeline::{MarkerSpec, TimelineItemSpec, TimelineSpec};
pub use topicmap::{CartoucheSpec, Corner, TopicLinkSpec, TopicMapSpec, TopicSpec};
pub use tree::{TreeLayout, TreeNodeKind, TreeNodeSpec, TreeSpec};
pub(crate) use treemap::{TmNode, TmTree};
pub use treemap::{TreemapItemSpec, TreemapSpec};
pub use waffle::{WafflePartSpec, WaffleSpec};
pub(crate) use waterfall::WaterfallBar;
pub use waterfall::{StepKind, StepSpec, WaterfallSpec};
const MAX_DATA_POINTS: usize = 100;
pub(crate) const MAX_SERIES: usize = 4;
pub(crate) const MAX_AXIS_TICKS: f64 = 50.0;
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 = "BoxDisplay::is_box")]
pub box_display: BoxDisplay,
#[serde(default, skip_serializing_if = "ViolinScale::is_width")]
pub violin_scale: ViolinScale,
#[serde(default, skip_serializing_if = "is_false")]
pub show_mean: bool,
#[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 = "Option::is_none")]
pub sequence: Option<SequenceSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow: Option<FlowSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<StateSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub architecture: Option<ArchitectureSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree: Option<TreeSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub waterfall: Option<WaterfallSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub waffle: Option<WaffleSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parliament: Option<ParliamentSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub treemap: Option<TreemapSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sankey: Option<SankeySpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub survival: Option<SurvivalSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scatter: Option<ScatterSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classes: Option<ClassesSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeline: Option<TimelineSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boxes: Vec<BoxSpec>,
#[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,
Blueprint,
}
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,
Sequence,
Flow,
State,
Architecture,
Tree,
Boxplot,
Timeline,
Waterfall,
Waffle,
Parliament,
Treemap,
Sankey,
Survival,
Scatter,
Classes,
}
impl ChartType {
pub const ALL: [Self; 24] = [
Self::Bar,
Self::Line,
Self::Time,
Self::Topicmap,
Self::Atlas,
Self::Stripes,
Self::Calendar,
Self::Rangebar,
Self::Multiples,
Self::Sequence,
Self::Flow,
Self::State,
Self::Architecture,
Self::Tree,
Self::Boxplot,
Self::Timeline,
Self::Waterfall,
Self::Waffle,
Self::Parliament,
Self::Treemap,
Self::Sankey,
Self::Survival,
Self::Scatter,
Self::Classes,
];
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
impl ChartSpec {
pub(crate) fn axis_unit(&self, pane: usize) -> Option<&str> {
match self.chart_type {
ChartType::Time => self.panes.get(pane)?.value_axis.unit.as_deref(),
ChartType::Multiples if self.is_bar_multiples() => {
self.panes.get(pane)?.value_axis.unit.as_deref()
}
_ => self.value_axis.unit.as_deref(),
}
}
}
#[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)]
pub(crate) 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_theme(&self) -> Result<(), ChartError> {
let diagram = matches!(
self.chart_type,
ChartType::Sequence
| ChartType::Flow
| ChartType::State
| ChartType::Architecture
| ChartType::Tree
| ChartType::Classes
);
if self.theme == Theme::Blueprint && !diagram {
return Err(ChartError::new(
"option_not_supported",
"/theme",
"the blueprint theme is made for diagrams (sequence, flow, state, architecture, tree, classes); use light or dark",
));
}
Ok(())
}
fn validate_sized(&self, check_size: bool) -> Result<Vec<ChartWarning>, ChartError> {
self.validate_metadata(check_size)?;
self.reject_foreign_blocks()?;
self.validate_theme()?;
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::Sequence => return self.validate_sequence(),
ChartType::Flow => return self.validate_flow(),
ChartType::State => return self.validate_state(),
ChartType::Architecture => return self.validate_architecture(),
ChartType::Tree => return self.validate_tree(),
ChartType::Boxplot => return self.validate_boxplot(),
ChartType::Timeline => return self.validate_timeline(),
ChartType::Classes => return self.validate_classes(),
ChartType::Scatter => return self.validate_scatter(),
ChartType::Survival => return self.validate_survival(),
ChartType::Sankey => return self.validate_sankey(),
ChartType::Treemap => return self.validate_treemap(),
ChartType::Parliament => return self.validate_parliament(),
ChartType::Waffle => return self.validate_waffle(),
ChartType::Waterfall => return self.validate_waterfall(),
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, point) in self.data.iter().enumerate() {
for (name, value) in [("lower", point.lower), ("upper", point.upper)] {
if let Some(value) = value {
values.push((None, value, format!("/data/{index}/{name}")));
}
}
}
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 || self.is_bar_multiples())
.then_some(pane_index);
for (index, value) in pane.values.iter().enumerate() {
if let Some(value) = value {
values.push((axis, *value, format!("/panes/{pane_index}/values/{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 || self.is_bar_multiples() {
for (index, pane) in self.panes.iter().enumerate() {
axes.push((
Some(index),
&pane.value_axis,
format!("/panes/{index}/valueAxis"),
));
}
}
axes
}
fn stack_totals(&self) -> Vec<f64> {
(0..self.categories.len())
.flat_map(|index| {
let values = self.series.iter().filter_map(|series| series.values[index]);
let positive: f64 = values.clone().filter(|value| *value > 0.0).sum();
let negative: f64 = values.filter(|value| *value < 0.0).sum();
[positive, negative]
})
.collect()
}
fn validate_tick_count(
&self,
(axis, axis_index): (&ValueAxisSpec, Option<usize>),
step: f64,
values: &[(Option<usize>, f64, String)],
path: &str,
) -> Result<(), ChartError> {
let own = values
.iter()
.filter(|(owner, _, _)| *owner == axis_index)
.map(|(_, value, _)| *value);
let zone = self.time_zone().unwrap_or_default();
let stacked: Vec<f64> = match axis_index {
Some(pane) if self.panes[pane].stack.is_some() => self
.data_layers()
.filter(|entry| entry.pane == pane)
.flat_map(|entry| self.drawn_points(entry, zone))
.map(|(_, value)| value)
.chain([0.0])
.collect(),
_ => Vec::new(),
};
let mut values: Vec<f64> = own.chain(stacked).chain(axis.min).chain(axis.max).collect();
if self.chart_type == ChartType::Bar {
values.push(0.0);
match self.stack {
Some(Stack::Percent) => values = vec![0.0, 1.0],
Some(Stack::Normal) => values.extend(self.stack_totals()),
None => {}
}
}
let (low, high) = values
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), value| {
(low.min(*value), high.max(*value))
});
if !low.is_finite() {
return Ok(());
}
let ticks = (high / step).floor() - (low / step).ceil() + 1.0;
let few = axis.exact && ticks < 2.0;
if ticks > MAX_AXIS_TICKS || few {
return Err(ChartError::new(
"invalid_axis_range",
format!("{path}/step"),
format!(
"a step of {step} gives {} ticks across {} to {}; choose one that gives 2 to {MAX_AXIS_TICKS}",
ticks.max(0.0),
low + 0.0,
high + 0.0
),
));
}
Ok(())
}
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 let Some(unit) = &axis.unit {
if !matches!(self.chart_type, ChartType::Time | ChartType::Multiples) {
return refuse(
"unit",
"a unit at the top tick belongs to a time chart or small multiples; use the axis title",
);
}
validate_text(unit, &format!("{path}/unit"), 20)?;
}
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 let Some(step) = axis.step {
self.validate_tick_count((axis, axis_index), step, &values, &path)?;
}
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(())
}
pub(super) fn validate_references(&self) -> Result<(), ChartError> {
if self.references.len() > MAX_REFERENCES {
return Err(ChartError::new(
"too_many_references",
"/references",
format!("use at most {MAX_REFERENCES} reference lines"),
));
}
for (index, reference) in self.references.iter().enumerate() {
validate_number(reference.value, &format!("/references/{index}/value"))?;
validate_text(&reference.label, &format!("/references/{index}/label"), 60)?;
}
Ok(())
}
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 !(200..=2_400).contains(&self.width) {
return Err(ChartError::new(
"invalid_dimension",
"/width",
"width must be between 200 and 2400",
));
}
if !(160..=1_600).contains(&self.height) {
return Err(ChartError::new(
"invalid_dimension",
"/height",
"height must be between 160 and 1600",
));
}
if let Some(mobile) = &self.mobile {
for (path, value, range) in [
("/mobile/width", mobile.width, 200..=600),
("/mobile/height", mobile.height, 160..=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(())
}
#[allow(clippy::too_many_lines)]
fn reject_foreign_blocks(&self) -> Result<(), ChartError> {
let own = self.chart_type;
for (index, pane) in self.panes.iter().enumerate() {
let Some(note) = &pane.note else {
if pane.note_emphasis {
return Err(ChartError::new(
"option_not_supported",
format!("/panes/{index}/noteEmphasis"),
"noteEmphasis needs a note",
));
}
continue;
};
let path = format!("/panes/{index}/note");
if own != ChartType::Multiples {
return Err(ChartError::new(
"option_not_supported",
path,
"a note sits under the title of a small-multiples panel",
));
}
validate_text(note, &path, 160)?;
}
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),
("/boxes", !self.boxes.is_empty(), ChartType::Boxplot),
("/showMean", self.show_mean, ChartType::Boxplot),
(
"/violinScale",
!self.violin_scale.is_width(),
ChartType::Boxplot,
),
(
"/boxDisplay",
self.box_display != BoxDisplay::Box,
ChartType::Boxplot,
),
("/timeline", self.timeline.is_some(), ChartType::Timeline),
("/classes", self.classes.is_some(), ChartType::Classes),
("/scatter", self.scatter.is_some(), ChartType::Scatter),
("/survival", self.survival.is_some(), ChartType::Survival),
("/sankey", self.sankey.is_some(), ChartType::Sankey),
("/treemap", self.treemap.is_some(), ChartType::Treemap),
(
"/parliament",
self.parliament.is_some(),
ChartType::Parliament,
),
("/waffle", self.waffle.is_some(), ChartType::Waffle),
("/waterfall", self.waterfall.is_some(), ChartType::Waterfall),
("/sequence", self.sequence.is_some(), ChartType::Sequence),
("/flow", self.flow.is_some(), ChartType::Flow),
("/state", self.state.is_some(), ChartType::State),
(
"/architecture",
self.architecture.is_some(),
ChartType::Architecture,
),
("/tree", self.tree.is_some(), ChartType::Tree),
("/columns", self.columns.is_some(), ChartType::Multiples),
(
"/references",
!self.references.is_empty() && own != ChartType::Rangebar,
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)
),
));
}
}
self.reject_map_blocks()
}
fn reject_map_blocks(&self) -> Result<(), ChartError> {
let own = self.chart_type;
if matches!(
own,
ChartType::Stripes
| ChartType::Calendar
| ChartType::Rangebar
| ChartType::Boxplot
| ChartType::Timeline
| ChartType::Classes
| ChartType::Scatter
| ChartType::Survival
| ChartType::Sankey
| ChartType::Treemap
| ChartType::Parliament
| ChartType::Waffle
| ChartType::Waterfall
| ChartType::Multiples
| ChartType::Sequence
| ChartType::Flow
| ChartType::State
| ChartType::Architecture
| ChartType::Tree
) {
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",
ChartType::Sequence => "sequence",
ChartType::Flow => "flow",
ChartType::State => "state",
ChartType::Architecture => "architecture",
ChartType::Tree => "tree",
ChartType::Boxplot => "boxplot",
ChartType::Timeline => "timeline",
ChartType::Classes => "classes",
ChartType::Scatter => "scatter",
ChartType::Survival => "survival",
ChartType::Sankey => "sankey",
ChartType::Treemap => "treemap",
ChartType::Parliament => "parliament",
ChartType::Waffle => "waffle",
ChartType::Waterfall => "waterfall",
}
}
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
}