Auto-install Lua, smooth all panels, airspace overlay + launcher region picker
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare). Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health. Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar. Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on settle. VFR steam gauges now interpolate to 60fps instead of stepping at the ~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom). Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies (B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed), non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume" section to pick/download regions via the running bridge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Airspace overlay data. X-Plane ships no airspace boundaries in its nav data,
|
||||
// so we keep them as GeoJSON files the user installs per region (chosen in the
|
||||
// desktop launcher). This module:
|
||||
// - resolves the airspace data dir (next to the FMS sync folder, overridable)
|
||||
// - loads every *.geojson there into a flat, bbox-indexed feature list
|
||||
// - answers bbox queries for the moving map (/api/airspace/bbox)
|
||||
// - downloads region datasets on demand (FAA = key-free US; OpenAIP = others,
|
||||
// needs the user's API key) and normalises them to one schema
|
||||
//
|
||||
// Normalised feature properties: { name, cls, lo, hi } where cls is a coarse
|
||||
// class the map colours by: B|C|D|E|TMA|CTR|MOA|RESTRICTED|PROHIBITED|DANGER|OTHER.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { xplaneRoot } from './navdata.js';
|
||||
|
||||
function dataDir() {
|
||||
if (process.env.AIRSPACE_DIR) return process.env.AIRSPACE_DIR;
|
||||
const r = xplaneRoot();
|
||||
return r ? path.join(r, 'Output', 'fms-sync', 'airspace') : path.join(process.cwd(), 'airspace-data');
|
||||
}
|
||||
|
||||
// flat store: { bbox:[s,w,n,e], geometry, props:{name,cls,lo,hi}, region }
|
||||
let store = [];
|
||||
let loaded = false;
|
||||
|
||||
function featureBbox(geom) {
|
||||
let s = 90, w = 180, n = -90, e = -180;
|
||||
const scan = (co) => {
|
||||
if (typeof co[0] === 'number') { const [x, y] = co; if (y < s) s = y; if (y > n) n = y; if (x < w) w = x; if (x > e) e = x; }
|
||||
else for (const c of co) scan(c);
|
||||
};
|
||||
try { scan(geom.coordinates); } catch { /* ignore */ }
|
||||
return [s, w, n, e];
|
||||
}
|
||||
|
||||
// Map many source schemas (FAA, OpenAIP, generic) onto one coarse class.
|
||||
function classify(p = {}) {
|
||||
const raw = String(
|
||||
p.cls ?? p.CLASS ?? p.class ?? p.Class ?? p.LOCAL_TYPE ?? p.TYPE_CODE ?? p.type ?? ''
|
||||
).toUpperCase();
|
||||
const name = String(p.name ?? p.NAME ?? p.Name ?? p.IDENT ?? '').toUpperCase();
|
||||
const hay = raw + ' ' + name;
|
||||
if (/PROHIBIT/.test(hay)) return 'PROHIBITED';
|
||||
if (/RESTRICT/.test(hay)) return 'RESTRICTED';
|
||||
if (/\bMOA\b|MILITARY OPERATION/.test(hay)) return 'MOA';
|
||||
if (/DANGER/.test(hay)) return 'DANGER';
|
||||
if (/\bTMA\b/.test(hay)) return 'TMA';
|
||||
if (/\bCTR\b|CONTROL ZONE/.test(hay)) return 'CTR';
|
||||
// OpenAIP icaoClass: 0=A 1=B 2=C 3=D 4=E 5=F 6=G
|
||||
if (p.icaoClass != null) return ['A', 'B', 'C', 'D', 'E', 'F', 'G'][p.icaoClass] || 'OTHER';
|
||||
const m = raw.match(/\b([A-G])\b/) || raw.match(/CLASS\s*([A-G])/) || raw.match(/^([A-G])\d?$/);
|
||||
if (m) return m[1];
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
// Pull a readable altitude limit out of whatever fields a source uses.
|
||||
function limit(p, kind) {
|
||||
const lo = kind === 'lo'
|
||||
? (p.lo ?? p.LOWER_VAL ?? p.lowerLimit?.value ?? p.LOWER_DESC ?? p.lower ?? null)
|
||||
: (p.hi ?? p.UPPER_VAL ?? p.upperLimit?.value ?? p.UPPER_DESC ?? p.upper ?? null);
|
||||
return lo == null ? null : (typeof lo === 'object' ? (lo.value ?? null) : lo);
|
||||
}
|
||||
|
||||
function ingest(fc, region) {
|
||||
const feats = Array.isArray(fc?.features) ? fc.features : [];
|
||||
for (const f of feats) {
|
||||
if (!f?.geometry?.coordinates) continue;
|
||||
const p = f.properties || {};
|
||||
store.push({
|
||||
bbox: featureBbox(f.geometry),
|
||||
geometry: f.geometry,
|
||||
props: { name: p.name ?? p.NAME ?? p.Name ?? '', cls: classify(p), lo: limit(p, 'lo'), hi: limit(p, 'hi') },
|
||||
region,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAirspace(log = console.log) {
|
||||
store = [];
|
||||
const dir = dataDir();
|
||||
let files = [];
|
||||
try { files = fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.geojson')); } catch { /* none yet */ }
|
||||
for (const f of files) {
|
||||
try { ingest(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')), f.replace(/\.geojson$/i, '')); }
|
||||
catch (e) { log(`airspace: ${f} parse failed: ${e.message}`); }
|
||||
}
|
||||
loaded = true;
|
||||
if (store.length) log(`airspace: ${store.length} features from ${files.length} file(s) in ${dir}`);
|
||||
return store.length;
|
||||
}
|
||||
|
||||
// Features whose bbox intersects the query window (linear scan — a few thousand
|
||||
// features, queried only on map move; cheap enough). Returns light DTOs.
|
||||
export function airspaceBbox(s, w, n, e, limit = 400) {
|
||||
if (!loaded) loadAirspace();
|
||||
const out = [];
|
||||
for (const a of store) {
|
||||
const [as, aw, an, ae] = a.bbox;
|
||||
if (an < s || as > n || ae < w || aw > e) continue;
|
||||
out.push({ name: a.props.name, cls: a.props.cls, lo: a.props.lo, hi: a.props.hi, geometry: a.geometry });
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function airspaceStatus() {
|
||||
if (!loaded) loadAirspace();
|
||||
const byRegion = {};
|
||||
for (const a of store) byRegion[a.region] = (byRegion[a.region] || 0) + 1;
|
||||
return { dir: dataDir(), features: store.length, regions: byRegion };
|
||||
}
|
||||
|
||||
// ---- region downloads ------------------------------------------------------
|
||||
|
||||
// kind 'faa': paginated ArcGIS FeatureServer → GeoJSON (US, public domain, no key)
|
||||
// kind 'openaip': OpenAIP REST by ICAO country code (needs the user's API key)
|
||||
export const REGIONS = [
|
||||
{ id: 'us', label: 'USA (FAA)', kind: 'faa', needsKey: false,
|
||||
layers: ['https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/Class_Airspace/FeatureServer/0'] },
|
||||
{ id: 'ch', label: 'Schweiz', kind: 'openaip', country: 'CH', needsKey: true },
|
||||
{ id: 'at', label: 'Österreich', kind: 'openaip', country: 'AT', needsKey: true },
|
||||
{ id: 'de', label: 'Deutschland', kind: 'openaip', country: 'DE', needsKey: true },
|
||||
{ id: 'fr', label: 'Frankreich', kind: 'openaip', country: 'FR', needsKey: true },
|
||||
{ id: 'it', label: 'Italien', kind: 'openaip', country: 'IT', needsKey: true },
|
||||
{ id: 'gb', label: 'Großbritannien', kind: 'openaip', country: 'GB', needsKey: true },
|
||||
];
|
||||
|
||||
async function fetchFaa(layerUrl, log) {
|
||||
const feats = [];
|
||||
let offset = 0;
|
||||
for (let page = 0; page < 80; page++) { // safety cap
|
||||
const url = `${layerUrl}/query?where=1%3D1&outFields=*&returnGeometry=true&outSR=4326&f=geojson&resultRecordCount=1000&resultOffset=${offset}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`FAA HTTP ${res.status}`);
|
||||
const fc = await res.json();
|
||||
const got = fc.features?.length || 0;
|
||||
feats.push(...(fc.features || []));
|
||||
log(`airspace: FAA page ${page + 1} (+${got}, total ${feats.length})`);
|
||||
if (got < 1000 && !fc.properties?.exceededTransferLimit) break;
|
||||
offset += 1000;
|
||||
}
|
||||
return { type: 'FeatureCollection', features: feats };
|
||||
}
|
||||
|
||||
async function fetchOpenAip(country, apiKey, log) {
|
||||
const feats = [];
|
||||
let pageNum = 1;
|
||||
for (; pageNum <= 50; pageNum++) {
|
||||
const url = `https://api.core.openaip.net/api/airspaces?country=${country}&limit=1000&page=${pageNum}`;
|
||||
const res = await fetch(url, { headers: { 'x-openaip-api-key': apiKey } });
|
||||
if (!res.ok) throw new Error(`OpenAIP HTTP ${res.status} (API-Key prüfen)`);
|
||||
const body = await res.json();
|
||||
const items = body.items || [];
|
||||
for (const a of items) {
|
||||
if (!a.geometry) continue;
|
||||
feats.push({ type: 'Feature', geometry: a.geometry, properties: { name: a.name, icaoClass: a.icaoClass, type: a.type, lower: a.lowerLimit, upper: a.upperLimit } });
|
||||
}
|
||||
log(`airspace: OpenAIP ${country} page ${pageNum} (+${items.length}, total ${feats.length})`);
|
||||
if (items.length < 1000 || pageNum >= (body.totalPages || 1)) break;
|
||||
}
|
||||
return { type: 'FeatureCollection', features: feats };
|
||||
}
|
||||
|
||||
export async function installRegion(id, { apiKey, log = console.log } = {}) {
|
||||
const region = REGIONS.find((r) => r.id === id);
|
||||
if (!region) return { ok: false, error: `unknown region: ${id}` };
|
||||
if (region.needsKey && !apiKey) return { ok: false, error: 'OpenAIP API-Key erforderlich' };
|
||||
try {
|
||||
let fc;
|
||||
if (region.kind === 'faa') {
|
||||
fc = { type: 'FeatureCollection', features: [] };
|
||||
for (const layer of region.layers) {
|
||||
const part = await fetchFaa(layer, log);
|
||||
fc.features.push(...part.features);
|
||||
}
|
||||
} else {
|
||||
fc = await fetchOpenAip(region.country, apiKey, log);
|
||||
}
|
||||
const dir = dataDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${id}.geojson`);
|
||||
fs.writeFileSync(file, JSON.stringify(fc));
|
||||
loadAirspace(log); // reload index so the new data is live immediately
|
||||
return { ok: true, id, features: fc.features.length, file };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export function regionList() {
|
||||
const st = airspaceStatus();
|
||||
return REGIONS.map((r) => ({ id: r.id, label: r.label, needsKey: r.needsKey, installed: (st.regions[r.id] || 0) }));
|
||||
}
|
||||
+29
-3
@@ -11,10 +11,12 @@ 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, airwaysBbox as navAirways } from './navdata.js';
|
||||
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways, xplaneRoot } from './navdata.js';
|
||||
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
|
||||
import * as fp from './flightplan.js';
|
||||
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
|
||||
import { installLuaScripts } from './luainstall.js';
|
||||
import { loadAirspace, airspaceBbox, airspaceStatus, regionList, installRegion } from './airspace.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
||||
@@ -34,6 +36,7 @@ const state = {
|
||||
cmdNameToId: new Map(), // sim/... -> id
|
||||
xpSocket: null,
|
||||
reqId: 1,
|
||||
lua: null, // last FlyWithLua-install report (see luainstall.js)
|
||||
};
|
||||
|
||||
const clients = new Set(); // connected browser sockets
|
||||
@@ -211,8 +214,14 @@ const app = express();
|
||||
// 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() })
|
||||
res.json({ xpConnected: state.xpConnected, datarefs: state.drefIdToAlias.size, clients: clients.size, nav: navStatus(), lua: state.lua })
|
||||
);
|
||||
// Re-run the FlyWithLua companion install on demand (e.g. after installing
|
||||
// FlyWithLua, or to push a freshly edited script without restarting the bridge).
|
||||
app.post('/api/lua/install', (_req, res) => {
|
||||
state.lua = installLuaScripts(xplaneRoot(), log);
|
||||
res.json(state.lua);
|
||||
});
|
||||
// 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).
|
||||
@@ -228,6 +237,19 @@ app.get('/api/nav/bbox', (req, res) =>
|
||||
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))
|
||||
);
|
||||
// Airspace polygons inside a map window — for the MFD AIRSPACE overlay. Data
|
||||
// comes from region GeoJSON files the user installs via the launcher (X-Plane
|
||||
// ships none). See server/airspace.js.
|
||||
app.get('/api/airspace/bbox', (req, res) =>
|
||||
res.json(airspaceBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 400))
|
||||
);
|
||||
// Available airspace regions + how many features of each are installed.
|
||||
app.get('/api/airspace/regions', (_req, res) => res.json({ regions: regionList(), status: airspaceStatus() }));
|
||||
// Download + install a region's airspace (FAA US is key-free; OpenAIP needs key).
|
||||
app.post('/api/airspace/install', express.json(), async (req, res) => {
|
||||
const r = await installRegion(req.body?.region, { apiKey: req.body?.apiKey, log });
|
||||
res.json(r);
|
||||
});
|
||||
// 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))
|
||||
@@ -342,7 +364,11 @@ function startDemo() {
|
||||
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
|
||||
loadNavData(); // async; FMS resolves idents once ready (sets root synchronously)
|
||||
// Drop the FlyWithLua companion scripts into the sim so the user never copies
|
||||
// files by hand; keeps the installed copies up to date on every start.
|
||||
state.lua = installLuaScripts(xplaneRoot(), log);
|
||||
loadAirspace(log); // installed region GeoJSON → /api/airspace/bbox overlay
|
||||
// FMS two-way sync (Sim → App): adopt plans built/edited in the real G1000
|
||||
startFmsSync({
|
||||
getPlan: () => fp.getPlan(),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Auto-installs the FlyWithLua companion scripts into X-Plane on bridge start.
|
||||
//
|
||||
// The web cockpit needs three .lua helpers running INSIDE X-Plane (they have the
|
||||
// FMS / scenery SDK the Web API lacks — see plugins/*.lua). Rather than make the
|
||||
// user copy files by hand, the bridge drops them into the sim's FlyWithLua
|
||||
// Scripts folder on startup and keeps them up to date (content-compare, so a new
|
||||
// build self-updates the installed copy; unchanged files are left alone).
|
||||
//
|
||||
// Everything degrades gracefully: no X-Plane found, no FlyWithLua installed, or
|
||||
// the script sources missing → we log a hint and carry on. We never create the
|
||||
// FlyWithLua folder ourselves (its absence means the user must install
|
||||
// FlyWithLua NG+ first; making an empty folder would only hide that).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// The companion scripts to install, in load-independent order.
|
||||
const SCRIPTS = ['fms-sync.lua', 'ui-sync.lua', 'terrain-probe.lua'];
|
||||
|
||||
// Where do the canonical .lua sources live? plugins/ sits next to server/ in the
|
||||
// repo; in the packaged desktop app it's bundled as a Tauri resource. Probe a
|
||||
// few locations so both `node server/bridge.js` and the compiled sidecar work.
|
||||
function sourceDir() {
|
||||
const candidates = [
|
||||
process.env.LUA_SRC_DIR,
|
||||
path.join(__dirname, '..', 'plugins'), // repo: server/ -> ../plugins
|
||||
path.join(process.cwd(), 'plugins'), // run from repo root
|
||||
path.join(path.dirname(process.execPath), 'plugins'),
|
||||
path.join(path.dirname(process.execPath), '..', 'Resources', 'plugins'),
|
||||
].filter(Boolean);
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, SCRIPTS[0]))) return dir;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Install / update the scripts under <root>. Returns a report object; never throws.
|
||||
export function installLuaScripts(root, log = console.log) {
|
||||
if (!root) {
|
||||
return { ok: false, reason: 'no-xplane', installed: [], updated: [], unchanged: [] };
|
||||
}
|
||||
const fwl = path.join(root, 'Resources', 'plugins', 'FlyWithLua');
|
||||
if (!fs.existsSync(fwl)) {
|
||||
log('lua-install: FlyWithLua not found — install FlyWithLua NG+ into ' +
|
||||
`${path.join(root, 'Resources', 'plugins')} to enable FMS/terrain sync`);
|
||||
return { ok: false, reason: 'no-flywithlua', installed: [], updated: [], unchanged: [] };
|
||||
}
|
||||
const src = sourceDir();
|
||||
if (!src) {
|
||||
log('lua-install: companion script sources not found (set LUA_SRC_DIR)');
|
||||
return { ok: false, reason: 'no-source', installed: [], updated: [], unchanged: [] };
|
||||
}
|
||||
|
||||
const dest = path.join(fwl, 'Scripts');
|
||||
const report = { ok: true, reason: 'ok', dir: dest, installed: [], updated: [], unchanged: [], failed: [] };
|
||||
try { fs.mkdirSync(dest, { recursive: true }); } catch { /* ignore */ }
|
||||
|
||||
for (const name of SCRIPTS) {
|
||||
const from = path.join(src, name);
|
||||
const to = path.join(dest, name);
|
||||
try {
|
||||
if (!fs.existsSync(from)) { report.failed.push(name); continue; }
|
||||
const want = fs.readFileSync(from, 'utf8');
|
||||
const have = fs.existsSync(to) ? fs.readFileSync(to, 'utf8') : null;
|
||||
if (have === null) { fs.writeFileSync(to, want); report.installed.push(name); }
|
||||
else if (have !== want) { fs.writeFileSync(to, want); report.updated.push(name); }
|
||||
else { report.unchanged.push(name); }
|
||||
} catch (e) {
|
||||
report.failed.push(name);
|
||||
log(`lua-install: ${name} failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (report.installed.length) parts.push(`installed ${report.installed.join(', ')}`);
|
||||
if (report.updated.length) parts.push(`updated ${report.updated.join(', ')}`);
|
||||
if (report.unchanged.length) parts.push(`${report.unchanged.length} up to date`);
|
||||
log(`lua-install: ${parts.join('; ') || 'nothing to do'} → ${dest}`);
|
||||
if (report.installed.length || report.updated.length) {
|
||||
log('lua-install: reload in X-Plane — "FlyWithLua > Reload all Lua script files" (or restart)');
|
||||
}
|
||||
return report;
|
||||
}
|
||||
Reference in New Issue
Block a user