// 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 dir = fmsDir(); try { fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${name}.fms`); fs.writeFileSync(file, content); 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: 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 }; }