use std::collections::{BTreeMap, HashMap, HashSet};
use crate::graph::geometry::GraphGeometry;
use crate::graph::measure::ProportionalTextMetrics;
use crate::graph::routing::labels::arc_length_midpoint;
use crate::graph::space::{FPoint, FRect};
use crate::graph::{Direction, Graph};
pub(crate) const LANE_GAP: f64 = 4.0;
pub(super) const MIN_LABEL_LANE_STEP: f64 = 16.0;
pub(super) const PATH_LANE_RATIO: f64 = 0.5;
#[derive(Debug, Clone)]
pub(super) struct LabelDescriptor {
pub edge_index: usize,
pub scope_parent: Option<String>,
pub axis_min: f64,
pub axis_max: f64,
pub cross_min: f64,
pub cross_max: f64,
pub direction_sign: i32,
pub midpoint: FPoint,
pub label_rect: FRect,
}
#[derive(Debug)]
pub(super) struct LabelCompartment {
pub members: Vec<LabelDescriptor>,
}
pub(super) fn group_label_compartments(descriptors: Vec<LabelDescriptor>) -> Vec<LabelCompartment> {
if descriptors.is_empty() {
return vec![];
}
let mut by_scope: HashMap<Option<String>, Vec<LabelDescriptor>> = HashMap::new();
for desc in descriptors {
by_scope
.entry(desc.scope_parent.clone())
.or_default()
.push(desc);
}
let mut compartments = Vec::new();
for (_scope, mut scope_descriptors) in by_scope {
scope_descriptors.sort_by(|a, b| {
a.cross_min
.partial_cmp(&b.cross_min)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut groups: Vec<Vec<LabelDescriptor>> = Vec::new();
for descriptor in scope_descriptors {
let merged = groups.iter_mut().find(|g| {
let group_max = g
.iter()
.map(|d| d.cross_max)
.fold(f64::NEG_INFINITY, f64::max);
descriptor.cross_min <= group_max + LANE_GAP
});
if let Some(group) = merged {
group.push(descriptor);
} else {
groups.push(vec![descriptor]);
}
}
for members in groups {
compartments.push(LabelCompartment { members });
}
}
compartments
}
pub(super) fn pack_signed_tracks(compartment: &LabelCompartment) -> HashMap<usize, i32> {
let mut sorted = compartment.members.clone();
sorted.sort_by(|a, b| {
a.axis_min
.partial_cmp(&b.axis_min)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.edge_index.cmp(&b.edge_index))
});
let mut last_end: BTreeMap<i32, f64> = BTreeMap::new();
let mut out: HashMap<usize, i32> = HashMap::new();
for desc in &sorted {
let track = find_or_open_track(&last_end, desc);
last_end.insert(track, desc.axis_max);
out.insert(desc.edge_index, track);
}
out
}
fn find_or_open_track(last_end: &BTreeMap<i32, f64>, desc: &LabelDescriptor) -> i32 {
candidate_track_order(desc.direction_sign)
.find(|k| {
last_end
.get(k)
.is_none_or(|&end| end + LANE_GAP <= desc.axis_min)
})
.expect("candidate_track_order is unbounded")
}
fn candidate_track_order(sign: i32) -> impl Iterator<Item = i32> {
std::iter::once(0).chain((1..).flat_map(move |k| [sign * k, -sign * k]))
}
#[derive(Debug, Clone)]
pub(super) struct LabelTrackOutcome {
pub label_center: FPoint,
pub label_rect: FRect,
pub track: i32,
pub adjusted_path: Vec<FPoint>,
pub compartment_size: usize,
pub full_compartment_size: usize,
pub label_step: f64,
pub track_center: f64,
pub compartment_id: usize,
pub midpoint: FPoint,
}
pub(super) fn assign_label_tracks(
diagram: &Graph,
geometry: &GraphGeometry,
paths: &HashMap<usize, Vec<FPoint>>,
backward_flags: &HashMap<usize, bool>,
metrics: &ProportionalTextMetrics,
direction: Direction,
) -> HashMap<usize, LabelTrackOutcome> {
let descriptors = build_label_descriptors(diagram, geometry, paths, backward_flags, metrics);
let compartments = group_label_compartments(descriptors);
let mut outcomes: HashMap<usize, LabelTrackOutcome> = HashMap::new();
#[cfg(test)]
let mut trace_edges = Vec::new();
#[cfg(test)]
let mut trace_compartments = Vec::new();
#[cfg(test)]
let mut trace_subclusters = Vec::new();
let mut next_id: usize = 0;
for compartment in compartments {
#[cfg(test)]
let trace_compartment_id = stable_label_lane_compartment_id(
compartment
.members
.first()
.and_then(|member| member.scope_parent.clone()),
&member_edge_ids(&compartment.members),
);
#[cfg(test)]
trace_compartments.push(label_lane_compartment_snapshot(
trace_compartment_id.clone(),
&compartment.members,
));
let full_compartment_size = compartment.members.len();
for mut subcluster in split_into_axis_conflict_subclusters(compartment.members) {
let compartment_id = next_id;
next_id += 1;
recenter_members_to_shared_anchor(&mut subcluster, direction);
let tracks = pack_signed_tracks_for_members(&subcluster);
let max_axis = subcluster
.iter()
.map(|m| m.axis_max - m.axis_min)
.fold(0.0_f64, f64::max);
let label_step = (max_axis + LANE_GAP).max(MIN_LABEL_LANE_STEP);
let path_step = label_step * PATH_LANE_RATIO;
let compartment_size = subcluster.len();
let track_center = if compartment_size > 1 {
let (min_track, max_track) = tracks
.values()
.fold((i32::MAX, i32::MIN), |(lo, hi), &t| (lo.min(t), hi.max(t)));
(min_track as f64 + max_track as f64) / 2.0
} else {
0.0
};
#[cfg(test)]
let (trace_subcluster_id, trace_sort_positions) = {
let mut sweep_order = subcluster.iter().collect::<Vec<_>>();
sweep_order.sort_by(|a, b| {
a.axis_min
.partial_cmp(&b.axis_min)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.edge_index.cmp(&b.edge_index))
});
let subcluster_id = stable_label_lane_subcluster_id(
&trace_compartment_id,
&member_edge_ids(&subcluster),
);
trace_subclusters.push(label_lane_subcluster_snapshot(
subcluster_id.clone(),
trace_compartment_id.clone(),
&sweep_order,
));
let sort_positions = sweep_order
.iter()
.enumerate()
.map(|(sort_position, desc)| (desc.edge_index, sort_position))
.collect::<HashMap<_, _>>();
(subcluster_id, sort_positions)
};
for desc in &subcluster {
let track = tracks[&desc.edge_index];
let centered_track = track as f64 - track_center;
let label_offset = centered_track * label_step;
let path_offset = centered_track * path_step;
let (new_center, new_rect) = shift_label(desc, label_offset, direction);
#[cfg(test)]
trace_edges.push(crate::graph::routing::trace::LabelLaneEdgeSnapshot {
edge_index: desc.edge_index,
mmds_edge_id: mmds_edge_id(desc.edge_index),
compartment_id: trace_compartment_id.clone(),
subcluster_id: trace_subcluster_id.clone(),
sort_position: trace_sort_positions[&desc.edge_index],
track,
track_center,
label_step,
label_rect: new_rect,
});
let new_path = paths
.get(&desc.edge_index)
.map(|p| shift_middle_segment(p, path_offset, direction))
.unwrap_or_default();
outcomes.insert(
desc.edge_index,
LabelTrackOutcome {
label_center: new_center,
label_rect: new_rect,
track,
adjusted_path: new_path,
compartment_size,
full_compartment_size,
label_step,
track_center,
compartment_id,
midpoint: desc.midpoint,
},
);
}
}
}
#[cfg(test)]
crate::graph::routing::trace::capture_label_lanes(
crate::graph::routing::trace::LabelLaneTraceSnapshot {
edges: trace_edges,
compartments: trace_compartments,
subclusters: trace_subclusters,
},
);
outcomes
}
fn split_into_axis_conflict_subclusters(
mut members: Vec<LabelDescriptor>,
) -> Vec<Vec<LabelDescriptor>> {
members.sort_by(|a, b| {
a.axis_min
.partial_cmp(&b.axis_min)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut clusters: Vec<Vec<LabelDescriptor>> = Vec::new();
let mut current: Vec<LabelDescriptor> = Vec::new();
let mut current_max: f64 = f64::NEG_INFINITY;
for desc in members {
if current.is_empty() || desc.axis_min <= current_max + LANE_GAP {
current_max = current_max.max(desc.axis_max);
current.push(desc);
} else {
clusters.push(std::mem::take(&mut current));
current_max = desc.axis_max;
current.push(desc);
}
}
if !current.is_empty() {
clusters.push(current);
}
clusters
}
fn pack_signed_tracks_for_members(members: &[LabelDescriptor]) -> HashMap<usize, i32> {
pack_signed_tracks(&LabelCompartment {
members: members.to_vec(),
})
}
fn recenter_members_to_shared_anchor(members: &mut [LabelDescriptor], direction: Direction) {
if members.len() < 2 {
return;
}
let n = members.len() as f64;
let anchor = members
.iter()
.map(|m| match direction {
Direction::TopDown | Direction::BottomTop => m.midpoint.y,
Direction::LeftRight | Direction::RightLeft => m.midpoint.x,
})
.sum::<f64>()
/ n;
for member in members.iter_mut() {
let delta = anchor
- match direction {
Direction::TopDown | Direction::BottomTop => member.midpoint.y,
Direction::LeftRight | Direction::RightLeft => member.midpoint.x,
};
if delta == 0.0 {
continue;
}
match direction {
Direction::TopDown | Direction::BottomTop => {
member.midpoint = FPoint::new(member.midpoint.x, anchor);
member.label_rect = FRect::new(
member.label_rect.x,
member.label_rect.y + delta,
member.label_rect.width,
member.label_rect.height,
);
member.axis_min += delta;
member.axis_max += delta;
}
Direction::LeftRight | Direction::RightLeft => {
member.midpoint = FPoint::new(anchor, member.midpoint.y);
member.label_rect = FRect::new(
member.label_rect.x + delta,
member.label_rect.y,
member.label_rect.width,
member.label_rect.height,
);
member.axis_min += delta;
member.axis_max += delta;
}
}
}
}
pub(super) fn build_label_descriptors(
diagram: &Graph,
geometry: &GraphGeometry,
paths: &HashMap<usize, Vec<FPoint>>,
backward_flags: &HashMap<usize, bool>,
metrics: &ProportionalTextMetrics,
) -> Vec<LabelDescriptor> {
let direction = diagram.direction;
let mut out = Vec::new();
for (edge_index, edge) in diagram.edges.iter().enumerate() {
let Some(label_text) = edge.label.as_deref() else {
continue;
};
if label_text.is_empty() {
continue;
}
let Some(path) = paths.get(&edge_index) else {
continue;
};
if path.len() < 2 {
continue;
}
let midpoint = arc_length_midpoint(path).unwrap_or_else(|| path[path.len() / 2]);
let (w, h) = match edge.wrapped_label_lines.as_deref() {
Some(lines) => metrics.edge_label_dimensions_wrapped(lines),
None => metrics.edge_label_dimensions(label_text),
};
let direction_sign = if *backward_flags.get(&edge_index).unwrap_or(&false) {
-1
} else {
1
};
let scope_parent = compute_shared_parent(diagram, geometry, &edge.from, &edge.to);
let (axis_dim, cross_dim, axis_center, cross_center) = match direction {
Direction::TopDown | Direction::BottomTop => (h, w, midpoint.y, midpoint.x),
Direction::LeftRight | Direction::RightLeft => (w, h, midpoint.x, midpoint.y),
};
let label_rect = FRect::new(midpoint.x - w / 2.0, midpoint.y - h / 2.0, w, h);
out.push(LabelDescriptor {
edge_index,
scope_parent,
axis_min: axis_center - axis_dim / 2.0,
axis_max: axis_center + axis_dim / 2.0,
cross_min: cross_center - cross_dim / 2.0,
cross_max: cross_center + cross_dim / 2.0,
direction_sign,
midpoint,
label_rect,
});
}
out
}
fn compute_shared_parent(
diagram: &Graph,
geometry: &GraphGeometry,
from: &str,
to: &str,
) -> Option<String> {
let from_chain = subgraph_ancestor_chain(diagram, geometry, from);
if from_chain.is_empty() {
return None;
}
let from_set: HashSet<&str> = from_chain.iter().map(String::as_str).collect();
subgraph_ancestor_chain(diagram, geometry, to)
.into_iter()
.find(|sg| from_set.contains(sg.as_str()))
}
fn subgraph_ancestor_chain(
diagram: &Graph,
geometry: &GraphGeometry,
node_id: &str,
) -> Vec<String> {
let mut chain = Vec::new();
let mut current = parent_of(diagram, geometry, node_id);
while let Some(sg_id) = current {
let next = diagram
.subgraphs
.get(&sg_id)
.and_then(|sg| sg.parent.clone());
chain.push(sg_id);
current = next;
}
chain
}
fn parent_of(diagram: &Graph, geometry: &GraphGeometry, node_id: &str) -> Option<String> {
if let Some(parent) = geometry
.nodes
.get(node_id)
.and_then(|n| n.parent.as_deref())
{
return Some(parent.to_string());
}
diagram
.subgraphs
.values()
.find(|sg| sg.nodes.iter().any(|n| n == node_id))
.map(|sg| sg.id.clone())
}
fn shift_label(desc: &LabelDescriptor, offset: f64, direction: Direction) -> (FPoint, FRect) {
let new_center = match direction {
Direction::TopDown | Direction::BottomTop => {
FPoint::new(desc.midpoint.x, desc.midpoint.y + offset)
}
Direction::LeftRight | Direction::RightLeft => {
FPoint::new(desc.midpoint.x + offset, desc.midpoint.y)
}
};
let new_rect = FRect::new(
new_center.x - desc.label_rect.width / 2.0,
new_center.y - desc.label_rect.height / 2.0,
desc.label_rect.width,
desc.label_rect.height,
);
(new_center, new_rect)
}
fn shift_middle_segment(path: &[FPoint], offset: f64, direction: Direction) -> Vec<FPoint> {
if path.len() < 2 || offset == 0.0 {
return path.to_vec();
}
if path.len() == 2 {
return path.to_vec();
}
if path.len() == 3 && are_collinear(path) {
let start = path[0];
let end = path[2];
let lerp = |t: f64, a: FPoint, b: FPoint| {
FPoint::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
};
let mut p1 = lerp(0.25, start, end);
let mut p2 = lerp(0.75, start, end);
offset_on_cross_axis(&mut p1, offset, direction);
offset_on_cross_axis(&mut p2, offset, direction);
return vec![start, p1, p2, end];
}
let mut new_path = path.to_vec();
let last = new_path.len() - 1;
for point in new_path.iter_mut().take(last).skip(1) {
offset_on_cross_axis(point, offset, direction);
}
new_path
}
fn offset_on_cross_axis(p: &mut FPoint, offset: f64, direction: Direction) {
match direction {
Direction::TopDown | Direction::BottomTop => p.x += offset,
Direction::LeftRight | Direction::RightLeft => p.y += offset,
}
}
fn are_collinear(path: &[FPoint]) -> bool {
if path.len() < 3 {
return true;
}
let (a, b, c) = (path[0], path[1], path[2]);
((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() < 1e-6
}
#[cfg(test)]
fn mmds_edge_id(edge_index: usize) -> String {
format!("e{edge_index}")
}
#[cfg(test)]
fn member_edge_ids(members: &[LabelDescriptor]) -> Vec<String> {
let mut edge_ids = members
.iter()
.map(|member| mmds_edge_id(member.edge_index))
.collect::<Vec<_>>();
edge_ids.sort();
edge_ids
}
#[cfg(test)]
fn member_edge_indices(members: &[LabelDescriptor]) -> Vec<usize> {
let mut edge_indices = members
.iter()
.map(|member| member.edge_index)
.collect::<Vec<_>>();
edge_indices.sort_unstable();
edge_indices
}
#[cfg(test)]
fn stable_label_lane_compartment_id(
scope_parent: Option<String>,
member_edge_ids: &[String],
) -> String {
let scope = scope_parent.unwrap_or_else(|| "none".to_string());
format!(
"scope:{scope}|members:{}",
sorted_member_edge_ids(member_edge_ids).join(",")
)
}
#[cfg(test)]
fn stable_label_lane_subcluster_id(compartment_id: &str, member_edge_ids: &[String]) -> String {
format!(
"{compartment_id}|cluster:{}",
sorted_member_edge_ids(member_edge_ids).join(",")
)
}
#[cfg(test)]
fn sorted_member_edge_ids(member_edge_ids: &[String]) -> Vec<String> {
let mut member_edge_ids = member_edge_ids.to_vec();
member_edge_ids.sort();
member_edge_ids
}
#[cfg(test)]
fn label_lane_compartment_snapshot(
id: String,
members: &[LabelDescriptor],
) -> crate::graph::routing::trace::LabelLaneCompartmentSnapshot {
crate::graph::routing::trace::LabelLaneCompartmentSnapshot {
id,
member_edge_indices: member_edge_indices(members),
member_edge_ids: member_edge_ids(members),
scope_parent: members
.first()
.and_then(|member| member.scope_parent.clone()),
cross_min: members
.iter()
.map(|member| member.cross_min)
.fold(f64::INFINITY, f64::min),
cross_max: members
.iter()
.map(|member| member.cross_max)
.fold(f64::NEG_INFINITY, f64::max),
}
}
#[cfg(test)]
fn label_lane_subcluster_snapshot(
id: String,
compartment_id: String,
sweep_order: &[&LabelDescriptor],
) -> crate::graph::routing::trace::LabelLaneSubclusterSnapshot {
crate::graph::routing::trace::LabelLaneSubclusterSnapshot {
id,
compartment_id,
member_edge_indices: {
let mut indices = sweep_order
.iter()
.map(|member| member.edge_index)
.collect::<Vec<_>>();
indices.sort_unstable();
indices
},
member_edge_ids: {
let mut edge_ids = sweep_order
.iter()
.map(|member| mmds_edge_id(member.edge_index))
.collect::<Vec<_>>();
edge_ids.sort();
edge_ids
},
sweep_order: sweep_order
.iter()
.map(|member| mmds_edge_id(member.edge_index))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_descriptor(
edge_index: usize,
scope_parent: Option<&str>,
axis: (f64, f64),
cross: (f64, f64),
sign: i32,
) -> LabelDescriptor {
LabelDescriptor {
edge_index,
scope_parent: scope_parent.map(|s| s.to_string()),
axis_min: axis.0,
axis_max: axis.1,
cross_min: cross.0,
cross_max: cross.1,
direction_sign: sign,
midpoint: FPoint::new((axis.0 + axis.1) / 2.0, (cross.0 + cross.1) / 2.0),
label_rect: FRect::new(axis.0, cross.0, axis.1 - axis.0, cross.1 - cross.0),
}
}
#[test]
fn label_descriptor_constructs_with_all_fields() {
let d = make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1);
assert_eq!(d.axis_max - d.axis_min, 40.0);
assert_eq!(d.cross_max - d.cross_min, 20.0);
assert_eq!(d.direction_sign, 1);
}
#[test]
fn stable_compartment_id_ignores_cross_band_extent_changes() {
let before = stable_label_lane_compartment_id(None, &["e1".to_string(), "e4".to_string()]);
let after = stable_label_lane_compartment_id(None, &["e1".to_string(), "e4".to_string()]);
assert_eq!(before, after);
}
#[test]
fn stable_compartment_id_sorts_member_edge_ids() {
let before = stable_label_lane_compartment_id(None, &["e4".to_string(), "e1".to_string()]);
let after = stable_label_lane_compartment_id(None, &["e1".to_string(), "e4".to_string()]);
assert_eq!(before, after);
}
#[test]
fn stable_subcluster_id_includes_parent_compartment_and_members() {
let compartment_id =
stable_label_lane_compartment_id(Some("outer".to_string()), &["e2".to_string()]);
let subcluster_id = stable_label_lane_subcluster_id(&compartment_id, &["e2".to_string()]);
assert!(subcluster_id.contains(&compartment_id));
assert!(subcluster_id.contains("e2"));
}
#[test]
fn label_lane_scope_snapshot_uses_lca_scope() {
let mut diagram = Graph::new(Direction::TopDown);
let mut source = crate::graph::Node::new("A");
source.parent = Some("LEFT".to_string());
let mut target = crate::graph::Node::new("B");
target.parent = Some("RIGHT".to_string());
diagram.add_node(source);
diagram.add_node(target);
diagram.subgraphs.insert(
"OUTER".to_string(),
crate::graph::Subgraph {
id: "OUTER".to_string(),
title: "OUTER".to_string(),
nodes: Vec::new(),
parent: None,
dir: None,
invisible: false,
concurrent_regions: Vec::new(),
},
);
diagram.subgraphs.insert(
"LEFT".to_string(),
crate::graph::Subgraph {
id: "LEFT".to_string(),
title: "LEFT".to_string(),
nodes: vec!["A".to_string()],
parent: Some("OUTER".to_string()),
dir: None,
invisible: false,
concurrent_regions: Vec::new(),
},
);
diagram.subgraphs.insert(
"RIGHT".to_string(),
crate::graph::Subgraph {
id: "RIGHT".to_string(),
title: "RIGHT".to_string(),
nodes: vec!["B".to_string()],
parent: Some("OUTER".to_string()),
dir: None,
invisible: false,
concurrent_regions: Vec::new(),
},
);
diagram.add_edge(crate::graph::Edge::new("A", "B").with_label("shared parent label"));
let geometry = GraphGeometry {
nodes: HashMap::from([
(
"A".to_string(),
crate::graph::geometry::PositionedNode {
id: "A".to_string(),
rect: FRect::new(0.0, 0.0, 40.0, 30.0),
shape: crate::graph::Shape::Rectangle,
label: "A".to_string(),
parent: Some("LEFT".to_string()),
},
),
(
"B".to_string(),
crate::graph::geometry::PositionedNode {
id: "B".to_string(),
rect: FRect::new(100.0, 100.0, 40.0, 30.0),
shape: crate::graph::Shape::Rectangle,
label: "B".to_string(),
parent: Some("RIGHT".to_string()),
},
),
]),
edges: Vec::new(),
subgraphs: HashMap::new(),
self_edges: Vec::new(),
direction: Direction::TopDown,
node_directions: HashMap::new(),
bounds: FRect::new(0.0, 0.0, 100.0, 100.0),
reversed_edges: Vec::new(),
engine_hints: None,
grid_projection: None,
rerouted_edges: HashSet::new(),
enhanced_backward_routing: false,
};
let paths = HashMap::from([(0, vec![FPoint::new(0.0, 0.0), FPoint::new(100.0, 100.0)])]);
let descriptors = build_label_descriptors(
&diagram,
&geometry,
&paths,
&HashMap::new(),
&ProportionalTextMetrics::new(16.0, 15.0, 15.0),
);
let compartment_id = stable_label_lane_compartment_id(
descriptors[0].scope_parent.clone(),
&member_edge_ids(&descriptors),
);
let snapshot = label_lane_compartment_snapshot(compartment_id, &descriptors);
assert_eq!(descriptors[0].scope_parent.as_deref(), Some("OUTER"));
assert_eq!(snapshot.scope_parent.as_deref(), Some("OUTER"));
}
#[test]
fn group_label_compartments_partitions_by_scope_parent() {
let a = make_descriptor(0, Some("A"), (10.0, 50.0), (100.0, 120.0), 1);
let b = make_descriptor(1, Some("B"), (10.0, 50.0), (100.0, 120.0), 1);
let compartments = group_label_compartments(vec![a, b]);
assert_eq!(
compartments.len(),
2,
"different scope_parent -> separate compartments"
);
}
#[test]
fn group_label_compartments_merges_overlapping_cross_bands_within_same_scope() {
let a = make_descriptor(0, Some("A"), (10.0, 50.0), (100.0, 120.0), 1);
let b = make_descriptor(1, Some("A"), (15.0, 55.0), (110.0, 130.0), -1);
let compartments = group_label_compartments(vec![a, b]);
assert_eq!(compartments.len(), 1);
assert_eq!(compartments[0].members.len(), 2);
}
#[test]
fn group_label_compartments_separates_non_overlapping_cross_bands_same_scope() {
let a = make_descriptor(0, Some("A"), (10.0, 50.0), (100.0, 120.0), 1);
let b = make_descriptor(1, Some("A"), (15.0, 55.0), (200.0, 220.0), -1);
let compartments = group_label_compartments(vec![a, b]);
assert_eq!(
compartments.len(),
2,
"non-overlapping cross bands -> separate compartments"
);
}
#[test]
fn group_label_compartments_merges_cross_bands_within_lane_gap() {
let a = make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1);
let b = make_descriptor(1, None, (10.0, 50.0), (123.0, 140.0), -1);
let compartments = group_label_compartments(vec![a, b]);
assert_eq!(compartments.len(), 1, "within LANE_GAP slack -> merge");
}
#[test]
fn group_label_compartments_empty_input() {
let compartments = group_label_compartments(vec![]);
assert!(compartments.is_empty());
}
#[test]
fn pack_signed_tracks_assigns_zero_track_to_singleton_compartment() {
let compartment = LabelCompartment {
members: vec![make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1)],
};
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks[&0], 0);
}
#[test]
fn pack_signed_tracks_assigns_opposite_signs_to_reciprocal_pair() {
let compartment = LabelCompartment {
members: vec![
make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1),
make_descriptor(1, None, (10.0, 50.0), (100.0, 120.0), -1),
],
};
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks[&0], 0);
assert_eq!(tracks[&1], -1);
}
#[test]
fn pack_signed_tracks_packs_three_same_direction_overlapping_axis() {
let compartment = LabelCompartment {
members: vec![
make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1),
make_descriptor(1, None, (20.0, 60.0), (100.0, 120.0), 1),
make_descriptor(2, None, (30.0, 70.0), (100.0, 120.0), 1),
],
};
let tracks = pack_signed_tracks(&compartment);
let mut values: Vec<_> = tracks.values().copied().collect();
values.sort();
assert_eq!(values, vec![-1, 0, 1]);
assert_eq!(tracks[&0], 0); assert_eq!(tracks[&1], 1);
assert_eq!(tracks[&2], -1);
}
#[test]
fn pack_signed_tracks_breaks_ties_by_edge_index() {
let compartment = LabelCompartment {
members: vec![
make_descriptor(1, None, (10.0, 50.0), (100.0, 120.0), 1),
make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1),
],
};
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks[&0], 0);
assert_eq!(tracks[&1], 1);
}
#[test]
fn pack_signed_tracks_non_overlapping_axis_can_reuse_track() {
let compartment = LabelCompartment {
members: vec![
make_descriptor(0, None, (10.0, 30.0), (100.0, 120.0), 1),
make_descriptor(1, None, (40.0, 60.0), (100.0, 120.0), 1),
],
};
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks[&0], 0);
assert_eq!(
tracks[&1], 0,
"non-overlapping axis ranges should both stay on track 0"
);
}
#[test]
fn pack_signed_tracks_handles_ten_same_sign_without_panic() {
let members: Vec<_> = (0..10)
.map(|i| {
make_descriptor(
i,
None,
(i as f64 * 5.0, i as f64 * 5.0 + 40.0),
(100.0, 120.0),
1,
)
})
.collect();
let compartment = LabelCompartment { members };
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks.len(), 10);
assert_eq!(tracks[&0], 0);
assert!(tracks.values().filter(|&&t| t != 0).count() >= 1);
}
#[test]
fn pack_signed_tracks_handles_dense_compartment_above_64_members() {
const N: usize = 65;
let members: Vec<_> = (0..N)
.map(|i| make_descriptor(i, None, (10.0, 50.0), (100.0, 120.0), 1))
.collect();
let compartment = LabelCompartment { members };
let tracks = pack_signed_tracks(&compartment);
assert_eq!(tracks.len(), N);
assert_eq!(tracks[&0], 0);
let mut sorted: Vec<i32> = tracks.values().copied().collect();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
N,
"expected {N} distinct tracks for fully-overlapping members"
);
}
#[test]
fn group_compartments_isolates_edges_in_different_outer_subgraphs_via_lca() {
let edge_under_outer1 = make_descriptor(0, Some("OUTER1"), (10.0, 50.0), (100.0, 120.0), 1);
let edge_under_outer2 = make_descriptor(1, Some("OUTER2"), (10.0, 50.0), (100.0, 120.0), 1);
let compartments = group_label_compartments(vec![edge_under_outer1, edge_under_outer2]);
assert_eq!(
compartments.len(),
2,
"edges under different outer subgraphs must NOT share a compartment"
);
for c in &compartments {
assert_eq!(c.members.len(), 1, "each compartment must be a singleton");
let tracks = pack_signed_tracks(c);
assert_eq!(
tracks.values().copied().collect::<Vec<_>>(),
vec![0],
"singleton must produce track 0 (no shift)"
);
}
}
#[test]
fn shift_label_topdown_offsets_y_axis() {
let desc = make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1);
let (new_center, new_rect) = shift_label(&desc, 16.0, Direction::TopDown);
assert!((new_center.y - desc.midpoint.y - 16.0).abs() < 1e-6);
assert!((new_center.x - desc.midpoint.x).abs() < 1e-6, "x unchanged");
assert!((new_rect.y - desc.label_rect.y - 16.0).abs() < 1e-6);
}
#[test]
fn shift_label_leftright_offsets_x_axis() {
let desc = make_descriptor(0, None, (10.0, 50.0), (100.0, 120.0), 1);
let (new_center, new_rect) = shift_label(&desc, 16.0, Direction::LeftRight);
assert!((new_center.x - desc.midpoint.x - 16.0).abs() < 1e-6);
assert!((new_center.y - desc.midpoint.y).abs() < 1e-6, "y unchanged");
let _ = new_rect;
}
#[test]
fn shift_middle_segment_two_point_path_unchanged() {
let path = vec![FPoint::new(0.0, 0.0), FPoint::new(10.0, 0.0)];
let new_path = shift_middle_segment(&path, 5.0, Direction::TopDown);
assert_eq!(new_path, path);
}
#[test]
fn shift_middle_segment_three_collinear_path_bends_on_cross_axis() {
let path = vec![
FPoint::new(0.0, 0.0),
FPoint::new(0.0, 5.0),
FPoint::new(0.0, 10.0),
];
let new_path = shift_middle_segment(&path, 5.0, Direction::TopDown);
assert_eq!(new_path.first(), path.first());
assert_eq!(new_path.last(), path.last());
assert!(new_path.len() >= 3);
let has_x_shift = new_path[1..new_path.len() - 1]
.iter()
.any(|p| (p.x - 0.0).abs() > 1e-6);
assert!(
has_x_shift,
"interior should shift on cross axis (x for TD)"
);
}
fn make_descriptor_midpoint(
edge_index: usize,
midpoint: FPoint,
label_w: f64,
label_h: f64,
sign: i32,
direction: Direction,
) -> LabelDescriptor {
let (axis_dim, cross_dim, axis_center, cross_center) = match direction {
Direction::TopDown | Direction::BottomTop => (label_h, label_w, midpoint.y, midpoint.x),
Direction::LeftRight | Direction::RightLeft => {
(label_w, label_h, midpoint.x, midpoint.y)
}
};
LabelDescriptor {
edge_index,
scope_parent: None,
axis_min: axis_center - axis_dim / 2.0,
axis_max: axis_center + axis_dim / 2.0,
cross_min: cross_center - cross_dim / 2.0,
cross_max: cross_center + cross_dim / 2.0,
direction_sign: sign,
midpoint,
label_rect: FRect::new(
midpoint.x - label_w / 2.0,
midpoint.y - label_h / 2.0,
label_w,
label_h,
),
}
}
#[test]
fn recenter_members_to_shared_anchor_is_noop_for_singleton() {
let d = make_descriptor_midpoint(
0,
FPoint::new(50.0, 100.0),
40.0,
20.0,
1,
Direction::TopDown,
);
let mut members = vec![d.clone()];
recenter_members_to_shared_anchor(&mut members, Direction::TopDown);
assert_eq!(members[0].midpoint, d.midpoint);
assert_eq!(members[0].axis_min, d.axis_min);
assert_eq!(members[0].axis_max, d.axis_max);
}
#[test]
fn recenter_members_to_shared_anchor_td_shifts_y_only() {
let a = make_descriptor_midpoint(
0,
FPoint::new(20.0, 100.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let b = make_descriptor_midpoint(
1,
FPoint::new(80.0, 110.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let mut members = vec![a.clone(), b.clone()];
recenter_members_to_shared_anchor(&mut members, Direction::TopDown);
assert_eq!(members[0].midpoint.y, 105.0, "anchor = mean(100, 110)");
assert_eq!(members[1].midpoint.y, 105.0);
assert_eq!(members[0].midpoint.x, 20.0, "x preserved per-edge");
assert_eq!(members[1].midpoint.x, 80.0, "x preserved per-edge");
assert_eq!(members[0].axis_min, a.axis_min + 5.0);
assert_eq!(members[0].axis_max, a.axis_max + 5.0);
assert_eq!(members[1].axis_min, b.axis_min - 5.0);
assert_eq!(members[1].axis_max, b.axis_max - 5.0);
}
#[test]
fn recenter_members_to_shared_anchor_lr_shifts_x_only() {
let a = make_descriptor_midpoint(
0,
FPoint::new(100.0, 20.0),
28.0,
40.0,
1,
Direction::LeftRight,
);
let b = make_descriptor_midpoint(
1,
FPoint::new(110.0, 80.0),
28.0,
40.0,
1,
Direction::LeftRight,
);
let mut members = vec![a, b];
recenter_members_to_shared_anchor(&mut members, Direction::LeftRight);
assert_eq!(members[0].midpoint.x, 105.0);
assert_eq!(members[1].midpoint.x, 105.0);
assert_eq!(members[0].midpoint.y, 20.0);
assert_eq!(members[1].midpoint.y, 80.0);
}
#[test]
fn split_into_axis_conflict_subclusters_isolates_non_conflicting_members() {
let a = make_descriptor_midpoint(
0,
FPoint::new(50.0, 100.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let b = make_descriptor_midpoint(
1,
FPoint::new(60.0, 250.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let clusters = split_into_axis_conflict_subclusters(vec![a, b]);
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].len(), 1);
assert_eq!(clusters[1].len(), 1);
}
#[test]
fn split_into_axis_conflict_subclusters_merges_overlapping_axis_bands() {
let a = make_descriptor_midpoint(
0,
FPoint::new(50.0, 100.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let b = make_descriptor_midpoint(
1,
FPoint::new(60.0, 110.0),
40.0,
28.0,
1,
Direction::TopDown,
);
let clusters = split_into_axis_conflict_subclusters(vec![a, b]);
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].len(), 2);
}
#[test]
fn shift_middle_segment_endpoints_preserved() {
let path = vec![
FPoint::new(0.0, 0.0),
FPoint::new(5.0, 5.0),
FPoint::new(10.0, 0.0),
FPoint::new(15.0, 5.0),
];
let new_path = shift_middle_segment(&path, 8.0, Direction::TopDown);
assert_eq!(new_path.first(), path.first());
assert_eq!(new_path.last(), path.last());
}
}