jem-id
Terug
tech blog

Een interactieve wereldkaart met D3 en GeoJson

Auteur
Gepost op 09 jul 2026
door Danny Nieuwmans (front-end developer)

In onze webapplicatie HortiHeat ontwikkelden we een statistiekenpagina, om de gebruiker meer inzicht te geven in zijn of haar bezoekers. Een belangrijk onderdeel hiervan is de bezoekersdata per land. Dat hadden we kunnen weergeven via een tabel met cijfers en landvlaggen, maar een interactieve wereldkaart is natuurlijk een stuk interessanter. Niet alleen voor onze klant, maar ook voor ons om te ontwikkelen! In dit artikel neem ik je mee in mijn aanpak en vertel ik hoe ik met React, GeoJSON en D3.js aan de slag ben gegaan om deze interactieve kaart te bouwen.

Goed, we gaan dus een interactieve wereldkaart maken. Dan is het eerst belangrijk om helder te krijgen wat de kaart precies moet kunnen of beter gezegd: welke uitdagingen er opgelost moeten worden.

  • Landinformatie vanuit de back-end moet gekoppeld kunnen worden aan een interactieve kaart, inclusief de juiste vertalingen.
  • De kaart moet ondersteuning bieden voor interactieve zoom- en panfunctionaliteit.
  • Landen moeten dynamisch ingekleurd worden op basis van bezoekersaantallen, vergelijkbaar met een heatmap of choropletenkaart.
  • De oplossing moet responsive én performant zijn. Je wilt tenslotte niet meerdere megabytes aan kaartdata inladen voor een eenvoudige visualisatie.


Waar te beginnen?

Ik ben begonnen zoals elke front-ender: eerst kijken of er een mooie React-package bestaat die al mijn problemen oplost. Al snel ontdekte ik dat de meeste libraries nét niet deden wat ik nodig had, of simpelweg niet meer actief onderhouden werden. Tijd om een andere weg in te slaan.

Tijdens die zoektocht kwam ik opnieuw GeoJSON tegen. Ik had er al vaker van gehoord, maar er nooit echt mee gewerkt. GeoJSON is in feite een standaardformaat gebaseerd op JSON waarmee je geografische data kunt beschrijven. Denk aan landen, provincies, steden of complete werelddelen, allemaal opgebouwd uit coördinaten.

Omdat ik in het verleden al eens met D3 had gewerkt, besloot ik daar opnieuw naar te kijken. D3 is een JavaScript-library waarmee je data kunt omzetten in interactieve visualisaties binnen de browser. Al snel ontdekte ik dat D3 perfect kan samenwerken met GeoJSON. Op basis van die data kun je namelijk een SVG-projectie genereren en in dit geval, van een wereldkaart.

Mijn vervolgstap was dan ook duidelijk: een klein prototype bouwen waarin ik GeoJSON-data inlaadde en met D3 projecteerde naar SVG-paden. Alsdat werkte, had ik een solide basis om verder op te bouwen. Pas daarna wilde ik de kaart écht interessant maken door bezoekersaantallen per land te verwerken en interactieve elementen toe te voegen. 


Tijd voor een proof-of-concept

Als eerste stap moest ik uitzoeken waar ik überhaupt GeoJSON-data vandaan kon halen. Na wat rondzoeken kwam ik terecht bij Natural Earth, een bron voor open source geografische datasets.

Voor deze toepassing had ik geen extreem gedetailleerde wereldkaart nodig, dus koos ik voor de dataset op schaal 1:110m. Die biedt meer dan genoeg detail voor een interactieve wereldkaart, terwijl de bestandsgrootte beperkt blijft. Hoe gedetailleerder de geometrie, hoe groter het bestand en hoe zwaarder de rendering wordt. En zoals eerder genoemd was performance een belangrijke vereiste binnen deze implementatie.

Om niet afhankelijk te zijn van externe bronnen tijdens runtime heb ik de dataset rechtstreeks in het project opgenomen. Daardoor hebben we altijd toegang tot de data en voorkomen we extra afhankelijkheden.

Daarnaast wilde ik voorkomen dat het GeoJSON-bestand telkens opnieuw geladen wordt. Het bestand is immers al snel zo’n 500 KB groot. Daarom heb ik gekozen voor een eenvoudige fetch-implementatie in combinatie met een kleine cache-wrapper. Zodra de data eenmaal geladen is, kan die opnieuw gebruikt worden zonder onnodige requests of extra verwerking.


D3 projectie 

Daarna ben ik met D3 aan de slag gegaan om op basis van de GeoJSON-data een kaartprojectie te renderen. Hiervoor heb ik gebruikgemaakt van geoNaturalEarth1. De eerste stap was het definiëren van een zogeheten *projection*. Door de projectie goed af te stemmen, blijft de kaart ook tijdens het inzoomen scherp en gedetailleerd.

const projection = geoNaturalEarth1().scale(110)
const pathGenerator = geoPath(projection);

De naam geeft eigenlijk al aan wat er gebeurt: met geoPath maakt D3 een functie waarmee GeoJSON-data automatisch vertaald kan worden naar SVG-paths. Vervolgens loop ik door alle landen heen om voor elk land een <path>-element binnen de SVG te renderen. De uiteindelijke implementatie is iets uitgebreider, maar vereenvoudigd zag dat er ongeveer zo uit:

<svg …>
    <g>
        {geoCountries.map((c, index) => {
            return (
                <path
                    key={index}
                    d={pathGenerator(c)}
                    fill='#000'
                    stroke='#fff'
                    strokeWidth={1}
                />
            );
        })}
    </g>
</svg>

Het <svg>-element bevat daarnaast uiteraard nog een viewBox en andere configuratie, maar die zijn afhankelijk van de specifieke implementatie.
Wat ik wel even wil benoemen, is het  gebruik van het <g>-element. Door alle landen binnen één group te renderen, ontstaat later de mogelijkheid om eenvoudig te zoomen, pannen en transformaties toe te passen op de volledige kaart.
Voordat ik die interacties verder kon uitwerken, wilde ik eerst de kaartdata gaan koppelen.


Koppelen van data

Nu de basis stond, was het tijd om de backend-data aan de kaart te koppelen. Dat bleek iets minder eenvoudig dan verwacht. De GeoJSON-data van Natural Earth gebruikt namelijk numerieke ISO 3166-1-landcodes, terwijl onze backend alpha-2-codes terugstuurt. Zo staat Nederland in de backend als NL, maar in de GeoJSON-data als 528. Voor Duitsland is dat DE tegenover 276.

Om die twee aan elkaar te koppelen heb ik gebruikgemaakt van de i18n-iso-countries package, die een handige numericToAlpha2-functie biedt. Hiermee kon ik per land in de GeoJSON de numerieke code omzetten naar een alpha-2 code en zo de bijbehorende bezoekersaantallen opzoeken.

Tegelijkertijd wilde ik ook de landnamen vertalen. Daarvoor gebruik ik een eigen hulpfunctie getCountry, die op basis van de alpha-2 code een vertaalde naam teruggeeft. Als er geen vertaling beschikbaar is, valt de implementatie terug op de Engelse naam die in de GeoJSON-data zelf al aanwezig is.

Het resultaat is een GeoCountry-type, een uitbreiding op het standaard GeoJSON Feature, dat per land zowel de bezoekersaantallen als de vertaalde naam meebrengt. Vereenvoudigd ziet de koppeling er zo uit:

const countries = geo.features.map((f): GeoCountry => 
    const countryCode = numericToAlpha2(f.id);
    const visits = countryResults.find(r => r.countryCode.toUpperCase() === countryCode)?.totalNumberOfVisits ?? 0;
    return {
        ...f,
        properties: {
            ...f.properties,
            visits,
            translatedName: getCountry(countryCode),
        },
    };
});

De useWorldMap hook

Voordat ik verder ga met de vervolgstappen, zoals het toevoegen van de heatmap-kleuren en de interactieve functionaliteit, wil ik eerst ingaan op de structuur.

Al vrij snel merkte ik dat de D3-logica, zoals projecties, kleurschalen en zoomgedrag, behoorlijk wat code met zich meebrengt. Om het component overzichtelijk te houden, heb ik deze logica volledig ondergebracht in een aparte custom hook: `useWorldMap`.

Een custom hook is een patroon waarmee je logica kunt isoleren en hergebruiken zonder deze direct in een component onder te brengen. In dit geval ontvangt de hook de SVG- en groepsreferenties (ref's), samen met het maximale aantal bezoekers (data vanuit de backend), en geeft hij functies terug waarmee het component kan werken, zoals `getPathForCountry`, `getColorForVisits`, `zoom` en `resetZoom`, aangevuld met enkele variabelen zoals het huidige zoomniveau en of je verder kan in-/uitzoomen.

Hierdoor blijft het component zelf volledig gericht op de presentatie. Het hoeft niets te weten van D3 en roept alleen de functies van de hook aan. Deze scheiding maakt de code niet alleen beter leesbaar, maar ook makkelijker uitbreidbaar.


Kleuren op basis van bezoekersaantallen

Met de data gekoppeld kon ik beginnen met het inkleuren van de landen. Hiervoor gebruik ik D3's `scaleLinear`, waarmee bezoekersaantallen worden vertaald naar een kleur op de kaart.

In eerste instantie gebruikte ik een schaal van `0` tot `maxVisits`, maar daardoor kregen landen zonder bezoekers dezelfde lichte oranje tint als landen met een laag bezoekersaantal. Om dat onderscheid zichtbaar te maken, heb ik een extra waarde toegevoegd:


scaleLinear()
    .domain([0, 1, maxVisits])
    .range([
        theme.palette.grey[400],
        worldMapColors.min,
        worldMapColors.max,
    ]);

Hierdoor worden landen zonder bezoekers grijs weergegeven, terwijl landen met bezoekers variëren van licht- tot donkeroranje.


Zoom en pan

Voor het zoomen en pannen maak ik gebruik van D3's `zoom`-behavior. D3 maakt bij zoomen en pannen zelf een transform aan, die aangeeft hoe de SVG-afbeelding verplaatst of vergroot moet worden. Zodra de gebruiker in- of uitzoomt, wordt de transform toegepast op de `<g>`-groep die alle landen bevat.


const zoomBehavior = zoom()
    .scaleExtent([1, maxZoomLevel]);

zoomBehavior.on('zoom', (event) => {
    g.attr('transform', event.transform);
});

Een klein detail dat ik daarbij heb toegevoegd, is het dynamisch aanpassen van de randdikte. Zonder dit worden de landgrenzen bij hogere zoomniveaus al snel te dik.


function getStrokeWidthBasedOnZoomLevel() {
    if (currentZoomLevel >= 4) return 0.5;
    if (currentZoomLevel >= 2) return 1;
    return 2;
}

Tooltip bij hover

Als laatste heb ik een tooltip toegevoegd waarmee gebruikers direct de landnaam en het aantal bezoekers kunnen zien. Hiervoor gebruik ik MUI’s `Popper`, een component voor het positioneren van zwevende elementen zoals tooltips. `Popper` verwacht normaal gesproken een DOM-element waaraan de tooltip wordt gekoppeld, wat `anchor` genoemd wordt. Omdat de kaart uit SVG-elementen bestaat en ik de tooltip wil positioneren op de huidige muispositie, gebruik ik een virtueel element als anchor.


const virtualAnchor = {
    getBoundingClientRect: () => ({
        top: mousePosition.y,
        left: mousePosition.x,
        right: mousePosition.x,
        bottom: mousePosition.y,
        width: 0,
        height: 0,
    }),
    nodeType: 1,
};

Daardoor volgt de tooltip de cursor terwijl de gebruiker over de kaart beweegt.

Tot slot een kleine maar pragmatische keuze die ik niet onvermeld wil laten: Antarctica wordt weggefilterd uit de kaart. Technisch werkt dat prima, het continent heeft gewoon een GeoJSON-geometrie zoals ieder ander land. Maar voor een visualisatie van bezoekersaantallen binnen een Lookbook voegt het weinig toe. Ik verwacht niet snel bezoekers vanuit Antarctica. Mocht dat ooit toch gebeuren, dan vind ik dat vooral erg cool. Bovendien is het eenvoudig terug te draaien: één regel code aanpassen en het werkt direct weer.

Conclusie

Wat begon als een relatief eenvoudige uitdaging, groeide uit tot een leuke verkenning van GeoJSON en D3. Daarbij ging het zeker niet in één keer goed. Zoals vaak bij het werken met nieuwe technieken bestond een groot deel van het proces uit experimenteren, documentatie doorspitten en verschillende oplossingen uitproberen voordat alles op zijn plek viel.

Het resultaat is een wereldkaart die voldoet aan de wensen die vooraf waren gesteld. Inmiddels draait deze implementatie al enkele maanden probleemloos in productie en wordt deze goed ontvangen.

Meer lezen over de back-end van deze wereldkaart? Lees dan de blog ‘Achter de schermen van een statistiekenpagina’.