// Reads X-Plane's own navigation data so the FMS can resolve real waypoint / // VOR / NDB / airport identifiers to coordinates — the same database the sim // uses. Runs on the X-Plane PC (where the bridge lives), so the files are local. // // Everything degrades gracefully: if X-Plane / the files can't be found, the // FMS still works with map-clicks and raw "LAT,LON" entry. import fs from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; // Common install locations to probe. Override with XPLANE_ROOT. function candidateRoots() { const env = process.env.XPLANE_ROOT; const home = process.env.HOME || process.env.USERPROFILE || ''; return [ env, 'C:/X-Plane 12', 'D:/X-Plane 12', 'E:/X-Plane 12', 'C:/X-Plane 11', 'D:/X-Plane 11', path.join(home, 'X-Plane 12'), path.join(home, 'Desktop', 'X-Plane 12'), '/Applications/X-Plane 12', ].filter(Boolean); } function findRoot() { for (const root of candidateRoots()) { try { if (fs.existsSync(path.join(root, 'Resources', 'default data'))) return root; } catch { /* ignore */ } } return null; } // alias -> { lat, lon, type } ; type: WPT | VOR | NDB | APT const index = new Map(); // Geographic stores for the moving map (bbox queries) and NEAREST search. // Airports + navaids stay in flat arrays (small enough to scan); the far more // numerous fixes go into 1°×1° buckets so a bbox query only scans nearby cells. const airports = []; // { id, lat, lon, name, elev } const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name } const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }] const rwyByApt = new Map(); // ICAO -> [{ n1, la1, lo1, n2, la2, lo2, w }] (runway ends + width m) const state = { root: null, loaded: false, count: 0 }; function add(id, lat, lon, type) { if (!id || !isFinite(lat) || !isFinite(lon)) return; const key = id.toUpperCase(); if (!index.has(key)) index.set(key, { id: key, lat, lon, type }); } function pushFix(f) { const k = `${Math.floor(f.lat)},${Math.floor(f.lon)}`; let a = fixCells.get(k); if (!a) { a = []; fixCells.set(k, a); } a.push(f); } const R_NM = 3440.065; // earth radius in nautical miles const rad = (d) => (d * Math.PI) / 180; function distNm(la1, lo1, la2, lo2) { const dLat = rad(la2 - la1), dLon = rad(lo2 - lo1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(rad(la1)) * Math.cos(rad(la2)) * Math.sin(dLon / 2) ** 2; return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a))); } function bearingDeg(la1, lo1, la2, lo2) { const y = Math.sin(rad(lo2 - lo1)) * Math.cos(rad(la2)); const x = Math.cos(rad(la1)) * Math.sin(rad(la2)) - Math.sin(rad(la1)) * Math.cos(rad(la2)) * Math.cos(rad(lo2 - lo1)); return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; } async function parseFixes(file) { if (!fs.existsSync(file)) return; const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity }); for await (const line of rl) { const t = line.trim(); if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue; const p = t.split(/\s+/); const lat = parseFloat(p[0]), lon = parseFloat(p[1]), id = p[2]; add(id, lat, lon, 'WPT'); if (id && isFinite(lat) && isFinite(lon)) pushFix({ id: id.toUpperCase(), lat, lon, type: 'FIX' }); } } async function parseNav(file) { if (!fs.existsSync(file)) return; const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity }); for await (const line of rl) { const t = line.trim(); if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue; const p = t.split(/\s+/); const code = parseInt(p[0], 10); if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7]; const type = code === 2 ? 'NDB' : 'VOR'; add(id, lat, lon, type); if (id && isFinite(lat) && isFinite(lon)) { // p[4] = frequency (VOR in 10 kHz e.g. 11630 → 116.30; NDB in kHz); // name is everything after the airport/region columns. navaids.push({ id: id.toUpperCase(), lat, lon, type, freq: parseInt(p[4], 10) || 0, name: p.slice(10).join(' ') }); } } } // Airports: derive a reference point from each airport's first runway (row 100) // in apt.dat. The "1" header row carries the ICAO but no coordinates. async function parseAirports(file) { if (!fs.existsSync(file)) return; const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity }); let icao = null, name = '', elev = 0, placed = false; const place = (lat, lon) => { if (!isFinite(lat) || !isFinite(lon)) return; add(icao, lat, lon, 'APT'); airports.push({ id: icao.toUpperCase(), lat, lon, name, elev }); placed = true; }; for await (const line of rl) { const p = line.trim().split(/\s+/); const code = parseInt(p[0], 10); if (code === 1 || code === 16 || code === 17) { // land/sea/heliport header icao = p[4]; elev = parseInt(p[1], 10) || 0; name = p.slice(5).join(' '); placed = false; } else if (icao && code === 100) { // land runway (both ends) const r = { n1: p[8], la1: parseFloat(p[9]), lo1: parseFloat(p[10]), n2: p[17], la2: parseFloat(p[18]), lo2: parseFloat(p[19]), w: parseFloat(p[1]) }; if (isFinite(r.la1) && isFinite(r.lo1) && isFinite(r.la2) && isFinite(r.lo2)) { const key = icao.toUpperCase(); let a = rwyByApt.get(key); if (!a) { a = []; rwyByApt.set(key, a); } a.push(r); if (!placed) place((r.la1 + r.la2) / 2, (r.lo1 + r.lo2) / 2); } } else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6])); } } } export async function loadNavData() { const root = findRoot(); state.root = root; if (!root) { console.log('navdata: X-Plane root not found (set XPLANE_ROOT) — FMS works with map-clicks / LAT,LON only'); state.loaded = true; return; } console.log(`navdata: X-Plane at ${root} — parsing nav data ...`); const dd = path.join(root, 'Resources', 'default data'); const cd = path.join(root, 'Custom Data'); // user nav data overrides if present const pick = (name) => (fs.existsSync(path.join(cd, name)) ? path.join(cd, name) : path.join(dd, name)); try { await parseFixes(pick('earth_fix.dat')); await parseNav(pick('earth_nav.dat')); // apt.dat is large; parse the global airports file in the background. parseAirports(path.join(root, 'Global Scenery', 'Global Airports', 'Earth nav data', 'apt.dat')) .then(() => { state.count = index.size; console.log(`navdata: airports done (${index.size} total entries)`); }) .catch((e) => console.log('navdata: airport parse skipped:', e.message)); } catch (e) { console.log('navdata: parse error:', e.message); } state.count = index.size; state.loaded = true; console.log(`navdata: ${index.size} fixes/navaids ready`); } export function lookup(id) { return index.get(String(id).toUpperCase()) || null; } export function search(q, limit = 20) { const needle = String(q || '').toUpperCase().trim(); if (!needle) return []; const exact = [], prefix = []; for (const v of index.values()) { if (v.id === needle) exact.push(v); else if (v.id.startsWith(needle)) prefix.push(v); if (exact.length + prefix.length > 400) break; } return [...exact, ...prefix].slice(0, limit); } // NEAREST: closest airports (default) or navaids to a point, with range/bearing. export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) { if (!isFinite(lat) || !isFinite(lon)) return []; const src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports; return src .filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true) .map((f) => ({ ...f, dist: distNm(lat, lon, f.lat, f.lon), brg: Math.round(bearingDeg(lat, lon, f.lat, f.lon)) })) .sort((a, b) => a.dist - b.dist) .slice(0, count) .map((f) => ({ ...f, dist: +f.dist.toFixed(1) })); } // BBOX: every feature inside a lat/lon window, for the moving map to draw. // types ⊆ { apt, vor, ndb, fix }. Output is capped so a wide view stays light. export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) { const out = []; const inB = (f) => f.lat >= s && f.lat <= n && f.lon >= w && f.lon <= e; if (types.includes('apt')) for (const f of airports) { if (inB(f)) { out.push({ ...f, type: 'APT' }); if (out.length >= limit) return out; } } for (const f of navaids) { if (types.includes(f.type.toLowerCase()) && inB(f)) { out.push(f); if (out.length >= limit) return out; } } if (types.includes('fix')) { for (let la = Math.floor(s); la <= Math.floor(n); la++) for (let lo = Math.floor(w); lo <= Math.floor(e); lo++) { const a = fixCells.get(`${la},${lo}`); if (!a) continue; for (const f of a) { if (inB(f)) { out.push(f); if (out.length >= limit) return out; } } } } return out; } // Runways of every airport within radiusNm — for the PFD's synthetic-vision view. export function runwaysNear(lat, lon, radiusNm = 12) { if (!isFinite(lat) || !isFinite(lon)) return []; const out = []; for (const a of airports) { if (distNm(lat, lon, a.lat, a.lon) > radiusNm) continue; const rs = rwyByApt.get(a.id); if (rs) for (const r of rs) out.push({ apt: a.id, ...r }); } return out; } export function navStatus() { return { root: state.root, loaded: state.loaded, entries: index.size, airports: airports.length, navaids: navaids.length }; } export function xplaneRoot() { return state.root; }