//! Parts of a node: a thin stripe inside the bottom of a box of a flow chart or a tree, split
//! into the shares of its parts, such as the records kept and excluded at a step of a study
//! selection. One legend below the diagram names the parts; the description and the table give
//! every part with its value and share.
use crate::{
layout::{LABEL_SIZE, count, format_value},
metrics::TextMetrics,
scene::{Element, Rect, Scene, Text, TextAnchor},
spec::{ChartSpec, NumberStyle, PartSpec, ValueFormat},
text,
};
/// How much taller a box with parts is drawn, and the stripe in that room.
pub(crate) const ROOM: f64 = 12.0;
const STRIPE: f64 = 6.0;
/// How far the stripe keeps from the sides and the bottom of its box.
const INSET: f64 = 12.0;
const LIFT: f64 = 6.0;
const LEGEND_ROW: f64 = 22.0;
pub(crate) const STYLE: &str = ".chartlet-root{--chartlet-part-5:#0f766e;--chartlet-part-6:#6d28d9;--chartlet-part-7:#b45309;--chartlet-part-8:#be185d}.chartlet-root.chartlet-theme-dark{--chartlet-part-5:#2dd4bf;--chartlet-part-6:#b69cff;--chartlet-part-7:#fbbf24;--chartlet-part-8:#f472b6}.chartlet-part{stroke:none}.chartlet-part-1{fill:var(--chartlet-color-1)}.chartlet-part-2{fill:var(--chartlet-color-2)}.chartlet-part-3{fill:var(--chartlet-color-3)}.chartlet-part-4{fill:var(--chartlet-color-4)}.chartlet-part-5{fill:var(--chartlet-part-5)}.chartlet-part-6{fill:var(--chartlet-part-6)}.chartlet-part-7{fill:var(--chartlet-part-7)}.chartlet-part-8{fill:var(--chartlet-part-8)}.chartlet-legend{font-size:12px;fill:var(--chartlet-muted)}";
/// The stripe of a component whose category a perspective does not state: an outline without
/// a color of its own.
pub(crate) const UNSTATED_STYLE: &str = ".chartlet-part-none{fill:none;stroke:var(--chartlet-muted);stroke-width:1;stroke-dasharray:2 2}";
pub(crate) const UNSTATED: &str = "chartlet-part chartlet-part-none";
/// The line and arrowheads of a connection in a category of a perspective, in the color of the
/// category's stripe.
pub(crate) const CONNECTION_STYLE: &str = ".chartlet-flow-part-1 .chartlet-flow-edge{stroke:var(--chartlet-color-1)}.chartlet-flow-part-1 .chartlet-flow-head{fill:var(--chartlet-color-1);stroke:var(--chartlet-color-1)}.chartlet-flow-part-2 .chartlet-flow-edge{stroke:var(--chartlet-color-2)}.chartlet-flow-part-2 .chartlet-flow-head{fill:var(--chartlet-color-2);stroke:var(--chartlet-color-2)}.chartlet-flow-part-3 .chartlet-flow-edge{stroke:var(--chartlet-color-3)}.chartlet-flow-part-3 .chartlet-flow-head{fill:var(--chartlet-color-3);stroke:var(--chartlet-color-3)}.chartlet-flow-part-4 .chartlet-flow-edge{stroke:var(--chartlet-color-4)}.chartlet-flow-part-4 .chartlet-flow-head{fill:var(--chartlet-color-4);stroke:var(--chartlet-color-4)}.chartlet-flow-part-5 .chartlet-flow-edge{stroke:var(--chartlet-part-5)}.chartlet-flow-part-5 .chartlet-flow-head{fill:var(--chartlet-part-5);stroke:var(--chartlet-part-5)}.chartlet-flow-part-6 .chartlet-flow-edge{stroke:var(--chartlet-part-6)}.chartlet-flow-part-6 .chartlet-flow-head{fill:var(--chartlet-part-6);stroke:var(--chartlet-part-6)}.chartlet-flow-part-7 .chartlet-flow-edge{stroke:var(--chartlet-part-7)}.chartlet-flow-part-7 .chartlet-flow-head{fill:var(--chartlet-part-7);stroke:var(--chartlet-part-7)}.chartlet-flow-part-8 .chartlet-flow-edge{stroke:var(--chartlet-part-8)}.chartlet-flow-part-8 .chartlet-flow-head{fill:var(--chartlet-part-8);stroke:var(--chartlet-part-8)}";
/// The class the group of a connection gains for the category it is in, by its place among the
/// perspective's categories.
pub(crate) const EDGE_CLASSES: [&str; 8] = [
" chartlet-flow-part-1",
" chartlet-flow-part-2",
" chartlet-flow-part-3",
" chartlet-flow-part-4",
" chartlet-flow-part-5",
" chartlet-flow-part-6",
" chartlet-flow-part-7",
" chartlet-flow-part-8",
];
pub(crate) const CLASSES: [&str; 8] = [
"chartlet-part chartlet-part-1",
"chartlet-part chartlet-part-2",
"chartlet-part chartlet-part-3",
"chartlet-part chartlet-part-4",
"chartlet-part chartlet-part-5",
"chartlet-part chartlet-part-6",
"chartlet-part chartlet-part-7",
"chartlet-part chartlet-part-8",
];
/// The names of the parts of every node, in the order they first appear: each name keeps one
/// color across the diagram.
pub(crate) fn names<'a>(nodes: impl Iterator<Item = &'a [PartSpec]>) -> Vec<&'a str> {
let mut names: Vec<&str> = Vec::new();
for part in nodes.flatten() {
if !names.contains(&part.label.as_str()) {
names.push(&part.label);
}
}
names
}
fn share(style: NumberStyle, value: f64, total: f64) -> String {
format_value(
value / total,
NumberStyle {
format: ValueFormat::Percent,
decimals: Some(0),
..style
},
)
}
/// The parts of one node in words: `kept 180 (60 %), excluded 120 (40 %)`.
pub(crate) fn listed(style: NumberStyle, parts: &[PartSpec]) -> String {
let total: f64 = parts.iter().map(|part| part.value).sum();
parts
.iter()
.map(|part| {
format!(
"{} {} ({})",
part.label,
format_value(part.value, style),
share(style, part.value, total)
)
})
.collect::<Vec<_>>()
.join(", ")
}
/// The stripe of a node's parts inside the bottom of its box `(x, y, width, height)`; `lift`
/// raises it above a curved bottom.
pub(crate) fn push_stripe(
style: NumberStyle,
(parts, names): (&[PartSpec], &[&str]),
(x, y, width, height): (f64, f64, f64, f64),
lift: f64,
elements: &mut Vec<Element>,
) {
let total: f64 = parts.iter().map(|part| part.value).sum();
let room = width - 2.0 * INSET;
let mut at = x + INSET;
for part in parts {
let span = room * part.value / total;
let color = names
.iter()
.position(|name| *name == part.label)
.expect("every part's name is listed");
elements.push(Element::Rect(Rect {
x: at,
y: y + height - LIFT - lift - STRIPE,
width: span,
height: STRIPE,
class: CLASSES[color],
series_index: None,
style_index: None,
tooltip: Some(format!(
"{} {} ({})",
part.label,
format_value(part.value, style),
share(style, part.value, total)
)),
}));
at += span;
}
}
/// A stripe of class `class` across the bottom of a box `(x, y, width, height)`, named by its
/// tooltip; `lift` raises it above a curved bottom.
pub(crate) fn push_band(
class: &'static str,
name: &str,
(x, y, width, height): (f64, f64, f64, f64),
lift: f64,
elements: &mut Vec<Element>,
) {
elements.push(Element::Rect(Rect {
x: x + INSET,
y: y + height - LIFT - lift - STRIPE,
width: width - 2.0 * INSET,
height: STRIPE,
class,
series_index: None,
style_index: None,
tooltip: Some(name.to_owned()),
}));
}
/// The legend of the parts below a diagram, entries in rows that fit; the canvas grows by its
/// height.
pub(crate) fn push_legend(
scene: &mut Scene,
names: &[&str],
margin: f64,
metrics: &impl TextMetrics,
) {
let entries: Vec<(&'static str, &str)> = names
.iter()
.enumerate()
.map(|(index, name)| (CLASSES[index], *name))
.collect();
push_entries(scene, None, &entries, margin, metrics);
}
/// A legend of stripes below a diagram: a `title` in front, then each entry as the class of its
/// stripe and its name, in rows that fit; the canvas grows by its height.
pub(crate) fn push_entries(
scene: &mut Scene,
title: Option<&str>,
entries: &[(&'static str, &str)],
margin: f64,
metrics: &impl TextMetrics,
) {
if entries.is_empty() {
return;
}
let room = f64::from(scene.width) - 2.0 * margin;
let top = f64::from(scene.height) + 4.0;
let (mut x, mut row) = (0.0, 0);
if let Some(title) = title {
scene.elements.push(Element::Text(Text {
x: margin,
y: top + 14.0,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: format!("{title}:"),
}));
x = metrics.width(title, LABEL_SIZE) + 16.0;
}
for &(class, name) in entries {
let width = 22.0 + metrics.width(name, LABEL_SIZE) + 20.0;
if x > 0.0 && x + width > room {
x = 0.0;
row += 1;
}
let y = top + 14.0 + LEGEND_ROW * count(row);
scene.elements.push(Element::Rect(Rect {
x: margin + x,
y: y - 8.0,
width: 16.0,
height: STRIPE + 2.0,
class,
series_index: None,
style_index: None,
tooltip: None,
}));
scene.elements.push(Element::Text(Text {
x: margin + x + 22.0,
y,
class: "chartlet-legend",
anchor: TextAnchor::Start,
content: (*name).to_owned(),
}));
x += width;
}
scene.height = crate::diagram::pixels(top + LEGEND_ROW * count(row + 1) + 6.0);
}
/// The sentence with the parts of every node that has some, nodes as `(label, parts)` in reading
/// order.
pub(crate) fn sentence(spec: &ChartSpec, nodes: &[(&str, &[PartSpec])]) -> String {
let entries: Vec<String> = nodes
.iter()
.map(|(label, parts)| format!("{label}: {}", listed(spec.number_style(), parts)))
.collect();
text::node_parts(spec.locale, &entries)
}