Skip to main content

model/
boxplot.rs

1//! Small, precomputed box plots. Statistical policy belongs to the host.
2use crate::{Color, categorical::Category};
3use std::collections::HashSet;
4
5pub const MAX_BOX_CATEGORIES: usize = 64;
6pub const MAX_BOX_SERIES: usize = 16;
7pub const MAX_BOXES: usize = 512;
8pub const MAX_BOX_OUTLIERS: usize = 128;
9pub const MAX_BOXPLOT_OUTLIERS: usize = 4096;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct BoxPlotTarget {
14    pub category_id: String,
15    pub series_id: String,
16}
17impl BoxPlotTarget {
18    pub fn new(category: impl Into<String>, series: impl Into<String>) -> Self {
19        Self {
20            category_id: category.into(),
21            series_id: series.into(),
22        }
23    }
24}
25/// Actual whisker endpoints, not the theoretical IQR fences. CI endpoints may
26/// extend outside Q1/Q3. No sample statistics are inferred by the renderer.
27#[derive(Clone, Debug, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct BoxSummary {
30    pub q1: f64,
31    pub median: f64,
32    pub q3: f64,
33    pub whisker_low: f64,
34    pub whisker_high: f64,
35    pub mean: Option<f64>,
36    pub sample_count: Option<u64>,
37    pub median_ci: Option<[f64; 2]>,
38    #[cfg_attr(feature = "serde", serde(default))]
39    pub outliers: Vec<f64>,
40}
41impl BoxSummary {
42    pub fn new(low: f64, q1: f64, median: f64, q3: f64, high: f64) -> Self {
43        Self {
44            q1,
45            median,
46            q3,
47            whisker_low: low,
48            whisker_high: high,
49            mean: None,
50            sample_count: None,
51            median_ci: None,
52            outliers: vec![],
53        }
54    }
55    pub fn validate(&self) -> Result<(), &'static str> {
56        let ordered = [
57            self.whisker_low,
58            self.q1,
59            self.median,
60            self.q3,
61            self.whisker_high,
62        ];
63        if ordered.iter().any(|v| !v.is_finite()) || ordered.windows(2).any(|p| p[0] > p[1]) {
64            return Err("box statistics must be finite and low <= Q1 <= median <= Q3 <= high");
65        }
66        if self.mean.is_some_and(|v| !v.is_finite()) || self.sample_count == Some(0) {
67            return Err("mean must be finite; sample count must be positive when supplied");
68        }
69        if self.median_ci.is_some_and(|p| {
70            !p[0].is_finite() || !p[1].is_finite() || p[0] > self.median || p[1] < self.median
71        }) {
72            return Err("median CI must be finite and contain the median");
73        }
74        if self.outliers.len() > MAX_BOX_OUTLIERS
75            || self
76                .outliers
77                .iter()
78                .any(|v| !v.is_finite() || (*v >= self.whisker_low && *v <= self.whisker_high))
79        {
80            return Err("at most 128 finite outliers per box, strictly outside the whiskers");
81        }
82        Ok(())
83    }
84}
85#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87pub enum BoxPlotDirection {
88    #[default]
89    Vertical,
90    Horizontal,
91}
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
94pub enum BoxPlotMaterial {
95    Flat,
96    #[default]
97    Matte,
98    SatinMetal,
99}
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102pub enum BoxPlotLabels {
103    #[default]
104    None,
105    Median,
106    MedianAndCount,
107}
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub enum BoxOutlierShape {
111    #[default]
112    Circle,
113    Square,
114}
115#[derive(Clone, Debug, PartialEq)]
116#[cfg_attr(
117    feature = "serde",
118    derive(serde::Serialize, serde::Deserialize),
119    serde(default)
120)]
121pub struct BoxPlotStyle {
122    pub material: BoxPlotMaterial,
123    /// Logical pixels, capped at 3 and at half the actual box extent.
124    pub corner_radius: f32,
125    pub outline: bool,
126    pub outline_width: f32,
127    pub outline_color: Color,
128    pub median_width: f32,
129    pub median_color: Color,
130    pub whisker_width: f32,
131    pub whisker_color: Color,
132    pub caps: bool,
133    pub cap_ratio: f32,
134    pub notched: bool,
135    pub notch_depth: f32,
136    pub show_mean: bool,
137    pub mean_size: f32,
138    pub mean_color: Color,
139    pub show_outliers: bool,
140    pub outlier_size: f32,
141    pub outlier_color: Color,
142    pub outlier_shape: BoxOutlierShape,
143    pub outlier_filled: bool,
144    pub texture_strength: f32,
145    pub texture_scale: f32,
146    pub gloss: f32,
147    pub emphasis_brightness: f32,
148}
149impl Default for BoxPlotStyle {
150    fn default() -> Self {
151        let ink = Color::from_rgb8(32, 46, 60);
152        Self {
153            material: BoxPlotMaterial::Matte,
154            corner_radius: 0.0,
155            outline: true,
156            outline_width: 1.2,
157            outline_color: ink,
158            median_width: 1.2,
159            median_color: ink,
160            whisker_width: 1.2,
161            whisker_color: ink,
162            caps: true,
163            cap_ratio: 0.5,
164            notched: false,
165            notch_depth: 0.22,
166            show_mean: false,
167            mean_size: 7.0,
168            mean_color: Color::from_rgb8(180, 45, 45),
169            show_outliers: true,
170            outlier_size: 7.0,
171            outlier_color: ink,
172            outlier_shape: BoxOutlierShape::Circle,
173            outlier_filled: false,
174            texture_strength: 0.2,
175            texture_scale: 1.0,
176            gloss: 0.45,
177            emphasis_brightness: 0.08,
178        }
179    }
180}
181#[derive(Clone, Debug, PartialEq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub struct BoxPlotSeries {
184    pub id: String,
185    pub label: String,
186    pub values: Vec<Option<BoxSummary>>,
187    pub color: Color,
188    pub style: Option<BoxPlotStyle>,
189}
190impl BoxPlotSeries {
191    pub fn new(
192        id: impl Into<String>,
193        label: impl Into<String>,
194        values: Vec<Option<BoxSummary>>,
195        color: Color,
196    ) -> Self {
197        Self {
198            id: id.into(),
199            label: label.into(),
200            values,
201            color,
202            style: None,
203        }
204    }
205}
206#[derive(Clone, Debug, PartialEq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub struct BoxPlotOverride {
209    pub target: BoxPlotTarget,
210    pub color: Option<Color>,
211    pub style: Option<BoxPlotStyle>,
212    pub labels: Option<BoxPlotLabels>,
213}
214impl BoxPlotOverride {
215    pub fn new(target: BoxPlotTarget) -> Self {
216        Self {
217            target,
218            color: None,
219            style: None,
220            labels: None,
221        }
222    }
223}
224#[derive(Clone, Debug, PartialEq)]
225#[cfg_attr(
226    feature = "serde",
227    derive(serde::Serialize, serde::Deserialize),
228    serde(default)
229)]
230pub struct BoxPlotChart {
231    /// Editable decoration positions and selection, in logical pixels.
232    #[cfg_attr(feature = "serde", serde(default))]
233    pub decorations: crate::DecorationLayout,
234    pub title: String,
235    pub categories: Vec<Category>,
236    pub series: Vec<BoxPlotSeries>,
237    pub direction: BoxPlotDirection,
238    pub style: BoxPlotStyle,
239    pub overrides: Vec<BoxPlotOverride>,
240    pub hovered: Option<BoxPlotTarget>,
241    pub selected: Option<BoxPlotTarget>,
242    pub labels: BoxPlotLabels,
243    pub label_decimals: usize,
244    pub value_suffix: String,
245    pub value_title: String,
246    pub category_title: String,
247    /// None fits ALL supplied summary values, including hidden means/outliers/CI.
248    /// Some fixes the linear viewport; geometry and picking are clipped to it.
249    pub value_range: Option<[f64; 2]>,
250    pub grid: bool,
251    pub legend: bool,
252    pub group_width: f32,
253    pub box_gap: f32,
254    pub font_family: String,
255    pub font_size: f32,
256    pub text_color: Color,
257    pub background: Color,
258}
259impl Default for BoxPlotChart {
260    fn default() -> Self {
261        Self {
262            decorations: Default::default(),
263            title: String::new(),
264            categories: vec![],
265            series: vec![],
266            direction: BoxPlotDirection::Vertical,
267            style: BoxPlotStyle::default(),
268            overrides: vec![],
269            hovered: None,
270            selected: None,
271            labels: BoxPlotLabels::None,
272            label_decimals: 1,
273            value_suffix: String::new(),
274            value_title: String::new(),
275            category_title: String::new(),
276            value_range: None,
277            grid: true,
278            legend: true,
279            group_width: 0.65,
280            box_gap: 8.0,
281            font_family: "sans-serif".into(),
282            font_size: 14.0,
283            text_color: Color::from_rgb8(32, 46, 60),
284            background: Color::WHITE,
285        }
286    }
287}
288impl BoxPlotChart {
289    pub fn summary(&self, target: &BoxPlotTarget) -> Option<&BoxSummary> {
290        let i = self
291            .categories
292            .iter()
293            .position(|c| c.id == target.category_id)?;
294        self.series
295            .iter()
296            .find(|s| s.id == target.series_id)?
297            .values
298            .get(i)?
299            .as_ref()
300    }
301    pub fn box_override(&self, target: &BoxPlotTarget) -> Option<&BoxPlotOverride> {
302        self.overrides.iter().find(|o| &o.target == target)
303    }
304    pub fn resolved_style(&self, target: &BoxPlotTarget) -> Option<&BoxPlotStyle> {
305        let s = self.series.iter().find(|s| s.id == target.series_id)?;
306        self.summary(target)?;
307        Some(
308            self.box_override(target)
309                .and_then(|o| o.style.as_ref())
310                .or(s.style.as_ref())
311                .unwrap_or(&self.style),
312        )
313    }
314    pub fn label_text(&self, target: &BoxPlotTarget) -> Option<String> {
315        let summary = self.summary(target)?;
316        let mode = self
317            .box_override(target)
318            .and_then(|o| o.labels)
319            .unwrap_or(self.labels);
320        if mode == BoxPlotLabels::None {
321            return None;
322        }
323        let median = format!(
324            "{:.*}{}",
325            self.label_decimals, summary.median, self.value_suffix
326        );
327        Some(if mode == BoxPlotLabels::MedianAndCount {
328            format!(
329                "{median} (n={})",
330                summary
331                    .sample_count
332                    .map_or_else(|| "—".into(), |n| n.to_string())
333            )
334        } else {
335            median
336        })
337    }
338    pub fn reorder_categories(&mut self, ids: &[&str]) -> Result<(), &'static str> {
339        self.validate()?;
340        if ids.len() != self.categories.len()
341            || ids.iter().collect::<HashSet<_>>().len() != ids.len()
342        {
343            return Err("category order must be a permutation");
344        }
345        let order = ids
346            .iter()
347            .map(|id| {
348                self.categories
349                    .iter()
350                    .position(|c| c.id == *id)
351                    .ok_or("unknown category ID")
352            })
353            .collect::<Result<Vec<_>, _>>()?;
354        self.categories = order.iter().map(|i| self.categories[*i].clone()).collect();
355        for series in &mut self.series {
356            series.values = order.iter().map(|i| series.values[*i].clone()).collect();
357        }
358        Ok(())
359    }
360    pub fn validate(&self) -> Result<(), &'static str> {
361        self.decorations.validate()?;
362        let (n, m) = (self.categories.len(), self.series.len());
363        if n == 0 || n > MAX_BOX_CATEGORIES || m == 0 || m > MAX_BOX_SERIES || n * m > MAX_BOXES {
364            return Err("boxplot limits: 1..64 categories, 1..16 series, at most 512 entries");
365        }
366        let mut ids = HashSet::new();
367        for c in &self.categories {
368            if c.id.is_empty() || !text(&c.id, 80) || !text(&c.label, 80) || !ids.insert(&c.id) {
369                return Err("invalid category ID/label");
370            }
371        }
372        ids.clear();
373        let mut outliers = 0;
374        for s in &self.series {
375            if s.id.is_empty()
376                || !text(&s.id, 80)
377                || !text(&s.label, 80)
378                || !ids.insert(&s.id)
379                || s.values.len() != n
380                || !color(s.color)
381            {
382                return Err("invalid series ID/label, color or value count");
383            }
384            if let Some(st) = &s.style {
385                validate_style(st)?;
386            }
387            for v in s.values.iter().flatten() {
388                v.validate()?;
389                outliers += v.outliers.len();
390            }
391        }
392        if outliers > MAX_BOXPLOT_OUTLIERS {
393            return Err("at most 4096 outliers per chart");
394        }
395        validate_style(&self.style)?;
396        if !range(self.font_size, 8.0, 32.0)
397            || !range(self.group_width, 0.1, 0.95)
398            || !range(self.box_gap, 0.0, 24.0)
399            || !color(self.text_color)
400            || !color(self.background)
401            || self.label_decimals > 6
402            || !text(&self.font_family, 160)
403            || !text(&self.value_suffix, 24)
404        {
405            return Err("invalid boxplot layout, font or colors");
406        }
407        for s in [&self.title, &self.value_title, &self.category_title] {
408            if !text(s, 160) {
409                return Err("invalid title");
410            }
411        }
412        if self
413            .value_range
414            .is_some_and(|r| !r[0].is_finite() || !r[1].is_finite() || r[0] >= r[1])
415        {
416            return Err("invalid linear value range");
417        }
418        let mut targets = HashSet::new();
419        for o in &self.overrides {
420            if !self.categories.iter().any(|c| c.id == o.target.category_id)
421                || !self.series.iter().any(|s| s.id == o.target.series_id)
422                || !targets.insert(&o.target)
423                || o.color.is_some_and(|c| !color(c))
424            {
425                return Err("invalid or duplicate box override");
426            }
427            if let Some(st) = &o.style {
428                validate_style(st)?;
429            }
430        }
431        for (i, c) in self.categories.iter().enumerate() {
432            for s in &self.series {
433                if let Some(v) = &s.values[i] {
434                    let target = BoxPlotTarget::new(&c.id, &s.id);
435                    if self.resolved_style(&target).unwrap().notched && v.median_ci.is_none() {
436                        return Err("notched boxes require an explicit median CI");
437                    }
438                }
439            }
440        }
441        for t in [&self.hovered, &self.selected].into_iter().flatten() {
442            if self.summary(t).is_none() {
443                return Err("unknown or missing interaction target");
444            }
445        }
446        Ok(())
447    }
448}
449fn text(s: &str, max: usize) -> bool {
450    s.chars().count() <= max && !s.chars().any(char::is_control)
451}
452fn range(v: f32, a: f32, b: f32) -> bool {
453    v.is_finite() && (a..=b).contains(&v)
454}
455fn color(c: Color) -> bool {
456    [c.r, c.g, c.b, c.a].into_iter().all(|v| range(v, 0.0, 1.0))
457}
458fn validate_style(s: &BoxPlotStyle) -> Result<(), &'static str> {
459    if !range(s.corner_radius, 0.0, 3.0)
460        || !range(s.outline_width, 0.0, 8.0)
461        || !range(s.median_width, 0.0, 8.0)
462        || !range(s.whisker_width, 0.0, 8.0)
463        || !range(s.cap_ratio, 0.0, 1.0)
464        || !range(s.notch_depth, 0.0, 0.45)
465        || !range(s.mean_size, 1.0, 24.0)
466        || !range(s.outlier_size, 1.0, 24.0)
467        || !range(s.texture_strength, 0.0, 1.0)
468        || !range(s.texture_scale, 0.1, 8.0)
469        || !range(s.gloss, 0.0, 1.0)
470        || !range(s.emphasis_brightness, 0.0, 0.5)
471        || [
472            s.outline_color,
473            s.median_color,
474            s.whisker_color,
475            s.mean_color,
476            s.outlier_color,
477        ]
478        .into_iter()
479        .any(|c| !color(c))
480    {
481        Err("invalid boxplot style")
482    } else {
483        Ok(())
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    fn chart() -> BoxPlotChart {
491        BoxPlotChart {
492            categories: vec![Category::new("a", "A"), Category::new("b", "B")],
493            series: vec![BoxPlotSeries::new(
494                "s",
495                "S",
496                vec![Some(BoxSummary::new(1., 2., 3., 4., 5.)), None],
497                Color::WHITE,
498            )],
499            ..Default::default()
500        }
501    }
502    #[test]
503    fn summaries_reject_invalid_order_nonfinite_and_nonoutliers() {
504        let v = BoxSummary::new(1., 2., 3., 4., 5.);
505        assert!(v.validate().is_ok());
506        for invalid in [
507            BoxSummary::new(2., 1., 3., 4., 5.),
508            BoxSummary::new(1., 4., 3., 4., 5.),
509            BoxSummary::new(1., 2., 3., 2., 5.),
510            BoxSummary::new(1., 2., 3., 4., 3.),
511            BoxSummary::new(1., 2., f64::NAN, 4., 5.),
512        ] {
513            assert!(invalid.validate().is_err());
514        }
515        for value in [1., 3., 5., f64::INFINITY] {
516            let mut x = v.clone();
517            x.outliers.push(value);
518            assert!(x.validate().is_err());
519        }
520        let mut x = v.clone();
521        x.outliers = vec![-1., 9.];
522        x.mean = Some(2.5);
523        assert!(x.validate().is_ok());
524        x.mean = Some(f64::NAN);
525        assert!(x.validate().is_err());
526        assert!(BoxSummary::new(3., 3., 3., 3., 3.).validate().is_ok());
527    }
528    #[test]
529    fn notches_require_explicit_ci_at_every_style_level_and_keep_extended_ci() {
530        let mut c = chart();
531        c.style.notched = true;
532        assert!(c.validate().is_err());
533        c.series[0].values[0].as_mut().unwrap().median_ci = Some([-5., 10.]);
534        assert!(c.validate().is_ok());
535        assert_eq!(
536            c.summary(&BoxPlotTarget::new("a", "s")).unwrap().median_ci,
537            Some([-5., 10.])
538        );
539        c.series[0].values[0].as_mut().unwrap().median_ci = None;
540        c.style.notched = false;
541        c.series[0].style = Some(BoxPlotStyle {
542            notched: true,
543            ..Default::default()
544        });
545        assert!(c.validate().is_err());
546        c.series[0].style = None;
547        let mut o = BoxPlotOverride::new(BoxPlotTarget::new("a", "s"));
548        o.style = Some(BoxPlotStyle {
549            notched: true,
550            ..Default::default()
551        });
552        c.overrides.push(o);
553        assert!(c.validate().is_err());
554        c.series[0].values[0].as_mut().unwrap().median_ci = Some([4., 6.]);
555        assert!(c.validate().is_err());
556        c.series[0].values[0].as_mut().unwrap().median_ci = Some([3., 3.]);
557        assert!(c.validate().is_ok());
558    }
559    #[test]
560    fn reorder_is_atomic_and_preserves_identity_edits_and_missing_values() {
561        let mut c = chart();
562        let t = BoxPlotTarget::new("a", "s");
563        c.selected = Some(t.clone());
564        c.overrides.push(BoxPlotOverride::new(t.clone()));
565        let before = c.clone();
566        assert!(c.reorder_categories(&["a", "a"]).is_err());
567        assert_eq!(c, before);
568        c.reorder_categories(&["b", "a"]).unwrap();
569        assert!(c.series[0].values[0].is_none());
570        assert_eq!(c.summary(&t).unwrap().median, 3.);
571        assert_eq!(c.selected, Some(t));
572        c.selected = Some(BoxPlotTarget::new("b", "s"));
573        assert!(c.validate().is_err());
574    }
575    #[test]
576    fn bounds_are_independent_and_total_outliers_are_bounded() {
577        for (n, m, valid) in [
578            (64, 8, true),
579            (32, 16, true),
580            (65, 1, false),
581            (1, 17, false),
582            (33, 16, false),
583            (0, 1, false),
584        ] {
585            let mut c = chart();
586            c.categories = (0..n).map(|i| Category::new(i.to_string(), "A")).collect();
587            c.series = (0..m)
588                .map(|i| {
589                    BoxPlotSeries::new(
590                        i.to_string(),
591                        "S",
592                        vec![Some(BoxSummary::new(1., 2., 3., 4., 5.)); n],
593                        Color::WHITE,
594                    )
595                })
596                .collect();
597            assert_eq!(c.validate().is_ok(), valid, "{n}x{m}");
598        }
599        let mut c = chart();
600        let v = c.series[0].values[0].as_mut().unwrap();
601        v.outliers = vec![9.; 128];
602        assert!(c.validate().is_ok());
603        c.series[0].values[0].as_mut().unwrap().outliers.push(9.);
604        assert!(c.validate().is_err());
605        c.categories = (0..33).map(|i| Category::new(i.to_string(), "A")).collect();
606        let mut v = BoxSummary::new(1., 2., 3., 4., 5.);
607        v.outliers = vec![9.; 128];
608        c.series[0].values = vec![Some(v); 33];
609        assert!(c.validate().is_err());
610        c.categories.pop();
611        c.series[0].values.pop();
612        assert!(c.validate().is_ok());
613    }
614    #[test]
615    fn style_ranges_and_override_targets_are_validated() {
616        for edit in [
617            |s: &mut BoxPlotStyle| s.corner_radius = 4.,
618            |s: &mut BoxPlotStyle| s.notch_depth = 0.5,
619            |s: &mut BoxPlotStyle| s.gloss = f32::NAN,
620            |s: &mut BoxPlotStyle| s.whisker_width = -1.,
621            |s: &mut BoxPlotStyle| s.outlier_color.a = 2.,
622        ] {
623            for level in 0..3 {
624                let mut c = chart();
625                let mut st = BoxPlotStyle::default();
626                edit(&mut st);
627                match level {
628                    0 => c.style = st,
629                    1 => c.series[0].style = Some(st),
630                    _ => {
631                        let mut o = BoxPlotOverride::new(BoxPlotTarget::new("a", "s"));
632                        o.style = Some(st);
633                        c.overrides.push(o);
634                    }
635                }
636                assert!(c.validate().is_err());
637            }
638        }
639        let mut c = chart();
640        c.overrides = vec![BoxPlotOverride::new(BoxPlotTarget::new("a", "s")); 2];
641        assert!(c.validate().is_err());
642        c.overrides = vec![BoxPlotOverride::new(BoxPlotTarget::new("unknown", "s"))];
643        assert!(c.validate().is_err());
644        c.overrides.clear();
645        c.value_range = Some([1., 1.]);
646        assert!(c.validate().is_err());
647        c.value_range = Some([-1., 2.]);
648        assert!(c.validate().is_ok());
649    }
650    #[test]
651    fn labels_use_supplied_median_and_sample_count_with_individual_override() {
652        let mut c = chart();
653        let t = BoxPlotTarget::new("a", "s");
654        assert_eq!(c.label_text(&t), None);
655        c.labels = BoxPlotLabels::MedianAndCount;
656        c.value_suffix = " ms".into();
657        assert_eq!(c.label_text(&t).unwrap(), "3.0 ms (n=—)");
658        c.series[0].values[0].as_mut().unwrap().sample_count = Some(42);
659        c.label_decimals = 2;
660        assert_eq!(c.label_text(&t).unwrap(), "3.00 ms (n=42)");
661        let mut o = BoxPlotOverride::new(t.clone());
662        o.labels = Some(BoxPlotLabels::Median);
663        c.overrides.push(o);
664        assert_eq!(c.label_text(&t).unwrap(), "3.00 ms");
665    }
666    #[cfg(feature = "serde")]
667    #[test]
668    fn complete_ssot_round_trips_without_losing_optional_statistics() {
669        let minimal: BoxSummary =
670            serde_json::from_str(r#"{"whisker_low":1,"q1":2,"median":3,"q3":4,"whisker_high":5}"#)
671                .unwrap();
672        assert_eq!(minimal, BoxSummary::new(1., 2., 3., 4., 5.));
673        let mut c = chart();
674        let v = c.series[0].values[0].as_mut().unwrap();
675        v.median_ci = Some([2.5, 3.5]);
676        v.mean = Some(3.1);
677        v.sample_count = Some(21);
678        v.outliers = vec![9.];
679        c.style.notched = true;
680        c.direction = BoxPlotDirection::Horizontal;
681        c.selected = Some(BoxPlotTarget::new("a", "s"));
682        let json = serde_json::to_string(&c).unwrap();
683        let back: BoxPlotChart = serde_json::from_str(&json).unwrap();
684        assert_eq!(c, back);
685        back.validate().unwrap();
686    }
687}