// Airspace overlay data. X-Plane ships no airspace boundaries in its nav data, // so we keep them as GeoJSON files the user installs per region (chosen in the // desktop launcher). This module: // - resolves the airspace data dir (next to the FMS sync folder, overridable) // - loads every *.geojson there into a flat, bbox-indexed feature list // - answers bbox queries for the moving map (/api/airspace/bbox) // - downloads region datasets on demand (FAA = key-free US; OpenAIP = others, // needs the user's API key) and normalises them to one schema // // Normalised feature properties: { name, cls, lo, hi } where cls is a coarse // class the map colours by: B|C|D|E|TMA|CTR|MOA|RESTRICTED|PROHIBITED|DANGER|OTHER. import fs from 'node:fs'; import path from 'node:path'; import { xplaneRoot } from './navdata.js'; function dataDir() { if (process.env.AIRSPACE_DIR) return process.env.AIRSPACE_DIR; const r = xplaneRoot(); return r ? path.join(r, 'Output', 'fms-sync', 'airspace') : path.join(process.cwd(), 'airspace-data'); } // flat store: { bbox:[s,w,n,e], geometry, props:{name,cls,lo,hi}, region } let store = []; let loaded = false; function featureBbox(geom) { let s = 90, w = 180, n = -90, e = -180; const scan = (co) => { if (typeof co[0] === 'number') { const [x, y] = co; if (y < s) s = y; if (y > n) n = y; if (x < w) w = x; if (x > e) e = x; } else for (const c of co) scan(c); }; try { scan(geom.coordinates); } catch { /* ignore */ } return [s, w, n, e]; } // Map many source schemas (FAA, OpenAIP, generic) onto one coarse class. function classify(p = {}) { const raw = String( p.cls ?? p.CLASS ?? p.class ?? p.Class ?? p.LOCAL_TYPE ?? p.TYPE_CODE ?? p.type ?? '' ).toUpperCase(); const name = String(p.name ?? p.NAME ?? p.Name ?? p.IDENT ?? '').toUpperCase(); const hay = raw + ' ' + name; if (/PROHIBIT/.test(hay)) return 'PROHIBITED'; if (/RESTRICT/.test(hay)) return 'RESTRICTED'; if (/\bMOA\b|MILITARY OPERATION/.test(hay)) return 'MOA'; if (/DANGER/.test(hay)) return 'DANGER'; if (/\bTMA\b/.test(hay)) return 'TMA'; if (/\bCTR\b|CONTROL ZONE/.test(hay)) return 'CTR'; // OpenAIP icaoClass: 0=A 1=B 2=C 3=D 4=E 5=F 6=G if (p.icaoClass != null) return ['A', 'B', 'C', 'D', 'E', 'F', 'G'][p.icaoClass] || 'OTHER'; const m = raw.match(/\b([A-G])\b/) || raw.match(/CLASS\s*([A-G])/) || raw.match(/^([A-G])\d?$/); if (m) return m[1]; return 'OTHER'; } // Pull a readable altitude limit out of whatever fields a source uses. function limit(p, kind) { const lo = kind === 'lo' ? (p.lo ?? p.LOWER_VAL ?? p.lowerLimit?.value ?? p.LOWER_DESC ?? p.lower ?? null) : (p.hi ?? p.UPPER_VAL ?? p.upperLimit?.value ?? p.UPPER_DESC ?? p.upper ?? null); return lo == null ? null : (typeof lo === 'object' ? (lo.value ?? null) : lo); } function ingest(fc, region) { const feats = Array.isArray(fc?.features) ? fc.features : []; for (const f of feats) { if (!f?.geometry?.coordinates) continue; const p = f.properties || {}; store.push({ bbox: featureBbox(f.geometry), geometry: f.geometry, props: { name: p.name ?? p.NAME ?? p.Name ?? '', cls: classify(p), lo: limit(p, 'lo'), hi: limit(p, 'hi') }, region, }); } } export function loadAirspace(log = console.log) { store = []; const dir = dataDir(); let files = []; try { files = fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.geojson')); } catch { /* none yet */ } for (const f of files) { try { ingest(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')), f.replace(/\.geojson$/i, '')); } catch (e) { log(`airspace: ${f} parse failed: ${e.message}`); } } loaded = true; if (store.length) log(`airspace: ${store.length} features from ${files.length} file(s) in ${dir}`); return store.length; } // Features whose bbox intersects the query window (linear scan — a few thousand // features, queried only on map move; cheap enough). Returns light DTOs. export function airspaceBbox(s, w, n, e, limit = 400) { if (!loaded) loadAirspace(); const out = []; for (const a of store) { const [as, aw, an, ae] = a.bbox; if (an < s || as > n || ae < w || aw > e) continue; out.push({ name: a.props.name, cls: a.props.cls, lo: a.props.lo, hi: a.props.hi, geometry: a.geometry }); if (out.length >= limit) break; } return out; } export function airspaceStatus() { if (!loaded) loadAirspace(); const byRegion = {}; for (const a of store) byRegion[a.region] = (byRegion[a.region] || 0) + 1; return { dir: dataDir(), features: store.length, regions: byRegion }; } // ---- region downloads ------------------------------------------------------ // kind 'faa': paginated ArcGIS FeatureServer → GeoJSON (US, public domain, no key) // kind 'openaip': OpenAIP REST by ICAO country code (needs the user's API key) export const REGIONS = [ { id: 'us', label: 'USA (FAA)', kind: 'faa', needsKey: false, layers: ['https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/Class_Airspace/FeatureServer/0'] }, { id: 'ch', label: 'Schweiz', kind: 'openaip', country: 'CH', needsKey: true }, { id: 'at', label: 'Österreich', kind: 'openaip', country: 'AT', needsKey: true }, { id: 'de', label: 'Deutschland', kind: 'openaip', country: 'DE', needsKey: true }, { id: 'fr', label: 'Frankreich', kind: 'openaip', country: 'FR', needsKey: true }, { id: 'it', label: 'Italien', kind: 'openaip', country: 'IT', needsKey: true }, { id: 'gb', label: 'Großbritannien', kind: 'openaip', country: 'GB', needsKey: true }, ]; async function fetchFaa(layerUrl, log) { const feats = []; let offset = 0; for (let page = 0; page < 80; page++) { // safety cap const url = `${layerUrl}/query?where=1%3D1&outFields=*&returnGeometry=true&outSR=4326&f=geojson&resultRecordCount=1000&resultOffset=${offset}`; const res = await fetch(url); if (!res.ok) throw new Error(`FAA HTTP ${res.status}`); const fc = await res.json(); const got = fc.features?.length || 0; feats.push(...(fc.features || [])); log(`airspace: FAA page ${page + 1} (+${got}, total ${feats.length})`); if (got < 1000 && !fc.properties?.exceededTransferLimit) break; offset += 1000; } return { type: 'FeatureCollection', features: feats }; } async function fetchOpenAip(country, apiKey, log) { const feats = []; let pageNum = 1; for (; pageNum <= 50; pageNum++) { const url = `https://api.core.openaip.net/api/airspaces?country=${country}&limit=1000&page=${pageNum}`; const res = await fetch(url, { headers: { 'x-openaip-api-key': apiKey } }); if (!res.ok) throw new Error(`OpenAIP HTTP ${res.status} (API-Key prüfen)`); const body = await res.json(); const items = body.items || []; for (const a of items) { if (!a.geometry) continue; feats.push({ type: 'Feature', geometry: a.geometry, properties: { name: a.name, icaoClass: a.icaoClass, type: a.type, lower: a.lowerLimit, upper: a.upperLimit } }); } log(`airspace: OpenAIP ${country} page ${pageNum} (+${items.length}, total ${feats.length})`); if (items.length < 1000 || pageNum >= (body.totalPages || 1)) break; } return { type: 'FeatureCollection', features: feats }; } export async function installRegion(id, { apiKey, log = console.log } = {}) { const region = REGIONS.find((r) => r.id === id); if (!region) return { ok: false, error: `unknown region: ${id}` }; if (region.needsKey && !apiKey) return { ok: false, error: 'OpenAIP API-Key erforderlich' }; try { let fc; if (region.kind === 'faa') { fc = { type: 'FeatureCollection', features: [] }; for (const layer of region.layers) { const part = await fetchFaa(layer, log); fc.features.push(...part.features); } } else { fc = await fetchOpenAip(region.country, apiKey, log); } const dir = dataDir(); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${id}.geojson`); fs.writeFileSync(file, JSON.stringify(fc)); loadAirspace(log); // reload index so the new data is live immediately return { ok: true, id, features: fc.features.length, file }; } catch (e) { return { ok: false, error: e.message }; } } export function regionList() { const st = airspaceStatus(); return REGIONS.map((r) => ({ id: r.id, label: r.label, needsKey: r.needsKey, installed: (st.regions[r.id] || 0) })); }