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:
2026-06-01 15:07:03 +02:00
commit ebc33a78b7
110 changed files with 14671 additions and 0 deletions
+141
View File
@@ -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;
}