Force-Directed Graph — lets-plot

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 lets-plot

Python source (lets-plot)

""" anyplot.ai
network-force-directed: Force-Directed Graph
Library: letsplot 4.11.0 | Python 3.13.14
Quality: 86/100 | Updated: 2026-07-01
"""

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    coord_fixed,
    element_blank,
    element_rect,
    element_text,
    geom_point,
    geom_segment,
    ggplot,
    ggsize,
    labs,
    layer_tooltips,
    scale_alpha_identity,
    scale_color_manual,
    scale_size_identity,
    scale_x_continuous,
    scale_y_continuous,
    theme,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme tokens
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"
EDGE_COLOR = "#1A1A17" if THEME == "light" else "#F0EFE8"

# Imprint palette — 3 community hues (positions 1–3)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data: 50-node social network with 3 communities
np.random.seed(42)
community_sizes = [18, 17, 15]
community_names = ["Engineering", "Marketing", "Sales"]
nodes = []
node_id = 0
for comm_idx, size in enumerate(community_sizes):
    for _ in range(size):
        nodes.append({"id": node_id, "community": comm_idx, "community_name": community_names[comm_idx]})
        node_id += 1

# Intra-community edges (dense within each community)
edges = []
ranges = [(0, 18), (18, 35), (35, 50)]
for start, end in ranges:
    for i in range(start, end):
        for j in range(i + 1, end):
            if np.random.random() < 0.3:
                weight = np.random.uniform(0.5, 1.0)
                edges.append((i, j, weight))

# Inter-community bridge edges (lighter weights)
bridges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
for src, tgt in bridges:
    edges.append((src, tgt, np.random.uniform(0.2, 0.5)))

# Force-directed layout (Fruchterman-Reingold)
n = len(nodes)
positions = np.random.rand(n, 2) * 2 - 1
k = 0.5
iterations = 200

for iteration in range(iterations):
    displacement = np.zeros((n, 2))
    for i in range(n):
        for j in range(i + 1, n):
            diff = positions[i] - positions[j]
            dist = max(np.linalg.norm(diff), 0.01)
            repulsive = (k * k / dist) * (diff / dist)
            displacement[i] += repulsive
            displacement[j] -= repulsive
    for src, tgt, _ in edges:
        diff = positions[src] - positions[tgt]
        dist = max(np.linalg.norm(diff), 0.01)
        attractive = (dist * dist / k) * (diff / dist)
        displacement[src] -= attractive
        displacement[tgt] += attractive
    temperature = 1 - iteration / iterations
    for i in range(n):
        disp_norm = np.linalg.norm(displacement[i])
        if disp_norm > 0:
            positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)

# Normalize positions to [0.05, 0.95]
pos_min = positions.min(axis=0)
pos_max = positions.max(axis=0)
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05
pos = {node["id"]: positions[i] for i, node in enumerate(nodes)}

# Node degrees
degrees = {node["id"]: 0 for node in nodes}
for src, tgt, _ in edges:
    degrees[src] += 1
    degrees[tgt] += 1

# Edge DataFrame — weight drives thickness and opacity
edges_df = pd.DataFrame(
    {
        "x": [pos[src][0] for src, tgt, _ in edges],
        "y": [pos[src][1] for src, tgt, _ in edges],
        "xend": [pos[tgt][0] for src, tgt, _ in edges],
        "yend": [pos[tgt][1] for src, tgt, _ in edges],
        "weight": [w for _, _, w in edges],
        "edge_size": [0.3 + w * 0.9 for _, _, w in edges],
        "edge_alpha": [0.15 + w * 0.35 for _, _, w in edges],
    }
)

# Node DataFrame — size scales with degree centrality
nodes_df = pd.DataFrame(
    {
        "x": [pos[node["id"]][0] for node in nodes],
        "y": [pos[node["id"]][1] for node in nodes],
        "Team": [node["community_name"] for node in nodes],
        "Connections": [degrees[node["id"]] for node in nodes],
        "size": [7 + degrees[node["id"]] * 1.2 for node in nodes],
    }
)

# Title — len("network-force-directed · python · letsplot · anyplot.ai") = 55 < 67, no scaling
TITLE = "network-force-directed · python · letsplot · anyplot.ai"
title_size = 16

# Plot
plot = (
    ggplot()
    + geom_segment(
        aes(x="x", y="y", xend="xend", yend="yend", size="edge_size", alpha="edge_alpha"),
        data=edges_df,
        color=EDGE_COLOR,
        tooltips="none",
    )
    + geom_point(
        aes(x="x", y="y", color="Team", size="size"),
        data=nodes_df,
        stroke=1.0,
        alpha=0.92,
        tooltips=layer_tooltips().line("Team: @Team").line("Connections: @Connections"),
    )
    + scale_color_manual(values=IMPRINT, name="Team")
    + scale_size_identity(guide="none")
    + scale_alpha_identity(guide="none")
    + coord_fixed(ratio=1)
    + scale_x_continuous(limits=(-0.05, 1.05))
    + scale_y_continuous(limits=(-0.05, 1.05))
    + labs(title=TITLE)
    + ggsize(600, 600)
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(size=title_size, face="bold", color=INK),
        axis_title=element_blank(),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        axis_line=element_blank(),
        panel_grid=element_blank(),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_title=element_text(size=12, color=INK),
        legend_position=(0.02, 0.78),
        legend_justification=(0, 1),
    )
)

# Save — ggsize(600, 600) × scale=4 → 2400 × 2400 px
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Force-Directed Graph on anyplot.ai.

Other implementations