use serde_json::json;
use crate::{
Cluster, ClusterId, Diagnostics, FaceError, Record, Strategy,
cluster_tree::{
AxisPlan, build_tree_impl, extend_id,
util::{resolve_numeric, score_range},
},
};
pub(super) fn cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
items: Vec<Record>,
parent: &ClusterId,
diag: &mut D,
) -> Result<Vec<Cluster>, FaceError> {
let Strategy::Bands { count } = &plan.axis.strategy else {
unreachable!("bands clusterer is only called for Strategy::Bands")
};
let count = (*count).max(1) as usize;
let resolved = resolve_numeric(items, &plan.axis.field, diag);
if resolved.is_empty() {
return Ok(Vec::new());
}
let (min, max) = min_max(&resolved);
if (max - min).abs() == 0.0 || count == 1 || resolved.len() == 1 {
let label = format_label(min, max, &[min, max]);
return Ok(vec![build_cluster(
plan,
parent,
label,
min,
max,
resolved.into_iter().map(|(_, r)| r).collect(),
diag,
)?]);
}
let width = (max - min) / count as f64;
let mut bounds: Vec<f64> = Vec::with_capacity(count + 1);
for i in 0..count {
bounds.push(min + width * i as f64);
}
bounds.push(max);
let mut buckets: Vec<Vec<Record>> = (0..count).map(|_| Vec::new()).collect();
for (value, record) in resolved {
let band = band_index(value, &bounds, count);
buckets[band].push(record);
}
let mut out = Vec::with_capacity(count);
for (i, group) in buckets.into_iter().enumerate() {
if group.is_empty() {
continue;
}
let lo = bounds[i];
let hi = bounds[i + 1];
let label = format_label(lo, hi, &bounds);
out.push(build_cluster(plan, parent, label, lo, hi, group, diag)?);
}
out.reverse();
Ok(out)
}
fn min_max(values: &[(f64, Record)]) -> (f64, f64) {
let mut min = values[0].0;
let mut max = values[0].0;
for (v, _) in &values[1..] {
if *v < min {
min = *v;
}
if *v > max {
max = *v;
}
}
(min, max)
}
fn band_index(value: f64, bounds: &[f64], count: usize) -> usize {
debug_assert_eq!(bounds.len(), count + 1);
for i in 0..count - 1 {
if value < bounds[i + 1] {
return i;
}
}
count - 1
}
fn build_cluster<D: Diagnostics + ?Sized>(
plan: &AxisPlan,
parent: &ClusterId,
label: String,
lo: f64,
hi: f64,
group: Vec<Record>,
diag: &mut D,
) -> Result<Cluster, FaceError> {
let id = extend_id(parent, &plan.axis.field, &label);
let total = group.len() as u64;
let (score_min, score_max) = score_range(&group);
let children = if plan.within.is_empty() {
Vec::new()
} else {
let mut acc: Vec<Cluster> = Vec::new();
for child_plan in &plan.within {
let child_clusters = build_tree_impl(child_plan, group.clone(), &id, diag)?;
acc.extend(child_clusters);
}
acc
};
Ok(Cluster {
id,
label,
axis: plan.axis.field.clone(),
value: json!({"min": lo, "max": hi}),
total,
score_min,
score_max,
clusters: children,
})
}
fn format_label(lo: f64, hi: f64, bounds: &[f64]) -> String {
if bounds.iter().all(|b| b.fract() == 0.0) {
format!("{}\u{2013}{}", lo as i64, hi as i64)
} else {
format!("{lo:.2}\u{2013}{hi:.2}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Axis, NullDiagnostics, Strategy, VecDiagnostics};
use serde_json::json;
fn axis(field: &str, count: u8) -> Axis {
Axis {
field: field.into(),
strategy: Strategy::Bands { count },
auto: false,
}
}
#[test]
fn partitions_into_equal_width_bands() {
let plan = AxisPlan::leaf(axis("v", 5));
let items = vec![
json!({"v": 0.0}),
json!({"v": 1.5}),
json!({"v": 3.0}),
json!({"v": 7.0}),
json!({"v": 10.0}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 4);
assert_eq!(clusters[0].total, 1); assert_eq!(clusters[1].total, 1); assert_eq!(clusters[2].total, 1); assert_eq!(clusters[3].total, 2); assert_eq!(clusters[0].label, "8\u{2013}10");
assert_eq!(clusters[3].label, "0\u{2013}2");
}
#[test]
fn single_record_one_cluster() {
let plan = AxisPlan::leaf(axis("v", 5));
let items = vec![json!({"v": 0.42})];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].total, 1);
assert_eq!(clusters[0].value, json!({"min": 0.42, "max": 0.42}));
}
#[test]
fn empty_records_returns_empty() {
let plan = AxisPlan::leaf(axis("v", 5));
let items: Vec<serde_json::Value> = vec![json!({"other": 1})]; let mut diag = VecDiagnostics::default();
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert!(clusters.is_empty());
assert!(
diag.skips.is_empty(),
"missing-path drops must be silent, got {} skips",
diag.skips.len()
);
}
#[test]
fn label_uses_en_dash() {
let plan = AxisPlan::leaf(axis("v", 4));
let items = vec![
json!({"v": 0.0}),
json!({"v": 0.25}),
json!({"v": 0.5}),
json!({"v": 0.75}),
json!({"v": 1.0}),
];
let mut diag = NullDiagnostics;
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(clusters.len(), 4);
assert_eq!(clusters[0].label, "0.75\u{2013}1.00");
assert_eq!(clusters[3].label, "0.00\u{2013}0.25");
}
#[test]
fn non_numeric_value_emits_skip() {
let plan = AxisPlan::leaf(axis("v", 3));
let items = vec![
json!({"v": 0.0}),
json!({"v": "high"}), json!({"v": 1.0}),
];
let mut diag = VecDiagnostics::default();
let clusters = super::cluster(
&plan,
Record::from_items(items, None),
&ClusterId::default(),
&mut diag,
)
.unwrap();
assert_eq!(diag.skips.len(), 1);
assert_eq!(diag.skips[0].record_index, 1);
let total: u64 = clusters.iter().map(|c| c.total).sum();
assert_eq!(total, 2);
}
}