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