Attraction-Repulsion Algorithm
Or how to layout graph nodes on a canvas using an iterative approach!
Or how to layout graph nodes on a canvas using an iterative approach!
The principle is as follow: two nodes linked by an edge attract themselves, but in the same time the closer two nodes are, the more they repel each other.
At each iteration we start by first adding forces that apply to each node and then we compute the new position.
Please notice that this approach gives better result when the nodes are already approximately placed near their final position (it is great to adjust the final position).
Let’s write some source-code in python
Defining what a node is
Well, let’s just say that a node is something with a center (here represented by x and y coordinates)
import random
from typing import Self
class Node:
def __init__(self, x: float, y: float):
self._x = x
self._y = y
# the vector is initially null
self._vx = 0
self._vy = 0
@classmethod
def from_random(cls) -> Self:
# helper method to get a node at a random position
return cls(random.randrange(10, 110, 1), random.randrange(10, 110, 1))
@property
def x(self) -> float:
return self._x
@property
def y(self) -> float:
return self._y
def add_vector(self, vx: float, vy: float):
self._vx += vx
self._vy += vy
def process_position(self):
self._x += self._vx
self._y += self._vy
# we reset the vectors
self._vx = 0
self._vy = 0
How to process the attraction-repulsion between two nodes
import math
NOMINAL_DISTANCE = 40 # expected "perfect" distance between two linked nodes
def process_vector(node_a: Node, node_b: Node, coef: float, linked: bool):
delta_x = node_b.x - node_a.x
delta_y = node_a.y - node_a.y
distance = math.sqrt(delta_x ** 2 + delta_y ** 2)
angle = math.atan2(delta_y, delta_x)
vector = 0
if linked:
# attraction
vector += coef
if distance <= NOMINAL_DISTANCE * 1.5:
# repulsion does not happen when nodes are far from each other
vector -= coef * NOMINAL_DISTANCE / distance
vx = math.cos(angle) * vector
vy = math.sin(angle) * vector
node_a.add_vector(vx, vy)
node_b.add_vector(-vx, -vy)
Let’s test it!
Using pillow to draw images
(In a real code you should use loops instead of calling x times process_vector and then process_position)
from PIL import Image, ImageDraw
if __name__ == '__main__':
node_a = Node.from_random()
node_b = Node.from_random()
node_c = Node.from_random()
node_d = Node.from_random()
node_e = Node.from_random()
node_f = Node.from_random()
for n in range(50):
coef = 1.0 - 1.0 * (float(n) / 50.0)
if n % 1 == 0:
im = Image.new('RGB', (120, 120), color=(255, 255, 255))
draw = ImageDraw.Draw(im)
draw.line((node_a.x, node_a.y, node_d.x, node_d.y), fill=(128, 128, 128), width=2)
draw.line((node_a.x, node_a.y, node_e.x, node_e.y), fill=(128, 128, 128), width=2)
draw.line((node_a.x, node_a.y, node_f.x, node_f.y), fill=(128, 128, 128), width=2)
draw.line((node_b.x, node_b.y, node_d.x, node_d.y), fill=(128, 128, 128), width=2)
draw.line((node_c.x, node_c.y, node_d.x, node_d.y), fill=(128, 128, 128), width=2)
draw.ellipse(
(node_a.x - 5, node_a.y - 5, node_a.x + 5, node_a.y + 5),
fill=(128, 0, 0),
outline=(0, 0, 0)
)
draw.ellipse(
(node_b.x - 5, node_b.y - 5, node_b.x + 5, node_b.y + 5),
fill=(0, 128, 0),
outline=(0, 0, 0)
)
draw.ellipse(
(node_c.x - 5, node_c.y - 5, node_c.x + 5, node_c.y + 5),
fill=(0, 0, 128),
outline=(0, 0, 0)
)
draw.ellipse(
(node_d.x - 5, node_d.y - 5, node_d.x + 5, node_d.y + 5),
fill=(128, 128, 0),
outline=(0, 0, 0)
)
draw.ellipse(
(node_e.x - 5, node_e.y - 5, node_e.x + 5, node_e.y + 5),
fill=(0, 128, 128),
outline=(0, 0, 0)
)
draw.ellipse(
(node_f.x - 5, node_f.y - 5, node_f.x + 5, node_f.y + 5),
fill=(128, 0, 128),
outline=(0, 0, 0)
)
im.save(f'image-{n:04d}.png')
process_vector(node_a, node_b, coef, False)
process_vector(node_a, node_c, coef, False)
process_vector(node_a, node_d, coef, True)
process_vector(node_a, node_e, coef, True)
process_vector(node_a, node_f, coef, True)
process_vector(node_b, node_c, coef, False)
process_vector(node_b, node_d, coef, True)
process_vector(node_b, node_e, coef, False)
process_vector(node_b, node_f, coef, False)
process_vector(node_c, node_d, coef, True)
process_vector(node_c, node_e, coef, False)
process_vector(node_c, node_f, coef, False)
process_vector(node_d, node_e, coef, False)
process_vector(node_d, node_f, coef, False)
process_vector(node_e, node_f, coef, False)
node_a.process_position()
node_b.process_position()
node_c.process_position()
node_d.process_position()
node_e.process_position()
node_f.process_position()

Initial position

Position after 10 steps.
As mentioned before, this method is great to adjust the position when the nodes are already in their approximate position. But you might want to adjust the formula used to compute both the attraction and the repulsion.
That’s all folks!