Skip to main content

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}