Force-Directed Graph — Makie.jl

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 Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# network-force-directed: Force-Directed Graph
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 90/100 | Created: 2026-07-01

using CairoMakie
using Colors
using Random

Random.seed!(42)

# Theme tokens
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG     = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const ELEVATED_BG = THEME == "light" ? colorant"#FFFDF6" : colorant"#242420"
const INK         = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT    = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"

const IMPRINT_PALETTE = [
    colorant"#009E73",
    colorant"#C475FD",
    colorant"#4467A3",
    colorant"#BD8233",
    colorant"#AE3030",
    colorant"#2ABCCD",
    colorant"#954477",
    colorant"#99B314",
]

# Data: academic research collaboration network (4 departments, 32 researchers)
n_nodes    = 32
dept_names = ["Machine Learning", "Networks", "Databases", "Systems"]
node_dept  = vcat([fill(d, 8) for d in 1:4]...)

edges = Tuple{Int,Int}[]

# Dense within-department edges
for d in 1:4
    members = findall(==(d), node_dept)
    for i in members, j in members
        if i < j && rand() < 0.65
            push!(edges, (i, j))
        end
    end
end

# Sparse cross-department bridges
for (d1, d2) in [(1, 2), (2, 3), (3, 4), (1, 3), (1, 4), (2, 4)]
    m1 = findall(==(d1), node_dept)
    m2 = findall(==(d2), node_dept)
    for _ in 1:2
        push!(edges, (rand(m1), rand(m2)))
    end
end

unique!(edges)

degree = zeros(Int, n_nodes)
for (u, v) in edges
    degree[u] += 1
    degree[v] += 1
end

# Force-directed layout — Fruchterman-Reingold algorithm
pos_x = randn(n_nodes) .* 3.0
pos_y = randn(n_nodes) .* 3.0

k = sqrt(100.0 / n_nodes)  # ideal spring length

for iter in 0:299
    t_step = max(1.0 * 0.97^iter, 0.005)  # cooling schedule

    dx = zeros(n_nodes)
    dy = zeros(n_nodes)

    # Repulsive forces between all node pairs
    for i in 1:n_nodes, j in 1:n_nodes
        if i != j
            δx = pos_x[i] - pos_x[j]
            δy = pos_y[i] - pos_y[j]
            d  = max(sqrt(δx^2 + δy^2), 1e-4)
            f  = k^2 / d
            dx[i] += δx / d * f
            dy[i] += δy / d * f
        end
    end

    # Attractive forces along edges
    for (u, v) in edges
        δx = pos_x[u] - pos_x[v]
        δy = pos_y[u] - pos_y[v]
        d  = max(sqrt(δx^2 + δy^2), 1e-4)
        f  = d^2 / k
        dx[u] -= δx / d * f
        dy[u] -= δy / d * f
        dx[v] += δx / d * f
        dy[v] += δy / d * f
    end

    # Update positions clipped to temperature
    for i in 1:n_nodes
        disp = sqrt(dx[i]^2 + dy[i]^2)
        if disp > 0
            pos_x[i] += dx[i] / disp * min(disp, t_step)
            pos_y[i] += dy[i] / disp * min(disp, t_step)
        end
    end
end

# Normalise to [0.05, 0.95]
pos_x = 0.05 .+ 0.90 .* (pos_x .- minimum(pos_x)) ./ (maximum(pos_x) - minimum(pos_x))
pos_y = 0.05 .+ 0.90 .* (pos_y .- minimum(pos_y)) ./ (maximum(pos_y) - minimum(pos_y))

node_colors = [IMPRINT_PALETTE[d] for d in node_dept]
node_sizes  = 12.0 .+ degree .* 2.5

# Title — 51 chars, below the 67-char baseline so no scaling needed
const TITLE        = "network-force-directed · julia · makie · anyplot.ai"
const TITLE_SIZE   = 22

# Figure
fig = Figure(
    size            = (1600, 900),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title              = TITLE,
    titlesize          = TITLE_SIZE,
    titlecolor         = INK,
    backgroundcolor    = PAGE_BG,
    topspinevisible    = false,
    rightspinevisible  = false,
    leftspinevisible   = false,
    bottomspinevisible = false,
    xgridvisible       = false,
    ygridvisible       = false,
    xticksvisible      = false,
    yticksvisible      = false,
    xticklabelsvisible = false,
    yticklabelsvisible = false,
)

limits!(ax, 0, 1, 0, 1)

# Draw edges
for (u, v) in edges
    lines!(ax, [pos_x[u], pos_x[v]], [pos_y[u], pos_y[v]];
           color = (INK_SOFT, 0.25), linewidth = 1.0)
end

# Draw nodes (size scales with degree)
scatter!(ax, pos_x, pos_y;
         color       = node_colors,
         markersize  = node_sizes,
         strokewidth = 1.5,
         strokecolor = PAGE_BG)

# Legend
legend_elems = [
    MarkerElement(
        color       = IMPRINT_PALETTE[i],
        marker      = :circle,
        markersize  = 16,
        strokewidth = 0,
    )
    for i in 1:4
]

Legend(
    fig[1, 2], legend_elems, dept_names;
    title        = "Department",
    titlesize    = 13,
    titlecolor   = INK,
    labelsize    = 12,
    labelcolor   = INK,
    framevisible    = true,
    framecolor      = (INK_SOFT, 0.3),
    backgroundcolor = ELEVATED_BG,
)

colsize!(fig.layout, 1, Relative(0.82))

# Annotation: explain the node-size encoding
text!(ax, 0.01, 0.01; text = "Node size ∝ degree (connections)",
      fontsize = 10, color = INK_SOFT, align = (:left, :bottom))

# Save
save("plot-$(THEME).png", fig; px_per_unit = 2)

Part of Force-Directed Graph on anyplot.ai.

Other implementations