use std::collections::HashMap;
use crate::graph::edge_marker::marker_avoidance_distance;
use crate::graph::geometry::{
EdgeLabelSide, FRect, PositionedNode, RoutedEdgeGeometry, UnfitOverlap,
};
use crate::graph::measure::ProportionalTextMetrics;
use crate::graph::routing::label_gap::Axis;
use crate::graph::{Direction, Graph};
pub(crate) fn clamp_label_geometry_to_node_bounds(
edges: &mut [RoutedEdgeGeometry],
nodes: &HashMap<String, PositionedNode>,
diagram: &Graph,
direction: Direction,
metrics: &ProportionalTextMetrics,
unfit_overlaps: &mut Vec<UnfitOverlap>,
) {
let spacing = metrics.label_padding_y;
for edge in edges.iter_mut() {
if edge.label_geometry.is_none() {
continue;
}
let edge_index = edge.index;
let Some(diagram_edge) = diagram.edges.get(edge_index) else {
continue;
};
let Some(from_node) = nodes.get(edge.from.as_str()) else {
continue;
};
let Some(to_node) = nodes.get(edge.to.as_str()) else {
continue;
};
let from_avoid = marker_avoidance_distance(diagram_edge.arrow_start);
let to_avoid = marker_avoidance_distance(diagram_edge.arrow_end);
let (lo, hi, axis) = edge_parallel_bounds(
from_node.rect,
to_node.rect,
direction,
from_avoid,
to_avoid,
spacing,
);
let geom = edge.label_geometry.as_mut().expect("checked is_some above");
match try_clamp(geom.rect, lo, hi, axis, geom.side) {
ClampResult::Ok(new_rect) => {
if new_rect != geom.rect {
geom.rect = new_rect;
geom.center = rect_center(new_rect);
edge.label_position = Some(rect_center(new_rect));
}
}
ClampResult::Unfit {
gap,
span,
attempted,
} => {
let label = diagram_edge.label.clone().unwrap_or_default();
unfit_overlaps.push(UnfitOverlap {
edge_index,
label,
gap_pixels: gap,
label_span_pixels: span,
attempted_side: attempted,
});
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ClampResult {
Ok(FRect),
Unfit {
gap: f64,
span: f64,
attempted: EdgeLabelSide,
},
}
fn try_clamp(
rect: FRect,
lo: f64,
hi: f64,
axis: Axis,
current_side: EdgeLabelSide,
) -> ClampResult {
let (start, span) = match axis {
Axis::Y => (rect.y, rect.height),
Axis::X => (rect.x, rect.width),
};
let gap = hi - lo;
if span > gap {
return ClampResult::Unfit {
gap,
span,
attempted: current_side,
};
}
let new_start = start.max(lo).min(hi - span);
if (new_start - start).abs() < 1e-9 {
return ClampResult::Ok(rect);
}
ClampResult::Ok(rect_with_axis_start(rect, axis, new_start))
}
fn rect_with_axis_start(rect: FRect, axis: Axis, new_start: f64) -> FRect {
match axis {
Axis::Y => FRect::new(rect.x, new_start, rect.width, rect.height),
Axis::X => FRect::new(new_start, rect.y, rect.width, rect.height),
}
}
fn rect_center(rect: FRect) -> crate::graph::geometry::FPoint {
crate::graph::geometry::FPoint::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0)
}
fn edge_parallel_bounds(
source_rect: FRect,
target_rect: FRect,
direction: Direction,
source_avoid: f64,
target_avoid: f64,
spacing: f64,
) -> (f64, f64, Axis) {
match direction {
Direction::TopDown | Direction::BottomTop => {
let (upper, upper_avoid, lower, lower_avoid) = if source_rect.y <= target_rect.y {
(source_rect, source_avoid, target_rect, target_avoid)
} else {
(target_rect, target_avoid, source_rect, source_avoid)
};
let lo = upper.y + upper.height + upper_avoid + spacing;
let hi = lower.y - lower_avoid - spacing;
(lo, hi, Axis::Y)
}
Direction::LeftRight | Direction::RightLeft => {
let (left, left_avoid, right, right_avoid) = if source_rect.x <= target_rect.x {
(source_rect, source_avoid, target_rect, target_avoid)
} else {
(target_rect, target_avoid, source_rect, source_avoid)
};
let lo = left.x + left.width + left_avoid + spacing;
let hi = right.x - right_avoid - spacing;
(lo, hi, Axis::X)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::geometry::{EdgeLabelGeometry, EdgeLabelSide, FPoint, RoutedGraphGeometry};
use crate::graph::measure::default_proportional_text_metrics;
use crate::graph::{Edge, Graph, Node, Shape};
fn synthetic(
direction: Direction,
source_rect: FRect,
target_rect: FRect,
label_rect: FRect,
is_backward: bool,
) -> (Graph, RoutedGraphGeometry) {
let mut diagram = Graph::new(direction);
diagram.add_node(Node::new("S"));
diagram.add_node(Node::new("T"));
diagram.add_edge(Edge::new("S", "T").with_label("synthetic"));
let mut nodes = HashMap::new();
nodes.insert(
"S".into(),
PositionedNode {
id: "S".into(),
rect: source_rect,
shape: Shape::Rectangle,
label: "S".into(),
parent: None,
},
);
nodes.insert(
"T".into(),
PositionedNode {
id: "T".into(),
rect: target_rect,
shape: Shape::Rectangle,
label: "T".into(),
parent: None,
},
);
let path = match direction {
Direction::TopDown | Direction::BottomTop => vec![
FPoint::new(
source_rect.x + source_rect.width / 2.0,
source_rect.y + source_rect.height,
),
FPoint::new(target_rect.x + target_rect.width / 2.0, target_rect.y),
],
Direction::LeftRight | Direction::RightLeft => vec![
FPoint::new(
source_rect.x + source_rect.width,
source_rect.y + source_rect.height / 2.0,
),
FPoint::new(target_rect.x, target_rect.y + target_rect.height / 2.0),
],
};
let edge = RoutedEdgeGeometry {
index: 0,
from: "S".into(),
to: "T".into(),
path,
label_position: Some(rect_center(label_rect)),
label_side: Some(EdgeLabelSide::Above),
head_label_position: None,
tail_label_position: None,
is_backward,
from_subgraph: None,
to_subgraph: None,
source_port: None,
target_port: None,
preserve_orthogonal_topology: false,
label_geometry: Some(EdgeLabelGeometry {
center: rect_center(label_rect),
rect: label_rect,
padding: (4.0, 2.0),
side: EdgeLabelSide::Above,
track: 0,
compartment_size: 1,
}),
effective_wrapped_lines: None,
};
let routed = RoutedGraphGeometry {
nodes,
edges: vec![edge],
subgraphs: HashMap::new(),
self_edges: vec![],
direction,
bounds: FRect::new(0.0, 0.0, 200.0, 200.0),
unfit_label_overlaps: Vec::new(),
};
(diagram, routed)
}
#[test]
fn clamp_lr_label_too_wide_for_gap_records_unfit() {
let (diagram, mut routed) = synthetic(
Direction::LeftRight,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(70.0, 0.0, 50.0, 30.0),
FRect::new(40.0, 5.0, 40.0, 20.0),
false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::LeftRight,
&metrics,
&mut unfits,
);
assert_eq!(unfits.len(), 1, "expected one unfit entry, got {unfits:?}");
let u = &unfits[0];
assert_eq!(u.edge_index, 0);
assert_eq!(u.label, "synthetic");
assert!(u.gap_pixels < u.label_span_pixels);
assert_eq!(routed.edges[0].label_geometry.unwrap().rect.width, 40.0);
assert_eq!(routed.edges[0].label_geometry.unwrap().rect.x, 40.0);
}
#[test]
fn clamp_lr_label_fits_with_slide_in() {
let (diagram, mut routed) = synthetic(
Direction::LeftRight,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(110.0, 0.0, 50.0, 30.0),
FRect::new(40.0, 5.0, 15.0, 20.0), false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::LeftRight,
&metrics,
&mut unfits,
);
assert!(unfits.is_empty(), "expected no unfit, got {unfits:?}");
let new_rect = routed.edges[0].label_geometry.unwrap().rect;
assert!(
(new_rect.x - 52.0).abs() < 1e-6,
"expected x=52 after slide-in, got {}",
new_rect.x
);
}
#[test]
fn clamp_rl_uses_x_axis() {
let (diagram, mut routed) = synthetic(
Direction::RightLeft,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(110.0, 0.0, 50.0, 30.0),
FRect::new(40.0, 5.0, 15.0, 20.0),
false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::RightLeft,
&metrics,
&mut unfits,
);
assert!(unfits.is_empty(), "expected no unfit, got {unfits:?}");
assert!(routed.edges[0].label_geometry.unwrap().rect.x >= 50.0);
}
#[test]
fn clamp_td_label_already_fits_unchanged() {
let (diagram, mut routed) = synthetic(
Direction::TopDown,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(0.0, 100.0, 50.0, 30.0),
FRect::new(10.0, 60.0, 30.0, 20.0), false,
);
let original_rect = routed.edges[0].label_geometry.unwrap().rect;
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::TopDown,
&metrics,
&mut unfits,
);
assert!(unfits.is_empty());
assert_eq!(routed.edges[0].label_geometry.unwrap().rect, original_rect);
}
#[test]
fn clamp_td_label_above_source_bottom_slides_down() {
let (diagram, mut routed) = synthetic(
Direction::TopDown,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(0.0, 100.0, 50.0, 30.0),
FRect::new(10.0, 20.0, 30.0, 20.0),
false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::TopDown,
&metrics,
&mut unfits,
);
assert!(unfits.is_empty());
let new_rect = routed.edges[0].label_geometry.unwrap().rect;
assert!(
(new_rect.y - 32.0).abs() < 1e-6,
"expected y=32 after slide-in, got {}",
new_rect.y
);
}
#[test]
fn clamp_bt_forward_uses_rect_positions_not_authored_direction() {
let (diagram, mut routed) = synthetic(
Direction::BottomTop,
FRect::new(0.0, 100.0, 50.0, 30.0), FRect::new(0.0, 0.0, 50.0, 30.0), FRect::new(10.0, 50.0, 30.0, 20.0), false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::BottomTop,
&metrics,
&mut unfits,
);
assert!(
unfits.is_empty(),
"BT forward edge with authored source below target must not \
produce a bogus UnfitOverlap; got {unfits:?}"
);
let new_rect = routed.edges[0].label_geometry.unwrap().rect;
assert!(
(new_rect.y - 50.0).abs() < 1e-6,
"BT label that already fits should not be moved; got y={}",
new_rect.y
);
}
#[test]
fn clamp_rl_forward_uses_rect_positions_not_authored_direction() {
let (diagram, mut routed) = synthetic(
Direction::RightLeft,
FRect::new(110.0, 0.0, 50.0, 30.0), FRect::new(0.0, 0.0, 50.0, 30.0), FRect::new(70.0, 5.0, 25.0, 20.0), false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::RightLeft,
&metrics,
&mut unfits,
);
assert!(
unfits.is_empty(),
"RL forward edge with authored source to the right of target \
must not produce a bogus UnfitOverlap; got {unfits:?}"
);
}
#[test]
fn clamp_bt_label_intruding_upper_node_slides_down() {
let (diagram, mut routed) = synthetic(
Direction::BottomTop,
FRect::new(0.0, 100.0, 50.0, 30.0), FRect::new(0.0, 0.0, 50.0, 30.0), FRect::new(10.0, 10.0, 30.0, 20.0), false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::BottomTop,
&metrics,
&mut unfits,
);
assert!(unfits.is_empty(), "got unfits: {unfits:?}");
let new_rect = routed.edges[0].label_geometry.unwrap().rect;
assert!(
(new_rect.y - 40.0).abs() < 1e-6,
"expected y=40 after slide-in, got {}",
new_rect.y
);
}
#[test]
fn unfit_collector_observable_in_all_builds() {
let (diagram, mut routed) = synthetic(
Direction::LeftRight,
FRect::new(0.0, 0.0, 50.0, 30.0),
FRect::new(60.0, 0.0, 50.0, 30.0),
FRect::new(45.0, 5.0, 30.0, 20.0),
false,
);
let metrics = default_proportional_text_metrics();
let mut unfits = Vec::new();
clamp_label_geometry_to_node_bounds(
&mut routed.edges,
&routed.nodes,
&diagram,
Direction::LeftRight,
&metrics,
&mut unfits,
);
assert_eq!(unfits.len(), 1);
}
}