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>
This commit is contained in:
2026-06-02 02:17:06 +02:00
parent 354ea5d44b
commit 38b048ad41
23 changed files with 1707 additions and 213 deletions
+77 -3
View File
@@ -41,7 +41,10 @@ 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 state = { root: null, loaded: false, count: 0 };
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;
@@ -90,6 +93,11 @@ async function parseNav(file) {
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';
@@ -128,10 +136,44 @@ async function parseAirports(file) {
}
} 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;
@@ -147,6 +189,10 @@ export async function loadNavData() {
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)`); })
@@ -178,13 +224,25 @@ export function search(q, limit = 20) {
// 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 src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports;
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) => ({ ...f, dist: +f.dist.toFixed(1) }));
.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.
@@ -205,6 +263,22 @@ export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) {
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 [];