Create a donut chart with svelte and d3
In this tutorial, we will be creating a donut chart representing type of meat consumption in the US.
We will be using d3 pie()
and arc()
functions for this chart.
Basically, pie() function calculates the necessary angles to represent a categorical data as a pie or donut, these angles are then passed to an arc() function which will create the slices via path data. If you haven’t used them before you can read further details on the D3.js documentation.
Below is data object that we will use, this time we are parsing our csv file into an object and exporting it from a .js file.
<!-- meat_data.js -->
const csv = `location,type,value
USA,POULTRY,50.0713589267626
USA,BEEF,26.7293486139702
USA,PIG,23.302141032229
USA,SHEEP,0.44835471639525`
const data = csv
.split('\n')
.slice(1)
.map((str) => {
const [location, type, value] = str.split(',');
return { location, type, value: parseFloat(value).toFixed(1) };
});
export default data;
First generate our pieData:
const pieGenerator = pie().value((d) => d.value);
$: pieData = pieGenerator(data);
and this is how pieData will look like:
[
{
data: {
location: 'USA',
type: 'POULTRY',
value: '50.1'
},
index: 0,
value: 50.1,
startAngle: 0,
endAngle: 3.132214765071615,
padAngle: 0
},
{
data: {
location: 'USA',
type: 'BEEF',
value: '26.7'
},
index: 1,
value: 26.7,
startAngle: 3.132214765071615,
endAngle: 4.801478921307385,
padAngle: 0
},
{
data: {
location: 'USA',
type: 'PIG',
value: '23.3'
},
index: 2,
value: 23.3,
startAngle: 4.801478921307385,
endAngle: 6.258177604464444,
padAngle: 0
},
{
data: {
location: 'USA',
type: 'SHEEP',
value: '0.4'
},
index: 3,
value: 0.4,
startAngle: 6.258177604464444,
endAngle: 6.283185307179586,
padAngle: 0
}
];
then we can feed this data to a d3 arc()
function and it will create the path data for individual slices of the donut shape.
$: arcGenerator = arc()
// 60% of the full radius gives a nice appearance (0.6)
// To see a pie chart, just change this to zero
.innerRadius((0.6 * height) / 2.4)
// Outer radius is less than the full radius because our labels will sit outside of the donut
.outerRadius((0.85 * height) / 2.2)
.padRadius(40)
.cornerRadius(4);
For example let’s feed the pie data corresponding to poultry to arcGenerator():
<path
pointer-events="all"
cursor="pointer"
d="{arcGenerator(pieData[0])}"
stroke="none"
stroke-width="2"
fill="green" />
And we get the slice representing poultry meat consumption in our donut. Now let’s put all slices and labels together and create the final donut chart.
<script>
import { arc, pie } from 'd3-shape';
import { scaleOrdinal } from 'd3-scale';
import { ascending, sort } from 'd3-array';
import data from '$lib/data/meat_data.js';
let el;
let width;
let height = 340;
let countryData;
let value = 'USA';
// sort the data so we will place the biggest slices first starting from 12 o'clock position.
$: if (data) {
countryData = sort(
data.filter((d) => d.location === value),
(a, b) => ascending(a.type, b.type)
);
}
const colorScale = scaleOrdinal()
.domain(['Beef', 'Pig', 'Poultry', 'Sheep'].sort(ascending))
.range(['#A8a1ff', '#FFF84A', '#45FFC8', '#ff0266']);
const pieGenerator = pie().value((d) => d.value);
// lets make pieData reactive so if we change the data the function will also be updated
$: pieData = pieGenerator(countryData);
const arcGenerator = arc()
// 60% of the full radius gives a nice appearance (0.6)
// To see a pie chart, just change this to zero
.innerRadius((0.6 * height) / 2.4)
// Outer radius is less than the full radius because our labels will sit outside of the donut
.outerRadius((0.85 * height) / 2.2)
.padRadius(40)
.cornerRadius(4);
const labelArcs = arc()
.innerRadius((0.96 * height) / 2)
.outerRadius((0.96 * height) / 2);
</script>
<div
class="chart-container flex flex-col items-center justify-around md:flex-row">
<div class="svg-container w-[500px]" bind:clientWidth={width}>
{#if width}
<svg {width} {height} class="chart">
<g
bind:this={el}
class="donut-container"
transform="translate({width / 2 - 5} {height / 2 + 20})">
{#each pieData as d, i (d.data.type)}
<path
class={i}
pointer-events="all"
cursor="pointer"
d={arcGenerator(d)}
fill={colorScale(d.data.type)} />
<!-- labels -->
<text
in:fly={{ delay: 100, duration: 1000 }}
x="0"
y="0"
text-anchor="middle"
font-size="0.8em"
class="fill-gray-100"
transform="translate({labelArcs.centroid(d).join(' ')})"
>{d.data.type}
</text>
<text
x="0"
y="1.2em"
text-anchor="middle"
font-size="0.8em"
font-weight="700"
class="fill-gray-100"
transform="translate({labelArcs.centroid(d).join(' ')})"
>{d.data.value + ' kg'}
</text>
{/each}
</g>
<!-- chart title -->
<g transform="translate({width / 2 - 5} {height / 2 + 20})">
<text
x="0"
y="0.5em"
font-weight="bold"
text-anchor="middle"
font-size="2em"
class="fill-gray-100"
>{value}
</text>
</g>
</svg>
{/if}
</div>
</div>
Add an animation
We can animate our donut by setting up a custom svelte transition function and interpolate endAngle values of the pie. Let’s see how we created the animation like the one at the top.
Pie shape is drawn starting from a startAngle and going clockwise to the endAngle. So to add an animation of growing a pie slice we can tweeen our endAngle starting from the startAngle and finishing at endAngle.
const reveal = (node, { index }) => {
const d = pieData[index];
const start = +d.startAngle;
const end = +d.endAngle;
const diff = end;
// interpolate from d3js
// use interpolate to transition d.endAngle starting from d.startAngle to its final value
// to create an effect of growing size of the slices
let i = interpolate(start, end);
// I would like slices to form one after the other, so I add here a delay
// which is a multiple of the end angle
return {
delay: index * diff * 20 + 300,
duration: 200,
tick: (t) => {
d.endAngle = i(t);
select(node).attr('d', arcGenerator(d));
}
};
};
Final Code
Here is our final code to create the animated donut chart at the top of the page:
<script>
import { select } from 'd3-selection';
import { arc, pie } from 'd3-shape';
import { scaleOrdinal } from 'd3-scale';
import { ascending, sort } from 'd3-array';
import data from '$lib/data/meat_data.js';
import { fly } from 'svelte/transition';
import { interpolate } from 'd3-interpolate';
let el;
let width;
let height = 340;
let countryData;
let value = 'USA';
$: if (data) {
countryData = sort(
data.filter((d) => d.location === value),
(a, b) => ascending(b.value, a.value)
);
}
const colorScale = scaleOrdinal()
.domain(['Beef', 'Pig', 'Poultry', 'Sheep'])
.range(['#45FFC8', '#A8a1ff', '#FFF84A', '#ff0266']);
// '#45FFC8'
const pieGenerator = pie()
.value((d) => d.value)
.sort(null);
$: pieData = pieGenerator(countryData);
const arcGenerator = arc()
// 60% of the full radius gives a nice appearance (0.6)
// To see a pie chart, just change this to zero
.innerRadius((0.6 * height) / 2.4)
// Outer radius is less than the full radius because our labels will sit outside of the donut
.outerRadius((0.85 * height) / 2.2)
.padRadius(40)
.cornerRadius(4);
const labelArcs = arc()
.innerRadius((0.96 * height) / 2)
.outerRadius((0.96 * height) / 2);
const reveal = (node, { index }) => {
const d = pieData[index];
const start = +d.startAngle;
const end = +d.endAngle;
const diff = end;
let i = interpolate(start, end);
return {
delay: index * diff * 20 + 300,
duration: 200,
tick: (t) => {
d.endAngle = i(t);
select(node).attr('d', arcGenerator(d));
}
};
};
</script>
<div
class="chart-container flex flex-col items-center justify-around md:flex-row">
<div class="svg-container w-[500px]" bind:clientWidth={width}>
{#if width}
<svg {width} {height} class="chart">
<g
bind:this={el}
class="donut-container"
transform="translate({width / 2 - 5} {height / 2 + 20})">
{#each pieData as d, i (d.data.type)}
<path
class={i}
pointer-events="all"
cursor="pointer"
d={arcGenerator(d)}
shape-rendering="geometricPrecision"
fill={colorScale(d.data.type)}
in:reveal={{
delay: 200 + i * 200,
duration: 1000,
index: i
}} />
<text
in:fly={{ delay: 1000, duration: 500 }}
x="0"
y="0"
text-anchor="middle"
font-size="0.8em"
class="fill-gray-100"
transform="translate({labelArcs.centroid(d).join(' ')})"
>{d.data.type}
</text>
<text
in:fly={{ delay: 1000, duration: 1000 }}
x="0"
y="1.2em"
text-anchor="middle"
font-size="0.8em"
font-weight="700"
class="fill-gray-100"
transform="translate({labelArcs.centroid(d).join(' ')})"
>{d.data.value + ' kg'}
</text>
{/each}
</g>
<g transform="translate({width / 2 - 5} {height / 2 + 20})">
<text
x="0"
y="0.5em"
font-weight="bold"
text-anchor="middle"
font-size="2em"
class="fill-gray-100"
>{value}
</text>
</g>
</svg>
{/if}
</div>
</div>
Join our Discord to share your charts, ask questions, or collaborate with fellow developers!