Adding Tooltips and Quadtree to Scatter Chart

Create a Svelte Quadtree Component

  • You can download the data here

By implementing a quadtree algorithm we can enhance our chart’s usability by making it effortless to hover over smaller circles for showing the tooltips for the details about our datapoint.

We are utilizing the d3 quadtree module here, Quadtree is a tree data structure commonly used in computer science for spatial partitioning of two-dimensional space and enables us to efficiently search for objects within this space. If you want to learn more about quadtree you can read more on the official d3 documentation website.

Let’s start by building a Quadtree component, what this component will do is given a mouse position it will calculate closest datapoint to it and pass this data to its child component (to its slot content). We will use this data to render a tooltip for that data point.

<!-- Quadtree.svelte -->
<script>
  import { quadtree } from 'd3-quadtree';
  export let xScale, yScale, width, height;

  let visible = false;
  let found = {};
  let e = {};

  export let margin;
  export let data;
  export let searchRadius = undefined;

  function findItem(evt) {
    const xLayerKey = 'layerX';
    const yLayerKey = 'layerY';

    found = finder.find(evt[xLayerKey], evt[yLayerKey], searchRadius) || {};
    visible = Object.keys(found).length > 0;
  }

  let finder;

  $: if (data) {
    finder = quadtree()
      .x(function (d) {
        return xScale(+d.gdp / +d.population);
      })
      .y(function (d) {
        return yScale(+d.life_expectancy);
      })
      .addAll(data);
  }

  const getPosition = (found) => {
    if (found && found.gdp && found.population) {
      const xPos = xScale(+found.gdp / +found.population);
      if (xPos > 0.9 * xScale.range()[1]) {
        return { circle: xPos, square: xPos - 100 };
      } else {
        return { circle: xPos, square: xPos };
      }
    }
  };
</script>

<div
  aria-hidden
  class="bg"
  on:mousemove={findItem}
  on:blur={() => (visible = false)}
  on:mouseout={() => (visible = false)} />
{#if found.gdp && found.population}
  <slot
    x={getPosition(found)}
    y={yScale(+found.life_expectancy)}
    {found}
    {visible}
    {margin}
    {e} />
{/if}

<style>
  .bg {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
  }
</style>

Let’s look at the important pieces of the above code. Here, we are using the data and scale functions to create a quatree layer and create a finder function.

$: if (data) {
  finder = quadtree()
    .x(function (d) {
      return xScale(+d.gdp / +d.population);
    })
    .y(function (d) {
      return yScale(+d.life_expectancy);
    })
    .addAll(data);
}

Then, we use the findItem function which takes the current mouse position via event object and utilizes the finder to find the data point closest to it.

function findItem(evt) {
  const xLayerKey = 'layerX';
  const yLayerKey = 'layerY';

  found = finder.find(evt[xLayerKey], evt[yLayerKey], searchRadius) || {};
  visible = Object.keys(found).length > 0;
}

Example returned data point:

 {
        "country": "Gabon",
        "code": "GAB",
        "continent": "Africa",
        "gdp": "15013950984.084",
        "life_expectancy": "66.105",
        "population": "2025137"
    }

Slot component will contain a higlighting circle element for the the found data point and tooltip element that shows the details about the higlighted country.

<Quadtree
  {data}
  {xScale}
  {yScale}
  {width}
  {height}
  {margin}
  let:visible
  let:x
  let:y
  let:found>
  <!-- higlight circle -->
  <div
    class="circle"
    style="top:{y}px;left:{x.circle}px;display: {visible
      ? 'block'
      : 'none'}; width: {radiusScale(+found.population) * 2 +
      5 +
      0}px ; height: {radiusScale(+found.population) * 2 + 5}px;" />
  <!-- Tooltip Element  -->
  <div
    class="tooltip pointer-events-none bg-gray-50 bg-opacity-90 text-gray-900"
    style="top:{y + 5}px;left:{x.square + 10}px;display: {visible
      ? 'block'
      : 'none'};">
    <h1 class="mb-1 text-base text-gray-900">{found.country}</h1>
    GDP: {Number(found.gdp / 100000000).toFixed(1) + ' Bil. $'}<br />Pop.: {Number(
      +found.population / 1000000
    ).toFixed(1) + ' Mil.'}<br />
    Life Expectancy: {Number(+found.life_expectancy).toFixed(1)}
  </div>
</Quadtree>

We are using here the svelte let directive. Let directive allows us to declare variables which are available to the child elements of a component.

let: visible;
let: x;
let: y;
let: found;

Here is the higlight circle and the tooltip:

<!-- higlight circle -->
<div
  class="circle"
  style="top:{y}px;left:{x.circle}px;display: {visible
      ? 'block'
      : 'none'}; width: {radiusScale(+found.population) * 2 +
      5 +
      0}px ; height: {radiusScale(+found.population) * 2 + 5}px;" />
<!-- Tooltip Element   -->
<div
  class="tooltip pointer-events-none bg-gray-50 bg-opacity-90 text-gray-900"
  style="top:{y + 5}px;left:{x.square + 10}px;display: {visible
      ? 'block'
      : 'none'};">
  <h1 class="mb-1 text-base text-gray-900">{found.country}</h1>
  GDP: {Number(found.gdp / 100000000).toFixed(1) + ' Bil. $'}<br />Pop.:
  {Number( +found.population / 1000000 ).toFixed(1) + ' Mil.'}<br />
  Life Expectancy: {Number(+found.life_expectancy).toFixed(1)}
</div>

Here is the final code:

<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/AxisD3.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';

  import Quadtree from '$lib/components/Basics/Quadtree.svelte';
  // 1. Getting the data
  let data;

  onMount(() => {
    csv('/data/world_bank.csv')
      .then((unsorted_data) =>
        unsorted_data.sort((a, b) => +b.population - +a.population)
      )
      .then((sorted_data) => (data = sorted_data));
  });

  $: console.log(data);

  const continents = [
    'North America',
    'Asia',
    'Europe',
    'South Ameria',
    '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 Ameria',
      '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}
      <Quadtree
        {data}
        {xScale}
        {yScale}
        {width}
        {height}
        {margin}
        let:visible
        let:x
        let:y
        let:found>
        <div
          class="circle"
          style="top:{y}px;left:{x.circle}px;display: {visible
            ? 'block'
            : 'none'}; width: {radiusScale(+found.population) * 2 +
            5 +
            0}px ; height: {radiusScale(+found.population) * 2 + 5}px;" />
        <div
          class="tooltip pointer-events-none bg-gray-50 bg-opacity-90 text-gray-900"
          style="top:{y + 5}px;left:{x.square + 10}px;display: {visible
            ? 'block'
            : 'none'};">
          <h1 class="mb-1 text-base text-gray-900">{found.country}</h1>
          GDP: {Number(found.gdp / 100000000).toFixed(1) + ' Bil. $'}<br />Pop.: {Number(
            +found.population / 1000000
          ).toFixed(1) + ' Mil.'}<br />
          Life Expectancy: {Number(+found.life_expectancy).toFixed(1)}
        </div>
      </Quadtree>
      <svg {width} {height}>
        <g class="pointer-events-none">
          <!-- <rect class='pointer-events-all' x={margin.left} y={margin.top} width={width-margin.right} height={height-margin.bottom}></rect> -->
          <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)}
              opacity="0.8" />
          {/each}
        </g>
        <g
          class="pointer-events-none"
          transform="translate({width - margin.right},300)">
          <RadialLegend {width} {height} {margin} {radiusScale} />
        </g>
        <g
          class="pointer-events-none"
          transform="translate({width - margin.right},130)">
          <CategoryLegend
            legend_data={continents}
            legend_color_function={colors}
            space={80} />
        </g>
      </svg>
    {/if}
  </div>
</div>

<style>
  b.tooltip {
    text-overflow: nowrap;
  }
  .tooltip {
    position: absolute;
    font-family: 'Poppins', sans-serif !important;
    min-width: 8em;
    line-height: 1.2;
    font-size: 0.875rem;
    z-index: 1;
    padding: 6px;
    transition: left 100ms ease, top 100ms ease;
  }

  .circle {
    position: absolute;
    border-radius: 50%;
    transform: translate(-50%, -50%);
    pointer-events: none;
    width: 10px;
    height: 10px;
    border: 1px solid #fff;
    transition: left 300ms ease, top 300ms ease;
  }
</style>

Thanks for reading! You can find the code for the <Axis>, <Legend>, <CategoryLegend> and the <RadialLegend> in the previous tutorial.