Initial commit: X-Plane G1000 web cockpit + bridge + Tauri desktop app
- 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>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user