use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use uzor::types::Rect;
use crate::graph::NodeIndex;
pub const LABEL_GRID_CELL_SIZE_PX: f64 = 100.0;
pub const DEFAULT_LABEL_DENSITY: f64 = 1.0;
pub const LOD_LABEL_FADE_LOW: f64 = 0.45;
pub const LOD_LABEL_FADE_HIGH: f64 = 0.9;
const DEGREE_ALPHA_SHIFT: f64 = 0.3;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LabelLodConfig {
pub grid_cell_size_px: f64,
pub fade_low: f64,
pub fade_high: f64,
pub degree_alpha_shift: f64,
}
impl Default for LabelLodConfig {
fn default() -> Self {
Self {
grid_cell_size_px: LABEL_GRID_CELL_SIZE_PX,
fade_low: LOD_LABEL_FADE_LOW,
fade_high: LOD_LABEL_FADE_HIGH,
degree_alpha_shift: DEGREE_ALPHA_SHIFT,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct LabelCandidate {
pub node: NodeIndex,
pub screen_pos: (f64, f64),
pub degree: u32,
pub screen_radius: f64,
}
fn cell_index(pos: (f64, f64), viewport: Rect, cols: u32, cell_size_px: f64) -> u32 {
let local_x = (pos.0 - viewport.x).max(0.0);
let local_y = (pos.1 - viewport.y).max(0.0);
let col = (local_x / cell_size_px).floor() as u32;
let row = (local_y / cell_size_px).floor() as u32;
row * cols + col
}
fn quota_per_cell(zoom: f64, density: f64) -> usize {
if density <= 0.0 {
return 0;
}
let zoom = zoom.max(1e-9);
(density * zoom * zoom).ceil().max(0.0) as usize
}
fn cmp_by_importance(a: &LabelCandidate, b: &LabelCandidate) -> Ordering {
b.degree
.cmp(&a.degree)
.then_with(|| b.screen_radius.partial_cmp(&a.screen_radius).unwrap_or(Ordering::Equal))
.then_with(|| a.node.0.cmp(&b.node.0))
}
pub fn select_labels(
candidates: &[LabelCandidate],
viewport: Rect,
zoom: f64,
density: f64,
forced: &HashSet<NodeIndex>,
lod: &LabelLodConfig,
) -> HashSet<NodeIndex> {
if viewport.width <= 0.0 || viewport.height <= 0.0 {
return forced.clone();
}
let cell_size_px = lod.grid_cell_size_px.max(1.0);
let cols = ((viewport.width / cell_size_px).ceil() as u32).max(1);
let mut cells: HashMap<u32, Vec<&LabelCandidate>> = HashMap::new();
for c in candidates {
cells.entry(cell_index(c.screen_pos, viewport, cols, cell_size_px)).or_default().push(c);
}
let quota = quota_per_cell(zoom, density);
let mut shown: HashSet<NodeIndex> = HashSet::new();
for bucket in cells.values_mut() {
bucket.sort_by(|a, b| cmp_by_importance(a, b));
shown.extend(bucket.iter().take(quota).map(|c| c.node));
}
shown.extend(forced.iter().copied());
shown
}
pub fn label_alpha(zoom: f64, normalized_degree: f64, lod: &LabelLodConfig) -> f64 {
let shift = normalized_degree.clamp(0.0, 1.0) * lod.degree_alpha_shift;
let low = (lod.fade_low - shift).max(0.0);
let high = (lod.fade_high - shift).max(low + 1e-6);
((zoom - low) / (high - low)).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn viewport() -> Rect {
Rect::new(0.0, 0.0, 800.0, 600.0)
}
fn candidate(idx: u32, screen_pos: (f64, f64), degree: u32, screen_radius: f64) -> LabelCandidate {
LabelCandidate { node: NodeIndex(idx), screen_pos, degree, screen_radius }
}
#[test]
fn grid_quota_limits_labels_per_cell_below_total_node_count() {
let candidates: Vec<LabelCandidate> = (0..40)
.map(|i| candidate(i, (10.0 + i as f64, 10.0 + i as f64), i % 7, 4.0))
.collect();
let zoom = 0.1; let shown = select_labels(&candidates, viewport(), zoom, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
assert!(shown.len() < candidates.len(), "LOD must cull most of a 40-candidate single-cell cluster: {} shown", shown.len());
assert_eq!(shown.len(), 1);
}
#[test]
fn highest_degree_candidate_wins_a_crowded_cells_single_slot() {
let winner = candidate(3, (50.0, 50.0), 99, 4.0);
let candidates = vec![
candidate(0, (20.0, 20.0), 2, 20.0), candidate(1, (30.0, 30.0), 5, 4.0),
winner,
candidate(2, (40.0, 40.0), 10, 4.0),
];
let shown = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
assert_eq!(shown, HashSet::from([NodeIndex(3)]), "the degree-99 candidate must win the cell's single quota slot");
}
#[test]
fn forced_candidates_bypass_the_quota_entirely() {
let candidates: Vec<LabelCandidate> =
(0..20).map(|i| candidate(i, (10.0 + i as f64, 10.0), 1, 4.0)).collect();
let forced_node = NodeIndex(15); let forced: HashSet<NodeIndex> = HashSet::from([forced_node]);
let shown = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &forced, &LabelLodConfig::default());
assert!(shown.contains(&forced_node), "a forced candidate must show regardless of the grid quota");
}
#[test]
fn identical_candidate_sets_produce_identical_label_sets() {
let candidates: Vec<LabelCandidate> = (0..60)
.map(|i| candidate(i, ((i % 8) as f64 * 90.0 + 5.0, (i / 8) as f64 * 90.0 + 5.0), i % 11, 3.0 + (i % 5) as f64))
.collect();
let forced: HashSet<NodeIndex> = HashSet::from([NodeIndex(2), NodeIndex(41)]);
let first = select_labels(&candidates, viewport(), 0.6, DEFAULT_LABEL_DENSITY, &forced, &LabelLodConfig::default());
for _ in 0..5 {
let again = select_labels(&candidates, viewport(), 0.6, DEFAULT_LABEL_DENSITY, &forced, &LabelLodConfig::default());
assert_eq!(first, again, "identical inputs must yield an identical label set on every repeated call");
}
}
#[test]
fn higher_zoom_yields_a_larger_per_cell_quota_and_therefore_more_labels() {
let candidates: Vec<LabelCandidate> =
(0..30).map(|i| candidate(i, (10.0 + i as f64 * 2.0, 10.0), i, 4.0)).collect();
let low_zoom_shown = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
let high_zoom_shown = select_labels(&candidates, viewport(), 3.0, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
assert!(
high_zoom_shown.len() > low_zoom_shown.len(),
"zooming in must reveal more labels: low-zoom {} vs high-zoom {}",
low_zoom_shown.len(),
high_zoom_shown.len()
);
}
#[test]
fn quota_per_cell_is_ceil_of_density_at_zoom_identity() {
assert_eq!(quota_per_cell(1.0, 1.0), 1);
assert_eq!(quota_per_cell(1.0, 2.5), 3);
assert_eq!(quota_per_cell(1.0, 0.0), 0);
assert_eq!(quota_per_cell(2.0, 1.0), 4); }
#[test]
fn label_alpha_is_boosted_by_degree_appearing_earlier_and_fading_later() {
let leaf_alpha_at_low_edge = label_alpha(LOD_LABEL_FADE_LOW, 0.0, &LabelLodConfig::default());
let hub_alpha_at_low_edge = label_alpha(LOD_LABEL_FADE_LOW, 1.0, &LabelLodConfig::default());
assert_eq!(leaf_alpha_at_low_edge, 0.0, "a degree-0 leaf must be exactly invisible at the unshifted low edge");
assert!(hub_alpha_at_low_edge > 0.0, "a max-degree hub must already be partially visible at the leaf's low edge");
let below_leaf_window = LOD_LABEL_FADE_LOW - 0.2;
assert_eq!(label_alpha(below_leaf_window, 0.0, &LabelLodConfig::default()), 0.0, "a leaf below its own window is still exactly invisible");
assert!(label_alpha(below_leaf_window, 1.0, &LabelLodConfig::default()) > 0.0, "a hub must stay partially visible below a leaf's fade-in zoom");
assert!(label_alpha(LOD_LABEL_FADE_HIGH, 0.5, &LabelLodConfig::default()) >= label_alpha(LOD_LABEL_FADE_LOW, 0.5, &LabelLodConfig::default()));
let mid_zoom = LOD_LABEL_FADE_LOW - 0.1;
assert!(label_alpha(mid_zoom, 1.0, &LabelLodConfig::default()) >= label_alpha(mid_zoom, 0.5, &LabelLodConfig::default()));
assert!(label_alpha(mid_zoom, 0.5, &LabelLodConfig::default()) >= label_alpha(mid_zoom, 0.0, &LabelLodConfig::default()));
}
#[test]
fn label_lod_config_default_matches_the_pre_existing_module_constants() {
let lod = LabelLodConfig::default();
assert_eq!(lod.grid_cell_size_px, LABEL_GRID_CELL_SIZE_PX);
assert_eq!(lod.fade_low, LOD_LABEL_FADE_LOW);
assert_eq!(lod.fade_high, LOD_LABEL_FADE_HIGH);
}
#[test]
fn select_labels_with_the_default_lod_config_matches_the_pre_existing_constants() {
let candidates: Vec<LabelCandidate> = (0..40)
.map(|i| candidate(i, (10.0 + i as f64, 10.0 + i as f64), i % 7, 4.0))
.collect();
let via_default = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
assert_eq!(via_default.len(), 1);
}
#[test]
fn a_smaller_grid_cell_size_never_shows_fewer_labels_than_the_default() {
let candidates: Vec<LabelCandidate> = (0..40)
.map(|i| candidate(i, (10.0 + i as f64 * 4.0, 10.0), i % 7, 4.0))
.collect();
let default_shown = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &HashSet::new(), &LabelLodConfig::default());
let small_cell_lod = LabelLodConfig { grid_cell_size_px: 20.0, ..LabelLodConfig::default() };
let small_cell_shown = select_labels(&candidates, viewport(), 0.1, DEFAULT_LABEL_DENSITY, &HashSet::new(), &small_cell_lod);
assert!(
small_cell_shown.len() >= default_shown.len(),
"smaller grid cells must split the same candidates into more/smaller buckets, never fewer labels: default {} vs small-cell {}",
default_shown.len(),
small_cell_shown.len()
);
assert!(small_cell_shown.len() > default_shown.len(), "this fixture's candidates are spread wide enough that a 20px cell must actually split them into more buckets than the 100px default");
}
#[test]
fn a_caller_supplied_fade_window_actually_changes_label_alpha() {
let default_alpha = label_alpha(LOD_LABEL_FADE_LOW, 0.0, &LabelLodConfig::default());
let shifted = LabelLodConfig { fade_low: LOD_LABEL_FADE_LOW + 0.3, fade_high: LOD_LABEL_FADE_HIGH + 0.3, ..LabelLodConfig::default() };
let shifted_alpha = label_alpha(LOD_LABEL_FADE_LOW, 0.0, &shifted);
assert_eq!(default_alpha, 0.0, "at the leaf's own unshifted low edge, alpha starts at exactly 0");
assert_eq!(shifted_alpha, 0.0, "the SAME zoom is now even further below the shifted-up window, still exactly 0");
let alpha_at_shifted_low_edge = label_alpha(shifted.fade_low, 0.0, &shifted);
let default_alpha_at_same_zoom = label_alpha(shifted.fade_low, 0.0, &LabelLodConfig::default());
assert_eq!(alpha_at_shifted_low_edge, 0.0, "the shifted window's own low edge is still exactly the fade-in threshold");
assert!(default_alpha_at_same_zoom > 0.0, "at the SAME zoom, the unshifted default window is already past its own low edge");
}
}