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.

The public endpoint on this page exposes featureCategory, not DYNAM’s internal/vector featurekind, notamcategory, georole, or lowercase facets. When consuming an authorized DYNAM vector contract, use featurekind for Format and combine facets with positive ANY/union semantics:

const graphical = ['==', ['get', 'featurekind'], 'graphical_notam'];
const pointOther = ['==', ['get', 'featurekind'], 'other_notam'];
const uasOrMilitary = ['any', ['==', ['get', 'isuasrelated'], 1], ['==', ['get', 'ismilitaryrelated'], 1]];

notamcategory explains backend A/G/O routing, while georole only controls point/area geometry handling. Neither should redefine Format. A false facet is not permission to fly.

See the canonical NOTAM Categories, Formats, and Facets matrix for field ownership and endpoint availability.

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.

function escapeHtml(value: unknown) {
	return String(value ?? '')
		.replaceAll('&', '&')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;');
}

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 === 99 and properties.featureCategory === "graphicalNotam".
  • Prefer featureCategory where present; it is clearer than relying on numeric type alone.
  • Use priority for custom draw ordering if your map stack supports it.
  • Use activationType to 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.