Initial hybrid graph layout crate

This commit is contained in:
Meta Vens 2026-05-07 01:55:48 +01:00
commit ff7e87b535
7 changed files with 646 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

7
Cargo.lock generated Normal file
View 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
View 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
View 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
View 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
View 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
View 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());
}
}