use crate::{
DataTable,
diagram::pixels,
error::ChartWarning,
layout::{LABEL_SIZE, count, fit_text, format_value, push_title, title_extra},
metrics::TextMetrics,
scene::{Element, Polyline, Rect, Scene, Text, TextAnchor},
spec::{ChartSpec, FunnelSpec, NumberStyle, ValueFormat},
text,
};
const BAR: &str = "chartlet-funnel-bar";
const BAR_HEIGHT: (f64, f64) = (24.0, 44.0);
const FUNNEL_GAP: f64 = 20.0;
const PYRAMID_GAP: f64 = 3.0;
const MIN_WIDTH: f64 = 4.0;
fn funnel(spec: &ChartSpec) -> &FunnelSpec {
spec.funnel
.as_ref()
.expect("validated funnels carry a funnel block")
}
fn percent(spec: &ChartSpec, share: f64) -> String {
format_value(
share,
NumberStyle {
format: ValueFormat::Percent,
decimals: Some(1),
..spec.number_style()
},
)
}
fn rates(values: &[f64]) -> Vec<f64> {
values.windows(2).map(|pair| pair[1] / pair[0]).collect()
}
fn tooltip(spec: &ChartSpec, index: usize) -> String {
let funnel = funnel(spec);
let label = &funnel.stages[index].label;
let Some(values) = funnel.values() else {
return label.clone();
};
let value = format_value(values[index], spec.number_style());
if funnel.is_pyramid() {
return format!("{label}: {value}");
}
if index == 0 {
format!("{label}: {value}")
} else {
format!(
"{label}: {value} ({} {})",
percent(spec, values[index] / values[index - 1]),
text::of_previous_stage(spec.locale)
)
}
}
#[allow(clippy::too_many_lines)]
pub(crate) fn layout(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let funnel = funnel(spec);
let values = funnel.values();
let stages = funnel.stages.len();
let pyramid = funnel.is_pyramid();
let compact = spec.width < crate::layout::NARROW;
let (width, height) = (f64::from(spec.width), f64::from(spec.height));
let margin = if compact { 12.0 } else { 24.0 };
let mut elements = Vec::new();
push_title(
&mut elements,
spec,
margin,
width - 2.0 * margin,
metrics,
warnings,
);
let top = 56.0 + title_extra(spec, width - 2.0 * margin, metrics);
let inner = width - 2.0 * margin;
let style = spec.number_style();
let texts: Vec<String> = values.as_ref().map_or_else(Vec::new, |values| {
values
.iter()
.map(|value| format_value(*value, style))
.collect()
});
let label_width = funnel
.stages
.iter()
.map(|stage| metrics.width(&stage.label, LABEL_SIZE))
.fold(0.0, f64::max)
.min(inner * 0.32)
+ 10.0;
let value_width = texts
.iter()
.map(|text| metrics.width(text, LABEL_SIZE))
.fold(0.0, f64::max)
+ if texts.is_empty() { 0.0 } else { 10.0 };
let left = margin + label_width;
let right = width - margin - value_width;
let area = (right - left).max(40.0);
let center = left + area / 2.0;
let gap = if pyramid { PYRAMID_GAP } else { FUNNEL_GAP };
let note = !pyramid;
let rows = count(stages);
let bar = ((height - top - 16.0 - gap * (rows - 1.0) - if note { 28.0 } else { 0.0 }) / rows)
.clamp(BAR_HEIGHT.0, BAR_HEIGHT.1)
.floor();
let reach = values.as_ref().map_or(1.0, |values| {
values.iter().copied().fold(f64::MIN, f64::max)
});
let total = rows * bar + (rows - 1.0) * gap;
for (index, stage) in funnel.stages.iter().enumerate() {
let y = top + count(index) * (bar + gap);
let tip = Some(tooltip(spec, index));
if let (false, Some(values)) = (pyramid, &values) {
let bar_width = (area * values[index] / reach).max(MIN_WIDTH);
elements.push(Element::Rect(Rect {
x: center - bar_width / 2.0,
y,
width: bar_width,
height: bar,
class: BAR,
series_index: None,
style_index: None,
tooltip: tip,
}));
} else {
let half = |at: f64| area / 2.0 * (at - top) / total;
let (above, below) = (half(y), half(y + bar));
elements.push(Element::Polyline(Polyline {
points: vec![
(center - above, y),
(center + above, y),
(center + below, y + bar),
(center - below, y + bar),
(center - above, y),
],
class: BAR,
series_index: None,
style_index: None,
tooltip: tip,
}));
}
elements.push(Element::Text(Text {
x: left - 8.0,
y: y + bar / 2.0 + 4.0,
class: "chartlet-label",
anchor: TextAnchor::End,
content: fit_text(
&stage.label,
label_width - 9.0,
LABEL_SIZE,
metrics,
warnings,
&format!("/funnel/stages/{index}/label"),
),
}));
if let Some(text) = texts.get(index) {
elements.push(Element::Text(Text {
x: width - margin,
y: y + bar / 2.0 + 4.0,
class: "chartlet-label",
anchor: TextAnchor::End,
content: text.clone(),
}));
}
}
if let (false, Some(values)) = (pyramid, &values) {
for (index, rate) in rates(values).into_iter().enumerate() {
elements.push(Element::Text(Text {
x: center,
y: top + count(index + 1) * (bar + gap) - gap / 2.0 + 4.0,
class: "chartlet-funnel-rate",
anchor: TextAnchor::Middle,
content: percent(spec, rate),
}));
}
}
let mut bottom = top + total + 16.0;
if note {
elements.push(Element::Text(Text {
x: margin,
y: bottom + 8.0,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: fit_text(
text::funnel_note(spec.locale),
inner,
LABEL_SIZE,
metrics,
warnings,
"/funnel",
),
}));
bottom += 20.0;
}
Scene {
width: spec.width,
height: pixels(height.max(bottom + 8.0)),
elements,
}
}
fn stage_list(spec: &ChartSpec) -> String {
let funnel = funnel(spec);
let style = spec.number_style();
let values = funnel.values();
funnel
.stages
.iter()
.enumerate()
.map(|(index, stage)| match (&values, funnel.is_pyramid()) {
(None, _) => stage.label.clone(),
(Some(values), true) => {
format!("{} {}", stage.label, format_value(values[index], style))
}
(Some(values), false) if index == 0 => {
format!("{} {}", stage.label, format_value(values[index], style))
}
(Some(values), false) => format!(
"{} {} ({})",
stage.label,
format_value(values[index], style),
percent(spec, values[index] / values[index - 1])
),
})
.collect::<Vec<_>>()
.join("; ")
}
pub(crate) fn description(spec: &ChartSpec) -> String {
let funnel = funnel(spec);
let list = stage_list(spec);
let stages = funnel.stages.len();
let Some(values) = funnel.values().filter(|_| !funnel.is_pyramid()) else {
return text::pyramid_summary(spec.locale, stages, &list);
};
let style = spec.number_style();
let end = stages - 1;
let drop = rates(&values)
.into_iter()
.enumerate()
.min_by(|a, b| a.1.total_cmp(&b.1))
.filter(|(_, rate)| *rate < 1.0)
.map(|(index, rate)| {
(
funnel.stages[index].label.as_str(),
funnel.stages[index + 1].label.as_str(),
percent(spec, rate),
)
});
text::funnel_summary(
spec.locale,
stages,
(
(
funnel.stages[0].label.as_str(),
&format_value(values[0], style),
),
(
funnel.stages[end].label.as_str(),
&format_value(values[end], style),
),
),
&percent(spec, values[end] / values[0]),
&list,
drop.as_ref()
.map(|(from, to, rate)| (*from, *to, rate.as_str())),
)
}
pub(crate) fn data_table(spec: &ChartSpec) -> DataTable {
let words = spec.locale.words();
let funnel = funnel(spec);
let style = spec.number_style();
let values = funnel.values();
let columns = if funnel.is_pyramid() {
let mut columns = vec![words.level, words.position];
if values.is_some() {
columns.push(words.value);
}
columns
} else {
vec![words.stage, words.value, words.share, words.rate]
};
let rows = funnel
.stages
.iter()
.enumerate()
.map(|(index, stage)| {
let mut row = vec![stage.label.clone()];
match (&values, funnel.is_pyramid()) {
(Some(values), false) => {
row.push(format_value(values[index], style));
row.push(percent(spec, values[index] / values[0]));
row.push(if index == 0 {
String::new()
} else {
percent(spec, values[index] / values[index - 1])
});
}
(values, _) => {
row.push((index + 1).to_string());
if let Some(values) = values {
row.push(format_value(values[index], style));
}
}
}
row
})
.collect();
DataTable {
caption: format!("{} {}", words.data_for, spec.title),
columns: columns.into_iter().map(str::to_owned).collect(),
rows,
}
}