abstracttui_graph/layout/mod.rs
1//! Layout passes and the output half of the crate contract: [`Layout`].
2//!
3//! Every pass is `GraphDesc -> Layout`:
4//!
5//! - [`layered`] — sugiyama-lite (v1): ranks by longest path, bounded
6//! median crossing-reduction sweeps, aligned-median coordinates, edge
7//! waypoints through rank gaps. The workflow/DAG path.
8//! - [`force`] — bounded, seeded, alpha-cooled force placement (v1.5):
9//! the knowledge-graph path (cyclic, non-hierarchical data).
10//! - [`grid`] — labeled grid placement: the honest fallback.
11//!
12//! Consumers select the algorithm, never a different data contract.
13
14mod coords;
15mod force;
16mod geom;
17mod grid;
18mod layered;
19mod ordering;
20mod resolve;
21
22pub use force::{force, ForceOpts, IterationBudget};
23pub use grid::grid;
24pub use layered::{layered, LayeredOpts};
25
26use abstracttui::base::{Point, Rect};
27
28/// Placement of one node: its card rectangle in cells plus its rank.
29///
30/// Engine-produced fact carrier (`#[non_exhaustive]` per ADR-0003 §1 —
31/// cycle 2 will grow it, e.g. with port anchors). Read fields freely;
32/// construct through [`NodeLayout::new`] (the 0430 editor synthesizes
33/// layouts from user drags through the same door).
34#[non_exhaustive]
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct NodeLayout {
37 /// The node id, echoed from the input.
38 pub id: String,
39 /// Card rectangle in cells; origin-normalized so the layout's
40 /// bounding box starts at (0, 0).
41 pub rect: Rect,
42 /// Layer index along the flow axis. [`layered`] computes it;
43 /// [`grid`] reports the grid row; [`force`] computes no hierarchy
44 /// and honestly reports 0 for every node.
45 pub rank: usize,
46}
47
48impl NodeLayout {
49 /// Construct a node placement (downstream construction path).
50 pub fn new(id: impl Into<String>, rect: Rect, rank: usize) -> Self {
51 NodeLayout {
52 id: id.into(),
53 rect,
54 rank,
55 }
56 }
57}
58
59/// Routing of one edge: a waypoint polyline from the source card border
60/// to the target card border (endpoints inclusive).
61///
62/// Renderers draw straight polylines or splines through the waypoints;
63/// multi-rank edges carry one interior waypoint per crossed rank gap.
64#[non_exhaustive]
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct EdgeLayout {
67 /// Source node id, echoed from the input.
68 pub from: String,
69 /// Target node id, echoed from the input.
70 pub to: String,
71 /// Index of this edge in `GraphDesc::edges`, so caller metadata
72 /// (label/style) maps back even for duplicate from/to pairs.
73 pub desc_index: usize,
74 /// Polyline in cells, source border first, target border last.
75 pub waypoints: Vec<Point>,
76 /// True when the cycle-breaking heuristic reversed this edge to
77 /// obtain a DAG. The polyline still runs from `from` to `to` (it
78 /// travels against the flow axis); it is MARKED, never silently
79 /// reordered, so renderers can style back edges distinctly.
80 pub broken: bool,
81}
82
83impl EdgeLayout {
84 /// Construct an edge routing (downstream construction path).
85 pub fn new(
86 from: impl Into<String>,
87 to: impl Into<String>,
88 desc_index: usize,
89 waypoints: Vec<Point>,
90 ) -> Self {
91 EdgeLayout {
92 from: from.into(),
93 to: to.into(),
94 desc_index,
95 waypoints,
96 broken: false,
97 }
98 }
99
100 /// Mark this edge as cycle-broken (builder style).
101 pub fn broken(mut self) -> Self {
102 self.broken = true;
103 self
104 }
105}
106
107/// The output half of the crate contract: positions, ranks, waypoints,
108/// bounding box, and the two honesty markers (cycle-broken edge set,
109/// fallback label).
110///
111/// Deterministic: the same `GraphDesc` and options yield an identical
112/// `Layout` (golden-test-pinned). Coordinates are origin-normalized:
113/// `bounds` always starts at (0, 0) and is the content size a scrolling
114/// container should advertise.
115#[non_exhaustive]
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct Layout {
118 /// Node placements, in input node order (minus dropped duplicates).
119 pub nodes: Vec<NodeLayout>,
120 /// Edge routings, in input edge order (minus edges whose endpoints
121 /// do not resolve — those drops are recorded in `fallback`).
122 pub edges: Vec<EdgeLayout>,
123 /// Bounding box of all cards and waypoints, anchored at (0, 0).
124 pub bounds: Rect,
125 /// Honesty label. `None` means the requested algorithm ran cleanly.
126 /// `Some` names every degradation that occurred: grid fallback past
127 /// the node cap, dropped duplicate node ids, skipped unresolvable
128 /// edges. A labeled degraded layout beats a hung or lying solver.
129 pub fallback: Option<String>,
130}
131
132impl Layout {
133 /// Construct a layout from parts, computing the bounding box from
134 /// the content (downstream construction path).
135 pub fn new(nodes: Vec<NodeLayout>, edges: Vec<EdgeLayout>) -> Self {
136 let bounds = bounds_of(&nodes, &edges);
137 Layout {
138 nodes,
139 edges,
140 bounds,
141 fallback: None,
142 }
143 }
144
145 /// The cycle-broken edge set, as indices into `GraphDesc::edges`.
146 /// Derived from the per-edge [`EdgeLayout::broken`] markers (one
147 /// source of truth).
148 pub fn broken_edges(&self) -> Vec<usize> {
149 self.edges
150 .iter()
151 .filter(|e| e.broken)
152 .map(|e| e.desc_index)
153 .collect()
154 }
155
156 /// Look up a node placement by id.
157 pub fn node(&self, id: &str) -> Option<&NodeLayout> {
158 self.nodes.iter().find(|n| n.id == id)
159 }
160}
161
162/// Bounding box of cards and waypoints (each waypoint counted as one
163/// cell), or `Rect::ZERO` for empty content.
164pub(crate) fn bounds_of(nodes: &[NodeLayout], edges: &[EdgeLayout]) -> Rect {
165 let mut acc = Rect::ZERO;
166 for n in nodes {
167 acc = acc.union(n.rect);
168 }
169 for e in edges {
170 for p in &e.waypoints {
171 acc = acc.union(Rect::new(p.x, p.y, 1, 1));
172 }
173 }
174 acc
175}
176
177/// Assemble a pass result: origin-normalize, compute bounds, fold the
178/// honesty notes into the fallback label. Every pass finishes here.
179pub(crate) fn assemble(
180 mut nodes: Vec<NodeLayout>,
181 mut edges: Vec<EdgeLayout>,
182 notes: Vec<String>,
183) -> Layout {
184 let bounds = normalize(&mut nodes, &mut edges);
185 Layout {
186 nodes,
187 edges,
188 bounds,
189 fallback: resolve::fold_notes(notes),
190 }
191}
192
193/// Shift every card and waypoint so the joint bounding box starts at
194/// (0, 0), then store it. Every pass normalizes through here.
195pub(crate) fn normalize(nodes: &mut [NodeLayout], edges: &mut [EdgeLayout]) -> Rect {
196 let raw = bounds_of(nodes, edges);
197 let (dx, dy) = (-raw.x, -raw.y);
198 if dx != 0 || dy != 0 {
199 for n in nodes.iter_mut() {
200 n.rect = n.rect.translate(dx, dy);
201 }
202 for e in edges.iter_mut() {
203 for p in e.waypoints.iter_mut() {
204 *p = p.translate(dx, dy);
205 }
206 }
207 }
208 Rect::new(0, 0, raw.w, raw.h)
209}