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: '© <a href="https://ec.europa.eu/eurostat" target="_blank" rel="noopener">Eurostat</a>, © 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: '© <a href="https://ec.europa.eu/eurostat" target="_blank" rel="noopener">Eurostat</a>, © 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.
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.