Skip to content

Commit ce4315d

Browse files
committed
Offset disconnected subgraphs to prevent overlap in layout
When a graph has multiple disconnected components or isolated nodes, each subgraph's local coordinates are now shifted along the perpendicular axis so they stack without overlapping.
1 parent 4987d6d commit ce4315d

1 file changed

Lines changed: 58 additions & 0 deletions

File tree

src/layout.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,47 @@ pub fn sugiyama_layout(
132132

133133
// Collect results from all subgraphs, translating indices back to node IDs.
134134
// For horizontal layout, swap x/y so layers run left-to-right.
135+
// Each subgraph is offset along the perpendicular axis so disconnected
136+
// components (and isolated nodes) don't overlap.
137+
let spacing = if config.vertex_spacing > 0.0 {
138+
config.vertex_spacing
139+
} else {
140+
10.0
141+
};
135142
let mut results = Vec::with_capacity(idx_to_id.len());
143+
let mut perpendicular_offset = 0.0_f64;
144+
136145
for (layout, _width, _height) in &subgraphs {
146+
// Compute bounding box of this subgraph in output coordinates
147+
let mut min_perp = f64::INFINITY;
148+
let mut max_perp = f64::NEG_INFINITY;
149+
150+
let start = results.len();
137151
for &(idx, (x, y)) in layout {
138152
if let Some(&node_id) = idx_to_id.get(idx) {
139153
let (px, py) = if horizontal { (y, x) } else { (x, y) };
140154
results.push(NodePosition { id: node_id, x: px, y: py });
155+
156+
// Perpendicular axis is y for both directions
157+
// Look up node height to get the full extent
158+
let node_h = id_to_idx
159+
.get(&node_id)
160+
.and_then(|&i| vertices.get(i as usize))
161+
.map(|&(_, (w, h))| if horizontal { w } else { h })
162+
.unwrap_or(0.0);
163+
min_perp = min_perp.min(py);
164+
max_perp = max_perp.max(py + node_h);
141165
}
142166
}
167+
168+
if start < results.len() {
169+
// Shift this subgraph so its top edge sits at the running offset
170+
let shift = perpendicular_offset - min_perp;
171+
for pos in &mut results[start..] {
172+
pos.y += shift;
173+
}
174+
perpendicular_offset += max_perp - min_perp + spacing;
175+
}
143176
}
144177

145178
results
@@ -363,6 +396,31 @@ mod tests {
363396
assert!(pos[&3].1 < pos[&4].1);
364397
}
365398

399+
#[test]
400+
fn test_isolated_nodes_do_not_overlap() {
401+
// Three isolated nodes (no edges) — should not all land at the same position
402+
let sizes = vec![
403+
(1, (100.0, 50.0)),
404+
(2, (100.0, 50.0)),
405+
(3, (100.0, 50.0)),
406+
];
407+
let result = sugiyama_layout(&[], &sizes, &SugiyamaConfig::default());
408+
assert_eq!(result.len(), 3);
409+
410+
let pos = pos_map(result);
411+
// All three should have distinct y values (stacked along perpendicular axis)
412+
let ys: Vec<f64> = pos.values().map(|p| p.1).collect();
413+
for i in 0..ys.len() {
414+
for j in (i + 1)..ys.len() {
415+
assert!(
416+
(ys[i] - ys[j]).abs() > 1.0,
417+
"isolated nodes should not overlap: y[{}]={}, y[{}]={}",
418+
i, ys[i], j, ys[j]
419+
);
420+
}
421+
}
422+
}
423+
366424
#[test]
367425
fn test_cycle_does_not_panic() {
368426
// rust-sugiyama handles cycles internally; verify we don't break

0 commit comments

Comments
 (0)