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!