//! Emphasis in a diagram: the one or two nodes and the path the reader should look at first.
//! An emphasized node gets the accent outline, heavier than any other, and an accent tint; the
//! edges of the emphasized path get the accent line, heavier than the main path; everything else
//! stays neutral. Weight says it without color. The marks are classes on the groups the diagram
//! draws, put there once the diagram is laid out, so every diagram type shares them; the
//! description and the data table name what is emphasized.
use std::fmt::Write as _;
use crate::{
DataTable,
scene::{Circle, Element, Scene},
spec::{ChartSpec, ChartType, Locale},
};
/// The marks of an emphasized node and edge. The tint is the accent at 14 % opacity over the
/// background, so the text on it keeps the contrast of the text on the background; a node's own
/// fill and its shadow give way to it.
pub(crate) const STYLE: &str = ".chartlet-emph .chartlet-flow-node,.chartlet-emph .chartlet-seq-box{stroke:var(--chartlet-accent);stroke-width:3.5;fill:var(--chartlet-accent);fill-opacity:.14}.chartlet-emph .chartlet-diagram-shadow{fill:none}.chartlet-emph .chartlet-seq-actor,.chartlet-emph .chartlet-seq-actor-head{stroke:var(--chartlet-accent);stroke-width:2.6}.chartlet-emph-ring{fill:none;stroke:var(--chartlet-accent);stroke-width:3}.chartlet-emph-edge .chartlet-flow-edge,.chartlet-emph-edge .chartlet-seq-message{stroke:var(--chartlet-accent);stroke-width:3.2}.chartlet-emph-edge .chartlet-flow-head,.chartlet-emph-edge .chartlet-seq-head{fill:var(--chartlet-accent);stroke:var(--chartlet-accent)}.chartlet-emph-edge .chartlet-flow-hollow{fill:var(--chartlet-background)}.chartlet-emph-edge .chartlet-flow-solid{fill:var(--chartlet-accent)}.chartlet-emph-edge .chartlet-flow-open,.chartlet-emph-edge .chartlet-seq-head-open{stroke:var(--chartlet-accent);stroke-width:2.4}.chartlet-emph-edge .chartlet-diagram-chip-text{font-weight:700}.chartlet-emph-quiet .chartlet-flow-main{stroke:var(--chartlet-zero);stroke-width:1.4}.chartlet-emph-quiet .chartlet-flow-main-head{fill:var(--chartlet-zero);stroke:var(--chartlet-zero)}";
/// How far the ring of an emphasized network node stands from its dot.
const RING_GAP: f64 = 6.5;
/// The index a node group names itself by: its `chartlet-n-<index>` class.
fn own_index(classes: &str) -> Option<usize> {
classes
.split(' ')
.find_map(|class| class.strip_prefix("chartlet-n-")?.parse().ok())
}
/// The nodes an edge group is tied to: its `chartlet-f-<index>` classes.
fn focus_indices(classes: &str) -> Vec<usize> {
classes
.split(' ')
.filter_map(|class| class.strip_prefix("chartlet-f-")?.parse().ok())
.collect()
}
/// Marks the emphasized nodes and the edges of the emphasized path in a laid-out diagram.
pub(crate) fn apply(spec: &ChartSpec, scene: &mut Scene) {
let Some(emphasis) = spec.emphasis.as_ref().filter(|_| spec.has_emphasis()) else {
return;
};
// Where each node of the specification stands among the nodes the diagram drew.
let drawn: Vec<(String, usize)> = scene
.elements
.iter()
.filter_map(|element| match element {
Element::NodeGroup(classes, id) => Some((id.clone(), own_index(classes)?)),
_ => None,
})
.collect();
let index_of = |id: &String| {
drawn
.iter()
.find(|(known, _)| known == id)
.map(|(_, at)| *at)
};
let path: Vec<(usize, usize)> = emphasis
.path
.windows(2)
.filter_map(|pair| {
let (from, to) = (index_of(&pair[0])?, index_of(&pair[1])?);
Some((from.min(to), from.max(to)))
})
.collect();
let has_path = !emphasis.path.is_empty();
let mut rings = Vec::new();
for (position, element) in scene.elements.iter_mut().enumerate() {
match element {
Element::NodeGroup(classes, id) if emphasis.has_node(id) => {
classes.push_str(" chartlet-emph");
rings.push(position);
}
Element::Group(classes) | Element::EdgeGroup(classes, _) => {
if own_index(classes).is_some() {
continue;
}
let tied = focus_indices(classes);
let Some((low, high)) = tied.first().zip(tied.last()) else {
continue;
};
if path.contains(&(*low, *high)) {
classes.push_str(" chartlet-emph-edge");
} else if has_path {
classes.push_str(" chartlet-emph-quiet");
}
}
_ => {}
}
}
if spec.chart_type != ChartType::Network {
return;
}
// A dot is too small for an outline of its own: an emphasized one wears a ring.
for position in rings.into_iter().rev() {
let dot = scene.elements[position..]
.iter()
.position(|element| matches!(element, Element::Circle(_)))
.map(|offset| position + offset);
let Some(dot) = dot else { continue };
let Element::Circle(circle) = &scene.elements[dot] else {
continue;
};
let ring = Element::Circle(Circle {
cx: circle.cx,
cy: circle.cy,
radius: circle.radius + RING_GAP,
class: "chartlet-emph-ring",
series_index: None,
style_index: None,
tooltip: None,
});
scene.elements.insert(dot + 1, ring);
}
}
/// The name of the emphasized nodes and of the path, as the description's sentences, each
/// preceded by a space; empty without emphasis.
pub(crate) fn sentences(spec: &ChartSpec) -> String {
let Some(emphasis) = spec.emphasis.as_ref().filter(|_| spec.has_emphasis()) else {
return String::new();
};
let labels = spec.emphasis_labels();
let label = |id: &String| {
labels
.iter()
.find(|(known, _)| known == id)
.map_or_else(|| id.clone(), |(_, label)| (*label).to_owned())
};
let mut sentences = String::new();
if !emphasis.nodes.is_empty() {
let names: Vec<String> = emphasis.nodes.iter().map(label).collect();
let lead = match spec.locale {
Locale::En => "Emphasized",
Locale::De => "Hervorgehoben",
};
write!(sentences, " {lead}: {}.", names.join(", ")).expect("writing to String cannot fail");
}
if !emphasis.path.is_empty() {
let names: Vec<String> = emphasis.path.iter().map(label).collect();
let lead = match spec.locale {
Locale::En => "Emphasized path",
Locale::De => "Hervorgehobener Weg",
};
write!(sentences, " {lead}: {}.", names.join(" → "))
.expect("writing to String cannot fail");
}
sentences
}
/// The description of a diagram with the sentences of its emphasis and its legend appended.
pub(crate) fn describe(spec: &ChartSpec, mut description: String) -> String {
if !spec.takes_emphasis() {
return description;
}
description.push_str(&sentences(spec));
if let Some(legend) = crate::legend::sentence(spec) {
description.push(' ');
description.push_str(&legend);
}
description
}
/// Adds a column that says which nodes are emphasized and which lie on the emphasized path.
pub(crate) fn extend_table(spec: &ChartSpec, table: &mut DataTable) {
let Some(emphasis) = spec.emphasis.as_ref().filter(|_| spec.has_emphasis()) else {
return;
};
let locale = spec.locale;
let labels = spec.emphasis_labels();
let id_of = |label: &str| {
labels
.iter()
.find(|(_, known)| *known == label)
.map(|(id, _)| (*id).to_owned())
};
let on_path = |id: &str| emphasis.path.iter().any(|step| step == id);
let node_cell = |label: &str| {
let Some(id) = id_of(label) else {
return String::new();
};
cell(locale, emphasis.has_node(&id), on_path(&id))
};
let column = match locale {
Locale::En => "Emphasis",
Locale::De => "Hervorhebung",
};
let rows: Vec<String> = if spec.chart_type == ChartType::Sequence {
// A message row names its sender and receiver; it is emphasized when either is, and on
// the path when both are consecutive steps of it.
table
.rows
.iter()
.map(|row| {
let ends: Vec<String> = [row.get(1), row.get(2)]
.into_iter()
.flatten()
.filter_map(|label| id_of(label))
.collect();
let joined = ends.len() == 2
&& emphasis.path.windows(2).any(|pair| {
(pair[0] == ends[0] && pair[1] == ends[1])
|| (pair[0] == ends[1] && pair[1] == ends[0])
});
cell(locale, false, joined)
})
.collect()
} else {
table
.rows
.iter()
.map(|row| {
row.first()
.map_or_else(String::new, |label| node_cell(label))
})
.collect()
};
table.columns.push(column.to_owned());
for (row, value) in table.rows.iter_mut().zip(rows) {
row.push(value);
}
}
/// The cell of a node or a message: emphasized, on the emphasized path, both or neither.
fn cell(locale: Locale, emphasized: bool, on_path: bool) -> String {
let mut parts = Vec::new();
if emphasized {
parts.push(match locale {
Locale::En => "emphasized",
Locale::De => "hervorgehoben",
});
}
if on_path {
parts.push(match locale {
Locale::En => "on emphasized path",
Locale::De => "auf hervorgehobenem Weg",
});
}
parts.join(", ")
}