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:
2026-06-02 13:57:50 +02:00
parent b2fab0c374
commit 9aba24978b
16 changed files with 572 additions and 77 deletions
+3
View File
@@ -24,3 +24,6 @@ desktop/.tauri-signing.pw
screenshots/
*.log
.DS_Store
# local airspace test data (real data is installed into X-Plane via the launcher)
airspace-data/
+9 -1
View File
@@ -119,13 +119,21 @@ async fn start_server(
.resolve("web", tauri::path::BaseDirectory::Resource)
.map_err(|e| format!("resource path: {e}"))?;
// The FlyWithLua companion scripts ship as a bundled resource; tell the
// bridge where they live so it can auto-install them into X-Plane.
let lua_src = app
.path()
.resolve("plugins", tauri::path::BaseDirectory::Resource)
.map_err(|e| format!("resource path: {e}"))?;
let mut cmd = app
.shell()
.sidecar("xpbridge")
.map_err(|e| format!("sidecar: {e}"))?
.env("BRIDGE_PORT", port.to_string())
.env("BRIDGE_HOST", "0.0.0.0")
.env("WEB_DIST", web_dist.to_string_lossy().to_string());
.env("WEB_DIST", web_dist.to_string_lossy().to_string())
.env("LUA_SRC_DIR", lua_src.to_string_lossy().to_string());
if !xplane_path.is_empty() {
cmd = cmd.env("XPLANE_ROOT", xplane_path);
+2 -1
View File
@@ -41,7 +41,8 @@
"binaries/xpbridge"
],
"resources": {
"resources/web": "web"
"resources/web": "web",
"resources/plugins": "plugins"
},
"createUpdaterArtifacts": true,
"macOS": {
+10
View File
@@ -65,6 +65,16 @@
<div class="diag-row"><span>Navdata</span><b id="dNav"></b></div>
<div class="diag-row"><span>Datarefs</span><b id="dRefs"></b></div>
</div>
<details class="asp-wrap">
<summary>Lufträume auf der Karte</summary>
<div class="asp-body">
<p class="asp-note">Wähle Regionen für die Luftraum-Anzeige (Class B/C/D, Restricted, MOA …). USA ist frei; Europa braucht einen kostenlosen <a href="#" id="aspKeyLink">OpenAIP-API-Key</a>.</p>
<input id="aspKey" type="password" placeholder="OpenAIP API-Key (für Europa)" spellcheck="false" />
<div id="aspRegions" class="asp-list"></div>
<div id="aspHint" class="hint"></div>
</div>
</details>
</section>
<details class="log-wrap">
+52 -1
View File
@@ -8,7 +8,7 @@ const xpPath = $('xpPath'), portEl = $('port'), demoEl = $('demo');
const startBtn = $('startBtn'), liveCard = $('liveCard'), urlEl = $('url');
const statusEl = $('status'), statusText = $('statusText'), logEl = $('log');
let running = false, healthTimer = null;
let running = false, healthTimer = null, serverPort = 0;
function setStatus(kind, text) {
statusEl.className = 'status ' + kind;
@@ -73,12 +73,14 @@ startBtn.addEventListener('click', async () => {
demo: demoEl.checked,
});
running = true;
serverPort = info.port;
urlEl.textContent = info.url;
liveCard.classList.remove('hidden');
startBtn.textContent = 'Server stoppen';
startBtn.classList.add('stop');
setStatus('warn', 'Server läuft · warte auf Sim');
pollHealth(info.port);
loadRegions();
} catch (e) {
appendLog('Fehler: ' + e);
setStatus('off', 'Fehler');
@@ -96,6 +98,8 @@ async function stop() {
function resetUi() {
running = false;
serverPort = 0;
const ar = $('aspRegions'); if (ar) ar.innerHTML = '';
liveCard.classList.add('hidden');
startBtn.textContent = 'Server starten';
startBtn.classList.remove('stop');
@@ -125,6 +129,53 @@ function pollHealth(port) {
healthTimer = setInterval(check, 3000);
}
/* ---------------- airspace regions ---------------- */
const aspBase = () => `http://127.0.0.1:${serverPort}/api/airspace`;
async function loadRegions() {
const wrap = $('aspRegions');
if (!wrap || !serverPort) return;
try {
const r = await fetch(`${aspBase()}/regions`, { cache: 'no-store' });
const { regions } = await r.json();
wrap.innerHTML = '';
for (const reg of regions) {
const row = document.createElement('div');
row.className = 'asp-row';
const installed = reg.installed > 0;
row.innerHTML = `
<span class="asp-name">${reg.label}${reg.needsKey ? ' <em>· Key</em>' : ''}</span>
<span class="asp-count">${installed ? reg.installed + ' Zonen' : '—'}</span>
<button class="btn ghost sm" data-region="${reg.id}" data-key="${reg.needsKey ? 1 : 0}">${installed ? 'Aktualisieren' : 'Laden'}</button>`;
wrap.appendChild(row);
}
wrap.querySelectorAll('button[data-region]').forEach((btn) =>
btn.addEventListener('click', () => installRegion(btn)));
} catch (e) { appendLog('airspace: ' + e); }
}
async function installRegion(btn) {
const region = btn.dataset.region;
const needsKey = btn.dataset.key === '1';
const apiKey = $('aspKey').value.trim();
const hint = $('aspHint');
if (needsKey && !apiKey) { hint.textContent = '⚠ OpenAIP-API-Key oben eingeben'; hint.className = 'hint bad'; return; }
btn.disabled = true; const was = btn.textContent; btn.textContent = 'Lädt…';
hint.textContent = `Lade ${region.toUpperCase()} … (Fortschritt im Server-Log)`; hint.className = 'hint';
try {
const r = await fetch(`${aspBase()}/install`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region, apiKey: needsKey ? apiKey : undefined }),
});
const d = await r.json();
if (d.ok) { hint.textContent = `${region.toUpperCase()}: ${d.features} Zonen geladen`; hint.className = 'hint ok'; }
else { hint.textContent = '⚠ ' + (d.error || 'Fehler'); hint.className = 'hint bad'; }
} catch (e) { hint.textContent = '⚠ ' + e; hint.className = 'hint bad'; }
finally { btn.disabled = false; btn.textContent = was; loadRegions(); }
}
$('aspKeyLink')?.addEventListener('click', (e) => { e.preventDefault(); openUrl('https://www.openaip.net/'); });
$('copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(urlEl.textContent); $('copy').textContent = '✓'; setTimeout(() => ($('copy').textContent = '⧉'), 1200); } catch {}
});
+17
View File
@@ -87,3 +87,20 @@ input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px r
.link:hover { text-decoration: underline; }
.link:disabled { color: var(--mut); cursor: default; text-decoration: none; }
.update-badge { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-left: 6px; box-shadow: 0 0 6px var(--green); vertical-align: middle; }
/* airspace region picker (in the live card) */
.asp-wrap { margin-top: 12px; border-top: 1px solid var(--line-soft); padding-top: 10px; }
.asp-wrap > summary { cursor: pointer; color: var(--txt2); font-size: 13px; font-weight: 600; list-style: none; }
.asp-wrap > summary::-webkit-details-marker { display: none; }
.asp-wrap > summary::before { content: '▸ '; color: var(--mut); }
.asp-wrap[open] > summary::before { content: '▾ '; }
.asp-body { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.asp-note { color: var(--mut); font-size: 12px; line-height: 1.4; margin: 0; }
.asp-note a { color: var(--green); }
#aspKey { width: 100%; }
.asp-list { display: flex; flex-direction: column; gap: 6px; }
.asp-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px;
background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 10px; }
.asp-name { color: var(--txt2); font-size: 13px; }
.asp-name em { color: var(--mut); font-style: normal; font-size: 11px; }
.asp-count { color: var(--mut); font-size: 12px; min-width: 56px; text-align: right; }
+11 -4
View File
@@ -8,15 +8,22 @@ cockpit plan ↔ the in-sim FMS through two files in `Output/fms-sync/`.
1. Install **FlyWithLua NG+** (free): copy its plugin folder into
`<X-Plane>/Resources/plugins/FlyWithLua/`.
2. Copy the scripts into `<X-Plane>/Resources/plugins/FlyWithLua/Scripts/`:
2. Start the bridge (desktop app or `node server/bridge.js`) **on the same PC**
as X-Plane. On startup it auto-copies these three scripts into
`<X-Plane>/Resources/plugins/FlyWithLua/Scripts/` and keeps them up to date
on every launch (it only writes changed/missing files):
- **`fms-sync.lua`** — flight-plan two-way sync
- **`ui-sync.lua`** — G1000 UI state (page / range / inset)
- **`terrain-probe.lua`** — terrain-awareness elevation grid for the MFD
3. Restart X-Plane (or *FlyWithLua → Reload all Lua script files*).
3. In X-Plane: *FlyWithLua → Reload all Lua script files* (or restart).
The log shows `[glass-cockpit] FMS sync active`.
The bridge (desktop app / `node server/bridge.js`) must run on the **same PC**
as X-Plane, so both see `<X-Plane>/Output/fms-sync/`.
No manual copying needed. If you install FlyWithLua *after* the bridge is
already running, trigger a re-install without restarting via
`POST /api/lua/install`. The current install state is reported under `lua` in
`GET /api/health`. The bridge must run on the **same PC** as X-Plane so both see
`<X-Plane>/Output/fms-sync/`. (The auto-install honours `LUA_SRC_DIR` — the
desktop app sets it to the bundled scripts; otherwise it finds `plugins/` itself.)
## What you get
+5
View File
@@ -15,6 +15,11 @@ rm -rf desktop/src-tauri/resources/web
mkdir -p desktop/src-tauri/resources/web
cp -R web/dist/. desktop/src-tauri/resources/web/
echo "==> copying FlyWithLua companion scripts into desktop resources"
rm -rf desktop/src-tauri/resources/plugins
mkdir -p desktop/src-tauri/resources/plugins
cp plugins/*.lua desktop/src-tauri/resources/plugins/
echo "==> compiling bridge sidecars (Bun)"
mkdir -p desktop/src-tauri/binaries
bun build --compile --target=bun-darwin-arm64 server/bridge.js \
+194
View File
@@ -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
View File
@@ -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(),
+88
View File
@@ -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;
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, useRef, useEffect } from 'react';
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
// dt + a time constant). The rAF loop runs continuously so the value keeps
// gliding between the bridge's discrete value updates — turning a stuttery
// ~1020 Hz stream into a smooth 60 fps motion. To avoid needless React work it
// only re-renders the consumer while the value is actually moving: once settled,
// the functional setState returns the same number and React bails (Object.is).
export function useEased(target, tau = 0.08) {
const [, force] = useState(0);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const next = cur.current + (tg.current - cur.current) * k;
const settled = Math.abs(tg.current - next) < 0.02;
const val = settled ? tg.current : next;
if (val !== cur.current) { cur.current = val; force((n) => (n + 1) & 0xffff); }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return cur.current;
}
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
export function useEasedAngle(target, tau = 0.08) {
const [, force] = useState(0);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
const next = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
const val = ((next % 360) + 360) % 360;
if (val !== cur.current) { cur.current = val; force((n) => (n + 1) & 0xffff); }
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return cur.current;
}
+4 -2
View File
@@ -29,7 +29,7 @@ const PFD_MENU = {
// extra layer in an otherwise-empty slot.)
const MFD_MENU = {
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', 'AIRSPACE', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
};
@@ -74,6 +74,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') cycleDcltr(onMapMode);
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace }));
} else {
if (label === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
@@ -109,7 +110,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways);
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways)
|| (label === 'AIRSPACE' && mapMode?.airspace);
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|| (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts))
|| (label === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
+87 -13
View File
@@ -57,6 +57,22 @@ const TILES = {
dark: null,
};
// G1000 / aeronautical-chart airspace styling, keyed by our coarse class. B/C/D
// follow chart convention (B solid blue, C solid magenta, D dashed blue);
// special-use areas use warm hues. Class A/E are omitted (they blanket huge
// areas and only clutter the moving map).
const ASP_STYLE = {
B: { color: '#2f7bff', weight: 1.8, dashArray: null },
C: { color: '#e23bd0', weight: 1.7, dashArray: null },
D: { color: '#4aa3ff', weight: 1.4, dashArray: '5 4' },
TMA: { color: '#2f7bff', weight: 1.3, dashArray: null },
CTR: { color: '#4aa3ff', weight: 1.4, dashArray: '5 4' },
MOA: { color: '#ff8a3b', weight: 1.4, dashArray: '7 4' },
RESTRICTED: { color: '#ff5a4a', weight: 1.5, dashArray: '7 4' },
PROHIBITED: { color: '#ff3636', weight: 2, dashArray: null },
DANGER: { color: '#ff5a4a', weight: 1.4, dashArray: '7 4' },
};
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView, rangeNm, terrain, rose = false }) {
const elRef = useRef(null);
const mapRef = useRef(null);
@@ -69,8 +85,12 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const awyLayerRef = useRef(null);
const awyOnRef = useRef(false);
const refreshAirwaysRef = useRef(null);
const aspLayerRef = useRef(null);
const aspOnRef = useRef(false);
const refreshAirspaceRef = useRef(null);
const baseRef = useRef(null);
const terrRef = useRef(null);
const zoomingRef = useRef(false);
const [follow, setFollow] = useState(true);
const followRef = useRef(true);
followRef.current = follow;
@@ -81,6 +101,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
const base = mapMode?.base || 'topo';
const airways = !!mapMode?.airways;
const airspace = !!mapMode?.airspace;
aspOnRef.current = airspace;
const dcltrRef = useRef(dcltr);
dcltrRef.current = dcltr;
awyOnRef.current = airways;
@@ -113,7 +135,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
map.getPane('terrain').style.zIndex = 250;
map.getPane('terrain').style.pointerEvents = 'none';
awyLayerRef.current = L.layerGroup().addTo(map); // airways (under everything else)
aspLayerRef.current = L.layerGroup().addTo(map); // airspace polygons (bottom overlay)
awyLayerRef.current = L.layerGroup().addTo(map); // airways
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
wpLayerRef.current = L.layerGroup().addTo(map);
@@ -146,6 +169,31 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
};
refreshAirwaysRef.current = refreshAirways;
// AIRSPACE overlay (Class B/C/D + special-use from installed region GeoJSON;
// see server/airspace.js). Boundaries are coloured by class with a faint
// fill; non-interactive so map-clicks still drop waypoints.
const refreshAirspace = async () => {
const layer = aspLayerRef.current;
if (!layer) return;
if (!aspOnRef.current || map.getZoom() < 6) { layer.clearLayers(); return; }
const b = map.getBounds();
try {
const res = await fetch(`/api/airspace/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=400`);
if (!res.ok) return;
const feats = await res.json();
layer.clearLayers();
for (const f of feats) {
const st = ASP_STYLE[f.cls];
if (!st) continue; // skip A/E/OTHER — too broad to be useful
L.geoJSON(f.geometry, {
interactive: false,
style: { color: st.color, weight: st.weight, opacity: 0.85, dashArray: st.dashArray, fill: true, fillColor: st.color, fillOpacity: 0.05 },
}).addTo(layer);
}
} catch { /* offline */ }
};
refreshAirspaceRef.current = refreshAirspace;
// Pull X-Plane's own nav data for the current view and draw it as G1000-style
// vector symbology (cyan airports, green VOR hexagons, NDB dot-rings, fixes).
const refreshNav = async () => {
@@ -170,9 +218,10 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
for (const f of feats) navSymbol(f, labels).addTo(layer);
} catch { /* aborted or offline — leave as is */ }
};
map.on('moveend', () => { refreshNav(); refreshAirways(); reportView(map); });
map.on('zoomend', () => reportView(map));
setTimeout(() => { refreshNav(); refreshAirways(); reportView(map); }, 300);
map.on('moveend', () => { refreshNav(); refreshAirways(); refreshAirspace(); reportView(map); });
map.on('zoomstart', () => { zoomingRef.current = true; });
map.on('zoomend', () => { zoomingRef.current = false; reportView(map); });
setTimeout(() => { refreshNav(); refreshAirways(); refreshAirspace(); reportView(map); }, 300);
// compass rose anchored to the aircraft (north-up) — tracks the ownship
if (rose) {
@@ -202,6 +251,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
// redraw airways when the AIRWAYS toggle changes
useEffect(() => { refreshAirwaysRef.current && refreshAirwaysRef.current(); }, [airways]); // eslint-disable-line
// redraw airspace when the AIRSPACE toggle changes
useEffect(() => { refreshAirspaceRef.current && refreshAirspaceRef.current(); }, [airspace]); // eslint-disable-line
// TERRAIN AWARENESS overlay: colour the elevation grid (from the FlyWithLua
// terrain probe) relative to aircraft altitude — red within 100 ft below/above,
@@ -260,16 +311,39 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
else map.fire('moveend'); // triggers refreshNav to redraw symbols
}, [dcltr]); // eslint-disable-line
// update aircraft position + heading
// Smooth ownship motion. The sim streams position/heading at ~10 Hz; setting
// the marker straight from that (and firing an animated panTo on every update)
// makes the aircraft vibrate and the animations fight each other. Instead we
// hold the latest values as a target and ease toward it in a single rAF loop,
// applying the result imperatively (no React re-render per frame) — so the
// aircraft glides at 60 fps and the map follows without jitter.
const tgtRef = useRef({ lat, lon, track });
const curRef = useRef({ lat, lon, track });
tgtRef.current = { lat, lon, track };
useEffect(() => {
const ac = acRef.current, map = mapRef.current;
if (!ac || !map) return;
ac.setLatLng([lat, lon]);
roseRef.current?.setLatLng([lat, lon]);
const el = ac.getElement()?.querySelector('svg');
if (el) el.style.transform = `rotate(${track}deg)`;
if (followRef.current) map.panTo([lat, lon], { animate: true, duration: 0.5 });
}, [lat, lon, track]);
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const kp = 1 - Math.exp(-dt / 0.22); // position time-constant (gentle)
const kt = 1 - Math.exp(-dt / 0.14); // heading time-constant
const c = curRef.current, t = tgtRef.current;
c.lat += (t.lat - c.lat) * kp;
c.lon += (t.lon - c.lon) * kp;
const d = ((t.track - c.track + 540) % 360) - 180; // shortest arc
c.track += d * kt;
const ac = acRef.current, map = mapRef.current;
if (ac && map) {
ac.setLatLng([c.lat, c.lon]);
roseRef.current?.setLatLng([c.lat, c.lon]);
const el = ac.getElement()?.querySelector('svg');
if (el) el.style.transform = `rotate(${((c.track % 360) + 360) % 360}deg)`;
if (followRef.current && !zoomingRef.current) map.panTo([c.lat, c.lon], { animate: false });
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []); // eslint-disable-line
// redraw route + waypoints when the plan changes. Like the real G1000, the
// active leg (to waypoint `activeLeg`) is magenta; all other legs are white.
+1 -44
View File
@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
import { num, systemAlerts } from '../api/useXplane.js';
import { useEased, useEasedAngle } from '../api/ease.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import TimerRef from './TimerRef.jsx';
@@ -99,50 +100,6 @@ const SVT_BOX = { x: 0, y: 74, w: W, h: H - 74 };
// The INSET moving map sits in the bottom-left corner (toggled by INSET softkey).
const INSET_BOX = { x: 6, y: 556, w: 300, h: 172 };
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
// dt + a time constant). Re-renders the consumer only while it's moving —
// setState bails out when the value has settled. Used to glide the speed/alt
// tapes and the heading rose, just like the imperative attitude smoothing.
function useEased(target, tau = 0.08) {
const [v, setV] = useState(target);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const next = cur.current + (tg.current - cur.current) * k;
cur.current = Math.abs(tg.current - next) < 0.02 ? tg.current : next;
setV(cur.current);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return v;
}
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
function useEasedAngle(target, tau = 0.08) {
const [v, setV] = useState(target);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
cur.current = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
setV(((cur.current % 360) + 360) % 360);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return v;
}
export default function PFD({ values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
+10 -8
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { num } from '../api/useXplane.js';
import { useEased, useEasedAngle } from '../api/ease.js';
import KAP140 from './KAP140.jsx';
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
@@ -55,7 +56,7 @@ function ticks(min, max, a0, a1, step, big = 1, r = 84, lab) {
/* ---------- Airspeed ---------- */
function ASI({ V }) {
const kt = num(V.airspeed);
const kt = useEased(num(V.airspeed));
const A0 = -150, A1 = 150, MIN = 0, MAX = 200;
const ang = A0 + (clamp(kt, MIN, MAX) - MIN) / (MAX - MIN) * (A1 - A0);
const arc = (lo, hi, color, rr, wdt) => {
@@ -80,7 +81,7 @@ function ASI({ V }) {
/* ---------- Attitude ---------- */
function AI({ V }) {
const pitch = num(V.pitch), roll = num(V.roll);
const pitch = useEased(num(V.pitch)), roll = useEased(num(V.roll));
const PPD = 2.0; // px per degree pitch
const off = clamp(pitch, -25, 25) * PPD;
return (
@@ -118,7 +119,7 @@ function AI({ V }) {
/* ---------- Altimeter (3-pointer) ---------- */
function ALT({ V }) {
const alt = num(V.altitude), baro = num(V.baro, 29.92);
const alt = useEased(num(V.altitude)), baro = num(V.baro, 29.92);
const a100 = (alt % 1000) / 1000 * 360;
const a1000 = (alt % 10000) / 10000 * 360;
const a10000 = (alt % 100000) / 100000 * 360;
@@ -141,7 +142,7 @@ function ALT({ V }) {
/* ---------- Turn coordinator ---------- */
function TC({ V }) {
const roll = num(V.roll), slip = num(V.slip);
const roll = useEased(num(V.roll)), slip = useEased(num(V.slip));
const bank = clamp(roll, -30, 30); // little-plane bank (approx turn rate)
const ballX = 100 + clamp(slip, -8, 8) * 3.0;
return (
@@ -167,7 +168,7 @@ function TC({ V }) {
/* ---------- Heading indicator ---------- */
function HI({ V }) {
const hdg = ((num(V.heading) % 360) + 360) % 360;
const hdg = useEasedAngle(((num(V.heading) % 360) + 360) % 360);
const card = [];
for (let d = 0; d < 360; d += 5) {
const big = d % 30 === 0;
@@ -193,7 +194,7 @@ function HI({ V }) {
/* ---------- Vertical speed ---------- */
function VSI({ V }) {
const vs = clamp(num(V.vspeed), -2000, 2000);
const vs = clamp(useEased(num(V.vspeed), 0.12), -2000, 2000);
// 0 at 9 o'clock (270°), climb sweeps up (toward 0/up), descent down.
const ang = 270 + (vs / 2000) * 160; // -2000→110°, 0→270°, +2000→430°(=70°)
return (
@@ -233,7 +234,8 @@ function Dual({ title, l, r }) {
const [x2, y2] = sp(60, 60, 46, sg(hi, min, max, a0, a1));
return <path d={`M${x1} ${y1} A46 46 0 0 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth="3" />;
};
const La = sg(l.value, l.min, l.max, -150, -20), Ra = sg(r.value, r.min, r.max, 20, 150);
const lv = useEased(l.value, 0.18), rv = useEased(r.value, 0.18);
const La = sg(lv, l.min, l.max, -150, -20), Ra = sg(rv, r.min, r.max, 20, 150);
return (
<SmallBezel title={title}>
{l.green && band(l.green[0], l.green[1], l.min, l.max, -150, -20, '#21d04a')}
@@ -249,7 +251,7 @@ function Dual({ title, l, r }) {
/* ---------- tachometer ---------- */
function Tach({ V }) {
const rpm = arr0(V.engRpm);
const rpm = useEased(arr0(V.engRpm));
const A0 = -150, A1 = 150;
const ang = A0 + clamp(rpm, 0, 3500) / 3500 * (A1 - A0);
return (