Force-Directed Graph — Seaborn

A force-directed graph uses physics simulation to position nodes, where connected nodes attract each other and all nodes repel. This creates organic layouts that naturally reveal community structure, central nodes, and overall network topology without manual positioning. The algorithm balances attractive forces (edges pulling connected nodes together) and repulsive forces (nodes pushing apart) until reaching equilibrium.

Force-Directed Graph rendered with Seaborn

Python source (Seaborn)

""" anyplot.ai
network-force-directed: Force-Directed Graph
Library: seaborn 0.13.2 | Python 3.13.14
Quality: 88/100 | Updated: 2026-07-01
"""

import os

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns


# Theme-adaptive chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
EDGE_COLOR = "#4A4A44" if THEME == "light" else "#B8B7B0"

sns.set_theme(
    style="ticks",
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "axes.edgecolor": INK_SOFT,
        "axes.labelcolor": INK,
        "text.color": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
        "grid.color": INK,
        "grid.alpha": 0.10,
        "legend.facecolor": ELEVATED_BG,
        "legend.edgecolor": INK_SOFT,
    },
)

np.random.seed(42)

# Imprint categorical palette — canonical order, first series always #009E73
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data — organizational social network: 3 departments
num_nodes = 37
community_sizes = [15, 12, 10]
community_names = ["Engineering", "Marketing", "Sales"]
communities = []
for comm_idx, size in enumerate(community_sizes):
    communities.extend([comm_idx] * size)

# Generate edges with community structure
edges = []
for i in range(num_nodes):
    for j in range(i + 1, num_nodes):
        if communities[i] == communities[j]:
            if np.random.random() < 0.35:
                weight = np.random.uniform(0.5, 1.0)
                edges.append((i, j, weight))
        else:
            if np.random.random() < 0.05:
                weight = np.random.uniform(0.3, 0.7)
                edges.append((i, j, weight))

# Calculate node degrees
degrees = [0] * num_nodes
for src, tgt, _ in edges:
    degrees[src] += 1
    degrees[tgt] += 1

# Identify bridge nodes (cross-community edges)
bridge_nodes = set()
for src, tgt, _ in edges:
    if communities[src] != communities[tgt]:
        bridge_nodes.add(src)
        bridge_nodes.add(tgt)

# Force-directed layout (Fruchterman-Reingold inline)
n = num_nodes
k = 0.5
iterations = 150
pos = np.random.rand(n, 2) * 2 - 1
t = 1.0
dt = t / (iterations + 1)

for _ in range(iterations):
    disp = np.zeros((n, 2))
    for i in range(n):
        for j in range(i + 1, n):
            delta = pos[i] - pos[j]
            dist = max(np.linalg.norm(delta), 0.01)
            force = (k * k) / dist
            force_vec = (delta / dist) * force
            disp[i] += force_vec
            disp[j] -= force_vec
    for src, tgt, _ in edges:
        delta = pos[src] - pos[tgt]
        dist = max(np.linalg.norm(delta), 0.01)
        force = (dist * dist) / k
        force_vec = (delta / dist) * force
        disp[src] -= force_vec
        disp[tgt] += force_vec
    for i in range(n):
        disp_norm = max(np.linalg.norm(disp[i]), 0.01)
        pos[i] += (disp[i] / disp_norm) * min(disp_norm, t)
    t -= dt

# Normalize positions to [-1, 1]
pos -= pos.mean(axis=0)
max_coord = np.abs(pos).max()
if max_coord > 0:
    pos /= max_coord

x_coords = pos[:, 0]
y_coords = pos[:, 1]

# Node DataFrame for seaborn three-way encoding (hue + size + style)
node_df = pd.DataFrame(
    {
        "x": x_coords,
        "y": y_coords,
        "department": [community_names[c] for c in communities],
        "degree": degrees,
        "node_type": ["Bridge" if i in bridge_nodes else "Member" for i in range(num_nodes)],
    }
)

# Plot — 3200×1800 px landscape (figsize=(8,4.5) × dpi=400)
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# Edges
for src, tgt, weight in edges:
    x0, y0 = pos[src]
    x1, y1 = pos[tgt]
    ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=0.5 + weight * 1.0, alpha=0.22, zorder=1)

# Nodes via seaborn three-way encoding: hue=department, size=degree, style=node_type
# Diamond markers distinguish bridge nodes (cross-community connectors) from regular members
palette = {name: IMPRINT[i] for i, name in enumerate(community_names)}
sns.scatterplot(
    data=node_df,
    x="x",
    y="y",
    hue="department",
    hue_order=community_names,
    palette=palette,
    size="degree",
    sizes=(80, 400),
    style="node_type",
    style_order=["Member", "Bridge"],
    markers={"Member": "o", "Bridge": "D"},
    alpha=0.90,
    edgecolor=PAGE_BG,
    linewidth=0.8,
    ax=ax,
    legend=False,
    zorder=2,
)

# Label top 4 hubs by degree only — prevents center-cluster label overlap
top_hubs = sorted(range(num_nodes), key=lambda i: degrees[i], reverse=True)[:4]
for node in top_hubs:
    ax.annotate(
        f"Node {node}",
        (pos[node, 0], pos[node, 1]),
        fontsize=8,
        ha="center",
        va="bottom",
        xytext=(0, 7),
        textcoords="offset points",
        fontweight="bold",
        color=INK_SOFT,
    )

# Legend — departments (circle) + bridge node indicator (diamond)
legend_elements = []
for idx, name in enumerate(community_names):
    count = community_sizes[idx]
    legend_elements.append(
        plt.scatter(
            [], [], c=IMPRINT[idx], s=80, marker="o", label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=0.8
        )
    )
legend_elements.append(
    plt.scatter([], [], c=INK_SOFT, s=80, marker="D", edgecolor=PAGE_BG, linewidth=0.8, label="Bridge node")
)

ax.legend(
    handles=legend_elements,
    loc="upper left",
    fontsize=8,
    title="Department",
    title_fontsize=9,
    frameon=True,
    labelcolor=INK,
)

# Network summary inside the axes (bottom centre)
total_edges = len(edges)
avg_degree = sum(degrees) / num_nodes
stats_text = (
    f"Nodes: {num_nodes}  ·  Edges: {total_edges}  ·  Avg degree: {avg_degree:.1f}  ·  Bridges: {len(bridge_nodes)}"
)
ax.text(0.5, 0.02, stats_text, transform=ax.transAxes, fontsize=7, ha="center", va="bottom", color=INK_MUTED)

# Style
title = "network-force-directed · python · seaborn · anyplot.ai"
ax.set_title(title, fontsize=12, fontweight="medium", pad=10, color=INK)
ax.set_xlabel("Force-directed X", fontsize=10, color=INK)
ax.set_ylabel("Force-directed Y", fontsize=10, color=INK)
ax.set_xticks([])
ax.set_yticks([])
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color(INK_SOFT)
ax.spines["bottom"].set_color(INK_SOFT)

plt.subplots_adjust(left=0.07, right=0.97, top=0.91, bottom=0.10)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Force-Directed Graph on anyplot.ai.

Other implementations