Basic Chord Diagram — plotnine

A chord diagram displays relationships or flows between entities arranged around a circle's perimeter. Arcs (chords) connect related entities, with chord width proportional to the flow magnitude. This visualization excels at revealing the overall structure of connections and identifying the strongest relationships within a system.

Basic Chord Diagram rendered with plotnine

Python source (plotnine)

""" anyplot.ai
chord-basic: Basic Chord Diagram
Library: plotnine 0.15.7 | Python 3.13.13
Quality: 93/100 | Created: 2026-06-17
"""

import os

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    coord_fixed,
    element_rect,
    element_text,
    geom_polygon,
    geom_text,
    ggplot,
    labs,
    scale_fill_manual,
    theme,
    theme_void,
)


# Theme tokens (see prompts/default-style-guide.md "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"

# Imprint palette — abstract entities, canonical order, first series #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD"]

# Data — annual migration flows between 6 continents (thousands of people / year)
# Square directed matrix: M[i, j] = flow FROM entity i TO entity j (diagonal = 0)
continents = ["Africa", "Asia", "Europe", "N. America", "S. America", "Oceania"]
flow_matrix = np.array(
    [
        [0, 35, 90, 40, 5, 8],  # Africa -> ...
        [20, 0, 110, 130, 10, 45],  # Asia -> ...
        [25, 30, 0, 70, 15, 30],  # Europe -> ...
        [10, 35, 40, 0, 25, 12],  # N. America -> ...
        [4, 8, 55, 60, 0, 5],  # S. America -> ...
        [3, 25, 20, 15, 2, 0],  # Oceania -> ...
    ],
    dtype=float,
)
n = len(continents)

# Chord layout — each entity occupies an arc proportional to its total outflow,
# subdivided into one slice per destination (d3-style chord geometry)
group_value = flow_matrix.sum(axis=1)
total = group_value.sum()
pad = 0.05  # angular gap between adjacent entity arcs (radians)
scale = (2 * np.pi - n * pad) / total

sub_start = np.zeros((n, n))
sub_end = np.zeros((n, n))
group_a0 = np.zeros(n)
group_a1 = np.zeros(n)

angle = np.pi / 2  # start at the top of the circle
for i in range(n):
    group_a0[i] = angle
    cursor = angle
    for j in range(n):
        sub_start[i, j] = cursor
        cursor += flow_matrix[i, j] * scale
        sub_end[i, j] = cursor
    group_a1[i] = cursor
    angle = cursor + pad

# Radii: ribbons attach at R_IN; the entity arc band sits between R_IN and R_OUT
R_IN = 1.0
R_OUT = 1.08
R_LABEL = 1.22

# Outer entity arc bands (filled annular sectors)
arc_x, arc_y, arc_gid, arc_entity = [], [], [], []
for i in range(n):
    span = group_a1[i] - group_a0[i]
    steps = max(8, int(span / 0.02))
    outer = np.linspace(group_a0[i], group_a1[i], steps)
    inner = outer[::-1]
    xs = np.concatenate([R_OUT * np.cos(outer), R_IN * np.cos(inner)])
    ys = np.concatenate([R_OUT * np.sin(outer), R_IN * np.sin(inner)])
    arc_x.extend(xs)
    arc_y.extend(ys)
    arc_gid.extend([f"arc{i}"] * len(xs))
    arc_entity.extend([continents[i]] * len(xs))

arcs_df = pd.DataFrame({"x": arc_x, "y": arc_y, "gid": arc_gid, "entity": arc_entity})

# Ribbons — one per entity pair, ends curve through the circle centre.
# The dominant flow direction gives the ribbon its colour (its source entity).
rib_x, rib_y, rib_gid, rib_entity = [], [], [], []
t = np.linspace(0.0, 1.0, 28)  # quadratic Bezier parameter (control point = origin)
for i in range(n):
    for j in range(i + 1, n):
        if flow_matrix[i, j] == 0 and flow_matrix[j, i] == 0:
            continue
        src, dst = (i, j) if flow_matrix[i, j] >= flow_matrix[j, i] else (j, i)

        s0, s1 = sub_start[src, dst], sub_end[src, dst]
        d0, d1 = sub_start[dst, src], sub_end[dst, src]

        # source arc
        s_arc = np.linspace(s0, s1, 16)
        px = list(R_IN * np.cos(s_arc))
        py = list(R_IN * np.sin(s_arc))
        # Bezier from source end to destination start (curving through origin)
        x0, y0 = R_IN * np.cos(s1), R_IN * np.sin(s1)
        x1, y1 = R_IN * np.cos(d0), R_IN * np.sin(d0)
        px.extend((1 - t) ** 2 * x0 + t**2 * x1)
        py.extend((1 - t) ** 2 * y0 + t**2 * y1)
        # destination arc
        d_arc = np.linspace(d0, d1, 16)
        px.extend(R_IN * np.cos(d_arc))
        py.extend(R_IN * np.sin(d_arc))
        # Bezier back from destination end to source start
        x0, y0 = R_IN * np.cos(d1), R_IN * np.sin(d1)
        x1, y1 = R_IN * np.cos(s0), R_IN * np.sin(s0)
        px.extend((1 - t) ** 2 * x0 + t**2 * x1)
        py.extend((1 - t) ** 2 * y0 + t**2 * y1)

        rid = f"rib{src}_{dst}"
        rib_x.extend(px)
        rib_y.extend(py)
        rib_gid.extend([rid] * len(px))
        rib_entity.extend([continents[src]] * len(px))

ribbons_df = pd.DataFrame({"x": rib_x, "y": rib_y, "gid": rib_gid, "entity": rib_entity})

# Entity labels at the arc midpoints
mid = (group_a0 + group_a1) / 2
labels_df = pd.DataFrame({"x": R_LABEL * np.cos(mid), "y": R_LABEL * np.sin(mid), "entity": continents})

# Keep the legend / fill order stable
cat = pd.Categorical(continents, categories=continents, ordered=True)
arcs_df["entity"] = pd.Categorical(arcs_df["entity"], categories=continents)
ribbons_df["entity"] = pd.Categorical(ribbons_df["entity"], categories=continents)

# Plot
plot = (
    ggplot()
    + geom_polygon(arcs_df, aes(x="x", y="y", group="gid", fill="entity"), color=PAGE_BG, size=0.2)
    + geom_polygon(ribbons_df, aes(x="x", y="y", group="gid", fill="entity"), color=PAGE_BG, size=0.15, alpha=0.6)
    + geom_text(labels_df, aes(x="x", y="y", label="entity"), color=INK, size=6.5, fontweight="bold")
    + scale_fill_manual(values=IMPRINT_PALETTE, limits=list(cat.categories))
    + coord_fixed(ratio=1, xlim=(-1.4, 1.4), ylim=(-1.4, 1.4))
    + labs(title="chord-basic · python · plotnine · anyplot.ai", fill="Continent")
    + theme_void()
    + theme(
        figure_size=(6, 6),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_key=element_rect(fill=ELEVATED_BG, color=ELEVATED_BG),
        plot_title=element_text(size=12, color=INK, ha="center"),
        legend_title=element_text(size=10, color=INK),
        legend_text=element_text(size=9, color=INK_SOFT),
    )
)

# Save
plot.save(f"plot-{THEME}.png", dpi=400, width=6, height=6, units="in", verbose=False)

Part of Basic Chord Diagram on anyplot.ai.

Other implementations