navi

Obsidian-style interactive graph viewer for org-roam — native window, no Emacs package required.
Log | Files | Refs | README

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 }