use crate::{
DataTable,
diagram::pixels,
error::ChartWarning,
layout::{LABEL_SIZE, count, fit_text, format_value, push_title, title_extra},
metrics::TextMetrics,
scene::{Element, Rect, Scene, Text, TextAnchor},
spec::{ChartSpec, MAX_SERIES, MosaicSpec, NumberStyle, ValueFormat},
text,
};
const CELLS: [&str; MAX_SERIES] = [
"chartlet-mosaic-cell chartlet-mosaic-1",
"chartlet-mosaic-cell chartlet-mosaic-2",
"chartlet-mosaic-cell chartlet-mosaic-3",
"chartlet-mosaic-cell chartlet-mosaic-4",
];
const SWATCHES: [&str; MAX_SERIES] = [
"chartlet-mosaic-swatch chartlet-mosaic-1",
"chartlet-mosaic-swatch chartlet-mosaic-2",
"chartlet-mosaic-swatch chartlet-mosaic-3",
"chartlet-mosaic-swatch chartlet-mosaic-4",
];
const GAP: f64 = 3.0;
const FOOTER: f64 = 40.0;
const ROW: f64 = 20.0;
fn mosaic(spec: &ChartSpec) -> &MosaicSpec {
spec.mosaic
.as_ref()
.expect("validated mosaic charts carry a mosaic block")
}
fn percent(spec: &ChartSpec, share: f64) -> String {
format_value(
share,
NumberStyle {
format: ValueFormat::Percent,
decimals: Some(1),
..spec.number_style()
},
)
}
fn push_legend(
elements: &mut Vec<Element>,
spec: &ChartSpec,
(left, top): (f64, f64),
(warnings, metrics): (&mut Vec<ChartWarning>, &impl TextMetrics),
) -> f64 {
let right = f64::from(spec.width) - left;
let (mut x, mut y) = (left, top);
for (index, label) in mosaic(spec).segments().into_iter().enumerate() {
let reach = 16.0 + metrics.width(label, LABEL_SIZE);
if x > left && x + reach > right {
x = left;
y += ROW;
}
elements.push(Element::Rect(Rect {
x,
y,
width: 10.0,
height: 10.0,
class: SWATCHES[index],
series_index: None,
style_index: None,
tooltip: None,
}));
elements.push(Element::Text(Text {
x: x + 16.0,
y: y + 9.0,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: fit_text(
label,
(right - x - 16.0).max(0.0),
LABEL_SIZE,
metrics,
warnings,
"/mosaic/columns",
),
}));
x += reach + 16.0;
}
y + ROW + 6.0
}
#[allow(clippy::too_many_lines)]
pub(crate) fn layout(
spec: &ChartSpec,
warnings: &mut Vec<ChartWarning>,
metrics: &impl TextMetrics,
) -> Scene {
let mosaic = mosaic(spec);
let segments = mosaic.segments();
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) - 10.0;
let top = push_legend(&mut elements, spec, (margin, top), (warnings, metrics));
let plot_height = (height - top - FOOTER - 16.0).max(60.0);
let columns = mosaic.columns.len();
let usable = width - 2.0 * margin - GAP * count(columns - 1);
let whole = mosaic.total();
let style = spec.number_style();
let mut x = margin;
for (index, column) in mosaic.columns.iter().enumerate() {
let total = mosaic.column_total(index);
let column_width = usable * total / whole;
let mut y = top;
for label in &segments {
let Some(segment) = column.segments.iter().find(|s| s.label == *label) else {
continue;
};
let color = segments.iter().position(|l| l == label).unwrap_or(0);
let cell_height = plot_height * segment.value / total;
let share = percent(spec, segment.value / total);
elements.push(Element::Rect(Rect {
x,
y,
width: column_width,
height: cell_height,
class: CELLS[color],
series_index: None,
style_index: None,
tooltip: Some(text::mosaic_tooltip(
spec.locale,
(
&column.label,
&segment.label,
&format_value(segment.value, style),
),
(&share, &percent(spec, segment.value / whole)),
)),
}));
if cell_height >= 18.0 && column_width >= metrics.width(&share, LABEL_SIZE) + 8.0 {
elements.push(Element::Text(Text {
x: x + column_width / 2.0,
y: y + cell_height / 2.0 + 4.0,
class: "chartlet-mosaic-value",
anchor: TextAnchor::Middle,
content: share,
}));
}
y += cell_height;
}
let center = x + column_width / 2.0;
let reach = column_width + GAP;
elements.push(Element::Text(Text {
x: center,
y: top + plot_height + 16.0,
class: "chartlet-label",
anchor: TextAnchor::Middle,
content: fit_text(
&column.label,
reach,
LABEL_SIZE,
metrics,
warnings,
&format!("/mosaic/columns/{index}/label"),
),
}));
let summary = format!(
"{} ({})",
format_value(total, style),
percent(spec, total / whole)
);
let short = percent(spec, total / whole);
let fitting = [summary, short]
.into_iter()
.find(|text| metrics.width(text, LABEL_SIZE) <= reach);
if let Some(content) = fitting {
elements.push(Element::Text(Text {
x: center,
y: top + plot_height + 32.0,
class: "chartlet-legend",
anchor: TextAnchor::Middle,
content,
}));
}
x += column_width + GAP;
}
Scene {
width: spec.width,
height: pixels(height),
elements,
}
}
pub(crate) fn description(spec: &ChartSpec) -> String {
let mosaic = mosaic(spec);
let style = spec.number_style();
let whole = mosaic.total();
let columns: Vec<String> = mosaic
.columns
.iter()
.enumerate()
.map(|(index, column)| {
let total = mosaic.column_total(index);
let parts: Vec<String> = column
.segments
.iter()
.map(|segment| {
format!("{} {}", segment.label, percent(spec, segment.value / total))
})
.collect();
format!(
"{} {} ({}): {}",
column.label,
percent(spec, total / whole),
format_value(total, style),
parts.join(", ")
)
})
.collect();
text::mosaic_summary(
spec.locale,
(
mosaic.columns.len(),
mosaic.segments().len(),
&format_value(whole, style),
),
&columns.join("; "),
)
}
pub(crate) fn data_table(spec: &ChartSpec) -> DataTable {
let words = spec.locale.words();
let mosaic = mosaic(spec);
let style = spec.number_style();
let whole = mosaic.total();
let mut rows = Vec::new();
for (index, column) in mosaic.columns.iter().enumerate() {
let total = mosaic.column_total(index);
for segment in &column.segments {
rows.push(vec![
column.label.clone(),
segment.label.clone(),
format_value(segment.value, style),
percent(spec, segment.value / total),
percent(spec, segment.value / whole),
]);
}
rows.push(vec![
column.label.clone(),
words.total.to_owned(),
format_value(total, style),
percent(spec, 1.0),
percent(spec, total / whole),
]);
}
DataTable {
caption: format!("{} {}", words.data_for, spec.title),
columns: vec![
words.column.to_owned(),
words.segment.to_owned(),
words.value.to_owned(),
words.share_of_column.to_owned(),
words.share_of_total.to_owned(),
],
rows,
}
}