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 { onMount } from 'svelte';
import { csv } from 'd3-fetch';
import { scaleLinear, scaleLog, scaleSqrt, scaleOrdinal } from 'd3-scale';
import { extent } from 'd3-array';
// 1. Getting the data
let data;
// 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));
});
const continents = [
'North America',
'South America',
'Asia',
'Europe',
'Africa',
'Oceania'
];
// 2. Dimensions, Margins & Scales
let width;
const height = 500;
const margin = { top: 40, right: 20, bottom: 20, left: 35 };
let xScale, yScale, radiusScale;
// 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.
$: if (data) {
xScale = scaleLog()
.domain(extent(data, (d) => +d.gdp / +d.population))
.range([margin.left, width - margin.right]);
yScale = scaleLinear()
.domain(extent(data, (d) => +d.life_expectancy))
.range([height - margin.bottom, margin.top]);
// It's a better practice to set the area of a circle rather than its radius proportional
// to the data thus we will use scaleSqrt function from d3 to do this.
radiusScale = scaleSqrt()
.domain(extent(data, (d) => +d.population))
.range([2, 20]);
}
// 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>
{#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
We will create a generic axis component that we can reuse for different charts. By setting the position
prop we can decide whether this will be a bottom or horizontal axis.
<!-- Axis.svelte -->
<script>
import { select } from 'd3-selection';
import { axisBottom, axisLeft } from 'd3-axis';
import { format } from 'd3-format';
export let width;
export let height;
export let margin;
export let position;
export let scale;
export let tick_outer;
export let tick_number;
export let to_format;
export let no_domain;
export let formatString = '$.0f';
export let format_mobile;
const formatMobile = (tick) => {
return "'" + tick.toString().slice(13, 15);
};
const formatter = format(formatString);
let transform;
let g;
$: {
select(g).selectAll('*').remove();
let axis;
if (width && scale) {
switch (position) {
case 'bottom':
if (format_mobile) {
axis = axisBottom(scale)
.tickFormat((d) => formatMobile(d))
.tickSizeOuter(tick_outer || 0);
transform = `translate(0, ${height - margin.bottom})`;
} else {
axis = axisBottom(scale)
.ticks(tick_number || 8)
.tickSizeOuter(tick_outer || 0);
transform = `translate(0, ${height - margin.bottom})`;
}
break;
case 'left':
if (to_format) {
axis = axisLeft(scale)
.tickSizeOuter(tick_outer || 0)
.tickFormat((d) => formatter(d))
.ticks(tick_number || 5);
transform = `translate(${margin.left}, 0)`;
} else {
axis = axisLeft(scale)
.ticks(tick_number || 5)
.tickSizeOuter(tick_outer || 0);
transform = `translate(${margin.left}, 0)`;
}
}
if (no_domain) {
select(g).call(axis).select('.domain').remove();
} else {
select(g).call(axis);
}
}
}
</script>
<g class="axis" bind:this={g} {transform} />
<style>
.axis {
shape-rendering: crispEdges;
}
</style>
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>
export let width, height, margin, label, yoffset, xoffset;
export let labelforx = false;
export let labelfory = false;
export let textanchor = 'end';
</script>
{#if width}
<g class="labels-x-y text-sm">
{#if labelforx}
<text
fill="white"
x={+margin.left + width - 66 + xoffset}
y={+height + margin.top - 40 + yoffset}
text-anchor={textanchor}>{label}</text>
{/if}
{#if labelfory}
<text
fill="white"
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 colors;
Radial Legend
<!-- RadialLegend.svelte -->
<script>
export let width, height, radiusScale;
export let radialLegendData = [30000000, 300000000, 1000000000];
function format_number(d) {
if (d < 1000000000) {
const div = +d / +1000000 + 'M';
return div;
} else {
const div = +d / +1000000000 + 'B';
return div;
}
}
</script>
<g fill="red" transform="translate(62,100)">
<text class="fill-gray-200 text-lg" x="0" y="-42" text-anchor="middle"
>Population</text>
{#each radialLegendData as d}
<circle
class="radial-legend-circle fill-transparent stroke-gray-100"
cx=""
cy={10 - radiusScale(d)}
r={radiusScale(d)} />
<text class="fill-gray-200 text-xs" x="40" y={13 - 2 * radiusScale(d)}
>{format_number(d)}</text>
<line
class=" stroke-gray-300 stroke-1"
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;
}
</style>
Category Legend
<script>
export let legend_data, legend_color_function;
</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="text fill-gray-200 text-base" x="60" y={25 + i * 30}
>{d[0] + d.slice(1).toLowerCase()}
</text>
{/each}
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 Axis from '$lib/components/Basics/Axis.svelte';
import Labels from '$lib/components/Basics/Labels.svelte';
import RadialLegend from '$lib/components/Basics/RadialLegend.svelte';
import CategoryLegend from '$lib/components/Basics/CategoryLegend.svelte';
// 1. Getting the data
let data;
onMount(() => {
csv('/data/world_bank.csv')
.then((unsorted_data) => unsorted_data.sort((a, b) => b.gdp - a.gdp))
.then((sorted_data) => (data = sorted_data));
});
$: console.log(data);
const continents = [
'North America',
'Asia',
'Europe',
'South America',
'Oceania',
'Africa'
];
// 2. Dimensions, Margins & Scales
let width;
const height = 500;
const margin = { top: 40, right: 200, bottom: 20, left: 35 };
let xScale, yScale, radiusScale;
$: if (data) {
xScale = scaleLog()
.domain(extent(data, (d) => +d.gdp / +d.population))
.range([margin.left, width - margin.right]);
yScale = scaleLinear()
.domain(extent(data, (d) => +d.life_expectancy))
.range([height - margin.bottom, margin.top]);
radiusScale = scaleSqrt()
.domain(extent(data, (d) => +d.population))
.range([2, 20]);
}
const colors = scaleOrdinal()
.range([
'#e0dff7',
'#EFB605',
'#FFF84A',
'#FF0266',
'#45FFC8',
'#FFC9C9', // replaced "#991C71",
'#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>
<Axis {width} {height} {margin} scale={xScale} position="bottom" />
<Axis {width} {height} {margin} scale={yScale} position="left" />
<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>