use crate::core::Result;
use crate::plots::traits::{
AxisScaleSupport, ComputedSeries, ComputedStyle, PlotArea, PlotConfig, PlotData, PlotPrimitive,
PlotRender, draw_primitives,
};
use crate::render::{Color, LineStyle, SkiaRenderer, Theme};
use crate::stats::clustering::Linkage;
#[allow(deprecated)] #[derive(Debug, Clone)]
pub struct DendrogramConfig {
pub orientation: DendrogramOrientation,
pub color: Option<Color>,
pub line_width: f32,
pub show_labels: bool,
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. Dendrograms are compute-only — set the font size on whatever draws DendrogramPlotData::labels"
)]
pub label_size: f32,
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. compute_dendrogram always returns the full tree"
)]
pub truncate_mode: Option<TruncateMode>,
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. compute_dendrogram does not colour clusters; compare DendrogramLink::join_y yourself"
)]
pub color_threshold: Option<f64>,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DendrogramOrientation {
#[default]
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, Copy)]
pub enum TruncateMode {
LastN(usize),
Level(usize),
}
impl Default for DendrogramConfig {
#[allow(deprecated)]
fn default() -> Self {
Self {
orientation: DendrogramOrientation::Top,
color: None,
line_width: 1.0,
show_labels: true,
label_size: 10.0,
truncate_mode: None,
color_threshold: None,
labels: vec![],
}
}
}
impl DendrogramConfig {
pub fn new() -> Self {
Self::default()
}
pub fn orientation(mut self, orient: DendrogramOrientation) -> Self {
self.orientation = orient;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn line_width(mut self, width: f32) -> Self {
self.line_width = width.max(0.1);
self
}
pub fn labels(mut self, labels: Vec<String>) -> Self {
self.labels = labels;
self
}
pub fn show_labels(mut self, show: bool) -> Self {
self.show_labels = show;
self
}
#[deprecated(
since = "0.6.0",
note = "not yet implemented; tracked for a future release. compute_dendrogram does not colour clusters; compare DendrogramLink::join_y yourself"
)]
#[allow(deprecated)]
pub fn color_threshold(mut self, threshold: f64) -> Self {
self.color_threshold = Some(threshold);
self
}
}
#[derive(Debug, Clone)]
pub struct DendrogramLink {
pub left_x: f64,
pub right_x: f64,
pub left_y: f64,
pub right_y: f64,
pub join_y: f64,
pub cluster_idx: usize,
}
#[derive(Debug, Clone)]
pub struct DendrogramPlotData {
pub links: Vec<DendrogramLink>,
pub leaf_positions: Vec<f64>,
pub leaf_order: Vec<usize>,
pub max_height: f64,
pub labels: Vec<(f64, String)>,
pub config: DendrogramConfig,
}
pub fn compute_dendrogram(linkage: &Linkage, config: &DendrogramConfig) -> DendrogramPlotData {
if linkage.matrix.is_empty() {
return DendrogramPlotData {
links: vec![],
leaf_positions: vec![],
leaf_order: linkage.leaves.clone(),
max_height: 1.0,
labels: vec![],
config: config.clone(),
};
}
let n_leaves = linkage.leaves.len();
let leaf_order = linkage.leaves.clone();
let mut leaf_pos = vec![0.0; n_leaves];
for (i, &leaf) in leaf_order.iter().enumerate() {
if leaf < n_leaves {
leaf_pos[leaf] = i as f64;
}
}
let mut cluster_pos: Vec<f64> = leaf_pos.clone();
let mut links = Vec::new();
let mut max_height = 0.0_f64;
for (i, row) in linkage.matrix.iter().enumerate() {
let left = row[0] as usize;
let right = row[1] as usize;
let dist = row[2];
let left_pos = cluster_pos.get(left).copied().unwrap_or(0.0);
let right_pos = cluster_pos.get(right).copied().unwrap_or(0.0);
let left_height = if left < n_leaves {
0.0
} else {
linkage
.matrix
.get(left - n_leaves)
.map(|r| r[2])
.unwrap_or(0.0)
};
let right_height = if right < n_leaves {
0.0
} else {
linkage
.matrix
.get(right - n_leaves)
.map(|r| r[2])
.unwrap_or(0.0)
};
links.push(DendrogramLink {
left_x: left_pos,
right_x: right_pos,
left_y: left_height,
right_y: right_height,
join_y: dist,
cluster_idx: n_leaves + i,
});
max_height = max_height.max(dist);
cluster_pos.push((left_pos + right_pos) / 2.0);
}
let labels: Vec<(f64, String)> = if config.show_labels {
leaf_order
.iter()
.enumerate()
.map(|(i, &leaf)| {
let label = config
.labels
.get(leaf)
.cloned()
.unwrap_or_else(|| format!("{}", leaf));
(i as f64, label)
})
.collect()
} else {
vec![]
};
DendrogramPlotData {
links,
leaf_positions: (0..n_leaves).map(|i| i as f64).collect(),
leaf_order,
max_height: if max_height > 0.0 { max_height } else { 1.0 },
labels,
config: config.clone(),
}
}
impl PlotConfig for DendrogramConfig {}
impl DendrogramPlotData {
pub fn segments(&self) -> Vec<((f64, f64), (f64, f64))> {
self.links
.iter()
.flat_map(|link| dendrogram_lines(link, self.config.orientation))
.collect()
}
}
impl ComputedSeries for DendrogramPlotData {
fn kind(&self) -> &'static str {
"dendrogram"
}
fn point_count(&self) -> usize {
self.links.len()
}
fn category_slots(&self) -> Vec<(String, f64)> {
match self.config.orientation {
DendrogramOrientation::Top | DendrogramOrientation::Bottom => self
.labels
.iter()
.map(|(position, label)| (label.clone(), *position))
.collect(),
DendrogramOrientation::Left | DendrogramOrientation::Right => Vec::new(),
}
}
fn axis_scale_support(&self) -> (AxisScaleSupport, AxisScaleSupport) {
match self.config.orientation {
DendrogramOrientation::Top | DendrogramOrientation::Bottom => {
(AxisScaleSupport::ORDINAL, AxisScaleSupport::Scaled)
}
DendrogramOrientation::Left | DendrogramOrientation::Right => {
(AxisScaleSupport::Scaled, AxisScaleSupport::ORDINAL)
}
}
}
fn primitives(&self, area: &PlotArea, style: &ComputedStyle) -> Vec<PlotPrimitive> {
let base = self.config.color.unwrap_or(style.color);
let color = style.tinted(base);
let width_px = style.stroke_px(self.config.line_width);
self.segments()
.into_iter()
.filter_map(|(start, end)| {
let from = area.try_data_to_screen(start.0, start.1)?;
let to = area.try_data_to_screen(end.0, end.1)?;
Some(PlotPrimitive::Line {
from,
to,
color,
width_px,
style: LineStyle::Solid,
})
})
.collect()
}
}
impl PlotData for DendrogramPlotData {
fn data_bounds(&self) -> ((f64, f64), (f64, f64)) {
if self.leaf_positions.is_empty() {
return ((0.0, 1.0), (0.0, 1.0));
}
let leaves = (-0.5, self.leaf_positions.len() as f64 - 0.5);
let heights = (0.0, self.max_height);
match self.config.orientation {
DendrogramOrientation::Top | DendrogramOrientation::Bottom => (leaves, heights),
DendrogramOrientation::Left | DendrogramOrientation::Right => (heights, leaves),
}
}
fn is_empty(&self) -> bool {
self.links.is_empty()
}
}
impl PlotRender for DendrogramPlotData {
fn render(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
) -> Result<()> {
let style = ComputedStyle::opaque(renderer.render_scale(), color);
draw_primitives(renderer, &self.primitives(area, &style))
}
fn render_styled(
&self,
renderer: &mut SkiaRenderer,
area: &PlotArea,
_theme: &Theme,
color: Color,
alpha: f32,
line_width: Option<f32>,
) -> Result<()> {
let style = ComputedStyle {
scale: renderer.render_scale(),
color,
alpha,
line_width,
};
draw_primitives(renderer, &self.primitives(area, &style))
}
}
pub fn dendrogram_lines(
link: &DendrogramLink,
orientation: DendrogramOrientation,
) -> Vec<((f64, f64), (f64, f64))> {
match orientation {
DendrogramOrientation::Top | DendrogramOrientation::Bottom => {
vec![
((link.left_x, link.left_y), (link.left_x, link.join_y)),
((link.left_x, link.join_y), (link.right_x, link.join_y)),
((link.right_x, link.right_y), (link.right_x, link.join_y)),
]
}
DendrogramOrientation::Left | DendrogramOrientation::Right => {
vec![
((link.left_y, link.left_x), (link.join_y, link.left_x)),
((link.join_y, link.left_x), (link.join_y, link.right_x)),
((link.right_y, link.right_x), (link.join_y, link.right_x)),
]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::clustering::{LinkageMethod, linkage, pdist_euclidean};
#[test]
fn test_dendrogram_basic() {
let points = vec![
vec![0.0, 0.0],
vec![1.0, 0.0],
vec![5.0, 0.0],
vec![6.0, 0.0],
];
let distances = pdist_euclidean(&points);
let linkage_result = linkage(&distances, LinkageMethod::Single);
let config = DendrogramConfig::default();
let data = compute_dendrogram(&linkage_result, &config);
assert_eq!(data.links.len(), 3);
assert_eq!(data.leaf_order.len(), 4);
}
#[test]
fn test_show_labels_gates_the_only_label_output() {
let points = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![5.0, 0.0]];
let distances = pdist_euclidean(&points);
let linkage_result = linkage(&distances, LinkageMethod::Single);
let with = compute_dendrogram(
&linkage_result,
&DendrogramConfig::new().labels(vec!["a".into(), "b".into(), "c".into()]),
);
let without = compute_dendrogram(
&linkage_result,
&DendrogramConfig::new()
.labels(vec!["a".into(), "b".into(), "c".into()])
.show_labels(false),
);
assert_eq!(with.labels.len(), 3);
assert!(without.labels.is_empty());
}
fn four_leaf_tree(config: DendrogramConfig) -> DendrogramPlotData {
let points = vec![
vec![0.0, 0.0],
vec![1.0, 0.0],
vec![5.0, 0.0],
vec![6.0, 0.0],
];
let distances = pdist_euclidean(&points);
compute_dendrogram(&linkage(&distances, LinkageMethod::Single), &config)
}
fn render(data: &DendrogramPlotData, dpi_scale: f32) -> crate::core::plot::Image {
let mut renderer =
SkiaRenderer::new(200, 200, Theme::default()).expect("renderer for a 200x200 canvas");
renderer.set_dpi_scale(dpi_scale);
let ((x_min, x_max), (y_min, y_max)) = data.data_bounds();
let area = PlotArea::new(10.0, 10.0, 180.0, 180.0, x_min, x_max, y_min, y_max);
data.render(
&mut renderer,
&area,
&Theme::default(),
Color::from_rgb(200, 0, 0),
)
.expect("dendrogram render");
renderer.into_image()
}
fn coverage(image: &crate::core::plot::Image) -> u64 {
image
.pixels
.chunks_exact(4)
.map(|p| u64::from(255 - p[1]))
.sum()
}
#[test]
fn test_dendrogram_renders_its_links() {
let drawn = coverage(&render(&four_leaf_tree(DendrogramConfig::new()), 1.0));
assert!(drawn > 0, "the dendrogram renderer left the canvas blank");
}
#[test]
fn test_dendrogram_segments_cover_every_link() {
let data = four_leaf_tree(DendrogramConfig::new());
assert_eq!(data.segments().len(), data.links.len() * 3);
assert_eq!(
data.segments(),
data.links
.iter()
.flat_map(|link| dendrogram_lines(link, DendrogramOrientation::Top))
.collect::<Vec<_>>(),
);
}
#[test]
fn test_dendrogram_bounds_follow_the_orientation() {
let upright = four_leaf_tree(DendrogramConfig::new());
let sideways =
four_leaf_tree(DendrogramConfig::new().orientation(DendrogramOrientation::Left));
let ((x_min, x_max), (y_min, y_max)) = upright.data_bounds();
assert!((x_min - -0.5).abs() < 1e-9 && (x_max - 3.5).abs() < 1e-9);
assert!((y_min - 0.0).abs() < 1e-9 && y_max > 0.0);
assert_eq!(sideways.data_bounds(), ((y_min, y_max), (x_min, x_max)));
}
#[test]
fn test_dendrogram_links_keep_their_physical_width_at_higher_dpi() {
let data = four_leaf_tree(DendrogramConfig::new());
let single = coverage(&render(&data, 1.0));
let double = coverage(&render(&data, 2.0));
assert!(single > 0, "the dendrogram drew nothing at all");
assert!(
double > single + single / 2,
"dendrogram links did not thicken with DPI ({double} vs {single} ink coverage)"
);
}
#[test]
fn test_dendrogram_config_color_is_honoured() {
let themed = render(&four_leaf_tree(DendrogramConfig::new()), 1.0);
let explicit = render(
&four_leaf_tree(DendrogramConfig::new().color(Color::from_rgb(0, 0, 255))),
1.0,
);
assert_ne!(
themed.pixels, explicit.pixels,
"DendrogramConfig::color left the image unchanged"
);
}
#[test]
fn test_dendrogram_config_line_width_is_honoured() {
let thin = coverage(&render(&four_leaf_tree(DendrogramConfig::new()), 1.0));
let thick = coverage(&render(
&four_leaf_tree(DendrogramConfig::new().line_width(4.0)),
1.0,
));
assert!(
thick > thin,
"DendrogramConfig::line_width drew no more ink ({thick} vs {thin})"
);
}
#[test]
fn test_dendrogram_config_implements_plot_config() {
fn assert_plot_config<T: PlotConfig>() {}
assert_plot_config::<DendrogramConfig>();
}
#[test]
fn test_dendrogram_lines() {
let link = DendrogramLink {
left_x: 0.0,
right_x: 1.0,
left_y: 0.0,
right_y: 0.0,
join_y: 1.0,
cluster_idx: 2,
};
let lines = dendrogram_lines(&link, DendrogramOrientation::Top);
assert_eq!(lines.len(), 3);
}
}