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>

Next tutorial: