use std::collections::{HashMap, HashSet};
use crate::graph::geometry::RoutedEdgeGeometry;
use crate::graph::measure::{ProportionalTextMetrics, wrap_lines};
use crate::graph::routing::label_lanes::{LANE_GAP, LabelTrackOutcome, MIN_LABEL_LANE_STEP};
use crate::graph::space::FPoint;
use crate::graph::{Direction, Graph};
const FIT_THRESHOLD: f64 = 1.0;
const REWRAP_SAFETY: f64 = 0.8;
const MIN_REWRAP_WIDTH: f64 = 48.0;
const MAX_REWRAP_ITERATIONS: usize = 3;
pub(super) fn re_wrap_labels_for_lane_fit(
diagram: &Graph,
edges: &mut [RoutedEdgeGeometry],
lane_outcomes: &mut HashMap<usize, LabelTrackOutcome>,
metrics: &ProportionalTextMetrics,
direction: Direction,
) -> usize {
let mut total_rewraps: usize = 0;
for _iteration in 0..MAX_REWRAP_ITERATIONS {
let overflowing: Vec<usize> = edges
.iter()
.filter_map(|edge| {
let outcome = lane_outcomes.get(&edge.index)?;
if outcome.compartment_size == 1 && outcome.track == 0 {
return None;
}
let rect = edge.label_geometry.as_ref()?.rect;
let axis = axis_projected_dim(rect.width, rect.height, direction);
if axis > outcome.label_step * FIT_THRESHOLD {
Some(edge.index)
} else {
None
}
})
.collect();
if overflowing.is_empty() {
break;
}
let mut touched_compartments: HashSet<usize> = HashSet::new();
for edge_idx in &overflowing {
let outcome = match lane_outcomes.get(edge_idx) {
Some(o) => o,
None => continue,
};
let Some(source_edge) = diagram.edges.get(*edge_idx) else {
continue;
};
let Some(label_text) = source_edge.label.as_deref() else {
continue;
};
if label_text.is_empty() {
continue;
}
let target = (outcome.label_step * REWRAP_SAFETY).max(MIN_REWRAP_WIDTH);
let new_lines = wrap_lines(metrics, label_text, target);
let (new_w, new_h) = metrics.edge_label_dimensions_wrapped(&new_lines);
let current_width = edges
.iter()
.find(|e| e.index == *edge_idx)
.and_then(|e| e.label_geometry.as_ref().map(|g| g.rect.width));
if let Some(cur) = current_width
&& new_w >= cur
{
continue;
}
if let Some(edge) = edges.iter_mut().find(|e| e.index == *edge_idx)
&& let Some(geom) = edge.label_geometry.as_mut()
{
geom.rect.width = new_w;
geom.rect.height = new_h;
geom.rect.x = geom.center.x - new_w / 2.0;
geom.rect.y = geom.center.y - new_h / 2.0;
edge.effective_wrapped_lines = Some(new_lines);
total_rewraps += 1;
touched_compartments.insert(outcome.compartment_id);
}
}
if touched_compartments.is_empty() {
break;
}
let new_steps =
recompute_label_steps(edges, lane_outcomes, &touched_compartments, direction);
apply_new_label_steps(edges, lane_outcomes, &new_steps, direction);
}
total_rewraps
}
fn recompute_label_steps(
edges: &[RoutedEdgeGeometry],
lane_outcomes: &HashMap<usize, LabelTrackOutcome>,
affected: &HashSet<usize>,
direction: Direction,
) -> HashMap<usize, f64> {
let mut max_axis_per_compartment: HashMap<usize, f64> = HashMap::new();
for edge in edges {
let Some(outcome) = lane_outcomes.get(&edge.index) else {
continue;
};
if !affected.contains(&outcome.compartment_id) {
continue;
}
let Some(rect) = edge.label_geometry.as_ref().map(|g| g.rect) else {
continue;
};
let axis_extent = axis_projected_dim(rect.width, rect.height, direction);
max_axis_per_compartment
.entry(outcome.compartment_id)
.and_modify(|cur| *cur = cur.max(axis_extent))
.or_insert(axis_extent);
}
max_axis_per_compartment
.into_iter()
.map(|(id, max_axis)| (id, (max_axis + LANE_GAP).max(MIN_LABEL_LANE_STEP)))
.collect()
}
fn apply_new_label_steps(
edges: &mut [RoutedEdgeGeometry],
lane_outcomes: &mut HashMap<usize, LabelTrackOutcome>,
new_steps: &HashMap<usize, f64>,
direction: Direction,
) {
for edge in edges.iter_mut() {
let Some(outcome) = lane_outcomes.get_mut(&edge.index) else {
continue;
};
let Some(&new_step) = new_steps.get(&outcome.compartment_id) else {
continue;
};
outcome.label_step = new_step;
let new_center = compute_label_center(
outcome.midpoint,
outcome.track,
outcome.track_center,
new_step,
direction,
);
outcome.label_center = new_center;
if let Some(geom) = edge.label_geometry.as_mut() {
geom.center = new_center;
geom.rect.x = new_center.x - geom.rect.width / 2.0;
geom.rect.y = new_center.y - geom.rect.height / 2.0;
outcome.label_rect = geom.rect;
}
if let Some(lp) = edge.label_position.as_mut() {
*lp = new_center;
}
}
}
fn axis_projected_dim(width: f64, height: f64, direction: Direction) -> f64 {
match direction {
Direction::TopDown | Direction::BottomTop => height,
Direction::LeftRight | Direction::RightLeft => width,
}
}
fn compute_label_center(
midpoint: FPoint,
track: i32,
track_center: f64,
label_step: f64,
direction: Direction,
) -> FPoint {
let offset = (track as f64 - track_center) * label_step;
match direction {
Direction::TopDown | Direction::BottomTop => FPoint::new(midpoint.x, midpoint.y + offset),
Direction::LeftRight | Direction::RightLeft => FPoint::new(midpoint.x + offset, midpoint.y),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::geometry::{EdgeLabelGeometry, EdgeLabelSide};
use crate::graph::measure::default_proportional_text_metrics;
use crate::graph::space::FRect;
use crate::graph::{Edge, Node};
fn graph_with_labels(labels: &[(&str, &str, &str)]) -> Graph {
let mut g = Graph::new(Direction::TopDown);
let mut seen: HashSet<String> = HashSet::new();
for (from, to, _label) in labels {
if seen.insert((*from).to_string()) {
g.add_node(Node::new(*from));
}
if seen.insert((*to).to_string()) {
g.add_node(Node::new(*to));
}
}
for (from, to, label) in labels {
g.add_edge(Edge::new(*from, *to).with_label(*label));
}
g
}
fn routed_edge_with_label(
index: usize,
from: &str,
to: &str,
rect: FRect,
) -> RoutedEdgeGeometry {
RoutedEdgeGeometry {
index,
from: from.into(),
to: to.into(),
path: vec![],
label_position: Some(FPoint::new(
rect.x + rect.width / 2.0,
rect.y + rect.height / 2.0,
)),
label_side: Some(EdgeLabelSide::Center),
head_label_position: None,
tail_label_position: None,
is_backward: false,
from_subgraph: None,
to_subgraph: None,
source_port: None,
target_port: None,
preserve_orthogonal_topology: false,
label_geometry: Some(EdgeLabelGeometry {
center: FPoint::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0),
rect,
padding: (4.0, 2.0),
side: EdgeLabelSide::Center,
track: 0,
compartment_size: 1,
}),
effective_wrapped_lines: None,
}
}
fn outcome(
track: i32,
label_step: f64,
compartment_id: usize,
compartment_size: usize,
midpoint: FPoint,
rect: FRect,
) -> LabelTrackOutcome {
outcome_with_track_center(
track,
0.0,
label_step,
compartment_id,
compartment_size,
midpoint,
rect,
)
}
fn outcome_with_track_center(
track: i32,
track_center: f64,
label_step: f64,
compartment_id: usize,
compartment_size: usize,
midpoint: FPoint,
rect: FRect,
) -> LabelTrackOutcome {
LabelTrackOutcome {
label_center: FPoint::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0),
label_rect: rect,
track,
adjusted_path: vec![],
compartment_size,
full_compartment_size: compartment_size,
label_step,
track_center,
compartment_id,
midpoint,
}
}
#[test]
fn rewrap_noop_for_singleton_track_zero_edges() {
let g = graph_with_labels(&[("A", "B", "this is a wide singleton label")]);
let rect = FRect::new(0.0, 0.0, 250.0, 28.0);
let mut edges = vec![routed_edge_with_label(0, "A", "B", rect)];
let mut outcomes = HashMap::new();
outcomes.insert(0, outcome(0, 32.0, 0, 1, FPoint::new(125.0, 14.0), rect));
let metrics = default_proportional_text_metrics();
let n = re_wrap_labels_for_lane_fit(
&g,
&mut edges,
&mut outcomes,
&metrics,
Direction::TopDown,
);
assert_eq!(n, 0);
assert!(edges[0].effective_wrapped_lines.is_none());
assert_eq!(edges[0].label_geometry.as_ref().unwrap().rect.width, 250.0);
}
#[test]
fn rewrap_noop_when_axis_extent_already_fits_budget() {
let g = graph_with_labels(&[("A", "B", "short"), ("B", "A", "short")]);
let rect = FRect::new(0.0, 0.0, 40.0, 28.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "B", "A", rect),
];
let mut outcomes = HashMap::new();
outcomes.insert(0, outcome(0, 50.0, 0, 2, FPoint::new(20.0, 14.0), rect));
outcomes.insert(1, outcome(-1, 50.0, 0, 2, FPoint::new(20.0, -36.0), rect));
let metrics = default_proportional_text_metrics();
let n = re_wrap_labels_for_lane_fit(
&g,
&mut edges,
&mut outcomes,
&metrics,
Direction::TopDown,
);
assert_eq!(n, 0);
assert!(edges[0].effective_wrapped_lines.is_none());
assert!(edges[1].effective_wrapped_lines.is_none());
}
#[test]
fn rewrap_shrinks_overflowing_label_and_records_effective_lines() {
let label = "this is a deliberately long label";
let g = graph_with_labels(&[("A", "B", label), ("B", "A", label)]);
let rect = FRect::new(0.0, 0.0, 200.0, 60.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "B", "A", rect),
];
let mut outcomes = HashMap::new();
outcomes.insert(0, outcome(0, 32.0, 0, 2, FPoint::new(100.0, 30.0), rect));
outcomes.insert(1, outcome(-1, 32.0, 0, 2, FPoint::new(100.0, -2.0), rect));
let metrics = default_proportional_text_metrics();
let n = re_wrap_labels_for_lane_fit(
&g,
&mut edges,
&mut outcomes,
&metrics,
Direction::TopDown,
);
assert!(n >= 1, "expected at least one rewrap, got {n}");
let e0 = &edges[0];
assert!(e0.effective_wrapped_lines.is_some());
let new_w = e0.label_geometry.as_ref().unwrap().rect.width;
assert!(
new_w < 200.0,
"rewrapped rect width must shrink below original 200: got {new_w}"
);
}
#[test]
fn rewrap_preserves_track_and_compartment_identity() {
let label = "this is a deliberately long label";
let g = graph_with_labels(&[("A", "B", label), ("B", "A", label)]);
let rect = FRect::new(0.0, 0.0, 200.0, 60.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "B", "A", rect),
];
let mut outcomes = HashMap::new();
outcomes.insert(0, outcome(0, 32.0, 0, 2, FPoint::new(100.0, 30.0), rect));
outcomes.insert(1, outcome(-1, 32.0, 0, 2, FPoint::new(100.0, -2.0), rect));
let metrics = default_proportional_text_metrics();
re_wrap_labels_for_lane_fit(&g, &mut edges, &mut outcomes, &metrics, Direction::TopDown);
assert_eq!(outcomes[&0].track, 0);
assert_eq!(outcomes[&1].track, -1);
assert_eq!(outcomes[&0].compartment_id, 0);
assert_eq!(outcomes[&1].compartment_id, 0);
}
#[test]
fn rewrap_fixed_point_grows_label_step_for_td_reciprocal_pair() {
let label = "this is a deliberately long reply label";
let g = graph_with_labels(&[("A", "B", label), ("B", "A", label)]);
let rect = FRect::new(0.0, 0.0, 200.0, 60.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "B", "A", rect),
];
let mut outcomes = HashMap::new();
let initial_step = 32.0;
outcomes.insert(
0,
outcome(0, initial_step, 0, 2, FPoint::new(100.0, 30.0), rect),
);
outcomes.insert(
1,
outcome(-1, initial_step, 0, 2, FPoint::new(100.0, -2.0), rect),
);
let metrics = default_proportional_text_metrics();
re_wrap_labels_for_lane_fit(&g, &mut edges, &mut outcomes, &metrics, Direction::TopDown);
assert!(
outcomes[&0].label_step > initial_step,
"label_step must grow to accommodate re-wrap height (was {}, now {})",
initial_step,
outcomes[&0].label_step
);
assert_eq!(outcomes[&0].label_step, outcomes[&1].label_step);
}
#[test]
fn rewrap_preserves_track_center_symmetry_for_asymmetric_tracks() {
let label = "this is a deliberately long label";
let g = graph_with_labels(&[("A", "B", label), ("A", "B", label)]);
let rect = FRect::new(0.0, 0.0, 200.0, 60.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "A", "B", rect),
];
let anchor = FPoint::new(100.0, 30.0);
let initial_step = 32.0;
let track_center = 0.5;
let mut outcomes = HashMap::new();
outcomes.insert(
0,
outcome_with_track_center(0, track_center, initial_step, 0, 2, anchor, rect),
);
outcomes.insert(
1,
outcome_with_track_center(1, track_center, initial_step, 0, 2, anchor, rect),
);
let metrics = default_proportional_text_metrics();
re_wrap_labels_for_lane_fit(&g, &mut edges, &mut outcomes, &metrics, Direction::TopDown);
let step_after = outcomes[&0].label_step;
let c0 = outcomes[&0].label_center;
let c1 = outcomes[&1].label_center;
let expected_c0_y = anchor.y + (0.0 - track_center) * step_after;
let expected_c1_y = anchor.y + (1.0 - track_center) * step_after;
assert!(
(c0.y - expected_c0_y).abs() < 1e-6,
"track-0 center drifted: got {}, expected {}",
c0.y,
expected_c0_y
);
assert!(
(c1.y - expected_c1_y).abs() < 1e-6,
"track-+1 center drifted: got {}, expected {}",
c1.y,
expected_c1_y
);
assert!(
((c1.y - c0.y) - step_after).abs() < 1e-6,
"centers must be exactly one label_step apart after rewrap"
);
assert!(
((c0.y + c1.y) / 2.0 - anchor.y).abs() < 1e-6,
"midpoint of centers must equal the compartment anchor"
);
}
#[test]
fn rewrap_is_idempotent_when_already_wrapped() {
let label = "this is a deliberately long label";
let g = graph_with_labels(&[("A", "B", label), ("B", "A", label)]);
let rect = FRect::new(0.0, 0.0, 200.0, 60.0);
let mut edges = vec![
routed_edge_with_label(0, "A", "B", rect),
routed_edge_with_label(1, "B", "A", rect),
];
let mut outcomes = HashMap::new();
outcomes.insert(0, outcome(0, 32.0, 0, 2, FPoint::new(100.0, 30.0), rect));
outcomes.insert(1, outcome(-1, 32.0, 0, 2, FPoint::new(100.0, -2.0), rect));
let metrics = default_proportional_text_metrics();
let first = re_wrap_labels_for_lane_fit(
&g,
&mut edges,
&mut outcomes,
&metrics,
Direction::TopDown,
);
assert!(first >= 1);
let second = re_wrap_labels_for_lane_fit(
&g,
&mut edges,
&mut outcomes,
&metrics,
Direction::TopDown,
);
assert_eq!(
second, 0,
"running rewrap a second time must be a no-op on already-fitted labels"
);
}
}