Basic Chord Diagram — Pygal

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 Pygal

Python source (Pygal)

""" anyplot.ai
chord-basic: Basic Chord Diagram
Library: pygal 3.1.0 | Python 3.13.14
Quality: 93/100 | Updated: 2026-06-17
"""

import math
import os
import re

import cairosvg
import pygal
from pygal.style import Style


# 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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — first series ALWAYS #009E73, then canonical order.
# Theme-independent so each continent keeps its identity across light/dark.
IMPRINT_PALETTE = (
    "#009E73",  # Africa     — brand green
    "#C475FD",  # Asia       — lavender
    "#4467A3",  # Europe     — blue
    "#BD8233",  # N. America — ochre
    "#AE3030",  # S. America — matte red
    "#2ABCCD",  # Oceania    — cyan
)

# Data: migration flows between 6 continents (thousands of people / year).
# Each (source, target, value) is one directed flow; both directions appear.
continents = ["Africa", "Asia", "Europe", "N. America", "S. America", "Oceania"]
n = len(continents)

flows = [
    (0, 1, 8),
    (0, 2, 25),
    (0, 3, 12),
    (0, 4, 5),
    (0, 5, 3),
    (1, 0, 6),
    (1, 2, 20),
    (1, 3, 35),
    (1, 4, 8),
    (1, 5, 18),
    (2, 0, 4),
    (2, 1, 12),
    (2, 3, 22),
    (2, 4, 15),
    (2, 5, 10),
    (3, 0, 2),
    (3, 1, 10),
    (3, 2, 18),
    (3, 4, 14),
    (3, 5, 6),
    (4, 0, 3),
    (4, 1, 7),
    (4, 2, 28),
    (4, 3, 20),
    (4, 5, 4),
    (5, 0, 2),
    (5, 1, 15),
    (5, 2, 12),
    (5, 3, 8),
    (5, 4, 3),
]

# Arc allocation proportional to total flow (in + out) per continent
totals = [0] * n
for s, t, v in flows:
    totals[s] += v
    totals[t] += v
grand_total = sum(totals)

# Square canvas — hard rule for symmetric circular plots (prompts/library/pygal.md)
W = H = 2400

# Style — pygal carries every theme token; sizes are native source pixels.
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_PALETTE,
    font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    title_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    legend_font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
    title_font_size=66,
    legend_font_size=44,
    stroke_width=0,
)

# Chart — pygal owns the title + bottom legend (one swatch per continent) and
# emits the interactive SVG/HTML. The chord geometry is injected afterwards in
# absolute pixel coordinates, so it never depends on pygal's internal padding.
chart = pygal.XY(
    width=W,
    height=H,
    style=custom_style,
    title="chord-basic · python · pygal · anyplot.ai",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=6,
    legend_box_size=40,
    show_x_labels=False,
    show_y_labels=False,
    show_x_guides=False,
    show_y_guides=False,
    stroke=False,
    dots_size=0,
    margin=40,
)

# One invisible anchor point per continent gives the legend its colored swatch
# without drawing anything in the plot area (and without a "No data" watermark).
for name in continents:
    chart.add(name, [(5, 5)], stroke=False, show_dots=False)

svg = chart.render().decode("utf-8")

# --- Locate title baseline + legend top so the diagram fits between them ---
title_ys = [float(y) for y in re.findall(r'<text x="[\d.]+" y="([\d.]+)" class="title"', svg)]
title_bottom = (max(title_ys) if title_ys else 80) + 70
m_leg = re.search(r'<g transform="translate\([\d.]+, ([\d.]+)\)" class="legends"', svg)
legend_top = float(m_leg.group(1)) if m_leg else H - 120

# Diagram geometry — all derived from canvas + parsed chrome, no magic numbers
cx = W / 2
cy = (title_bottom + legend_top) / 2
max_r_v = (legend_top - title_bottom) / 2 - 20
max_r_h = W / 2 - 340  # leave room for the widest side labels ("S. America")
r_label = min(max_r_v, max_r_h)
r_outer = r_label - 80  # colored band outer edge
band = r_outer * 0.075  # band thickness
r_inner = r_outer - band  # chords attach to the inner edge

# Arc spans (clockwise from top), proportional to each continent's total flow
gap = 0.045  # radians of whitespace between arcs
available = 2 * math.pi - gap * n
spans = [available * totals[i] / grand_total for i in range(n)]
arc_start = []  # higher angle (clockwise sweep decreases the angle)
angle = math.pi / 2  # start at the top
for i in range(n):
    arc_start.append(angle)
    angle -= spans[i] + gap

svg_elems = []

# Subtle hub disc to ground the circular composition
svg_elems.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r_inner - 6:.1f}" fill="{ELEVATED_BG}" stroke="none"/>')

# Filled chord ribbons — width proportional to flow, colored by source,
# opacity scaled by magnitude so dominant corridors read first.
chord_r = r_inner
arc_pos = list(arc_start)
max_val = max(v for _, _, v in flows)
for s, t, v in flows:
    s_ext = spans[s] * v / totals[s]
    t_ext = spans[t] * v / totals[t]
    s_a1, s_a2 = arc_pos[s], arc_pos[s] - s_ext
    t_a1, t_a2 = arc_pos[t], arc_pos[t] - t_ext
    arc_pos[s] = s_a2
    arc_pos[t] = t_a2

    sx1, sy1 = cx + chord_r * math.cos(s_a1), cy - chord_r * math.sin(s_a1)
    sx2, sy2 = cx + chord_r * math.cos(s_a2), cy - chord_r * math.sin(s_a2)
    tx1, ty1 = cx + chord_r * math.cos(t_a1), cy - chord_r * math.sin(t_a1)
    tx2, ty2 = cx + chord_r * math.cos(t_a2), cy - chord_r * math.sin(t_a2)

    # Inset the quadratic control point toward — but short of — the hub: thick
    # corridors dive deep to the center, thin ones stay shallow, so the many
    # low-magnitude ribbons no longer pile up on a single point at the hub.
    depth = 0.55 + 0.35 * (v / max_val)
    c1x, c1y = (sx1 + tx1) / 2, (sy1 + ty1) / 2
    c2x, c2y = (tx2 + sx2) / 2, (ty2 + sy2) / 2
    q1x, q1y = c1x + depth * (cx - c1x), c1y + depth * (cy - c1y)
    q2x, q2y = c2x + depth * (cx - c2x), c2y + depth * (cy - c2y)

    opacity = 0.30 + 0.55 * (v / max_val)
    path = (
        f"M {sx1:.1f},{sy1:.1f} "
        f"Q {q1x:.1f},{q1y:.1f} {tx1:.1f},{ty1:.1f} "
        f"A {chord_r:.1f},{chord_r:.1f} 0 0,1 {tx2:.1f},{ty2:.1f} "
        f"Q {q2x:.1f},{q2y:.1f} {sx2:.1f},{sy2:.1f} "
        f"A {chord_r:.1f},{chord_r:.1f} 0 0,0 {sx1:.1f},{sy1:.1f} Z"
    )
    tip = f"{continents[s]} → {continents[t]}: {v}k/yr"
    svg_elems.append(
        f'<path d="{path}" fill="{IMPRINT_PALETTE[s]}" fill-opacity="{opacity:.2f}" '
        f'stroke="none"><title>{tip}</title></path>'
    )

# Node arcs — filled annular sectors on the perimeter, one per continent
for i in range(n):
    a0, a1 = arc_start[i], arc_start[i] - spans[i]
    ox1, oy1 = cx + r_outer * math.cos(a0), cy - r_outer * math.sin(a0)
    ox2, oy2 = cx + r_outer * math.cos(a1), cy - r_outer * math.sin(a1)
    ix2, iy2 = cx + r_inner * math.cos(a1), cy - r_inner * math.sin(a1)
    ix1, iy1 = cx + r_inner * math.cos(a0), cy - r_inner * math.sin(a0)
    sector = (
        f"M {ox1:.1f},{oy1:.1f} "
        f"A {r_outer:.1f},{r_outer:.1f} 0 0,1 {ox2:.1f},{oy2:.1f} "
        f"L {ix2:.1f},{iy2:.1f} "
        f"A {r_inner:.1f},{r_inner:.1f} 0 0,0 {ix1:.1f},{iy1:.1f} Z"
    )
    tip = f"{continents[i]}: {totals[i]}k/yr total flow"
    svg_elems.append(
        f'<path d="{sector}" fill="{IMPRINT_PALETTE[i]}" stroke="{PAGE_BG}" '
        f'stroke-width="3"><title>{tip}</title></path>'
    )

# Continent labels just outside their arc, colored for identity
for i, name in enumerate(continents):
    mid = arc_start[i] - spans[i] / 2
    lx, ly = cx + (r_outer + 46) * math.cos(mid), cy - (r_outer + 46) * math.sin(mid)
    deg = math.degrees(mid) % 360
    if deg <= 80 or deg >= 280:
        anchor = "start"
    elif 100 <= deg <= 260:
        anchor = "end"
    else:
        anchor = "middle"
    svg_elems.append(
        f'<text x="{lx:.1f}" y="{ly:.1f}" fill="{IMPRINT_PALETTE[i]}" '
        f'font-size="56" font-weight="bold" text-anchor="{anchor}" '
        f'dominant-baseline="central" '
        f'font-family="DejaVu Sans, Helvetica, Arial, sans-serif">{name}</text>'
    )

# Inject the diagram just before the closing tag and write outputs
svg = svg.replace("</svg>", "<g>" + "".join(svg_elems) + "</g></svg>")

with open(f"plot-{THEME}.svg", "w") as f:
    f.write(svg)

cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=W, output_height=H)

# Interactive HTML — embed the composed SVG so it matches the PNG exactly
with open(f"plot-{THEME}.html", "w") as f:
    f.write(
        '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n'
        "<title>chord-basic · python · pygal · anyplot.ai</title>\n"
        f"<style>body{{margin:0;background:{PAGE_BG}}}"
        "svg{width:100%;height:auto;display:block}</style>\n</head>\n<body>\n"
        f"{svg}\n</body>\n</html>"
    )

Part of Basic Chord Diagram on anyplot.ai.

Other implementations