use std::fmt::Write as _;
use crate::{
error::ChartWarning,
layout::{
CONTENT_LEFT, LABEL_SIZE, NumericScale, PlotArea, WithReserve, add_bottom_category_title,
base_elements, base_elements_with_title, count, fit_text, 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::{ChartSpec, Orientation, RangeDisplay, RangeSpec},
text,
};
const GROUP_CLASSES: [&str; crate::spec::MAX_SERIES] = [
"chartlet-range chartlet-range-series-1",
"chartlet-range chartlet-range-series-2",
"chartlet-range chartlet-range-series-3",
"chartlet-range chartlet-range-series-4",
];
const DUMBBELL_LINE: [&str; 5] = [
"chartlet-dumbbell-line",
"chartlet-dumbbell-line chartlet-range-series-1",
"chartlet-dumbbell-line chartlet-range-series-2",
"chartlet-dumbbell-line chartlet-range-series-3",
"chartlet-dumbbell-line chartlet-range-series-4",
];
const DUMBBELL_LOW: [&str; 5] = [
"chartlet-dumbbell-low",
"chartlet-dumbbell-low chartlet-range-series-1",
"chartlet-dumbbell-low chartlet-range-series-2",
"chartlet-dumbbell-low chartlet-range-series-3",
"chartlet-dumbbell-low chartlet-range-series-4",
];
const DUMBBELL_HIGH: [&str; 5] = [
"chartlet-dumbbell-high",
"chartlet-dumbbell-high chartlet-range-series-1",
"chartlet-dumbbell-high chartlet-range-series-2",
"chartlet-dumbbell-high chartlet-range-series-3",
"chartlet-dumbbell-high chartlet-range-series-4",
];
const DUMBBELL_DOT: f64 = 5.5;
const DUMBBELL_REACH: f64 = 11.0;
const LEGEND_GAP: f64 = 16.0;
const LEGEND_HEIGHT: f64 = 24.0;
const LEGEND_SWATCH: f64 = 16.0;
const MIN_EXTENT: f64 = 2.0;
const SQUARE: f64 = 14.0;
const MIN_SQUARE: f64 = 4.0;
const MID_OVERHANG: f64 = 3.0;
fn span_label(spec: &ChartSpec, range: &RangeSpec) -> String {
let show = |value| format_value(value, spec.number_style());
let to = spec.locale.words().to;
if range.low.total_cmp(&range.high).is_eq()
&& range
.mid
.is_none_or(|mid| mid.total_cmp(&range.low).is_eq())
{
return show(range.low);
}
match range.mid {
Some(mid) => format!(
"{} ({} {to} {})",
show(mid),
show(range.low),
show(range.high)
),
None => format!("{} {to} {}", show(range.low), show(range.high)),
}
}
fn tooltip(spec: &ChartSpec, range: &RangeSpec) -> String {
let weight = range
.weight
.map(|weight| {
format!(
", {} {}",
spec.locale.words().weight.to_lowercase(),
format_value(weight, spec.number_style())
)
})
.unwrap_or_default();
let modeled = if range.modeled {
format!(", {}", spec.locale.words().modeled)
} else {
String::new()
};
let group = range
.group
.as_ref()
.map(|group| format!(" ({group})"))
.unwrap_or_default();
format!(
"{}{group}: {}{modeled}{weight}",
range.label,
span_label(spec, range)
)
}
fn range_class(spec: &ChartSpec, range: &RangeSpec) -> &'static str {
range.group.as_deref().map_or("chartlet-range", |group| {
let index = spec
.range_groups()
.iter()
.position(|known| *known == group)
.expect("every group is among the chart's groups");
GROUP_CLASSES[index]
})
}
pub(crate) fn layout(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let values = spec
.ranges
.iter()
.flat_map(|range| [Some(range.low), Some(range.high), range.mid])
.flatten()
.chain(crate::reference::values(spec));
let scale = NumericScale::for_axis(values, false, &spec.value_axis);
let elements = match spec.orientation {
Orientation::Horizontal => layout_horizontal(spec, &scale, warnings, metrics),
Orientation::Vertical => layout_vertical(spec, &scale, warnings, metrics),
};
Scene {
width: spec.width,
height: spec.height,
elements,
}
}
const LEGEND_ROW: f64 = 20.0;
fn plot_top(spec: &ChartSpec, title_width: f64, left: f64, metrics: &impl TextMetrics) -> f64 {
let legend = match legend_layout(spec, left, metrics)
.iter()
.map(|(_, _, row)| *row)
.max()
{
Some(rows) => LEGEND_HEIGHT + LEGEND_ROW * count(rows),
None => 0.0,
};
78.0 + title_extra(spec, title_width, metrics) + legend
}
fn legend_entries(spec: &ChartSpec) -> Vec<(&'static [&'static str], String, String)> {
let mut entries: Vec<(&'static [&'static str], String, String)> = spec
.range_groups()
.into_iter()
.enumerate()
.map(|(index, group)| {
let first = spec
.ranges
.iter()
.position(|range| range.group.as_deref() == Some(group))
.expect("a group comes from a range");
(
std::slice::from_ref(&GROUP_CLASSES[index]),
group.to_owned(),
format!("/ranges/{first}/group"),
)
})
.collect();
if spec.ranges.iter().any(|range| range.modeled) {
entries.push((
&["chartlet-range", "chartlet-range-hatch"],
spec.locale.words().hatched_modeled.to_owned(),
"/locale".to_owned(),
));
}
entries
}
fn legend_layout(
spec: &ChartSpec,
left: f64,
metrics: &impl TextMetrics,
) -> Vec<(usize, f64, usize)> {
let entries = legend_entries(spec);
let right = legend_right(spec);
let widths: Vec<f64> = entries
.iter()
.map(|(_, text, _)| LEGEND_SWATCH + WithReserve(metrics).width(text, LABEL_SIZE))
.collect();
let total = widths.iter().sum::<f64>() + LEGEND_GAP * count(widths.len().saturating_sub(1));
let start = if left + total <= right {
left
} else {
CONTENT_LEFT
};
let (mut x, mut row) = (start, 0);
let mut placed = Vec::new();
for (index, width) in widths.into_iter().enumerate() {
if x > start && x + width > right {
x = start;
row += 1;
}
placed.push((index, x, row));
x += width + LEGEND_GAP;
}
placed
}
fn legend_right(spec: &ChartSpec) -> f64 {
f64::from(spec.width) - f64::from(crate::layout::plot_margin(spec.width))
}
fn push_legend(
elements: &mut Vec<Element>,
warnings: &mut Vec<ChartWarning>,
spec: &ChartSpec,
(left, title_width): (f64, f64),
metrics: &impl TextMetrics,
) {
let entries = legend_entries(spec);
let right = legend_right(spec);
let top = 46.0 + title_extra(spec, title_width, metrics);
for (index, x, row) in legend_layout(spec, left, metrics) {
let (classes, text, path) = &entries[index];
let y = top + LEGEND_ROW * count(row);
let content = fit_text(
text,
(right - x - LEGEND_SWATCH).max(0.0),
LABEL_SIZE,
metrics,
warnings,
path,
);
for class in *classes {
elements.push(Element::Rect(Rect {
x,
y,
width: 10.0,
height: 10.0,
class,
series_index: None,
style_index: None,
tooltip: None,
}));
}
elements.push(Element::Text(Text {
x: x + LEGEND_SWATCH,
y: y + 9.0,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content,
}));
}
}
fn range_tone(spec: &ChartSpec, range: &RangeSpec) -> usize {
range.group.as_deref().map_or(0, |group| {
spec.range_groups()
.iter()
.position(|known| *known == group)
.map_or(0, |position| position + 1)
})
}
fn push_dumbbell(
elements: &mut Vec<Element>,
spec: &ChartSpec,
range: &RangeSpec,
orientation: Orientation,
along: &impl Fn(f64) -> f64,
across: (f64, f64),
) {
let tone = range_tone(spec, range);
let middle = across.0 + across.1 / 2.0;
let page = |value: f64| match orientation {
Orientation::Horizontal => (along(value), middle),
Orientation::Vertical => (middle, along(value)),
};
let (from, to) = (page(range.low), page(range.high));
elements.push(Element::Line(Line {
x1: from.0,
y1: from.1,
x2: to.0,
y2: to.1,
class: DUMBBELL_LINE[tone],
}));
for (at, class) in [(from, DUMBBELL_LOW[tone]), (to, DUMBBELL_HIGH[tone])] {
elements.push(Element::Circle(Circle {
cx: at.0,
cy: at.1,
radius: DUMBBELL_DOT,
class,
series_index: None,
style_index: None,
tooltip: Some(tooltip(spec, range)),
}));
}
}
fn end_sides(range: &RangeSpec, along: &impl Fn(f64) -> f64) -> [(f64, f64); 2] {
if along(range.low) <= along(range.high) {
[(range.low, -1.0), (range.high, 1.0)]
} else {
[(range.low, 1.0), (range.high, -1.0)]
}
}
fn push_end_value(
elements: &mut Vec<Element>,
spec: &ChartSpec,
value: f64,
(at, middle, side): (f64, f64, f64),
orientation: Orientation,
) {
let content = format_value(value, spec.number_style());
let offset = side * DUMBBELL_REACH;
elements.push(Element::Text(match orientation {
Orientation::Horizontal => Text {
x: at + offset,
y: middle + 4.0,
class: "chartlet-value",
anchor: if side < 0.0 {
TextAnchor::End
} else {
TextAnchor::Start
},
content,
},
Orientation::Vertical => Text {
x: middle,
y: at + offset + if side < 0.0 { -2.0 } else { 6.0 },
class: "chartlet-value",
anchor: TextAnchor::Middle,
content,
},
}));
}
fn split_values(
spec: &ChartSpec,
fits: impl Fn(&RangeSpec, f64, f64) -> bool,
along: &impl Fn(f64) -> f64,
) -> bool {
spec.display == RangeDisplay::Dumbbell
&& spec.show_values
&& spec.ranges.iter().all(|range| {
end_sides(range, along)
.into_iter()
.all(|(value, side)| fits(range, value, side))
})
}
fn push_span(
elements: &mut Vec<Element>,
spec: &ChartSpec,
range: &RangeSpec,
orientation: Orientation,
along: impl Fn(f64) -> f64,
across: (f64, f64),
max_weight: f64,
) {
let (low, high) = (along(range.low), along(range.high));
let (start, extent) = (low.min(high), (high - low).abs().max(MIN_EXTENT));
let rect = |class| {
let (x, y, width, height) = match orientation {
Orientation::Horizontal => (start, across.0, extent, across.1),
Orientation::Vertical => (across.0, start, across.1, extent),
};
Element::Rect(Rect {
x,
y,
width,
height,
class,
series_index: None,
style_index: None,
tooltip: Some(tooltip(spec, range)),
})
};
if spec.display == RangeDisplay::Dumbbell {
push_dumbbell(elements, spec, range, orientation, &along, across);
return;
}
if range.summary {
push_diamond(elements, spec, range, orientation, &along, across);
return;
}
elements.push(rect(range_class(spec, range)));
if range.modeled {
elements.push(rect("chartlet-range-hatch"));
}
let Some(mid) = range.mid else {
return;
};
let position = along(mid);
if let Some(weight) = range.weight {
let side = (SQUARE * (weight / max_weight).sqrt()).max(MIN_SQUARE);
let center = across.0 + across.1 / 2.0;
let (x, y) = match orientation {
Orientation::Horizontal => (position - side / 2.0, center - side / 2.0),
Orientation::Vertical => (center - side / 2.0, position - side / 2.0),
};
elements.push(Element::Rect(Rect {
x,
y,
width: side,
height: side,
class: "chartlet-range-weight",
series_index: None,
style_index: None,
tooltip: Some(tooltip(spec, range)),
}));
return;
}
let (from, to) = (across.0 - MID_OVERHANG, across.0 + across.1 + MID_OVERHANG);
elements.push(Element::Line(match orientation {
Orientation::Horizontal => Line {
x1: position,
y1: from,
x2: position,
y2: to,
class: "chartlet-range-mid",
},
Orientation::Vertical => Line {
x1: from,
y1: position,
x2: to,
y2: position,
class: "chartlet-range-mid",
},
}));
}
fn push_diamond(
elements: &mut Vec<Element>,
spec: &ChartSpec,
range: &RangeSpec,
orientation: Orientation,
along: &impl Fn(f64) -> f64,
across: (f64, f64),
) {
let mid = along(range.mid.expect("validated summaries have a mid"));
let center = across.0 + across.1 / 2.0;
let half = (across.1 * 0.75).max(SQUARE * 0.65);
let points = [
(along(range.low), center),
(mid, center - half),
(along(range.high), center),
(mid, center + half),
]
.into_iter()
.map(|(a, b)| match orientation {
Orientation::Horizontal => (a, b),
Orientation::Vertical => (b, a),
})
.collect();
let mut polygon =
crate::diagram::polyline(points, "chartlet-range-summary", Some(tooltip(spec, range)));
polygon.points.push(polygon.points[0]);
elements.push(Element::Polyline(polygon));
}
fn forest(spec: &ChartSpec) -> bool {
spec.ranges
.iter()
.any(|range| range.weight.is_some() || range.summary)
}
const FOREST_LINE: f64 = 3.0;
fn heaviest(spec: &ChartSpec) -> f64 {
spec.ranges
.iter()
.filter_map(|range| range.weight)
.fold(f64::MIN_POSITIVE, f64::max)
}
fn horizontal_gutter(spec: &ChartSpec, metrics: &impl TextMetrics) -> f64 {
let widest = spec
.ranges
.iter()
.map(|range| metrics.width(&range.label, LABEL_SIZE))
.fold(0.0, f64::max);
(widest + 29.0).clamp(88.0, 210.0)
}
#[allow(clippy::too_many_lines)]
fn layout_horizontal(
spec: &ChartSpec,
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 = horizontal_gutter(spec, metrics);
let right = if spec.show_values {
spec.ranges
.iter()
.map(|range| WithReserve(metrics).width(&span_label(spec, range), LABEL_SIZE))
.fold(0.0, f64::max)
.clamp(8.0, 200.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 = plot_top(spec, title.1, left, 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 bars = elements.len();
let max_weight = heaviest(spec);
let band = plot.height / count(spec.ranges.len());
let thickness = if forest(spec) {
FOREST_LINE
} else {
(band * 0.5).clamp(2.0, 28.0)
};
let along = |value| scale.map(value, plot.left, plot.left + plot.width);
let rules: Vec<f64> = spec
.references
.iter()
.map(|reference| along(reference.value))
.collect();
let split = split_values(
spec,
|_, value, side| {
side > 0.0 || {
let reach = WithReserve(metrics)
.width(&format_value(value, spec.number_style()), LABEL_SIZE);
along(value) - plot.left >= reach + DUMBBELL_REACH
}
},
&along,
);
for (row, index) in spec.range_order().into_iter().enumerate() {
let range = &spec.ranges[index];
let center = plot.top + band * (count(row) + 0.5);
push_span(
&mut elements,
spec,
range,
Orientation::Horizontal,
along,
(center - thickness / 2.0, thickness),
max_weight,
);
if split {
for (value, side) in end_sides(range, &along) {
push_end_value(
&mut elements,
spec,
value,
(along(value), center, side),
Orientation::Horizontal,
);
}
} else if spec.show_values {
let start = along(range.high).max(along(range.low)) + 8.0;
let reach = WithReserve(metrics).width(&span_label(spec, range), LABEL_SIZE);
let x = rules
.iter()
.find(|rule| **rule > start - 3.0 && **rule < start + reach + 3.0)
.map_or(start, |rule| rule + 6.0);
elements.push(Element::Text(Text {
x,
y: center + 4.0,
class: "chartlet-value",
anchor: TextAnchor::Start,
content: span_label(spec, range),
}));
}
push_side_label(
&mut elements,
&range.label,
(plot.left - 12.0, center),
(plot.left - 28.0, band),
metrics,
warnings,
&format!("/ranges/{index}/label"),
);
}
crate::reference::push(spec, *scale, plot, bars, &mut elements, warnings, metrics);
push_legend(&mut elements, warnings, spec, (plot.left, title.1), metrics);
elements
}
#[allow(clippy::too_many_lines)]
fn layout_vertical(
spec: &ChartSpec,
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 = plot_top(spec, width - left - right, left, 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 bars = elements.len();
let max_weight = heaviest(spec);
let band = plot.width / count(spec.ranges.len());
let thickness = if forest(spec) {
FOREST_LINE
} else {
(band * 0.5).clamp(2.0, 36.0)
};
let along = |value| scale.map(value, plot.top + plot.height, plot.top);
let mut omitted = false;
let split = split_values(
spec,
|_, value, side| {
let width = metrics.width(&format_value(value, spec.number_style()), LABEL_SIZE);
width <= band - 4.0
&& (side < 0.0 || along(value) + DUMBBELL_REACH <= plot.top + plot.height)
},
&along,
);
for (row, index) in spec.range_order().into_iter().enumerate() {
let range = &spec.ranges[index];
let center = plot.left + band * (count(row) + 0.5);
push_span(
&mut elements,
spec,
range,
Orientation::Vertical,
along,
(center - thickness / 2.0, thickness),
max_weight,
);
if split {
for (value, side) in end_sides(range, &along) {
push_end_value(
&mut elements,
spec,
value,
(along(value), center, side),
Orientation::Vertical,
);
}
} else if spec.show_values {
let content = span_label(spec, range);
if metrics.width(&content, LABEL_SIZE) <= band - 4.0 {
elements.push(Element::Text(Text {
x: center,
y: along(range.high).min(along(range.low)) - 8.0,
class: "chartlet-value",
anchor: TextAnchor::Middle,
content,
}));
} else {
omitted = true;
}
}
push_category_label(
&mut elements,
&range.label,
(center, plot.top + plot.height + 24.0),
(band - 8.0).max(20.0),
metrics,
warnings,
&format!("/ranges/{index}/label"),
);
}
warn_if_labels_omitted(omitted, warnings);
add_bottom_category_title(
spec,
plot.left,
plot.width,
height,
&mut elements,
warnings,
metrics,
);
crate::reference::push(spec, *scale, plot, bars, &mut elements, warnings, metrics);
push_legend(
&mut elements,
warnings,
spec,
(plot.left, plot.width),
metrics,
);
elements
}
pub(crate) fn description(spec: &ChartSpec) -> String {
let show = |value| format_value(value, spec.number_style());
let lowest = spec
.ranges
.iter()
.min_by(|a, b| a.low.total_cmp(&b.low))
.expect("validated rangebar charts have a range");
let highest = spec
.ranges
.iter()
.max_by(|a, b| a.high.total_cmp(&b.high))
.expect("validated rangebar charts have a range");
let mut description = text::rangebar_opening(
spec.locale,
spec.ranges.len(),
spec.ranges.iter().any(|range| range.mid.is_some()),
(&show(lowest.low), &lowest.label),
(&show(highest.high), &highest.label),
);
let modeled: Vec<&str> = spec
.ranges
.iter()
.filter(|range| range.modeled)
.map(|range| range.label.as_str())
.collect();
let groups: Vec<String> = spec
.range_groups()
.into_iter()
.map(|group| {
let labels: Vec<&str> = spec
.ranges
.iter()
.filter(|range| range.group.as_deref() == Some(group))
.map(|range| range.label.as_str())
.collect();
format!("{group}: {}", labels.join(", "))
})
.collect();
if !groups.is_empty() {
description.push_str(&text::range_groups(spec.locale, &groups.join("; ")));
}
if !modeled.is_empty() {
description.push_str(&text::modeled_ranges(spec.locale, &modeled.join(", ")));
}
let summaries: Vec<String> = spec
.ranges
.iter()
.filter(|range| range.summary)
.map(|range| format!("{} ({})", range.label, span_label(spec, range)))
.collect();
if !summaries.is_empty() {
description.push_str(&text::summary_ranges(spec.locale, &summaries.join(", ")));
}
if spec.ranges.iter().any(|range| range.weight.is_some()) {
description.push_str(&text::weighted_ranges(spec.locale));
}
if spec.display == RangeDisplay::Dumbbell {
let widest = spec
.range_order()
.into_iter()
.map(|index| &spec.ranges[index])
.fold(None::<&RangeSpec>, |best, range| match best {
Some(known) if known.high - known.low >= range.high - range.low => Some(known),
_ => Some(range),
})
.expect("validated rangebar charts have a range");
description.push_str(&text::dumbbell_note(
spec.locale,
(&show(widest.high - widest.low), &widest.label),
));
}
if !spec.references.is_empty() {
let words = spec.locale.words();
let references: Vec<String> = spec
.references
.iter()
.map(|reference| format!("{} {} {}", reference.label, words.at, show(reference.value)))
.collect();
write!(
description,
" {}: {}.",
words.reference_lines,
references.join("; ")
)
.expect("writing to String cannot fail");
}
description
}