Directed Network Graph — lets-plot

A directed network graph visualizes relationships between entities using nodes connected by edges with arrows, indicating the direction of relationships or flow. Unlike undirected graphs, directed graphs reveal asymmetric relationships such as dependencies, hierarchies, or information flow. The arrows clearly communicate which entity points to which, making cause-and-effect relationships and directional dependencies immediately visible.

Directed Network Graph rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
network-directed: Directed Network Graph
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 90/100 | Updated: 2026-05-14
"""

import os
import shutil

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    arrow,
    element_rect,
    element_text,
    geom_point,
    geom_segment,
    geom_text,
    ggplot,
    ggsize,
    labs,
    scale_fill_manual,
    theme,
    theme_void,
    xlim,
    ylim,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

np.random.seed(42)

# Theme-adaptive colors
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"

# Okabe-Ito palette (positions 1-4 for node groups)
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

# Define software package dependency network
nodes = [
    {"id": "app", "label": "App", "group": "Application"},
    {"id": "api", "label": "API", "group": "Core"},
    {"id": "auth", "label": "Auth", "group": "Core"},
    {"id": "db", "label": "Database", "group": "Core"},
    {"id": "cache", "label": "Cache", "group": "Infrastructure"},
    {"id": "config", "label": "Config", "group": "Utility"},
    {"id": "logger", "label": "Logger", "group": "Utility"},
    {"id": "utils", "label": "Utils", "group": "Utility"},
    {"id": "http", "label": "HTTP Client", "group": "Infrastructure"},
    {"id": "queue", "label": "Queue", "group": "Infrastructure"},
]

# Directed edges (source depends on target, arrow from source to target)
edges = [
    ("app", "api"),
    ("app", "config"),
    ("api", "auth"),
    ("api", "db"),
    ("api", "cache"),
    ("api", "logger"),
    ("auth", "db"),
    ("auth", "config"),
    ("auth", "logger"),
    ("db", "config"),
    ("db", "logger"),
    ("cache", "config"),
    ("cache", "logger"),
    ("http", "config"),
    ("http", "logger"),
    ("queue", "config"),
    ("queue", "logger"),
    ("api", "http"),
    ("api", "queue"),
    ("utils", "logger"),
]

# Create node positions using hierarchical layout
node_positions = {
    "app": (0.5, 1.0),
    "api": (0.5, 0.75),
    "auth": (0.15, 0.5),
    "db": (0.5, 0.5),
    "cache": (0.85, 0.5),
    "http": (0.05, 0.25),
    "queue": (0.3, 0.25),
    "config": (0.55, 0.25),
    "logger": (0.8, 0.25),
    "utils": (1.0, 0.75),
}

# Build node dataframe
node_df = pd.DataFrame(nodes)
node_df["x"] = node_df["id"].map(lambda n: node_positions[n][0])
node_df["y"] = node_df["id"].map(lambda n: node_positions[n][1])

# Map groups to Okabe-Ito colors
group_order = ["Application", "Core", "Infrastructure", "Utility"]
group_colors = {group: IMPRINT[i] for i, group in enumerate(group_order)}
node_df["color"] = node_df["group"].map(group_colors)

# Build edge dataframe with arrow endpoints
edge_data = []
for source, target in edges:
    x0, y0 = node_positions[source]
    x1, y1 = node_positions[target]

    # Shorten edges to not overlap with nodes
    dx, dy = x1 - x0, y1 - y0
    length = np.sqrt(dx**2 + dy**2)
    if length > 0:
        # Shrink by node radius on each end
        shrink = 0.05
        x0_adj = x0 + (dx / length) * shrink
        y0_adj = y0 + (dy / length) * shrink
        x1_adj = x1 - (dx / length) * shrink * 1.8
        y1_adj = y1 - (dy / length) * shrink * 1.8
    else:
        x0_adj, y0_adj, x1_adj, y1_adj = x0, y0, x1, y1

    edge_data.append({"x": x0_adj, "y": y0_adj, "xend": x1_adj, "yend": y1_adj})

edge_df = pd.DataFrame(edge_data)

# Create the plot
plot = (
    ggplot()
    # Draw edges with arrows
    + geom_segment(
        aes(x="x", y="y", xend="xend", yend="yend"),
        data=edge_df,
        color=INK_SOFT,
        size=1.2,
        arrow=arrow(length=12, type="closed"),
        alpha=0.5,
    )
    # Draw nodes
    + geom_point(aes(x="x", y="y", fill="group"), data=node_df, size=22, shape=21, stroke=2.5, color="white")
    # Add node labels
    + geom_text(aes(x="x", y="y", label="label"), data=node_df, size=12, color=INK, fontface="bold", nudge_y=-0.06)
    # Color scale
    + scale_fill_manual(values=IMPRINT, name="Module Type")
    # Theme and styling
    + theme_void()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        plot_title=element_text(size=28, face="bold", hjust=0.5, color=INK),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_title=element_text(size=18, color=INK),
        legend_text=element_text(size=16, color=INK_SOFT),
        legend_position="right",
        plot_margin=[60, 80, 80, 60],
    )
    + labs(title="network-directed · letsplot · anyplot.ai")
    + ggsize(1600, 900)
    + xlim(-0.05, 1.15)
    + ylim(0.1, 1.1)
)

# Save as PNG and HTML
ggsave(plot, f"plot-{THEME}.png", scale=3)
ggsave(plot, f"plot-{THEME}.html")

# Move files from lets-plot-images subdirectory to current directory
if os.path.exists(f"lets-plot-images/plot-{THEME}.png"):
    shutil.move(f"lets-plot-images/plot-{THEME}.png", f"plot-{THEME}.png")
if os.path.exists(f"lets-plot-images/plot-{THEME}.html"):
    shutil.move(f"lets-plot-images/plot-{THEME}.html", f"plot-{THEME}.html")
if os.path.exists("lets-plot-images") and not os.listdir("lets-plot-images"):
    os.rmdir("lets-plot-images")

Part of Directed Network Graph on anyplot.ai.

Other implementations