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!