38b048ad41
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>
301 lines
13 KiB
JavaScript
301 lines
13 KiB
JavaScript
// 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;
|
||
}
|