1mod atlas;
2mod calendar;
3mod categorical;
4mod dataset;
5mod rangebar;
6mod stripes;
7mod timechart;
8mod topicmap;
9
10use serde::{Deserialize, Serialize};
11use serde_path_to_error::Segment;
12
13use crate::{
14 error::{ChartError, ChartWarning},
15 time::TimeValue,
16};
17pub use atlas::{AtlasSpec, PlaceSpec, RegionSpec};
18pub(crate) use calendar::calendar_date;
19pub use calendar::{CalendarDay, CalendarLayout, CalendarSpec};
20pub use categorical::{DataPoint, SeriesSpec};
21pub use rangebar::RangeSpec;
22pub(crate) use stripes::Diverging;
23pub use stripes::StripesSpec;
24pub use timechart::{
25 Curve, Dash, Gaps, LayerSpec, Mark, OhlcPoint, PaneSpec, Shape, Stroke, TimeAxisKind,
26 TimeAxisSpec, TimePoint, TimePrecision, Tooltips,
27};
28pub(crate) use timechart::{LayerContext, MAX_TIME_POINTS_PER_LAYER, validate_layer_name};
29pub use topicmap::{CartoucheSpec, Corner, TopicLinkSpec, TopicMapSpec, TopicSpec};
30
31const MAX_DATA_POINTS: usize = 100;
32pub(crate) const MAX_SERIES: usize = 4;
35pub(crate) const MAX_REFERENCES: usize = 4;
37pub(crate) const MAX_DECIMALS: u8 = 6;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42#[allow(
43 clippy::struct_excessive_bools,
44 reason = "every bool is a switch of the JSON specification"
45)]
46pub struct ChartSpec {
47 pub schema_version: u8,
48 #[serde(rename = "type")]
49 pub chart_type: ChartType,
50 #[serde(default)]
51 pub orientation: Orientation,
52 #[serde(default, skip_serializing_if = "is_false")]
55 pub patterns: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub stack: Option<Stack>,
59 pub title: String,
60 #[serde(default)]
61 pub description: Option<String>,
62 #[serde(default)]
63 pub source: Option<String>,
64 #[serde(default)]
65 pub category_axis: CategoryAxisSpec,
66 #[serde(default)]
67 pub value_axis: ValueAxisSpec,
68 #[serde(default = "default_width")]
69 pub width: u32,
70 #[serde(default = "default_height")]
71 pub height: u32,
72 #[serde(default = "default_show_values")]
73 pub show_values: bool,
74 #[serde(default = "default_true", skip_serializing_if = "is_true")]
77 pub show_title: bool,
78 #[serde(default)]
80 pub data: Vec<DataPoint>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub categories: Vec<String>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub series: Vec<SeriesSpec>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub zoom_steps: Vec<ZoomStep>,
88 #[serde(default, skip_serializing_if = "Theme::is_light")]
92 pub theme: Theme,
93 #[serde(default, skip_serializing_if = "TimeAxisSpec::is_default")]
95 pub time_axis: TimeAxisSpec,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub panes: Vec<PaneSpec>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub topicmap: Option<TopicMapSpec>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub atlas: Option<AtlasSpec>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub stripes: Option<StripesSpec>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub calendar: Option<CalendarSpec>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub ranges: Vec<RangeSpec>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub references: Vec<ReferenceSpec>,
121 #[serde(default, skip_serializing_if = "is_false")]
124 pub sparkline: bool,
125 #[serde(default, skip_serializing_if = "Tooltips::is_observations")]
127 pub tooltips: Tooltips,
128 #[serde(default, skip_serializing_if = "LegendPlacement::is_top")]
131 pub legend: LegendPlacement,
132 #[serde(default, skip_serializing_if = "is_false")]
135 pub independent_axes: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub columns: Option<u32>,
139 #[serde(default, skip_serializing_if = "Locale::is_en")]
143 pub locale: Locale,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub mobile: Option<MobileSpec>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct MobileSpec {
154 pub width: u32,
155 #[serde(default = "default_mobile_height")]
156 pub height: u32,
157 #[serde(default = "default_breakpoint")]
159 pub breakpoint: u32,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub columns: Option<u32>,
164}
165
166#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "lowercase")]
170pub enum Theme {
171 #[default]
172 Light,
173 Dark,
174}
175
176impl Theme {
177 #[allow(clippy::trivially_copy_pass_by_ref)]
179 const fn is_light(&self) -> bool {
180 matches!(self, Self::Light)
181 }
182}
183
184#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(rename_all = "lowercase")]
187pub enum Locale {
188 #[default]
189 En,
190 De,
191}
192
193impl Locale {
194 pub(crate) const fn code(self) -> &'static str {
196 match self {
197 Self::En => "en",
198 Self::De => "de",
199 }
200 }
201
202 #[allow(clippy::trivially_copy_pass_by_ref)]
204 const fn is_en(&self) -> bool {
205 matches!(self, Self::En)
206 }
207}
208
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub(crate) struct NumberStyle {
213 pub format: ValueFormat,
214 pub decimals: Option<u8>,
215 pub locale: Locale,
216 pub thousands: bool,
217}
218
219impl From<ValueFormat> for NumberStyle {
220 fn from(format: ValueFormat) -> Self {
221 Self {
222 format,
223 ..Self::default()
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(rename_all = "lowercase")]
230pub enum ChartType {
231 Bar,
232 Line,
233 Time,
235 Topicmap,
237 Atlas,
241 Stripes,
243 Calendar,
245 Rangebar,
247 Multiples,
249}
250
251impl ChartType {
252 pub const ALL: [Self; 9] = [
254 Self::Bar,
255 Self::Line,
256 Self::Time,
257 Self::Topicmap,
258 Self::Atlas,
259 Self::Stripes,
260 Self::Calendar,
261 Self::Rangebar,
262 Self::Multiples,
263 ];
264
265 #[must_use]
267 pub fn from_name(name: &str) -> Option<Self> {
268 Self::ALL
269 .into_iter()
270 .find(|chart_type| type_name(*chart_type) == name)
271 }
272}
273
274#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "lowercase")]
277pub enum LegendPlacement {
278 #[default]
280 Top,
281 End,
283}
284
285impl LegendPlacement {
286 #[allow(clippy::trivially_copy_pass_by_ref)]
288 const fn is_top(&self) -> bool {
289 matches!(self, Self::Top)
290 }
291}
292
293#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "lowercase")]
297pub enum Stack {
298 Normal,
299 Percent,
300}
301
302#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
303#[serde(rename_all = "lowercase")]
304pub enum Orientation {
305 #[default]
306 Vertical,
307 Horizontal,
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312pub struct CategoryAxisSpec {
313 #[serde(default)]
314 pub title: Option<String>,
315}
316
317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase", deny_unknown_fields)]
319pub struct ValueAxisSpec {
320 #[serde(default)]
321 pub title: Option<String>,
322 #[serde(default)]
323 pub format: ValueFormat,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub decimals: Option<u8>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub min: Option<f64>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub max: Option<f64>,
335 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
338 pub thousands_separator: bool,
339 #[serde(default, skip_serializing_if = "AxisScale::is_linear")]
342 pub scale: AxisScale,
343 #[serde(default, skip_serializing_if = "is_false")]
345 pub reverse: bool,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub step: Option<f64>,
349 #[serde(default, skip_serializing_if = "is_false")]
352 pub exact: bool,
353}
354
355#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
357#[serde(rename_all = "lowercase")]
358pub enum AxisScale {
359 #[default]
360 Linear,
361 Log,
362}
363
364impl AxisScale {
365 #[allow(clippy::trivially_copy_pass_by_ref)]
367 const fn is_linear(&self) -> bool {
368 matches!(self, Self::Linear)
369 }
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase", deny_unknown_fields)]
375pub struct ReferenceSpec {
376 pub value: f64,
377 pub label: String,
378}
379
380impl ValueAxisSpec {
381 pub(crate) const fn bounds(&self) -> (Option<f64>, Option<f64>) {
383 (self.min, self.max)
384 }
385}
386
387#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
388#[serde(rename_all = "lowercase")]
389pub enum ValueFormat {
390 #[default]
391 Number,
392 Percent,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase", deny_unknown_fields)]
399pub struct ZoomStep {
400 pub label: String,
401 pub from: ZoomBound,
402 pub to: ZoomBound,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(untagged)]
410pub enum ZoomBound {
411 Index(usize),
412 Time(TimeValue),
413}
414
415impl ZoomBound {
416 pub(crate) const fn index(&self) -> Option<usize> {
418 match self {
419 Self::Index(index) => Some(*index),
420 Self::Time(_) => None,
421 }
422 }
423
424 pub(crate) fn resolve(&self, zone: crate::time::TimeZone) -> Result<i64, &'static str> {
426 match self {
427 Self::Index(seconds) => {
428 #[allow(clippy::cast_precision_loss)]
430 let seconds = *seconds as f64;
431 TimeValue::Number(seconds).resolve(zone)
432 }
433 Self::Time(time) => time.resolve(zone),
434 }
435 }
436}
437
438#[derive(Clone, Copy)]
440pub(crate) struct LayerRef<'a> {
441 pub global: usize,
443 pub pane: usize,
444 pub local: usize,
446 pub layer: &'a LayerSpec,
447}
448
449pub(crate) struct Dataset {
451 pub categories: Vec<String>,
452 pub series: Vec<Series>,
453}
454
455pub(crate) struct Series {
456 pub name: Option<String>,
458 pub values: Vec<Option<f64>>,
459 pub style: Option<NumberStyle>,
463}
464
465impl Dataset {
466 pub(crate) fn values(&self) -> impl Iterator<Item = f64> + '_ {
467 self.series
468 .iter()
469 .flat_map(|series| series.values.iter().flatten().copied())
470 }
471
472 pub(crate) fn category_path(&self, index: usize) -> String {
474 if self.series[0].name.is_none() {
475 format!("/data/{index}/label")
476 } else {
477 format!("/categories/{index}")
478 }
479 }
480}
481
482impl ChartSpec {
483 pub fn from_json(input: &str) -> Result<Self, ChartError> {
491 let mut deserializer = serde_json::Deserializer::from_str(input);
492 serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
493 let source = error.inner();
494 let (code, path) = match source.classify() {
495 serde_json::error::Category::Syntax | serde_json::error::Category::Eof => {
496 ("invalid_json", "/".to_owned())
497 }
498 serde_json::error::Category::Data | serde_json::error::Category::Io => {
499 ("invalid_spec", json_pointer(error.path()))
500 }
501 };
502 ChartError::new(code, path, source.to_string())
503 })
504 }
505
506 pub(crate) fn validate(&self) -> Result<Vec<ChartWarning>, ChartError> {
507 self.validate_sized(true)
508 }
509
510 pub(crate) fn mobile_variant(&self) -> Option<ChartSpec> {
512 let mobile = self.mobile.as_ref()?;
513 let mut spec = self.clone();
514 spec.width = mobile.width;
515 spec.height = mobile.height;
516 if mobile.columns.is_some() {
517 spec.columns = mobile.columns;
518 }
519 spec.mobile = None;
520 Some(spec)
521 }
522
523 pub(crate) fn validate_mobile_variant(&self) -> Result<Vec<ChartWarning>, ChartError> {
527 self.validate_sized(false)
528 }
529
530 fn validate_sized(&self, check_size: bool) -> Result<Vec<ChartWarning>, ChartError> {
531 self.validate_metadata(check_size)?;
532 self.reject_foreign_blocks()?;
533 self.validate_value_axes()?;
534 match self.chart_type {
535 ChartType::Time => return self.validate_time(),
536 ChartType::Multiples => return self.validate_multiples(),
537 ChartType::Topicmap => return self.validate_topicmap(),
538 ChartType::Atlas => return self.validate_atlas(),
539 ChartType::Stripes => return self.validate_stripes(),
540 ChartType::Calendar => return self.validate_calendar(),
541 ChartType::Rangebar => return self.validate_rangebar(),
542 ChartType::Bar | ChartType::Line => {}
543 }
544 let warnings = self.validate_data()?;
545 self.validate_references()?;
546 if self.chart_type == ChartType::Line
547 && self.series.is_empty()
548 && self.data.iter().all(|point| point.value.is_none())
549 {
550 return Err(ChartError::new(
551 "empty_series",
552 "/data",
553 "line charts require at least one numeric value",
554 ));
555 }
556 Ok(warnings)
557 }
558
559 fn axis_values(&self) -> Vec<(Option<usize>, f64, String)> {
564 let mut values = Vec::new();
565 for (index, point) in self.data.iter().enumerate() {
566 if let Some(value) = point.value {
567 values.push((None, value, format!("/data/{index}/value")));
568 }
569 }
570 for (series_index, series) in self.series.iter().enumerate() {
571 for (index, value) in series.values.iter().enumerate() {
572 if let Some(value) = value {
573 values.push((
574 None,
575 *value,
576 format!("/series/{series_index}/values/{index}"),
577 ));
578 }
579 }
580 }
581 for (index, reference) in self.references.iter().enumerate() {
582 values.push((None, reference.value, format!("/references/{index}/value")));
583 }
584 for (index, range) in self.ranges.iter().enumerate() {
585 for (name, value) in [
586 ("low", Some(range.low)),
587 ("high", Some(range.high)),
588 ("mid", range.mid),
589 ] {
590 if let Some(value) = value {
591 values.push((None, value, format!("/ranges/{index}/{name}")));
592 }
593 }
594 }
595 for (pane_index, pane) in self.panes.iter().enumerate() {
596 let axis = (self.chart_type != ChartType::Multiples).then_some(pane_index);
597 for (layer_index, layer) in pane.layers.iter().enumerate() {
598 let path = format!("/panes/{pane_index}/layers/{layer_index}");
599 for (index, point) in layer.points.iter().enumerate() {
600 for (name, value) in [
601 ("value", point.value),
602 ("lower", point.lower),
603 ("upper", point.upper),
604 ] {
605 if let Some(value) = value {
606 values.push((axis, value, format!("{path}/points/{index}/{name}")));
607 }
608 }
609 }
610 for (index, candle) in layer.data.iter().enumerate() {
611 values.push((axis, candle.low, format!("{path}/data/{index}/low")));
612 values.push((axis, candle.high, format!("{path}/data/{index}/high")));
613 }
614 for (name, value) in [
615 ("value", layer.value),
616 ("bottom", layer.bottom),
617 ("top", layer.top),
618 ] {
619 if let Some(value) = value {
620 values.push((axis, value, format!("{path}/{name}")));
621 }
622 }
623 }
624 }
625 values
626 }
627
628 fn value_axes(&self) -> Vec<(Option<usize>, &ValueAxisSpec, String)> {
631 let mut axes = vec![(None, &self.value_axis, "/valueAxis".to_owned())];
632 if self.chart_type == ChartType::Time {
633 for (index, pane) in self.panes.iter().enumerate() {
634 axes.push((
635 Some(index),
636 &pane.value_axis,
637 format!("/panes/{index}/valueAxis"),
638 ));
639 }
640 }
641 axes
642 }
643
644 fn validate_value_axes(&self) -> Result<(), ChartError> {
648 let values = self.axis_values();
649 for (axis_index, axis, path) in self.value_axes() {
650 let log = axis.scale == AxisScale::Log;
651 let refuse = |field: &str, reason: &str| {
652 Err(ChartError::new(
653 "option_not_supported",
654 format!("{path}/{field}"),
655 reason.to_owned(),
656 ))
657 };
658 if log && self.stack.is_some() {
659 return refuse(
660 "scale",
661 "a stack is measured from zero and takes no logarithmic axis",
662 );
663 }
664 if log && (axis.step.is_some() || axis.exact) {
665 return refuse("step", "a logarithmic axis ticks at powers of ten");
666 }
667 if axis.exact && self.stack.is_some() {
668 return refuse("exact", "a stack's totals decide its axis");
669 }
670 if let Some(step) = axis.step {
671 validate_number(step, &format!("{path}/step"))?;
672 if step <= 0.0 {
673 return Err(ChartError::new(
674 "invalid_value",
675 format!("{path}/step"),
676 "the step between ticks must be above zero",
677 ));
678 }
679 }
680 if let (true, Some(min), Some(max)) = (axis.exact, axis.min, axis.max)
681 && self.chart_type == ChartType::Bar
682 && !(min..=max).contains(&0.0)
683 {
684 return Err(ChartError::new(
685 "invalid_axis_range",
686 format!("{path}/min"),
687 "bars start at zero, so an exact axis of a bar chart includes zero",
688 ));
689 }
690 if axis.exact && (axis.min.is_none() || axis.max.is_none()) {
691 return Err(ChartError::new(
692 "invalid_axis_range",
693 format!("{path}/exact"),
694 "an exact axis needs both min and max",
695 ));
696 }
697 if log {
698 positive_bounds(axis, &path)?;
699 }
700 for (_, value, value_path) in values.iter().filter(|(owner, _, _)| *owner == axis_index)
701 {
702 if log {
703 positive(*value, value_path.clone())?;
704 }
705 if let (true, Some(min), Some(max)) = (axis.exact, axis.min, axis.max)
706 && !(min..=max).contains(value)
707 {
708 return Err(ChartError::new(
709 "value_outside_axis",
710 value_path.clone(),
711 format!("the value lies outside the exact axis from {min} to {max}"),
712 ));
713 }
714 }
715 if log {
716 let panes: Vec<usize> = match axis_index {
717 Some(pane) => vec![pane],
718 None if self.chart_type == ChartType::Multiples => {
719 (0..self.panes.len()).collect()
720 }
721 None => Vec::new(),
722 };
723 for pane in panes {
724 reject_log_area(&self.panes[pane], pane)?;
725 }
726 }
727 }
728 Ok(())
729 }
730
731 fn validate_references(&self) -> Result<(), ChartError> {
733 if self.references.len() > MAX_REFERENCES {
734 return Err(ChartError::new(
735 "too_many_references",
736 "/references",
737 format!("use at most {MAX_REFERENCES} reference lines"),
738 ));
739 }
740 for (index, reference) in self.references.iter().enumerate() {
741 validate_number(reference.value, &format!("/references/{index}/value"))?;
742 validate_text(&reference.label, &format!("/references/{index}/label"), 60)?;
743 }
744 Ok(())
745 }
746
747 fn validate_decimals(&self) -> Result<(), ChartError> {
749 let axes = std::iter::once(("/valueAxis/decimals".to_owned(), &self.value_axis)).chain(
750 self.panes.iter().enumerate().map(|(index, pane)| {
751 (
752 format!("/panes/{index}/valueAxis/decimals"),
753 &pane.value_axis,
754 )
755 }),
756 );
757 for (path, axis) in axes {
758 let base = path.trim_end_matches("/decimals");
759 for (name, bound) in [("min", axis.min), ("max", axis.max)] {
760 if let Some(bound) = bound {
761 validate_number(bound, &format!("{base}/{name}"))?;
762 }
763 }
764 if let (Some(min), Some(max)) = (axis.min, axis.max)
765 && min >= max
766 {
767 return Err(ChartError::new(
768 "invalid_axis_range",
769 format!("{base}/max"),
770 "valueAxis.max must be greater than valueAxis.min",
771 ));
772 }
773 if axis
774 .decimals
775 .is_some_and(|decimals| decimals > MAX_DECIMALS)
776 {
777 return Err(ChartError::new(
778 "invalid_decimals",
779 path,
780 format!("use 0 to {MAX_DECIMALS} decimal places"),
781 ));
782 }
783 }
784 Ok(())
785 }
786
787 fn validate_size(&self) -> Result<(), ChartError> {
789 if self.sparkline && self.chart_type == ChartType::Time {
790 for (path, value, range) in [
791 ("/width", self.width, 60..=600),
792 ("/height", self.height, 16..=200),
793 ] {
794 if !range.contains(&value) {
795 return Err(ChartError::new(
796 "invalid_dimension",
797 path,
798 format!(
799 "a sparkline's {} must be between {} and {}",
800 &path[1..],
801 range.start(),
802 range.end()
803 ),
804 ));
805 }
806 }
807 return Ok(());
808 }
809 if !(320..=2_400).contains(&self.width) {
810 return Err(ChartError::new(
811 "invalid_dimension",
812 "/width",
813 "width must be between 320 and 2400",
814 ));
815 }
816 if !(240..=1_600).contains(&self.height) {
817 return Err(ChartError::new(
818 "invalid_dimension",
819 "/height",
820 "height must be between 240 and 1600",
821 ));
822 }
823 if let Some(mobile) = &self.mobile {
824 for (path, value, range) in [
825 ("/mobile/width", mobile.width, 280..=600),
826 ("/mobile/height", mobile.height, 240..=1_600),
827 ("/mobile/breakpoint", mobile.breakpoint, 320..=1_600),
828 ] {
829 if !range.contains(&value) {
830 return Err(ChartError::new(
831 "invalid_dimension",
832 path,
833 format!(
834 "{} must be between {} and {}",
835 &path[1..],
836 range.start(),
837 range.end()
838 ),
839 ));
840 }
841 }
842 }
843 Ok(())
844 }
845
846 fn validate_metadata(&self, check_size: bool) -> Result<(), ChartError> {
847 if self.schema_version != 1 {
848 return Err(ChartError::new(
849 "unsupported_schema_version",
850 "/schemaVersion",
851 "expected schemaVersion 1",
852 ));
853 }
854 if self.chart_type == ChartType::Line && self.orientation != Orientation::Vertical {
855 return Err(ChartError::new(
856 "option_not_supported",
857 "/orientation",
858 "orientation is only available for bar charts",
859 ));
860 }
861 self.validate_decimals()?;
862 validate_text(&self.title, "/title", 200)?;
863 if let Some(description) = &self.description {
864 validate_text(description, "/description", 1_000)?;
865 }
866 if let Some(source) = &self.source {
867 validate_text(source, "/source", 300)?;
868 }
869 validate_optional_text(
870 self.category_axis.title.as_ref(),
871 "/categoryAxis/title",
872 100,
873 )?;
874 validate_optional_text(self.value_axis.title.as_ref(), "/valueAxis/title", 100)?;
875
876 if check_size {
877 self.validate_size()?;
878 }
879 if !matches!(self.chart_type, ChartType::Bar | ChartType::Line) {
882 return Ok(());
883 }
884 if self.zoom_steps.len() == 1 {
885 return Err(ChartError::new(
886 "not_enough_zoom_steps",
887 "/zoomSteps",
888 "provide at least 2 zoom steps so the view can be switched",
889 ));
890 }
891 for (i, step) in self.zoom_steps.iter().enumerate() {
892 validate_text(&step.label, &format!("/zoomSteps/{i}/label"), 40)?;
893 let index = |field: &str, bound: &ZoomBound| {
894 bound.index().ok_or_else(|| {
895 ChartError::new(
896 "invalid_spec",
897 format!("/zoomSteps/{i}/{field}"),
898 "expected a category index, a whole number from 0",
899 )
900 })
901 };
902 let from = index("from", &step.from)?;
903 let to = index("to", &step.to)?;
904 if from > to {
905 return Err(ChartError::new(
906 "invalid_zoom_step",
907 format!("/zoomSteps/{i}/from"),
908 "from must not be greater than to",
909 ));
910 }
911 let count = if self.data.is_empty() {
912 self.categories.len()
913 } else {
914 self.data.len()
915 };
916 if to >= count {
917 return Err(ChartError::new(
918 "zoom_out_of_range",
919 format!("/zoomSteps/{i}/to"),
920 format!("to must be less than the number of categories ({count})"),
921 ));
922 }
923 }
924 if self.zoom_steps.len() > 4 {
925 return Err(ChartError::new(
926 "too_many_zoom_steps",
927 "/zoomSteps",
928 "at most 4 zoom steps are supported",
929 ));
930 }
931 Ok(())
932 }
933
934 fn reject_bar_and_line_options(&self, noun: &str) -> Result<(), ChartError> {
937 for (field, present) in [
938 ("/data", !self.data.is_empty()),
939 ("/categories", !self.categories.is_empty()),
940 ("/series", !self.series.is_empty()),
941 (
942 "/zoomSteps",
943 !self.zoom_steps.is_empty() && self.chart_type == ChartType::Multiples,
944 ),
945 ] {
946 if present {
947 return Err(ChartError::new(
948 "option_not_supported",
949 field,
950 format!("{noun} is drawn from panes and layers; remove this field"),
951 ));
952 }
953 }
954 if self.orientation != Orientation::Vertical {
955 return Err(ChartError::new(
956 "option_not_supported",
957 "/orientation",
958 "orientation is only available for bar charts",
959 ));
960 }
961 if self.category_axis.title.is_some() {
962 return Err(ChartError::new(
963 "option_not_supported",
964 "/categoryAxis/title",
965 format!("{noun} labels its time axis with timeAxis.title"),
966 ));
967 }
968 if self.chart_type == ChartType::Multiples {
969 return Ok(());
970 }
971 if self.value_axis.title.is_some() {
972 return Err(ChartError::new(
973 "option_not_supported",
974 "/valueAxis/title",
975 "a time chart sets the value axis title inside its pane",
976 ));
977 }
978 if self.value_axis.format != ValueFormat::Number {
979 return Err(ChartError::new(
980 "option_not_supported",
981 "/valueAxis/format",
982 "a time chart sets the value format inside its pane",
983 ));
984 }
985 if self.value_axis.min.is_some()
986 || self.value_axis.max.is_some()
987 || self.value_axis.thousands_separator
988 || self.value_axis.scale != AxisScale::Linear
989 || self.value_axis.reverse
990 || self.value_axis.step.is_some()
991 || self.value_axis.exact
992 {
993 return Err(ChartError::new(
994 "option_not_supported",
995 "/valueAxis",
996 "a time chart sets the value axis range and number style inside its pane",
997 ));
998 }
999 Ok(())
1000 }
1001
1002 fn reject_foreign_blocks(&self) -> Result<(), ChartError> {
1004 let own = self.chart_type;
1005 if own != ChartType::Time
1006 && let Some(index) = self.panes.iter().position(|pane| pane.stack.is_some())
1007 {
1008 return Err(ChartError::new(
1009 "option_not_supported",
1010 format!("/panes/{index}/stack"),
1011 "stacked areas belong to the pane of a time chart",
1012 ));
1013 }
1014 for (field, present, owner) in [
1015 ("/stripes", self.stripes.is_some(), ChartType::Stripes),
1016 ("/calendar", self.calendar.is_some(), ChartType::Calendar),
1017 ("/ranges", !self.ranges.is_empty(), ChartType::Rangebar),
1018 ("/columns", self.columns.is_some(), ChartType::Multiples),
1019 ("/references", !self.references.is_empty(), ChartType::Bar),
1020 ("/stack", self.stack.is_some(), ChartType::Bar),
1021 (
1022 "/legend",
1023 self.legend == LegendPlacement::End,
1024 ChartType::Time,
1025 ),
1026 ("/sparkline", self.sparkline, ChartType::Time),
1027 (
1028 "/tooltips",
1029 self.tooltips == Tooltips::Markers,
1030 ChartType::Time,
1031 ),
1032 ("/patterns", self.patterns, ChartType::Bar),
1033 (
1034 "/independentAxes",
1035 self.independent_axes,
1036 ChartType::Multiples,
1037 ),
1038 (
1039 "/mobile/columns",
1040 self.mobile
1041 .as_ref()
1042 .is_some_and(|mobile| mobile.columns.is_some()),
1043 ChartType::Multiples,
1044 ),
1045 ] {
1046 if present && own != owner {
1047 return Err(ChartError::new(
1048 "option_not_supported",
1049 field,
1050 format!(
1051 "{field} belongs to a {} chart; remove it or change the type",
1052 type_name(owner)
1053 ),
1054 ));
1055 }
1056 }
1057 if matches!(
1058 own,
1059 ChartType::Stripes | ChartType::Calendar | ChartType::Rangebar | ChartType::Multiples
1060 ) {
1061 for (field, present, owner) in [
1062 ("/topicmap", self.topicmap.is_some(), ChartType::Topicmap),
1063 ("/atlas", self.atlas.is_some(), ChartType::Atlas),
1064 ] {
1065 if present {
1066 return Err(ChartError::new(
1067 "option_not_supported",
1068 field,
1069 format!(
1070 "{field} belongs to a {} chart; remove it or change the type",
1071 type_name(owner)
1072 ),
1073 ));
1074 }
1075 }
1076 }
1077 Ok(())
1078 }
1079
1080 fn reject_map_options(&self, noun: &str, source: &str) -> Result<(), ChartError> {
1084 for (field, present) in [
1085 ("/data", !self.data.is_empty()),
1086 ("/categories", !self.categories.is_empty()),
1087 ("/series", !self.series.is_empty()),
1088 ("/zoomSteps", !self.zoom_steps.is_empty()),
1089 ("/panes", !self.panes.is_empty()),
1090 ] {
1091 if present {
1092 return Err(ChartError::new(
1093 "option_not_supported",
1094 field,
1095 format!("{noun} is drawn from {source}; remove this field"),
1096 ));
1097 }
1098 }
1099 if self.orientation != Orientation::Vertical {
1100 return Err(ChartError::new(
1101 "option_not_supported",
1102 "/orientation",
1103 "orientation is only available for bar charts",
1104 ));
1105 }
1106 if self.category_axis.title.is_some() {
1107 return Err(ChartError::new(
1108 "option_not_supported",
1109 "/categoryAxis/title",
1110 format!("{noun} has no category axis"),
1111 ));
1112 }
1113 if self.value_axis.title.is_some()
1114 || self.value_axis.format != ValueFormat::Number
1115 || self.value_axis.min.is_some()
1116 || self.value_axis.max.is_some()
1117 || self.value_axis.thousands_separator
1118 || self.value_axis.scale != AxisScale::Linear
1119 || self.value_axis.reverse
1120 || self.value_axis.step.is_some()
1121 || self.value_axis.exact
1122 {
1123 return Err(ChartError::new(
1124 "option_not_supported",
1125 "/valueAxis",
1126 format!("{noun} has no value axis"),
1127 ));
1128 }
1129 if !self.time_axis.is_default() {
1130 return Err(ChartError::new(
1131 "option_not_supported",
1132 "/timeAxis",
1133 format!("{noun} has no time axis"),
1134 ));
1135 }
1136 Ok(())
1137 }
1138}
1139
1140pub(crate) const fn type_name(chart_type: ChartType) -> &'static str {
1142 match chart_type {
1143 ChartType::Bar => "bar",
1144 ChartType::Line => "line",
1145 ChartType::Time => "time",
1146 ChartType::Topicmap => "topicmap",
1147 ChartType::Atlas => "atlas",
1148 ChartType::Stripes => "stripes",
1149 ChartType::Calendar => "calendar",
1150 ChartType::Rangebar => "rangebar",
1151 ChartType::Multiples => "multiples",
1152 }
1153}
1154
1155fn json_pointer(path: &serde_path_to_error::Path) -> String {
1158 let pointer: String = path
1159 .iter()
1160 .filter_map(|segment| match segment {
1161 Segment::Seq { index } => Some(format!("/{index}")),
1162 Segment::Map { key } => Some(format!("/{}", key.replace('~', "~0").replace('/', "~1"))),
1163 Segment::Enum { variant } => Some(format!("/{variant}")),
1164 Segment::Unknown => None,
1165 })
1166 .collect();
1167 if pointer.is_empty() {
1168 "/".to_owned()
1169 } else {
1170 pointer
1171 }
1172}
1173
1174fn positive(value: f64, path: String) -> Result<(), ChartError> {
1176 if value > 0.0 {
1177 Ok(())
1178 } else {
1179 Err(ChartError::new(
1180 "invalid_value",
1181 path,
1182 "a logarithmic axis takes values above zero only",
1183 ))
1184 }
1185}
1186
1187fn positive_bounds(axis: &ValueAxisSpec, path: &str) -> Result<(), ChartError> {
1189 for (name, bound) in [("min", axis.min), ("max", axis.max)] {
1190 if let Some(bound) = bound {
1191 positive(bound, format!("{path}/{name}"))?;
1192 }
1193 }
1194 Ok(())
1195}
1196
1197fn reject_log_area(pane: &PaneSpec, pane_index: usize) -> Result<(), ChartError> {
1199 for (layer_index, layer) in pane.layers.iter().enumerate() {
1200 if layer.mark == Mark::Area {
1201 return Err(ChartError::new(
1202 "option_not_supported",
1203 format!("/panes/{pane_index}/layers/{layer_index}/mark"),
1204 "an area is filled down to zero and takes no logarithmic axis",
1205 ));
1206 }
1207 }
1208 Ok(())
1209}
1210
1211pub(crate) fn validate_number(value: f64, path: &str) -> Result<(), ChartError> {
1212 if !value.is_finite() {
1213 return Err(ChartError::new(
1214 "non_finite_value",
1215 path,
1216 "value must be finite",
1217 ));
1218 }
1219 let magnitude = value.abs();
1220 if magnitude > 1e100 || (magnitude > 0.0 && magnitude < 1e-100) {
1221 return Err(ChartError::new(
1222 "unsupported_numeric_range",
1223 path,
1224 "value magnitude must be zero or between 1e-100 and 1e100",
1225 ));
1226 }
1227 Ok(())
1228}
1229
1230fn validate_optional_text(
1231 value: Option<&String>,
1232 path: &str,
1233 max_length: usize,
1234) -> Result<(), ChartError> {
1235 if let Some(value) = value {
1236 validate_text(value, path, max_length)?;
1237 }
1238 Ok(())
1239}
1240
1241fn validate_text(value: &str, path: &str, max_length: usize) -> Result<(), ChartError> {
1242 let length = value.chars().count();
1243 if value.trim().is_empty() {
1244 return Err(ChartError::new(
1245 "empty_text",
1246 path,
1247 "value must contain visible text",
1248 ));
1249 }
1250 if length > max_length {
1251 return Err(ChartError::new(
1252 "text_too_long",
1253 path,
1254 format!("value must not exceed {max_length} characters"),
1255 ));
1256 }
1257 if value.chars().any(|character| !is_xml_character(character)) {
1258 return Err(ChartError::new(
1259 "invalid_xml_character",
1260 path,
1261 "text contains a control character that XML 1.0 cannot represent",
1262 ));
1263 }
1264 Ok(())
1265}
1266
1267fn is_xml_character(character: char) -> bool {
1268 matches!(character, '\u{9}' | '\u{A}' | '\u{D}')
1269 || ('\u{20}'..='\u{D7FF}').contains(&character)
1270 || ('\u{E000}'..='\u{FFFD}').contains(&character)
1271 || ('\u{10000}'..='\u{10FFFF}').contains(&character)
1272}
1273
1274const fn default_width() -> u32 {
1275 800
1276}
1277
1278const fn default_height() -> u32 {
1279 450
1280}
1281
1282const fn default_mobile_height() -> u32 {
1283 360
1284}
1285
1286const fn default_breakpoint() -> u32 {
1287 640
1288}
1289
1290const fn default_show_values() -> bool {
1291 true
1292}
1293
1294const fn one() -> f64 {
1295 1.0
1296}
1297
1298const fn default_true() -> bool {
1299 true
1300}
1301
1302#[allow(clippy::trivially_copy_pass_by_ref)]
1304const fn is_false(value: &bool) -> bool {
1305 !*value
1306}
1307
1308#[allow(clippy::trivially_copy_pass_by_ref)]
1310const fn is_true(value: &bool) -> bool {
1311 *value
1312}