Skip to content

Build a Choropleth from Your Own Data in Maptoolkit Maps JS

A choropleth is two datasets that have never met: shapes from one place, numbers from another, joined on a code they happen to share. The map supplies neither, so the work is the join and the classification. This example shades the EU member states by GDP per capita, using open boundaries from Eurostat GISCO and open figures from the Eurostat statistics API.

const API_KEY = 'YOUR_API_KEY';

    const BOUNDARIES = 'https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_20M_2021_4326_LEVL_0.geojson';
    const STATISTICS = 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10r_2gdp' +
                       '?format=JSON&lang=EN&unit=EUR_HAB&time=2022';

    const STOPS = [
        { from: 0,     color: '#eff3ff', label: 'under €20,000' },
        { from: 20000, color: '#bdd7e7', label: '€20,000 to €30,000' },
        { from: 30000, color: '#6baed6', label: '€30,000 to €45,000' },
        { from: 45000, color: '#3182bd', label: '€45,000 to €70,000' },
        { from: 70000, color: '#08519c', label: '€70,000 and above' }
    ];

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [10.0, 52.0],
        zoom: 3,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // JSON-stat: `value` is keyed by flat index, and the geo dimension maps code to index.
    // With unit and time pinned to one value each, the flat index is the geo index.
    function toLookup(jsonStat) {
        const index = jsonStat.dimension.geo.category.index;
        const values = jsonStat.value;
        const lookup = {};
        for (const [code, position] of Object.entries(index)) {
            const value = values[position];
            if (value !== undefined && value !== null) lookup[code] = value;
        }
        return lookup;
    }

    map.on('load', async () => {
        const [boundaries, statistics] = await Promise.all([
            fetch(BOUNDARIES).then(r => r.json()),
            fetch(STATISTICS).then(r => r.json())
        ]);

        const gdp = toLookup(statistics);

        // The join. NUTS_ID on the geometry, the same code as the key in the table.
        const joined = {
            type: 'FeatureCollection',
            features: boundaries.features
                .filter(f => f.properties.EU_STAT === 'T')
                .map(f => ({
                    ...f,
                    properties: {
                        code: f.properties.NUTS_ID,
                        name: f.properties.NAME_ENGL,
                        // null, not 0: an unknown value must not read as a low one.
                        gdp: gdp[f.properties.NUTS_ID] ?? null
                    }
                }))
        };

        const matched = joined.features.filter(f => f.properties.gdp !== null).length;

        map.addSource('states', {
            type: 'geojson',
            data: joined,
            attribution: '&copy; <a href="https://ec.europa.eu/eurostat" target="_blank" rel="noopener">Eurostat</a>, &copy; EuroGeographics'
        });

        const fillColor = ['case',
            ['==', ['get', 'gdp'], null], '#e0e0e0',
            ['step', ['get', 'gdp'], STOPS[0].color, ...STOPS.slice(1).flatMap(s => [s.from, s.color])]
        ];

        map.addLayer({
            id: 'states-fill', type: 'fill', source: 'states',
            paint: { 'fill-color': fillColor, 'fill-opacity': 0.75 }
        }, map.getStyle().layers.find(l => l.type === 'symbol')?.id);

        map.addLayer({
            id: 'states-line', type: 'line', source: 'states',
            paint: { 'line-color': '#ffffff', 'line-width': 0.8 }
        }, map.getStyle().layers.find(l => l.type === 'symbol')?.id);

        const popup = new maptoolkit.Popup({ closeButton: false, closeOnClick: false });

        map.on('mousemove', 'states-fill', (e) => {
            map.getCanvas().style.cursor = 'pointer';
            const p = e.features[0].properties;
            popup.setLngLat(e.lngLat)
                .setHTML(`<strong>${p.name}</strong><br>${p.gdp ? `€${p.gdp.toLocaleString()} per capita` : 'No data'}`)
                .addTo(map);
        });
        map.on('mouseleave', 'states-fill', () => {
            map.getCanvas().style.cursor = '';
            popup.remove();
        });

        document.getElementById('legend').innerHTML =
            '<h4>GDP per capita, 2022</h4>'
            + '<div class="sub">euro per inhabitant</div>' +
            STOPS.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('') +
            `<div class="row"><span class="sw" style="background:#e0e0e0"></span><span>No data</span></div>` +
            `<div class="row" style="margin-top:6px;color:#777">${matched} of ${joined.features.length} matched</div>`;
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Build a Choropleth - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Join statistics to boundaries and shade by value." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #legend {
            position: absolute; bottom: 30px; right: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 12px/1.5 system-ui, sans-serif; padding: 10px 12px;
        }
        #legend h4 { margin: 0; font-size: 12px; }
        #legend .sub { color: #777; margin: 0 0 6px; font-size: 11px; }
        #legend .row { display: flex; align-items: center; gap: 8px; }
        #legend .sw { width: 14px; height: 14px; flex: none; border: 1px solid #0002; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="legend">Loading...</div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const BOUNDARIES = 'https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_20M_2021_4326_LEVL_0.geojson';
    const STATISTICS = 'https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10r_2gdp' +
                       '?format=JSON&lang=EN&unit=EUR_HAB&time=2022';

    const STOPS = [
        { from: 0,     color: '#eff3ff', label: 'under €20,000' },
        { from: 20000, color: '#bdd7e7', label: '€20,000 to €30,000' },
        { from: 30000, color: '#6baed6', label: '€30,000 to €45,000' },
        { from: 45000, color: '#3182bd', label: '€45,000 to €70,000' },
        { from: 70000, color: '#08519c', label: '€70,000 and above' }
    ];

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [10.0, 52.0],
        zoom: 3,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // JSON-stat: `value` is keyed by flat index, and the geo dimension maps code to index.
    // With unit and time pinned to one value each, the flat index is the geo index.
    function toLookup(jsonStat) {
        const index = jsonStat.dimension.geo.category.index;
        const values = jsonStat.value;
        const lookup = {};
        for (const [code, position] of Object.entries(index)) {
            const value = values[position];
            if (value !== undefined && value !== null) lookup[code] = value;
        }
        return lookup;
    }

    map.on('load', async () => {
        const [boundaries, statistics] = await Promise.all([
            fetch(BOUNDARIES).then(r => r.json()),
            fetch(STATISTICS).then(r => r.json())
        ]);

        const gdp = toLookup(statistics);

        // The join. NUTS_ID on the geometry, the same code as the key in the table.
        const joined = {
            type: 'FeatureCollection',
            features: boundaries.features
                .filter(f => f.properties.EU_STAT === 'T')
                .map(f => ({
                    ...f,
                    properties: {
                        code: f.properties.NUTS_ID,
                        name: f.properties.NAME_ENGL,
                        // null, not 0: an unknown value must not read as a low one.
                        gdp: gdp[f.properties.NUTS_ID] ?? null
                    }
                }))
        };

        const matched = joined.features.filter(f => f.properties.gdp !== null).length;

        map.addSource('states', {
            type: 'geojson',
            data: joined,
            attribution: '&copy; <a href="https://ec.europa.eu/eurostat" target="_blank" rel="noopener">Eurostat</a>, &copy; EuroGeographics'
        });

        const fillColor = ['case',
            ['==', ['get', 'gdp'], null], '#e0e0e0',
            ['step', ['get', 'gdp'], STOPS[0].color, ...STOPS.slice(1).flatMap(s => [s.from, s.color])]
        ];

        map.addLayer({
            id: 'states-fill', type: 'fill', source: 'states',
            paint: { 'fill-color': fillColor, 'fill-opacity': 0.75 }
        }, map.getStyle().layers.find(l => l.type === 'symbol')?.id);

        map.addLayer({
            id: 'states-line', type: 'line', source: 'states',
            paint: { 'line-color': '#ffffff', 'line-width': 0.8 }
        }, map.getStyle().layers.find(l => l.type === 'symbol')?.id);

        const popup = new maptoolkit.Popup({ closeButton: false, closeOnClick: false });

        map.on('mousemove', 'states-fill', (e) => {
            map.getCanvas().style.cursor = 'pointer';
            const p = e.features[0].properties;
            popup.setLngLat(e.lngLat)
                .setHTML(`<strong>${p.name}</strong><br>${p.gdp ? `€${p.gdp.toLocaleString()} per capita` : 'No data'}`)
                .addTo(map);
        });
        map.on('mouseleave', 'states-fill', () => {
            map.getCanvas().style.cursor = '';
            popup.remove();
        });

        document.getElementById('legend').innerHTML =
            '<h4>GDP per capita, 2022</h4>'
            + '<div class="sub">euro per inhabitant</div>' +
            STOPS.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('') +
            `<div class="row"><span class="sw" style="background:#e0e0e0"></span><span>No data</span></div>` +
            `<div class="row" style="margin-top:6px;color:#777">${matched} of ${joined.features.length} matched</div>`;
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map that loads EU country boundaries from Eurostat GISCO and GDP per capita from the Eurostat statistics API, joins them on the NUTS code, shades each country by value, and shows the figure in a popup on hover.

How it works

The join is the example. Both datasets are keyed on the NUTS code, NUTS_ID on the geometry and the geo dimension key in the statistics, so the whole join is one lookup per feature. Picking datasets that already share a code is most of the work of building a choropleth; joining on names instead means reconciling “Czechia” against “Czech Republic” against “CZ” forever.

Name the unit on the map. “GDP per capita” alone tells the reader nothing about what the numbers are counted in, and a legend of bare thousands invites them to guess. This one says euro per inhabitant, which is what the EUR_HAB unit in the request returns.

One caveat worth knowing before you draw conclusions from it: these are plain euros, not adjusted for what a euro actually buys locally, so the gap between the richest and poorest states looks wider than lived experience. Eurostat publishes a price-adjusted version of the same figure measured in purchasing power standards: an artificial currency in which one unit buys the same basket of goods in every member state. Swap the request’s unit to PPS_EU27_2020_HAB if you need countries to be strictly comparable. It is the better statistic and the worse label, so write “purchasing power standards” in the legend. The abbreviation means nothing to a reader who is not an economist, and searching for it does not help them.

Eurostat returns JSON-stat, which is not a table. value is a flat object keyed by numeric position, and each dimension carries an index mapping its codes to positions. With unit and time pinned to a single value each, only geo varies, so the flat position is the geo position. Leave a second dimension unpinned and that stops being true: the flat index becomes a product of the dimension sizes and the lookup silently returns the wrong country’s number.

Missing data is null, never 0. A country with no figure is rendered in grey by the case that tests for null before the step runs. Defaulting to zero paints it as the poorest state on the map, which is a plausible-looking lie. The legend carries a “no data” row for the same reason, and the match count is shown so a broken join is visible rather than looking like a lot of grey.

Classification decides what the map says. step with fixed thresholds keeps two loads of the same map comparable, which quantiles computed from the data do not: adding one country reshuffles every band. Fixed bands are also what lets the legend be written once.

The fill goes below the basemap’s symbol layers, so place names stay readable over it. At 0.75 opacity the terrain underneath still shows through, which is usually what you want on a choropleth over a detailed basemap.

Attribution rides on the source. Both datasets require a credit, and attaching it to the source means it appears with the layer rather than being a string somebody has to remember.

Area distortion is worth a thought at this zoom. Web Mercator inflates northern countries, so Finland and Sweden look far larger than their population or economy, and a reader takes size as significance. Switching to a globe projection removes the distortion.

Next steps

At country level a choropleth hides everything interesting inside each country. The same code works at NUTS level 2 by swapping LEVL_0 for LEVL_2 in the boundary URL and using the nama_10r_2gdp figures already keyed at that level, which turns 27 shapes into 334 and shows the regional spread.

This pattern stops scaling when the join gets big. At NUTS level 3 the boundaries are 28 MB at full resolution, which is past what belongs in a browser download; Connectors do the join on the server and serve the result as vector tiles, and the paint expression is unchanged.