Skip to main content

abstracttui_graph/layout/
layered.rs

1//! The layered pass (sugiyama-lite): the workflow/DAG path.
2//!
3//! Pipeline: resolve -> cycle break (DFS back edges, marked) ->
4//! longest-path ranks -> dummy chains for multi-rank edges -> bounded
5//! median crossing-reduction sweeps -> aligned-median coordinates ->
6//! waypoints through rank gaps -> direction mapping -> component
7//! packing. Every stage is deterministic and bounded; graphs past the
8//! node cap degrade to the labeled grid placement.
9
10use std::collections::HashMap;
11
12use crate::desc::{Direction, GraphDesc};
13
14use super::coords::assign;
15use super::geom::{cross_extent, flow_extent, map_point, map_rect, self_loop};
16use super::grid::grid_with_notes;
17use super::ordering::RankStructure;
18use super::resolve::Resolved;
19use super::{assemble, EdgeLayout, Layout, NodeLayout};
20
21/// Options for [`layered`]. Author-written, shape-stable: construct via
22/// functional record update over `Default` (ADR-0003 ยง2):
23///
24/// ```
25/// use abstracttui_graph::{Direction, LayeredOpts};
26/// let opts = LayeredOpts {
27///     direction: Direction::LeftRight,
28///     ..Default::default()
29/// };
30/// # let _ = opts;
31/// ```
32#[derive(Clone, Debug, PartialEq)]
33pub struct LayeredOpts {
34    /// Flow direction of the picture (default: [`Direction::TopDown`]).
35    pub direction: Direction,
36    /// Minimum cells between sibling cards along the cross axis
37    /// (default 3, clamped to at least 1).
38    pub node_gap: i32,
39    /// Cells between rank bands along the flow axis โ€” the corridor edge
40    /// waypoints route through (default 2, clamped to at least 1).
41    pub rank_gap: i32,
42    /// Bound on crossing-reduction sweeps (default 4). Each sweep is
43    /// one downward plus one upward median pass; the best ordering seen
44    /// wins. More sweeps buy diminishing quality, never correctness.
45    pub sweeps: u32,
46    /// Node cap (default 512). Graphs with more nodes degrade to the
47    /// grid placement with a fallback label naming the cap โ€” a labeled
48    /// grid beats a slow or hung solver at terminal scale.
49    pub node_cap: usize,
50}
51
52impl Default for LayeredOpts {
53    fn default() -> Self {
54        LayeredOpts {
55            direction: Direction::TopDown,
56            node_gap: 3,
57            rank_gap: 2,
58            sweeps: 4,
59            node_cap: 512,
60        }
61    }
62}
63
64/// Layered (sugiyama-lite) layout: ranks by longest path, bounded
65/// median crossing reduction, aligned-median coordinates, waypoints
66/// through rank gaps. Deterministic: same graph, same `Layout`.
67///
68/// Cycles are broken by the documented DFS heuristic and MARKED
69/// ([`EdgeLayout::broken`]); disconnected components lay out side by
70/// side along the cross axis.
71pub fn layered(desc: &GraphDesc, opts: &LayeredOpts) -> Layout {
72    let mut resolved = Resolved::new(desc);
73    let notes = resolved.notes();
74    let n = resolved.len();
75
76    if n > opts.node_cap {
77        let mut all = vec![format!(
78            "node cap exceeded ({n} > {}); grid placement fallback",
79            opts.node_cap
80        )];
81        all.extend(notes);
82        return grid_with_notes(desc, all);
83    }
84    if n == 0 {
85        return assemble(Vec::new(), Vec::new(), notes);
86    }
87
88    let dir = opts.direction;
89    let node_gap = f64::from(opts.node_gap.max(1));
90    let rank_gap = f64::from(opts.rank_gap.max(1));
91
92    resolved.break_cycles();
93    let rank = longest_path_ranks(&resolved);
94    let comp_of = resolved.components();
95    let comp_count = comp_of.iter().copied().max().map_or(0, |c| c + 1);
96
97    let mut node_out: Vec<Option<NodeLayout>> = (0..n).map(|_| None).collect();
98    let mut edge_out: Vec<(usize, EdgeLayout)> = Vec::new();
99    let mut running_cross = 0.0f64;
100    let comp_gap = node_gap * 2.0;
101
102    for comp in 0..comp_count {
103        let locals: Vec<usize> = (0..n).filter(|&g| comp_of[g] == comp).collect();
104        let piece = layout_component(
105            &resolved,
106            &rank,
107            &locals,
108            dir,
109            node_gap,
110            rank_gap,
111            opts.sweeps,
112        );
113
114        // Pack components side by side along the cross axis.
115        let offset = running_cross - piece.min_cross;
116        running_cross += piece.cross_span() + comp_gap;
117
118        for (g, cross, flow) in piece.node_places {
119            let size = resolved.sizes[g];
120            let rect = map_rect(dir, cross + offset, flow, size);
121            node_out[g] = Some(NodeLayout::new(resolved.id(desc, g), rect, rank[g]));
122        }
123        for poly in piece.edge_polylines {
124            let waypoints = poly
125                .points
126                .into_iter()
127                .map(|(c, f)| map_point(dir, c + offset, f))
128                .collect();
129            let e = &desc.edges[poly.desc_index];
130            let mut el = EdgeLayout::new(e.from.clone(), e.to.clone(), poly.desc_index, waypoints);
131            el.broken = poly.broken;
132            edge_out.push((poly.desc_index, el));
133        }
134    }
135
136    let nodes: Vec<NodeLayout> = node_out.into_iter().flatten().collect();
137
138    // Self-edges: a lobe on the card's right face, built from the final
139    // rect so all directions share one code path.
140    for &(g, desc_index) in &resolved.self_edges {
141        let rect = nodes[g].rect;
142        let e = &desc.edges[desc_index];
143        edge_out.push((
144            desc_index,
145            EdgeLayout::new(e.from.clone(), e.to.clone(), desc_index, self_loop(rect)),
146        ));
147    }
148
149    edge_out.sort_by_key(|(i, _)| *i);
150    let edges = edge_out.into_iter().map(|(_, e)| e).collect();
151    assemble(nodes, edges, notes)
152}
153
154/// Longest-path ranks over the broken-cycle DAG orientation (Kahn, FIFO
155/// seeded in input order โ€” deterministic). Ranks strictly increase
156/// along every oriented edge.
157fn longest_path_ranks(resolved: &Resolved) -> Vec<usize> {
158    let n = resolved.len();
159    let mut out: Vec<Vec<usize>> = vec![Vec::new(); n];
160    let mut indeg = vec![0usize; n];
161    for e in &resolved.edges {
162        let (u, v) = oriented(e.from, e.to, e.broken);
163        out[u].push(v);
164        indeg[v] += 1;
165    }
166    let mut rank = vec![0usize; n];
167    let mut queue: std::collections::VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
168    while let Some(u) = queue.pop_front() {
169        for &v in &out[u] {
170            rank[v] = rank[v].max(rank[u] + 1);
171            indeg[v] -= 1;
172            if indeg[v] == 0 {
173                queue.push_back(v);
174            }
175        }
176    }
177    rank
178}
179
180const fn oriented(from: usize, to: usize, broken: bool) -> (usize, usize) {
181    if broken {
182        (to, from)
183    } else {
184        (from, to)
185    }
186}
187
188/// One edge's polyline in (cross, flow) space, pre-packing.
189struct EdgePolyline {
190    desc_index: usize,
191    broken: bool,
192    points: Vec<(f64, f64)>,
193}
194
195/// One component's layout in (cross, flow) space, pre-packing.
196struct ComponentPiece {
197    /// (global local node id, cross start, flow start).
198    node_places: Vec<(usize, f64, f64)>,
199    edge_polylines: Vec<EdgePolyline>,
200    min_cross: f64,
201    max_cross: f64,
202}
203
204impl ComponentPiece {
205    fn cross_span(&self) -> f64 {
206        (self.max_cross - self.min_cross).max(0.0)
207    }
208}
209
210#[allow(clippy::too_many_arguments)]
211fn layout_component(
212    resolved: &Resolved,
213    rank: &[usize],
214    locals: &[usize],
215    dir: Direction,
216    node_gap: f64,
217    rank_gap: f64,
218    sweeps: u32,
219) -> ComponentPiece {
220    // Member space: 0..locals.len() are this component's real nodes (in
221    // input order); dummies for multi-rank edges follow.
222    let mut member_of = HashMap::with_capacity(locals.len());
223    for (m, &g) in locals.iter().enumerate() {
224        member_of.insert(g, m);
225    }
226    let rank_count = locals.iter().map(|&g| rank[g] + 1).max().unwrap_or(1);
227
228    let mut cross_ext: Vec<f64> = locals
229        .iter()
230        .map(|&g| cross_extent(dir, resolved.sizes[g]))
231        .collect();
232    let mut flow_ext: Vec<f64> = locals
233        .iter()
234        .map(|&g| flow_extent(dir, resolved.sizes[g]))
235        .collect();
236
237    // Edge chains: [source member, dummies.., target member] in the
238    // oriented (rank-increasing) direction.
239    struct Chain {
240        desc_index: usize,
241        broken: bool,
242        members: Vec<usize>,
243        u: usize,
244        v: usize,
245    }
246    let mut chains: Vec<Chain> = Vec::new();
247    let mut dummy_ranks: Vec<usize> = Vec::new();
248    for e in &resolved.edges {
249        let (gu, gv) = oriented(e.from, e.to, e.broken);
250        let (Some(&mu), Some(&mv)) = (member_of.get(&gu), member_of.get(&gv)) else {
251            continue; // edge belongs to another component
252        };
253        let mut members = vec![mu];
254        for r in (rank[gu] + 1)..rank[gv] {
255            let d = locals.len() + dummy_ranks.len();
256            dummy_ranks.push(r);
257            cross_ext.push(1.0);
258            flow_ext.push(1.0);
259            members.push(d);
260        }
261        members.push(mv);
262        chains.push(Chain {
263            desc_index: e.desc_index,
264            broken: e.broken,
265            members,
266            u: mu,
267            v: mv,
268        });
269    }
270
271    // Rank structure: reals in input order, then dummies in creation
272    // order; adjacency from chain segments.
273    let member_count = cross_ext.len();
274    let mut ranks: Vec<Vec<usize>> = vec![Vec::new(); rank_count];
275    for (m, &g) in locals.iter().enumerate() {
276        ranks[rank[g]].push(m);
277    }
278    for (i, &r) in dummy_ranks.iter().enumerate() {
279        ranks[r].push(locals.len() + i);
280    }
281    let mut up: Vec<Vec<usize>> = vec![Vec::new(); member_count];
282    let mut down: Vec<Vec<usize>> = vec![Vec::new(); member_count];
283    for chain in &chains {
284        for pair in chain.members.windows(2) {
285            down[pair[0]].push(pair[1]);
286            up[pair[1]].push(pair[0]);
287        }
288    }
289    let mut rs = RankStructure { ranks, up, down };
290    rs.reduce_crossings(sweeps);
291    let coords = assign(&rs, &cross_ext, &flow_ext, node_gap, rank_gap);
292
293    let member_rank = {
294        let mut mr = vec![0usize; member_count];
295        for (r, members) in rs.ranks.iter().enumerate() {
296            for &m in members {
297                mr[m] = r;
298            }
299        }
300        mr
301    };
302    let center = |m: usize| coords.cross[m] + cross_ext[m] / 2.0;
303
304    // Parallel-edge anchor spreading: edges sharing an oriented member
305    // pair fan out around the shared centers so duplicates stay
306    // distinguishable. Maps are lookup-only (determinism).
307    let mut pair_total: HashMap<(usize, usize), usize> = HashMap::new();
308    for chain in &chains {
309        *pair_total.entry((chain.u, chain.v)).or_insert(0) += 1;
310    }
311    let mut pair_seen: HashMap<(usize, usize), usize> = HashMap::new();
312
313    let mut node_places = Vec::with_capacity(locals.len());
314    for (m, &g) in locals.iter().enumerate() {
315        node_places.push((g, coords.cross[m], coords.flow_start[member_rank[m]]));
316    }
317
318    let mut edge_polylines = Vec::with_capacity(chains.len());
319    for chain in &chains {
320        let total = pair_total[&(chain.u, chain.v)];
321        let ordinal = pair_seen.entry((chain.u, chain.v)).or_insert(0);
322        let k = *ordinal;
323        *ordinal += 1;
324        let spread = if total > 1 {
325            let raw = (2 * k) as f64 - (total - 1) as f64;
326            let limit = ((cross_ext[chain.u].min(cross_ext[chain.v])) / 2.0 - 0.5).max(0.0);
327            raw.clamp(-limit, limit)
328        } else {
329            0.0
330        };
331
332        let mut points = Vec::with_capacity(chain.members.len());
333        let ru = member_rank[chain.u];
334        points.push((
335            center(chain.u) + spread,
336            coords.flow_start[ru] + flow_ext[chain.u],
337        ));
338        for &d in &chain.members[1..chain.members.len() - 1] {
339            let rd = member_rank[d];
340            points.push((center(d), coords.flow_start[rd] + coords.band_ext[rd] / 2.0));
341        }
342        let rv = member_rank[chain.v];
343        points.push((center(chain.v) + spread, coords.flow_start[rv] - 1.0));
344
345        if chain.broken {
346            // Present the polyline in original from -> to order: the
347            // chain was computed on the reversed orientation.
348            points.reverse();
349        }
350        edge_polylines.push(EdgePolyline {
351            desc_index: chain.desc_index,
352            broken: chain.broken,
353            points,
354        });
355    }
356
357    let min_cross = (0..member_count)
358        .map(|m| coords.cross[m])
359        .fold(f64::INFINITY, f64::min);
360    let max_cross = (0..member_count)
361        .map(|m| coords.cross[m] + cross_ext[m])
362        .fold(f64::NEG_INFINITY, f64::max);
363    ComponentPiece {
364        node_places,
365        edge_polylines,
366        min_cross,
367        max_cross,
368    }
369}