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.

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.