Which APIs the page uses, which coordinate systems are involved, and how to call them yourself. The running example is Rokin 111, Amsterdam (the Ohpen office).
The page does no coordinate maths. It only ever sends GPS coordinates (WGS84), and tells ArcGIS so with inSR=4326. The ArcGIS server converts them to the Dutch RD New grid that the data is stored in.
Everything runs in the browser. Neither API needs a key or a login, and both allow cross-origin requests.
ArcGIS Online is run by Esri Inc., a US company. In the ArcGIS tab your browser sends the address's postcode and GPS point straight to Esri. The Self hosted tab avoids that: the data is downloaded once from the Klimaateffectatlas's own service, cut into small files, and served from this server. Visitors then only talk to this server and to PDOK (Kadaster) for the address search.
/suggest and /lookup, but also asking for centroide_rd.data/tiles/{tx}_{ty}.json.data/buurt/{code}.json.There is no server code and no database: nginx serves plain JSON files. A lookup downloads one tile (at most 80 kB) and one neighbourhood file (about 1 kB). This server only learns which 2×2 km square was looked up.
The files are built from the Klimaateffectatlas's own service, not from the ArcGIS copies. Why is explained under Licensing.
| System | EPSG code | Units | Rokin 111 | Where it appears |
|---|---|---|---|---|
| WGS84 (GPS) | 4326 | Degrees, longitude then latitude | 4.89278973, 52.3695768 | What PDOK gives us and what the ArcGIS tab sends to ArcGIS |
| RD New (Rijksdriehoek) | 28992 | Metres, x then y | 121328, 486970 | How every layer is stored, on ArcGIS and in the self-hosted tiles |
PDOK returns both forms of the same point: centroide_ll is WGS84 and centroide_rd is RD New. The ArcGIS tab only needs centroide_ll, because ArcGIS converts it. The self-hosted tab uses centroide_rd, because its tiles are in RD New and the browser does no conversion either.
POINT(...) text and the ArcGIS geometry parameter put longitude first, then latitude. Most map apps show them the other way round.Base URL: https://api.pdok.nl/bzk/locatieserver/search/v3_1
| Endpoint | Used for | Coordinates |
|---|---|---|
/suggest | Autocomplete while typing | None: text in, address ids out |
/lookup | Details for one address id | Returns centroide_ll (WGS84) and centroide_rd (RD New) |
Base URL: https://services.arcgis.com/nSZVuSZjHpEZZbRo/arcgis/rest/services
Each service is called as {base}/{service}/FeatureServer/{layer}/query. All layers are stored in RD New (28992).
| Service | Queried by | We send | What it holds |
|---|---|---|---|
Klimaatdata | Postcode | No coordinates | Flood depth, rainfall, tropical days, rainfall deficit (now and 2050) |
Klimaateffectatlas_Sociale_kwetsbaarheid_hitte | Point | WGS84 | Social vulnerability to heat |
Klimaateffectatlas_Groen_per_buurt | Point | WGS84 | Share of green, trees, buildings and water per neighbourhood |
Klimaateffectatlas_Percentage_schaduw_op_fiets_en_wandelpaden_buurtniveau | Point | WGS84 | Shade on cycle and walking paths |
Klimaateffectatlas_Wijktypologie_Buurtniveau | Point | WGS84 | Neighbourhood type |
Klimaateffectatlas_Basiskaart_natuurlijk_systeem_eenheden | Point | WGS84 | Natural system (soil and landscape unit) |
Klimaateffectatlas_Ernstig_eenzame_75ers | Point | WGS84 | Severely lonely people aged 75 and over |
Klimaateffectatlas_Gevoelstemperatuur_buurtniveau_2012_en_2022 | Point | WGS84 | Feels-like temperature (PET) per neighbourhood |
0. The exception is Gevoelstemperatuur, which has no layer 0: layer 1 is 2022 and layer 2 is 2012. The page queries both.Base URL: https://klimaat.mostlyvibes.com/data. Plain JSON files, built once a year by tools/build.py. The examples are the files the page loads for Rokin 111.
| File | Fetched | What it holds | Example |
|---|---|---|---|
/meta.json | Once per visit | Tile grid, build date, source layer names, natural-system legends | meta.json |
/tiles/{tx}_{ty}.json | Per address | The polygons in one 2×2 km tile, each with only a code | tiles/60_93.json |
/buurt/{buurtcode}.json | Per address | All values for one neighbourhood | buurt/BU0363AE04.json |
Send the typed text to /suggest. fq=type:adres limits results to full addresses, and fl picks the fields to return.
const PDOK = 'https://api.pdok.nl/bzk/locatieserver/search/v3_1';
const q = 'Rokin 111 Amsterdam';
const resp = await fetch(`${PDOK}/suggest?q=${encodeURIComponent(q)}&fq=type:adres&rows=7&fl=id,weergavenaam,type,score`);
const docs = (await resp.json()).response.docs;
Response (trimmed):
{ "response": { "docs": [ {
"type": "adres",
"weergavenaam": "Rokin 111, 1012KN Amsterdam",
"id": "adr-636952f00c72313cf8d7b88dcb4ca35f",
"score": 10.274468
} ] } }
Pass the id to /lookup and ask for the fields you need.
const id = 'adr-636952f00c72313cf8d7b88dcb4ca35f';
const fields = 'weergavenaam,postcode,buurtcode,buurtnaam,wijkcode,wijknaam,gemeentenaam,provincienaam,centroide_ll,centroide_rd';
const resp = await fetch(`${PDOK}/lookup?id=${encodeURIComponent(id)}&fl=${fields}`);
const doc = (await resp.json()).response.docs[0];
Response (trimmed):
{
"weergavenaam": "Rokin 111, 1012KN Amsterdam",
"postcode": "1012KN",
"buurtcode": "BU0363AE04",
"buurtnaam": "Nes e.o.",
"wijknaam": "Burgwallen-Oude Zijde",
"gemeentenaam": "Amsterdam",
"provincienaam": "Noord-Holland",
"centroide_ll": "POINT(4.89278973 52.3695768)",
"centroide_rd": "POINT(121328 486970)"
}
The points arrive as text, so pull the numbers out. Longitude comes first; in RD New, x comes first.
const match = doc.centroide_ll.match(/POINT\(([\d.]+)\s+([\d.]+)\)/);
const lon = parseFloat(match[1]); // 4.89278973
const lat = parseFloat(match[2]); // 52.3695768
const rd = doc.centroide_rd.match(/POINT\(([\d.]+)\s+([\d.]+)\)/);
const x = parseFloat(rd[1]); // 121328 (only the self-hosted tab needs these)
const y = parseFloat(rd[2]); // 486970
Seven of the eight services are neighbourhood polygons. Ask each one "which polygon contains this point?". inSR=4326 is the part that says the point is in GPS coordinates.
const ARCGIS = 'https://services.arcgis.com/nSZVuSZjHpEZZbRo/arcgis/rest/services';
async function queryBySpatial(service, lon, lat, layer = 0) {
const params = new URLSearchParams({
geometry: `${lon},${lat}`,
geometryType: 'esriGeometryPoint',
inSR: '4326', // our point is WGS84
spatialRel: 'esriSpatialRelIntersects',
outFields: '*',
returnGeometry: 'false', // we only want the values
resultRecordCount: '1',
f: 'json',
});
const resp = await fetch(`${ARCGIS}/${service}/FeatureServer/${layer}/query?${params}`);
const data = await resp.json();
return data.features?.[0]?.attributes || null;
}
const groen = await queryBySpatial('Klimaateffectatlas_Groen_per_buurt', lon, lat);
Result for Rokin 111 (trimmed):
{
"buurtcode": "BU03630003",
"perc_bebouwing_totaal": 53.93,
"perc_openbaar_totaal": 33.69,
"perc_water_totaal": 6.58,
"perc_openbaar_boom": 0.93
}
Feels-like temperature lives in layer 1 (2022) and layer 2 (2012), so pass the layer number:
const svc = 'Klimaateffectatlas_Gevoelstemperatuur_buurtniveau_2012_en_2022';
const pet2022 = await queryBySpatial(svc, lon, lat, 1); // { "PET_gem": 44, "RIVMClass": 6, ... }
const pet2012 = await queryBySpatial(svc, lon, lat, 2); // { "PET_gem": 43, "Class": 5, ... }
returnGeometry=false unless you need the outline. Without it, the natural-system layer returns a 2 MB polygon and takes seconds instead of a fraction of one. The page also shows each section as soon as its own query finishes, so one slow service does not hold up the rest.Klimaatdata has one record per postcode, so no coordinates are needed. Use the postcode without a space.
async function queryByPostcode(service, postcode) {
const params = new URLSearchParams({
where: `postcode='${postcode}'`,
outFields: '*',
returnGeometry: 'false',
f: 'json',
});
const resp = await fetch(`${ARCGIS}/${service}/FeatureServer/0/query?${params}`);
const data = await resp.json();
return data.features?.[0]?.attributes || null;
}
const klimaat = await queryByPostcode('Klimaatdata', '1012KN');
Result for 1012KN:
{
"postcode": "1012KN",
"overstromingsdiepte": "Uw locatie ligt binnen bedijkt gebied, dit betekent dat de kans op overstroming klein is",
"jaarlijkse_neerslag_huidig": "900-950",
"jaarlijkse_neerslag_2050": "950-1000",
"Tropische_dagen_huidig": "0-3",
"tropische_dagen_2050": "6-9",
"neerslagtekort_huidig": "150-180",
"neerslagtekort_2050": "210-240"
}
Instead of asking a server which neighbourhood contains the point, the browser fetches the tile the point falls in and checks the polygons itself.
Tile (tx, ty) covers RD x from tx × 2000 and RD y from 300000 + ty × 2000, each 2 km wide. Rokin 111 (RD 121328, 486970) is in tile 60_93. Polygons are clipped to the tile, simplified to 1 m, and stored as flat lists of whole metres relative to the tile's lower-left corner:
{
"buurt": [ { "a": { "code": "BU0363AE04" }, "r": [[1401, 1299, 1343, 1318, 1339, 1302, ...]] }, ... ],
"natuur_hoofdklasse": [ { "a": { "code": "3000" }, "r": [...] } ],
"natuur_subklasse": [ { "a": { "code": "B" }, "r": [...] }, { "a": { "code": "W" }, "r": [...] }, ... ]
}
Holes are just more rings. A point lies in a feature when it is inside an odd number of that feature's rings, so the page needs no geometry library:
function insideRings(rings, x, y) {
let inside = false;
for (const r of rings) {
for (let i = 0, j = r.length - 2; i < r.length; j = i, i += 2) {
const xi = r[i], yi = r[i + 1], xj = r[j], yj = r[j + 1];
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
}
}
return inside;
}
const tx = Math.floor(x / 2000), ty = Math.floor((y - 300000) / 2000);
const tile = await (await fetch(`data/tiles/${tx}_${ty}.json`)).json();
const px = x - tx * 2000, py = y - 300000 - ty * 2000;
const code = tile.buurt.find(f => insideRings(f.r, px, py))?.a.code; // "BU0363AE04"
const buurt = await (await fetch(`data/buurt/${code}.json`)).json();
The official Klimaateffectatlas WFS: https://cas.cloud.sogelink.com/public/data/org/gws/YWFMLMWERURF/kea_public/wfs (capabilities, the list of all layers). It is linked from the Klimaateffectatlas FAQ and needs no key.
The links below open one feature as JSON (sample) or the layer's field list (fields). The full layers are 100 to 250 MB each; tools/fetch.py downloads them in pages of 5,000 features.
| WFS layer | Features | Used for | Links |
|---|---|---|---|
BD_buurtdashboard_20260312 | 14,574 | Neighbourhood outlines (CBS 2024) and almost all values: neighbourhood type, flood depth, green and paving, shade, residents and vulnerability. This is the data behind the official Buurtdashboard. | sample · fields |
GevoelstemperatuurBuurt_2022, _2012 | 14,574 each | Average feels-like temperature (PET) and class. Same neighbourhoods, so they are joined by neighbourhood code and their outlines are not downloaded. | 2022: sample · fields 2012: sample · fields |
bknsn_hoofdklasse | 6,157 | Natural system: main landscape | sample · fields |
bknsn_subklasse | 223,759 | Natural system: landscape unit (small grid cells, merged per tile during the build) | sample · fields |
The natural-system layers only hold codes such as Dz1. The names come from the WMS legend (GetLegendGraphic with format=application/json): bknsn_hoofdklasse and bknsn_subklasse. Labels, units and sources for the dashboard fields come from the dashboard's own indicator config.
Two scripts in the repository's tools/ folder do the work. They only need Python and shapely.
tools/.venv/bin/python tools/fetch.py # WFS -> tools/raw/, GeoJSON in RD New, ~370 MB
tools/.venv/bin/python tools/build.py # tools/raw/ -> data/, ~58 MB, about a minute
| File | Count | Contents |
|---|---|---|
data/meta.json | 1 | Tile grid, build date, source layer names and the natural-system legends |
data/tiles/{tx}_{ty}.json | 9,716 | The polygons of every layer that falls in the tile, each with only a code |
data/buurt/{buurtcode}.json | 14,574 | The values for one neighbourhood. Stored once, because one neighbourhood can touch many tiles. |
The raw download is only needed during the build and can be deleted afterwards. Only data/ goes to the server.
The lookup page never needs this, because ArcGIS converts for us. If you do need RD New metres from a GPS point (or the other way round) without calling a server, these two functions do it with plain arithmetic. They use the well-known approximation formulas by Schreutelkamp and Strang van Hees.
// WGS84 (GPS) -> RD New, approximation by Schreutelkamp & Strang van Hees
function wgs84ToRd(lon, lat) {
const dLat = 0.36 * (lat - 52.15517440);
const dLon = 0.36 * (lon - 5.38720621);
// [power of dLat, power of dLon, coefficient]
const X = [[0,1,190094.945],[1,1,-11832.228],[2,1,-114.221],[0,3,-32.391],[1,0,-0.705],
[3,1,-2.340],[1,3,-0.608],[0,2,-0.008],[2,3,0.148]];
const Y = [[1,0,309056.544],[0,2,3638.893],[2,0,73.077],[1,2,-157.984],[3,0,59.788],
[0,1,0.433],[2,2,-6.439],[1,1,-0.032],[0,4,0.092],[1,4,-0.054]];
const sum = (terms) => terms.reduce((t, [p, q, c]) => t + c * dLat ** p * dLon ** q, 0);
return { x: 155000 + sum(X), y: 463000 + sum(Y) };
}
// RD New -> WGS84 (GPS)
function rdToWgs84(x, y) {
const dX = (x - 155000) * 1e-5;
const dY = (y - 463000) * 1e-5;
// [power of dX, power of dY, coefficient]
const LAT = [[0,1,3235.65389],[2,0,-32.58297],[0,2,-0.24750],[2,1,-0.84978],[0,3,-0.06550],
[2,2,-0.01709],[1,0,-0.00738],[4,0,0.00530],[2,3,-0.00039],[4,1,0.00033],[1,1,-0.00012]];
const LON = [[1,0,5260.52916],[1,1,105.94684],[1,2,2.45656],[3,0,-0.81885],[1,3,0.05594],
[3,1,-0.05607],[0,1,0.01199],[3,2,-0.00256],[1,4,0.00128],[0,2,0.00022],
[2,0,-0.00022],[5,0,0.00026]];
const sum = (terms) => terms.reduce((t, [p, q, c]) => t + c * dX ** p * dY ** q, 0);
return { lon: 5.38720621 + sum(LON) / 3600, lat: 52.15517440 + sum(LAT) / 3600 };
}
wgs84ToRd(4.89278973, 52.3695768); // { x: 121327.82, y: 486969.77 } PDOK says 121328, 486970
rdToWgs84(121328, 486970); // { lon: 4.892792, lat: 52.369579 }
Checked against PDOK's own RD values for ten addresses from Groningen to Maastricht: the result was 0.30 m off every time, in both directions. That is fine for finding the right neighbourhood or postcode, but not for surveying work.
proj4 library with its EPSG:28992 definition. Or skip the maths altogether: ask PDOK for centroide_rd, or let ArcGIS convert by sending inSR=4326.# 1. Find the address
curl "https://api.pdok.nl/bzk/locatieserver/search/v3_1/suggest?q=Rokin%20111%20Amsterdam&fq=type:adres&rows=1&fl=id,weergavenaam"
# 2. Look up postcode and GPS point
curl "https://api.pdok.nl/bzk/locatieserver/search/v3_1/lookup?id=adr-636952f00c72313cf8d7b88dcb4ca35f&fl=weergavenaam,postcode,centroide_ll,centroide_rd"
# 3a. Climate data by GPS point
curl "https://services.arcgis.com/nSZVuSZjHpEZZbRo/arcgis/rest/services/Klimaateffectatlas_Groen_per_buurt/FeatureServer/0/query?geometry=4.89278973,52.3695768&geometryType=esriGeometryPoint&inSR=4326&spatialRel=esriSpatialRelIntersects&outFields=*&returnGeometry=false&f=json"
# 3b. Climate data by postcode
curl "https://services.arcgis.com/nSZVuSZjHpEZZbRo/arcgis/rest/services/Klimaatdata/FeatureServer/0/query?where=postcode='1012KN'&outFields=*&returnGeometry=false&f=json"
# 1. Find the address, and 2. look up postcode and RD point
curl "https://api.pdok.nl/bzk/locatieserver/search/v3_1/suggest?q=Rokin%20111%20Amsterdam&fq=type:adres&rows=1&fl=id,weergavenaam"
curl "https://api.pdok.nl/bzk/locatieserver/search/v3_1/lookup?id=adr-636952f00c72313cf8d7b88dcb4ca35f&fl=weergavenaam,postcode,centroide_rd"
# 3. The tile for RD 121328, 486970: tx = 121328 / 2000 = 60, ty = (486970 - 300000) / 2000 = 93
curl "https://klimaat.mostlyvibes.com/data/tiles/60_93.json"
# 4. The values of the neighbourhood the point falls in
curl "https://klimaat.mostlyvibes.com/data/buurt/BU0363AE04.json"
# Build date, sources and legends
curl "https://klimaat.mostlyvibes.com/data/meta.json"
buurtcode, another BU_CODE, another bu_code. To see a layer's fields, open {base}/{service}/FeatureServer/{layer}?f=json.{base}/{service}/FeatureServer?f=json.{"error": {...}} in the body, so check for data.error rather than the status code.BU0363AE04 for Rokin 111), while some layers still use an older one (BU03630003). Matching by point avoids this; matching by code would not.centroide_rd and send it with inSR=28992. The result is the same.The data changes about once a year, and some layer names carry a date (BD_buurtdashboard_20260312). Once a year: check the WFS capabilities for newer layer names, update them in tools/fetch.py, run both scripts, and upload data/. The README in the repository has the exact commands.
A few things to watch: addresses in new-build areas can fall outside the neighbourhood map until it is refreshed, and if the dashboard renames a field the page shows nothing for it until tools/build.py and the page are updated.
Checked for nine addresses from Groningen to Maastricht. Both tabs always found the same neighbourhood.
| Topic | Result |
|---|---|
| Feels-like temperature 2012 and 2022, class | Identical everywhere |
| Neighbourhood type, households around the social minimum, frail 65+ | Identical everywhere |
| Neighbourhood code | The self-hosted data uses the current CBS codes, the same as PDOK (BU0363AE04 for Rokin 111). The ArcGIS layers partly use older ones (BU03630003). |
| Residents | A few percent higher in the self-hosted data: newer CBS figures |
| Shade on cycle and walking paths | Equal or lower in the self-hosted data, by up to 12 points: the dashboard uses the 2026 shade map (on AHN5 where available, otherwise AHN4); the page shows which one |
| Green | Differs by up to about 9 points either way: the dashboard counts trees, shrubs and grass from the Basiskaart Groen en verharding and leaves out farmland, so it is not the same measure as the ArcGIS layer |
| Natural system | Same unit outside built-up areas. In built-up areas ArcGIS says "Antropogeen element / Alle landschappen"; the official map says "Bebouwing" and still names the landscape underneath (e.g. Laagveen). |
Some things are only in one tab:
Klimaatdata service), flood depth at the postcode, the number of severely lonely people aged 75 and over, and the detailed green breakdown in m². Klimaatdata is not a Klimaateffectatlas layer at all: Esri Nederland built it for klimaat.esri.nl by joining Klimaateffectatlas maps to postcodes. The Klimaateffectatlas itself only offers those maps as rasters (GeoTIFF on request via Data opvragen), so they are not in the self-hosted version yet.There are two different publishers of the same climate data, with different terms.
The Klimaateffectatlas FAQ says the data is open and free to use, also for new applications, under Creative Commons CC BY 4.0. Crediting the source is required, in the form "Klimaateffectatlas, 2026". GIS files can be requested free of charge via Data opvragen (GeoPackage/GeoTIFF), and the data is also offered as WMS. The WMS capabilities list no fees and no access constraints.
The self-hosted tab is built only from this source. Each section names the data owner from the dashboard config, and the bottom of the results credits "Klimaateffectatlas, <year> (CC BY 4.0)".
The layers the ArcGIS tab uses are published by the ArcGIS account Esri_NL_Content (Esri Nederland). Every one of them says in its licence field that the Esri Nederland Terms of Use apply, and that using or downloading the data or services counts as accepting those terms. They also ask to credit the Klimaateffectatlas and the data owner listed under Credits. None of them mention CC BY 4.0, and we could not find the text of the Esri Nederland Terms of Use. The licence text is under "Terms of Use" on each item page.
| ArcGIS service | Data owner (from the item description) | Links |
|---|---|---|
Klimaatdata | Made by Esri Nederland for klimaat.esri.nl from Klimaateffectatlas maps, with CAS | item page (licence and credits) · service |
Sociale_kwetsbaarheid_hitte | RIVM (credits also GGD, CBS) | item page (licence and credits) · service |
Groen_per_buurt | Klimaateffectatlas | item page (licence and credits) · service |
Percentage_schaduw_op_fiets_en_wandelpaden_buurtniveau | Friedenau Society | item page (licence and credits) · service |
Wijktypologie_Buurtniveau | Hogeschool van Amsterdam | item page (licence and credits) · service |
Basiskaart_natuurlijk_systeem_eenheden | Geo-Inspiratie & GrondRR | item page (licence and credits) · service |
Ernstig_eenzame_75ers | Gezondheidsmonitor / RIVM | item page (licence and credits) · service |
Gevoelstemperatuur_buurtniveau_2012_en_2022 | Nelen & Schuurmans (credits also Witteveen+Bos, Wageningen University, Climate Adaptation Services) | item page (licence and credits) · service |
services.arcgis.com): Esri Inc. (US), behind Microsoft Azure Front Door. The Esri Nederland account uses the default address, not the EU-hosted variant.cas.cloud.sogelink.com): Sogelink, a European company. The address is in Google Cloud's IP range. Only the build script uses it, not visitors.api.pdok.nl): Kadaster, the Dutch government. Used by both tabs for the address search.