Chord chart

_images/light-chord.svg _images/dark-chord.svg
  1. Prepare data

# Source: https://observablehq.com/@d3/chord-diagram/2
import detroit as d3
from itertools import chain
from math import pi, degrees

# https://docs.python.org/3/library/itertools.html#itertools-recipes
def flatten(list_of_lists):
    return chain.from_iterable(list_of_lists)

# Declare data
matrix = [
    [11975, 5871, 8916, 2868],
    [1951, 10048, 2060, 6171],
    [8010, 16145, 8090, 8045],
    [1013, 990, 940, 6907],
]
names = ["black", "blond", "brown", "red"]
colors = ["black", "#ffdd89", "#957244", "#f26223"]

def group_ticks(d, step):
    k = (d.end_angle - d.start_angle) / d.value
    return [
        {"value": value, "angle": value * k + d.start_angle}
        for value in range(0, d.value, step)
    ]
  1. Make the chord chart

# Declare the chart dimensions.
width = 640
height = width
outer_radius = min(width, height) * 0.5 - 30
inner_radius = outer_radius - 20

sum_value = sum(flatten(matrix))
tick_step = d3.tick_step(0, sum_value, 100)
tick_step_major = d3.tick_step(0, sum_value, 20)
format_value = d3.format_prefix(",.0", tick_step)

# Create chord, arc and ribbon generators
chord = (
    d3.chord()
    .set_pad_angle(20 / inner_radius)
    .set_sort_subgroups(lambda a, b: -1 if b < a else (1 if b > a else 0))
)

arc = d3.arc().set_inner_radius(inner_radius).set_outer_radius(outer_radius)

ribbon = d3.ribbon().set_radius(inner_radius)

# Create the SVG container.
svg = (
    d3.create("svg")
    .attr("width", width)
    .attr("height", height)
    .attr("viewBox", ", ".join(map(str, (-width / 2, -height / 2, width, height))))
    .attr("style", "max-width: 100%; height: auto; font: 10px sans-serif;")
)

# Compute chord values.
chords = chord(matrix)

# Add chord groups.
group = svg.append("g").select_all().data(chords.groups).join("g")

(
    group.append("path")
    .attr("fill", lambda d: colors[d.index])
    .attr("d", arc)
    .append("title")
    .text(lambda d: f"{d.value:,} {names[d.index]}")
)

# Add group ticks.
group_tick = (
    group.append("g")
    .select_all()
    .data(lambda _, d: group_ticks(d, tick_step))
    .join("g")
    .attr(
        "transform",
        lambda d: f"rotate({degrees(d["angle"]) - 90}) translate({outer_radius},0)",
    )
)

group_tick.append("line").attr("stroke", "black").attr("x2", 6)

(
    group_tick.filter(lambda d: d["value"] % tick_step_major == 0)
    .append("text")
    .attr("x", 8)
    .attr("dy", ".35em")
    .attr(
        "transform", lambda d: "rotate(180) translate(-16)" if d["angle"] > pi else "none"
    )
    .attr("text-anchor", lambda d: "end" if d["angle"] > pi else "none")
    .attr("fill", "black")
    .text(lambda d: format_value(d["value"]))
)


# Add chord values in the SVG containers.
def alt(d):
    if d.source.index != d.target.index:
        value = f"\n{d.target.value:,} {names[d.target.index]}{names[d.source.index]}"
    else:
        value = ""
    return f"{d.source.value:,} {names[d.source.index]}{names[d.target.index]}{value}"


(
    svg.append("g")
    .attr("fill-opacity", 0.7)
    .select_all()
    .data(chords)
    .join("path")
    .attr("d", ribbon)
    .attr("fill", lambda d: colors[d.target.index])
    .attr("stroke", "white")
    .append("title")
    .text(alt)
)
  1. Save your chart

with open("chord.svg", "w") as file:
    file.write(str(svg))