ebc33a78b7
- server/: Node bridge (datarefs/commands, navdata, CIFP procedures, flight plan) - web/: React cockpit (PFD/MFD/Map, VFR six-pack, AFCS, FMS CDU), PWA, collapsible sidebar - desktop/: Tauri 2 launcher (Bun sidecar, system tray, updater) + Linux build via Docker - scripts/: prep-desktop, build-linux, Gitea release + latest.json Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.8 KiB
JavaScript
142 lines
5.8 KiB
JavaScript
// Parses X-Plane's CIFP procedure data (SIDs / STARs / approaches) on demand —
|
|
// one small file per airport in Resources/default data/CIFP/<ICAO>.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;
|
|
}
|