Initial hybrid graph layout crate
This commit is contained in:
commit
ff7e87b535
7 changed files with 646 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
||||||
7
Cargo.lock
generated
Normal file
7
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "epiphany-graph-rs"
|
||||||
|
version = "0.1.0"
|
||||||
11
Cargo.toml
Normal file
11
Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "epiphany-graph-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Hybrid Sugiyama/force-directed graph layout constraints in fast, deterministic Rust."
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/GameCult/epiphany-graph-rs"
|
||||||
|
keywords = ["graph", "layout", "sugiyama", "force-directed"]
|
||||||
|
categories = ["algorithms", "visualization"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
13
LICENSE-APACHE
Normal file
13
LICENSE-APACHE
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
Copyright 2026 GameCult
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
21
LICENSE-MIT
Normal file
21
LICENSE-MIT
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 GameCult
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
52
README.md
Normal file
52
README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# epiphany-graph-rs
|
||||||
|
|
||||||
|
Hybrid graph layout for DAGs that still want to breathe.
|
||||||
|
|
||||||
|
This crate treats Sugiyama as a constraint generator instead of a final layout.
|
||||||
|
The Sugiyama pass assigns ranks and same-rank order; the force pass relaxes
|
||||||
|
those constraints with springs, repulsion, collision, and edge direction
|
||||||
|
pressure.
|
||||||
|
|
||||||
|
The result is a spectrum:
|
||||||
|
|
||||||
|
- force-dominant for organic structure
|
||||||
|
- Sugiyama-dominant for hierarchical DAGs
|
||||||
|
- hybrid for the usual graph reality, where purity gets mugged in the hallway
|
||||||
|
|
||||||
|
## Current Pipeline
|
||||||
|
|
||||||
|
1. Assign ranks from directed edges using a longest-path DAG pass.
|
||||||
|
2. Preserve partial hierarchy for cyclic leftovers with local edge pressure.
|
||||||
|
3. Order nodes inside each rank with repeated barycenter sweeps.
|
||||||
|
4. Generate rank, order, and edge-direction constraints.
|
||||||
|
5. Relax the graph with deterministic force iterations.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use epiphany_graph_rs::{layout, Graph, LayoutConfig};
|
||||||
|
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let a = graph.add_node(1.0);
|
||||||
|
let b = graph.add_node(1.0);
|
||||||
|
let c = graph.add_node(1.0);
|
||||||
|
|
||||||
|
graph.add_edge(a, b);
|
||||||
|
graph.add_edge(b, c);
|
||||||
|
|
||||||
|
let result = layout(&graph, &LayoutConfig::default());
|
||||||
|
|
||||||
|
for node in result.nodes {
|
||||||
|
println!("{:?}: ({}, {}) rank={}", node.id, node.x, node.y, node.rank);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The first release favors a small deterministic core over a sprawling layout
|
||||||
|
cathedral. The public API exposes generated constraints so callers can inspect,
|
||||||
|
reuse, tune, or replace the force relaxation phase later.
|
||||||
|
|
||||||
|
The current repulsion pass is exact pairwise repulsion. That is simple and
|
||||||
|
stable for modest graphs; larger graphs should move toward a Barnes-Hut or grid
|
||||||
|
approximation before pretending heroism is a scalability plan.
|
||||||
541
src/lib.rs
Normal file
541
src/lib.rs
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
//! Hybrid Sugiyama/force-directed graph layout.
|
||||||
|
//!
|
||||||
|
//! Sugiyama is used here as a constraint generator: it supplies ranks, local
|
||||||
|
//! ordering, and edge direction pressure. A deterministic force solver then
|
||||||
|
//! relaxes those constraints alongside springs, repulsion, and collision.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Index of a node in a [`Graph`].
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct NodeId(pub usize);
|
||||||
|
|
||||||
|
/// Index of an edge in a [`Graph`].
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct EdgeId(pub usize);
|
||||||
|
|
||||||
|
/// A directed graph with optional per-node weight.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Graph {
|
||||||
|
node_weights: Vec<f32>,
|
||||||
|
edges: Vec<(NodeId, NodeId)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Graph {
|
||||||
|
/// Create an empty graph.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty graph with capacity for nodes and edges.
|
||||||
|
pub fn with_capacity(nodes: usize, edges: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
node_weights: Vec::with_capacity(nodes),
|
||||||
|
edges: Vec::with_capacity(edges),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a node and return its stable index.
|
||||||
|
pub fn add_node(&mut self, weight: f32) -> NodeId {
|
||||||
|
let id = NodeId(self.node_weights.len());
|
||||||
|
self.node_weights.push(weight.max(0.001));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a directed edge.
|
||||||
|
pub fn add_edge(&mut self, source: NodeId, target: NodeId) -> EdgeId {
|
||||||
|
assert!(source.0 < self.node_count(), "source node is out of bounds");
|
||||||
|
assert!(target.0 < self.node_count(), "target node is out of bounds");
|
||||||
|
let id = EdgeId(self.edges.len());
|
||||||
|
self.edges.push((source, target));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of nodes.
|
||||||
|
pub fn node_count(&self) -> usize {
|
||||||
|
self.node_weights.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of edges.
|
||||||
|
pub fn edge_count(&self) -> usize {
|
||||||
|
self.edges.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directed edges as `(source, target)` pairs.
|
||||||
|
pub fn edges(&self) -> &[(NodeId, NodeId)] {
|
||||||
|
&self.edges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunable parameters for hybrid layout.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LayoutConfig {
|
||||||
|
/// Number of force relaxation iterations.
|
||||||
|
pub iterations: usize,
|
||||||
|
/// Distance between Sugiyama ranks.
|
||||||
|
pub rank_gap: f32,
|
||||||
|
/// Minimum same-rank spacing.
|
||||||
|
pub node_gap: f32,
|
||||||
|
/// Preferred spring length for one-rank edges.
|
||||||
|
pub edge_length: f32,
|
||||||
|
/// Force pulling nodes toward their Sugiyama rank coordinate.
|
||||||
|
pub rank_strength: f32,
|
||||||
|
/// Force preserving Sugiyama order inside each rank.
|
||||||
|
pub order_strength: f32,
|
||||||
|
/// Force encouraging edges to travel forward through ranks.
|
||||||
|
pub edge_direction_strength: f32,
|
||||||
|
/// Edge spring force.
|
||||||
|
pub spring_strength: f32,
|
||||||
|
/// Pairwise repulsion force.
|
||||||
|
pub repulsion_strength: f32,
|
||||||
|
/// Collision separation force.
|
||||||
|
pub collision_strength: f32,
|
||||||
|
/// Initial and maximum per-iteration step size.
|
||||||
|
pub step_size: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LayoutConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
iterations: 350,
|
||||||
|
rank_gap: 140.0,
|
||||||
|
node_gap: 80.0,
|
||||||
|
edge_length: 120.0,
|
||||||
|
rank_strength: 0.12,
|
||||||
|
order_strength: 0.08,
|
||||||
|
edge_direction_strength: 0.04,
|
||||||
|
spring_strength: 0.035,
|
||||||
|
repulsion_strength: 4800.0,
|
||||||
|
collision_strength: 0.35,
|
||||||
|
step_size: 0.75,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output for one laid-out node.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct NodeLayout {
|
||||||
|
pub id: NodeId,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub rank: usize,
|
||||||
|
pub order: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full layout result plus generated Sugiyama-derived constraints.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Layout {
|
||||||
|
pub nodes: Vec<NodeLayout>,
|
||||||
|
pub constraints: ConstraintSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constraints generated by the Sugiyama pass.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct ConstraintSet {
|
||||||
|
pub ranks: Vec<RankConstraint>,
|
||||||
|
pub orders: Vec<OrderConstraint>,
|
||||||
|
pub edge_directions: Vec<EdgeDirectionConstraint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct RankConstraint {
|
||||||
|
pub node: NodeId,
|
||||||
|
pub rank: usize,
|
||||||
|
pub target_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct OrderConstraint {
|
||||||
|
pub left: NodeId,
|
||||||
|
pub right: NodeId,
|
||||||
|
pub min_gap: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct EdgeDirectionConstraint {
|
||||||
|
pub source: NodeId,
|
||||||
|
pub target: NodeId,
|
||||||
|
pub min_delta_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a hybrid layout.
|
||||||
|
pub fn layout(graph: &Graph, config: &LayoutConfig) -> Layout {
|
||||||
|
let n = graph.node_count();
|
||||||
|
if n == 0 {
|
||||||
|
return Layout {
|
||||||
|
nodes: Vec::new(),
|
||||||
|
constraints: ConstraintSet::default(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ranks = assign_ranks(graph);
|
||||||
|
let orders = order_ranks(graph, &ranks);
|
||||||
|
let constraints = build_constraints(graph, &ranks, &orders, config);
|
||||||
|
let mut positions = initial_positions(&ranks, &orders, config);
|
||||||
|
|
||||||
|
relax(graph, &constraints, config, &mut positions);
|
||||||
|
|
||||||
|
let mut nodes = (0..n)
|
||||||
|
.map(|idx| NodeLayout {
|
||||||
|
id: NodeId(idx),
|
||||||
|
x: positions[idx].0,
|
||||||
|
y: positions[idx].1,
|
||||||
|
rank: ranks[idx],
|
||||||
|
order: orders[idx],
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
nodes.sort_by_key(|node| node.id.0);
|
||||||
|
|
||||||
|
Layout { nodes, constraints }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_ranks(graph: &Graph) -> Vec<usize> {
|
||||||
|
let n = graph.node_count();
|
||||||
|
let mut indegree = vec![0usize; n];
|
||||||
|
let mut outgoing = vec![Vec::new(); n];
|
||||||
|
|
||||||
|
for &(source, target) in graph.edges() {
|
||||||
|
outgoing[source.0].push(target.0);
|
||||||
|
indegree[target.0] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ranks = vec![0usize; n];
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
for (idx, degree) in indegree.iter().enumerate() {
|
||||||
|
if *degree == 0 {
|
||||||
|
queue.push_back(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited = 0usize;
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
visited += 1;
|
||||||
|
let next_rank = ranks[node] + 1;
|
||||||
|
for &target in &outgoing[node] {
|
||||||
|
ranks[target] = ranks[target].max(next_rank);
|
||||||
|
indegree[target] -= 1;
|
||||||
|
if indegree[target] == 0 {
|
||||||
|
queue.push_back(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited == n {
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cyclic leftovers keep their best discovered rank and get nudged by
|
||||||
|
// local edge pressure. This preserves partial DAG structure without
|
||||||
|
// pretending cycles can be layered perfectly.
|
||||||
|
for _ in 0..n.min(32) {
|
||||||
|
let mut changed = false;
|
||||||
|
for &(source, target) in graph.edges() {
|
||||||
|
if ranks[target.0] <= ranks[source.0] {
|
||||||
|
ranks[target.0] = ranks[source.0] + 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_ranks(&mut ranks);
|
||||||
|
ranks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_ranks(ranks: &mut [usize]) {
|
||||||
|
let mut used = ranks.to_vec();
|
||||||
|
used.sort_unstable();
|
||||||
|
used.dedup();
|
||||||
|
|
||||||
|
for rank in ranks {
|
||||||
|
*rank = used.binary_search(rank).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_ranks(graph: &Graph, ranks: &[usize]) -> Vec<usize> {
|
||||||
|
let n = graph.node_count();
|
||||||
|
let max_rank = ranks.iter().copied().max().unwrap_or(0);
|
||||||
|
let mut by_rank = vec![Vec::new(); max_rank + 1];
|
||||||
|
for (node, &rank) in ranks.iter().enumerate() {
|
||||||
|
by_rank[rank].push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut orders = vec![0usize; n];
|
||||||
|
for rank_nodes in &by_rank {
|
||||||
|
for (order, &node) in rank_nodes.iter().enumerate() {
|
||||||
|
orders[node] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut neighbors = vec![Vec::<usize>::new(); n];
|
||||||
|
for &(source, target) in graph.edges() {
|
||||||
|
neighbors[source.0].push(target.0);
|
||||||
|
neighbors[target.0].push(source.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
for rank_nodes in &mut by_rank {
|
||||||
|
rank_nodes.sort_by(|&a, &b| {
|
||||||
|
let ba = barycenter(a, &neighbors, &orders);
|
||||||
|
let bb = barycenter(b, &neighbors, &orders);
|
||||||
|
ba.total_cmp(&bb).then_with(|| a.cmp(&b))
|
||||||
|
});
|
||||||
|
for (order, &node) in rank_nodes.iter().enumerate() {
|
||||||
|
orders[node] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for rank_nodes in by_rank.iter_mut().rev() {
|
||||||
|
rank_nodes.sort_by(|&a, &b| {
|
||||||
|
let ba = barycenter(a, &neighbors, &orders);
|
||||||
|
let bb = barycenter(b, &neighbors, &orders);
|
||||||
|
ba.total_cmp(&bb).then_with(|| a.cmp(&b))
|
||||||
|
});
|
||||||
|
for (order, &node) in rank_nodes.iter().enumerate() {
|
||||||
|
orders[node] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orders
|
||||||
|
}
|
||||||
|
|
||||||
|
fn barycenter(node: usize, neighbors: &[Vec<usize>], orders: &[usize]) -> f32 {
|
||||||
|
let adjacent = &neighbors[node];
|
||||||
|
if adjacent.is_empty() {
|
||||||
|
return orders[node] as f32;
|
||||||
|
}
|
||||||
|
let sum = adjacent
|
||||||
|
.iter()
|
||||||
|
.map(|&other| orders[other] as f32)
|
||||||
|
.sum::<f32>();
|
||||||
|
sum / adjacent.len() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_constraints(
|
||||||
|
graph: &Graph,
|
||||||
|
ranks: &[usize],
|
||||||
|
orders: &[usize],
|
||||||
|
config: &LayoutConfig,
|
||||||
|
) -> ConstraintSet {
|
||||||
|
let mut constraints = ConstraintSet::default();
|
||||||
|
|
||||||
|
for (idx, &rank) in ranks.iter().enumerate() {
|
||||||
|
constraints.ranks.push(RankConstraint {
|
||||||
|
node: NodeId(idx),
|
||||||
|
rank,
|
||||||
|
target_y: rank as f32 * config.rank_gap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_rank = ranks.iter().copied().max().unwrap_or(0);
|
||||||
|
for rank in 0..=max_rank {
|
||||||
|
let mut nodes = ranks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, &node_rank)| (node_rank == rank).then_some(idx))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
nodes.sort_by_key(|&node| orders[node]);
|
||||||
|
for pair in nodes.windows(2) {
|
||||||
|
constraints.orders.push(OrderConstraint {
|
||||||
|
left: NodeId(pair[0]),
|
||||||
|
right: NodeId(pair[1]),
|
||||||
|
min_gap: config.node_gap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for &(source, target) in graph.edges() {
|
||||||
|
let span = ranks[target.0].saturating_sub(ranks[source.0]).max(1);
|
||||||
|
constraints.edge_directions.push(EdgeDirectionConstraint {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
min_delta_y: span as f32 * config.rank_gap * 0.65,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_positions(ranks: &[usize], orders: &[usize], config: &LayoutConfig) -> Vec<(f32, f32)> {
|
||||||
|
ranks
|
||||||
|
.iter()
|
||||||
|
.zip(orders.iter())
|
||||||
|
.map(|(&rank, &order)| {
|
||||||
|
(
|
||||||
|
order as f32 * config.node_gap,
|
||||||
|
rank as f32 * config.rank_gap,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax(
|
||||||
|
graph: &Graph,
|
||||||
|
constraints: &ConstraintSet,
|
||||||
|
config: &LayoutConfig,
|
||||||
|
positions: &mut [(f32, f32)],
|
||||||
|
) {
|
||||||
|
let n = graph.node_count();
|
||||||
|
let mut forces = vec![(0.0f32, 0.0f32); n];
|
||||||
|
|
||||||
|
for iter in 0..config.iterations {
|
||||||
|
forces.fill((0.0, 0.0));
|
||||||
|
|
||||||
|
apply_repulsion(positions, &mut forces, config.repulsion_strength);
|
||||||
|
apply_springs(graph, positions, &mut forces, config);
|
||||||
|
apply_constraints(constraints, positions, &mut forces, config);
|
||||||
|
|
||||||
|
let cooling = 1.0 - (iter as f32 / config.iterations.max(1) as f32);
|
||||||
|
let step = config.step_size * cooling.max(0.08);
|
||||||
|
for (position, force) in positions.iter_mut().zip(forces.iter()) {
|
||||||
|
position.0 += clamp(force.0, -24.0, 24.0) * step;
|
||||||
|
position.1 += clamp(force.1, -24.0, 24.0) * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_repulsion(positions: &[(f32, f32)], forces: &mut [(f32, f32)], strength: f32) {
|
||||||
|
for a in 0..positions.len() {
|
||||||
|
for b in (a + 1)..positions.len() {
|
||||||
|
let dx = positions[a].0 - positions[b].0;
|
||||||
|
let dy = positions[a].1 - positions[b].1;
|
||||||
|
let dist2 = (dx * dx + dy * dy).max(0.01);
|
||||||
|
let dist = dist2.sqrt();
|
||||||
|
let magnitude = strength / dist2;
|
||||||
|
let fx = dx / dist * magnitude;
|
||||||
|
let fy = dy / dist * magnitude;
|
||||||
|
forces[a].0 += fx;
|
||||||
|
forces[a].1 += fy;
|
||||||
|
forces[b].0 -= fx;
|
||||||
|
forces[b].1 -= fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_springs(
|
||||||
|
graph: &Graph,
|
||||||
|
positions: &[(f32, f32)],
|
||||||
|
forces: &mut [(f32, f32)],
|
||||||
|
config: &LayoutConfig,
|
||||||
|
) {
|
||||||
|
for &(source, target) in graph.edges() {
|
||||||
|
let a = source.0;
|
||||||
|
let b = target.0;
|
||||||
|
let dx = positions[b].0 - positions[a].0;
|
||||||
|
let dy = positions[b].1 - positions[a].1;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||||
|
let delta = dist - config.edge_length;
|
||||||
|
let magnitude = delta * config.spring_strength;
|
||||||
|
let fx = dx / dist * magnitude;
|
||||||
|
let fy = dy / dist * magnitude;
|
||||||
|
forces[a].0 += fx;
|
||||||
|
forces[a].1 += fy;
|
||||||
|
forces[b].0 -= fx;
|
||||||
|
forces[b].1 -= fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_constraints(
|
||||||
|
constraints: &ConstraintSet,
|
||||||
|
positions: &[(f32, f32)],
|
||||||
|
forces: &mut [(f32, f32)],
|
||||||
|
config: &LayoutConfig,
|
||||||
|
) {
|
||||||
|
for constraint in &constraints.ranks {
|
||||||
|
let node = constraint.node.0;
|
||||||
|
forces[node].1 += (constraint.target_y - positions[node].1) * config.rank_strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
for constraint in &constraints.orders {
|
||||||
|
let left = constraint.left.0;
|
||||||
|
let right = constraint.right.0;
|
||||||
|
let gap = positions[right].0 - positions[left].0;
|
||||||
|
if gap < constraint.min_gap {
|
||||||
|
let push = (constraint.min_gap - gap) * 0.5 * config.order_strength;
|
||||||
|
forces[left].0 -= push;
|
||||||
|
forces[right].0 += push;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for constraint in &constraints.edge_directions {
|
||||||
|
let source = constraint.source.0;
|
||||||
|
let target = constraint.target.0;
|
||||||
|
let delta = positions[target].1 - positions[source].1;
|
||||||
|
if delta < constraint.min_delta_y {
|
||||||
|
let push = (constraint.min_delta_y - delta) * 0.5 * config.edge_direction_strength;
|
||||||
|
forces[source].1 -= push;
|
||||||
|
forces[target].1 += push;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for constraint in &constraints.orders {
|
||||||
|
let left = constraint.left.0;
|
||||||
|
let right = constraint.right.0;
|
||||||
|
let dx = positions[right].0 - positions[left].0;
|
||||||
|
let dy = positions[right].1 - positions[left].1;
|
||||||
|
let min_dist = constraint.min_gap * 0.75;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||||
|
if dist < min_dist {
|
||||||
|
let push = (min_dist - dist) * config.collision_strength;
|
||||||
|
let fx = dx / dist * push;
|
||||||
|
let fy = dy / dist * push;
|
||||||
|
forces[left].0 -= fx;
|
||||||
|
forces[left].1 -= fy;
|
||||||
|
forces[right].0 += fx;
|
||||||
|
forces[right].1 += fy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||||
|
value.max(min).min(max)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dag_edges_receive_forward_ranks() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let a = graph.add_node(1.0);
|
||||||
|
let b = graph.add_node(1.0);
|
||||||
|
let c = graph.add_node(1.0);
|
||||||
|
graph.add_edge(a, b);
|
||||||
|
graph.add_edge(b, c);
|
||||||
|
|
||||||
|
let result = layout(&graph, &LayoutConfig::default());
|
||||||
|
|
||||||
|
assert_eq!(result.nodes[a.0].rank, 0);
|
||||||
|
assert_eq!(result.nodes[b.0].rank, 1);
|
||||||
|
assert_eq!(result.nodes[c.0].rank, 2);
|
||||||
|
assert!(result.nodes[c.0].y > result.nodes[a.0].y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_rank_order_constraints_are_generated() {
|
||||||
|
let mut graph = Graph::new();
|
||||||
|
let a = graph.add_node(1.0);
|
||||||
|
let b = graph.add_node(1.0);
|
||||||
|
let c = graph.add_node(1.0);
|
||||||
|
graph.add_edge(a, c);
|
||||||
|
graph.add_edge(b, c);
|
||||||
|
|
||||||
|
let result = layout(&graph, &LayoutConfig::default());
|
||||||
|
|
||||||
|
assert_eq!(result.constraints.orders.len(), 1);
|
||||||
|
assert_eq!(result.nodes[a.0].rank, result.nodes[b.0].rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_graph_layout_is_empty() {
|
||||||
|
let graph = Graph::new();
|
||||||
|
let result = layout(&graph, &LayoutConfig::default());
|
||||||
|
|
||||||
|
assert!(result.nodes.is_empty());
|
||||||
|
assert!(result.constraints.ranks.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in a new issue