use std::fmt::Write as _;
use crate::{
error::ChartWarning,
layout::{
LABEL_SIZE, NumericScale, PlotArea, WithReserve, add_bottom_category_title, base_elements,
base_elements_with_title, count, format_value, horizontal_title, push_category_label,
push_side_label, title_extra, warn_if_labels_omitted,
},
metrics::TextMetrics,
scene::{Circle, Element, Line, Rect, Scene, Text, TextAnchor},
spec::{BoxSummary, ChartSpec, Orientation},
text,
};
const CAP: f64 = 0.5;
const OUTLIER_RADIUS: f64 = 3.5;
const MIN_EXTENT: f64 = 2.0;
fn summary_label(spec: &ChartSpec, summary: &BoxSummary) -> String {
let show = |value| format_value(value, spec.number_style());
let words = spec.locale.words();
let mut label = format!(
"{}: {} ({}: {}, {}: {}, {}: {}, {}: {})",
words.median,
show(summary.median),
words.quartile_1,
show(summary.q1),
words.quartile_3,
show(summary.q3),
words.minimum,
show(summary.min),
words.maximum,
show(summary.max),
);
if !summary.outliers.is_empty() {
let outliers: Vec<String> = summary.outliers.iter().map(|value| show(*value)).collect();
write!(label, ", {}: {}", words.outliers, outliers.join(", "))
.expect("writing to String cannot fail");
}
label
}
fn tooltip(spec: &ChartSpec, index: usize, summary: &BoxSummary) -> String {
let count = summary
.count
.map(|count| format!(", {} {count}", spec.locale.words().observations))
.unwrap_or_default();
format!(
"{}: {}{count}",
spec.boxes[index].label,
summary_label(spec, summary)
)
}
pub(crate) fn layout(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let summaries = spec.box_summaries();
let values = summaries.iter().flat_map(|summary| {
[summary.min, summary.max]
.into_iter()
.chain(summary.outliers.iter().copied())
});
let scale = NumericScale::for_axis(values, false, &spec.value_axis);
let elements = match spec.orientation {
Orientation::Horizontal => layout_horizontal(spec, &summaries, &scale, warnings, metrics),
Orientation::Vertical => layout_vertical(spec, &summaries, &scale, warnings, metrics),
};
Scene {
width: spec.width,
height: spec.height,
elements,
}
}
fn push_box(
elements: &mut Vec<Element>,
spec: &ChartSpec,
(index, summary): (usize, &BoxSummary),
orientation: Orientation,
along: impl Fn(f64) -> f64,
across: (f64, f64),
) {
let (lead, thick) = across;
let middle = lead + thick / 2.0;
let line = |from: (f64, f64), to: (f64, f64), class| {
let (x1, y1, x2, y2) = match orientation {
Orientation::Horizontal => (from.0, from.1, to.0, to.1),
Orientation::Vertical => (from.1, from.0, to.1, to.0),
};
Element::Line(Line {
x1,
y1,
x2,
y2,
class,
})
};
let (low, high) = (along(summary.q1), along(summary.q3));
let (start, extent) = (low.min(high), (high - low).abs().max(MIN_EXTENT));
let (x, y, width, height) = match orientation {
Orientation::Horizontal => (start, lead, extent, thick),
Orientation::Vertical => (lead, start, thick, extent),
};
let cap = thick * CAP / 2.0;
for (end, edge) in [(summary.min, summary.q1), (summary.max, summary.q3)] {
elements.push(line(
(along(edge), middle),
(along(end), middle),
"chartlet-box-whisker",
));
elements.push(line(
(along(end), middle - cap),
(along(end), middle + cap),
"chartlet-box-whisker",
));
}
elements.push(Element::Rect(Rect {
x,
y,
width,
height,
class: "chartlet-box",
series_index: None,
style_index: None,
tooltip: Some(tooltip(spec, index, summary)),
}));
elements.push(line(
(along(summary.median), lead),
(along(summary.median), lead + thick),
"chartlet-box-median",
));
for value in &summary.outliers {
let (cx, cy) = match orientation {
Orientation::Horizontal => (along(*value), middle),
Orientation::Vertical => (middle, along(*value)),
};
elements.push(Element::Circle(Circle {
cx,
cy,
radius: OUTLIER_RADIUS,
class: "chartlet-box-outlier",
topic: None,
series_index: None,
style_index: None,
tooltip: Some(format!(
"{}: {}",
spec.boxes[index].label,
format_value(*value, spec.number_style())
)),
}));
}
}
fn reach(summary: &BoxSummary) -> (f64, f64) {
summary
.outliers
.iter()
.fold((summary.min, summary.max), |(low, high), value| {
(low.min(*value), high.max(*value))
})
}
fn layout_horizontal(
spec: &ChartSpec,
summaries: &[BoxSummary],
scale: &NumericScale,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Vec<Element> {
let width = f64::from(spec.width);
let height = f64::from(spec.height);
let widest = spec
.boxes
.iter()
.map(|boxed| metrics.width(&boxed.label, LABEL_SIZE))
.fold(0.0, f64::max);
let left = (widest + 29.0).clamp(88.0, 210.0);
let right = if spec.show_values {
summaries
.iter()
.map(|summary| {
WithReserve(metrics).width(
&format_value(summary.median, spec.number_style()),
LABEL_SIZE,
)
})
.fold(0.0, f64::max)
.clamp(8.0, 120.0)
+ 16.0
} else {
let last = scale.ticks().last().map_or(0.0, |value| {
metrics.width(&scale.tick_label(value, spec.axis_style()), LABEL_SIZE)
});
f64::max(24.0, last / 2.0 + 4.0)
};
let bottom = if spec.value_axis.title.is_some() {
56.0
} else {
36.0
};
let title = horizontal_title(spec, (left, width - left - right), metrics);
let top = 78.0 + title_extra(spec, title.1, metrics);
let plot = PlotArea {
left,
top,
width: width - left - right,
height: crate::layout::plot_height(height - top - bottom, warnings),
vertical_bars: false,
};
let mut elements = base_elements_with_title(spec, scale, plot, title, warnings, metrics);
let band = plot.height / count(spec.boxes.len());
let thickness = (band * 0.5).clamp(4.0, 32.0);
let along = |value| scale.map(value, plot.left, plot.left + plot.width);
for (index, summary) in summaries.iter().enumerate() {
let center = plot.top + band * (count(index) + 0.5);
push_box(
&mut elements,
spec,
(index, summary),
Orientation::Horizontal,
along,
(center - thickness / 2.0, thickness),
);
if spec.show_values {
elements.push(Element::Text(Text {
x: along(reach(summary).1) + 8.0,
y: center + 4.0,
class: "chartlet-value",
anchor: TextAnchor::Start,
content: format_value(summary.median, spec.number_style()),
}));
}
push_side_label(
&mut elements,
&spec.boxes[index].label,
(plot.left - 12.0, center),
(plot.left - 28.0, band),
metrics,
warnings,
&format!("/boxes/{index}/label"),
);
}
elements
}
fn layout_vertical(
spec: &ChartSpec,
summaries: &[BoxSummary],
scale: &NumericScale,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Vec<Element> {
let width = f64::from(spec.width);
let height = f64::from(spec.height);
let left = f64::from(crate::layout::axis_gutter(spec.width));
let right = f64::from(crate::layout::plot_margin(spec.width));
let bottom = if spec.category_axis.title.is_some() {
82.0
} else {
62.0
};
let top = 78.0 + title_extra(spec, width - left - right, metrics);
let plot = PlotArea {
left,
top,
width: width - left - right,
height: crate::layout::plot_height(height - top - bottom, warnings),
vertical_bars: true,
};
let mut elements = base_elements(spec, scale, plot, warnings, metrics);
let band = plot.width / count(spec.boxes.len());
let thickness = (band * 0.5).clamp(6.0, 48.0);
let along = |value| scale.map(value, plot.top + plot.height, plot.top);
let mut omitted = false;
for (index, summary) in summaries.iter().enumerate() {
let center = plot.left + band * (count(index) + 0.5);
push_box(
&mut elements,
spec,
(index, summary),
Orientation::Vertical,
along,
(center - thickness / 2.0, thickness),
);
if spec.show_values {
let content = format_value(summary.median, spec.number_style());
if metrics.width(&content, LABEL_SIZE) <= band - 4.0 {
elements.push(Element::Text(Text {
x: center,
y: along(reach(summary).1) - 8.0,
class: "chartlet-value",
anchor: TextAnchor::Middle,
content,
}));
} else {
omitted = true;
}
}
push_category_label(
&mut elements,
&spec.boxes[index].label,
(center, plot.top + plot.height + 24.0),
(band - 8.0).max(20.0),
metrics,
warnings,
&format!("/boxes/{index}/label"),
);
}
warn_if_labels_omitted(omitted, warnings);
add_bottom_category_title(
spec,
plot.left,
plot.width,
height,
&mut elements,
warnings,
metrics,
);
elements
}
pub(crate) fn description(spec: &ChartSpec) -> String {
let show = |value| format_value(value, spec.number_style());
let summaries = spec.box_summaries();
let highest = (0..summaries.len())
.max_by(|a, b| summaries[*a].median.total_cmp(&summaries[*b].median))
.expect("validated boxplots have a box");
let lowest = (0..summaries.len())
.min_by(|a, b| summaries[*a].median.total_cmp(&summaries[*b].median))
.expect("validated boxplots have a box");
let outliers: Vec<&str> = (0..summaries.len())
.filter(|index| !summaries[*index].outliers.is_empty())
.map(|index| spec.boxes[index].label.as_str())
.collect();
text::boxplot_summary(
spec.locale,
summaries.len(),
(&show(summaries[highest].median), &spec.boxes[highest].label),
(&show(summaries[lowest].median), &spec.boxes[lowest].label),
&outliers,
)
}
pub(crate) fn outlier_cells(spec: &ChartSpec) -> Vec<String> {
spec.box_summaries()
.iter()
.map(|summary| {
summary
.outliers
.iter()
.map(|value| format_value(*value, spec.number_style()))
.collect::<Vec<_>>()
.join(", ")
})
.collect()
}