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:
@@ -0,0 +1,305 @@
|
||||
// X-Plane Glass Cockpit — Bridge
|
||||
// -------------------------------------------------------------------------
|
||||
// Connects to X-Plane 12's built-in web API (localhost only), resolves
|
||||
// dataref/command names to per-session IDs, subscribes to live values, and
|
||||
// fans them out over a LAN-facing WebSocket to any number of tablets/laptops.
|
||||
// Also serves the built React UI.
|
||||
|
||||
import express from 'express';
|
||||
import { WebSocketServer, WebSocket as WsClient } from 'ws';
|
||||
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 { parseProcedures, procedureLegs as procLegs } from './procedures.js';
|
||||
import * as fp from './flightplan.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
||||
// files it bundles as a resource); otherwise default to ../web/dist.
|
||||
const WEB_DIST = process.env.WEB_DIST || path.join(__dirname, '..', 'web', 'dist');
|
||||
const REST = `http://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
|
||||
const WS_URL = `ws://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
|
||||
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
|
||||
|
||||
// ---- shared state ---------------------------------------------------------
|
||||
const state = {
|
||||
xpConnected: false,
|
||||
values: {}, // alias -> latest value
|
||||
drefIdToAlias: new Map(), // X-Plane dataref id -> our alias
|
||||
drefNameToId: new Map(), // sim/... -> id
|
||||
cmdNameToId: new Map(), // sim/... -> id
|
||||
xpSocket: null,
|
||||
reqId: 1,
|
||||
};
|
||||
|
||||
const clients = new Set(); // connected browser sockets
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
function broadcast(obj) {
|
||||
const msg = JSON.stringify(obj);
|
||||
for (const c of clients) {
|
||||
if (c.readyState === WsClient.OPEN) c.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPlan() {
|
||||
broadcast({ type: 'flightplan', data: fp.getPlan() });
|
||||
}
|
||||
|
||||
async function fetchAllByName(resource, names) {
|
||||
// X-Plane's list endpoints can be filtered by name. We query each name so we
|
||||
// don't pull the full ~15k dataref catalogue.
|
||||
const map = new Map();
|
||||
await Promise.all(
|
||||
[...new Set(names)].map(async (name) => {
|
||||
try {
|
||||
const url = `${REST}/${resource}?filter[name]=${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
const item = (body.data || []).find((d) => d.name === name);
|
||||
if (item) map.set(name, item.id);
|
||||
else log(`! ${resource} not found: ${name}`);
|
||||
} catch (e) {
|
||||
log(`! lookup failed for ${name}: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---- X-Plane connection ---------------------------------------------------
|
||||
async function resolveIds() {
|
||||
const drefNames = Object.values(DATAREFS);
|
||||
const cmdNames = Object.values(COMMANDS);
|
||||
state.drefNameToId = await fetchAllByName('datarefs', [
|
||||
...drefNames,
|
||||
...Object.values(WRITABLE_DATAREFS),
|
||||
]);
|
||||
state.cmdNameToId = await fetchAllByName('commands', cmdNames);
|
||||
|
||||
// build reverse map id -> alias for incoming updates
|
||||
state.drefIdToAlias.clear();
|
||||
for (const [alias, name] of Object.entries(DATAREFS)) {
|
||||
const id = state.drefNameToId.get(name);
|
||||
if (id != null) state.drefIdToAlias.set(id, alias);
|
||||
}
|
||||
log(`resolved ${state.drefNameToId.size} datarefs, ${state.cmdNameToId.size} commands`);
|
||||
}
|
||||
|
||||
function subscribeValues() {
|
||||
const datarefs = [];
|
||||
for (const id of state.drefIdToAlias.keys()) datarefs.push({ id });
|
||||
if (!datarefs.length) return;
|
||||
state.xpSocket.send(
|
||||
JSON.stringify({
|
||||
req_id: state.reqId++,
|
||||
type: 'dataref_subscribe_values',
|
||||
params: { datarefs },
|
||||
})
|
||||
);
|
||||
log(`subscribed to ${datarefs.length} datarefs`);
|
||||
}
|
||||
|
||||
function connectXPlane() {
|
||||
log(`connecting to X-Plane @ ${WS_URL} ...`);
|
||||
let sock;
|
||||
try {
|
||||
sock = new WsClient(WS_URL);
|
||||
} catch (e) {
|
||||
log('X-Plane connect threw, retrying in 3s:', e.message);
|
||||
return setTimeout(connectXPlane, 3000);
|
||||
}
|
||||
state.xpSocket = sock;
|
||||
|
||||
sock.on('open', async () => {
|
||||
try {
|
||||
await resolveIds();
|
||||
subscribeValues();
|
||||
state.xpConnected = true;
|
||||
broadcast({ type: 'status', xpConnected: true });
|
||||
log('X-Plane connected ✓');
|
||||
} catch (e) {
|
||||
log('setup after connect failed:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
sock.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
if (msg.type === 'dataref_update_values' && msg.data) {
|
||||
const patch = {};
|
||||
for (const [id, value] of Object.entries(msg.data)) {
|
||||
const alias = state.drefIdToAlias.get(Number(id));
|
||||
if (alias) { state.values[alias] = value; patch[alias] = value; }
|
||||
}
|
||||
if (Object.keys(patch).length) broadcast({ type: 'values', data: patch });
|
||||
}
|
||||
});
|
||||
|
||||
const onDown = (why) => {
|
||||
if (state.xpConnected) log(`X-Plane disconnected (${why})`);
|
||||
state.xpConnected = false;
|
||||
broadcast({ type: 'status', xpConnected: false });
|
||||
if (state.xpSocket === sock) state.xpSocket = null;
|
||||
setTimeout(connectXPlane, 3000);
|
||||
};
|
||||
sock.on('close', () => onDown('close'));
|
||||
sock.on('error', (e) => onDown(e.message));
|
||||
}
|
||||
|
||||
// ---- commands coming FROM the browser ------------------------------------
|
||||
function handleClientMessage(msg) {
|
||||
// --- flight plan (works even without a sim connection) ---
|
||||
if (msg.type === 'fp_set') { fp.setPlan(msg.plan); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_add') {
|
||||
const r = fp.addWaypoint(msg.ident);
|
||||
if (!r.ok) return; // silently ignore unknown idents
|
||||
return broadcastPlan();
|
||||
}
|
||||
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_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_export') {
|
||||
const r = fp.exportFms(msg.name || 'WEBFPL');
|
||||
broadcast({ type: 'fp_export_result', ...r });
|
||||
return;
|
||||
}
|
||||
|
||||
// --- everything below talks to X-Plane; needs a live sim socket ---
|
||||
if (!state.xpSocket || state.xpSocket.readyState !== WsClient.OPEN) return;
|
||||
|
||||
if (msg.type === 'command') {
|
||||
const name = COMMANDS[msg.name];
|
||||
const id = name && state.cmdNameToId.get(name);
|
||||
if (id == null) return log(`! unknown command alias: ${msg.name}`);
|
||||
state.xpSocket.send(
|
||||
JSON.stringify({
|
||||
req_id: state.reqId++,
|
||||
type: 'command_set_is_active',
|
||||
params: { commands: [{ id, is_active: true, duration: msg.duration ?? 0 }] },
|
||||
})
|
||||
);
|
||||
} else if (msg.type === 'setDataref') {
|
||||
const name = WRITABLE_DATAREFS[msg.name];
|
||||
const id = name && state.drefNameToId.get(name);
|
||||
if (id == null) return log(`! unknown writable dataref alias: ${msg.name}`);
|
||||
state.xpSocket.send(
|
||||
JSON.stringify({
|
||||
req_id: state.reqId++,
|
||||
type: 'dataref_set_values',
|
||||
params: { datarefs: [{ id, value: Number(msg.value) }] },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP + LAN WebSocket server -----------------------------------------
|
||||
const app = express();
|
||||
// Allow the desktop launcher (a different origin) to read the JSON API. LAN-only
|
||||
// by design, so a wildcard here is harmless and keeps tablets/the app simple.
|
||||
app.use('/api', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); });
|
||||
app.get('/api/health', (_req, res) =>
|
||||
res.json({ xpConnected: state.xpConnected, datarefs: state.drefIdToAlias.size, clients: clients.size, nav: navStatus() })
|
||||
);
|
||||
// Waypoint / navaid / airport search from X-Plane's own nav database.
|
||||
app.get('/api/nav/search', (req, res) => res.json(navSearch(req.query.q || '', 25)));
|
||||
// NEAREST airports/navaids to a point (NRST page).
|
||||
app.get('/api/nav/nearest', (req, res) =>
|
||||
res.json(navNearest(+req.query.lat, +req.query.lon, { count: +req.query.count || 15, type: req.query.type || 'apt' }))
|
||||
);
|
||||
// Features inside a map window (airports/navaids/fixes) for the moving map.
|
||||
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))
|
||||
);
|
||||
// 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))
|
||||
);
|
||||
// PROC: an airport's procedures (SIDs/STARs/approaches) and the resolved leg
|
||||
// fixes for a chosen procedure+transition (from X-Plane's CIFP data).
|
||||
app.get('/api/nav/procs', (req, res) => {
|
||||
const p = parseProcedures(String(req.query.icao || ''));
|
||||
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 });
|
||||
});
|
||||
app.get('/api/nav/proc', (req, res) =>
|
||||
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
|
||||
);
|
||||
app.use(express.static(WEB_DIST));
|
||||
// SPA fallback so client-side routes work.
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(WEB_DIST, 'index.html')));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clients.add(ws);
|
||||
log(`browser connected (${clients.size} total)`);
|
||||
// send current snapshot immediately so the UI isn't blank
|
||||
ws.send(JSON.stringify({ type: 'status', xpConnected: state.xpConnected }));
|
||||
ws.send(JSON.stringify({ type: 'values', data: state.values }));
|
||||
ws.send(JSON.stringify({ type: 'flightplan', data: fp.getPlan() }));
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try { handleClientMessage(JSON.parse(raw)); } catch { /* ignore */ }
|
||||
});
|
||||
ws.on('close', () => { clients.delete(ws); log(`browser left (${clients.size} total)`); });
|
||||
ws.on('error', () => clients.delete(ws));
|
||||
});
|
||||
|
||||
// ---- demo mode: synthetic values when there's no X-Plane (for previews) ---
|
||||
function startDemo() {
|
||||
log('DEMO mode — emitting synthetic values, not connecting to X-Plane');
|
||||
state.xpConnected = true;
|
||||
Object.assign(state.values, {
|
||||
airspeed: 124, altitude: 5500, vspeed: 320, pitch: 4.5, roll: -12,
|
||||
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,
|
||||
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,
|
||||
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
|
||||
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
|
||||
// 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],
|
||||
});
|
||||
// a sample plan so the map/FMS show something in demo mode
|
||||
fp.setPlan({ name: 'DEMO', waypoints: [
|
||||
{ id: 'KSEA', lat: 47.449, lon: -122.309, type: 'APT' },
|
||||
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
|
||||
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
|
||||
]});
|
||||
let t = 0;
|
||||
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;
|
||||
broadcast({ type: 'status', xpConnected: true });
|
||||
broadcast({ type: 'values', data: state.values });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
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
|
||||
if (process.env.DEMO) startDemo();
|
||||
else connectXPlane();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// Central configuration: which X-Plane datarefs/commands the cockpit needs.
|
||||
//
|
||||
// These are *universal* datarefs that work on virtually every aircraft.
|
||||
// To add a G1000- or aircraft-specific instrument, just add its dataref name
|
||||
// here under DATAREFS (read) and/or WRITABLE_DATAREFS / COMMANDS (interact).
|
||||
|
||||
export const CONFIG = {
|
||||
// Where X-Plane's built-in web server listens (on the same PC). X-Plane 12.1.1+.
|
||||
xplaneHost: process.env.XPLANE_HOST || 'localhost',
|
||||
xplanePort: Number(process.env.XPLANE_PORT || 8086),
|
||||
xplaneApiBase: '/api/v3',
|
||||
|
||||
// Where THIS bridge serves the UI + relays data. 0.0.0.0 => reachable from the LAN.
|
||||
bridgeHost: process.env.BRIDGE_HOST || '0.0.0.0',
|
||||
bridgePort: Number(process.env.BRIDGE_PORT || 8080),
|
||||
|
||||
// How often X-Plane pushes value updates (it caps near 10–20 Hz anyway).
|
||||
updateHz: Number(process.env.UPDATE_HZ || 20),
|
||||
};
|
||||
|
||||
// Datarefs we SUBSCRIBE to and stream to every client. Keyed by a short alias
|
||||
// the frontend uses, so the long sim/... names live in exactly one place.
|
||||
export const DATAREFS = {
|
||||
// --- primary flight data ---
|
||||
airspeed: 'sim/cockpit2/gauges/indicators/airspeed_kts_pilot',
|
||||
altitude: 'sim/cockpit2/gauges/indicators/altitude_ft_pilot',
|
||||
vspeed: 'sim/cockpit2/gauges/indicators/vvi_fpm_pilot',
|
||||
pitch: 'sim/cockpit2/gauges/indicators/pitch_AHARS_deg_pilot',
|
||||
roll: 'sim/cockpit2/gauges/indicators/roll_AHARS_deg_pilot',
|
||||
heading: 'sim/cockpit2/gauges/indicators/heading_AHARS_deg_mag_pilot',
|
||||
slip: 'sim/cockpit2/gauges/indicators/slip_deg',
|
||||
gForce: 'sim/flightmodel/forces/g_nrml',
|
||||
|
||||
// --- position / navigation (for the moving map) ---
|
||||
lat: 'sim/flightmodel/position/latitude',
|
||||
lon: 'sim/flightmodel/position/longitude',
|
||||
groundspeed: 'sim/flightmodel/position/groundspeed', // m/s
|
||||
track: 'sim/cockpit2/gauges/indicators/ground_track_mag_pilot', // deg
|
||||
gpsDistNm: 'sim/cockpit2/radios/indicators/gps_dme_distance_nm',
|
||||
gpsBearing: 'sim/cockpit2/radios/indicators/gps_bearing_deg_mag',
|
||||
|
||||
// --- engine / misc (handy on an MFD) ---
|
||||
fuelTotal: 'sim/cockpit2/fuel/fuel_quantity', // array
|
||||
oat: 'sim/cockpit2/temperature/outside_air_temp_degc',
|
||||
|
||||
// --- G1000 PFD: radios (NAV/COM active + standby) ---
|
||||
nav1: 'sim/cockpit2/radios/actuators/nav1_frequency_hz',
|
||||
nav1Sb: 'sim/cockpit2/radios/actuators/nav1_standby_frequency_hz',
|
||||
nav2: 'sim/cockpit2/radios/actuators/nav2_frequency_hz',
|
||||
nav2Sb: 'sim/cockpit2/radios/actuators/nav2_standby_frequency_hz',
|
||||
com1: 'sim/cockpit2/radios/actuators/com1_frequency_hz',
|
||||
com1Sb: 'sim/cockpit2/radios/actuators/com1_standby_frequency_hz',
|
||||
com2: 'sim/cockpit2/radios/actuators/com2_frequency_hz',
|
||||
com2Sb: 'sim/cockpit2/radios/actuators/com2_standby_frequency_hz',
|
||||
|
||||
// --- G1000 PFD: HSI / CDI ---
|
||||
obsCrs: 'sim/cockpit2/radios/actuators/nav1_obs_deg_mag_pilot',
|
||||
gsDef: 'sim/cockpit/radios/nav1_vdef', // glideslope vertical deflection (dots)
|
||||
hsiDef: 'sim/cockpit2/radios/indicators/hsi_hdef_dots_pilot',
|
||||
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
|
||||
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot',
|
||||
|
||||
// --- G1000 PFD: data fields ---
|
||||
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
|
||||
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot',
|
||||
windSpd: 'sim/cockpit2/gauges/indicators/wind_speed_kts',
|
||||
windDir: 'sim/cockpit2/gauges/indicators/wind_heading_deg_mag',
|
||||
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code',
|
||||
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode',
|
||||
fdPitch: 'sim/cockpit2/autopilot/flight_director_pitch_deg',
|
||||
fdRoll: 'sim/cockpit2/autopilot/flight_director_roll_deg',
|
||||
|
||||
// --- G1000 MFD: engine strip (arrays — UI reads index 0/1) ---
|
||||
engRpm: 'sim/cockpit2/engine/indicators/engine_speed_rpm',
|
||||
fuelFlow: 'sim/cockpit2/engine/indicators/fuel_flow_kg_sec',
|
||||
oilTemp: 'sim/cockpit2/engine/indicators/oil_temperature_deg_C',
|
||||
oilPress: 'sim/cockpit2/engine/indicators/oil_pressure_psi',
|
||||
egt: 'sim/cockpit2/engine/indicators/EGT_deg_C',
|
||||
fuelQty: 'sim/cockpit2/fuel/fuel_quantity',
|
||||
volts: 'sim/cockpit2/electrical/bus_volts',
|
||||
amps: 'sim/cockpit2/electrical/battery_amps',
|
||||
|
||||
// --- autopilot readouts (live values, so the panel reflects reality) ---
|
||||
apState: 'sim/cockpit2/autopilot/autopilot_state', // bitfield of active modes
|
||||
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
|
||||
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
|
||||
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
|
||||
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||
apEngaged: 'sim/cockpit2/autopilot/servos_on',
|
||||
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
|
||||
};
|
||||
|
||||
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
|
||||
export const WRITABLE_DATAREFS = {
|
||||
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
|
||||
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
|
||||
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
|
||||
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode', // 0 off,1 stby,2 on,3 alt
|
||||
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code', // 4-digit squawk
|
||||
};
|
||||
|
||||
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
|
||||
export const COMMANDS = {
|
||||
apToggle: 'sim/autopilot/servos_toggle',
|
||||
fdToggle: 'sim/autopilot/fdir_toggle',
|
||||
hdg: 'sim/autopilot/heading',
|
||||
nav: 'sim/autopilot/NAV',
|
||||
apr: 'sim/autopilot/approach',
|
||||
altHold: 'sim/autopilot/altitude_hold',
|
||||
vs: 'sim/autopilot/vertical_speed',
|
||||
flc: 'sim/autopilot/level_change',
|
||||
vnav: 'sim/autopilot/vnav',
|
||||
backCourse:'sim/autopilot/back_course',
|
||||
noseUp: 'sim/autopilot/nose_up',
|
||||
noseDown: 'sim/autopilot/nose_down',
|
||||
altUp: 'sim/autopilot/altitude_up',
|
||||
altDown: 'sim/autopilot/altitude_down',
|
||||
hdgUp: 'sim/autopilot/heading_up',
|
||||
hdgDown: 'sim/autopilot/heading_down',
|
||||
xpdrIdent: 'sim/transponder/transponder_ident',
|
||||
};
|
||||
|
||||
// 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').
|
||||
const G1000_KEYS = [
|
||||
...Array.from({ length: 12 }, (_, i) => `softkey${i + 1}`),
|
||||
'direct', 'menu', 'fpl', 'proc', 'clr', 'ent', 'cursor',
|
||||
'fms_outer_up', 'fms_outer_down', 'fms_inner_up', 'fms_inner_down',
|
||||
'range_up', 'range_down', 'pan_push', 'pan_up', 'pan_down', 'pan_left', 'pan_right',
|
||||
'hdg_up', 'hdg_down', 'hdg_sync',
|
||||
'alt_outer_up', 'alt_outer_down', 'alt_inner_up', 'alt_inner_down',
|
||||
'crs_up', 'crs_down', 'crs_sync', 'baro_up', 'baro_down',
|
||||
'nav_outer_up', 'nav_outer_down', 'nav_inner_up', 'nav_inner_down', 'nav12', 'nvol_up', 'nvol_dn',
|
||||
'com_outer_up', 'com_outer_down', 'com_inner_up', 'com_inner_down', 'com12', 'cvol_up', 'cvol_dn',
|
||||
'ap', 'fd', 'hdg', 'alt', 'nav', 'vnv', 'apr', 'bc', 'vs', 'flc', 'nose_up', 'nose_down',
|
||||
];
|
||||
for (const [unit, prefix] of [['n1', 'pfd'], ['n3', 'mfd']]) {
|
||||
for (const k of G1000_KEYS) COMMANDS[`${prefix}_${k}`] = `sim/GPS/g1000${unit}_${k}`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Shared flight plan: one plan, synced to every connected tablet. Can resolve
|
||||
// idents via navdata, and export an X-Plane .fms file the sim can load.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { lookup, xplaneRoot } from './navdata.js';
|
||||
|
||||
// waypoint: { id, lat, lon, type, alt? }
|
||||
// activeLeg = index of the waypoint the active (magenta) leg flies TO. The leg
|
||||
// runs from waypoints[activeLeg-1] to waypoints[activeLeg]. Defaults to 1.
|
||||
let plan = { name: 'ACTIVE', waypoints: [], activeLeg: 1 };
|
||||
|
||||
const clampLeg = (i) => Math.max(1, Math.min(plan.waypoints.length - 1, i | 0));
|
||||
|
||||
export const getPlan = () => plan;
|
||||
|
||||
export function setPlan(next) {
|
||||
const wps = Array.isArray(next?.waypoints)
|
||||
? next.waypoints
|
||||
.filter((w) => isFinite(w.lat) && isFinite(w.lon))
|
||||
.map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null }))
|
||||
: [];
|
||||
const wantLeg = Number.isFinite(next?.activeLeg) ? next.activeLeg : 1;
|
||||
plan = { name: next?.name || 'ACTIVE', waypoints: wps, activeLeg: Math.max(1, Math.min(wps.length - 1, wantLeg)) || 1 };
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function setActiveLeg(index) {
|
||||
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(index);
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Add a waypoint by ident (resolved against navdata) or raw "lat,lon".
|
||||
export function addWaypoint(input) {
|
||||
const raw = String(input || '').trim();
|
||||
const m = raw.match(/^(-?\d+(?:\.\d+)?)[ ,]+(-?\d+(?:\.\d+)?)$/);
|
||||
if (m) {
|
||||
plan.waypoints.push({ id: 'USR', lat: +m[1], lon: +m[2], type: 'USR', alt: null });
|
||||
return { ok: true, plan };
|
||||
}
|
||||
const hit = lookup(raw);
|
||||
if (!hit) return { ok: false, error: `unknown ident: ${raw}` };
|
||||
plan.waypoints.push({ ...hit, alt: null });
|
||||
return { ok: true, plan };
|
||||
}
|
||||
|
||||
export function removeWaypoint(index) {
|
||||
if (index >= 0 && index < plan.waypoints.length) plan.waypoints.splice(index, 1);
|
||||
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(plan.activeLeg);
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ---- great-circle helpers (nm + degrees) ----
|
||||
const R_NM = 3440.065;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
const deg = (r) => (r * 180) / Math.PI;
|
||||
|
||||
export function legDistanceNm(a, b) {
|
||||
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export function legBearing(a, b) {
|
||||
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
|
||||
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) -
|
||||
Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
|
||||
return (deg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
// ---- X-Plane .fms (v1100) export ----
|
||||
function fmsType(t) {
|
||||
return { APT: 1, NDB: 2, VOR: 3, WPT: 11, USR: 28 }[t] || 11;
|
||||
}
|
||||
|
||||
export function exportFms(name = 'WEBFPL') {
|
||||
const wp = plan.waypoints;
|
||||
if (wp.length < 2) return { ok: false, error: 'need at least 2 waypoints' };
|
||||
|
||||
const lines = ['I', '1100 Version', 'CYCLE 2501'];
|
||||
lines.push(`ADEP ${wp[0].id}`);
|
||||
lines.push(`ADES ${wp[wp.length - 1].id}`);
|
||||
lines.push(`NUMENR ${wp.length}`);
|
||||
for (const w of wp) {
|
||||
const alt = w.alt ?? 0;
|
||||
lines.push(`${fmsType(w.type)} ${w.id} ${alt.toFixed(6)} ${w.lat.toFixed(6)} ${w.lon.toFixed(6)}`);
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
const root = xplaneRoot();
|
||||
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${name}.fms`);
|
||||
fs.writeFileSync(file, content);
|
||||
return { ok: true, file, intoXplane: !!root };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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 state = { root: null, loaded: false, count: 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 !== 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
// 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 src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports;
|
||||
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) }));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user