layout.rs
8.4 kB · rust · 255 lines
1use super::models::Network;2use crate::core::error::{value_error, Result};3use crate::core::Rng;45const SPAN: usize = 300;6const FLOOR: f64 = 0.02;7const GAIN: f64 = 0.1;8const JITTER: f64 = 0.05;9const APART: f64 = 1e-9;1011/// A force-directed layout: every node repels every other, every branch pulls its ends together, and a cooling cap on the move per tick lets the lattice settle.12///13/// The forces are Fruchterman and Reingold's: repulsion `k^2 / d` between every pair, attraction14/// `d^2 / k` along every branch, with `k` the ideal length `extent / n^(1/dim)` read off the15/// starting box. The temperature caps the move of any node in one tick and cools linearly from16/// a tenth of the extent to a floor over the first ticks, then holds, so the layout keeps17/// creeping toward rest. The seed jitters the start so a symmetric lattice can fold.18///19/// ```20/// let square = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0];21/// let ring = [(0, 1), (1, 2), (2, 3), (3, 0)];22/// let mut layout = mrlyrs::math::graph::Layout::new(&square, &ring, 2, 1).unwrap();23/// assert!(layout.step(500) < 1e-3);24/// ```25#[derive(Clone)]26pub struct Layout {27 dim: usize,28 positions: Vec<f64>,29 branches: Vec<(usize, usize)>,30 ideal: f64,31 heat: f64,32 tick: usize,33 energy: f64,34 moved: f64,35 rng: Rng,36}3738impl Layout {39 /// Starts a layout from flat positions, `dim` floats per node, and the branch pairs.40 ///41 /// # Errors42 ///43 /// Errors when a branch points off the list, or the positions are not dim floats a node.44 pub fn new(45 positions: &[f64],46 branches: &[(usize, usize)],47 dim: usize,48 seed: u64,49 ) -> Result<Layout> {50 if dim == 0 || !positions.len().is_multiple_of(dim) {51 return value_error("positions must hold dim floats per node.");52 }53 let count = positions.len() / dim;54 if branches.iter().any(|&(a, b)| a >= count || b >= count) {55 return value_error("a branch points past the last node.");56 }57 let mut extent: f64 = 0.0;58 for axis in 0..dim {59 let column = positions.iter().skip(axis).step_by(dim).copied();60 let low = column.clone().fold(f64::INFINITY, f64::min);61 let high = column.fold(f64::NEG_INFINITY, f64::max);62 extent = extent.max(high - low);63 }64 if extent <= 0.0 || extent.is_nan() {65 extent = 1.0;66 }67 let ideal = extent / (count.max(1) as f64).powf(1.0 / dim as f64);68 let mut rng = Rng::new(seed);69 let positions = positions70 .iter()71 .map(|&p| p + (rng.unit() - 0.5) * 2.0 * JITTER * ideal)72 .collect();73 Ok(Layout {74 dim,75 positions,76 branches: branches.to_vec(),77 ideal,78 heat: extent / 10.0,79 tick: 0,80 energy: 0.0,81 moved: 0.0,82 rng,83 })84 }85 /// Starts a layout from a network's own positions and branches.86 ///87 /// # Errors88 ///89 /// Errors when a branch names a node the network does not hold.90 pub fn from_network(network: &Network, seed: u64) -> Result<Layout> {91 let positions: Vec<f64> = network92 .nodes93 .iter()94 .flat_map(|node| node.position.iter().copied())95 .collect();96 let branches: Vec<(usize, usize)> = network97 .branches98 .iter()99 .map(|b| (b.parent, b.child))100 .collect();101 Layout::new(&positions, &branches, network.dim, seed)102 }103 /// Returns the node count.104 pub fn nodes(&self) -> usize {105 self.positions.len() / self.dim106 }107 /// Returns the ideal branch length `k`.108 pub fn ideal(&self) -> f64 {109 self.ideal110 }111 /// Returns the cap on one node's move in the next tick.112 pub fn temperature(&self) -> f64 {113 let cooled = 1.0 - self.tick.min(SPAN) as f64 / SPAN as f64;114 self.heat * cooled.max(FLOOR)115 }116 /// Returns the ticks stepped so far.117 pub fn ticks(&self) -> usize {118 self.tick119 }120 /// Runs the ticks and returns the energy left: the mean net force per node in units of `k`.121 pub fn step(&mut self, ticks: usize) -> f64 {122 for _ in 0..ticks {123 self.tick_once();124 }125 self.energy126 }127 fn tick_once(&mut self) {128 let (dim, n, k) = (self.dim, self.nodes(), self.ideal);129 let mut push = vec![0.0; n * dim];130 let mut delta = vec![0.0; dim];131 for i in 0..n {132 for j in (i + 1)..n {133 let d = gap(&self.positions, dim, i, j, &mut delta, &mut self.rng);134 let f = k * k / (d * d);135 for a in 0..dim {136 push[i * dim + a] += delta[a] * f;137 push[j * dim + a] -= delta[a] * f;138 }139 }140 }141 for &(i, j) in &self.branches {142 let d = gap(&self.positions, dim, i, j, &mut delta, &mut self.rng);143 let f = d / k;144 for a in 0..dim {145 push[i * dim + a] -= delta[a] * f;146 push[j * dim + a] += delta[a] * f;147 }148 }149 let cap = self.temperature();150 let (mut force, mut moved) = (0.0, 0.0);151 for i in 0..n {152 let len = (0..dim)153 .map(|a| push[i * dim + a].powi(2))154 .sum::<f64>()155 .sqrt();156 force += len;157 if len == 0.0 {158 continue;159 }160 let reach = (len * GAIN).min(cap);161 moved += reach;162 for a in 0..dim {163 self.positions[i * dim + a] += push[i * dim + a] / len * reach;164 }165 }166 self.energy = force / (n.max(1) as f64 * k);167 self.moved = moved / n.max(1) as f64;168 self.tick += 1;169 }170 /// Returns the positions, `dim` floats per node.171 pub fn positions(&self) -> &[f64] {172 &self.positions173 }174 /// Returns the mean net force per node in units of `k` after the last tick.175 pub fn energy(&self) -> f64 {176 self.energy177 }178 /// Returns the mean distance a node moved in the last tick.179 pub fn moved(&self) -> f64 {180 self.moved181 }182}183184fn gap(positions: &[f64], dim: usize, i: usize, j: usize, delta: &mut [f64], rng: &mut Rng) -> f64 {185 for a in 0..dim {186 delta[a] = positions[i * dim + a] - positions[j * dim + a];187 }188 let d = delta.iter().map(|x| x * x).sum::<f64>().sqrt();189 if d >= APART {190 return d;191 }192 for x in delta.iter_mut() {193 *x = (rng.unit() - 0.5) * APART;194 }195 delta196 .iter()197 .map(|x| x * x)198 .sum::<f64>()199 .sqrt()200 .max(APART / 2.0)201}202203#[cfg(test)]204mod tests {205 use super::*;206 fn ring() -> Layout {207 let square = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0];208 let ring = [(0, 1), (1, 2), (2, 3), (3, 0)];209 Layout::new(&square, &ring, 2, 1).unwrap()210 }211 #[test]212 fn the_four_cycle_relaxes_to_a_square() {213 let mut layout = ring();214 assert!((layout.ideal() - 0.5).abs() < 1e-12);215 let rest = layout.step(500);216 assert!(rest < 1e-3, "energy {rest}");217 let p = layout.positions();218 let gaps: Vec<f64> = [(0, 1), (1, 2), (2, 3), (3, 0)]219 .iter()220 .map(|&(a, b)| {221 ((p[2 * a] - p[2 * b]).powi(2) + (p[2 * a + 1] - p[2 * b + 1]).powi(2)).sqrt()222 })223 .collect();224 let spread = gaps.iter().cloned().fold(0.0, f64::max)225 - gaps.iter().cloned().fold(f64::INFINITY, f64::min);226 assert!(spread < 1e-3, "gaps {gaps:?}");227 let side = 0.5 * 1.5f64.cbrt();228 assert!((gaps[0] - side).abs() < 1e-2, "side {}", gaps[0]);229 assert_eq!(layout.ticks(), 500);230 }231 #[test]232 fn the_seed_replays_and_the_cap_cools() {233 let mut a = ring();234 let mut b = ring();235 a.step(20);236 b.step(20);237 assert_eq!(a.positions(), b.positions());238 assert!(239 a.temperature()240 < Layout::new(&[0.0, 0.0, 1.0, 1.0], &[], 2, 1)241 .unwrap()242 .temperature()243 );244 assert!(a.moved() > 0.0);245 }246 #[test]247 fn the_faults_are_named() {248 assert!(Layout::new(&[0.0, 0.0, 1.0], &[], 2, 1).is_err());249 assert!(Layout::new(&[0.0, 0.0], &[(0, 1)], 2, 1).is_err());250 assert!(Layout::new(&[0.0, 0.0, 0.0, 0.0], &[(0, 1)], 2, 1)251 .unwrap()252 .step(3)253 .is_finite());254 }255}