// Parses X-Plane's CIFP procedure data (SIDs / STARs / approaches) on demand — // one small file per airport in Resources/default data/CIFP/.dat, in the // ARINC-424-derived "XP CIFP" format. Fix idents are resolved to coordinates // via the shared navdata index; runway thresholds come from the RWY records. // // Used by the G1000 PROC page: list a destination's procedures, then load a // chosen procedure+transition's leg fixes into the active flight plan. import fs from 'node:fs'; import path from 'node:path'; import { lookup, xplaneRoot } from './navdata.js'; // "N47274972" -> 47.4638.. / "W122183954" -> -122.3109.. function parseCoord(s) { if (!s || s.length < 8) return null; const hemi = s[0]; const neg = hemi === 'S' || hemi === 'W'; const digits = s.slice(1); // lat = DDMMSSss (8) ; lon = DDDMMSSss (9) const degLen = (hemi === 'E' || hemi === 'W') ? 3 : 2; const dd = parseInt(digits.slice(0, degLen), 10); const mm = parseInt(digits.slice(degLen, degLen + 2), 10); const ss = parseInt(digits.slice(degLen + 2, degLen + 4), 10); const hh = parseInt(digits.slice(degLen + 4, degLen + 6) || '0', 10); if (!isFinite(dd) || !isFinite(mm)) return null; const val = dd + mm / 60 + (ss + hh / 100) / 3600; return neg ? -val : val; } function cifpFile(icao) { const root = xplaneRoot(); if (!root) return null; const f = path.join(root, 'Resources', 'default data', 'CIFP', `${icao.toUpperCase()}.dat`); return fs.existsSync(f) ? f : null; } // Parse one airport's procedures into a structured summary + leg store. // Returns null if the airport has no CIFP file. export function parseProcedures(icao) { const file = cifpFile(icao); if (!file) return null; const runways = {}; // RW16C -> { lat, lon, elev } const groups = { SID: {}, STAR: {}, APPCH: {} }; // groups[type][procName] = { order:[trans...], legs:{ trans:[{fix,term,alt}] } } const ensure = (type, name, trans) => { const g = groups[type]; if (!g[name]) g[name] = { order: [], legs: {} }; if (!(trans in g[name].legs)) { g[name].legs[trans] = []; g[name].order.push(trans); } return g[name].legs[trans]; }; for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { const line = raw.trim().replace(/;$/, ''); if (!line) continue; const colon = line.indexOf(':'); if (colon < 0) continue; const type = line.slice(0, colon); const f = line.slice(colon + 1).split(','); if (type === 'RWY') { // RWY:RW16C, , ,00429, ,ISZI,3, ;N47274972,W122183954,0000 const id = f[0]; const tail = line.split(';')[1] || ''; // "N47274972,W122183954,0000" const [latS, lonS] = tail.split(','); const lat = parseCoord(latS), lon = parseCoord(lonS); if (lat != null && lon != null) runways[id] = { lat, lon, elev: parseInt(f[3], 10) || 0 }; continue; } if (type !== 'SID' && type !== 'STAR' && type !== 'APPCH') continue; // f[0]=seqno, f[1]=route type, f[2]=proc, f[3]=transition, f[4]=fix, // f[11]=path/termination, f[22]=alt flag (+/-), f[23]=altitude. const procName = f[2]; // BANGR9 / CHINS5 / I16C const trans = (f[3] || '').trim(); // RW16C / PDT / ERYKA / '' (common) const fix = (f[4] || '').trim(); // OTLIE / ANVIL / RW16C const term = (f[11] || '').trim(); // path/termination: IF TF CF DF VA CA ... const altFlag = (f[22] || '').trim(); const altVal = parseInt((f[23] || '').trim(), 10); const alt = isFinite(altVal) && altVal > 0 ? altVal : null; const legs = ensure(type, procName, trans || '(common)'); legs.push({ fix, term, alt, altFlag }); } // Build the client-facing summary (names + their transitions). const summarize = (g) => Object.entries(g).map(([name, v]) => ({ name, transitions: v.order.filter((t) => t !== '(common)'), })); return { icao: icao.toUpperCase(), runways: Object.keys(runways), sids: summarize(groups.SID), stars: summarize(groups.STAR), approaches: summarize(groups.APPCH), _groups: groups, _rwy: runways, }; } // Resolve a chosen procedure+transition to a list of waypoints with coordinates. // type: 'sid' | 'star' | 'approach'. Fixes are resolved via the navdata index; // runway "fixes" (RWxx) and unresolved fixes fall back to the RWY threshold. export function procedureLegs(icao, type, name, trans) { const parsed = parseProcedures(icao); if (!parsed) return []; const TYPE = { sid: 'SID', star: 'STAR', approach: 'APPCH' }[String(type).toLowerCase()]; const g = parsed._groups[TYPE]; if (!g || !g[name]) return []; const node = g[name]; // Compose the leg list: chosen transition first, then the common segment. // (SID: runway/enroute transition then common climb-out; STAR: enroute entry // then common arrival; approach: IAF transition then final-approach segment.) const seq = []; const wantTrans = trans && node.legs[trans] ? trans : node.order.find((t) => t !== '(common)'); if (wantTrans && node.legs[wantTrans]) seq.push(...node.legs[wantTrans]); if (node.legs['(common)']) seq.push(...node.legs['(common)']); const out = []; const seen = new Set(); for (const leg of seq) { if (!leg.fix) continue; // heading/altitude legs w/o a fix if (seen.has(leg.fix)) continue; // de-dupe repeated fixes let pt = null; const isRwy = /^RW/.test(leg.fix); if (isRwy && parsed._rwy[leg.fix]) pt = parsed._rwy[leg.fix]; else { const hit = lookup(leg.fix); if (hit) pt = { lat: hit.lat, lon: hit.lon }; } if (!pt) continue; // unresolved fix → skip seen.add(leg.fix); out.push({ id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt }); // An approach ends at the runway threshold — drop the missed-approach legs. if (TYPE === 'APPCH' && isRwy) break; } return out; }