graph.rs (35931B)
1 use std::collections::{HashMap, HashSet}; 2 use rayon::prelude::*; 3 use crate::db::RawNode; 4 5 // ── physics constants ───────────────────────────────────────────────────────── 6 pub const REPULSION: f64 = 16000.0; 7 pub const SPRING_K: f64 = 0.055; 8 pub const SPRING_L: f64 = 110.0; 9 pub const GRAVITY: f64 = 0.020; 10 pub const DAMPING: f64 = 0.90; 11 pub const BASE_DT: f64 = 1.0 / 60.0; 12 pub const DT_CAP: f64 = 0.05; 13 pub const R_MIN: f32 = 5.0; 14 pub const R_MAX: f32 = 22.0; 15 16 const LAYOUT_DUR: f32 = 1.3; // seconds for layout transition animation 17 18 #[derive(Debug, Clone)] 19 pub struct Node { 20 pub id: String, 21 pub title: String, 22 pub file: String, 23 pub level: i64, 24 pub pos: i64, 25 pub mtime: i64, 26 pub aliases: Vec<String>, 27 pub tags: Vec<String>, 28 pub x: f64, pub y: f64, 29 pub vx: f64, pub vy: f64, 30 pub pinned: bool, 31 pub degree: usize, 32 pub radius: f32, 33 } 34 35 struct LayoutAnim { 36 from: Vec<(f64, f64)>, 37 to: Vec<(f64, f64)>, 38 t: f32, 39 resume_physics: bool, // true = restart force-directed after landing; false = breathing mode 40 } 41 42 pub struct Graph { 43 pub nodes: HashMap<String, usize>, 44 pub node_list: Vec<Node>, 45 pub edges: Vec<(usize, usize)>, 46 pub adj: HashMap<String, HashSet<String>>, 47 pub tag_colors: HashMap<String, [u8; 3]>, 48 pub physics_on: bool, 49 pub step_count: u64, 50 layout_anim: Option<LayoutAnim>, 51 physics_ease: f32, // 0 → 1 ramp after layout animation so forces fade in, not snap 52 settle_frames: u32, // consecutive frames below velocity threshold; auto-pauses at limit 53 } 54 55 impl Graph { 56 pub fn new(raw_nodes: Vec<RawNode>, raw_edges: Vec<(String, String)>) -> Self { 57 let n = raw_nodes.len(); 58 let mut nodes: HashMap<String, usize> = HashMap::with_capacity(n); 59 let mut node_list: Vec<Node> = Vec::with_capacity(n); 60 61 for (i, rn) in raw_nodes.iter().enumerate() { 62 let node = Node { 63 id: rn.id.clone(), 64 title: if rn.title.is_empty() { rn.id[..rn.id.len().min(12)].to_string() } else { rn.title.clone() }, 65 file: rn.file.clone(), 66 level: rn.level, 67 pos: rn.pos, 68 mtime: rn.mtime, 69 aliases: rn.aliases.clone(), 70 tags: rn.tags.clone(), 71 x: 0.0, y: 0.0, 72 vx: lcg_uniform(i as u64 * 2 + 3) * 4.0 - 2.0, 73 vy: lcg_uniform(i as u64 * 2 + 4) * 4.0 - 2.0, 74 pinned: false, degree: 0, radius: R_MIN, 75 }; 76 nodes.insert(rn.id.clone(), i); 77 node_list.push(node); 78 } 79 80 let mut edges: Vec<(usize, usize)> = Vec::new(); 81 let mut adj: HashMap<String, HashSet<String>> = HashMap::new(); 82 for nid in node_list.iter().map(|n| n.id.clone()) { adj.insert(nid, HashSet::new()); } 83 for (src, dst) in &raw_edges { 84 if let (Some(&si), Some(&di)) = (nodes.get(src), nodes.get(dst)) { 85 edges.push((si, di)); 86 node_list[si].degree += 1; 87 node_list[di].degree += 1; 88 adj.entry(src.clone()).or_default().insert(dst.clone()); 89 adj.entry(dst.clone()).or_default().insert(src.clone()); 90 } 91 } 92 93 let max_deg = node_list.iter().map(|n| n.degree).max().unwrap_or(1).max(1); 94 let log_span = (max_deg as f32 + 1.0).ln_1p(); 95 for nd in node_list.iter_mut() { 96 nd.radius = R_MIN + (R_MAX - R_MIN) * (nd.degree as f32 + 1.0).ln_1p() / log_span; 97 } 98 99 // Initial placement: disk (phyllotaxis) so physics settles into a 100 // natural filled-circle shape like Obsidian's graph view. 101 let positions = positions_disk(&node_list, &edges, n); 102 for (i, (x, y)) in positions.iter().enumerate() { 103 node_list[i].x = *x; 104 node_list[i].y = *y; 105 } 106 107 let mut all_tags: Vec<String> = node_list.iter().flat_map(|n| n.tags.iter().cloned()).collect(); 108 all_tags.sort(); all_tags.dedup(); 109 let phi = 0.618033988749895f64; 110 let mut tag_colors: HashMap<String, [u8; 3]> = HashMap::new(); 111 for (i, tag) in all_tags.iter().enumerate() { 112 let h = (i as f64 * phi) % 1.0; 113 tag_colors.insert(tag.clone(), hsv_to_rgb(h, 0.62, 0.88)); 114 } 115 116 Graph { nodes, node_list, edges, adj, tag_colors, 117 physics_on: true, step_count: 0, layout_anim: None, physics_ease: 1.0, 118 settle_frames: 0 } 119 } 120 121 // ── Layout transitions ──────────────────────────────────────────────────── 122 123 pub fn positions_radial(&self) -> Vec<(f64, f64)> { 124 positions_radial(&self.node_list, &self.edges, self.node_list.len()) 125 } 126 127 pub fn positions_ring(&self) -> Vec<(f64, f64)> { 128 positions_ring(&self.node_list, &self.edges, self.node_list.len()) 129 } 130 131 pub fn positions_disk(&self) -> Vec<(f64, f64)> { 132 positions_disk(&self.node_list, &self.edges, self.node_list.len()) 133 } 134 135 /// Column — BFS shells as vertical strips (L→R), nodes spread vertically. 136 pub fn positions_column(&self) -> Vec<(f64, f64)> { 137 positions_layered(&self.node_list, &self.edges, self.node_list.len(), true) 138 } 139 140 /// Row — BFS shells as horizontal strips (T→B), nodes spread horizontally. 141 pub fn positions_row(&self) -> Vec<(f64, f64)> { 142 positions_layered(&self.node_list, &self.edges, self.node_list.len(), false) 143 } 144 145 /// Top-down tree — hub at top, each BFS shell becomes a horizontal row. 146 /// Nodes within each row sorted by parent position to reduce crossings. 147 pub fn positions_tree(&self) -> Vec<(f64, f64)> { 148 positions_tree(&self.node_list, &self.edges, self.node_list.len()) 149 } 150 151 /// Begin a smooth transition to `targets`. Nodes float to their new 152 /// positions over LAYOUT_DUR seconds; physics resumes after. 153 pub fn begin_layout_transition(&mut self, targets: Vec<(f64, f64)>, resume_physics: bool) { 154 let from = self.node_list.iter().map(|n| (n.x, n.y)).collect(); 155 self.layout_anim = Some(LayoutAnim { from, to: targets, t: 0.0, resume_physics }); 156 self.physics_on = true; 157 } 158 159 pub fn is_layout_animating(&self) -> bool { self.layout_anim.is_some() } 160 161 // ── Step ───────────────────────────────────────────────────────────────── 162 163 /// Resume full force-directed physics from the current node positions, 164 /// easing forces in so nodes don't snap from their current layout. 165 pub fn resume_physics(&mut self) { 166 self.physics_on = true; 167 self.physics_ease = 0.0; 168 } 169 170 pub fn step(&mut self, dt: f64) { 171 let dt = dt.min(DT_CAP); 172 let n = self.node_list.len(); 173 if n == 0 { return; } 174 175 // Layout animation: smoothstep lerp, no physics 176 if let Some(ref mut anim) = self.layout_anim { 177 self.physics_on = true; // keep step() ticking 178 anim.t = (anim.t + dt as f32 / LAYOUT_DUR).min(1.0); 179 let t = anim.t; 180 let ease = (t * t * (3.0 - 2.0 * t)) as f64; 181 for i in 0..n { 182 let nd = &mut self.node_list[i]; 183 nd.x = anim.from[i].0 + (anim.to[i].0 - anim.from[i].0) * ease; 184 nd.y = anim.from[i].1 + (anim.to[i].1 - anim.from[i].1) * ease; 185 nd.vx = 0.0; nd.vy = 0.0; 186 } 187 if anim.t >= 1.0 { 188 let resume = anim.resume_physics; 189 self.layout_anim = None; 190 if resume { 191 self.resume_physics(); // ease forces back in so nodes settle naturally 192 } else { 193 self.physics_on = false; // breathing mode — holds layout positions 194 } 195 } 196 return; 197 } 198 199 if !self.physics_on { 200 // Breathing is handled purely visually in the painter — no position mutation here. 201 self.step_count = self.step_count.wrapping_add(1); 202 return; 203 } 204 205 // Full force-directed physics — ease forces in after a layout to avoid snapping 206 if self.physics_ease < 1.0 { 207 self.physics_ease = (self.physics_ease + dt as f32 / 3.5).min(1.0); 208 } 209 let ease = self.physics_ease as f64; 210 211 let mut fx = vec![0.0f64; n]; 212 let mut fy = vec![0.0f64; n]; 213 214 { 215 let pts: Vec<(f64, f64)> = self.node_list.iter().map(|nd| (nd.x, nd.y)).collect(); 216 let bh = bh_build(&pts); 217 // Each node queries the read-only tree independently — embarrassingly parallel. 218 let rep_forces: Vec<(f64, f64)> = pts.par_iter().enumerate() 219 .map_with(Vec::with_capacity(64), |stk, (i, &(x, y))| bh_force(&bh, x, y, i, stk)) 220 .collect(); 221 for (i, (rfx, rfy)) in rep_forces.into_iter().enumerate() { 222 fx[i] += rfx; fy[i] += rfy; 223 } 224 } 225 226 for &(si, di) in &self.edges { 227 let dx = self.node_list[di].x - self.node_list[si].x; 228 let dy = self.node_list[di].y - self.node_list[si].y; 229 let dist = dx.hypot(dy).max(1e-4); 230 let f = SPRING_K * (dist - SPRING_L) / dist; 231 fx[si] += dx * f; fy[si] += dy * f; 232 fx[di] -= dx * f; fy[di] -= dy * f; 233 } 234 235 self.step_count = self.step_count.wrapping_add(1); 236 let t = self.step_count as f64 * 0.006; 237 const PHI: f64 = 1.618033988749895; 238 for i in 0..n { 239 let phase = i as f64 * PHI; 240 fx[i] += (t + phase).sin() * 0.35; 241 fy[i] += (t * 1.272 + phase * 0.849).cos() * 0.35; 242 } 243 244 // Scale ALL forces (including gravity) by ease so nothing snaps after layout animation 245 let g = GRAVITY * ease; 246 for i in 0..n { fx[i] *= ease; fy[i] *= ease; } 247 248 let damp = DAMPING.powf(dt / BASE_DT); 249 let max_r = (n as f64 * 15.0).max(350.0); 250 const MAX_V: f64 = 650.0; 251 for i in 0..n { 252 let nd = &mut self.node_list[i]; 253 if nd.pinned { nd.vx = 0.0; nd.vy = 0.0; continue; } 254 nd.vx = (nd.vx + (fx[i] - nd.x * g) * dt) * damp; 255 nd.vy = (nd.vy + (fy[i] - nd.y * g) * dt) * damp; 256 // Cap velocity before position update so no single step can escape 257 let spd = nd.vx.hypot(nd.vy); 258 if spd > MAX_V { nd.vx = nd.vx / spd * MAX_V; nd.vy = nd.vy / spd * MAX_V; } 259 nd.x += nd.vx * dt; 260 nd.y += nd.vy * dt; 261 // Hard radial clamp — any node that slips past loses its velocity 262 let d = nd.x.hypot(nd.y); 263 if d > max_r { 264 let s = max_r / d; 265 nd.x *= s; nd.y *= s; 266 nd.vx *= 0.1; nd.vy *= 0.1; 267 } 268 } 269 270 // Auto-pause: if all nodes are near-still for ~1.5 s, switch to breathing mode 271 let max_v = self.node_list.iter().map(|nd| nd.vx.hypot(nd.vy)).fold(0.0f64, f64::max); 272 if max_v < 1.5 && self.physics_ease >= 1.0 { 273 self.settle_frames += 1; 274 if self.settle_frames > 90 { self.physics_on = false; self.settle_frames = 0; } 275 } else { 276 self.settle_frames = 0; 277 } 278 } 279 280 pub fn bfs(&self, start_id: &str, hops: usize) -> HashSet<String> { 281 let mut visited: HashSet<String> = HashSet::new(); 282 visited.insert(start_id.to_string()); 283 let mut frontier = visited.clone(); 284 for _ in 0..hops { 285 let mut nxt = HashSet::new(); 286 for nid in &frontier { 287 for nb in self.adj.get(nid).into_iter().flatten() { 288 if visited.insert(nb.clone()) { nxt.insert(nb.clone()); } 289 } 290 } 291 frontier = nxt; 292 } 293 visited 294 } 295 296 pub fn transplant_positions(&mut self, old: &Graph) { 297 for nd in &mut self.node_list { 298 if let Some(&oi) = old.nodes.get(&nd.id) { 299 let on = &old.node_list[oi]; 300 nd.x = on.x; nd.y = on.y; 301 nd.vx = on.vx; nd.vy = on.vy; 302 } 303 } 304 } 305 } 306 307 // ── Layout position calculators ─────────────────────────────────────────────── 308 309 /// Radial shell layout: hub at origin, BFS shells as concentric rings. 310 /// Nodes within each ring sorted by parent angle to minimise crossings. 311 fn positions_radial(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> { 312 if n == 0 { return Vec::new(); } 313 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n]; 314 for &(s, d) in edges { adj[s].push(d); adj[d].push(s); } 315 316 let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0); 317 let unvisited = n + 1; 318 let mut shell = vec![unvisited; n]; 319 shell[hub] = 0; 320 let mut shells: Vec<Vec<usize>> = vec![vec![hub]]; 321 let mut frontier = vec![hub]; 322 323 while !frontier.is_empty() { 324 let sh = shells.len(); 325 let mut next = Vec::new(); 326 let mut sh_nodes = Vec::new(); 327 for &cur in &frontier { 328 for &nb in &adj[cur] { 329 if shell[nb] == unvisited { 330 shell[nb] = sh; sh_nodes.push(nb); next.push(nb); 331 } 332 } 333 } 334 if !sh_nodes.is_empty() { shells.push(sh_nodes); } 335 frontier = next; 336 } 337 338 // Disconnected nodes — collected but NOT added to shells. 339 // They get their own compact cluster near the hub so gravity keeps them stable. 340 let disc: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect(); 341 342 let n_shells = shells.len().saturating_sub(1).max(1); 343 let ring_gap = SPRING_L.max(80.0 + n as f64 * 4.0 / n_shells as f64); 344 345 let mut positions = vec![(0.0_f64, 0.0_f64); n]; 346 // hub at origin 347 positions[hub] = (0.0, 0.0); 348 349 for (sh_idx, sh_nodes) in shells.iter().enumerate() { 350 if sh_idx == 0 { continue; } 351 let r = sh_idx as f64 * ring_gap; 352 let count = sh_nodes.len(); 353 354 let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| { 355 let pa = adj[ni].iter() 356 .filter(|&&nb| shell[nb] < sh_idx) 357 .map(|&nb| positions[nb].1.atan2(positions[nb].0)) 358 .next().unwrap_or(0.0); 359 (ni, pa) 360 }).collect(); 361 sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); 362 363 for (pos, &(ni, _)) in sorted.iter().enumerate() { 364 let angle = std::f64::consts::TAU * pos as f64 / count as f64; 365 let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 12.0 - 6.0; 366 let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 12.0 - 6.0; 367 positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy); 368 } 369 } 370 371 // Orphans: compact cluster at gravity equilibrium (r ≈ perturbation/GRAVITY ≈ 25wu). 372 // Placed in the bottom-left quadrant so they're visually distinct from 373 // connected rings and never on the same radial lines. 374 if !disc.is_empty() { 375 let orphan_r = 28.0_f64; 376 let arc_start = std::f64::consts::PI * 1.15; 377 let arc_spread = std::f64::consts::PI * 0.45; 378 let dcount = disc.len(); 379 for (i, &ni) in disc.iter().enumerate() { 380 let t = if dcount == 1 { 0.5 } else { i as f64 / (dcount - 1) as f64 }; 381 let angle = arc_start + arc_spread * t; 382 let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(7)) * 8.0 - 4.0; 383 let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(8)) * 8.0 - 4.0; 384 positions[ni] = (orphan_r * angle.cos() + jx, orphan_r * angle.sin() + jy); 385 } 386 } 387 388 positions 389 } 390 391 /// Ring layout: all nodes on a single circle, BFS-ordered from hub. 392 fn positions_ring(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> { 393 if n == 0 { return Vec::new(); } 394 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n]; 395 for &(s, d) in edges { adj[s].push(d); adj[d].push(s); } 396 397 let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0); 398 let mut order = Vec::with_capacity(n); 399 let mut visited = vec![false; n]; 400 let mut queue = std::collections::VecDeque::new(); 401 queue.push_back(hub); visited[hub] = true; 402 while let Some(cur) = queue.pop_front() { 403 order.push(cur); 404 let mut nbs: Vec<usize> = adj[cur].iter().copied().filter(|&nb| !visited[nb]).collect(); 405 nbs.sort_by_key(|&nb| std::cmp::Reverse(node_list[nb].degree)); 406 for nb in nbs { visited[nb] = true; queue.push_back(nb); } 407 } 408 for i in 0..n { if !visited[i] { order.push(i); } } 409 410 let r = (60.0_f64).max((380.0_f64).min(55.0 + n as f64 * 2.8)); 411 let mut positions = vec![(0.0_f64, 0.0_f64); n]; 412 for (pos, &ni) in order.iter().enumerate() { 413 let angle = std::f64::consts::TAU * pos as f64 / n as f64; 414 let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 12.0 - 6.0; 415 let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 12.0 - 6.0; 416 positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy); 417 } 418 positions 419 } 420 421 /// Disk layout: shell-based placement at SPRING_L ring gap. 422 /// Nodes start at their physics equilibrium distance so physics barely needs 423 /// to move them — edges never cross on spawn because each shell's nodes are 424 /// angle-sorted by their parent's position. Physics then relaxes the layout 425 /// into a natural filled circle (Obsidian-style). 426 fn positions_disk(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> { 427 if n == 0 { return Vec::new(); } 428 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n]; 429 for &(s, d) in edges { adj[s].push(d); adj[d].push(s); } 430 431 let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0); 432 let unvisited = n + 1; 433 let mut shell = vec![unvisited; n]; 434 shell[hub] = 0; 435 let mut shells: Vec<Vec<usize>> = vec![vec![hub]]; 436 let mut frontier = vec![hub]; 437 while !frontier.is_empty() { 438 let sh = shells.len(); 439 let mut next = Vec::new(); let mut sh_nodes = Vec::new(); 440 for &cur in &frontier { 441 for &nb in &adj[cur] { 442 if shell[nb] == unvisited { shell[nb] = sh; sh_nodes.push(nb); next.push(nb); } 443 } 444 } 445 if !sh_nodes.is_empty() { shells.push(sh_nodes); } 446 frontier = next; 447 } 448 let disc: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect(); 449 450 // Ring gap = SPRING_L so connected nodes begin exactly at their rest length 451 let ring_gap = SPRING_L; 452 453 let mut positions = vec![(0.0_f64, 0.0_f64); n]; 454 positions[hub] = (0.0, 0.0); 455 456 for (sh_idx, sh_nodes) in shells.iter().enumerate() { 457 if sh_idx == 0 { continue; } 458 let r = sh_idx as f64 * ring_gap; 459 let count = sh_nodes.len(); 460 // Sort by parent angle → no crossings between adjacent shells 461 let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| { 462 let pa = adj[ni].iter() 463 .filter(|&&nb| shell[nb] < sh_idx) 464 .map(|&nb| positions[nb].1.atan2(positions[nb].0)) 465 .next().unwrap_or(0.0); 466 (ni, pa) 467 }).collect(); 468 sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); 469 for (pos, &(ni, _)) in sorted.iter().enumerate() { 470 let angle = std::f64::consts::TAU * pos as f64 / count as f64; 471 let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(1)) * 8.0 - 4.0; 472 let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(2)) * 8.0 - 4.0; 473 positions[ni] = (r * angle.cos() + jx, r * angle.sin() + jy); 474 } 475 } 476 477 // Orphans near origin — at gravity equilibrium, won't drift 478 if !disc.is_empty() { 479 let orphan_r = 28.0_f64; 480 let arc_start = std::f64::consts::PI * 1.15; 481 let arc_span = std::f64::consts::PI * 0.45; 482 let dc = disc.len(); 483 for (i, &ni) in disc.iter().enumerate() { 484 let t = if dc == 1 { 0.5 } else { i as f64 / (dc - 1) as f64 }; 485 let angle = arc_start + arc_span * t; 486 let jx = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(7)) * 8.0 - 4.0; 487 let jy = lcg_uniform((ni as u64).wrapping_mul(6364136223846793005).wrapping_add(8)) * 8.0 - 4.0; 488 positions[ni] = (orphan_r * angle.cos() + jx, orphan_r * angle.sin() + jy); 489 } 490 } 491 prerelax(positions, edges, n) 492 } 493 494 /// Generic layered layout used by both Column and Row. 495 /// `column_mode=true` → connected shells advance left→right, orphans placed 496 /// in a separate cluster BELOW the connected graph. 497 /// `column_mode=false` → connected shells advance top→bottom, orphans placed 498 /// in a separate cluster to the RIGHT of the connected graph. 499 /// Orphans are NEVER placed inline with connected nodes so they cannot be 500 /// mistaken for part of a chain. 501 fn positions_layered(node_list: &[Node], edges: &[(usize, usize)], n: usize, column_mode: bool) -> Vec<(f64, f64)> { 502 if n == 0 { return Vec::new(); } 503 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n]; 504 for &(s, d) in edges { adj[s].push(d); adj[d].push(s); } 505 506 let hub = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0); 507 let unvisited = n + 1; 508 let mut shell = vec![unvisited; n]; 509 shell[hub] = 0; 510 let mut shells: Vec<Vec<usize>> = vec![vec![hub]]; 511 let mut frontier = vec![hub]; 512 while !frontier.is_empty() { 513 let sh = shells.len(); 514 let mut next = Vec::new(); let mut sh_nodes = Vec::new(); 515 for &cur in &frontier { 516 for &nb in &adj[cur] { 517 if shell[nb] == unvisited { shell[nb] = sh; sh_nodes.push(nb); next.push(nb); } 518 } 519 } 520 if !sh_nodes.is_empty() { shells.push(sh_nodes); } 521 frontier = next; 522 } 523 // Collect disconnected nodes — kept completely separate, never added to shells 524 let orphans: Vec<usize> = (0..n).filter(|&i| shell[i] == unvisited).collect(); 525 526 // main_gap ≈ SPRING_L so physics equilibrium matches the layout. 527 // cross_gap larger so labels never crowd each other. 528 let main_gap = SPRING_L * 1.1; 529 let cross_gap = SPRING_L * 1.8; 530 let n_shells = shells.len(); 531 let total_main = (n_shells as f64 - 1.0) * main_gap; 532 // Furthest extent of connected graph on the main axis 533 let connected_main_max = total_main / 2.0; 534 535 let mut positions = vec![(0.0_f64, 0.0_f64); n]; 536 537 // Place connected shells 538 for (sh_idx, sh_nodes) in shells.iter().enumerate() { 539 let main = sh_idx as f64 * main_gap - total_main / 2.0; 540 let count = sh_nodes.len(); 541 let total_cross = (count as f64 - 1.0) * cross_gap; 542 543 // Sort by parent's cross-axis position so edges don't cross between shells 544 let mut sorted: Vec<(usize, f64)> = sh_nodes.iter().map(|&ni| { 545 let cp = adj[ni].iter() 546 .filter(|&&nb| shell[nb] < sh_idx) 547 .map(|&nb| if column_mode { positions[nb].1 } else { positions[nb].0 }) 548 .next().unwrap_or(0.0); 549 (ni, cp) 550 }).collect(); 551 sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); 552 553 for (pos, &(ni, _)) in sorted.iter().enumerate() { 554 let cross = pos as f64 * cross_gap - total_cross / 2.0; 555 positions[ni] = if column_mode { (main, cross) } else { (cross, main) }; 556 } 557 } 558 559 // Orphans always go to the RIGHT of the entire connected graph, spread in y. 560 // This guarantees no drawn edge (which stays within the connected x-range) 561 // can pass through an orphan node regardless of layout mode. 562 if !orphans.is_empty() { 563 let max_conn_x = shells.iter().flatten() 564 .map(|&i| positions[i].0) 565 .fold(0.0_f64, f64::max); 566 // Cluster orphans in a phyllotaxis spiral to the right — no straight-line 567 // alignment possible so they can never look like they sit on a connection. 568 let orphan_cx = max_conn_x + main_gap * 1.8; 569 let cluster_r = cross_gap * (orphans.len() as f64).sqrt().max(1.0) * 0.55; 570 let golden = std::f64::consts::TAU * (1.0 - 1.0 / 1.618033988749895); 571 for (i, &ni) in orphans.iter().enumerate() { 572 let r = (i as f64 / orphans.len().max(1) as f64).sqrt() * cluster_r; 573 let theta = i as f64 * golden; 574 positions[ni] = (orphan_cx + r * theta.cos(), r * theta.sin()); 575 } 576 } 577 578 positions 579 } 580 581 /// Proper top-down tree using subtree-width allocation (simplified Reingold–Tilford). 582 /// Each subtree occupies a non-overlapping horizontal band, so no edges can cross. 583 fn positions_tree(node_list: &[Node], edges: &[(usize, usize)], n: usize) -> Vec<(f64, f64)> { 584 if n == 0 { return Vec::new(); } 585 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n]; 586 for &(s, d) in edges { adj[s].push(d); adj[d].push(s); } 587 588 let root = (0..n).max_by_key(|&i| node_list[i].degree).unwrap_or(0); 589 590 // Build spanning tree via BFS; each node gets exactly one parent 591 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n]; 592 let mut depth = vec![0usize; n]; 593 let mut bfs_ord = Vec::with_capacity(n); 594 let mut visited = vec![false; n]; 595 let mut queue = std::collections::VecDeque::new(); 596 queue.push_back(root); visited[root] = true; 597 while let Some(cur) = queue.pop_front() { 598 bfs_ord.push(cur); 599 let mut nbs: Vec<usize> = adj[cur].iter().copied().filter(|&nb| !visited[nb]).collect(); 600 nbs.sort_by_key(|&nb| std::cmp::Reverse(node_list[nb].degree)); 601 for nb in nbs { 602 visited[nb] = true; 603 children[cur].push(nb); 604 depth[nb] = depth[cur] + 1; 605 queue.push_back(nb); 606 } 607 } 608 let mut disconnected: Vec<usize> = (0..n).filter(|&i| !visited[i]).collect(); 609 610 let unit = SPRING_L * 1.8; // wide enough for labels; ~matches cross-axis rest length 611 let row_gap = SPRING_L * 1.1; 612 613 // Bottom-up: each leaf = width 1, each internal node = sum of children widths 614 let mut width = vec![1.0_f64; n]; 615 for &ni in bfs_ord.iter().rev() { 616 if !children[ni].is_empty() { 617 width[ni] = children[ni].iter().map(|&c| width[c]).sum(); 618 } 619 } 620 621 let max_depth = depth.iter().copied().max().unwrap_or(0); 622 let total_h = max_depth as f64 * row_gap; 623 624 let mut positions = vec![(0.0_f64, 0.0_f64); n]; 625 let mut left_edge = vec![0.0_f64; n]; 626 left_edge[root] = -(width[root] * unit) / 2.0; 627 628 // Top-down: place each node at the centre of its subtree's horizontal band 629 for &ni in &bfs_ord { 630 let x = left_edge[ni] + width[ni] * unit / 2.0; 631 let y = depth[ni] as f64 * row_gap - total_h / 2.0; 632 positions[ni] = (x, y); 633 634 // Distribute children left-to-right within this node's band 635 let mut cursor = left_edge[ni]; 636 for &ci in &children[ni] { 637 left_edge[ci] = cursor; 638 cursor += width[ci] * unit; 639 } 640 } 641 642 // Disconnected nodes go to the RIGHT of the tree, spread in y. 643 // Placing them below would put them in the path of the tree's vertical edges. 644 if !disconnected.is_empty() { 645 let max_tree_x = bfs_ord.iter() 646 .map(|&i| positions[i].0) 647 .fold(0.0_f64, f64::max); 648 let orphan_cx = max_tree_x + unit * 1.8; 649 let cluster_r = unit * (disconnected.len() as f64).sqrt().max(1.0) * 0.55; 650 let golden = std::f64::consts::TAU * (1.0 - 1.0 / 1.618033988749895); 651 for (i, &ni) in disconnected.iter().enumerate() { 652 let r = (i as f64 / disconnected.len().max(1) as f64).sqrt() * cluster_r; 653 let theta = i as f64 * golden; 654 positions[ni] = (orphan_cx + r * theta.cos(), r * theta.sin()); 655 } 656 } 657 positions 658 } 659 660 /// Run simplified physics on `pos` until near-equilibrium, then return the 661 /// settled coordinates. Operates on a pure copy — the Graph is not mutated. 662 // ── Barnes-Hut O(n log n) repulsion ────────────────────────────────────────── 663 664 const BH_THETA: f64 = 0.9; // opening angle; lower = more accurate, higher = faster 665 666 struct BHCell { 667 x0: f64, y0: f64, x1: f64, y1: f64, // bounds 668 mass: f64, 669 cx: f64, cy: f64, // center of mass 670 ch: [u32; 4], // children; u32::MAX = absent 671 body: i32, // ≥0 leaf idx, -1 internal, -2 empty 672 } 673 674 impl BHCell { 675 fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self { 676 Self { x0, y0, x1, y1, mass: 0.0, cx: 0.0, cy: 0.0, ch: [u32::MAX; 4], body: -2 } 677 } 678 fn quad(&self, x: f64, y: f64) -> usize { 679 let mx = (self.x0 + self.x1) * 0.5; 680 let my = (self.y0 + self.y1) * 0.5; 681 (x >= mx) as usize | (((y >= my) as usize) << 1) 682 } 683 fn child_bounds(&self, q: usize) -> (f64, f64, f64, f64) { 684 let mx = (self.x0 + self.x1) * 0.5; 685 let my = (self.y0 + self.y1) * 0.5; 686 match q { 687 0 => (self.x0, self.y0, mx, my ), 688 1 => (mx, self.y0, self.x1, my ), 689 2 => (self.x0, my, mx, self.y1 ), 690 _ => (mx, my, self.x1, self.y1 ), 691 } 692 } 693 } 694 695 fn bh_insert(pool: &mut Vec<BHCell>, start: usize, bx: f64, by: f64, bi: usize) { 696 let mut ci = start; 697 loop { 698 match pool[ci].body { 699 -2 => { 700 pool[ci].body = bi as i32; 701 pool[ci].mass = 1.0; 702 pool[ci].cx = bx; 703 pool[ci].cy = by; 704 return; 705 } 706 -1 => { 707 let m = pool[ci].mass; 708 pool[ci].cx = (pool[ci].cx * m + bx) / (m + 1.0); 709 pool[ci].cy = (pool[ci].cy * m + by) / (m + 1.0); 710 pool[ci].mass = m + 1.0; 711 let q = pool[ci].quad(bx, by); 712 let ch = pool[ci].ch[q]; 713 if ch == u32::MAX { 714 let (cx0, cy0, cx1, cy1) = pool[ci].child_bounds(q); 715 let new = pool.len() as u32; 716 pool.push(BHCell::new(cx0, cy0, cx1, cy1)); 717 pool[ci].ch[q] = new; 718 ci = new as usize; 719 } else { 720 ci = ch as usize; 721 } 722 } 723 _ => { 724 // Leaf → split into internal, then re-insert both bodies 725 let ob = pool[ci].body as usize; 726 let (ox, oy) = (pool[ci].cx, pool[ci].cy); 727 if (ox - bx).abs() + (oy - by).abs() < 1e-9 { 728 pool[ci].mass += 1.0; // coincident: just accumulate mass 729 return; 730 } 731 pool[ci].body = -1; 732 pool[ci].ch = [u32::MAX; 4]; 733 pool[ci].mass = 0.0; 734 bh_insert(pool, ci, ox, oy, ob); 735 bh_insert(pool, ci, bx, by, bi); 736 return; 737 } 738 } 739 } 740 } 741 742 fn bh_force(pool: &[BHCell], bx: f64, by: f64, self_bi: usize, stack: &mut Vec<usize>) -> (f64, f64) { 743 let t2 = BH_THETA * BH_THETA; 744 stack.clear(); 745 stack.push(0); 746 let (mut fx, mut fy) = (0.0f64, 0.0f64); 747 while let Some(ci) = stack.pop() { 748 let c = &pool[ci]; 749 if c.body == -2 || c.body == self_bi as i32 { continue; } 750 let dx = c.cx - bx; 751 let dy = c.cy - by; 752 let d2 = dx * dx + dy * dy; 753 if d2 < 0.01 { continue; } 754 let size = (c.x1 - c.x0).max(c.y1 - c.y0); 755 if c.body >= 0 || size * size < t2 * d2 { 756 let d = d2.sqrt(); 757 let f = REPULSION * c.mass / (d2 * d); 758 fx -= dx * f; 759 fy -= dy * f; 760 } else { 761 for &ch in &c.ch { if ch != u32::MAX { stack.push(ch as usize); } } 762 } 763 } 764 (fx, fy) 765 } 766 767 fn bh_build(pts: &[(f64, f64)]) -> Vec<BHCell> { 768 let n = pts.len(); 769 if n == 0 { return vec![]; } 770 let (mut x0, mut x1, mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); 771 for &(x, y) in pts { 772 x0 = x0.min(x); x1 = x1.max(x); 773 y0 = y0.min(y); y1 = y1.max(y); 774 } 775 let pad = 100.0; 776 let mut pool = Vec::with_capacity(n * 4); 777 pool.push(BHCell::new(x0 - pad, y0 - pad, x1 + pad, y1 + pad)); 778 for (i, &(x, y)) in pts.iter().enumerate() { bh_insert(&mut pool, 0, x, y, i); } 779 pool 780 } 781 782 fn prerelax( 783 mut pos: Vec<(f64, f64)>, 784 edges: &[(usize, usize)], 785 n: usize, 786 ) -> Vec<(f64, f64)> { 787 if n == 0 { return pos; } 788 let steps = (2_000_000usize / (n * n).max(1)).clamp(30, 240); 789 let mut vel: Vec<(f64, f64)> = vec![(0.0, 0.0); n]; 790 let dt = 1.0 / 60.0_f64; 791 let damp = DAMPING.powf(dt / BASE_DT); 792 let max_r = (n as f64 * 15.0).max(350.0); 793 const MV: f64 = 650.0; 794 795 for _ in 0..steps { 796 let mut fx = vec![0.0f64; n]; 797 let mut fy = vec![0.0f64; n]; 798 799 { 800 let bh = bh_build(&pos); 801 let rep_forces: Vec<(f64, f64)> = pos.par_iter().enumerate() 802 .map_with(Vec::with_capacity(64), |stk, (i, &(x, y))| bh_force(&bh, x, y, i, stk)) 803 .collect(); 804 for (i, (rfx, rfy)) in rep_forces.into_iter().enumerate() { 805 fx[i] += rfx; fy[i] += rfy; 806 } 807 } 808 for &(si, di) in edges { 809 let dx = pos[di].0 - pos[si].0; 810 let dy = pos[di].1 - pos[si].1; 811 let dist = dx.hypot(dy).max(1e-4); 812 let f = SPRING_K * (dist - SPRING_L) / dist; 813 fx[si] += dx * f; fy[si] += dy * f; 814 fx[di] -= dx * f; fy[di] -= dy * f; 815 } 816 for i in 0..n { 817 vel[i].0 = (vel[i].0 + (fx[i] - pos[i].0 * GRAVITY) * dt) * damp; 818 vel[i].1 = (vel[i].1 + (fy[i] - pos[i].1 * GRAVITY) * dt) * damp; 819 let spd = vel[i].0.hypot(vel[i].1); 820 if spd > MV { vel[i].0 = vel[i].0 / spd * MV; vel[i].1 = vel[i].1 / spd * MV; } 821 pos[i].0 += vel[i].0 * dt; 822 pos[i].1 += vel[i].1 * dt; 823 let d = pos[i].0.hypot(pos[i].1); 824 if d > max_r { let s = max_r / d; pos[i].0 *= s; pos[i].1 *= s; vel[i] = (0.0, 0.0); } 825 } 826 } 827 pos 828 } 829 830 // ── helpers ─────────────────────────────────────────────────────────────────── 831 832 fn lcg_uniform(seed: u64) -> f64 { 833 let x = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); 834 (x >> 11) as f64 / (1u64 << 53) as f64 835 } 836 837 fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [u8; 3] { 838 let i = (h * 6.0) as u32; 839 let f = h * 6.0 - i as f64; 840 let p = v * (1.0 - s); 841 let q = v * (1.0 - f * s); 842 let t = v * (1.0 - (1.0 - f) * s); 843 let (r, g, b) = match i % 6 { 0=>(v,t,p), 1=>(q,v,p), 2=>(p,v,t), 3=>(p,q,v), 4=>(t,p,v), _=>(v,p,q) }; 844 [(r*255.0) as u8, (g*255.0) as u8, (b*255.0) as u8] 845 }