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:
+60
-10
@@ -11,9 +11,10 @@ import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { CONFIG, DATAREFS, WRITABLE_DATAREFS, COMMANDS } from './config.js';
|
||||
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways } from './navdata.js';
|
||||
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways } from './navdata.js';
|
||||
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
|
||||
import * as fp from './flightplan.js';
|
||||
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
||||
@@ -46,7 +47,9 @@ function broadcast(obj) {
|
||||
}
|
||||
|
||||
function broadcastPlan() {
|
||||
broadcast({ type: 'flightplan', data: fp.getPlan() });
|
||||
const plan = fp.getPlan();
|
||||
broadcast({ type: 'flightplan', data: plan });
|
||||
pushToSim(plan); // hand the plan to the FlyWithLua FMS bridge (App → Sim)
|
||||
}
|
||||
|
||||
async function fetchAllByName(resource, names) {
|
||||
@@ -162,6 +165,11 @@ function handleClientMessage(msg) {
|
||||
}
|
||||
if (msg.type === 'fp_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_active') { fp.setActiveLeg(msg.index); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_load') {
|
||||
const r = fp.loadFms(msg.name);
|
||||
if (r.ok) return broadcastPlan();
|
||||
return broadcast({ type: 'fp_export_result', ...r });
|
||||
}
|
||||
if (msg.type === 'fp_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_export') {
|
||||
const r = fp.exportFms(msg.name || 'WEBFPL');
|
||||
@@ -216,6 +224,10 @@ app.get('/api/nav/bbox', (req, res) =>
|
||||
res.json(navBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e,
|
||||
(req.query.types || 'apt,vor,ndb').split(','), +req.query.limit || 800))
|
||||
);
|
||||
// Airways (Victor/Jet routes) inside a map window — for the MFD AIRWAYS overlay.
|
||||
app.get('/api/nav/airways', (req, res) =>
|
||||
res.json(navAirways(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 500))
|
||||
);
|
||||
// Runways near a point — drawn in the PFD synthetic-vision view.
|
||||
app.get('/api/nav/runways', (req, res) =>
|
||||
res.json(navRunways(+req.query.lat, +req.query.lon, +req.query.radius || 12))
|
||||
@@ -227,6 +239,8 @@ app.get('/api/nav/procs', (req, res) => {
|
||||
if (!p) return res.status(404).json({ error: 'no procedures for ' + req.query.icao });
|
||||
res.json({ icao: p.icao, runways: p.runways, sids: p.sids, stars: p.stars, approaches: p.approaches });
|
||||
});
|
||||
// Saved flight plans (Output/FMS plans) — list for the FPL "load" picker.
|
||||
app.get('/api/fms/list', (_req, res) => res.json(fp.listPlans()));
|
||||
app.get('/api/nav/proc', (req, res) =>
|
||||
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
|
||||
);
|
||||
@@ -261,17 +275,23 @@ function startDemo() {
|
||||
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
|
||||
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
|
||||
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
|
||||
// AFCS annunciation: AP on, HDG active + GPS armed (lateral), ALT active (vertical)
|
||||
apMode: 2, hdgStatus: 2, gpssStatus: 1, altStatus: 2,
|
||||
lat: 47.45, lon: -122.31, track: 90, groundspeed: 64, gpsDistNm: 18.4, gpsBearing: 92,
|
||||
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
|
||||
nav1: 11030, nav1Sb: 11150, nav2: 11380, nav2Sb: 10890,
|
||||
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
|
||||
// HSI / data fields
|
||||
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
|
||||
nav1Brg: 210, nav1Dme: 12.4, nav2Brg: 320, nav2Dme: 0, // BRG1 (NAV1 VOR/DME) demo
|
||||
|
||||
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
|
||||
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
|
||||
cdiSrc: Number(process.env.DEMO_CDI ?? 2), // 0 VLOC1, 1 VLOC2, 2 GPS
|
||||
...(process.env.DEMO_RANGE ? { uiMapRange: Number(process.env.DEMO_RANGE) } : {}),
|
||||
// engine strip (arrays, like the sim)
|
||||
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
|
||||
fuelQty: [60, 58], volts: [28.0], amps: [12],
|
||||
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0], amps: [12],
|
||||
});
|
||||
// a sample plan so the map/FMS show something in demo mode
|
||||
fp.setPlan({ name: 'DEMO', waypoints: [
|
||||
@@ -279,27 +299,57 @@ function startDemo() {
|
||||
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
|
||||
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
|
||||
]});
|
||||
pushToSim(fp.getPlan());
|
||||
let t = 0;
|
||||
const lat0 = 47.45, lon0 = -122.31, R = 0.05, w = 0.02; // gentle orbit around KSEA
|
||||
const cosL = Math.cos(lat0 * Math.PI / 180);
|
||||
let pLat = lat0, pLon = lon0;
|
||||
setInterval(() => {
|
||||
t += 0.1;
|
||||
state.values.roll = -12 + Math.sin(t) * 4;
|
||||
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
|
||||
state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360;
|
||||
state.values.track = state.values.heading;
|
||||
state.values.altitude = 5500 + Math.sin(t * 0.5) * 40;
|
||||
state.values.airspeed = 124 + Math.sin(t * 0.4) * 3;
|
||||
// creep south-east so the aircraft visibly moves on the map
|
||||
state.values.lat -= 0.0006;
|
||||
state.values.lon -= 0.0009;
|
||||
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
|
||||
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
|
||||
state.values.altitude = newAlt;
|
||||
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
|
||||
// orbit so the aircraft visibly moves but stays near the demo flight plan
|
||||
const lat = lat0 + Math.cos(t * w) * R;
|
||||
const lon = lon0 + Math.sin(t * w) * R / cosL;
|
||||
const trk = (Math.atan2((lon - pLon) * cosL, lat - pLat) * 180 / Math.PI + 360) % 360;
|
||||
state.values.lat = lat; state.values.lon = lon;
|
||||
state.values.track = trk; state.values.heading = trk;
|
||||
pLat = lat; pLon = lon;
|
||||
broadcast({ type: 'status', xpConnected: true });
|
||||
broadcast({ type: 'values', data: state.values });
|
||||
}, 100);
|
||||
// synthetic terrain grid (a Cascades-style ridge rising eastward) so the MFD
|
||||
// terrain-awareness colouring (yellow/red vs aircraft altitude) is visible
|
||||
const emitTerrain = () => {
|
||||
const lat = state.values.lat, lon = state.values.lon, alt = state.values.altitude;
|
||||
const rows = 28, cols = 28, n = lat + 0.35, s = lat - 0.35, w = lon - 0.5, e = lon + 0.5;
|
||||
const elev = [];
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||
const fx = c / (cols - 1), fy = r / (rows - 1); // fx: 0 west → 1 east
|
||||
let h = fx * 9000 - 1200 + Math.sin(fy * 6 + fx * 4) * 800 + Math.cos(fx * 9) * 400;
|
||||
elev.push(Math.max(0, Math.round(h)));
|
||||
}
|
||||
broadcast({ type: 'terrain', data: { lat, lon, alt, n, s, w, e, rows, cols, elev } });
|
||||
};
|
||||
emitTerrain();
|
||||
setInterval(emitTerrain, 1500);
|
||||
}
|
||||
|
||||
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
|
||||
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
|
||||
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
|
||||
loadNavData(); // async; FMS resolves idents once ready
|
||||
// FMS two-way sync (Sim → App): adopt plans built/edited in the real G1000
|
||||
startFmsSync({
|
||||
getPlan: () => fp.getPlan(),
|
||||
onSimPlan: (waypoints) => { fp.setPlan({ name: 'ACTIVE', waypoints, activeLeg: 1 }); broadcastPlan(); },
|
||||
});
|
||||
// Terrain awareness grid (from the FlyWithLua terrain probe) → MFD colouring
|
||||
startTerrainSync((t) => broadcast({ type: 'terrain', data: t }));
|
||||
if (process.env.DEMO) startDemo();
|
||||
else connectXPlane();
|
||||
});
|
||||
|
||||
@@ -60,6 +60,25 @@ export const DATAREFS = {
|
||||
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
|
||||
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot',
|
||||
|
||||
// --- bearing pointers (BRG1/BRG2) + DME + marker beacons ---
|
||||
nav1Brg: 'sim/cockpit2/radios/indicators/nav1_bearing_deg_mag',
|
||||
nav2Brg: 'sim/cockpit2/radios/indicators/nav2_bearing_deg_mag',
|
||||
nav1Dme: 'sim/cockpit2/radios/indicators/nav1_dme_distance_nm',
|
||||
nav2Dme: 'sim/cockpit2/radios/indicators/nav2_dme_distance_nm',
|
||||
mkrOuter: 'sim/cockpit2/radios/indicators/outer_marker_lit',
|
||||
mkrMiddle: 'sim/cockpit2/radios/indicators/middle_marker_lit',
|
||||
mkrInner: 'sim/cockpit2/radios/indicators/inner_marker_lit',
|
||||
|
||||
// --- G1000 UI state (for display sync with the in-sim G1000) ---
|
||||
// CDI/HSI source: 0 = NAV1/VLOC1, 1 = NAV2/VLOC2, 2 = GPS (standard dataref).
|
||||
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot',
|
||||
// The rest are G1000-internal, so the FlyWithLua companion (ui-sync.lua)
|
||||
// publishes them as custom datarefs. Absent until the plugin runs -> the web
|
||||
// G1000 just keeps its own local UI state (graceful).
|
||||
uiMfdPage: 'glasscockpit/ui/mfd_page', // 0 map, 1 fpl, 2 nrst
|
||||
uiMapRange: 'glasscockpit/ui/map_range_nm', // active map range, NM
|
||||
uiInset: 'glasscockpit/ui/inset', // PFD inset map on/off (0/1)
|
||||
|
||||
// --- G1000 PFD: data fields ---
|
||||
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
|
||||
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot',
|
||||
@@ -88,6 +107,21 @@ export const DATAREFS = {
|
||||
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||
apEngaged: 'sim/cockpit2/autopilot/servos_on',
|
||||
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
|
||||
|
||||
// --- AFCS mode annunciation (the green/white mode strip on a real G1000) ---
|
||||
// X-Plane's per-mode status datarefs: 0 = off, 1 = armed, 2 = active/captured.
|
||||
// These mean the AFCS bar mirrors the sim exactly, no Lua needed.
|
||||
apMode: 'sim/cockpit2/autopilot/autopilot_mode', // 0 off, 1 FD, 2 AP
|
||||
hdgStatus: 'sim/cockpit2/autopilot/hdg_status',
|
||||
navStatus: 'sim/cockpit2/autopilot/nav_status',
|
||||
gpssStatus: 'sim/cockpit2/autopilot/gpss_status',
|
||||
aprStatus: 'sim/cockpit2/autopilot/approach_status',
|
||||
bcStatus: 'sim/cockpit2/autopilot/backcourse_status',
|
||||
altStatus: 'sim/cockpit2/autopilot/alt_hold_status',
|
||||
vsStatus: 'sim/cockpit2/autopilot/vvi_status',
|
||||
flcStatus: 'sim/cockpit2/autopilot/speed_status',
|
||||
gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
|
||||
vnavStatus: 'sim/cockpit2/autopilot/vnav_status',
|
||||
};
|
||||
|
||||
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
|
||||
@@ -121,6 +155,17 @@ export const COMMANDS = {
|
||||
xpdrIdent: 'sim/transponder/transponder_ident',
|
||||
};
|
||||
|
||||
// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
|
||||
// These work regardless of the dataref's frequency units, so the web tuner just
|
||||
// fires them — no risky raw frequency writes.
|
||||
for (const r of ['nav1', 'nav2', 'com1', 'com2']) {
|
||||
COMMANDS[`${r}CoarseUp`] = `sim/radios/stby_${r}_coarse_up`;
|
||||
COMMANDS[`${r}CoarseDown`] = `sim/radios/stby_${r}_coarse_down`;
|
||||
COMMANDS[`${r}FineUp`] = `sim/radios/stby_${r}_fine_up`;
|
||||
COMMANDS[`${r}FineDown`] = `sim/radios/stby_${r}_fine_down`;
|
||||
COMMANDS[`${r}Swap`] = `sim/radios/${r}_standby_flip`;
|
||||
}
|
||||
|
||||
// Every clickable G1000 bezel control maps to a real X-Plane command. The PFD
|
||||
// is unit n1, the MFD is unit n3 (the default C172 layout). Aliases are
|
||||
// prefixed pfd_/mfd_ so the frontend just says e.g. command('mfd_fpl').
|
||||
|
||||
+40
-3
@@ -87,14 +87,51 @@ export function exportFms(name = 'WEBFPL') {
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
const root = xplaneRoot();
|
||||
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
||||
const dir = fmsDir();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${name}.fms`);
|
||||
fs.writeFileSync(file, content);
|
||||
return { ok: true, file, intoXplane: !!root };
|
||||
return { ok: true, file, intoXplane: !!xplaneRoot() };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- load saved X-Plane .fms plans (Output/FMS plans) ----
|
||||
function fmsDir() {
|
||||
const root = xplaneRoot();
|
||||
return root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
||||
}
|
||||
const FMS_TYPE = { 1: 'APT', 2: 'NDB', 3: 'VOR', 11: 'WPT', 28: 'USR' };
|
||||
|
||||
// List the names of every saved .fms plan (X-Plane's own + our exports).
|
||||
export function listPlans() {
|
||||
try {
|
||||
return fs.readdirSync(fmsDir())
|
||||
.filter((f) => f.toLowerCase().endsWith('.fms'))
|
||||
.map((f) => f.replace(/\.fms$/i, ''))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Parse a saved .fms (v1100/v3) into our waypoints and make it the active plan.
|
||||
export function loadFms(name) {
|
||||
const safe = String(name || '').replace(/[^\w .+-]/g, '');
|
||||
const file = path.join(fmsDir(), `${safe}.fms`);
|
||||
if (!fs.existsSync(file)) return { ok: false, error: `not found: ${safe}` };
|
||||
const wps = [];
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const p = raw.trim().split(/\s+/);
|
||||
// waypoint rows start with a numeric type code: <type> <ident> <alt> <lat> <lon>
|
||||
if (p.length >= 5 && /^\d+$/.test(p[0]) && p[0] !== '1100') {
|
||||
const lat = parseFloat(p[3]), lon = parseFloat(p[4]), alt = parseFloat(p[2]);
|
||||
if (isFinite(lat) && isFinite(lon)) {
|
||||
wps.push({ id: p[1], lat, lon, type: FMS_TYPE[+p[0]] || 'WPT', alt: alt > 0 ? Math.round(alt) : null });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wps.length < 1) return { ok: false, error: 'no waypoints in file' };
|
||||
setPlan({ name: safe.toUpperCase(), waypoints: wps, activeLeg: 1 });
|
||||
return { ok: true, plan, count: wps.length };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Two-way flight-plan sync with X-Plane's in-sim FMS, bridged by a FlyWithLua
|
||||
// companion script (see plugins/fms-sync.lua). X-Plane's Web API can't inject a
|
||||
// flight plan into the FMS, so the Lua script (which has the FMS SDK) does it.
|
||||
//
|
||||
// Channel = two text files in <X-Plane>/Output/fms-sync/ (bridge + Lua run on
|
||||
// the same PC). We write to_sim.txt (our plan); Lua applies it to the FMS and
|
||||
// writes from_sim.txt (the sim's plan); we adopt sim-side changes. A position
|
||||
// signature (3-decimal lat/lon) de-dupes so the two sides never loop.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { xplaneRoot } from './navdata.js';
|
||||
|
||||
function dir() {
|
||||
const r = xplaneRoot();
|
||||
return r ? path.join(r, 'Output', 'fms-sync') : path.join(process.cwd(), 'fms-sync');
|
||||
}
|
||||
const toSimFile = () => path.join(dir(), 'to_sim.txt');
|
||||
const fromSimFile = () => path.join(dir(), 'from_sim.txt');
|
||||
|
||||
// loop-guard signature: rounded lat/lon list (idents/alt ignored, and coords
|
||||
// from our navdata == X-Plane's, so it stays stable across the round-trip)
|
||||
const sig = (wps) => (wps || []).map((w) => `${(+w.lat).toFixed(3)},${(+w.lon).toFixed(3)}`).join(';');
|
||||
let lastSig = null;
|
||||
|
||||
function serialize(wps) {
|
||||
const body = (wps || [])
|
||||
.map((w) => `${(+w.lat).toFixed(6)} ${(+w.lon).toFixed(6)} ${Math.round(w.alt || 0)} ${w.id || 'WPT'} ${w.type || 'WPT'}`)
|
||||
.join('\n');
|
||||
return `# ${sig(wps)}\n${body}\n`; // first line = sig comment, then waypoints
|
||||
}
|
||||
|
||||
function parse(txt) {
|
||||
const wps = [];
|
||||
for (const ln of (txt || '').split(/\r?\n/)) {
|
||||
const t = ln.trim();
|
||||
if (!t || t.startsWith('#')) continue; // skip sig/comment line
|
||||
const p = t.split(/\s+/);
|
||||
const lat = +p[0], lon = +p[1];
|
||||
if (p.length >= 2 && isFinite(lat) && isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
|
||||
const alt = +p[2] || 0;
|
||||
wps.push({ id: p[3] || 'WPT', lat, lon, type: p[4] || 'WPT', alt: alt > 0 ? alt : null });
|
||||
}
|
||||
}
|
||||
return wps;
|
||||
}
|
||||
|
||||
// our plan changed → hand it to the Lua script
|
||||
export function pushToSim(plan) {
|
||||
try {
|
||||
fs.mkdirSync(dir(), { recursive: true });
|
||||
fs.writeFileSync(toSimFile(), serialize(plan?.waypoints || []));
|
||||
lastSig = sig(plan?.waypoints || []);
|
||||
} catch { /* sim not local / no write access */ }
|
||||
}
|
||||
|
||||
// Terrain elevation grid published by the FlyWithLua terrain probe
|
||||
// (terrain.json in the sync dir). Polled and broadcast so the MFD can colour
|
||||
// terrain awareness (red/yellow). Only re-broadcasts when it actually changes.
|
||||
const terrainFile = () => path.join(dir(), 'terrain.json');
|
||||
export function startTerrainSync(onTerrain, intervalMs = 1500) {
|
||||
let lastMtime = 0;
|
||||
setInterval(() => {
|
||||
let st;
|
||||
try { st = fs.statSync(terrainFile()); } catch { return; }
|
||||
if (st.mtimeMs === lastMtime) return;
|
||||
lastMtime = st.mtimeMs;
|
||||
try {
|
||||
const t = JSON.parse(fs.readFileSync(terrainFile(), 'utf8'));
|
||||
if (t && Array.isArray(t.elev) && t.elev.length) onTerrain(t);
|
||||
} catch { /* mid-write / malformed */ }
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
// poll the Lua-written sim plan; adopt genuine sim-side changes
|
||||
export function startFmsSync({ getPlan, onSimPlan }) {
|
||||
pushToSim(getPlan());
|
||||
setInterval(() => {
|
||||
let txt;
|
||||
try { txt = fs.readFileSync(fromSimFile(), 'utf8'); } catch { return; }
|
||||
const wps = parse(txt);
|
||||
const s = sig(wps);
|
||||
if (wps.length && s && s !== lastSig) { lastSig = s; onSimPlan(wps); }
|
||||
}, 1200);
|
||||
}
|
||||
+77
-3
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user