Files
xplane-cockpit/server/navdata.js
T
karim 38b048ad41 G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs
Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:17:06 +02:00

301 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 comByApt = new Map(); // ICAO -> { freq, label, prio } (best ATC/CTAF frequency)
const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST "ILS")
const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }]
const state = { root: null, loaded: false, count: 0, awy: 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 === 4 || code === 5) { // ILS/LOC localizer → airport has an ILS
const ic = (p[8] || '').toUpperCase();
if (ic && ic !== 'ENRT') ilsApts.add(ic);
continue;
}
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]));
} else if (icao && ((code >= 50 && code <= 56) || (code >= 1050 && code <= 1056))) {
// ATC / CTAF frequencies. Old codes 50-56, new 1050-1056. Freq is kHz
// (>100000) or MHz×100. Keep the most useful one (TWR > UNICOM > ATIS …).
const c = code > 1000 ? code - 1000 : code;
const raw = parseInt(p[1], 10);
if (isFinite(raw) && raw > 0) {
const mhz = raw > 100000 ? raw / 1000 : raw / 100;
const meta = { 54: ['TOWER', 5], 51: ['UNICOM', 4], 50: ['ATIS', 3], 53: ['GROUND', 2], 55: ['APP', 1], 56: ['DEP', 1], 52: ['CLNC', 1] }[c] || ['COM', 0];
const key = icao.toUpperCase(), prev = comByApt.get(key);
if (!prev || meta[1] > prev.prio) comByApt.set(key, { freq: mhz, label: meta[0], prio: meta[1] });
}
}
}
}
// Airways (earth_awy.dat): each row is a segment between two named waypoints.
// We resolve both endpoints to coordinates via the fix/navaid index (so this
// must run AFTER parseFixes/parseNav) and bucket segments by their midpoint
// cell for fast bbox queries — exactly like fixes.
async function parseAirways(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+/);
if (p.length < 10) continue;
const a = index.get((p[0] || '').toUpperCase());
const b = index.get((p[3] || '').toUpperCase());
if (!a || !b) continue; // endpoint not in our database
const name = p[p.length - 1];
const k = `${Math.floor((a.lat + b.lat) / 2)},${Math.floor((a.lon + b.lon) / 2)}`;
let arr = awyCells.get(k); if (!arr) { arr = []; awyCells.set(k, arr); }
arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name });
state.awy++;
}
}
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'));
// airways need the fix/navaid index above; parse in the background.
parseAirways(pick('earth_awy.dat'))
.then(() => console.log(`navdata: airways done (${state.awy} segments)`))
.catch((e) => console.log('navdata: airway parse skipped:', e.message));
// 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 isApt = !(type === 'vor' || type === 'ndb' || type === 'nav');
const src = isApt ? airports : navaids;
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) => {
const o = { ...f, dist: +f.dist.toFixed(1) };
if (isApt) { // runway length, COM freq, approach type
const rs = rwyByApt.get(f.id);
let ft = 0;
if (rs) for (const r of rs) ft = Math.max(ft, distNm(r.la1, r.lo1, r.la2, r.lo2) * 6076.12);
o.rwyFt = Math.round(ft);
o.com = comByApt.get(f.id) || null;
o.app = ilsApts.has(f.id) ? 'ILS' : 'VFR';
}
return o;
});
}
// 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;
}
// BBOX airways: every segment touching a lat/lon window (scan the midpoint
// cells overlapping the box, ±1 to catch segments crossing the edge).
export function airwaysBbox(s, w, n, e, limit = 500) {
const out = [];
const inB = (la, lo) => la >= s && la <= n && lo >= w && lo <= e;
for (let la = Math.floor(s) - 1; la <= Math.floor(n) + 1; la++)
for (let lo = Math.floor(w) - 1; lo <= Math.floor(e) + 1; lo++) {
const arr = awyCells.get(`${la},${lo}`);
if (!arr) continue;
for (const sg of arr) {
if (inB(sg.la1, sg.lo1) || inB(sg.la2, sg.lo2)) { out.push(sg); 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;
}