MapLibre/Mapbox
DYNAM returns standard GeoJSON. You can add the response as a GeoJSON source in MapLibre GL JS or Mapbox GL JS.
Graphical NOTAMs are normal features in the same collection. They use the same geometry, vertical-limit, activation, and styling structure as airspaces, so most apps can render both from one source.
Fetch
async function fetchDynamGeojson(accessToken: string) {
const url = new URL("https://dynam.easyvfr.stream/api/v1/data/airspaces");
url.searchParams.set("countries", "NL,BE,DE");
url.searchParams.set("scope", "dynam");
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`DYNAM request failed: ${response.status}`);
}
return response.json();
}
For server-side processing, make the same request from your backend with your API token and cache the response.
Prepare Map Properties
MapLibre and Mapbox style expressions are simpler when commonly used nested values are projected to feature properties before adding the source.
function prepareDynamForMap(geojson: any) {
return {
...geojson,
features: geojson.features.map((feature: any) => {
const props = feature.properties || {};
const easyvfr = props.easyvfr || {};
const mapping = easyvfr.dynamicAirspaceMapping || {};
const style = mapping.dynamicStyle || {};
return {
...feature,
properties: {
...props,
featureCategory:
props.featureCategory || (props.type === 99 ? "graphicalNotam" : "airspace"),
isActive: mapping.isActive || "Unknown",
activationType: mapping.activationType || "",
priority: style.priority ?? style.Priority ?? 0,
fillColor: style.fillColor || "rgba(80,120,220,0.25)",
fillOpacity: style.fillOpacity ?? 0.15,
lineColor: style.lineColor || "rgba(40,80,180,0.85)",
lineWidth: style.lineWidth ?? 1,
lineOpacity: style.lineOpacity ?? 1,
formattedLowerLimit: easyvfr.formattedLowerLimit || "",
formattedUpperLimit: easyvfr.formattedUpperLimit || "",
notamKind: mapping.notams?.[0]?.NotamIsOfKind ?? "",
},
};
}),
};
}
Add Source And Layers
const dynamGeojson = prepareDynamForMap(await fetchDynamGeojson(easyVfrAccessToken));
map.addSource("dynam-airspaces", {
type: "geojson",
data: dynamGeojson,
});
map.addLayer({
id: "dynam-airspace-fill",
type: "fill",
source: "dynam-airspaces",
filter: ["!=", ["get", "featureCategory"], "graphicalNotam"],
paint: {
"fill-color": ["get", "fillColor"],
"fill-opacity": [
"case",
["==", ["get", "isActive"], "No"],
0.08,
["get", "fillOpacity"],
],
},
});
map.addLayer({
id: "dynam-airspace-line",
type: "line",
source: "dynam-airspaces",
paint: {
"line-color": ["get", "lineColor"],
"line-width": ["get", "lineWidth"],
"line-opacity": ["get", "lineOpacity"],
},
});
map.addLayer({
id: "dynam-graphical-notam-fill",
type: "fill",
source: "dynam-airspaces",
filter: ["==", ["get", "featureCategory"], "graphicalNotam"],
paint: {
"fill-color": ["get", "fillColor"],
"fill-opacity": [
"case",
[">", ["get", "fillOpacity"], 0.25],
["get", "fillOpacity"],
0.25,
],
},
});
map.addLayer({
id: "dynam-labels",
type: "symbol",
source: "dynam-airspaces",
minzoom: 7,
layout: {
"text-field": [
"format",
["get", "name"],
{},
"\n",
{},
["get", "formattedLowerLimit"],
{ "font-scale": 0.85 },
" - ",
{ "font-scale": 0.85 },
["get", "formattedUpperLimit"],
{ "font-scale": 0.85 }
],
"text-size": 11,
"text-anchor": "center",
},
paint: {
"text-color": "#1f2937",
"text-halo-color": "#ffffff",
"text-halo-width": 1,
},
});
For inactive airspaces, lowering opacity is usually better than hiding them. The pilot still gets orientation context while active or NOTAM-driven features remain visually stronger.
Popup
function escapeHtml(value: unknown) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
map.on("click", "dynam-airspace-line", (event) => {
const feature = event.features?.[0];
if (!feature) return;
const props = feature.properties as any;
const easyvfr = props.easyvfr || {};
const mapping = easyvfr.dynamicAirspaceMapping || {};
const notam = mapping.notams?.[0];
const limits = `${props.formattedLowerLimit || ""} - ${props.formattedUpperLimit || ""}`;
const source =
mapping.aupuups?.[0]?.Designator ||
props.activationType ||
"Unknown";
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(`
<strong>${escapeHtml(props.name || "Airspace")}</strong><br>
${escapeHtml(limits)}<br>
Active: ${escapeHtml(props.isActive || "Unknown")}<br>
Source: ${escapeHtml(source)}<br>
Type: ${escapeHtml(props.featureCategory || props.type)}
${notam ? `<br>NOTAM: ${escapeHtml(notam.NotamId)}` : ""}
`)
.addTo(map);
});
Optional Filters
Feature type filter:
map.setFilter("dynam-graphical-notam-fill", [
"all",
["==", ["get", "featureCategory"], "graphicalNotam"],
["in", ["get", "notamKind"], ["literal", ["R", "V"]]],
]);
Active-only filter:
map.setFilter("dynam-airspace-fill", ["==", ["get", "isActive"], "Yes"]);
Altitude-aware filtering needs your own conversion logic because limits can be feet, meters, flight levels, MSL, AGL, or STD. Use upperLimit and lowerLimit for computation, and the formatted limit fields for display.
Notes
- Graphical NOTAMs use
properties.type === 99andproperties.featureCategory === "graphicalNotam". - Prefer
featureCategorywhere present; it is clearer than relying on numeric type alone. - Use
priorityfor custom draw ordering if your map stack supports it. - Use
activationTypeto group airspaces activated by NOTAM, AUP/UUP, or both. - Large country sets can be tens of megabytes uncompressed; use gzip and cache on your backend when you do not need per-user direct OAuth.