Skip to main content

chartlet/spec/
mod.rs

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;
32/// Limited so that every series keeps a color that stays distinguishable for common
33/// color-vision deficiencies.
34pub(crate) const MAX_SERIES: usize = 4;
35/// Reference lines on a bar chart; beyond this the lines crowd the bars they explain.
36pub(crate) const MAX_REFERENCES: usize = 4;
37/// Fixed decimal places; beyond this a value stops being readable as a number.
38pub(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    /// Draws every other series of a `type: "bar"` chart as an outline, so that series differ
53    /// in form as well as in color.
54    #[serde(default, skip_serializing_if = "is_false")]
55    pub patterns: bool,
56    /// Stacks the series of a `type: "bar"` chart instead of setting them side by side.
57    #[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    /// Whether the SVG profile draws the title in the chart. It always remains the accessible name;
75    /// the HTML profile never draws it, its caption is the visible title.
76    #[serde(default = "default_true", skip_serializing_if = "is_true")]
77    pub show_title: bool,
78    /// Single-series data. Use either `data` or `categories` with `series`.
79    #[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    /// Optional zoom steps rendered as radio-selectable, pre-computed variants (HTML profile).
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub zoom_steps: Vec<ZoomStep>,
88    /// Palette a chart is rendered in. Skipped while it is the default so that adding the field
89    /// did not change the serialized form, and with it the generated accessibility IDs, of every
90    /// existing specification.
91    #[serde(default, skip_serializing_if = "Theme::is_light")]
92    pub theme: Theme,
93    /// The time axis of a `type: "time"` chart.
94    #[serde(default, skip_serializing_if = "TimeAxisSpec::is_default")]
95    pub time_axis: TimeAxisSpec,
96    /// The panes of a `type: "time"` chart; every pane stacks one value axis over the shared
97    /// time axis and holds its own layers.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub panes: Vec<PaneSpec>,
100    /// The topics of a `type: "topicmap"` chart. Skipped while absent, for the same reason as
101    /// `theme`: adding the field must not change the serialized form, and with it the generated
102    /// accessibility IDs, of every existing specification.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub topicmap: Option<TopicMapSpec>,
105    /// The landscape of a `type: "atlas"` chart. Skipped while absent, for the same reason as
106    /// `topicmap`.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub atlas: Option<AtlasSpec>,
109    /// The yearly values of a `type: "stripes"` chart. Skipped while absent, like `topicmap`.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub stripes: Option<StripesSpec>,
112    /// The daily values of a `type: "calendar"` chart. Skipped while absent, like `topicmap`.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub calendar: Option<CalendarSpec>,
115    /// The spans of a `type: "rangebar"` chart, one per category.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub ranges: Vec<RangeSpec>,
118    /// Reference lines across a `type: "bar"` chart, such as an average or a target.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub references: Vec<ReferenceSpec>,
121    /// Draws a `type: "time"` chart as a sparkline: only its lines and areas, no axes, title or
122    /// legend, at a size down to 60 × 16. Title and description stay its accessible name.
123    #[serde(default, skip_serializing_if = "is_false")]
124    pub sparkline: bool,
125    /// Which observations of a `type: "time"` chart carry a tooltip.
126    #[serde(default, skip_serializing_if = "Tooltips::is_observations")]
127    pub tooltips: Tooltips,
128    /// Where a `type: "time"` chart names its series: in a legend above the plot, or at the end
129    /// of every line.
130    #[serde(default, skip_serializing_if = "LegendPlacement::is_top")]
131    pub legend: LegendPlacement,
132    /// Gives every panel of a `type: "multiples"` chart its own value axis, for panels whose
133    /// values differ in unit or size; they can then no longer be compared by height.
134    #[serde(default, skip_serializing_if = "is_false")]
135    pub independent_axes: bool,
136    /// Number of grid columns of a `type: "multiples"` chart; defaults to up to three.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub columns: Option<u32>,
139    /// Language of every text chartlet generates: description, legend additions, tooltips, the
140    /// HTML figure and its data table, and the number format. Skipped while it is the default,
141    /// like `theme`.
142    #[serde(default, skip_serializing_if = "Locale::is_en")]
143    pub locale: Locale,
144    /// A second layout for narrow containers. Skipped while absent, like `topicmap`.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub mobile: Option<MobileSpec>,
147}
148
149/// The size of the mobile variant, and the container width below which the HTML profile shows it
150/// instead of the chart at `width` × `height`.
151#[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    /// Container width in CSS pixels; the mobile variant shows below it.
158    #[serde(default = "default_breakpoint")]
159    pub breakpoint: u32,
160    /// Grid columns of small multiples in the mobile variant, usually fewer than on the wide
161    /// chart.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub columns: Option<u32>,
164}
165
166/// The palette a chart is rendered in. Both palettes are expressed as CSS custom properties, so a
167/// host page can override any single color.
168#[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    // serde hands this function a reference, so the signature follows serde's shape.
178    #[allow(clippy::trivially_copy_pass_by_ref)]
179    const fn is_light(&self) -> bool {
180        matches!(self, Self::Light)
181    }
182}
183
184/// The language of generated texts and numbers.
185#[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    /// The language code the specification uses: `en` or `de`.
195    pub(crate) const fn code(self) -> &'static str {
196        match self {
197            Self::En => "en",
198            Self::De => "de",
199        }
200    }
201
202    // serde hands this function a reference, so the signature follows serde's shape.
203    #[allow(clippy::trivially_copy_pass_by_ref)]
204    const fn is_en(&self) -> bool {
205        matches!(self, Self::En)
206    }
207}
208
209/// How a value is written: format, fixed decimals if any, locale, and whether thousands are
210/// separated.
211#[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    /// A time series on a shared time axis, drawn from panes and layers.
234    Time,
235    /// Topic areas as a landmass map; area proportional to each topic's value.
236    Topicmap,
237    /// A knowledge landscape: realms of adjacent regions, holding places. Where `topicmap` packs
238    /// separate landmasses and lets size carry the whole statement, an `atlas` tiles one
239    /// continuous land, so that *where* something lies says as much as how large it is.
240    Atlas,
241    /// Warming stripes: one colored stripe per year on a diverging scale.
242    Stripes,
243    /// A calendar heatmap: one cell per day of a year on a diverging scale.
244    Calendar,
245    /// Spans with a low and a high value per category, optionally with a central estimate.
246    Rangebar,
247    /// Small multiples: several small time charts in a grid, sharing both axes.
248    Multiples,
249}
250
251impl ChartType {
252    /// Every chart type, in the order of the specification's documentation.
253    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    /// The chart type that `type` names, such as `"bar"`.
266    #[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/// Where a time chart names its series.
275#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "lowercase")]
277pub enum LegendPlacement {
278    /// A legend above the plot.
279    #[default]
280    Top,
281    /// The name of each line at its last observation, right of the plot.
282    End,
283}
284
285impl LegendPlacement {
286    // serde hands this function a reference, so the signature follows serde's shape.
287    #[allow(clippy::trivially_copy_pass_by_ref)]
288    const fn is_top(&self) -> bool {
289        matches!(self, Self::Top)
290    }
291}
292
293/// How the series of a bar chart share a category: stacked by value, or as shares of the
294/// category's total.
295#[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    /// Fixed number of decimal places for values in labels, tooltips, the description and the
325    /// data table. Axis ticks take theirs from the tick step.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub decimals: Option<u8>,
328    /// The axis reaches at least down to this value; without it, a line never pads below zero
329    /// when all values are zero or more.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub min: Option<f64>,
332    /// The axis reaches at least up to this value.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub max: Option<f64>,
335    /// Separates thousands in every written value: `12,500` or, in German, `12.500`. Off by
336    /// default, because four digits are often years.
337    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
338    pub thousands_separator: bool,
339    /// Linear by default; `log` spaces powers of ten evenly, for values across several orders of
340    /// magnitude.
341    #[serde(default, skip_serializing_if = "AxisScale::is_linear")]
342    pub scale: AxisScale,
343    /// Runs the axis the other way: larger values down (or left), as δ18O records are drawn.
344    #[serde(default, skip_serializing_if = "is_false")]
345    pub reverse: bool,
346    /// The distance between two ticks, instead of a round step chosen from the values.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub step: Option<f64>,
349    /// `min` and `max` are the ends of the axis, not only values it reaches; every value has to
350    /// lie between them.
351    #[serde(default, skip_serializing_if = "is_false")]
352    pub exact: bool,
353}
354
355/// How a value axis spaces its values.
356#[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    // serde hands this function a reference, so the signature follows serde's shape.
366    #[allow(clippy::trivially_copy_pass_by_ref)]
367    const fn is_linear(&self) -> bool {
368        matches!(self, Self::Linear)
369    }
370}
371
372/// A reference line across a bar chart: a value every bar is read against.
373#[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    /// The declared lower and upper reach of the axis.
382    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/// A pre-computed zoom step: shows categories `from..=to`, or on a time chart the observations
396/// from `from` to `to`, as its own chart variant.
397#[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/// One end of a zoom step: a category index on a bar or line chart, a timestamp on a time chart.
406/// A whole number from 0 up reads as an index first, so that a category chart keeps its
407/// serialized form; on a time chart the same number is Unix seconds.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409#[serde(untagged)]
410pub enum ZoomBound {
411    Index(usize),
412    Time(TimeValue),
413}
414
415impl ZoomBound {
416    /// The category index, `None` for a timestamp that is not a whole number from 0.
417    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    /// The bound as Unix seconds, in the forms a point's `time` accepts.
425    pub(crate) fn resolve(&self, zone: crate::time::TimeZone) -> Result<i64, &'static str> {
426        match self {
427            Self::Index(seconds) => {
428                // Anything beyond the 1700–2200 contract is refused by `resolve` anyway.
429                #[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/// A layer together with where it sits in the specification.
439#[derive(Clone, Copy)]
440pub(crate) struct LayerRef<'a> {
441    /// Index among all layers of all panes; keys a declared color and a hatch pattern.
442    pub global: usize,
443    pub pane: usize,
444    /// Index within its pane, as the specification path names it.
445    pub local: usize,
446    pub layer: &'a LayerSpec,
447}
448
449/// Categories × series: the single shape that layout, description and data table work with.
450pub(crate) struct Dataset {
451    pub categories: Vec<String>,
452    pub series: Vec<Series>,
453}
454
455pub(crate) struct Series {
456    /// `None` when the chart was given as a single `data` list.
457    pub name: Option<String>,
458    pub values: Vec<Option<f64>>,
459    /// How this column is written when it is not written like the chart's values: a topic map's
460    /// share column is a percentage while the counts beside it are plain numbers, and every pane
461    /// of a time chart has its own value axis.
462    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    /// JSON pointer to a category label in the specification.
473    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    /// Parses a chart specification from JSON.
484    ///
485    /// # Errors
486    ///
487    /// Returns `invalid_json` for malformed JSON and `invalid_spec` for JSON that does not match
488    /// the versioned specification. Like every other error, both carry a JSON Pointer path; a
489    /// syntax error points at the document root and names line and column in its message.
490    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    /// The chart laid out at the size of its mobile variant, or `None` without one.
511    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    /// Validates a mobile variant. Its size was already checked against the limits of `mobile`,
524    /// which admit narrower charts than `width`; the checks that depend on the size, such as
525    /// observations per plot pixel, run at that size.
526    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    /// Every value a value axis has to place, with its path: the values, band and zone edges,
560    /// candles, reference lines and point markers, by the axis they belong to — `None` for the
561    /// top-level value axis, `Some(pane)` for the own axis of a time chart's pane. Small multiples
562    /// share the top-level axis.
563    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    /// The value axes and their paths: the top-level one, and the own axis of every time chart
629    /// pane.
630    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    /// A logarithmic axis places only values above zero, and neither a stack nor an area, which
645    /// are measured from zero. A declared `step` is a positive distance. An `exact` axis has both
646    /// ends, and every value lies between them, so that nothing reaches outside the plot.
647    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    /// Every reference line has a usable value and a label; a chart takes at most four.
732    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    /// Fixed decimals stay in a readable range; a declared axis range is finite and ordered.
748    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    /// The size of the chart and of its mobile variant.
788    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        // Zoom steps slice `data` or `categories`, which only bar and line charts have; every
880        // other type rejects the field by name.
881        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    /// Rejects the fields that only a bar or line chart has, naming the field and the fix. Small
935    /// multiples keep the top-level value axis, which all their panels share.
936    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    /// Rejects a block that belongs to another chart type, so that it is never silently ignored.
1003    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    /// Rejects the fields that only a bar, line, or time chart has, naming the field and the fix.
1081    /// Neither map has axes, series or panes. `noun` and `source` name the type in the message,
1082    /// so the error says what to do rather than only what is wrong.
1083    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
1140/// The value of `type` that selects a chart type.
1141pub(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
1155/// Converts a deserialization path such as `data[0].value` into the JSON Pointer
1156/// `/data/0/value` that validation errors use. The document root is `/`.
1157fn 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
1174/// A value on a logarithmic axis: above zero.
1175fn 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
1187/// The declared range of a logarithmic axis: above zero.
1188fn 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
1197/// An area is filled down to zero, so a pane with one takes no logarithmic axis.
1198fn 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// serde hands this function a reference, so the signature follows serde's shape.
1303#[allow(clippy::trivially_copy_pass_by_ref)]
1304const fn is_false(value: &bool) -> bool {
1305    !*value
1306}
1307
1308// serde hands this function a reference, so the signature follows serde's shape.
1309#[allow(clippy::trivially_copy_pass_by_ref)]
1310const fn is_true(value: &bool) -> bool {
1311    *value
1312}