Skip to main content

okf_studio/graph/
layout.rs

1//! Hand-rolled Fruchterman–Reingold force-directed layout, plus a radial
2//! by-directory-depth fallback.
3//!
4//! Layouts are deterministic: initial positions come from a hash of the node
5//! key rather than an RNG, so the same bundle always settles into the same
6//! shape. Simulation is incremental — the caller budgets iterations per
7//! frame — and positions persist across snapshot reloads so the picture does
8//! not jump when a file changes.
9
10use super::model::{EdgeKind, GraphModel, NodeKind};
11use std::collections::HashMap;
12
13/// The available layout algorithms, cycled with `L`.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum LayoutMode {
16    /// Force-directed (Fruchterman–Reingold).
17    #[default]
18    Force,
19    /// Radial rings by concept-id directory depth.
20    Radial,
21}
22
23impl LayoutMode {
24    /// The next mode in the cycle.
25    #[must_use]
26    pub const fn next(self) -> Self {
27        match self {
28            Self::Force => Self::Radial,
29            Self::Radial => Self::Force,
30        }
31    }
32
33    /// Display name.
34    #[must_use]
35    pub const fn name(self) -> &'static str {
36        match self {
37            Self::Force => "force",
38            Self::Radial => "radial",
39        }
40    }
41}
42
43/// FNV-1a, used for deterministic position seeding.
44fn fnv1a(s: &str) -> u64 {
45    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
46    for b in s.as_bytes() {
47        hash ^= u64::from(*b);
48        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
49    }
50    hash
51}
52
53/// The layout state: node positions keyed by stable node key, plus the
54/// simulated-annealing temperature.
55#[derive(Clone, Debug)]
56pub struct LayoutEngine {
57    /// Positions in abstract layout space, keyed by node key.
58    pub positions: HashMap<String, (f64, f64)>,
59    temperature: f64,
60    /// `true` while the simulation still runs on ticks.
61    pub running: bool,
62}
63
64impl Default for LayoutEngine {
65    fn default() -> Self {
66        Self {
67            positions: HashMap::new(),
68            temperature: 1.0,
69            running: true,
70        }
71    }
72}
73
74impl LayoutEngine {
75    /// Ensures every node in the model has a position, seeding new nodes
76    /// deterministically near their neighbors' centroid (or on a hash-derived
77    /// ring when they have none), and re-heats the simulation.
78    pub fn seed(&mut self, model: &GraphModel) {
79        let missing: Vec<usize> = (0..model.nodes.len())
80            .filter(|&i| !self.positions.contains_key(&model.nodes[i].key))
81            .collect();
82        for &i in &missing {
83            let node = &model.nodes[i];
84            let hash = fnv1a(&node.key);
85            #[allow(clippy::cast_precision_loss)]
86            let angle = (hash % 6283) as f64 / 1000.0;
87            #[allow(clippy::cast_precision_loss)]
88            let radius = 0.35 + ((hash >> 16) % 1000) as f64 / 2000.0;
89            // Phantom (broken) nodes are pinned toward the periphery.
90            let radius = if node.kind == NodeKind::Phantom {
91                radius + 0.6
92            } else {
93                radius
94            };
95            let mut pos = (radius * angle.cos(), radius * angle.sin());
96            // New nodes enter near their neighbors' centroid when possible.
97            let mut cx = 0.0;
98            let mut cy = 0.0;
99            let mut count = 0;
100            for edge in &model.edges {
101                let other = if edge.from == i {
102                    edge.to
103                } else if edge.to == i {
104                    edge.from
105                } else {
106                    continue;
107                };
108                if let Some(&(x, y)) = self.positions.get(&model.nodes[other].key) {
109                    cx += x;
110                    cy += y;
111                    count += 1;
112                }
113            }
114            if count > 0 {
115                let jitter = 0.05 + f64::from(u32::try_from(hash % 100).unwrap_or(0)) / 1000.0;
116                pos = (
117                    cx / f64::from(count) + jitter * angle.cos(),
118                    cy / f64::from(count) + jitter * angle.sin(),
119                );
120            }
121            self.positions.insert(node.key.clone(), pos);
122        }
123        if !missing.is_empty() {
124            self.temperature = self.temperature.max(0.3);
125            self.running = true;
126        }
127    }
128
129    /// Runs `iterations` Fruchterman–Reingold steps over the nodes selected
130    /// by `included` (pass all-true for the full graph). Returns `false`
131    /// once the layout has cooled and no further stepping is useful.
132    #[allow(clippy::cast_precision_loss)]
133    pub fn step(&mut self, model: &GraphModel, included: &[bool], iterations: usize) -> bool {
134        let indices: Vec<usize> = (0..model.nodes.len())
135            .filter(|&i| included.get(i).copied().unwrap_or(false))
136            .collect();
137        let n = indices.len();
138        if n < 2 || !self.running || self.temperature < 0.005 {
139            self.running = false;
140            return false;
141        }
142        let area = 4.0;
143        let k = (area / n as f64).sqrt();
144
145        for _ in 0..iterations {
146            let mut displacements: HashMap<usize, (f64, f64)> = HashMap::new();
147            // Repulsion between every included pair; hubs repel harder.
148            for (a_pos, &a) in indices.iter().enumerate() {
149                let pa = self.positions[&model.nodes[a].key];
150                for &b in &indices[a_pos + 1..] {
151                    let pb = self.positions[&model.nodes[b].key];
152                    let (mut dx, mut dy) = (pa.0 - pb.0, pa.1 - pb.1);
153                    let mut distance = dx.hypot(dy);
154                    if distance < 1e-6 {
155                        // Deterministic nudge for coincident nodes.
156                        dx = 1e-3;
157                        dy = 1e-3;
158                        distance = 1.5e-3;
159                    }
160                    let hub = 1.0
161                        + (model.nodes[a].degree.max(model.nodes[b].degree) as f64).sqrt() / 4.0;
162                    let force = k * k / distance * hub;
163                    let (ux, uy) = (dx / distance * force, dy / distance * force);
164                    let da = displacements.entry(a).or_insert((0.0, 0.0));
165                    da.0 += ux;
166                    da.1 += uy;
167                    let db = displacements.entry(b).or_insert((0.0, 0.0));
168                    db.0 -= ux;
169                    db.1 -= uy;
170                }
171            }
172            // Attraction along edges; derivation edges prefer shorter length.
173            for edge in &model.edges {
174                if !included.get(edge.from).copied().unwrap_or(false)
175                    || !included.get(edge.to).copied().unwrap_or(false)
176                {
177                    continue;
178                }
179                let pa = self.positions[&model.nodes[edge.from].key];
180                let pb = self.positions[&model.nodes[edge.to].key];
181                let (dx, dy) = (pa.0 - pb.0, pa.1 - pb.1);
182                let distance = dx.hypot(dy).max(1e-6);
183                let ideal = if edge.kind == EdgeKind::Derivation {
184                    k * 0.6
185                } else {
186                    k
187                };
188                let force = distance * distance / ideal;
189                let (ux, uy) = (dx / distance * force, dy / distance * force);
190                let da = displacements.entry(edge.from).or_insert((0.0, 0.0));
191                da.0 += ux;
192                da.1 += uy;
193                let db = displacements.entry(edge.to).or_insert((0.0, 0.0));
194                db.0 += ux;
195                db.1 += uy;
196            }
197            // Apply displacement, clamped by temperature.
198            let limit = self.temperature * 0.1;
199            for &i in &indices {
200                let Some(&(dx, dy)) = displacements.get(&i) else {
201                    continue;
202                };
203                let len = dx.hypot(dy);
204                if len < 1e-9 {
205                    continue;
206                }
207                let scale = (len.min(limit)) / len;
208                let Some(pos) = self.positions.get_mut(&model.nodes[i].key) else {
209                    continue;
210                };
211                pos.0 = dx.mul_add(scale, pos.0);
212                pos.1 = dy.mul_add(scale, pos.1);
213            }
214            self.temperature *= 0.98;
215        }
216        true
217    }
218
219    /// Replaces positions with a radial layout: concepts ring by directory
220    /// depth, spread deterministically by key hash within each ring.
221    #[allow(clippy::cast_precision_loss)]
222    pub fn radial(&mut self, model: &GraphModel) {
223        let mut by_depth: HashMap<usize, Vec<usize>> = HashMap::new();
224        for (i, node) in model.nodes.iter().enumerate() {
225            let depth = match node.kind {
226                NodeKind::Phantom | NodeKind::Source => 9,
227                _ => node.id.as_ref().map_or(1, |id| id.segments().len()),
228            };
229            by_depth.entry(depth).or_default().push(i);
230        }
231        let mut depths: Vec<usize> = by_depth.keys().copied().collect();
232        depths.sort_unstable();
233        for (ring, depth) in depths.iter().enumerate() {
234            let members = &by_depth[depth];
235            let radius = (ring as f64).mul_add(0.35, 0.25);
236            let count = members.len().max(1) as f64;
237            let mut ordered = members.clone();
238            ordered.sort_by_key(|&i| model.nodes[i].key.clone());
239            for (slot, &i) in ordered.iter().enumerate() {
240                let angle = std::f64::consts::TAU * slot as f64 / count;
241                self.positions.insert(
242                    model.nodes[i].key.clone(),
243                    (radius * angle.cos(), radius * angle.sin()),
244                );
245            }
246        }
247        self.running = false;
248    }
249
250    /// Pauses or resumes the simulation. Resuming re-heats it slightly.
251    pub const fn toggle_running(&mut self) {
252        self.running = !self.running;
253        if self.running && self.temperature < 0.05 {
254            self.temperature = 0.2;
255        }
256    }
257}