Creating a scatter chart with Svelte
In this tutorial, we will be creating a scatter chart
exploring the data from the world bank by looking at how the life expectancy
changes with increasing GDP per capita
.
- You can download the data here
- We will use d3 scale functions to map our data into our chart dimensions. i.e scaleLinear to map life expectancy on the y-axis, scaleLog for mapping GDP per capita on the x-axis, scaleSqrt to map population size to circle radius and scaleOrdinal to map a color to each continent.
<script>
import { csv } from 'd3-fetch';
import { scaleLinear, scaleLog, scaleSqrt, scaleOrdinal } from 'd3-scale';
import { extent } from 'd3-array';
import { onMount } from 'svelte';
// 1. Getting the data
let data = $state(null);
// We will use csv from d3 to fetch the data and we'll sort it by descending gdp
// download data on: https://datavisualizationwithsvelte.com/data/world_bank.csv
onMount(() => {
csv('/data/world_bank.csv')
.then((unsorted_data) => unsorted_data.sort((a, b) => b.gdp - a.gdp))
.then((sorted_data) => (data = sorted_data));
});
// 2. Dimensions, Margins & Scales
// We will be using scale functions from D3 to map our data points
// within the dimensions of our svg.
// Set the scale functions when the data is available.
let width = $state(null);
const height = 500;
const margin = { top: 40, right: 20, bottom: 20, left: 35 };
const continents = [
'North America',
'South America',
'Asia',
'Europe',
'Africa',
'Oceania'
];
// The $derived rune in Svelte 5 creates a reactive value derived from other reactive values.
const xScale = $derived(
data && width
? scaleLog()
.domain(extent(data, (d) => +d.gdp / +d.population))
.range([margin.left, width - margin.right])
: null
);
const yScale = $derived(
data
? scaleLinear()
.domain(extent(data, (d) => +d.life_expectancy))
.range([height - margin.bottom, margin.top])
: null
);
// We use the `scaleSqrt` function from D3 to make the circle's area proportional to the data values.
// This approach better aligns with how we perceive size,
// as we intuitively sense the area of a circle rather than its radius:
const radiusScale = $derived(
data
? scaleSqrt()
.domain(extent(data, (d) => +d.population))
.range([2, 20])
: null
);
// We will use D3 scaleOrdinal for mapping a color to each continent.
const colors = scaleOrdinal()
.range([
'#e0dff7',
'#EFB605',
'#FFF84A',
'#FF0266',
'#45FFC8',
'#FFC9C9',
'#A8a1ff',
'#2172FF',
'#45FFC8'
])
.domain([
'All',
'America',
'Asia',
'Europe',
'South America',
'Oceania',
'Africa'
]);
</script>
<div
class="flex max-w-3xl flex-col items-center lg:flex-row"
bind:clientWidth={width}>
<div class="relative">
{#if data && width && xScale && yScale && radiusScale}
<svg {width} {height}>
<g>
{#each data as d, i}
<circle
class={d.continent.split(' ').join('')}
cx={xScale(+d.gdp / +d.population)}
cy={yScale(+d.life_expectancy)}
r={radiusScale(+d.population)}
fill={colors(d.continent)} />
{/each}
</g>
</svg>
{/if}
</div>
</div>
At this stage this is how our chart looks like:
- Now we have our data mapped, let’s add x and y axes & labels and our legends.
We will create a generic axis components that we can reuse for different charts: AxisLeft and AxisBottom.
<!-- AxisLeftV5.svelte -->
<script>
let { yScale, margin, ticksNumber = 5 } = $props();
</script>
{#if yScale}
<g transform="translate({margin.left},0)">
<!-- yScale.domain() is an array with two elements min and max values
from the data, so we can use it to create the start and end point
of our axis line -->
<line
stroke="currentColor"
y1={yScale(yScale.domain()[0])}
y2={yScale(yScale.domain()[1])} />
<!-- Specify the number of ticks here -->
{#each yScale.ticks(ticksNumber) as tick}
{#if tick !== 0}
<line
stroke="currentColor"
x1={0}
x2={-6}
y1={yScale(tick)}
y2={yScale(tick)} />
{/if}
<text
fill="currentColor"
text-anchor="end"
font-size="10"
dominant-baseline="middle"
x={-9}
y={yScale(tick)}>
{tick}
</text>
{/each}
</g>
{/if}
<!-- AxisBottomV5.svelte -->
<script>
// Props
let {
xScale,
margin,
height,
width,
ticksNumber = 2,
format = null
} = $props();
// Conditionally apply the formatter if provided
const formatter = format
? (tick) => format(tick) // Use the provided formatter
: (tick) => tick; // Default: no formatting
</script>
{#if xScale}
<g transform="translate(0,{height - margin.bottom})">
<!-- Axis line -->
<line stroke="currentColor" x1={margin.left} x2={width - margin.right} />
<!-- Ticks -->
{#each xScale.ticks(ticksNumber) as tick}
<line
stroke="currentColor"
x1={xScale(tick)}
x2={xScale(tick)}
y1={0}
y2={6} />
{/each}
<!-- Tick labels -->
{#each xScale.ticks(ticksNumber) as tick}
<text
font-size="12px"
fill="currentColor"
text-anchor="middle"
x={xScale(tick)}
y={16}>
{formatter(tick)}
</text>
{/each}
</g>
{/if}
Axis Labels
Let’s also create a Labels component so we can use whenever we want to add labels to our axis.
<!-- Labels.svelte -->
<script>
// Declare props using $props
let {
width,
height,
margin,
label,
yoffset = 0,
xoffset = 0,
labelforx = false,
labelfory = false,
textanchor = 'end',
fillColor = 'white'
} = $props();
</script>
{#if width}
<g class="labels-x-y text-sm">
{#if labelforx}
<text
fill={fillColor}
x={+margin.left + width - 66 + xoffset}
y={+height + margin.top - 40 + yoffset}
text-anchor={textanchor}>
{label}
</text>
{/if}
{#if labelfory}
<text
fill={fillColor}
x={+margin.left + xoffset}
y={-margin.top + 80 + yoffset}
text-anchor={textanchor}>
{label}
</text>
{/if}
</g>
{/if}
Data legends
We will also add data legends for circle radius size and different category colors;
Radial Legend
<!-- RadialLegend.svelte -->
<script>
let { radiusScale, radialLegendData = [30000000, 300000000, 1000000000] } =
$props();
function format_number(d) {
if (d < 1000000000) {
return `${d / 1000000}M`;
} else {
return `${d / 1000000000}B`;
}
}
</script>
<g transform="translate(62,100)">
<!-- Title for the radial legend -->
<text class="text-lg" x="0" y="-42" text-anchor="middle">Population</text>
{#each radialLegendData as d}
<!-- Radial legend circle -->
<circle
class="radial-legend-circle"
cx="0"
cy={10 - radiusScale(d)}
r={radiusScale(d)} />
<!-- Label for each circle -->
<text class="text-xs" x="40" y={13 - 2 * radiusScale(d)}>
{format_number(d)}
</text>
<!-- Line connecting the label to the circle -->
<line
x1="0"
x2="35"
y1={10 - 2 * radiusScale(d)}
y2={10 - 2 * radiusScale(d)} />
{/each}
</g>
<style>
.radial-legend-circle {
stroke-dasharray: 2 2;
box-sizing: border-box;
}
text {
/* using theme for accessing tailwind colors */
fill: theme(colors.gray.200);
}
circle {
stroke: theme(colors.gray.100);
fill: none;
}
line {
stroke: theme(colors.gray.300);
stroke-width: 1;
}
</style>
Category Legend
<script>
// Declare props using $props
let { legend_data, legend_color_function } = $props();
</script>
{#each legend_data as d, i}
<rect
x="25"
y={i * 30 + 10}
width="20"
height="20"
fill={legend_color_function(d)} />
<text class="" x="60" y={25 + i * 30}>
{d[0] + d.slice(1).toLowerCase()}
</text>
{/each}
<style>
text {
fill: theme(colors.gray.200);
}
</style>
And we will import those and add to our svg. Here is how our final ScatterPlot.svelte file looks like.
<!-- ScatterPlot.svelte -->
<script>
import { onMount } from 'svelte';
import { csv } from 'd3-fetch';
import { scaleLinear, scaleLog, scaleSqrt, scaleOrdinal } from 'd3-scale';
import { extent } from 'd3-array';
import Labels from '$lib/components/Basics/Labels.svelte';
import RadialLegend from '$lib/components/Basics/RadialLegend.svelte';
import CategoryLegend from '$lib/components/Basics/CategoryLegend.svelte';
import AxisLeft from './AxisLeftV5.svelte';
import AxisBottom from './AxisBottomV5.svelte';
// 1. Getting the data
let data = $state(null);
// We will use csv from d3 to fetch the data and we'll sort it by descending gdp
// download data on: https://datavisualizationwithsvelte.com/data/world_bank.csv
onMount(() => {
csv('/data/world_bank.csv')
.then((unsorted_data) => unsorted_data.sort((a, b) => b.gdp - a.gdp))
.then((sorted_data) => (data = sorted_data));
});
// 2. Dimensions, Margins & Scales
// We will be using scale functions from D3 to map our data points
// within the dimensions of our svg.
// Set the scale functions when the data is available.
let width = $state(null);
const height = 500;
const margin = { top: 40, right: 200, bottom: 20, left: 35 };
const continents = [
'North America',
'South America',
'Asia',
'Europe',
'Africa',
'Oceania'
];
// The $derived rune in Svelte 5 creates a reactive value derived from other reactive values.
const xScale = $derived(
data && width
? scaleLog()
.domain(extent(data, (d) => +d.gdp / +d.population))
.range([margin.left, width - margin.right])
: null
);
const yScale = $derived(
data
? scaleLinear()
.domain(extent(data, (d) => +d.life_expectancy))
.range([height - margin.bottom, margin.top])
: null
);
// We use the `scaleSqrt` function from D3 to make the circle's area proportional to the data values.
// This approach better aligns with how we perceive size,
// as we intuitively sense the area of a circle rather than its radius:
const radiusScale = $derived(
data
? scaleSqrt()
.domain(extent(data, (d) => +d.population))
.range([2, 20])
: null
);
// We will use D3 scaleOrdinal for mapping a color to each continent.
const colors = scaleOrdinal()
.range([
'#e0dff7',
'#EFB605',
'#FFF84A',
'#FF0266',
'#45FFC8',
'#FFC9C9',
'#A8a1ff',
'#2172FF',
'#45FFC8'
])
.domain([
'All',
'America',
'Asia',
'Europe',
'South America',
'Oceania',
'Africa'
]);
</script>
<div
class="flex max-w-3xl flex-col items-center lg:flex-row"
bind:clientWidth={width}>
<div class="relative">
{#if data && width}
<svg {width} {height}>
<g>
<AxisLeft {yScale} {margin} {height} {width} />
<AxisBottom {xScale} {margin} {height} {width} />
<Labels
labelforx={true}
{width}
{height}
{margin}
yoffset={-30}
xoffset={-170}
label={'GDP per capita →'} />
<Labels
labelfory={true}
textanchor={'start'}
{width}
{height}
{margin}
yoffset={10}
xoffset={10}
label={'Life Expectancy ↑'} />
{#each data as d, i}
<circle
class={d.continent.split(' ').join('')}
cx={xScale(+d.gdp / +d.population)}
cy={yScale(+d.life_expectancy)}
r={radiusScale(+d.population)}
fill={colors(d.continent)} />
{/each}
</g>
<g transform="translate({width - margin.right},300)">
<RadialLegend {width} {height} {margin} {radiusScale} />
</g>
<g transform="translate({width - margin.right},130)">
<CategoryLegend
legend_data={continents}
legend_color_function={colors}
space={80} />
</g>
</svg>
{/if}
</div>
</div>
Next tutorial:
Join our Discord to share your charts, ask questions, or collaborate with fellow developers!