1use 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#[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 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 #[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 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}