← Back to the address lookup

How the Klimaateffectatlas lookup works

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

In short

1. Find the addressPDOK Locatieserver turns typed text into an address id.
2. Get its detailsThe same service returns the postcode and a GPS point for that id.
3. Ask for climate dataEight ArcGIS services are queried in parallel, by postcode or by GPS point.

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.

In short

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.

1. Find the addressSame as before: PDOK /suggest and /lookup, but also asking for centroide_rd.
2. Load one tileThe RD point says which 2×2 km tile to fetch: data/tiles/{tx}_{ty}.json.
3. Find the neighbourhoodThe browser checks which polygon in the tile contains the point, then fetches 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.

Coordinate systems

SystemEPSG codeUnitsRokin 111Where it appears
WGS84 (GPS)4326Degrees, longitude then latitude4.89278973, 52.3695768What PDOK gives us and what the ArcGIS tab sends to ArcGIS
RD New (Rijksdriehoek)28992Metres, x then y121328, 486970How 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.

Order matters. Both PDOK's POINT(...) text and the ArcGIS geometry parameter put longitude first, then latitude. Most map apps show them the other way round.

APIs we use

PDOK Locatieserver

Base URL: https://api.pdok.nl/bzk/locatieserver/search/v3_1

EndpointUsed forCoordinates
/suggestAutocomplete while typingNone: text in, address ids out
/lookupDetails for one address idReturns centroide_ll (WGS84) and centroide_rd (RD New)

Klimaateffectatlas on ArcGIS

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

ServiceQueried byWe sendWhat it holds
KlimaatdataPostcodeNo coordinatesFlood depth, rainfall, tropical days, rainfall deficit (now and 2050)
Klimaateffectatlas_Sociale_kwetsbaarheid_hittePointWGS84Social vulnerability to heat
Klimaateffectatlas_Groen_per_buurtPointWGS84Share of green, trees, buildings and water per neighbourhood
Klimaateffectatlas_Percentage_schaduw_op_fiets_en_wandelpaden_buurtniveauPointWGS84Shade on cycle and walking paths
Klimaateffectatlas_Wijktypologie_BuurtniveauPointWGS84Neighbourhood type
Klimaateffectatlas_Basiskaart_natuurlijk_systeem_eenhedenPointWGS84Natural system (soil and landscape unit)
Klimaateffectatlas_Ernstig_eenzame_75ersPointWGS84Severely lonely people aged 75 and over
Klimaateffectatlas_Gevoelstemperatuur_buurtniveau_2012_en_2022PointWGS84Feels-like temperature (PET) per neighbourhood
Almost every service has its data in layer 0. The exception is Gevoelstemperatuur, which has no layer 0: layer 1 is 2022 and layer 2 is 2012. The page queries both.

Files on this server

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.

FileFetchedWhat it holdsExample
/meta.jsonOnce per visitTile grid, build date, source layer names, natural-system legendsmeta.json
/tiles/{tx}_{ty}.jsonPer addressThe polygons in one 2×2 km tile, each with only a codetiles/60_93.json
/buurt/{buurtcode}.jsonPer addressAll values for one neighbourhoodbuurt/BU0363AE04.json

Step 1: find the address

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

Step 2: get the postcode and coordinates

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

Step 3a: query by GPS point

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, ... }
Always send 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.

Step 3b: query by postcode

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

Step 3: find the neighbourhood in a tile

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();

Where the data comes from

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 layerFeaturesUsed forLinks
BD_buurtdashboard_2026031214,574Neighbourhood 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, _201214,574 eachAverage 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_hoofdklasse6,157Natural system: main landscapesample · fields
bknsn_subklasse223,759Natural 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.

Building the files

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
FileCountContents
data/meta.json1Tile grid, build date, source layer names and the natural-system legends
data/tiles/{tx}_{ty}.json9,716The polygons of every layer that falls in the tile, each with only a code
data/buurt/{buurtcode}.json14,574The 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.

Converting the coordinates yourself

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.

For exact results use the official RDNAPTRANS transformation, for example through the 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.

Try it from a terminal

# 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"

Try it from a terminal

# 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"

Good to know

Keeping it up to date

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.

How the two versions compare

Checked for nine addresses from Groningen to Maastricht. Both tabs always found the same neighbourhood.

TopicResult
Feels-like temperature 2012 and 2022, classIdentical everywhere
Neighbourhood type, households around the social minimum, frail 65+Identical everywhere
Neighbourhood codeThe self-hosted data uses the current CBS codes, the same as PDOK (BU0363AE04 for Rokin 111). The ArcGIS layers partly use older ones (BU03630003).
ResidentsA few percent higher in the self-hosted data: newer CBS figures
Shade on cycle and walking pathsEqual 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
GreenDiffers 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 systemSame 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:

Licensing

There are two different publishers of the same climate data, with different terms.

The Klimaateffectatlas itself: CC BY 4.0

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 ArcGIS copies: Esri Nederland's terms

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 serviceData owner (from the item description)Links
KlimaatdataMade by Esri Nederland for klimaat.esri.nl from Klimaateffectatlas maps, with CASitem page (licence and credits) · service
Sociale_kwetsbaarheid_hitteRIVM (credits also GGD, CBS)item page (licence and credits) · service
Groen_per_buurtKlimaateffectatlasitem page (licence and credits) · service
Percentage_schaduw_op_fiets_en_wandelpaden_buurtniveauFriedenau Societyitem page (licence and credits) · service
Wijktypologie_BuurtniveauHogeschool van Amsterdamitem page (licence and credits) · service
Basiskaart_natuurlijk_systeem_eenhedenGeo-Inspiratie & GrondRRitem page (licence and credits) · service
Ernstig_eenzame_75ersGezondheidsmonitor / RIVMitem page (licence and credits) · service
Gevoelstemperatuur_buurtniveau_2012_en_2022Nelen & Schuurmans (credits also Witteveen+Bos, Wageningen University, Climate Adaptation Services)item page (licence and credits) · service
Because of these terms, the self-hosted version does not copy anything from ArcGIS, even where it would be easier. The ArcGIS tab keeps querying Esri's service live, as the page always did.

Hosting, for the record