Adding Tooltips by using Quadtree algorithm to Scatter Chart

Why Use a Quadtree?

Instead of requiring the mouse to hover precisely over a circle, the quadtree algorithm makes it possible to display tooltips for the closest circle to the mouse position. This enhances usability, especially for smaller circles or dense charts, by allowing users to easily access details about data points.

The D3 quadtree module implements a tree data structure commonly used in computer science for spatial partitioning. It allows us to efficiently search for objects in a two-dimensional space. Learn more in the official D3 documentation.

Step 1: Create a Svelte Quadtree Component

Download the dataset used in this tutorial here.

This component:

  • Receives the mouse position and calculates the closest data point.
  • Passes the data point to its child component for rendering (e.g., a tooltip).
<!-- Quadtree.svelte -->
<script>
  import { quadtree } from 'd3-quadtree';

  // Props declaration using $props
  let {
    xScale,
    yScale,
    margin,
    data,
    searchRadius = undefined,
    children // Pass rendering logic as a prop
  } = $props();

  // State declarations
  let visible = $state(false);
  let found = $state(null);
  let e = $state({});
  let finder = $state(null);

  // Create quadtree when data changes
  $effect(() => {
    if (data && xScale && yScale) {
      finder = quadtree()
        .x((d) => xScale(+d.gdp / +d.population))
        .y((d) => yScale(+d.life_expectancy))
        .addAll(data);
    }
  });

  // Function to find item in the quadtree
  function findItem(evt) {
    const { layerX, layerY } = evt;

    if (finder) {
      const result = finder.find(layerX, layerY, searchRadius);
      found = result || null;
      visible = result !== null;
      e = evt;
    }
  }

  // Get position for found data
  function getPosition(foundItem) {
    if (foundItem?.gdp && foundItem?.population) {
      const xPos = xScale(+foundItem.gdp / +foundItem.population);
      return xPos > 0.9 * xScale.range()[1]
        ? { circle: xPos, square: xPos - 100 }
        : { circle: xPos, square: xPos };
    }
    return null;
  }

  // Computed values
  const position = $derived(found ? getPosition(found) : null);
  const yPosition = $derived(found ? yScale(+found.life_expectancy) : null);
</script>

<div
  aria-hidden
  class="bg"
  onmousemove={findItem}
  onblur={() => {
    visible = false;
    found = null;
  }}
  onmouseout={() => {
    visible = false;
    found = null;
  }}>
</div>

{#if found && position && yPosition}
  {@render children({
    x: position,
    y: yPosition,
    found,
    visible,
    margin,
    e
  })}
{/if}

<style>
  .bg {
    position: absolute;
    width: 100%;
    height: 100%;
  }
</style>

Let’s look at the important pieces of the above code. Here, we are setting the finder if data and scales are available. finder will be updated as well if any of its dependencies change.

// Create quadtree when data changes
$effect(() => {
  if (data && xScale && yScale) {
    finder = quadtree()
      .x((d) => xScale(+d.gdp / +d.population))
      .y((d) => 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 to find item in the quadtree
function findItem(evt) {
  const { layerX, layerY } = evt;

  if (finder) {
    const result = finder.find(layerX, layerY, searchRadius);
    found = result || null;
    visible = result !== null;
    e = evt;
  }
}

Example returned data point:

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

Step 2: Highlight Circles and Show Tooltips

We use the {@render children} syntax to pass data from the quadtree to child components. Below is an example of rendering a tooltip and a highlighting circle:

<Quadtree {data} {xScale} {yScale} {width} {height} {margin} searchRadius={30}>
  {#snippet children({ x, y, found, visible })}
    <div
      class="circle"
      style="top:{y}px;left:{x.circle}px;display: {visible
        ? 'block'
        : 'none'}; width: {radiusScale(+found.population) * 2 +
        5}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>
  {/snippet}
</Quadtree>

We are passing the data from the parent to its child component as below:

 {#snippet children({ x, y, found, visible })}

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>

Step 3: Integrate the Scatter Chart

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 AxisLeft from '$lib/components/Basics/AxisLeftV5.svelte';
  import AxisBottom from '$lib/components/Basics/AxisBottomV5.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 = $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 && xScale && yScale && radiusScale}
      <Quadtree
        {data}
        {xScale}
        {yScale}
        {width}
        {height}
        {margin}
        searchRadius={30}>
        {#snippet children({ x, y, found, visible })}
          <div
            class="circle"
            style="top:{y}px;left:{x.circle}px;display: {visible
              ? 'block'
              : 'none'}; width: {radiusScale(+found.population) * 2 +
              5}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>
        {/snippet}
      </Quadtree>

      <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>

<style>
  .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>

Key Notes:

Tailwind CSS:

Dynamic Data Fetching:

Reusable Components:

Join our Discord to share your charts, ask questions, or collaborate with fellow developers!